- 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.
369 lines
13 KiB
Markdown
369 lines
13 KiB
Markdown
# Air quality sensor on the Bee
|
||
|
||
Can the Bee carry an air quality sensor alongside the existing Hivemapper pipeline and feed readings into AdaMaps? Short answer: yes, with the USB-C data port and a USB-to-I2C bridge. The polling overhead is negligible — the real work is on the AdaMaps side (new ingest endpoint, PostGIS table, heatmap overlay).
|
||
|
||
## what's free on the Bee
|
||
|
||
Hardware is Keem Bay (RVC2), 4× A53 @ 1.5GHz, Myriad X VPU, 3.5GB usable RAM (1.34GB of that is CMA-reserved for VPU DMA), leaving ~2.2GB for userspace.
|
||
|
||
Current service load (pre Phase-1 cleanup):
|
||
|
||
| Service | CPU | RAM | Notes |
|
||
|---------|-----|-----|-------|
|
||
| map-ai | ~32% | ~1.1GB | VPU inference |
|
||
| odc-api | ~48% | ~139MB | Phase 2 replacement target |
|
||
| depthai_gate | ~5% | ~200MB | camera |
|
||
| redis | <1% | ~50MB | |
|
||
| **total** | ~85% | ~1.5GB | |
|
||
|
||
After Phase 1 (odc-api shrink): ~50-60% CPU free (2-2.4 cores idle) and 700MB-1GB RAM free. More than enough for a 1Hz polling loop.
|
||
|
||
USB topology — the Keem Bay USB controller hosts an internal hub. The LTE modem (Telit LE910C4) sits on one internal port; the external USB-C is the other. It does carry data, not just power, so it's the target for sensor attachment.
|
||
|
||
```
|
||
Keem Bay USB controller
|
||
└── internal hub
|
||
├── Telit LE910C4 (internal)
|
||
└── USB-C data port (external) ← us
|
||
```
|
||
|
||
## sensor candidates
|
||
|
||
| Model | Maker | Measures | Iface | Notes |
|
||
|-------|-------|----------|-------|-------|
|
||
| BME680 | Bosch | VOC, temp, RH, pressure | I2C/SPI | indoor IAQ, ~$10-20 |
|
||
| BME688 | Bosch | BME680 + AI gas scanning | I2C/SPI | advanced VOC classification |
|
||
| SEN50 | Sensirion | PM1.0/PM2.5/PM4/PM10 | I2C/UART | particulates only |
|
||
| SEN54 | Sensirion | PM + VOC + temp + RH | I2C/UART | |
|
||
| SEN55 | Sensirion | SEN54 + NOx | I2C/UART | full air-quality suite |
|
||
|
||
Worth flagging: SEN5x is Sensirion, not Bosch. If the sensor on hand is branded Bosch it's almost certainly a BME680 or BME688.
|
||
|
||
**BME680/688** — VOC as IAQ index 0-500; temp -40 to +85°C ±1°C; RH 0-100% ±3%; pressure 300-1100 hPa ±1 hPa; 3.6mA active, <1µA sleep. I2C address 0x76 or 0x77. Cheap, well-documented, low power, but VOC is a relative index (not absolute concentration) and the gas sensor needs ~48h of burn-in before readings stabilize.
|
||
|
||
**SEN55** — PM1.0/PM2.5/PM4/PM10 (0-1000 µg/m³), VOC index, NOx index, temp -10 to +50°C, RH 0-100%. ~60mA. Native I2C or UART. Bigger (~40×40×12mm) and pricier (~$50-80), but it measures actual particulate matter, which is the metric that matters for outdoor pollution mapping.
|
||
|
||
For AdaMaps urban pollution work, SEN55 is the right pick — PM2.5 and NOx are the actionable numbers. For a quick "does this work at all" prototype, BME680 is fine.
|
||
|
||
## USB bridge options
|
||
|
||
For I2C sensors (BME680/688) we need a USB-to-I2C adapter:
|
||
|
||
| Adapter | Cost | Notes |
|
||
|---------|------|-------|
|
||
| Adafruit FT232H | $15 | FTDI, good support, `ftdi_sio` driver |
|
||
| MCP2221A | $5 | Microchip, HID mode, `i2c-mcp2221` |
|
||
| CP2112 | $8 | Silicon Labs, HID mode |
|
||
| CH341 | $3 | generic Chinese, works but flaky |
|
||
|
||
FT232H shows up as `/dev/i2c-X` via `ftdi_sio`; MCP2221A as `/dev/hidraw*` or `/dev/i2c-X`. Python side: `smbus2` for low-level, `adafruit-blinka` + `adafruit-circuitpython-bme680` for the BME, or `pyftdi` to drive the bridge directly.
|
||
|
||
Quick read with FT232H + BME680:
|
||
|
||
```python
|
||
import board, adafruit_bme680
|
||
i2c = board.I2C()
|
||
sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c)
|
||
print(sensor.temperature, sensor.humidity, sensor.pressure, sensor.gas)
|
||
```
|
||
|
||
For SEN5x, simplest is UART mode (jumper on the sensor) + CP2102 USB-UART (~$2). Sensor shows up as `/dev/ttyUSB0`; talk SHDLC at 115200. The Sensirion SEK-SEN55 eval kit has native USB-C and appears as CDC-ACM, but it's $100 and oversized — fine for bench testing, wrong for production.
|
||
|
||
Picking one: MCP2221A + BME680 breakout ~$15 total for basic VOC. CP2102 + SEN55 ~$55 for full particulate matter.
|
||
|
||
## bee-side integration
|
||
|
||
New service `air-sensor.service` polls the sensor at 1Hz and writes a Redis key. `bee-collector.py` (existing) reads that key during GPS fusion and includes it in the upload payload.
|
||
|
||
```
|
||
USB sensor + adapter
|
||
│
|
||
▼
|
||
air-sensor.service (Python, 1Hz)
|
||
│
|
||
▼
|
||
Redis: AirQuality1Hz
|
||
│
|
||
▼
|
||
bee-collector.py ─ GNSSFusion30Hz ── (fuse) ─► HTTPS to AdaMaps
|
||
```
|
||
|
||
Service unit:
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=Air Quality Sensor Reader
|
||
After=redis.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=root
|
||
ExecStart=/opt/air-sensor/air_sensor.py
|
||
Restart=always
|
||
RestartSec=5
|
||
```
|
||
|
||
`/opt/air-sensor/air_sensor.py`:
|
||
|
||
```python
|
||
#!/usr/bin/env python3
|
||
"""Poll BME680/688 over USB-I2C, publish to Redis at 1Hz."""
|
||
|
||
import json, time, redis
|
||
import board, adafruit_bme680
|
||
|
||
POLL = 1.0
|
||
KEY = "AirQuality1Hz"
|
||
|
||
def iaq(gas, _humidity):
|
||
# placeholder — for real IAQ use Bosch BSEC
|
||
if gas > 300000: return 50
|
||
if gas > 200000: return 100
|
||
if gas > 100000: return 150
|
||
if gas > 50000: return 200
|
||
return 300
|
||
|
||
def main():
|
||
r = redis.Redis()
|
||
i2c = board.I2C()
|
||
sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=0x77)
|
||
sensor.sea_level_pressure = 1013.25
|
||
|
||
while True:
|
||
reading = {
|
||
"ts": int(time.time() * 1000),
|
||
"temperature_c": round(sensor.temperature, 2),
|
||
"humidity_pct": round(sensor.humidity, 2),
|
||
"pressure_hpa": round(sensor.pressure, 2),
|
||
"gas_resistance_ohms": sensor.gas,
|
||
"iaq_index": iaq(sensor.gas, sensor.humidity),
|
||
}
|
||
r.set(KEY, json.dumps(reading))
|
||
r.publish("air_quality", json.dumps(reading))
|
||
time.sleep(POLL)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
The IAQ calc above is a placeholder — for real-world readings you want the Bosch BSEC library, which is closed-source but free for non-commercial use (license check needed before shipping anything that's not personal).
|
||
|
||
Extending bee-collector to merge it in:
|
||
|
||
```python
|
||
def get_air_quality():
|
||
data = redis_client.get("AirQuality1Hz")
|
||
return json.loads(data) if data else None
|
||
|
||
def collect_frame():
|
||
gnss = json.loads(redis_client.get("GNSSFusion30Hz") or "{}")
|
||
air = get_air_quality()
|
||
return {
|
||
"timestamp": int(time.time() * 1000),
|
||
"lat": gnss.get("lat"),
|
||
"lon": gnss.get("lon"),
|
||
"speed_kmh": gnss.get("speed"),
|
||
"air_temperature_c": air and air.get("temperature_c"),
|
||
"air_humidity_pct": air and air.get("humidity_pct"),
|
||
"air_pressure_hpa": air and air.get("pressure_hpa"),
|
||
"air_iaq_index": air and air.get("iaq_index"),
|
||
"air_gas_ohms": air and air.get("gas_resistance_ohms"),
|
||
}
|
||
```
|
||
|
||
Resource impact: <0.5% CPU, ~15MB RAM, one thread, <1KB/s USB traffic. Doesn't touch the camera, VPU, or map-ai. Negligible.
|
||
|
||
## AdaMaps side
|
||
|
||
Two endpoints + one table.
|
||
|
||
### `/api/ingest/air`
|
||
|
||
```python
|
||
@app.route('/api/ingest/air', methods=['POST'])
|
||
def ingest_air_quality():
|
||
if not verify_api_key(request):
|
||
return jsonify({"error": "Unauthorized"}), 401
|
||
data = request.json
|
||
required = ['lat', 'lon', 'timestamp']
|
||
if not all(k in data for k in required):
|
||
return jsonify({"error": "Missing required fields"}), 400
|
||
|
||
conn = get_db(); cur = conn.cursor()
|
||
cur.execute("""
|
||
INSERT INTO air_quality (
|
||
device_id, timestamp,
|
||
lat, lon, geom,
|
||
temperature_c, humidity_pct, pressure_hpa,
|
||
iaq_index, gas_ohms,
|
||
pm1_0, pm2_5, pm4_0, pm10,
|
||
voc_index, nox_index
|
||
) VALUES (
|
||
%s, to_timestamp(%s / 1000.0),
|
||
%s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326),
|
||
%s, %s, %s,
|
||
%s, %s,
|
||
%s, %s, %s, %s,
|
||
%s, %s
|
||
)
|
||
""", (
|
||
data.get('device_id'), data['timestamp'],
|
||
data['lat'], data['lon'],
|
||
data['lon'], data['lat'], # ST_MakePoint is (lon, lat)
|
||
data.get('air_temperature_c'),
|
||
data.get('air_humidity_pct'),
|
||
data.get('air_pressure_hpa'),
|
||
data.get('air_iaq_index'),
|
||
data.get('air_gas_ohms'),
|
||
data.get('pm1_0'), data.get('pm2_5'),
|
||
data.get('pm4_0'), data.get('pm10'),
|
||
data.get('voc_index'), data.get('nox_index'),
|
||
))
|
||
conn.commit(); cur.close()
|
||
return jsonify({"inserted": 1})
|
||
```
|
||
|
||
### schema
|
||
|
||
```sql
|
||
CREATE TABLE air_quality (
|
||
id SERIAL PRIMARY KEY,
|
||
device_id VARCHAR(64),
|
||
timestamp TIMESTAMPTZ NOT NULL,
|
||
|
||
lat DOUBLE PRECISION NOT NULL,
|
||
lon DOUBLE PRECISION NOT NULL,
|
||
geom GEOMETRY(Point, 4326),
|
||
|
||
-- BME680/688
|
||
temperature_c REAL,
|
||
humidity_pct REAL,
|
||
pressure_hpa REAL,
|
||
iaq_index INTEGER, -- 0-500 (Bosch IAQ)
|
||
gas_ohms INTEGER,
|
||
|
||
-- SEN5x
|
||
pm1_0 REAL, -- µg/m³
|
||
pm2_5 REAL,
|
||
pm4_0 REAL,
|
||
pm10 REAL,
|
||
voc_index INTEGER, -- 1-500
|
||
nox_index INTEGER, -- 1-500
|
||
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_air_quality_geom ON air_quality USING GIST (geom);
|
||
CREATE INDEX idx_air_quality_timestamp ON air_quality (timestamp DESC);
|
||
CREATE INDEX idx_air_quality_device ON air_quality (device_id);
|
||
```
|
||
|
||
### `/api/air/heatmap`
|
||
|
||
```python
|
||
@app.route('/api/air/heatmap', methods=['GET'])
|
||
def air_quality_heatmap():
|
||
hours = request.args.get('hours', 24, type=int)
|
||
metric = request.args.get('metric', 'iaq_index') # or pm2_5, voc_index
|
||
# bounds param parsed but unused for now — TODO
|
||
|
||
conn = get_db(); cur = conn.cursor()
|
||
cur.execute(f"""
|
||
SELECT
|
||
ST_X(ST_Centroid(ST_Collect(geom))) AS lon,
|
||
ST_Y(ST_Centroid(ST_Collect(geom))) AS lat,
|
||
AVG({metric}) AS value,
|
||
COUNT(*) AS samples
|
||
FROM air_quality
|
||
WHERE timestamp > NOW() - INTERVAL '%s hours'
|
||
GROUP BY ROUND(lat::numeric, 3), ROUND(lon::numeric, 3)
|
||
HAVING AVG({metric}) IS NOT NULL
|
||
""", (hours,))
|
||
|
||
results = [
|
||
{"lon": r[0], "lat": r[1], "value": round(r[2], 1), "samples": r[3]}
|
||
for r in cur.fetchall()
|
||
]
|
||
cur.close()
|
||
return jsonify({"data": results, "metric": metric})
|
||
```
|
||
|
||
The `metric` arg interpolates into the SQL — fine since the value is validated against a column allowlist before reaching this point (don't skip that part).
|
||
|
||
## frontend overlay
|
||
|
||
Leaflet.heat does most of the work. Convert the heatmap response to `[lat, lon, intensity]` triples, normalize IAQ 0-500 down to 0-1:
|
||
|
||
```javascript
|
||
import L from 'leaflet';
|
||
import 'leaflet.heat';
|
||
|
||
async function loadAirQualityLayer(map) {
|
||
const r = await fetch('/api/air/heatmap?hours=24&metric=iaq_index');
|
||
const { data } = await r.json();
|
||
|
||
const heat = data.map(p => [p.lat, p.lon, Math.min(p.value / 300, 1.0)]);
|
||
|
||
return L.heatLayer(heat, {
|
||
radius: 25, blur: 15, maxZoom: 17,
|
||
gradient: {
|
||
0.0: 'green', // 0-50 Excellent
|
||
0.2: 'yellow', // 51-100 Good
|
||
0.4: 'orange', // 101-150 Moderate
|
||
0.6: 'red', // 151-200 Unhealthy
|
||
0.8: 'purple', // 201-300 Very unhealthy
|
||
1.0: 'maroon', // 301+ Hazardous
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
Legend markup:
|
||
|
||
```html
|
||
<div class="air-quality-legend">
|
||
<h4>Air Quality Index</h4>
|
||
<div><span class="color green"></span> 0-50 Excellent</div>
|
||
<div><span class="color yellow"></span> 51-100 Good</div>
|
||
<div><span class="color orange"></span> 101-150 Moderate</div>
|
||
<div><span class="color red"></span> 151-200 Unhealthy</div>
|
||
<div><span class="color purple"></span> 201-300 Very unhealthy</div>
|
||
<div><span class="color maroon"></span> 301+ Hazardous</div>
|
||
</div>
|
||
```
|
||
|
||
## BOM
|
||
|
||
Option A — BME680, basic VOC/IAQ:
|
||
|
||
```
|
||
BME680 breakout $15 Adafruit/SparkFun
|
||
MCP2221A USB-I2C $7 Adafruit
|
||
Qwiic/STEMMA cables $3 SparkFun
|
||
-----
|
||
$25
|
||
```
|
||
|
||
Option B — SEN55, full air quality:
|
||
|
||
```
|
||
SEN55 sensor $45 DigiKey/Mouser
|
||
breakout PCB $5 JLCPCB/OSHPark
|
||
CP2102 USB-UART $3 Amazon
|
||
-----
|
||
$53
|
||
```
|
||
|
||
Both, if we want everything (VOC index from BME688 cross-checked against SEN55's separate VOC/NOx readings): ~$80.
|
||
|
||
## things still to confirm
|
||
|
||
- exact sensor model on hand (BME680? 688? something else?) — needs a look
|
||
- Bee USB-C port host mode — plug something in and see if it enumerates
|
||
- can we `pip install` on the Bee, or is the Yocto rootfs read-only? need a wheel-bundle plan if so
|
||
- Bosch BSEC licensing for the real IAQ calculation — non-commercial vs. commercial terms differ
|
||
- 1Hz is the default polling rate; bump up or down once we see what the data looks like
|
||
|
||
## rollout order
|
||
|
||
Sensor on the bench first (laptop or Pi) to confirm it actually reads. Then onto the Bee — service deploys, Redis key check, fusion in bee-collector, upload spot-check. AdaMaps side (table + endpoints) can land in parallel; curl-test before pointing the Bee at it. Frontend last, drive a route, eyeball the heatmap.
|