- 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.
13 KiB
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 installon 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.