Pollen Alarm
Hay fever alarm for Europe. Quiet while pollen is low, then the worst species with a 24 hour intensity timeline, a marker on the announced hour and drifting pollen specks. Data from Open-Meteo.
How to run
This app is a single Python file that talks directly to the BUSY Bar HTTP API: no libraries, no setup. Connect your bar via USB and run:
python app.py
Over USB the bar is always at 10.0.4.20. On Wi-Fi, pass the bar's IP
with --host <address>.
No bar at hand? Run the
BUSY Bar Emulator
and point the app at it: python app.py --host 127.0.0.1:8080.
Source code
app.py
#!/usr/bin/env python3
"""Pollen alarm: takes the screen only when hay fever pollen is high, with a 24-hour timeline.
python3 app.py [--host 127.0.0.1:8080] [--lat 52.37] [--lon 4.89] [--test]
"""
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
APP = "pollen-alarm"
METEO_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
# Species key -> Dutch display name
SPECIES = [
("alder_pollen", "ELS"),
("birch_pollen", "BERK"),
("grass_pollen", "GRAS"),
("mugwort_pollen", "BIJVOET"),
("olive_pollen", "OLIJF"),
("ragweed_pollen", "AMBROSIA"),
]
# Level thresholds
LEVEL_LAAG = "laag"
LEVEL_MIDDEL = "middel"
LEVEL_HOOG = "hoog"
COLOR_MIDDEL = "#FFC800FF"
COLOR_HOOG = "#FF3C00FF"
COLOR_BASELINE = "#2A2A1AFF"
COLOR_WHITE = "#EAF6FFFF"
COLOR_AMBER = "#FFC800FF"
COLOR_GREEN = "#00C800FF"
def parse_args():
p = argparse.ArgumentParser(description="Pollen alarm for BUSY Bar")
p.add_argument("--host", default="10.0.4.20")
p.add_argument("--lat", type=float, default=52.37)
p.add_argument("--lon", type=float, default=4.89)
p.add_argument("--interval", type=int, default=60, help="seconds between redraws (default: 60)")
p.add_argument("--refresh", type=int, default=3600, help="seconds between data fetches (default: 3600)")
p.add_argument("--test", action="store_true")
return p.parse_args()
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _base(host):
host = host.replace("http://", "").replace("https://", "").rstrip("/")
return "http://" + host
def _draw(host, elements, led_notification=False):
"""POST /api/display/draw. Returns (status_code, body_text)."""
body = {
"application_name": APP,
"priority": 60,
"elements": elements,
}
if led_notification:
body["led_notification_color"] = COLOR_AMBER
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
_base(host) + "/api/display/draw",
data=data,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=8) as r:
return r.getcode(), r.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "ignore")
except urllib.error.URLError as e:
raise RuntimeError(f"draw failed: {e}") from e
def _clear(host):
"""DELETE /api/display/draw?application_name=..."""
qs = urllib.parse.urlencode({"application_name": APP})
req = urllib.request.Request(
_base(host) + "/api/display/draw?" + qs,
method="DELETE",
)
try:
with urllib.request.urlopen(req, timeout=8) as r:
return r.getcode()
except urllib.error.HTTPError as e:
return e.code
except urllib.error.URLError as e:
raise RuntimeError(f"clear failed: {e}") from e
def _fetch_pollen(lat, lon):
"""Fetch pollen data from Open-Meteo. Returns dict of hourly arrays keyed by species key."""
species_keys = [s[0] for s in SPECIES]
params = {
"latitude": lat,
"longitude": lon,
"hourly": ",".join(species_keys),
"forecast_days": 2,
}
url = METEO_URL + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": "busybar-pollen-alarm/1.0"})
with urllib.request.urlopen(req, timeout=10) as r:
raw = r.read().decode("utf-8", "ignore")
parsed = json.loads(raw)
hourly = parsed.get("hourly", {})
return {
"time": hourly.get("time", []),
**{key: hourly.get(key, []) for key, _ in SPECIES},
}
def _fake_data():
"""Test data: hours 0-3 GRAS laag (5), hours 4-9 GRAS rising, hours 10-23 BERK middel (25)."""
now = datetime.now()
base_hour = now.replace(minute=0, second=0, microsecond=0)
times = []
for i in range(48):
h = base_hour.replace(hour=(base_hour.hour + i) % 24)
if i >= 24:
# second day -- simple offset
from datetime import timedelta
h = base_hour + timedelta(hours=i)
times.append(h.strftime("%Y-%m-%dT%H:00"))
grass_vals = [5, 5, 5, 5, 30, 60, 90, 120, 90, 60] + [0] * 38
birch_vals = [0] * 10 + [25] * 38
data = {"time": times}
for key, _ in SPECIES:
if key == "grass_pollen":
data[key] = grass_vals
elif key == "birch_pollen":
data[key] = birch_vals
else:
data[key] = [0] * 48
return data
# ---------------------------------------------------------------------------
# Pollen logic
# ---------------------------------------------------------------------------
def _level(value):
if value is None or value < 20:
return LEVEL_LAAG
if value < 80:
return LEVEL_MIDDEL
return LEVEL_HOOG
def _level_color(level):
if level == LEVEL_HOOG:
return COLOR_HOOG
return COLOR_MIDDEL
def _dominant(values_by_species):
"""Given dict {key: value}, return (key, value) for species with highest value."""
best_key, best_val = None, -1
for key, val in values_by_species.items():
if val is not None and val > best_val:
best_val = val
best_key = key
return best_key, best_val
def _build_24h(data):
"""Extract next 24 hours starting from current hour.
Returns list of 24 dicts: {hour, dominant_label, dominant_value, level}.
"""
now = datetime.now()
current_hour_str = now.strftime("%Y-%m-%dT%H:00")
times = data.get("time", [])
# Find index of current hour
start_idx = None
for i, t in enumerate(times):
if t == current_hour_str:
start_idx = i
break
if start_idx is None:
# Fallback: find nearest future hour
for i, t in enumerate(times):
if t >= current_hour_str:
start_idx = i
break
if start_idx is None:
return []
slots = []
for offset in range(24):
idx = start_idx + offset
if idx >= len(times):
break
t = times[idx]
# Parse hour for display
try:
slot_hour = int(t[11:13])
except (ValueError, IndexError):
slot_hour = 0
vals = {}
for key, _ in SPECIES:
arr = data.get(key, [])
v = arr[idx] if idx < len(arr) else None
vals[key] = v
dom_key, dom_val = _dominant(vals)
if dom_key is None or dom_val <= 0:
dom_label = None
dom_val = 0
else:
dom_label = dict(SPECIES)[dom_key]
level = _level(dom_val)
slots.append({
"hour": slot_hour,
"dominant_label": dom_label,
"dominant_value": dom_val,
"level": level,
})
return slots
def _build_headline(slots):
"""Return (text, level, slot_index) for the announced hour, or None if
everything is laag. The level colors the headline; the index marks the
matching bar in the timeline."""
if not slots:
return None
current = slots[0]
if current["level"] != LEVEL_LAAG:
label = current["dominant_label"] or "POLLEN"
return (f"{label} {current['level'].upper()}", current["level"], 0)
# Current hour is laag -- find first non-laag hour
for i, slot in enumerate(slots[1:], start=1):
if slot["level"] != LEVEL_LAAG:
label = slot["dominant_label"] or "POLLEN"
text = f"{label} OM {slot['hour']:02d}:00"
if len(text) > 15: # long species names (AMBROSIA, BIJVOET) drop the OM to fit
text = f"{label} {slot['hour']:02d}:00"
return (text, slot["level"], i)
return None
# ---------------------------------------------------------------------------
# Display building
# ---------------------------------------------------------------------------
def _rect(el_id, x, y, w, h, color):
return {"id": str(el_id), "type": "rectangle", "x": x, "y": y, "width": w, "height": h,
"border_width": 0, "fill": "solid", "fill_colors": [color]}
def _grad(el_id, x, y, w, h, top, bottom):
return {"id": str(el_id), "type": "rectangle", "x": x, "y": y, "width": w, "height": h,
"border_width": 0, "fill": "gradient_v", "fill_colors": [top, bottom]}
# Pollen specks drifting right to left through the free lane (rows 7-8):
# (start_x, row, step, color). The tick advances them every redraw.
DRIFT = [(4, 7, 3, "#C89600FF"), (17, 8, 2, "#8C6E00FF"), (31, 7, 4, "#8C6E00FF"),
(45, 8, 3, "#C89600FF"), (58, 7, 2, "#8C6E00FF"), (68, 8, 5, "#C89600FF")]
def _drift(tick):
return [_rect(f"drift{i}", (sx - tick * step) % 72, row, 1, 1, color)
for i, (sx, row, step, color) in enumerate(DRIFT)]
def _build_elements(headline, headline_level, marker_idx, slots, tick=0):
elements = []
# Flower icon (7x7): petal ring around a white heart, green stem with a
# leaf. The petals breathe between amber and bright yellow every redraw.
petal = COLOR_AMBER if tick % 2 == 0 else "#FFE13CFF"
elements += [
_rect("petal_top", 2, 0, 3, 1, petal), # petals top
_rect("petal_left", 1, 1, 1, 1, petal), # petal left
_rect("petal_right", 5, 1, 1, 1, petal), # petal right
_rect("petal_bottom", 2, 2, 3, 1, petal), # petals bottom
_rect("heart", 3, 1, 1, 1, COLOR_WHITE), # heart
_rect("stem", 3, 3, 1, 4, COLOR_GREEN), # stem
_rect("leaf", 1, 4, 2, 1, COLOR_GREEN), # leaf
]
# Drifting pollen specks in the free lane between text and chart
elements += _drift(tick)
# Headline always white; the bars carry the level colors
elements.append({
"id": "headline",
"type": "text",
"text": headline,
"x": 41,
# y=-1: on the device the small font renders ~2px low, so this lands the
# headline ink on rows 1-5, vertically centered against the 7px flower icon.
"y": -1,
"font": "small",
"color": COLOR_WHITE,
"align": "top_mid",
})
# Quarter-day ticks in the gap columns so the 24h axis has anchor points
for tick_slot in (6, 12, 18):
elements.append(_rect(f"tick{tick_slot}", tick_slot * 3 - 1, 14, 1, 2, "#5A5A5AFF"))
# Timeline: 24 hourly slots, 2px wide bars with 1px gap, max height 7,
# anchored at bottom (y=9..15), with a vertical gradient for depth and a
# bright cap on the peak hour.
peak_idx, peak_h = None, 0
for slot_idx, slot in enumerate(slots[:24]):
x = slot_idx * 3
val = slot["dominant_value"]
level = slot["level"]
if level == LEVEL_LAAG:
# Dim 1px baseline
elements.append(_rect(f"bar{slot_idx}", x, 15, 2, 1, COLOR_BASELINE))
else:
h = max(1, min(7, round(val / 120 * 7)))
if level == LEVEL_HOOG:
top, bottom = "#FF5A14FF", "#781400FF"
else:
top, bottom = "#FFD23CFF", "#785000FF"
elements.append(_grad(f"bar{slot_idx}", x, 16 - h, 2, h, top, bottom))
if h > peak_h:
peak_idx, peak_h = slot_idx, h
if peak_idx is not None and peak_h >= 2:
elements.append(_rect("peak", peak_idx * 3, 16 - peak_h, 2, 1, "#FFF0A0FF"))
# White marker on the bottom row of the bar the headline points at
if marker_idx is not None and 0 <= marker_idx < 24:
elements.append(_rect("marker", marker_idx * 3, 15, 2, 1, COLOR_WHITE))
return elements
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def main():
args = parse_args()
data = {}
last_fetch = 0.0
last_clear_sent = False
first_alert = True
tick = 0
if args.test:
data = _fake_data()
slots = _build_24h(data)
result = _build_headline(slots)
if result is None:
print("test: pollen laag in alle slots -- niets te tekenen")
sys.exit(0)
text, level, marker = result
elements = _build_elements(text, level, marker, slots, tick=0)
status, body = _draw(args.host, elements, led_notification=True)
print(f"drew: '{text}' ({level}, marker slot {marker}), {len(elements)} elements (status {status})")
sys.exit(0)
print(f"pollen-alarm -> {_base(args.host)} (Ctrl-C to stop)")
try:
while True:
now = time.monotonic()
# Fetch / refresh
if now - last_fetch >= args.refresh or last_fetch == 0.0:
try:
data = _fetch_pollen(args.lat, args.lon)
last_fetch = now
except Exception as e:
print(f"fetch error (reusing previous data): {e}")
if last_fetch == 0.0:
last_fetch = now # avoid hammering on boot error
slots = _build_24h(data)
any_alert = any(s["level"] != LEVEL_LAAG for s in slots)
result = _build_headline(slots)
if not any_alert or result is None:
if not last_clear_sent:
try:
_clear(args.host)
print("pollen laag, scherm vrijgegeven")
except Exception as e:
print(f"clear error: {e}")
last_clear_sent = True
first_alert = True
else:
text, level, marker = result
elements = _build_elements(text, level, marker, slots, tick=tick)
notify = first_alert
try:
status, body = _draw(args.host, elements, led_notification=notify)
if status == 409:
print(f"display busy (409), retrying next cycle")
else:
if notify:
first_alert = False
last_clear_sent = False
print(f"drew: '{text}' ({len(elements)} elements)")
except Exception as e:
print(f"draw error: {e}")
tick += 1
time.sleep(args.interval)
except KeyboardInterrupt:
print("\nstopped.")
try:
_clear(args.host)
except Exception:
pass
if __name__ == "__main__":
main()