# @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()