Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
435 lines
21 KiB
Python
435 lines
21 KiB
Python
"""Weather icon-drawing primitives (draw_weather_icon/draw_weather_row --
|
|
extracted out of calendar_render.py, which still imports draw_weather_row
|
|
for its own embedded weather strip, unchanged) plus the standalone
|
|
weather widget's four per-mode renderers (build_current/build_hourly/
|
|
build_daily/build_multi_city, dispatched by build()) and its preview-PNG
|
|
wrapper -- the weather analogue of calendar_render.py's own
|
|
_build_tasks/render_tasks_preview_png relationship.
|
|
|
|
Every build_* function takes already-normalized data (see app/weather/'s
|
|
provider modules -- a `category` key from the shared clear/partly_cloudy/
|
|
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
|
|
returns an RGB Image exactly target_w x target_h, same contract every
|
|
other widget renderer in this project follows.
|
|
|
|
Icons are hand-drawn (no custom font/icon asset, same primitives-only
|
|
approach calendar_render.py uses elsewhere for e.g. month view's density
|
|
dots), styled after Environment Canada's own icon set (pointed sun rays,
|
|
a smooth puffy cloud, teardrop rain, dendrite snowflakes, a zigzag bolt)
|
|
but filled with this frame's *exact* panel ink RGB values rather than an
|
|
arbitrary bitmap's anti-aliased colors -- a flat fill that's already one
|
|
of the palette's 6 colors quantizes with zero dithering error to diffuse,
|
|
where a fetched/vendored bitmap's colors (almost never an exact palette
|
|
match) dither into a visible speckle. An early plain circle-with-4-ticks
|
|
"sun" also just didn't read as a sun at a glance -- pointed triangular
|
|
rays fixed that without giving up the clean-quantization property.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import math
|
|
from datetime import date, datetime
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from . import panel_style
|
|
from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _apply_manage_overlay, _quantize, draw_text, logical_render_size
|
|
|
|
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
|
|
# re-tuned -- every column-width/icon-size calc below was measured
|
|
# against 20px). BG/FG are this module's own cloud-icon fill/outline and
|
|
# fog-line color (see draw_cloud/draw_weather_icon), not a text-emphasis
|
|
# concern -- those live in panel_style (font_bold/font_regular, no MUTED
|
|
# gray -- see its module docstring for why).
|
|
MARGIN = panel_style.CONTENT_MARGIN
|
|
BG = (255, 255, 255)
|
|
FG = (0, 0, 0)
|
|
|
|
|
|
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
|
|
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
|
|
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
|
|
already this module's BG/FG) -- thin wrapper over panel_style.ink
|
|
(which generalized this same resolution idiom), kept so every
|
|
draw_weather_icon call site below doesn't need touching."""
|
|
return panel_style.ink(palette_rgb, index)
|
|
|
|
|
|
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
|
|
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
|
|
with a clean outline -- drawn as one outline-color pass slightly
|
|
larger than the shapes, then the same shapes again in `fill` on top.
|
|
Overlapping ellipses each drawn with their own `outline=` would leave
|
|
visible seams where they cross; this double-draw trick sidesteps that
|
|
entirely regardless of how the lobes overlap."""
|
|
stroke = 2
|
|
lobes = [
|
|
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
|
|
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
|
|
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
|
|
]
|
|
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
|
|
for x0, y0, x1, y1 in lobes:
|
|
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=outline)
|
|
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline)
|
|
for x0, y0, x1, y1 in lobes:
|
|
draw.ellipse([x0, y0, x1, y1], fill=fill)
|
|
draw.rectangle(base, fill=fill)
|
|
|
|
|
|
def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None:
|
|
"""A filled disc + 8 pointed triangular rays -- styled after
|
|
Environment Canada's own sun glyph. Rays are solid triangles (base on
|
|
the disc's edge, tip pointing outward), not thin lines: at small icon
|
|
sizes thin lines read as a crosshair/asterisk, not sun rays, which is
|
|
exactly what an earlier attempt here looked like."""
|
|
draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
|
|
base_r, tip_r, half_w = r * 0.6, r * 1.2, r * 0.16
|
|
for i in range(8):
|
|
angle = i * (math.pi / 4)
|
|
perp = angle + math.pi / 2
|
|
bx, by = cx + math.cos(angle) * base_r, cy + math.sin(angle) * base_r
|
|
tx, ty = cx + math.cos(angle) * tip_r, cy + math.sin(angle) * tip_r
|
|
p1 = (bx + math.cos(perp) * half_w, by + math.sin(perp) * half_w)
|
|
p2 = (bx - math.cos(perp) * half_w, by - math.sin(perp) * half_w)
|
|
draw.polygon([p1, p2, (tx, ty)], fill=color)
|
|
|
|
|
|
def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None:
|
|
"""A rounded teardrop (point up, bulb down) -- the standard rain
|
|
glyph, not a bare diagonal tick."""
|
|
draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color)
|
|
draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color)
|
|
|
|
|
|
def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None:
|
|
"""A 6-pointed dendrite -- three crossing lines plus a short
|
|
perpendicular tick near each of the 6 tips, closer to a real
|
|
snowflake glyph than a bare asterisk."""
|
|
for i in range(3):
|
|
angle = i * (math.pi / 3)
|
|
dx, dy = math.cos(angle) * r, math.sin(angle) * r
|
|
draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.28)))
|
|
perp = angle + math.pi / 2
|
|
tick = r * 0.35
|
|
for sign in (1, -1):
|
|
tx, ty = x + dx * sign, y + dy * sign
|
|
ex, ey = tx * 0.75 + x * 0.25, ty * 0.75 + y * 0.25
|
|
draw.line([(ex - math.cos(perp) * tick, ey - math.sin(perp) * tick),
|
|
(ex + math.cos(perp) * tick, ey + math.sin(perp) * tick)], fill=color, width=2)
|
|
|
|
|
|
def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None:
|
|
"""A zigzag bolt polygon -- the standard lightning glyph, not a bare
|
|
3-segment line."""
|
|
points = [
|
|
(cx + size * 0.15, cy - size * 0.7),
|
|
(cx - size * 0.35, cy + size * 0.05),
|
|
(cx - size * 0.05, cy + size * 0.05),
|
|
(cx - size * 0.2, cy + size * 0.7),
|
|
(cx + size * 0.4, cy - size * 0.1),
|
|
(cx + size * 0.05, cy - size * 0.1),
|
|
]
|
|
draw.polygon(points, fill=color)
|
|
|
|
|
|
def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str,
|
|
palette_rgb: list | None = None) -> None:
|
|
"""A small glyph for one weather category, styled after Environment
|
|
Canada's own icon set but hand-drawn in this frame's exact panel ink
|
|
colors (yellow sun/bolt, blue rain/snow) -- see module docstring for
|
|
why that's better for this display than reusing an actual bitmap."""
|
|
yellow = _ink(palette_rgb, 2)
|
|
blue = _ink(palette_rgb, 4)
|
|
|
|
if category == "clear":
|
|
draw_sun(draw, cx, cy, r, yellow)
|
|
return
|
|
|
|
if category == "partly_cloudy":
|
|
draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow)
|
|
draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9)
|
|
return
|
|
|
|
cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25
|
|
draw_cloud(draw, cx, cloud_cy, r)
|
|
|
|
if category == "fog":
|
|
for i in range(3):
|
|
y = cy + r * 0.55 + i * (r * 0.4)
|
|
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
|
|
elif category == "rain":
|
|
for dx in (-0.55, 0, 0.55):
|
|
draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue)
|
|
elif category == "snow":
|
|
for dx in (-0.55, 0, 0.55):
|
|
draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue)
|
|
elif category == "thunderstorm":
|
|
draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow)
|
|
|
|
|
|
def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
|
|
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
|
|
show_labels: bool = True, palette_rgb: list | None = None) -> int:
|
|
"""Draws one or more cities' weather side by side starting at
|
|
(x0, y0), stopping once another entry wouldn't fit within max_w
|
|
(narrow views like week columns just end up showing fewer cities --
|
|
same graceful-degradation approach month view takes with density
|
|
dots). Returns the row height consumed (0 if there was nothing to
|
|
draw, so callers can skip reserving space entirely)."""
|
|
if not entries:
|
|
return 0
|
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
|
row_h = icon_r * 2 + 8
|
|
x = x0
|
|
drew_any = False
|
|
for entry in entries:
|
|
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
|
|
label = f"{entry['label']} {temps}" if show_labels else temps
|
|
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
|
|
if drew_any and x + entry_w > x0 + max_w:
|
|
break
|
|
cx, cy = x + icon_r, y0 + icon_r
|
|
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
|
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
|
|
x += entry_w
|
|
drew_any = True
|
|
return row_h + 6
|
|
|
|
|
|
# --- Standalone weather widget (app/widgets/weather.py) -----------------
|
|
|
|
def _format_hour_label(iso_time: str) -> str:
|
|
dt = datetime.fromisoformat(iso_time)
|
|
text = dt.strftime("%I %p").lstrip("0")
|
|
return text if text else "12 AM"
|
|
|
|
|
|
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
|
|
min_size: int = 9, font_loader=panel_style.font_bold) -> int:
|
|
"""Largest size <= max_size at which every string in `texts` fits
|
|
within max_width -- used to size a per-column label/temp font against
|
|
the actual column width instead of an icon-radius-derived guess,
|
|
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
|
|
next column at a large icon size on a narrow column. Measured against
|
|
`font_loader` (default Inter Bold -- the wider of the two weights a
|
|
column actually mixes, a label in Regular and a temp in Bold, so
|
|
fitting against Bold keeps both safely inside max_width)."""
|
|
for size in range(max_size, min_size - 1, -1):
|
|
font = font_loader(size)
|
|
if all(draw.textlength(t, font=font) <= max_width for t in texts):
|
|
return size
|
|
return min_size
|
|
|
|
|
|
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:
|
|
"""One big icon + big temp number + (optional) city label, centered --
|
|
entry is {"temp", "category"} or None if nothing's been fetched yet
|
|
(callers normally catch that earlier and show a placeholder instead,
|
|
but this degrades to a blank canvas rather than erroring either
|
|
way)."""
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
if not entry:
|
|
return img
|
|
|
|
icon_r = max(20, min(cw, ch) // 4)
|
|
cx, cy = cx0 + cw // 2, cy0 + ch // 2 - icon_r // 2
|
|
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
|
|
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
|
temp_size = max(24, min(cw, ch) // 3)
|
|
temp_font = panel_style.font_bold(temp_size)
|
|
temp_text = f"{round(entry['temp'])}°{unit_suffix}"
|
|
bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
|
|
temp_y = cy + icon_r + 12
|
|
draw_text(img, (cx0 + cw // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
|
|
|
|
if city_label:
|
|
label_size = max(12, temp_size // 3)
|
|
label_font = panel_style.font_regular(label_size)
|
|
lbbox = draw.textbbox((0, 0), city_label, font=label_font)
|
|
draw_text(img, (cx0 + cw // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
|
|
city_label, label_font)
|
|
return img
|
|
|
|
|
|
def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
units: str = "fahrenheit", interval_hours: int = 4, city_label: str = "") -> Image.Image:
|
|
"""A row of ticks across the day, one every `interval_hours` hours
|
|
(entries is always 1-hour resolution -- see app/weather's provider
|
|
fetch_hourly), each showing an hour label, icon, and temp. Same
|
|
"draw however many fit" graceful degradation as draw_weather_row if
|
|
the box is too narrow for every tick."""
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
text_x0 = cx0 + MARGIN
|
|
text_w = cw - MARGIN * 2
|
|
y = cy0 + MARGIN
|
|
|
|
title_size = max(14, min(cw, ch) // 16)
|
|
if city_label:
|
|
# A filled header bar (this widget's chosen accent is black, not
|
|
# a color, so the hand-drawn icons below stay the star -- see
|
|
# panel_style module docstring) replaces the old plain title +
|
|
# thin rule line.
|
|
header_h = title_size + 20
|
|
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
|
|
panel_style.theme_color("weather", palette_rgb))
|
|
title_font = panel_style.font_bold(title_size)
|
|
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
|
|
y = cy0 + header_h + 12
|
|
|
|
# Capped to however many columns actually fit at a legible width
|
|
# (narrower widgets/smaller intervals just show fewer ticks) rather
|
|
# than cramming every sampled tick in regardless of how narrow that
|
|
# makes each one -- same graceful-degradation idiom as
|
|
# draw_weather_row's own per-pixel-width stopping point.
|
|
min_col_w = 46
|
|
max_ticks = max(1, text_w // min_col_w)
|
|
ticks = entries[::max(1, interval_hours)][:max_ticks]
|
|
if not ticks:
|
|
return img
|
|
col_w = max(1, text_w // len(ticks))
|
|
icon_r = max(10, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
|
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
|
time_labels = [_format_hour_label(e["time"]) for e in ticks]
|
|
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
|
|
label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
|
|
time_font = panel_style.font_regular(label_size)
|
|
temp_font = panel_style.font_bold(label_size)
|
|
|
|
for i, entry in enumerate(ticks):
|
|
cx = text_x0 + i * col_w + col_w // 2
|
|
time_label = time_labels[i]
|
|
tbbox = draw.textbbox((0, 0), time_label, font=time_font)
|
|
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, time_font)
|
|
cy = y + label_size + 10 + icon_r
|
|
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
|
temp_label = temp_labels[i]
|
|
tempbbox = draw.textbbox((0, 0), temp_label, font=temp_font)
|
|
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, temp_font)
|
|
return img
|
|
|
|
|
|
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:
|
|
"""A day-by-day strip (day label, icon, high/low), however many days
|
|
fit `target_w` (`daily` is already clamped to the widget's own
|
|
configured day count by app/weather's provider fetch_daily -- this
|
|
just draws whatever it's handed, same "stop once it doesn't fit"
|
|
graceful degradation as draw_weather_row)."""
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
text_x0 = cx0 + MARGIN
|
|
text_w = cw - MARGIN * 2
|
|
y = cy0 + MARGIN
|
|
|
|
title_size = max(14, min(cw, ch) // 16)
|
|
if city_label:
|
|
header_h = title_size + 20
|
|
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
|
|
panel_style.theme_color("weather", palette_rgb))
|
|
title_font = panel_style.font_bold(title_size)
|
|
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
|
|
y = cy0 + header_h + 12
|
|
|
|
days = list(daily.items())
|
|
if not days:
|
|
return img
|
|
col_w = max(1, text_w // len(days))
|
|
icon_r = max(12, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
|
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
|
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
|
|
temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
|
|
label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
|
|
label_font = panel_style.font_regular(label_size)
|
|
temp_font = panel_style.font_bold(label_size)
|
|
|
|
for i, (_, d) in enumerate(days):
|
|
x0 = text_x0 + i * col_w
|
|
label, temps = labels[i], temps_strs[i]
|
|
lbbox = draw.textbbox((0, 0), label, font=label_font)
|
|
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font)
|
|
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
|
|
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
|
|
tbbox = draw.textbbox((0, 0), temps, font=temp_font)
|
|
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, temp_font)
|
|
return img
|
|
|
|
|
|
def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
units: str = "fahrenheit") -> Image.Image:
|
|
"""Several cities' current-day high/low/icon side by side -- directly
|
|
reuses draw_weather_row (the same layout calendar_render.py's
|
|
embedded strip uses), just as the whole widget's own content instead
|
|
of a strip above an agenda day."""
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
if not cities:
|
|
return img
|
|
# Just the city name on-panel ("Portland", not the full disambiguated
|
|
# "Portland, Oregon, United States") -- that fuller form matters for
|
|
# telling apart geocoder candidates when adding a city (see
|
|
# weather.geocode_city, and the dialog's own "Cities" management
|
|
# list), not for a compact display row. Same shortening
|
|
# calendar_render.py's _weather_for_day already does for its own
|
|
# embedded strip.
|
|
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
|
|
text_w = cw - MARGIN * 2
|
|
# Sized against how many entries actually need to fit side by side,
|
|
# not just the box's height -- an icon/font picked from target_h
|
|
# alone (as this used to do) drew each entry so wide that only the
|
|
# first city ever fit, and draw_weather_row's own "stop once it
|
|
# doesn't fit" degradation silently dropped every city after it,
|
|
# even in an ordinary-sized widget with plenty of cities configured.
|
|
col_w = max(1, text_w // len(cities))
|
|
icon_r = max(10, min(col_w // 6, ch // 6, 40))
|
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
|
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
|
|
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r,
|
|
font_loader=panel_style.font_regular)
|
|
font = panel_style.font_regular(font_size)
|
|
y = max(cy0 + MARGIN, cy0 + (ch - (icon_r * 2 + 8)) // 2)
|
|
draw_weather_row(img, draw, cx0 + MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
|
|
show_labels=True, palette_rgb=palette_rgb)
|
|
return img
|
|
|
|
|
|
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
units: str = "fahrenheit", city_label: str = "", interval_hours: int = 4) -> Image.Image:
|
|
"""Dispatches to the right build_* function for this widget's
|
|
configured mode -- shared by app/widgets/weather.py's render() and
|
|
render_weather_preview_png below, so the two never drift apart."""
|
|
if mode == "current":
|
|
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
|
|
if mode == "hourly":
|
|
return build_hourly(data, target_w, target_h, palette_rgb, units, interval_hours, city_label)
|
|
if mode == "daily":
|
|
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
|
|
if mode == "multi_city":
|
|
return build_multi_city(data, target_w, target_h, palette_rgb, units)
|
|
return Image.new("RGB", (target_w, target_h), BG) # unreachable via a valid config -- see WeatherWidgetConfig.mode
|
|
|
|
|
|
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
|
|
units: str = "fahrenheit", manage: dict | None = None,
|
|
city_label: str = "", interval_hours: int = 4,
|
|
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
|
|
"""Same pipeline as calendar_render.render_tasks_preview_png -- a
|
|
normal browser-viewable PNG in logical (upright) orientation."""
|
|
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
|
|
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|