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
This commit is contained in:
Kayos 2026-03-12 08:32:27 -07:00
parent 02d6599801
commit 0a5ded8feb
6 changed files with 1558 additions and 167 deletions

View file

@ -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() })
}
}
}

View file

@ -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<String> = 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<PairResponse> = withContext(Dispatchers.IO) {
when (val r = getRaw("/pair")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, PairResponse::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
// ── Landmarks API ─────────────────────────────────────────────────────────
suspend fun getLandmarks(): ApiResult<List<BeeDetection>> = withContext(Dispatchers.IO) {
Log.d(TAG, "getLandmarks() called - fetching ALL landmarks")
@ -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<WifiConfig> = 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<String> = 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<WifiStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifi/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, WifiStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
is ApiResult.Error -> r
}
}
suspend fun setWifiConfig(ssid: String, password: String): ApiResult<String> {
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<com.adamaps.varroa.data.WifiStatus> = 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<WifiClientSettings> = 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<String> {
val json = gson.toJson(settings)
return postRaw("/api/1/wifiClient/settings", json)
}
suspend fun setWifiEnabled(enabled: Boolean): ApiResult<String> {
val json = """{"enabled": $enabled}"""
return postRaw("/api/1/wifiClient/enable", json)
}
suspend fun scanWifi(): ApiResult<List<WifiNetwork>> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifiClient/scan")) {
is ApiResult.Success -> try {
val type = object : TypeToken<List<WifiNetwork>>() {}.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<String> = 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<SshStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/ssh/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, SshStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun setSshEnabled(enabled: Boolean): ApiResult<String> {
val json = gson.toJson(mapOf("enable" to enabled))
return postRaw("/api/1/ssh/toggle", json)
}
// ── Storage & GNSS Status ─────────────────────────────────────────────────
suspend fun getStorageStatus(): ApiResult<StorageStatus> = 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<String> = 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<String> = 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<String> {
val json = """{"mode": "$mode"}"""
return postRaw("/api/1/config/uploadMode", json)
}
suspend fun getDeviceIdViaSsh(): ApiResult<String> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Attempting SSH connection to root@192.168.0.10:22")
val jsch = JSch()
val session = jsch.getSession("root", "192.168.0.10", 22)
session.setConfig("StrictHostKeyChecking", "no")
session.connect(10000) // 10 second timeout
// ── 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<BeeConfig> = 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<CacheStatus> = 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<FrameKmTotal> = 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<PluginState> = 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<ByteArray> = 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<Long>): ApiResult<String> = 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<Long>): ApiResult<String> = withContext(Dispatchers.IO) {
try {
// Try to find where landmarks are stored and delete them via filesystem commands
val commands = listOf(
"find /data -name '*landmark*' -type f -ls",
"find /tmp -name '*landmark*' -type f -ls",
"ls -la /data/recording/",
"redis-cli KEYS '*landmark*'",
"redis-cli KEYS '*detection*'"
)
for (cmd in commands) {
Log.d(TAG, "Trying cmd: $cmd")
val jsonBody = gson.toJson(mapOf("cmd" to cmd))
val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
val request = Request.Builder()
.url("$apiUrl/api/1/cmd")
.post(requestBody)
.build()
client.newCall(request).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) {
Log.d(TAG, "Cmd '$cmd' result: $body")
// For now, just log the results to understand the data structure
if (body.contains("landmark") || body.contains("detection")) {
Log.i(TAG, "Found potential landmark storage: $body")
}
}
}
}
// Note: We're not actually deleting anything via cmd yet, just exploring
Log.w(TAG, "Cleanup via cmd endpoint: exploration complete, actual deletion not implemented yet")
return@withContext ApiResult.Error("Cleanup via cmd: exploration only, deletion not yet implemented")
} catch (e: Exception) {
Log.e(TAG, "Failed to explore via cmd endpoint", e)
return@withContext ApiResult.Error("Cmd exploration failed: ${e.message}")
}
}
}

View file

@ -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(

View file

@ -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<Pair<String, String>> = listOf(
"Firmware" to (info.firmwareVersion ?: ""),

View file

@ -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<WifiNetwork>,
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<PluginState>,
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()
}
}

View file

@ -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<WifiNetwork> = emptyList(),
val cacheStatus: CacheStatus? = null,
val frameKmTotal: FrameKmTotal? = null,
val gnssStatus: GnssStatus? = null,
val uploadMode: String? = null,
val config: BeeConfig? = null,
val plugins: List<PluginState> = 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<BeeSettingsState> = _state.asStateFlow()
// Toast/snackbar messages
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _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
}
}