# Air Quality Sensor Integration — Feasibility Report *Generated: 2026-03-13* --- ## Executive Summary **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) | 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 | | ### 1.3 Post-Phase 1 Headroom 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 ### 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** ``` **USB-C Data Port Availability:** YES — The Bee's USB-C port supports data (not just power). This is the target for sensor attachment. --- ## 2. Bosch Air Quality Sensor Options ### 2.1 Sensor Model Comparison | 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 | **Note:** SEN5x is Sensirion, not Bosch. If the sensor is branded "Bosch", it's likely **BME680 or BME688**. ### 2.2 BME680/BME688 (Most Likely) **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 **Pros:** - Compact, cheap (~$10-20 on breakout boards) - Well-documented, extensive library support - Low power **Cons:** - I2C/SPI only — requires USB adapter for Bee - VOC is relative index, not absolute concentration - Requires burn-in calibration period (~48 hours) ### 2.3 SEN55 (If Particulate Matter Needed) **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 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") ``` ### 3.2 Option B: USB-Serial (UART) for SEN5x **Hardware:** - **CP2102** USB-UART adapter ($2) - **FTDI FT232RL** ($5) - SEN5x set to UART mode (hardware jumper) **Linux:** ```bash # Appears as /dev/ttyUSB0 or /dev/ttyACM0 ls /dev/ttyUSB* ``` **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 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 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 │ │ │ └──────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 4.2 Bee-Side Components **New Service: `air-sensor.service`** ```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 [Install] WantedBy=multi-user.target ``` **Python Script: `/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. """ import json import time import redis import board import adafruit_bme680 # or sensirion_i2c_sen5x POLL_INTERVAL = 1.0 # seconds REDIS_KEY = "AirQuality1Hz" 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 "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": calculate_iaq(sensor.gas, sensor.humidity), } r.set(REDIS_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 if __name__ == "__main__": main() ``` **Extend bee-collector.py (fusion):** ```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 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 = { "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, } return payload ``` ### 4.3 Resource Estimate (Bee-Side) | 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 | **Conclusion:** Negligible impact. Safe to run alongside existing services. --- ## 5. AdaMaps API Changes ### 5.1 New Endpoint: `/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() 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 takes 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}) ``` ### 5.2 Database 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 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() ); -- 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_timestamp ON air_quality (timestamp DESC); -- Device filtering CREATE INDEX idx_air_quality_device ON air_quality (device_id); ``` ### 5.3 Query Endpoint: `/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 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 = [] for row in cur.fetchall(): results.append({ "lon": row[0], "lat": row[1], "value": round(row[2], 1), "samples": row[3] }) cur.close() return jsonify({"data": results, "metric": metric}) ``` --- ## 6. Frontend Integration ### 6.1 Heatmap Layer (Leaflet) ```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; } function normalizeIAQ(iaq) { // Normalize IAQ 0-500 to 0-1 for heatmap intensity return Math.min(iaq / 300, 1.0); } ``` ### 6.2 Legend / UI ```html