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.
151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
"""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()
|