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:
@@ -269,6 +269,25 @@ modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
|
||||
side -- the calendar widget's embedded strip, as a standalone
|
||||
widget's whole content instead of a strip above an agenda day.
|
||||
|
||||
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
|
||||
| `"modern"`, experimental): `current`/`daily` only -- `hourly`/
|
||||
`multi_city` always render classic regardless of this setting. `modern`
|
||||
draws the widget as an HTML/CSS card (Jinja2 templates under
|
||||
`app/templates/widget_html/`) through a persistent headless Chromium
|
||||
browser (`app/html_render.py`, Playwright) instead of `app/weather_render.
|
||||
py`'s hand-drawn PIL primitives -- gradients/shadows/soft icon shading
|
||||
PIL can't easily do. Its own `ordered_dither` (Bayer/ordered, not Floyd-
|
||||
Steinberg) commits the rendered widget to exact palette colors *before*
|
||||
compositing, so it's safe to mix with photo/other classic-rendered
|
||||
widgets on the same frame without a Floyd-Steinberg seam at the boundary
|
||||
(see that module's docstring for why ordered dithering doesn't have this
|
||||
problem and Floyd-Steinberg does) -- no `Frame`-level dithering setting
|
||||
was needed. Playwright/Chromium is a real, heavyweight runtime dependency
|
||||
imported lazily only when a weather widget actually uses this style, and
|
||||
its Docker packaging has a known likely-blocking image-size problem not
|
||||
yet resolved (see `server/Dockerfile`'s own comment) -- treat this style
|
||||
as unshipped/local-only until that's sorted out.
|
||||
|
||||
`current`/`hourly`/`daily` share one configured location
|
||||
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
|
||||
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
|
||||
|
||||
+46
-9
@@ -36,25 +36,62 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata fontconfig fonts-dejavu-core nodejs npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# EXPERIMENTAL, likely unmergeable as-is -- see below. System libs a
|
||||
# headless Chromium needs (app/html_render.py, the weather widget's
|
||||
# opt-in "modern" render style), trimmed from Playwright's own full
|
||||
# `install-deps chromium` list to just what a headless (no Xvfb),
|
||||
# Latin-text-plus-emoji use case needs: dropped xvfb (only needed for a
|
||||
# *headed* browser) and the CJK/Cyrillic/Thai locale font packages
|
||||
# (fonts-ipafont-gothic, fonts-wqy-zenhei, fonts-tlwg-loma-otf,
|
||||
# xfonts-cyrillic, xfonts-scalable, fonts-freefont-ttf, fonts-unifont) --
|
||||
# fonts-noto-color-emoji is the one that actually matters here (real
|
||||
# color emoji in the weather icons, vs. WeasyPrint/Pango's monochrome
|
||||
# fallback glyphs in this feature's original spike).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
|
||||
libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \
|
||||
libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 \
|
||||
libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \
|
||||
fonts-noto-color-emoji libfontconfig1 libfreetype6 fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
# Split across several layers rather than one `pip install -r
|
||||
# requirements.txt` -- same Cloudflare single-blob/layer payload-size
|
||||
# limit as render-service's npm installs below. The single combined
|
||||
# layer was measured at ~113MB unpacked, over the limit on its own.
|
||||
# Isolating the three largest packages gets every layer's unpacked size
|
||||
# well clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB,
|
||||
# the remaining `-r requirements.txt` layer ~71MB). Each package
|
||||
# version here still comes from requirements.txt (`pip install -r` for
|
||||
# everything that doesn't need its own layer skips these three, since
|
||||
# pip sees them already satisfied); the explicit versions below just
|
||||
# control *when* each installs -- same "single source of truth, just
|
||||
# splitting *when* it installs" tradeoff as the npm section's --no-save
|
||||
# comment below.
|
||||
# Isolating the largest packages gets every layer's unpacked size well
|
||||
# clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, the
|
||||
# remaining `-r requirements.txt` layer ~71MB). Each package version here
|
||||
# still comes from requirements.txt (`pip install -r` for everything that
|
||||
# doesn't need its own layer skips these, since pip sees them already
|
||||
# satisfied); the explicit versions below just control *when* each
|
||||
# installs -- same "single source of truth, just splitting *when* it
|
||||
# installs" tradeoff as the npm section's --no-save comment below.
|
||||
RUN pip install --no-cache-dir sqlalchemy==2.0.51
|
||||
RUN pip install --no-cache-dir pillow==12.3.0
|
||||
RUN pip install --no-cache-dir pypdfium2==5.12.1
|
||||
RUN pip install --no-cache-dir playwright==1.61.0
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# KNOWN LIKELY BLOCKER, not resolved by pulling this into its own layer:
|
||||
# `playwright install chromium-headless-shell` unpacks to ~262MB, and its
|
||||
# single `chrome-headless-shell` binary alone (measured: 181MB) is one
|
||||
# file -- unlike the pip/npm splits above (independently-installable
|
||||
# smaller packages moved into their own layers), a single 181MB file
|
||||
# can't be divided across multiple <100MB Docker layers by any ordinary
|
||||
# COPY/RUN restructuring; the whole file lands in whichever layer's diff
|
||||
# contains it. This almost certainly exceeds the same Cloudflare single-
|
||||
# blob/layer limit that forced the pip/npm splits elsewhere in this file
|
||||
# (see their comments) -- an actual push to this project's registry
|
||||
# hasn't been attempted (would require pushing to `main`, which triggers
|
||||
# deploy) to confirm, but there is no reason to expect a single 181MB
|
||||
# blob to fit where combined ~113MB of many small wheels didn't. Needs a
|
||||
# real resolution (a registry without this limit, hosting the browser
|
||||
# binary outside the image, etc.) before this branch can actually ship --
|
||||
# tracked as open, not silently assumed away.
|
||||
RUN playwright install chromium-headless-shell
|
||||
|
||||
# render-service/'s dependencies installed as several separate layers
|
||||
# rather than one `npm install` covering all of them -- a from-scratch
|
||||
# push of this image once hit Cloudflare's payload-size limit on a
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Experimental "modern" weather widget render style: Jinja2 + a
|
||||
persistent headless Chromium browser (Playwright) instead of the hand-
|
||||
drawn PIL primitives in weather_render.py -- see docs/widgets.md and the
|
||||
`html-widget-render` branch's PR description for the design rationale
|
||||
(gradients/shadows/soft shading that PIL can't easily do, at the cost of
|
||||
a real browser-process dependency).
|
||||
|
||||
Two things this module owns that nothing else in the codebase needed
|
||||
before:
|
||||
|
||||
1. A **persistent** background browser process. Widget rendering already
|
||||
happens concurrently across a fresh `ThreadPoolExecutor` per frame
|
||||
request (routers/device.py's _render_widgets) -- Playwright's sync
|
||||
API is thread-affine (an object must be used from the thread that
|
||||
created it), so a single browser object can't be handed across those
|
||||
ad-hoc worker threads, and relaunching a full Chromium process on
|
||||
every widget render would be real, avoidable latency. Fix: one
|
||||
background thread runs its own persistent asyncio event loop hosting
|
||||
one long-lived `Browser`, lazily started on first use (see start()) --
|
||||
not eagerly at server startup, so a deployment that never enables the
|
||||
weather widget's "modern" style never launches Chromium at all and
|
||||
never needs Playwright's browser binaries installed. main.py's
|
||||
lifespan only wires up the *shutdown* half (stop()), so a clean
|
||||
server restart doesn't leave an orphaned Chromium process behind if
|
||||
this was ever actually used. render_html_to_image() is a plain sync
|
||||
function any worker thread can call, bridging in via
|
||||
`asyncio.run_coroutine_threadsafe` (the standard safe cross-thread
|
||||
entry point into a *running* loop on another thread).
|
||||
|
||||
2. **Per-region ordered (Bayer) dithering against the palette**, done
|
||||
here rather than in the shared image_pipeline.py pipeline.
|
||||
render_panel's whole-canvas single Floyd-Steinberg pass exists
|
||||
because Floyd-Steinberg's error diffusion can't be split across
|
||||
independently-quantized regions without a visible seam at the
|
||||
boundary -- but that reasoning doesn't apply to ordered dithering,
|
||||
which has no cross-pixel error term (each pixel's dither decision
|
||||
only depends on its own position + color). So this module dithers its
|
||||
own rendered widget to *already-exact* palette colors before
|
||||
returning it; the later shared Floyd-Steinberg pass sees zero
|
||||
quantization error there and leaves it untouched -- the same
|
||||
"pre-commit to exact palette colors" trick image_pipeline.draw_text
|
||||
and the hand-drawn weather icons already rely on, just reached a
|
||||
different way. Floyd-Steinberg keeps working exactly as before for
|
||||
photos and every other (classic-rendered) widget region.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import threading
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from PIL import Image
|
||||
|
||||
from . import panel_style
|
||||
from .image_pipeline import DEFAULT_PALETTE_RGB
|
||||
|
||||
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
|
||||
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
|
||||
|
||||
_jinja_env = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html", "jinja"]),
|
||||
)
|
||||
|
||||
CATEGORY_EMOJI = {
|
||||
"clear": "☀️",
|
||||
"partly_cloudy": "⛅",
|
||||
"cloudy": "☁️",
|
||||
"fog": "\U0001f32b️",
|
||||
"rain": "\U0001f327️",
|
||||
"snow": "❄️",
|
||||
"thunderstorm": "⛈️",
|
||||
}
|
||||
|
||||
# ACCENT_START/END: a fixed blue gradient pair for the "modern" style's
|
||||
# card header -- deliberately not routed through panel_style.theme_color
|
||||
# (unlike every classic-rendered widget's chrome), since the whole point
|
||||
# of this style is the gradient look ordered_dither below then commits
|
||||
# to exact palette colors anyway; which literal hex this starts from
|
||||
# doesn't matter to the end result the way it would for a flat PIL fill.
|
||||
ACCENT_START = "#1c4fd6"
|
||||
ACCENT_END = "#6fa8ff"
|
||||
|
||||
|
||||
# --- Persistent background browser -------------------------------------
|
||||
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
_loop_thread: threading.Thread | None = None
|
||||
_browser = None
|
||||
_playwright_cm = None
|
||||
_start_lock = threading.Lock()
|
||||
|
||||
|
||||
async def _launch_browser() -> None:
|
||||
global _browser, _playwright_cm
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
_playwright_cm = async_playwright()
|
||||
playwright = await _playwright_cm.__aenter__()
|
||||
_browser = await playwright.chromium.launch()
|
||||
|
||||
|
||||
async def _close_browser() -> None:
|
||||
global _browser, _playwright_cm
|
||||
if _browser is not None:
|
||||
await _browser.close()
|
||||
_browser = None
|
||||
if _playwright_cm is not None:
|
||||
await _playwright_cm.__aexit__(None, None, None)
|
||||
_playwright_cm = None
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Launches the background event loop + persistent Chromium browser,
|
||||
if not already running. Called lazily by render_html_to_image on
|
||||
first use (not from main.py's lifespan -- see module docstring for
|
||||
why this must stay opt-in) -- exposed directly too, for tests that
|
||||
want to control startup explicitly. Idempotent -- a second call
|
||||
while already started is a no-op."""
|
||||
global _loop, _loop_thread
|
||||
if _loop is not None:
|
||||
return
|
||||
ready = threading.Event()
|
||||
|
||||
def _run() -> None:
|
||||
global _loop
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
_loop = loop
|
||||
ready.set()
|
||||
loop.run_forever()
|
||||
|
||||
_loop_thread = threading.Thread(target=_run, daemon=True, name="html-render-loop")
|
||||
_loop_thread.start()
|
||||
ready.wait()
|
||||
asyncio.run_coroutine_threadsafe(_launch_browser(), _loop).result()
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
"""Closes the browser and stops the background loop -- called from
|
||||
main.py's lifespan shutdown so a server restart never leaves an
|
||||
orphaned Chromium process behind. No-op if start() was never called
|
||||
(the common case: most deployments never enable "modern" style)."""
|
||||
global _loop, _loop_thread
|
||||
if _loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(_close_browser(), _loop).result()
|
||||
_loop.call_soon_threadsafe(_loop.stop)
|
||||
_loop_thread.join(timeout=5)
|
||||
_loop = None
|
||||
_loop_thread = None
|
||||
|
||||
|
||||
async def _screenshot(html: str, target_w: int, target_h: int) -> bytes:
|
||||
page = await _browser.new_page(viewport={"width": target_w, "height": target_h}, device_scale_factor=1)
|
||||
try:
|
||||
await page.set_content(html, wait_until="networkidle")
|
||||
return await page.screenshot()
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
def render_html_to_image(html: str, target_w: int, target_h: int) -> Image.Image:
|
||||
"""Renders `html` (already sized to target_w x target_h via its own
|
||||
<style>) through the persistent headless Chromium browser and
|
||||
returns an RGB image of exactly that size. Safe to call from any
|
||||
thread -- bridges into the dedicated background asyncio loop via
|
||||
run_coroutine_threadsafe. Lazily calls start() on first use (see its
|
||||
docstring) -- the first "modern" style render on a freshly-started
|
||||
server pays Chromium's launch latency; every render after that reuses
|
||||
the same persistent browser."""
|
||||
if _loop is None:
|
||||
with _start_lock:
|
||||
if _loop is None:
|
||||
start()
|
||||
future = asyncio.run_coroutine_threadsafe(_screenshot(html, target_w, target_h), _loop)
|
||||
png_bytes = future.result()
|
||||
return Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
|
||||
|
||||
# --- Ordered (Bayer 8x8) dithering against an arbitrary palette ---------
|
||||
|
||||
_BAYER8 = (
|
||||
np.array(
|
||||
[
|
||||
[0, 32, 8, 40, 2, 34, 10, 42],
|
||||
[48, 16, 56, 24, 50, 18, 58, 26],
|
||||
[12, 44, 4, 36, 14, 46, 6, 38],
|
||||
[60, 28, 52, 20, 62, 30, 54, 22],
|
||||
[3, 35, 11, 43, 1, 33, 9, 41],
|
||||
[51, 19, 59, 27, 49, 17, 57, 25],
|
||||
[15, 47, 7, 39, 13, 45, 5, 37],
|
||||
[63, 31, 55, 23, 61, 29, 53, 21],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
/ 64.0
|
||||
- 0.5
|
||||
)
|
||||
|
||||
|
||||
def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float = 48.0) -> Image.Image:
|
||||
"""Bayer-ordered dither of `img` against `palette_rgb` (falls back to
|
||||
DEFAULT_PALETTE_RGB) -- every output pixel is one of the palette's
|
||||
exact colors, spatially patterned rather than error-diffused, so it's
|
||||
safe to run per-region before compositing (see module docstring for
|
||||
why that's not true of Floyd-Steinberg). `amplitude` is the Bayer
|
||||
bias's full swing in 0-255 RGB units before nearest-palette-color
|
||||
matching -- 48 was the value this render style was tuned against in
|
||||
the exploratory spike behind this feature; not exposed as a per-frame
|
||||
setting (unlike dither_strength) since there's only one consumer of
|
||||
it today."""
|
||||
palette = np.array(palette_rgb or DEFAULT_PALETTE_RGB, dtype=np.float32)
|
||||
arr = np.asarray(img.convert("RGB"), dtype=np.float32)
|
||||
h, w, _ = arr.shape
|
||||
tile = np.tile(_BAYER8, (h // 8 + 1, w // 8 + 1))[:h, :w]
|
||||
biased = np.clip(arr + tile[:, :, None] * amplitude, 0, 255)
|
||||
diffs = biased[:, :, None, :] - palette[None, None, :, :]
|
||||
dists = np.einsum("hwkc,hwkc->hwk", diffs, diffs)
|
||||
idx = np.argmin(dists, axis=2)
|
||||
return Image.fromarray(palette[idx].astype(np.uint8), "RGB")
|
||||
|
||||
|
||||
# --- Weather "modern" style ----------------------------------------------
|
||||
|
||||
def _day_label(day_date: date) -> str:
|
||||
delta = (day_date - date.today()).days
|
||||
if delta == 0:
|
||||
return "Today"
|
||||
if delta == 1:
|
||||
return "Tomorrow"
|
||||
return day_date.strftime("%a")
|
||||
|
||||
|
||||
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of weather_render.build_current --
|
||||
same call signature, so app/widgets/weather.py can dispatch to
|
||||
either interchangeably. Returns an already-palette-exact RGB image
|
||||
(see ordered_dither)."""
|
||||
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||
if not entry:
|
||||
return img
|
||||
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
icon_size = max(28, min(target_w, target_h) // 3)
|
||||
template = _jinja_env.get_template("weather_current.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
font_dir=str(_FONT_DIR), emoji=CATEGORY_EMOJI.get(entry["category"], ""),
|
||||
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
|
||||
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
|
||||
label_size=max(12, icon_size // 3),
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
|
||||
|
||||
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
|
||||
call signature. Returns an already-palette-exact RGB image (see
|
||||
ordered_dither)."""
|
||||
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||
days = list(daily.items())
|
||||
if not days:
|
||||
return img
|
||||
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
|
||||
col_w = max(1, target_w // len(days))
|
||||
icon_size = max(16, min(col_w // 2, 36))
|
||||
day_entries = [
|
||||
{
|
||||
"label": _day_label(date.fromisoformat(day_str)),
|
||||
"emoji": CATEGORY_EMOJI.get(d["category"], ""),
|
||||
"high": round(d["high"]),
|
||||
"low": round(d["low"]),
|
||||
}
|
||||
for day_str, d in days
|
||||
]
|
||||
template = _jinja_env.get_template("weather_daily.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
font_dir=str(_FONT_DIR), city_label=city_label, header_h=header_h,
|
||||
title_size=max(14, header_h - 12), accent_start=ACCENT_START, accent_end=ACCENT_END,
|
||||
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
|
||||
unit_suffix=unit_suffix,
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
|
||||
|
||||
SUPPORTED_MODES = ("current", "daily")
|
||||
|
||||
|
||||
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||
"""Dispatches to build_current/build_daily -- mirrors weather_render.
|
||||
build()'s signature (minus interval_hours, which no modern-style mode
|
||||
uses) so app/widgets/weather.py and the weather preview endpoint can
|
||||
call either module identically. Only call this for mode in
|
||||
SUPPORTED_MODES -- callers are expected to have already fallen back to
|
||||
weather_render.build() for hourly/multi_city (see weather.py)."""
|
||||
if mode == "current":
|
||||
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
|
||||
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
|
||||
|
||||
|
||||
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> bytes:
|
||||
"""Modern-style analogue of weather_render.render_weather_preview_png
|
||||
-- same browser-viewable-PNG convention every other widget's preview
|
||||
endpoint uses. build()'s output is already palette-exact (see
|
||||
ordered_dither), so the final _quantize pass here is a no-op on it,
|
||||
same reasoning as the module docstring's compositing story."""
|
||||
from .image_pipeline import _quantize, _png_bytes, logical_render_size
|
||||
|
||||
target_w, target_h = logical_render_size(orientation)
|
||||
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
return _png_bytes(quantized)
|
||||
@@ -187,6 +187,27 @@ DEFAULT_PALETTE_RGB = [
|
||||
|
||||
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||
|
||||
# A community-measured alternative starting point for the same 6 slots,
|
||||
# ported (data only, not code) from paperlesspaper/epdoptimize's
|
||||
# src/dither/data/default-palettes.json "spectra6" entry (Apache
|
||||
# License 2.0, https://github.com/paperlesspaper/epdoptimize) -- offered
|
||||
# as a one-click "Load calibrated preset" in the Advanced configuration
|
||||
# UI, not a new default: unlike DEFAULT_PALETTE_RGB above, these are an
|
||||
# actual panel's measured appearance rather than idealized primaries
|
||||
# (real Spectra 6 white/black are notably duller than pure #fff/#000),
|
||||
# but measured from a different unit than any given frame's actual
|
||||
# panel -- panel_style.py's own docstring already notes units vary
|
||||
# enough to be worth calibrating per frame, and this hasn't been
|
||||
# verified against this project's own hardware.
|
||||
CALIBRATED_SPECTRA6_RGB = [
|
||||
(0x1F, 0x22, 0x26), # BLACK
|
||||
(0xB9, 0xC7, 0xC9), # WHITE
|
||||
(0xC1, 0xBB, 0x1E), # YELLOW
|
||||
(0x62, 0x20, 0x1E), # RED
|
||||
(0x23, 0x3F, 0x8E), # BLUE
|
||||
(0x35, 0x56, 0x3A), # GREEN
|
||||
]
|
||||
|
||||
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
||||
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
||||
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
||||
|
||||
+16
-2
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
@@ -24,7 +25,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import logging_setup, migration
|
||||
from . import html_render, logging_setup, migration
|
||||
from .auth import (
|
||||
browser_token_valid,
|
||||
current_user,
|
||||
@@ -46,7 +47,20 @@ logging_setup.configure_logging()
|
||||
# Schema + legacy-config import, before the first request is served.
|
||||
migration.run_migrations()
|
||||
|
||||
app = FastAPI(title="ESPresso Frame Server")
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
"""Startup does nothing browser-related -- html_render.start() is
|
||||
lazy (only the weather widget's opt-in "modern" render style ever
|
||||
triggers it, see that module's docstring), so a deployment that
|
||||
never uses it never launches Chromium or needs Playwright's browser
|
||||
binaries installed. Shutdown calls html_render.stop() unconditionally
|
||||
(a no-op if it was never started) so a server restart never leaves
|
||||
an orphaned Chromium process running."""
|
||||
yield
|
||||
html_render.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="ESPresso Frame Server", lifespan=_lifespan)
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
|
||||
@@ -798,6 +798,22 @@ def _migration_30(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
|
||||
|
||||
|
||||
def _migration_31(conn) -> None:
|
||||
"""Weather widget render style (models.WeatherWidgetConfig.
|
||||
render_style): "classic" (existing hand-drawn PIL renderer,
|
||||
unchanged) or "modern" (app/html_render.py's headless-Chromium/CSS
|
||||
renderer). Every existing weather widget defaults to "classic" --
|
||||
no behavior change until a widget's dialog switches it.
|
||||
|
||||
Guarded per-column, same reasoning as migration 30's own comment:
|
||||
weather_widget_configs is a table some replay tests may re-create
|
||||
fresh via create_all() (which already has this column) rather than
|
||||
replaying migration 24's raw CREATE TABLE."""
|
||||
existing = {c["name"] for c in inspect(conn).get_columns("weather_widget_configs")}
|
||||
if "render_style" not in existing:
|
||||
conn.execute(text("ALTER TABLE weather_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -829,6 +845,7 @@ MIGRATIONS = [
|
||||
(28, _migration_28),
|
||||
(29, _migration_29),
|
||||
(30, _migration_30),
|
||||
(31, _migration_31),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -607,13 +607,19 @@ class WeatherWidgetConfig(Base):
|
||||
`cached`'s shape depends on `mode`: {"temp","category"} for current,
|
||||
a list of {"time","temp","category"} for hourly, a
|
||||
{"YYYY-MM-DD": {...}} dict for daily, or a list of
|
||||
{"label","high","low","category"} for multi_city."""
|
||||
{"label","high","low","category"} for multi_city.
|
||||
`render_style` picks which renderer draws the widget: "classic" (the
|
||||
hand-drawn PIL primitives in app/weather_render.py, unchanged
|
||||
default) or "modern" (app/html_render.py's Jinja2/headless-Chromium
|
||||
path, "current"/"daily" modes only for now -- see weather.py's
|
||||
render())."""
|
||||
|
||||
__tablename__ = "weather_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
|
||||
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
|
||||
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
|
||||
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
|
||||
# Single-location modes only (current/hourly/daily) -- geocoded once
|
||||
# via weather.geocode_city() when set, same idiom as
|
||||
|
||||
@@ -65,7 +65,7 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"battery": ("mode",),
|
||||
"weather": (
|
||||
"mode", "provider", "units", "city_label", "city_latitude", "city_longitude",
|
||||
"hourly_interval_hours", "daily_days", "cities",
|
||||
"hourly_interval_hours", "daily_days", "cities", "render_style",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +341,7 @@ def api_widget_config_save(
|
||||
weather_units: str | None = Form(None),
|
||||
weather_hourly_interval_hours: int | None = Form(None),
|
||||
weather_daily_days: int | None = Form(None),
|
||||
weather_render_style: str | None = Form(None),
|
||||
# battery
|
||||
battery_mode: str | None = Form(None),
|
||||
):
|
||||
@@ -469,6 +470,8 @@ def api_widget_config_save(
|
||||
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
|
||||
if weather_daily_days is not None:
|
||||
wcfg.daily_days = max(1, min(14, weather_daily_days))
|
||||
if weather_render_style is not None and weather_render_style in ("classic", "modern"):
|
||||
wcfg.render_style = weather_render_style
|
||||
elif widget.widget_type == "battery":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
|
||||
if battery_mode is not None:
|
||||
@@ -1108,10 +1111,19 @@ def api_widget_preview_weather(
|
||||
if wcfg.mode == "multi_city":
|
||||
raise HTTPException(400, "No cities added to this widget yet")
|
||||
raise HTTPException(400, "No location set on this widget yet")
|
||||
png = weather_render.render_weather_preview_png(
|
||||
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
||||
)
|
||||
if wcfg.render_style == "modern" and wcfg.mode in ("current", "daily"):
|
||||
# Same local-import reasoning as widgets/weather.py's render().
|
||||
from .. import html_render
|
||||
|
||||
png = html_render.render_weather_preview_png(
|
||||
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||
city_label=wcfg.city_label or "",
|
||||
)
|
||||
else:
|
||||
png = weather_render.render_weather_preview_png(
|
||||
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from ..global_actions import GLOBAL_ACTION_LABELS
|
||||
from ..image_pipeline import (
|
||||
BORDER_STYLES,
|
||||
BORDER_STYLE_LABELS,
|
||||
CALIBRATED_SPECTRA6_RGB,
|
||||
DEFAULT_PALETTE_RGB,
|
||||
DISPLAY_MODE_LABELS,
|
||||
MAX_BORDER_THICKNESS,
|
||||
@@ -89,6 +90,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
|
||||
timezones=ALL_TIMEZONES,
|
||||
palette_labels=PALETTE_LABELS,
|
||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
|
||||
palette_to_hex=palette_to_hex,
|
||||
photo_widget_id=photo_widget_id,
|
||||
global_action_labels=GLOBAL_ACTION_LABELS,
|
||||
|
||||
@@ -215,6 +215,18 @@ document.getElementById('palette-reset').addEventListener('click', () => {
|
||||
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
||||
});
|
||||
|
||||
// Fills the table with a community-measured starting point (see the
|
||||
// card's own explanatory text) -- doesn't save by itself, same as
|
||||
// editing the hex/RGB fields by hand; the user still clicks Save (or
|
||||
// Reset) to commit or discard it.
|
||||
document.getElementById('palette-load-calibrated').addEventListener('click', () => {
|
||||
const inputs = paletteHexInputs();
|
||||
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
|
||||
inputs[i].value = hex;
|
||||
syncPaletteFromHex(i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||||
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
|
||||
// set by the template) rather than window.FRAME_API -- palette/color/
|
||||
|
||||
@@ -14,6 +14,12 @@ function updateWeatherFieldVisibility() {
|
||||
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
|
||||
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
|
||||
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
|
||||
// Modern style is only built for current/daily (see app/html_render.py) --
|
||||
// hourly/multi_city always render classic server-side regardless of this
|
||||
// setting, so hide the row entirely rather than offer a choice that's a
|
||||
// silent no-op.
|
||||
document.getElementById('weather-render-style-row').style.display =
|
||||
(mode === 'current' || mode === 'daily') ? '' : 'none';
|
||||
}
|
||||
|
||||
function addWeatherWidgetCityRow(label) {
|
||||
@@ -74,6 +80,7 @@ function initWeatherDialog() {
|
||||
weather_units: document.getElementById('weather_units').value,
|
||||
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
|
||||
weather_daily_days: document.getElementById('weather_daily_days').value,
|
||||
weather_render_style: document.getElementById('weather_render_style').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="weather-render-style-row">
|
||||
<label>Render style
|
||||
<select id="weather_render_style">
|
||||
<option value="classic" {% if weather_cfg.render_style == "classic" %}selected{% endif %}>Classic (hand-drawn icons)</option>
|
||||
<option value="modern" {% if weather_cfg.render_style == "modern" %}selected{% endif %}>Modern (experimental, current/daily only)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>Weather source
|
||||
<select id="weather_provider">
|
||||
{% for value, label in weather_provider_labels.items() %}
|
||||
|
||||
@@ -187,6 +187,12 @@
|
||||
|
||||
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
||||
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
||||
<button type="button" class="secondary" id="palette-load-calibrated">Load calibrated Spectra 6 preset</button>
|
||||
<p class="sub" style="margin-top: 8px;">Experimental: a community-measured
|
||||
starting point (not this specific panel) -- fills the table above,
|
||||
doesn't save by itself. Real Spectra 6 ink is duller than the
|
||||
idealized defaults; this may or may not match your actual unit.
|
||||
Compare against the physical panel before keeping it.</p>
|
||||
</details>
|
||||
|
||||
<section class="card">
|
||||
@@ -221,6 +227,7 @@
|
||||
<script>
|
||||
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||
window.CALIBRATED_SPECTRA6_HEX = {{ calibrated_spectra6_hex | tojson }};
|
||||
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
|
||||
</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.icon { font-size: {{ icon_size }}px; line-height: 1; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.18)); }
|
||||
.temp { font-weight: 700; font-size: {{ temp_size }}px; color: #17233b; }
|
||||
.city { font-weight: 400; font-size: {{ label_size }}px; color: #5b6674; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">{{ emoji }}</div>
|
||||
<div class="temp">{{ temp }}°{{ unit_suffix }}</div>
|
||||
{% if city_label %}<div class="city">{{ city_label }}</div>{% endif %}
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);
|
||||
background: #ffffff;
|
||||
}
|
||||
.header {
|
||||
height: {{ header_h }}px;
|
||||
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; }
|
||||
.body { display: flex; height: calc(100% - {{ header_h }}px); padding: 12px 8px; }
|
||||
.col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.day { font-weight: 600; font-size: {{ label_size }}px; color: #17233b; }
|
||||
.icon { font-size: {{ icon_size }}px; line-height: 1; }
|
||||
.temps { font-weight: 700; font-size: {{ label_size }}px; color: #17233b; }
|
||||
.temps .low { color: #6b7788; font-weight: 400; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{% if city_label %}<div class="header"><div class="title">{{ city_label }}</div></div>{% endif %}
|
||||
<div class="body">
|
||||
{% for d in days %}
|
||||
<div class="col">
|
||||
<div class="day">{{ d.label }}</div>
|
||||
<div class="icon">{{ d.emoji }}</div>
|
||||
<div class="temps">{{ d.high }}°<span class="low">/{{ d.low }}°{{ unit_suffix }}</span></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Weather widget: one of four display modes (see models.
|
||||
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
|
||||
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
|
||||
py's build() dispatch. No real "next"/"back" concept (same as
|
||||
whiteboard) -- a single "check now" action forces a re-fetch bypassing
|
||||
the normal throttle."""
|
||||
py's build() dispatch -- or, for "current"/"daily" modes with
|
||||
render_style="modern", by app/html_render.py's Jinja2/headless-Chromium
|
||||
renderer instead (experimental; hourly/multi_city always render classic
|
||||
regardless of render_style, see html_render's module docstring). No real
|
||||
"next"/"back" concept (same as whiteboard) -- a single "check now"
|
||||
action forces a re-fetch bypassing the normal throttle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,6 +30,18 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
data = get_or_refresh_weather_widget_data(db, frame, widget)
|
||||
if data is None:
|
||||
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"])
|
||||
|
||||
if cfg.render_style == "modern" and cfg.mode in ("current", "daily"):
|
||||
# Local import: html_render pulls in Playwright, a real headless-
|
||||
# Chromium dependency -- every other widget type, and this one's
|
||||
# own classic/hourly/multi_city paths, should never pay for it
|
||||
# (same reasoning as image_pipeline.render_placeholder's local
|
||||
# `import qrcode`).
|
||||
from .. import html_render
|
||||
|
||||
return html_render.build(cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
|
||||
city_label=cfg.city_label or "")
|
||||
|
||||
return weather_render.build(
|
||||
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
|
||||
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours,
|
||||
|
||||
@@ -11,3 +11,5 @@ icalendar==7.2.2
|
||||
recurring-ical-events==3.8.2
|
||||
caldav==3.2.1
|
||||
pypdfium2==5.12.1
|
||||
playwright==1.61.0
|
||||
numpy==2.5.1
|
||||
|
||||
@@ -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