v1.3.0: Port 5001 proxy, exponential backoff, retry queue

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.
This commit is contained in:
Kayos 2026-03-11 09:57:02 -07:00
parent a05b41cfc4
commit de0f97c4f2
5 changed files with 32 additions and 180 deletions

View file

@ -11,40 +11,28 @@ 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 primaryUrl: String = "http://192.168.0.10:5000", private var baseUrl: String = "http://192.168.0.10:5001"
private var altUrl: String = "http://192.168.0.155:5000"
) { ) {
private var client = buildClient(null) private var client = buildClient(null)
private var fastClient = buildFastClient(null)
private val gson = Gson() private val gson = Gson()
// Track which URL is currently active (null = unknown/offline)
private var activeUrl: String? = null
// Connection state // Connection state
var isConnected: Boolean = false var isConnected: Boolean = false
private set private set
fun updateUrls(primary: String, alt: String) { fun updateBaseUrl(url: String) {
primaryUrl = primary.trimEnd('/') baseUrl = url.trimEnd('/')
altUrl = alt.trimEnd('/')
// Reset active URL when settings change
activeUrl = null
} }
fun bindToWifiNetwork(context: Context) { fun bindToWifiNetwork(context: Context) {
val net = getWifiNetwork(context) client = buildClient(getWifiNetwork(context))
client = buildClient(net)
fastClient = buildFastClient(net)
} }
private fun buildClient(net: Network?): OkHttpClient { private fun buildClient(net: Network?): OkHttpClient {
@ -56,15 +44,7 @@ class BeeApiClient(
return b.build() return b.build()
} }
private fun buildFastClient(net: Network?): OkHttpClient { @Suppress("DEPRECATION")
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
@ -78,27 +58,25 @@ class BeeApiClient(
} catch (e: Exception) { null } } catch (e: Exception) { null }
} }
private suspend fun getRaw(path: String, useActiveUrl: Boolean = true): ApiResult<String> = withContext(Dispatchers.IO) { private suspend fun getRaw(path: String): 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) { if (resp.isSuccessful) {
isConnected = true isConnected = true
activeUrl = baseUrl
ApiResult.Success(body) ApiResult.Success(body)
} else { } else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
isConnected = false
ApiResult.Error(e.message ?: "Unknown error") ApiResult.Error(e.message ?: "Unknown error")
} }
} }
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 ->
@ -111,95 +89,12 @@ class BeeApiClient(
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
isConnected = false
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
@ -210,24 +105,13 @@ class BeeApiClient(
ApiResult.Error("Parse error: ${e.message}") ApiResult.Error("Parse error: ${e.message}")
} }
is ApiResult.Error -> { is ApiResult.Error -> {
// Connection failed, clear active URL for next attempt isConnected = false
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 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))
@ -239,10 +123,6 @@ 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))
@ -255,23 +135,13 @@ 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.
*/ */
suspend fun getCameraFrame(endpoint: String): ApiResult<ByteArray> { suspend fun getCameraFrame(endpoint: String): ApiResult<ByteArray> = getBytes(endpoint)
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",
@ -285,4 +155,6 @@ 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

@ -13,8 +13,7 @@ import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings")
data class VarroaSettings( data class VarroaSettings(
val beeApiUrl: String = "http://192.168.0.10:5000", val beeApiUrl: String = "http://192.168.0.10:5001",
val beeAltApiUrl: String = "http://192.168.0.155: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,
@ -26,7 +25,6 @@ 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")
@ -36,8 +34,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:5001",
beeAltApiUrl = prefs[KEY_BEE_ALT_URL] ?: "http://192.168.0.155: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,
@ -49,7 +46,6 @@ 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

@ -139,7 +139,7 @@ class ForwardingService : LifecycleService() {
} }
private fun applySettings(s: VarroaSettings) { private fun applySettings(s: VarroaSettings) {
beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl) beeClient.updateBaseUrl(s.beeApiUrl)
adamapsClient.updateConfig(s.adamapsApiUrl, s.adamapsApiKey) adamapsClient.updateConfig(s.adamapsApiUrl, s.adamapsApiKey)
} }
@ -187,13 +187,7 @@ class ForwardingService : LifecycleService() {
consecutiveFailures = 0 consecutiveFailures = 0
currentBackoffMs = MIN_BACKOFF_MS currentBackoffMs = MIN_BACKOFF_MS
_lastError.value = null _lastError.value = null
_beeStatus.value = "Connected"
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()
@ -219,13 +213,15 @@ class ForwardingService : LifecycleService() {
MAX_BACKOFF_MS MAX_BACKOFF_MS
) )
// Set clean offline status instead of ugly error // Clean offline status
if (result.message == "Bee offline") { _beeStatus.value = "Bee offline (retry in ${currentBackoffMs / 1000}s)"
_beeStatus.value = "Bee offline (retry in ${currentBackoffMs / 1000}s)" // Don't show red error banner for connection timeouts
_lastError.value = null // Don't show red error for expected offline state if (!result.message.contains("timeout", ignoreCase = true) &&
} else { !result.message.contains("connect", ignoreCase = true) &&
_beeStatus.value = "Connection error" !result.message.contains("refused", ignoreCase = true)) {
_lastError.value = result.message _lastError.value = result.message
} else {
_lastError.value = null
} }
} }
} }

View file

@ -5,7 +5,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.material.icons.filled.Check
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -32,7 +32,6 @@ 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()) }
@ -64,7 +63,7 @@ fun SettingsScreen(
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { 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), colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface),
@ -73,7 +72,6 @@ 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,
@ -98,23 +96,15 @@ fun SettingsScreen(
) { ) {
SettingsSection("BEE DEVICE") { SettingsSection("BEE DEVICE") {
SettingsField( SettingsField(
label = "Bee API URL (Primary)", label = "Bee API URL",
value = beeApiUrl, value = beeApiUrl,
onValueChange = { beeApiUrl = it }, onValueChange = { beeApiUrl = it },
hint = "http://192.168.0.10:5000" hint = "http://192.168.0.10:5001"
)
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)) Spacer(Modifier.height(8.dp))
Text( Text(
"Primary = Bee's own AP (192.168.0.10)\n" + "Port 5001 = socat proxy to odc-api\n" +
"Alt = Home WiFi IP when Bee is docked\n" + "Must run proxy setup on Bee first",
"App tries both automatically",
color = Color.Gray, color = Color.Gray,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 10.sp, fontSize = 10.sp,

View file

@ -74,7 +74,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
} }
private fun applySettings(s: VarroaSettings) { private fun applySettings(s: VarroaSettings) {
beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl) beeClient.updateBaseUrl(s.beeApiUrl)
} }
private fun startPolling(s: VarroaSettings) { private fun startPolling(s: VarroaSettings) {
@ -122,9 +122,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
} }
is ApiResult.Error -> { is ApiResult.Error -> {
// Clear camera bytes when Bee is offline // Clear camera bytes when Bee is offline
if (result.message == "Bee offline") { _cameraBytes.value = null
_cameraBytes.value = null
}
} }
} }
delay(intervalMs) delay(intervalMs)