711 lines
24 KiB
Markdown
711 lines
24 KiB
Markdown
# 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*
|