v7.7: Device Status screen with WiFi, storage, GPS, plugins

- Add DeviceStatusScreen with full device info display
- Add WiFi client configuration
- Add storage status with usage bar
- Add GPS/GNSS status display
- Add upload mode toggle (auto/wifi/lte/off)
- Add plugin list display
- New API endpoints in BeeApiClient for device settings
- Navigate to Device Status from Settings screen
This commit is contained in:
Kayos 2026-03-12 09:42:50 -07:00
parent 5d8cacfacb
commit 0f22c155cd
7 changed files with 858 additions and 4 deletions

View file

@ -13,8 +13,8 @@ android {
applicationId = "com.adamaps.varroa"
minSdk = 26
targetSdk = 34
versionCode = 12
versionName = "1.7.6"
versionCode = 13
versionName = "1.7.7"
vectorDrawables {
useSupportLibrary = true

View file

@ -5,11 +5,13 @@ 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.DeviceStatusScreen
import com.adamaps.varroa.ui.settings.SettingsScreen
object Routes {
const val DASHBOARD = "dashboard"
const val SETTINGS = "settings"
const val DEVICE_STATUS = "device_status"
}
@Composable
@ -20,7 +22,13 @@ fun VarroaNavGraph() {
DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) })
}
composable(Routes.SETTINGS) {
SettingsScreen(onBack = { nav.popBackStack() })
SettingsScreen(
onBack = { nav.popBackStack() },
onNavigateToDeviceStatus = { nav.navigate(Routes.DEVICE_STATUS) }
)
}
composable(Routes.DEVICE_STATUS) {
DeviceStatusScreen(onBack = { nav.popBackStack() })
}
}
}

View file

@ -8,7 +8,11 @@ import android.util.Log
import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.BeeDetection
import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssData
import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
@ -256,6 +260,105 @@ class BeeApiClient(
}
}
// ── v7.7 Settings API endpoints ───────────────────────────────────────────
suspend fun getWifiConfig(): ApiResult<WifiConfig> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifi/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, WifiConfig::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
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)
}
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
}
}
suspend fun getStorageStatus(): ApiResult<StorageStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/storage/usage")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, StorageStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun getGnssStatus(): ApiResult<GnssStatus> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/gnss/status")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, GnssStatus::class.java))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
suspend fun getPlugins(): ApiResult<List<BeePlugin>> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/plugin/list")) {
is ApiResult.Success -> try {
val type = object : TypeToken<List<BeePlugin>>() {}.type
ApiResult.Success(gson.fromJson(r.data, type))
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
}
}
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()
client.newCall(request).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) {
ApiResult.Success(body)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
}
}
// ── End v7.7 Settings API ─────────────────────────────────────────────────
suspend fun getDeviceIdViaSsh(): ApiResult<String> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Attempting SSH connection to root@192.168.0.10:22")

View file

@ -87,6 +87,41 @@ fun BeeDetection.toAdaMapsDetection(deviceId: String, walletAddress: String? = n
walletAddress = walletAddress?.takeIf { it.isNotBlank() }
)
// ── Device status (for Settings v7.7) ─────────────────────────────────────────
data class WifiConfig(
@SerializedName("ssid") val ssid: String? = null,
@SerializedName("password") val password: String? = null,
@SerializedName("connected") val connected: Boolean? = null,
@SerializedName("ip") val ip: String? = null
)
data class StorageStatus(
@SerializedName("total_bytes") val totalBytes: Long? = null,
@SerializedName("used_bytes") val usedBytes: Long? = null,
@SerializedName("free_bytes") val freeBytes: Long? = null,
@SerializedName("percent_used") val percentUsed: Double? = null,
@SerializedName("recording_hours_available") val recordingHoursAvailable: Double? = null
)
data class GnssStatus(
@SerializedName("has_lock") val hasLock: Boolean? = null,
@SerializedName("satellites") val satellites: Int? = null,
@SerializedName("hdop") val hdop: Double? = null,
@SerializedName("lat") val lat: Double? = null,
@SerializedName("lon") val lon: Double? = null,
@SerializedName("alt") val alt: Double? = null,
@SerializedName("speed_kmh") val speedKmh: Double? = null,
@SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = null
)
data class BeePlugin(
@SerializedName("name") val name: String? = null,
@SerializedName("version") val version: String? = null,
@SerializedName("enabled") val enabled: Boolean? = null,
@SerializedName("running") val running: Boolean? = null
)
// ── App state ─────────────────────────────────────────────────────────────────
data class SessionStats(

View file

@ -0,0 +1,520 @@
package com.adamaps.varroa.ui.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
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.Refresh
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig
import com.adamaps.varroa.ui.theme.*
import com.adamaps.varroa.viewmodel.DeviceStatusViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeviceStatusScreen(
vm: DeviceStatusViewModel = viewModel(),
onBack: () -> Unit
) {
val state by vm.state.collectAsState()
val wifiResult by vm.wifiSaveResult.collectAsState()
val uploadResult by vm.uploadModeResult.collectAsState()
// Refresh on first load
LaunchedEffect(Unit) {
vm.refresh()
}
// Snackbar for results
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(wifiResult) {
wifiResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearWifiResult()
}
}
LaunchedEffect(uploadResult) {
uploadResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearUploadModeResult()
}
}
Scaffold(
containerColor = Background,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
"DEVICE STATUS",
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.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = Amber)
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Amber
)
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Connection status
ConnectionStatusCard(state.isConnected, state.error)
if (state.isConnected) {
// Device Info
state.deviceInfo?.let { DeviceInfoCard(it) }
// WiFi Config
WifiConfigCard(state.wifiConfig, vm)
// Storage Status
state.storageStatus?.let { StorageStatusCard(it) }
// GPS/GNSS Status
GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock)
// Upload Mode
UploadModeCard(state.deviceInfo?.uploadMode, vm)
// Plugins
if (state.plugins.isNotEmpty()) {
PluginsCard(state.plugins)
}
}
}
}
}
}
}
@Composable
private fun StatusCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = Surface),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(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 StatusRow(label: String, value: String?, valueColor: Color = OnSurface) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
label,
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
Text(
value ?: "",
color = valueColor,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun ConnectionStatusCard(isConnected: Boolean, error: String?) {
StatusCard("CONNECTION") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isConnected) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (isConnected) Color.Green else Color.Red,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
if (isConnected) "Connected to Bee" else "Disconnected",
color = if (isConnected) Color.Green else Color.Red,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = Color.Red,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp
)
}
}
}
@Composable
private fun DeviceInfoCard(info: BeeDeviceInfo) {
StatusCard("DEVICE INFO") {
StatusRow("Firmware", info.firmwareVersion)
Spacer(Modifier.height(4.dp))
StatusRow("API Version", info.apiVersion)
Spacer(Modifier.height(4.dp))
StatusRow("Build Date", info.buildDate)
Spacer(Modifier.height(4.dp))
StatusRow("Serial", info.serial)
Spacer(Modifier.height(4.dp))
StatusRow("Device ID", info.deviceId?.take(16)?.plus("..."))
Spacer(Modifier.height(4.dp))
StatusRow("Model", info.model)
info.uptime?.let {
Spacer(Modifier.height(4.dp))
val hours = it / 3600
val mins = (it % 3600) / 60
StatusRow("Uptime", "${hours}h ${mins}m")
}
}
}
@Composable
private fun WifiConfigCard(wifi: WifiConfig?, vm: DeviceStatusViewModel) {
var ssid by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
StatusCard("WIFI CLIENT") {
if (wifi != null) {
StatusRow(
"Status",
if (wifi.connected == true) "Connected" else "Disconnected",
if (wifi.connected == true) Color.Green else Color.Yellow
)
wifi.ssid?.let {
Spacer(Modifier.height(4.dp))
StatusRow("Current SSID", it)
}
wifi.ip?.let {
Spacer(Modifier.height(4.dp))
StatusRow("IP Address", it)
}
Spacer(Modifier.height(12.dp))
Divider(color = SurfaceVariant)
Spacer(Modifier.height(12.dp))
}
Text(
"Configure WiFi",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text("SSID", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber,
unfocusedLabelColor = Color.Gray,
cursorColor = Amber,
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (showPassword)
androidx.compose.ui.text.input.VisualTransformation.None
else
androidx.compose.ui.text.input.PasswordVisualTransformation(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber,
unfocusedLabelColor = Color.Gray,
cursorColor = Amber,
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { vm.setWifiConfig(ssid, password) },
enabled = ssid.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Amber)
) {
Text("Save WiFi Config", color = Background)
}
}
}
@Composable
private fun StorageStatusCard(storage: StorageStatus) {
StatusCard("STORAGE") {
val usedGb = (storage.usedBytes ?: 0) / 1_000_000_000.0
val totalGb = (storage.totalBytes ?: 0) / 1_000_000_000.0
val freeGb = (storage.freeBytes ?: 0) / 1_000_000_000.0
val percent = storage.percentUsed ?: 0.0
StatusRow("Used", "%.1f GB".format(usedGb))
Spacer(Modifier.height(4.dp))
StatusRow("Free", "%.1f GB".format(freeGb), if (freeGb < 10) Color.Yellow else OnSurface)
Spacer(Modifier.height(4.dp))
StatusRow("Total", "%.1f GB".format(totalGb))
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { (percent / 100.0).toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = when {
percent > 90 -> Color.Red
percent > 75 -> Color.Yellow
else -> Color.Green
},
trackColor = SurfaceVariant
)
Spacer(Modifier.height(4.dp))
Text(
"%.1f%% used".format(percent),
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
storage.recordingHoursAvailable?.let {
Spacer(Modifier.height(8.dp))
StatusRow("Recording Available", "%.1f hours".format(it))
}
}
}
@Composable
private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) {
StatusCard("GPS / GNSS") {
val hasGps = gnss?.hasLock == true || hasLock == true
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (hasGps) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = null,
tint = if (hasGps) Color.Green else Color.Yellow,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(8.dp))
Text(
if (hasGps) "GPS Lock Acquired" else "Searching for GPS...",
color = if (hasGps) Color.Green else Color.Yellow,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
if (gnss != null) {
Spacer(Modifier.height(8.dp))
gnss.satellites?.let { StatusRow("Satellites", it.toString()) }
gnss.hdop?.let {
Spacer(Modifier.height(4.dp))
StatusRow("HDOP", "%.2f".format(it), if (it > 5) Color.Yellow else OnSurface)
}
if (gnss.lat != null && gnss.lon != null) {
Spacer(Modifier.height(4.dp))
StatusRow("Position", "%.5f, %.5f".format(gnss.lat, gnss.lon))
}
gnss.alt?.let {
Spacer(Modifier.height(4.dp))
StatusRow("Altitude", "%.1f m".format(it))
}
gnss.speedKmh?.let {
Spacer(Modifier.height(4.dp))
StatusRow("Speed", "%.1f km/h".format(it))
}
gnss.lastFixAgeSec?.let {
Spacer(Modifier.height(4.dp))
StatusRow("Last Fix", "${it}s ago", if (it > 30) Color.Yellow else OnSurface)
}
}
}
}
@Composable
private fun UploadModeCard(currentMode: String?, vm: DeviceStatusViewModel) {
var selectedMode by remember(currentMode) { mutableStateOf(currentMode ?: "auto") }
StatusCard("UPLOAD MODE") {
StatusRow("Current Mode", currentMode ?: "unknown")
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf("auto", "wifi", "lte", "off").forEach { mode ->
FilterChip(
selected = selectedMode == mode,
onClick = {
selectedMode = mode
vm.setUploadMode(mode)
},
label = {
Text(
mode.uppercase(),
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Amber,
selectedLabelColor = Background
)
)
}
}
Spacer(Modifier.height(8.dp))
Text(
when (selectedMode) {
"auto" -> "Uploads via WiFi when available, falls back to LTE"
"wifi" -> "Only uploads when connected to WiFi"
"lte" -> "Uploads via cellular data"
"off" -> "Uploads disabled"
else -> ""
},
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
@Composable
private fun PluginsCard(plugins: List<BeePlugin>) {
StatusCard("PLUGINS") {
plugins.forEachIndexed { index, plugin ->
if (index > 0) {
Spacer(Modifier.height(8.dp))
Divider(color = SurfaceVariant)
Spacer(Modifier.height(8.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
plugin.name ?: "Unknown",
color = OnSurface,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
plugin.version?.let {
Text(
"v$it",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (plugin.enabled == true) {
Text(
"ENABLED",
color = Color.Green,
fontFamily = FontFamily.Monospace,
fontSize = 9.sp
)
}
if (plugin.running == true) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Running",
tint = Color.Green,
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}

View file

@ -9,7 +9,9 @@ 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.Check
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -37,7 +39,8 @@ import com.journeyapps.barcodescanner.ScanOptions
@Composable
fun SettingsScreen(
vm: SettingsViewModel = viewModel(),
onBack: () -> Unit
onBack: () -> Unit,
onNavigateToDeviceStatus: () -> Unit = {}
) {
val currentSettings by vm.settings.collectAsState()
val saved by vm.saved.collectAsState()
@ -108,6 +111,52 @@ fun SettingsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Device Status navigation card
Card(
onClick = onNavigateToDeviceStatus,
colors = CardDefaults.cardColors(containerColor = Surface),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.PhoneAndroid,
contentDescription = null,
tint = Amber,
modifier = Modifier.size(24.dp)
)
Spacer(Modifier.width(12.dp))
Column {
Text(
"DEVICE STATUS",
color = Amber,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
letterSpacing = 2.sp
)
Text(
"WiFi, storage, GPS, plugins",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = Color.Gray
)
}
}
SettingsSection("BEE DEVICE") {
SettingsField(
label = "Bee API URL",

View file

@ -0,0 +1,139 @@
package com.adamaps.varroa.viewmodel
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.adamaps.varroa.api.BeeApiClient
import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class DeviceStatusState(
val isLoading: Boolean = false,
val isConnected: Boolean = false,
val deviceInfo: BeeDeviceInfo? = null,
val wifiConfig: WifiConfig? = null,
val storageStatus: StorageStatus? = null,
val gnssStatus: GnssStatus? = null,
val plugins: List<BeePlugin> = emptyList(),
val error: String? = null
)
class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
companion object {
private const val TAG = "DeviceStatusVM"
}
private val store = SettingsDataStore(app)
private var beeClient: BeeApiClient? = null
private val _state = MutableStateFlow(DeviceStatusState())
val state: StateFlow<DeviceStatusState> = _state.asStateFlow()
private val _wifiSaveResult = MutableStateFlow<String?>(null)
val wifiSaveResult: StateFlow<String?> = _wifiSaveResult.asStateFlow()
private val _uploadModeResult = MutableStateFlow<String?>(null)
val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow()
fun refresh() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val settings = store.settings.first()
val client = BeeApiClient(settings.beeApiUrl)
beeClient = client
// Fetch all status in parallel
val deviceInfoResult = client.getDeviceInfo()
val wifiResult = client.getWifiConfig()
val storageResult = client.getStorageStatus()
val gnssResult = client.getGnssStatus()
val pluginsResult = client.getPlugins()
val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data
val wifi = (wifiResult as? ApiResult.Success)?.data
val storage = (storageResult as? ApiResult.Success)?.data
val gnss = (gnssResult as? ApiResult.Success)?.data
val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList()
val isConnected = deviceInfo != null
_state.value = DeviceStatusState(
isLoading = false,
isConnected = isConnected,
deviceInfo = deviceInfo,
wifiConfig = wifi,
storageStatus = storage,
gnssStatus = gnss,
plugins = plugins,
error = if (!isConnected) "Cannot connect to Bee device" else null
)
Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}")
} catch (e: Exception) {
Log.e(TAG, "Failed to refresh device status", e)
_state.value = DeviceStatusState(
isLoading = false,
isConnected = false,
error = e.message ?: "Unknown error"
)
}
}
}
fun setWifiConfig(ssid: String, password: String) {
viewModelScope.launch {
val client = beeClient ?: return@launch
_wifiSaveResult.value = null
when (val result = client.setWifiConfig(ssid, password)) {
is ApiResult.Success -> {
_wifiSaveResult.value = "WiFi configuration saved"
refresh() // Refresh to show new status
}
is ApiResult.Error -> {
_wifiSaveResult.value = "Failed: ${result.message}"
}
}
}
}
fun setUploadMode(mode: String) {
viewModelScope.launch {
val client = beeClient ?: return@launch
_uploadModeResult.value = null
when (val result = client.setUploadMode(mode)) {
is ApiResult.Success -> {
_uploadModeResult.value = "Upload mode set to $mode"
refresh()
}
is ApiResult.Error -> {
_uploadModeResult.value = "Failed: ${result.message}"
}
}
}
}
fun clearWifiResult() {
_wifiSaveResult.value = null
}
fun clearUploadModeResult() {
_uploadModeResult.value = null
}
}