From 84e8777290208a997abdfbf21d376c6b37a01b95 Mon Sep 17 00:00:00 2001 From: Cobb Hayes Date: Wed, 27 May 2026 10:30:02 -0700 Subject: [PATCH] Public-flip prep: env-driven keystore, README, hardened cleartext, leaner docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/build.gradle.kts: remove hardcoded keystore password (was 'adacam-varroa-2026' in 4 spots across a duplicated signingConfigs block). Now reads VARROA_KEYSTORE_PATH + VARROA_KEYSTORE_PASSWORD + VARROA_KEY_PASSWORD from env. Password vaulted as 'Varroa — release keystore'. Drops orphan zxing/camera deps that aren't wired up. - app/src/main/res/xml/network_security_config.xml: tighten cleartext scope from global to just 192.168.0.10 (Bee AP). HTTPS strict for everything else. - app/src/main/java/.../api/AdaMapsApiClient.kt: drop apiKey.take(8) in log to apiKey.length — no need to leak prefix to logcat. - README.md: add. Public repo without one was a bad first impression. - docs/BEE-CAMERA.md: rewrite (811→467 lines). Keep all paths, pinouts, bus diagrams, depthai/VPU/xlink details, intercept architecture. Strip Executive-Summary framing, verdict box, phased roadmap, appendices. - docs/AIR-QUALITY-INTEGRATION.md: rewrite (712→369 lines). Keep BOM, sensor comparisons, wiring, IAQ calc, ingest endpoint shape. Strip feasibility-report scaffolding. - docs/AIR-API-PATCH.py: delete. Was a one-shot apply-and-discard patch script, not docs. --- README.md | 46 + app/build.gradle.kts | 34 +- .../adamaps/varroa/api/AdaMapsApiClient.kt | 4 +- .../main/res/xml/network_security_config.xml | 10 +- docs/AIR-API-PATCH.py | 145 ---- docs/AIR-QUALITY-INTEGRATION.md | 740 +++++----------- docs/BEE-CAMERA.md | 799 +++++------------- 7 files changed, 495 insertions(+), 1283 deletions(-) create mode 100644 README.md delete mode 100644 docs/AIR-API-PATCH.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce62f5e --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# varroa + +Android companion for the Hivemapper Bee dashcam. Pulls detection +landmarks off the on-Bee `adacam-api`, queues them in a local Room +DB, forwards to AdaMaps when the phone has real internet. + +Sister piece: `blackbox/` — Python aggregator that runs on a truck +Pi (BME680 + PMS5003) and ships air-quality readings into the same +AdaMaps stream. + +## Build + +``` +JDK 17, Android SDK 34 +./gradlew :app:assembleDebug +``` + +Release signing needs: + +``` +VARROA_KEYSTORE_PATH=/path/to/varroa-release.keystore +VARROA_KEYSTORE_PASSWORD= +VARROA_KEY_PASSWORD= +./gradlew :app:assembleRelease +``` + +## Config (set in-app) + +- **Bee URL** — defaults to `http://192.168.0.10:5000` (Bee AP). +- **AdaMaps URL + ingest key** — required before uploads run. +- **Cardano wallet** — optional. Attaches to detection ingest for + rewards routing. + +## Architecture + +- `BeeCollectorService` — polls the Bee, writes landmarks to Room. +- `AdaMapsUploadWorker` — drains Room to `api.adamaps.org` once + validated internet is available. +- `ImageCollectorService` — pulls detection JPEGs from the Bee. + +## blackbox + +The air-quality side. `air_aggregator.py` reads BME680 + +PMS5003 over USB, posts to AdaMaps every 60s. Systemd unit at +`blackbox/air-aggregator.service` — set `ADAMAPS_KEY` and +`AGGREGATOR_BEE_URL` in the unit before enabling. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a5b76d..20dbc60 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,10 +11,17 @@ android { signingConfigs { create("release") { - storeFile = file("/keystore/varroa-release.keystore") - storePassword = "adacam-varroa-2026" - keyAlias = "varroa-release" - keyPassword = "adacam-varroa-2026" + // 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 + } } } @@ -30,15 +37,6 @@ android { } } - signingConfigs { - create("release") { - storeFile = file("/keystore/varroa-release.keystore") - storePassword = "adacam-varroa-2026" - keyAlias = "varroa-release" - keyPassword = "adacam-varroa-2026" - } - } - buildTypes { release { isMinifyEnabled = false @@ -89,19 +87,9 @@ dependencies { implementation(libs.osmdroid.android) implementation(libs.datastore.preferences) implementation(libs.coil.compose) - // Room (local database) implementation(libs.room.runtime) implementation(libs.room.ktx) ksp(libs.room.compiler) - // WorkManager (background uploads) implementation(libs.work.runtime.ktx) - // SSH connectivity for device_id fallback - - // QR Code scanning - implementation("com.google.zxing:core:3.5.2") - implementation("com.journeyapps:zxing-android-embedded:4.3.0") - implementation("androidx.camera:camera-camera2:1.3.0") - implementation("androidx.camera:camera-lifecycle:1.3.0") - implementation("androidx.camera:camera-view:1.3.0") debugImplementation(libs.androidx.ui.tooling) } diff --git a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt index 5acaee7..39a8410 100644 --- a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt @@ -57,7 +57,7 @@ class AdaMapsApiClient( fun updateConfig(url: String, key: String) { val oldUrl = apiUrl - val oldKeyPrefix = apiKey.take(8) + val oldKeyPrefix = apiKey.length apiUrl = url.trimEnd('/') apiKey = key Log.d(TAG, "AdaMaps config updated - URL: $oldUrl -> $apiUrl, Key: ${oldKeyPrefix}... -> ${key.take(8)}...") @@ -80,7 +80,7 @@ class AdaMapsApiClient( .post(body) .build() - Log.d(TAG, "Sending POST request with key: ${apiKey.take(8)}...") + Log.d(TAG, "Sending POST request with key: ${apiKey.length}...") client.newCall(req).execute().use { resp -> val respBody = resp.body?.string() ?: "" Log.d(TAG, "HTTP ${resp.code} ${resp.message} - response length: ${respBody.length}") diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 2439f15..e7f24f7 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,4 +1,12 @@ - + + + + + + 192.168.0.10 + diff --git a/docs/AIR-API-PATCH.py b/docs/AIR-API-PATCH.py deleted file mode 100644 index 24a5016..0000000 --- a/docs/AIR-API-PATCH.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python3 -""" -AIR QUALITY API — Routes to add to /app/app.py on Rackham (adamaps-api container) -Review this file, then apply via: docker exec adamaps-api python3 /tmp/apply_air_patch.py -DO NOT apply until Cobb approves. -""" - -# ─── EPA AQI calculation ────────────────────────────────────────────────────── -def pm25_to_aqi(pm25): - if pm25 is None: return None - breakpoints = [ - (0.0, 12.0, 0, 50), - (12.1, 35.4, 51, 100), - (35.5, 55.4, 101, 150), - (55.5, 150.4, 151, 200), - (150.5, 250.4, 201, 300), - (250.5, 350.4, 301, 400), - (350.5, 500.4, 401, 500), - ] - for c_lo, c_hi, i_lo, i_hi in breakpoints: - if c_lo <= pm25 <= c_hi: - return round((i_hi - i_lo) / (c_hi - c_lo) * (pm25 - c_lo) + i_lo) - return 500 if pm25 > 500 else 0 - -def init_air_table(): - try: - conn = get_db() - cur = conn.cursor() - cur.execute(""" - CREATE TABLE IF NOT EXISTS air_quality ( - id SERIAL PRIMARY KEY, - device_id VARCHAR(64), - sampled_at TIMESTAMP, - lat DOUBLE PRECISION, - lon DOUBLE PRECISION, - alt DOUBLE PRECISION, - gps_fix BOOLEAN DEFAULT FALSE, - pm1_0 FLOAT, - pm2_5 FLOAT, - pm10 FLOAT, - temperature_c FLOAT, - humidity_pct FLOAT, - pressure_hpa FLOAT, - gas_resistance_ohm FLOAT, - aqi INTEGER, - created_at TIMESTAMP DEFAULT NOW() - ) - """) - cur.execute("CREATE INDEX IF NOT EXISTS air_latlon_idx ON air_quality (lat, lon)") - cur.execute("CREATE INDEX IF NOT EXISTS air_sampled_idx ON air_quality (sampled_at DESC)") - conn.commit(); cur.close(); conn.close() - except Exception as e: - print(f"air table init: {e}") - -# ─── /api/ingest/air ────────────────────────────────────────────────────────── -# POST — auth required (X-AdaMaps-Key) -# Body: {"device_id": "blackbox-pi", "readings": [{sampled_at, lat, lon, pm2_5_ug_m3, ...}]} -# Returns: {"inserted": N} -def ingest_air(): - if request.headers.get("X-AdaMaps-Key") != API_KEY: - return jsonify({"error": "unauthorized"}), 401 - data = request.json - if not data: return jsonify({"error": "invalid"}), 400 - device_id = data.get("device_id", "unknown") - readings = data.get("readings", [data] if "sampled_at" in data else []) - if not readings: return jsonify({"error": "no readings"}), 400 - init_air_table() - inserted = 0 - try: - conn = get_db(); cur = conn.cursor() - for r in readings: - try: - pm25 = r.get("pm2_5_ug_m3") or r.get("pm2_5") - cur.execute(""" - INSERT INTO air_quality - (device_id, sampled_at, lat, lon, alt, gps_fix, - pm1_0, pm2_5, pm10, temperature_c, humidity_pct, - pressure_hpa, gas_resistance_ohm, aqi) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) - """, ( - r.get("device_id", device_id), r.get("sampled_at"), - r.get("lat"), r.get("lon"), r.get("alt"), r.get("gps_fix", False), - r.get("pm1_0_ug_m3"), pm25, r.get("pm10_ug_m3"), - r.get("temperature_c"), r.get("humidity_pct"), - r.get("pressure_hpa"), r.get("gas_resistance_ohm"), - pm25_to_aqi(pm25) - )) - inserted += 1 - except: conn.rollback() - conn.commit(); cur.close(); conn.close() - except Exception as e: - return jsonify({"error": "db_unavailable", "detail": str(e)}), 503 - return jsonify({"inserted": inserted, "device_id": device_id}) - -# ─── /api/air/heatmap ───────────────────────────────────────────────────────── -# GET ?metric=aqi|pm2_5 &hours=24 -# Returns [[lat, lon, intensity_0_to_1], ...] for Leaflet.heat -def air_heatmap(): - metric = request.args.get("metric", "aqi") - hours = request.args.get("hours", 24, type=int) - col = "aqi" if metric == "aqi" else "pm2_5" - max_val = 300.0 if metric == "aqi" else 150.0 - try: - conn = get_db(); cur = conn.cursor() - cur.execute(f""" - SELECT lat, lon, {col} FROM air_quality - WHERE sampled_at > NOW() - INTERVAL '%s hours' - AND lat IS NOT NULL AND lon IS NOT NULL - AND {col} IS NOT NULL AND gps_fix = TRUE - ORDER BY sampled_at DESC LIMIT 50000 - """, (hours,)) - rows = [[float(r[0]), float(r[1]), min(float(r[2]) / max_val, 1.0)] - for r in cur.fetchall()] - cur.close(); conn.close() - return jsonify(rows) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -# ─── /api/air/latest ────────────────────────────────────────────────────────── -# GET — most recent reading per device -def air_latest(): - try: - conn = get_db(); cur = conn.cursor() - cur.execute(""" - SELECT DISTINCT ON (device_id) - device_id, sampled_at, lat, lon, - pm1_0, pm2_5, pm10, temperature_c, humidity_pct, aqi - FROM air_quality - WHERE sampled_at > NOW() - INTERVAL '1 hour' - ORDER BY device_id, sampled_at DESC - """) - rows = [{"device_id": r[0], "sampled_at": r[1].isoformat() if r[1] else None, - "lat": float(r[2]) if r[2] else None, "lon": float(r[3]) if r[3] else None, - "pm1_0": r[4], "pm2_5": r[5], "pm10": r[6], - "temperature_c": r[7], "humidity_pct": r[8], "aqi": r[9]} - for r in cur.fetchall()] - cur.close(); conn.close() - return jsonify(rows) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -# ─── Flask route registration (add to app after existing routes) ─────────────── -# app.add_url_rule("/api/ingest/air", "ingest_air", ingest_air, methods=["POST"]) -# app.add_url_rule("/api/air/heatmap", "air_heatmap", air_heatmap, methods=["GET"]) -# app.add_url_rule("/api/air/latest", "air_latest", air_latest, methods=["GET"]) diff --git a/docs/AIR-QUALITY-INTEGRATION.md b/docs/AIR-QUALITY-INTEGRATION.md index a205091..2f4be27 100644 --- a/docs/AIR-QUALITY-INTEGRATION.md +++ b/docs/AIR-QUALITY-INTEGRATION.md @@ -1,275 +1,94 @@ -# Air Quality Sensor Integration — Feasibility Report +# Air quality sensor on the Bee -*Generated: 2026-03-13* +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 -## Executive Summary +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. -**Verdict: FEASIBLE** — Adding Bosch air quality sensor support to the Hivemapper Bee dashcam is technically feasible with minimal resource overhead. The primary path is USB-to-I2C adapter for BME680/BME688 sensors, or direct USB-C for Sensirion SEN5x sensors if particulate matter measurement is desired. - -**Key Findings:** -- Bee has sufficient headroom after Phase 1 bloat removal (~50% CPU, ~1GB RAM available) -- USB host port is available (Keem Bay SoC has USB controller, LTE modem uses different interface) -- Polling a sensor at 1Hz adds <1% CPU overhead -- Existing Redis infrastructure (GNSSFusion30Hz) can be leveraged for GPS fusion -- AdaMaps API requires new `/api/ingest/air` endpoint + DB schema + frontend overlay - ---- - -## 1. Current Resource Assessment - -### 1.1 Bee Hardware Specs - -| Component | Specification | -|-----------|---------------| -| **SoC** | Intel Keem Bay (RVC2) | -| **CPU** | 4× ARM Cortex-A53 @ 1.5GHz | -| **VPU** | Intel Movidius Myriad X | -| **RAM** | 3.5GB usable (~1.34GB reserved for VPU DMA) | -| **Available RAM** | ~2.2GB for userspace | - -### 1.2 Current Service Load (Pre-Optimization) +Current service load (pre Phase-1 cleanup): | Service | CPU | RAM | Notes | |---------|-----|-----|-------| -| map-ai | ~32% | ~1.1GB | ML inference on VPU | -| odc-api | ~48% | ~139MB | **Target for Phase 2 replacement** | -| depthai_gate | ~5% | ~200MB | Camera pipeline | -| Redis | <1% | ~50MB | Key-value store | -| **Total** | ~85% | ~1.5GB | | +| 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 | | -### 1.3 Post-Phase 1 Headroom +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. -After killing Phase 1 bloat (odc-api optimization pending): -- **CPU Available:** ~50-60% (2-2.4 cores idle) -- **RAM Available:** ~700MB-1GB free -- **Conclusion:** Plenty of headroom for a lightweight sensor polling service +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. -### 1.4 USB Topology - -From Keem Bay bus architecture: ``` -┌────────────────────────────────────────────────┐ -│ Intel Keem Bay SoC │ -│ ┌────────────┐ │ -│ │ USB │ │ -│ │ Controller │ │ -│ └─────┬──────┘ │ -└────────┼───────────────────────────────────────┘ - │ - ┌────┴────┐ - │USB Hub? │ ← Keem Bay may have internal hub - └────┬────┘ - ├──── Telit LE910C4 LTE Modem (internal) - └──── USB-C Data Port (external) ← **AVAILABLE** +Keem Bay USB controller + └── internal hub + ├── Telit LE910C4 (internal) + └── USB-C data port (external) ← us ``` -**USB-C Data Port Availability:** YES — The Bee's USB-C port supports data (not just power). This is the target for sensor attachment. +## 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 | -## 2. Bosch Air Quality Sensor Options +Worth flagging: SEN5x is Sensirion, not Bosch. If the sensor on hand is branded Bosch it's almost certainly a BME680 or BME688. -### 2.1 Sensor Model Comparison +**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. -| Model | Manufacturer | Measurements | Interface | Best For | -|-------|--------------|--------------|-----------|----------| -| **BME680** | Bosch | VOC, temp, humidity, pressure | I2C/SPI | Indoor air quality | -| **BME688** | Bosch | BME680 + AI gas scanning | I2C/SPI | Advanced VOC classification | -| **SEN50** | Sensirion | PM1.0/PM2.5/PM4/PM10 | I2C/UART | Particulate matter only | -| **SEN54** | Sensirion | PM + VOC + temp + humidity | I2C/UART | Multi-parameter | -| **SEN55** | Sensirion | SEN54 + NOx | I2C/UART | Full air quality suite | +**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. -**Note:** SEN5x is Sensirion, not Bosch. If the sensor is branded "Bosch", it's likely **BME680 or BME688**. +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. -### 2.2 BME680/BME688 (Most Likely) +## USB bridge options -**Specifications:** -- VOC (Volatile Organic Compounds): IAQ index 0-500 -- Temperature: -40 to +85°C, ±1°C accuracy -- Humidity: 0-100% RH, ±3% accuracy -- Pressure: 300-1100 hPa, ±1 hPa accuracy -- Power: 3.6mA during measurement, <1µA sleep -- I2C Address: 0x76 or 0x77 +For I2C sensors (BME680/688) we need a USB-to-I2C adapter: -**Pros:** -- Compact, cheap (~$10-20 on breakout boards) -- Well-documented, extensive library support -- Low power +| 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 | -**Cons:** -- I2C/SPI only — requires USB adapter for Bee -- VOC is relative index, not absolute concentration -- Requires burn-in calibration period (~48 hours) +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. -### 2.3 SEN55 (If Particulate Matter Needed) +Quick read with FT232H + BME680: -**Specifications:** -- PM1.0/PM2.5/PM4/PM10: 0-1000 µg/m³ -- VOC: 1-500 index -- NOx: 1-500 index -- Temperature: -10 to +50°C -- Humidity: 0-100% RH -- Interface: I2C (default) or UART -- Power: 60mA avg - -**Pros:** -- Measures actual particulate matter (smoke, dust, pollution) -- More relevant for outdoor/driving air quality mapping -- USB-C variants available (no adapter needed) - -**Cons:** -- Larger form factor (~40×40×12mm) -- Higher power consumption -- More expensive (~$50-80) - -### 2.4 Recommendation - -| Use Case | Recommended Sensor | -|----------|--------------------| -| Basic air quality index | BME680 + USB-I2C adapter | -| Advanced gas classification | BME688 + USB-I2C adapter | -| Pollution/smoke mapping | SEN55 (native I2C or USB-C) | -| Full environmental suite | SEN55 + BME688 combo | - -**For AdaMaps urban pollution mapping:** **SEN55** is ideal — PM2.5 and NOx are the most actionable metrics for air quality maps. - ---- - -## 3. USB Interface Options - -### 3.1 Option A: USB-to-I2C Adapter (Recommended for BME680/688) - -**Hardware:** -- **Adafruit FT232H** — FTDI chip, well-supported ($15) -- **MCP2221A** — Microchip, HID mode ($5) -- **CP2112** — Silicon Labs, HID mode ($8) -- **CH341** — Common Chinese adapter ($3) - -**Linux Support:** -```bash -# FT232H appears as /dev/i2c-X via ftdi_sio driver -lsmod | grep ftdi_sio -ls /dev/i2c-* - -# MCP2221A appears as /dev/hidraw* or /dev/i2c-X via i2c-mcp2221 driver -``` - -**Python Libraries:** -- `smbus2` — Standard I2C -- `adafruit-blinka` + `adafruit-circuitpython-bme680` — High-level BME680 -- `pyftdi` — Direct FTDI control - -**Example (FT232H + BME680):** ```python -import board -import adafruit_bme680 - +import board, adafruit_bme680 i2c = board.I2C() sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c) - -print(f"Temperature: {sensor.temperature} °C") -print(f"Humidity: {sensor.humidity} %") -print(f"Pressure: {sensor.pressure} hPa") -print(f"Gas (VOC): {sensor.gas} ohms") +print(sensor.temperature, sensor.humidity, sensor.pressure, sensor.gas) ``` -### 3.2 Option B: USB-Serial (UART) for SEN5x +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. -**Hardware:** -- **CP2102** USB-UART adapter ($2) -- **FTDI FT232RL** ($5) -- SEN5x set to UART mode (hardware jumper) +Picking one: MCP2221A + BME680 breakout ~$15 total for basic VOC. CP2102 + SEN55 ~$55 for full particulate matter. -**Linux:** -```bash -# Appears as /dev/ttyUSB0 or /dev/ttyACM0 -ls /dev/ttyUSB* -``` +## bee-side integration -**Python:** -```python -import serial -from sensirion_i2c_driver import LinuxI2cTransceiver, I2cConnection -from sensirion_i2c_sen5x import Sen5xI2cDevice - -# For UART mode (simpler): -ser = serial.Serial('/dev/ttyUSB0', 115200) -# Send SHDLC commands per Sensirion protocol -``` - -### 3.3 Option C: Native USB-C (SEN55 Evaluation Kit) - -**Sensirion SEK-SEN55** evaluation kit includes USB-C interface: -- Appears as CDC-ACM device (/dev/ttyACM0) -- Built-in firmware streams measurements -- No adapter needed - -**Caveat:** Evaluation kit is large and expensive (~$100). For production, better to use raw sensor + adapter. - -### 3.4 Recommendation - -| Sensor | Interface Method | Cost | Complexity | -|--------|------------------|------|------------| -| BME680/688 | FT232H USB-I2C | $20 | Medium | -| BME680/688 | MCP2221A USB-I2C | $10 | Low | -| SEN55 | CP2102 USB-UART | $55 | Low | -| SEN55 | Native USB eval kit | $100 | Very Low | - -**Best balance:** MCP2221A + BME680 breakout (~$15 total) for basic VOC, or SEN55 + CP2102 (~$55) for full particulate matter. - ---- - -## 4. Integration Architecture - -### 4.1 Data Flow +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. ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ BEE DEVICE │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────┐ ┌─────────────────────┐ │ -│ │ USB Air Quality │ --> │ air-sensor.service │ │ -│ │ Sensor + Adapter │ │ (Python, port N/A) │ │ -│ └──────────────────┘ └──────────┬──────────┘ │ -│ │ │ -│ v │ -│ ┌──────────────────────┐ │ -│ │ Redis │ │ -│ │ AirQuality30Hz key │ │ -│ └──────────┬───────────┘ │ -│ │ │ -│ ┌──────────────────┐ │ │ -│ │ bee-collector │ <--------------┘ │ -│ │ (existing) │ <-- GNSSFusion30Hz (GPS) │ -│ └──────────┬───────┘ │ -│ │ │ -└─────────────┼───────────────────────────────────────────────────────────┘ - │ - v (HTTPS POST) -┌─────────────────────────────────────────────────────────────────────────┐ -│ ADAMAPS API (Rackham) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────┐ ┌─────────────────────┐ │ -│ │ /api/ingest/air │ --> │ air_quality table │ │ -│ │ (new endpoint) │ │ (PostGIS) │ │ -│ └──────────────────┘ └──────────┬──────────┘ │ -│ │ │ -│ v │ -│ ┌──────────────────────┐ │ -│ │ adamaps.org frontend │ │ -│ │ Air Quality Overlay │ │ -│ └──────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +USB sensor + adapter + │ + ▼ +air-sensor.service (Python, 1Hz) + │ + ▼ +Redis: AirQuality1Hz + │ + ▼ +bee-collector.py ─ GNSSFusion30Hz ── (fuse) ─► HTTPS to AdaMaps ``` -### 4.2 Bee-Side Components - -**New Service: `air-sensor.service`** +Service unit: ```ini [Unit] @@ -282,151 +101,98 @@ User=root ExecStart=/opt/air-sensor/air_sensor.py Restart=always RestartSec=5 - -[Install] -WantedBy=multi-user.target ``` -**Python Script: `/opt/air-sensor/air_sensor.py`** +`/opt/air-sensor/air_sensor.py`: ```python #!/usr/bin/env python3 -""" -Air quality sensor reader for Hivemapper Bee. -Reads from USB-connected Bosch BME680/688 or Sensirion SEN55. -Publishes to Redis for bee-collector fusion. -""" +"""Poll BME680/688 over USB-I2C, publish to Redis at 1Hz.""" -import json -import time -import redis -import board -import adafruit_bme680 # or sensirion_i2c_sen5x +import json, time, redis +import board, adafruit_bme680 -POLL_INTERVAL = 1.0 # seconds -REDIS_KEY = "AirQuality1Hz" +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() - - # Initialize sensor (BME680 via FT232H/MCP2221A) i2c = board.I2C() sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=0x77) - - # Sea level pressure for altitude calculation (optional) sensor.sea_level_pressure = 1013.25 - + while True: reading = { - "ts": int(time.time() * 1000), # milliseconds + "ts": int(time.time() * 1000), "temperature_c": round(sensor.temperature, 2), - "humidity_pct": round(sensor.humidity, 2), - "pressure_hpa": round(sensor.pressure, 2), + "humidity_pct": round(sensor.humidity, 2), + "pressure_hpa": round(sensor.pressure, 2), "gas_resistance_ohms": sensor.gas, - "iaq_index": calculate_iaq(sensor.gas, sensor.humidity), + "iaq_index": iaq(sensor.gas, sensor.humidity), } - - r.set(REDIS_KEY, json.dumps(reading)) + r.set(KEY, json.dumps(reading)) r.publish("air_quality", json.dumps(reading)) - - time.sleep(POLL_INTERVAL) - -def calculate_iaq(gas_resistance, humidity): - """ - Simple IAQ calculation. - Real implementation should use Bosch BSEC library. - """ - # Placeholder: higher resistance = better air quality - # Humidity affects gas sensor, compensate roughly - if gas_resistance > 300000: - return 50 # Excellent - elif gas_resistance > 200000: - return 100 # Good - elif gas_resistance > 100000: - return 150 # Moderate - elif gas_resistance > 50000: - return 200 # Unhealthy for sensitive - else: - return 300 # Unhealthy + time.sleep(POLL) if __name__ == "__main__": main() ``` -**Extend bee-collector.py (fusion):** +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 -# In existing bee-collector.py, add air quality fusion: - def get_air_quality(): - """Read latest air quality from Redis.""" data = redis_client.get("AirQuality1Hz") - if data: - return json.loads(data) - return None + return json.loads(data) if data else None def collect_frame(): - # Existing GPS fusion - gnss = redis_client.get("GNSSFusion30Hz") - gnss_data = json.loads(gnss) if gnss else {} - - # Add air quality - air = get_air_quality() - - payload = { + gnss = json.loads(redis_client.get("GNSSFusion30Hz") or "{}") + air = get_air_quality() + return { "timestamp": int(time.time() * 1000), - "lat": gnss_data.get("lat"), - "lon": gnss_data.get("lon"), - "speed_kmh": gnss_data.get("speed"), - # Air quality fields - "air_temperature_c": air.get("temperature_c") if air else None, - "air_humidity_pct": air.get("humidity_pct") if air else None, - "air_pressure_hpa": air.get("pressure_hpa") if air else None, - "air_iaq_index": air.get("iaq_index") if air else None, - "air_gas_ohms": air.get("gas_resistance_ohms") if air else None, + "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"), } - - return payload ``` -### 4.3 Resource Estimate (Bee-Side) +Resource impact: <0.5% CPU, ~15MB RAM, one thread, <1KB/s USB traffic. Doesn't touch the camera, VPU, or map-ai. Negligible. -| Metric | Estimate | Notes | -|--------|----------|-------| -| CPU | <0.5% | I2C read + JSON serialize @ 1Hz | -| RAM | ~15MB | Python interpreter + libraries | -| Threads | 1 | Single-threaded polling loop | -| USB | <1KB/s | I2C traffic minimal | -| Conflicts | None | Doesn't touch camera/VPU/map-ai | +## AdaMaps side -**Conclusion:** Negligible impact. Safe to run alongside existing services. +Two endpoints + one table. ---- - -## 5. AdaMaps API Changes - -### 5.1 New Endpoint: `/api/ingest/air` +### `/api/ingest/air` ```python -# In app.py - @app.route('/api/ingest/air', methods=['POST']) def ingest_air_quality(): - """Ingest air quality reading with location.""" 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() - + + conn = get_db(); cur = conn.cursor() cur.execute(""" INSERT INTO air_quality ( - device_id, timestamp, + device_id, timestamp, lat, lon, geom, temperature_c, humidity_pct, pressure_hpa, iaq_index, gas_ohms, @@ -441,271 +207,163 @@ def ingest_air_quality(): %s, %s ) """, ( - data.get('device_id'), - data['timestamp'], + data.get('device_id'), data['timestamp'], data['lat'], data['lon'], - data['lon'], data['lat'], # ST_MakePoint takes lon,lat + 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'), + 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() - + conn.commit(); cur.close() return jsonify({"inserted": 1}) ``` -### 5.2 Database Schema +### schema ```sql --- Air quality measurements table CREATE TABLE air_quality ( - id SERIAL PRIMARY KEY, - device_id VARCHAR(64), - timestamp TIMESTAMPTZ NOT NULL, - - -- Location - lat DOUBLE PRECISION NOT NULL, - lon DOUBLE PRECISION NOT NULL, - geom GEOMETRY(Point, 4326), - - -- BME680/688 fields + 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 scale) - gas_ohms INTEGER, -- Raw gas resistance - - -- SEN5x fields (if using particulate sensor) - 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() + 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() ); --- Spatial index for heatmap queries -CREATE INDEX idx_air_quality_geom ON air_quality USING GIST (geom); - --- Time-based queries +CREATE INDEX idx_air_quality_geom ON air_quality USING GIST (geom); CREATE INDEX idx_air_quality_timestamp ON air_quality (timestamp DESC); - --- Device filtering -CREATE INDEX idx_air_quality_device ON air_quality (device_id); +CREATE INDEX idx_air_quality_device ON air_quality (device_id); ``` -### 5.3 Query Endpoint: `/api/air/heatmap` +### `/api/air/heatmap` ```python @app.route('/api/air/heatmap', methods=['GET']) def air_quality_heatmap(): - """Get air quality readings for map overlay.""" - hours = request.args.get('hours', 24, type=int) - bounds = request.args.get('bounds') # sw_lat,sw_lon,ne_lat,ne_lon - metric = request.args.get('metric', 'iaq_index') # or pm2_5, voc_index - - conn = get_db() - cur = conn.cursor() - - # Grid aggregation for 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 + 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) + GROUP BY ROUND(lat::numeric, 3), ROUND(lon::numeric, 3) HAVING AVG({metric}) IS NOT NULL """, (hours,)) - - results = [] - for row in cur.fetchall(): - results.append({ - "lon": row[0], - "lat": row[1], - "value": round(row[2], 1), - "samples": row[3] - }) - + + 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). -## 6. Frontend Integration +## frontend overlay -### 6.1 Heatmap Layer (Leaflet) +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 -// In adamaps.org frontend - import L from 'leaflet'; import 'leaflet.heat'; async function loadAirQualityLayer(map) { - const response = await fetch('/api/air/heatmap?hours=24&metric=iaq_index'); - const data = await response.json(); - - // Convert to heatmap format [lat, lon, intensity] - const heatData = data.data.map(point => [ - point.lat, - point.lon, - normalizeIAQ(point.value) // 0-1 scale - ]); - - const heatLayer = L.heatLayer(heatData, { - radius: 25, - blur: 15, - maxZoom: 17, - gradient: { - 0.0: 'green', // Excellent (IAQ 0-50) - 0.2: 'yellow', // Good (IAQ 51-100) - 0.4: 'orange', // Moderate (IAQ 101-150) - 0.6: 'red', // Unhealthy (IAQ 151-200) - 0.8: 'purple', // Very Unhealthy (201-300) - 1.0: 'maroon' // Hazardous (301+) - } - }); - - return heatLayer; -} + const r = await fetch('/api/air/heatmap?hours=24&metric=iaq_index'); + const { data } = await r.json(); -function normalizeIAQ(iaq) { - // Normalize IAQ 0-500 to 0-1 for heatmap intensity - return Math.min(iaq / 300, 1.0); + 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 + }, + }); } ``` -### 6.2 Legend / UI +Legend markup: ```html
-

Air Quality Index

-
0-50 Excellent
-
51-100 Good
-
101-150 Moderate
-
151-200 Unhealthy
-
201-300 Very Unhealthy
-
301+ Hazardous
+

Air Quality Index

+
0-50 Excellent
+
51-100 Good
+
101-150 Moderate
+
151-200 Unhealthy
+
201-300 Very unhealthy
+
301+ Hazardous
``` ---- +## BOM -## 7. Implementation Roadmap +Option A — BME680, basic VOC/IAQ: -### Phase 1: Sensor Validation (1-2 days) +``` +BME680 breakout $15 Adafruit/SparkFun +MCP2221A USB-I2C $7 Adafruit +Qwiic/STEMMA cables $3 SparkFun + ----- + $25 +``` -1. Identify exact sensor model Cobb has (BME680? BME688? SEN5x?) -2. Acquire USB-I2C adapter if needed (MCP2221A recommended) -3. Test sensor on laptop/Pi to confirm readings work -4. Verify USB-C data port on Bee accepts USB devices +Option B — SEN55, full air quality: -### Phase 2: Bee-Side Integration (2-3 days) +``` +SEN55 sensor $45 DigiKey/Mouser +breakout PCB $5 JLCPCB/OSHPark +CP2102 USB-UART $3 Amazon + ----- + $53 +``` -1. SSH to Bee, install Python dependencies -2. Deploy `air-sensor.service` -3. Verify Redis key `AirQuality1Hz` is being written -4. Extend `bee-collector.py` to read air quality -5. Confirm fused data appears in uploads +Both, if we want everything (VOC index from BME688 cross-checked against SEN55's separate VOC/NOx readings): ~$80. -### Phase 3: AdaMaps API (1-2 days) +## things still to confirm -1. Add `air_quality` table to PostgreSQL -2. Add `/api/ingest/air` endpoint -3. Add `/api/air/heatmap` query endpoint -4. Test end-to-end with curl +- 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 -### Phase 4: Frontend Overlay (1-2 days) +## rollout order -1. Add Leaflet.heat library -2. Implement air quality heatmap layer -3. Add legend and metric selector -4. Deploy to adamaps.org - -### Phase 5: Testing & Refinement (ongoing) - -1. Drive routes to collect data -2. Validate heatmap accuracy -3. Tune grid resolution and time windows -4. Consider Bosch BSEC library for accurate IAQ - ---- - -## 8. Bill of Materials - -### Option A: BME680 (Basic VOC/IAQ) - -| Item | Price | Source | -|------|-------|--------| -| BME680 Breakout | $15 | Adafruit/SparkFun | -| MCP2221A USB-I2C | $7 | Adafruit | -| Qwiic/STEMMA cables | $3 | SparkFun | -| **Total** | **~$25** | | - -### Option B: SEN55 (Full Air Quality) - -| Item | Price | Source | -|------|-------|--------| -| SEN55 Sensor | $45 | DigiKey/Mouser | -| Breakout PCB | $5 | JLCPCB/OSHPark | -| CP2102 USB-UART | $3 | Amazon | -| **Total** | **~$55** | | - -### Option C: Both (Comprehensive) - -| Item | Price | -|------|-------| -| BME688 + MCP2221A | $25 | -| SEN55 + CP2102 | $55 | -| **Total** | **~$80** | - ---- - -## 9. Open Questions - -| Question | Priority | Resolution Path | -|----------|----------|-----------------| -| Exact sensor model Cobb has? | High | Ask Cobb | -| Does Bee USB-C port support host mode? | High | Test with USB device | -| Can we install Python packages on Bee? | High | Check if pip works on Yocto | -| Bosch BSEC library licensing? | Medium | Review Bosch terms | -| Target polling rate? | Low | 1Hz default, adjust as needed | - ---- - -## 10. Conclusion - -**Adding air quality sensing to the Hivemapper Bee is feasible and lightweight.** - -The recommended path: -1. **Sensor:** Start with BME680 for quick wins (VOC/IAQ), upgrade to SEN55 for particulate matter if needed -2. **Interface:** MCP2221A USB-I2C adapter ($7) — plug and play on Linux -3. **Software:** Simple Python service (<100 lines), <1% CPU overhead -4. **Data fusion:** Leverage existing Redis infrastructure (GNSSFusion30Hz pattern) -5. **Backend:** New PostGIS table + 2 API endpoints -6. **Frontend:** Leaflet.heat overlay with IAQ color gradient - -**Total estimated effort:** ~1 week for end-to-end prototype -**Total BOM cost:** ~$25-80 depending on sensor choice - ---- - -*End of Report* +Sensor on the bench first (laptop or Pi) to confirm it actually reads. Then onto the Bee — service deploys, Redis key check, fusion in bee-collector, upload spot-check. AdaMaps side (table + endpoints) can land in parallel; curl-test before pointing the Bee at it. Frontend last, drive a route, eyeball the heatmap. diff --git a/docs/BEE-CAMERA.md b/docs/BEE-CAMERA.md index da5318c..852046d 100644 --- a/docs/BEE-CAMERA.md +++ b/docs/BEE-CAMERA.md @@ -1,132 +1,54 @@ -# Bee Camera System — Full Technical Report +# Bee camera system -*Generated: 2026-03-13* +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 -## Executive Summary - -The Hivemapper Bee dashcam uses an Intel Keem Bay SoC with an integrated Myriad X VPU for camera capture and ML inference. The camera pipeline flows from a Sony IMX378-equivalent sensor through MIPI CSI-2 to the VPU, where DepthAI firmware handles image processing and neural network inference. Frames are written to disk and exposed through multiple odc-api REST endpoints. - -**Key Findings:** -- Camera controlled via `depthai_gate.service` (Python/Flask on port 11492) -- ML inference handled by `map-ai.service` using the VPU's Neural Compute Engine -- Live frames stored in `/tmp/recording/pics/` -- Landmark observation images stored in `/data/recording/cached_observations/` -- Preview mode restarts the camera-bridge service with different configuration -- No direct V4L2 access — all camera access goes through DepthAI pipeline - ---- - -## 1. Hardware - -### 1.1 System-on-Chip: Intel Keem Bay - -| Component | Specification | -|-----------|---------------| -| **SoC** | Intel Keem Bay (RVC2 / Robotics Vision Core 2) | -| **CPU** | 4× ARM Cortex-A53 @ 1.5GHz | -| **VPU** | Intel Movidius Myriad X (16 SHAVE cores) | -| **NPU** | Integrated Neural Compute Engine (hardware inference) | -| **RAM** | 4GB LPDDR4 (~3.5GB usable) | -| **ISP** | Integrated Image Signal Processor on VPU | -| **Process** | 10nm (Intel) | - -**Memory Configuration:** -``` -MemTotal: 3,584,000 kB (~3.5GB) -SwapTotal: 2,097,148 kB (~2GB) -CmaTotal: 1,408,000 kB (~1.34GB reserved for VPU/camera DMA) -``` - -### 1.2 Camera Sensor - -| Specification | Value | -|---------------|-------| -| **Sensor** | Sony IMX378 (or equivalent 12MP) | -| **Resolution** | 4056 × 3040 native, downscaled to 2028 × 1024 | -| **Interface** | MIPI CSI-2 | -| **Frame Rate** | Variable, typically 30 FPS | -| **ISP** | On-VPU processing via DepthAI | - -The Bee uses a Luxonis OAK-1 compatible camera module integrated with the Keem Bay SoC. The camera sensor connects directly to the SoC's MIPI CSI-2 interface, which is managed entirely by the DepthAI/Luxonis firmware running on the Myriad X VPU. - -### 1.3 Bus Architecture +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. ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Intel Keem Bay SoC │ -├─────────────────────────────────────────────────────────────────┤ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ -│ │ ARM Cortex │ │ Myriad X VPU │ │ Neural Compute │ │ -│ │ A53 (4-core) │ │ (16 SHAVE) │ │ Engine (NCE) │ │ -│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │ -│ │ │ │ │ -│ └────────┬────────┴──────────────────────┘ │ -│ │ │ -│ ┌────────┴────────┐ │ -│ │ Internal Bus │ │ -│ │ (AXI/NoC) │ │ -│ └────────┬────────┘ │ -│ │ │ -│ ┌─────────────┼─────────────────────────────────────┐ │ -│ ┌─┴──┐ ┌────┴────┐ ┌────────┐ ┌───────────┐ │ │ -│ │PCIe│ │ USB │ │ SDIO │ │ MIPI CSI │ │ │ -│ └──┬─┘ └────┬────┘ └────┬───┘ └─────┬─────┘ │ │ -└─────┼───────────┼──────────────┼──────────────┼────────┘ │ - │ │ │ │ │ -┌─────┴─────┐ ┌───┴───────┐ ┌──┴──┐ ┌────┴─────┐ │ -│ Marvell │ │ Telit │ │eMMC │ │ Camera │ │ -│ 88W8997 │ │ LE910C4 │ │Flash│ │ Module │ │ -│ WiFi/BT │ │ LTE Modem │ │ │ │ (IMX378) │ │ -└───────────┘ └───────────┘ └─────┘ └──────────┘ │ +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. -## 2. Kernel / V4L2 +Bus layout: -### 2.1 Kernel Modules - -The Bee runs a custom Yocto-based Linux with Intel-specific VPU drivers: - -| Module | Purpose | Status | -|--------|---------|--------| -| **kmb_cam** (if present) | Keem Bay camera driver | Likely used internally | -| **kmb_imx412** (if present) | Sony IMX412 sensor driver | May be loaded for sensor | -| **videodev** | V4L2 subsystem | Core video framework | -| **v4l2_fwnode** | V4L2 firmware node parsing | Device tree integration | - -**Note:** Standard V4L2 device access (`/dev/video*`) is **not used** for normal operation. The camera is accessed exclusively through the DepthAI XLink protocol running on the VPU. The VPU owns the camera hardware completely. - -### 2.2 VPU Sysfs Interface - -The VPU is controlled via sysfs: ``` -/sys/class/vpu/ +┌─────────────────────────────────────────────────────┐ +│ 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 ``` -**Firmware Loading:** -- `luxonis_vpu.bin` — DepthAI firmware (Luxonis/OAK) -- `vpu_nvr_b0.bin` — Intel HDDL firmware (NOT used, conflicts) +## kernel + V4L2 -The VPU firmware is written to a sysfs attribute (`fwname`) to trigger loading. The DepthAI firmware must load first, otherwise the Intel HDDL service (`deviceservice`) grabs the VPU and causes conflicts. +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). -### 2.3 No Direct V4L2 Access +VPU is controlled via sysfs at `/sys/class/vpu/`. Firmware is loaded by writing the filename to the `fwname` attribute. Two firmwares are present: -**Important:** You cannot access the camera via `/dev/video*` while `depthai_gate` is running. The DepthAI pipeline has exclusive ownership of the camera hardware. To get frames, you must: -1. Use the existing depthai_gate/odc-api stack, OR -2. Stop depthai_gate and implement your own DepthAI pipeline, OR -3. Reverse-engineer XLink and write custom firmware +- `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. -## 3. DepthAI Gate +## depthai_gate -### 3.1 Service Configuration +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 -# depthai_gate.service (inferred from analysis) [Unit] Description=DepthAI Camera Gate After=network.target @@ -136,84 +58,40 @@ Type=simple User=root ExecStart=/opt/depthai_gate/run.py Restart=always - -[Install] -WantedBy=multi-user.target ``` -### 3.2 Technical Details +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. -| Property | Value | -|----------|-------| -| **Language** | Python 3 + Flask | -| **Port** | 11492 (localhost) | -| **Tasks** | ~158 threads observed | -| **Memory** | ~200MB RSS | -| **Location** | `/opt/depthai_gate/` (estimated) | +XLink status values seen in logs: -### 3.3 Responsibilities - -1. **VPU Firmware Loading** — Writes `luxonis_vpu.bin` to VPU sysfs -2. **XLink Connection** — Establishes PCIe XLink to Myriad X VPU -3. **DepthAI Pipeline** — Configures camera capture and ISP settings -4. **Frame Capture** — Captures frames at configured resolution/framerate -5. **Frame Output** — Writes frames to `/tmp/recording/pics/` - -### 3.4 XLink Protocol - -XLink is Luxonis's proprietary protocol for host-to-VPU communication: -- **Transport:** PCIe (on Keem Bay) or USB (on desktop OAK devices) -- **Channels:** Bidirectional data streams for frames, tensors, and control -- **Status Values:** - - `0` = Disconnected - - `1` = Connecting / Error - - `2` = Connected (good) - -**Status Check (from logs):** ``` -xlink_device_status=2 # Healthy -vpu_firmware=luxonis_vpu.bin +xlink_device_status=2 # connected, healthy + =1 # connecting / error + =0 # disconnected ``` -### 3.5 Pipeline Configuration +### the VPU conflict -The DepthAI pipeline likely includes: -- **ColorCamera node** — IMX378 capture at 4K, downscaled to 2028×1024 -- **ImageManipNode** — Resize, crop, color conversion -- **XLinkOut node** — Send frames to host for storage -- **NeuralNetwork node** (optional) — On-VPU inference - -Pipeline configs may exist at: -- `/opt/depthai_gate/pipeline.json` -- `/data/camera_config.json` -- Hardcoded in Python - -### 3.6 VPU Conflict Bug - -**Root Cause (identified and fixed):** - -`deviceservice.service` (Intel HDDL / OpenVINO) was racing with `depthai_gate.service`: +`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 locked out, retries XLink every 2 seconds forever -4. On depthai_gate restart, HDDL grabs VPU first → camera dead -5. Watchdog (`secure-wdtclient`) crash loops → memory pressure → OOM +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: -**Fix:** ```bash systemctl disable --now deviceservice -systemctl mask deviceservice # Survives OTA better +systemctl mask deviceservice # survives OTA ``` ---- +## map-ai -## 4. map-ai Pipeline - -### 4.1 Service Configuration +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 -# map-ai.service (inferred) [Unit] Description=Map AI Processing After=depthai_gate.service @@ -223,156 +101,102 @@ Type=simple User=root ExecStart=/opt/map-ai/run.py Restart=always - -[Install] -WantedBy=multi-user.target ``` -### 4.2 Technical Details - -| Property | Value | -|----------|-------| -| **Language** | Python 3 | -| **Model Format** | ONNX (via OpenVINO or DepthAI NCE) | -| **Input** | Frames from depthai_gate | -| **Output** | Detections to Redis, blurred frames to disk | - -### 4.3 Processing Pipeline +Pipeline: ``` -Frame from depthai → map-ai.py +frame from depthai_gate │ ▼ -┌───────────────────────────────────────┐ -│ ML INFERENCE (on VPU) │ -│ - Road sign classifier │ -│ - Face detector (privacy) │ -│ - License plate detector (privacy) │ -└───────────────────────┬───────────────┘ - │ - ▼ -┌───────────────────────────────────────┐ -│ PRIVACY PROCESSING │ -│ - PrivacyBlurNode │ -│ - Gaussian blur on faces/plates │ -│ - cv2.imwrite blurred frames │ -└───────────────────────┬───────────────┘ - │ - ┌───────────────┴───────────────┐ - ▼ ▼ -Redis ZSET (detections) Disk (blurred frames) + 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) ``` -### 4.4 AI Models +Models: -| Model | Location | Purpose | -|-------|----------|---------| -| Road Signs | `/opt/object-detection/model.blob` or `/data/models/` | Sign classification | -| Privacy | `/opt/odc-api/python/` or `/data/models/` | Face/plate detection | -| PVC | `/data/recording/models/pvc.onnx` | Unknown (227 bytes — likely index) | +| 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:** Stored in FrameKm metadata for verification. +Privacy model hash gets baked into FrameKm metadata for verification. -### 4.5 Redis Integration +Redis only carries readiness flags: -map-ai writes to Redis status keys: ``` -GET MAP_AI_READY → "True" -GET EXTERNAL_MODEL_CLASSIFIER_READY → "True" +GET MAP_AI_READY → "True" +GET EXTERNAL_MODEL_CLASSIFIER_READY → "True" ``` -Detection results stored in SQLite, not Redis ZSETs. +Detections go to SQLite, not Redis ZSETs. ---- +## frame storage -## 5. 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 | -### 5.1 Storage Locations +Frames are JPEG at 2028×1024, ~85% quality, ~150-200KB each. -| Path | Type | Purpose | Persistence | -|------|------|---------|-------------| -| `/tmp/recording/pics/` | tmpfs | Live camera frames | Ephemeral | -| `/tmp/recording/preview/` | tmpfs | Preview mode frames | Ephemeral | -| `/data/recording/cached_observations/` | ext4 | Landmark observation images | Persistent | -| `/data/recording/framekm/` | ext4 | FrameKm upload bundles | Persistent | -| `/tmp/rgb/` | tmpfs | Frame list files | Ephemeral | +Naming: -### 5.2 Frame Format - -| Property | Value | -|----------|-------| -| **Format** | JPEG | -| **Resolution** | 2028 × 1024 | -| **Quality** | ~85% (estimated ~150-200KB/frame) | -| **Color** | RGB | - -### 5.3 Naming Convention - -**Live frames** (`/tmp/recording/pics/`): ``` -{system_time_ms}_{frame_id}_{sequence}.jpg -Example: 1709920000123_0001_0042.jpg +# 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 ``` -**Cached observations** (`/data/recording/cached_observations/`): -``` -{timestamp}_{subsecond}_{frame_number}.jpg -Example: 1746377552_043000_2945056.jpg -``` +`folder_purger` keeps disk under control — when `/tmp/recording/pics/` crosses 400MB, oldest frames go: -### 5.4 Frame Purger - -The `folder_purger` service manages disk space: -```bash +``` folder-purger /tmp/recording/pic 400000000 /mnt/data/gps 2000000000 ... ``` -When `/tmp/recording/pics/` exceeds 400MB, older frames are deleted. - -### 5.5 Database Schema - -Frames are tracked in SQLite (`/data/recording/odc-api.db` or `data-logger.v2.0.0.db`): +SQLite schemas (simplified): ```sql --- frames table CREATE TABLE frames ( system_time INTEGER PRIMARY KEY, image_name TEXT ); -``` -Landmark observations reference frames: -```sql --- observations table (simplified) CREATE TABLE observations ( id INTEGER PRIMARY KEY, landmark_id INTEGER, image_name TEXT, - x1 REAL, y1 REAL, x2 REAL, y2 REAL, -- bounding box - ts INTEGER, - ... + 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`). -## 6. video-processor +## video-processor + FrameKm -### 6.1 Service Details +`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. -The `video-processor` service is not explicitly documented in the analyzed firmware, but based on naming patterns, it likely handles: +A FrameKm is ~1km of driving data: -1. **FrameKm Bundling** — Package frames + metadata for upload -2. **Video Encoding** — H.264/H.265 encoding for preview/streaming -3. **Frame Sequencing** — Order frames for FrameKm creation - -### 6.2 FrameKm Format - -**Purpose:** Bundle ~1km of driving data for upload to Hivemapper/HERE. - -**Path:** `/data/recording/framekm/` - -**Contents:** ``` framekm-2024-03-08-12-34-56-abc123.tar ├── manifest.json @@ -383,7 +207,8 @@ framekm-2024-03-08-12-34-56-abc123.tar └── gnss_auth_signature.bin ``` -**Manifest Fields:** +Manifest: + ```json { "name": "framekm-2024-03-08-12-34-56-abc123", @@ -398,253 +223,141 @@ framekm-2024-03-08-12-34-56-abc123.tar } ``` -### 6.3 Relationship to Camera Frames +## odc-api -The video-processor does NOT produce the frames we care about for camera access. It only packages existing blurred frames for upload. For raw frame access, focus on `depthai_gate` and the preview system. +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: -## 7. odc-api Camera Endpoints +| Endpoint | Method | Notes | +|----------|--------|-------| +| `/preview/start` | GET | 120s timeout, then auto-stop | +| `/preview/stop` | GET | | +| `/preview/status` | GET | | +| `/preview/metadata` | GET | latest frame metadata | -### 7.1 Base URL - -``` -http://192.168.0.10:5000/api/1/ -``` - -Binds to AP interface (`wlp1s0f0`) only — not accessible from home LAN directly. - -### 7.2 Preview Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/preview/start` | GET | Start preview mode (120s timeout) | -| `/preview/stop` | GET | Stop preview mode | -| `/preview/status` | GET | Check if preview is active | -| `/preview/metadata` | GET | Get latest frame metadata | - -**Preview Implementation (`util/preview.ts`):** +Preview works by writing a new config and bouncing camera-bridge: ```typescript export const startPreview = async () => { - // Create preview directory await execSync('mkdir /tmp/recording/preview'); - - // Write preview config writeFileSync(IMAGER_CONFIG_PATH, JSON.stringify(getPreviewConfig())); - - // Restart camera-bridge with new config - await execSync(CMD.STOP_CAMERA); // systemctl stop camera-bridge + await execSync(CMD.STOP_CAMERA); await sleep(1000); - await execSync(CMD.START_CAMERA); // systemctl start camera-bridge + await execSync(CMD.START_CAMERA); }; ``` -**Preview Timeout:** 120 seconds (auto-stops to preserve 4K quality recording) +The 120s auto-stop is there to protect 4K recording quality. -### 7.3 Landmark Image Endpoints +Landmark endpoints (where the cached observation images come out): -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/landmarks/images/:id` | GET | Get image paths for landmark | -| `/landmarks/:id/chips` | GET | Get chip endpoints for landmark | -| `/landmarks/:id/chips/:chip_id` | GET | Get cropped observation image (JPEG) | -| `/landmarks/boundingBox/:id` | GET | Get bounding box coordinates | -| `/landmarks/upload` | PUT | Upload landmark image to external URL | +| 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:** +Image retrieval flow: ``` GET /landmarks/images/123 - ↓ -Returns: ["/data/recording/cached_observations/1746377552_043000_2945056.jpg"] - ↓ + → ["/data/recording/cached_observations/1746377552_043000_2945056.jpg"] + GET /landmarks/123/chips/456 - ↓ -Returns: Cropped JPEG (bounding box region) + → cropped JPEG of the bbox region ``` -### 7.4 Camera Configuration +Camera bridge config: `/opt/camera-bridge/config.json`. Control commands from `bee.ts`: -**Config Path:** `/opt/camera-bridge/config.json` - -**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', - // ... + START_CAMERA: 'systemctl start camera-bridge', + STOP_CAMERA: 'systemctl stop camera-bridge', + START_PREVIEW: 'systemctl start camera-preview', + STOP_PREVIEW: 'systemctl stop camera-preview', }; ``` -### 7.5 Frame Retrieval +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. -There is **no direct `/camera/frame` endpoint** in the current odc-api. To get a camera frame: - -1. **Via Preview Mode:** - - Call `/preview/start` - - Read frames from `/tmp/recording/preview/` - - Call `/preview/stop` when done - -2. **Via Landmark Images:** - - Call `/landmarks/last/N` to get recent detections - - Call `/landmarks/images/:id` to get observation image paths - - Call `/landmarks/:id/chips/:chip_id` to get cropped JPEG - -3. **Direct File Access (SSH):** - - Read from `/tmp/recording/pics/` for latest frames - - Read from `/data/recording/cached_observations/` for landmark images - ---- - -## 8. Full Data Flow - -### 8.1 Complete Pipeline +## full data flow ``` -┌──────────────────────────────────────────────────────────────────────────────┐ -│ CAMERA CAPTURE │ -│ IMX378 Sensor → MIPI CSI-2 → VPU ISP → DepthAI Pipeline │ -└───────────────────────────────────┬──────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────────────┐ -│ DEPTHAI_GATE (port 11492) │ -│ - XLink communication with Myriad X VPU │ -│ - Frame capture from DepthAI pipeline │ -│ - Writes frames to /tmp/recording/pics/ │ -└───────────────────────────────────┬──────────────────────────────────────────┘ - │ - ┌───────────────┴───────────────┐ - ▼ ▼ -┌───────────────────────────────┐ ┌──────────────────────────────────────────┐ -│ RAW FRAME STORAGE │ │ MAP-AI INFERENCE │ -│ /tmp/recording/pics/ │ │ - Road sign detection (VPU NCE) │ -│ - Temporary frames │ │ - Privacy blur (faces/plates) │ -│ - Purged when >400MB │ │ - Outputs to Redis + SQLite │ -└───────────────────────────────┘ └─────────────────┬────────────────────────┘ - │ - ┌─────────────────────────────────┤ - ▼ ▼ -┌───────────────────────────────────┐ ┌──────────────────────────────────────┐ -│ CACHED OBSERVATIONS │ │ LANDMARK DATABASE │ -│ /data/recording/ │ │ /data/recording/odc-api.db │ -│ cached_observations/ │ │ - landmarks table │ -│ - Persistent blurred frames │ │ - observations table │ -│ - Referenced by landmark ID │ │ - frames table │ -└───────────────────┬───────────────┘ └─────────────────┬────────────────────┘ - │ │ - └──────────────┬──────────────────────┘ +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 (port 5000) │ -│ - /preview/* — Start/stop preview mode │ -│ - /landmarks/last/N — Get recent detections │ -│ - /landmarks/images/:id — Get observation image paths │ -│ - /landmarks/:id/chips/:chip_id — Get cropped JPEG │ -└───────────────────────────────────┬──────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────────────┐ -│ FRAMEKM BUNDLING │ -│ hivemapper-data-logger │ -│ - Collect ~1km of frames + metadata │ -│ - Bundle with GNSS auth signatures │ -│ - Store at /data/recording/framekm/ │ -└───────────────────────────────────┬──────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────────────┐ -│ UPLOAD PATH │ -│ odc-api → mitmdump (port 8888) → Cloudflare Workers → HERE OLP │ -└──────────────────────────────────────────────────────────────────────────────┘ + odc-api (:5000) + /preview/*, /landmarks/* + │ + ▼ + hivemapper-data-logger + → FrameKm bundles in /data/recording/framekm/ + │ + ▼ + odc-api → mitmdump :8888 → Cloudflare Workers → HERE OLP ``` -### 8.2 Single Detection Event Trace +Single detection trace: ``` -1. Camera captures frame - └── IMX378 → MIPI → VPU ISP → depthai_gate - -2. Frame written to disk - └── /tmp/recording/pics/1709920000123_0001_0042.jpg - -3. map-ai reads frame - └── Runs road sign classifier on VPU NCE - -4. Detection found (speed limit 35) - └── Privacy blur applied to any faces/plates - -5. Observation stored - └── SQLite: observations table (landmark_id, bbox, ts, image_name) - └── File: /data/recording/cached_observations/... - -6. Landmark created/updated - └── SQLite: landmarks table (class_label, lat, lon, confidence) - -7. odc-api exposes data - └── GET /landmarks/last/5 returns detection - └── GET /landmarks/images/{id} returns image path - └── GET /landmarks/{id}/chips/{chip_id} returns cropped JPEG +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 -## 9. Replacement Considerations +### getting a frame without odc-api -### 9.1 Accessing Frames Without odc-api +Direct file read (simplest, race-prone, no metadata): -**Option 1: Direct File Read** ```bash -# SSH to Bee -ssh -p 2222 root@localhost # via Lucy tunnel - -# Read latest frames +ssh -p 2222 root@localhost # via Lucy tunnel ls -lt /tmp/recording/pics/ | head -10 -cp /tmp/recording/pics/latest_frame.jpg /tmp/ -# Stream frames (naive) +# poor man's stream while true; do cp $(ls -t /tmp/recording/pics/*.jpg | head -1) /tmp/current.jpg - sleep 0.033 # ~30 FPS + sleep 0.033 done ``` -**Pros:** Simple, no service changes -**Cons:** Race conditions, no metadata +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. -**Option 2: Redis Pub/Sub** -Subscribe to frame events if depthai_gate publishes them: -```python -import redis -r = redis.Redis() -p = r.pubsub() -p.subscribe('frame_ready') -for message in p.listen(): - print(message) # Contains frame path or metadata -``` +### getting a frame without depthai_gate -**Pros:** Event-driven, no polling -**Cons:** May not exist in current firmware +Don't, unless you really mean it. You'd be replacing the whole VPU pipeline: -### 9.2 Accessing Frames Without depthai_gate - -**Not recommended** — requires implementing your own DepthAI pipeline. - -If you must: -1. Stop depthai_gate: `systemctl stop depthai_gate` -2. Use Luxonis depthai Python SDK -3. Create minimal 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) # Downscale to 2028x1024 +cam.setIspScale(1, 2) # → 2028×1024 xout = pipeline.create(dai.node.XLinkOut) xout.setStreamName("video") @@ -653,51 +366,37 @@ cam.video.link(xout.input) with dai.Device(pipeline) as device: q = device.getOutputQueue("video") while True: - frame = q.get() - cv2.imwrite("/tmp/frame.jpg", frame.getCvFrame()) + cv2.imwrite("/tmp/frame.jpg", q.get().getCvFrame()) ``` -**Pros:** Full control over camera -**Cons:** Breaks all Hivemapper services, loses ML pipeline +Breaks everything Hivemapper depends on — map-ai, landmarks, FrameKm. Only useful if the goal is full liberation, not augmentation. -### 9.3 Minimal Path to JPEG Frame +### fastest frame grabs + +With the stack running: -**Fastest (with existing stack):** ```bash -# Via SSH +# direct ssh -p 2222 root@localhost 'ls -t /tmp/recording/pics/*.jpg | head -1 | xargs cat' > frame.jpg -``` -**Via API (requires preview mode):** -```bash +# 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 ``` -### 9.4 Building a Custom Camera Interface +### proposed odc-api extension -**Requirements:** -1. Maintain depthai_gate (or reimplement VPU control) -2. Expose a REST endpoint for single-frame capture -3. Optionally implement MJPEG streaming +Two new routes — single frame + MJPEG stream: -**Proposed odc-api Addition:** ```typescript -// routes/camera.ts router.get('/frame', async (req, res) => { const frames = readdirSync('/tmp/recording/pics') .filter(f => f.endsWith('.jpg')) - .sort() - .reverse(); - - if (frames.length === 0) { - return res.status(404).send('No frames available'); - } - - const framePath = join('/tmp/recording/pics', frames[0]); - res.sendFile(framePath); + .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) => { @@ -705,106 +404,64 @@ router.get('/stream', async (req, res) => { '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 > 0) { - const framePath = join('/tmp/recording/pics', frames[0]); - const frameData = readFileSync(framePath); - + .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: ${frameData.length}\r\n\r\n`); - res.write(frameData); + res.write(`Content-Length: ${data.length}\r\n\r\n`); + res.write(data); res.write('\r\n'); } - }, 33); // ~30 FPS - + }, 33); // ~30 FPS req.on('close', () => clearInterval(interval)); }); ``` -### 9.5 Architecture for Replacement System +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 ``` -┌──────────────────────────────────────────────────────────────────┐ -│ VARROA CAMERA SERVICE │ -├──────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ -│ │ depthai_gate │ → │ varroa-camera │ → │ HTTP API │ │ -│ │ (unchanged) │ │ (new service) │ │ (port 80) │ │ -│ └─────────────────┘ └─────────────────┘ └──────────────┘ │ -│ ↓ ↓ ↓ │ -│ /tmp/recording/pics/ Monitor & serve GET /frame │ -│ frames via inotify GET /stream │ -│ GET /landmarks │ -└──────────────────────────────────────────────────────────────────┘ +/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 ``` ---- - -## 10. Open Questions - -| Question | Priority | How to Investigate | -|----------|----------|-------------------| -| Exact depthai_gate pipeline config | High | SSH in, find config files in /opt/ | -| Does depthai_gate publish to Redis? | High | `redis-cli MONITOR` while recording | -| Camera-bridge vs depthai_gate relationship | High | Check systemd deps, trace with strace | -| Preview config format | Medium | Read `getPreviewConfig()` implementation | -| ML model exact location on Bee | Medium | `find /opt /data -name "*.blob" -o -name "*.onnx"` | -| Frame timestamp accuracy | Medium | Compare frame timestamps to GNSS time | - ---- - -## Appendix A: Key File Paths - -| Path | Purpose | -|------|---------| -| `/tmp/recording/pics/` | Live camera frames | -| `/tmp/recording/preview/` | Preview mode frames | -| `/data/recording/cached_observations/` | Landmark observation images | -| `/data/recording/framekm/` | FrameKm upload bundles | -| `/data/recording/odc-api.db` | SQLite database | -| `/opt/camera-bridge/config.json` | Camera configuration | -| `/opt/depthai_gate/` | DepthAI service (estimated) | -| `/opt/odc-api/` | Node.js API service | -| `/sys/class/vpu/` | VPU sysfs interface | - -## Appendix B: Service Dependencies +## appendix — boot order ``` multi-user.target - │ - ├── redis.service [t+2s] - │ - ├── depthai_gate.service [t+8s] # MUST start before map-ai - │ │ - │ └── Loads luxonis_vpu.bin - │ - ├── map-ai.service [t+12s] # Depends on depthai_gate - │ │ - │ └── Privacy blur, ML inference - │ - ├── hivemapper-data-logger.service [t+15s] - │ - └── odc-api.service [t+18s] # REST API + ├── 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 C: Port Reference +## appendix — ports -| Port | Service | Protocol | Binding | -|------|---------|----------|---------| -| 22 | sshd | TCP | AP only (via socket) | -| 5000 | odc-api | HTTP | AP interface | -| 6379 | Redis | TCP | localhost | -| 8888 | mitmdump | HTTP | localhost | -| 11492 | depthai_gate | HTTP/Flask | localhost | - ---- - -*End of Report* +``` +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 +```