From de0f97c4f2b1368a4cb213119ca48b3003d7717e Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 11 Mar 2026 09:57:02 -0700 Subject: [PATCH] v1.3.0: Port 5001 proxy, exponential backoff, retry queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: odc-api binds to localhost only, not accessible from AP clients. Solution: socat/python proxy on Bee forwards 0.0.0.0:5001 → 127.0.0.1:5000 App changes: - Default URL now http://192.168.0.10:5001 (proxy port) - Exponential backoff: 5s → 60s max when offline - Clean 'Bee offline' status instead of red errors - Retry queue: Failed ADAMaps sends retry up to 5x - Camera shows 'Bee offline' when disconnected - Simplified settings (removed dual-IP, single URL) Requires running bee-proxy-setup.sh on the Bee first. --- .../com/adamaps/varroa/api/BeeApiClient.kt | 152 ++---------------- .../adamaps/varroa/data/SettingsDataStore.kt | 8 +- .../varroa/service/ForwardingService.kt | 24 ++- .../varroa/ui/settings/SettingsScreen.kt | 22 +-- .../varroa/viewmodel/DashboardViewModel.kt | 6 +- 5 files changed, 32 insertions(+), 180 deletions(-) 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 bf28539..10d2b2a 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -11,40 +11,28 @@ 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 primaryUrl: String = "http://192.168.0.10:5000", - private var altUrl: String = "http://192.168.0.155:5000" + private var baseUrl: String = "http://192.168.0.10:5001" ) { private var client = buildClient(null) - private var fastClient = buildFastClient(null) private val gson = Gson() - // 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 updateBaseUrl(url: String) { + baseUrl = url.trimEnd('/') } fun bindToWifiNetwork(context: Context) { - val net = getWifiNetwork(context) - client = buildClient(net) - fastClient = buildFastClient(net) + client = buildClient(getWifiNetwork(context)) } private fun buildClient(net: Network?): OkHttpClient { @@ -56,15 +44,7 @@ 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() - } - + @Suppress("DEPRECATION") private fun getWifiNetwork(context: Context): Network? { return try { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -78,27 +58,25 @@ class BeeApiClient( } catch (e: Exception) { null } } - private suspend fun getRaw(path: String, useActiveUrl: Boolean = true): ApiResult = withContext(Dispatchers.IO) { - val baseUrl = if (useActiveUrl && activeUrl != null) activeUrl!! else primaryUrl + private suspend fun getRaw(path: String): ApiResult = withContext(Dispatchers.IO) { try { val req = Request.Builder().url("$baseUrl$path").get().build() client.newCall(req).execute().use { resp -> val body = resp.body?.string() ?: "" if (resp.isSuccessful) { isConnected = true - activeUrl = baseUrl ApiResult.Success(body) } else { ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) } } } catch (e: Exception) { + isConnected = false ApiResult.Error(e.message ?: "Unknown error") } } 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 -> @@ -111,95 +89,12 @@ class BeeApiClient( } } } catch (e: Exception) { + isConnected = false 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 @@ -210,24 +105,13 @@ class BeeApiClient( ApiResult.Error("Parse error: ${e.message}") } 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 - } + 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)) @@ -239,10 +123,6 @@ 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)) @@ -255,23 +135,13 @@ class BeeApiClient( /** * Try the given endpoint; returns raw image bytes. - * The caller is responsible for trying fallback endpoints. */ - suspend fun getCameraFrame(endpoint: String): ApiResult { - if (activeUrl == null && discoverActiveUrl() == null) { - return ApiResult.Error("Bee offline") - } - return getBytes(endpoint) - } + suspend fun getCameraFrame(endpoint: String): ApiResult = 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", @@ -285,4 +155,6 @@ 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 10d057b..ec32de8 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -13,8 +13,7 @@ import kotlinx.coroutines.flow.map private val Context.dataStore: DataStore by preferencesDataStore(name = "varroa_settings") data class VarroaSettings( - val beeApiUrl: String = "http://192.168.0.10:5000", - val beeAltApiUrl: String = "http://192.168.0.155:5000", + val beeApiUrl: String = "http://192.168.0.10:5001", val adamapsApiUrl: String = "https://api.adamaps.org", val adamapsApiKey: String = "***REMOVED***", val pollIntervalSeconds: Int = 30, @@ -26,7 +25,6 @@ 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") @@ -36,8 +34,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", + beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5001", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***", pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, @@ -49,7 +46,6 @@ 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 c24c56e..107ad5f 100644 --- a/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt +++ b/app/src/main/java/com/adamaps/varroa/service/ForwardingService.kt @@ -139,7 +139,7 @@ class ForwardingService : LifecycleService() { } private fun applySettings(s: VarroaSettings) { - beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl) + beeClient.updateBaseUrl(s.beeApiUrl) adamapsClient.updateConfig(s.adamapsApiUrl, s.adamapsApiKey) } @@ -187,13 +187,7 @@ class ForwardingService : LifecycleService() { 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)" - } + _beeStatus.value = "Connected" val newDetections = result.data.filter { d -> val key = d.dedupKey() @@ -219,13 +213,15 @@ class ForwardingService : LifecycleService() { 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" + // Clean offline status + _beeStatus.value = "Bee offline (retry in ${currentBackoffMs / 1000}s)" + // Don't show red error banner for connection timeouts + if (!result.message.contains("timeout", ignoreCase = true) && + !result.message.contains("connect", ignoreCase = true) && + !result.message.contains("refused", ignoreCase = true)) { _lastError.value = result.message + } else { + _lastError.value = null } } } 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 64840e8..d9b2461 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 @@ -5,7 +5,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material3.* import androidx.compose.runtime.* @@ -32,7 +32,6 @@ 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()) } @@ -64,7 +63,7 @@ fun SettingsScreen( }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back", tint = Amber) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Amber) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), @@ -73,7 +72,6 @@ fun SettingsScreen( vm.save( VarroaSettings( beeApiUrl = beeApiUrl.trim(), - beeAltApiUrl = beeAltApiUrl.trim(), adamapsApiUrl = adamapsApiUrl.trim(), adamapsApiKey = adamapsApiKey.trim(), pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30, @@ -98,23 +96,15 @@ fun SettingsScreen( ) { SettingsSection("BEE DEVICE") { SettingsField( - label = "Bee API URL (Primary)", + label = "Bee API URL", 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" + hint = "http://192.168.0.10:5001" ) 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", + "Port 5001 = socat proxy to odc-api\n" + + "Must run proxy setup on Bee first", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp, 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 2d1492c..9ad8b19 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt @@ -74,7 +74,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) { } private fun applySettings(s: VarroaSettings) { - beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl) + beeClient.updateBaseUrl(s.beeApiUrl) } private fun startPolling(s: VarroaSettings) { @@ -122,9 +122,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) { } is ApiResult.Error -> { // Clear camera bytes when Bee is offline - if (result.message == "Bee offline") { - _cameraBytes.value = null - } + _cameraBytes.value = null } } delay(intervalMs)