Skip to content

Commit 6477e0e

Browse files
committed
Combined: Windows daemon + Activity backport + multi-session fixes
Branches three open PRs into one working stack on a Windows host with the 480x480 Waveshare AMOLED-2.16: - Base: PR HermannBjorgvin#26 (dilbery's USB CDC transport) - Plus daemon: cross-platform port + Windows hook-compat - Plus feature: PR HermannBjorgvin#22 (tobby168's Activity screen / Claude Code hooks) - Plus 3 fixes: firmware buffers + parse for multi-session payloads Windows daemon port (vs PR HermannBjorgvin#26) ------------------------------- daemon/claude_usage_daemon.py: - find_port() on win32 uses pyserial list_ports filtered by USB VID (303A Espressif, 10C4 SiLabs, 1A86 WCH, 0403 FTDI). Locale-independent — works on non-English Windows where caption strings are translated. - termios import + HUPCL clear gated to sys.platform != "win32". - write_payload uses ensure_ascii=False so UTF-8 stays raw (em-dashes etc are 3 bytes instead of 6-byte \uXXXX escapes; smaller payloads, less pressure on ArduinoJson on the firmware side). daemon/clawdmeter_hook.py (from PR HermannBjorgvin#22): - fcntl is Unix-only; replace with msvcrt.locking on win32. daemon/install_hooks_win.py (new): - Windows analogue of install-mac.sh's inline-Python hook registration. Backs up settings.json before writing, idempotent. Windows host scripts (from PR HermannBjorgvin#23): flash-win.ps1, install-win.ps1, setup-win.ps1, uninstall-win.ps1, daemon/run-daemon.ps1. Brought along so this branch is buildable + installable on Windows without cherry-picking. Activity backport on USB CDC (vs PR HermannBjorgvin#22) ---------------------------------------- firmware/src/data.h: PR HermannBjorgvin#22 ActivityData / SessionData / TodoItem structs. firmware/src/ui.h: +SCREEN_ACTIVITY enum + ui_update_activity() API. firmware/src/ui.cpp: PR HermannBjorgvin#22 init_activity_screen / render_activity / activity_gesture_cb / ui_update_activity grafted onto PR HermannBjorgvin#26's ui.cpp; cycle order Splash > Usage > Link > Activity > Usage. firmware/src/main.cpp: parse_json gets optional ActivityData* out-param; sessions / todos extracted into the struct; ui_update_activity() called after each successful parse. daemon/claude_usage_daemon.py: - load_activity_sessions() + state_file_mtime() from PR HermannBjorgvin#22. - Main loop tracks last_state_mtime; payload includes "sessions" whenever the file changes OR a fresh API poll happens. Three firmware fixes needed for multi-session payloads ------------------------------------------------------ Real-world payloads with 2 sessions x 5-10 todos each routinely hit 700-1000 bytes — well past anything PR HermannBjorgvin#26's defaults handled. 1. firmware/src/serial_link.cpp: LINE_BUF_SIZE / DATA_BUF_SIZE 192 -> 4096. 192 was tuned for the ~120-byte Usage-only payload; longer lines were silently dropped at the line-accumulator level. 2. firmware/src/main.cpp: Serial.setRxBufferSize(4096) before Serial.begin. USB-CDC RX defaults to ~256 bytes; a 798-byte payload arrived as strlen=317 on the firmware side (truncated at receive). Bumping the buffer is the actual fix (CFG_TUD_CDC_RX_BUFSIZE redefine is ignored because Arduino-ESP32 overrides it via CONFIG_TINYUSB_CDC_RX_BUFSIZE). 3. firmware/src/main.cpp: parse_json is two-pass. - Pass 1 is a Filter()'d deserialization that only extracts the Usage scalars (s/sr/w/wr/st/ok). A tiny doc, always succeeds. - Pass 2 is a best-effort full parse for the "sessions" array. If it fails (oversized, mid-line truncation, unicode oddity) we leave ActivityData empty but still ACK the payload upstream — Usage stays intact, Activity falls back to its empty render state. Tested ------ Waveshare ESP32-S3-Touch-AMOLED-2.16 on Windows 10 (Dutch locale, Realtek BT 5.1 adapter unused, USB-C only). Two parallel Claude Code sessions on the same host: - 11+ todos across two sessions, ~1KB payload, ACK'd reliably. - Activity screen renders project name, in-progress todo activeForm, counter coloured by phase, todo list windowed around the in-progress item. PWR cycles Usage <-> Link <-> Activity. - state.json updates on every PreToolUse/PostToolUse/UserPromptSubmit/ Stop/SessionStart; daemon picks it up on its 5s tick.
1 parent 04a6b68 commit 6477e0e

14 files changed

Lines changed: 1652 additions & 40 deletions

daemon/claude_usage_daemon.py

Lines changed: 167 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@
2121
import signal
2222
import subprocess
2323
import sys
24-
import termios
2524
import time
2625
from pathlib import Path
2726

2827
import httpx
2928
import serial # pyserial
29+
from serial.tools import list_ports
30+
31+
# termios is Unix-only — used for HUPCL clear on macOS/Linux. Skipped on Windows
32+
# where the kernel doesn't toggle DTR-on-close the same way; pyserial's
33+
# dsrdtr=False covers the open-side case sufficiently.
34+
if sys.platform != "win32":
35+
import termios
3036

3137
POLL_INTERVAL = 60
3238
TICK = 5
@@ -37,6 +43,23 @@
3743
)
3844
ENV_PORT = os.environ.get("DEVICE_PORT") # explicit override wins
3945

46+
# Activity / hook state — written by daemon/clawdmeter_hook.py on every
47+
# Claude Code hook event, read here on every tick. The wire schema is the
48+
# same compact form PR #22 uses over BLE; USB CDC has no MTU budget so we
49+
# don't enforce a payload cap.
50+
STATE_FILE = Path.home() / ".clawdmeter" / "state.json"
51+
MAX_SESSIONS = 3
52+
MAX_TODOS_PER_SESSION = 10
53+
TODO_CONTENT_MAX = 50
54+
TODO_ACTIVEFORM_MAX = 40
55+
USER_PROMPT_MAX = 60
56+
CURRENT_TOOL_MAX = 24
57+
TOOL_ARGS_MAX = 60
58+
SESSION_STALE_SECONDS = 10 * 60
59+
60+
_STATUS_NUM = {"pending": 0, "in_progress": 1, "completed": 2}
61+
_PHASE_NUM = {"idle": 0, "running": 1}
62+
4063
# macOS: token lives in Keychain (service "Claude Code-credentials").
4164
# Linux: token lives in ~/.claude/.credentials.json.
4265
KEYCHAIN_SERVICE = "Claude Code-credentials"
@@ -130,9 +153,92 @@ def read_token() -> str | None:
130153
return _read_token_file()
131154

132155

156+
def load_activity_sessions() -> list[dict]:
157+
"""Read state.json written by the hook script and project it into the
158+
compact session schema the firmware expects.
159+
160+
Returns up to MAX_SESSIONS entries, sorted most-recently-active first,
161+
each with a head-truncated todos list. activeForm is included only on
162+
the in-progress item — every other state would never display it.
163+
"""
164+
try:
165+
state = json.loads(STATE_FILE.read_text())
166+
except (OSError, json.JSONDecodeError):
167+
return []
168+
sessions = state.get("sessions") or {}
169+
if not isinstance(sessions, dict):
170+
return []
171+
now = time.time()
172+
fresh = []
173+
for sid, s in sessions.items():
174+
if not isinstance(s, dict):
175+
continue
176+
la_ts = s.get("last_active_ts") or 0
177+
age = now - la_ts
178+
if age > SESSION_STALE_SECONDS:
179+
continue
180+
fresh.append((la_ts, s))
181+
fresh.sort(key=lambda x: x[0], reverse=True)
182+
out = []
183+
for la_ts, s in fresh[:MAX_SESSIONS]:
184+
todos = s.get("todos") or []
185+
compact_todos = []
186+
for t in todos[:MAX_TODOS_PER_SESSION]:
187+
if not isinstance(t, dict):
188+
continue
189+
sn = _STATUS_NUM.get(str(t.get("status", "pending")), 0)
190+
entry = {
191+
"c": str(t.get("content", ""))[:TODO_CONTENT_MAX],
192+
"s": sn,
193+
}
194+
if sn == 1:
195+
af = str(t.get("activeForm", ""))[:TODO_ACTIVEFORM_MAX]
196+
if af:
197+
entry["a"] = af
198+
compact_todos.append(entry)
199+
entry = {
200+
"p": str(s.get("project", ""))[:24],
201+
"m": str(s.get("model", ""))[:24],
202+
"la": max(0, int(now - la_ts)),
203+
"ph": _PHASE_NUM.get(str(s.get("phase", "idle")), 0),
204+
"td": compact_todos,
205+
}
206+
ct = str(s.get("current_tool", ""))[:CURRENT_TOOL_MAX]
207+
if ct:
208+
entry["t"] = ct
209+
ta = str(s.get("current_tool_args", ""))[:TOOL_ARGS_MAX]
210+
if ta:
211+
entry["ta"] = ta
212+
up = str(s.get("last_user_prompt", ""))[:USER_PROMPT_MAX]
213+
if up:
214+
entry["u"] = up
215+
out.append(entry)
216+
return out
217+
218+
219+
def state_file_mtime() -> float:
220+
try:
221+
return STATE_FILE.stat().st_mtime
222+
except OSError:
223+
return 0.0
224+
225+
133226
def find_port() -> str | None:
134-
if ENV_PORT and os.path.exists(ENV_PORT):
135-
return ENV_PORT
227+
if ENV_PORT:
228+
# On Windows ENV_PORT will be a COM name like 'COM3' which doesn't
229+
# exist as a filesystem path, so trust the user input directly.
230+
if sys.platform == "win32" or os.path.exists(ENV_PORT):
231+
return ENV_PORT
232+
if sys.platform == "win32":
233+
# Match by USB VID using pyserial's list_ports. Same VID list as
234+
# flash-win.ps1's auto-detect — Espressif, Silicon Labs CP210x,
235+
# WCH CH340/CH341, FTDI. This is locale-independent so it works
236+
# on non-English Windows where driver captions are translated.
237+
wanted_vids = {0x303A, 0x10C4, 0x1A86, 0x0403}
238+
for p in sorted(list_ports.comports(), key=lambda x: x.device):
239+
if p.vid in wanted_vids:
240+
return p.device # e.g. 'COM3'
241+
return None
136242
for pattern in PORT_GLOBS:
137243
matches = sorted(glob.glob(pattern))
138244
if matches:
@@ -141,12 +247,13 @@ def find_port() -> str | None:
141247

142248

143249
def open_port(path: str) -> serial.Serial:
144-
"""Open the CDC port with HUPCL cleared.
250+
"""Open the CDC port with DTR-on-open suppressed.
145251
146-
pyserial's `dsrdtr=False` covers the obvious DTR-on-open case, but on
147-
some kernels HUPCL still drops DTR when the file descriptor closes,
148-
which the firmware sees as a reset on every reconnect. Stomp it
149-
explicitly via termios after open.
252+
pyserial's `dsrdtr=False` covers the obvious DTR-on-open case on all
253+
platforms. On macOS/Linux we additionally clear HUPCL via termios because
254+
some kernels still drop DTR when the file descriptor closes, which the
255+
firmware sees as a reset on every reconnect. Windows handles this
256+
differently — no termios available, dsrdtr=False is sufficient.
150257
"""
151258
ser = serial.Serial(
152259
path,
@@ -157,14 +264,15 @@ def open_port(path: str) -> serial.Serial:
157264
rtscts=False,
158265
xonxoff=False,
159266
)
160-
try:
161-
fd = ser.fileno()
162-
attrs = termios.tcgetattr(fd)
163-
# cflag is index 2; HUPCL is in cflag.
164-
attrs[2] &= ~termios.HUPCL
165-
termios.tcsetattr(fd, termios.TCSANOW, attrs)
166-
except (termios.error, OSError) as e:
167-
log(f"HUPCL clear failed (non-fatal): {e}")
267+
if sys.platform != "win32":
268+
try:
269+
fd = ser.fileno()
270+
attrs = termios.tcgetattr(fd)
271+
# cflag is index 2; HUPCL is in cflag.
272+
attrs[2] &= ~termios.HUPCL
273+
termios.tcsetattr(fd, termios.TCSANOW, attrs)
274+
except (termios.error, OSError) as e:
275+
log(f"HUPCL clear failed (non-fatal): {e}")
168276
return ser
169277

170278

@@ -236,7 +344,13 @@ async def reader_task(ser: serial.Serial, refresh_event: asyncio.Event,
236344

237345

238346
async def write_payload(ser: serial.Serial, payload: dict) -> bool:
239-
data = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
347+
# ensure_ascii=False keeps multi-byte UTF-8 chars raw instead of expanding
348+
# them to 6-byte \uXXXX escapes. Two reasons: (1) smaller payloads — em-dashes
349+
# and accents would otherwise multiply payload size; (2) ArduinoJson on the
350+
# firmware parses raw UTF-8 transparently but can struggle with escape
351+
# sequences in long payloads (we saw "JSON parse error: InvalidInput" on the
352+
# ~700-byte multi-session form before this fix).
353+
data = (json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8")
240354
log(f"Sending: {data.decode().rstrip()}")
241355
try:
242356
await asyncio.get_running_loop().run_in_executor(
@@ -262,28 +376,52 @@ async def run_session(port_path: str, stop_event: asyncio.Event) -> None:
262376
reader = asyncio.create_task(reader_task(ser, refresh_event, stop_event))
263377

264378
last_poll = 0.0
379+
last_state_mtime = -1.0
380+
cached_api: dict | None = None
265381
write_fails = 0
266382
try:
267383
while not stop_event.is_set():
268384
now = time.time()
269-
if refresh_event.is_set() or (now - last_poll) >= POLL_INTERVAL:
385+
api_due = (
386+
refresh_event.is_set()
387+
or (now - last_poll) >= POLL_INTERVAL
388+
or cached_api is None
389+
)
390+
state_mtime = state_file_mtime()
391+
state_changed = state_mtime != last_state_mtime
392+
393+
if api_due:
270394
if refresh_event.is_set():
271395
log("Refresh requested by device")
272396
refresh_event.clear()
273397
token = read_token()
274398
if not token:
275-
log("No token; skipping poll")
399+
log("No token; skipping API poll")
400+
else:
401+
fresh = await poll_api(token)
402+
if fresh is not None:
403+
cached_api = fresh
404+
last_poll = time.time()
405+
406+
if api_due or state_changed:
407+
payload = dict(cached_api) if cached_api else {
408+
"s": 0, "sr": 0, "w": 0, "wr": 0,
409+
"st": "unknown", "ok": False,
410+
}
411+
# Sessions data — firmware's parse_json is now two-pass
412+
# (filtered Usage first, sessions best-effort second), so
413+
# malformed/oversized session arrays no longer break Usage.
414+
sessions = load_activity_sessions()
415+
if sessions:
416+
payload["sessions"] = sessions
417+
if await write_payload(ser, payload):
418+
last_state_mtime = state_mtime
419+
write_fails = 0
276420
else:
277-
payload = await poll_api(token)
278-
if payload is not None:
279-
if await write_payload(ser, payload):
280-
last_poll = time.time()
281-
write_fails = 0
282-
else:
283-
write_fails += 1
284-
if write_fails >= 2:
285-
log(f"Write failed {write_fails}x, recycling port")
286-
break
421+
write_fails += 1
422+
if write_fails >= 2:
423+
log(f"Write failed {write_fails}x, recycling port")
424+
break
287425

288426
try:
289427
await asyncio.wait_for(refresh_event.wait(), timeout=TICK)

0 commit comments

Comments
 (0)