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
+8
View File
@@ -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)
+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 = [
(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
View File
@@ -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,
}
+1
View File
@@ -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)
+26
View File
@@ -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):
+7
View File
@@ -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}")
+1 -1
View File
@@ -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) {
+2
View File
@@ -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>
+1
View File
@@ -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 %}
+2 -1
View File
@@ -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,
}
+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()