BUSY.Tedt.org BUSY Bar Apps
← All Apps

Buienradar Alarm

Dutch rain alarm that takes the screen only when rain is coming. Shows minutes until rain or until dry, plus a 2-hour Buienradar intensity timeline colored from drizzle to downpour.

Updated Aug 16, 2026 · Added Jul 26, 2026 · ↓ 0 package download starts

By maxswinkels · ▲ 0 · Rate and Discuss

How It Works

Dutch rain alarm that takes the screen only when rain is coming. Shows minutes until rain or until dry, plus a 2-hour Buienradar intensity timeline colored from drizzle to downpour.

Download and extract the complete app folder. Keep its files together; some apps include additional modules, assets, or configuration examples.

Review app.py and any configuration examples for this app’s options, then run it from the extracted folder:

python3 app.py

Connect BUSY Bar over USB, or use the connection options supported by this app. See the official HTTP API documentation for device setup.

Source Files

These are the mirrored source bytes. The complete package also includes every asset and supporting file.

app.py
#!/usr/bin/env python3
"""Buienradar rain alarm: takes the screen only when rain is coming, with a 2-hour intensity timeline.

    python3 apps/local/buienradar-alarm/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

APP = "buienradar-alarm"
BUIENRADAR_URL = "https://gpsgadget.buienradar.nl/data/raintext"


def parse_args():
    p = argparse.ArgumentParser(description="Buienradar rain 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")
    p.add_argument("--refresh", type=int, default=300, help="seconds between data fetches")
    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"] = "#2196F3FF"
    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_rain(lat, lon):
    """Fetch rain data from Buienradar. Returns list of (minutes_from_now, value) tuples."""
    url = BUIENRADAR_URL + "?" + urllib.parse.urlencode({"lat": lat, "lon": lon})
    req = urllib.request.Request(url, headers={"User-Agent": "busybar-rain-alarm/1.0"})
    with urllib.request.urlopen(req, timeout=10) as r:
        raw = r.read().decode("utf-8", "ignore")
    return _parse_rain(raw)


def _parse_rain(raw):
    """Parse plain-text lines 'NNN|HH:MM' into (minutes_from_now, value) list."""
    now = datetime.now()
    now_minutes = now.hour * 60 + now.minute
    result = []
    for line in raw.strip().splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split("|")
        if len(parts) != 2:
            continue
        try:
            value = int(parts[0].strip())
            hh, mm = parts[1].strip().split(":")
            entry_minutes = int(hh) * 60 + int(mm)
        except (ValueError, IndexError):
            continue
        delta = entry_minutes - now_minutes
        # entries can cross midnight; if more than 60 min in the past, add 24h
        if delta < -60:
            delta += 24 * 60
        result.append((delta, value))
    return result


def _fake_series():
    """Test data: 2 dry entries, then rain ramp, then dry to 24 total."""
    values = [0, 0, 40, 80, 120, 180, 220, 180, 120, 80, 40]
    series = []
    for i in range(24):
        v = values[i] if i < len(values) else 0
        series.append((i * 5, v))
    return series


# ---------------------------------------------------------------------------
# Display logic
# ---------------------------------------------------------------------------

def _build_headline(series):
    """Return (headline_str, is_raining_now)."""
    if not series:
        return None, False
    first_minutes, first_value = series[0]
    if first_value <= 0:
        # Dry now -- find when rain starts
        for minutes, value in series:
            if value > 0:
                return f"REGEN IN {max(minutes, 0)} MIN", False
        return None, False  # no rain in entire series
    else:
        # Raining now -- find when it becomes dry
        for minutes, value in series:
            if minutes >= 0 and value <= 0:
                return f"DROOG IN {minutes} MIN", True
        return "REGEN", True


def _rect(el_id, x, y, w, h, color):
    return {"id": el_id, "type": "rectangle", "x": x, "y": y, "width": w, "height": h,
            "border_width": 0, "fill": "solid", "fill_colors": [color]}


def _bar_color(value):
    """Buienradar-style intensity colors: drizzle cyan, showers blue, downpour purple.
    Saturated hues with zeroed channels survive the display's gamma curve."""
    if value < 70:
        return "#00C8FFFF"
    if value < 150:
        return "#0064FFFF"
    return "#9600FFFF"


def _build_elements(headline, series):
    """Droplet icon + headline on top, gapped intensity bars over a baseline below."""
    elements = []
    # Droplet icon (5x6) at the top left, with a light catch-light pixel
    drop = "#00A8FFFF"
    elements += [
        _rect("drop_tip", 4, 0, 1, 2, drop),
        _rect("drop_neck", 3, 2, 3, 1, drop),
        _rect("drop_body", 2, 3, 5, 2, drop),   # body
        _rect("drop_base", 3, 5, 3, 1, drop),
        _rect("drop_glint", 3, 3, 1, 1, "#B3E5FCFF"),
    ]
    # Headline, centered in the space right of the icon (x=10..72; the widest
    # case "REGEN IN 120 MIN" is 62px in the small font, measured via the atlas)
    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 0-4, vertically aligned with the 6px droplet icon.
        "y": -1,
        "font": "small",
        "color": "#EAF6FFFF",
        "align": "top_mid",
    })
    # Baseline with subtle half-hour ticks
    elements.append(_rect("baseline", 0, 15, 72, 1, "#102027FF"))
    for i, tick_x in enumerate((18, 36, 54)):
        elements.append(_rect(f"tick{i}", tick_x, 15, 1, 1, "#3A4A55FF"))
    # Timeline: 24 slots of 5 min, 2px bars with a 1px gap, colored by intensity
    for slot in range(24):
        if slot < len(series):
            _, value = series[slot]
        else:
            value = 0
        if value <= 0:
            continue
        h = max(1, round(value / 255 * 7))
        elements.append(_rect(f"bar{slot}", slot * 3, 16 - h, 2, h, _bar_color(value)))
    return elements


# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------

def main():
    args = parse_args()

    series = []
    last_fetch = 0.0
    last_clear_sent = False  # True after we sent a DELETE for a dry period
    first_alert = True       # True until we draw the first rain alert

    if args.test:
        series = _fake_series()
        headline, _ = _build_headline(series)
        if headline is None:
            print("test: no rain in series -- nothing to draw")
            sys.exit(0)
        elements = _build_elements(headline, series)
        status, body = _draw(args.host, elements, led_notification=True)
        print(f"drew: '{headline}', {len(elements)} elements (status {status})")
        sys.exit(0)

    print(f"buienradar-alarm -> {_base(args.host)}  (Ctrl-C to stop)")

    try:
        while True:
            now = time.monotonic()

            # Fetch/refresh data
            if now - last_fetch >= args.refresh or last_fetch == 0.0:
                try:
                    series = _fetch_rain(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

            # Determine what to display
            any_rain = any(v > 0 for _, v in series)

            if not any_rain:
                if not last_clear_sent:
                    try:
                        _clear(args.host)
                        print("droog, scherm vrijgegeven")
                    except Exception as e:
                        print(f"clear error: {e}")
                    last_clear_sent = True
                    first_alert = True  # next rain alert gets LED notification
            else:
                headline, _ = _build_headline(series)
                if headline is None:
                    # Shouldn't happen when any_rain is True, but be safe
                    time.sleep(args.interval)
                    continue
                elements = _build_elements(headline, series)
                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: '{headline}' ({len(elements)} elements)")
                except Exception as e:
                    print(f"draw error: {e}")

            time.sleep(args.interval)

    except KeyboardInterrupt:
        print("\nstopped.")
        try:
            _clear(args.host)
        except Exception:
            pass


if __name__ == "__main__":
    main()
manifest.yaml
name: Buienradar Alarm
author: maxswinkels
description: Dutch rain alarm that takes the screen only when rain is coming. Shows minutes until rain or until dry, plus a 2-hour Buienradar intensity timeline colored from drizzle to downpour.
tags:
  - weather
  - info
preview: ./preview.gif

Source and Provenance

View This Version on GitHub →

Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: 8cf9e693e764e972f6f5785c312431d47a2b2ca3822f2b6a628594288ebd651e

The Community Workbench

Rate and Discuss

Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.