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,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)
|
||||
Reference in New Issue
Block a user