Add experimental HTML/CSS "modern" render style for weather widget
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:
@@ -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) ---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user