Add experimental HTML/CSS "modern" render style for weather widget
Build and push server image / test (push) Successful in 39s
Build and push server image / build-and-push (push) Failing after 2m34s
Build and push server image / deploy (push) Has been skipped

The weather widget's icons/layout are hand-drawn PIL primitives -- clean
under quantization but flat, no gradients/shadows. Adds an opt-in
render_style="modern" (current/daily modes only) that instead renders a
Jinja2 template through a persistent headless-Chromium browser
(app/html_render.py), following the approach of Tesserae, an open-source
e-ink dashboard targeting this same panel family.

Key design points:
- The Chromium dependency (Playwright) is lazily imported only when a
  weather widget actually uses "modern" style, and the background browser
  itself only launches on first use -- every other widget type, and this
  one's own classic/hourly/multi_city paths, never pay for it.
- No Frame-level dithering setting needed: html_render dithers its own
  rendered widget to exact palette colors (Bayer/ordered, not
  Floyd-Steinberg) before compositing, so the shared whole-canvas
  Floyd-Steinberg pass sees zero quantization error there and leaves it
  untouched -- same trick draw_text/hand-drawn icons already use. Floyd-
  Steinberg keeps working unchanged for photos and every other widget.
- A "Load calibrated Spectra 6 preset" button in Advanced configuration
  offers a community-measured palette (data ported from
  paperlesspaper/epdoptimize, Apache 2.0) as an alternative starting
  point to the existing idealized DEFAULT_PALETTE_RGB -- fills the
  existing palette table, doesn't save by itself.

Known open risk, not resolved here: a headless Chromium binary is far
larger than the ~100MB single-layer limit that already forced this
project's pip/npm installs into split layers, and (unlike those) is a
single ~180MB file that can't be split across layers by ordinary
Dockerfile restructuring. Flagged prominently in server/Dockerfile and
docs/widgets.md -- treat this render style as experimental/local-only
until that's resolved.
This commit is contained in:
2026-07-30 22:18:43 +00:00
parent d34eb1bf45
commit 8ea1c53ec3
22 changed files with 717 additions and 22 deletions
+1
View File
@@ -93,6 +93,7 @@ def test_expected_columns_exist_on_current_schema():
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
assert "render_style" in weather_widget_columns # migration 31
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
+1 -1
View File
@@ -125,7 +125,7 @@ def test_save_and_apply_round_trip_weather_settings(client, db_session):
assert snap.config == {
"mode": "daily", "provider": "nws", "units": "celsius", "city_label": "Boston, MA",
"city_latitude": 42.36, "city_longitude": -71.06, "hourly_interval_hours": 6, "daily_days": 7,
"cities": None,
"cities": None, "render_style": "classic",
}
client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
@@ -243,6 +243,35 @@ def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db
assert resp.status_code == 200, resp.text
def test_preview_weather_honors_modern_render_style(client, db_session, monkeypatch):
"""Regression test: api_widget_preview_weather originally called
weather_render.render_weather_preview_png directly, unconditionally --
the dialog's own live preview never reflected render_style="modern" at
all, even though the real device-facing render (widgets/weather.py's
render()) did. Route through html_render instead for modern/current or
modern/daily, same as the device path -- assert it's actually reached,
not just that the request 200s (it would 200 either way if this
silently fell back to classic)."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current", render_style="modern")
monkeypatch.setattr(
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
lambda db, frame, widget, force=False: {"temp": 72.0, "category": "clear"},
)
calls = []
def _fake_render_html_to_image(html, target_w, target_h):
calls.append((target_w, target_h))
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
monkeypatch.setattr("app.html_render.render_html_to_image", _fake_render_html_to_image)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert calls, "html_render.render_html_to_image was never called -- preview endpoint didn't honor render_style"
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session)
+79 -1
View File
@@ -11,7 +11,9 @@ from __future__ import annotations
import time
from app import grid, widgets
from PIL import Image
from app import grid, html_render, widgets
from app.models import Frame, WeatherWidgetConfig, Widget
@@ -109,3 +111,79 @@ def test_check_now_forces_a_refetch(db_session, monkeypatch):
def test_action_labels():
assert set(widgets.weather.ACTIONS.keys()) == {"check_now"}
assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"}
# --- render_style="modern" (app/html_render.py) -------------------------
# No real browser here -- html_render.render_html_to_image is monkeypatched
# to a stub, so these tests exercise weather.py's dispatch + html_render's
# own template-rendering/ordered_dither logic, not Playwright/Chromium
# itself (that needs a real browser install -- see the run-server-driven
# manual verification these tests don't replace).
def _stub_render_html_to_image(monkeypatch, fill=(10, 20, 200)):
def _stub(html, target_w, target_h):
return Image.new("RGB", (target_w, target_h), fill)
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
def test_render_modern_current_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current", city_label="Portland", render_style="modern")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
_stub_render_html_to_image(monkeypatch)
img = widgets.weather.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_modern_daily_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", daily_days=5, render_style="modern")
daily = {f"2026-07-{27 + i}": {"high": 70 + i, "low": 50 + i, "category": "clear"} for i in range(5)}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
_stub_render_html_to_image(monkeypatch)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
assert img.mode == "RGB"
def test_render_modern_ordered_dither_is_exact_palette(db_session, monkeypatch):
"""The whole point of doing ordered dithering inside html_render (see
its module docstring) is that its output is already exact palette
colors before the shared whole-canvas Floyd-Steinberg pass ever sees
it -- assert that directly, not just "an image came back"."""
frame, widget = _make_widget(db_session, mode="current", render_style="modern")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
# A mid-gray fill is nowhere near any of DEFAULT_PALETTE_RGB's 6 exact
# colors -- if ordered_dither's nearest-palette-match ran, every pixel
# must land on one of them regardless.
_stub_render_html_to_image(monkeypatch, fill=(128, 128, 128))
img = widgets.weather.render(db_session, frame, widget, 120, 100)
from app.image_pipeline import DEFAULT_PALETTE_RGB
palette = set(DEFAULT_PALETTE_RGB)
assert set(img.getdata()) <= palette
def test_render_modern_falls_back_to_classic_for_hourly_and_multi_city(db_session, monkeypatch):
"""hourly/multi_city have no "modern" template yet (see html_render's
module docstring) -- render_style="modern" on those modes must still
produce the classic PIL render, not error or silently do nothing.
Deliberately does NOT stub html_render, so this also proves the
classic path never imports it for these modes."""
frame, widget = _make_widget(db_session, mode="multi_city", render_style="modern")
cities = [{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"}]
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: cities)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
def test_render_style_default_is_classic(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current")
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.render_style == "classic"