# 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