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
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
#!/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}
|
||||
Reference in New Issue
Block a user