Compare commits

..

1 commit

Author SHA1 Message Date
01b031cec3 feat: adacam migration — update IP, add pairing, bearer auth, wifi/ssh config, remove tunnel and cmd
- Changed default API URL from 192.168.0.10 to 10.77.0.1
- Added bearer token authentication for POST endpoints
- Added /pair endpoint support for device pairing
- Added GET/POST /api/1/ssh/status and /api/1/ssh/toggle
- Added WiFi config via /api/1/wifi/connect
- Removed JSch dependency (no more SSH from app)
- Removed /api/1/cmd endpoint references (CVE)
- Added PairResponse, SshStatus, WifiStatus models
- Added deviceSerial, apiToken, isPaired to settings
- Added deriveApiToken() function for token derivation
- Updated SettingsViewModel with pairing/wifi/ssh methods
- Updated SettingsScreen with pairing UI, WiFi config, SSH toggle
- Updated DeviceStatusScreen with SSH toggle card
2026-03-14 11:50:00 -07:00
35 changed files with 587 additions and 3423 deletions

View file

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

View file

@ -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=<see vault>
VARROA_KEY_PASSWORD=<see vault>
./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.

View file

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

View file

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

View file

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

View file

@ -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<String> = 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<String> {
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<String> = 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<com.adamaps.varroa.data.WifiStatus> = 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<WifiClientSettings> = 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<String> {
val json = gson.toJson(settings)
return postRaw("/api/1/wifiClient/settings", json)
}
suspend fun setWifiEnabled(enabled: Boolean): ApiResult<String> {
val json = """{"enabled": $enabled}"""
return postRaw("/api/1/wifiClient/enable", json)
}
suspend fun scanWifi(): ApiResult<List<WifiNetwork>> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifiClient/scan")) {
is ApiResult.Success -> try {
val type = object : TypeToken<List<WifiNetwork>>() {}.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<String> = 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<String> {
val json = gson.toJson(mapOf("enable" to enabled))
return postRaw("/api/1/ssh/toggle", json)
}
suspend fun setSshEnabled(enabled: Boolean): ApiResult<String> = 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<String> {
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<String> = 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<String> = 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<String> {
val json = """{"mode": "$mode"}"""
return postRaw("/api/1/config/uploadMode", json)
}
// ── Config ────────────────────────────────────────────────────────────────
suspend fun getConfig(): ApiResult<BeeConfig> = 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<CacheStatus> = 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<FrameKmTotal> = 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<PluginState> = 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<Long>): ApiResult<String> = 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}")
}
}
}
}

View file

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

View file

@ -15,9 +15,9 @@ import java.security.MessageDigest
private val Context.dataStore: DataStore<Preferences> 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<VarroaSettings> = 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,

View file

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

View file

@ -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<Pair<String, String>> = listOf(
"Firmware" to (info.firmwareVersion ?: ""),

View file

@ -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<WifiNetwork>,
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<PluginState>,
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()
}
}

View file

@ -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@<device-ip>",
color = Color.Green,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
}
}
}
@Composable
private fun PluginsCard(plugins: List<BeePlugin>) {
StatusCard("PLUGINS") {

View file

@ -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@<device-ip>" 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@<device-ip>",
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(
)
}
}

View file

@ -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<WifiNetwork> = emptyList(),
val cacheStatus: CacheStatus? = null,
val frameKmTotal: FrameKmTotal? = null,
val gnssStatus: GnssStatus? = null,
val uploadMode: String? = null,
val config: BeeConfig? = null,
val plugins: List<PluginState> = 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<BeeSettingsState> = _state.asStateFlow()
// Toast/snackbar messages
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _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
}
}

View file

@ -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<BeePlugin> = emptyList(),
val error: String? = null
)
@ -47,6 +49,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
private val _uploadModeResult = MutableStateFlow<String?>(null)
val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow()
private val _sshToggleResult = MutableStateFlow<String?>(null)
val sshToggleResult: StateFlow<String?> = _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
}
}

View file

@ -64,7 +64,6 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val _wifiConnectResult = MutableStateFlow<String?>(null)
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
init {
// Initialize BeeApiClient with stored settings and token
viewModelScope.launch {

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#000000" />
<solid android:color="#0A0A0A" />
</shape>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F59E0B"
android:pathData="M54,20 C35.2,20 20,35.2 20,54 C20,72.8 35.2,88 54,88 C72.8,88 88,72.8 88,54 C88,35.2 72.8,20 54,20 Z M54,30 C67.3,30 78,40.7 78,54 C78,67.3 67.3,78 54,78 C40.7,78 30,67.3 30,54 C30,40.7 40.7,30 54,30 Z" />
<path
android:fillColor="#F59E0B"
android:pathData="M54,40 L58,50 L68,50 L60,57 L63,68 L54,61 L45,68 L48,57 L40,50 L50,50 Z" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View file

@ -1,12 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- HTTPS strict everywhere by default. -->
<base-config cleartextTrafficPermitted="false" />
<!-- Bee AP runs HTTP on the device-AP subnet — there's no real
alternative without breaking the Bee protocol. Scope the
cleartext exception to just that one host. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">192.168.0.10</domain>
<domain includeSubdomains="true">192.168.0.10</domain>
</domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

View file

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

View file

@ -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", "<your-adamaps-ingest-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()

View file

@ -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
<div class="air-quality-legend">
<h4>Air Quality Index</h4>
<div><span class="color green"></span> 0-50 Excellent</div>
<div><span class="color yellow"></span> 51-100 Good</div>
<div><span class="color orange"></span> 101-150 Moderate</div>
<div><span class="color red"></span> 151-200 Unhealthy</div>
<div><span class="color purple"></span> 201-300 Very unhealthy</div>
<div><span class="color maroon"></span> 301+ Hazardous</div>
</div>
```
## 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.

View file

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

View file

@ -1,545 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AdaMaps — Verified Sign Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d0d0d;
color: #e0e0e0;
font-family: 'Courier New', monospace;
height: 100vh;
display: flex;
flex-direction: column;
}
#header {
background: #111;
border-bottom: 1px solid #222;
padding: 10px 18px;
display: flex;
align-items: center;
gap: 12px;
z-index: 1000;
flex-wrap: wrap;
}
#header h1 {
font-size: 1.1rem;
font-weight: bold;
color: #00e5ff;
letter-spacing: 2px;
text-transform: uppercase;
}
#status {
margin-left: auto;
font-size: 0.75rem;
color: #555;
}
#status.live { color: #00e5ff; }
#status.error { color: #ff4444; }
#map {
flex: 1;
background: #111;
}
.leaflet-tile-pane { filter: brightness(0.7) invert(1) hue-rotate(180deg) saturate(0.6); }
.leaflet-popup-content-wrapper {
background: #1a1a1a;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
}
.leaflet-popup-tip { background: #1a1a1a; }
.leaflet-popup-content { margin: 10px 14px; }
.popup-title { color: #00e5ff; font-weight: bold; margin-bottom: 4px; }
.popup-row { color: #aaa; }
.popup-row span { color: #e0e0e0; }
.popup-verified { color: #00ff88; font-weight: bold; }
.popup-unverified { color: #ffaa00; }
.popup-img {
width: 100%;
max-width: 240px;
border-radius: 3px;
margin-top: 8px;
border: 1px solid #333;
display: block;
cursor: pointer;
}
.popup-img:hover { border-color: #00e5ff; }
/* Marker styles */
.verified-marker {
width: 14px;
height: 14px;
background: #00ff88;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 6px rgba(0,255,136,0.5);
}
.unverified-marker {
width: 10px;
height: 10px;
background: #ffaa00;
border-radius: 50%;
border: 2px solid #fff;
opacity: 0.7;
}
.multi-obs-marker {
width: 16px;
height: 16px;
background: #00e5ff;
border-radius: 50%;
border: 2px solid #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: bold;
color: #000;
}
#count-badge {
background: #00e5ff;
color: #000;
font-size: 0.7rem;
font-weight: bold;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 1px;
}
#verified-badge {
background: #00ff88;
color: #000;
font-size: 0.7rem;
font-weight: bold;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 1px;
}
.toggle-btn {
background: #222;
border: 1px solid #444;
color: #e0e0e0;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
font-family: 'Courier New', monospace;
}
.toggle-btn:hover {
background: #333;
}
.toggle-btn.active {
background: #00e5ff;
color: #000;
border-color: #00e5ff;
}
.marker-cluster-small {
background-color: rgba(0, 229, 255, 0.4);
}
.marker-cluster-small div {
background-color: rgba(0, 229, 255, 0.8);
color: #000;
font-weight: bold;
}
.marker-cluster-medium {
background-color: rgba(0, 200, 255, 0.4);
}
.marker-cluster-medium div {
background-color: rgba(0, 200, 255, 0.8);
color: #000;
font-weight: bold;
}
.marker-cluster-large {
background-color: rgba(0, 150, 255, 0.4);
}
.air-legend {
background: #1a1a1a;
border: 1px solid #333;
padding: 8px 12px;
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: #ccc;
border-radius: 4px;
}
.legend-title { color: #00e5ff; font-weight: bold; margin-bottom: 4px; }
.legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-row span {
display: inline-block; width: 12px; height: 12px;
border-radius: 2px; flex-shrink: 0;
}
.marker-cluster-large div {
background-color: rgba(0, 150, 255, 0.8);
color: #000;
font-weight: bold;
}
</style>
</head>
<body>
<div id="header">
<h1>AdaMaps</h1>
<span id="count-badge">0 signs</span>
<span id="verified-badge">0 verified</span>
<button class="toggle-btn active" id="btn-signs">Signs</button>
<button class="toggle-btn" id="btn-raw">Raw</button>
<button class="toggle-btn" id="btn-verified">Verified Only</button>
<span style="color:#333;margin:0 4px">|</span>
<button class="toggle-btn" id="btn-aqi">AQI Heat</button>
<button class="toggle-btn" id="btn-pm25">PM2.5 Heat</button>
<span id="status">loading...</span>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
const map = L.map('map', {
center: [33.88, -118.0],
zoom: 10,
zoomControl: true,
attributionControl: true
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
const statusEl = document.getElementById('status');
const countBadge = document.getElementById('count-badge');
const verifiedBadge = document.getElementById('verified-badge');
const btnSigns = document.getElementById('btn-signs');
const btnRaw = document.getElementById('btn-raw');
const btnVerified = document.getElementById('btn-verified');
const btnAqi = document.getElementById('btn-aqi');
const btnPm25 = document.getElementById('btn-pm25');
const markerCluster = L.markerClusterGroup({
chunkedLoading: true,
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
disableClusteringAtZoom: 16
});
map.addLayer(markerCluster);
let currentMode = 'signs';
let verifiedOnly = false;
function createVerifiedIcon() {
return L.divIcon({
className: '',
html: '<div class="verified-marker"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7],
popupAnchor: [0, -10]
});
}
function createUnverifiedIcon() {
return L.divIcon({
className: '',
html: '<div class="unverified-marker"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
popupAnchor: [0, -8]
});
}
function createMultiObsIcon(count) {
return L.divIcon({
className: '',
html: `<div class="multi-obs-marker">${count}</div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
popupAnchor: [0, -10]
});
}
function formatTimestamp(ts) {
if (!ts) return 'unknown';
try { return new Date(ts).toLocaleString(); } catch(e) { return ts; }
}
function buildSignPopup(s) {
const verifiedClass = s.verified ? 'popup-verified' : 'popup-unverified';
const verifiedText = s.verified ? '✓ VERIFIED' : 'unverified';
return `
<div class="popup-title">${s.sign_type || 'Unknown Sign'}</div>
<div class="popup-row ${verifiedClass}">${verifiedText}</div>
<div class="popup-row">Observations: <span>${s.observations}</span></div>
<div class="popup-row">Devices: <span>${s.devices}</span></div>
<div class="popup-row">Confidence: <span>${(s.confidence * 100).toFixed(1)}%</span></div>
<div class="popup-row">Heading: <span>${s.heading || '—'}</span></div>
<div class="popup-row">First seen: <span>${formatTimestamp(s.first_seen)}</span></div>
<div class="popup-row">Last seen: <span>${formatTimestamp(s.last_seen)}</span></div>
<div class="popup-row">Coords: <span>${s.lat.toFixed(6)}, ${s.lon.toFixed(6)}</span></div>
${s.image_url ? `<img class="popup-img" src="${s.image_url}" loading="lazy" alt="detection photo" onclick="window.open('${s.image_url}','_blank')">` : ''}
`;
}
function buildDetectionPopup(d) {
return `
<div class="popup-title">${d.class_label || 'Detection'}</div>
<div class="popup-row">Device: <span>${d.device_id || '—'}</span></div>
<div class="popup-row">Time: <span>${formatTimestamp(d.ts)}</span></div>
<div class="popup-row">Confidence: <span>${d.confidence ? (d.confidence * 100).toFixed(1) + '%' : '—'}</span></div>
<div class="popup-row">Coords: <span>${d.lat?.toFixed(6)}, ${d.lon?.toFixed(6)}</span></div>
${d.image_url ? `<img class="popup-img" src="${d.image_url}" loading="lazy" alt="detection photo" onclick="window.open('${d.image_url}','_blank')">` : ''}
`;
}
async function fetchAndPlot() {
statusEl.textContent = 'fetching...';
statusEl.className = '';
try {
let url, data;
if (currentMode === 'signs') {
url = verifiedOnly
? 'https://api.adamaps.org/api/signs?verified=true&limit=5000'
: 'https://api.adamaps.org/api/signs?limit=5000';
} else {
url = 'https://api.adamaps.org/api/detections?limit=5000';
}
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) {
if (res.status === 404 && currentMode === 'signs') {
statusEl.textContent = 'Run clustering first';
statusEl.className = 'error';
return;
}
throw new Error(`HTTP ${res.status}`);
}
data = await res.json();
markerCluster.clearLayers();
const bounds = [];
let verifiedCount = 0;
if (currentMode === 'signs') {
data.forEach(s => {
if (s.lat == null || s.lon == null) return;
let icon;
if (s.verified) {
verifiedCount++;
icon = s.observations > 1 ? createMultiObsIcon(s.observations) : createVerifiedIcon();
} else {
icon = createUnverifiedIcon();
}
const marker = L.marker([s.lat, s.lon], { icon });
marker.bindPopup(buildSignPopup(s));
markerCluster.addLayer(marker);
bounds.push([s.lat, s.lon]);
});
countBadge.textContent = `${data.length.toLocaleString()} sign${data.length !== 1 ? 's' : ''}`;
verifiedBadge.textContent = `${verifiedCount} verified`;
} else {
data.forEach(d => {
const lat = d.lat ?? d.latitude;
const lng = d.lng ?? d.longitude ?? d.lon;
if (lat == null || lng == null) return;
const marker = L.marker([lat, lng], { icon: createUnverifiedIcon() });
marker.bindPopup(buildDetectionPopup(d));
markerCluster.addLayer(marker);
bounds.push([lat, lng]);
});
countBadge.textContent = `${data.length.toLocaleString()} detection${data.length !== 1 ? 's' : ''}`;
verifiedBadge.textContent = '—';
}
statusEl.textContent = `live · ${new Date().toLocaleTimeString()}`;
statusEl.className = 'live';
if (bounds.length > 0 && map.getZoom() < 8) {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
}
} catch(err) {
statusEl.textContent = `error: ${err.message}`;
statusEl.className = 'error';
console.error('AdaMaps fetch error:', err);
}
}
// Button handlers
btnSigns.addEventListener('click', () => {
currentMode = 'signs';
verifiedOnly = false;
btnSigns.classList.add('active');
btnRaw.classList.remove('active');
btnVerified.classList.remove('active');
fetchAndPlot();
});
btnRaw.addEventListener('click', () => {
currentMode = 'raw';
verifiedOnly = false;
btnRaw.classList.add('active');
btnSigns.classList.remove('active');
btnVerified.classList.remove('active');
fetchAndPlot();
});
btnVerified.addEventListener('click', () => {
currentMode = 'signs';
verifiedOnly = true;
btnVerified.classList.add('active');
btnSigns.classList.remove('active');
btnRaw.classList.remove('active');
fetchAndPlot();
});
fetchAndPlot();
setInterval(fetchAndPlot, 60000);
// AQI color gradient: green→yellow→orange→red→purple
const AQI_GRADIENT = {
0.0: '#00e400', // Good (0-50)
0.17: '#ffff00', // Moderate (51-100)
0.33: '#ff7e00', // Unhealthy for sensitive (101-150)
0.50: '#ff0000', // Unhealthy (151-200)
0.67: '#8f3f97', // Very unhealthy (201-300)
1.0: '#7e0023', // Hazardous (301+)
};
const PM25_GRADIENT = {
0.0: '#00e5ff',
0.25: '#00ff88',
0.50: '#ffff00',
0.75: '#ff7e00',
1.0: '#ff0000',
};
function aqiLabel(aqi) {
if (aqi == null) return '—';
if (aqi <= 50) return `${aqi} Good`;
if (aqi <= 100) return `${aqi} Moderate`;
if (aqi <= 150) return `${aqi} Unhealthy (Sensitive)`;
if (aqi <= 200) return `${aqi} Unhealthy`;
if (aqi <= 300) return `${aqi} Very Unhealthy`;
return `${aqi} Hazardous`;
}
function aqiColor(aqi) {
if (aqi == null) return '#888';
if (aqi <= 50) return '#00e400';
if (aqi <= 100) return '#ffff00';
if (aqi <= 150) return '#ff7e00';
if (aqi <= 200) return '#ff0000';
if (aqi <= 300) return '#8f3f97';
return '#7e0023';
}
let airHeatAqi = null;
let airHeatPm25 = null;
let airMode = null; // null | 'aqi' | 'pm25'
async function fetchAirOverlay(metric) {
try {
const res = await fetch(
`https://api.adamaps.org/api/air/heatmap?metric=${metric}&hours=24`,
{ cache: 'no-store' }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json(); // [[lat, lon, intensity], ...]
} catch (e) {
console.warn('Air overlay fetch failed:', e);
return [];
}
}
async function toggleAirOverlay(metric) {
// Clear both layers first
if (airHeatAqi) { map.removeLayer(airHeatAqi); airHeatAqi = null; }
if (airHeatPm25) { map.removeLayer(airHeatPm25); airHeatPm25 = null; }
// Toggle off if same metric clicked again
if (airMode === metric) {
airMode = null;
btnAqi.classList.remove('active');
btnPm25.classList.remove('active');
statusEl.textContent = 'live · ' + new Date().toLocaleTimeString();
if (airLegend) { map.removeControl(airLegend); airLegend = null; }
return;
}
airMode = metric;
btnAqi.classList.toggle('active', metric === 'aqi');
btnPm25.classList.toggle('active', metric === 'pm25');
statusEl.textContent = `loading ${metric.toUpperCase()} overlay...`;
const points = await fetchAirOverlay(metric);
if (!points.length) {
statusEl.textContent = 'no air quality data yet';
airMode = null;
btnAqi.classList.remove('active');
btnPm25.classList.remove('active');
return;
}
const gradient = metric === 'aqi' ? AQI_GRADIENT : PM25_GRADIENT;
const layer = L.heatLayer(points, {
radius: 25,
blur: 20,
maxZoom: 17,
gradient,
minOpacity: 0.3,
});
if (metric === 'aqi') airHeatAqi = layer;
else airHeatPm25 = layer;
layer.addTo(map);
statusEl.textContent = `${metric.toUpperCase()} overlay · ${points.length.toLocaleString()} pts`;
// Show legend when overlay is active
if (!airLegend) { airLegend = buildAirLegend(); airLegend.addTo(map); }
}
// ── Legend ────────────────────────────────────────────────────────────────────
function buildAirLegend() {
const legend = L.control({ position: 'bottomright' });
legend.onAdd = () => {
const div = L.DomUtil.create('div', 'air-legend');
div.innerHTML = `
<div class="legend-title">AQI / PM2.5</div>
<div class="legend-row"><span style="background:#00e400"></span> Good (0-50)</div>
<div class="legend-row"><span style="background:#ffff00"></span> Moderate (51-100)</div>
<div class="legend-row"><span style="background:#ff7e00"></span> Unhealthy* (101-150)</div>
<div class="legend-row"><span style="background:#ff0000"></span> Unhealthy (151-200)</div>
<div class="legend-row"><span style="background:#8f3f97"></span> Very Unhealthy (201-300)</div>
<div class="legend-row"><span style="background:#7e0023"></span> Hazardous (301+)</div>
`;
return div;
};
return legend;
}
let airLegend = null;
// Call this after map init — wires up buttons and legend
function initAirOverlay() {
btnAqi.addEventListener('click', () => toggleAirOverlay('aqi'));
btnPm25.addEventListener('click', () => toggleAirOverlay('pm25'));
}
// Init air overlay buttons + legend after map is ready
initAirOverlay();
</script>
</body>
</html>