Files
clawdmeter/daemon/icon_assets.py
T
kvenanzi eb70456755 Add native Windows host daemon (bleak/WinRT)
Adds a Windows port of the host daemon so the Clawdmeter stays connected
on Windows independent of WSL. Mirrors the macOS daemon and speaks the
existing GATT data service unchanged — no firmware changes.

- claude_usage_daemon_windows.py: Windows-local OAuth token read +
  Anthropic poll + BLE scan/connect/write, with auto-reconnect
  (connect-retry wrapper, zombie-link break, split fast/slow backoff)
- tray_windows.py: pystray login-startup tray app (status icon + Quit)
- autostart_windows.py: winreg HKCU\Run autostart via pythonw.exe
- icon_assets.py: per-state tray icons composited from logo.h
- install-windows.ps1 + daemon/README-windows.md: turnkey setup
- pytest suite: token / poll / reconnect / tray / autostart / no-WSL guard
2026-06-02 16:39:07 -05:00

157 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Icon asset layer for the Windows tray app.
Parses the firmware/src/logo.h RGB565A8 brand logo into a Pillow RGBA image,
expands RGB565->RGB888 with correct rounding, and composites per-state corner
bubbles (green=connected / amber=scanning / red=error) onto the constant brand
mark. All logic is pure and unit-testable off-Windows (Pillow only, no pystray
or winreg here).
Usage::
from daemon.icon_assets import load_logo_rgba, build_state_icons
base = load_logo_rgba("firmware/src/logo.h")
icons = build_state_icons(base) # dict: "connected"/"scanning"/"error" -> Image
"""
import re
from PIL import Image, ImageDraw
# Logo dimensions from logo.h #defines.
W: int = 80
H: int = 80
# Brand hex derived from the dominant opaque RGB565 color in logo.h (D-03).
# 0xDBAA -> RGB888 (222, 117, 82) -> #DE7552
BRAND_HEX: str = "#DE7552"
# Locked corner-bubble RGBA colors (from RESEARCH, verified end-to-end).
BUBBLE: dict[str, tuple[int, int, int, int]] = {
"connected": (60, 200, 90, 255), # green
"scanning": (240, 180, 40, 255), # amber
"error": (220, 60, 60, 255), # red
}
def _expand565(v: int) -> tuple[int, int, int]:
"""Expand a 16-bit RGB565 value to an (R, G, B) tuple using proper rounding.
Uses ``(channel * 255 + max // 2) // max`` per channel, NOT a *8 bit-shift.
A *8 shift loses the low bits and does not correctly round to 255 for 0xFFFF.
Examples::
_expand565(0x0000) == (0, 0, 0)
_expand565(0xFFFF) == (255, 255, 255)
_expand565(0xDBAA) == (222, 117, 82) # brand hex
"""
r5 = (v >> 11) & 0x1F # 5-bit red channel (max 31)
g6 = (v >> 5) & 0x3F # 6-bit green channel (max 63)
b5 = v & 0x1F # 5-bit blue channel (max 31)
r = (r5 * 255 + 15) // 31
g = (g6 * 255 + 31) // 63
b = (b5 * 255 + 15) // 31
return (r, g, b)
def load_logo_rgba(header_path: str) -> Image.Image:
"""Parse the firmware logo.h C header and return an 80x80 Pillow RGBA Image.
The logo.h layout (RGB565A8 planar, little-endian RGB565):
- First ``W * H * 2`` bytes: little-endian RGB565 pixel data
- Next ``W * H`` bytes: 8-bit alpha plane
Args:
header_path: Path to ``firmware/src/logo.h`` (or any compatible header).
Returns:
An ``Image.Image`` of mode ``"RGBA"`` and size ``(W, H)``.
Raises:
ValueError: If the extracted byte array length != ``W * H * 3`` (ASVS V5
bound-check before indexing).
"""
with open(header_path) as f:
txt = f.read()
# Extract the byte array body from: logo_data[N] = { 0xFF, 0xAA, ... };
match = re.search(r'logo_data\[\d+\]\s*=\s*\{(.*?)\};', txt, re.S)
if not match:
raise ValueError(f"Could not find logo_data[] array in {header_path!r}")
body = match.group(1)
raw_bytes = [int(x, 16) for x in re.findall(r'0x([0-9A-Fa-f]{2})', body)]
# Bound-check before indexing (ASVS V5).
expected = W * H * 3 # W*H*2 RGB565 bytes + W*H alpha bytes = 19200
if len(raw_bytes) != expected:
raise ValueError(
f"logo_data byte count mismatch: expected {expected}, got {len(raw_bytes)}"
)
n = W * H
rgb_bytes = raw_bytes[:n * 2] # first 12800 bytes: little-endian RGB565
alpha_bytes = raw_bytes[n * 2:] # last 6400 bytes: 8-bit alpha
img = Image.new("RGBA", (W, H))
px = img.load()
for i in range(n):
# Little-endian: low byte first, high byte second.
v = rgb_bytes[i * 2] | (rgb_bytes[i * 2 + 1] << 8)
r, g, b = _expand565(v)
px[i % W, i // W] = (r, g, b, alpha_bytes[i])
return img
def state_icon(base: Image.Image, state: str, size: int = 32) -> Image.Image:
"""Composite a colored corner bubble onto the brand mark for the given state.
Args:
base: The RGBA brand image (as returned by ``load_logo_rgba``).
state: One of ``"connected"``, ``"scanning"``, or ``"error"``.
Any other value raises ``KeyError`` — no silent fallback.
size: Target icon edge length in pixels (default 32).
Returns:
A new ``Image.Image`` of mode ``"RGBA"`` and size ``(size, size)``.
Raises:
KeyError: If ``state`` is not one of the three known states.
"""
# Raises KeyError on unknown state — no silent default (per plan anti-pattern).
bubble_color = BUBBLE[state]
icon = base.resize((size, size), Image.LANCZOS).convert("RGBA")
draw = ImageDraw.Draw(icon)
# Corner bubble: ~1/3 of the icon, drawn in the bottom-right corner.
r = size // 3
x0 = size - r - 1
y0 = size - r - 1
x1 = size - 2
y1 = size - 2
draw.ellipse([x0, y0, x1, y1], fill=bubble_color)
return icon
def build_state_icons(
base: Image.Image,
size: int = 32,
) -> dict[str, Image.Image]:
"""Build all three connection-state icons from the brand base image.
Build them once at startup; swap ``icon.icon = icons[state]`` in the tray
loop — never recomposite per tick (per RESEARCH anti-pattern).
Args:
base: The RGBA brand image (as returned by ``load_logo_rgba``).
size: Target icon edge length in pixels (default 32).
Returns:
A dict mapping ``"connected"``, ``"scanning"``, and ``"error"`` to
their respective composited ``Image.Image`` objects.
"""
return {state: state_icon(base, state, size) for state in BUBBLE}