initial mp90 panel renderer
Add the MP90 front panel clock/weather renderer, systemd service, ignore rules, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user