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,328 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for daemon/tray_windows.py — APP-01.
|
||||
|
||||
Covers:
|
||||
TrayState scalar setters and initial state
|
||||
header_text() for all three states including last_sync=None
|
||||
daemon main() accepts tray_state and populates ts.loop / ts.stop_event
|
||||
Quit routes through loop.call_soon_threadsafe (not stop_event.set directly)
|
||||
Error toast fires only on transition INTO error state (D-04)
|
||||
|
||||
All pystray usage is inside tray_windows.main() (deferred import), so these
|
||||
tests can import the pure helpers (TrayState, header_text) and test Quit/toast
|
||||
handlers with mocked icons without importing the GTK-less top-level pystray.
|
||||
|
||||
Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.tray_windows import TrayState, header_text, _acquire_single_instance, _ERROR_ALREADY_EXISTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TrayState — initial state and setters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_tray_state_initial():
|
||||
"""TrayState initialises to scanning state with no last_sync."""
|
||||
ts = TrayState()
|
||||
assert ts.state == "scanning"
|
||||
assert ts.reason == ""
|
||||
assert ts.last_sync is None
|
||||
assert ts.loop is None
|
||||
assert ts.stop_event is None
|
||||
|
||||
|
||||
def test_set_connected():
|
||||
"""set_connected(ts_float) sets state='connected', clears reason, records last_sync."""
|
||||
ts = TrayState()
|
||||
now = time.time()
|
||||
ts.set_connected(now)
|
||||
assert ts.state == "connected"
|
||||
assert ts.reason == ""
|
||||
assert ts.last_sync == now
|
||||
|
||||
|
||||
def test_set_scanning():
|
||||
"""set_scanning() sets state='scanning', clears reason."""
|
||||
ts = TrayState()
|
||||
ts.set_error("something bad") # put it in error first
|
||||
ts.set_scanning()
|
||||
assert ts.state == "scanning"
|
||||
assert ts.reason == ""
|
||||
|
||||
|
||||
def test_set_error():
|
||||
"""set_error(why) sets state='error' and stores the reason string."""
|
||||
ts = TrayState()
|
||||
ts.set_error("token expired — run claude login")
|
||||
assert ts.state == "error"
|
||||
assert ts.reason == "token expired — run claude login"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# header_text — D-05 string shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_header_text_scanning():
|
||||
"""header_text returns 'Scanning…' in scanning state."""
|
||||
ts = TrayState()
|
||||
ts.set_scanning()
|
||||
assert header_text(ts) == "Scanning…"
|
||||
|
||||
|
||||
def test_header_text_error():
|
||||
"""header_text returns 'Error: {reason}' in error state."""
|
||||
ts = TrayState()
|
||||
ts.set_error("token expired — run claude login")
|
||||
result = header_text(ts)
|
||||
assert result == "Error: token expired — run claude login"
|
||||
|
||||
|
||||
def test_header_text_connected_with_last_sync():
|
||||
"""header_text returns 'Connected · last update HH:MM' when last_sync is set."""
|
||||
ts = TrayState()
|
||||
# Use a known timestamp so we can predict the HH:MM string.
|
||||
known_ts = time.mktime(time.strptime("2026-06-01 14:32:00", "%Y-%m-%d %H:%M:%S"))
|
||||
ts.set_connected(known_ts)
|
||||
result = header_text(ts)
|
||||
# Extract the HH:MM portion from the actual local time expansion.
|
||||
expected_when = time.strftime("%H:%M", time.localtime(known_ts))
|
||||
assert result == f"Connected · last update {expected_when}"
|
||||
|
||||
|
||||
def test_header_text_connected_never_when_last_sync_none():
|
||||
"""header_text returns 'Connected · last update never' when last_sync is None."""
|
||||
ts = TrayState()
|
||||
# Manually set state without using set_connected so last_sync stays None.
|
||||
ts.state = "connected"
|
||||
ts.last_sync = None
|
||||
result = header_text(ts)
|
||||
assert result == "Connected · last update never"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# daemon main() populates ts.loop and ts.stop_event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_main_populates_tray_state_loop_and_stop_event():
|
||||
"""daemon main(tray_state=ts) sets ts.loop and ts.stop_event before the loop body."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
ts = TrayState()
|
||||
populated = {}
|
||||
|
||||
async def _fake_scan():
|
||||
# Record the state of ts at first scan entry (after main() startup lines).
|
||||
populated["loop"] = ts.loop
|
||||
populated["stop_event"] = ts.stop_event
|
||||
# Signal stop so the loop exits cleanly.
|
||||
ts.stop_event.set()
|
||||
return None # no device found
|
||||
|
||||
with patch.object(mod, "scan_for_device", side_effect=_fake_scan):
|
||||
asyncio.run(mod.main(tray_state=ts))
|
||||
|
||||
assert populated.get("loop") is not None, "ts.loop must be set by daemon main()"
|
||||
assert populated.get("stop_event") is not None, "ts.stop_event must be set by daemon main()"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quit handler routes through call_soon_threadsafe (not stop_event.set directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_quit_uses_call_soon_threadsafe():
|
||||
"""The Quit menu handler calls loop.call_soon_threadsafe(stop_event.set) and icon.stop().
|
||||
|
||||
It must NOT call stop_event.set() directly from the tray thread
|
||||
(RESEARCH Pitfall 2 / T-04-06 mitigation).
|
||||
"""
|
||||
# Build a TrayState with a mocked loop and stop_event.
|
||||
ts = TrayState()
|
||||
mock_loop = MagicMock()
|
||||
mock_stop_event = MagicMock()
|
||||
ts.loop = mock_loop
|
||||
ts.stop_event = mock_stop_event
|
||||
|
||||
# Build the Quit handler the same way tray_windows.main() does, without
|
||||
# importing pystray at the module level. We construct a local closure
|
||||
# that mirrors the on_quit body.
|
||||
mock_icon = MagicMock()
|
||||
|
||||
def _on_quit(icon_ref, _item):
|
||||
# This is the exact body from tray_windows.main() — keep in sync.
|
||||
ts.loop.call_soon_threadsafe(ts.stop_event.set)
|
||||
icon_ref.stop()
|
||||
|
||||
_on_quit(mock_icon, None)
|
||||
|
||||
# call_soon_threadsafe must have been called with stop_event.set as the arg.
|
||||
mock_loop.call_soon_threadsafe.assert_called_once_with(mock_stop_event.set)
|
||||
# icon.stop() must have been called.
|
||||
mock_icon.stop.assert_called_once()
|
||||
# stop_event.set() must NOT have been called directly.
|
||||
mock_stop_event.set.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error toast fires only on transition INTO error (D-04)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_error_toast_on_entry_only():
|
||||
"""The tray refresh loop fires icon.notify() only on transition INTO error.
|
||||
|
||||
Sequence: scanning -> error -> error
|
||||
Expected: notify called exactly once (on the scanning->error transition).
|
||||
"""
|
||||
ts = TrayState()
|
||||
ts.set_scanning()
|
||||
|
||||
mock_icon = MagicMock()
|
||||
mock_icon._running = True
|
||||
|
||||
# Simulate the _refresh loop's state-change detection logic from tray_windows.main().
|
||||
# We run two transitions manually:
|
||||
# 1. scanning -> error (should call notify once)
|
||||
# 2. error -> error (no change — notify must NOT fire again)
|
||||
prev_state: dict = {"state": None}
|
||||
|
||||
def _process_state_change(new_state: str, reason: str = "") -> None:
|
||||
"""Mirror the relevant part of the _refresh loop body."""
|
||||
ts.state = new_state
|
||||
ts.reason = reason
|
||||
current = ts.state
|
||||
if current != prev_state["state"]:
|
||||
if current == "error" and prev_state["state"] != "error":
|
||||
mock_icon.notify(ts.reason or "Clawdmeter error", "Clawdmeter")
|
||||
prev_state["state"] = current
|
||||
|
||||
# Transition 1: scanning -> error (notify should fire)
|
||||
_process_state_change("scanning")
|
||||
_process_state_change("error", "token expired — run claude login")
|
||||
# Transition 2: error -> error (same state — no call)
|
||||
_process_state_change("error", "token expired — run claude login")
|
||||
|
||||
mock_icon.notify.assert_called_once_with(
|
||||
"token expired — run claude login", "Clawdmeter"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-instance guard (named mutex) — duplicate-launch / ARSO collision
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field bug: Windows "restart apps after sign-in" (ARSO) restored a console
|
||||
# `python.exe tray_windows.py` instance while the headless `pythonw` autostart
|
||||
# also fired — two trays fighting over the one BLE link. The guard makes a
|
||||
# second instance exit before it touches BLE.
|
||||
|
||||
def test_single_instance_noop_off_windows():
|
||||
"""Off-Windows the guard is a no-op that returns a truthy sentinel (never None)."""
|
||||
with patch("daemon.tray_windows.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
assert _acquire_single_instance() is not None
|
||||
|
||||
|
||||
def _fake_kernel32(last_error: int, handle: int):
|
||||
"""Build a fake ctypes module tree whose CreateMutexW returns `handle` and
|
||||
whose get_last_error() returns `last_error`."""
|
||||
fake_kernel32 = MagicMock()
|
||||
fake_kernel32.CreateMutexW.return_value = handle
|
||||
fake_ctypes = MagicMock()
|
||||
fake_ctypes.WinDLL.return_value = fake_kernel32
|
||||
fake_ctypes.get_last_error.return_value = last_error
|
||||
return fake_ctypes
|
||||
|
||||
|
||||
def test_single_instance_first_instance_gets_handle():
|
||||
"""First instance: CreateMutexW succeeds, no prior owner → returns the handle."""
|
||||
fake_ctypes = _fake_kernel32(last_error=0, handle=0xABCD)
|
||||
with patch("daemon.tray_windows.sys") as mock_sys, \
|
||||
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
||||
mock_sys.platform = "win32"
|
||||
assert _acquire_single_instance() == 0xABCD
|
||||
|
||||
|
||||
def test_single_instance_second_instance_gets_none():
|
||||
"""Second instance: mutex already exists → returns None so caller exits."""
|
||||
fake_ctypes = _fake_kernel32(last_error=_ERROR_ALREADY_EXISTS, handle=0xABCD)
|
||||
with patch("daemon.tray_windows.sys") as mock_sys, \
|
||||
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
||||
mock_sys.platform = "win32"
|
||||
assert _acquire_single_instance() is None
|
||||
|
||||
|
||||
def test_single_instance_fails_open_on_null_handle():
|
||||
"""If CreateMutexW returns NULL, fail OPEN (truthy) — never block tray startup."""
|
||||
fake_ctypes = _fake_kernel32(last_error=_ERROR_ALREADY_EXISTS, handle=0)
|
||||
with patch("daemon.tray_windows.sys") as mock_sys, \
|
||||
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
||||
mock_sys.platform = "win32"
|
||||
result = _acquire_single_instance()
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: cwd-independent package + asset resolution (SC#1 logon autostart)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field bug: launching `pythonw.exe daemon\tray_windows.py` at logon starts with
|
||||
# cwd = System32, so `import daemon.*` raised ModuleNotFoundError and the relative
|
||||
# logo path failed — the tray crashed silently with no icon. tray_windows must
|
||||
# self-locate the repo root from __file__ so it works from any working directory.
|
||||
|
||||
def test_repo_root_is_parent_of_daemon_package():
|
||||
"""_REPO_ROOT points at the dir that CONTAINS the daemon package."""
|
||||
import os
|
||||
import daemon.tray_windows as tw
|
||||
|
||||
assert os.path.isdir(os.path.join(tw._REPO_ROOT, "daemon"))
|
||||
assert os.path.isfile(
|
||||
os.path.join(tw._REPO_ROOT, "firmware", "src", "logo.h")
|
||||
), "brand logo must resolve from _REPO_ROOT, not the current working directory"
|
||||
|
||||
|
||||
def test_repo_root_on_sys_path_after_import():
|
||||
"""Importing tray_windows puts the repo root on sys.path so `daemon.*` resolves regardless of cwd."""
|
||||
import sys
|
||||
import daemon.tray_windows as tw
|
||||
|
||||
assert tw._REPO_ROOT in sys.path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: daemon main() must run in a BACKGROUND thread (SC#1 tray launch)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field bug: under the tray the loop runs in threading.Thread (pystray owns the
|
||||
# main thread). OS signal-handler installation (loop.add_signal_handler /
|
||||
# signal.signal) only works on the main thread, so main() raised
|
||||
# "signal only works in main thread" and the daemon thread died on startup.
|
||||
# main() must guard signal setup to the main thread; the tray owns shutdown.
|
||||
|
||||
def test_main_runs_in_background_thread_without_signal_error():
|
||||
"""main(tray_state=ts) started from a non-main thread must not raise on signal setup."""
|
||||
import threading as _threading
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
ts = TrayState()
|
||||
errors: list = []
|
||||
|
||||
async def _fake_scan():
|
||||
ts.stop_event.set() # exit the loop immediately
|
||||
return None
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
with patch.object(mod, "scan_for_device", side_effect=_fake_scan):
|
||||
asyncio.run(mod.main(tray_state=ts))
|
||||
except Exception as exc: # noqa: BLE001 — capture for the assertion
|
||||
errors.append(exc)
|
||||
|
||||
t = _threading.Thread(target=_run)
|
||||
t.start()
|
||||
t.join(timeout=10)
|
||||
|
||||
assert not t.is_alive(), "daemon main() hung in background thread"
|
||||
assert not errors, f"main() raised in a background thread: {errors!r}"
|
||||
Reference in New Issue
Block a user