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
157 lines
5.3 KiB
Python
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}
|