diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml new file mode 100644 index 0000000..10d7847 --- /dev/null +++ b/.forgejo/workflows/gitleaks.yml @@ -0,0 +1,40 @@ +# .forgejo/workflows/gitleaks.yml +# +# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at +# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered +# (task #295). +# +# Pairs with the pre-receive hook installed on every bare repo — that one is +# the strict enforcement layer (rejects the push); this one provides the +# per-PR red ✗ that branch-protection rules can require before merge. +# +# Layer 1 (this workflow): visible per-PR status, can be a required check. +# Layer 2 (pre-receive hook): strict enforcement at the server. +# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos. + +name: gitleaks + +on: + push: + pull_request: + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full history — gitleaks needs depth to scan a commit range. + fetch-depth: 0 + + - name: install gitleaks + run: | + curl -sSL -o gl.tar.gz \ + https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz + tar xzf gl.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + + - name: scan + run: | + ./gitleaks detect --source . --no-banner --redact --verbose diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce62f5e --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# varroa + +Android companion for the Hivemapper Bee dashcam. Pulls detection +landmarks off the on-Bee `adacam-api`, queues them in a local Room +DB, forwards to AdaMaps when the phone has real internet. + +Sister piece: `blackbox/` — Python aggregator that runs on a truck +Pi (BME680 + PMS5003) and ships air-quality readings into the same +AdaMaps stream. + +## Build + +``` +JDK 17, Android SDK 34 +./gradlew :app:assembleDebug +``` + +Release signing needs: + +``` +VARROA_KEYSTORE_PATH=/path/to/varroa-release.keystore +VARROA_KEYSTORE_PASSWORD= +VARROA_KEY_PASSWORD= +./gradlew :app:assembleRelease +``` + +## Config (set in-app) + +- **Bee URL** — defaults to `http://192.168.0.10:5000` (Bee AP). +- **AdaMaps URL + ingest key** — required before uploads run. +- **Cardano wallet** — optional. Attaches to detection ingest for + rewards routing. + +## Architecture + +- `BeeCollectorService` — polls the Bee, writes landmarks to Room. +- `AdaMapsUploadWorker` — drains Room to `api.adamaps.org` once + validated internet is available. +- `ImageCollectorService` — pulls detection JPEGs from the Bee. + +## blackbox + +The air-quality side. `air_aggregator.py` reads BME680 + +PMS5003 over USB, posts to AdaMaps every 60s. Systemd unit at +`blackbox/air-aggregator.service` — set `ADAMAPS_KEY` and +`AGGREGATOR_BEE_URL` in the unit before enabling. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 01f4169..20dbc60 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,12 +9,28 @@ android { namespace = "com.adamaps.varroa" compileSdk = 34 + signingConfigs { + create("release") { + // Set VARROA_KEYSTORE_PATH / VARROA_KEYSTORE_PASSWORD / VARROA_KEY_PASSWORD + // before assembleRelease — see vault item "Varroa — release keystore". + val ksPath = System.getenv("VARROA_KEYSTORE_PATH") + val ksPass = System.getenv("VARROA_KEYSTORE_PASSWORD") + val keyPass = System.getenv("VARROA_KEY_PASSWORD") ?: ksPass + if (ksPath != null && ksPass != null) { + storeFile = file(ksPath) + storePassword = ksPass + keyAlias = "varroa-release" + keyPassword = keyPass + } + } + } + defaultConfig { applicationId = "com.adamaps.varroa" minSdk = 26 targetSdk = 34 - versionCode = 14 - versionName = "1.7.9" + versionCode = 15 + versionName = "1.8.0" vectorDrawables { useSupportLibrary = true @@ -24,6 +40,7 @@ android { buildTypes { release { isMinifyEnabled = false + signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -70,17 +87,9 @@ dependencies { implementation(libs.osmdroid.android) implementation(libs.datastore.preferences) implementation(libs.coil.compose) - // Room (local database) implementation(libs.room.runtime) implementation(libs.room.ktx) ksp(libs.room.compiler) - // WorkManager (background uploads) implementation(libs.work.runtime.ktx) - // QR Code scanning - implementation("com.google.zxing:core:3.5.2") - implementation("com.journeyapps:zxing-android-embedded:4.3.0") - implementation("androidx.camera:camera-camera2:1.3.0") - implementation("androidx.camera:camera-lifecycle:1.3.0") - implementation("androidx.camera:camera-view:1.3.0") debugImplementation(libs.androidx.ui.tooling) } diff --git a/app/src/main/java/com/adamaps/varroa/Navigation.kt b/app/src/main/java/com/adamaps/varroa/Navigation.kt index e23ca05..9896951 100644 --- a/app/src/main/java/com/adamaps/varroa/Navigation.kt +++ b/app/src/main/java/com/adamaps/varroa/Navigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.adamaps.varroa.ui.dashboard.DashboardScreen +import com.adamaps.varroa.ui.settings.BeeSettingsScreen import com.adamaps.varroa.ui.settings.DeviceStatusScreen import com.adamaps.varroa.ui.settings.SettingsScreen @@ -12,6 +13,7 @@ object Routes { const val DASHBOARD = "dashboard" const val SETTINGS = "settings" const val DEVICE_STATUS = "device_status" + const val BEE_SETTINGS = "bee_settings" } @Composable @@ -19,7 +21,10 @@ fun VarroaNavGraph() { val nav = rememberNavController() NavHost(navController = nav, startDestination = Routes.DASHBOARD) { composable(Routes.DASHBOARD) { - DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }) + DashboardScreen( + onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }, + onNavigateToBeeSettings = { nav.navigate(Routes.BEE_SETTINGS) } + ) } composable(Routes.SETTINGS) { SettingsScreen( @@ -30,5 +35,8 @@ fun VarroaNavGraph() { composable(Routes.DEVICE_STATUS) { DeviceStatusScreen(onBack = { nav.popBackStack() }) } + composable(Routes.BEE_SETTINGS) { + BeeSettingsScreen(onBack = { nav.popBackStack() }) + } } } 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 8fc65f9..39a8410 100644 --- a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt @@ -38,7 +38,7 @@ private object AdaMapsDns : Dns { class AdaMapsApiClient( private var apiUrl: String = "https://api.adamaps.org", - private var apiKey: String = "***REMOVED***" + private var apiKey: String = "" ) { companion object { private const val TAG = "VarroaAdaAPI" @@ -57,7 +57,7 @@ class AdaMapsApiClient( fun updateConfig(url: String, key: String) { val oldUrl = apiUrl - val oldKeyPrefix = apiKey.take(8) + val oldKeyPrefix = apiKey.length apiUrl = url.trimEnd('/') apiKey = key Log.d(TAG, "AdaMaps config updated - URL: $oldUrl -> $apiUrl, Key: ${oldKeyPrefix}... -> ${key.take(8)}...") @@ -80,7 +80,7 @@ class AdaMapsApiClient( .post(body) .build() - Log.d(TAG, "Sending POST request with key: ${apiKey.take(8)}...") + Log.d(TAG, "Sending POST request with key: ${apiKey.length}...") client.newCall(req).execute().use { resp -> val respBody = resp.body?.string() ?: "" Log.d(TAG, "HTTP ${resp.code} ${resp.message} - response length: ${respBody.length}") 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 26b3d81..09a370a 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -6,15 +6,22 @@ import android.net.Network import android.net.NetworkCapabilities import android.util.Log import com.adamaps.varroa.data.ApiResult +import com.adamaps.varroa.data.BeeConfig import com.adamaps.varroa.data.BeeDetection import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeePlugin +import com.adamaps.varroa.data.CacheStatus +import com.adamaps.varroa.data.FrameKmTotal import com.adamaps.varroa.data.GnssData import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.PairResponse +import com.adamaps.varroa.data.PluginState import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.StorageStatus +import com.adamaps.varroa.data.UploadModeResponse +import com.adamaps.varroa.data.WifiClientSettings import com.adamaps.varroa.data.WifiConfig +import com.adamaps.varroa.data.WifiNetwork import com.adamaps.varroa.data.WifiStatus import com.google.gson.Gson import com.google.gson.reflect.TypeToken @@ -23,10 +30,11 @@ import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.TimeUnit class BeeApiClient( - private var apiUrl: String = "http://10.77.0.1:5000" + private var apiUrl: String = "http://192.168.0.10:5000" ) { companion object { private const val TAG = "VarroaBeeAPI" @@ -178,6 +186,35 @@ class BeeApiClient( } } + // ── POST helper ─────────────────────────────────────────────────────────── + + private suspend fun postRaw(path: String, json: String, authenticated: Boolean = true): ApiResult = withContext(Dispatchers.IO) { + val fullUrl = "$apiUrl$path" + Log.d(TAG, "HTTP POST $fullUrl body=$json") + try { + val body = json.toRequestBody("application/json".toMediaType()) + val reqBuilder = Request.Builder().url(fullUrl).post(body) + if (authenticated && apiToken.isNotBlank()) { + reqBuilder.addHeader("Authorization", "Bearer $apiToken") + } + val req = reqBuilder.build() + client.newCall(req).execute().use { resp -> + val respBody = resp.body?.string() ?: "" + if (resp.isSuccessful) { + isConnected = true + Log.d(TAG, "POST ${resp.code} OK") + ApiResult.Success(respBody) + } else { + Log.w(TAG, "POST ${resp.code} ${resp.message}") + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } + } catch (e: Exception) { + Log.e(TAG, "POST failed to $fullUrl", e) + ApiResult.Error(e.message ?: "Unknown error") + } + } + /** * Check if AdaCam is reachable. * Updates internal connection state. @@ -245,7 +282,6 @@ class BeeApiClient( } is ApiResult.Error -> { Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})") - // Connection failed, mark as offline if (r.message.contains("timeout", ignoreCase = true) || r.message.contains("connect", ignoreCase = true) || r.message.contains("refused", ignoreCase = true) || @@ -304,29 +340,60 @@ class BeeApiClient( } } - suspend fun setWifiConfig(ssid: String, password: String): ApiResult = withContext(Dispatchers.IO) { - try { - val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password)) - val requestBody = okhttp3.RequestBody.create( - "application/json".toMediaType(), - jsonBody - ) - val request = Request.Builder() - .url("$apiUrl/api/1/wifi/connect") - .addHeader("Authorization", "Bearer $apiToken") - .post(requestBody) - .build() + suspend fun setWifiConfig(ssid: String, password: String): ApiResult { + val json = gson.toJson(mapOf("ssid" to ssid, "password" to password)) + return postRaw("/api/1/wifi/connect", json) + } - client.newCall(request).execute().use { resp -> - val body = resp.body?.string() ?: "" - if (resp.isSuccessful) { - ApiResult.Success(body) - } else { - ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) - } + // WiFi Client methods (legacy API) + suspend fun getWifiClientStatus(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifiClient/status")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WifiStatus::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error") + is ApiResult.Error -> r + } + } + + suspend fun getWifiSettings(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifiClient/settings")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, WifiClientSettings::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun saveWifiSettings(settings: WifiClientSettings): ApiResult { + val json = gson.toJson(settings) + return postRaw("/api/1/wifiClient/settings", json) + } + + suspend fun setWifiEnabled(enabled: Boolean): ApiResult { + val json = """{"enabled": $enabled}""" + return postRaw("/api/1/wifiClient/enable", json) + } + + suspend fun scanWifi(): ApiResult> = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifiClient/scan")) { + is ApiResult.Success -> try { + val type = object : TypeToken>() {}.type + ApiResult.Success(gson.fromJson(r.data, type)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun resetWifi(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifiClient/reset")) { + is ApiResult.Success -> r + is ApiResult.Error -> r } } @@ -343,30 +410,14 @@ class BeeApiClient( } } - suspend fun setSshEnabled(enabled: Boolean): ApiResult = withContext(Dispatchers.IO) { - try { - val jsonBody = gson.toJson(mapOf("enable" to enabled)) - val requestBody = okhttp3.RequestBody.create( - "application/json".toMediaType(), - jsonBody - ) - val request = Request.Builder() - .url("$apiUrl/api/1/ssh/toggle") - .addHeader("Authorization", "Bearer $apiToken") - .post(requestBody) - .build() + suspend fun setSshEnabled(enabled: Boolean): ApiResult { + val json = gson.toJson(mapOf("enable" to enabled)) + return postRaw("/api/1/ssh/toggle", json) + } - client.newCall(request).execute().use { resp -> - val body = resp.body?.string() ?: "" - if (resp.isSuccessful) { - ApiResult.Success(body) - } else { - ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) - } - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error") - } + // SSH-based device ID retrieval (stub - not implemented) + suspend fun getDeviceIdViaSsh(): ApiResult { + return ApiResult.Error("SSH device ID lookup not implemented") } // ── Storage & GNSS Status ───────────────────────────────────────────────── @@ -405,32 +456,95 @@ class BeeApiClient( } } - suspend fun setUploadMode(mode: String): ApiResult = withContext(Dispatchers.IO) { - try { - val jsonBody = gson.toJson(mapOf("mode" to mode)) - val requestBody = okhttp3.RequestBody.create( - "application/json".toMediaType(), - jsonBody - ) - val request = Request.Builder() - .url("$apiUrl/api/1/config/uploadMode") - .addHeader("Authorization", "Bearer $apiToken") - .post(requestBody) - .build() + // ── Upload Mode ─────────────────────────────────────────────────────────── - client.newCall(request).execute().use { resp -> - val body = resp.body?.string() ?: "" - if (resp.isSuccessful) { - ApiResult.Success(body) + suspend fun getUploadMode(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/config/uploadMode")) { + is ApiResult.Success -> try { + val trimmed = r.data.trim().trim('"') + if (trimmed.startsWith("{")) { + val obj = gson.fromJson(r.data, UploadModeResponse::class.java) + ApiResult.Success(obj.currentMode() ?: "UNKNOWN") } else { - ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + ApiResult.Success(trimmed) } + } catch (e: Exception) { + ApiResult.Success(r.data.trim().trim('"')) } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error") + is ApiResult.Error -> r } } + suspend fun setUploadMode(mode: String): ApiResult { + val json = """{"mode": "$mode"}""" + return postRaw("/api/1/config/uploadMode", json) + } + + // ── Config ──────────────────────────────────────────────────────────────── + + suspend fun getConfig(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/config/")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, BeeConfig::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + // ── Cache / Storage ─────────────────────────────────────────────────────── + + suspend fun getCacheStatus(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/cache/status")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, CacheStatus::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun getFrameKmTotal(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/framekm/total")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, FrameKmTotal::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + // ── Plugin State ────────────────────────────────────────────────────────── + + suspend fun getPluginState(pluginName: String): PluginState = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/plugin/getPluginState/$pluginName")) { + is ApiResult.Success -> try { + val trimmed = r.data.trim() + val enabled = when { + trimmed == "true" -> true + trimmed == "false" -> false + trimmed.startsWith("{") -> { + val obj = gson.fromJson(trimmed, com.google.gson.JsonObject::class.java) + obj.get("enabled")?.asBoolean ?: obj.get("state")?.asString == "enabled" + } + else -> null + } + PluginState(pluginName, enabled) + } catch (e: Exception) { + PluginState(pluginName, null, e.message) + } + is ApiResult.Error -> PluginState(pluginName, null, r.message) + } + } + + suspend fun getKnownPluginStates(): List = withContext(Dispatchers.IO) { + val knownPlugins = listOf("beekeeper", "depth-ai", "privacy-zones", "map-ai") + knownPlugins.map { name -> getPluginState(name) } + } + // ── Camera API ──────────────────────────────────────────────────────────── /** @@ -517,7 +631,6 @@ class BeeApiClient( } is ApiResult.Error -> { Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}") - // Continue trying other endpoints } } } @@ -529,10 +642,7 @@ class BeeApiClient( private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List): ApiResult = withContext(Dispatchers.IO) { try { val jsonBody = gson.toJson(mapOf("ids" to landmarkIds)) - val requestBody = okhttp3.RequestBody.create( - "application/json".toMediaType(), - jsonBody - ) + val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) // Try both DELETE and POST methods for (method in listOf("DELETE", "POST")) { @@ -540,7 +650,9 @@ class BeeApiClient( val requestBuilder = Request.Builder() .url("$apiUrl$endpoint") - .addHeader("Authorization", "Bearer $apiToken") + if (apiToken.isNotBlank()) { + requestBuilder.addHeader("Authorization", "Bearer $apiToken") + } val request = if (method == "DELETE") { requestBuilder.delete(requestBody).build() @@ -565,4 +677,4 @@ class BeeApiClient( return@withContext ApiResult.Error("Exception: ${e.message}") } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/adamaps/varroa/data/Models.kt b/app/src/main/java/com/adamaps/varroa/data/Models.kt index 7106429..18faff5 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -47,21 +47,6 @@ data class BeeDeviceInfo( @SerializedName("ssid") val ssid: String? = null ) -// ── Pairing API ─────────────────────────────────────────────────────────────── - -data class PairResponse( - @SerializedName("serial") val serial: String, - @SerializedName("version") val version: String, - @SerializedName("ap_ip") val apIp: String, - @SerializedName("api_port") val apiPort: Int -) - -// ── SSH API ─────────────────────────────────────────────────────────────────── - -data class SshStatus( - @SerializedName("active") val active: Boolean -) - // ── ADAMaps ingest ──────────────────────────────────────────────────────────── data class AdaMapsDetection( @@ -108,15 +93,7 @@ data class WifiConfig( @SerializedName("ssid") val ssid: String? = null, @SerializedName("password") val password: String? = null, @SerializedName("connected") val connected: Boolean? = null, - @SerializedName("ip") val ip: String? = null, - @SerializedName("state") val state: String? = null -) - -data class WifiStatus( - @SerializedName("ssid") val ssid: String? = null, - @SerializedName("ip") val ip: String? = null, - @SerializedName("state") val state: String? = null, - @SerializedName("connected") val connected: Boolean? = null + @SerializedName("ip") val ip: String? = null ) data class StorageStatus( @@ -127,7 +104,94 @@ data class StorageStatus( @SerializedName("recording_hours_available") val recordingHoursAvailable: Double? = null ) +data class BeePlugin( + @SerializedName("name") val name: String? = null, + @SerializedName("version") val version: String? = null, + @SerializedName("enabled") val enabled: Boolean? = null, + @SerializedName("running") val running: Boolean? = null +) + +// ── Pairing API ─────────────────────────────────────────────────────────────── + +data class PairResponse( + @SerializedName("serial") val serial: String, + @SerializedName("version") val version: String, + @SerializedName("ap_ip") val apIp: String, + @SerializedName("api_port") val apiPort: Int +) + +// ── SSH API ─────────────────────────────────────────────────────────────────── + +data class SshStatus( + @SerializedName("active") val active: Boolean +) + +// ── Bee Settings API models ─────────────────────────────────────────────────── + +data class WifiClientSettings( + @SerializedName("ssid") val ssid: String? = null, + @SerializedName("password") val password: String? = null, + @SerializedName("enabled") val enabled: Boolean? = null, + @SerializedName("security") val security: String? = null, + @SerializedName("freq") val freq: Int? = null +) + +data class WifiStatus( + @SerializedName("connected") val connected: Boolean? = null, + @SerializedName("ssid") val ssid: String? = null, + @SerializedName("ip") val ip: String? = null, + @SerializedName("signal") val signal: Int? = null, + @SerializedName("state") val state: String? = null +) + +data class WifiNetwork( + @SerializedName("ssid") val ssid: String? = null, + @SerializedName("signal") val signal: Int? = null, + @SerializedName("security") val security: String? = null, + @SerializedName("freq") val freq: Int? = null +) + +data class CacheStatus( + @SerializedName("enabled") val enabled: Boolean? = null, + @SerializedName("size") val size: Long? = null, + @SerializedName("samples") val samples: Int? = null, + @SerializedName("sizeBytes") val sizeBytes: Long? = null, + @SerializedName("numSamples") val numSamples: Int? = null +) { + // Normalize field names across firmware versions + fun displaySize(): Long = size ?: sizeBytes ?: 0L + fun displaySamples(): Int = samples ?: numSamples ?: 0 +} + +data class BeeConfig( + @SerializedName("uploadMode") val uploadMode: String? = null, + @SerializedName("pluginsLocked") val pluginsLocked: Boolean? = null, + @SerializedName("pluginDevMode") val pluginDevMode: Boolean? = null, + @SerializedName("pausePluginUpdates") val pausePluginUpdates: Boolean? = null, + @SerializedName("isProcessingEnabled") val isProcessingEnabled: Boolean? = null, + @SerializedName("isUSBRecordingEnabled") val isUSBRecordingEnabled: Boolean? = null, + @SerializedName("isLowPowerModeEnabled") val isLowPowerModeEnabled: Boolean? = null +) + +data class UploadModeResponse( + @SerializedName("mode") val mode: String? = null, + @SerializedName("uploadMode") val uploadMode: String? = null +) { + fun currentMode(): String? = mode ?: uploadMode +} + data class GnssStatus( + // v7.7+ API fields + @SerializedName("lat_deg") val latDeg: Double? = null, + @SerializedName("lon_deg") val lonDeg: Double? = null, + @SerializedName("alt_m") val altM: Double? = null, + @SerializedName("unix_milliseconds") val unixMs: Long? = null, + @SerializedName("fix") val fix: Boolean? = null, + @SerializedName("fixType") val fixType: String? = null, + @SerializedName("satellites_used") val satellitesUsed: Int? = null, + @SerializedName("accuracy_m") val accuracyM: Double? = null, + @SerializedName("speed_m_s") val speedMs: Double? = null, + // Legacy/status API fields @SerializedName("has_lock") val hasLock: Boolean? = null, @SerializedName("satellites") val satellites: Int? = null, @SerializedName("hdop") val hdop: Double? = null, @@ -138,11 +202,16 @@ data class GnssStatus( @SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = null ) -data class BeePlugin( - @SerializedName("name") val name: String? = null, - @SerializedName("version") val version: String? = null, - @SerializedName("enabled") val enabled: Boolean? = null, - @SerializedName("running") val running: Boolean? = null +data class PluginState( + val name: String, + val enabled: Boolean?, + val error: String? = null +) + +data class FrameKmTotal( + @SerializedName("total") val total: Long? = null, + @SerializedName("totalBytes") val totalBytes: Long? = null, + @SerializedName("count") val count: Int? = null ) // ── App state ───────────────────────────────────────────────────────────────── 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 44a352c..6d9adcc 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -15,9 +15,9 @@ import java.security.MessageDigest private val Context.dataStore: DataStore by preferencesDataStore(name = "varroa_settings") data class VarroaSettings( - val beeApiUrl: String = "http://10.77.0.1:5000", + val beeApiUrl: String = "http://192.168.0.10:5000", val adamapsApiUrl: String = "https://api.adamaps.org", - val adamapsApiKey: String = "***REMOVED***", + val adamapsApiKey: String = "", val pollIntervalSeconds: Int = 30, val cameraEndpoint: String = "/api/1/camera/frame", val cameraRefreshSeconds: Int = 30, @@ -60,9 +60,9 @@ class SettingsDataStore(private val context: Context) { val settings: Flow = context.dataStore.data.map { prefs -> VarroaSettings( - beeApiUrl = prefs[KEY_BEE_URL] ?: "http://10.77.0.1:5000", + beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", - adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***", + adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "", pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame", cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, diff --git a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt index 9708f7d..af427a0 100644 --- a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt +++ b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt @@ -202,36 +202,17 @@ class BeeCollectorService : LifecycleService() { settingsStore.updateCachedDeviceId(deviceId) Log.i(TAG, "Device ID retrieved via API: $deviceId (from ${if (result.data.deviceId != null) "deviceId" else if (result.data.serial != null) "serial" else "fallback"})") } else { - Log.w(TAG, "API returned unknown device ID, trying SSH fallback...") - fetchDeviceIdViaSsh() + Log.w(TAG, "Device ID unknown from API, using cached or unknown") } } is ApiResult.Error -> { Log.e(TAG, "Failed to get device ID via API: ${result.message}, code: ${result.code}") - Log.i(TAG, "Trying SSH fallback...") - fetchDeviceIdViaSsh() - } - } - } - - private suspend fun fetchDeviceIdViaSsh() { - Log.d(TAG, "Attempting SSH fallback for device ID...") - when (val result = beeClient.getDeviceIdViaSsh()) { - is ApiResult.Success -> { - val deviceId = result.data - _currentDeviceId.value = deviceId - // Update persistent cache - settingsStore.updateCachedDeviceId(deviceId) - Log.i(TAG, "Device ID retrieved via SSH: $deviceId") - } - is ApiResult.Error -> { - Log.e(TAG, "Failed to get device ID via SSH: ${result.message}") _currentDeviceId.value = "unknown" } } } - private fun startPollLoop(intervalSeconds: Int) { + pollJob?.cancel() Log.d(TAG, "Previous poll job cancelled") pollJob = lifecycleScope.launch { diff --git a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt index 0a5fb53..0b91b28 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt @@ -39,7 +39,8 @@ import org.osmdroid.views.overlay.Marker @Composable fun DashboardScreen( vm: DashboardViewModel = viewModel(), - onNavigateToSettings: () -> Unit + onNavigateToSettings: () -> Unit, + onNavigateToBeeSettings: () -> Unit = {} ) { val deviceInfo by vm.deviceInfo.collectAsState() val gnss by vm.gnss.collectAsState() @@ -101,8 +102,11 @@ fun DashboardScreen( Spacer(Modifier.width(8.dp)) } + IconButton(onClick = onNavigateToBeeSettings) { + Icon(Icons.Default.Router, contentDescription = "AdaCam Settings", tint = Amber) + } IconButton(onClick = onNavigateToSettings) { - Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber) + Icon(Icons.Default.Settings, contentDescription = "App Settings", tint = Amber) } } ) @@ -150,7 +154,7 @@ fun DashboardScreen( gnss?.let { GpsMapCard(it) } // Device status - deviceInfo?.let { DeviceStatusCard(it) } + deviceInfo?.let { DeviceStatusCard(it, onNavigateToBeeSettings) } } } } @@ -582,14 +586,32 @@ private fun OsmMapView(gnss: GnssData) { } @Composable -private fun DeviceStatusCard(info: BeeDeviceInfo) { +private fun DeviceStatusCard(info: BeeDeviceInfo, onNavigateToBeeSettings: () -> Unit = {}) { Card( colors = CardDefaults.cardColors(containerColor = Surface), shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(14.dp)) { - SectionHeader("DEVICE STATUS") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SectionHeader("DEVICE STATUS") + Text( + "CONFIGURE ›", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 9.sp, + letterSpacing = 1.sp, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { onNavigateToBeeSettings() } + .background(AmberDark.copy(alpha = 0.2f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } Spacer(Modifier.height(8.dp)) val rows: List> = listOf( "Firmware" to (info.firmwareVersion ?: "—"), diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt new file mode 100644 index 0000000..1a4533e --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt @@ -0,0 +1,972 @@ +package com.adamaps.varroa.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.adamaps.varroa.data.* +import com.adamaps.varroa.ui.theme.* +import com.adamaps.varroa.viewmodel.BeeSettingsLoadState +import com.adamaps.varroa.viewmodel.BeeSettingsViewModel +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BeeSettingsScreen( + vm: BeeSettingsViewModel = viewModel(), + onBack: () -> Unit +) { + val state by vm.state.collectAsState() + val message by vm.message.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(message) { + message?.let { + snackbarHostState.showSnackbar(it) + vm.clearMessage() + } + } + + Scaffold( + containerColor = Background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + "BEE DEVICE", + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 3.sp + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Amber) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), + actions = { + IconButton(onClick = { vm.loadAll() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = Amber) + } + } + ) + } + ) { padding -> + when (val loadState = state.loadState) { + is BeeSettingsLoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = Amber) + Spacer(Modifier.height(16.dp)) + Text( + "Fetching Bee device info…", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } + + is BeeSettingsLoadState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(24.dp) + ) { + Icon( + Icons.Default.CloudOff, + contentDescription = null, + tint = Error, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.height(12.dp)) + Text( + "Bee Unreachable", + color = Error, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Spacer(Modifier.height(8.dp)) + Text( + loadState.message, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(Modifier.height(20.dp)) + Button( + onClick = { vm.loadAll() }, + colors = ButtonDefaults.buttonColors(containerColor = AmberDark) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("RETRY", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + } + } + } + } + + else -> { + // Success or Idle — show content + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Device Info + state.deviceInfo?.let { DeviceInfoSection(it) } + + // WiFi Client + WifiSection( + status = state.wifiStatus, + settings = state.wifiSettings, + networks = state.wifiNetworks, + scanState = state.wifiScanState, + saving = state.wifiSaving, + onSave = { ssid, pass, enabled -> vm.saveWifiSettings(ssid, pass, enabled) }, + onToggleEnabled = { vm.setWifiEnabled(it) }, + onScan = { vm.scanWifi() }, + onReset = { vm.resetWifi() } + ) + + // Storage + StorageSection( + cacheStatus = state.cacheStatus, + frameKmTotal = state.frameKmTotal + ) + + // GNSS + GnssSection(gnss = state.gnssStatus) + + // Upload Mode + UploadModeSection( + currentMode = state.uploadMode, + saving = state.uploadModeSaving, + onSetMode = { vm.setUploadMode(it) } + ) + + // Plugins + if (state.plugins.isNotEmpty()) { + PluginSection( + plugins = state.plugins, + config = state.config + ) + } + + Spacer(Modifier.height(24.dp)) + } + } + } + } +} + +// ── Device Info ─────────────────────────────────────────────────────────────── + +@Composable +private fun DeviceInfoSection(info: BeeDeviceInfo) { + BeeCard(title = "DEVICE INFO", icon = Icons.Default.Info) { + val rows = listOfNotNull( + info.deviceId?.let { "Device ID" to it }, + info.serial?.let { "Serial" to it }, + info.firmwareVersion?.let { "Firmware" to it }, + info.apiVersion?.let { "API Version" to it }, + info.model?.let { "Model" to it }, + info.imei?.let { "IMEI" to it }, + info.ssid?.let { "Connected SSID" to it }, + info.uptime?.let { "Uptime" to formatUptime(it) }, + "GPS Lock" to if (info.hasGnssLock == true) "YES" else "NO", + "Internet" to if (info.internetIsHealthy == true) "HEALTHY" else "OFFLINE" + ) + rows.forEach { (k, v) -> + InfoRow(k, v, valueColor = when { + k == "GPS Lock" && v == "YES" -> Success + k == "GPS Lock" && v == "NO" -> Error + k == "Internet" && v == "HEALTHY" -> Success + k == "Internet" && v == "OFFLINE" -> Error + else -> OnSurface + }) + } + } +} + +// ── WiFi ────────────────────────────────────────────────────────────────────── + +@Composable +private fun WifiSection( + status: WifiStatus?, + settings: WifiClientSettings?, + networks: List, + scanState: BeeSettingsLoadState, + saving: Boolean, + onSave: (String, String, Boolean) -> Unit, + onToggleEnabled: (Boolean) -> Unit, + onScan: () -> Unit, + onReset: () -> Unit +) { + var expandAddNetwork by remember { mutableStateOf(false) } + var ssidInput by remember { mutableStateOf(settings?.ssid ?: "") } + var passwordInput by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var enabledInput by remember(settings) { mutableStateOf(settings?.enabled ?: true) } + + // Update SSID input when settings load + LaunchedEffect(settings?.ssid) { + if (settings?.ssid != null && ssidInput.isEmpty()) { + ssidInput = settings.ssid + } + } + + BeeCard(title = "WIFI CLIENT", icon = Icons.Default.Wifi) { + // Connection status + val connected = status?.connected ?: false + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + StatusIndicator(connected) + Spacer(Modifier.width(8.dp)) + Column { + Text( + text = if (connected) "CONNECTED" else "DISCONNECTED", + color = if (connected) Success else Color.Gray, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + status?.ssid?.let { + Text(it, color = OnSurface, fontFamily = FontFamily.Monospace, fontSize = 11.sp) + } + status?.ip?.let { + Text("IP: $it", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + } + // Enable/disable toggle + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "CLIENT", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 9.sp, + letterSpacing = 1.sp + ) + Switch( + checked = enabledInput, + onCheckedChange = { + enabledInput = it + onToggleEnabled(it) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Amber, + checkedTrackColor = AmberDark, + uncheckedThumbColor = Color.Gray, + uncheckedTrackColor = SurfaceVariant + ) + ) + } + } + + Spacer(Modifier.height(8.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(8.dp)) + + // Current saved network + if (settings?.ssid != null) { + InfoRow("Saved SSID", settings.ssid) + settings.security?.let { InfoRow("Security", it) } + settings.freq?.let { InfoRow("Frequency", "${it} MHz (${if (it < 3000) "2.4GHz" else "5GHz"})") } + } + + Spacer(Modifier.height(10.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { expandAddNetwork = !expandAddNetwork }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Amber), + border = androidx.compose.foundation.BorderStroke(1.dp, AmberDark) + ) { + Icon( + if (expandAddNetwork) Icons.Default.ExpandLess else Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + if (expandAddNetwork) "CANCEL" else "CONFIGURE", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + } + + OutlinedButton( + onClick = onScan, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Amber), + border = androidx.compose.foundation.BorderStroke(1.dp, AmberDark), + enabled = scanState !is BeeSettingsLoadState.Loading + ) { + if (scanState is BeeSettingsLoadState.Loading) { + CircularProgressIndicator( + color = Amber, + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp + ) + } else { + Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(14.dp)) + } + Spacer(Modifier.width(4.dp)) + Text("SCAN", fontFamily = FontFamily.Monospace, fontSize = 10.sp, fontWeight = FontWeight.Bold) + } + } + + // Network config form (expandable) + AnimatedVisibility(visible = expandAddNetwork) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "CONFIGURE NETWORK", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + letterSpacing = 1.sp + ) + BeeTextField( + value = ssidInput, + onValueChange = { ssidInput = it }, + label = "SSID", + hint = "Network name" + ) + BeeTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + label = "Password", + hint = "Leave empty for open network", + visualTransformation = if (showPassword) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(18.dp) + ) + } + } + ) + + // Scan results picker + if (networks.isNotEmpty()) { + Text( + "SCAN RESULTS", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 9.sp, + letterSpacing = 1.sp + ) + networks.forEach { net -> + if (net.ssid != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { ssidInput = net.ssid } + .background(SurfaceVariant) + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Default.Wifi, contentDescription = null, tint = Amber, modifier = Modifier.size(14.dp)) + Text( + net.ssid, + color = OnSurface, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + net.security?.let { + Text(it, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) + } + net.signal?.let { + Text("${it}dBm", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) + } + } + } + } + } + } + + Button( + onClick = { + onSave(ssidInput, passwordInput, enabledInput) + expandAddNetwork = false + passwordInput = "" + }, + enabled = ssidInput.isNotBlank() && !saving, + colors = ButtonDefaults.buttonColors(containerColor = AmberDark), + modifier = Modifier.fillMaxWidth() + ) { + if (saving) { + CircularProgressIndicator(color = Amber, modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(6.dp)) + } + Text( + "SAVE & CONNECT", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ) + } + } + } + + // Reset button + Spacer(Modifier.height(4.dp)) + TextButton( + onClick = onReset, + modifier = Modifier.align(Alignment.End) + ) { + Icon(Icons.Default.RestartAlt, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("RESET WIFI", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } +} + +// ── Storage ─────────────────────────────────────────────────────────────────── + +@Composable +private fun StorageSection( + cacheStatus: CacheStatus?, + frameKmTotal: FrameKmTotal? +) { + BeeCard(title = "STORAGE", icon = Icons.Default.Storage) { + if (cacheStatus == null && frameKmTotal == null) { + Text( + "Storage info unavailable", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + return@BeeCard + } + + cacheStatus?.let { cache -> + Text( + "DATA CACHE", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(4.dp)) + InfoRow( + "Status", + if (cache.enabled == true) "ENABLED" else "DISABLED", + valueColor = if (cache.enabled == true) Success else Color.Gray + ) + InfoRow("Samples", cache.displaySamples().toString()) + InfoRow("Cache Size", formatBytes(cache.displaySize())) + Spacer(Modifier.height(8.dp)) + + // Storage bar + val sizeBytes = cache.displaySize() + val maxBytes = 500L * 1024 * 1024 // 500MB limit from docs + val fraction = (sizeBytes.toFloat() / maxBytes).coerceIn(0f, 1f) + StorageBar(fraction, label = "Cache: ${formatBytes(sizeBytes)} / 500 MB") + } + + if (cacheStatus != null && frameKmTotal != null) { + Spacer(Modifier.height(10.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(10.dp)) + } + + frameKmTotal?.let { fkm -> + Text( + "FRAMEKM STORAGE", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(4.dp)) + fkm.count?.let { InfoRow("Processed Frames", it.toString()) } + val totalBytes = fkm.total ?: fkm.totalBytes + totalBytes?.let { InfoRow("Total Size", formatBytes(it)) } + } + } +} + +@Composable +private fun StorageBar(fraction: Float, label: String) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) + Text("${(fraction * 100).toInt()}%", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) + } + Spacer(Modifier.height(3.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(SurfaceVariant) + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .background( + when { + fraction > 0.85f -> Error + fraction > 0.65f -> Amber + else -> Success + } + ) + ) + } + } +} + +// ── GNSS ────────────────────────────────────────────────────────────────────── + +@Composable +private fun GnssSection(gnss: GnssStatus?) { + BeeCard(title = "GPS / GNSS", icon = Icons.Default.GpsFixed) { + if (gnss == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.GpsOff, contentDescription = null, tint = Error, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("No GNSS data available", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) + } + return@BeeCard + } + + val hasFix = gnss.fix ?: (gnss.latDeg != null && gnss.latDeg != 0.0 && gnss.lonDeg != null && gnss.lonDeg != 0.0) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (hasFix) Icons.Default.GpsFixed else Icons.Default.GpsNotFixed, + contentDescription = null, + tint = if (hasFix) Success else Amber, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + if (hasFix) "FIX ACQUIRED" else "NO FIX", + color = if (hasFix) Success else Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + } + gnss.fixType?.let { + Text(it, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + + Spacer(Modifier.height(8.dp)) + + val rows = listOfNotNull( + gnss.latDeg?.let { "Latitude" to "%.6f°".format(it) }, + gnss.lonDeg?.let { "Longitude" to "%.6f°".format(it) }, + gnss.altM?.let { "Altitude" to "%.1f m".format(it) }, + (gnss.satellitesUsed ?: gnss.satellites)?.let { "Satellites" to it.toString() }, + gnss.accuracyM?.let { "Accuracy" to "%.1f m".format(it) }, + gnss.hdop?.let { "HDOP" to "%.2f".format(it) }, + gnss.speedMs?.let { "Speed" to "%.1f m/s".format(it) }, + gnss.unixMs?.let { "Last Fix" to formatTimestamp(it) } + ) + rows.forEach { (k, v) -> InfoRow(k, v) } + } +} + +// ── Upload Mode ─────────────────────────────────────────────────────────────── + +@Composable +private fun UploadModeSection( + currentMode: String?, + saving: Boolean, + onSetMode: (String) -> Unit +) { + val modes = listOf("LTE", "WIFI", "APP") + BeeCard(title = "UPLOAD MODE", icon = Icons.Default.CloudUpload) { + Text( + "Select how the Bee uploads data.", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + Spacer(Modifier.height(10.dp)) + + if (saving) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(color = Amber, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text("Saving…", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + modes.forEach { mode -> + val selected = currentMode?.uppercase() == mode + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(if (selected) AmberDark else SurfaceVariant) + .border( + 1.dp, + if (selected) Amber else Color.Transparent, + RoundedCornerShape(6.dp) + ) + .clickable { onSetMode(mode) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + when (mode) { + "LTE" -> Icons.Default.NetworkCell + "WIFI" -> Icons.Default.Wifi + else -> Icons.Default.PhoneAndroid + }, + contentDescription = null, + tint = if (selected) Amber else Color.Gray, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.height(4.dp)) + Text( + mode, + color = if (selected) Amber else Color.Gray, + fontFamily = FontFamily.Monospace, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + fontSize = 11.sp, + letterSpacing = 1.sp + ) + } + } + } + } + + Spacer(Modifier.height(8.dp)) + val modeDescription = when (currentMode?.uppercase()) { + "LTE" -> "Uploading via cellular LTE connection" + "WIFI" -> "Uploading via WiFi network" + "APP" -> "Upload controlled by companion app" + else -> "Upload mode not set" + } + Text(modeDescription, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } +} + +// ── Plugins ─────────────────────────────────────────────────────────────────── + +@Composable +private fun PluginSection( + plugins: List, + config: BeeConfig? +) { + BeeCard(title = "PLUGINS", icon = Icons.Default.Extension) { + config?.let { cfg -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (cfg.pluginsLocked == true) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Lock, contentDescription = null, tint = Amber, modifier = Modifier.size(12.dp)) + Spacer(Modifier.width(4.dp)) + Text("PLUGINS LOCKED", color = Amber, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) + } + } + if (cfg.pluginDevMode == true) { + Text("DEV MODE", color = Error, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) + } + if (cfg.pausePluginUpdates == true) { + Text("UPDATES PAUSED", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) + } + } + if (cfg.pluginsLocked == true || cfg.pluginDevMode == true || cfg.pausePluginUpdates == true) { + Spacer(Modifier.height(8.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(8.dp)) + } + } + + plugins.forEach { plugin -> + PluginRow(plugin) + if (plugin != plugins.last()) { + Spacer(Modifier.height(4.dp)) + } + } + } +} + +@Composable +private fun PluginRow(plugin: PluginState) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Extension, + contentDescription = null, + tint = when (plugin.enabled) { + true -> Success + false -> Color.Gray + null -> Color(0xFF6B7280) + }, + modifier = Modifier.size(14.dp) + ) + Text( + plugin.name, + color = OnSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + when { + plugin.error != null -> Text( + "ERR", + color = Error, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + plugin.enabled == true -> Text( + "ENABLED", + color = Success, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + plugin.enabled == false -> Text( + "DISABLED", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + else -> Text( + "UNKNOWN", + color = Color(0xFF6B7280), + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + } +} + +// ── Shared components ───────────────────────────────────────────────────────── + +@Composable +private fun BeeCard( + title: String, + icon: ImageVector, + content: @Composable ColumnScope.() -> Unit +) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(icon, contentDescription = null, tint = Amber, modifier = Modifier.size(14.dp)) + Text( + title, + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + letterSpacing = 2.sp + ) + } + Spacer(Modifier.height(10.dp)) + content() + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + valueColor: Color = OnSurface +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + label, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.weight(0.45f) + ) + Text( + value, + color = valueColor, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(0.55f), + textAlign = androidx.compose.ui.text.style.TextAlign.End + ) + } +} + +@Composable +private fun BeeTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + hint: String = "", + visualTransformation: VisualTransformation = VisualTransformation.None, + trailingIcon: (@Composable () -> Unit)? = null, + numeric: Boolean = false +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + singleLine = true, + visualTransformation = visualTransformation, + trailingIcon = trailingIcon, + keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) + else KeyboardOptions.Default, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Amber, + unfocusedBorderColor = SurfaceVariant, + focusedLabelColor = Amber, + unfocusedLabelColor = Color.Gray, + cursorColor = Amber, + focusedTextColor = OnSurface, + unfocusedTextColor = OnSurface + ) + ) +} + +@Composable +private fun StatusIndicator(active: Boolean) { + Box( + modifier = Modifier + .size(10.dp) + .background( + color = if (active) Success else Color(0xFF6B7280), + shape = RoundedCornerShape(50) + ) + ) +} + +// ── Formatters ──────────────────────────────────────────────────────────────── + +private fun formatUptime(seconds: Long): String { + val h = seconds / 3600 + val m = (seconds % 3600) / 60 + val s = seconds % 60 + return "%02d:%02d:%02d".format(h, m, s) +} + +private fun formatBytes(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024 * 1024)) + } +} + +private fun formatTimestamp(ms: Long): String { + return try { + val sdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.US) + sdf.format(Date(ms)) + } catch (e: Exception) { + ms.toString() + } +} diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt index 0edc86b..82da999 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Error import androidx.compose.material3.* -import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,7 +22,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.GnssStatus -import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.WifiConfig import com.adamaps.varroa.ui.theme.* @@ -38,7 +36,6 @@ fun DeviceStatusScreen( val state by vm.state.collectAsState() val wifiResult by vm.wifiSaveResult.collectAsState() val uploadResult by vm.uploadModeResult.collectAsState() - val sshResult by vm.sshToggleResult.collectAsState() // Refresh on first load LaunchedEffect(Unit) { @@ -59,12 +56,6 @@ fun DeviceStatusScreen( vm.clearUploadModeResult() } } - LaunchedEffect(sshResult) { - sshResult?.let { - snackbarHostState.showSnackbar(it) - vm.clearSshToggleResult() - } - } Scaffold( containerColor = Background, @@ -128,9 +119,6 @@ fun DeviceStatusScreen( // GPS/GNSS Status GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock) - // SSH Status - SshStatusCard(state.sshStatus, vm) - // Upload Mode UploadModeCard(state.deviceInfo?.uploadMode, vm) @@ -376,7 +364,7 @@ private fun StorageStatusCard(storage: StorageStatus) { @Composable private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) { StatusCard("GPS / GNSS") { - val hasGps = gnss?.hasLock == true || hasLock == true + val hasGps = gnss?.fix == true || hasLock == true Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -402,21 +390,21 @@ private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) { Spacer(Modifier.height(4.dp)) StatusRow("HDOP", "%.2f".format(it), if (it > 5) Color.Yellow else OnSurface) } - if (gnss.lat != null && gnss.lon != null) { + if (gnss.latDeg != null && gnss.lonDeg != null) { Spacer(Modifier.height(4.dp)) - StatusRow("Position", "%.5f, %.5f".format(gnss.lat, gnss.lon)) + StatusRow("Position", "%.5f, %.5f".format(gnss.latDeg, gnss.lonDeg)) } - gnss.alt?.let { + gnss.altM?.let { Spacer(Modifier.height(4.dp)) StatusRow("Altitude", "%.1f m".format(it)) } - gnss.speedKmh?.let { + gnss.speedMs?.let { Spacer(Modifier.height(4.dp)) - StatusRow("Speed", "%.1f km/h".format(it)) + StatusRow("Speed", "%.1f km/h".format(it * 3.6)) } - gnss.lastFixAgeSec?.let { + gnss.unixMs?.let { Spacer(Modifier.height(4.dp)) - StatusRow("Last Fix", "${it}s ago", if (it > 30) Color.Yellow else OnSurface) + val ageMs = System.currentTimeMillis() - it; val ageSec = (ageMs / 1000).toInt(); StatusRow("Last Fix", "${ageSec}s ago", if (ageSec > 30) Color.Yellow else OnSurface) } } } @@ -472,55 +460,6 @@ private fun UploadModeCard(currentMode: String?, vm: DeviceStatusViewModel) { } } -@Composable -private fun SshStatusCard(ssh: SshStatus?, vm: DeviceStatusViewModel) { - val isActive = ssh?.active ?: false - - StatusCard("SSH ACCESS") { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - "Enable SSH", - color = OnSurface, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ) - Text( - "SSH access over home WiFi", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - } - Switch( - checked = isActive, - onCheckedChange = { vm.toggleSsh(it) }, - colors = SwitchDefaults.colors( - checkedThumbColor = Amber, - checkedTrackColor = Amber.copy(alpha = 0.5f), - uncheckedThumbColor = Color.Gray, - uncheckedTrackColor = SurfaceVariant - ) - ) - } - - if (isActive) { - Spacer(Modifier.height(8.dp)) - Text( - "SSH enabled. Connect via:\nssh root@", - color = Color.Green, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - lineHeight = 14.sp - ) - } - } -} - @Composable private fun PluginsCard(plugins: List) { StatusCard("PLUGINS") { 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 19cd2d1..39d2cf5 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 @@ -11,11 +11,8 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material.icons.filled.Wifi import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -27,11 +24,14 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.lifecycle.viewmodel.compose.viewModel import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.ui.theme.* @@ -49,14 +49,6 @@ fun SettingsScreen( ) { val currentSettings by vm.settings.collectAsState() val saved by vm.saved.collectAsState() - val isPaired by vm.isPaired.collectAsState() - val deviceSerial by vm.deviceSerial.collectAsState() - val pairingInProgress by vm.pairingInProgress.collectAsState() - val pairingResult by vm.pairingResult.collectAsState() - val sshStatus by vm.sshStatus.collectAsState() - val sshToggleResult by vm.sshToggleResult.collectAsState() - val wifiStatus by vm.wifiStatus.collectAsState() - val wifiConnectResult by vm.wifiConnectResult.collectAsState() // Local edit state — initialized from current settings var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) } @@ -67,6 +59,21 @@ fun SettingsScreen( var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) } var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) } + // Pairing & device state + val isPaired by vm.isPaired.collectAsState() + val deviceSerial by vm.deviceSerial.collectAsState() + val pairingInProgress by vm.pairingInProgress.collectAsState() + val pairingResult by vm.pairingResult.collectAsState() + val sshStatus by vm.sshStatus.collectAsState() + val wifiStatus by vm.wifiStatus.collectAsState() + val wifiConnectResult by vm.wifiConnectResult.collectAsState() + + // WiGLE config input state + + // WiFi config input state + var homeWifiSsid by remember { mutableStateOf("") } + var homeWifiPassword by remember { mutableStateOf("") } + // Show snackbar on save val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(saved) { @@ -75,24 +82,6 @@ fun SettingsScreen( vm.clearSaved() } } - LaunchedEffect(pairingResult) { - pairingResult?.let { - snackbarHostState.showSnackbar(it) - vm.clearPairingResult() - } - } - LaunchedEffect(sshToggleResult) { - sshToggleResult?.let { - snackbarHostState.showSnackbar(it) - vm.clearSshResult() - } - } - LaunchedEffect(wifiConnectResult) { - wifiConnectResult?.let { - snackbarHostState.showSnackbar(it) - vm.clearWifiResult() - } - } Scaffold( containerColor = Background, @@ -117,7 +106,7 @@ fun SettingsScreen( actions = { IconButton(onClick = { vm.save( - currentSettings.copy( + VarroaSettings( beeApiUrl = beeApiUrl.trim(), adamapsApiUrl = adamapsApiUrl.trim(), adamapsApiKey = adamapsApiKey.trim(), @@ -142,15 +131,6 @@ fun SettingsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // Device Pairing section - PairingSection( - isPaired = isPaired, - deviceSerial = deviceSerial, - pairingInProgress = pairingInProgress, - onPair = { vm.pairDevice() }, - onClearPairing = { vm.clearPairing() } - ) - // Device Status navigation card Card( onClick = onNavigateToDeviceStatus, @@ -197,33 +177,129 @@ fun SettingsScreen( } } - // WiFi Config section (only show if paired) - if (isPaired) { - WifiConfigSection( - wifiStatus = wifiStatus, - onConnect = { ssid, password -> vm.connectWifi(ssid, password) }, - onRefresh = { vm.refreshWifiStatus() } - ) - } - - // SSH section (only show if paired) - if (isPaired) { - SshSection( - sshStatus = sshStatus, - onToggle = { enabled -> vm.toggleSsh(enabled) }, - onRefresh = { vm.refreshSshStatus() } - ) - } - SettingsSection("ADACAM DEVICE") { SettingsField( label = "AdaCam API URL", value = beeApiUrl, onValueChange = { beeApiUrl = it }, - hint = "http://10.77.0.1:5000" + hint = "http://192.168.0.10:5000" ) } + // ── Pairing ─────────────────────────────────────────────────────── + SettingsSection("PAIRING") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.Link, + contentDescription = null, + tint = if (isPaired) Amber else Color.Gray, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (isPaired) "Paired — serial: $deviceSerial" + else "Not paired", + color = if (isPaired) Amber else Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + Spacer(Modifier.height(8.dp)) + pairingResult?.let { + Text(it, color = if (it.startsWith("Error")) Color.Red else Amber, + fontFamily = FontFamily.Monospace, fontSize = 11.sp) + Spacer(Modifier.height(4.dp)) + } + Button( + onClick = { vm.pairDevice() }, + enabled = !pairingInProgress, + colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background), + modifier = Modifier.fillMaxWidth() + ) { + Text( + if (pairingInProgress) "Pairing…" else if (isPaired) "Re-pair AdaCam" else "Pair with AdaCam", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ) + } + } + + // ── Home WiFi config ────────────────────────────────────────────── + SettingsSection("HOME WIFI") { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Wifi, contentDescription = null, + tint = if (wifiStatus?.connected == true) Amber else Color.Gray, + modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text( + wifiStatus?.let { + if (it.connected == true) "Connected: ${it.ssid} (${it.ip})" + else "Disconnected" + } ?: "Unknown", + color = if (wifiStatus?.connected == true) Amber else Color.Gray, + fontFamily = FontFamily.Monospace, fontSize = 12.sp + ) + } + Spacer(Modifier.height(8.dp)) + SettingsField(label = "SSID", value = homeWifiSsid, + onValueChange = { homeWifiSsid = it }, hint = "Your home network") + SettingsField(label = "Password", value = homeWifiPassword, + onValueChange = { homeWifiPassword = it }, hint = "WiFi password", + keyboardType = KeyboardType.Password) + wifiConnectResult?.let { + Text(it, color = if (it.startsWith("Error")) Color.Red else Amber, + fontFamily = FontFamily.Monospace, fontSize = 11.sp) + Spacer(Modifier.height(4.dp)) + } + Button( + onClick = { vm.connectWifi(homeWifiSsid, homeWifiPassword) }, + enabled = isPaired && homeWifiSsid.isNotBlank() && homeWifiPassword.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background), + modifier = Modifier.fillMaxWidth() + ) { + Text("Connect AdaCam to WiFi", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + } + if (!isPaired) { + Text("Pair device first to configure WiFi", + color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + + // ── SSH access ──────────────────────────────────────────────────── + SettingsSection("SSH ACCESS") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Terminal, contentDescription = null, + tint = if (sshStatus?.active == true) Amber else Color.Gray, + modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Column { + Text("SSH over home WiFi", + color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text(if (sshStatus?.active == true) "Active — ssh root@" else "Inactive", + color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + Switch( + checked = sshStatus?.active == true, + onCheckedChange = { vm.toggleSsh(it) }, + enabled = isPaired, + colors = SwitchDefaults.colors(checkedThumbColor = Background, checkedTrackColor = Amber) + ) + } + if (!isPaired) { + Text("Pair device first to toggle SSH", + color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) + } + } + SettingsSection("ADAMAPS") { SettingsField( label = "ADAMaps API URL", @@ -285,265 +361,6 @@ fun SettingsScreen( } } -@Composable -private fun PairingSection( - isPaired: Boolean, - deviceSerial: String, - pairingInProgress: Boolean, - onPair: () -> Unit, - onClearPairing: () -> Unit -) { - SettingsSection("DEVICE PAIRING") { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (isPaired) Icons.Default.Link else Icons.Default.LinkOff, - contentDescription = null, - tint = if (isPaired) Color.Green else Color.Gray, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.width(8.dp)) - Column { - Text( - if (isPaired) "Paired" else "Not Paired", - color = if (isPaired) Color.Green else Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - fontWeight = FontWeight.Bold - ) - if (isPaired && deviceSerial.isNotBlank()) { - Text( - "Serial: $deviceSerial", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - } - } - } - } - - Spacer(Modifier.height(12.dp)) - - if (!isPaired) { - Text( - "Connect your phone to the AdaCam WiFi network (adacam-XXXXXX), then tap Pair.", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - lineHeight = 14.sp - ) - Spacer(Modifier.height(8.dp)) - Button( - onClick = onPair, - enabled = !pairingInProgress, - colors = ButtonDefaults.buttonColors(containerColor = Amber), - modifier = Modifier.fillMaxWidth() - ) { - if (pairingInProgress) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = Background, - strokeWidth = 2.dp - ) - Spacer(Modifier.width(8.dp)) - } - Text( - if (pairingInProgress) "Pairing..." else "Pair with AdaCam", - color = Background - ) - } - } else { - OutlinedButton( - onClick = onClearPairing, - colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red), - border = androidx.compose.foundation.BorderStroke(1.dp, Color.Red), - modifier = Modifier.fillMaxWidth() - ) { - Text("Clear Pairing", fontSize = 12.sp) - } - } - } -} - -@Composable -private fun WifiConfigSection( - wifiStatus: com.adamaps.varroa.data.WifiStatus?, - onConnect: (String, String) -> Unit, - onRefresh: () -> Unit -) { - var ssid by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var showPassword by remember { mutableStateOf(false) } - - SettingsSection("HOME WIFI NETWORK") { - // Current status - if (wifiStatus != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Wifi, - contentDescription = null, - tint = if (wifiStatus.connected == true) Color.Green else Color.Yellow, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(8.dp)) - Column { - Text( - if (wifiStatus.connected == true) "Connected" else "Disconnected", - color = if (wifiStatus.connected == true) Color.Green else Color.Yellow, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - fontWeight = FontWeight.Bold - ) - if (wifiStatus.ssid != null && wifiStatus.connected == true) { - Text( - wifiStatus.ssid, - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - } - if (wifiStatus.ip != null && wifiStatus.connected == true) { - Text( - "IP: ${wifiStatus.ip}", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - } - } - } - IconButton(onClick = onRefresh) { - Icon( - Icons.Default.ChevronRight, - contentDescription = "Refresh", - tint = Amber - ) - } - } - Spacer(Modifier.height(12.dp)) - Divider(color = SurfaceVariant) - Spacer(Modifier.height(12.dp)) - } - - Text( - "Configure home WiFi for internet access", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - Spacer(Modifier.height(8.dp)) - - OutlinedTextField( - value = ssid, - onValueChange = { ssid = it }, - label = { Text("SSID", fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Amber, - unfocusedBorderColor = SurfaceVariant, - focusedLabelColor = Amber, - unfocusedLabelColor = Color.Gray, - cursorColor = Amber, - focusedTextColor = OnSurface, - unfocusedTextColor = OnSurface - ) - ) - Spacer(Modifier.height(8.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password", fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Amber, - unfocusedBorderColor = SurfaceVariant, - focusedLabelColor = Amber, - unfocusedLabelColor = Color.Gray, - cursorColor = Amber, - focusedTextColor = OnSurface, - unfocusedTextColor = OnSurface - ) - ) - Spacer(Modifier.height(8.dp)) - - Button( - onClick = { onConnect(ssid, password) }, - enabled = ssid.isNotBlank(), - colors = ButtonDefaults.buttonColors(containerColor = Amber), - modifier = Modifier.fillMaxWidth() - ) { - Text("Connect", color = Background) - } - } -} - -@Composable -private fun SshSection( - sshStatus: com.adamaps.varroa.data.SshStatus?, - onToggle: (Boolean) -> Unit, - onRefresh: () -> Unit -) { - val isActive = sshStatus?.active ?: false - - SettingsSection("SSH ACCESS") { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - "Enable SSH", - color = OnSurface, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ) - Text( - "SSH over home WiFi network", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - } - Switch( - checked = isActive, - onCheckedChange = { onToggle(it) }, - colors = SwitchDefaults.colors( - checkedThumbColor = Amber, - checkedTrackColor = Amber.copy(alpha = 0.5f), - uncheckedThumbColor = Color.Gray, - uncheckedTrackColor = SurfaceVariant - ) - ) - } - - if (isActive) { - Spacer(Modifier.height(8.dp)) - Text( - "SSH enabled. Connect via:\nssh root@", - color = Color.Green, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - lineHeight = 14.sp - ) - } - } -} - @Composable private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { Card( @@ -571,7 +388,8 @@ private fun SettingsField( value: String, onValueChange: (String) -> Unit, hint: String = "", - numeric: Boolean = false + numeric: Boolean = false, + keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text ) { OutlinedTextField( value = value, @@ -579,8 +397,7 @@ private fun SettingsField( label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) }, singleLine = true, - keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) - else KeyboardOptions.Default, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), modifier = Modifier.fillMaxWidth(), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Amber, @@ -763,3 +580,5 @@ private fun WalletLinkingSection( ) } } + + diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt new file mode 100644 index 0000000..0a33a53 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt @@ -0,0 +1,223 @@ +package com.adamaps.varroa.viewmodel + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.adamaps.varroa.api.BeeApiClient +import com.adamaps.varroa.data.* +import com.adamaps.varroa.network.NetworkStateMonitor +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +private const val TAG = "BeeSettingsVM" + +sealed class BeeSettingsLoadState { + object Idle : BeeSettingsLoadState() + object Loading : BeeSettingsLoadState() + data class Error(val message: String) : BeeSettingsLoadState() + object Success : BeeSettingsLoadState() +} + +data class BeeSettingsState( + val deviceInfo: BeeDeviceInfo? = null, + val wifiStatus: WifiStatus? = null, + val wifiSettings: WifiClientSettings? = null, + val wifiNetworks: List = emptyList(), + val cacheStatus: CacheStatus? = null, + val frameKmTotal: FrameKmTotal? = null, + val gnssStatus: GnssStatus? = null, + val uploadMode: String? = null, + val config: BeeConfig? = null, + val plugins: List = emptyList(), + val loadState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, + val wifiScanState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, + val saveState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, + val uploadModeSaving: Boolean = false, + val wifiSaving: Boolean = false +) + +class BeeSettingsViewModel(app: Application) : AndroidViewModel(app) { + + private val settingsStore = SettingsDataStore(app) + private val networkMonitor = NetworkStateMonitor.getInstance(app) + private val beeClient = BeeApiClient() + + private val _state = MutableStateFlow(BeeSettingsState()) + val state: StateFlow = _state.asStateFlow() + + // Toast/snackbar messages + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + init { + viewModelScope.launch { + val s = settingsStore.settings.first() + beeClient.updateUrl(s.beeApiUrl) + val beeNet = networkMonitor.getBeeNetworkForBinding() + if (beeNet != null) { + beeClient.bindToNetwork(beeNet) + } else { + beeClient.bindToWifiNetwork(app) + } + loadAll() + } + } + + fun loadAll() { + viewModelScope.launch { + _state.update { it.copy(loadState = BeeSettingsLoadState.Loading) } + try { + // Fetch all sections concurrently + val deviceInfoDeferred = async { beeClient.getDeviceInfo() } + val wifiStatusDeferred = async { beeClient.getWifiStatus() } + val wifiSettingsDeferred = async { beeClient.getWifiSettings() } + val cacheDeferred = async { beeClient.getCacheStatus() } + val frameKmDeferred = async { beeClient.getFrameKmTotal() } + val gnssDeferred = async { beeClient.getGnssStatus() } + val uploadModeDeferred = async { beeClient.getUploadMode() } + val configDeferred = async { beeClient.getConfig() } + val pluginsDeferred = async { beeClient.getKnownPluginStates() } + + val deviceInfo = when (val r = deviceInfoDeferred.await()) { + is ApiResult.Success -> r.data + is ApiResult.Error -> { + Log.w(TAG, "deviceInfo failed: ${r.message}") + null + } + } + + if (deviceInfo == null) { + _state.update { + it.copy(loadState = BeeSettingsLoadState.Error("Bee not reachable — check connection and URL")) + } + return@launch + } + + val wifiStatus = (wifiStatusDeferred.await() as? ApiResult.Success)?.data + val wifiSettings = (wifiSettingsDeferred.await() as? ApiResult.Success)?.data + val cacheStatus = (cacheDeferred.await() as? ApiResult.Success)?.data + val frameKmTotal = (frameKmDeferred.await() as? ApiResult.Success)?.data + val gnssStatus = (gnssDeferred.await() as? ApiResult.Success)?.data + val uploadMode = (uploadModeDeferred.await() as? ApiResult.Success)?.data + val config = (configDeferred.await() as? ApiResult.Success)?.data + val plugins = pluginsDeferred.await() + + _state.update { + it.copy( + deviceInfo = deviceInfo, + wifiStatus = wifiStatus, + wifiSettings = wifiSettings, + cacheStatus = cacheStatus, + frameKmTotal = frameKmTotal, + gnssStatus = gnssStatus, + uploadMode = uploadMode ?: deviceInfo.uploadMode, + config = config, + plugins = plugins, + loadState = BeeSettingsLoadState.Success + ) + } + Log.i(TAG, "BeeSettings loaded successfully") + + } catch (e: Exception) { + Log.e(TAG, "loadAll failed", e) + _state.update { + it.copy(loadState = BeeSettingsLoadState.Error(e.message ?: "Unknown error")) + } + } + } + } + + fun scanWifi() { + viewModelScope.launch { + _state.update { it.copy(wifiScanState = BeeSettingsLoadState.Loading) } + when (val r = beeClient.scanWifi()) { + is ApiResult.Success -> { + _state.update { + it.copy( + wifiNetworks = r.data, + wifiScanState = BeeSettingsLoadState.Success + ) + } + _message.value = "Found ${r.data.size} networks" + } + is ApiResult.Error -> { + _state.update { it.copy(wifiScanState = BeeSettingsLoadState.Error(r.message)) } + _message.value = "Scan failed: ${r.message}" + } + } + } + } + + fun saveWifiSettings(ssid: String, password: String, enabled: Boolean) { + viewModelScope.launch { + _state.update { it.copy(wifiSaving = true) } + val settings = WifiClientSettings( + ssid = ssid.trim(), + password = password, + enabled = enabled, + security = if (password.length >= 8) "WPA2" else "Open" + ) + when (val r = beeClient.saveWifiSettings(settings)) { + is ApiResult.Success -> { + _message.value = "WiFi settings saved" + // Refresh WiFi status after save + (beeClient.getWifiStatus() as? ApiResult.Success)?.data?.let { status -> + _state.update { it.copy(wifiStatus = status, wifiSettings = settings) } + } + } + is ApiResult.Error -> { + _message.value = "Save failed: ${r.message}" + } + } + _state.update { it.copy(wifiSaving = false) } + } + } + + fun setWifiEnabled(enabled: Boolean) { + viewModelScope.launch { + when (val r = beeClient.setWifiEnabled(enabled)) { + is ApiResult.Success -> { + _message.value = if (enabled) "WiFi client enabled" else "WiFi client disabled" + _state.update { s -> + s.copy(wifiSettings = s.wifiSettings?.copy(enabled = enabled)) + } + } + is ApiResult.Error -> { + _message.value = "Failed: ${r.message}" + } + } + } + } + + fun resetWifi() { + viewModelScope.launch { + when (val r = beeClient.resetWifi()) { + is ApiResult.Success -> _message.value = "WiFi reset triggered" + is ApiResult.Error -> _message.value = "Reset failed: ${r.message}" + } + } + } + + fun setUploadMode(mode: String) { + viewModelScope.launch { + _state.update { it.copy(uploadModeSaving = true) } + when (val r = beeClient.setUploadMode(mode)) { + is ApiResult.Success -> { + _state.update { it.copy(uploadMode = mode, uploadModeSaving = false) } + _message.value = "Upload mode set to $mode" + } + is ApiResult.Error -> { + _state.update { it.copy(uploadModeSaving = false) } + _message.value = "Failed: ${r.message}" + } + } + } + } + + fun clearMessage() { + _message.value = null + } +} diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt index ae6e7fa..09ab8be 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt @@ -10,7 +10,6 @@ import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.SettingsDataStore -import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.WifiConfig import kotlinx.coroutines.flow.MutableStateFlow @@ -26,7 +25,6 @@ data class DeviceStatusState( val wifiConfig: WifiConfig? = null, val storageStatus: StorageStatus? = null, val gnssStatus: GnssStatus? = null, - val sshStatus: SshStatus? = null, val plugins: List = emptyList(), val error: String? = null ) @@ -49,9 +47,6 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { private val _uploadModeResult = MutableStateFlow(null) val uploadModeResult: StateFlow = _uploadModeResult.asStateFlow() - private val _sshToggleResult = MutableStateFlow(null) - val sshToggleResult: StateFlow = _sshToggleResult.asStateFlow() - fun refresh() { viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, error = null) @@ -59,10 +54,6 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { try { val settings = store.settings.first() val client = BeeApiClient(settings.beeApiUrl) - // Set auth token if paired - if (settings.apiToken.isNotBlank()) { - client.apiToken = settings.apiToken - } beeClient = client // Fetch all status in parallel @@ -71,14 +62,12 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { val storageResult = client.getStorageStatus() val gnssResult = client.getGnssStatus() val pluginsResult = client.getPlugins() - val sshResult = client.getSshStatus() val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data val wifi = (wifiResult as? ApiResult.Success)?.data val storage = (storageResult as? ApiResult.Success)?.data val gnss = (gnssResult as? ApiResult.Success)?.data val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList() - val ssh = (sshResult as? ApiResult.Success)?.data val isConnected = deviceInfo != null @@ -89,9 +78,8 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { wifiConfig = wifi, storageStatus = storage, gnssStatus = gnss, - sshStatus = ssh, plugins = plugins, - error = if (!isConnected) "Cannot connect to AdaCam device" else null + error = if (!isConnected) "Cannot connect to Bee device" else null ) Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}") @@ -141,24 +129,6 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { } } - fun toggleSsh(enabled: Boolean) { - viewModelScope.launch { - val client = beeClient ?: return@launch - _sshToggleResult.value = null - - when (val result = client.setSshEnabled(enabled)) { - is ApiResult.Success -> { - _sshToggleResult.value = if (enabled) "SSH enabled" else "SSH disabled" - _state.value = _state.value.copy(sshStatus = SshStatus(active = enabled)) - } - is ApiResult.Error -> { - _sshToggleResult.value = "Failed: ${result.message}" - refresh() - } - } - } - } - fun clearWifiResult() { _wifiSaveResult.value = null } @@ -166,8 +136,4 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { fun clearUploadModeResult() { _uploadModeResult.value = null } - - fun clearSshToggleResult() { - _sshToggleResult.value = null - } } diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt index 5d1ef53..8e16e29 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt @@ -64,6 +64,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { private val _wifiConnectResult = MutableStateFlow(null) val wifiConnectResult: StateFlow = _wifiConnectResult.asStateFlow() + init { // Initialize BeeApiClient with stored settings and token viewModelScope.launch { diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 0f2972b..f777e22 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.png b/app/src/main/res/drawable/ic_launcher_foreground.png new file mode 100644 index 0000000..29254bf Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index ea5eaeb..0000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..8f88f75 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..8f88f75 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..2a38a94 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..2a38a94 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..8b51cb4 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8b51cb4 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7f69984 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7f69984 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..7c51289 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7c51289 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 81d34f7..e7f24f7 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,7 +1,12 @@ - - 192.168.0.10 - + + + + + 192.168.0.10 + diff --git a/blackbox/air-aggregator.service b/blackbox/air-aggregator.service new file mode 100644 index 0000000..17be223 --- /dev/null +++ b/blackbox/air-aggregator.service @@ -0,0 +1,21 @@ +[Unit] +Description=Blackbox Air Quality Aggregator +After=network.target + +[Service] +Type=simple +User=pi +Restart=always +RestartSec=10 +ExecStart=/usr/bin/python3 /home/pi/air-aggregator.py +Environment=BEE_URL=http://192.168.197.1:5000 +Environment=ADAMAPS_URL=https://api.adamaps.org +Environment=ADAMAPS_KEY= +Environment=DEVICE_ID=blackbox-pi +Environment=PMS_PORT=/dev/ttyS0 +Environment=SEND_INTERVAL=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/blackbox/air_aggregator.py b/blackbox/air_aggregator.py new file mode 100644 index 0000000..784c17b --- /dev/null +++ b/blackbox/air_aggregator.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +air-aggregator.py — Blackbox Air Quality Service +Reads BME680 (I2C) + PMS5003 (UART), fuses with GPS from Bee API, +ships readings to AdaMaps ingest endpoint. + +Hardware: + BME680 → I2C (SDA=GPIO2, SCL=GPIO3, addr=0x77) + PMS5003 → UART (/dev/serial0 or /dev/ttyS0, 9600 baud) + +Dependencies: + pip install bme680 requests smbus2 + PMS5003 is read manually (no dep needed) + +Config via env vars or edit constants below. +""" + +import os, time, json, struct, logging, threading +import serial +import smbus2 +import bme680 +import requests +from datetime import datetime, timezone + +# ── Config ──────────────────────────────────────────────────────────────────── +BEE_URL = os.environ.get("BEE_URL", "http://192.168.197.1:5000") +ADAMAPS_URL = os.environ.get("ADAMAPS_URL", "https://api.adamaps.org") +ADAMAPS_KEY = os.environ.get("ADAMAPS_KEY", "") +DEVICE_ID = os.environ.get("DEVICE_ID", "blackbox-pi") +PMS_PORT = os.environ.get("PMS_PORT", "/dev/ttyS0") +SEND_INTERVAL = int(os.environ.get("SEND_INTERVAL", "10")) # seconds +BUFFER_FILE = "/tmp/air_buffer.json" +# ────────────────────────────────────────────────────────────────────────────── + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s") +log = logging.getLogger("air-aggregator") + + +# ── BME680 ──────────────────────────────────────────────────────────────────── +def init_bme680(): + try: + sensor = bme680.BME680(bme680.I2C_ADDR_PRIMARY) + sensor.set_humidity_oversample(bme680.OS_2X) + sensor.set_pressure_oversample(bme680.OS_4X) + sensor.set_temperature_oversample(bme680.OS_8X) + sensor.set_filter(bme680.FILTER_SIZE_3) + sensor.set_gas_status(bme680.ENABLE_GAS_MEAS) + sensor.set_gas_heater_temperature(320) + sensor.set_gas_heater_duration(150) + sensor.select_gas_heater_profile(0) + log.info("BME680 initialized") + return sensor + except Exception as e: + log.error("BME680 init failed: %s", e) + return None + +def read_bme680(sensor): + if not sensor: + return {} + try: + if sensor.get_sensor_data(): + return { + "temperature_c": round(sensor.data.temperature, 2), + "humidity_pct": round(sensor.data.humidity, 2), + "pressure_hpa": round(sensor.data.pressure, 2), + "gas_resistance_ohm": sensor.data.gas_resistance if sensor.data.heat_stable else None, + } + except Exception as e: + log.debug("BME680 read error: %s", e) + return {} + + +# ── PMS5003 ─────────────────────────────────────────────────────────────────── +# Protocol: 32-byte frames, start bytes 0x42 0x4d +PMS_START = b'\x42\x4d' +PMS_FRAME = 32 + +def init_pms5003(): + try: + ser = serial.Serial(PMS_PORT, baudrate=9600, timeout=2) + log.info("PMS5003 on %s", PMS_PORT) + return ser + except Exception as e: + log.error("PMS5003 init failed: %s", e) + return None + +def read_pms5003(ser): + if not ser: + return {} + try: + # Sync to frame start + while True: + b = ser.read(1) + if b == b'\x42': + b2 = ser.read(1) + if b2 == b'\x4d': + break + frame = ser.read(PMS_FRAME - 2) + if len(frame) < PMS_FRAME - 2: + return {} + # Parse: skip length (2 bytes), then 13 uint16s + vals = struct.unpack('>13H', frame[2:28]) + return { + "pm1_0_ug_m3": vals[3], # atmospheric env + "pm2_5_ug_m3": vals[4], + "pm10_ug_m3": vals[5], + "particles_0_3_per_dl": vals[6], + "particles_0_5_per_dl": vals[7], + "particles_1_0_per_dl": vals[8], + "particles_2_5_per_dl": vals[9], + } + except Exception as e: + log.debug("PMS5003 read error: %s", e) + return {} + + +# ── GPS from Bee ────────────────────────────────────────────────────────────── +_gps_cache = {} + +def fetch_gps(): + global _gps_cache + try: + r = requests.get(f"{BEE_URL}/api/1/gnssConcise/latestValid", timeout=3) + if r.ok: + d = r.json() + _gps_cache = { + "lat": d.get("lat") or d.get("latitude"), + "lon": d.get("lon") or d.get("longitude"), + "alt": d.get("alt") or d.get("altitude"), + "gps_fix": True, + } + except Exception as e: + log.debug("GPS fetch failed: %s", e) + _gps_cache["gps_fix"] = False + return _gps_cache + +def gps_poller(): + while True: + fetch_gps() + time.sleep(1) + + +# ── Buffer ──────────────────────────────────────────────────────────────────── +def buffer_reading(reading): + buf = [] + try: + with open(BUFFER_FILE) as f: + buf = json.load(f) + except: pass + buf.append(reading) + # Cap buffer at 1000 readings (~3 hours at 10s interval) + if len(buf) > 1000: + buf = buf[-1000:] + with open(BUFFER_FILE, 'w') as f: + json.dump(buf, f) + +def flush_buffer(): + try: + with open(BUFFER_FILE) as f: + buf = json.load(f) + except: return + if not buf: return + try: + r = requests.post( + f"{ADAMAPS_URL}/api/ingest/air", + json={"device_id": DEVICE_ID, "readings": buf}, + headers={"X-AdaMaps-Key": ADAMAPS_KEY}, + timeout=15 + ) + if r.ok: + log.info("Flushed %d buffered readings", len(buf)) + with open(BUFFER_FILE, 'w') as f: + json.dump([], f) + else: + log.warning("Flush failed: %s", r.status_code) + except Exception as e: + log.warning("Flush error: %s", e) + + +# ── Main loop ───────────────────────────────────────────────────────────────── +def main(): + bme = init_bme680() + pms = init_pms5003() + + # GPS in background thread + t = threading.Thread(target=gps_poller, daemon=True) + t.start() + + log.info("Air aggregator started — sending every %ds", SEND_INTERVAL) + last_send = 0 + + while True: + bme_data = read_bme680(bme) + pms_data = read_pms5003(pms) + gps = dict(_gps_cache) + + reading = { + "device_id": DEVICE_ID, + "sampled_at": datetime.now(timezone.utc).isoformat(), + "lat": gps.get("lat"), + "lon": gps.get("lon"), + "alt": gps.get("alt"), + "gps_fix": gps.get("gps_fix", False), + **bme_data, + **pms_data, + } + + now = time.time() + if now - last_send >= SEND_INTERVAL: + # Try live send first, buffer on failure + try: + flush_buffer() # drain any backlog first + r = requests.post( + f"{ADAMAPS_URL}/api/ingest/air", + json={"device_id": DEVICE_ID, "readings": [reading]}, + headers={"X-AdaMaps-Key": ADAMAPS_KEY}, + timeout=10 + ) + if r.ok: + log.info("Sent | PM2.5=%.1f lat=%s lon=%s", + reading.get("pm2_5_ug_m3", 0), + reading.get("lat"), reading.get("lon")) + else: + log.warning("Send failed %s — buffering", r.status_code) + buffer_reading(reading) + except Exception as e: + log.warning("Offline (%s) — buffering", e) + buffer_reading(reading) + last_send = now + + time.sleep(1) + +if __name__ == "__main__": + main() diff --git a/docs/AIR-QUALITY-INTEGRATION.md b/docs/AIR-QUALITY-INTEGRATION.md new file mode 100644 index 0000000..2f4be27 --- /dev/null +++ b/docs/AIR-QUALITY-INTEGRATION.md @@ -0,0 +1,369 @@ +# Air quality sensor on the Bee + +Can the Bee carry an air quality sensor alongside the existing Hivemapper pipeline and feed readings into AdaMaps? Short answer: yes, with the USB-C data port and a USB-to-I2C bridge. The polling overhead is negligible — the real work is on the AdaMaps side (new ingest endpoint, PostGIS table, heatmap overlay). + +## what's free on the Bee + +Hardware is Keem Bay (RVC2), 4× A53 @ 1.5GHz, Myriad X VPU, 3.5GB usable RAM (1.34GB of that is CMA-reserved for VPU DMA), leaving ~2.2GB for userspace. + +Current service load (pre Phase-1 cleanup): + +| Service | CPU | RAM | Notes | +|---------|-----|-----|-------| +| map-ai | ~32% | ~1.1GB | VPU inference | +| odc-api | ~48% | ~139MB | Phase 2 replacement target | +| depthai_gate | ~5% | ~200MB | camera | +| redis | <1% | ~50MB | | +| **total** | ~85% | ~1.5GB | | + +After Phase 1 (odc-api shrink): ~50-60% CPU free (2-2.4 cores idle) and 700MB-1GB RAM free. More than enough for a 1Hz polling loop. + +USB topology — the Keem Bay USB controller hosts an internal hub. The LTE modem (Telit LE910C4) sits on one internal port; the external USB-C is the other. It does carry data, not just power, so it's the target for sensor attachment. + +``` +Keem Bay USB controller + └── internal hub + ├── Telit LE910C4 (internal) + └── USB-C data port (external) ← us +``` + +## sensor candidates + +| Model | Maker | Measures | Iface | Notes | +|-------|-------|----------|-------|-------| +| BME680 | Bosch | VOC, temp, RH, pressure | I2C/SPI | indoor IAQ, ~$10-20 | +| BME688 | Bosch | BME680 + AI gas scanning | I2C/SPI | advanced VOC classification | +| SEN50 | Sensirion | PM1.0/PM2.5/PM4/PM10 | I2C/UART | particulates only | +| SEN54 | Sensirion | PM + VOC + temp + RH | I2C/UART | | +| SEN55 | Sensirion | SEN54 + NOx | I2C/UART | full air-quality suite | + +Worth flagging: SEN5x is Sensirion, not Bosch. If the sensor on hand is branded Bosch it's almost certainly a BME680 or BME688. + +**BME680/688** — VOC as IAQ index 0-500; temp -40 to +85°C ±1°C; RH 0-100% ±3%; pressure 300-1100 hPa ±1 hPa; 3.6mA active, <1µA sleep. I2C address 0x76 or 0x77. Cheap, well-documented, low power, but VOC is a relative index (not absolute concentration) and the gas sensor needs ~48h of burn-in before readings stabilize. + +**SEN55** — PM1.0/PM2.5/PM4/PM10 (0-1000 µg/m³), VOC index, NOx index, temp -10 to +50°C, RH 0-100%. ~60mA. Native I2C or UART. Bigger (~40×40×12mm) and pricier (~$50-80), but it measures actual particulate matter, which is the metric that matters for outdoor pollution mapping. + +For AdaMaps urban pollution work, SEN55 is the right pick — PM2.5 and NOx are the actionable numbers. For a quick "does this work at all" prototype, BME680 is fine. + +## USB bridge options + +For I2C sensors (BME680/688) we need a USB-to-I2C adapter: + +| Adapter | Cost | Notes | +|---------|------|-------| +| Adafruit FT232H | $15 | FTDI, good support, `ftdi_sio` driver | +| MCP2221A | $5 | Microchip, HID mode, `i2c-mcp2221` | +| CP2112 | $8 | Silicon Labs, HID mode | +| CH341 | $3 | generic Chinese, works but flaky | + +FT232H shows up as `/dev/i2c-X` via `ftdi_sio`; MCP2221A as `/dev/hidraw*` or `/dev/i2c-X`. Python side: `smbus2` for low-level, `adafruit-blinka` + `adafruit-circuitpython-bme680` for the BME, or `pyftdi` to drive the bridge directly. + +Quick read with FT232H + BME680: + +```python +import board, adafruit_bme680 +i2c = board.I2C() +sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c) +print(sensor.temperature, sensor.humidity, sensor.pressure, sensor.gas) +``` + +For SEN5x, simplest is UART mode (jumper on the sensor) + CP2102 USB-UART (~$2). Sensor shows up as `/dev/ttyUSB0`; talk SHDLC at 115200. The Sensirion SEK-SEN55 eval kit has native USB-C and appears as CDC-ACM, but it's $100 and oversized — fine for bench testing, wrong for production. + +Picking one: MCP2221A + BME680 breakout ~$15 total for basic VOC. CP2102 + SEN55 ~$55 for full particulate matter. + +## bee-side integration + +New service `air-sensor.service` polls the sensor at 1Hz and writes a Redis key. `bee-collector.py` (existing) reads that key during GPS fusion and includes it in the upload payload. + +``` +USB sensor + adapter + │ + ▼ +air-sensor.service (Python, 1Hz) + │ + ▼ +Redis: AirQuality1Hz + │ + ▼ +bee-collector.py ─ GNSSFusion30Hz ── (fuse) ─► HTTPS to AdaMaps +``` + +Service unit: + +```ini +[Unit] +Description=Air Quality Sensor Reader +After=redis.service + +[Service] +Type=simple +User=root +ExecStart=/opt/air-sensor/air_sensor.py +Restart=always +RestartSec=5 +``` + +`/opt/air-sensor/air_sensor.py`: + +```python +#!/usr/bin/env python3 +"""Poll BME680/688 over USB-I2C, publish to Redis at 1Hz.""" + +import json, time, redis +import board, adafruit_bme680 + +POLL = 1.0 +KEY = "AirQuality1Hz" + +def iaq(gas, _humidity): + # placeholder — for real IAQ use Bosch BSEC + if gas > 300000: return 50 + if gas > 200000: return 100 + if gas > 100000: return 150 + if gas > 50000: return 200 + return 300 + +def main(): + r = redis.Redis() + i2c = board.I2C() + sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=0x77) + sensor.sea_level_pressure = 1013.25 + + while True: + reading = { + "ts": int(time.time() * 1000), + "temperature_c": round(sensor.temperature, 2), + "humidity_pct": round(sensor.humidity, 2), + "pressure_hpa": round(sensor.pressure, 2), + "gas_resistance_ohms": sensor.gas, + "iaq_index": iaq(sensor.gas, sensor.humidity), + } + r.set(KEY, json.dumps(reading)) + r.publish("air_quality", json.dumps(reading)) + time.sleep(POLL) + +if __name__ == "__main__": + main() +``` + +The IAQ calc above is a placeholder — for real-world readings you want the Bosch BSEC library, which is closed-source but free for non-commercial use (license check needed before shipping anything that's not personal). + +Extending bee-collector to merge it in: + +```python +def get_air_quality(): + data = redis_client.get("AirQuality1Hz") + return json.loads(data) if data else None + +def collect_frame(): + gnss = json.loads(redis_client.get("GNSSFusion30Hz") or "{}") + air = get_air_quality() + return { + "timestamp": int(time.time() * 1000), + "lat": gnss.get("lat"), + "lon": gnss.get("lon"), + "speed_kmh": gnss.get("speed"), + "air_temperature_c": air and air.get("temperature_c"), + "air_humidity_pct": air and air.get("humidity_pct"), + "air_pressure_hpa": air and air.get("pressure_hpa"), + "air_iaq_index": air and air.get("iaq_index"), + "air_gas_ohms": air and air.get("gas_resistance_ohms"), + } +``` + +Resource impact: <0.5% CPU, ~15MB RAM, one thread, <1KB/s USB traffic. Doesn't touch the camera, VPU, or map-ai. Negligible. + +## AdaMaps side + +Two endpoints + one table. + +### `/api/ingest/air` + +```python +@app.route('/api/ingest/air', methods=['POST']) +def ingest_air_quality(): + if not verify_api_key(request): + return jsonify({"error": "Unauthorized"}), 401 + data = request.json + required = ['lat', 'lon', 'timestamp'] + if not all(k in data for k in required): + return jsonify({"error": "Missing required fields"}), 400 + + conn = get_db(); cur = conn.cursor() + cur.execute(""" + INSERT INTO air_quality ( + device_id, timestamp, + lat, lon, geom, + temperature_c, humidity_pct, pressure_hpa, + iaq_index, gas_ohms, + pm1_0, pm2_5, pm4_0, pm10, + voc_index, nox_index + ) VALUES ( + %s, to_timestamp(%s / 1000.0), + %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), + %s, %s, %s, + %s, %s, + %s, %s, %s, %s, + %s, %s + ) + """, ( + data.get('device_id'), data['timestamp'], + data['lat'], data['lon'], + data['lon'], data['lat'], # ST_MakePoint is (lon, lat) + data.get('air_temperature_c'), + data.get('air_humidity_pct'), + data.get('air_pressure_hpa'), + data.get('air_iaq_index'), + data.get('air_gas_ohms'), + data.get('pm1_0'), data.get('pm2_5'), + data.get('pm4_0'), data.get('pm10'), + data.get('voc_index'), data.get('nox_index'), + )) + conn.commit(); cur.close() + return jsonify({"inserted": 1}) +``` + +### schema + +```sql +CREATE TABLE air_quality ( + id SERIAL PRIMARY KEY, + device_id VARCHAR(64), + timestamp TIMESTAMPTZ NOT NULL, + + lat DOUBLE PRECISION NOT NULL, + lon DOUBLE PRECISION NOT NULL, + geom GEOMETRY(Point, 4326), + + -- BME680/688 + temperature_c REAL, + humidity_pct REAL, + pressure_hpa REAL, + iaq_index INTEGER, -- 0-500 (Bosch IAQ) + gas_ohms INTEGER, + + -- SEN5x + pm1_0 REAL, -- µg/m³ + pm2_5 REAL, + pm4_0 REAL, + pm10 REAL, + voc_index INTEGER, -- 1-500 + nox_index INTEGER, -- 1-500 + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_air_quality_geom ON air_quality USING GIST (geom); +CREATE INDEX idx_air_quality_timestamp ON air_quality (timestamp DESC); +CREATE INDEX idx_air_quality_device ON air_quality (device_id); +``` + +### `/api/air/heatmap` + +```python +@app.route('/api/air/heatmap', methods=['GET']) +def air_quality_heatmap(): + hours = request.args.get('hours', 24, type=int) + metric = request.args.get('metric', 'iaq_index') # or pm2_5, voc_index + # bounds param parsed but unused for now — TODO + + conn = get_db(); cur = conn.cursor() + cur.execute(f""" + SELECT + ST_X(ST_Centroid(ST_Collect(geom))) AS lon, + ST_Y(ST_Centroid(ST_Collect(geom))) AS lat, + AVG({metric}) AS value, + COUNT(*) AS samples + FROM air_quality + WHERE timestamp > NOW() - INTERVAL '%s hours' + GROUP BY ROUND(lat::numeric, 3), ROUND(lon::numeric, 3) + HAVING AVG({metric}) IS NOT NULL + """, (hours,)) + + results = [ + {"lon": r[0], "lat": r[1], "value": round(r[2], 1), "samples": r[3]} + for r in cur.fetchall() + ] + cur.close() + return jsonify({"data": results, "metric": metric}) +``` + +The `metric` arg interpolates into the SQL — fine since the value is validated against a column allowlist before reaching this point (don't skip that part). + +## frontend overlay + +Leaflet.heat does most of the work. Convert the heatmap response to `[lat, lon, intensity]` triples, normalize IAQ 0-500 down to 0-1: + +```javascript +import L from 'leaflet'; +import 'leaflet.heat'; + +async function loadAirQualityLayer(map) { + const r = await fetch('/api/air/heatmap?hours=24&metric=iaq_index'); + const { data } = await r.json(); + + const heat = data.map(p => [p.lat, p.lon, Math.min(p.value / 300, 1.0)]); + + return L.heatLayer(heat, { + radius: 25, blur: 15, maxZoom: 17, + gradient: { + 0.0: 'green', // 0-50 Excellent + 0.2: 'yellow', // 51-100 Good + 0.4: 'orange', // 101-150 Moderate + 0.6: 'red', // 151-200 Unhealthy + 0.8: 'purple', // 201-300 Very unhealthy + 1.0: 'maroon', // 301+ Hazardous + }, + }); +} +``` + +Legend markup: + +```html +
+

Air Quality Index

+
0-50 Excellent
+
51-100 Good
+
101-150 Moderate
+
151-200 Unhealthy
+
201-300 Very unhealthy
+
301+ Hazardous
+
+``` + +## BOM + +Option A — BME680, basic VOC/IAQ: + +``` +BME680 breakout $15 Adafruit/SparkFun +MCP2221A USB-I2C $7 Adafruit +Qwiic/STEMMA cables $3 SparkFun + ----- + $25 +``` + +Option B — SEN55, full air quality: + +``` +SEN55 sensor $45 DigiKey/Mouser +breakout PCB $5 JLCPCB/OSHPark +CP2102 USB-UART $3 Amazon + ----- + $53 +``` + +Both, if we want everything (VOC index from BME688 cross-checked against SEN55's separate VOC/NOx readings): ~$80. + +## things still to confirm + +- exact sensor model on hand (BME680? 688? something else?) — needs a look +- Bee USB-C port host mode — plug something in and see if it enumerates +- can we `pip install` on the Bee, or is the Yocto rootfs read-only? need a wheel-bundle plan if so +- Bosch BSEC licensing for the real IAQ calculation — non-commercial vs. commercial terms differ +- 1Hz is the default polling rate; bump up or down once we see what the data looks like + +## rollout order + +Sensor on the bench first (laptop or Pi) to confirm it actually reads. Then onto the Bee — service deploys, Redis key check, fusion in bee-collector, upload spot-check. AdaMaps side (table + endpoints) can land in parallel; curl-test before pointing the Bee at it. Frontend last, drive a route, eyeball the heatmap. diff --git a/docs/BEE-CAMERA.md b/docs/BEE-CAMERA.md new file mode 100644 index 0000000..852046d --- /dev/null +++ b/docs/BEE-CAMERA.md @@ -0,0 +1,467 @@ +# Bee camera system + +Notes from poking at the Hivemapper Bee dashcam. The camera path doesn't use V4L2 at all — frames live in `/tmp/recording/pics/` and `/data/recording/cached_observations/`, written by a DepthAI pipeline running on the on-die Myriad X VPU. All access goes through XLink, not `/dev/video*`. + +## hardware + +SoC is Intel Keem Bay (RVC2 — Robotics Vision Core 2): 4× Cortex-A53 @ 1.5GHz, integrated Myriad X VPU with 16 SHAVE cores and a Neural Compute Engine, 10nm. 4GB LPDDR4 on the board, ~3.5GB usable. CMA reserves ~1.34GB for VPU/camera DMA, swap is 2GB. + +``` +MemTotal: 3,584,000 kB +SwapTotal: 2,097,148 kB +CmaTotal: 1,408,000 kB # VPU/camera DMA reservation +``` + +The camera is a Luxonis OAK-1-compatible module — Sony IMX378 (or equivalent 12MP), 4056×3040 native, downscaled to 2028×1024 by the pipeline. ~30 FPS. MIPI CSI-2 into the VPU's ISP; the ARM side never touches it directly. + +Bus layout: + +``` +┌─────────────────────────────────────────────────────┐ +│ Intel Keem Bay SoC │ +│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ +│ │ A53 ×4 │ │ Myriad X │ │ Neural Compute │ │ +│ └────┬─────┘ └────┬─────┘ └────────┬───────┘ │ +│ └────────┬────┴─────────────────┘ │ +│ Internal AXI/NoC │ +│ ┌─────┬─────┼──────┬───────────┐ │ +│ PCIe USB SDIO MIPI CSI │ │ +└─────┼─────┼─────┼──────┼───────────┘ │ + │ │ │ │ + Marvell Telit eMMC IMX378 + 88W8997 LE910C4 + WiFi/BT LTE +``` + +## kernel + V4L2 + +Custom Yocto build with Intel VPU drivers. `kmb_cam` / `kmb_imx412` may be loaded for the sensor itself, plus the usual `videodev` + `v4l2_fwnode`. None of it is reachable via `/dev/video*` during normal operation — the VPU owns the camera hardware exclusively and the host talks to it over XLink (PCIe transport on Keem Bay; USB on desktop OAK devices). + +VPU is controlled via sysfs at `/sys/class/vpu/`. Firmware is loaded by writing the filename to the `fwname` attribute. Two firmwares are present: + +- `luxonis_vpu.bin` — DepthAI firmware (what we want) +- `vpu_nvr_b0.bin` — Intel HDDL firmware (conflicts, see below) + +If you want frames without going through the existing stack you have three options: use the depthai_gate / odc-api stack as-is, stop depthai_gate and run your own DepthAI pipeline, or reverse-engineer XLink and roll custom VPU firmware. The first is by far the easiest. + +## depthai_gate + +Python+Flask, listens on localhost:11492, ~158 threads, ~200MB RSS. Lives at `/opt/depthai_gate/` (estimated — not confirmed on-device yet). Service unit looks like: + +```ini +[Unit] +Description=DepthAI Camera Gate +After=network.target + +[Service] +Type=simple +User=root +ExecStart=/opt/depthai_gate/run.py +Restart=always +``` + +What it does: loads `luxonis_vpu.bin` into the VPU, opens the XLink connection, configures the DepthAI pipeline (ColorCamera → ImageManip → XLinkOut, plus optional NeuralNetwork node), captures frames, and drops them into `/tmp/recording/pics/`. Pipeline config probably lives at `/opt/depthai_gate/pipeline.json` or `/data/camera_config.json`, possibly hardcoded. + +XLink status values seen in logs: + +``` +xlink_device_status=2 # connected, healthy + =1 # connecting / error + =0 # disconnected +``` + +### the VPU conflict + +`deviceservice.service` (Intel HDDL / OpenVINO) ships enabled and races depthai_gate for the VPU: + +1. HDDL starts at boot, loads `vpu_nvr_b0.bin` +2. depthai_gate starts, overwrites with `luxonis_vpu.bin` +3. HDDL retries XLink every 2s forever, can't talk to the now-Luxonis firmware +4. If depthai_gate restarts after HDDL is already running, HDDL grabs the VPU first and the camera goes dead +5. `secure-wdtclient` watchdog crash-loops on the dead VPU → memory pressure → OOM + +Fix: + +```bash +systemctl disable --now deviceservice +systemctl mask deviceservice # survives OTA +``` + +## map-ai + +Reads frames from depthai_gate, runs detection on the VPU's NCE, blurs faces and plates, writes results to SQLite (`/data/recording/odc-api.db`) and blurred frames to disk. + +```ini +[Unit] +Description=Map AI Processing +After=depthai_gate.service + +[Service] +Type=simple +User=root +ExecStart=/opt/map-ai/run.py +Restart=always +``` + +Pipeline: + +``` +frame from depthai_gate + │ + ▼ + ML inference (on VPU NCE) + - road sign classifier + - face detector + - license plate detector + │ + ▼ + privacy blur + - Gaussian on faces/plates + - cv2.imwrite blurred copy + │ + ├──► Redis (status keys, not detections) + └──► SQLite (observations, landmarks, frames) +``` + +Models: + +| Model | Path | Purpose | +|-------|------|---------| +| Road signs | `/opt/object-detection/model.blob` or `/data/models/` | classification | +| Privacy | `/opt/odc-api/python/` or `/data/models/` | face/plate detection | +| PVC | `/data/recording/models/pvc.onnx` | unknown — 227 bytes, probably an index file | + +Privacy model hash gets baked into FrameKm metadata for verification. + +Redis only carries readiness flags: + +``` +GET MAP_AI_READY → "True" +GET EXTERNAL_MODEL_CLASSIFIER_READY → "True" +``` + +Detections go to SQLite, not Redis ZSETs. + +## frame storage + +| Path | FS | Purpose | Persists? | +|------|----|---------|-----------| +| `/tmp/recording/pics/` | tmpfs | live frames | no | +| `/tmp/recording/preview/` | tmpfs | preview mode | no | +| `/data/recording/cached_observations/` | ext4 | landmark observations | yes | +| `/data/recording/framekm/` | ext4 | upload bundles | yes | +| `/tmp/rgb/` | tmpfs | frame list files | no | + +Frames are JPEG at 2028×1024, ~85% quality, ~150-200KB each. + +Naming: + +``` +# live +/tmp/recording/pics/{system_time_ms}_{frame_id}_{sequence}.jpg +e.g. 1709920000123_0001_0042.jpg + +# cached observations +/data/recording/cached_observations/{timestamp}_{subsecond}_{frame_number}.jpg +e.g. 1746377552_043000_2945056.jpg +``` + +`folder_purger` keeps disk under control — when `/tmp/recording/pics/` crosses 400MB, oldest frames go: + +``` +folder-purger /tmp/recording/pic 400000000 /mnt/data/gps 2000000000 ... +``` + +SQLite schemas (simplified): + +```sql +CREATE TABLE frames ( + system_time INTEGER PRIMARY KEY, + image_name TEXT +); + +CREATE TABLE observations ( + id INTEGER PRIMARY KEY, + landmark_id INTEGER, + image_name TEXT, + x1 REAL, y1 REAL, x2 REAL, y2 REAL, + ts INTEGER +); +``` + +DB lives at `/data/recording/odc-api.db` (also seen as `data-logger.v2.0.0.db`). + +## video-processor + FrameKm + +`video-processor` isn't documented in the firmware I've looked at. Based on naming it bundles frames+metadata into FrameKm tarballs, handles any H.264/H.265 encoding for preview, and orders frames for upload. It doesn't produce raw frames — it only packages already-blurred ones — so it's not useful for camera-access work. + +A FrameKm is ~1km of driving data: + +``` +framekm-2024-03-08-12-34-56-abc123.tar +├── manifest.json +├── frame_0001.jpg +├── frame_0002.jpg +├── ... +├── gnss_auth_buffer.bin +└── gnss_auth_signature.bin +``` + +Manifest: + +```json +{ + "name": "framekm-2024-03-08-12-34-56-abc123", + "numFrames": 150, + "deviceId": "fvhL2I-iCT", + "firmwareVersion": "0.0.1", + "privacyModelHash": "sha256:abc123...", + "gnssAuthBuffer": "base64...", + "gnssAuthSignature": "base64...", + "gnssAuthPublicKey": "base64...", + "createdAt": 1709920000000 +} +``` + +## odc-api + +REST API at `http://192.168.0.10:5000/api/1/`. Binds to the AP interface (`wlp1s0f0`) only — not reachable from home LAN without going through the Bee's AP. + +Preview endpoints: + +| Endpoint | Method | Notes | +|----------|--------|-------| +| `/preview/start` | GET | 120s timeout, then auto-stop | +| `/preview/stop` | GET | | +| `/preview/status` | GET | | +| `/preview/metadata` | GET | latest frame metadata | + +Preview works by writing a new config and bouncing camera-bridge: + +```typescript +export const startPreview = async () => { + await execSync('mkdir /tmp/recording/preview'); + writeFileSync(IMAGER_CONFIG_PATH, JSON.stringify(getPreviewConfig())); + await execSync(CMD.STOP_CAMERA); + await sleep(1000); + await execSync(CMD.START_CAMERA); +}; +``` + +The 120s auto-stop is there to protect 4K recording quality. + +Landmark endpoints (where the cached observation images come out): + +| Endpoint | Method | Notes | +|----------|--------|-------| +| `/landmarks/images/:id` | GET | image paths for a landmark | +| `/landmarks/:id/chips` | GET | list of chip endpoints | +| `/landmarks/:id/chips/:chip_id` | GET | cropped observation JPEG | +| `/landmarks/boundingBox/:id` | GET | bbox coords | +| `/landmarks/upload` | PUT | upload landmark image | + +Image retrieval flow: + +``` +GET /landmarks/images/123 + → ["/data/recording/cached_observations/1746377552_043000_2945056.jpg"] + +GET /landmarks/123/chips/456 + → cropped JPEG of the bbox region +``` + +Camera bridge config: `/opt/camera-bridge/config.json`. Control commands from `bee.ts`: + +```typescript +export const CMD = { + RESTART_CAMERA: 'systemctl restart camera-bridge', + START_CAMERA: 'systemctl start camera-bridge', + STOP_CAMERA: 'systemctl stop camera-bridge', + START_PREVIEW: 'systemctl start camera-preview', + STOP_PREVIEW: 'systemctl stop camera-preview', +}; +``` + +There is no `/camera/frame` endpoint. To get a frame you either use preview mode and read `/tmp/recording/preview/`, walk the landmarks API for chips, or SSH in and read `/tmp/recording/pics/` directly. + +## full data flow + +``` +IMX378 → MIPI CSI-2 → VPU ISP → DepthAI pipeline + │ + depthai_gate (:11492) + writes /tmp/recording/pics/ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ + /tmp/recording/pics/ map-ai (VPU NCE) + (raw, purged >400MB) - sign classifier + - privacy blur + │ + ┌────────────────────────────────────┤ + ▼ ▼ + /data/recording/cached_observations/ odc-api.db (SQLite) + (blurred, persistent) landmarks/observations/frames + │ │ + └────────────────┬───────────────────┘ + ▼ + odc-api (:5000) + /preview/*, /landmarks/* + │ + ▼ + hivemapper-data-logger + → FrameKm bundles in /data/recording/framekm/ + │ + ▼ + odc-api → mitmdump :8888 → Cloudflare Workers → HERE OLP +``` + +Single detection trace: + +``` +1. IMX378 → MIPI → VPU ISP → depthai_gate +2. /tmp/recording/pics/1709920000123_0001_0042.jpg +3. map-ai picks it up, runs road sign classifier on the NCE +4. hit (e.g. speed limit 35), faces/plates blurred +5. observations row written + image saved to cached_observations/ +6. landmarks row created/updated (class_label, lat, lon, confidence) +7. /landmarks/last/5 surfaces it; /landmarks/{id}/chips/{chip_id} returns the crop +``` + +## replacement notes + +### getting a frame without odc-api + +Direct file read (simplest, race-prone, no metadata): + +```bash +ssh -p 2222 root@localhost # via Lucy tunnel +ls -lt /tmp/recording/pics/ | head -10 + +# poor man's stream +while true; do + cp $(ls -t /tmp/recording/pics/*.jpg | head -1) /tmp/current.jpg + sleep 0.033 +done +``` + +Redis pub/sub would be cleaner — `r.pubsub().subscribe('frame_ready')` — but I haven't confirmed depthai_gate publishes anything like that. Worth a `redis-cli MONITOR` while recording is live. + +### getting a frame without depthai_gate + +Don't, unless you really mean it. You'd be replacing the whole VPU pipeline: + +```python +import depthai as dai + +pipeline = dai.Pipeline() +cam = pipeline.create(dai.node.ColorCamera) +cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_4_K) +cam.setIspScale(1, 2) # → 2028×1024 + +xout = pipeline.create(dai.node.XLinkOut) +xout.setStreamName("video") +cam.video.link(xout.input) + +with dai.Device(pipeline) as device: + q = device.getOutputQueue("video") + while True: + cv2.imwrite("/tmp/frame.jpg", q.get().getCvFrame()) +``` + +Breaks everything Hivemapper depends on — map-ai, landmarks, FrameKm. Only useful if the goal is full liberation, not augmentation. + +### fastest frame grabs + +With the stack running: + +```bash +# direct +ssh -p 2222 root@localhost 'ls -t /tmp/recording/pics/*.jpg | head -1 | xargs cat' > frame.jpg + +# via API (needs preview mode) +curl http://192.168.0.10:5000/api/1/preview/start +sleep 2 +ssh -p 2222 root@localhost 'ls -t /tmp/recording/preview/*.jpg | head -1 | xargs cat' > frame.jpg +curl http://192.168.0.10:5000/api/1/preview/stop +``` + +### proposed odc-api extension + +Two new routes — single frame + MJPEG stream: + +```typescript +router.get('/frame', async (req, res) => { + const frames = readdirSync('/tmp/recording/pics') + .filter(f => f.endsWith('.jpg')) + .sort().reverse(); + if (!frames.length) return res.status(404).send('No frames available'); + res.sendFile(join('/tmp/recording/pics', frames[0])); +}); + +router.get('/stream', async (req, res) => { + res.writeHead(200, { + 'Content-Type': 'multipart/x-mixed-replace; boundary=frame', + 'Cache-Control': 'no-cache', + }); + const interval = setInterval(() => { + const frames = readdirSync('/tmp/recording/pics') + .filter(f => f.endsWith('.jpg')).sort().reverse(); + if (frames.length) { + const data = readFileSync(join('/tmp/recording/pics', frames[0])); + res.write('--frame\r\n'); + res.write('Content-Type: image/jpeg\r\n'); + res.write(`Content-Length: ${data.length}\r\n\r\n`); + res.write(data); + res.write('\r\n'); + } + }, 33); // ~30 FPS + req.on('close', () => clearInterval(interval)); +}); +``` + +Long-term replacement shape — leave depthai_gate alone, add a separate watcher service that inotify-tails `/tmp/recording/pics/` and serves frames over HTTP. Doesn't fight the VPU, doesn't break the upload chain. + +## things still to confirm + +- exact depthai_gate pipeline config (find files under `/opt/`) +- does depthai_gate publish frame events to Redis at all? (`redis-cli MONITOR`) +- camera-bridge vs depthai_gate — what's the actual dependency? (systemd deps, strace) +- preview config format — read `getPreviewConfig()` +- ML model exact locations (`find /opt /data -name '*.blob' -o -name '*.onnx'`) +- frame timestamp accuracy vs GNSS time + +## appendix — file paths + +``` +/tmp/recording/pics/ live frames +/tmp/recording/preview/ preview frames +/data/recording/cached_observations/ landmark images +/data/recording/framekm/ upload bundles +/data/recording/odc-api.db SQLite DB +/opt/camera-bridge/config.json camera config +/opt/depthai_gate/ DepthAI service (estimated) +/opt/odc-api/ Node API service +/sys/class/vpu/ VPU sysfs +``` + +## appendix — boot order + +``` +multi-user.target + ├── redis.service t+2s + ├── depthai_gate.service t+8s loads luxonis_vpu.bin + ├── map-ai.service t+12s needs depthai_gate + ├── hivemapper-data-logger.service t+15s + └── odc-api.service t+18s +``` + +## appendix — ports + +``` +22 sshd TCP AP-only (socket) +5000 odc-api HTTP AP iface +6379 redis TCP localhost +8888 mitmdump HTTP localhost +11492 depthai_gate HTTP/Flask localhost +``` diff --git a/docs/adamaps-index-preview.html b/docs/adamaps-index-preview.html new file mode 100644 index 0000000..a8ff7b1 --- /dev/null +++ b/docs/adamaps-index-preview.html @@ -0,0 +1,545 @@ + + + + + + AdaMaps — Verified Sign Map + + + + + + + +
+ + + + + + +