Network dashboard
Sample week of hourly readings · times in Pacific
PM2.5 over the past 7 days
City median of all reporting sites. Shaded bands are AQI categories.
Average AQI by neighborhood
24 hours ending at the hour on the map. Click a bar to filter the sites.
Hours in each AQI category
Every site-hour this week
Does traffic matter?
Weekday rush-hour PM2.5 against distance to the nearest freeway. Click a dot to open the site.
Daily pattern
City median PM2.5 by day and hour
Reports received on time
Hourly posts that arrived within the hour, out of 60 · past 48 hours
Data pipeline · for the site build
How sensors report
Each Wi-Fi module averages its own readings and sends one data point per hour. This page is the contract between the hardware and the website: when readings are taken, what the module sends, what the server answers, and how the map treats a sensor that goes quiet.
The hourly cycle
- every 2 minThe fan runs for 30 seconds to pull in fresh air, then the module reads PM1, PM2.5 and PM10, plus temperature, humidity and pressure if a BME280 is fitted. That makes 30 samples per hour. The fan sleeps between samples to save power and extend sensor life.
- :00The module closes the hour, averages its samples into one reading and saves it to flash. A reading with fewer than 20 samples is still stored but flagged
low_coverage. - :00 – :05Wi-Fi turns on and the module posts every unsent reading. Each sensor uses its own fixed delay inside these five minutes, so 60 modules don't hit the server in the same second.
- on the siteThe map shows the latest complete hour. At 3:20 pm that is the 2:00–3:00 pm average. The live site should compute AQI with the EPA NowCast over the last 12 hours, the same method AirNow uses. The sample dashboard shows plain hourly AQI.
- 2 missed hoursThe marker turns gray and the site card says stale. Its last value stays visible but no longer counts toward the city average.
- 24 missed hoursThe site card says offline, the marker stays gray, and the sensor goes on the list for a site visit.
What a module sends
hour is the start of the averaged hour in UTC. Bellevue is on PST in November, so 22:00Z is the 2–3 pm reading.
POST /api/v1/readings · Authorization: Bearer <device key>
{
"device_id": "BLV-17",
"fw": "0.3.1",
"uptime_s": 266410,
"rssi": -67,
"readings": [
{ "hour": "2026-11-04T21:00:00Z",
"pm1": 3.1, "pm25": 5.4, "pm10": 7.9,
"temp_c": 9.8, "rh": 81.2, "pressure_hpa": 1012.6,
"samples": 30 },
{ "hour": "2026-11-04T22:00:00Z",
"pm1": 3.6, "pm25": 6.2, "pm10": 8.8,
"temp_c": 9.1, "rh": 84.0, "pressure_hpa": 1012.9,
"samples": 29 }
]
}
200 OK · the module deletes these hours from flash
{
"accepted": [
"2026-11-04T21:00:00Z",
"2026-11-04T22:00:00Z"
],
"rejected": []
}
A rejected hour is dropped, not retried
"rejected": [
{ "hour": "2026-11-04T22:30:00Z",
"reason": "not_on_the_hour" }
]
Fields
| Field | Unit | Meaning |
|---|---|---|
| device_id | text | Site ID from the placement plan, BLV-01 to BLV-60 |
| hour | ISO 8601, UTC | Start of the averaged hour, always on the hour |
| pm1 · pm25 · pm10 | µg/m³ | Hourly mean of the sensor's atmospheric readings. pm25 is required and drives the AQI |
| temp_c | °C | Optional. Used later to correct PM for humidity |
| rh | % | Optional relative humidity. Fog and drizzle push PM readings up |
| pressure_hpa | hPa | Optional barometric pressure |
| samples | count | Samples behind the average. 30 is a full hour; under 20 is flagged |
| rssi | dBm | Wi-Fi signal at upload. Below about −80 means a weak link worth checking |
| fw · uptime_s | text · s | Firmware version and seconds since boot, for spotting crash loops |
When Wi-Fi drops
- The module keeps sampling. Each finished hour is saved to flash, which also survives a power cut.
- Flash holds 96 hours. After that the oldest hour is dropped first.
- Failed uploads retry after 1, 2, 4, 8 minutes and so on, up to once an hour.
- On reconnect it sends the backlog oldest first, 24 hours per request.
- If the server stored a batch but the reply was lost, the module sends the same hours again. The database keeps one row per sensor per hour, so the resend overwrites instead of doubling.
- On the map the sensor keeps its last value until it has missed 2 hours, then turns gray.
Not built yet. This page can show sample data, but it can't receive posts from real modules. The API and database below need to run on a server, for example Vercel with a Postgres database.
Reference code
Module firmware firmware/vrtcel_node.ino · ESP32 Arduino
/*
vrtcel_node.ino — Bellevue air sensor node
ESP32 + Plantower PMS5003 (UART2) + optional BME280 (I2C)
What it does
* Every 2 minutes: fan on, 30 s warm-up, read PM1 / PM2.5 / PM10 (+ temp / RH / pressure).
* At the top of each UTC hour: average that hour's samples into ONE reading and save it
to flash (LittleFS), so readings survive Wi-Fi outages and power cuts. Holds 96 hours.
* A few minutes past the hour (fixed per-device offset, 0–5 min): Wi-Fi on, POST every
unsent reading as a JSON batch to /api/v1/readings, Wi-Fi off. A reading is removed
from flash only when the server lists its hour in "accepted" (or "rejected").
* Failed uploads back off 1, 2, 4 ... 60 minutes. Between samples the chip light-sleeps.
Libraries: ArduinoJson v7, Adafruit BME280 (+ Adafruit Unified Sensor). Everything else
ships with the ESP32 Arduino core (WiFi, HTTPClient, WiFiClientSecure, Preferences, LittleFS).
Provisioning: Wi-Fi credentials, device id (BLV-01 ... BLV-60) and the device key live in
NVS (Preferences namespace "vrtcel"). In production they are written once by a setup portal
(e.g. WiFiManager with custom fields for id + key). Never commit real values to this file.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <time.h>
#include <esp_sleep.h>
#include <esp_timer.h>
#define FW_VERSION "0.3.1"
#define API_URL "https://YOUR-PROJECT.vercel.app/api/v1/readings" // placeholder
// ---- Bench-test provisioning only. Leave EMPTY in any committed or shipped build. ----
#define PROVISION_SSID ""
#define PROVISION_PASS ""
#define PROVISION_DEVICE_ID "" // e.g. "BLV-17"
#define PROVISION_DEVICE_KEY "" // issued per device; the server stores only its SHA-256
// Root CA of the API host (Vercel certificates chain to ISRG Root X1 / Let's Encrypt).
static const char ROOT_CA[] PROGMEM = R"PEM(
-----BEGIN CERTIFICATE-----
REPLACE_WITH_ROOT_CA_PEM_FOR_YOUR_API_HOST
-----END CERTIFICATE-----
)PEM";
// ---- Pins ----
#define PMS_RX 16 // ESP32 RX2 <- PMS5003 TX
#define PMS_TX 17 // ESP32 TX2 -> PMS5003 RX
#define PMS_SET 4 // HIGH = fan running, LOW = sleep
#define I2C_SDA 21
#define I2C_SCL 22
// ---- Timing ----
static const uint32_t SAMPLE_INTERVAL_S = 120; // 30 samples per hour
static const uint32_t WARMUP_S = 30; // PMS5003 needs ~30 s of airflow
static const uint32_t UPLOAD_WINDOW_S = 300; // post between :00 and :05
static const uint32_t BACKOFF_MIN_S = 60;
static const uint32_t BACKOFF_MAX_S = 3600;
static const uint16_t BUF_CAP = 96; // hours kept on flash (>= 72 required)
static const uint16_t BATCH_MAX = 24; // hours per POST
static const char* BUF_PATH = "/hours.bin";
HardwareSerial& PMS = Serial2;
Adafruit_BME280 bme;
bool hasBme = false;
struct Config { String ssid, pass, deviceId, deviceKey; } cfg;
// One finished hour. hour = UTC epoch seconds of the START of the averaged hour.
struct __attribute__((packed)) HourReading {
uint32_t hour;
float pm1, pm25, pm10;
float temp, rh, pres; // NAN when no BME280
uint16_t samples;
};
// Running sums for the hour in progress (RAM survives light sleep).
struct Accumulator {
uint32_t hour = 0;
double pm1 = 0, pm25 = 0, pm10 = 0, temp = 0, rh = 0, pres = 0;
uint16_t n = 0, nEnv = 0;
} cur;
HourReading buf[BUF_CAP];
uint16_t bufCount = 0;
time_t nextUploadAt = 0;
uint32_t backoffS = BACKOFF_MIN_S;
uint32_t uploadOffsetS = 0; // fixed per device so 60 nodes don't post at the same second
// ------------------------------------------------------------------ helpers
static bool timeValid(time_t t) { return t > 1700000000; } // after Nov 2023
static void isoHour(uint32_t hour, char* out, size_t len) {
time_t t = hour; struct tm g; gmtime_r(&t, &g);
strftime(out, len, "%Y-%m-%dT%H:00:00Z", &g);
}
static void sleepFor(uint32_t seconds) {
Serial.flush();
esp_sleep_enable_timer_wakeup((uint64_t)seconds * 1000000ULL);
esp_light_sleep_start(); // GPIO levels (PMS_SET) and RAM are kept
}
// ------------------------------------------------------------------ flash buffer
static void saveBuffer() {
File f = LittleFS.open(BUF_PATH, "w");
if (!f) { Serial.println("buffer: open for write failed"); return; }
f.write((const uint8_t*)&bufCount, sizeof(bufCount));
f.write((const uint8_t*)buf, sizeof(HourReading) * bufCount);
f.close();
}
static void loadBuffer() {
bufCount = 0;
File f = LittleFS.open(BUF_PATH, "r");
if (!f) return;
uint16_t n = 0;
if (f.read((uint8_t*)&n, sizeof(n)) == sizeof(n) && n <= BUF_CAP) {
size_t want = sizeof(HourReading) * n;
if (f.read((uint8_t*)buf, want) == want) bufCount = n;
}
f.close();
Serial.printf("buffer: %u unsent hours restored\n", bufCount);
}
static void pushHour(const HourReading& r) {
if (bufCount == BUF_CAP) { // full: drop the oldest hour
memmove(&buf[0], &buf[1], sizeof(HourReading) * (BUF_CAP - 1));
bufCount--;
}
buf[bufCount++] = r;
saveBuffer();
}
// ------------------------------------------------------------------ provisioning
static void loadConfig() {
Preferences p; p.begin("vrtcel", false);
if (strlen(PROVISION_SSID) && !p.isKey("ssid")) { // bench test convenience
p.putString("ssid", PROVISION_SSID); p.putString("pass", PROVISION_PASS);
p.putString("id", PROVISION_DEVICE_ID); p.putString("key", PROVISION_DEVICE_KEY);
}
cfg.ssid = p.getString("ssid", ""); cfg.pass = p.getString("pass", "");
cfg.deviceId = p.getString("id", ""); cfg.deviceKey = p.getString("key", "");
p.end();
// Spread uploads across the 5-minute window using the device id.
uint32_t h = 2166136261u; for (char c : cfg.deviceId) h = (h ^ (uint8_t)c) * 16777619u;
uploadOffsetS = h % UPLOAD_WINDOW_S;
}
static void runProvisioningPortal() {
// Production: start a captive portal here (WiFiManager + custom params for device id and
// key), write the values with Preferences into namespace "vrtcel", then ESP.restart().
Serial.println("not provisioned: start setup portal");
sleepFor(600);
ESP.restart();
}
// ------------------------------------------------------------------ network
static bool wifiOn() {
WiFi.mode(WIFI_STA);
WiFi.begin(cfg.ssid.c_str(), cfg.pass.c_str());
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) delay(250);
if (WiFi.status() != WL_CONNECTED) { Serial.println("wifi: connect timeout"); return false; }
configTime(0, 0, "pool.ntp.org", "time.google.com"); // UTC, re-sync on every connect
for (int i = 0; i < 40 && !timeValid(time(nullptr)); i++) delay(250);
return true;
}
static void wifiOff() { WiFi.disconnect(true); WiFi.mode(WIFI_OFF); }
// ------------------------------------------------------------------ sensors
// PMS5003 frame: 0x42 0x4D, len(28), 13 data words, checksum = sum of bytes 0..29.
static bool readPmsFrame(float& pm1, float& pm25, float& pm10, uint32_t timeoutMs = 2500) {
uint8_t b[32];
uint32_t t0 = millis();
while (millis() - t0 < timeoutMs) {
if (PMS.available() < 32) { delay(10); continue; }
if (PMS.read() != 0x42 || PMS.peek() != 0x4D) continue;
b[0] = 0x42; b[1] = PMS.read();
if (PMS.readBytes(b + 2, 30) != 30) continue;
uint16_t sum = 0; for (int i = 0; i < 30; i++) sum += b[i];
uint16_t chk = (b[30] << 8) | b[31];
uint16_t len = (b[2] << 8) | b[3];
if (len != 28 || sum != chk) continue;
pm1 = (b[10] << 8) | b[11]; // "atmospheric environment" values, µg/m³
pm25 = (b[12] << 8) | b[13];
pm10 = (b[14] << 8) | b[15];
return true;
}
return false;
}
static void takeSample(uint32_t hour) {
digitalWrite(PMS_SET, HIGH);
sleepFor(WARMUP_S);
while (PMS.available()) PMS.read(); // drop frames from before warm-up
float s1 = 0, s25 = 0, s10 = 0; int n = 0;
for (int i = 0; i < 5; i++) { // average 5 frames (~1 s apart)
float a, b, c;
if (readPmsFrame(a, b, c)) { s1 += a; s25 += b; s10 += c; n++; }
}
digitalWrite(PMS_SET, LOW); // fan off until the next slot
if (n == 0) { Serial.println("pms: no valid frame"); return; }
if (cur.n == 0) { cur = Accumulator(); cur.hour = hour; }
cur.pm1 += s1 / n; cur.pm25 += s25 / n; cur.pm10 += s10 / n; cur.n++;
if (hasBme) {
// Mount the BME280 away from the ESP32; enclosure heat reads 1–3 °C high.
cur.temp += bme.readTemperature(); cur.rh += bme.readHumidity();
cur.pres += bme.readPressure() / 100.0; cur.nEnv++;
}
Serial.printf("sample %u/30 pm2.5=%.1f\n", cur.n, s25 / n);
}
static void closeHour() {
HourReading r;
r.hour = cur.hour; r.samples = cur.n;
r.pm1 = cur.pm1 / cur.n; r.pm25 = cur.pm25 / cur.n; r.pm10 = cur.pm10 / cur.n;
r.temp = cur.nEnv ? cur.temp / cur.nEnv : NAN;
r.rh = cur.nEnv ? cur.rh / cur.nEnv : NAN;
r.pres = cur.nEnv ? cur.pres / cur.nEnv : NAN;
pushHour(r);
char iso[24]; isoHour(r.hour, iso, sizeof iso);
Serial.printf("hour %s closed: pm2.5=%.1f (%u samples), %u unsent\n", iso, r.pm25, r.samples, bufCount);
cur = Accumulator();
nextUploadAt = (time_t)r.hour + 3600 + uploadOffsetS; // a few minutes past the hour
}
// ------------------------------------------------------------------ upload
static bool listed(JsonArrayConst arr, const char* iso, bool objects) {
for (JsonVariantConst v : arr) {
const char* h = objects ? v["hour"].as<const char*>() : v.as<const char*>();
if (h && strcmp(h, iso) == 0) return true;
}
return false;
}
// Sends the oldest <= BATCH_MAX hours. Returns true on a 2xx with a parseable body.
static bool postBatch() {
uint16_t n = min<uint16_t>(bufCount, BATCH_MAX);
JsonDocument doc;
doc["device_id"] = cfg.deviceId;
doc["fw"] = FW_VERSION;
doc["uptime_s"] = (uint32_t)(esp_timer_get_time() / 1000000ULL);
doc["rssi"] = WiFi.RSSI();
JsonArray arr = doc["readings"].to<JsonArray>();
char iso[24];
for (uint16_t i = 0; i < n; i++) {
const HourReading& r = buf[i];
JsonObject o = arr.add<JsonObject>();
isoHour(r.hour, iso, sizeof iso);
o["hour"] = iso;
o["pm1"] = serialized(String(r.pm1, 1));
o["pm25"] = serialized(String(r.pm25, 1));
o["pm10"] = serialized(String(r.pm10, 1));
if (!isnan(r.temp)) {
o["temp_c"] = serialized(String(r.temp, 1));
o["rh"] = serialized(String(r.rh, 1));
o["pressure_hpa"] = serialized(String(r.pres, 1));
}
o["samples"] = r.samples;
}
String body; serializeJson(doc, body);
WiFiClientSecure tls; tls.setCACert(ROOT_CA);
HTTPClient http; http.setTimeout(15000);
if (!http.begin(tls, API_URL)) return false;
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", "Bearer " + cfg.deviceKey);
int code = http.POST(body);
String resp = code > 0 ? http.getString() : String();
http.end();
Serial.printf("POST %u hours -> HTTP %d\n", n, code);
if (code < 200 || code >= 300) return false;
JsonDocument res;
if (deserializeJson(res, resp)) return false;
JsonArrayConst ok = res["accepted"].as<JsonArrayConst>();
JsonArrayConst bad = res["rejected"].as<JsonArrayConst>();
// Keep only hours the server did not mention (accepted = stored, rejected = never retry).
uint16_t keep = 0;
for (uint16_t i = 0; i < bufCount; i++) {
isoHour(buf[i].hour, iso, sizeof iso);
bool done = i < n && (listed(ok, iso, false) || listed(bad, iso, true));
if (i < n && listed(bad, iso, true)) Serial.printf("rejected %s\n", iso);
if (!done) buf[keep++] = buf[i];
}
bufCount = keep;
saveBuffer();
return true;
}
static void upload() {
bool ok = wifiOn();
for (int b = 0; ok && bufCount > 0 && b < 5; b++) ok = postBatch(); // up to 120 h per wake
wifiOff();
if (ok) {
backoffS = BACKOFF_MIN_S;
nextUploadAt = bufCount ? time(nullptr) + SAMPLE_INTERVAL_S : 0; // more backlog: next slot
} else {
nextUploadAt = time(nullptr) + backoffS;
Serial.printf("upload failed, retry in %u s\n", backoffS);
backoffS = min(backoffS * 2, BACKOFF_MAX_S);
}
}
// ------------------------------------------------------------------ main
void setup() {
Serial.begin(115200);
PMS.begin(9600, SERIAL_8N1, PMS_RX, PMS_TX);
pinMode(PMS_SET, OUTPUT); digitalWrite(PMS_SET, LOW);
Wire.begin(I2C_SDA, I2C_SCL);
hasBme = bme.begin(0x76) || bme.begin(0x77);
if (!LittleFS.begin(true)) Serial.println("LittleFS mount failed");
loadConfig();
if (cfg.ssid.isEmpty() || cfg.deviceId.isEmpty() || cfg.deviceKey.isEmpty()) runProvisioningPortal();
loadBuffer();
Serial.printf("%s fw %s, BME280 %s\n", cfg.deviceId.c_str(), FW_VERSION, hasBme ? "yes" : "no");
if (wifiOn() && bufCount) nextUploadAt = time(nullptr); // flush backlog after a reboot
wifiOff();
}
void loop() {
time_t now = time(nullptr);
if (!timeValid(now)) { // no clock yet: cannot label hours
if (wifiOn()) Serial.println("time synced");
wifiOff();
sleepFor(60);
return;
}
uint32_t slot = (now / SAMPLE_INTERVAL_S) * SAMPLE_INTERVAL_S;
uint32_t hour = (slot / 3600) * 3600;
if (cur.n > 0 && hour != cur.hour) closeHour(); // first sample of a new hour closes the last one
takeSample(hour);
now = time(nullptr);
if (bufCount > 0 && nextUploadAt && now >= nextUploadAt) upload();
// Sleep to the start of the next 2-minute slot.
now = time(nullptr);
uint32_t next = slot + SAMPLE_INTERVAL_S;
if ((uint32_t)now < next) sleepFor(next - now);
}Upload endpoint api/readings.ts · POST /api/v1/readings
// POST /api/v1/readings — hourly readings from a sensor module (see vercel.json rewrite).
// Body: { device_id, fw, uptime_s, rssi, readings: [{ hour, pm1, pm25, pm10, temp_c, rh, pressure_hpa, samples }] }
// Auth: Authorization: Bearer <device key>. Only the SHA-256 of each key is stored.
// Idempotent: one row per (device_id, hour_utc); a resent hour overwrites the same row.
import type { VercelRequest, VercelResponse } from '@vercel/node';
import postgres from 'postgres';
import { createHash } from 'node:crypto';
const sql = postgres(process.env.DATABASE_URL as string, { ssl: 'require', max: 1, idle_timeout: 20 });
const HOUR_MS = 3_600_000;
const MAX_BATCH = 72;
const MAX_AGE_DAYS = 30;
const FULL_HOUR_SAMPLES = 20; // of 30; fewer is stored but flagged
type Incoming = Record<string, unknown>;
type Row = {
hour_utc: Date; pm1: number | null; pm25: number; pm10: number | null;
temp_c: number | null; rh: number | null; pressure_hpa: number | null; samples: number; flags: string[];
};
type Checked = { hour: string; row?: Row; reason?: string };
const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const r1 = (v: number) => Math.round(v * 10) / 10;
function check(r: Incoming, now: number): Checked {
const hour = typeof r.hour === 'string' ? r.hour : String(r.hour ?? '');
const t = Date.parse(hour);
if (!Number.isFinite(t)) return { hour, reason: 'bad_hour' };
if (t % HOUR_MS !== 0) return { hour, reason: 'not_on_the_hour' };
if (t + HOUR_MS > now + 5 * 60_000) return { hour, reason: 'hour_not_finished' };
if (now - t > MAX_AGE_DAYS * 24 * HOUR_MS) return { hour, reason: 'too_old' };
const samples = num(r.samples);
if (samples === null || !Number.isInteger(samples) || samples < 1 || samples > 60) return { hour, reason: 'bad_samples' };
let pm25 = num(r.pm25);
if (pm25 === null || pm25 < 0) return { hour, reason: 'bad_pm25' };
if (pm25 > 1000) return { hour, reason: 'pm25_implausible' }; // beyond the PMS5003's range
const flags: string[] = [];
if (pm25 > 500) { pm25 = 500; flags.push('pm25_clamped'); }
// Optional fields: out-of-range values are dropped (stored as null) and flagged.
const opt = (v: unknown, lo: number, hi: number, name: string): number | null => {
const x = num(v);
if (x === null) return null;
if (x < lo || x > hi) { flags.push(`${name}_dropped`); return null; }
return r1(x);
};
const pm1 = opt(r.pm1, 0, 1000, 'pm1');
const pm10 = opt(r.pm10, 0, 2000, 'pm10');
const temp_c = opt(r.temp_c, -40, 60, 'temp');
const rh = opt(r.rh, 0, 100, 'rh');
const pressure_hpa = opt(r.pressure_hpa, 870, 1085, 'pressure');
if ((pm1 !== null && pm1 > pm25 + 1) || (pm10 !== null && pm10 + 1 < pm25)) flags.push('size_order');
if (samples < FULL_HOUR_SAMPLES) flags.push('low_coverage');
return { hour, row: { hour_utc: new Date(t), pm1, pm25: r1(pm25), pm10, temp_c, rh, pressure_hpa, samples, flags } };
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ error: 'method_not_allowed' });
}
const auth = req.headers.authorization ?? '';
const key = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
if (!key) return res.status(401).json({ error: 'missing_device_key' });
const body = req.body as Incoming | undefined;
const readings = body?.readings;
if (!body || typeof body.device_id !== 'string' || !Array.isArray(readings)) {
return res.status(400).json({ error: 'bad_payload', detail: 'expected { device_id, readings: [...] }' });
}
if (readings.length === 0 || readings.length > MAX_BATCH) {
return res.status(400).json({ error: 'bad_batch_size', detail: `send 1–${MAX_BATCH} readings per request` });
}
const keyHash = createHash('sha256').update(key).digest('hex');
const [device] = await sql<{ id: string }[]>`
select id from devices
where id = ${body.device_id} and key_hash = ${keyHash} and retired_at is null`;
if (!device) return res.status(401).json({ error: 'unknown_device_or_key' });
const fw = typeof body.fw === 'string' ? body.fw.slice(0, 32) : null;
const rssiRaw = num(body.rssi);
const rssi = rssiRaw !== null && rssiRaw >= -120 && rssiRaw <= 0 ? Math.round(rssiRaw) : null;
const now = Date.now();
const accepted: string[] = [];
const rejected: { hour: string; reason: string }[] = [];
const rows: Row[] = [];
const seen = new Set<number>();
for (const item of readings) {
const c = check((item ?? {}) as Incoming, now);
if (!c.row) { rejected.push({ hour: c.hour, reason: c.reason ?? 'invalid' }); continue; }
const t = c.row.hour_utc.getTime();
if (!seen.has(t)) { seen.add(t); rows.push(c.row); } // duplicate hour in one batch: keep the first
accepted.push(c.hour); // echo the device's own string
}
await sql.begin(async (tx) => {
for (const r of rows) {
await tx`
insert into readings
(device_id, hour_utc, pm1, pm25, pm10, temp_c, rh, pressure_hpa, samples, rssi, fw, flags)
values
(${device.id}, ${r.hour_utc}, ${r.pm1}, ${r.pm25}, ${r.pm10}, ${r.temp_c}, ${r.rh},
${r.pressure_hpa}, ${r.samples}, ${rssi}, ${fw}, ${tx.array(r.flags)}::text[])
on conflict (device_id, hour_utc) do update set
pm1 = excluded.pm1, pm25 = excluded.pm25, pm10 = excluded.pm10,
temp_c = excluded.temp_c, rh = excluded.rh, pressure_hpa = excluded.pressure_hpa,
samples = excluded.samples, rssi = excluded.rssi, fw = excluded.fw,
flags = excluded.flags, received_at = now()`;
}
await tx`update devices set last_seen = now(), fw = coalesce(${fw}, fw) where id = ${device.id}`;
});
return res.status(200).json({ accepted, rejected });
}Map endpoint api/latest.ts · GET /api/v1/latest
// GET /api/v1/latest — one entry per installed sensor for the map.
// Uses the latest COMPLETE hour (at 3:20 pm that is the 2:00–3:00 pm average) and the EPA NowCast
// over the previous 12 hours. A sensor is "stale" after 2 missed hours and "offline" after 24.
import type { VercelRequest, VercelResponse } from '@vercel/node';
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL as string, { ssl: 'require', max: 1, idle_timeout: 20 });
const HOUR_MS = 3_600_000;
// EPA PM2.5 AQI breakpoints, revised 2024: [C_lo, C_hi, I_lo, I_hi]
const BP: [number, number, number, number][] = [
[0.0, 9.0, 0, 50], [9.1, 35.4, 51, 100], [35.5, 55.4, 101, 150],
[55.5, 125.4, 151, 200], [125.5, 225.4, 201, 300], [225.5, 325.4, 301, 500],
];
const CATEGORY = ['Good', 'Moderate', 'Unhealthy for sensitive groups', 'Unhealthy', 'Very unhealthy', 'Hazardous'];
export function aqiFromPm25(pm: number): { aqi: number; category: string } {
const c = Math.floor(Math.max(0, pm) * 10) / 10;
for (let i = 0; i < BP.length; i++) {
const [cl, ch, il, ih] = BP[i];
if (c <= ch) return { aqi: Math.round(((ih - il) / (ch - cl)) * (c - cl) + il), category: CATEGORY[i] };
}
return { aqi: 500, category: CATEGORY[5] };
}
// EPA NowCast for PM: c[0] = latest complete hour, c[11] = 11 hours earlier; null = missing.
// Needs 2 of the 3 most recent hours. Weight = min/max over the window, floored at 0.5.
export function nowcast(c: (number | null)[]): number | null {
if (c.slice(0, 3).filter((v) => v !== null).length < 2) return null;
const vals = c.filter((v): v is number => v !== null);
const max = Math.max(...vals), min = Math.min(...vals);
const w = Math.max(max > 0 ? min / max : 1, 0.5);
let num = 0, den = 0;
c.forEach((v, i) => { if (v !== null) { const f = w ** i; num += f * v; den += f; } });
return Math.floor((num / den) * 10) / 10;
}
type DbRow = {
id: string; lat: number; lon: number; neighborhood: string | null;
last_hour: Date | null; hour_utc: Date | null; pm25: number | null; flags: string[] | null;
};
export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
return res.status(405).json({ error: 'method_not_allowed' });
}
const latestComplete = Math.floor(Date.now() / HOUR_MS) * HOUR_MS - HOUR_MS; // start of last full hour
const from = new Date(latestComplete - 11 * HOUR_MS);
const to = new Date(latestComplete);
const rows = await sql<DbRow[]>`
select d.id, d.lat, d.lon, d.neighborhood,
(select max(x.hour_utc) from readings x where x.device_id = d.id) as last_hour,
r.hour_utc, r.pm25, r.flags
from devices d
left join readings r
on r.device_id = d.id and r.hour_utc between ${from} and ${to}
where d.retired_at is null
order by d.id, r.hour_utc desc`;
const byId = new Map<string, { base: DbRow; hours: Map<number, number> }>();
for (const r of rows) {
const e = byId.get(r.id) ?? { base: r, hours: new Map<number, number>() };
if (r.hour_utc && r.pm25 !== null) e.hours.set(new Date(r.hour_utc).getTime(), r.pm25);
byId.set(r.id, e);
}
const sites = [...byId.values()].map(({ base, hours }) => {
const last = base.last_hour ? new Date(base.last_hour).getTime() : null;
const missed = last === null ? null : Math.max(0, Math.round((latestComplete - last) / HOUR_MS));
const status = missed === null ? 'not_reporting_yet' : missed >= 24 ? 'offline' : missed >= 2 ? 'stale' : 'ok';
const window = Array.from({ length: 12 }, (_, i) => hours.get(latestComplete - i * HOUR_MS) ?? null);
const nc = status === 'ok' ? nowcast(window) : null;
const a = nc === null ? null : aqiFromPm25(nc);
return {
id: base.id, lat: base.lat, lon: base.lon, neighborhood: base.neighborhood,
status, hours_missed: missed,
last_hour: last === null ? null : new Date(last).toISOString(),
pm25_hour: window[0], // plain mean of the latest complete hour, µg/m³
nowcast_pm25: nc,
aqi: a?.aqi ?? null, category: a?.category ?? null,
};
});
// Cache at the edge for 5 minutes; data only changes once an hour.
res.setHeader('Cache-Control', 's-maxage=300, stale-while-revalidate=600');
return res.status(200).json({
generated_at: new Date().toISOString(),
hour_utc: new Date(latestComplete).toISOString(),
sites,
});
}Database schema schema.sql · Postgres
-- Bellevue air sensor network — Postgres schema
-- One row per sensor per UTC hour. hour_utc is the START of the averaged hour.
create table devices (
id text primary key check (id ~ '^BLV-[0-9]{2}$'), -- BLV-01 ... BLV-60
lat double precision not null,
lon double precision not null,
neighborhood text,
key_hash text not null unique, -- SHA-256 hex of the device key; the key is never stored
fw text, -- last firmware version reported
installed_at timestamptz,
last_seen timestamptz, -- last successful POST
retired_at timestamptz
);
create table readings (
device_id text not null references devices (id),
hour_utc timestamptz not null check (hour_utc = date_trunc('hour', hour_utc)),
pm1 real, -- µg/m³, hourly mean
pm25 real not null, -- µg/m³, hourly mean (clamped at 500, see flags)
pm10 real, -- µg/m³, hourly mean
temp_c real, -- °C
rh real, -- % relative humidity
pressure_hpa real, -- hPa
samples smallint not null check (samples between 1 and 60), -- 30 = full hour
rssi smallint, -- Wi-Fi signal at upload, dBm
fw text,
received_at timestamptz not null default now(),
flags text[] not null default '{}', -- e.g. low_coverage, pm25_clamped, size_order
primary key (device_id, hour_utc)
);
create index readings_hour_idx on readings (hour_utc);
-- City-wide summary per hour, for the trend line and the uptime chart.
create view hourly_city as
select
hour_utc,
count(*) as sites_reporting, -- of 60
percentile_cont(0.5) within group (order by pm25) as pm25_median,
percentile_cont(0.9) within group (order by pm25) as pm25_p90,
max(pm25) as pm25_max,
avg(samples)::numeric(4,1) as avg_samples
from readings
group by hour_utc;
-- Register a sensor (generate the key on a laptop, flash it to the module, store only the hash):
-- insert into devices (id, lat, lon, neighborhood, key_hash, installed_at)
-- values ('BLV-17', 47.608782, -122.124994, 'Lake Hills', encode(sha256('<device key>'::bytea), 'hex'), now());Route config vercel.json
{
"rewrites": [
{ "source": "/api/v1/readings", "destination": "/api/readings" },
{ "source": "/api/v1/latest", "destination": "/api/latest" }
]
}