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"
|
namespace = "com.adamaps.varroa"
|
||||||
compileSdk = 34
|
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 {
|
defaultConfig {
|
||||||
applicationId = "com.adamaps.varroa"
|
applicationId = "com.adamaps.varroa"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 15
|
versionCode = 14
|
||||||
versionName = "1.8.0"
|
versionName = "1.7.9"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|
@ -40,7 +24,6 @@ android {
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
|
|
@ -87,9 +70,17 @@ dependencies {
|
||||||
implementation(libs.osmdroid.android)
|
implementation(libs.osmdroid.android)
|
||||||
implementation(libs.datastore.preferences)
|
implementation(libs.datastore.preferences)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
// Room (local database)
|
||||||
implementation(libs.room.runtime)
|
implementation(libs.room.runtime)
|
||||||
implementation(libs.room.ktx)
|
implementation(libs.room.ktx)
|
||||||
ksp(libs.room.compiler)
|
ksp(libs.room.compiler)
|
||||||
|
// WorkManager (background uploads)
|
||||||
implementation(libs.work.runtime.ktx)
|
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)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.adamaps.varroa.ui.dashboard.DashboardScreen
|
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.DeviceStatusScreen
|
||||||
import com.adamaps.varroa.ui.settings.SettingsScreen
|
import com.adamaps.varroa.ui.settings.SettingsScreen
|
||||||
|
|
||||||
|
|
@ -13,7 +12,6 @@ object Routes {
|
||||||
const val DASHBOARD = "dashboard"
|
const val DASHBOARD = "dashboard"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val DEVICE_STATUS = "device_status"
|
const val DEVICE_STATUS = "device_status"
|
||||||
const val BEE_SETTINGS = "bee_settings"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -21,10 +19,7 @@ fun VarroaNavGraph() {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
NavHost(navController = nav, startDestination = Routes.DASHBOARD) {
|
NavHost(navController = nav, startDestination = Routes.DASHBOARD) {
|
||||||
composable(Routes.DASHBOARD) {
|
composable(Routes.DASHBOARD) {
|
||||||
DashboardScreen(
|
DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) })
|
||||||
onNavigateToSettings = { nav.navigate(Routes.SETTINGS) },
|
|
||||||
onNavigateToBeeSettings = { nav.navigate(Routes.BEE_SETTINGS) }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
|
|
@ -35,8 +30,5 @@ fun VarroaNavGraph() {
|
||||||
composable(Routes.DEVICE_STATUS) {
|
composable(Routes.DEVICE_STATUS) {
|
||||||
DeviceStatusScreen(onBack = { nav.popBackStack() })
|
DeviceStatusScreen(onBack = { nav.popBackStack() })
|
||||||
}
|
}
|
||||||
composable(Routes.BEE_SETTINGS) {
|
|
||||||
BeeSettingsScreen(onBack = { nav.popBackStack() })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ private object AdaMapsDns : Dns {
|
||||||
|
|
||||||
class AdaMapsApiClient(
|
class AdaMapsApiClient(
|
||||||
private var apiUrl: String = "https://api.adamaps.org",
|
private var apiUrl: String = "https://api.adamaps.org",
|
||||||
private var apiKey: String = ""
|
private var apiKey: String = "***REMOVED***"
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "VarroaAdaAPI"
|
private const val TAG = "VarroaAdaAPI"
|
||||||
|
|
@ -57,7 +57,7 @@ class AdaMapsApiClient(
|
||||||
|
|
||||||
fun updateConfig(url: String, key: String) {
|
fun updateConfig(url: String, key: String) {
|
||||||
val oldUrl = apiUrl
|
val oldUrl = apiUrl
|
||||||
val oldKeyPrefix = apiKey.length
|
val oldKeyPrefix = apiKey.take(8)
|
||||||
apiUrl = url.trimEnd('/')
|
apiUrl = url.trimEnd('/')
|
||||||
apiKey = key
|
apiKey = key
|
||||||
Log.d(TAG, "AdaMaps config updated - URL: $oldUrl -> $apiUrl, Key: ${oldKeyPrefix}... -> ${key.take(8)}...")
|
Log.d(TAG, "AdaMaps config updated - URL: $oldUrl -> $apiUrl, Key: ${oldKeyPrefix}... -> ${key.take(8)}...")
|
||||||
|
|
@ -80,7 +80,7 @@ class AdaMapsApiClient(
|
||||||
.post(body)
|
.post(body)
|
||||||
.build()
|
.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 ->
|
client.newCall(req).execute().use { resp ->
|
||||||
val respBody = resp.body?.string() ?: ""
|
val respBody = resp.body?.string() ?: ""
|
||||||
Log.d(TAG, "HTTP ${resp.code} ${resp.message} - response length: ${respBody.length}")
|
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.net.NetworkCapabilities
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.adamaps.varroa.data.ApiResult
|
import com.adamaps.varroa.data.ApiResult
|
||||||
import com.adamaps.varroa.data.BeeConfig
|
|
||||||
import com.adamaps.varroa.data.BeeDetection
|
import com.adamaps.varroa.data.BeeDetection
|
||||||
import com.adamaps.varroa.data.BeeDeviceInfo
|
import com.adamaps.varroa.data.BeeDeviceInfo
|
||||||
import com.adamaps.varroa.data.BeePlugin
|
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.GnssData
|
||||||
import com.adamaps.varroa.data.GnssStatus
|
import com.adamaps.varroa.data.GnssStatus
|
||||||
import com.adamaps.varroa.data.PairResponse
|
import com.adamaps.varroa.data.PairResponse
|
||||||
import com.adamaps.varroa.data.PluginState
|
|
||||||
import com.adamaps.varroa.data.SshStatus
|
import com.adamaps.varroa.data.SshStatus
|
||||||
import com.adamaps.varroa.data.StorageStatus
|
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.WifiConfig
|
||||||
import com.adamaps.varroa.data.WifiNetwork
|
|
||||||
import com.adamaps.varroa.data.WifiStatus
|
import com.adamaps.varroa.data.WifiStatus
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
@ -30,11 +23,10 @@ import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class BeeApiClient(
|
class BeeApiClient(
|
||||||
private var apiUrl: String = "http://192.168.0.10:5000"
|
private var apiUrl: String = "http://10.77.0.1:5000"
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "VarroaBeeAPI"
|
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.
|
* Check if AdaCam is reachable.
|
||||||
* Updates internal connection state.
|
* Updates internal connection state.
|
||||||
|
|
@ -282,6 +245,7 @@ class BeeApiClient(
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})")
|
Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})")
|
||||||
|
// Connection failed, mark as offline
|
||||||
if (r.message.contains("timeout", ignoreCase = true) ||
|
if (r.message.contains("timeout", ignoreCase = true) ||
|
||||||
r.message.contains("connect", ignoreCase = true) ||
|
r.message.contains("connect", ignoreCase = true) ||
|
||||||
r.message.contains("refused", ignoreCase = true) ||
|
r.message.contains("refused", ignoreCase = true) ||
|
||||||
|
|
@ -340,60 +304,29 @@ class BeeApiClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setWifiConfig(ssid: String, password: String): ApiResult<String> {
|
suspend fun setWifiConfig(ssid: String, password: String): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||||
val json = gson.toJson(mapOf("ssid" to ssid, "password" to password))
|
try {
|
||||||
return postRaw("/api/1/wifi/connect", json)
|
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)
|
client.newCall(request).execute().use { resp ->
|
||||||
suspend fun getWifiClientStatus(): ApiResult<com.adamaps.varroa.data.WifiStatus> = withContext(Dispatchers.IO) {
|
val body = resp.body?.string() ?: ""
|
||||||
when (val r = getRaw("/api/1/wifiClient/status")) {
|
if (resp.isSuccessful) {
|
||||||
is ApiResult.Success -> try {
|
ApiResult.Success(body)
|
||||||
ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WifiStatus::class.java))
|
} else {
|
||||||
} catch (e: Exception) {
|
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
|
||||||
ApiResult.Error("Parse error: ${e.message}")
|
}
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> r
|
} catch (e: Exception) {
|
||||||
}
|
ApiResult.Error(e.message ?: "Unknown error")
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,14 +343,30 @@ class BeeApiClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setSshEnabled(enabled: Boolean): ApiResult<String> {
|
suspend fun setSshEnabled(enabled: Boolean): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||||
val json = gson.toJson(mapOf("enable" to enabled))
|
try {
|
||||||
return postRaw("/api/1/ssh/toggle", json)
|
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)
|
client.newCall(request).execute().use { resp ->
|
||||||
suspend fun getDeviceIdViaSsh(): ApiResult<String> {
|
val body = resp.body?.string() ?: ""
|
||||||
return ApiResult.Error("SSH device ID lookup not implemented")
|
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 ─────────────────────────────────────────────────
|
// ── 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) {
|
client.newCall(request).execute().use { resp ->
|
||||||
when (val r = getRaw("/api/1/config/uploadMode")) {
|
val body = resp.body?.string() ?: ""
|
||||||
is ApiResult.Success -> try {
|
if (resp.isSuccessful) {
|
||||||
val trimmed = r.data.trim().trim('"')
|
ApiResult.Success(body)
|
||||||
if (trimmed.startsWith("{")) {
|
|
||||||
val obj = gson.fromJson(r.data, UploadModeResponse::class.java)
|
|
||||||
ApiResult.Success(obj.currentMode() ?: "UNKNOWN")
|
|
||||||
} else {
|
} 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 ────────────────────────────────────────────────────────────
|
// ── Camera API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -631,6 +517,7 @@ class BeeApiClient(
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}")
|
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) {
|
private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val jsonBody = gson.toJson(mapOf("ids" to landmarkIds))
|
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
|
// Try both DELETE and POST methods
|
||||||
for (method in listOf("DELETE", "POST")) {
|
for (method in listOf("DELETE", "POST")) {
|
||||||
|
|
@ -650,9 +540,7 @@ class BeeApiClient(
|
||||||
|
|
||||||
val requestBuilder = Request.Builder()
|
val requestBuilder = Request.Builder()
|
||||||
.url("$apiUrl$endpoint")
|
.url("$apiUrl$endpoint")
|
||||||
if (apiToken.isNotBlank()) {
|
.addHeader("Authorization", "Bearer $apiToken")
|
||||||
requestBuilder.addHeader("Authorization", "Bearer $apiToken")
|
|
||||||
}
|
|
||||||
|
|
||||||
val request = if (method == "DELETE") {
|
val request = if (method == "DELETE") {
|
||||||
requestBuilder.delete(requestBody).build()
|
requestBuilder.delete(requestBody).build()
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,21 @@ data class BeeDeviceInfo(
|
||||||
@SerializedName("ssid") val ssid: String? = null
|
@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 ────────────────────────────────────────────────────────────
|
// ── ADAMaps ingest ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class AdaMapsDetection(
|
data class AdaMapsDetection(
|
||||||
|
|
@ -93,7 +108,15 @@ data class WifiConfig(
|
||||||
@SerializedName("ssid") val ssid: String? = null,
|
@SerializedName("ssid") val ssid: String? = null,
|
||||||
@SerializedName("password") val password: String? = null,
|
@SerializedName("password") val password: String? = null,
|
||||||
@SerializedName("connected") val connected: Boolean? = 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(
|
data class StorageStatus(
|
||||||
|
|
@ -104,94 +127,7 @@ data class StorageStatus(
|
||||||
@SerializedName("recording_hours_available") val recordingHoursAvailable: Double? = null
|
@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(
|
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("has_lock") val hasLock: Boolean? = null,
|
||||||
@SerializedName("satellites") val satellites: Int? = null,
|
@SerializedName("satellites") val satellites: Int? = null,
|
||||||
@SerializedName("hdop") val hdop: Double? = null,
|
@SerializedName("hdop") val hdop: Double? = null,
|
||||||
|
|
@ -202,16 +138,11 @@ data class GnssStatus(
|
||||||
@SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = null
|
@SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PluginState(
|
data class BeePlugin(
|
||||||
val name: String,
|
@SerializedName("name") val name: String? = null,
|
||||||
val enabled: Boolean?,
|
@SerializedName("version") val version: String? = null,
|
||||||
val error: String? = null
|
@SerializedName("enabled") val enabled: Boolean? = null,
|
||||||
)
|
@SerializedName("running") val running: Boolean? = null
|
||||||
|
|
||||||
data class FrameKmTotal(
|
|
||||||
@SerializedName("total") val total: Long? = null,
|
|
||||||
@SerializedName("totalBytes") val totalBytes: Long? = null,
|
|
||||||
@SerializedName("count") val count: Int? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── App state ─────────────────────────────────────────────────────────────────
|
// ── App state ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ import java.security.MessageDigest
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings")
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "varroa_settings")
|
||||||
|
|
||||||
data class VarroaSettings(
|
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 adamapsApiUrl: String = "https://api.adamaps.org",
|
||||||
val adamapsApiKey: String = "",
|
val adamapsApiKey: String = "***REMOVED***",
|
||||||
val pollIntervalSeconds: Int = 30,
|
val pollIntervalSeconds: Int = 30,
|
||||||
val cameraEndpoint: String = "/api/1/camera/frame",
|
val cameraEndpoint: String = "/api/1/camera/frame",
|
||||||
val cameraRefreshSeconds: Int = 30,
|
val cameraRefreshSeconds: Int = 30,
|
||||||
|
|
@ -60,9 +60,9 @@ class SettingsDataStore(private val context: Context) {
|
||||||
|
|
||||||
val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
|
val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
|
||||||
VarroaSettings(
|
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",
|
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,
|
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
|
||||||
cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame",
|
cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame",
|
||||||
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,
|
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,
|
||||||
|
|
|
||||||
|
|
@ -202,17 +202,36 @@ class BeeCollectorService : LifecycleService() {
|
||||||
settingsStore.updateCachedDeviceId(deviceId)
|
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"})")
|
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 {
|
} 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 -> {
|
is ApiResult.Error -> {
|
||||||
Log.e(TAG, "Failed to get device ID via API: ${result.message}, code: ${result.code}")
|
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"
|
_currentDeviceId.value = "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun startPollLoop(intervalSeconds: Int) {
|
|
||||||
|
|
||||||
|
private fun startPollLoop(intervalSeconds: Int) {
|
||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
Log.d(TAG, "Previous poll job cancelled")
|
Log.d(TAG, "Previous poll job cancelled")
|
||||||
pollJob = lifecycleScope.launch {
|
pollJob = lifecycleScope.launch {
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,7 @@ import org.osmdroid.views.overlay.Marker
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
vm: DashboardViewModel = viewModel(),
|
vm: DashboardViewModel = viewModel(),
|
||||||
onNavigateToSettings: () -> Unit,
|
onNavigateToSettings: () -> Unit
|
||||||
onNavigateToBeeSettings: () -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
val deviceInfo by vm.deviceInfo.collectAsState()
|
val deviceInfo by vm.deviceInfo.collectAsState()
|
||||||
val gnss by vm.gnss.collectAsState()
|
val gnss by vm.gnss.collectAsState()
|
||||||
|
|
@ -102,11 +101,8 @@ fun DashboardScreen(
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = onNavigateToBeeSettings) {
|
|
||||||
Icon(Icons.Default.Router, contentDescription = "AdaCam Settings", tint = Amber)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onNavigateToSettings) {
|
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) }
|
gnss?.let { GpsMapCard(it) }
|
||||||
|
|
||||||
// Device status
|
// Device status
|
||||||
deviceInfo?.let { DeviceStatusCard(it, onNavigateToBeeSettings) }
|
deviceInfo?.let { DeviceStatusCard(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -586,32 +582,14 @@ private fun OsmMapView(gnss: GnssData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceStatusCard(info: BeeDeviceInfo, onNavigateToBeeSettings: () -> Unit = {}) {
|
private fun DeviceStatusCard(info: BeeDeviceInfo) {
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = Surface),
|
colors = CardDefaults.cardColors(containerColor = Surface),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(14.dp)) {
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
Row(
|
SectionHeader("DEVICE STATUS")
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
SectionHeader("DEVICE STATUS")
|
|
||||||
Text(
|
|
||||||
"CONFIGURE ›",
|
|
||||||
color = Amber,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 9.sp,
|
|
||||||
letterSpacing = 1.sp,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.clickable { onNavigateToBeeSettings() }
|
|
||||||
.background(AmberDark.copy(alpha = 0.2f))
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
val rows: List<Pair<String, String>> = listOf(
|
val rows: List<Pair<String, String>> = listOf(
|
||||||
"Firmware" to (info.firmwareVersion ?: "—"),
|
"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.Warning
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.BeeDeviceInfo
|
||||||
import com.adamaps.varroa.data.BeePlugin
|
import com.adamaps.varroa.data.BeePlugin
|
||||||
import com.adamaps.varroa.data.GnssStatus
|
import com.adamaps.varroa.data.GnssStatus
|
||||||
|
import com.adamaps.varroa.data.SshStatus
|
||||||
import com.adamaps.varroa.data.StorageStatus
|
import com.adamaps.varroa.data.StorageStatus
|
||||||
import com.adamaps.varroa.data.WifiConfig
|
import com.adamaps.varroa.data.WifiConfig
|
||||||
import com.adamaps.varroa.ui.theme.*
|
import com.adamaps.varroa.ui.theme.*
|
||||||
|
|
@ -36,6 +38,7 @@ fun DeviceStatusScreen(
|
||||||
val state by vm.state.collectAsState()
|
val state by vm.state.collectAsState()
|
||||||
val wifiResult by vm.wifiSaveResult.collectAsState()
|
val wifiResult by vm.wifiSaveResult.collectAsState()
|
||||||
val uploadResult by vm.uploadModeResult.collectAsState()
|
val uploadResult by vm.uploadModeResult.collectAsState()
|
||||||
|
val sshResult by vm.sshToggleResult.collectAsState()
|
||||||
|
|
||||||
// Refresh on first load
|
// Refresh on first load
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -56,6 +59,12 @@ fun DeviceStatusScreen(
|
||||||
vm.clearUploadModeResult()
|
vm.clearUploadModeResult()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(sshResult) {
|
||||||
|
sshResult?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
vm.clearSshToggleResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = Background,
|
containerColor = Background,
|
||||||
|
|
@ -119,6 +128,9 @@ fun DeviceStatusScreen(
|
||||||
// GPS/GNSS Status
|
// GPS/GNSS Status
|
||||||
GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock)
|
GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock)
|
||||||
|
|
||||||
|
// SSH Status
|
||||||
|
SshStatusCard(state.sshStatus, vm)
|
||||||
|
|
||||||
// Upload Mode
|
// Upload Mode
|
||||||
UploadModeCard(state.deviceInfo?.uploadMode, vm)
|
UploadModeCard(state.deviceInfo?.uploadMode, vm)
|
||||||
|
|
||||||
|
|
@ -364,7 +376,7 @@ private fun StorageStatusCard(storage: StorageStatus) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) {
|
private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) {
|
||||||
StatusCard("GPS / GNSS") {
|
StatusCard("GPS / GNSS") {
|
||||||
val hasGps = gnss?.fix == true || hasLock == true
|
val hasGps = gnss?.hasLock == true || hasLock == true
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -390,21 +402,21 @@ private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
StatusRow("HDOP", "%.2f".format(it), if (it > 5) Color.Yellow else OnSurface)
|
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))
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
StatusRow("Altitude", "%.1f m".format(it))
|
StatusRow("Altitude", "%.1f m".format(it))
|
||||||
}
|
}
|
||||||
gnss.speedMs?.let {
|
gnss.speedKmh?.let {
|
||||||
Spacer(Modifier.height(4.dp))
|
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))
|
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
|
@Composable
|
||||||
private fun PluginsCard(plugins: List<BeePlugin>) {
|
private fun PluginsCard(plugins: List<BeePlugin>) {
|
||||||
StatusCard("PLUGINS") {
|
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.Check
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
import androidx.compose.material.icons.filled.Clear
|
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.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.adamaps.varroa.data.VarroaSettings
|
import com.adamaps.varroa.data.VarroaSettings
|
||||||
import com.adamaps.varroa.ui.theme.*
|
import com.adamaps.varroa.ui.theme.*
|
||||||
|
|
@ -49,6 +49,14 @@ fun SettingsScreen(
|
||||||
) {
|
) {
|
||||||
val currentSettings by vm.settings.collectAsState()
|
val currentSettings by vm.settings.collectAsState()
|
||||||
val saved by vm.saved.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
|
// Local edit state — initialized from current settings
|
||||||
var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) }
|
var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) }
|
||||||
|
|
@ -59,21 +67,6 @@ fun SettingsScreen(
|
||||||
var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) }
|
var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) }
|
||||||
var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) }
|
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
|
// Show snackbar on save
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
LaunchedEffect(saved) {
|
LaunchedEffect(saved) {
|
||||||
|
|
@ -82,6 +75,24 @@ fun SettingsScreen(
|
||||||
vm.clearSaved()
|
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(
|
Scaffold(
|
||||||
containerColor = Background,
|
containerColor = Background,
|
||||||
|
|
@ -106,7 +117,7 @@ fun SettingsScreen(
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
vm.save(
|
vm.save(
|
||||||
VarroaSettings(
|
currentSettings.copy(
|
||||||
beeApiUrl = beeApiUrl.trim(),
|
beeApiUrl = beeApiUrl.trim(),
|
||||||
adamapsApiUrl = adamapsApiUrl.trim(),
|
adamapsApiUrl = adamapsApiUrl.trim(),
|
||||||
adamapsApiKey = adamapsApiKey.trim(),
|
adamapsApiKey = adamapsApiKey.trim(),
|
||||||
|
|
@ -131,6 +142,15 @@ fun SettingsScreen(
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(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
|
// Device Status navigation card
|
||||||
Card(
|
Card(
|
||||||
onClick = onNavigateToDeviceStatus,
|
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") {
|
SettingsSection("ADACAM DEVICE") {
|
||||||
SettingsField(
|
SettingsField(
|
||||||
label = "AdaCam API URL",
|
label = "AdaCam API URL",
|
||||||
value = beeApiUrl,
|
value = beeApiUrl,
|
||||||
onValueChange = { beeApiUrl = it },
|
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") {
|
SettingsSection("ADAMAPS") {
|
||||||
SettingsField(
|
SettingsField(
|
||||||
label = "ADAMaps API URL",
|
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
|
@Composable
|
||||||
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
|
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||||
Card(
|
Card(
|
||||||
|
|
@ -388,8 +571,7 @@ private fun SettingsField(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
hint: String = "",
|
hint: String = "",
|
||||||
numeric: Boolean = false,
|
numeric: Boolean = false
|
||||||
keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text
|
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
|
|
@ -397,7 +579,8 @@ private fun SettingsField(
|
||||||
label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
|
label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
|
||||||
placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
|
placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
|
else KeyboardOptions.Default,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedBorderColor = Amber,
|
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.BeePlugin
|
||||||
import com.adamaps.varroa.data.GnssStatus
|
import com.adamaps.varroa.data.GnssStatus
|
||||||
import com.adamaps.varroa.data.SettingsDataStore
|
import com.adamaps.varroa.data.SettingsDataStore
|
||||||
|
import com.adamaps.varroa.data.SshStatus
|
||||||
import com.adamaps.varroa.data.StorageStatus
|
import com.adamaps.varroa.data.StorageStatus
|
||||||
import com.adamaps.varroa.data.WifiConfig
|
import com.adamaps.varroa.data.WifiConfig
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -25,6 +26,7 @@ data class DeviceStatusState(
|
||||||
val wifiConfig: WifiConfig? = null,
|
val wifiConfig: WifiConfig? = null,
|
||||||
val storageStatus: StorageStatus? = null,
|
val storageStatus: StorageStatus? = null,
|
||||||
val gnssStatus: GnssStatus? = null,
|
val gnssStatus: GnssStatus? = null,
|
||||||
|
val sshStatus: SshStatus? = null,
|
||||||
val plugins: List<BeePlugin> = emptyList(),
|
val plugins: List<BeePlugin> = emptyList(),
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
@ -47,6 +49,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
private val _uploadModeResult = MutableStateFlow<String?>(null)
|
private val _uploadModeResult = MutableStateFlow<String?>(null)
|
||||||
val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow()
|
val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow()
|
||||||
|
|
||||||
|
private val _sshToggleResult = MutableStateFlow<String?>(null)
|
||||||
|
val sshToggleResult: StateFlow<String?> = _sshToggleResult.asStateFlow()
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
|
|
@ -54,6 +59,10 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
try {
|
try {
|
||||||
val settings = store.settings.first()
|
val settings = store.settings.first()
|
||||||
val client = BeeApiClient(settings.beeApiUrl)
|
val client = BeeApiClient(settings.beeApiUrl)
|
||||||
|
// Set auth token if paired
|
||||||
|
if (settings.apiToken.isNotBlank()) {
|
||||||
|
client.apiToken = settings.apiToken
|
||||||
|
}
|
||||||
beeClient = client
|
beeClient = client
|
||||||
|
|
||||||
// Fetch all status in parallel
|
// Fetch all status in parallel
|
||||||
|
|
@ -62,12 +71,14 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
val storageResult = client.getStorageStatus()
|
val storageResult = client.getStorageStatus()
|
||||||
val gnssResult = client.getGnssStatus()
|
val gnssResult = client.getGnssStatus()
|
||||||
val pluginsResult = client.getPlugins()
|
val pluginsResult = client.getPlugins()
|
||||||
|
val sshResult = client.getSshStatus()
|
||||||
|
|
||||||
val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data
|
val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data
|
||||||
val wifi = (wifiResult as? ApiResult.Success)?.data
|
val wifi = (wifiResult as? ApiResult.Success)?.data
|
||||||
val storage = (storageResult as? ApiResult.Success)?.data
|
val storage = (storageResult as? ApiResult.Success)?.data
|
||||||
val gnss = (gnssResult as? ApiResult.Success)?.data
|
val gnss = (gnssResult as? ApiResult.Success)?.data
|
||||||
val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList()
|
val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList()
|
||||||
|
val ssh = (sshResult as? ApiResult.Success)?.data
|
||||||
|
|
||||||
val isConnected = deviceInfo != null
|
val isConnected = deviceInfo != null
|
||||||
|
|
||||||
|
|
@ -78,8 +89,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
wifiConfig = wifi,
|
wifiConfig = wifi,
|
||||||
storageStatus = storage,
|
storageStatus = storage,
|
||||||
gnssStatus = gnss,
|
gnssStatus = gnss,
|
||||||
|
sshStatus = ssh,
|
||||||
plugins = plugins,
|
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}")
|
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() {
|
fun clearWifiResult() {
|
||||||
_wifiSaveResult.value = null
|
_wifiSaveResult.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -136,4 +166,8 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
fun clearUploadModeResult() {
|
fun clearUploadModeResult() {
|
||||||
_uploadModeResult.value = null
|
_uploadModeResult.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearSshToggleResult() {
|
||||||
|
_sshToggleResult.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
private val _wifiConnectResult = MutableStateFlow<String?>(null)
|
private val _wifiConnectResult = MutableStateFlow<String?>(null)
|
||||||
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
|
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
|
||||||
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Initialize BeeApiClient with stored settings and token
|
// Initialize BeeApiClient with stored settings and token
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="#000000" />
|
<solid android:color="#0A0A0A" />
|
||||||
</shape>
|
</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<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-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="false">192.168.0.10</domain>
|
<domain includeSubdomains="true">192.168.0.10</domain>
|
||||||
</domain-config>
|
</domain-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false" />
|
||||||
</network-security-config>
|
</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>
|
|
||||||