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:
parent
4cd65cc441
commit
60d2f693d1
6 changed files with 1558 additions and 167 deletions
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ?: "—"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue