Weather Forecast

Weather Forecast

by @robynhub

Current weather and multi-day forecasts for BUSY Bar, with configurable location, units, and language.

zero-install weatherinfo

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.

To keep it running without a terminal open, install it with busybar-manager, which handles the virtualenv, autostart and scheduling, and lets you switch between apps from a web dashboard.

Source code

app.py
#!/usr/bin/env python3
"""BUSY Bar weather forecast.

Shows current weather and the next days on a 72x16 BUSY Bar display.
Weather data comes from Open-Meteo and requires no API key.

Examples:
    python3 weather_forecast.py
    python3 weather_forecast.py --city Roma --lat 41.9028 --lon 12.4964
    python3 weather_forecast.py --host 127.0.0.1:8080 --days 4
    python3 weather_forecast.py --unit fahrenheit
    python3 weather_forecast.py --lang en
    python3 weather_forecast.py --test

Default location is Rome, Italy. Change --city/--lat/--lon for your location.
"""

import argparse
import datetime as dt
import json
import math
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib

APP = "weather-forecast"
W, H = 72, 16
DEFAULT_HOST = "10.0.4.20"
DEFAULT_CITY = "Roma"
DEFAULT_LAT = 41.9028
DEFAULT_LON = 12.4964
DEFAULT_REFRESH = 15 * 60
DEFAULT_DWELL = 6

WHITE = "#FFFFFFFF"
YELLOW = "#FFD54FFF"
CYAN = "#64D8FFFF"
BLUE = "#42A5F5FF"
GRAY = "#B0BEC5FF"

LANGUAGES = {

    "en": {
        "weekdays": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
        "today": "Today", "tomorrow": "Tomorrow", "wind": "Wind", "rain": "Rain",
        "weather": {
            "sun": "Clear", "partly": "Partly cloudy", "cloud": "Cloudy",
            "fog": "Fog", "rain": "Rain", "snow": "Snow",
        },
    },
    "it": {
        "weekdays": ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"],
        "today": "Oggi", "tomorrow": "Domani", "wind": "Vento", "rain": "Pioggia",
        "weather": {
            "sun": "Sereno", "partly": "Poco nuvoloso", "cloud": "Nuvoloso",
            "fog": "Nebbia", "rain": "Pioggia", "snow": "Neve",
        },
    },
    "de": {
        "weekdays": ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
        "today": "Heute", "tomorrow": "Morgen", "wind": "Wind", "rain": "Regen",
        "weather": {
            "sun": "Klar", "partly": "Teilw. bewoelkt", "cloud": "Bewoelkt",
            "fog": "Nebel", "rain": "Regen", "snow": "Schnee",
        },
    },
    "fr": {
        "weekdays": ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"],
        "today": "Auj.", "tomorrow": "Demain", "wind": "Vent", "rain": "Pluie",
        "weather": {
            "sun": "Clair", "partly": "Peu nuageux", "cloud": "Nuageux",
            "fog": "Brouillard", "rain": "Pluie", "snow": "Neige",
        },
    },
    "es": {
        "weekdays": ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"],
        "today": "Hoy", "tomorrow": "Manana", "wind": "Viento", "rain": "Lluvia",
        "weather": {
            "sun": "Despejado", "partly": "Poco nuboso", "cloud": "Nublado",
            "fog": "Niebla", "rain": "Lluvia", "snow": "Nieve",
        },
    },
}


def parse_args():
    p = argparse.ArgumentParser(description="Weather forecast for BUSY Bar")
    p.add_argument("--host", default=DEFAULT_HOST,
                   help=f"BUSY Bar host (default: {DEFAULT_HOST})")
    p.add_argument("--city", default=DEFAULT_CITY,
                   help=f"display city name (default: {DEFAULT_CITY})")
    p.add_argument("--lat", type=float, default=DEFAULT_LAT,
                   help=f"latitude (default: {DEFAULT_LAT})")
    p.add_argument("--lon", type=float, default=DEFAULT_LON,
                   help=f"longitude (default: {DEFAULT_LON})")
    p.add_argument("--days", type=int, default=4,
                   help="number of forecast days including today, 1-7 (default: 4)")
    p.add_argument("--refresh", type=int, default=DEFAULT_REFRESH,
                   help=f"weather refresh seconds (default: {DEFAULT_REFRESH})")
    p.add_argument("--dwell", type=float, default=DEFAULT_DWELL,
                   help=f"seconds per screen (default: {DEFAULT_DWELL})")
    p.add_argument("--unit", choices=["celsius", "fahrenheit"], default="celsius",
                   help="temperature unit (default: celsius)")
    p.add_argument("--lang", choices=sorted(LANGUAGES), default="en",
                   help="display language: it, en, de, fr, es (default: en)")
    p.add_argument("--priority", type=int, default=30,
                   help="BUSY Bar display priority (default: 30)")
    p.add_argument("--test", action="store_true",
                   help="fetch weather, print it, draw one cycle, then exit")
    return p.parse_args()


def base_url(host):
    host = host.strip().rstrip("/")
    if host.startswith("http://") or host.startswith("https://"):
        return host
    return "http://" + host


def http_json(url, params, timeout=10):
    query = urllib.parse.urlencode(params)
    req = urllib.request.Request(
        url + "?" + query,
        headers={"User-Agent": "busybar-weather-forecast/1.0"},
    )
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def weather_kind(code):
    if code == 0:
        return "sun"
    if code in (1, 2):
        return "partly"
    if code == 3:
        return "cloud"
    if code in (45, 48):
        return "fog"
    if code in (51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82, 95, 96, 99):
        return "rain"
    if code in (71, 73, 75, 77, 85, 86):
        return "snow"
    return "cloud"


def weather_label(code, lang="en"):
    kind = weather_kind(code)
    return LANGUAGES[lang]["weather"][kind]


def fetch_weather(lat, lon, days, unit):
    payload = http_json(
        "https://api.open-meteo.com/v1/forecast",
        {
            "latitude": lat,
            "longitude": lon,
            "current": "temperature_2m,apparent_temperature,weather_code,wind_speed_10m",
            "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
            "temperature_unit": "fahrenheit" if unit == "fahrenheit" else "celsius",
            "wind_speed_unit": "kmh",
            "timezone": "auto",
            "forecast_days": max(1, min(7, days)),
        },
    )

    cur = payload["current"]
    daily = payload["daily"]
    out_days = []
    for i, date_s in enumerate(daily["time"]):
        date = dt.date.fromisoformat(date_s)
        out_days.append({
            "date": date,
            "code": int(daily["weather_code"][i]),
            "high": round(daily["temperature_2m_max"][i]),
            "low": round(daily["temperature_2m_min"][i]),
            "rain": round(daily["precipitation_probability_max"][i] or 0),
        })

    return {
        "current": {
            "temp": round(cur["temperature_2m"]),
            "feels": round(cur["apparent_temperature"]),
            "code": int(cur["weather_code"]),
            "wind": round(cur["wind_speed_10m"]),
        },
        "days": out_days,
    }


# ---------------------------------------------------------------------------
# Minimal 16x16 weather icons, generated as PNG with stdlib only.
# ---------------------------------------------------------------------------

BLACK = (0, 0, 0)
ICON_COLORS = {
    "sun": (255, 205, 55),
    "cloud": (180, 195, 205),
    "partly": (255, 205, 55),
    "rain": (80, 170, 255),
    "snow": (220, 245, 255),
    "fog": (150, 165, 175),
}


def blank_pixels():
    return [BLACK] * (16 * 16)


def px(buf, x, y, c):
    if 0 <= x < 16 and 0 <= y < 16:
        buf[y * 16 + x] = c


def rect(buf, x, y, w, h, c):
    for yy in range(y, y + h):
        for xx in range(x, x + w):
            px(buf, xx, yy, c)


def circle(buf, cx, cy, r, c):
    rr = r * r
    for y in range(cy - r, cy + r + 1):
        for x in range(cx - r, cx + r + 1):
            if (x - cx) ** 2 + (y - cy) ** 2 <= rr:
                px(buf, x, y, c)


def draw_cloud(buf, xoff=0, yoff=0, c=(180, 195, 205)):
    circle(buf, 6 + xoff, 8 + yoff, 3, c)
    circle(buf, 10 + xoff, 7 + yoff, 4, c)
    rect(buf, 3 + xoff, 8 + yoff, 11, 4, c)


def build_icon(kind):
    b = blank_pixels()
    if kind == "sun":
        c = ICON_COLORS["sun"]
        circle(b, 8, 8, 4, c)
        for x, y in ((8,1),(8,15),(1,8),(15,8),(3,3),(13,3),(3,13),(13,13)):
            px(b, x, y, c)
    elif kind == "cloud":
        draw_cloud(b)
    elif kind == "partly":
        sun = ICON_COLORS["sun"]
        circle(b, 5, 5, 3, sun)
        draw_cloud(b, 1, 2)
    elif kind == "rain":
        draw_cloud(b, 0, -2)
        c = ICON_COLORS["rain"]
        for x in (5, 9, 13):
            px(b, x, 12, c)
            px(b, x - 1, 13, c)
            px(b, x - 1, 14, c)
    elif kind == "snow":
        draw_cloud(b, 0, -2)
        c = ICON_COLORS["snow"]
        for x in (5, 10, 14):
            px(b, x, 12, c); px(b, x-1, 13, c); px(b, x+1, 13, c); px(b, x, 14, c)
    elif kind == "fog":
        c = ICON_COLORS["fog"]
        for y in (5, 8, 11):
            rect(b, 2, y, 12, 1, c)
    return b


def png_bytes(pixels, width=16, height=16):
    raw = bytearray()
    for y in range(height):
        raw.append(0)
        for x in range(width):
            r, g, b = pixels[y * width + x]
            raw += bytes((r, g, b, 255))

    def chunk(tag, data):
        c = tag + data
        return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)

    return (b"\x89PNG\r\n\x1a\n"
            + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
            + chunk(b"IDAT", zlib.compress(bytes(raw), 9))
            + chunk(b"IEND", b""))


def api_request(base, method, path, body=None, content_type=None, timeout=8):
    headers = {}
    if content_type:
        headers["Content-Type"] = content_type
    req = urllib.request.Request(base + path, data=body, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return r.getcode(), r.read()
    except urllib.error.HTTPError as e:
        return e.code, e.read()


def upload_icons(base):
    files = {}
    for kind in ("sun", "partly", "cloud", "fog", "rain", "snow"):
        name = f"weather_{kind}.png"
        qs = urllib.parse.urlencode({"application_name": APP, "file": name})
        status, body = api_request(
            base, "POST", "/api/assets/upload?" + qs,
            body=png_bytes(build_icon(kind)),
            content_type="application/octet-stream",
        )
        if status not in (200, 201, 204):
            raise RuntimeError(f"icon upload {kind}: HTTP {status} {body[:120]!r}")
        files[kind] = name
    return files


def text_el(el_id, text, x, y, font="small", color=WHITE, align="top_left", width=None):
    e = {
        "id": el_id,
        "type": "text",
        "text": str(text),
        "x": x,
        "y": y,
        "font": font,
        "color": color,
        "align": align,
    }
    if width is not None:
        e.update({
            "width": width,
            "scroll_rate": 500,
            "scroll_start_delay": 1200,
            "scroll_repeat_delay": 1200,
        })
    return e


def image_el(el_id, path):
    return {"id": el_id, "type": "image", "path": path, "x": 0, "y": 0}


def busy_draw(base, elements, priority):
    payload = json.dumps({
        "application_name": APP,
        "priority": priority,
        "elements": elements,
    }).encode("utf-8")
    status, body = api_request(base, "POST", "/api/display/draw", payload, "application/json")
    if status == 409:
        return False
    if status not in (200, 201, 204):
        raise RuntimeError(f"BUSY Bar draw HTTP {status}: {body[:200]!r}")
    return True


def busy_clear(base):
    qs = urllib.parse.urlencode({"application_name": APP})
    try:
        api_request(base, "DELETE", "/api/display/draw?" + qs)
    except Exception:
        pass


def temp_suffix(unit):
    return "F" if unit == "fahrenheit" else "C"


def current_elements(data, city, icons, unit, lang):
    c = data["current"]
    kind = weather_kind(c["code"])
    suffix = temp_suffix(unit)
    top = f"{city} {c['temp']}°{suffix}"
    tr = LANGUAGES[lang]
    bottom = f"{weather_label(c['code'], lang)}  {tr['wind']} {c['wind']}km/h"
    return [
        image_el("icon", icons[kind]),
        text_el("top", top, 18, 0, font="small", color=YELLOW, width=54),
        text_el("bottom", bottom, 18, 8, font="small", color=CYAN, width=54),
    ]


def forecast_elements(day, index, icons, unit, lang):
    kind = weather_kind(day["code"])
    suffix = temp_suffix(unit)
    tr = LANGUAGES[lang]
    if index == 0:
        day_name = tr["today"]
    elif index == 1:
        day_name = tr["tomorrow"]
    else:
        day_name = tr["weekdays"][day["date"].weekday()]
    top = f"{day_name} {day['high']}°/{day['low']}°{suffix}"
    bottom = f"{weather_label(day['code'], lang)} | {tr['rain']} {day['rain']}%"
    color = BLUE if kind in ("rain", "snow") else GRAY
    return [
        image_el("icon", icons[kind]),
        text_el("top", top, 18, 0, font="small", color=WHITE, width=54),
        text_el("bottom", bottom, 18, 8, font="small", color=color, width=54),
    ]


def print_weather(data, city, unit, lang):
    suffix = temp_suffix(unit)
    c = data["current"]
    tr = LANGUAGES[lang]
    print(f"{city}: {c['temp']}°{suffix}, {weather_label(c['code'], lang)}, {tr['wind'].lower()} {c['wind']} km/h")
    for i, d in enumerate(data["days"]):
        label = tr["today"] if i == 0 else tr["tomorrow"] if i == 1 else tr["weekdays"][d["date"].weekday()]
        print(f"  {label}: {d['low']}..{d['high']}°{suffix}, {weather_label(d['code'], lang)}, {tr['rain'].lower()} {d['rain']}%")


def main():
    args = parse_args()
    args.days = max(1, min(7, args.days))
    args.refresh = max(60, args.refresh)
    args.dwell = max(1.0, args.dwell)
    base = base_url(args.host)

    print(f"weather-forecast -> {base}")
    print(f"location: {args.city} ({args.lat:.4f}, {args.lon:.4f})")
    print("Ctrl-C to stop.")

    try:
        icons = upload_icons(base)
    except Exception as e:
        sys.exit(f"BUSY Bar setup failed: {e}")

    data = None
    last_fetch = 0.0

    try:
        while True:
            now = time.monotonic()
            if data is None or now - last_fetch >= args.refresh:
                try:
                    fresh = fetch_weather(args.lat, args.lon, args.days, args.unit)
                    data = fresh
                    last_fetch = now
                    print_weather(data, args.city, args.unit, args.lang)
                except Exception as e:
                    print(f"weather fetch failed: {e}")
                    if data is None:
                        time.sleep(10)
                        continue

            screens = [current_elements(data, args.city, icons, args.unit, args.lang)]
            screens.extend(forecast_elements(day, i, icons, args.unit, args.lang)
                           for i, day in enumerate(data["days"]))

            for elements in screens:
                try:
                    drawn = busy_draw(base, elements, args.priority)
                    if not drawn:
                        print("display busy (409), retrying next screen")
                except Exception as e:
                    print(f"draw failed: {e}")
                time.sleep(args.dwell)

            if args.test:
                break

    except KeyboardInterrupt:
        print("\nstopped.")
    finally:
        busy_clear(base)


if __name__ == "__main__":
    main()
View source on GitHub →