From c780aa8f54aa0330a5b88884f1d48d4589e91619 Mon Sep 17 00:00:00 2001 From: kayos Date: Fri, 13 Mar 2026 07:16:02 -0700 Subject: [PATCH] Add air quality sensor integration feasibility report --- docs/AIR-QUALITY-INTEGRATION.md | 711 ++++++++++++++++++++++++++++++++ 1 file changed, 711 insertions(+) create mode 100644 docs/AIR-QUALITY-INTEGRATION.md diff --git a/docs/AIR-QUALITY-INTEGRATION.md b/docs/AIR-QUALITY-INTEGRATION.md new file mode 100644 index 0000000..a205091 --- /dev/null +++ b/docs/AIR-QUALITY-INTEGRATION.md @@ -0,0 +1,711 @@ +# 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 +
+

Air Quality Index

+
0-50 Excellent
+
51-100 Good
+
101-150 Moderate
+
151-200 Unhealthy
+
201-300 Very Unhealthy
+
301+ Hazardous
+
+``` + +--- + +## 7. Implementation Roadmap + +### Phase 1: Sensor Validation (1-2 days) + +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 + +### Phase 2: Bee-Side Integration (2-3 days) + +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 + +### Phase 3: AdaMaps API (1-2 days) + +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 + +### Phase 4: Frontend Overlay (1-2 days) + +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*