"""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()