From eb7127718b6f169c43443934685c33be88f28b43 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Mon, 27 Jul 2026 19:02:21 +0000 Subject: [PATCH] Add battery widget (device's own last-reported level, no live upstream) Shows Frame.battery_percent/battery_as_of, already set by every device wake-on-battery report, plus routers/common.py's existing battery_estimate_s time-remaining estimate -- nothing new to fetch or cache. Compact (icon + percent) or detailed (+ estimate, last report age) display mode. No button actions. --- docs/widgets.md | 57 +++++-- server/app/grid.py | 8 + server/app/migration.py | 22 +++ server/app/models.py | 19 ++- server/app/routers/api_layouts.py | 1 + server/app/routers/api_widgets.py | 26 +++ server/app/routers/frame_pages.py | 7 + server/app/static/common.js | 2 +- server/app/static/frame_layout.js | 2 + server/app/static/widget_dialog_battery.js | 37 +++++ .../app/templates/_widget_dialog_battery.html | 23 +++ server/app/templates/frame_layout.html | 1 + server/app/widgets/__init__.py | 3 +- server/app/widgets/battery.py | 150 ++++++++++++++++++ server/tests/test_migrations.py | 31 ++-- .../test_widget_config_and_queue_endpoints.py | 77 +++++++++ server/tests/test_widgets_battery.py | 83 ++++++++++ 17 files changed, 521 insertions(+), 28 deletions(-) create mode 100644 server/app/static/widget_dialog_battery.js create mode 100644 server/app/templates/_widget_dialog_battery.html create mode 100644 server/app/widgets/battery.py create mode 100644 server/tests/test_widgets_battery.py diff --git a/docs/widgets.md b/docs/widgets.md index 3edfa15..285d198 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -2,9 +2,9 @@ A frame's panel isn't one fixed "mode" anymore -- it holds N independently placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/ -weather), like arranging icons on an Android home screen. A frame can hold -several widgets of the same type (e.g. two photo widgets pointed at -different Immich albums side by side). +weather/battery), like arranging icons on an Android home screen. A frame +can hold several widgets of the same type (e.g. two photo widgets pointed +at different Immich albums side by side). This replaced an earlier design where `Frame.mode` picked exactly one full-panel renderer; that column (and the other now-dead per-mode `Frame` columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) @@ -21,15 +21,15 @@ a button press does. - `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type` (`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | - `"text"` | `"weather"`), `x`/`y`/`w`/`h` + `"text"` | `"weather"` | `"battery"`), `x`/`y`/`w`/`h` (grid cells), `sort_order`. Widgets never overlap (enforced server-side in `routers/api_widgets.py`, re-validated regardless of what the client already checked) -- that's what keeps compositing simple: no z-order, no blending, just N independent regions pasted onto one shared canvas. - Per-type 1:1 extension tables -- `PhotoWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, - `StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`, each - keyed by `widget_id` with + `StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`, + `BatteryWidgetConfig`, each keyed by `widget_id` with `ondelete="CASCADE"` -- rather than one wide table with every type's mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich text (paragraphs of styled runs), never raw HTML -- see @@ -46,7 +46,12 @@ a button press does. `WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded weather strip (still present and unchanged, `weather_*` columns) out into its own placeable widget type (migration 24) -- see "Weather - widget" below. + widget" below. `BatteryWidgetConfig` (migration 25) is the odd one out + -- its actual content (`Frame.battery_percent`/`battery_as_of`) isn't + in this table at all, already existing frame-level state set by + `routers/device.py`'s `frame_battery` regardless of whether a battery + widget is even placed; the config row only holds a display-mode + setting (`"compact"` | `"detailed"`). - `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not `frame_id`) since a frame can now have more than one independent calendar/tasks widget, each with its own included set. Identical @@ -73,8 +78,11 @@ Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`): photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1, weather 2x2 (its hourly/daily strips need the room; current/multi_city -modes would tolerate smaller, but every mode shares one footprint value). -Enforced both client-side +modes would tolerate smaller, but every mode shares one footprint value), +battery 1x1 (just an icon + a percent, legible even at a single cell, +like photos/static -- though see `MIN_FOOTPRINT`'s own comment in +`grid.py` on a mobile-width gear-icon click-target gap at that size, +already pre-existing for photos/static too). Enforced both client-side (UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`) and server-side (`routers/api_widgets.py`) -- the client is never trusted alone. @@ -83,7 +91,7 @@ alone. `app/widgets/` is the render/action registry -- one module per `widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`, -`static_image.py`, `text.py`, `weather.py`), each exposing: +`static_image.py`, `text.py`, `weather.py`, `battery.py`), each exposing: - `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`: an unquantized RGB image exactly `target_w x target_h`, the widget's @@ -94,9 +102,10 @@ alone. - `ACTIONS: dict[str, Callable]` -- named button actions this type supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` for whiteboard and weather -- both throttled external fetches with a - forced-refetch action). Empty for tasks, static image, and text -- - nothing to advance/back/force for a passive checklist, a fixed - uploaded image, or a fixed block of authored text. + forced-refetch action). Empty for tasks, static image, text, and + battery -- nothing to advance/back/force for a passive checklist, a + fixed uploaded image, a fixed block of authored text, or a number the + device itself pushes on every wake. - `ACTION_LABELS: dict[str, str]` -- human labels for the button- assignment UI. @@ -265,6 +274,28 @@ by actually running one through the real quantize pass during development). Used for every provider's rendering, not just when EC is selected as the provider. +## Battery widget + +The simplest widget type (`models.BatteryWidgetConfig`, `app/widgets/ +battery.py`): shows this frame's own last-reported battery level. Unlike +every other widget type, there's no live upstream to poll and nothing to +cache -- the content is `Frame.battery_percent`/`battery_as_of`, set by +`routers/device.py`'s `frame_battery` on every device wake-on-battery +report, which already existed for the Device panel's own history chart +regardless of whether a battery widget is placed anywhere. The widget's +own config is just a display mode: `"compact"` (icon + percent) or +`"detailed"` (default, adds `routers/common.py`'s existing +`battery_estimate_s` time-remaining estimate and the last report's age). +`render()` falls back to a "No reports yet" placeholder for a frame that +has never reported (never run on battery, or not yet claimed by a +device) rather than showing a stale or fabricated number. The battery +icon fill color (red/yellow/green by percent) uses the same exact-panel- +ink-RGB approach as the weather icons above and `manage_overlay.py`'s own +battery glyph on the "scan to manage" overlay -- a separate, unrelated +piece of code with its own fixed small size, not shared with this +widget, but drawing from the same thresholds/colors so a battery glyph +reads the same wherever one shows up on a panel. + ## Known gaps (Phase 6, not yet done) The original 8-phase rollout plan's last phase is still open: diff --git a/server/app/grid.py b/server/app/grid.py index 6a172d5..807106b 100644 --- a/server/app/grid.py +++ b/server/app/grid.py @@ -27,6 +27,13 @@ GRID_SHORT = 5 # without truncating on every row; weather needs enough room for its # hourly/daily strips to stay legible (its current/multi_city modes # would tolerate smaller, but every mode shares one footprint value). +# battery is just an icon + a percent (+ two optional small lines in +# "detailed" mode) -- legible even at a single cell, like photos/static. +# NOTE: a 1x1 widget-box on a narrow mobile canvas can clip its own +# gear/remove buttons behind theme.css's overflow: hidden (their fixed +# pixel offsets overflow the box's clipped width) -- a pre-existing +# layout gap that already affects photos/static at 1x1 too, not fixed +# here; see the finding called out where this was discovered. MIN_FOOTPRINT: dict[str, tuple[int, int]] = { "photos": (1, 1), "calendar": (3, 2), @@ -35,6 +42,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = { "static": (1, 1), "text": (2, 1), "weather": (2, 2), + "battery": (1, 1), } Rect = tuple[int, int, int, int] # (x, y, w, h) diff --git a/server/app/migration.py b/server/app/migration.py index d1f51b3..eecb004 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -656,6 +656,27 @@ def _migration_24(conn) -> None: )) +def _migration_25(conn) -> None: + """New widget type: battery (see models.BatteryWidgetConfig, + app/widgets/battery.py) -- shows the frame's own last-reported + battery level. No live upstream to poll and nothing to cache: unlike + every other widget type added since migration 20, the content is + frame-level state (Frame.battery_percent/battery_as_of) that already + existed before this widget did, so the only new column is a display + mode. + + Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as + migration 20/21/23/24's own comments: create_all always reflects + models.py's CURRENT shape, so replaying the full chain on an old + database could collide with a later migration's ALTER TABLE on this + same table.""" + conn.execute(text( + "CREATE TABLE battery_widget_configs (" + "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " + "mode TEXT NOT NULL DEFAULT 'detailed')" + )) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -681,6 +702,7 @@ MIGRATIONS = [ (22, _migration_22), (23, _migration_23), (24, _migration_24), + (25, _migration_25), ] diff --git a/server/app/models.py b/server/app/models.py index 7253f2d..cd6f86e 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -433,7 +433,7 @@ class Widget(Base): id: Mapped[int] = mapped_column(primary_key=True) frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) - widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather" + widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather" | "battery" x: Mapped[int] = mapped_column(Integer) y: Mapped[int] = mapped_column(Integer) w: Mapped[int] = mapped_column(Integer) @@ -643,6 +643,22 @@ class StaticWidgetConfig(Base): display_mode: Mapped[str] = mapped_column(String, default="crop_fill") +class BatteryWidgetConfig(Base): + """One battery widget's display settings -- another no-live-upstream + type like StaticWidgetConfig/TextWidgetConfig, just showing existing + frame-level state (Frame.battery_percent/battery_as_of, already set + by routers/device.py's frame_battery on every device report) instead + of anything the widget itself fetches or the user authors. `mode` + "compact" is icon + percent only; "detailed" (default) adds the + routers.common.battery_estimate_s time-remaining estimate and the + last report's age.""" + + __tablename__ = "battery_widget_configs" + + widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) + mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed + + # widget_type -> its per-type extension table, keyed by widget_id. Used # by db.widget_locked() to resolve the right config row without importing # app/widgets/'s heavier render/action registry just for this lookup. @@ -652,6 +668,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = { "whiteboard": WhiteboardWidgetConfig, "tasks": TaskWidgetConfig, "static": StaticWidgetConfig, + "battery": BatteryWidgetConfig, "text": TextWidgetConfig, "weather": WeatherWidgetConfig, } diff --git a/server/app/routers/api_layouts.py b/server/app/routers/api_layouts.py index 3cd7683..8f2d80b 100644 --- a/server/app/routers/api_layouts.py +++ b/server/app/routers/api_layouts.py @@ -62,6 +62,7 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = { "static": ("display_mode", "original_filename"), "text": ("content", "font_size", "font_family", "align", "background_color"), "whiteboard": ("user_id", "url"), + "battery": ("mode",), } # widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind) diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index b206f1f..bd43376 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -54,6 +54,7 @@ from ..models import ( ) from ..text_content import has_text, parse_rich_text from ..widgets import WIDGET_TYPES +from ..widgets import battery as battery_widget from ..widgets import text as text_widget from .common import ( calendar_sources_for_widget, @@ -291,6 +292,8 @@ 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), + # battery + battery_mode: str | None = Form(None), ): """Every field optional -- same partial-update, form-urlencoded convention as the old frame-level api_config_save, now scoped to one @@ -417,6 +420,10 @@ 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)) + elif widget.widget_type == "battery": + with widget_locked(db, frame.id, widget.id) as (_, _, bcfg): + if battery_mode is not None: + bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed" with frame_locked(db, frame.id) as cfg: cfg.stats_config_saves += 1 return {"status": "saved"} @@ -1059,6 +1066,25 @@ def api_widget_preview_text( return Response(content=png, media_type="image/png") +# --- Battery: preview -------------------------------------------------------- + +@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/battery") +def api_widget_preview_battery( + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db) +): + """Unlike every other preview endpoint, there's no "not configured + yet" 400 case -- the content is frame-level state (battery_percent) + that either exists or doesn't, and render() already degrades to a + "No reports yet" placeholder either way, same as a live device + render would.""" + frame, widget = frame_widget + _require_widget_type(widget, "battery") + png = battery_widget.render_preview_png( + db, frame, widget, orientation=frame.orientation, palette_rgb=frame.palette_rgb + ) + return Response(content=png, media_type="image/png") + + # --- Whiteboard: source/preview ------------------------------------------ class WhiteboardSourceRequest(BaseModel): diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index bcb7819..827adc9 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -29,6 +29,7 @@ from ..image_pipeline import ( palette_to_hex, ) from ..models import ( + BatteryWidgetConfig, CalendarWidgetConfig, Frame, FrameCalendar, @@ -274,4 +275,10 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = "weather_provider_labels": weather.PROVIDER_LABELS, }) + if widget.widget_type == "battery": + battery_cfg = db.get(BatteryWidgetConfig, widget.id) + return templates.TemplateResponse("_widget_dialog_battery.html", { + "request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, + }) + raise HTTPException(400, f"Unknown widget type: {widget.widget_type}") diff --git a/server/app/static/common.js b/server/app/static/common.js index c2cde42..56e2ace 100644 --- a/server/app/static/common.js +++ b/server/app/static/common.js @@ -62,7 +62,7 @@ // UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns). const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks', - static: 'Static image', text: 'Text', weather: 'Weather', + static: 'Static image', text: 'Text', weather: 'Weather', battery: 'Battery', }; function showStatus(ok, message) { diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index 0d1fab6..6f47e7b 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -287,10 +287,12 @@ window.addEventListener('resize', () => { const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog, + battery: initBatteryDialog, }; const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog, + battery: closeBatteryDialog, }; let openDialogWidgetType = null; diff --git a/server/app/static/widget_dialog_battery.js b/server/app/static/widget_dialog_battery.js new file mode 100644 index 0000000..a982970 --- /dev/null +++ b/server/app/static/widget_dialog_battery.js @@ -0,0 +1,37 @@ +// Battery widget dialog: display-mode setting and the rendered preview. +// Not a page-load script -- frame_layout.js fetches this widget's dialog +// HTML fragment, injects it into the shared , points +// window.FRAME_API at this specific widget +// (/api/frames/{id}/widgets/{widget_id}), then calls initBatteryDialog(). + +function loadBatteryPreview() { + document.getElementById('battery-preview').src = `${window.FRAME_API}/preview/battery?_=${Date.now()}`; +} + +function initBatteryDialog() { + document.getElementById('battery-config-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = new URLSearchParams({ + battery_mode: document.getElementById('battery_mode').value, + }); + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadBatteryPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + + document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview); + loadBatteryPreview(); +} + +function closeBatteryDialog() { + // Nothing to tear down -- no poll interval, no upload state. +} diff --git a/server/app/templates/_widget_dialog_battery.html b/server/app/templates/_widget_dialog_battery.html new file mode 100644 index 0000000..c2cd274 --- /dev/null +++ b/server/app/templates/_widget_dialog_battery.html @@ -0,0 +1,23 @@ +

Battery widget

+ +
+

Settings

+

Shows this frame's own last-reported battery level -- + nothing to configure beyond how much detail to show.

+
+ + +
+
+ +
+

Preview

+

How this widget currently renders.

+ Battery widget preview + +
diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index b2af519..cc3b163 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -75,6 +75,7 @@ + {% endblock %} diff --git a/server/app/widgets/__init__.py b/server/app/widgets/__init__.py index 946b1e5..4974ae2 100644 --- a/server/app/widgets/__init__.py +++ b/server/app/widgets/__init__.py @@ -36,7 +36,7 @@ Each module in this package exposes: from __future__ import annotations -from . import calendar, photos, static_image, tasks, text, weather, whiteboard +from . import battery, calendar, photos, static_image, tasks, text, weather, whiteboard WIDGET_TYPES = { "photos": photos, @@ -46,4 +46,5 @@ WIDGET_TYPES = { "static": static_image, "text": text, "weather": weather, + "battery": battery, } diff --git a/server/app/widgets/battery.py b/server/app/widgets/battery.py new file mode 100644 index 0000000..35ee28f --- /dev/null +++ b/server/app/widgets/battery.py @@ -0,0 +1,150 @@ +"""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, ImageFont +from sqlalchemy.orm import Session + +from ..image_pipeline import DEFAULT_PALETTE_RGB, _quantize, draw_text, logical_render_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] = {} + +BG = (255, 255, 255) +MUTED = (110, 110, 110) + +# Same thresholds/colors as manage_overlay.py's own battery glyph (not +# shared code -- that one draws onto the manage-QR overlay in a fixed +# small size, this one fills an arbitrary widget region -- but the +# "how worried should I be" color story should read the same wherever a +# battery glyph shows up on a panel). Exact panel ink RGB values, not +# arbitrary reds/yellows/greens -- a flat fill already at a palette +# color quantizes with zero dithering error once the whole composited +# canvas gets quantized, where an off-palette color would dither into a +# visible speckle at these small on-panel sizes. +_LOW = DEFAULT_PALETTE_RGB[3] # red +_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow +_HIGH = DEFAULT_PALETTE_RGB[5] # green + + +def _fill_color(percent: int) -> tuple[int, int, int]: + if percent <= 15: + return _LOW + if percent <= 40: + return _MEDIUM + return _HIGH + + +def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int) -> None: + stroke = max(2, icon_h // 12) + nub_w = max(3, icon_w // 10) + nub_h = icon_h // 2 + x0 = cx - (icon_w + nub_w) // 2 + y0 = top + inner_x0, inner_y0 = x0 + stroke, y0 + stroke + inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke + fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100)) + if fill_x1 > inner_x0: + draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_fill_color(percent)) + draw.rectangle([x0, y0, x0 + icon_w, y0 + icon_h], outline=(0, 0, 0), width=stroke) + nub_y = y0 + (icon_h - nub_h) // 2 + draw.rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], fill=(0, 0, 0)) + + +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 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" + + img = Image.new("RGB", (target_w, target_h), BG) + draw = ImageDraw.Draw(img) + cx = target_w // 2 + + icon_h = max(20, min(target_w, target_h) // 3) + icon_w = int(icon_h * 1.8) + icon_top = max(4, target_h // 8) + _draw_icon(draw, cx, icon_top, icon_w, icon_h, percent) + + pct_font_size = max(18, min(target_w, target_h) // 3) + pct_font = ImageFont.load_default(size=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) + + if mode == "detailed": + 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)}") + + small_font_size = max(11, pct_font_size // 3) + small_font = ImageFont.load_default(size=small_font_size) + y = pct_y + pct_font_size + 12 + for line in lines: + if y + small_font_size > target_h - 4: + break + lbbox = draw.textbbox((0, 0), line, font=small_font) + draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font, MUTED) + 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) + 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() diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index 5bbae5b..e7bbe75 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -82,6 +82,9 @@ def test_expected_columns_exist_on_current_schema(): assert "weather_widget_configs" in inspector.get_table_names() # migration 24 weather_widget_columns = {c["name"] for c in inspector.get_columns("weather_widget_configs")} assert {"mode", "provider", "city_latitude", "cities"} <= weather_widget_columns + assert "battery_widget_configs" in inspector.get_table_names() # migration 25 + battery_widget_columns = {c["name"] for c in inspector.get_columns("battery_widget_configs")} + assert "mode" in battery_widget_columns # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- @@ -192,22 +195,24 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db with db_module.engine.begin() as conn: # static_widget_configs/text_widget_configs are migration 20/21 # tables, saved_layouts/saved_layout_widgets/saved_layout_ - # sources/saved_layout_button_actions are migration 23's, and - # weather_widget_configs is migration 24's (all post-16, like the + # sources/saved_layout_button_actions are migration 23's, + # weather_widget_configs is migration 24's, and + # battery_widget_configs is migration 25's (all post-16, like the # rest of this list) -- dropped here too so a real version-15 # database is what's actually being simulated, not "version 15 # plus tables that wouldn't exist yet". Harmless to omit as long # as no migration after the one that creates a table also ALTERs # or re-CREATEs it (that's what let a create_all-based migration # go unlisted safely so far), but static_widget_configs/ - # text_widget_configs/weather_widget_configs all use a raw - # CREATE TABLE (not create_all -- see migration 20's own - # docstring on why), so an already-present one is a real "table - # already exists" collision, not a silent no-op. + # text_widget_configs/weather_widget_configs/battery_widget_ + # configs all use a raw CREATE TABLE (not create_all -- see + # migration 20's own docstring on why), so an already-present one + # is a real "table already exists" collision, not a silent no-op. for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists", "calendar_widget_configs", "photo_widget_configs", "static_widget_configs", "text_widget_configs", "saved_layout_button_actions", "saved_layout_sources", - "saved_layout_widgets", "saved_layouts", "weather_widget_configs", "widgets"): + "saved_layout_widgets", "saved_layouts", "weather_widget_configs", + "battery_widget_configs", "widgets"): conn.execute(text(f"DROP TABLE {table}")) conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text( @@ -275,11 +280,11 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d calendar_key columns either.""" with db_module.engine.begin() as conn: # static_widget_configs/text_widget_configs/saved_layout_*/ - # weather_widget_configs dropped too -- see the comment on the - # identical setup in + # weather_widget_configs/battery_widget_configs dropped too -- + # see the comment on the identical setup in # test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations - # 20/21/23/24's raw CREATE TABLE collides with an already-present table - # otherwise, since this test replays 17 through 24 and none of + # 20/21/23/24/25's raw CREATE TABLE collides with an already-present table + # otherwise, since this test replays 17 through 25 and none of # these tables would really exist yet at a genuine pre-migration-17 # schema_version). conn.execute(text("DROP TABLE task_widget_configs")) @@ -292,6 +297,7 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d conn.execute(text("DROP TABLE saved_layout_widgets")) conn.execute(text("DROP TABLE saved_layouts")) conn.execute(text("DROP TABLE weather_widget_configs")) + conn.execute(text("DROP TABLE battery_widget_configs")) conn.execute(text( "CREATE TABLE calendar_widget_configs (" "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " @@ -393,7 +399,8 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists", "calendar_widget_configs", "photo_widget_configs", "static_widget_configs", "text_widget_configs", "saved_layout_button_actions", "saved_layout_sources", - "saved_layout_widgets", "saved_layouts", "weather_widget_configs", "widgets"): + "saved_layout_widgets", "saved_layouts", "weather_widget_configs", + "battery_widget_configs", "widgets"): conn.execute(text(f"DROP TABLE {table}")) conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text( diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py index 92a6f54..cbe14eb 100644 --- a/server/tests/test_widget_config_and_queue_endpoints.py +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -14,6 +14,7 @@ import io from PIL import Image from app.models import ( + BatteryWidgetConfig, CalendarWidgetConfig, Frame, PhotoWidgetConfig, @@ -93,6 +94,18 @@ def _add_weather_widget(db_session, **cfg_kwargs) -> Widget: return widget +def _add_battery_widget(db_session, **cfg_kwargs) -> Widget: + import time + + widget = Widget(frame_id=1, widget_type="battery", x=0, y=0, w=1, h=1, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(BatteryWidgetConfig(widget_id=widget.id, **cfg_kwargs)) + db_session.commit() + return widget + + def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes: buf = io.BytesIO() Image.new("RGB", size, color).save(buf, format="PNG") @@ -277,6 +290,38 @@ def test_config_save_rejects_an_unrecognized_static_display_mode(client, db_sess assert cfg.display_mode == "crop_fill" +def test_config_save_updates_a_battery_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_battery_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"battery_mode": "compact"}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(BatteryWidgetConfig, widget.id) + assert cfg.mode == "compact" + + +def test_config_save_rejects_an_unrecognized_battery_mode(client, db_session): + """Falls back to the default rather than erroring -- same "clamp, + don't reject" posture as the other config-save fields.""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_battery_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"battery_mode": "graph"}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(BatteryWidgetConfig, widget.id) + assert cfg.mode == "detailed" + + def test_config_save_updates_a_text_widget(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) widget = _add_text_widget(db_session) @@ -502,6 +547,38 @@ def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session): assert resp.status_code == 400 +# --- battery: preview --------------------------------------------------- + +def test_preview_battery_renders_even_before_any_report(client, db_session): + """Unlike every other widget type's preview endpoint, there's no + "not configured yet" 400 -- render() always has something to show + (a placeholder, here, since the frame's never reported).""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_battery_widget(db_session) + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/battery") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == "image/png" + + +def test_preview_battery_renders_after_a_report(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_battery_widget(db_session) + frame = db_session.get(Frame, 1) + frame.battery_percent = 77 + db_session.commit() + + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/battery") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == "image/png" + + +def test_preview_battery_400s_for_a_widget_that_is_not_battery(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_static_widget(db_session) + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/battery") + assert resp.status_code == 400 + + # --- weather: location/cities/preview --------------------------------- def _mock_geocode(monkeypatch, label="Portland, Oregon, United States", latitude=45.5, longitude=-122.6): diff --git a/server/tests/test_widgets_battery.py b/server/tests/test_widgets_battery.py new file mode 100644 index 0000000..69775ff --- /dev/null +++ b/server/tests/test_widgets_battery.py @@ -0,0 +1,83 @@ +"""app.widgets.battery -- unit-level, no HTTP: constructs Widget/ +BatteryWidgetConfig rows directly and sets Frame.battery_percent/ +battery_as_of straight on the Frame row, same as routers/device.py's +frame_battery would.""" + +from __future__ import annotations + +import time + +from app import widgets +from app.models import BatteryWidgetConfig, Frame, Widget + + +def _make_widget(db_session, mode="detailed") -> tuple[Frame, Widget]: + frame = db_session.get(Frame, 1) + widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1, + sort_order=0, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(BatteryWidgetConfig(widget_id=widget.id, mode=mode)) + db_session.commit() + return frame, widget + + +def test_render_shows_a_placeholder_when_frame_has_never_reported(db_session): + frame, widget = _make_widget(db_session) + assert frame.battery_percent == -1 + img = widgets.battery.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" + + +def test_render_shows_the_reported_percent(db_session): + frame, widget = _make_widget(db_session) + frame.battery_percent = 42 + frame.battery_as_of = time.time() + db_session.commit() + img = widgets.battery.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" + + +def test_render_compact_mode_omits_the_estimate_lines(db_session): + frame, widget = _make_widget(db_session, mode="compact") + frame.battery_percent = 80 + frame.battery_as_of = time.time() + db_session.commit() + img = widgets.battery.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + + +def test_render_at_minimum_grid_footprint(db_session): + """grid.MIN_FOOTPRINT["battery"] is (1, 1) cells -- on an 8x5 grid + against a full 800x480 panel that's a 100x96 box, the smallest a + battery widget can actually be placed at.""" + frame, widget = _make_widget(db_session) + frame.battery_percent = 15 + db_session.commit() + img = widgets.battery.render(db_session, frame, widget, 100, 96) + assert img.size == (100, 96) + + +def test_render_falls_back_to_detailed_mode_with_no_config_row(db_session): + """widget_id has no BatteryWidgetConfig row at all -- render() must + not raise, matching every other widget type's "never raises for a + foreseeable failure" contract.""" + frame = db_session.get(Frame, 1) + widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1, + sort_order=0, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.commit() + frame.battery_percent = 60 + db_session.commit() + img = widgets.battery.render(db_session, frame, widget, 100, 96) + assert img.size == (100, 96) + + +def test_no_button_actions(): + """A number the device itself pushes on every wake -- nothing to + advance/back/check.""" + assert widgets.battery.ACTIONS == {} + assert widgets.battery.ACTION_LABELS == {}