From e28234c7b95b7c7bb81f3081718e8ad04c3b84ac Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 11 Mar 2026 09:53:01 -0700 Subject: [PATCH] v1.3.0: Multi-IP fallback, exponential backoff, retry queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 4 +- .../com/adamaps/varroa/api/BeeApiClient.kt | 172 ++++++++++++++++-- .../adamaps/varroa/data/SettingsDataStore.kt | 4 + .../varroa/service/ForwardingService.kt | 121 +++++++++++- .../varroa/ui/dashboard/DashboardScreen.kt | 132 ++++++++------ .../varroa/ui/settings/SettingsScreen.kt | 21 ++- .../varroa/viewmodel/DashboardViewModel.kt | 10 +- 7 files changed, 384 insertions(+), 80 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 205cd43..fb5e48c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt index 3e9f300..bf28539 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -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 = withContext(Dispatchers.IO) { + private suspend fun getRaw(path: String, useActiveUrl: Boolean = true): ApiResult = 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 = 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> = 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>() {}.type val list: List = 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 = 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 = 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 = getBytes(endpoint) + suspend fun getCameraFrame(endpoint: String): ApiResult { + 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> { + 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 } diff --git a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt index 0682876..207be9d 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -14,6 +14,7 @@ private val Context.dataStore: DataStore 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 = 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 diff --git a/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt b/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt index 57ad432..c24c56e 100644 --- a/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt +++ b/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt @@ -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 = _stats.asStateFlow() @@ -54,6 +61,10 @@ class ForwardingService : LifecycleService() { private val _adamapsReachable = MutableStateFlow(false) val adamapsReachable: StateFlow = _adamapsReachable.asStateFlow() + + // Connection status message (for cleaner display) + private val _beeStatus = MutableStateFlow("Connecting...") + val beeStatus: StateFlow = _beeStatus.asStateFlow() } private val beeClient = BeeApiClient() @@ -62,6 +73,20 @@ class ForwardingService : LifecycleService() { private val seenKeys = mutableSetOf() 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() + + private data class QueuedIngest( + val deviceId: String, + val detections: List, + 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() + 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 + } } } } diff --git a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt index 3301ccd..b1d6d1a 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt @@ -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) diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt index c901535..7b93f80 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt @@ -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") { diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt index 4fc34e1..2d1492c 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt @@ -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(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) }