Add battery widget (device's own last-reported level, no live upstream)
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 50s

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.
This commit is contained in:
2026-07-27 19:02:21 +00:00
parent 90a014d161
commit eb7127718b
17 changed files with 521 additions and 28 deletions
+44 -13
View File
@@ -2,9 +2,9 @@
A frame's panel isn't one fixed "mode" anymore -- it holds N independently A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/ placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
weather), like arranging icons on an Android home screen. A frame can hold weather/battery), like arranging icons on an Android home screen. A frame
several widgets of the same type (e.g. two photo widgets pointed at can hold several widgets of the same type (e.g. two photo widgets pointed
different Immich albums side by side). at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame` full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) 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` - `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | (`"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 (grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client `routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order, already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas. no blending, just N independent regions pasted onto one shared canvas.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`, - Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`, each `StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
keyed by `widget_id` with `BatteryWidgetConfig`, each keyed by `widget_id` with
`ondelete="CASCADE"` -- rather than one wide table with every type's `ondelete="CASCADE"` -- rather than one wide table with every type's
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
text (paragraphs of styled runs), never raw HTML -- see text (paragraphs of styled runs), never raw HTML -- see
@@ -46,7 +46,12 @@ a button press does.
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded `WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
weather strip (still present and unchanged, `weather_*` columns) out weather strip (still present and unchanged, `weather_*` columns) out
into its own placeable widget type (migration 24) -- see "Weather 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 - `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`frame_id`) since a frame can now have more than one independent `frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical 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 photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1, size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
weather 2x2 (its hourly/daily strips need the room; current/multi_city weather 2x2 (its hourly/daily strips need the room; current/multi_city
modes would tolerate smaller, but every mode shares one footprint value). modes would tolerate smaller, but every mode shares one footprint value),
Enforced both client-side 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`) (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 and server-side (`routers/api_widgets.py`) -- the client is never trusted
alone. alone.
@@ -83,7 +91,7 @@ alone.
`app/widgets/` is the render/action registry -- one module per `app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`, `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`: - `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 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 - `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard and weather -- both throttled external fetches with a for whiteboard and weather -- both throttled external fetches with a
forced-refetch action). Empty for tasks, static image, and text -- forced-refetch action). Empty for tasks, static image, text, and
nothing to advance/back/force for a passive checklist, a fixed battery -- nothing to advance/back/force for a passive checklist, a
uploaded image, or a fixed block of authored text. 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- - `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI. 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 development). Used for every provider's rendering, not just when EC is
selected as the provider. 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) ## Known gaps (Phase 6, not yet done)
The original 8-phase rollout plan's last phase is still open: The original 8-phase rollout plan's last phase is still open:
+8
View File
@@ -27,6 +27,13 @@ GRID_SHORT = 5
# without truncating on every row; weather needs enough room for its # without truncating on every row; weather needs enough room for its
# hourly/daily strips to stay legible (its current/multi_city modes # hourly/daily strips to stay legible (its current/multi_city modes
# would tolerate smaller, but every mode shares one footprint value). # 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]] = { MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1), "photos": (1, 1),
"calendar": (3, 2), "calendar": (3, 2),
@@ -35,6 +42,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"static": (1, 1), "static": (1, 1),
"text": (2, 1), "text": (2, 1),
"weather": (2, 2), "weather": (2, 2),
"battery": (1, 1),
} }
Rect = tuple[int, int, int, int] # (x, y, w, h) Rect = tuple[int, int, int, int] # (x, y, w, h)
+22
View File
@@ -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 = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -681,6 +702,7 @@ MIGRATIONS = [
(22, _migration_22), (22, _migration_22),
(23, _migration_23), (23, _migration_23),
(24, _migration_24), (24, _migration_24),
(25, _migration_25),
] ]
+18 -1
View File
@@ -433,7 +433,7 @@ class Widget(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) 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) x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer) y: Mapped[int] = mapped_column(Integer)
w: 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") 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 # widget_type -> its per-type extension table, keyed by widget_id. Used
# by db.widget_locked() to resolve the right config row without importing # by db.widget_locked() to resolve the right config row without importing
# app/widgets/'s heavier render/action registry just for this lookup. # app/widgets/'s heavier render/action registry just for this lookup.
@@ -652,6 +668,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"whiteboard": WhiteboardWidgetConfig, "whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig, "tasks": TaskWidgetConfig,
"static": StaticWidgetConfig, "static": StaticWidgetConfig,
"battery": BatteryWidgetConfig,
"text": TextWidgetConfig, "text": TextWidgetConfig,
"weather": WeatherWidgetConfig, "weather": WeatherWidgetConfig,
} }
+1
View File
@@ -62,6 +62,7 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
"static": ("display_mode", "original_filename"), "static": ("display_mode", "original_filename"),
"text": ("content", "font_size", "font_family", "align", "background_color"), "text": ("content", "font_size", "font_family", "align", "background_color"),
"whiteboard": ("user_id", "url"), "whiteboard": ("user_id", "url"),
"battery": ("mode",),
} }
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind) # widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
+26
View File
@@ -54,6 +54,7 @@ from ..models import (
) )
from ..text_content import has_text, parse_rich_text from ..text_content import has_text, parse_rich_text
from ..widgets import WIDGET_TYPES from ..widgets import WIDGET_TYPES
from ..widgets import battery as battery_widget
from ..widgets import text as text_widget from ..widgets import text as text_widget
from .common import ( from .common import (
calendar_sources_for_widget, calendar_sources_for_widget,
@@ -291,6 +292,8 @@ def api_widget_config_save(
weather_units: str | None = Form(None), weather_units: str | None = Form(None),
weather_hourly_interval_hours: int | None = Form(None), weather_hourly_interval_hours: int | None = Form(None),
weather_daily_days: 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 """Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one 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)) wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
if weather_daily_days is not None: if weather_daily_days is not None:
wcfg.daily_days = max(1, min(14, weather_daily_days)) 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: with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -1059,6 +1066,25 @@ def api_widget_preview_text(
return Response(content=png, media_type="image/png") 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 ------------------------------------------ # --- Whiteboard: source/preview ------------------------------------------
class WhiteboardSourceRequest(BaseModel): class WhiteboardSourceRequest(BaseModel):
+7
View File
@@ -29,6 +29,7 @@ from ..image_pipeline import (
palette_to_hex, palette_to_hex,
) )
from ..models import ( from ..models import (
BatteryWidgetConfig,
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameCalendar, FrameCalendar,
@@ -274,4 +275,10 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"weather_provider_labels": weather.PROVIDER_LABELS, "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}") raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
+1 -1
View File
@@ -62,7 +62,7 @@
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns). // UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = { const WIDGET_LABELS = {
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks', 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) { function showStatus(ok, message) {
+2
View File
@@ -287,10 +287,12 @@ window.addEventListener('resize', () => {
const DIALOG_INIT = { const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog, tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
battery: initBatteryDialog,
}; };
const DIALOG_CLOSE = { const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog, tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
battery: closeBatteryDialog,
}; };
let openDialogWidgetType = null; let openDialogWidgetType = null;
@@ -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 <dialog>, 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.
}
@@ -0,0 +1,23 @@
<h2 class="dialog-title">Battery widget</h2>
<section class="card">
<h2 class="card-title">Settings</h2>
<p class="sub">Shows this frame's own last-reported battery level --
nothing to configure beyond how much detail to show.</p>
<form id="battery-config-form">
<label>Display mode
<select id="battery_mode">
<option value="compact" {% if battery_cfg and battery_cfg.mode == 'compact' %}selected{% endif %}>Compact (icon + percent only)</option>
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="battery-preview" alt="Battery widget preview">
<button type="button" class="secondary" id="battery-preview-refresh">Refresh now</button>
</section>
+1
View File
@@ -75,6 +75,7 @@
<script src="/static/widget_dialog_static.js"></script> <script src="/static/widget_dialog_static.js"></script>
<script src="/static/widget_dialog_text.js"></script> <script src="/static/widget_dialog_text.js"></script>
<script src="/static/widget_dialog_weather.js"></script> <script src="/static/widget_dialog_weather.js"></script>
<script src="/static/widget_dialog_battery.js"></script>
<script src="/static/frame_layout.js"></script> <script src="/static/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script> <script src="/static/saved_layouts.js"></script>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -36,7 +36,7 @@ Each module in this package exposes:
from __future__ import annotations 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 = { WIDGET_TYPES = {
"photos": photos, "photos": photos,
@@ -46,4 +46,5 @@ WIDGET_TYPES = {
"static": static_image, "static": static_image,
"text": text, "text": text,
"weather": weather, "weather": weather,
"battery": battery,
} }
+150
View File
@@ -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()
+19 -12
View File
@@ -82,6 +82,9 @@ def test_expected_columns_exist_on_current_schema():
assert "weather_widget_configs" in inspector.get_table_names() # migration 24 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")} weather_widget_columns = {c["name"] for c in inspector.get_columns("weather_widget_configs")}
assert {"mode", "provider", "city_latitude", "cities"} <= weather_widget_columns 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) --- # --- 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: with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs are migration 20/21 # static_widget_configs/text_widget_configs are migration 20/21
# tables, saved_layouts/saved_layout_widgets/saved_layout_ # tables, saved_layouts/saved_layout_widgets/saved_layout_
# sources/saved_layout_button_actions are migration 23's, and # sources/saved_layout_button_actions are migration 23's,
# weather_widget_configs is migration 24's (all post-16, like the # 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 # rest of this list) -- dropped here too so a real version-15
# database is what's actually being simulated, not "version 15 # database is what's actually being simulated, not "version 15
# plus tables that wouldn't exist yet". Harmless to omit as long # plus tables that wouldn't exist yet". Harmless to omit as long
# as no migration after the one that creates a table also ALTERs # 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 # or re-CREATEs it (that's what let a create_all-based migration
# go unlisted safely so far), but static_widget_configs/ # go unlisted safely so far), but static_widget_configs/
# text_widget_configs/weather_widget_configs all use a raw # text_widget_configs/weather_widget_configs/battery_widget_
# CREATE TABLE (not create_all -- see migration 20's own # configs all use a raw CREATE TABLE (not create_all -- see
# docstring on why), so an already-present one is a real "table # migration 20's own docstring on why), so an already-present one
# already exists" collision, not a silent no-op. # 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", for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs", "calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources", "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(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text( 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.""" calendar_key columns either."""
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
# static_widget_configs/text_widget_configs/saved_layout_*/ # static_widget_configs/text_widget_configs/saved_layout_*/
# weather_widget_configs dropped too -- see the comment on the # weather_widget_configs/battery_widget_configs dropped too --
# identical setup in # see the comment on the identical setup in
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations # 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 # 20/21/23/24/25's raw CREATE TABLE collides with an already-present table
# otherwise, since this test replays 17 through 24 and none of # otherwise, since this test replays 17 through 25 and none of
# these tables would really exist yet at a genuine pre-migration-17 # these tables would really exist yet at a genuine pre-migration-17
# schema_version). # schema_version).
conn.execute(text("DROP TABLE task_widget_configs")) 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_layout_widgets"))
conn.execute(text("DROP TABLE saved_layouts")) conn.execute(text("DROP TABLE saved_layouts"))
conn.execute(text("DROP TABLE weather_widget_configs")) conn.execute(text("DROP TABLE weather_widget_configs"))
conn.execute(text("DROP TABLE battery_widget_configs"))
conn.execute(text( conn.execute(text(
"CREATE TABLE calendar_widget_configs (" "CREATE TABLE calendar_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " "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", for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs", "calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources", "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(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars")) conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text( conn.execute(text(
@@ -14,6 +14,7 @@ import io
from PIL import Image from PIL import Image
from app.models import ( from app.models import (
BatteryWidgetConfig,
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
PhotoWidgetConfig, PhotoWidgetConfig,
@@ -93,6 +94,18 @@ def _add_weather_widget(db_session, **cfg_kwargs) -> Widget:
return 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: def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
buf = io.BytesIO() buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG") 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" 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): def test_config_save_updates_a_text_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session) 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 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 --------------------------------- # --- weather: location/cities/preview ---------------------------------
def _mock_geocode(monkeypatch, label="Portland, Oregon, United States", latitude=45.5, longitude=-122.6): def _mock_geocode(monkeypatch, label="Portland, Oregon, United States", latitude=45.5, longitude=-122.6):
+83
View File
@@ -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 == {}