Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
01b031cec3 feat: adacam migration — update IP, add pairing, bearer auth, wifi/ssh config, remove tunnel and cmd
- Changed default API URL from 192.168.0.10 to 10.77.0.1
- Added bearer token authentication for POST endpoints
- Added /pair endpoint support for device pairing
- Added GET/POST /api/1/ssh/status and /api/1/ssh/toggle
- Added WiFi config via /api/1/wifi/connect
- Removed JSch dependency (no more SSH from app)
- Removed /api/1/cmd endpoint references (CVE)
- Added PairResponse, SshStatus, WifiStatus models
- Added deviceSerial, apiToken, isPaired to settings
- Added deriveApiToken() function for token derivation
- Updated SettingsViewModel with pairing/wifi/ssh methods
- Updated SettingsScreen with pairing UI, WiFi config, SSH toggle
- Updated DeviceStatusScreen with SSH toggle card
2026-03-14 11:50:00 -07:00
8 changed files with 822 additions and 133 deletions

View file

@ -76,8 +76,6 @@ dependencies {
ksp(libs.room.compiler)
// WorkManager (background uploads)
implementation(libs.work.runtime.ktx)
// SSH connectivity for device_id fallback
implementation("com.jcraft:jsch:0.1.55")
// QR Code scanning
implementation("com.google.zxing:core:3.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")

View file

@ -11,24 +11,22 @@ import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssData
import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.PairResponse
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig
import com.adamaps.varroa.data.WifiStatus
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
import com.jcraft.jsch.JSch
import com.jcraft.jsch.Session
import java.io.ByteArrayOutputStream
class BeeApiClient(
private var apiUrl: String = "http://192.168.0.10:5000"
private var apiUrl: String = "http://10.77.0.1:5000"
) {
companion object {
private const val TAG = "VarroaBeeAPI"
@ -43,6 +41,9 @@ class BeeApiClient(
var isConnected: Boolean = false
private set
// Bearer token for authenticated endpoints
var apiToken: String = ""
fun updateUrl(url: String) {
val oldUrl = apiUrl
apiUrl = url.trimEnd('/')
@ -50,7 +51,7 @@ class BeeApiClient(
}
/**
* Bind to a specific network (e.g., unvalidated WiFi for Bee AP).
* Bind to a specific network (e.g., unvalidated WiFi for AdaCam AP).
* This is the preferred method when using NetworkStateMonitor.
*/
fun bindToNetwork(network: Network) {
@ -107,7 +108,7 @@ class BeeApiClient(
val allWifi = cm.allNetworks.filter { n ->
cm.getNetworkCapabilities(n)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}
// prefer unvalidated wifi (Bee AP has no internet)
// prefer unvalidated wifi (AdaCam AP has no internet)
allWifi.firstOrNull { n ->
cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == false
} ?: allWifi.firstOrNull()
@ -178,7 +179,7 @@ class BeeApiClient(
}
/**
* Check if Bee is reachable.
* Check if AdaCam is reachable.
* Updates internal connection state.
*/
suspend fun ping(): Boolean {
@ -208,6 +209,25 @@ class BeeApiClient(
isConnected = false
}
// ── Pairing API ───────────────────────────────────────────────────────────
/**
* Pair with AdaCam device. Unauthenticated endpoint.
* Returns serial, version, and connection info.
*/
suspend fun pair(): ApiResult<PairResponse> = withContext(Dispatchers.IO) {
when (val r = getRaw("/pair")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, PairResponse::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
// ── Landmarks API ─────────────────────────────────────────────────────────
suspend fun getLandmarks(): ApiResult<List<BeeDetection>> = withContext(Dispatchers.IO) {
Log.d(TAG, "getLandmarks() called - fetching ALL landmarks")
@ -260,7 +280,7 @@ class BeeApiClient(
}
}
// ── v7.7 Settings API endpoints ───────────────────────────────────────────
// ── WiFi API ──────────────────────────────────────────────────────────────
suspend fun getWifiConfig(): ApiResult<WifiConfig> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifi/status")) {
@ -273,6 +293,17 @@ class BeeApiClient(
}
}
suspend fun getWifiStatus(): ApiResult<WifiStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifi/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, WifiStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun setWifiConfig(ssid: String, password: String): ApiResult<String> = withContext(Dispatchers.IO) {
try {
val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password))
@ -282,6 +313,7 @@ class BeeApiClient(
)
val request = Request.Builder()
.url("$apiUrl/api/1/wifi/connect")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody)
.build()
@ -298,6 +330,47 @@ class BeeApiClient(
}
}
// ── SSH API ───────────────────────────────────────────────────────────────
suspend fun getSshStatus(): ApiResult<SshStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/ssh/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, SshStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun setSshEnabled(enabled: Boolean): ApiResult<String> = withContext(Dispatchers.IO) {
try {
val jsonBody = gson.toJson(mapOf("enable" to enabled))
val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
val request = Request.Builder()
.url("$apiUrl/api/1/ssh/toggle")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody)
.build()
client.newCall(request).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) {
ApiResult.Success(body)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
}
}
// ── Storage & GNSS Status ─────────────────────────────────────────────────
suspend fun getStorageStatus(): ApiResult<StorageStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/storage/usage")) {
is ApiResult.Success -> try {
@ -341,6 +414,7 @@ class BeeApiClient(
)
val request = Request.Builder()
.url("$apiUrl/api/1/config/uploadMode")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody)
.build()
@ -357,50 +431,7 @@ class BeeApiClient(
}
}
// ── End v7.7 Settings API ─────────────────────────────────────────────────
suspend fun getDeviceIdViaSsh(): ApiResult<String> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Attempting SSH connection to root@192.168.0.10:22")
val jsch = JSch()
val session = jsch.getSession("root", "192.168.0.10", 22)
session.setConfig("StrictHostKeyChecking", "no")
session.connect(10000) // 10 second timeout
val channel = session.openChannel("exec")
val execChannel = channel as com.jcraft.jsch.ChannelExec
// Command to find device_id from various locations
val command = "cat /data/registration/device_id 2>/dev/null || cat /opt/dashcam/config/device_id 2>/dev/null || grep device_id /data/persist/*.conf 2>/dev/null | head -1 | cut -d= -f2"
execChannel.setCommand(command)
val outputStream = ByteArrayOutputStream()
execChannel.outputStream = outputStream
execChannel.connect(5000) // 5 second timeout for command execution
// Wait for command completion
while (!execChannel.isClosed) {
Thread.sleep(100)
}
execChannel.disconnect()
session.disconnect()
val output = outputStream.toString().trim()
Log.d(TAG, "SSH command output: '$output'")
if (output.isNotEmpty() && !output.contains("No such file") && !output.contains("not found")) {
Log.i(TAG, "Device ID retrieved via SSH: $output")
ApiResult.Success(output)
} else {
Log.w(TAG, "SSH command succeeded but no device_id found")
ApiResult.Error("No device_id found via SSH")
}
} catch (e: Exception) {
Log.e(TAG, "SSH connection failed", e)
ApiResult.Error("SSH error: ${e.message}")
}
}
// ── Camera API ────────────────────────────────────────────────────────────
/**
* Try the given endpoint; returns raw image bytes.
@ -429,9 +460,9 @@ class BeeApiClient(
}
/**
* Fetch detection image from Bee device.
* Fetch detection image from AdaCam device.
*
* @param detectionId The landmark/detection ID from the Bee API
* @param detectionId The landmark/detection ID from the API
* @return ApiResult containing image bytes (JPEG) or error
*/
suspend fun getDetectionImage(detectionId: Long): ApiResult<ByteArray> = withContext(Dispatchers.IO) {
@ -452,7 +483,7 @@ class BeeApiClient(
}
/**
* Attempt to delete landmarks from Bee device after successful upload.
* Attempt to delete landmarks from AdaCam device after successful upload.
* This tries various potential DELETE endpoints.
*
* @param landmarkIds List of landmark IDs to delete
@ -471,24 +502,13 @@ class BeeApiClient(
"/api/1/landmarks/delete",
"/api/1/landmarks/clear",
"/api/1/landmarks/cleanup",
"/api/1/landmarks/remove",
"/api/1/cmd" // As a last resort using the cmd endpoint
"/api/1/landmarks/remove"
)
for (endpoint in endpoints) {
Log.d(TAG, "Trying cleanup endpoint: $endpoint")
val result = when (endpoint) {
"/api/1/cmd" -> {
// Use the cmd endpoint to try deleting landmarks via system commands
Log.d(TAG, "Attempting cleanup via cmd endpoint...")
tryCleanupViaCmd(landmarkIds)
}
else -> {
// Try standard DELETE/POST requests
tryCleanupEndpoint(endpoint, landmarkIds)
}
}
val result = tryCleanupEndpoint(endpoint, landmarkIds)
when (result) {
is ApiResult.Success -> {
@ -502,8 +522,8 @@ class BeeApiClient(
}
}
Log.w(TAG, "All cleanup endpoints failed - Bee may not support landmark deletion")
return@withContext ApiResult.Error("No working DELETE endpoint found - cleanup not supported by Bee device")
Log.w(TAG, "All cleanup endpoints failed - AdaCam may not support landmark deletion")
return@withContext ApiResult.Error("No working DELETE endpoint found - cleanup not supported by device")
}
private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) {
@ -518,10 +538,15 @@ class BeeApiClient(
for (method in listOf("DELETE", "POST")) {
Log.d(TAG, "Trying $method $endpoint")
val request = Request.Builder()
val requestBuilder = Request.Builder()
.url("$apiUrl$endpoint")
.method(method, if (method == "DELETE") null else requestBody)
.build()
.addHeader("Authorization", "Bearer $apiToken")
val request = if (method == "DELETE") {
requestBuilder.delete(requestBody).build()
} else {
requestBuilder.post(requestBody).build()
}
client.newCall(request).execute().use { resp ->
val body = resp.body?.string() ?: ""
@ -540,50 +565,4 @@ class BeeApiClient(
return@withContext ApiResult.Error("Exception: ${e.message}")
}
}
private suspend fun tryCleanupViaCmd(landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) {
try {
// Try to find where landmarks are stored and delete them via filesystem commands
val commands = listOf(
"find /data -name '*landmark*' -type f -ls",
"find /tmp -name '*landmark*' -type f -ls",
"ls -la /data/recording/",
"redis-cli KEYS '*landmark*'",
"redis-cli KEYS '*detection*'"
)
for (cmd in commands) {
Log.d(TAG, "Trying cmd: $cmd")
val jsonBody = gson.toJson(mapOf("cmd" to cmd))
val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
val request = Request.Builder()
.url("$apiUrl/api/1/cmd")
.post(requestBody)
.build()
client.newCall(request).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) {
Log.d(TAG, "Cmd '$cmd' result: $body")
// For now, just log the results to understand the data structure
if (body.contains("landmark") || body.contains("detection")) {
Log.i(TAG, "Found potential landmark storage: $body")
}
}
}
}
// Note: We're not actually deleting anything via cmd yet, just exploring
Log.w(TAG, "Cleanup via cmd endpoint: exploration complete, actual deletion not implemented yet")
return@withContext ApiResult.Error("Cleanup via cmd: exploration only, deletion not yet implemented")
} catch (e: Exception) {
Log.e(TAG, "Failed to explore via cmd endpoint", e)
return@withContext ApiResult.Error("Cmd exploration failed: ${e.message}")
}
}
}

View file

@ -47,6 +47,21 @@ data class BeeDeviceInfo(
@SerializedName("ssid") val ssid: String? = null
)
// ── Pairing API ───────────────────────────────────────────────────────────────
data class PairResponse(
@SerializedName("serial") val serial: String,
@SerializedName("version") val version: String,
@SerializedName("ap_ip") val apIp: String,
@SerializedName("api_port") val apiPort: Int
)
// ── SSH API ───────────────────────────────────────────────────────────────────
data class SshStatus(
@SerializedName("active") val active: Boolean
)
// ── ADAMaps ingest ────────────────────────────────────────────────────────────
data class AdaMapsDetection(
@ -93,7 +108,15 @@ data class WifiConfig(
@SerializedName("ssid") val ssid: String? = null,
@SerializedName("password") val password: String? = null,
@SerializedName("connected") val connected: Boolean? = null,
@SerializedName("ip") val ip: String? = null
@SerializedName("ip") val ip: String? = null,
@SerializedName("state") val state: String? = null
)
data class WifiStatus(
@SerializedName("ssid") val ssid: String? = null,
@SerializedName("ip") val ip: String? = null,
@SerializedName("state") val state: String? = null,
@SerializedName("connected") val connected: Boolean? = null
)
data class StorageStatus(

View file

@ -10,11 +10,12 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.security.MessageDigest
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings")
data class VarroaSettings(
val beeApiUrl: String = "http://192.168.0.10:5000",
val beeApiUrl: String = "http://10.77.0.1:5000",
val adamapsApiUrl: String = "https://api.adamaps.org",
val adamapsApiKey: String = "***REMOVED***",
val pollIntervalSeconds: Int = 30,
@ -22,9 +23,23 @@ data class VarroaSettings(
val cameraRefreshSeconds: Int = 30,
val forwardingEnabled: Boolean = true,
val cachedDeviceId: String = "unknown",
val walletAddress: String = ""
val walletAddress: String = "",
// AdaCam pairing
val deviceSerial: String = "",
val apiToken: String = "",
val isPaired: Boolean = false
)
/**
* Derive the API token from the device serial.
* Token = first 32 chars of SHA-256("adacam-api-{serial}-token")
*/
fun deriveApiToken(serial: String): String {
val input = "adacam-api-$serial-token"
val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }.substring(0, 32)
}
class SettingsDataStore(private val context: Context) {
companion object {
@ -37,11 +52,15 @@ class SettingsDataStore(private val context: Context) {
private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled")
private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id")
private val KEY_WALLET_ADDRESS = stringPreferencesKey("wallet_address")
// AdaCam pairing keys
private val KEY_DEVICE_SERIAL = stringPreferencesKey("device_serial")
private val KEY_API_TOKEN = stringPreferencesKey("api_token")
private val KEY_IS_PAIRED = booleanPreferencesKey("is_paired")
}
val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
VarroaSettings(
beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000",
beeApiUrl = prefs[KEY_BEE_URL] ?: "http://10.77.0.1:5000",
adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org",
adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***",
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
@ -49,7 +68,10 @@ class SettingsDataStore(private val context: Context) {
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,
forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true,
cachedDeviceId = prefs[KEY_CACHED_DEVICE_ID] ?: "unknown",
walletAddress = prefs[KEY_WALLET_ADDRESS] ?: ""
walletAddress = prefs[KEY_WALLET_ADDRESS] ?: "",
deviceSerial = prefs[KEY_DEVICE_SERIAL] ?: "",
apiToken = prefs[KEY_API_TOKEN] ?: "",
isPaired = prefs[KEY_IS_PAIRED] ?: false
)
}
@ -64,6 +86,9 @@ class SettingsDataStore(private val context: Context) {
prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled
prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId
prefs[KEY_WALLET_ADDRESS] = s.walletAddress
prefs[KEY_DEVICE_SERIAL] = s.deviceSerial
prefs[KEY_API_TOKEN] = s.apiToken
prefs[KEY_IS_PAIRED] = s.isPaired
}
}
@ -72,4 +97,28 @@ class SettingsDataStore(private val context: Context) {
prefs[KEY_CACHED_DEVICE_ID] = deviceId
}
}
/**
* Store pairing data after successful pairing with AdaCam.
* Derives and stores the API token from the serial.
*/
suspend fun savePairing(serial: String) {
val token = deriveApiToken(serial)
context.dataStore.edit { prefs ->
prefs[KEY_DEVICE_SERIAL] = serial
prefs[KEY_API_TOKEN] = token
prefs[KEY_IS_PAIRED] = true
}
}
/**
* Clear pairing data (for re-pairing or reset).
*/
suspend fun clearPairing() {
context.dataStore.edit { prefs ->
prefs[KEY_DEVICE_SERIAL] = ""
prefs[KEY_API_TOKEN] = ""
prefs[KEY_IS_PAIRED] = false
}
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.*
import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -22,6 +23,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig
import com.adamaps.varroa.ui.theme.*
@ -36,6 +38,7 @@ fun DeviceStatusScreen(
val state by vm.state.collectAsState()
val wifiResult by vm.wifiSaveResult.collectAsState()
val uploadResult by vm.uploadModeResult.collectAsState()
val sshResult by vm.sshToggleResult.collectAsState()
// Refresh on first load
LaunchedEffect(Unit) {
@ -56,6 +59,12 @@ fun DeviceStatusScreen(
vm.clearUploadModeResult()
}
}
LaunchedEffect(sshResult) {
sshResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearSshToggleResult()
}
}
Scaffold(
containerColor = Background,
@ -119,6 +128,9 @@ fun DeviceStatusScreen(
// GPS/GNSS Status
GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock)
// SSH Status
SshStatusCard(state.sshStatus, vm)
// Upload Mode
UploadModeCard(state.deviceInfo?.uploadMode, vm)
@ -460,6 +472,55 @@ private fun UploadModeCard(currentMode: String?, vm: DeviceStatusViewModel) {
}
}
@Composable
private fun SshStatusCard(ssh: SshStatus?, vm: DeviceStatusViewModel) {
val isActive = ssh?.active ?: false
StatusCard("SSH ACCESS") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Enable SSH",
color = OnSurface,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
Text(
"SSH access over home WiFi",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
Switch(
checked = isActive,
onCheckedChange = { vm.toggleSsh(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
checkedTrackColor = Amber.copy(alpha = 0.5f),
uncheckedThumbColor = Color.Gray,
uncheckedTrackColor = SurfaceVariant
)
)
}
if (isActive) {
Spacer(Modifier.height(8.dp))
Text(
"SSH enabled. Connect via:\nssh root@<device-ip>",
color = Color.Green,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
}
}
}
@Composable
private fun PluginsCard(plugins: List<BeePlugin>) {
StatusCard("PLUGINS") {

View file

@ -11,8 +11,11 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -24,6 +27,8 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -44,6 +49,14 @@ fun SettingsScreen(
) {
val currentSettings by vm.settings.collectAsState()
val saved by vm.saved.collectAsState()
val isPaired by vm.isPaired.collectAsState()
val deviceSerial by vm.deviceSerial.collectAsState()
val pairingInProgress by vm.pairingInProgress.collectAsState()
val pairingResult by vm.pairingResult.collectAsState()
val sshStatus by vm.sshStatus.collectAsState()
val sshToggleResult by vm.sshToggleResult.collectAsState()
val wifiStatus by vm.wifiStatus.collectAsState()
val wifiConnectResult by vm.wifiConnectResult.collectAsState()
// Local edit state — initialized from current settings
var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) }
@ -62,6 +75,24 @@ fun SettingsScreen(
vm.clearSaved()
}
}
LaunchedEffect(pairingResult) {
pairingResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearPairingResult()
}
}
LaunchedEffect(sshToggleResult) {
sshToggleResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearSshResult()
}
}
LaunchedEffect(wifiConnectResult) {
wifiConnectResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearWifiResult()
}
}
Scaffold(
containerColor = Background,
@ -86,7 +117,7 @@ fun SettingsScreen(
actions = {
IconButton(onClick = {
vm.save(
VarroaSettings(
currentSettings.copy(
beeApiUrl = beeApiUrl.trim(),
adamapsApiUrl = adamapsApiUrl.trim(),
adamapsApiKey = adamapsApiKey.trim(),
@ -111,6 +142,15 @@ fun SettingsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Device Pairing section
PairingSection(
isPaired = isPaired,
deviceSerial = deviceSerial,
pairingInProgress = pairingInProgress,
onPair = { vm.pairDevice() },
onClearPairing = { vm.clearPairing() }
)
// Device Status navigation card
Card(
onClick = onNavigateToDeviceStatus,
@ -157,12 +197,30 @@ fun SettingsScreen(
}
}
SettingsSection("BEE DEVICE") {
// WiFi Config section (only show if paired)
if (isPaired) {
WifiConfigSection(
wifiStatus = wifiStatus,
onConnect = { ssid, password -> vm.connectWifi(ssid, password) },
onRefresh = { vm.refreshWifiStatus() }
)
}
// SSH section (only show if paired)
if (isPaired) {
SshSection(
sshStatus = sshStatus,
onToggle = { enabled -> vm.toggleSsh(enabled) },
onRefresh = { vm.refreshSshStatus() }
)
}
SettingsSection("ADACAM DEVICE") {
SettingsField(
label = "Bee API URL",
label = "AdaCam API URL",
value = beeApiUrl,
onValueChange = { beeApiUrl = it },
hint = "http://192.168.0.10:5000"
hint = "http://10.77.0.1:5000"
)
}
@ -227,6 +285,265 @@ fun SettingsScreen(
}
}
@Composable
private fun PairingSection(
isPaired: Boolean,
deviceSerial: String,
pairingInProgress: Boolean,
onPair: () -> Unit,
onClearPairing: () -> Unit
) {
SettingsSection("DEVICE PAIRING") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isPaired) Icons.Default.Link else Icons.Default.LinkOff,
contentDescription = null,
tint = if (isPaired) Color.Green else Color.Gray,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Column {
Text(
if (isPaired) "Paired" else "Not Paired",
color = if (isPaired) Color.Green else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
if (isPaired && deviceSerial.isNotBlank()) {
Text(
"Serial: $deviceSerial",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
}
Spacer(Modifier.height(12.dp))
if (!isPaired) {
Text(
"Connect your phone to the AdaCam WiFi network (adacam-XXXXXX), then tap Pair.",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onPair,
enabled = !pairingInProgress,
colors = ButtonDefaults.buttonColors(containerColor = Amber),
modifier = Modifier.fillMaxWidth()
) {
if (pairingInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = Background,
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
}
Text(
if (pairingInProgress) "Pairing..." else "Pair with AdaCam",
color = Background
)
}
} else {
OutlinedButton(
onClick = onClearPairing,
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.Red),
modifier = Modifier.fillMaxWidth()
) {
Text("Clear Pairing", fontSize = 12.sp)
}
}
}
}
@Composable
private fun WifiConfigSection(
wifiStatus: com.adamaps.varroa.data.WifiStatus?,
onConnect: (String, String) -> Unit,
onRefresh: () -> Unit
) {
var ssid by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
SettingsSection("HOME WIFI NETWORK") {
// Current status
if (wifiStatus != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Wifi,
contentDescription = null,
tint = if (wifiStatus.connected == true) Color.Green else Color.Yellow,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(8.dp))
Column {
Text(
if (wifiStatus.connected == true) "Connected" else "Disconnected",
color = if (wifiStatus.connected == true) Color.Green else Color.Yellow,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
if (wifiStatus.ssid != null && wifiStatus.connected == true) {
Text(
wifiStatus.ssid,
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
if (wifiStatus.ip != null && wifiStatus.connected == true) {
Text(
"IP: ${wifiStatus.ip}",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
IconButton(onClick = onRefresh) {
Icon(
Icons.Default.ChevronRight,
contentDescription = "Refresh",
tint = Amber
)
}
}
Spacer(Modifier.height(12.dp))
Divider(color = SurfaceVariant)
Spacer(Modifier.height(12.dp))
}
Text(
"Configure home WiFi for internet access",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text("SSID", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber,
unfocusedLabelColor = Color.Gray,
cursorColor = Amber,
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber,
unfocusedLabelColor = Color.Gray,
cursorColor = Amber,
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { onConnect(ssid, password) },
enabled = ssid.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Amber),
modifier = Modifier.fillMaxWidth()
) {
Text("Connect", color = Background)
}
}
}
@Composable
private fun SshSection(
sshStatus: com.adamaps.varroa.data.SshStatus?,
onToggle: (Boolean) -> Unit,
onRefresh: () -> Unit
) {
val isActive = sshStatus?.active ?: false
SettingsSection("SSH ACCESS") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Enable SSH",
color = OnSurface,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
Text(
"SSH over home WiFi network",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
Switch(
checked = isActive,
onCheckedChange = { onToggle(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
checkedTrackColor = Amber.copy(alpha = 0.5f),
uncheckedThumbColor = Color.Gray,
uncheckedTrackColor = SurfaceVariant
)
)
}
if (isActive) {
Spacer(Modifier.height(8.dp))
Text(
"SSH enabled. Connect via:\nssh root@<device-ip>",
color = Color.Green,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
}
}
}
@Composable
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Card(

View file

@ -10,6 +10,7 @@ import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig
import kotlinx.coroutines.flow.MutableStateFlow
@ -25,6 +26,7 @@ data class DeviceStatusState(
val wifiConfig: WifiConfig? = null,
val storageStatus: StorageStatus? = null,
val gnssStatus: GnssStatus? = null,
val sshStatus: SshStatus? = null,
val plugins: List<BeePlugin> = emptyList(),
val error: String? = null
)
@ -47,6 +49,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
private val _uploadModeResult = MutableStateFlow<String?>(null)
val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow()
private val _sshToggleResult = MutableStateFlow<String?>(null)
val sshToggleResult: StateFlow<String?> = _sshToggleResult.asStateFlow()
fun refresh() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
@ -54,6 +59,10 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
try {
val settings = store.settings.first()
val client = BeeApiClient(settings.beeApiUrl)
// Set auth token if paired
if (settings.apiToken.isNotBlank()) {
client.apiToken = settings.apiToken
}
beeClient = client
// Fetch all status in parallel
@ -62,12 +71,14 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
val storageResult = client.getStorageStatus()
val gnssResult = client.getGnssStatus()
val pluginsResult = client.getPlugins()
val sshResult = client.getSshStatus()
val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data
val wifi = (wifiResult as? ApiResult.Success)?.data
val storage = (storageResult as? ApiResult.Success)?.data
val gnss = (gnssResult as? ApiResult.Success)?.data
val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList()
val ssh = (sshResult as? ApiResult.Success)?.data
val isConnected = deviceInfo != null
@ -78,8 +89,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
wifiConfig = wifi,
storageStatus = storage,
gnssStatus = gnss,
sshStatus = ssh,
plugins = plugins,
error = if (!isConnected) "Cannot connect to Bee device" else null
error = if (!isConnected) "Cannot connect to AdaCam device" else null
)
Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}")
@ -129,6 +141,24 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
}
}
fun toggleSsh(enabled: Boolean) {
viewModelScope.launch {
val client = beeClient ?: return@launch
_sshToggleResult.value = null
when (val result = client.setSshEnabled(enabled)) {
is ApiResult.Success -> {
_sshToggleResult.value = if (enabled) "SSH enabled" else "SSH disabled"
_state.value = _state.value.copy(sshStatus = SshStatus(active = enabled))
}
is ApiResult.Error -> {
_sshToggleResult.value = "Failed: ${result.message}"
refresh()
}
}
}
}
fun clearWifiResult() {
_wifiSaveResult.value = null
}
@ -136,4 +166,8 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
fun clearUploadModeResult() {
_uploadModeResult.value = null
}
fun clearSshToggleResult() {
_sshToggleResult.value = null
}
}

View file

@ -1,30 +1,97 @@
package com.adamaps.varroa.viewmodel
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.adamaps.varroa.api.BeeApiClient
import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.VarroaSettings
import com.adamaps.varroa.data.WifiStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
companion object {
private const val TAG = "SettingsVM"
}
private val store = SettingsDataStore(app)
private var beeClient: BeeApiClient? = null
val settings: StateFlow<VarroaSettings> = store.settings
.stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings())
// Derived observables from settings
val isPaired: StateFlow<Boolean> = store.settings
.map { it.isPaired }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val deviceSerial: StateFlow<String> = store.settings
.map { it.deviceSerial }
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
private val _saved = MutableStateFlow(false)
val saved: StateFlow<Boolean> = _saved.asStateFlow()
// Pairing state
private val _pairingInProgress = MutableStateFlow(false)
val pairingInProgress: StateFlow<Boolean> = _pairingInProgress.asStateFlow()
private val _pairingResult = MutableStateFlow<String?>(null)
val pairingResult: StateFlow<String?> = _pairingResult.asStateFlow()
// SSH state
private val _sshStatus = MutableStateFlow<SshStatus?>(null)
val sshStatus: StateFlow<SshStatus?> = _sshStatus.asStateFlow()
private val _sshToggleResult = MutableStateFlow<String?>(null)
val sshToggleResult: StateFlow<String?> = _sshToggleResult.asStateFlow()
// WiFi state
private val _wifiStatus = MutableStateFlow<WifiStatus?>(null)
val wifiStatus: StateFlow<WifiStatus?> = _wifiStatus.asStateFlow()
private val _wifiConnectResult = MutableStateFlow<String?>(null)
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
init {
// Initialize BeeApiClient with stored settings and token
viewModelScope.launch {
val s = store.settings.first()
val client = BeeApiClient(s.beeApiUrl)
if (s.apiToken.isNotBlank()) {
client.apiToken = s.apiToken
}
beeClient = client
// Fetch initial statuses if paired
if (s.isPaired) {
refreshSshStatus()
refreshWifiStatus()
}
}
}
fun save(s: VarroaSettings) {
viewModelScope.launch {
store.save(s)
// Update client URL and token if changed
beeClient?.updateUrl(s.beeApiUrl)
if (s.apiToken.isNotBlank()) {
beeClient?.apiToken = s.apiToken
}
_saved.value = true
}
}
@ -32,4 +99,165 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun clearSaved() {
_saved.value = false
}
// ── Pairing ───────────────────────────────────────────────────────────────
/**
* Pair with the AdaCam device.
* Fetches serial from /pair endpoint, derives token, stores both.
*/
fun pairDevice() {
viewModelScope.launch {
_pairingInProgress.value = true
_pairingResult.value = null
try {
val s = store.settings.first()
val client = BeeApiClient(s.beeApiUrl)
beeClient = client
when (val result = client.pair()) {
is ApiResult.Success -> {
val serial = result.data.serial
Log.i(TAG, "Pairing successful: serial=$serial")
// Store pairing data (derives token automatically)
store.savePairing(serial)
// Update client with new token
val newSettings = store.settings.first()
client.apiToken = newSettings.apiToken
_pairingResult.value = "Paired successfully! Serial: $serial"
// Fetch statuses now that we're paired
refreshSshStatus()
refreshWifiStatus()
}
is ApiResult.Error -> {
Log.e(TAG, "Pairing failed: ${result.message}")
_pairingResult.value = "Pairing failed: ${result.message}"
}
}
} catch (e: Exception) {
Log.e(TAG, "Pairing exception", e)
_pairingResult.value = "Pairing error: ${e.message}"
} finally {
_pairingInProgress.value = false
}
}
}
/**
* Clear pairing data (for re-pairing).
*/
fun clearPairing() {
viewModelScope.launch {
store.clearPairing()
beeClient?.apiToken = ""
_sshStatus.value = null
_wifiStatus.value = null
_pairingResult.value = "Pairing cleared"
}
}
fun clearPairingResult() {
_pairingResult.value = null
}
// ── WiFi ──────────────────────────────────────────────────────────────────
/**
* Refresh WiFi status from device.
*/
fun refreshWifiStatus() {
viewModelScope.launch {
val client = beeClient ?: return@launch
when (val result = client.getWifiStatus()) {
is ApiResult.Success -> {
_wifiStatus.value = result.data
}
is ApiResult.Error -> {
Log.w(TAG, "Failed to get WiFi status: ${result.message}")
}
}
}
}
/**
* Connect device to a home WiFi network.
*/
fun connectWifi(ssid: String, password: String) {
viewModelScope.launch {
val client = beeClient ?: run {
_wifiConnectResult.value = "Not connected to device"
return@launch
}
_wifiConnectResult.value = null
when (val result = client.setWifiConfig(ssid, password)) {
is ApiResult.Success -> {
_wifiConnectResult.value = "WiFi config sent. Connecting to $ssid..."
// Refresh status after a short delay
kotlinx.coroutines.delay(3000)
refreshWifiStatus()
}
is ApiResult.Error -> {
_wifiConnectResult.value = "Failed: ${result.message}"
}
}
}
}
fun clearWifiResult() {
_wifiConnectResult.value = null
}
// ── SSH ───────────────────────────────────────────────────────────────────
/**
* Refresh SSH status from device.
*/
fun refreshSshStatus() {
viewModelScope.launch {
val client = beeClient ?: return@launch
when (val result = client.getSshStatus()) {
is ApiResult.Success -> {
_sshStatus.value = result.data
}
is ApiResult.Error -> {
Log.w(TAG, "Failed to get SSH status: ${result.message}")
}
}
}
}
/**
* Toggle SSH on/off on the device.
*/
fun toggleSsh(enabled: Boolean) {
viewModelScope.launch {
val client = beeClient ?: run {
_sshToggleResult.value = "Not connected to device"
return@launch
}
_sshToggleResult.value = null
when (val result = client.setSshEnabled(enabled)) {
is ApiResult.Success -> {
_sshToggleResult.value = if (enabled) "SSH enabled" else "SSH disabled"
_sshStatus.value = SshStatus(active = enabled)
}
is ApiResult.Error -> {
_sshToggleResult.value = "Failed: ${result.message}"
// Refresh to get actual state
refreshSshStatus()
}
}
}
}
fun clearSshResult() {
_sshToggleResult.value = null
}
}