initial mp90 panel renderer

Add the MP90 front panel clock/weather renderer, systemd service, ignore rules, and deployment documentation.
This commit is contained in:
2026-06-30 12:41:41 +02:00
commit 2c53b4fd2d
4 changed files with 393 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.venv/
venv/
dist/
build/
*.egg-info/
+109
View File
@@ -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.
+15
View File
@@ -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
+262
View File
@@ -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()