Building a Live PM2.5 Monitor: ESP32 → MQTT → Cloudflare

I wanted one thing on screen: the live PM2.5 reading from whichever air sensor is physically closest to me, updating in real time, no login, no loading spinner that never ends. The result is a single-page app that fetches nothing from a backend during normal use — because there is no backend. The interesting part of this build is how much I managed to remove.

The Shape of It

Two pieces live in one repo: the firmware and the web app.

   ESP32S + PMS7003        Browser (Nuxt SPA)
   publishes air/<id>      subscribes,
   every ~2s               renders live
         │                       │
         │ esp-mqtt              │ MQTT over
         │ wss:// or mqtt://     │ WebSocket
         └───────────┬───────────┘
                     ▼
        Cloudflare Tunnel  ──  wss://mq.kpp.sh
        (TLS at the edge; ws → :9001)
                     │
                     ▼
        Mosquitto broker (standalone, Docker)
        :1883 MQTT/TCP  ·  :9001 MQTT/WebSocket

The web app is a Nuxt 4 SPA (ssr: false) built with nuxt generate into a fully static bundle and deployed as Cloudflare Workers Static Assets. There is no Nitro server, no /api routes, no Worker script — the entire wrangler.toml is:

name = "pm25-monitor"
compatibility_date = "2026-01-01"

[assets]
directory = ".output/public"

The key design decision: nothing on a server is in the live data path, and nothing is even needed to figure out which sensor is nearest. Both of those jobs happen in the browser. That's what lets the whole thing ship as flat files behind a CDN.

The Sensor

The firmware is an ESP32S reading a Plantower PMS7003 over serial. Every ~2 seconds it publishes a retained JSON message to air/<id>:

{ "pm1": 8, "pm1raw": 9, "pm25": 14, "pm25raw": 17, "pm10": 18, "pm10raw": 19, "ts": 1718960000 }

Two details matter here.

Retained messages. Because the message is retained, a browser that subscribes at any moment immediately gets the last known reading instead of waiting up to two seconds for the next publish. The dashboard shows a real number the instant you open it.

On-device smoothing. Raw PMS7003 output is jumpy. So all three channels — PM1, PM2.5, and PM10 — go through a median filter followed by an EMA on the device; the plain pm1/pm25/pm10 keys carry the smoothed value, and each gets a …raw sibling with the last unfiltered frame. Smoothing on the device means every consumer — the dashboard, any future logger — gets the same clean signal without re-implementing the filter.

The publish side is worth a note too. The firmware doesn't use the usual Arduino PubSubClient; it drives ESP-IDF's native esp-mqtt client directly, because that's what can speak wss://. A sensor on the broker's LAN can talk plain mqtt:// on port 1883, but the one deployed in the field connects over wss:// through a Cloudflare Tunnel (with the server certificate validated against the bundled root store). Which of the two it uses is just one of its config fields — the same MQTT_USE_WSS value you can flip over Bluetooth (more on that next).

For testing the web app without waving a lighter at the sensor, the firmware has a serial-toggled test mode. Type random on into the Serial Monitor and it publishes synthetic frames through the same median+EMA pipeline, so the fake data behaves like real data. random off returns to the sensor.

Reconfiguring a Deployed Sensor Over Bluetooth

Here's the problem that took the most firmware work to solve well, and it's not hypothetical: the one sensor I have deployed lives at a university campus across town, not on the broker's home network. Changing its WiFi or broker credentials shouldn't mean driving over, unmounting it, and reflashing over USB. So all eight config fields — WiFi SSID and password, sensor ID, whether MQTT uses WSS, the LAN and WSS broker URIs, and the MQTT username/password — are reconfigurable at runtime over Bluetooth LE, through a passphrase-gated GATT service backed by NVS (the ESP32's non-volatile storage).

The nice property of NVS is that it wins over the compiled defaults permanently, even across reflashes. A brand-new device has no NVS keys, so every field falls back to the value baked into secrets.h and it behaves exactly as an unprovisioned device would. The moment you set anything over BLE, that value is what it uses on every subsequent boot — so a firmware update doesn't clobber the field config. There's no separate "is this provisioned yet" flag; the presence or absence of each NVS key is the state.

BLE Is Off Until the Sensor Actually Needs It

The naive design is to always advertise a BLE service. That turned out to be a bad idea for two reasons, and both shaped the firmware.

It costs ~90KB of heap you can't spare. Bringing up BLEDevice::init() and the GATT server permanently consumes around 90KB of heap (measured on real hardware) — enough to starve the TLS handshake that the WiFi + MQTT-over-WSS connection needs. So begin() deliberately does not touch the BLE stack. It only stores the config. The actual radio init is deferred to the first start() call:

// Stores `initial` for later use — does NOT touch the BLE stack yet. The
// actual BLEDevice::init()/GATT server setup is deferred to the first call
// to start(), since it permanently costs ~90KB of heap that would otherwise
// starve the WiFi/MQTT TLS handshake during normal operation.
void begin(const Config& initial);

A healthy sensor has no reason to be provisionable. BLE only comes up as a fallback — and precisely when you'd want it. The main loop watches how long WiFi and MQTT have been down, and turns provisioning on only after a 60-second outage, then back off once both recover:

bool wifiFailing = (now - wifiLastConnectedAt) >= BLE_FAIL_MS;
bool mqttFailing = WiFi.status() == WL_CONNECTED
                   && (now - mqttLastConnectedAt) >= BLE_FAIL_MS;
if (wifiFailing || mqttFailing) {
  BleProvisioning::start();   // idempotent
} else {
  BleProvisioning::stop();    // idempotent
}

Both calls are idempotent, so the loop can call them every iteration. The effect: a sensor that's happily publishing never advertises BLE, so there's no radio to attack and no heap spent on it. The moment it can't reach its broker — exactly when you'd walk up to it with your phone — it starts advertising. The reconfiguration path lights up only in the situation that needs it.

The GATT Handshake

The service exposes four characteristics, and the browser drives them in order:

  1. AUTH — write the passphrase. The device replies via the STATUS characteristic; anything other than AUTHENTICATED and the flow stops. The passphrase is a compile-time value (BLE_CONFIG_PASSWORD) — it's the one thing that can't be set over BLE, because it's what bootstraps trust for everything that can.
  2. CONFIG — write the changed fields as a KEY=value payload (only the fields you're changing; unmentioned keys are left untouched).
  3. APPLY — write 1 to commit the staged values to NVS and reboot.
  4. STATUS — read/notify, so the client reflects what the device is actually doing: WAITING_AUTH, AUTHENTICATED, SAVED_REBOOTING.

I should be honest about the current security model: these writes have no link-layer encryption. It's app-level passphrase gating, not BLE pairing/bonding, so today the passphrase and credentials travel in the clear over the air. That stops casual access, not someone with BLE-capture gear parked next to the sensor. For a hobby air-quality node it was an acceptable starting point — but it's the weakest part of the design, and it's the next thing I want to improve, most likely by moving to real BLE pairing/bonding so the link itself is encrypted rather than leaning on a passphrase sent in the clear.

Why a Custom Web Page Instead of nRF Connect

You can drive a raw GATT service with a generic BLE explorer like nRF Connect, and I did at first. It's miserable: you're hunting for raw UUIDs, writing one characteristic at a time, with no feedback on whether auth actually succeeded before you start writing config. Worse, its multi-segment "add value" write silently concatenated two of my fields together with no separator and corrupted both.

So the provisioning client is a page in the same static site — a /provision route (deliberately unlinked; it's a maintenance tool, not public) that speaks Web Bluetooth and drives the exact same GATT service through a form. It filters by the advertised service UUID, so the browser's own device picker shows each sensor by its advertised name (PM25-<sensorId>, e.g. PM25-tnsuatg). It also does the thing nRF Connect wouldn't: after writing the passphrase, it reads STATUS back to confirm AUTHENTICATED before writing a single config field. (Web Bluetooth is Chrome/Edge on desktop or Android only — Safari and iOS don't implement it at all, which is fine for an admin tool.)

That corrupted-write incident left a mark on the firmware, too. The config parser accepts either \n or ; between KEY=value pairs — because some BLE apps can't send a literal newline byte from a plain text field, but a semicolon is right there on the keyboard. The web page sends real newlines and never needs the fallback, but the firmware supports both so a bare BLE explorer is still a usable escape hatch.

The Footgun: Apply Reboots Before It Can ACK

This one cost a real debugging session. A BLE write with response expects the peripheral to send a GATT-level acknowledgement. But APPLY tells the ESP to save and reboot immediately — so the device tears down the BLE link before that ACK ever goes out. From the browser's side, writeValueWithResponse rejects with a DOMException as the connection drops, which looks exactly like a failure.

It isn't. The reboot-without-ACK is the expected, successful outcome. So the client treats a disconnect on the apply write as success rather than surfacing an error:

pushLog('Applying...')
try {
  await applyChar.writeValueWithResponse(new TextEncoder().encode('1'))
} catch (e) {
  // ESP reboots immediately on apply — disconnect before the GATT
  // response is expected. That's success, not failure.
  if (e instanceof DOMException) {
    pushLog('Saved — device is rebooting')
    return
  }
  throw e
}

The narrow instanceof DOMException check matters: a real write failure (wrong characteristic, out-of-range value) throws something else and still gets reported.

One More: The Sketch Won't Fit

Piling esp-mqtt, TLS (for wss://), the BLE stack, and Preferences into one firmware image pushes it to about 1.84MB — which doesn't fit the ESP32's default 1.3MB app partition, so it fails to compile with a flat Sketch too big. The fix is a build-time setting, not a code change: in the Arduino IDE, Tools → Partition Scheme → "Huge APP (3MB No OTA/1MB SPIFFS)". Worth writing down, because the error message doesn't point at the partition table.

Finding the Nearest Sensor, Client-Side

This is the part I originally over-built. The first version had a Cloudflare Worker with a /api/nearest endpoint that did the Haversine math server-side and fell back to Cloudflare's edge geolocation when the browser wouldn't share a location. It worked. It was also completely unnecessary.

The sensor registry is tiny and public. So I bake it into the bundle at build time and do the distance math in the browser: a useGeoSensor composable asks the browser for a location via navigator.geolocation.getCurrentPosition, then runs a few lines of Haversine (nearestSensor()) over the baked-in list to pick the closest.

There is no edge-geo fallback. If the browser's Geolocation API is denied or times out, I don't try to guess from an IP — I just show the manual sensor picker, which is on screen anyway. IP geolocation is coarse enough that a wrong guess is worse than asking, and dropping it deleted an entire class of server code.

Getting MQTT Into a Browser — Safely

Browsers cannot open a raw TCP socket, so they can't speak MQTT the way a device does. They can only do MQTT over WebSocket. The broker — a standalone Mosquitto running in Docker — has two listeners for exactly this split: 1883 for plain MQTT (what a device on the same LAN uses) and 9001 for MQTT over WebSocket. A Cloudflare Tunnel sits in front of the WebSocket listener and exposes it as wss://mq.kpp.sh, terminating TLS at the edge and forwarding plain ws:// to 9001 behind it. That single wss:// endpoint is how both the browser and the field-deployed sensor reach the broker from outside its LAN.

The catch: the broker requires authentication, and whatever credentials the browser uses to connect are right there in the page for anyone who opens dev tools. You cannot hide them. So the rule is:

Never hand the browser a device's publish credentials. Give it a dedicated, read-only account.

On the broker that's a two-account ACL: the device account (iot) can publish to air/#, and a separate mqtt-web-reader account can only read air/#. That read-only account is the one baked into the page. Even fully exposed, the worst someone can do with it is read the same public air-quality numbers the page already shows.

The per-sensor credentials live in a gitignored sensors.secrets.json, and they get merged into the public sensor list at build time, not at request time:

export function writeSensorsJson(): void {
  const merged = mergeSecrets(sensors, secrets)
  writeFileSync(resolve(process.cwd(), 'public/sensors.json'), JSON.stringify(merged))
}

That runs from a build:before Nuxt hook and writes public/sensors.json, which becomes a static asset. The secrets never touch git, but the merged output ships in the bundle — which is fine, precisely because the read-only account is designed to be public.

(This standalone broker replaced an earlier Home Assistant Mosquitto add-on. If you ever run on that add-on instead, mind its own trap: the moment you enable an ACL file you must keep homeassistant and addons accounts with full readwrite # access, or you'll break Home Assistant's own MQTT integration — add your read-only web account alongside them, not instead of them. Moving to a standalone Mosquitto dropped that constraint entirely, since there's no Home Assistant expecting those reserved accounts.)

Thai AQI, Not US EPA

A subtle but important correctness detail: the color bands and category labels use Thailand's official PCD standard — five bands, confirmed straight from air4thai.pcd.go.th — not the US EPA scale a lot of libraries ship by default. The same 35 µg/m³ reading lands in a different category depending on which standard you use, and for a monitor meant to be read in Thailand, showing the wrong category is showing wrong information.

The Footguns

Every build has a few. These are the ones that cost me time.

nuxt generate, Not nuxt build

This one flipped on me mid-project. While the app still had /api routes, nuxt generate was wrong — it prerenders and drops the Nitro server, taking your API routes with it. Once I moved everything client-side, generate became exactly right: a static .output/public/ with no server, which is what Workers Static Assets wants. If your deploy is nuxt generate && wrangler deploy and you're missing routes, the question to ask is whether you still have a server at all.

pnpm 11 Won't Install Without Build Approval

pnpm 11 refuses to run native dependency build scripts (esbuild, sharp, and friends) unless you explicitly approve them — and it exits 1 if you don't, which aborts the whole install. The approval list is a map under allowBuilds: in pnpm-workspace.yaml:

allowBuilds:
  esbuild: true

Note this is not the old package.jsonpnpm.onlyBuiltDependencies, which pnpm 11 silently ignores with a warning. I chased that for a while.

Cloudflare Rocket Loader Breaks the Bundle

After deploying, the page loaded blank. Cloudflare's Rocket Loader and automatic JS minification both rewrite your script tags in ways Nuxt's hydration doesn't survive. Turn both off for the domain. Nothing in your code is wrong — the CDN is "helpfully" mangling the output.

@nuxtjs/i18n: v-model Doesn't Switch Locales

The language switcher rendered, clicked, updated the dropdown — and changed nothing. Binding v-model="$i18n.locale" only mutates vue-i18n's raw locale ref; it skips the module's setLocale() pipeline that actually lazy-loads the target locale's messages and writes the persistence cookie. The symptom is $t() rendering literal keys and a console warning about a missing key, with no thrown error. The fix is to drive it explicitly:

<!-- broken: silently no-ops -->
<Select v-model="$i18n.locale" ... />

<!-- works -->
<Select
  :modelValue="$i18n.locale"
  @update:modelValue="$i18n.setLocale($event)"
  ...
/>

What's Next

The sensor registry is a JSON file baked into the bundle, which is perfect at this scale. When it outgrows a hand-edited file, I'd rather stand up a small dedicated service than reintroduce a server into the web app. And when I do, I already know the stack: Elysia on Bun, Better Auth for sessions, a real database behind it, and a CMS so the sensor registry stops being a file I hand-edit. The read is already isolated behind a small module, so the rest of the app doesn't need to know whether the list comes from a static file or an API. But that's a problem for more than a handful of sensors. Until then, the best backend is no backend.