From 01b031cec3f5a77535c0b8381fc0b38f55ab3f0f Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 14 Mar 2026 11:50:00 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20adacam=20migration=20=E2=80=94=20update?= =?UTF-8?q?=20IP,=20add=20pairing,=20bearer=20auth,=20wifi/ssh=20config,?= =?UTF-8?q?=20remove=20tunnel=20and=20cmd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/build.gradle.kts | 2 - .../com/adamaps/varroa/api/BeeApiClient.kt | 221 ++++++------ .../java/com/adamaps/varroa/data/Models.kt | 25 +- .../adamaps/varroa/data/SettingsDataStore.kt | 57 ++- .../varroa/ui/settings/DeviceStatusScreen.kt | 61 ++++ .../varroa/ui/settings/SettingsScreen.kt | 325 +++++++++++++++++- .../varroa/viewmodel/DeviceStatusViewModel.kt | 36 +- .../varroa/viewmodel/SettingsViewModel.kt | 228 ++++++++++++ 8 files changed, 822 insertions(+), 133 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 903d76a..01f4169 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt index a5ed89a..26b3d81 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -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 = 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> = 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 = withContext(Dispatchers.IO) { when (val r = getRaw("/api/1/wifi/status")) { @@ -273,6 +293,17 @@ class BeeApiClient( } } + suspend fun getWifiStatus(): ApiResult = 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 = 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 = 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 = 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 = 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 = 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 = 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): ApiResult = 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): ApiResult = 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}") - } - } } diff --git a/app/src/main/java/com/adamaps/varroa/data/Models.kt b/app/src/main/java/com/adamaps/varroa/data/Models.kt index f9a9e64..7106429 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -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( diff --git a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt index c97c981..44a352c 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -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 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 = 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 + } + } } diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt index 0403e38..0edc86b 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt @@ -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@", + color = Color.Green, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + lineHeight = 14.sp + ) + } + } +} + @Composable private fun PluginsCard(plugins: List) { StatusCard("PLUGINS") { diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt index 1698807..19cd2d1 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt @@ -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@", + color = Color.Green, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + lineHeight = 14.sp + ) + } + } +} + @Composable private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { Card( diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt index 09ab8be..ae6e7fa 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt @@ -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 = emptyList(), val error: String? = null ) @@ -47,6 +49,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { private val _uploadModeResult = MutableStateFlow(null) val uploadModeResult: StateFlow = _uploadModeResult.asStateFlow() + private val _sshToggleResult = MutableStateFlow(null) + val sshToggleResult: StateFlow = _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 + } } diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt index a703b95..5d1ef53 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt @@ -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 = store.settings .stateIn(viewModelScope, SharingStarted.Eagerly, VarroaSettings()) + // Derived observables from settings + val isPaired: StateFlow = store.settings + .map { it.isPaired } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val deviceSerial: StateFlow = store.settings + .map { it.deviceSerial } + .stateIn(viewModelScope, SharingStarted.Eagerly, "") + private val _saved = MutableStateFlow(false) val saved: StateFlow = _saved.asStateFlow() + // Pairing state + private val _pairingInProgress = MutableStateFlow(false) + val pairingInProgress: StateFlow = _pairingInProgress.asStateFlow() + + private val _pairingResult = MutableStateFlow(null) + val pairingResult: StateFlow = _pairingResult.asStateFlow() + + // SSH state + private val _sshStatus = MutableStateFlow(null) + val sshStatus: StateFlow = _sshStatus.asStateFlow() + + private val _sshToggleResult = MutableStateFlow(null) + val sshToggleResult: StateFlow = _sshToggleResult.asStateFlow() + + // WiFi state + private val _wifiStatus = MutableStateFlow(null) + val wifiStatus: StateFlow = _wifiStatus.asStateFlow() + + private val _wifiConnectResult = MutableStateFlow(null) + val wifiConnectResult: StateFlow = _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 + } }