Lunar Phases

Lunar Phases

by @robynhub

Animated lunar phases for BUSY Bar, showing the current Moon phase with multilingual labels, automatic replay, and button-triggered animation.

pip: websockets moonastronomyambient

How to run

This app depends on websockets. Grab the app folder from GitHub, install once, then run:

pip install -r requirements.txt
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
"""Lunar Phases for BUSY Bar.

A small pixel-art Moon travels from right to left through a full lunar cycle,
then returns from left to right through the remaining phases. At the end it
parks in a presentation position and shows today's lunar phase in the selected
language.

The animation starts automatically every N seconds and can also be triggered
with the BUSY Bar START/top button.

Examples:
    python3 app.py
    python3 app.py --host 127.0.0.1:8080
    python3 app.py --lang it
    python3 app.py --auto-seconds 90 --fps 14
    python3 app.py --debug-phase full --font large

Dependency for physical controls only:
    pip install websockets

No busylib and no websocket-client are used.
"""

from __future__ import annotations

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

APP = "lunar-phases"
W, H = 72, 16
DEFAULT_HOST = "10.0.4.20"
DEFAULT_FPS = 12
DEFAULT_AUTO_SECONDS = 60.0

MOON_R = 6
MOON_DIAMETER = 13
TRAVEL_LEFT_X = -2
TRAVEL_RIGHT_X = W - MOON_DIAMETER + 2
PARK_X = 2
MOON_Y = 1

OUTBOUND_SECONDS = 2.6
RETURN_SECONDS = 2.6
PARK_SECONDS = 0.45
LABEL_FADE_SECONDS = 0.55

BLACK = (0, 0, 0)
SPACE = (1, 2, 7)
STAR_DIM = (45, 55, 85)
STAR_BRIGHT = (130, 150, 210)
MOON_DARK = (7, 10, 18)
MOON_EDGE = (62, 68, 82)
MOON_MID = (145, 149, 154)
MOON_LIGHT = (224, 224, 214)
MOON_HIGHLIGHT = (250, 247, 225)
TEXT_MAIN = (205, 215, 244)
TEXT_ACCENT = (123, 151, 255)

SYNODIC_MONTH_DAYS = 29.530588853
# Well-known new moon epoch, close enough for a display widget.
NEW_MOON_EPOCH = dt.datetime(2000, 1, 6, 18, 14, tzinfo=dt.timezone.utc)

PHASE_KEYS = (
    "new",
    "waxing_crescent",
    "first_quarter",
    "waxing_gibbous",
    "full",
    "waning_gibbous",
    "last_quarter",
    "waning_crescent",
)

TITLE_TRANSLATIONS = {
    "en": "LUNAR PHASES",
    "fr": "PHASES LUNAIRES",
    "de": "MONDPHASEN",
    "es": "FASES LUNARES",
    "nl": "MAANFASEN",
    "it": "FASI LUNARI",
}

EASTER_EGG_TRANSLATIONS = {
    "en": ["THAT'S NO", "MOON..."],
    "fr": ["CE N'EST PAS", "UNE LUNE..."],
    "de": ["DAS IST KEIN", "MOND..."],
    "es": ["ESO NO ES", "UNA LUNA..."],
    "nl": ["DAT IS GEEN", "MAAN..."],
    "it": ["QUELLA NON E'", "UNA LUNA..."],
}

TRANSLATIONS = {
    "en": {
        "new": "NEW MOON",
        "waxing_crescent": "WAXING CRESCENT",
        "first_quarter": "FIRST QUARTER",
        "waxing_gibbous": "WAXING GIBBOUS",
        "full": "FULL MOON",
        "waning_gibbous": "WANING GIBBOUS",
        "last_quarter": "LAST QUARTER",
        "waning_crescent": "WANING CRESCENT",
    },
    "fr": {
        "new": "NOUVELLE LUNE",
        "waxing_crescent": "PREMIER CROISSANT",
        "first_quarter": "PREMIER QUARTIER",
        "waxing_gibbous": "GIBBEUSE CROISSANTE",
        "full": "PLEINE LUNE",
        "waning_gibbous": "GIBBEUSE DECROISSANTE",
        "last_quarter": "DERNIER QUARTIER",
        "waning_crescent": "DERNIER CROISSANT",
    },
    "de": {
        "new": "NEUMOND",
        "waxing_crescent": "ZUNEHMENDE SICHEL",
        "first_quarter": "ERSTES VIERTEL",
        "waxing_gibbous": "ZUNEHMENDER MOND",
        "full": "VOLLMOND",
        "waning_gibbous": "ABNEHMENDER MOND",
        "last_quarter": "LETZTES VIERTEL",
        "waning_crescent": "ABNEHMENDE SICHEL",
    },
    "es": {
        "new": "LUNA NUEVA",
        "waxing_crescent": "CRECIENTE",
        "first_quarter": "CUARTO CRECIENTE",
        "waxing_gibbous": "GIBOSA CRECIENTE",
        "full": "LUNA LLENA",
        "waning_gibbous": "GIBOSA MENGUANTE",
        "last_quarter": "CUARTO MENGUANTE",
        "waning_crescent": "MENGUANTE",
    },
    "nl": {
        "new": "NIEUWE MAAN",
        "waxing_crescent": "WASSENDE SIKKEL",
        "first_quarter": "EERSTE KWARTIER",
        "waxing_gibbous": "WASSENDE MAAN",
        "full": "VOLLE MAAN",
        "waning_gibbous": "AFNEMENDE MAAN",
        "last_quarter": "LAATSTE KWARTIER",
        "waning_crescent": "AFNEMENDE SIKKEL",
    },
    "it": {
        "new": "LUNA NUOVA",
        "waxing_crescent": "LUNA CRESCENTE",
        "first_quarter": "PRIMO QUARTO",
        "waxing_gibbous": "GIBBOSA CRESCENTE",
        "full": "LUNA PIENA",
        "waning_gibbous": "GIBBOSA CALANTE",
        "last_quarter": "ULTIMO QUARTO",
        "waning_crescent": "LUNA CALANTE",
    },
}

# Readable pixel fonts for the final phase label. Unsupported accented
# letters are transliterated by normalize_text().
LARGE_FONT = {
    "A": ("01110", "10001", "10001", "11111", "10001", "10001", "10001"),
    "B": ("11110", "10001", "10001", "11110", "10001", "10001", "11110"),
    "C": ("01111", "10000", "10000", "10000", "10000", "10000", "01111"),
    "D": ("11110", "10001", "10001", "10001", "10001", "10001", "11110"),
    "E": ("11111", "10000", "10000", "11110", "10000", "10000", "11111"),
    "F": ("11111", "10000", "10000", "11110", "10000", "10000", "10000"),
    "G": ("01111", "10000", "10000", "10111", "10001", "10001", "01111"),
    "H": ("10001", "10001", "10001", "11111", "10001", "10001", "10001"),
    "I": ("11111", "00100", "00100", "00100", "00100", "00100", "11111"),
    "J": ("00111", "00010", "00010", "00010", "10010", "10010", "01100"),
    "K": ("10001", "10010", "10100", "11000", "10100", "10010", "10001"),
    "L": ("10000", "10000", "10000", "10000", "10000", "10000", "11111"),
    "M": ("10001", "11011", "10101", "10101", "10001", "10001", "10001"),
    "N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
    "O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
    "P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
    "Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
    "R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
    "S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
    "T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
    "U": ("10001", "10001", "10001", "10001", "10001", "10001", "01110"),
    "V": ("10001", "10001", "10001", "10001", "10001", "01010", "00100"),
    "W": ("10001", "10001", "10001", "10101", "10101", "10101", "01010"),
    "X": ("10001", "10001", "01010", "00100", "01010", "10001", "10001"),
    "Y": ("10001", "10001", "01010", "00100", "00100", "00100", "00100"),
    "Z": ("11111", "00001", "00010", "00100", "01000", "10000", "11111"),
    "-": ("00000", "00000", "00000", "11111", "00000", "00000", "00000"),
    " ": ("00000", "00000", "00000", "00000", "00000", "00000", "00000"),
}


SMALL_FONT = {
    "A": ("010", "101", "111", "101", "101"),
    "B": ("110", "101", "110", "101", "110"),
    "C": ("011", "100", "100", "100", "011"),
    "D": ("110", "101", "101", "101", "110"),
    "E": ("111", "100", "110", "100", "111"),
    "F": ("111", "100", "110", "100", "100"),
    "G": ("011", "100", "101", "101", "011"),
    "H": ("101", "101", "111", "101", "101"),
    "I": ("111", "010", "010", "010", "111"),
    "J": ("001", "001", "001", "101", "010"),
    "K": ("101", "101", "110", "101", "101"),
    "L": ("100", "100", "100", "100", "111"),
    "M": ("101", "111", "111", "101", "101"),
    "N": ("101", "111", "111", "111", "101"),
    "O": ("010", "101", "101", "101", "010"),
    "P": ("110", "101", "110", "100", "100"),
    "Q": ("010", "101", "101", "011", "001"),
    "R": ("110", "101", "110", "101", "101"),
    "S": ("011", "100", "010", "001", "110"),
    "T": ("111", "010", "010", "010", "010"),
    "U": ("101", "101", "101", "101", "111"),
    "V": ("101", "101", "101", "101", "010"),
    "W": ("101", "101", "111", "111", "101"),
    "X": ("101", "101", "010", "101", "101"),
    "Y": ("101", "101", "010", "010", "010"),
    "Z": ("111", "001", "010", "100", "111"),
    "-": ("000", "000", "111", "000", "000"),
    " ": ("000", "000", "000", "000", "000"),
}

FONTS = {
    "small": {"glyphs": SMALL_FONT, "advance": 4, "height": 5},
    "large": {"glyphs": LARGE_FONT, "advance": 6, "height": 7},
}

STARS = (
    (8, 2, 0), (21, 12, 1), (31, 4, 2), (45, 1, 1),
    (58, 11, 0), (68, 5, 2), (13, 8, 2), (52, 7, 0),
)


def parse_args():
    p = argparse.ArgumentParser(description="Animated lunar phases for BUSY Bar")
    p.add_argument("--host", default=DEFAULT_HOST, help="BUSY Bar host ip[:port]")
    p.add_argument(
        "--lang", dest="language", choices=sorted(TRANSLATIONS), default="en",
        help="display language: en, fr, de, es, nl, it (default: en)",
    )
    p.add_argument(
        "--auto-seconds", type=float, default=DEFAULT_AUTO_SECONDS,
        help=f"automatically replay every N seconds; 0 disables (default: {DEFAULT_AUTO_SECONDS:g})",
    )
    p.add_argument(
        "--fps", type=int, default=DEFAULT_FPS,
        help=f"animation frame rate (default: {DEFAULT_FPS})",
    )
    p.add_argument(
        "--font", choices=sorted(FONTS), default="small",
        help="pixel font: small (3x5, default) or large (5x7)",
    )
    p.add_argument(
        "--debug-phase", choices=("current",) + PHASE_KEYS, default="current",
        help="force a lunar phase for testing instead of using the current phase",
    )
    p.add_argument(
        "--easter-egg", action="store_true",
        help="replace the final lunar phase with a Death Star easter egg",
    )
    p.add_argument(
        "--demo", action="store_true",
        help="same as --auto-seconds 8; useful with the emulator",
    )
    return p.parse_args()


def _base(host):
    host = host.replace("http://", "").replace("https://", "").rstrip("/")
    return "http://" + host


def _ws_url(host):
    raw = host.rstrip("/")
    if "://" not in raw:
        raw = "http://" + raw
    parsed = urllib.parse.urlparse(raw)
    scheme = "wss" if parsed.scheme == "https" else "ws"
    return urllib.parse.urlunparse((scheme, parsed.netloc, "/api/status/ws", "", "", ""))


def _png(pixels):
    raw = bytearray()
    for y in range(H):
        raw.append(0)
        for x in range(W):
            r, g, b = pixels[y * W + 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", W, H, 8, 6, 0, 0, 0))
        + chunk(b"IDAT", zlib.compress(bytes(raw), 5))
        + chunk(b"IEND", b"")
    )


_RING = 5
_frame_no = 0


def _post(host, path, data, content_type):
    req = urllib.request.Request(
        _base(host) + path,
        data=data,
        method="POST",
        headers={"Content-Type": content_type},
    )
    with urllib.request.urlopen(req, timeout=5) as r:
        return r.getcode()


def show(host, pixels):
    global _frame_no
    fn = "frame%d.png" % (_frame_no % _RING)
    _frame_no += 1
    try:
        q = urllib.parse.urlencode({"application_name": APP, "file": fn})
        _post(host, "/api/assets/upload?" + q, _png(pixels), "application/octet-stream")
        body = {
            "application_name": APP,
            "priority": 30,
            "elements": [{"id": "frame", "type": "image", "path": fn, "x": 0, "y": 0}],
        }
        return _post(host, "/api/display/draw", json.dumps(body).encode(), "application/json")
    except urllib.error.HTTPError as exc:
        if exc.code == 409:
            return 409
        raise


def clear(host):
    q = urllib.parse.urlencode({"application_name": APP})
    req = urllib.request.Request(_base(host) + "/api/display/draw?" + q, method="DELETE")
    try:
        with urllib.request.urlopen(req, timeout=5):
            pass
    except (urllib.error.URLError, urllib.error.HTTPError):
        pass


def delete_assets(host):
    q = urllib.parse.urlencode({"application_name": APP})
    req = urllib.request.Request(_base(host) + "/api/assets/upload?" + q, method="DELETE")
    try:
        with urllib.request.urlopen(req, timeout=5):
            pass
    except (urllib.error.URLError, urllib.error.HTTPError):
        pass


# ---------------------------------------------------------------------------
# Minimal protobuf decoder for /api/status/ws
# ---------------------------------------------------------------------------

def _read_varint(buf, pos):
    value = 0
    shift = 0
    while pos < len(buf):
        b = buf[pos]
        pos += 1
        value |= (b & 0x7F) << shift
        if not (b & 0x80):
            return value, pos
        shift += 7
        if shift > 63:
            raise ValueError("protobuf varint too long")
    raise ValueError("truncated protobuf varint")


def _fields(buf):
    pos = 0
    while pos < len(buf):
        tag, pos = _read_varint(buf, pos)
        field_no, wire = tag >> 3, tag & 7
        if wire == 0:
            value, pos = _read_varint(buf, pos)
            yield field_no, wire, value
        elif wire == 1:
            pos += 8
        elif wire == 2:
            n, pos = _read_varint(buf, pos)
            end = pos + n
            if end > len(buf):
                return
            yield field_no, wire, buf[pos:end]
            pos = end
        elif wire == 5:
            pos += 4
        else:
            return


def _is_start_press(raw):
    """Decode a physical START/top-button press.

    BUSY firmware revisions seen in the existing apps used two protobuf enum/
    oneof layouts. This decoder accepts both known layouts while rejecting the
    encoder form, so the app remains usable across those revisions.
    """
    try:
        for sf, sw, update in _fields(raw):
            if sf != 2 or sw != 2:  # State.updates
                continue
            for uf, uw, input_event in _fields(update):
                if uf != 11 or uw != 2:  # StateUpdate.input
                    continue
                for inf, inw, payload in _fields(input_event):
                    if inw != 2 or inf not in (1, 3):
                        continue
                    vals = {f: v for f, w, v in _fields(payload) if w == 0}
                    button = int(vals.get(1, 0))
                    action = int(vals.get(2, 0))

                    # Current schema: button_event field 1, START=2, PRESS=0.
                    if inf == 1 and button == 2 and action == 0:
                        return True

                    # Older observed schema: button_event field 3, START=0,
                    # PRESS=0. Encoder field 3 has a non-zero delta in field 1,
                    # so only an empty/zero payload is accepted here.
                    if inf == 3 and button == 0 and action == 0:
                        return True
    except Exception:
        return False
    return False


class ButtonListener:
    def __init__(self, host):
        self.host = host
        self.events = queue.Queue()
        self.stop_event = threading.Event()
        self.thread = threading.Thread(target=self._run, daemon=True)
        self.available = True
        self.error = None

    def start(self):
        try:
            import websockets  # noqa: F401
        except ImportError as exc:
            self.available = False
            self.error = f"websockets not installed: {exc}"
            return
        self.thread.start()

    def stop(self):
        self.stop_event.set()

    def poll_press(self):
        pressed = False
        try:
            while True:
                self.events.get_nowait()
                pressed = True
        except queue.Empty:
            return pressed

    def _run(self):
        try:
            asyncio.run(self._listen_forever())
        except Exception as exc:
            if not self.stop_event.is_set():
                self.available = False
                self.error = str(exc)
                print(f"[ws] controls unavailable: {exc}")

    async def _listen_forever(self):
        import websockets

        backoff = 1.0
        connected_once = False
        while not self.stop_event.is_set():
            try:
                async with websockets.connect(
                    _ws_url(self.host),
                    max_size=4 * 1024 * 1024,
                    ping_interval=20,
                    ping_timeout=20,
                ) as ws:
                    await ws.send(json.dumps({"enable": True}))
                    print("[ws] reconnected" if connected_once else "[ws] connected")
                    connected_once = True
                    backoff = 1.0
                    async for message in ws:
                        if self.stop_event.is_set():
                            return
                        if isinstance(message, str):
                            continue
                        if _is_start_press(bytes(message)):
                            self.events.put(time.monotonic())
            except Exception as exc:
                if self.stop_event.is_set():
                    return
                print(f"[ws] disconnected: {exc}; reconnecting")
                await asyncio.sleep(backoff)
                backoff = min(8.0, backoff * 1.7)


# ---------------------------------------------------------------------------
# Lunar math
# ---------------------------------------------------------------------------

def lunar_phase_fraction(now=None):
    """Return phase age normalized to [0,1): 0=new, 0.5=full."""
    now = now or dt.datetime.now(dt.timezone.utc)
    if now.tzinfo is None:
        now = now.replace(tzinfo=dt.timezone.utc)
    days = (now.astimezone(dt.timezone.utc) - NEW_MOON_EPOCH).total_seconds() / 86400.0
    return (days / SYNODIC_MONTH_DAYS) % 1.0


def phase_key(phase):
    idx = int(math.floor((phase * 8.0) + 0.5)) % 8
    return PHASE_KEYS[idx]


def configured_phase(debug_phase):
    """Return the real phase, or an exact canonical phase when debugging."""
    if debug_phase == "current":
        return lunar_phase_fraction()
    return PHASE_KEYS.index(debug_phase) / 8.0


# ---------------------------------------------------------------------------
# Pixel rendering
# ---------------------------------------------------------------------------

def blank():
    return [SPACE] * (W * H)


def px(buf, x, y, color):
    if 0 <= x < W and 0 <= y < H:
        buf[y * W + x] = color


def blend(a, b, t):
    t = max(0.0, min(1.0, t))
    return tuple(int(a[i] + (b[i] - a[i]) * t + 0.5) for i in range(3))


def scale(c, amount):
    amount = max(0.0, min(1.0, amount))
    return tuple(int(v * amount + 0.5) for v in c)


def draw_stars(buf, t, dim_for_text=False):
    for x, y, phase in STARS:
        pulse = 0.5 + 0.5 * math.sin(t * 1.8 + phase * 2.1)
        col = blend(STAR_DIM, STAR_BRIGHT, pulse * (0.45 if dim_for_text else 0.75))
        px(buf, x, y, col)
        if pulse > 0.88 and not dim_for_text:
            px(buf, x + 1, y, scale(col, 0.55))


def moon_surface_color(nx, ny):
    # Radial shading plus deterministic tiny "maria" patches.
    radial = max(0.0, 1.0 - math.sqrt(nx * nx + ny * ny))
    base = blend(MOON_MID, MOON_LIGHT, 0.35 + radial * 0.65)
    patch = (
        math.sin(nx * 8.1 + ny * 5.7)
        + math.sin(nx * 13.2 - ny * 9.3) * 0.55
        + math.cos(nx * 5.1 + ny * 12.7) * 0.35
    )
    if patch > 1.0:
        base = blend(base, MOON_MID, min(0.42, (patch - 1.0) * 0.22))
    return base


def is_illuminated(nx, ny, phase):
    """Orthographic sphere lighting for a phase fraction.

    Observer is +Z. Sun direction rotates in the X/Z plane. The sign convention
    produces a bright right-hand crescent while waxing and a bright left-hand
    crescent while waning.
    """
    rr = nx * nx + ny * ny
    if rr > 1.0:
        return False, -1.0
    nz = math.sqrt(max(0.0, 1.0 - rr))
    angle = math.tau * phase
    sx = math.sin(angle)
    sz = -math.cos(angle)
    dot = nx * sx + nz * sz
    return dot > -0.03, dot


def draw_moon(buf, x, phase, shimmer=0.0):
    cx = x + MOON_R
    cy = MOON_Y + MOON_R
    for yy in range(cy - MOON_R, cy + MOON_R + 1):
        for xx in range(cx - MOON_R, cx + MOON_R + 1):
            dx = xx - cx
            dy = yy - cy
            rr = dx * dx + dy * dy
            if rr > MOON_R * MOON_R:
                continue
            nx = dx / MOON_R
            ny = dy / MOON_R
            lit, dot = is_illuminated(nx, ny, phase)
            edge = math.sqrt(rr) / MOON_R
            if edge > 0.90:
                col = MOON_EDGE
            elif lit:
                col = moon_surface_color(nx, ny)
                # A very slight brightness modulation makes the moving moon feel alive.
                col = blend(col, MOON_HIGHLIGHT, max(0.0, dot) * 0.14 + shimmer * 0.05)
            else:
                # Keep the unlit side faintly visible, like earthshine, rather than black.
                earthshine = max(0.0, 0.18 - edge * 0.05)
                col = blend(MOON_DARK, MOON_EDGE, earthshine)
            px(buf, xx, yy, col)


def normalize_text(text, font_name="large"):
    repl = {
        "À": "A", "Á": "A", "Â": "A", "Ä": "A", "Ã": "A",
        "È": "E", "É": "E", "Ê": "E", "Ë": "E",
        "Ì": "I", "Í": "I", "Î": "I", "Ï": "I",
        "Ò": "O", "Ó": "O", "Ô": "O", "Ö": "O",
        "Ù": "U", "Ú": "U", "Û": "U", "Ü": "U",
        "Ç": "C", "Ñ": "N", "ß": "SS",
    }
    s = text.upper()
    out = "".join(repl.get(ch, ch) for ch in s)
    glyphs = FONTS[font_name]["glyphs"]
    return "".join(ch if ch in glyphs else " " for ch in out)


def text_width(text, font_name="large"):
    advance = FONTS[font_name]["advance"]
    return max(0, len(normalize_text(text, font_name)) * advance - 1)


def draw_text(buf, text, x, y, color, clip_x0=0, clip_x1=W, font_name="large"):
    pen = x
    glyphs = FONTS[font_name]["glyphs"]
    advance = FONTS[font_name]["advance"]
    for ch in normalize_text(text, font_name):
        glyph = glyphs.get(ch, glyphs[" "])
        for gy, row in enumerate(glyph):
            for gx, bit in enumerate(row):
                xx = pen + gx
                if bit == "1" and clip_x0 <= xx < clip_x1:
                    px(buf, xx, y + gy, color)
        pen += advance


def split_label(text, max_width, font_name="large"):
    words = normalize_text(text, font_name).split()
    if not words:
        return [""]
    lines = []
    for word in words:
        if not lines:
            lines.append(word)
            continue
        candidate = lines[-1] + " " + word
        if text_width(candidate, font_name) <= max_width:
            lines[-1] = candidate
        elif len(lines) < 2:
            lines.append(word)
        else:
            lines[-1] += " " + word
    return lines[:2]


def draw_phase_label(buf, text, alpha, elapsed, font_name="small"):
    x0, x1 = 18, 72
    width = x1 - x0
    lines = split_label(text, width, font_name)
    col = scale(TEXT_MAIN, alpha)

    # No decorative accent line here: on the 16px-high panel it could look
    # like an unwanted blue/purple display artefact.
    if font_name == "small":
        ys = [5] if len(lines) == 1 else [2, 9]
    else:
        ys = [4] if len(lines) == 1 else [0, 9]
    for line, y in zip(lines, ys):
        tw = text_width(line, font_name)
        if tw <= width:
            x = x0 + (width - tw) // 2
        else:
            overflow = tw - width
            cycle = max(1.0, 2.0 * (overflow + 8))
            p = (elapsed * 9.0) % cycle
            offset = int(p if p <= overflow + 8 else cycle - p)
            x = x0 + 3 - offset
        draw_text(buf, line, x, y, col, x0, x1, font_name)


def ease(t):
    t = max(0.0, min(1.0, t))
    return 0.5 - 0.5 * math.cos(math.pi * t)


def travel_phase(progress, outbound):
    # One full visual lunar cycle over the round trip: 0->0.5 outbound,
    # 0.5->1.0 on return. That exposes every canonical phase in order.
    return progress * 0.5 if outbound else 0.5 + progress * 0.5


def draw_sweep_title(buf, language, moon_x, direction, font_name="small"):
    """Draw the localized app title as if the moving moon wipes/writes it.

    On the right-to-left leg only the pixels still ahead of the moon remain,
    so the title is progressively erased. On the left-to-right leg the same
    clipping boundary expands, making the moon appear to write the title back.
    If the selected font is too wide for the 72px panel, fall back to small.
    """
    text = TITLE_TRANSLATIONS[language]
    chosen = font_name
    if text_width(text, chosen) > W - 4:
        chosen = "small"

    tw = text_width(text, chosen)
    x = (W - tw) // 2
    y = 5 if chosen == "small" else 4

    # The moon's visual center is the moving wipe edge. A tiny overlap keeps
    # the title hidden under the lunar disk instead of visibly touching it.
    wipe_x = int(round(moon_x + MOON_R - 1))
    if direction == "left":
        clip_x0, clip_x1 = 0, max(0, min(W, wipe_x))
    else:
        clip_x0, clip_x1 = 0, max(0, min(W, wipe_x))

    draw_text(buf, text, x, y, TEXT_MAIN, clip_x0, clip_x1, chosen)


def build_animation_frame(elapsed, current_phase, language="en", font_name="small"):
    total_travel = OUTBOUND_SECONDS + RETURN_SECONDS
    buf = blank()
    draw_stars(buf, elapsed)

    if elapsed < OUTBOUND_SECONDS:
        # First pass: keep the title completely hidden. The reveal starts only
        # after the moon has reached the left edge once.
        p = ease(elapsed / OUTBOUND_SECONDS)
        x = int(round(TRAVEL_RIGHT_X + (TRAVEL_LEFT_X - TRAVEL_RIGHT_X) * p))
        phase = travel_phase(p, True)
        draw_moon(buf, x, phase, shimmer=0.5 + 0.5 * math.sin(elapsed * 8.0))
        return buf, False

    if elapsed < total_travel:
        local = elapsed - OUTBOUND_SECONDS
        p = ease(local / RETURN_SECONDS)
        x = int(round(TRAVEL_LEFT_X + (TRAVEL_RIGHT_X - TRAVEL_LEFT_X) * p))
        phase = travel_phase(p, False) % 1.0
        draw_sweep_title(buf, language, x, "right", font_name)
        draw_moon(buf, x, phase, shimmer=0.5 + 0.5 * math.sin(elapsed * 8.0))
        return buf, False

    # After touching the right edge, make a short elegant parking glide to the
    # left-side information-card position, while morphing to today's real phase.
    local = elapsed - total_travel
    if local < PARK_SECONDS:
        p = ease(local / PARK_SECONDS)
        x = int(round(TRAVEL_RIGHT_X + (PARK_X - TRAVEL_RIGHT_X) * p))
        # On this final right-to-left glide the moon wipes the title away,
        # mirroring the reveal performed on the return leg.
        draw_sweep_title(buf, language, x, "left", font_name)
        # shortest circular interpolation from new-moon endpoint to current phase
        phase = current_phase * p
        draw_moon(buf, x, phase, shimmer=0.2)
        return buf, False

    return final_frame(current_phase, elapsed - total_travel - PARK_SECONDS, language, font_name), True



def draw_death_star(buf, x=PARK_X):
    """Draw a compact 13px Death Star inspired pixel-art sphere."""
    cx = x + MOON_R
    cy = MOON_Y + MOON_R
    for yy in range(cy - MOON_R, cy + MOON_R + 1):
        for xx in range(cx - MOON_R, cx + MOON_R + 1):
            dx, dy = xx - cx, yy - cy
            rr = dx * dx + dy * dy
            if rr > MOON_R * MOON_R:
                continue
            edge = math.sqrt(rr) / MOON_R
            col = (88, 94, 104) if edge < 0.86 else (48, 53, 62)
            if (xx + yy) % 5 == 0:
                col = (112, 118, 126)
            px(buf, xx, yy, col)
    # Equatorial trench.
    for xx in range(cx - 5, cx + 6):
        px(buf, xx, cy + 1, (30, 34, 42))
    # Superlaser dish in the upper-right quadrant.
    dish_cx, dish_cy = cx + 2, cy - 2
    for yy in range(dish_cy - 2, dish_cy + 3):
        for xx in range(dish_cx - 2, dish_cx + 3):
            if (xx - dish_cx) ** 2 + (yy - dish_cy) ** 2 <= 4:
                px(buf, xx, yy, (34, 39, 48))
    px(buf, dish_cx, dish_cy, (150, 158, 166))


def easter_egg_frame(elapsed, language="en", font_name="small"):
    buf = blank()
    draw_stars(buf, elapsed, dim_for_text=True)
    draw_death_star(buf)
    # Localized Star Wars easter-egg quote, split for the 72x16 display.
    col = scale(TEXT_MAIN, min(1.0, elapsed / LABEL_FADE_SECONDS))
    lines = EASTER_EGG_TRANSLATIONS[language]
    chosen = "small" if text_width(lines[0], font_name) > 54 else font_name
    ys = [2, 9] if chosen == "small" else [0, 9]
    for line, y in zip(lines, ys):
        x0 = 18
        tw = text_width(line, chosen)
        x = x0 + max(0, (54 - tw) // 2)
        draw_text(buf, line, x, y, col, x0, W, chosen)
    return buf


def final_frame(current_phase, label_elapsed, language="en", font_name="small", easter_egg=False):
    if easter_egg:
        return easter_egg_frame(label_elapsed, language, font_name)
    buf = blank()
    draw_stars(buf, label_elapsed, dim_for_text=True)
    draw_moon(buf, PARK_X, current_phase, shimmer=0.15)
    label = TRANSLATIONS[language][phase_key(current_phase)]
    alpha = min(1.0, max(0.0, label_elapsed / LABEL_FADE_SECONDS))
    draw_phase_label(buf, label, alpha, label_elapsed, font_name)
    return buf


def main():
    args = parse_args()
    args.fps = max(6, min(20, args.fps))
    if args.demo:
        args.auto_seconds = 8.0
    args.auto_seconds = max(0.0, args.auto_seconds)

    listener = ButtonListener(args.host)
    listener.start()
    if not listener.available:
        print(f"[ws] physical button disabled: {listener.error}")

    current_phase = configured_phase(args.debug_phase)
    current_key = phase_key(current_phase)
    print(
        f"lunar-phases -> {_base(args.host)}  language={args.language}  "
        f"phase={current_key}  font={args.font}  debug={args.debug_phase}  easter_egg={args.easter_egg}  (Ctrl-C to stop)"
    )
    print("Press the top/START button to replay the lunar cycle.")

    active = True  # play once on startup
    started = time.monotonic()
    last_animation_start = started
    last_frame_at = 0.0
    phase_refresh_at = time.monotonic()

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

            if listener.poll_press():
                active = True
                started = now
                last_animation_start = now
                current_phase = configured_phase(args.debug_phase)
                current_key = phase_key(current_phase)
                print(f"trigger: button -> {current_key}")

            if args.auto_seconds > 0 and not active and now - last_animation_start >= args.auto_seconds:
                active = True
                started = now
                last_animation_start = now
                current_phase = configured_phase(args.debug_phase)
                current_key = phase_key(current_phase)
                print(f"trigger: auto -> {current_key}")

            # Refresh the displayed real phase periodically even when idle.
            if now - phase_refresh_at >= 900:
                current_phase = configured_phase(args.debug_phase)
                current_key = phase_key(current_phase)
                phase_refresh_at = now

            frame_interval = 1.0 / args.fps
            if now - last_frame_at >= frame_interval:
                if active:
                    frame, finished = build_animation_frame(now - started, current_phase, args.language, args.font)
                    # build_animation_frame uses English only for its final fallback;
                    # replace the final frame with the selected language.
                    if finished:
                        label_elapsed = now - started - OUTBOUND_SECONDS - RETURN_SECONDS - PARK_SECONDS
                        frame = final_frame(current_phase, label_elapsed, args.language, args.font, args.easter_egg)
                        active = False
                    show(args.host, frame)
                else:
                    # Redrawing this slowly keeps the stars gently twinkling without
                    # hammering the device while the app is idle.
                    show(args.host, final_frame(current_phase, now - started, args.language, args.font, args.easter_egg))
                last_frame_at = now

            time.sleep(0.008 if active else 0.08)

    except KeyboardInterrupt:
        print("\nstopped.")
    except urllib.error.URLError as exc:
        sys.exit(f"error: cannot reach {_base(args.host)} - {exc.reason}")
    finally:
        listener.stop()
        clear(args.host)
        delete_assets(args.host)


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