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:
kvenanzi
2026-06-02 16:39:07 -05:00
parent 7df64eecec
commit eb70456755
20 changed files with 3592 additions and 2 deletions
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Unit tests for daemon/icon_assets.py — APP-01 tray icon asset layer.
Run: python -m pytest daemon/tests/test_windows_icon.py -x -q
"""
from pathlib import Path
import pytest
from daemon.icon_assets import _expand565, load_logo_rgba
# The "fixture" is the real in-repo firmware logo header — trusted asset.
LOGO_H = Path(__file__).parent.parent.parent / "firmware" / "src" / "logo.h"
# ---------------------------------------------------------------------------
# Task 1: logo parse + RGB565->RGB888 expand
# ---------------------------------------------------------------------------
def test_logo_parse():
"""load_logo_rgba returns an 80x80 RGBA image; dominant opaque color is #DE7552."""
img = load_logo_rgba(str(LOGO_H))
assert img.mode == "RGBA", f"Expected RGBA, got {img.mode}"
assert img.size == (80, 80), f"Expected (80,80), got {img.size}"
# Collect all fully-opaque pixels and find the dominant RGB.
from collections import Counter
px = img.load()
opaque = [
(px[x, y][0], px[x, y][1], px[x, y][2])
for y in range(80)
for x in range(80)
if px[x, y][3] == 255
]
assert opaque, "No fully-opaque pixels found in logo"
dominant = Counter(opaque).most_common(1)[0][0]
# Brand hex #DE7552 = (222, 117, 82)
assert dominant == (222, 117, 82), (
f"Expected dominant opaque color (222,117,82), got {dominant}"
)
def test_rgb565_expand():
"""_expand565 uses proper rounding, not a *8 bit-shift."""
assert _expand565(0xDBAA) == (222, 117, 82), (
f"0xDBAA should be (222,117,82), got {_expand565(0xDBAA)}"
)
assert _expand565(0x0000) == (0, 0, 0), (
f"0x0000 should be (0,0,0), got {_expand565(0x0000)}"
)
assert _expand565(0xFFFF) == (255, 255, 255), (
f"0xFFFF should be (255,255,255), got {_expand565(0xFFFF)}"
)
def test_logo_parse_bounds_check():
"""load_logo_rgba raises ValueError if the data array length != W*H*3."""
import tempfile, os
# Write a malformed header with fewer bytes than expected
malformed = (
"#pragma once\n"
"static const uint8_t logo_data[100] = {\n"
" 0x00, 0x01, 0x02\n"
"};\n"
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".h", delete=False) as f:
f.write(malformed)
path = f.name
try:
with pytest.raises(ValueError):
load_logo_rgba(path)
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# Task 2: state->image corner-bubble compositor
# ---------------------------------------------------------------------------
def test_state_icon_bubble():
"""state_icon returns distinct 32x32 RGBA images per state with correct bubble color."""
from daemon.icon_assets import state_icon
base = load_logo_rgba(str(LOGO_H))
connected = state_icon(base, "connected", 32)
scanning = state_icon(base, "scanning", 32)
error = state_icon(base, "error", 32)
# All are 32x32 RGBA
for name, img in [("connected", connected), ("scanning", scanning), ("error", error)]:
assert img.mode == "RGBA", f"{name}: expected RGBA, got {img.mode}"
assert img.size == (32, 32), f"{name}: expected (32,32), got {img.size}"
# The three images must be pixel-distinct from each other
def img_bytes(img):
return img.tobytes()
assert img_bytes(connected) != img_bytes(scanning), "connected and scanning should differ"
assert img_bytes(connected) != img_bytes(error), "connected and error should differ"
assert img_bytes(scanning) != img_bytes(error), "scanning and error should differ"
# Sample the bottom-right corner region; the closest bubble color should match the state.
# Bubble colors: connected (60,200,90), scanning (240,180,40), error (220,60,60)
BUBBLE_COLORS = {
"connected": (60, 200, 90),
"scanning": (240, 180, 40),
"error": (220, 60, 60),
}
def color_distance(c1, c2):
return sum((a - b) ** 2 for a, b in zip(c1, c2)) ** 0.5
def nearest_bubble(pixel_rgb):
return min(BUBBLE_COLORS.items(), key=lambda kv: color_distance(pixel_rgb, kv[1]))[0]
size = 32
r = size // 3
# Sample the center of the expected bubble region (bottom-right corner)
bx = size - r // 2 - 2
by = size - r // 2 - 2
bx = max(0, min(bx, size - 1))
by = max(0, min(by, size - 1))
for state_name, img in [("connected", connected), ("scanning", scanning), ("error", error)]:
px = img.load()
pixel = px[bx, by][:3] # RGB only
nearest = nearest_bubble(pixel)
assert nearest == state_name, (
f"State '{state_name}': bottom-right corner pixel {pixel} is nearest to "
f"'{nearest}' bubble, expected '{state_name}'"
)
def test_build_icons_once():
"""build_state_icons returns a dict with connected/scanning/error as distinct Images."""
from daemon.icon_assets import build_state_icons
base = load_logo_rgba(str(LOGO_H))
icons = build_state_icons(base)
assert set(icons.keys()) == {"connected", "scanning", "error"}, (
f"Expected keys connected/scanning/error, got {set(icons.keys())}"
)
# All distinct
def img_bytes(img):
return img.tobytes()
imgs = list(icons.values())
assert img_bytes(imgs[0]) != img_bytes(imgs[1])
assert img_bytes(imgs[0]) != img_bytes(imgs[2])
assert img_bytes(imgs[1]) != img_bytes(imgs[2])
def test_state_icon_unknown_state():
"""state_icon raises KeyError/ValueError on an unknown state string."""
from daemon.icon_assets import state_icon
base = load_logo_rgba(str(LOGO_H))
with pytest.raises((KeyError, ValueError)):
state_icon(base, "unknown_state", 32)