From 2c53b4fd2dfe3a10282a3df3c0db70f29bb6a996 Mon Sep 17 00:00:00 2001 From: acidburns Date: Tue, 30 Jun 2026 12:41:41 +0200 Subject: [PATCH] initial mp90 panel renderer Add the MP90 front panel clock/weather renderer, systemd service, ignore rules, and deployment documentation. --- .gitignore | 7 ++ README.md | 109 +++++++++++++++++++ mp90-panel.service | 15 +++ mp90_panel.py | 262 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 393 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 mp90-panel.service create mode 100644 mp90_panel.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57fbbc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +dist/ +build/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ea204b --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# MP90 Panel + +Clock and weather renderer for the MP90 front TFT. + +Panel photo: add `docs/mp90-panel.jpg` when publishing the hardware photo. + +## What It Does + +`mp90_panel.py` drives the MP90 front display over USB HID and continuously renders a portrait dashboard: + +- current time in the `Europe/Berlin` timezone +- current date +- tomorrow's Mannheim forecast for 09:00 and 17:00 +- temperature, weather condition, and precipitation probability + +Weather data comes from the Open-Meteo forecast API for Mannheim. The script refreshes weather every 15 minutes, redraws the display once per minute, and sends a heartbeat packet to the panel between redraws. + +## Hardware + +The code targets the MP90 front TFT exposed as a Holtek USB HID display. + +Current local device path: + +```text +1-8:1.1 +``` + +The display is treated as a `320x170` RGB565 framebuffer. The script switches the panel to portrait mode and sends framebuffer chunks using the panel's HID command protocol. + +## Requirements + +- Linux with access to the MP90 HID device +- Python 3.14 as installed by Linuxbrew at `/home/linuxbrew/.linuxbrew/bin/python3` +- Python packages: + - `hid` + - `Pillow` + - `requests` +- DejaVu fonts: + - `/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf` + - `/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf` + +The included systemd unit currently runs as `root`, which avoids HID permission setup. For a non-root deployment, add a udev rule for the Holtek HID device and adjust the service user. + +## Run Once + +From this directory: + +```bash +/home/linuxbrew/.linuxbrew/bin/python3 ./mp90_panel.py --once +``` + +This fetches the forecast, draws one frame, sends it to the panel, and exits. + +## Run Continuously + +```bash +/home/linuxbrew/.linuxbrew/bin/python3 ./mp90_panel.py +``` + +The script reconnects after HID or network failures and keeps retrying until stopped. + +## Deploy With Systemd + +Copy or link the service file: + +```bash +sudo cp mp90-panel.service /etc/systemd/system/mp90-panel.service +sudo systemctl daemon-reload +sudo systemctl enable --now mp90-panel.service +``` + +Check logs: + +```bash +journalctl -u mp90-panel.service -f +``` + +Restart after code changes: + +```bash +sudo systemctl restart mp90-panel.service +``` + +## Service Notes + +`mp90-panel.service` expects this checkout at: + +```text +/home/acidburns/.openclaw/workspace/mp90-panel +``` + +It also sets `PYTHONPATH` to the local Python 3.14 package directories used on the MP90 host. Update the unit if the checkout or Python environment moves. + +## Configuration + +Most settings are constants near the top of `mp90_panel.py`: + +- `HID_PATH`: USB HID interface path +- `MANNHEIM`: latitude/longitude for weather lookup +- `TZ`: display timezone +- `OPEN_METEO_URL`: forecast API endpoint +- `FONT_REGULAR` / `FONT_BOLD`: font paths + +## Troubleshooting + +- `OSError` on HID open: confirm the panel path with `lsusb`, `hid.enumerate()`, or `/sys/bus/hid/devices`, then update `HID_PATH`. +- Blank or stale panel: restart the service and check `journalctl -u mp90-panel.service`. +- Weather fetch failures: confirm network access and Open-Meteo availability. +- Text rendering errors: install DejaVu fonts or update the font constants. diff --git a/mp90-panel.service b/mp90-panel.service new file mode 100644 index 0000000..589eeb5 --- /dev/null +++ b/mp90-panel.service @@ -0,0 +1,15 @@ +[Unit] +Description=MP90 front panel clock and weather +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +Environment=PYTHONPATH=/home/acidburns/.local/lib/python3.14/site-packages:/home/linuxbrew/.linuxbrew/lib/python3.14/site-packages +ExecStart=/home/linuxbrew/.linuxbrew/bin/python3 /home/acidburns/.openclaw/workspace/mp90-panel/mp90_panel.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/mp90_panel.py b/mp90_panel.py new file mode 100644 index 0000000..f58ab61 --- /dev/null +++ b/mp90_panel.py @@ -0,0 +1,262 @@ +#!/home/linuxbrew/.linuxbrew/bin/python3 +"""Render a clock and Mannheim weather forecast on the MP90 front TFT.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import logging +import signal +import time +from dataclasses import dataclass +from typing import Iterable +from zoneinfo import ZoneInfo + +import hid +import requests +from PIL import Image, ImageDraw, ImageFont + + +WIDTH = 320 +HEIGHT = 170 +PORTRAIT_WIDTH = HEIGHT +PORTRAIT_HEIGHT = WIDTH +FRAMEBUFFER_BYTES = WIDTH * HEIGHT * 2 +DATA_BYTES = 4096 +REPORT_BYTES = 1 +HEADER_BYTES = 8 +PACKET_BYTES = REPORT_BYTES + HEADER_BYTES + DATA_BYTES +FINAL_CHUNK = 26 +TZ = ZoneInfo("Europe/Berlin") +HID_PATH = b"1-8:1.1" +FONT_REGULAR = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" +MANNHEIM = (49.4891, 8.46694) + + +@dataclass(frozen=True) +class Forecast: + target: dt.datetime + temperature: float + weather_code: int + precipitation_probability: int + + +WEATHER_TEXT = { + 0: "Clear", + 1: "Mostly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Fog", + 51: "Light drizzle", + 53: "Drizzle", + 55: "Heavy drizzle", + 61: "Light rain", + 63: "Rain", + 65: "Heavy rain", + 71: "Light snow", + 73: "Snow", + 75: "Heavy snow", + 80: "Showers", + 81: "Showers", + 82: "Heavy showers", + 95: "Thunderstorm", + 96: "Thunderstorm", + 99: "Thunderstorm", +} + + +def packet(header: bytes, data: bytes = b"") -> bytes: + if len(header) != HEADER_BYTES: + raise ValueError("panel headers must be exactly 8 bytes") + if len(data) > DATA_BYTES: + raise ValueError("panel payload exceeds 4096 bytes") + return bytes(REPORT_BYTES) + header + data + bytes(DATA_BYTES - len(data)) + + +def checked_write(device: hid.device, payload: bytes) -> None: + written = device.write(payload) + if written != len(payload): + raise OSError(f"short HID write: expected {len(payload)}, got {written}") + + +def set_portrait(device: hid.device) -> None: + checked_write(device, packet(bytes((0x55, 0xA1, 0xF1, 0x02, 0, 0, 0, 0)))) + + +def heartbeat(device: hid.device, now: dt.datetime) -> None: + header = bytes((0x55, 0xA1, 0xF2, now.hour, now.minute, now.second, 0, 0)) + checked_write(device, packet(header)) + + +def rgb565_bytes(image: Image.Image) -> bytes: + result = bytearray() + for red, green, blue in image.convert("RGB").get_flattened_data(): + value = ((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3) + result.extend((value >> 8, value & 0xFF)) + return bytes(result) + + +def redraw(device: hid.device, image: Image.Image) -> None: + framebuffer = rgb565_bytes(image) + if len(framebuffer) != FRAMEBUFFER_BYTES: + raise ValueError("unexpected framebuffer size") + + for index in range(FINAL_CHUNK + 1): + offset = index * DATA_BYTES + chunk = framebuffer[offset : offset + DATA_BYTES] + command = 0xF0 if index == 0 else 0xF2 if index == FINAL_CHUNK else 0xF1 + header = bytes( + ( + 0x55, + 0xA3, + command, + index + 1, + (offset >> 8) & 0xFF, + offset & 0xFF, + (len(chunk) >> 8) & 0xFF, + len(chunk) & 0xFF, + ) + ) + checked_write(device, packet(header, chunk)) + + +def fetch_forecasts(now: dt.datetime) -> list[Forecast]: + tomorrow = (now + dt.timedelta(days=1)).date() + targets = [ + dt.datetime.combine(tomorrow, dt.time(hour=9), tzinfo=TZ), + dt.datetime.combine(tomorrow, dt.time(hour=17), tzinfo=TZ), + ] + response = requests.get( + OPEN_METEO_URL, + params={ + "latitude": MANNHEIM[0], + "longitude": MANNHEIM[1], + "timezone": "Europe/Berlin", + "forecast_days": 3, + "hourly": "temperature_2m,weather_code,precipitation_probability", + }, + timeout=20, + ) + response.raise_for_status() + hourly = response.json()["hourly"] + indices = {value: index for index, value in enumerate(hourly["time"])} + + forecasts = [] + for target in targets: + index = indices[target.strftime("%Y-%m-%dT%H:00")] + forecasts.append( + Forecast( + target=target, + temperature=float(hourly["temperature_2m"][index]), + weather_code=int(hourly["weather_code"][index]), + precipitation_probability=int(hourly["precipitation_probability"][index]), + ) + ) + return forecasts + + +def condition(code: int) -> str: + return WEATHER_TEXT.get(code, "Mixed") + + +def font(path: str, size: int) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(path, size=size) + + +def draw_card(draw: ImageDraw.ImageDraw, y: int, forecast: Forecast) -> None: + draw.rounded_rectangle((12, y, 158, y + 82), radius=12, fill="#183149", outline="#2e5875") + draw.text((24, y + 9), forecast.target.strftime("%H:%M"), font=font(FONT_BOLD, 19), fill="#ffce67") + draw.text((24, y + 34), f"{forecast.temperature:.1f} C", font=font(FONT_BOLD, 22), fill="#f5fbff") + detail = f"{condition(forecast.weather_code)} {forecast.precipitation_probability}% rain" + draw.text((24, y + 62), detail, font=font(FONT_REGULAR, 10), fill="#b7d5e8") + + +def render(now: dt.datetime, forecasts: Iterable[Forecast]) -> Image.Image: + image = Image.new("RGB", (PORTRAIT_WIDTH, PORTRAIT_HEIGHT), "#071a29") + draw = ImageDraw.Draw(image) + draw.rectangle((0, 0, PORTRAIT_WIDTH, 8), fill="#4cb8c4") + draw.text((15, 18), now.strftime("%H:%M"), font=font(FONT_BOLD, 45), fill="#f5fbff") + draw.text((17, 73), "MANNHEIM", font=font(FONT_BOLD, 18), fill="#ffce67") + draw.text((18, 98), now.strftime("%a %d %b"), font=font(FONT_REGULAR, 15), fill="#b7d5e8") + draw.text((17, 128), "TOMORROW", font=font(FONT_BOLD, 11), fill="#76d2d9") + for y, forecast in zip((148, 239), forecasts): + draw_card(draw, y, forecast) + return image.rotate(90, expand=True) + + +class Panel: + def __init__(self) -> None: + self.device: hid.device | None = None + + def close(self) -> None: + if self.device is not None: + self.device.close() + self.device = None + + def connect(self) -> hid.device: + self.close() + device = hid.device() + device.open_path(HID_PATH) + self.device = device + set_portrait(device) + time.sleep(1.7) + logging.info("connected to MP90 front panel") + return device + + +def run(once: bool) -> None: + panel = Panel() + forecasts: list[Forecast] = [] + weather_due = 0.0 + redraw_due = 0.0 + stop = False + + def request_stop(_signum: int, _frame: object) -> None: + nonlocal stop + stop = True + + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + + try: + while not stop: + now = dt.datetime.now(TZ) + monotonic = time.monotonic() + try: + if monotonic >= weather_due or not forecasts: + forecasts = fetch_forecasts(now) + weather_due = monotonic + 15 * 60 + logging.info("updated Mannheim weather forecast") + + device = panel.device or panel.connect() + if monotonic >= redraw_due: + redraw(device, render(now, forecasts)) + redraw_due = monotonic + 60 + logging.info("redrew clock at %s", now.strftime("%H:%M:%S")) + if once: + return + time.sleep(1.7) + + heartbeat(device, dt.datetime.now(TZ)) + time.sleep(1.7) + except Exception: + logging.exception("panel update failed; reconnecting") + panel.close() + time.sleep(3) + finally: + panel.close() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--once", action="store_true", help="render one frame and exit") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + run(args.once) + + +if __name__ == "__main__": + main()