Air Quality Index from specific site in London
Scrolls through all valid measurements reported by specific site.
| System | AWTRIX NG Scripts |
|---|---|
| Firmware | AWTRIX NG |
| Topic | Smarthome |
| Built by | kowalcj0 |
| File | 5Tw5xjVl6v96.ax · 6.9 KB |
| Icons | — |
| Published | 2 Sep 2026 · updated 2 Sep 2026 |
| Downloads | 1 |
You only need to set the Site Code (e.g. BT8) in the app settings. To find it: open the London Air index page, find the site on the map, click View monitoring data, and take the value of the site parameter from the resulting URL.
FYI: Script will then fetch the Air Quality data from this API endpoint:
http://api.erg.ic.ac.uk/AirQuality/Hourly/MonitoringIndex/SiteCode=<SITE_CODE>/Json
It uses http instead of https to save on resources.
5Tw5xjVl6v96.ax
# @name London Air Quality
# @desc Show per-pollutant Air Quality Index (NO2, O3, PM1, PM2.5, PM10, CO, SO2, NO - whichever the site reports) for a specific location in London, each reading tinted by its London Air band colour. Data feeds comes from Environmental Research Group (ERGP) at Imperial College London. More details on https://www.londonair.org.uk/Londonair/API/ and API documentation https://api.erg.ic.ac.uk/airquality/help
# @author kowalcj0
# @version 1.2
# @config site_code text "Site Code" default="BT8" help="site code, e.g. BT6"
# @config every number "Refresh" default=30 min=5 max=120 unit=min
import gc
class AirQuality
# London Air index values 1-10 -> colour, see
# https://www.londonair.org.uk/london/asp/airpollutionindex.asp
var levels, url, label, color, opts, err
# refresh countdown + failure backoff, see due()/failed()/ok()/now()
var ticks, interval, retry, busy
def init()
self.levels = [
0xADD189, 0x77AD53, 0x2E8405, # 1-3 low
0xEFC192, 0xFE9C53, 0xF46200, # 4-6 moderate
0xDC1B1B, 0xA70B0B, 0x7B0000, # 7-9 high
0xFFFFFF, # 10 very high (official black is invisible on the panel)
]
# saving a setting restarts the app, so the URL built here never goes
# stale. Plain HTTP on purpose: a TLS handshake needs ~45 KB contiguous
# heap, which this ESP32 can't spare (attempts OOM-panic the device);
# the ERG API also serves unencrypted, and the payload is public data
self.url = ""
var site_code = store.get("site_code")
if site_code != nil && site_code != ""
self.url = "http://api.erg.ic.ac.uk/AirQuality/Hourly/MonitoringIndex/SiteCode=" + str(site_code) + "/Json"
end
# refresh interval in minutes (hourly feed; keep requests few)
self.interval = clamp(num(store.get("every"), 0), 5, 120) * 60
# label is a list of [text, colour] pieces, one per reading; plain
# string ("--" / "?") until the first successful fetch
self.label = nil
self.color = 0x666666
# marquee over the whole panel; built once, passed every frame
self.opts = {'mode': "loop", 'speed': 100}
self.ticks = 0
self.retry = 30
self.busy = false
self.err = false
end
# first fetch + fill display state before the first frame
def setup()
log("London Air: " + str(gc.allocated()) + " B live in the Berry heap")
self.loop()
end
# select forces a refresh; left and right just rotate
def on_button(btn)
if btn == "select"
self.now()
end
end
def on_body(body, status)
shared.set("f", 0)
# non-200 or nil body: nothing usable this attempt
if status != 200 || body == nil
log("London Air: fetch failed, status " + str(status))
self.failed()
return
end
# the find/keep window holds the species list only; the site reports
# one index per pollutant and which species a site has varies, so every
# known code is probed, in health-impact order: NO2 nitrogen dioxide,
# O3 ozone, PM1/PM25/PM10 particulate, CO carbon monoxide, SO2 sulphur
# dioxide, NO nitric oxide. Each is shown with its own London Air band
# colour. (A full json.load() of the body would build a tree of ~30
# small maps/strings per fetch and fragment the tight ESP32 heap.)
var pieces = []
for code : ["NO2", "O3", "PM1", "PM25", "PM10", "CO", "SO2", "NO"]
var pat = "\"@SpeciesCode\":\\s*\"" + code + "\"\\s*,\\s*\"@AirQualityIndex\":\\s*\"(\\d+)\",\\s*\"@AirQualityBand\":\\s*\"([^\"]+)\""
var m = re.search(pat, body)
var n = nil
var band = nil
if m != nil
n = num(m[1])
band = m[2]
end
if n != nil && n > 0 && band != nil && band != "No data"
# display name: PM25 -> "PM2.5"
var name = (code == "PM25" ? "PM2.5" : code)
if size(pieces) > 0
pieces.push([" - ", 0x888888])
end
# "NO2 3", tinted by this pollutant's London Air band
pieces.push([name + " " + str(n), self.band_color(n)])
end
end
if size(pieces) == 0
log("London Air: no readings in window")
self.failed()
return
end
self.label = pieces
self.ticks = self.interval
self.ok()
log("London Air: ok, " + str(int(size(pieces) / 2)) + " readings")
end
# London Air band colour for a reading (1-10); clamps out-of-range values
def band_color(n)
n = int(n)
if n < 1
n = 1
elif n > 10
n = 10
end
return self.levels[n - 1]
end
def loop()
# url checked first: due() raises busy as a side effect
if self.url != "" && self.due()
shared.set("f", 1)
# stream-skip everything before the species list: only a 1 KB window
# lands on the heap, not the full ~1.4 KB body. 1024 B covers the
# largest response seen so far (4 species); if a site ever reports
# more, the tail entries simply don't match and are not shown
http.get(self.url, / b, st -> self.on_body(b, st), {'find': '"species"', 'keep': 1024})
end
# until the first successful fetch: show "--", or "?" once a fetch has
# failed (see failed())
if self.label == nil
self.label = self.err ? "?" : "--"
self.color = self.err ? 0xCC7722 : 0x666666
end
end
# Fires when the countdown has run down, never while a request is out or
# another app's TLS handshake is running (shared "f" flag, official
# convention). Raises busy, so only call it when a request will actually
# be issued. The countdown is armed by the fetch result (interval on
# success, backoff on failure).
def due()
if self.ticks > 0
self.ticks = self.ticks - 1
return false
end
if self.busy
return false
end
for k : shared.keys()
var n = size(k)
if n > 2 && k[n - 2 .. n - 1] == ".f" && shared.get(k) == 1
var a = shared.age(k)
if a != nil && a < 20000
return false
end
end
end
# floor: even if the callback never came, the next try is a minute away
self.ticks = 60
self.busy = true
return true
end
# failure backoff: retry at 30s, doubling, capped at 5 min; raises err so
# a fetch with no data yet can show "?" instead of "--"
def failed()
self.busy = false
self.ticks = self.retry
self.retry = min(self.retry * 2, 300)
self.err = true
end
def ok()
self.busy = false
self.retry = 30
self.err = false
end
# manual refresh: clears the countdown but leaves busy alone, so a press
# while a request is out can't fire a second one
def now()
self.ticks = 0
self.retry = 30
end
def draw()
clear()
# each reading piece carries its own band colour; the plain "--"/"?"
# fallback uses the call colour
scroll_text(0, 6, width(), self.label, self.color, self.opts)
end
end
return AirQuality()
Install it on your AWTRIX NG
Your browser writes the script straight to the device on your network. It joins the app rotation right away.
Some browsers refuse to talk to a device on your network from a public page. Download the flow and paste it into the Scripts tab of your device, or install it from a terminal:
More flows for AWTRIX NG Scripts or Smarthome
Something wrong with this flow? Report it.