diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fb5e48c..5b59701 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 = 4 - versionName = "1.3.0" + versionCode = 5 + versionName = "1.4.0" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt index 3993845..928e81d 100644 --- a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt @@ -5,18 +5,41 @@ import com.adamaps.varroa.data.ApiResult import com.google.gson.Gson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.Dns import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.net.InetAddress import java.util.concurrent.TimeUnit +/** + * Custom DNS resolver that hardcodes api.adamaps.org to bypass Android's DNS + * when connected to Bee's AP (which has no upstream DNS server). + */ +private object AdaMapsDns : Dns { + // api.adamaps.org resolves to this IP + private val ADAMAPS_IP = "142.44.213.229" + + override fun lookup(hostname: String): List { + return if (hostname.equals("api.adamaps.org", ignoreCase = true)) { + // Return hardcoded IP to bypass DNS lookup + listOf(InetAddress.getByName(ADAMAPS_IP)) + } else { + // Fall back to system DNS for other hosts + Dns.SYSTEM.lookup(hostname) + } + } +} + class AdaMapsApiClient( private var apiUrl: String = "https://api.adamaps.org", private var apiKey: String = "mapnet-ingest-2026" ) { + // Use custom DNS resolver to handle Bee AP's lack of upstream DNS private val client = OkHttpClient.Builder() + .dns(AdaMapsDns) .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) 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 10d2b2a..ec68860 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -11,28 +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:5001" + 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() + // Track which URL is currently active (null = unknown/offline) + private var activeUrl: String? = null + // Connection state var isConnected: Boolean = false private set - fun updateBaseUrl(url: String) { - baseUrl = url.trimEnd('/') + 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 { @@ -44,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() + } + @Suppress("DEPRECATION") private fun getWifiNetwork(context: Context): Network? { return try { @@ -58,25 +79,27 @@ 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) { 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 -> @@ -89,12 +112,95 @@ 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 @@ -105,13 +211,24 @@ class BeeApiClient( ApiResult.Error("Parse error: ${e.message}") } is ApiResult.Error -> { - isConnected = false + // 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)) @@ -123,6 +240,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)) @@ -135,13 +256,23 @@ 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", @@ -155,6 +286,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 db2de15..af587a8 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -3,6 +3,7 @@ package com.adamaps.varroa.data import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -13,44 +14,52 @@ 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:5001", + 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, + val cameraEndpoint: String = "/api/1/camera/frame", val cameraRefreshSeconds: Int = 30, - val cameraEndpoint: String = "/api/1/camera/frame" + val forwardingEnabled: Boolean = true ) 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") - private val KEY_CAMERA_REFRESH = intPreferencesKey("camera_refresh_seconds") private val KEY_CAMERA_ENDPOINT = stringPreferencesKey("camera_endpoint") + private val KEY_CAMERA_REFRESH = intPreferencesKey("camera_refresh_seconds") + private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled") } val settings: Flow = context.dataStore.data.map { prefs -> VarroaSettings( - beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5001", + 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, + cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame", cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, - cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame" + forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true ) } 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 - prefs[KEY_CAMERA_REFRESH] = s.cameraRefreshSeconds prefs[KEY_CAMERA_ENDPOINT] = s.cameraEndpoint + prefs[KEY_CAMERA_REFRESH] = s.cameraRefreshSeconds + prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled } } } 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 107ad5f..ee67494 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.updateBaseUrl(s.beeApiUrl) + beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl) adamapsClient.updateConfig(s.adamapsApiUrl, s.adamapsApiKey) } @@ -187,7 +187,14 @@ class ForwardingService : LifecycleService() { consecutiveFailures = 0 currentBackoffMs = MIN_BACKOFF_MS _lastError.value = null - _beeStatus.value = "Connected" + + // Show which IP we're connected to + 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() @@ -213,15 +220,19 @@ class ForwardingService : LifecycleService() { MAX_BACKOFF_MS ) - // 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 { + // 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 if (result.message.contains("timeout", ignoreCase = true) || + result.message.contains("connect", ignoreCase = true) || + result.message.contains("refused", ignoreCase = true) || + result.message.contains("unreachable", ignoreCase = true)) { + _beeStatus.value = "Bee offline (retry in ${currentBackoffMs / 1000}s)" _lastError.value = null + } else { + _beeStatus.value = "Connection error" + _lastError.value = result.message } } } 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 adab5d5..8b7a132 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,15 +98,23 @@ 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:5001" + 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( - "Port 5001 = socat proxy to odc-api\n" + - "Must run proxy setup on Bee first", + "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, 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 9ad8b19..b28e83b 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,8 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) { } private fun applySettings(s: VarroaSettings) { - beeClient.updateBaseUrl(s.beeApiUrl) + beeClient.updateUrls(s.beeApiUrl, s.beeAltApiUrl) + beeClient.bindToWifiNetwork(getApplication()) } private fun startPolling(s: VarroaSettings) {