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
d2684efa1a
commit
66793b77c0
6 changed files with 1557 additions and 166 deletions
|
|
@ -5,6 +5,7 @@ import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.adamaps.varroa.ui.dashboard.DashboardScreen
|
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.DeviceStatusScreen
|
||||||
import com.adamaps.varroa.ui.settings.SettingsScreen
|
import com.adamaps.varroa.ui.settings.SettingsScreen
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ object Routes {
|
||||||
const val DASHBOARD = "dashboard"
|
const val DASHBOARD = "dashboard"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val DEVICE_STATUS = "device_status"
|
const val DEVICE_STATUS = "device_status"
|
||||||
|
const val BEE_SETTINGS = "bee_settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -19,7 +21,10 @@ fun VarroaNavGraph() {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
NavHost(navController = nav, startDestination = Routes.DASHBOARD) {
|
NavHost(navController = nav, startDestination = Routes.DASHBOARD) {
|
||||||
composable(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) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
|
|
@ -30,5 +35,8 @@ fun VarroaNavGraph() {
|
||||||
composable(Routes.DEVICE_STATUS) {
|
composable(Routes.DEVICE_STATUS) {
|
||||||
DeviceStatusScreen(onBack = { nav.popBackStack() })
|
DeviceStatusScreen(onBack = { nav.popBackStack() })
|
||||||
}
|
}
|
||||||
|
composable(Routes.BEE_SETTINGS) {
|
||||||
|
BeeSettingsScreen(onBack = { nav.popBackStack() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,32 @@ import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.adamaps.varroa.data.ApiResult
|
import com.adamaps.varroa.data.ApiResult
|
||||||
|
import com.adamaps.varroa.data.BeeConfig
|
||||||
import com.adamaps.varroa.data.BeeDetection
|
import com.adamaps.varroa.data.BeeDetection
|
||||||
import com.adamaps.varroa.data.BeeDeviceInfo
|
import com.adamaps.varroa.data.BeeDeviceInfo
|
||||||
import com.adamaps.varroa.data.BeePlugin
|
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.GnssData
|
||||||
import com.adamaps.varroa.data.GnssStatus
|
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.StorageStatus
|
||||||
|
import com.adamaps.varroa.data.UploadModeResponse
|
||||||
|
import com.adamaps.varroa.data.WifiClientSettings
|
||||||
import com.adamaps.varroa.data.WifiConfig
|
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.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import com.jcraft.jsch.JSch
|
|
||||||
import com.jcraft.jsch.Session
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
|
|
||||||
class BeeApiClient(
|
class BeeApiClient(
|
||||||
private var apiUrl: String = "http://192.168.0.10:5000"
|
private var apiUrl: String = "http://192.168.0.10:5000"
|
||||||
|
|
@ -43,6 +49,9 @@ class BeeApiClient(
|
||||||
var isConnected: Boolean = false
|
var isConnected: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
// Bearer token for authenticated endpoints
|
||||||
|
var apiToken: String = ""
|
||||||
|
|
||||||
fun updateUrl(url: String) {
|
fun updateUrl(url: String) {
|
||||||
val oldUrl = apiUrl
|
val oldUrl = apiUrl
|
||||||
apiUrl = url.trimEnd('/')
|
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.
|
* This is the preferred method when using NetworkStateMonitor.
|
||||||
*/
|
*/
|
||||||
fun bindToNetwork(network: Network) {
|
fun bindToNetwork(network: Network) {
|
||||||
|
|
@ -107,7 +116,7 @@ class BeeApiClient(
|
||||||
val allWifi = cm.allNetworks.filter { n ->
|
val allWifi = cm.allNetworks.filter { n ->
|
||||||
cm.getNetworkCapabilities(n)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
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 ->
|
allWifi.firstOrNull { n ->
|
||||||
cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == false
|
cm.getNetworkCapabilities(n)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == false
|
||||||
} ?: allWifi.firstOrNull()
|
} ?: 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.
|
* Updates internal connection state.
|
||||||
*/
|
*/
|
||||||
suspend fun ping(): Boolean {
|
suspend fun ping(): Boolean {
|
||||||
|
|
@ -208,6 +246,25 @@ class BeeApiClient(
|
||||||
isConnected = false
|
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) {
|
suspend fun getLandmarks(): ApiResult<List<BeeDetection>> = withContext(Dispatchers.IO) {
|
||||||
Log.d(TAG, "getLandmarks() called - fetching ALL landmarks")
|
Log.d(TAG, "getLandmarks() called - fetching ALL landmarks")
|
||||||
|
|
||||||
|
|
@ -225,7 +282,6 @@ class BeeApiClient(
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})")
|
Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})")
|
||||||
// Connection failed, mark as offline
|
|
||||||
if (r.message.contains("timeout", ignoreCase = true) ||
|
if (r.message.contains("timeout", ignoreCase = true) ||
|
||||||
r.message.contains("connect", ignoreCase = true) ||
|
r.message.contains("connect", ignoreCase = true) ||
|
||||||
r.message.contains("refused", 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) {
|
suspend fun getWifiConfig(): ApiResult<WifiConfig> = withContext(Dispatchers.IO) {
|
||||||
when (val r = getRaw("/api/1/wifi/status")) {
|
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) {
|
suspend fun getWifiStatus(): ApiResult<WifiStatus> = withContext(Dispatchers.IO) {
|
||||||
try {
|
when (val r = getRaw("/api/1/wifi/status")) {
|
||||||
val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password))
|
is ApiResult.Success -> try {
|
||||||
val requestBody = okhttp3.RequestBody.create(
|
ApiResult.Success(gson.fromJson(r.data, WifiStatus::class.java))
|
||||||
"application/json".toMediaType(),
|
} catch (e: Exception) {
|
||||||
jsonBody
|
ApiResult.Error("Parse error: ${e.message}")
|
||||||
)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
is ApiResult.Error -> r
|
||||||
ApiResult.Error(e.message ?: "Unknown error")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
suspend fun getStorageStatus(): ApiResult<StorageStatus> = withContext(Dispatchers.IO) {
|
||||||
when (val r = getRaw("/api/1/storage/usage")) {
|
when (val r = getRaw("/api/1/storage/usage")) {
|
||||||
is ApiResult.Success -> try {
|
is ApiResult.Success -> try {
|
||||||
|
|
@ -332,76 +451,97 @@ class BeeApiClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setUploadMode(mode: String): ApiResult<String> = withContext(Dispatchers.IO) {
|
// ── Upload Mode ───────────────────────────────────────────────────────────
|
||||||
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()
|
|
||||||
|
|
||||||
client.newCall(request).execute().use { resp ->
|
suspend fun getUploadMode(): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||||
val body = resp.body?.string() ?: ""
|
when (val r = getRaw("/api/1/config/uploadMode")) {
|
||||||
if (resp.isSuccessful) {
|
is ApiResult.Success -> try {
|
||||||
ApiResult.Success(body)
|
val trimmed = r.data.trim().trim('"')
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
val obj = gson.fromJson(r.data, UploadModeResponse::class.java)
|
||||||
|
ApiResult.Success(obj.currentMode() ?: "UNKNOWN")
|
||||||
} else {
|
} 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) {
|
is ApiResult.Error -> r
|
||||||
ApiResult.Error(e.message ?: "Unknown error")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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) {
|
// ── Config ────────────────────────────────────────────────────────────────
|
||||||
try {
|
|
||||||
Log.d(TAG, "Attempting SSH connection to root@192.168.0.10:22")
|
|
||||||
val jsch = JSch()
|
|
||||||
val session = jsch.getSession("root", "192.168.0.10", 22)
|
|
||||||
session.setConfig("StrictHostKeyChecking", "no")
|
|
||||||
session.connect(10000) // 10 second timeout
|
|
||||||
|
|
||||||
val channel = session.openChannel("exec")
|
suspend fun getConfig(): ApiResult<BeeConfig> = withContext(Dispatchers.IO) {
|
||||||
val execChannel = channel as com.jcraft.jsch.ChannelExec
|
when (val r = getRaw("/api/1/config/")) {
|
||||||
|
is ApiResult.Success -> try {
|
||||||
// Command to find device_id from various locations
|
ApiResult.Success(gson.fromJson(r.data, BeeConfig::class.java))
|
||||||
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"
|
} catch (e: Exception) {
|
||||||
execChannel.setCommand(command)
|
ApiResult.Error("Parse error: ${e.message}")
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
is ApiResult.Error -> r
|
||||||
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}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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.
|
* Try the given endpoint; returns raw image bytes.
|
||||||
* The caller is responsible for trying fallback endpoints.
|
* 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
|
* @return ApiResult containing image bytes (JPEG) or error
|
||||||
*/
|
*/
|
||||||
suspend fun getDetectionImage(detectionId: Long): ApiResult<ByteArray> = withContext(Dispatchers.IO) {
|
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.
|
* This tries various potential DELETE endpoints.
|
||||||
*
|
*
|
||||||
* @param landmarkIds List of landmark IDs to delete
|
* @param landmarkIds List of landmark IDs to delete
|
||||||
|
|
@ -471,24 +611,13 @@ class BeeApiClient(
|
||||||
"/api/1/landmarks/delete",
|
"/api/1/landmarks/delete",
|
||||||
"/api/1/landmarks/clear",
|
"/api/1/landmarks/clear",
|
||||||
"/api/1/landmarks/cleanup",
|
"/api/1/landmarks/cleanup",
|
||||||
"/api/1/landmarks/remove",
|
"/api/1/landmarks/remove"
|
||||||
"/api/1/cmd" // As a last resort using the cmd endpoint
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for (endpoint in endpoints) {
|
for (endpoint in endpoints) {
|
||||||
Log.d(TAG, "Trying cleanup endpoint: $endpoint")
|
Log.d(TAG, "Trying cleanup endpoint: $endpoint")
|
||||||
|
|
||||||
val result = when (endpoint) {
|
val result = tryCleanupEndpoint(endpoint, landmarkIds)
|
||||||
"/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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
|
|
@ -497,31 +626,34 @@ class BeeApiClient(
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}")
|
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")
|
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 Bee device")
|
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) {
|
private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val jsonBody = gson.toJson(mapOf("ids" to landmarkIds))
|
val jsonBody = gson.toJson(mapOf("ids" to landmarkIds))
|
||||||
val requestBody = okhttp3.RequestBody.create(
|
val requestBody = jsonBody.toRequestBody("application/json".toMediaType())
|
||||||
"application/json".toMediaType(),
|
|
||||||
jsonBody
|
|
||||||
)
|
|
||||||
|
|
||||||
// Try both DELETE and POST methods
|
// Try both DELETE and POST methods
|
||||||
for (method in listOf("DELETE", "POST")) {
|
for (method in listOf("DELETE", "POST")) {
|
||||||
Log.d(TAG, "Trying $method $endpoint")
|
Log.d(TAG, "Trying $method $endpoint")
|
||||||
|
|
||||||
val request = Request.Builder()
|
val requestBuilder = Request.Builder()
|
||||||
.url("$apiUrl$endpoint")
|
.url("$apiUrl$endpoint")
|
||||||
.method(method, if (method == "DELETE") null else requestBody)
|
if (apiToken.isNotBlank()) {
|
||||||
.build()
|
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 ->
|
client.newCall(request).execute().use { resp ->
|
||||||
val body = resp.body?.string() ?: ""
|
val body = resp.body?.string() ?: ""
|
||||||
|
|
@ -540,50 +672,4 @@ class BeeApiClient(
|
||||||
return@withContext ApiResult.Error("Exception: ${e.message}")
|
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
|
@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 ─────────────────────────────────────────────────────────────────
|
// ── App state ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class SessionStats(
|
data class SessionStats(
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ import org.osmdroid.views.overlay.Marker
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
vm: DashboardViewModel = viewModel(),
|
vm: DashboardViewModel = viewModel(),
|
||||||
onNavigateToSettings: () -> Unit
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onNavigateToBeeSettings: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val deviceInfo by vm.deviceInfo.collectAsState()
|
val deviceInfo by vm.deviceInfo.collectAsState()
|
||||||
val gnss by vm.gnss.collectAsState()
|
val gnss by vm.gnss.collectAsState()
|
||||||
|
|
@ -101,8 +102,11 @@ fun DashboardScreen(
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onNavigateToBeeSettings) {
|
||||||
|
Icon(Icons.Default.Router, contentDescription = "AdaCam Settings", tint = Amber)
|
||||||
|
}
|
||||||
IconButton(onClick = onNavigateToSettings) {
|
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) }
|
gnss?.let { GpsMapCard(it) }
|
||||||
|
|
||||||
// Device status
|
// Device status
|
||||||
deviceInfo?.let { DeviceStatusCard(it) }
|
deviceInfo?.let { DeviceStatusCard(it, onNavigateToBeeSettings) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -582,14 +586,32 @@ private fun OsmMapView(gnss: GnssData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceStatusCard(info: BeeDeviceInfo) {
|
private fun DeviceStatusCard(info: BeeDeviceInfo, onNavigateToBeeSettings: () -> Unit = {}) {
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = Surface),
|
colors = CardDefaults.cardColors(containerColor = Surface),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(14.dp)) {
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
val rows: List<Pair<String, String>> = listOf(
|
val rows: List<Pair<String, String>> = listOf(
|
||||||
"Firmware" to (info.firmwareVersion ?: "—"),
|
"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