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:
Sulkta 2026-03-11 09:53:01 -07:00
parent 02c813e5b0
commit c1b446f89c
7 changed files with 384 additions and 80 deletions

View file

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

View file

@ -11,24 +11,40 @@ import com.adamaps.varroa.data.GnssData
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.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class BeeApiClient( 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.197.1:5000"
) { ) {
private var client = buildClient(null) private var client = buildClient(null)
private var fastClient = buildFastClient(null)
private val gson = Gson() private val gson = Gson()
fun updateBaseUrl(url: String) { // Track which URL is currently active (null = unknown/offline)
baseUrl = url.trimEnd('/') 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) { fun bindToWifiNetwork(context: Context) {
client = buildClient(getWifiNetwork(context)) val net = getWifiNetwork(context)
client = buildClient(net)
fastClient = buildFastClient(net)
} }
private fun buildClient(net: Network?): OkHttpClient { private fun buildClient(net: Network?): OkHttpClient {
@ -40,6 +56,15 @@ class BeeApiClient(
return b.build() 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? { private fun getWifiNetwork(context: Context): Network? {
return try { return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@ -53,13 +78,19 @@ class BeeApiClient(
} catch (e: Exception) { null } } 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 { try {
val req = Request.Builder().url("$baseUrl$path").get().build() val req = Request.Builder().url("$baseUrl$path").get().build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: "" val body = resp.body?.string() ?: ""
if (resp.isSuccessful) ApiResult.Success(body) if (resp.isSuccessful) {
else ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) isConnected = true
activeUrl = baseUrl
ApiResult.Success(body)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error") ApiResult.Error(e.message ?: "Unknown error")
@ -67,32 +98,136 @@ class BeeApiClient(
} }
private suspend fun getBytes(path: String): ApiResult<ByteArray> = withContext(Dispatchers.IO) { private suspend fun getBytes(path: String): ApiResult<ByteArray> = withContext(Dispatchers.IO) {
val baseUrl = activeUrl ?: primaryUrl
try { try {
val req = Request.Builder().url("$baseUrl$path").get().build() val req = Request.Builder().url("$baseUrl$path").get().build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
val bytes = resp.body?.bytes() ?: ByteArray(0) val bytes = resp.body?.bytes() ?: ByteArray(0)
if (resp.isSuccessful && bytes.isNotEmpty()) ApiResult.Success(bytes) if (resp.isSuccessful && bytes.isNotEmpty()) {
else ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) isConnected = true
ApiResult.Success(bytes)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error") 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) { 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")) { when (val r = getRaw("/api/1/landmarks/last/200")) {
is ApiResult.Success -> try { is ApiResult.Success -> try {
val type = object : TypeToken<List<BeeDetection>>() {}.type val type = object : TypeToken<List<BeeDetection>>() {}.type
val list: List<BeeDetection> = gson.fromJson(r.data, type) val list: List<BeeDetection> = gson.fromJson(r.data, type)
isConnected = true
ApiResult.Success(list) ApiResult.Success(list)
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}") 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) { 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")) { when (val r = getRaw("/api/1/gnssConcise/latestValid")) {
is ApiResult.Success -> try { is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, GnssData::class.java)) ApiResult.Success(gson.fromJson(r.data, GnssData::class.java))
@ -104,6 +239,10 @@ class BeeApiClient(
} }
suspend fun getDeviceInfo(): ApiResult<BeeDeviceInfo> = withContext(Dispatchers.IO) { 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")) { when (val r = getRaw("/api/1/info")) {
is ApiResult.Success -> try { is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, BeeDeviceInfo::class.java)) ApiResult.Success(gson.fromJson(r.data, BeeDeviceInfo::class.java))
@ -118,12 +257,21 @@ class BeeApiClient(
* 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.
*/ */
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. * Try multiple camera endpoints in order, return first success.
*/ */
suspend fun getCameraFrameAuto(configured: String): Pair<String, ApiResult<ByteArray>> { suspend fun getCameraFrameAuto(configured: String): Pair<String, ApiResult<ByteArray>> {
if (activeUrl == null && discoverActiveUrl() == null) {
return configured to ApiResult.Error("Bee offline")
}
val candidates = listOf( val candidates = listOf(
configured, configured,
"/api/1/camera/frame", "/api/1/camera/frame",
@ -137,6 +285,4 @@ class BeeApiClient(
} }
return configured to ApiResult.Error("No camera endpoint responded") 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( data class VarroaSettings(
val beeApiUrl: String = "http://192.168.0.10:5000", val beeApiUrl: String = "http://192.168.0.10:5000",
val beeAltApiUrl: String = "http://192.168.197.1:5000",
val adamapsApiUrl: String = "https://api.adamaps.org", val adamapsApiUrl: String = "https://api.adamaps.org",
val adamapsApiKey: String = "***REMOVED***", val adamapsApiKey: String = "***REMOVED***",
val pollIntervalSeconds: Int = 30, val pollIntervalSeconds: Int = 30,
@ -25,6 +26,7 @@ class SettingsDataStore(private val context: Context) {
companion object { companion object {
private val KEY_BEE_URL = stringPreferencesKey("bee_api_url") 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_URL = stringPreferencesKey("adamaps_api_url")
private val KEY_ADAMAPS_KEY = stringPreferencesKey("adamaps_api_key") private val KEY_ADAMAPS_KEY = stringPreferencesKey("adamaps_api_key")
private val KEY_POLL_INTERVAL = intPreferencesKey("poll_interval_seconds") 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 -> val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
VarroaSettings( VarroaSettings(
beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000", beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000",
beeAltApiUrl = prefs[KEY_BEE_ALT_URL] ?: "http://192.168.197.1:5000",
adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org",
adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***", adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***",
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
@ -46,6 +49,7 @@ class SettingsDataStore(private val context: Context) {
suspend fun save(s: VarroaSettings) { suspend fun save(s: VarroaSettings) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[KEY_BEE_URL] = s.beeApiUrl prefs[KEY_BEE_URL] = s.beeApiUrl
prefs[KEY_BEE_ALT_URL] = s.beeAltApiUrl
prefs[KEY_ADAMAPS_URL] = s.adamapsApiUrl prefs[KEY_ADAMAPS_URL] = s.adamapsApiUrl
prefs[KEY_ADAMAPS_KEY] = s.adamapsApiKey prefs[KEY_ADAMAPS_KEY] = s.adamapsApiKey
prefs[KEY_POLL_INTERVAL] = s.pollIntervalSeconds 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.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.math.min
class ForwardingService : LifecycleService() { class ForwardingService : LifecycleService() {
@ -39,6 +41,11 @@ class ForwardingService : LifecycleService() {
const val ACTION_START = "com.adamaps.varroa.START_FORWARDING" const val ACTION_START = "com.adamaps.varroa.START_FORWARDING"
const val ACTION_STOP = "com.adamaps.varroa.STOP_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 // Shared state exposed to ViewModels
private val _stats = MutableStateFlow(SessionStats()) private val _stats = MutableStateFlow(SessionStats())
val stats: StateFlow<SessionStats> = _stats.asStateFlow() val stats: StateFlow<SessionStats> = _stats.asStateFlow()
@ -54,6 +61,10 @@ class ForwardingService : LifecycleService() {
private val _adamapsReachable = MutableStateFlow(false) private val _adamapsReachable = MutableStateFlow(false)
val adamapsReachable: StateFlow<Boolean> = _adamapsReachable.asStateFlow() 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() private val beeClient = BeeApiClient()
@ -62,6 +73,20 @@ class ForwardingService : LifecycleService() {
private val seenKeys = mutableSetOf<String>() private val seenKeys = mutableSetOf<String>()
private var pollJob: Job? = null private var pollJob: Job? = null
private var reachJob: 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() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -93,6 +118,7 @@ class ForwardingService : LifecycleService() {
private fun startForwarding() { private fun startForwarding() {
startForeground(NOTIF_ID, buildNotification(0)) startForeground(NOTIF_ID, buildNotification(0))
_isRunning.value = true _isRunning.value = true
_beeStatus.value = "Connecting..."
lifecycleScope.launch { lifecycleScope.launch {
val settings = SettingsDataStore(applicationContext).settings.first() val settings = SettingsDataStore(applicationContext).settings.first()
@ -101,17 +127,19 @@ class ForwardingService : LifecycleService() {
startPollLoop(settings.pollIntervalSeconds) startPollLoop(settings.pollIntervalSeconds)
startReachabilityLoop() startReachabilityLoop()
startRetryLoop()
} }
} }
private fun stopForwarding() { private fun stopForwarding() {
pollJob?.cancel() pollJob?.cancel()
reachJob?.cancel() reachJob?.cancel()
retryJob?.cancel()
_isRunning.value = false _isRunning.value = false
} }
private fun applySettings(s: VarroaSettings) { private fun applySettings(s: VarroaSettings) {
beeClient.updateBaseUrl(s.beeApiUrl) beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl)
adamapsClient.updateConfig(s.adamapsApiUrl, s.adamapsApiKey) adamapsClient.updateConfig(s.adamapsApiUrl, s.adamapsApiKey)
} }
@ -120,7 +148,13 @@ class ForwardingService : LifecycleService() {
pollJob = lifecycleScope.launch { pollJob = lifecycleScope.launch {
while (true) { while (true) {
runPollCycle() 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() { private suspend fun runPollCycle() {
when (val result = beeClient.getLandmarks()) { when (val result = beeClient.getLandmarks()) {
is ApiResult.Success -> { is ApiResult.Success -> {
// Success! Reset backoff
_beeConnected.value = true _beeConnected.value = true
consecutiveFailures = 0
currentBackoffMs = MIN_BACKOFF_MS
_lastError.value = null _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 newDetections = result.data.filter { d ->
val key = d.dedupKey() val key = d.dedupKey()
if (seenKeys.contains(key)) false if (seenKeys.contains(key)) false
@ -156,7 +211,22 @@ class ForwardingService : LifecycleService() {
} }
is ApiResult.Error -> { is ApiResult.Error -> {
_beeConnected.value = false _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 -> { is ApiResult.Error -> {
_lastError.value = "ADAMaps: ${result.message}" _lastError.value = "ADAMaps: ${result.message}"
_adamapsReachable.value = false _adamapsReachable.value = false
// keep queued count — retry on next cycle not implemented yet // Add to retry queue instead of losing detections
_stats.update { it.copy(queued = it.queued - detections.size) } 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 beeConnected by vm.beeConnected.collectAsState()
val adamapsReachable by vm.adamapsReachable.collectAsState() val adamapsReachable by vm.adamapsReachable.collectAsState()
val lastError by vm.lastError.collectAsState() val lastError by vm.lastError.collectAsState()
val beeStatus by vm.beeStatus.collectAsState()
Scaffold( Scaffold(
containerColor = Background, containerColor = Background,
@ -84,12 +85,13 @@ fun DashboardScreen(
// Connection status bar // Connection status bar
ConnectionStatusBar( ConnectionStatusBar(
beeConnected = beeConnected, beeConnected = beeConnected,
beeStatus = beeStatus,
adamapsReachable = adamapsReachable, adamapsReachable = adamapsReachable,
isForwarding = isForwarding, isForwarding = isForwarding,
onToggle = { vm.toggleForwarding() } onToggle = { vm.toggleForwarding() }
) )
// Error banner // Error banner (only show for actual errors, not "offline" state)
if (lastError != null) { if (lastError != null) {
ErrorBanner(lastError!!) ErrorBanner(lastError!!)
} }
@ -98,7 +100,7 @@ fun DashboardScreen(
SessionStatsCard(stats) SessionStatsCard(stats)
// Camera snapshot // Camera snapshot
CameraCard(cameraBytes) CameraCard(cameraBytes, beeConnected)
// GPS mini-map // GPS mini-map
gnss?.let { GpsMapCard(it) } gnss?.let { GpsMapCard(it) }
@ -112,6 +114,7 @@ fun DashboardScreen(
@Composable @Composable
private fun ConnectionStatusBar( private fun ConnectionStatusBar(
beeConnected: Boolean, beeConnected: Boolean,
beeStatus: String,
adamapsReachable: Boolean, adamapsReachable: Boolean,
isForwarding: Boolean, isForwarding: Boolean,
onToggle: () -> Unit onToggle: () -> Unit
@ -121,49 +124,57 @@ private fun ConnectionStatusBar(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Row( Column(modifier = Modifier.padding(12.dp)) {
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
StatusDot( Row(
label = "BEE", horizontalArrangement = Arrangement.spacedBy(16.dp),
active = beeConnected verticalAlignment = Alignment.CenterVertically
) ) {
StatusDot( StatusDot(
label = "ADAMAPS", label = "BEE",
active = adamapsReachable active = beeConnected
)
}
// 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
) )
) 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 @Composable
private fun CameraCard(bytes: ByteArray?) { private fun CameraCard(bytes: ByteArray?, beeConnected: Boolean) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = Surface), colors = CardDefaults.cardColors(containerColor = Surface),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@ -277,22 +288,29 @@ private fun CameraCard(bytes: ByteArray?) {
.background(SurfaceVariant), .background(SurfaceVariant),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (bytes != null && bytes.isNotEmpty()) { when {
val bitmap = remember(bytes) { !beeConnected -> {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size) // Show "Bee offline" instead of "Awaiting frame"
CameraPlaceholder("Bee offline", Icons.Default.CloudOff)
} }
if (bitmap != null) { bytes != null && bytes.isNotEmpty() -> {
Image( val bitmap = remember(bytes) {
bitmap = bitmap.asImageBitmap(), BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
contentDescription = "Camera feed", }
modifier = Modifier.fillMaxSize(), if (bitmap != null) {
contentScale = ContentScale.Fit Image(
) bitmap = bitmap.asImageBitmap(),
} else { contentDescription = "Camera feed",
CameraPlaceholder("Failed to decode frame") 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 @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) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon( Icon(
Icons.Default.CameraAlt, icon,
contentDescription = null, contentDescription = null,
tint = Color.Gray, tint = Color.Gray,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)

View file

@ -32,6 +32,7 @@ fun SettingsScreen(
// Local edit state — initialized from current settings // Local edit state — initialized from current settings
var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) } 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 adamapsApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.adamapsApiUrl) }
var adamapsApiKey by remember(currentSettings) { mutableStateOf(currentSettings.adamapsApiKey) } var adamapsApiKey by remember(currentSettings) { mutableStateOf(currentSettings.adamapsApiKey) }
var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) } var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) }
@ -72,6 +73,7 @@ fun SettingsScreen(
vm.save( vm.save(
VarroaSettings( VarroaSettings(
beeApiUrl = beeApiUrl.trim(), beeApiUrl = beeApiUrl.trim(),
beeAltApiUrl = beeAltApiUrl.trim(),
adamapsApiUrl = adamapsApiUrl.trim(), adamapsApiUrl = adamapsApiUrl.trim(),
adamapsApiKey = adamapsApiKey.trim(), adamapsApiKey = adamapsApiKey.trim(),
pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30, pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30,
@ -96,11 +98,28 @@ fun SettingsScreen(
) { ) {
SettingsSection("BEE DEVICE") { SettingsSection("BEE DEVICE") {
SettingsField( SettingsField(
label = "Bee API URL", label = "Bee API URL (Primary)",
value = beeApiUrl, value = beeApiUrl,
onValueChange = { beeApiUrl = it }, onValueChange = { beeApiUrl = it },
hint = "http://192.168.0.10:5000" 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.197.1: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") { SettingsSection("ADAMAPS") {

View file

@ -50,6 +50,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
val beeConnected = ForwardingService.beeConnected val beeConnected = ForwardingService.beeConnected
val adamapsReachable = ForwardingService.adamapsReachable val adamapsReachable = ForwardingService.adamapsReachable
val lastError = ForwardingService.lastError val lastError = ForwardingService.lastError
val beeStatus = ForwardingService.beeStatus
// ── Error ───────────────────────────────────────────────────────────────── // ── Error ─────────────────────────────────────────────────────────────────
private val _error = MutableStateFlow<String?>(null) private val _error = MutableStateFlow<String?>(null)
@ -73,7 +74,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
} }
private fun applySettings(s: VarroaSettings) { private fun applySettings(s: VarroaSettings) {
beeClient.updateBaseUrl(s.beeApiUrl) beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl)
} }
private fun startPolling(s: VarroaSettings) { private fun startPolling(s: VarroaSettings) {
@ -119,7 +120,12 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
_cameraEndpointWorking.value = foundEp _cameraEndpointWorking.value = foundEp
_cameraBytes.value = result.data _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) delay(intervalMs)
} }