varroa/docs/AIR-QUALITY-INTEGRATION.md
Cobb Hayes 84e8777290 Public-flip prep: env-driven keystore, README, hardened cleartext, leaner docs
- app/build.gradle.kts: remove hardcoded keystore password (was 'adacam-varroa-2026'
  in 4 spots across a duplicated signingConfigs block). Now reads VARROA_KEYSTORE_PATH
  + VARROA_KEYSTORE_PASSWORD + VARROA_KEY_PASSWORD from env. Password vaulted as
  'Varroa — release keystore'. Drops orphan zxing/camera deps that aren't wired up.
- app/src/main/res/xml/network_security_config.xml: tighten cleartext scope from
  global to just 192.168.0.10 (Bee AP). HTTPS strict for everything else.
- app/src/main/java/.../api/AdaMapsApiClient.kt: drop apiKey.take(8) in log to
  apiKey.length — no need to leak prefix to logcat.
- README.md: add. Public repo without one was a bad first impression.
- docs/BEE-CAMERA.md: rewrite (811→467 lines). Keep all paths, pinouts, bus
  diagrams, depthai/VPU/xlink details, intercept architecture. Strip
  Executive-Summary framing, verdict box, phased roadmap, appendices.
- docs/AIR-QUALITY-INTEGRATION.md: rewrite (712→369 lines). Keep BOM, sensor
  comparisons, wiring, IAQ calc, ingest endpoint shape. Strip feasibility-report
  scaffolding.
- docs/AIR-API-PATCH.py: delete. Was a one-shot apply-and-discard patch script,
  not docs.
2026-05-27 10:30:02 -07:00

13 KiB
Raw Permalink Blame History

Air quality sensor on the Bee

Can the Bee carry an air quality sensor alongside the existing Hivemapper pipeline and feed readings into AdaMaps? Short answer: yes, with the USB-C data port and a USB-to-I2C bridge. The polling overhead is negligible — the real work is on the AdaMaps side (new ingest endpoint, PostGIS table, heatmap overlay).

what's free on the Bee

Hardware is Keem Bay (RVC2), 4× A53 @ 1.5GHz, Myriad X VPU, 3.5GB usable RAM (1.34GB of that is CMA-reserved for VPU DMA), leaving ~2.2GB for userspace.

Current service load (pre Phase-1 cleanup):

Service CPU RAM Notes
map-ai ~32% ~1.1GB VPU inference
odc-api ~48% ~139MB Phase 2 replacement target
depthai_gate ~5% ~200MB camera
redis <1% ~50MB
total ~85% ~1.5GB

After Phase 1 (odc-api shrink): ~50-60% CPU free (2-2.4 cores idle) and 700MB-1GB RAM free. More than enough for a 1Hz polling loop.

USB topology — the Keem Bay USB controller hosts an internal hub. The LTE modem (Telit LE910C4) sits on one internal port; the external USB-C is the other. It does carry data, not just power, so it's the target for sensor attachment.

Keem Bay USB controller
   └── internal hub
       ├── Telit LE910C4 (internal)
       └── USB-C data port (external) ← us

sensor candidates

Model Maker Measures Iface Notes
BME680 Bosch VOC, temp, RH, pressure I2C/SPI indoor IAQ, ~$10-20
BME688 Bosch BME680 + AI gas scanning I2C/SPI advanced VOC classification
SEN50 Sensirion PM1.0/PM2.5/PM4/PM10 I2C/UART particulates only
SEN54 Sensirion PM + VOC + temp + RH I2C/UART
SEN55 Sensirion SEN54 + NOx I2C/UART full air-quality suite

Worth flagging: SEN5x is Sensirion, not Bosch. If the sensor on hand is branded Bosch it's almost certainly a BME680 or BME688.

BME680/688 — VOC as IAQ index 0-500; temp -40 to +85°C ±1°C; RH 0-100% ±3%; pressure 300-1100 hPa ±1 hPa; 3.6mA active, <1µA sleep. I2C address 0x76 or 0x77. Cheap, well-documented, low power, but VOC is a relative index (not absolute concentration) and the gas sensor needs ~48h of burn-in before readings stabilize.

SEN55 — PM1.0/PM2.5/PM4/PM10 (0-1000 µg/m³), VOC index, NOx index, temp -10 to +50°C, RH 0-100%. ~60mA. Native I2C or UART. Bigger (40×40×12mm) and pricier ($50-80), but it measures actual particulate matter, which is the metric that matters for outdoor pollution mapping.

For AdaMaps urban pollution work, SEN55 is the right pick — PM2.5 and NOx are the actionable numbers. For a quick "does this work at all" prototype, BME680 is fine.

USB bridge options

For I2C sensors (BME680/688) we need a USB-to-I2C adapter:

Adapter Cost Notes
Adafruit FT232H $15 FTDI, good support, ftdi_sio driver
MCP2221A $5 Microchip, HID mode, i2c-mcp2221
CP2112 $8 Silicon Labs, HID mode
CH341 $3 generic Chinese, works but flaky

FT232H shows up as /dev/i2c-X via ftdi_sio; MCP2221A as /dev/hidraw* or /dev/i2c-X. Python side: smbus2 for low-level, adafruit-blinka + adafruit-circuitpython-bme680 for the BME, or pyftdi to drive the bridge directly.

Quick read with FT232H + BME680:

import board, adafruit_bme680
i2c = board.I2C()
sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c)
print(sensor.temperature, sensor.humidity, sensor.pressure, sensor.gas)

For SEN5x, simplest is UART mode (jumper on the sensor) + CP2102 USB-UART (~$2). Sensor shows up as /dev/ttyUSB0; talk SHDLC at 115200. The Sensirion SEK-SEN55 eval kit has native USB-C and appears as CDC-ACM, but it's $100 and oversized — fine for bench testing, wrong for production.

Picking one: MCP2221A + BME680 breakout ~$15 total for basic VOC. CP2102 + SEN55 ~$55 for full particulate matter.

bee-side integration

New service air-sensor.service polls the sensor at 1Hz and writes a Redis key. bee-collector.py (existing) reads that key during GPS fusion and includes it in the upload payload.

USB sensor + adapter
        │
        ▼
air-sensor.service (Python, 1Hz)
        │
        ▼
Redis:  AirQuality1Hz
        │
        ▼
bee-collector.py ─ GNSSFusion30Hz ── (fuse) ─► HTTPS to AdaMaps

Service unit:

[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

/opt/air-sensor/air_sensor.py:

#!/usr/bin/env python3
"""Poll BME680/688 over USB-I2C, publish to Redis at 1Hz."""

import json, time, redis
import board, adafruit_bme680

POLL = 1.0
KEY  = "AirQuality1Hz"

def iaq(gas, _humidity):
    # placeholder — for real IAQ use Bosch BSEC
    if gas > 300000: return 50
    if gas > 200000: return 100
    if gas > 100000: return 150
    if gas >  50000: return 200
    return 300

def main():
    r = redis.Redis()
    i2c = board.I2C()
    sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=0x77)
    sensor.sea_level_pressure = 1013.25

    while True:
        reading = {
            "ts": int(time.time() * 1000),
            "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": iaq(sensor.gas, sensor.humidity),
        }
        r.set(KEY, json.dumps(reading))
        r.publish("air_quality", json.dumps(reading))
        time.sleep(POLL)

if __name__ == "__main__":
    main()

The IAQ calc above is a placeholder — for real-world readings you want the Bosch BSEC library, which is closed-source but free for non-commercial use (license check needed before shipping anything that's not personal).

Extending bee-collector to merge it in:

def get_air_quality():
    data = redis_client.get("AirQuality1Hz")
    return json.loads(data) if data else None

def collect_frame():
    gnss = json.loads(redis_client.get("GNSSFusion30Hz") or "{}")
    air  = get_air_quality()
    return {
        "timestamp": int(time.time() * 1000),
        "lat": gnss.get("lat"),
        "lon": gnss.get("lon"),
        "speed_kmh": gnss.get("speed"),
        "air_temperature_c": air and air.get("temperature_c"),
        "air_humidity_pct":  air and air.get("humidity_pct"),
        "air_pressure_hpa":  air and air.get("pressure_hpa"),
        "air_iaq_index":     air and air.get("iaq_index"),
        "air_gas_ohms":      air and air.get("gas_resistance_ohms"),
    }

Resource impact: <0.5% CPU, ~15MB RAM, one thread, <1KB/s USB traffic. Doesn't touch the camera, VPU, or map-ai. Negligible.

AdaMaps side

Two endpoints + one table.

/api/ingest/air

@app.route('/api/ingest/air', methods=['POST'])
def ingest_air_quality():
    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 is (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})

schema

CREATE TABLE air_quality (
    id            SERIAL PRIMARY KEY,
    device_id     VARCHAR(64),
    timestamp     TIMESTAMPTZ NOT NULL,

    lat           DOUBLE PRECISION NOT NULL,
    lon           DOUBLE PRECISION NOT NULL,
    geom          GEOMETRY(Point, 4326),

    -- BME680/688
    temperature_c REAL,
    humidity_pct  REAL,
    pressure_hpa  REAL,
    iaq_index     INTEGER,    -- 0-500 (Bosch IAQ)
    gas_ohms      INTEGER,

    -- SEN5x
    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()
);

CREATE INDEX idx_air_quality_geom      ON air_quality USING GIST (geom);
CREATE INDEX idx_air_quality_timestamp ON air_quality (timestamp DESC);
CREATE INDEX idx_air_quality_device    ON air_quality (device_id);

/api/air/heatmap

@app.route('/api/air/heatmap', methods=['GET'])
def air_quality_heatmap():
    hours  = request.args.get('hours', 24, type=int)
    metric = request.args.get('metric', 'iaq_index')   # or pm2_5, voc_index
    # bounds param parsed but unused for now — TODO

    conn = get_db(); cur = conn.cursor()
    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 = [
        {"lon": r[0], "lat": r[1], "value": round(r[2], 1), "samples": r[3]}
        for r in cur.fetchall()
    ]
    cur.close()
    return jsonify({"data": results, "metric": metric})

The metric arg interpolates into the SQL — fine since the value is validated against a column allowlist before reaching this point (don't skip that part).

frontend overlay

Leaflet.heat does most of the work. Convert the heatmap response to [lat, lon, intensity] triples, normalize IAQ 0-500 down to 0-1:

import L from 'leaflet';
import 'leaflet.heat';

async function loadAirQualityLayer(map) {
  const r = await fetch('/api/air/heatmap?hours=24&metric=iaq_index');
  const { data } = await r.json();

  const heat = data.map(p => [p.lat, p.lon, Math.min(p.value / 300, 1.0)]);

  return L.heatLayer(heat, {
    radius: 25, blur: 15, maxZoom: 17,
    gradient: {
      0.0: 'green',    // 0-50    Excellent
      0.2: 'yellow',   // 51-100  Good
      0.4: 'orange',   // 101-150 Moderate
      0.6: 'red',      // 151-200 Unhealthy
      0.8: 'purple',   // 201-300 Very unhealthy
      1.0: 'maroon',   // 301+    Hazardous
    },
  });
}

Legend markup:

<div class="air-quality-legend">
  <h4>Air Quality Index</h4>
  <div><span class="color green"></span>   0-50   Excellent</div>
  <div><span class="color yellow"></span>  51-100  Good</div>
  <div><span class="color orange"></span> 101-150 Moderate</div>
  <div><span class="color red"></span>    151-200 Unhealthy</div>
  <div><span class="color purple"></span> 201-300 Very unhealthy</div>
  <div><span class="color maroon"></span> 301+    Hazardous</div>
</div>

BOM

Option A — BME680, basic VOC/IAQ:

BME680 breakout            $15   Adafruit/SparkFun
MCP2221A USB-I2C            $7   Adafruit
Qwiic/STEMMA cables         $3   SparkFun
                          -----
                           $25

Option B — SEN55, full air quality:

SEN55 sensor               $45   DigiKey/Mouser
breakout PCB                $5   JLCPCB/OSHPark
CP2102 USB-UART             $3   Amazon
                          -----
                           $53

Both, if we want everything (VOC index from BME688 cross-checked against SEN55's separate VOC/NOx readings): ~$80.

things still to confirm

  • exact sensor model on hand (BME680? 688? something else?) — needs a look
  • Bee USB-C port host mode — plug something in and see if it enumerates
  • can we pip install on the Bee, or is the Yocto rootfs read-only? need a wheel-bundle plan if so
  • Bosch BSEC licensing for the real IAQ calculation — non-commercial vs. commercial terms differ
  • 1Hz is the default polling rate; bump up or down once we see what the data looks like

rollout order

Sensor on the bench first (laptop or Pi) to confirm it actually reads. Then onto the Bee — service deploys, Redis key check, fusion in bee-collector, upload spot-check. AdaMaps side (table + endpoints) can land in parallel; curl-test before pointing the Bee at it. Frontend last, drive a route, eyeball the heatmap.