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