Public-flip prep: env-driven keystore, README, hardened cleartext, leaner docs
- 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.
This commit is contained in:
parent
261b31c49a
commit
84e8777290
7 changed files with 495 additions and 1283 deletions
46
README.md
Normal file
46
README.md
Normal file
|
|
@ -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=<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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
<!-- HTTPS strict everywhere by default. -->
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<!-- Bee AP runs HTTP on the device-AP subnet — there's no real
|
||||
alternative without breaking the Bee protocol. Scope the
|
||||
cleartext exception to just that one host. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">192.168.0.10</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
@ -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
|
||||
<div class="air-quality-legend">
|
||||
<h4>Air Quality Index</h4>
|
||||
<div class="legend-item"><span class="color green"></span> 0-50 Excellent</div>
|
||||
<div class="legend-item"><span class="color yellow"></span> 51-100 Good</div>
|
||||
<div class="legend-item"><span class="color orange"></span> 101-150 Moderate</div>
|
||||
<div class="legend-item"><span class="color red"></span> 151-200 Unhealthy</div>
|
||||
<div class="legend-item"><span class="color purple"></span> 201-300 Very Unhealthy</div>
|
||||
<div class="legend-item"><span class="color maroon"></span> 301+ Hazardous</div>
|
||||
<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
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue