varroa/docs/AIR-QUALITY-INTEGRATION.md

24 KiB
Raw Blame History

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

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:

# 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):

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:

# Appears as /dev/ttyUSB0 or /dev/ttyACM0
ls /dev/ttyUSB*

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

[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

#!/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):

# 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

# 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

-- 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

@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)

// 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

<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>
</div>

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