diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml deleted file mode 100644 index 10d7847..0000000 --- a/.forgejo/workflows/gitleaks.yml +++ /dev/null @@ -1,40 +0,0 @@ -# .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 deleted file mode 100644 index ce62f5e..0000000 --- a/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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 20dbc60..01f4169 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,28 +9,12 @@ 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 = 15 - versionName = "1.8.0" + versionCode = 14 + versionName = "1.7.9" vectorDrawables { useSupportLibrary = true @@ -40,7 +24,6 @@ android { buildTypes { release { isMinifyEnabled = false - signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -87,9 +70,17 @@ 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 9896951..e23ca05 100644 --- a/app/src/main/java/com/adamaps/varroa/Navigation.kt +++ b/app/src/main/java/com/adamaps/varroa/Navigation.kt @@ -5,7 +5,6 @@ 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 @@ -13,7 +12,6 @@ object Routes { const val DASHBOARD = "dashboard" const val SETTINGS = "settings" const val DEVICE_STATUS = "device_status" - const val BEE_SETTINGS = "bee_settings" } @Composable @@ -21,10 +19,7 @@ fun VarroaNavGraph() { val nav = rememberNavController() NavHost(navController = nav, startDestination = Routes.DASHBOARD) { composable(Routes.DASHBOARD) { - DashboardScreen( - onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }, - onNavigateToBeeSettings = { nav.navigate(Routes.BEE_SETTINGS) } - ) + DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }) } composable(Routes.SETTINGS) { SettingsScreen( @@ -35,8 +30,5 @@ 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 39a8410..8fc65f9 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 = "" + private var apiKey: String = "***REMOVED***" ) { 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.length + val oldKeyPrefix = apiKey.take(8) 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.length}...") + Log.d(TAG, "Sending POST request with key: ${apiKey.take(8)}...") 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 09a370a..26b3d81 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -6,22 +6,15 @@ 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 @@ -30,11 +23,10 @@ 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://192.168.0.10:5000" + private var apiUrl: String = "http://10.77.0.1:5000" ) { companion object { private const val TAG = "VarroaBeeAPI" @@ -186,35 +178,6 @@ 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. @@ -282,6 +245,7 @@ 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) || @@ -340,60 +304,29 @@ class BeeApiClient( } } - 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) - } + 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() - // 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}") + 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) + } } - 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 + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") } } @@ -410,14 +343,30 @@ class BeeApiClient( } } - suspend fun setSshEnabled(enabled: Boolean): ApiResult { - val json = gson.toJson(mapOf("enable" to enabled)) - return postRaw("/api/1/ssh/toggle", json) - } + 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() - // SSH-based device ID retrieval (stub - not implemented) - suspend fun getDeviceIdViaSsh(): ApiResult { - return ApiResult.Error("SSH device ID lookup not implemented") + 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") + } } // ── Storage & GNSS Status ───────────────────────────────────────────────── @@ -456,95 +405,32 @@ class BeeApiClient( } } - // ── Upload Mode ─────────────────────────────────────────────────────────── + 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() - 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") + client.newCall(request).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (resp.isSuccessful) { + ApiResult.Success(body) } else { - ApiResult.Success(trimmed) + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) } - } catch (e: Exception) { - ApiResult.Success(r.data.trim().trim('"')) } - is ApiResult.Error -> r + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") } } - 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 ──────────────────────────────────────────────────────────── /** @@ -631,6 +517,7 @@ class BeeApiClient( } is ApiResult.Error -> { Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}") + // Continue trying other endpoints } } } @@ -642,7 +529,10 @@ 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 = jsonBody.toRequestBody("application/json".toMediaType()) + val requestBody = okhttp3.RequestBody.create( + "application/json".toMediaType(), + jsonBody + ) // Try both DELETE and POST methods for (method in listOf("DELETE", "POST")) { @@ -650,9 +540,7 @@ class BeeApiClient( val requestBuilder = Request.Builder() .url("$apiUrl$endpoint") - if (apiToken.isNotBlank()) { - requestBuilder.addHeader("Authorization", "Bearer $apiToken") - } + .addHeader("Authorization", "Bearer $apiToken") val request = if (method == "DELETE") { requestBuilder.delete(requestBody).build() @@ -677,4 +565,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 18faff5..7106429 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -47,6 +47,21 @@ 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( @@ -93,7 +108,15 @@ 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("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 ) data class StorageStatus( @@ -104,94 +127,7 @@ 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, @@ -202,16 +138,11 @@ data class GnssStatus( @SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = 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 +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 ) // ── 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 6d9adcc..44a352c 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://192.168.0.10:5000", + val beeApiUrl: String = "http://10.77.0.1:5000", val adamapsApiUrl: String = "https://api.adamaps.org", - val adamapsApiKey: String = "", + val adamapsApiKey: String = "***REMOVED***", 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://192.168.0.10:5000", + beeApiUrl = prefs[KEY_BEE_URL] ?: "http://10.77.0.1:5000", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", - adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "", + adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***", 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 af427a0..9708f7d 100644 --- a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt +++ b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt @@ -202,17 +202,36 @@ 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, "Device ID unknown from API, using cached or unknown") + Log.w(TAG, "API returned unknown device ID, trying SSH fallback...") + fetchDeviceIdViaSsh() } } 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) { + 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 0b91b28..0a5fb53 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,8 +39,7 @@ import org.osmdroid.views.overlay.Marker @Composable fun DashboardScreen( vm: DashboardViewModel = viewModel(), - onNavigateToSettings: () -> Unit, - onNavigateToBeeSettings: () -> Unit = {} + onNavigateToSettings: () -> Unit ) { val deviceInfo by vm.deviceInfo.collectAsState() val gnss by vm.gnss.collectAsState() @@ -102,11 +101,8 @@ 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 = "App Settings", tint = Amber) + Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber) } } ) @@ -154,7 +150,7 @@ fun DashboardScreen( gnss?.let { GpsMapCard(it) } // Device status - deviceInfo?.let { DeviceStatusCard(it, onNavigateToBeeSettings) } + deviceInfo?.let { DeviceStatusCard(it) } } } } @@ -586,32 +582,14 @@ private fun OsmMapView(gnss: GnssData) { } @Composable -private fun DeviceStatusCard(info: BeeDeviceInfo, onNavigateToBeeSettings: () -> Unit = {}) { +private fun DeviceStatusCard(info: BeeDeviceInfo) { Card( colors = CardDefaults.cardColors(containerColor = Surface), shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(14.dp)) { - 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) - ) - } + SectionHeader("DEVICE STATUS") 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 deleted file mode 100644 index 1a4533e..0000000 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt +++ /dev/null @@ -1,972 +0,0 @@ -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 82da999..0edc86b 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,6 +10,7 @@ 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 @@ -22,6 +23,7 @@ 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.* @@ -36,6 +38,7 @@ 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) { @@ -56,6 +59,12 @@ fun DeviceStatusScreen( vm.clearUploadModeResult() } } + LaunchedEffect(sshResult) { + sshResult?.let { + snackbarHostState.showSnackbar(it) + vm.clearSshToggleResult() + } + } Scaffold( containerColor = Background, @@ -119,6 +128,9 @@ fun DeviceStatusScreen( // GPS/GNSS Status GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock) + // SSH Status + SshStatusCard(state.sshStatus, vm) + // Upload Mode UploadModeCard(state.deviceInfo?.uploadMode, vm) @@ -364,7 +376,7 @@ private fun StorageStatusCard(storage: StorageStatus) { @Composable private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) { StatusCard("GPS / GNSS") { - val hasGps = gnss?.fix == true || hasLock == true + val hasGps = gnss?.hasLock == true || hasLock == true Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -390,21 +402,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.latDeg != null && gnss.lonDeg != null) { + if (gnss.lat != null && gnss.lon != null) { Spacer(Modifier.height(4.dp)) - StatusRow("Position", "%.5f, %.5f".format(gnss.latDeg, gnss.lonDeg)) + StatusRow("Position", "%.5f, %.5f".format(gnss.lat, gnss.lon)) } - gnss.altM?.let { + gnss.alt?.let { Spacer(Modifier.height(4.dp)) StatusRow("Altitude", "%.1f m".format(it)) } - gnss.speedMs?.let { + gnss.speedKmh?.let { Spacer(Modifier.height(4.dp)) - StatusRow("Speed", "%.1f km/h".format(it * 3.6)) + StatusRow("Speed", "%.1f km/h".format(it)) } - gnss.unixMs?.let { + gnss.lastFixAgeSec?.let { Spacer(Modifier.height(4.dp)) - val ageMs = System.currentTimeMillis() - it; val ageSec = (ageMs / 1000).toInt(); StatusRow("Last Fix", "${ageSec}s ago", if (ageSec > 30) Color.Yellow else OnSurface) + StatusRow("Last Fix", "${it}s ago", if (it > 30) Color.Yellow else OnSurface) } } } @@ -460,6 +472,55 @@ 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 39d2cf5..19cd2d1 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,8 +11,11 @@ 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 @@ -24,14 +27,11 @@ 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,6 +49,14 @@ 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) } @@ -59,21 +67,6 @@ 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) { @@ -82,6 +75,24 @@ 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, @@ -106,7 +117,7 @@ fun SettingsScreen( actions = { IconButton(onClick = { vm.save( - VarroaSettings( + currentSettings.copy( beeApiUrl = beeApiUrl.trim(), adamapsApiUrl = adamapsApiUrl.trim(), adamapsApiKey = adamapsApiKey.trim(), @@ -131,6 +142,15 @@ 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, @@ -177,129 +197,33 @@ 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://192.168.0.10:5000" + hint = "http://10.77.0.1: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", @@ -361,6 +285,265 @@ 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( @@ -388,8 +571,7 @@ private fun SettingsField( value: String, onValueChange: (String) -> Unit, hint: String = "", - numeric: Boolean = false, - keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text + numeric: Boolean = false ) { OutlinedTextField( value = value, @@ -397,7 +579,8 @@ 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 = KeyboardOptions(keyboardType = keyboardType), + keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) + else KeyboardOptions.Default, modifier = Modifier.fillMaxWidth(), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Amber, @@ -580,5 +763,3 @@ 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 deleted file mode 100644 index 0a33a53..0000000 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt +++ /dev/null @@ -1,223 +0,0 @@ -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 09ab8be..ae6e7fa 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt @@ -10,6 +10,7 @@ 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 @@ -25,6 +26,7 @@ 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 ) @@ -47,6 +49,9 @@ 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) @@ -54,6 +59,10 @@ 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 @@ -62,12 +71,14 @@ 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 @@ -78,8 +89,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { wifiConfig = wifi, storageStatus = storage, gnssStatus = gnss, + sshStatus = ssh, plugins = plugins, - error = if (!isConnected) "Cannot connect to Bee device" else null + error = if (!isConnected) "Cannot connect to AdaCam device" else null ) Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}") @@ -129,6 +141,24 @@ 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 } @@ -136,4 +166,8 @@ 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 8e16e29..5d1ef53 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt @@ -64,7 +64,6 @@ 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 f777e22..0f2972b 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 deleted file mode 100644 index 29254bf..0000000 Binary files a/app/src/main/res/drawable/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ea5eaeb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 8f88f75..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null 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 deleted file mode 100644 index 8f88f75..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 2a38a94..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null 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 deleted file mode 100644 index 2a38a94..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 8b51cb4..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null 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 deleted file mode 100644 index 8b51cb4..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 7f69984..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null 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 deleted file mode 100644 index 7f69984..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 7c51289..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null 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 deleted file mode 100644 index 7c51289..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index e7f24f7..81d34f7 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,12 +1,7 @@ - - - - - 192.168.0.10 + 192.168.0.10 + diff --git a/blackbox/air-aggregator.service b/blackbox/air-aggregator.service deleted file mode 100644 index 17be223..0000000 --- a/blackbox/air-aggregator.service +++ /dev/null @@ -1,21 +0,0 @@ -[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 deleted file mode 100644 index 784c17b..0000000 --- a/blackbox/air_aggregator.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/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 deleted file mode 100644 index 2f4be27..0000000 --- a/docs/AIR-QUALITY-INTEGRATION.md +++ /dev/null @@ -1,369 +0,0 @@ -# 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 deleted file mode 100644 index 852046d..0000000 --- a/docs/BEE-CAMERA.md +++ /dev/null @@ -1,467 +0,0 @@ -# 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 deleted file mode 100644 index a8ff7b1..0000000 --- a/docs/adamaps-index-preview.html +++ /dev/null @@ -1,545 +0,0 @@ - - - - - - AdaMaps — Verified Sign Map - - - - - - - -
- - - - - - -