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.
This commit is contained in:
+44
-13
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+18
-1
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -75,6 +75,7 @@
|
||||
<script src="/static/widget_dialog_static.js"></script>
|
||||
<script src="/static/widget_dialog_text.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/saved_layouts.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 == {}
|
||||
Reference in New Issue
Block a user