varroa/docs/AIR-QUALITY-INTEGRATION.md

711 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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