v1.3.0: Multi-IP fallback, exponential backoff, retry queue

Fixes:
- Multi-IP discovery: Try both primary (AP) and alt (home WiFi) IPs in parallel
  with 3-4s timeouts each. First responder wins.
- Exponential backoff: When Bee is offline, retry with increasing delays
  (5s → 60s max) instead of hammering every 10s
- Clean offline state: Show 'Bee offline' status instead of ugly red errors
- Retry queue: Failed ADAMaps sends are queued and retried (up to 5 attempts)
  instead of being silently dropped
- Camera shows 'Bee offline' instead of spinning 'Awaiting frame...'
- Settings: Added 'Bee Alt URL' field for configuring home WiFi IP
- Better connection status display showing which mode is active

Fixes the '206 collected, 0 sent' bug - detections now queue and retry
This commit is contained in:
Kayos 2026-03-11 09:53:01 -07:00
parent 71bb4e16b9
commit e28234c7b9
7 changed files with 384 additions and 80 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "com.adamaps.varroa"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
versionCode = 4
versionName = "1.3.0"
vectorDrawables {
useSupportLibrary = true

View file

@ -11,24 +11,40 @@ import com.adamaps.varroa.data.GnssData
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
class BeeApiClient(
private var baseUrl: String = "http://192.168.0.10:5000"
private var primaryUrl: String = "http://192.168.0.10:5000",
private var altUrl: String = "http://192.168.0.155:5000"
) {
private var client = buildClient(null)
private var fastClient = buildFastClient(null)
private val gson = Gson()
fun updateBaseUrl(url: String) {
baseUrl = url.trimEnd('/')
// Track which URL is currently active (null = unknown/offline)
private var activeUrl: String? = null
// Connection state
var isConnected: Boolean = false
private set
fun updateUrls(primary: String, alt: String) {
primaryUrl = primary.trimEnd('/')
altUrl = alt.trimEnd('/')
// Reset active URL when settings change
activeUrl = null
}
fun bindToWifiNetwork(context: Context) {
client = buildClient(getWifiNetwork(context))
val net = getWifiNetwork(context)
client = buildClient(net)
fastClient = buildFastClient(net)
}
private fun buildClient(net: Network?): OkHttpClient {
@ -40,6 +56,15 @@ class BeeApiClient(
return b.build()
}
private fun buildFastClient(net: Network?): OkHttpClient {
val b = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
net?.let { b.socketFactory(it.socketFactory) }
return b.build()
}
private fun getWifiNetwork(context: Context): Network? {
return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@ -53,13 +78,19 @@ class BeeApiClient(
} catch (e: Exception) { null }
}
private suspend fun getRaw(path: String): ApiResult<String> = withContext(Dispatchers.IO) {
private suspend fun getRaw(path: String, useActiveUrl: Boolean = true): ApiResult<String> = withContext(Dispatchers.IO) {
val baseUrl = if (useActiveUrl && activeUrl != null) activeUrl!! else primaryUrl
try {
val req = Request.Builder().url("$baseUrl$path").get().build()
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: ""
if (resp.isSuccessful) ApiResult.Success(body)
else ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
if (resp.isSuccessful) {
isConnected = true
activeUrl = baseUrl
ApiResult.Success(body)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
@ -67,32 +98,136 @@ class BeeApiClient(
}
private suspend fun getBytes(path: String): ApiResult<ByteArray> = withContext(Dispatchers.IO) {
val baseUrl = activeUrl ?: primaryUrl
try {
val req = Request.Builder().url("$baseUrl$path").get().build()
client.newCall(req).execute().use { resp ->
val bytes = resp.body?.bytes() ?: ByteArray(0)
if (resp.isSuccessful && bytes.isNotEmpty()) ApiResult.Success(bytes)
else ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
if (resp.isSuccessful && bytes.isNotEmpty()) {
isConnected = true
ApiResult.Success(bytes)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
}
}
/**
* Try to ping the Bee at the given URL with a short timeout.
* Returns the URL if successful, null otherwise.
*/
private suspend fun tryPing(url: String): String? = withContext(Dispatchers.IO) {
try {
val req = Request.Builder().url("$url/api/1/info").get().build()
fastClient.newCall(req).execute().use { resp ->
if (resp.isSuccessful) url else null
}
} catch (e: Exception) {
null
}
}
/**
* Try both IPs in parallel with short timeouts. Return the first one that responds.
* If we already have an active URL, try it first (fast path).
*/
suspend fun discoverActiveUrl(): String? = withContext(Dispatchers.IO) {
// Fast path: if we have an active URL, try it first
if (activeUrl != null) {
val result = tryPing(activeUrl!!)
if (result != null) {
isConnected = true
return@withContext result
}
// Active URL failed, clear it and try both
activeUrl = null
}
// Try both URLs in parallel
val primaryDeferred = async { tryPing(primaryUrl) }
val altDeferred = async { tryPing(altUrl) }
// Wait for first success with timeout
val result = withTimeoutOrNull(4000L) {
val primary = primaryDeferred.await()
if (primary != null) {
altDeferred.cancel()
return@withTimeoutOrNull primary
}
altDeferred.await()
}
if (result != null) {
activeUrl = result
isConnected = true
} else {
isConnected = false
}
result
}
/**
* Check if Bee is reachable (with discovery fallback).
* Updates internal connection state.
*/
suspend fun ping(): Boolean {
val url = discoverActiveUrl()
return url != null
}
/**
* Get the currently active URL (or null if offline).
*/
fun getActiveUrl(): String? = activeUrl
/**
* Force offline state (for exponential backoff scenarios).
*/
fun setOffline() {
isConnected = false
activeUrl = null
}
suspend fun getLandmarks(): ApiResult<List<BeeDetection>> = withContext(Dispatchers.IO) {
// If no active URL, try discovery first
if (activeUrl == null) {
if (discoverActiveUrl() == null) {
isConnected = false
return@withContext ApiResult.Error("Bee offline")
}
}
when (val r = getRaw("/api/1/landmarks/last/200")) {
is ApiResult.Success -> try {
val type = object : TypeToken<List<BeeDetection>>() {}.type
val list: List<BeeDetection> = gson.fromJson(r.data, type)
isConnected = true
ApiResult.Success(list)
} catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}")
}
is ApiResult.Error -> r
is ApiResult.Error -> {
// Connection failed, clear active URL for next attempt
if (r.message.contains("timeout", ignoreCase = true) ||
r.message.contains("connect", ignoreCase = true) ||
r.message.contains("refused", ignoreCase = true) ||
r.message.contains("unreachable", ignoreCase = true)) {
activeUrl = null
isConnected = false
}
r
}
}
}
suspend fun getGnss(): ApiResult<GnssData> = withContext(Dispatchers.IO) {
if (activeUrl == null && discoverActiveUrl() == null) {
return@withContext ApiResult.Error("Bee offline")
}
when (val r = getRaw("/api/1/gnssConcise/latestValid")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, GnssData::class.java))
@ -104,6 +239,10 @@ class BeeApiClient(
}
suspend fun getDeviceInfo(): ApiResult<BeeDeviceInfo> = withContext(Dispatchers.IO) {
if (activeUrl == null && discoverActiveUrl() == null) {
return@withContext ApiResult.Error("Bee offline")
}
when (val r = getRaw("/api/1/info")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, BeeDeviceInfo::class.java))
@ -118,12 +257,21 @@ class BeeApiClient(
* Try the given endpoint; returns raw image bytes.
* The caller is responsible for trying fallback endpoints.
*/
suspend fun getCameraFrame(endpoint: String): ApiResult<ByteArray> = getBytes(endpoint)
suspend fun getCameraFrame(endpoint: String): ApiResult<ByteArray> {
if (activeUrl == null && discoverActiveUrl() == null) {
return ApiResult.Error("Bee offline")
}
return getBytes(endpoint)
}
/**
* Try multiple camera endpoints in order, return first success.
*/
suspend fun getCameraFrameAuto(configured: String): Pair<String, ApiResult<ByteArray>> {
if (activeUrl == null && discoverActiveUrl() == null) {
return configured to ApiResult.Error("Bee offline")
}
val candidates = listOf(
configured,
"/api/1/camera/frame",
@ -137,6 +285,4 @@ class BeeApiClient(
}
return configured to ApiResult.Error("No camera endpoint responded")
}
suspend fun ping(): Boolean = getDeviceInfo() is ApiResult.Success
}

View file

@ -14,6 +14,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
data class VarroaSettings(
val beeApiUrl: String = "http://192.168.0.10:5000",
val beeAltApiUrl: String = "http://192.168.0.155:5000",
val adamapsApiUrl: String = "https://api.adamaps.org",
val adamapsApiKey: String = "mapnet-ingest-2026",
val pollIntervalSeconds: Int = 30,
@ -25,6 +26,7 @@ class SettingsDataStore(private val context: Context) {
companion object {
private val KEY_BEE_URL = stringPreferencesKey("bee_api_url")
private val KEY_BEE_ALT_URL = stringPreferencesKey("bee_alt_api_url")
private val KEY_ADAMAPS_URL = stringPreferencesKey("adamaps_api_url")
private val KEY_ADAMAPS_KEY = stringPreferencesKey("adamaps_api_key")
private val KEY_POLL_INTERVAL = intPreferencesKey("poll_interval_seconds")
@ -35,6 +37,7 @@ class SettingsDataStore(private val context: Context) {
val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
VarroaSettings(
beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000",
beeAltApiUrl = prefs[KEY_BEE_ALT_URL] ?: "http://192.168.0.155:5000",
adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org",
adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "mapnet-ingest-2026",
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
@ -46,6 +49,7 @@ class SettingsDataStore(private val context: Context) {
suspend fun save(s: VarroaSettings) {
context.dataStore.edit { prefs ->
prefs[KEY_BEE_URL] = s.beeApiUrl
prefs[KEY_BEE_ALT_URL] = s.beeAltApiUrl
prefs[KEY_ADAMAPS_URL] = s.adamapsApiUrl
prefs[KEY_ADAMAPS_KEY] = s.adamapsApiKey
prefs[KEY_POLL_INTERVAL] = s.pollIntervalSeconds

View file

@ -29,6 +29,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.math.min
class ForwardingService : LifecycleService() {
@ -39,6 +41,11 @@ class ForwardingService : LifecycleService() {
const val ACTION_START = "com.adamaps.varroa.START_FORWARDING"
const val ACTION_STOP = "com.adamaps.varroa.STOP_FORWARDING"
// Exponential backoff constants
private const val MIN_BACKOFF_MS = 5_000L // 5 seconds
private const val MAX_BACKOFF_MS = 60_000L // 60 seconds
private const val BACKOFF_MULTIPLIER = 2.0
// Shared state exposed to ViewModels
private val _stats = MutableStateFlow(SessionStats())
val stats: StateFlow<SessionStats> = _stats.asStateFlow()
@ -54,6 +61,10 @@ class ForwardingService : LifecycleService() {
private val _adamapsReachable = MutableStateFlow(false)
val adamapsReachable: StateFlow<Boolean> = _adamapsReachable.asStateFlow()
// Connection status message (for cleaner display)
private val _beeStatus = MutableStateFlow("Connecting...")
val beeStatus: StateFlow<String> = _beeStatus.asStateFlow()
}
private val beeClient = BeeApiClient()
@ -62,6 +73,20 @@ class ForwardingService : LifecycleService() {
private val seenKeys = mutableSetOf<String>()
private var pollJob: Job? = null
private var reachJob: Job? = null
private var retryJob: Job? = null
// Exponential backoff state
private var currentBackoffMs = MIN_BACKOFF_MS
private var consecutiveFailures = 0
// Retry queue for failed ADAMaps sends
private val pendingQueue = ConcurrentLinkedQueue<QueuedIngest>()
private data class QueuedIngest(
val deviceId: String,
val detections: List<BeeDetection>,
var attempts: Int = 0
)
override fun onCreate() {
super.onCreate()
@ -93,6 +118,7 @@ class ForwardingService : LifecycleService() {
private fun startForwarding() {
startForeground(NOTIF_ID, buildNotification(0))
_isRunning.value = true
_beeStatus.value = "Connecting..."
lifecycleScope.launch {
val settings = SettingsDataStore(applicationContext).settings.first()
@ -101,17 +127,19 @@ class ForwardingService : LifecycleService() {
startPollLoop(settings.pollIntervalSeconds)
startReachabilityLoop()
startRetryLoop()
}
}
private fun stopForwarding() {
pollJob?.cancel()
reachJob?.cancel()
retryJob?.cancel()
_isRunning.value = false
}
private fun applySettings(s: VarroaSettings) {
beeClient.updateBaseUrl(s.beeApiUrl)
beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl)
adamapsClient.updateConfig(s.adamapsApiUrl, s.adamapsApiKey)
}
@ -120,7 +148,13 @@ class ForwardingService : LifecycleService() {
pollJob = lifecycleScope.launch {
while (true) {
runPollCycle()
delay(intervalSeconds * 1000L)
// Use backoff delay when Bee is offline
val delayMs = if (_beeConnected.value) {
intervalSeconds * 1000L
} else {
currentBackoffMs
}
delay(delayMs)
}
}
}
@ -135,11 +169,32 @@ class ForwardingService : LifecycleService() {
}
}
private fun startRetryLoop() {
retryJob?.cancel()
retryJob = lifecycleScope.launch {
while (true) {
delay(10_000L) // Check retry queue every 10 seconds
processRetryQueue()
}
}
}
private suspend fun runPollCycle() {
when (val result = beeClient.getLandmarks()) {
is ApiResult.Success -> {
// Success! Reset backoff
_beeConnected.value = true
consecutiveFailures = 0
currentBackoffMs = MIN_BACKOFF_MS
_lastError.value = null
val activeUrl = beeClient.getActiveUrl()
_beeStatus.value = if (activeUrl?.contains("155") == true) {
"Connected (home WiFi)"
} else {
"Connected (AP mode)"
}
val newDetections = result.data.filter { d ->
val key = d.dedupKey()
if (seenKeys.contains(key)) false
@ -156,7 +211,22 @@ class ForwardingService : LifecycleService() {
}
is ApiResult.Error -> {
_beeConnected.value = false
_lastError.value = result.message
consecutiveFailures++
// Calculate exponential backoff
currentBackoffMs = min(
(MIN_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, (consecutiveFailures - 1).toDouble())).toLong(),
MAX_BACKOFF_MS
)
// Set clean offline status instead of ugly error
if (result.message == "Bee offline") {
_beeStatus.value = "Bee offline (retry in ${currentBackoffMs / 1000}s)"
_lastError.value = null // Don't show red error for expected offline state
} else {
_beeStatus.value = "Connection error"
_lastError.value = result.message
}
}
}
}
@ -184,8 +254,49 @@ class ForwardingService : LifecycleService() {
is ApiResult.Error -> {
_lastError.value = "ADAMaps: ${result.message}"
_adamapsReachable.value = false
// keep queued count — retry on next cycle not implemented yet
_stats.update { it.copy(queued = it.queued - detections.size) }
// Add to retry queue instead of losing detections
pendingQueue.add(QueuedIngest(deviceId, detections))
// Keep queued count accurate
}
}
}
private suspend fun processRetryQueue() {
if (pendingQueue.isEmpty() || !_adamapsReachable.value) return
val toRetry = mutableListOf<QueuedIngest>()
while (pendingQueue.isNotEmpty()) {
pendingQueue.poll()?.let { toRetry.add(it) }
}
for (item in toRetry) {
val request = AdaMapsIngestRequest(
deviceId = item.deviceId,
detections = item.detections.map { it.toAdaMapsDetection() }
)
when (val result = adamapsClient.ingest(request)) {
is ApiResult.Success -> {
_stats.update {
it.copy(
sent = it.sent + item.detections.size,
queued = it.queued - item.detections.size
)
}
_adamapsReachable.value = true
}
is ApiResult.Error -> {
item.attempts++
if (item.attempts < 5) {
// Re-queue for retry (max 5 attempts)
pendingQueue.add(item)
} else {
// Give up after 5 attempts, remove from queued count
_stats.update { it.copy(queued = it.queued - item.detections.size) }
_lastError.value = "Dropped ${item.detections.size} detections after 5 failed attempts"
}
_adamapsReachable.value = false
}
}
}
}

View file

@ -49,6 +49,7 @@ fun DashboardScreen(
val beeConnected by vm.beeConnected.collectAsState()
val adamapsReachable by vm.adamapsReachable.collectAsState()
val lastError by vm.lastError.collectAsState()
val beeStatus by vm.beeStatus.collectAsState()
Scaffold(
containerColor = Background,
@ -84,12 +85,13 @@ fun DashboardScreen(
// Connection status bar
ConnectionStatusBar(
beeConnected = beeConnected,
beeStatus = beeStatus,
adamapsReachable = adamapsReachable,
isForwarding = isForwarding,
onToggle = { vm.toggleForwarding() }
)
// Error banner
// Error banner (only show for actual errors, not "offline" state)
if (lastError != null) {
ErrorBanner(lastError!!)
}
@ -98,7 +100,7 @@ fun DashboardScreen(
SessionStatsCard(stats)
// Camera snapshot
CameraCard(cameraBytes)
CameraCard(cameraBytes, beeConnected)
// GPS mini-map
gnss?.let { GpsMapCard(it) }
@ -112,6 +114,7 @@ fun DashboardScreen(
@Composable
private fun ConnectionStatusBar(
beeConnected: Boolean,
beeStatus: String,
adamapsReachable: Boolean,
isForwarding: Boolean,
onToggle: () -> Unit
@ -121,49 +124,57 @@ private fun ConnectionStatusBar(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StatusDot(
label = "BEE",
active = beeConnected
)
StatusDot(
label = "ADAMAPS",
active = adamapsReachable
)
}
// Forwarding toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (isForwarding) "FORWARDING" else "PAUSED",
color = if (isForwarding) Amber else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp
)
Spacer(Modifier.width(8.dp))
Switch(
checked = isForwarding,
onCheckedChange = { onToggle() },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
checkedTrackColor = AmberDark,
uncheckedThumbColor = Color.Gray,
uncheckedTrackColor = SurfaceVariant
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
StatusDot(
label = "BEE",
active = beeConnected
)
)
StatusDot(
label = "ADAMAPS",
active = adamapsReachable
)
}
// Forwarding toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (isForwarding) "FORWARDING" else "PAUSED",
color = if (isForwarding) Amber else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp
)
Spacer(Modifier.width(8.dp))
Switch(
checked = isForwarding,
onCheckedChange = { onToggle() },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
checkedTrackColor = AmberDark,
uncheckedThumbColor = Color.Gray,
uncheckedTrackColor = SurfaceVariant
)
)
}
}
// Show Bee status text below the status dots
Spacer(Modifier.height(6.dp))
Text(
text = beeStatus,
color = if (beeConnected) Color.Gray else Color(0xFF9CA3AF),
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
@ -260,7 +271,7 @@ private fun StatItem(label: String, value: String) {
}
@Composable
private fun CameraCard(bytes: ByteArray?) {
private fun CameraCard(bytes: ByteArray?, beeConnected: Boolean) {
Card(
colors = CardDefaults.cardColors(containerColor = Surface),
shape = RoundedCornerShape(8.dp),
@ -277,22 +288,29 @@ private fun CameraCard(bytes: ByteArray?) {
.background(SurfaceVariant),
contentAlignment = Alignment.Center
) {
if (bytes != null && bytes.isNotEmpty()) {
val bitmap = remember(bytes) {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
when {
!beeConnected -> {
// Show "Bee offline" instead of "Awaiting frame"
CameraPlaceholder("Bee offline", Icons.Default.CloudOff)
}
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Camera feed",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
} else {
CameraPlaceholder("Failed to decode frame")
bytes != null && bytes.isNotEmpty() -> {
val bitmap = remember(bytes) {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Camera feed",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
} else {
CameraPlaceholder("Failed to decode frame", Icons.Default.BrokenImage)
}
}
else -> {
CameraPlaceholder("Awaiting frame…", Icons.Default.CameraAlt)
}
} else {
CameraPlaceholder("Awaiting frame…")
}
}
}
@ -300,10 +318,10 @@ private fun CameraCard(bytes: ByteArray?) {
}
@Composable
private fun CameraPlaceholder(msg: String) {
private fun CameraPlaceholder(msg: String, icon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Default.CameraAlt) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.CameraAlt,
icon,
contentDescription = null,
tint = Color.Gray,
modifier = Modifier.size(32.dp)

View file

@ -32,6 +32,7 @@ fun SettingsScreen(
// Local edit state — initialized from current settings
var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) }
var beeAltApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeAltApiUrl) }
var adamapsApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.adamapsApiUrl) }
var adamapsApiKey by remember(currentSettings) { mutableStateOf(currentSettings.adamapsApiKey) }
var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) }
@ -72,6 +73,7 @@ fun SettingsScreen(
vm.save(
VarroaSettings(
beeApiUrl = beeApiUrl.trim(),
beeAltApiUrl = beeAltApiUrl.trim(),
adamapsApiUrl = adamapsApiUrl.trim(),
adamapsApiKey = adamapsApiKey.trim(),
pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30,
@ -96,11 +98,28 @@ fun SettingsScreen(
) {
SettingsSection("BEE DEVICE") {
SettingsField(
label = "Bee API URL",
label = "Bee API URL (Primary)",
value = beeApiUrl,
onValueChange = { beeApiUrl = it },
hint = "http://192.168.0.10:5000"
)
Spacer(Modifier.height(8.dp))
SettingsField(
label = "Bee Alt URL (Home WiFi)",
value = beeAltApiUrl,
onValueChange = { beeAltApiUrl = it },
hint = "http://192.168.0.155:5000"
)
Spacer(Modifier.height(8.dp))
Text(
"Primary = Bee's own AP (192.168.0.10)\n" +
"Alt = Home WiFi IP when Bee is docked\n" +
"App tries both automatically",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 16.sp
)
}
SettingsSection("ADAMAPS") {

View file

@ -50,6 +50,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
val beeConnected = ForwardingService.beeConnected
val adamapsReachable = ForwardingService.adamapsReachable
val lastError = ForwardingService.lastError
val beeStatus = ForwardingService.beeStatus
// ── Error ─────────────────────────────────────────────────────────────────
private val _error = MutableStateFlow<String?>(null)
@ -73,7 +74,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
}
private fun applySettings(s: VarroaSettings) {
beeClient.updateBaseUrl(s.beeApiUrl)
beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl)
}
private fun startPolling(s: VarroaSettings) {
@ -119,7 +120,12 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
_cameraEndpointWorking.value = foundEp
_cameraBytes.value = result.data
}
is ApiResult.Error -> { /* camera unavailable */ }
is ApiResult.Error -> {
// Clear camera bytes when Bee is offline
if (result.message == "Bee offline") {
_cameraBytes.value = null
}
}
}
delay(intervalMs)
}