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.
144 lines
5.8 KiB
Python
144 lines
5.8 KiB
Python
"""Battery widget: shows the frame's own last-reported battery level --
|
|
no live upstream to poll, unlike almost every other widget type. The
|
|
content is frame-level state that already exists regardless of this
|
|
widget (frame.battery_percent/battery_as_of, set by routers/device.py's
|
|
frame_battery on every device wake-on-battery report) plus routers.
|
|
common.battery_estimate_s's existing recency-weighted "how much longer"
|
|
estimate (computed there for the Device panel's own history chart) --
|
|
this widget just draws them, it doesn't fetch or compute anything new.
|
|
BatteryWidgetConfig only holds a display mode (compact: icon + percent;
|
|
detailed: also the estimate + last-report age).
|
|
|
|
No button actions -- there's nothing to advance/back/force for a number
|
|
the device itself pushes on every wake."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import time
|
|
|
|
from PIL import Image, ImageDraw
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import panel_style
|
|
from ..image_pipeline import _quantize, draw_text, logical_render_size, panel_size
|
|
from ..models import BatteryWidgetConfig, Frame, Widget
|
|
from ..routers.common import battery_estimate_s
|
|
from ._shared import placeholder_image
|
|
|
|
ACTIONS: dict = {}
|
|
ACTION_LABELS: dict[str, str] = {}
|
|
|
|
|
|
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int,
|
|
palette_rgb: list | None = None) -> None:
|
|
"""Centers panel_style.draw_battery_icon (top-left-anchored) under
|
|
`cx` -- this widget's own layout picks a center point, that helper's
|
|
shared implementation (also used by manage_overlay.py's battery
|
|
readout) just needs a top-left corner."""
|
|
nub_w = max(3, icon_w // 10)
|
|
x0 = cx - (icon_w + nub_w) // 2
|
|
panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb)
|
|
|
|
|
|
def _format_estimate(seconds: float) -> str:
|
|
days = seconds / 86400
|
|
if days >= 2:
|
|
return f"~{days:.0f}d left"
|
|
hours = seconds / 3600
|
|
if hours >= 20:
|
|
return "~1d left"
|
|
return f"~{max(1, round(hours))}h left"
|
|
|
|
|
|
def _format_age(as_of: float) -> str:
|
|
delta = max(0.0, time.time() - as_of)
|
|
if delta < 3600:
|
|
return f"{max(1, round(delta / 60))}m ago"
|
|
if delta < 86400:
|
|
return f"{round(delta / 3600)}h ago"
|
|
return f"{round(delta / 86400)}d ago"
|
|
|
|
|
|
def _lines_for(mode: str, frame: Frame, db: Session) -> list[str]:
|
|
"""The 0-2 caption lines "detailed" mode shows below the percent --
|
|
shared by both render styles so the estimate/age formatting only
|
|
lives in one place."""
|
|
if mode != "detailed":
|
|
return []
|
|
lines = []
|
|
estimate_s = battery_estimate_s(frame, db)
|
|
if estimate_s is not None:
|
|
lines.append(_format_estimate(estimate_s))
|
|
if frame.battery_as_of:
|
|
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
|
|
return lines
|
|
|
|
|
|
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
|
is_normal_wake: bool = True) -> Image.Image:
|
|
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
|
identical note; every widget type's render() shares one call
|
|
signature regardless of which ones actually care."""
|
|
percent = frame.battery_percent
|
|
if percent < 0:
|
|
return placeholder_image(target_w, target_h, ["Battery", "No reports yet"])
|
|
|
|
cfg = db.get(BatteryWidgetConfig, widget.id)
|
|
mode = cfg.mode if cfg else "detailed"
|
|
palette_rgb = frame.palette_rgb
|
|
lines = _lines_for(mode, frame, db)
|
|
|
|
if cfg and cfg.render_style == "modern":
|
|
# Local import: html_render pulls in Playwright, a real headless-
|
|
# Chromium dependency -- every other widget type, and this one's
|
|
# own classic path, should never pay for it (same reasoning as
|
|
# image_pipeline.render_placeholder's local `import qrcode`).
|
|
from .. import html_render
|
|
|
|
return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb, frame.theme)
|
|
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
cx = cx0 + cw // 2
|
|
|
|
icon_h = max(20, min(cw, ch) // 3)
|
|
icon_w = int(icon_h * 1.8)
|
|
icon_top = max(4, cy0 + ch // 8)
|
|
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb)
|
|
|
|
# The percent number picks up the icon's own charge-level color
|
|
# (red/yellow/green) instead of plain black -- ties the two into one
|
|
# visual statement rather than "colored icon, black number".
|
|
pct_font_size = max(18, min(cw, ch) // 3)
|
|
pct_font = panel_style.font_bold(pct_font_size)
|
|
pct_text = f"{percent}%"
|
|
bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
|
|
pct_y = icon_top + icon_h + 10
|
|
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font,
|
|
panel_style.battery_fill_color(percent, palette_rgb))
|
|
|
|
small_font_size = max(11, pct_font_size // 3)
|
|
small_font = panel_style.font_regular(small_font_size)
|
|
y = pct_y + pct_font_size + 12
|
|
for line in lines:
|
|
if y + small_font_size > cy0 + ch - 4:
|
|
break
|
|
lbbox = draw.textbbox((0, 0), line, font=small_font)
|
|
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
|
|
y += small_font_size + 6
|
|
|
|
return img
|
|
|
|
|
|
def render_preview_png(db: Session, frame: Frame, widget: Widget, orientation: str,
|
|
palette_rgb: list | None) -> bytes:
|
|
"""A normal browser-viewable PNG at full logical panel size -- same
|
|
"dialog preview always renders at the frame's full size, not the
|
|
widget's actual grid box" convention as text.py's render_preview_png."""
|
|
target_w, target_h = logical_render_size(orientation, *panel_size(frame.panel_type))
|
|
img = render(db, frame, widget, target_w, target_h)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|