Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 01b031cec3 |
|
|
@ -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
|
||||
46
README.md
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ?: "—"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 71 KiB |
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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: '© 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>
|
||||