From 60d2f693d1652829473f43b63ab44dd8350de91a Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 12 Mar 2026 08:32:27 -0700 Subject: [PATCH] Add BeeSettingsScreen with device info, WiFi, storage, GNSS, upload mode, plugins - New BeeSettingsScreen.kt: full Bee device settings UI - Device Info section (read-only): ID, firmware, serial, uptime, GPS lock - WiFi Client section (read/write): status, saved network, scan, configure - Storage section: cache status + FrameKM storage with visual bar - GPS/GNSS section: fix status, coordinates, satellites, accuracy - Upload Mode section: LTE/WIFI/APP toggle buttons - Plugin Status section: beekeeper, depth-ai, privacy-zones, map-ai states - Loading/error/offline states with retry - New BeeSettingsViewModel.kt: parallel fetching from all Bee API sections - Extended BeeApiClient: WiFi settings/status/scan/enable/reset, upload mode, config, cache status, FrameKM total, GNSS status, plugin state methods - Extended Models.kt: WifiClientSettings, WifiStatus, WifiNetwork, CacheStatus, BeeConfig, UploadModeResponse, GnssStatus, PluginState, FrameKmTotal - Navigation.kt: added BEE_SETTINGS route - DashboardScreen: added Bee Device router icon in top bar, CONFIGURE link in Device Status card --- .../java/com/adamaps/varroa/Navigation.kt | 10 +- .../com/adamaps/varroa/api/BeeApiClient.kt | 408 +++++--- .../java/com/adamaps/varroa/data/Models.kt | 80 ++ .../varroa/ui/dashboard/DashboardScreen.kt | 32 +- .../varroa/ui/settings/BeeSettingsScreen.kt | 972 ++++++++++++++++++ .../varroa/viewmodel/BeeSettingsViewModel.kt | 223 ++++ 6 files changed, 1558 insertions(+), 167 deletions(-) create mode 100644 app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt create mode 100644 app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt diff --git a/app/src/main/java/com/adamaps/varroa/Navigation.kt b/app/src/main/java/com/adamaps/varroa/Navigation.kt index e23ca05..9896951 100644 --- a/app/src/main/java/com/adamaps/varroa/Navigation.kt +++ b/app/src/main/java/com/adamaps/varroa/Navigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.adamaps.varroa.ui.dashboard.DashboardScreen +import com.adamaps.varroa.ui.settings.BeeSettingsScreen import com.adamaps.varroa.ui.settings.DeviceStatusScreen import com.adamaps.varroa.ui.settings.SettingsScreen @@ -12,6 +13,7 @@ object Routes { const val DASHBOARD = "dashboard" const val SETTINGS = "settings" const val DEVICE_STATUS = "device_status" + const val BEE_SETTINGS = "bee_settings" } @Composable @@ -19,7 +21,10 @@ fun VarroaNavGraph() { val nav = rememberNavController() NavHost(navController = nav, startDestination = Routes.DASHBOARD) { composable(Routes.DASHBOARD) { - DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }) + DashboardScreen( + onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }, + onNavigateToBeeSettings = { nav.navigate(Routes.BEE_SETTINGS) } + ) } composable(Routes.SETTINGS) { SettingsScreen( @@ -30,5 +35,8 @@ fun VarroaNavGraph() { composable(Routes.DEVICE_STATUS) { DeviceStatusScreen(onBack = { nav.popBackStack() }) } + composable(Routes.BEE_SETTINGS) { + BeeSettingsScreen(onBack = { nav.popBackStack() }) + } } } 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..6215393 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -6,29 +6,35 @@ import android.net.Network import android.net.NetworkCapabilities import android.util.Log import com.adamaps.varroa.data.ApiResult +import com.adamaps.varroa.data.BeeConfig import com.adamaps.varroa.data.BeeDetection import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeePlugin +import com.adamaps.varroa.data.CacheStatus +import com.adamaps.varroa.data.FrameKmTotal import com.adamaps.varroa.data.GnssData import com.adamaps.varroa.data.GnssStatus +import com.adamaps.varroa.data.PairResponse +import com.adamaps.varroa.data.PluginState +import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.StorageStatus +import com.adamaps.varroa.data.UploadModeResponse +import com.adamaps.varroa.data.WifiClientSettings import com.adamaps.varroa.data.WifiConfig +import com.adamaps.varroa.data.WifiNetwork +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 okhttp3.RequestBody.Companion.toRequestBody 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 +49,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 +59,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 +116,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() @@ -177,8 +186,37 @@ class BeeApiClient( } } + // ── POST helper ─────────────────────────────────────────────────────────── + + private suspend fun postRaw(path: String, json: String, authenticated: Boolean = true): ApiResult = withContext(Dispatchers.IO) { + val fullUrl = "$apiUrl$path" + Log.d(TAG, "HTTP POST $fullUrl body=$json") + try { + val body = json.toRequestBody("application/json".toMediaType()) + val reqBuilder = Request.Builder().url(fullUrl).post(body) + if (authenticated && apiToken.isNotBlank()) { + reqBuilder.addHeader("Authorization", "Bearer $apiToken") + } + val req = reqBuilder.build() + client.newCall(req).execute().use { resp -> + val respBody = resp.body?.string() ?: "" + if (resp.isSuccessful) { + isConnected = true + Log.d(TAG, "POST ${resp.code} OK") + ApiResult.Success(respBody) + } else { + Log.w(TAG, "POST ${resp.code} ${resp.message}") + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } + } catch (e: Exception) { + Log.e(TAG, "POST failed to $fullUrl", e) + ApiResult.Error(e.message ?: "Unknown error") + } + } + /** - * Check if Bee is reachable. + * Check if AdaCam is reachable. * Updates internal connection state. */ suspend fun ping(): Boolean { @@ -208,6 +246,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") @@ -225,7 +282,6 @@ class BeeApiClient( } is ApiResult.Error -> { Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})") - // Connection failed, mark as offline if (r.message.contains("timeout", ignoreCase = true) || r.message.contains("connect", ignoreCase = true) || r.message.contains("refused", ignoreCase = true) || @@ -260,7 +316,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,31 +329,94 @@ class BeeApiClient( } } - suspend fun setWifiConfig(ssid: String, password: String): ApiResult = withContext(Dispatchers.IO) { - try { - val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password)) - val requestBody = okhttp3.RequestBody.create( - "application/json".toMediaType(), - jsonBody - ) - val request = Request.Builder() - .url("$apiUrl/api/1/wifi/connect") - .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) - } + 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}") } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error") + is ApiResult.Error -> r } } + suspend fun setWifiConfig(ssid: String, password: String): ApiResult { + val json = gson.toJson(mapOf("ssid" to ssid, "password" to password)) + return postRaw("/api/1/wifi/connect", json) + } + + // WiFi Client methods (legacy API) + suspend fun getWifiClientStatus(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifiClient/status")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WifiStatus::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun getWifiSettings(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifiClient/settings")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, WifiClientSettings::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun saveWifiSettings(settings: WifiClientSettings): ApiResult { + val json = gson.toJson(settings) + return postRaw("/api/1/wifiClient/settings", json) + } + + suspend fun setWifiEnabled(enabled: Boolean): ApiResult { + val json = """{"enabled": $enabled}""" + return postRaw("/api/1/wifiClient/enable", json) + } + + suspend fun scanWifi(): ApiResult> = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifiClient/scan")) { + is ApiResult.Success -> try { + val type = object : TypeToken>() {}.type + ApiResult.Success(gson.fromJson(r.data, type)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun resetWifi(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifiClient/reset")) { + is ApiResult.Success -> r + is ApiResult.Error -> r + } + } + + // ── 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 { + val json = gson.toJson(mapOf("enable" to enabled)) + return postRaw("/api/1/ssh/toggle", json) + } + + // ── Storage & GNSS Status ───────────────────────────────────────────────── + suspend fun getStorageStatus(): ApiResult = withContext(Dispatchers.IO) { when (val r = getRaw("/api/1/storage/usage")) { is ApiResult.Success -> try { @@ -332,76 +451,97 @@ class BeeApiClient( } } - suspend fun setUploadMode(mode: String): ApiResult = withContext(Dispatchers.IO) { - try { - val jsonBody = gson.toJson(mapOf("mode" to mode)) - val requestBody = okhttp3.RequestBody.create( - "application/json".toMediaType(), - jsonBody - ) - val request = Request.Builder() - .url("$apiUrl/api/1/config/uploadMode") - .post(requestBody) - .build() + // ── Upload Mode ─────────────────────────────────────────────────────────── - client.newCall(request).execute().use { resp -> - val body = resp.body?.string() ?: "" - if (resp.isSuccessful) { - ApiResult.Success(body) + suspend fun getUploadMode(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/config/uploadMode")) { + is ApiResult.Success -> try { + val trimmed = r.data.trim().trim('"') + if (trimmed.startsWith("{")) { + val obj = gson.fromJson(r.data, UploadModeResponse::class.java) + ApiResult.Success(obj.currentMode() ?: "UNKNOWN") } else { - ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + ApiResult.Success(trimmed) } + } catch (e: Exception) { + ApiResult.Success(r.data.trim().trim('"')) } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error") + is ApiResult.Error -> r } } - // ── End v7.7 Settings API ───────────────────────────────────────────────── + suspend fun setUploadMode(mode: String): ApiResult { + val json = """{"mode": "$mode"}""" + return postRaw("/api/1/config/uploadMode", json) + } - 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 + // ── Config ──────────────────────────────────────────────────────────────── - 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) + suspend fun getConfig(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/config/")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, BeeConfig::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") } - - 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}") + is ApiResult.Error -> r } } + // ── Cache / Storage ─────────────────────────────────────────────────────── + + suspend fun getCacheStatus(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/cache/status")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, CacheStatus::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun getFrameKmTotal(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/framekm/total")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, FrameKmTotal::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + // ── Plugin State ────────────────────────────────────────────────────────── + + suspend fun getPluginState(pluginName: String): PluginState = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/plugin/getPluginState/$pluginName")) { + is ApiResult.Success -> try { + val trimmed = r.data.trim() + val enabled = when { + trimmed == "true" -> true + trimmed == "false" -> false + trimmed.startsWith("{") -> { + val obj = gson.fromJson(trimmed, com.google.gson.JsonObject::class.java) + obj.get("enabled")?.asBoolean ?: obj.get("state")?.asString == "enabled" + } + else -> null + } + PluginState(pluginName, enabled) + } catch (e: Exception) { + PluginState(pluginName, null, e.message) + } + is ApiResult.Error -> PluginState(pluginName, null, r.message) + } + } + + suspend fun getKnownPluginStates(): List = withContext(Dispatchers.IO) { + val knownPlugins = listOf("beekeeper", "depth-ai", "privacy-zones", "map-ai") + knownPlugins.map { name -> getPluginState(name) } + } + + // ── Camera API ──────────────────────────────────────────────────────────── + /** * Try the given endpoint; returns raw image bytes. * The caller is responsible for trying fallback endpoints. @@ -429,9 +569,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 +592,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 +611,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 -> { @@ -497,31 +626,34 @@ class BeeApiClient( } is ApiResult.Error -> { Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}") - // Continue trying other endpoints } } } - 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) { try { val jsonBody = gson.toJson(mapOf("ids" to landmarkIds)) - val requestBody = okhttp3.RequestBody.create( - "application/json".toMediaType(), - jsonBody - ) + val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) // Try both DELETE and POST methods 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() + if (apiToken.isNotBlank()) { + requestBuilder.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 +672,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..87a5968 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -122,6 +122,86 @@ data class BeePlugin( @SerializedName("running") val running: Boolean? = null ) +// ── Bee Settings API models ─────────────────────────────────────────────────── + +data class WifiClientSettings( + @SerializedName("ssid") val ssid: String? = null, + @SerializedName("password") val password: String? = null, + @SerializedName("enabled") val enabled: Boolean? = null, + @SerializedName("security") val security: String? = null, + @SerializedName("freq") val freq: Int? = null +) + +data class WifiStatus( + @SerializedName("connected") val connected: Boolean? = null, + @SerializedName("ssid") val ssid: String? = null, + @SerializedName("ip") val ip: String? = null, + @SerializedName("signal") val signal: Int? = null, + @SerializedName("state") val state: String? = null +) + +data class WifiNetwork( + @SerializedName("ssid") val ssid: String? = null, + @SerializedName("signal") val signal: Int? = null, + @SerializedName("security") val security: String? = null, + @SerializedName("freq") val freq: Int? = null +) + +data class CacheStatus( + @SerializedName("enabled") val enabled: Boolean? = null, + @SerializedName("size") val size: Long? = null, + @SerializedName("samples") val samples: Int? = null, + @SerializedName("sizeBytes") val sizeBytes: Long? = null, + @SerializedName("numSamples") val numSamples: Int? = null +) { + // Normalize field names across firmware versions + fun displaySize(): Long = size ?: sizeBytes ?: 0L + fun displaySamples(): Int = samples ?: numSamples ?: 0 +} + +data class BeeConfig( + @SerializedName("uploadMode") val uploadMode: String? = null, + @SerializedName("pluginsLocked") val pluginsLocked: Boolean? = null, + @SerializedName("pluginDevMode") val pluginDevMode: Boolean? = null, + @SerializedName("pausePluginUpdates") val pausePluginUpdates: Boolean? = null, + @SerializedName("isProcessingEnabled") val isProcessingEnabled: Boolean? = null, + @SerializedName("isUSBRecordingEnabled") val isUSBRecordingEnabled: Boolean? = null, + @SerializedName("isLowPowerModeEnabled") val isLowPowerModeEnabled: Boolean? = null +) + +data class UploadModeResponse( + @SerializedName("mode") val mode: String? = null, + @SerializedName("uploadMode") val uploadMode: String? = null +) { + fun currentMode(): String? = mode ?: uploadMode +} + +data class GnssStatus( + @SerializedName("lat_deg") val latDeg: Double? = null, + @SerializedName("lon_deg") val lonDeg: Double? = null, + @SerializedName("alt_m") val altM: Double? = null, + @SerializedName("unix_milliseconds") val unixMs: Long? = null, + @SerializedName("fix") val fix: Boolean? = null, + @SerializedName("fixType") val fixType: String? = null, + @SerializedName("satellites") val satellites: Int? = null, + @SerializedName("satellites_used") val satellitesUsed: Int? = null, + @SerializedName("accuracy_m") val accuracyM: Double? = null, + @SerializedName("hdop") val hdop: Double? = null, + @SerializedName("speed_m_s") val speedMs: Double? = null +) + +data class PluginState( + val name: String, + val enabled: Boolean?, + val error: String? = null +) + +data class FrameKmTotal( + @SerializedName("total") val total: Long? = null, + @SerializedName("totalBytes") val totalBytes: Long? = null, + @SerializedName("count") val count: Int? = null +) + // ── App state ───────────────────────────────────────────────────────────────── data class SessionStats( diff --git a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt index 0a5fb53..0b91b28 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt @@ -39,7 +39,8 @@ import org.osmdroid.views.overlay.Marker @Composable fun DashboardScreen( vm: DashboardViewModel = viewModel(), - onNavigateToSettings: () -> Unit + onNavigateToSettings: () -> Unit, + onNavigateToBeeSettings: () -> Unit = {} ) { val deviceInfo by vm.deviceInfo.collectAsState() val gnss by vm.gnss.collectAsState() @@ -101,8 +102,11 @@ fun DashboardScreen( Spacer(Modifier.width(8.dp)) } + IconButton(onClick = onNavigateToBeeSettings) { + Icon(Icons.Default.Router, contentDescription = "AdaCam Settings", tint = Amber) + } IconButton(onClick = onNavigateToSettings) { - Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber) + Icon(Icons.Default.Settings, contentDescription = "App Settings", tint = Amber) } } ) @@ -150,7 +154,7 @@ fun DashboardScreen( gnss?.let { GpsMapCard(it) } // Device status - deviceInfo?.let { DeviceStatusCard(it) } + deviceInfo?.let { DeviceStatusCard(it, onNavigateToBeeSettings) } } } } @@ -582,14 +586,32 @@ private fun OsmMapView(gnss: GnssData) { } @Composable -private fun DeviceStatusCard(info: BeeDeviceInfo) { +private fun DeviceStatusCard(info: BeeDeviceInfo, onNavigateToBeeSettings: () -> Unit = {}) { Card( colors = CardDefaults.cardColors(containerColor = Surface), shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(14.dp)) { - SectionHeader("DEVICE STATUS") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SectionHeader("DEVICE STATUS") + Text( + "CONFIGURE ›", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 9.sp, + letterSpacing = 1.sp, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { onNavigateToBeeSettings() } + .background(AmberDark.copy(alpha = 0.2f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } Spacer(Modifier.height(8.dp)) val rows: List> = listOf( "Firmware" to (info.firmwareVersion ?: "—"), diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt new file mode 100644 index 0000000..1a4533e --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt @@ -0,0 +1,972 @@ +package com.adamaps.varroa.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.adamaps.varroa.data.* +import com.adamaps.varroa.ui.theme.* +import com.adamaps.varroa.viewmodel.BeeSettingsLoadState +import com.adamaps.varroa.viewmodel.BeeSettingsViewModel +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BeeSettingsScreen( + vm: BeeSettingsViewModel = viewModel(), + onBack: () -> Unit +) { + val state by vm.state.collectAsState() + val message by vm.message.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(message) { + message?.let { + snackbarHostState.showSnackbar(it) + vm.clearMessage() + } + } + + Scaffold( + containerColor = Background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + "BEE DEVICE", + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 3.sp + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Amber) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), + actions = { + IconButton(onClick = { vm.loadAll() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = Amber) + } + } + ) + } + ) { padding -> + when (val loadState = state.loadState) { + is BeeSettingsLoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = Amber) + Spacer(Modifier.height(16.dp)) + Text( + "Fetching Bee device info…", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } + + is BeeSettingsLoadState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(24.dp) + ) { + Icon( + Icons.Default.CloudOff, + contentDescription = null, + tint = Error, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.height(12.dp)) + Text( + "Bee Unreachable", + color = Error, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Spacer(Modifier.height(8.dp)) + Text( + loadState.message, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(Modifier.height(20.dp)) + Button( + onClick = { vm.loadAll() }, + colors = ButtonDefaults.buttonColors(containerColor = AmberDark) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("RETRY", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + } + } + } + } + + else -> { + // Success or Idle — show content + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Device Info + state.deviceInfo?.let { DeviceInfoSection(it) } + + // WiFi Client + WifiSection( + status = state.wifiStatus, + settings = state.wifiSettings, + networks = state.wifiNetworks, + scanState = state.wifiScanState, + saving = state.wifiSaving, + onSave = { ssid, pass, enabled -> vm.saveWifiSettings(ssid, pass, enabled) }, + onToggleEnabled = { vm.setWifiEnabled(it) }, + onScan = { vm.scanWifi() }, + onReset = { vm.resetWifi() } + ) + + // Storage + StorageSection( + cacheStatus = state.cacheStatus, + frameKmTotal = state.frameKmTotal + ) + + // GNSS + GnssSection(gnss = state.gnssStatus) + + // Upload Mode + UploadModeSection( + currentMode = state.uploadMode, + saving = state.uploadModeSaving, + onSetMode = { vm.setUploadMode(it) } + ) + + // Plugins + if (state.plugins.isNotEmpty()) { + PluginSection( + plugins = state.plugins, + config = state.config + ) + } + + Spacer(Modifier.height(24.dp)) + } + } + } + } +} + +// ── Device Info ─────────────────────────────────────────────────────────────── + +@Composable +private fun DeviceInfoSection(info: BeeDeviceInfo) { + BeeCard(title = "DEVICE INFO", icon = Icons.Default.Info) { + val rows = listOfNotNull( + info.deviceId?.let { "Device ID" to it }, + info.serial?.let { "Serial" to it }, + info.firmwareVersion?.let { "Firmware" to it }, + info.apiVersion?.let { "API Version" to it }, + info.model?.let { "Model" to it }, + info.imei?.let { "IMEI" to it }, + info.ssid?.let { "Connected SSID" to it }, + info.uptime?.let { "Uptime" to formatUptime(it) }, + "GPS Lock" to if (info.hasGnssLock == true) "YES" else "NO", + "Internet" to if (info.internetIsHealthy == true) "HEALTHY" else "OFFLINE" + ) + rows.forEach { (k, v) -> + InfoRow(k, v, valueColor = when { + k == "GPS Lock" && v == "YES" -> Success + k == "GPS Lock" && v == "NO" -> Error + k == "Internet" && v == "HEALTHY" -> Success + k == "Internet" && v == "OFFLINE" -> Error + else -> OnSurface + }) + } + } +} + +// ── WiFi ────────────────────────────────────────────────────────────────────── + +@Composable +private fun WifiSection( + status: WifiStatus?, + settings: WifiClientSettings?, + networks: List, + scanState: BeeSettingsLoadState, + saving: Boolean, + onSave: (String, String, Boolean) -> Unit, + onToggleEnabled: (Boolean) -> Unit, + onScan: () -> Unit, + onReset: () -> Unit +) { + var expandAddNetwork by remember { mutableStateOf(false) } + var ssidInput by remember { mutableStateOf(settings?.ssid ?: "") } + var passwordInput by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var enabledInput by remember(settings) { mutableStateOf(settings?.enabled ?: true) } + + // Update SSID input when settings load + LaunchedEffect(settings?.ssid) { + if (settings?.ssid != null && ssidInput.isEmpty()) { + ssidInput = settings.ssid + } + } + + BeeCard(title = "WIFI CLIENT", icon = Icons.Default.Wifi) { + // Connection status + val connected = status?.connected ?: false + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + StatusIndicator(connected) + Spacer(Modifier.width(8.dp)) + Column { + Text( + text = if (connected) "CONNECTED" else "DISCONNECTED", + color = if (connected) Success else Color.Gray, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + status?.ssid?.let { + Text(it, color = OnSurface, fontFamily = FontFamily.Monospace, fontSize = 11.sp) + } + status?.ip?.let { + Text("IP: $it", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + } + // Enable/disable toggle + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "CLIENT", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 9.sp, + letterSpacing = 1.sp + ) + Switch( + checked = enabledInput, + onCheckedChange = { + enabledInput = it + onToggleEnabled(it) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Amber, + checkedTrackColor = AmberDark, + uncheckedThumbColor = Color.Gray, + uncheckedTrackColor = SurfaceVariant + ) + ) + } + } + + Spacer(Modifier.height(8.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(8.dp)) + + // Current saved network + if (settings?.ssid != null) { + InfoRow("Saved SSID", settings.ssid) + settings.security?.let { InfoRow("Security", it) } + settings.freq?.let { InfoRow("Frequency", "${it} MHz (${if (it < 3000) "2.4GHz" else "5GHz"})") } + } + + Spacer(Modifier.height(10.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { expandAddNetwork = !expandAddNetwork }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Amber), + border = androidx.compose.foundation.BorderStroke(1.dp, AmberDark) + ) { + Icon( + if (expandAddNetwork) Icons.Default.ExpandLess else Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + if (expandAddNetwork) "CANCEL" else "CONFIGURE", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + } + + OutlinedButton( + onClick = onScan, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Amber), + border = androidx.compose.foundation.BorderStroke(1.dp, AmberDark), + enabled = scanState !is BeeSettingsLoadState.Loading + ) { + if (scanState is BeeSettingsLoadState.Loading) { + CircularProgressIndicator( + color = Amber, + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp + ) + } else { + Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(14.dp)) + } + Spacer(Modifier.width(4.dp)) + Text("SCAN", fontFamily = FontFamily.Monospace, fontSize = 10.sp, fontWeight = FontWeight.Bold) + } + } + + // Network config form (expandable) + AnimatedVisibility(visible = expandAddNetwork) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "CONFIGURE NETWORK", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + letterSpacing = 1.sp + ) + BeeTextField( + value = ssidInput, + onValueChange = { ssidInput = it }, + label = "SSID", + hint = "Network name" + ) + BeeTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + label = "Password", + hint = "Leave empty for open network", + visualTransformation = if (showPassword) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(18.dp) + ) + } + } + ) + + // Scan results picker + if (networks.isNotEmpty()) { + Text( + "SCAN RESULTS", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 9.sp, + letterSpacing = 1.sp + ) + networks.forEach { net -> + if (net.ssid != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { ssidInput = net.ssid } + .background(SurfaceVariant) + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Default.Wifi, contentDescription = null, tint = Amber, modifier = Modifier.size(14.dp)) + Text( + net.ssid, + color = OnSurface, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + net.security?.let { + Text(it, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) + } + net.signal?.let { + Text("${it}dBm", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) + } + } + } + } + } + } + + Button( + onClick = { + onSave(ssidInput, passwordInput, enabledInput) + expandAddNetwork = false + passwordInput = "" + }, + enabled = ssidInput.isNotBlank() && !saving, + colors = ButtonDefaults.buttonColors(containerColor = AmberDark), + modifier = Modifier.fillMaxWidth() + ) { + if (saving) { + CircularProgressIndicator(color = Amber, modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(6.dp)) + } + Text( + "SAVE & CONNECT", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ) + } + } + } + + // Reset button + Spacer(Modifier.height(4.dp)) + TextButton( + onClick = onReset, + modifier = Modifier.align(Alignment.End) + ) { + Icon(Icons.Default.RestartAlt, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("RESET WIFI", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } +} + +// ── Storage ─────────────────────────────────────────────────────────────────── + +@Composable +private fun StorageSection( + cacheStatus: CacheStatus?, + frameKmTotal: FrameKmTotal? +) { + BeeCard(title = "STORAGE", icon = Icons.Default.Storage) { + if (cacheStatus == null && frameKmTotal == null) { + Text( + "Storage info unavailable", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + return@BeeCard + } + + cacheStatus?.let { cache -> + Text( + "DATA CACHE", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(4.dp)) + InfoRow( + "Status", + if (cache.enabled == true) "ENABLED" else "DISABLED", + valueColor = if (cache.enabled == true) Success else Color.Gray + ) + InfoRow("Samples", cache.displaySamples().toString()) + InfoRow("Cache Size", formatBytes(cache.displaySize())) + Spacer(Modifier.height(8.dp)) + + // Storage bar + val sizeBytes = cache.displaySize() + val maxBytes = 500L * 1024 * 1024 // 500MB limit from docs + val fraction = (sizeBytes.toFloat() / maxBytes).coerceIn(0f, 1f) + StorageBar(fraction, label = "Cache: ${formatBytes(sizeBytes)} / 500 MB") + } + + if (cacheStatus != null && frameKmTotal != null) { + Spacer(Modifier.height(10.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(10.dp)) + } + + frameKmTotal?.let { fkm -> + Text( + "FRAMEKM STORAGE", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(4.dp)) + fkm.count?.let { InfoRow("Processed Frames", it.toString()) } + val totalBytes = fkm.total ?: fkm.totalBytes + totalBytes?.let { InfoRow("Total Size", formatBytes(it)) } + } + } +} + +@Composable +private fun StorageBar(fraction: Float, label: String) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) + Text("${(fraction * 100).toInt()}%", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) + } + Spacer(Modifier.height(3.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(SurfaceVariant) + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .background( + when { + fraction > 0.85f -> Error + fraction > 0.65f -> Amber + else -> Success + } + ) + ) + } + } +} + +// ── GNSS ────────────────────────────────────────────────────────────────────── + +@Composable +private fun GnssSection(gnss: GnssStatus?) { + BeeCard(title = "GPS / GNSS", icon = Icons.Default.GpsFixed) { + if (gnss == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.GpsOff, contentDescription = null, tint = Error, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("No GNSS data available", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) + } + return@BeeCard + } + + val hasFix = gnss.fix ?: (gnss.latDeg != null && gnss.latDeg != 0.0 && gnss.lonDeg != null && gnss.lonDeg != 0.0) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (hasFix) Icons.Default.GpsFixed else Icons.Default.GpsNotFixed, + contentDescription = null, + tint = if (hasFix) Success else Amber, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + if (hasFix) "FIX ACQUIRED" else "NO FIX", + color = if (hasFix) Success else Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + } + gnss.fixType?.let { + Text(it, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + + Spacer(Modifier.height(8.dp)) + + val rows = listOfNotNull( + gnss.latDeg?.let { "Latitude" to "%.6f°".format(it) }, + gnss.lonDeg?.let { "Longitude" to "%.6f°".format(it) }, + gnss.altM?.let { "Altitude" to "%.1f m".format(it) }, + (gnss.satellitesUsed ?: gnss.satellites)?.let { "Satellites" to it.toString() }, + gnss.accuracyM?.let { "Accuracy" to "%.1f m".format(it) }, + gnss.hdop?.let { "HDOP" to "%.2f".format(it) }, + gnss.speedMs?.let { "Speed" to "%.1f m/s".format(it) }, + gnss.unixMs?.let { "Last Fix" to formatTimestamp(it) } + ) + rows.forEach { (k, v) -> InfoRow(k, v) } + } +} + +// ── Upload Mode ─────────────────────────────────────────────────────────────── + +@Composable +private fun UploadModeSection( + currentMode: String?, + saving: Boolean, + onSetMode: (String) -> Unit +) { + val modes = listOf("LTE", "WIFI", "APP") + BeeCard(title = "UPLOAD MODE", icon = Icons.Default.CloudUpload) { + Text( + "Select how the Bee uploads data.", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + Spacer(Modifier.height(10.dp)) + + if (saving) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(color = Amber, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text("Saving…", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + modes.forEach { mode -> + val selected = currentMode?.uppercase() == mode + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(if (selected) AmberDark else SurfaceVariant) + .border( + 1.dp, + if (selected) Amber else Color.Transparent, + RoundedCornerShape(6.dp) + ) + .clickable { onSetMode(mode) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + when (mode) { + "LTE" -> Icons.Default.NetworkCell + "WIFI" -> Icons.Default.Wifi + else -> Icons.Default.PhoneAndroid + }, + contentDescription = null, + tint = if (selected) Amber else Color.Gray, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.height(4.dp)) + Text( + mode, + color = if (selected) Amber else Color.Gray, + fontFamily = FontFamily.Monospace, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + fontSize = 11.sp, + letterSpacing = 1.sp + ) + } + } + } + } + + Spacer(Modifier.height(8.dp)) + val modeDescription = when (currentMode?.uppercase()) { + "LTE" -> "Uploading via cellular LTE connection" + "WIFI" -> "Uploading via WiFi network" + "APP" -> "Upload controlled by companion app" + else -> "Upload mode not set" + } + Text(modeDescription, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } +} + +// ── Plugins ─────────────────────────────────────────────────────────────────── + +@Composable +private fun PluginSection( + plugins: List, + config: BeeConfig? +) { + BeeCard(title = "PLUGINS", icon = Icons.Default.Extension) { + config?.let { cfg -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (cfg.pluginsLocked == true) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Lock, contentDescription = null, tint = Amber, modifier = Modifier.size(12.dp)) + Spacer(Modifier.width(4.dp)) + Text("PLUGINS LOCKED", color = Amber, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) + } + } + if (cfg.pluginDevMode == true) { + Text("DEV MODE", color = Error, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) + } + if (cfg.pausePluginUpdates == true) { + Text("UPDATES PAUSED", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) + } + } + if (cfg.pluginsLocked == true || cfg.pluginDevMode == true || cfg.pausePluginUpdates == true) { + Spacer(Modifier.height(8.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(8.dp)) + } + } + + plugins.forEach { plugin -> + PluginRow(plugin) + if (plugin != plugins.last()) { + Spacer(Modifier.height(4.dp)) + } + } + } +} + +@Composable +private fun PluginRow(plugin: PluginState) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Extension, + contentDescription = null, + tint = when (plugin.enabled) { + true -> Success + false -> Color.Gray + null -> Color(0xFF6B7280) + }, + modifier = Modifier.size(14.dp) + ) + Text( + plugin.name, + color = OnSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + when { + plugin.error != null -> Text( + "ERR", + color = Error, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + plugin.enabled == true -> Text( + "ENABLED", + color = Success, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + plugin.enabled == false -> Text( + "DISABLED", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + else -> Text( + "UNKNOWN", + color = Color(0xFF6B7280), + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + } +} + +// ── Shared components ───────────────────────────────────────────────────────── + +@Composable +private fun BeeCard( + title: String, + icon: ImageVector, + content: @Composable ColumnScope.() -> Unit +) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(icon, contentDescription = null, tint = Amber, modifier = Modifier.size(14.dp)) + Text( + title, + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + letterSpacing = 2.sp + ) + } + Spacer(Modifier.height(10.dp)) + content() + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + valueColor: Color = OnSurface +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + label, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.weight(0.45f) + ) + Text( + value, + color = valueColor, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(0.55f), + textAlign = androidx.compose.ui.text.style.TextAlign.End + ) + } +} + +@Composable +private fun BeeTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + hint: String = "", + visualTransformation: VisualTransformation = VisualTransformation.None, + trailingIcon: (@Composable () -> Unit)? = null, + numeric: Boolean = false +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + singleLine = true, + visualTransformation = visualTransformation, + trailingIcon = trailingIcon, + keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) + else KeyboardOptions.Default, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Amber, + unfocusedBorderColor = SurfaceVariant, + focusedLabelColor = Amber, + unfocusedLabelColor = Color.Gray, + cursorColor = Amber, + focusedTextColor = OnSurface, + unfocusedTextColor = OnSurface + ) + ) +} + +@Composable +private fun StatusIndicator(active: Boolean) { + Box( + modifier = Modifier + .size(10.dp) + .background( + color = if (active) Success else Color(0xFF6B7280), + shape = RoundedCornerShape(50) + ) + ) +} + +// ── Formatters ──────────────────────────────────────────────────────────────── + +private fun formatUptime(seconds: Long): String { + val h = seconds / 3600 + val m = (seconds % 3600) / 60 + val s = seconds % 60 + return "%02d:%02d:%02d".format(h, m, s) +} + +private fun formatBytes(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024 * 1024)) + } +} + +private fun formatTimestamp(ms: Long): String { + return try { + val sdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.US) + sdf.format(Date(ms)) + } catch (e: Exception) { + ms.toString() + } +} diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt new file mode 100644 index 0000000..0a33a53 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt @@ -0,0 +1,223 @@ +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.* +import com.adamaps.varroa.network.NetworkStateMonitor +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +private const val TAG = "BeeSettingsVM" + +sealed class BeeSettingsLoadState { + object Idle : BeeSettingsLoadState() + object Loading : BeeSettingsLoadState() + data class Error(val message: String) : BeeSettingsLoadState() + object Success : BeeSettingsLoadState() +} + +data class BeeSettingsState( + val deviceInfo: BeeDeviceInfo? = null, + val wifiStatus: WifiStatus? = null, + val wifiSettings: WifiClientSettings? = null, + val wifiNetworks: List = emptyList(), + val cacheStatus: CacheStatus? = null, + val frameKmTotal: FrameKmTotal? = null, + val gnssStatus: GnssStatus? = null, + val uploadMode: String? = null, + val config: BeeConfig? = null, + val plugins: List = emptyList(), + val loadState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, + val wifiScanState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, + val saveState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, + val uploadModeSaving: Boolean = false, + val wifiSaving: Boolean = false +) + +class BeeSettingsViewModel(app: Application) : AndroidViewModel(app) { + + private val settingsStore = SettingsDataStore(app) + private val networkMonitor = NetworkStateMonitor.getInstance(app) + private val beeClient = BeeApiClient() + + private val _state = MutableStateFlow(BeeSettingsState()) + val state: StateFlow = _state.asStateFlow() + + // Toast/snackbar messages + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + init { + viewModelScope.launch { + val s = settingsStore.settings.first() + beeClient.updateUrl(s.beeApiUrl) + val beeNet = networkMonitor.getBeeNetworkForBinding() + if (beeNet != null) { + beeClient.bindToNetwork(beeNet) + } else { + beeClient.bindToWifiNetwork(app) + } + loadAll() + } + } + + fun loadAll() { + viewModelScope.launch { + _state.update { it.copy(loadState = BeeSettingsLoadState.Loading) } + try { + // Fetch all sections concurrently + val deviceInfoDeferred = async { beeClient.getDeviceInfo() } + val wifiStatusDeferred = async { beeClient.getWifiStatus() } + val wifiSettingsDeferred = async { beeClient.getWifiSettings() } + val cacheDeferred = async { beeClient.getCacheStatus() } + val frameKmDeferred = async { beeClient.getFrameKmTotal() } + val gnssDeferred = async { beeClient.getGnssStatus() } + val uploadModeDeferred = async { beeClient.getUploadMode() } + val configDeferred = async { beeClient.getConfig() } + val pluginsDeferred = async { beeClient.getKnownPluginStates() } + + val deviceInfo = when (val r = deviceInfoDeferred.await()) { + is ApiResult.Success -> r.data + is ApiResult.Error -> { + Log.w(TAG, "deviceInfo failed: ${r.message}") + null + } + } + + if (deviceInfo == null) { + _state.update { + it.copy(loadState = BeeSettingsLoadState.Error("Bee not reachable — check connection and URL")) + } + return@launch + } + + val wifiStatus = (wifiStatusDeferred.await() as? ApiResult.Success)?.data + val wifiSettings = (wifiSettingsDeferred.await() as? ApiResult.Success)?.data + val cacheStatus = (cacheDeferred.await() as? ApiResult.Success)?.data + val frameKmTotal = (frameKmDeferred.await() as? ApiResult.Success)?.data + val gnssStatus = (gnssDeferred.await() as? ApiResult.Success)?.data + val uploadMode = (uploadModeDeferred.await() as? ApiResult.Success)?.data + val config = (configDeferred.await() as? ApiResult.Success)?.data + val plugins = pluginsDeferred.await() + + _state.update { + it.copy( + deviceInfo = deviceInfo, + wifiStatus = wifiStatus, + wifiSettings = wifiSettings, + cacheStatus = cacheStatus, + frameKmTotal = frameKmTotal, + gnssStatus = gnssStatus, + uploadMode = uploadMode ?: deviceInfo.uploadMode, + config = config, + plugins = plugins, + loadState = BeeSettingsLoadState.Success + ) + } + Log.i(TAG, "BeeSettings loaded successfully") + + } catch (e: Exception) { + Log.e(TAG, "loadAll failed", e) + _state.update { + it.copy(loadState = BeeSettingsLoadState.Error(e.message ?: "Unknown error")) + } + } + } + } + + fun scanWifi() { + viewModelScope.launch { + _state.update { it.copy(wifiScanState = BeeSettingsLoadState.Loading) } + when (val r = beeClient.scanWifi()) { + is ApiResult.Success -> { + _state.update { + it.copy( + wifiNetworks = r.data, + wifiScanState = BeeSettingsLoadState.Success + ) + } + _message.value = "Found ${r.data.size} networks" + } + is ApiResult.Error -> { + _state.update { it.copy(wifiScanState = BeeSettingsLoadState.Error(r.message)) } + _message.value = "Scan failed: ${r.message}" + } + } + } + } + + fun saveWifiSettings(ssid: String, password: String, enabled: Boolean) { + viewModelScope.launch { + _state.update { it.copy(wifiSaving = true) } + val settings = WifiClientSettings( + ssid = ssid.trim(), + password = password, + enabled = enabled, + security = if (password.length >= 8) "WPA2" else "Open" + ) + when (val r = beeClient.saveWifiSettings(settings)) { + is ApiResult.Success -> { + _message.value = "WiFi settings saved" + // Refresh WiFi status after save + (beeClient.getWifiStatus() as? ApiResult.Success)?.data?.let { status -> + _state.update { it.copy(wifiStatus = status, wifiSettings = settings) } + } + } + is ApiResult.Error -> { + _message.value = "Save failed: ${r.message}" + } + } + _state.update { it.copy(wifiSaving = false) } + } + } + + fun setWifiEnabled(enabled: Boolean) { + viewModelScope.launch { + when (val r = beeClient.setWifiEnabled(enabled)) { + is ApiResult.Success -> { + _message.value = if (enabled) "WiFi client enabled" else "WiFi client disabled" + _state.update { s -> + s.copy(wifiSettings = s.wifiSettings?.copy(enabled = enabled)) + } + } + is ApiResult.Error -> { + _message.value = "Failed: ${r.message}" + } + } + } + } + + fun resetWifi() { + viewModelScope.launch { + when (val r = beeClient.resetWifi()) { + is ApiResult.Success -> _message.value = "WiFi reset triggered" + is ApiResult.Error -> _message.value = "Reset failed: ${r.message}" + } + } + } + + fun setUploadMode(mode: String) { + viewModelScope.launch { + _state.update { it.copy(uploadModeSaving = true) } + when (val r = beeClient.setUploadMode(mode)) { + is ApiResult.Success -> { + _state.update { it.copy(uploadMode = mode, uploadModeSaving = false) } + _message.value = "Upload mode set to $mode" + } + is ApiResult.Error -> { + _state.update { it.copy(uploadModeSaving = false) } + _message.value = "Failed: ${r.message}" + } + } + } + } + + fun clearMessage() { + _message.value = null + } +}