Designing air quality for a 1-bit display
Have Haze Not puts Singapore's air quality on a TRMNL e-ink panel. The interesting problems were all about which number to show.
During haze season in Singapore, the first thing many of us check in the morning is the air. I wanted that on the e-ink TRMNL on my shelf instead of in an app, so I built Have Haze Not. It shows a map of NEA's five reporting regions, the headline reading for my region and the pollutant behind it, a 24-hour PM2.5 forecast, and how nearby cities compare.
It has no server. TRMNL polls public APIs directly and Liquid templates do the rendering. So most of the engineering went into two questions: which number is right, and how to draw it in black and white.
Which number to show
There is no free per-region AQI for Singapore. NEA publishes PSI and PM2.5 by region, but not US AQI. So the plugin computes AQI itself from NEA's PM2.5 using the EPA breakpoints.
Hourly, not daily. The 24-hour average lags badly during haze. When I compared them, the daily mean read about 60 points higher than the current hour. Neither number is wrong; they answer different questions. "Should I open the window right now?" is an hourly question, and it's the one consumer apps answer, so that's what the panel shows.
Which breakpoint table. The EPA revised its PM2.5 table in May 2024; "Good" now tops out at 9.0 µg/m³ instead of 12.0. aqicn still uses the old table, which explains most of the gap between my numbers and theirs. If you'd rather match aqicn, an optional token swaps in their measured figure.
Local data for local readings. Open-Meteo's global grid can't tell one part of Singapore from another, so it's used only for the forecast and for distant cities. Everything inside Singapore comes from NEA.
Drawing it in 1-bit
The panel has no grey. A region shaded "moderate" in grey simply doesn't exist on this screen, so the map is halftone: a build script lays a hex lattice of dots over Singapore's coastline, assigns each dot to its nearest NEA reporting point, and bakes the result into the template. At render time, dot size follows each region's current band, so worse air reads as darker.
The regions are approximate Voronoi cells, not NEA's official boundaries. For a glance at a shelf, that's the right trade.
Bugs worth guarding with tests
- A missing key that renders as "Hazardous". TRMNL passes the home region as a display label. If the lookup misses, the value is nil, and nil falls through the band chain to the worst one: "Hazardous" with a blank number. That's the most alarming possible failure for an air-quality display. The code now normalises the label and falls back to Central, and a render test covers it.
- A CSS rule that collapses stacked columns. The framework's columns rely on flex-basis with
width: 0. That works in a row and collapses everything when the layout stacks on a portrait panel. A layout test now guards the geometry. - Secrets. The optional token never goes in tracked files, and a test checks for leaks.
make check also verifies that the live APIs still return what the templates expect, because with no server in between, an upstream change goes straight to the screen.
What I'd do the same way again
Start with the question the reader is asking at that moment, and pick the data source that answers it, even when a more official-looking number exists. On a display you glance at for two seconds, the wrong number shown confidently is worse than no number.
Have Haze Not is a public TRMNL recipe, and the code is on GitHub. If you have a TRMNL and live in Singapore, give it a try.