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 e28234c7b9
commit 3cac92fbfd
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.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<String> = withContext(Dispatchers.IO) {
val baseUrl = if (useActiveUrl && activeUrl != null) activeUrl!! else primaryUrl
private suspend fun getRaw(path: String): ApiResult<String> = 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<ByteArray> = 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<List<BeeDetection>> = withContext(Dispatchers.IO) {
// If no active URL, try discovery first
if (activeUrl == null) {
if (discoverActiveUrl() == null) {
isConnected = false
return@withContext ApiResult.Error("Bee offline")
}
}
when (val r = getRaw("/api/1/landmarks/last/200")) {
is ApiResult.Success -> try {
val type = object : TypeToken<List<BeeDetection>>() {}.type
@ -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<GnssData> = withContext(Dispatchers.IO) {
if (activeUrl == null && discoverActiveUrl() == null) {
return@withContext ApiResult.Error("Bee offline")
}
when (val r = getRaw("/api/1/gnssConcise/latestValid")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, GnssData::class.java))
@ -239,10 +123,6 @@ class BeeApiClient(
}
suspend fun getDeviceInfo(): ApiResult<BeeDeviceInfo> = withContext(Dispatchers.IO) {
if (activeUrl == null && discoverActiveUrl() == null) {
return@withContext ApiResult.Error("Bee offline")
}
when (val r = getRaw("/api/1/info")) {
is ApiResult.Success -> try {
ApiResult.Success(gson.fromJson(r.data, BeeDeviceInfo::class.java))
@ -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<ByteArray> {
if (activeUrl == null && discoverActiveUrl() == null) {
return ApiResult.Error("Bee offline")
}
return getBytes(endpoint)
}
suspend fun getCameraFrame(endpoint: String): ApiResult<ByteArray> = getBytes(endpoint)
/**
* Try multiple camera endpoints in order, return first success.
*/
suspend fun getCameraFrameAuto(configured: String): Pair<String, ApiResult<ByteArray>> {
if (activeUrl == null && discoverActiveUrl() == null) {
return configured to ApiResult.Error("Bee offline")
}
val candidates = listOf(
configured,
"/api/1/camera/frame",
@ -285,4 +155,6 @@ class BeeApiClient(
}
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")
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 = "mapnet-ingest-2026",
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<VarroaSettings> = context.dataStore.data.map { prefs ->
VarroaSettings(
beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000",
beeAltApiUrl = prefs[KEY_BEE_ALT_URL] ?: "http://192.168.0.155:5000",
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] ?: "mapnet-ingest-2026",
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

View file

@ -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
}
}
}

View file

@ -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,

View file

@ -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)