Compare commits
30
Commits
main
..
v3.0-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1cc4670b1 | ||
|
|
4eeb6e242b | ||
|
|
ac0742d92c | ||
|
|
b63a6a62ae | ||
|
|
a975eb434b | ||
|
|
1b14c363c2 | ||
|
|
83bd2bed43 | ||
|
|
6e3f8e9084 | ||
|
|
e99c27babd | ||
|
|
d55dc4a268 | ||
|
|
1c64386996 | ||
|
|
578bc04248 | ||
|
|
a6d391e9d1 | ||
|
|
39a24eebad | ||
|
|
67df140ca4 | ||
|
|
ec8322fa21 | ||
|
|
650af4221b | ||
|
|
b8d4daa03f | ||
|
|
e682b43735 | ||
|
|
b188349262 | ||
|
|
4c1b63e645 | ||
|
|
26bc85ffe6 | ||
|
|
c2020dcea2 | ||
|
|
466e32fda1 | ||
|
|
56b49898a3 | ||
|
|
abe72eca0a | ||
|
|
d5d80e81c0 | ||
|
|
5bed8cbd1f | ||
|
|
8d82beaacf | ||
|
|
25b101d086 |
+17
@@ -18,3 +18,20 @@ daemon/.venv/
|
|||||||
# Python bytecode cache
|
# Python bytecode cache
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Windows daemon venv
|
||||||
|
/.venv/
|
||||||
|
|
||||||
|
# PyInstaller build artifacts — the standalone exe ships via a Gitea release,
|
||||||
|
# not git (build it with build-exe.ps1 / clawdmeter.spec).
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
|
*_dbg.spec
|
||||||
|
|
||||||
|
# Local Home Assistant config (holds a secret token - never commit)
|
||||||
|
daemon/ha_config.json
|
||||||
|
|
||||||
|
# Session scratch — BLE daemon run logs + one-off on-device QA screenshots
|
||||||
|
/m3_daemon*.log
|
||||||
|
/*_qa.png
|
||||||
|
/build-exe.log
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ Run `pio run -d firmware` with no env to see the available board envs.
|
|||||||
|
|
||||||
### Pair the device
|
### Pair the device
|
||||||
|
|
||||||
The device is a bonded BLE HID keyboard, so pair it once: **Settings → Bluetooth & devices → Add device → Bluetooth**, then select "Claude Controller". Pairing is **required** — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use **Remove device** (this disables the buttons).
|
The device is a bonded BLE HID keyboard, so pair it once: **Settings → Bluetooth & devices → Add device → Bluetooth**, then select "Clawdmeter". Pairing is **required** — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use **Remove device** (this disables the buttons).
|
||||||
|
|
||||||
### Install the daemon (recommended)
|
### Install the daemon (recommended)
|
||||||
|
|
||||||
@@ -289,6 +289,28 @@ to preserve the brand font — Chinese text in those slots renders as
|
|||||||
empty boxes. Add a `font_cjk_28.c` if full coverage is needed (~1MB
|
empty boxes. Add a `font_cjk_28.c` if full coverage is needed (~1MB
|
||||||
more flash).
|
more flash).
|
||||||
|
|
||||||
|
### Cyrillic (Now Playing titles)
|
||||||
|
|
||||||
|
`firmware/src/font_styrene_cyr_{28,20}.c` back the Now Playing screen's
|
||||||
|
title/artist, where user content (media titles) can be non-Latin. Styrene B
|
||||||
|
has no Cyrillic glyphs, so these are **composite** fonts: Latin from Styrene B,
|
||||||
|
Cyrillic + typographic punctuation (– — ' ' " " …) from Montserrat. `lv_font_conv`
|
||||||
|
merges multiple `--font`/`-r` groups into one font — a pure-Latin title stays
|
||||||
|
on-brand, only Cyrillic falls back to Montserrat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for size in 28 20; do
|
||||||
|
lv_font_conv \
|
||||||
|
--font assets/StyreneB-Regular.otf -r 0x20-0x7E \
|
||||||
|
--font assets/Montserrat-Medium.ttf -r '0x2013,0x2014,0x2018,0x2019,0x201C,0x201D,0x2026,0x400-0x45F' \
|
||||||
|
--size $size --format lvgl --bpp 4 --no-compress \
|
||||||
|
-o firmware/src/font_styrene_cyr_${size}.c --lv-include lvgl.h
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Then apply the four LVGL 9 patches above — `tools/patch_lvgl9_font.py <file...>` does
|
||||||
|
all four automatically.
|
||||||
|
|
||||||
## Converting Lucide icons
|
## Converting Lucide icons
|
||||||
|
|
||||||
The UI uses a small set of [Lucide](https://lucide.dev) icons (bluetooth + battery states) converted to RGB565 / RGB565A8 C arrays for LVGL.
|
The UI uses a small set of [Lucide](https://lucide.dev) icons (bluetooth + battery states) converted to RGB565 / RGB565A8 C arrays for LVGL.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,48 @@
|
|||||||
|
# build-exe.ps1 - build the standalone Clawdmeter.exe (PyInstaller).
|
||||||
|
#
|
||||||
|
# Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable
|
||||||
|
# that runs on ANY Windows 11 machine with no Python install and no pip. Build it
|
||||||
|
# on a machine that DOES have Python (e.g. your dev box), then distribute the exe
|
||||||
|
# via a Gitea release; end users just download and run it (see daemon\README-windows.md).
|
||||||
|
#
|
||||||
|
# Usage (from anywhere):
|
||||||
|
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Log { param([string]$Msg) Write-Host "[$(Get-Date -Format HH:mm:ss)] $Msg" }
|
||||||
|
|
||||||
|
$RepoRoot = $PSScriptRoot
|
||||||
|
if (-not $RepoRoot) { $RepoRoot = (Get-Location).Path }
|
||||||
|
Set-Location $RepoRoot
|
||||||
|
|
||||||
|
$VenvDir = Join-Path $RepoRoot ".venv"
|
||||||
|
$PythonExe = Join-Path $VenvDir "Scripts\python.exe"
|
||||||
|
|
||||||
|
Log "=== Clawdmeter exe build ==="
|
||||||
|
|
||||||
|
# 1. Virtual environment
|
||||||
|
if (-not (Test-Path $PythonExe)) {
|
||||||
|
Log "Creating virtual environment at .venv ..."
|
||||||
|
& python -m venv $VenvDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) - is Python on PATH?" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Runtime dependencies + PyInstaller (the only build-time extra)
|
||||||
|
Log "Installing runtime dependencies + PyInstaller ..."
|
||||||
|
& $PythonExe -m pip install --quiet -r (Join-Path $RepoRoot "daemon\requirements-windows.txt")
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pip install (runtime deps) failed (exit $LASTEXITCODE)" }
|
||||||
|
& $PythonExe -m pip install --quiet pyinstaller
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pip install pyinstaller failed (exit $LASTEXITCODE)" }
|
||||||
|
|
||||||
|
# 3. Build from the committed spec (onefile, windowed)
|
||||||
|
Log "Building dist\Clawdmeter.exe (this takes a minute) ..."
|
||||||
|
& $PythonExe -m PyInstaller --noconfirm (Join-Path $RepoRoot "clawdmeter.spec")
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed (exit $LASTEXITCODE)" }
|
||||||
|
|
||||||
|
$ExePath = Join-Path $RepoRoot "dist\Clawdmeter.exe"
|
||||||
|
if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" }
|
||||||
|
$sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
|
||||||
|
Log "Build complete: $ExePath ($sizeMB MB)"
|
||||||
|
Log "Distribute this exe via a Gitea release - it is intentionally not committed to git."
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# clawdmeter.spec — build the standalone Windows daemon + tray executable.
|
||||||
|
#
|
||||||
|
# Produces a single, self-contained dist\Clawdmeter.exe that bundles its own
|
||||||
|
# Python, bleak (WinRT BLE) and the winrt media projection — so it runs on ANY
|
||||||
|
# Windows 11 machine with no Python and no pip install. The exe is the tray app
|
||||||
|
# (daemon thread + notification-area icon + login-autostart toggle); it is built
|
||||||
|
# windowed, so there is no console window and it logs to
|
||||||
|
# %LOCALAPPDATA%\Clawdmeter\daemon.log.
|
||||||
|
#
|
||||||
|
# Build (from the repo root, inside the venv) — or just run build-exe.ps1:
|
||||||
|
# .venv\Scripts\python.exe -m PyInstaller --noconfirm clawdmeter.spec
|
||||||
|
#
|
||||||
|
# winrt + bleak ship C-extension projections (.pyd) that PyInstaller's static
|
||||||
|
# analysis misses: bleak imports its WinRT backend dynamically, and the
|
||||||
|
# winrt.windows.* namespaces are split across separate distributions. collect_all
|
||||||
|
# pulls in their submodules, binaries and metadata so both BLE (bluetooth) and the
|
||||||
|
# now-playing media session work inside the frozen exe. Verified on hardware:
|
||||||
|
# connects, reads the media session, and pushes Cyrillic now-playing payloads.
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
|
datas = [
|
||||||
|
('firmware/src/logo.h', 'firmware/src'), # tray icon parsed at runtime
|
||||||
|
('daemon/web', 'daemon/web'), # Phase 7 control-panel UI (server._web_dir)
|
||||||
|
]
|
||||||
|
binaries = []
|
||||||
|
hiddenimports = [
|
||||||
|
# Imported lazily inside tray_windows.main(), so name them explicitly.
|
||||||
|
'daemon.claude_usage_daemon_windows',
|
||||||
|
'daemon.autostart_windows',
|
||||||
|
'daemon.icon_assets',
|
||||||
|
# Phase 7 control panel (lazy string-imports inside tray_windows / server).
|
||||||
|
'daemon.config',
|
||||||
|
'daemon.server',
|
||||||
|
'daemon.panel',
|
||||||
|
'daemon.ha_client',
|
||||||
|
# v3 provider package (registry + providers are lazy-imported in the loop).
|
||||||
|
'daemon.providers',
|
||||||
|
'daemon.providers.base',
|
||||||
|
'daemon.providers.anthropic',
|
||||||
|
'daemon.providers.openai_codex',
|
||||||
|
'daemon.providers.zai',
|
||||||
|
# The exact winrt media modules read_now_playing() pulls in.
|
||||||
|
'winrt.windows.media',
|
||||||
|
'winrt.windows.media.control',
|
||||||
|
# pywebview's Windows backend + pythonnet bridge load these dynamically.
|
||||||
|
'webview.platforms.edgechromium',
|
||||||
|
'clr',
|
||||||
|
]
|
||||||
|
# Phase 7 adds the FastAPI control panel + the pywebview WebView2 window. uvicorn
|
||||||
|
# and pywebview both import their submodules (loop/protocol pickers; the
|
||||||
|
# edgechromium backend + bundled WebView2 DLLs) dynamically, which PyInstaller's
|
||||||
|
# static analysis misses — collect_all pulls submodules, binaries and data files.
|
||||||
|
# clr_loader/pythonnet ship the .NET runtime-config JSON pywebview's WinForms host
|
||||||
|
# needs. If a package is absent the build fails loudly (better than a silent
|
||||||
|
# blank window at runtime).
|
||||||
|
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL',
|
||||||
|
'fastapi', 'uvicorn', 'webview', 'clr_loader', 'pythonnet'):
|
||||||
|
_d, _b, _h = collect_all(_pkg)
|
||||||
|
datas += _d
|
||||||
|
binaries += _b
|
||||||
|
hiddenimports += _h
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['daemon\\tray_windows.py'],
|
||||||
|
pathex=['.'],
|
||||||
|
binaries=binaries,
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=['tkinter', 'pytest'],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='Clawdmeter',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=False, # UPX compression raises AV false-positive rates — leave it off
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False, # windowed tray app: no console window; logs to daemon.log
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
@@ -59,7 +59,52 @@ pairing disables the keyboard buttons.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup (one time)
|
## Standalone executable — no Python required (recommended)
|
||||||
|
|
||||||
|
To run Clawdmeter on a machine **without Python**, use the single-file
|
||||||
|
`Clawdmeter.exe`. It bundles its own Python, the WinRT BLE stack and the
|
||||||
|
media-session reader, so nothing needs to be installed.
|
||||||
|
|
||||||
|
> **Download:** the repo is private, so **sign in to Gitea first**, then download
|
||||||
|
> **`Clawdmeter.exe`** with this direct link:
|
||||||
|
> <https://gitea.bvrdo.online/wenil/clawdmeter/releases/download/v2.0-beta.1/Clawdmeter.exe>
|
||||||
|
> — or browse [all releases](https://gitea.bvrdo.online/wenil/clawdmeter/releases) for the newest build.
|
||||||
|
|
||||||
|
1. Pair the device with Windows once (see [Pair the device](#pair-the-device-one-time)).
|
||||||
|
2. Get `Clawdmeter.exe` — download it from the
|
||||||
|
[latest Gitea release](https://gitea.bvrdo.online/wenil/clawdmeter/releases/latest), or build it
|
||||||
|
yourself (below).
|
||||||
|
3. Double-click `Clawdmeter.exe`. The tray icon appears and the watch starts
|
||||||
|
updating within ~10 seconds.
|
||||||
|
4. To launch it automatically at every logon, right-click the tray icon →
|
||||||
|
**Start at login**. That registers the exe itself under
|
||||||
|
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` — no Python, no console window.
|
||||||
|
|
||||||
|
There is no console window; the exe logs to `%LOCALAPPDATA%\Clawdmeter\daemon.log`.
|
||||||
|
|
||||||
|
### Building the exe
|
||||||
|
|
||||||
|
On a machine that *does* have Python (e.g. your dev box), from the repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File build-exe.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `dist\Clawdmeter.exe` (~30–60 MB). The PyInstaller config lives in
|
||||||
|
`clawdmeter.spec`. The exe is intentionally **not** committed to git — distribute
|
||||||
|
it through a Gitea release.
|
||||||
|
|
||||||
|
> **SmartScreen / antivirus:** unsigned PyInstaller executables are sometimes
|
||||||
|
> flagged by a generic heuristic (not a real detection). Until the exe is
|
||||||
|
> code-signed, you may need to allow it through SmartScreen ("More info → Run
|
||||||
|
> anyway"). Building from source sidesteps this entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup from source (one time)
|
||||||
|
|
||||||
|
> Use this if you prefer running from a Python checkout instead of the standalone
|
||||||
|
> exe above (e.g. on your dev box).
|
||||||
|
|
||||||
Open a PowerShell terminal and `cd` to the repository root.
|
Open a PowerShell terminal and `cd` to the repository root.
|
||||||
|
|
||||||
@@ -118,9 +163,14 @@ python daemon\claude_usage_daemon_windows.py
|
|||||||
payload within a few seconds of connect (warm token path). With a valid, non-expired token
|
payload within a few seconds of connect (warm token path). With a valid, non-expired token
|
||||||
the device should leave its waiting screen and show session + weekly percentages within
|
the device should leave its waiting screen and show session + weekly percentages within
|
||||||
about 10 seconds of launch.
|
about 10 seconds of launch.
|
||||||
- The daemon then re-polls every 60 seconds while connected. If the device fires a refresh
|
- The daemon then re-polls the Anthropic API every 60 seconds while connected. If the device
|
||||||
request (e.g., after a button press), an immediate re-poll occurs without waiting for the
|
fires a refresh request (e.g., after a button press), an immediate re-poll occurs without
|
||||||
60-second interval.
|
waiting for the 60-second interval.
|
||||||
|
- The **Now Playing** screen updates on a separate, faster cadence: the daemon reads the
|
||||||
|
Windows media session every 3 seconds and pushes an update the moment the track or
|
||||||
|
play/pause state changes — so the watch reflects a song change within a few seconds, not at
|
||||||
|
the next 60-second API poll. The cached usage data is re-sent with each of these updates, so
|
||||||
|
the rate-limit / token screens never blank between polls.
|
||||||
- If the device disconnects or goes out of range, the daemon logs `Device disconnected` and
|
- If the device disconnects or goes out of range, the daemon logs `Device disconnected` and
|
||||||
re-scans automatically with exponential backoff (starting at 1 second, capped at 60 seconds).
|
re-scans automatically with exponential backoff (starting at 1 second, capped at 60 seconds).
|
||||||
|
|
||||||
@@ -219,5 +269,5 @@ launched.
|
|||||||
|
|
||||||
## What is NOT covered here
|
## What is NOT covered here
|
||||||
|
|
||||||
- PyInstaller / one-file `.exe` packaging — v2
|
- Code-signing the standalone `.exe` (to avoid SmartScreen/AV prompts) — future
|
||||||
- MAC-address cache / sleep-wake reconnect hardening — Phase 3
|
- MAC-address cache / sleep-wake reconnect hardening — Phase 3
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ def _command(tray_script: str | None = None) -> str:
|
|||||||
module's own path (useful when autostart_windows.py IS
|
module's own path (useful when autostart_windows.py IS
|
||||||
the entry point, but callers should pass tray_windows.py).
|
the entry point, but callers should pass tray_windows.py).
|
||||||
"""
|
"""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# Running as the bundled PyInstaller exe: autostart launches the exe
|
||||||
|
# itself (sys.executable), not a pythonw + script pair. The exe is built
|
||||||
|
# windowed, so this is genuinely consoleless — no script path involved.
|
||||||
|
return f'"{sys.executable}"'
|
||||||
pythonw = os.path.join(sys.base_exec_prefix, "pythonw.exe")
|
pythonw = os.path.join(sys.base_exec_prefix, "pythonw.exe")
|
||||||
script = os.path.abspath(tray_script if tray_script is not None else __file__)
|
script = os.path.abspath(tray_script if tray_script is not None else __file__)
|
||||||
return f'"{pythonw}" "{script}"'
|
return f'"{pythonw}" "{script}"'
|
||||||
|
|||||||
@@ -32,9 +32,22 @@ DEVICE_ADDRESS = os.environ.get("CLAWDMETER_ADDRESS")
|
|||||||
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
|
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
|
||||||
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
|
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
|
||||||
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
|
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
|
||||||
|
CMD_CHAR_UUID = "4c41555a-4465-7669-6365-000000000005" # watch → host commands (Phase 6 HA)
|
||||||
|
|
||||||
POLL_INTERVAL = 60
|
# Phase 7 M2 — dynamic watch buttons. The daemon pushes up to this many button
|
||||||
TICK = 5
|
# labels (each truncated) in the RX payload's "btns" array on the ~60s heartbeat;
|
||||||
|
# the watch renders a grid and reports only the pressed index. Bounds keep the
|
||||||
|
# merged BLE payload comfortably under the firmware's 512-byte RX buffer.
|
||||||
|
WATCH_MAX_BUTTONS = 6
|
||||||
|
WATCH_LABEL_MAX = 16
|
||||||
|
|
||||||
|
POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
|
||||||
|
NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The
|
||||||
|
# "music card" is refreshed ~20x faster than the API
|
||||||
|
# poll so a track / play-pause change reaches the watch
|
||||||
|
# within a few seconds instead of at the next 60s poll.
|
||||||
|
# Doubles as the inner-loop tick (was TICK=5) — the loop
|
||||||
|
# wakes this often to read media + detect a dropped link.
|
||||||
SCAN_TIMEOUT = 8.0
|
SCAN_TIMEOUT = 8.0
|
||||||
CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device
|
CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device
|
||||||
CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts
|
CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts
|
||||||
@@ -57,6 +70,120 @@ API_BODY = {
|
|||||||
"messages": [{"role": "user", "content": "hi"}],
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- OAuth self-refresh -------------------------------------------------------
|
||||||
|
# In a managed (Agent SDK) environment `claude login` is unavailable, so once the
|
||||||
|
# stored access token expires nothing renews it and the 5h/7d rate-limit % goes
|
||||||
|
# dark permanently. The credentials file ships a refreshToken, so the daemon
|
||||||
|
# renews its own access token via the standard Claude Code OAuth refresh grant
|
||||||
|
# and writes the (rotated) tokens back. Endpoint + client_id verified empirically
|
||||||
|
# against a live refresh: HTTP 200, refresh_token rotates, expires_in=28800 (8h).
|
||||||
|
OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||||
|
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
TOKEN_REFRESH_SKEW = 300 # refresh this many seconds BEFORE expiry (proactive)
|
||||||
|
|
||||||
|
# Anthropic list prices, USD per million tokens, keyed by a substring of the
|
||||||
|
# model id. Used to show the "equivalent API cost" of today's Claude Code usage
|
||||||
|
# — subscription users don't actually pay this; it's the ccusage-style flex.
|
||||||
|
# Verified against ccusage/LiteLLM: cost reproduces to 100%. The rates follow
|
||||||
|
# Anthropic's fixed structure (output 5x, cache-write 2x, cache-read 0.1x of the
|
||||||
|
# base input rate). Opus 4.x is $5/$25 — NOT the older $15/$75.
|
||||||
|
PRICING = {
|
||||||
|
"opus": {"in": 5.0, "out": 25.0, "cache_w": 10.0, "cache_r": 0.50},
|
||||||
|
"sonnet": {"in": 3.0, "out": 15.0, "cache_w": 6.0, "cache_r": 0.30},
|
||||||
|
"haiku": {"in": 0.75, "out": 3.75, "cache_w": 1.50, "cache_r": 0.075},
|
||||||
|
}
|
||||||
|
_DEFAULT_PRICE = PRICING["opus"]
|
||||||
|
|
||||||
|
|
||||||
|
def _price_for(model: str) -> dict:
|
||||||
|
m = (model or "").lower()
|
||||||
|
for key, price in PRICING.items():
|
||||||
|
if key in m:
|
||||||
|
return price
|
||||||
|
return _DEFAULT_PRICE
|
||||||
|
|
||||||
|
|
||||||
|
def compute_today_usage() -> dict:
|
||||||
|
"""Sum today's Claude Code token usage + equivalent API cost across all local
|
||||||
|
project transcripts (~/.claude/projects/**/*.jsonl).
|
||||||
|
|
||||||
|
Reads only files modified today (a session that touched today has mtime
|
||||||
|
today), so the scan stays cheap even with a large transcript history. Each
|
||||||
|
assistant line carries message.usage (input/output/cache token counts) and a
|
||||||
|
UTC timestamp; per-line timestamps gate to today so a session spanning
|
||||||
|
midnight is split correctly. Assistant turns are de-duplicated by
|
||||||
|
(messageId, requestId): Claude Code copies history into new transcript files
|
||||||
|
on compaction/resume, so the same API turn appears in several files and a
|
||||||
|
naive sum over-counts (it inflated the total ~2.7x). Matches ccusage.
|
||||||
|
Returns the compact BLE fields tk/tc/tn/to.
|
||||||
|
"""
|
||||||
|
base = Path.home() / ".claude" / "projects"
|
||||||
|
today = datetime.date.today()
|
||||||
|
midnight = datetime.datetime.combine(today, datetime.time.min)
|
||||||
|
total_tokens = 0
|
||||||
|
output_tokens = 0
|
||||||
|
cost = 0.0
|
||||||
|
messages = 0
|
||||||
|
seen = set()
|
||||||
|
try:
|
||||||
|
files = list(base.glob("**/*.jsonl"))
|
||||||
|
except OSError:
|
||||||
|
return {"tk": 0, "tc": 0, "tn": 0}
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
if datetime.datetime.fromtimestamp(f.stat().st_mtime) < midnight:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
continue
|
||||||
|
msg = obj.get("message") if isinstance(obj, dict) else None
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
usage = msg.get("usage")
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
continue
|
||||||
|
ts = obj.get("timestamp")
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
d = datetime.datetime.fromisoformat(
|
||||||
|
ts.replace("Z", "+00:00")).astimezone().date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if d != today:
|
||||||
|
continue
|
||||||
|
# De-dup the same API turn copied across resumed/compacted files.
|
||||||
|
mid = obj.get("messageId") or msg.get("id")
|
||||||
|
rid = obj.get("requestId")
|
||||||
|
if mid is not None and rid is not None:
|
||||||
|
key = (mid, rid)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
inp = usage.get("input_tokens", 0) or 0
|
||||||
|
out = usage.get("output_tokens", 0) or 0
|
||||||
|
cw = usage.get("cache_creation_input_tokens", 0) or 0
|
||||||
|
cr = usage.get("cache_read_input_tokens", 0) or 0
|
||||||
|
total_tokens += inp + out + cw + cr
|
||||||
|
output_tokens += out
|
||||||
|
messages += 1
|
||||||
|
p = _price_for(msg.get("model"))
|
||||||
|
cost += (inp * p["in"] + out * p["out"]
|
||||||
|
+ cw * p["cache_w"] + cr * p["cache_r"]) / 1_000_000
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return {"tk": int(total_tokens), "tc": int(round(cost * 100)),
|
||||||
|
"tn": messages, "to": int(output_tokens)}
|
||||||
|
|
||||||
|
|
||||||
def _build_file_logger() -> logging.Logger | None:
|
def _build_file_logger() -> logging.Logger | None:
|
||||||
"""Create a rotating file logger for field diagnostics, or None.
|
"""Create a rotating file logger for field diagnostics, or None.
|
||||||
@@ -150,15 +277,16 @@ async def poll_api(token: str) -> dict | None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
payload = {
|
# Rate-limit utilization only. The local token usage and the "ok" flag are
|
||||||
|
# merged by the caller (connect_and_run) so the Session screen keeps updating
|
||||||
|
# even when this call fails (expired token / API down) — see decoupling there.
|
||||||
|
return {
|
||||||
"s": pct(hdr("anthropic-ratelimit-unified-5h-utilization")),
|
"s": pct(hdr("anthropic-ratelimit-unified-5h-utilization")),
|
||||||
"sr": reset_minutes(hdr("anthropic-ratelimit-unified-5h-reset")),
|
"sr": reset_minutes(hdr("anthropic-ratelimit-unified-5h-reset")),
|
||||||
"w": pct(hdr("anthropic-ratelimit-unified-7d-utilization")),
|
"w": pct(hdr("anthropic-ratelimit-unified-7d-utilization")),
|
||||||
"wr": reset_minutes(hdr("anthropic-ratelimit-unified-7d-reset")),
|
"wr": reset_minutes(hdr("anthropic-ratelimit-unified-7d-reset")),
|
||||||
"st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"),
|
"st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"),
|
||||||
"ok": True,
|
|
||||||
}
|
}
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
async def scan_for_device():
|
async def scan_for_device():
|
||||||
@@ -176,9 +304,58 @@ async def scan_for_device():
|
|||||||
|
|
||||||
|
|
||||||
class Session:
|
class Session:
|
||||||
def __init__(self, client: BleakClient) -> None:
|
def __init__(self, client: BleakClient, tray_state=None) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self.tray_state = tray_state
|
||||||
self.refresh_requested = asyncio.Event()
|
self.refresh_requested = asyncio.Event()
|
||||||
|
self.dim_requested = asyncio.Event() # watch opened the Dimmer screen → push a fresh light snapshot
|
||||||
|
self.ha = None # HAClient once a valid HA config is loaded
|
||||||
|
self.ha_entities: list = [] # controllable entity_ids from the config
|
||||||
|
self._loop = None # event loop captured for thread-safe BLE dispatch
|
||||||
|
self.low_bat_pct = 15 # warn at/below this watch battery %
|
||||||
|
self._battery_warned = False
|
||||||
|
self._last_logged_bat = None
|
||||||
|
self.buttons = [] # config buttons; list index == watch button index
|
||||||
|
|
||||||
|
def reload_buttons(self) -> None:
|
||||||
|
"""Re-read the config's button list so panel edits propagate to the watch
|
||||||
|
(called each poll). The list index is the index the watch reports back."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.config import load_config
|
||||||
|
except ImportError:
|
||||||
|
from config import load_config
|
||||||
|
self.buttons = load_config().get("buttons") or []
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Button reload failed: {e!r}")
|
||||||
|
|
||||||
|
def button_labels(self) -> list:
|
||||||
|
"""Compact label list for the RX 'btns' payload (count + length capped so
|
||||||
|
the merged BLE write stays under the firmware's 512-byte buffer)."""
|
||||||
|
out = []
|
||||||
|
for b in self.buttons[:WATCH_MAX_BUTTONS]:
|
||||||
|
lbl = str(b.get("label") or b.get("entity") or "Button").strip()
|
||||||
|
out.append(lbl[:WATCH_LABEL_MAX])
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def dim_snapshot(self) -> dict | None:
|
||||||
|
"""Compact live state of the tilt-dimmer's light for the RX 'dim' object,
|
||||||
|
so the watch dial seeds from reality. The dimmer targets the first HA
|
||||||
|
entity (same default the bri/ct commands fall back to). Returns None when
|
||||||
|
HA is off or the read fails — the watch then just shows its last value."""
|
||||||
|
if not self.ha or not self.ha_entities:
|
||||||
|
return None
|
||||||
|
snap = await self.ha.light_snapshot(self.ha_entities[0])
|
||||||
|
if not snap:
|
||||||
|
return None
|
||||||
|
out = {"on": 1 if snap["on"] else 0,
|
||||||
|
"mink": int(snap["min_kelvin"]),
|
||||||
|
"maxk": int(snap["max_kelvin"])}
|
||||||
|
if snap["brightness_pct"] is not None:
|
||||||
|
out["bri"] = int(snap["brightness_pct"])
|
||||||
|
if snap["color_temp_kelvin"] is not None:
|
||||||
|
out["ct"] = int(snap["color_temp_kelvin"])
|
||||||
|
return out
|
||||||
|
|
||||||
def _on_refresh(self, _char, _data: bytearray) -> None:
|
def _on_refresh(self, _char, _data: bytearray) -> None:
|
||||||
log("Refresh requested by device")
|
log("Refresh requested by device")
|
||||||
@@ -196,11 +373,179 @@ class Session:
|
|||||||
except (BleakError, ValueError, OSError) as e:
|
except (BleakError, ValueError, OSError) as e:
|
||||||
log(f"Refresh subscription unavailable: {e}")
|
log(f"Refresh subscription unavailable: {e}")
|
||||||
|
|
||||||
|
async def setup_command_subscription(self) -> None:
|
||||||
|
# Watch → host channel (…0005): battery telemetry (always) plus HA commands
|
||||||
|
# (when HA is configured). We subscribe regardless of HA so the low-battery
|
||||||
|
# warning works even with Home Assistant unset. Imports are lazy so a
|
||||||
|
# missing config/dep can never break daemon startup, and so they resolve
|
||||||
|
# after `log` is defined (ha_client imports it).
|
||||||
|
self._loop = asyncio.get_running_loop() # thread-safe dispatch from the BLE callback
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.ha_client import HAClient
|
||||||
|
from daemon.config import load_config, ha_settings
|
||||||
|
except ImportError:
|
||||||
|
from ha_client import HAClient
|
||||||
|
from config import load_config, ha_settings
|
||||||
|
cfg = load_config()
|
||||||
|
self.low_bat_pct = int(cfg.get("settings", {}).get("low_battery_pct", 15))
|
||||||
|
self.buttons = cfg.get("buttons") or []
|
||||||
|
ha = ha_settings(cfg)
|
||||||
|
if ha:
|
||||||
|
self.ha = HAClient(ha["url"], ha["token"])
|
||||||
|
self.ha_entities = ha["entities"]
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Config/HA init failed ({e!r}); HA control disabled")
|
||||||
|
try:
|
||||||
|
await self.client.start_notify(CMD_CHAR_UUID, self._on_command)
|
||||||
|
log(f"Command channel ready (HA={'on' if self.ha else 'off'}, "
|
||||||
|
f"low-batt={self.low_bat_pct}%, entities={self.ha_entities})")
|
||||||
|
except (BleakError, ValueError, OSError) as e:
|
||||||
|
log(f"Command subscription unavailable: {e}")
|
||||||
|
|
||||||
|
def _on_command(self, _char, data: bytearray) -> None:
|
||||||
|
try:
|
||||||
|
payload = json.loads(bytes(data).decode("utf-8", "replace"))
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
log(f"Watch msg: bad payload {bytes(data)!r}")
|
||||||
|
return
|
||||||
|
# Battery telemetry ({"bat":pct,"mv":..,"chg":0/1}) is handled inline (quick,
|
||||||
|
# no await). HA commands ({"cmd":...}) are dispatched onto the loop because
|
||||||
|
# bleak may deliver this callback on a non-loop thread.
|
||||||
|
if "bat" in payload:
|
||||||
|
self._handle_battery(payload)
|
||||||
|
return
|
||||||
|
# Dimmer screen opened (M3): ask the poll loop to push a fresh light
|
||||||
|
# snapshot on its next tick (~3s). asyncio.Event isn't thread-safe and
|
||||||
|
# this callback may run off-loop, so flip it via the loop.
|
||||||
|
if payload.get("cmd") == "dimreq":
|
||||||
|
loop = self._loop
|
||||||
|
if loop is not None:
|
||||||
|
loop.call_soon_threadsafe(self.dim_requested.set)
|
||||||
|
return
|
||||||
|
# v3: watch switched the displayed provider ({"cmd":"prov","id":".."}).
|
||||||
|
# Persist it to config and poll it immediately, on the loop thread.
|
||||||
|
if payload.get("cmd") == "prov":
|
||||||
|
pid = payload.get("id")
|
||||||
|
loop = self._loop
|
||||||
|
if isinstance(pid, str) and loop is not None:
|
||||||
|
loop.call_soon_threadsafe(self._set_active_provider, pid)
|
||||||
|
return
|
||||||
|
# v3: watch's Provider screen "switch" tap ({"cmd":"provnext"}) — the watch
|
||||||
|
# doesn't know the enabled set/order, so the daemon cycles to the next one.
|
||||||
|
if payload.get("cmd") == "provnext":
|
||||||
|
loop = self._loop
|
||||||
|
if loop is not None:
|
||||||
|
loop.call_soon_threadsafe(self._cycle_provider)
|
||||||
|
return
|
||||||
|
# Phase 7 M2: a watch button press carries only its index — map it to the
|
||||||
|
# configured action/entity/value here (the watch stays dumb).
|
||||||
|
if payload.get("cmd") == "btn":
|
||||||
|
i = payload.get("i")
|
||||||
|
if not isinstance(i, int) or not (0 <= i < len(self.buttons)):
|
||||||
|
log(f"Watch button {i}: out of range (have {len(self.buttons)})")
|
||||||
|
return
|
||||||
|
b = self.buttons[i]
|
||||||
|
payload = {"cmd": b.get("action") or "toggle", "e": b.get("entity")}
|
||||||
|
if b.get("value") is not None:
|
||||||
|
payload["v"] = b["value"]
|
||||||
|
log(f"Watch button {i} -> {payload}")
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None:
|
||||||
|
return
|
||||||
|
asyncio.run_coroutine_threadsafe(self._dispatch_command(payload), loop)
|
||||||
|
|
||||||
|
def _set_active_provider(self, pid: str) -> None:
|
||||||
|
"""Persist the watch-selected provider to config and poll it now. Runs on
|
||||||
|
the loop thread (via call_soon_threadsafe), so touching refresh_requested
|
||||||
|
is safe."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.config import load_config, save_config, PROVIDER_IDS
|
||||||
|
except ImportError:
|
||||||
|
from config import load_config, save_config, PROVIDER_IDS
|
||||||
|
if pid not in PROVIDER_IDS:
|
||||||
|
log(f"Provider switch: unknown id {pid!r}")
|
||||||
|
return
|
||||||
|
cfg = load_config()
|
||||||
|
if cfg.get("active_provider") != pid:
|
||||||
|
cfg["active_provider"] = pid
|
||||||
|
save_config(cfg)
|
||||||
|
log(f"Active provider -> {pid}")
|
||||||
|
self.refresh_requested.set() # poll the new provider on the next tick
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Provider switch failed: {e!r}")
|
||||||
|
|
||||||
|
def _cycle_provider(self) -> None:
|
||||||
|
"""Advance to the next enabled provider (on-watch 'switch' tap). Runs on
|
||||||
|
the loop thread. With a single enabled provider this is a no-op poll."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.config import active_provider_id
|
||||||
|
except ImportError:
|
||||||
|
from config import active_provider_id
|
||||||
|
ids = _enabled_ids()
|
||||||
|
cur = active_provider_id()
|
||||||
|
i = ids.index(cur) if cur in ids else -1
|
||||||
|
self._set_active_provider(ids[(i + 1) % len(ids)])
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Provider cycle failed: {e!r}")
|
||||||
|
|
||||||
|
def _handle_battery(self, payload: dict) -> None:
|
||||||
|
pct = payload.get("bat")
|
||||||
|
charging = bool(payload.get("chg", 0))
|
||||||
|
if not isinstance(pct, int):
|
||||||
|
return
|
||||||
|
if pct != self._last_logged_bat:
|
||||||
|
self._last_logged_bat = pct
|
||||||
|
log(f"Watch battery: {pct}%{' (charging)' if charging else ''}")
|
||||||
|
if self.tray_state is not None:
|
||||||
|
self.tray_state.battery_pct = pct
|
||||||
|
# Warn once on the way down; re-arm when charging or comfortably recovered.
|
||||||
|
if charging or pct > self.low_bat_pct + 10:
|
||||||
|
self._battery_warned = False
|
||||||
|
if not charging and pct <= self.low_bat_pct and not self._battery_warned:
|
||||||
|
self._battery_warned = True
|
||||||
|
log(f"Watch battery low: {pct}% (<= {self.low_bat_pct}%)")
|
||||||
|
if self.tray_state is not None:
|
||||||
|
self.tray_state.toasts.put(
|
||||||
|
("Clawdmeter", f"Watch battery low - {pct}%. Time to charge it."))
|
||||||
|
|
||||||
|
async def _dispatch_command(self, payload: dict) -> None:
|
||||||
|
if not self.ha:
|
||||||
|
return
|
||||||
|
cmd = payload.get("cmd")
|
||||||
|
entity = payload.get("e") or (self.ha_entities[0] if self.ha_entities else None)
|
||||||
|
if not entity:
|
||||||
|
log("HA cmd: no entity to target")
|
||||||
|
return
|
||||||
|
if cmd == "toggle":
|
||||||
|
ok = await self.ha.toggle(entity)
|
||||||
|
elif cmd == "bri": # step 3: brightness %, joystick
|
||||||
|
ok = await self.ha.set_brightness(entity, float(payload.get("v", 0)))
|
||||||
|
elif cmd == "ct": # step 3: color temperature, kelvin
|
||||||
|
ok = await self.ha.set_color_temp(entity, float(payload.get("v", 0)))
|
||||||
|
else:
|
||||||
|
log(f"HA cmd: unknown cmd {cmd!r}")
|
||||||
|
return
|
||||||
|
log(f"HA cmd {cmd} -> {'ok' if ok else 'FAIL'}")
|
||||||
|
|
||||||
async def write_payload(self, payload: dict) -> bool:
|
async def write_payload(self, payload: dict) -> bool:
|
||||||
data = json.dumps(payload, separators=(",", ":")).encode()
|
# ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char)
|
||||||
|
# instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic
|
||||||
|
# "now playing" payload. ArduinoJson parses UTF-8 directly and LVGL renders
|
||||||
|
# it, so this is a pure wire-size win with no firmware change.
|
||||||
|
data = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
|
||||||
log(f"Sending: {data.decode()}")
|
log(f"Sending: {data.decode()}")
|
||||||
try:
|
try:
|
||||||
await self.client.write_gatt_char(RX_CHAR_UUID, data, response=False)
|
# response=True (Write Request, not Write Command). WinRT performs a
|
||||||
|
# reliable long write when the payload exceeds the ATT MTU, so a long
|
||||||
|
# media title no longer fails with E_INVALIDARG the way a larger-than-MTU
|
||||||
|
# write-without-response does. The RX characteristic advertises WRITE as
|
||||||
|
# well as WRITE_NR and NimBLE reassembles the long write (512 B buffer).
|
||||||
|
# A successful write now also means the peer actually ACKed, which
|
||||||
|
# sharpens the zombie-link detection below.
|
||||||
|
await self.client.write_gatt_char(RX_CHAR_UUID, data, response=True)
|
||||||
return True
|
return True
|
||||||
except (BleakError, OSError) as e:
|
except (BleakError, OSError) as e:
|
||||||
# WinRT can raise a raw OSError/WinError (NOT wrapped as BleakError)
|
# WinRT can raise a raw OSError/WinError (NOT wrapped as BleakError)
|
||||||
@@ -312,12 +657,164 @@ def _read_expiry() -> str:
|
|||||||
return "expiry unknown"
|
return "expiry unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_credentials() -> tuple[Path, dict] | None:
|
||||||
|
"""Return (path, parsed-JSON dict) for the first readable credentials file.
|
||||||
|
|
||||||
|
Same probe order as read_token() so the file we refresh is the file we read
|
||||||
|
the token from.
|
||||||
|
"""
|
||||||
|
for path in _windows_credential_candidates():
|
||||||
|
try:
|
||||||
|
return path, json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_credentials_atomic(path: Path, data: dict) -> None:
|
||||||
|
"""Write credentials JSON via temp-file + os.replace (atomic same-volume).
|
||||||
|
|
||||||
|
Avoids leaving a half-written .credentials.json if the process dies mid-write
|
||||||
|
— a corrupt credentials file would break BOTH the daemon and Claude Code.
|
||||||
|
"""
|
||||||
|
tmp = path.with_name(path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _token_expired(oauth: dict, skew: int = TOKEN_REFRESH_SKEW) -> bool:
|
||||||
|
"""True if the access token is within `skew` seconds of expiry (or past it).
|
||||||
|
|
||||||
|
Unknown/absent expiresAt -> False: don't refresh blindly on every cycle; the
|
||||||
|
API-401 path (force=True) drives a refresh if the token is actually rejected.
|
||||||
|
"""
|
||||||
|
exp = oauth.get("expiresAt")
|
||||||
|
if not isinstance(exp, (int, float)):
|
||||||
|
return False
|
||||||
|
return (exp / 1000.0) <= (time.time() + skew)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_token_if_needed(force: bool = False) -> bool:
|
||||||
|
"""Renew the OAuth access token from the stored refresh token when it is
|
||||||
|
expired/near-expiry (or force=True), writing the rotated tokens back atomically.
|
||||||
|
|
||||||
|
Returns True iff a refresh succeeded and credentials were updated. Never
|
||||||
|
raises — every failure is logged and returned as False so a refresh hiccup
|
||||||
|
can never take down the poll loop.
|
||||||
|
"""
|
||||||
|
rc = _read_credentials()
|
||||||
|
if rc is None:
|
||||||
|
return False
|
||||||
|
path, data = rc
|
||||||
|
oauth = data.get("claudeAiOauth")
|
||||||
|
if not isinstance(oauth, dict):
|
||||||
|
return False
|
||||||
|
refresh = oauth.get("refreshToken")
|
||||||
|
if not isinstance(refresh, str) or not refresh.strip():
|
||||||
|
return False
|
||||||
|
if not force and not _token_expired(oauth):
|
||||||
|
return False
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"client_id": OAUTH_CLIENT_ID,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as http:
|
||||||
|
resp = await http.post(
|
||||||
|
OAUTH_TOKEN_URL, json=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
log(f"Token refresh network error: {e}")
|
||||||
|
return False
|
||||||
|
if resp.status_code != 200:
|
||||||
|
# Error bodies are {"error": ...} — no secrets; safe to log a snippet.
|
||||||
|
log(f"Token refresh HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
tok = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
log("Token refresh: non-JSON response")
|
||||||
|
return False
|
||||||
|
|
||||||
|
new_access = tok.get("access_token")
|
||||||
|
if not new_access:
|
||||||
|
log("Token refresh: response had no access_token")
|
||||||
|
return False
|
||||||
|
oauth["accessToken"] = new_access
|
||||||
|
if tok.get("refresh_token"):
|
||||||
|
oauth["refreshToken"] = tok["refresh_token"] # refresh tokens rotate
|
||||||
|
if tok.get("expires_in"):
|
||||||
|
oauth["expiresAt"] = int((time.time() + float(tok["expires_in"])) * 1000)
|
||||||
|
try:
|
||||||
|
_write_credentials_atomic(path, data)
|
||||||
|
except OSError as e:
|
||||||
|
log(f"Token refresh: could not write credentials: {e}")
|
||||||
|
return False
|
||||||
|
log(f"OAuth token refreshed; new expiry {_read_expiry()}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Now Playing (Phase 5) ----------------------------------------------------
|
||||||
|
# Best-effort read of the Windows "now playing" media session (System Media
|
||||||
|
# Transport Controls) so the watch can show the current track. Pure local WinRT —
|
||||||
|
# no network, no token — so it works even when the rate-limit data is unavailable.
|
||||||
|
# Any failure degrades to {"np": 0} (nothing playing) and never disturbs the loop.
|
||||||
|
NP_MAX_LEN = 60 # truncate title/artist so the BLE payload stays comfortably small
|
||||||
|
|
||||||
|
|
||||||
|
async def read_now_playing() -> dict:
|
||||||
|
"""Return compact now-playing fields for the BLE payload::
|
||||||
|
|
||||||
|
{"np": 0|1|2, "nt": <title>, "na": <artist>}
|
||||||
|
|
||||||
|
np: 0 = nothing playing, 1 = playing, 2 = paused. nt/na are omitted when
|
||||||
|
empty. Never raises — the daemon must keep polling even if WinRT hiccups
|
||||||
|
(and the winrt media package may simply be absent on some installs).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from winrt.windows.media.control import (
|
||||||
|
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
|
||||||
|
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PB,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
return {"np": 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
mgr = await MediaManager.request_async()
|
||||||
|
sess = mgr.get_current_session()
|
||||||
|
if sess is None:
|
||||||
|
return {"np": 0}
|
||||||
|
status = sess.get_playback_info().playback_status
|
||||||
|
if status == PB.PLAYING:
|
||||||
|
np = 1
|
||||||
|
elif status == PB.PAUSED:
|
||||||
|
np = 2
|
||||||
|
else:
|
||||||
|
np = 0 # stopped / closed / changing -> treat as nothing playing
|
||||||
|
out: dict = {"np": np}
|
||||||
|
if np:
|
||||||
|
info = await sess.try_get_media_properties_async()
|
||||||
|
title = (info.title or "").strip()
|
||||||
|
artist = (info.artist or "").strip()
|
||||||
|
if title:
|
||||||
|
out["nt"] = title[:NP_MAX_LEN]
|
||||||
|
if artist:
|
||||||
|
out["na"] = artist[:NP_MAX_LEN]
|
||||||
|
return out
|
||||||
|
except Exception as e: # WinRT can surface assorted OSError/RuntimeError types
|
||||||
|
log(f"Now-playing read failed: {e!r}")
|
||||||
|
return {"np": 0}
|
||||||
|
|
||||||
|
|
||||||
async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
|
async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
|
||||||
"""Return when any of `events` is set, or after `timeout` seconds.
|
"""Return when any of `events` is set, or after `timeout` seconds.
|
||||||
|
|
||||||
Lets the poll loop's TICK wait wake immediately on a stop signal (clean,
|
Lets the poll loop's tick wait wake immediately on a stop signal (clean,
|
||||||
responsive Quit) without losing the refresh-request wakeup — instead of
|
responsive Quit) without losing the refresh-request wakeup — instead of
|
||||||
waiting only on refresh_requested and re-checking stop_event up to TICK
|
waiting only on refresh_requested and re-checking stop_event up to a tick
|
||||||
later. Cancels and drains the loser tasks so they don't warn.
|
later. Cancels and drains the loser tasks so they don't warn.
|
||||||
"""
|
"""
|
||||||
tasks = [asyncio.ensure_future(e.wait()) for e in events]
|
tasks = [asyncio.ensure_future(e.wait()) for e in events]
|
||||||
@@ -329,6 +826,75 @@ async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
|
|||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_provider():
|
||||||
|
"""(id, Provider) for the currently configured active provider. Lazy imports
|
||||||
|
mirror the rest of the daemon so this resolves under `-m`, the frozen exe,
|
||||||
|
and pytest alike. Falls back to Anthropic if the config can't be read."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.providers import get_provider
|
||||||
|
from daemon.config import active_provider_id
|
||||||
|
except ImportError:
|
||||||
|
from providers import get_provider
|
||||||
|
from config import active_provider_id
|
||||||
|
pid = active_provider_id()
|
||||||
|
except Exception as e: # never let provider selection break the loop
|
||||||
|
log(f"Provider select failed ({e!r}); using anthropic")
|
||||||
|
try:
|
||||||
|
from daemon.providers import get_provider
|
||||||
|
except ImportError:
|
||||||
|
from providers import get_provider
|
||||||
|
pid = "anthropic"
|
||||||
|
return pid, get_provider(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_ids() -> list:
|
||||||
|
"""Enabled provider ids in on-watch cycle order (never empty — the watch
|
||||||
|
always has at least Anthropic to cycle/show). Used for both the on-watch
|
||||||
|
'switch' cycle and the pi/pc badge sent to the Provider screen."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.config import enabled_providers
|
||||||
|
except ImportError:
|
||||||
|
from config import enabled_providers
|
||||||
|
return enabled_providers() or ["anthropic"]
|
||||||
|
except Exception:
|
||||||
|
return ["anthropic"]
|
||||||
|
|
||||||
|
|
||||||
|
# The live Session, exposed module-wide so the control-panel HTTP thread can push
|
||||||
|
# an active-provider switch into the BLE loop (set on connect, cleared on exit).
|
||||||
|
_active_session = None
|
||||||
|
|
||||||
|
|
||||||
|
def request_provider_switch(pid: str) -> None:
|
||||||
|
"""Control-panel hook: switch the watch's displayed provider immediately,
|
||||||
|
reusing the exact on-watch path (persist config + refresh-poll + BLE push).
|
||||||
|
Called from the panel's HTTP thread, so dispatch onto the BLE loop. If no
|
||||||
|
watch is connected, persist directly so the next connect uses it. Never raises."""
|
||||||
|
if not isinstance(pid, str):
|
||||||
|
return
|
||||||
|
sess = _active_session
|
||||||
|
loop = getattr(sess, "_loop", None) if sess is not None else None
|
||||||
|
if sess is not None and loop is not None:
|
||||||
|
loop.call_soon_threadsafe(sess._set_active_provider, pid)
|
||||||
|
return
|
||||||
|
# No live session/loop yet: persist directly. (The panel PUT already saved the
|
||||||
|
# config, so this is usually a no-op — but it keeps the hook correct on its own.)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.config import load_config, save_config, PROVIDER_IDS
|
||||||
|
except ImportError:
|
||||||
|
from config import load_config, save_config, PROVIDER_IDS
|
||||||
|
if pid in PROVIDER_IDS:
|
||||||
|
cfg = load_config()
|
||||||
|
if cfg.get("active_provider") != pid:
|
||||||
|
cfg["active_provider"] = pid
|
||||||
|
save_config(cfg)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Provider switch (offline) failed: {e!r}")
|
||||||
|
|
||||||
|
|
||||||
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
|
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
|
||||||
"""Connect to device and poll until disconnected or stopped.
|
"""Connect to device and poll until disconnected or stopped.
|
||||||
|
|
||||||
@@ -378,38 +944,105 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
log("Connected")
|
log("Connected")
|
||||||
session = Session(client)
|
session = Session(client, tray_state)
|
||||||
|
global _active_session
|
||||||
|
_active_session = session # let the control panel reach this session for live switches
|
||||||
await session.setup_refresh_subscription()
|
await session.setup_refresh_subscription()
|
||||||
|
await session.setup_command_subscription()
|
||||||
|
|
||||||
last_poll = 0.0 # D-03: poll immediately on first connect
|
# Two cadences share one connection: the Anthropic usage / rate-limit poll runs
|
||||||
|
# every POLL_INTERVAL (60s) while the Windows media session is read every
|
||||||
|
# NOWPLAYING_INTERVAL (3s). The last usage payload is cached and merged into
|
||||||
|
# each now-playing write, so the firmware always gets one complete JSON object
|
||||||
|
# (its parser defaults any missing field to 0 — a partial write would blank the
|
||||||
|
# usage screens) and a track change shows within a few seconds, not 60.
|
||||||
|
last_claude_poll = 0.0 # 0 => poll Anthropic immediately on first connect
|
||||||
|
cached: dict = {} # last usage + rate-limit fields, re-sent every tick
|
||||||
|
last_np_sent = None # last now-playing fields actually written (change gate)
|
||||||
used_successfully = False
|
used_successfully = False
|
||||||
consecutive_failures = 0 # D-03: zombie-link break counter
|
consecutive_failures = 0 # D-03: zombie-link break counter
|
||||||
try:
|
try:
|
||||||
while client.is_connected and not stop_event.is_set():
|
while client.is_connected and not stop_event.is_set():
|
||||||
now = time.time()
|
now = time.time()
|
||||||
elapsed = now - last_poll
|
claude_due = (session.refresh_requested.is_set()
|
||||||
if session.refresh_requested.is_set() or elapsed >= POLL_INTERVAL:
|
or (now - last_claude_poll) >= POLL_INTERVAL)
|
||||||
|
dim_due = session.dim_requested.is_set() # watch opened the Dimmer screen
|
||||||
|
if dim_due:
|
||||||
|
session.dim_requested.clear()
|
||||||
|
auth_problem = False
|
||||||
|
|
||||||
|
if claude_due:
|
||||||
session.refresh_requested.clear()
|
session.refresh_requested.clear()
|
||||||
token = read_token() # D-09: fresh each cycle
|
session.reload_buttons() # pick up desktop-panel edits to the button set
|
||||||
if not token:
|
|
||||||
log("No token; skipping poll")
|
# v3: poll whichever provider is active (config-driven, so an
|
||||||
if tray_state:
|
# on-watch switch takes effect on the next cycle). The provider
|
||||||
tray_state.set_error("token expired — run claude login")
|
# normalizes its own auth + rate-limit + local usage into a
|
||||||
else:
|
# ProviderStatus and never raises — a genuine auth failure sets
|
||||||
|
# auth_problem, a transient blip just leaves ok=False (SC#5).
|
||||||
|
# Today only Anthropic is real; OpenAI/z.ai are stubs until M2/M3.
|
||||||
|
active_pv, provider = _active_provider()
|
||||||
|
status = await provider.poll()
|
||||||
|
auth_problem = status.auth_problem
|
||||||
|
cached = status.to_payload()
|
||||||
|
cached["pv"] = active_pv # which brand theme the watch wears
|
||||||
|
cached["pnm"] = str(provider.label)[:16] # name for the Provider screen
|
||||||
|
try: # brand accent (0xRRGGBB) for the theme
|
||||||
|
cached["ac"] = int(provider.accent, 16)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
# 1-based position + count among enabled providers, so the watch's
|
||||||
|
# Provider screen shows "1/3" and hides the switch hint when alone.
|
||||||
|
_ids = _enabled_ids()
|
||||||
|
cached["pc"] = len(_ids)
|
||||||
|
cached["pi"] = (_ids.index(active_pv) + 1) if active_pv in _ids else 1
|
||||||
|
last_claude_poll = now
|
||||||
|
|
||||||
|
# Now Playing (Phase 5) — best-effort Windows media session, read every
|
||||||
|
# tick (fast cadence). Local only, so it works regardless of "ok" above.
|
||||||
try:
|
try:
|
||||||
payload = await poll_api(token)
|
np = await read_now_playing()
|
||||||
except AuthError:
|
except Exception as e: # never let media reading break the poll loop
|
||||||
# Real 401/403 — token genuinely needs a refresh.
|
log(f"Now-playing skipped: {e!r}")
|
||||||
if tray_state:
|
np = {"np": 0}
|
||||||
tray_state.set_error("token expired — run claude login")
|
|
||||||
payload = None
|
# Light snapshot for the tilt-dimmer dial — fetched only when the watch
|
||||||
if payload is not None:
|
# opens the Dimmer screen (dim_due), to seed the dial from reality. No
|
||||||
|
# per-heartbeat fetch: during a session the watch is authoritative (it
|
||||||
|
# streams absolute values), and it re-requests on every re-open. Best-
|
||||||
|
# effort — a slow/dead HA just omits the field, never stalls the loop.
|
||||||
|
dim_snap = None
|
||||||
|
if dim_due:
|
||||||
|
try:
|
||||||
|
dim_snap = await session.dim_snapshot()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Dim snapshot skipped: {e!r}")
|
||||||
|
|
||||||
|
# Write when the usage data was just refreshed (this is also the ~60s
|
||||||
|
# heartbeat), when the track / playback state changed since the last
|
||||||
|
# write, or when the watch asked for a dimmer snapshot — skip otherwise
|
||||||
|
# so the link, and the field log, stay quiet between changes.
|
||||||
|
if claude_due or dim_due or np != last_np_sent:
|
||||||
|
payload = dict(cached)
|
||||||
|
payload.update(np)
|
||||||
|
if claude_due:
|
||||||
|
payload["btns"] = session.button_labels() # ~60s heartbeat only
|
||||||
|
if dim_snap is not None:
|
||||||
|
payload["dim"] = dim_snap
|
||||||
if await session.write_payload(payload):
|
if await session.write_payload(payload):
|
||||||
last_poll = time.time()
|
|
||||||
used_successfully = True
|
used_successfully = True
|
||||||
consecutive_failures = 0 # D-03: reset on success
|
consecutive_failures = 0 # D-03: reset on success
|
||||||
|
last_np_sent = np
|
||||||
|
# The tray reflects the Anthropic data freshness, so only touch
|
||||||
|
# it on a usage poll — never on a now-playing-only write.
|
||||||
|
if claude_due:
|
||||||
|
if cached.get("ok"):
|
||||||
if tray_state:
|
if tray_state:
|
||||||
tray_state.set_connected(time.time())
|
tray_state.set_connected(time.time())
|
||||||
|
elif auth_problem:
|
||||||
|
if tray_state:
|
||||||
|
tray_state.set_error("token expired — run claude login")
|
||||||
|
# transient rate-limit failure: leave tray state unchanged
|
||||||
else:
|
else:
|
||||||
consecutive_failures += 1
|
consecutive_failures += 1
|
||||||
if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
|
if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
|
||||||
@@ -418,19 +1051,17 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
f" write failures); abandoning connection"
|
f" write failures); abandoning connection"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
# else: payload is None from a TRANSIENT failure (network/DNS,
|
|
||||||
# timeout, rate-limit, 5xx). poll_api already logged it; do NOT
|
|
||||||
# toast "token expired" — that mislabeled a boot-time DNS blip
|
|
||||||
# as an auth problem (SC#5). Leave tray state unchanged; the next
|
|
||||||
# tick retries and set_connected() recovers it.
|
|
||||||
|
|
||||||
# Wake on a refresh request OR a stop, whichever comes first. Waking
|
# Wake on a refresh request OR a stop, whichever comes first, but no
|
||||||
# promptly on stop_event is what lets the finally below run
|
# later than NOWPLAYING_INTERVAL so the media session is re-read on time.
|
||||||
# client.disconnect() before the process exits, so the peer gets a
|
# Waking promptly on stop_event is what lets the finally below run
|
||||||
# clean GATT disconnect (returns to its waiting screen) instead of
|
# client.disconnect() before the process exits, so the peer gets a clean
|
||||||
# being left frozen on stale data after Quit (SC#3 graceful shutdown).
|
# GATT disconnect (returns to its waiting screen) instead of being left
|
||||||
await _wait_first(session.refresh_requested, stop_event, timeout=TICK)
|
# frozen on stale data after Quit (SC#3 graceful shutdown).
|
||||||
|
await _wait_first(session.refresh_requested, stop_event,
|
||||||
|
timeout=NOWPLAYING_INTERVAL)
|
||||||
finally:
|
finally:
|
||||||
|
_active_session = None # no live session for the panel to push into
|
||||||
# Clean GATT disconnect on the way out — this is what tells the peripheral
|
# Clean GATT disconnect on the way out — this is what tells the peripheral
|
||||||
# the link is gone. WinRT can surface a raw OSError (not BleakError) here,
|
# the link is gone. WinRT can surface a raw OSError (not BleakError) here,
|
||||||
# so swallow both; the link tears down regardless once we exit.
|
# so swallow both; the link tears down regardless once we exit.
|
||||||
@@ -438,6 +1069,11 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
await client.disconnect()
|
await client.disconnect()
|
||||||
except (BleakError, OSError):
|
except (BleakError, OSError):
|
||||||
pass
|
pass
|
||||||
|
if session.ha:
|
||||||
|
try:
|
||||||
|
await session.ha.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
log("Device disconnected" if not stop_event.is_set() else "Stopping")
|
log("Device disconnected" if not stop_event.is_set() else "Stopping")
|
||||||
return used_successfully
|
return used_successfully
|
||||||
@@ -490,6 +1126,7 @@ async def main(tray_state=None) -> None:
|
|||||||
search_backoff = 1 # caps at 60s — gentle, for a device that is genuinely absent/off
|
search_backoff = 1 # caps at 60s — gentle, for a device that is genuinely absent/off
|
||||||
reconnect_backoff = 1 # caps at RECONNECT_BACKOFF_CAP — fast, to clear the 120s SLA after a drop
|
reconnect_backoff = 1 # caps at RECONNECT_BACKOFF_CAP — fast, to clear the 120s SLA after a drop
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
device = await scan_for_device()
|
device = await scan_for_device()
|
||||||
if not device:
|
if not device:
|
||||||
# Slow-search regime: device was not found by scan — back off gently
|
# Slow-search regime: device was not found by scan — back off gently
|
||||||
@@ -518,6 +1155,22 @@ async def main(tray_state=None) -> None:
|
|||||||
# Successful session — reset reconnect counter to floor; search_backoff also reset
|
# Successful session — reset reconnect counter to floor; search_backoff also reset
|
||||||
reconnect_backoff = 1
|
reconnect_backoff = 1
|
||||||
search_backoff = 1
|
search_backoff = 1
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# The whole BLE adapter can disappear (USB dongle unplugged) — bleak/
|
||||||
|
# WinRT then raises from the scan or the connect. Never let that kill
|
||||||
|
# the loop: log, show Scanning, back off, and keep retrying so
|
||||||
|
# replugging the adapter recovers on its own, no manual restart
|
||||||
|
# (field SC: pulled the dongle -> daemon crashed and stayed down).
|
||||||
|
if tray_state:
|
||||||
|
tray_state.set_scanning()
|
||||||
|
log(f"BLE error ({type(e).__name__}: {e}); retrying in {search_backoff}s...")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop_event.wait(), timeout=search_backoff)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
search_backoff = _next_backoff(search_backoff, 60)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Unified Clawdmeter configuration (Phase 7).
|
||||||
|
|
||||||
|
Single source of truth at ``%LOCALAPPDATA%\\Clawdmeter\\config.json``, shared by
|
||||||
|
the tray daemon and the FastAPI control panel. Replaces the Phase-6
|
||||||
|
``ha_config.json`` (auto-migrated on first load).
|
||||||
|
|
||||||
|
Shape (v2)::
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"ha": {"url": str, "token": str, "entities": [str, ...]},
|
||||||
|
"buttons": [{"id": str, "label": str, "icon": str,
|
||||||
|
"action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}],
|
||||||
|
"providers": { # v3: multi-provider usage
|
||||||
|
"anthropic": {"enabled": bool},
|
||||||
|
"openai": {"enabled": bool},
|
||||||
|
"zai": {"enabled": bool, "base_url": str, "api_key": str},
|
||||||
|
},
|
||||||
|
"active_provider": "anthropic", # which provider the watch displays
|
||||||
|
"display_order": ["anthropic", "openai", "zai"], # on-watch cycle order
|
||||||
|
"settings": {"device_address": str, "autostart": bool}
|
||||||
|
}
|
||||||
|
|
||||||
|
The token is a secret: it lives only in this file (outside the repo, gitignored)
|
||||||
|
and is NEVER logged (only its length) — same discipline as ha_client.py. The
|
||||||
|
control panel masks it in API responses. Provider API keys (e.g. z.ai) are
|
||||||
|
secrets too and follow the same rule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CONFIG_VERSION = 2
|
||||||
|
DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only
|
||||||
|
VALID_ACTIONS = ("toggle", "bri", "ct")
|
||||||
|
|
||||||
|
# v3 providers. Order here is the default on-watch cycle order. Anthropic needs
|
||||||
|
# no extra config (it reads Claude Code's OAuth creds); z.ai needs a base URL +
|
||||||
|
# API key; OpenAI reads the local Codex install. Keep this list and the per-
|
||||||
|
# provider default shapes in sync with default_config().
|
||||||
|
PROVIDER_IDS = ("anthropic", "openai", "zai")
|
||||||
|
|
||||||
|
|
||||||
|
def _dir() -> Path:
|
||||||
|
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||||
|
return base / "Clawdmeter"
|
||||||
|
|
||||||
|
|
||||||
|
def config_path() -> Path:
|
||||||
|
if override := os.environ.get("CLAWDMETER_CONFIG"):
|
||||||
|
return Path(override)
|
||||||
|
return _dir() / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_ha_path() -> Path:
|
||||||
|
return _dir() / "ha_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def default_providers() -> dict:
|
||||||
|
"""Per-provider config defaults. Anthropic is enabled+active out of the box so
|
||||||
|
a v1 config (no providers section) keeps behaving exactly as before."""
|
||||||
|
return {
|
||||||
|
"anthropic": {"enabled": True},
|
||||||
|
"openai": {"enabled": False},
|
||||||
|
"zai": {"enabled": False, "base_url": "", "api_key": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_config() -> dict:
|
||||||
|
return {
|
||||||
|
"version": CONFIG_VERSION,
|
||||||
|
"ha": {"url": "", "token": "", "entities": []},
|
||||||
|
"buttons": [],
|
||||||
|
"providers": default_providers(),
|
||||||
|
"active_provider": "anthropic",
|
||||||
|
"display_order": list(PROVIDER_IDS),
|
||||||
|
"settings": {"device_address": "", "autostart": False, "low_battery_pct": 15},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy(cfg: dict) -> dict:
|
||||||
|
"""Import the Phase-6 ha_config.json into the unified config when the unified
|
||||||
|
HA section is still empty. Non-destructive: the old file is left in place."""
|
||||||
|
legacy = _legacy_ha_path()
|
||||||
|
if cfg["ha"]["url"] or not legacy.exists():
|
||||||
|
return cfg
|
||||||
|
try:
|
||||||
|
# utf-8-sig: PowerShell's Out-File writes a BOM that json.loads chokes on.
|
||||||
|
old = json.loads(legacy.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return cfg
|
||||||
|
cfg["ha"]["url"] = (old.get("url") or "").strip().rstrip("/")
|
||||||
|
cfg["ha"]["token"] = (old.get("token") or "").strip()
|
||||||
|
cfg["ha"]["entities"] = list(old.get("entities") or [])
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
"""Load the unified config, filling defaults for any missing section so new
|
||||||
|
keys added in later versions always resolve. Never raises."""
|
||||||
|
cfg = default_config()
|
||||||
|
try:
|
||||||
|
loaded = json.loads(config_path().read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
loaded = None
|
||||||
|
if isinstance(loaded, dict):
|
||||||
|
for section in ("ha", "settings"):
|
||||||
|
if isinstance(loaded.get(section), dict):
|
||||||
|
cfg[section].update(loaded[section])
|
||||||
|
if isinstance(loaded.get("buttons"), list):
|
||||||
|
cfg["buttons"] = loaded["buttons"]
|
||||||
|
# v3 providers — deep-merge per provider so new keys added in later
|
||||||
|
# versions always resolve, and a v1 config (no providers) keeps the
|
||||||
|
# defaults (anthropic enabled + active) → behaviour unchanged.
|
||||||
|
if isinstance(loaded.get("providers"), dict):
|
||||||
|
for pid, pconf in loaded["providers"].items():
|
||||||
|
if pid in cfg["providers"] and isinstance(pconf, dict):
|
||||||
|
cfg["providers"][pid].update(pconf)
|
||||||
|
if isinstance(loaded.get("active_provider"), str):
|
||||||
|
cfg["active_provider"] = loaded["active_provider"]
|
||||||
|
if isinstance(loaded.get("display_order"), list):
|
||||||
|
cfg["display_order"] = [p for p in loaded["display_order"] if p in PROVIDER_IDS]
|
||||||
|
# active provider must be a known id; fall back to anthropic otherwise.
|
||||||
|
if cfg["active_provider"] not in PROVIDER_IDS:
|
||||||
|
cfg["active_provider"] = "anthropic"
|
||||||
|
# display order must cover every provider (append any missing at the end).
|
||||||
|
for pid in PROVIDER_IDS:
|
||||||
|
if pid not in cfg["display_order"]:
|
||||||
|
cfg["display_order"].append(pid)
|
||||||
|
if not cfg["ha"]["url"] and not cfg["ha"]["token"]:
|
||||||
|
cfg = _migrate_legacy(cfg)
|
||||||
|
# Normalize the HA section the same way ha_client expects it.
|
||||||
|
cfg["ha"]["url"] = (cfg["ha"].get("url") or "").strip().rstrip("/")
|
||||||
|
cfg["ha"]["token"] = (cfg["ha"].get("token") or "").strip()
|
||||||
|
cfg["ha"]["entities"] = list(cfg["ha"].get("entities") or [])
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict) -> None:
|
||||||
|
"""Persist atomically (temp file + os.replace) so a crash mid-write can't
|
||||||
|
leave a truncated config."""
|
||||||
|
path = config_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_name(path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def ha_settings(cfg: dict | None = None) -> dict | None:
|
||||||
|
"""Return {url, token, entities} when HA is configured (url + real token),
|
||||||
|
else None — the shape the daemon needs to build an HAClient. Logging of the
|
||||||
|
token is the caller's responsibility (ha_client logs only the length)."""
|
||||||
|
if cfg is None:
|
||||||
|
cfg = load_config()
|
||||||
|
ha = cfg.get("ha", {})
|
||||||
|
url = (ha.get("url") or "").strip().rstrip("/")
|
||||||
|
token = (ha.get("token") or "").strip()
|
||||||
|
if not url or not token or token.startswith("PASTE_"):
|
||||||
|
return None
|
||||||
|
return {"url": url, "token": token, "entities": list(ha.get("entities") or [])}
|
||||||
|
|
||||||
|
|
||||||
|
# --- v3 provider accessors -------------------------------------------------
|
||||||
|
|
||||||
|
def active_provider_id(cfg: dict | None = None) -> str:
|
||||||
|
"""The provider id the watch should display right now (always a valid id)."""
|
||||||
|
if cfg is None:
|
||||||
|
cfg = load_config()
|
||||||
|
pid = cfg.get("active_provider", "anthropic")
|
||||||
|
return pid if pid in PROVIDER_IDS else "anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def provider_conf(cfg: dict, pid: str) -> dict:
|
||||||
|
"""Per-provider config dict (empty dict if the id is unknown)."""
|
||||||
|
return (cfg.get("providers") or {}).get(pid, {})
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_providers(cfg: dict | None = None) -> list:
|
||||||
|
"""Enabled provider ids in the configured on-watch cycle order."""
|
||||||
|
if cfg is None:
|
||||||
|
cfg = load_config()
|
||||||
|
order = cfg.get("display_order") or list(PROVIDER_IDS)
|
||||||
|
return [p for p in order if p in PROVIDER_IDS and provider_conf(cfg, p).get("enabled")]
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Home Assistant REST client for the Clawdmeter daemon (Phase 6).
|
||||||
|
|
||||||
|
The watch stays "dumb": it sends a short command over BLE (which entity, which
|
||||||
|
parameter, what value) and this module turns that into a Home Assistant service
|
||||||
|
call over the local network. Everything HA-specific — the base URL, the
|
||||||
|
long-lived access token, the controllable entities — lives on the PC, never on
|
||||||
|
the device.
|
||||||
|
|
||||||
|
Config lives OUTSIDE the repo at %LOCALAPPDATA%\\Clawdmeter\\ha_config.json so
|
||||||
|
the token is never committed. Copy daemon/ha_config.example.json there and fill
|
||||||
|
in `url` + `token`. The token is a secret: it is sent only as the HTTP
|
||||||
|
Authorization header and is NEVER written to any log (mirrors how the OAuth
|
||||||
|
secret is handled in the main daemon).
|
||||||
|
|
||||||
|
Live self-test against your real light (after the config is filled in), run
|
||||||
|
from the repo root with the venv python:
|
||||||
|
.venv\\Scripts\\python.exe -m daemon.ha_client
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Reuse the daemon's logger so HA lines land in the same daemon.log. Importing
|
||||||
|
# the daemon module is safe (the tray already imports it; it has no import-time
|
||||||
|
# side effects beyond setting up the file logger). Fall back to print() when run
|
||||||
|
# in isolation before packaging.
|
||||||
|
try:
|
||||||
|
from daemon.claude_usage_daemon_windows import log
|
||||||
|
except Exception: # pragma: no cover - standalone/fallback
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# GL-C-006P advertises feature 32 (transition), so every change can EASE instead
|
||||||
|
# of snapping. Keep it short: the joystick sends ~5x/sec, so a long transition
|
||||||
|
# would lag behind the wrist. 0.3s reads as "smooth" without feeling laggy.
|
||||||
|
DEFAULT_TRANSITION = 0.3
|
||||||
|
DEFAULT_MIN_KELVIN = 2000
|
||||||
|
DEFAULT_MAX_KELVIN = 6500
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path() -> Path:
|
||||||
|
if override := os.environ.get("CLAWDMETER_HA_CONFIG"):
|
||||||
|
return Path(override)
|
||||||
|
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||||
|
return base / "Clawdmeter" / "ha_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_ha_config() -> dict | None:
|
||||||
|
"""Load {url, token, entities} from the local HA config file.
|
||||||
|
|
||||||
|
Returns None (with a helpful log) when the file is missing or still holds the
|
||||||
|
placeholder token, so the daemon runs fine with HA simply disabled.
|
||||||
|
"""
|
||||||
|
path = _config_path()
|
||||||
|
try:
|
||||||
|
# utf-8-sig: PowerShell's `Out-File -Encoding utf8` writes a BOM, which
|
||||||
|
# would otherwise make json.loads choke on the first character.
|
||||||
|
raw = path.read_text(encoding="utf-8-sig")
|
||||||
|
except OSError:
|
||||||
|
log(f"HA: no config at {path} - HA control disabled "
|
||||||
|
f"(copy daemon/ha_config.example.json there)")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cfg = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
log(f"HA: config at {path} is not valid JSON ({e}) — HA control disabled")
|
||||||
|
return None
|
||||||
|
url = (cfg.get("url") or "").strip().rstrip("/")
|
||||||
|
token = (cfg.get("token") or "").strip()
|
||||||
|
entities = cfg.get("entities") or []
|
||||||
|
if not url or not token or token.startswith("PASTE_"):
|
||||||
|
log("HA: config present but `url`/`token` not filled in - HA control disabled")
|
||||||
|
return None
|
||||||
|
# Confirm load WITHOUT ever logging the token itself.
|
||||||
|
log(f"HA: config loaded - url={url}, entities={entities}, token len={len(token)}")
|
||||||
|
return {"url": url, "token": token, "entities": list(entities)}
|
||||||
|
|
||||||
|
|
||||||
|
class HAClient:
|
||||||
|
"""Thin async wrapper over the Home Assistant REST API.
|
||||||
|
|
||||||
|
One long-lived httpx client; calls are local and quick. Methods return
|
||||||
|
True/False (or parsed state) and never raise on an HTTP/Zigbee failure — a
|
||||||
|
dead HA or a flaky mesh must not crash the daemon loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, url: str, token: str, *, timeout: float = 10.0) -> None:
|
||||||
|
self._url = url.rstrip("/")
|
||||||
|
self._http = httpx.AsyncClient(
|
||||||
|
timeout=timeout,
|
||||||
|
headers={"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._http.aclose()
|
||||||
|
|
||||||
|
async def _service(self, domain: str, service: str, data: dict) -> bool:
|
||||||
|
url = f"{self._url}/api/services/{domain}/{service}"
|
||||||
|
try:
|
||||||
|
resp = await self._http.post(url, json=data)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
log(f"HA: {domain}.{service} failed: {e}")
|
||||||
|
return False
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
# 401 (token) and 400 (bad entity) both land here. Log status + short
|
||||||
|
# body only — never the request headers, which carry the token.
|
||||||
|
log(f"HA: {domain}.{service} HTTP {resp.status_code}: {resp.text[:160]}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_state(self, entity_id: str) -> dict | None:
|
||||||
|
url = f"{self._url}/api/states/{entity_id}"
|
||||||
|
try:
|
||||||
|
resp = await self._http.get(url)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
log(f"HA: get_state({entity_id}) failed: {e}")
|
||||||
|
return None
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
log(f"HA: get_state({entity_id}) HTTP {resp.status_code}")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- light helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
async def turn_off(self, entity_id: str, transition: float = DEFAULT_TRANSITION) -> bool:
|
||||||
|
return await self._service("light", "turn_off",
|
||||||
|
{"entity_id": entity_id, "transition": transition})
|
||||||
|
|
||||||
|
async def toggle(self, entity_id: str) -> bool:
|
||||||
|
return await self._service("light", "toggle", {"entity_id": entity_id})
|
||||||
|
|
||||||
|
async def set_brightness(self, entity_id: str, pct: float,
|
||||||
|
transition: float = DEFAULT_TRANSITION) -> bool:
|
||||||
|
"""Set brightness 0-100 %. 0 (or below) turns the light off."""
|
||||||
|
pct = max(0, min(100, round(pct)))
|
||||||
|
if pct <= 0:
|
||||||
|
return await self.turn_off(entity_id, transition)
|
||||||
|
return await self._service("light", "turn_on", {
|
||||||
|
"entity_id": entity_id, "brightness_pct": pct, "transition": transition})
|
||||||
|
|
||||||
|
async def set_color_temp(self, entity_id: str, kelvin: float,
|
||||||
|
min_k: int = DEFAULT_MIN_KELVIN,
|
||||||
|
max_k: int = DEFAULT_MAX_KELVIN,
|
||||||
|
transition: float = DEFAULT_TRANSITION) -> bool:
|
||||||
|
kelvin = int(max(min_k, min(max_k, round(kelvin))))
|
||||||
|
return await self._service("light", "turn_on", {
|
||||||
|
"entity_id": entity_id, "color_temp_kelvin": kelvin, "transition": transition})
|
||||||
|
|
||||||
|
async def light_snapshot(self, entity_id: str) -> dict | None:
|
||||||
|
"""Read the light's current values so the watch dial starts from the real
|
||||||
|
state instead of 0. Returns normalized fields, or None on failure."""
|
||||||
|
st = await self.get_state(entity_id)
|
||||||
|
if not st:
|
||||||
|
return None
|
||||||
|
attrs = st.get("attributes", {})
|
||||||
|
bri_255 = attrs.get("brightness") # 0-255, or None when off
|
||||||
|
bri_pct = round(bri_255 / 255 * 100) if isinstance(bri_255, (int, float)) else None
|
||||||
|
return {
|
||||||
|
"on": st.get("state") == "on",
|
||||||
|
"brightness_pct": bri_pct,
|
||||||
|
"color_temp_kelvin": attrs.get("color_temp_kelvin"),
|
||||||
|
"min_kelvin": attrs.get("min_color_temp_kelvin", DEFAULT_MIN_KELVIN),
|
||||||
|
"max_kelvin": attrs.get("max_color_temp_kelvin", DEFAULT_MAX_KELVIN),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _self_test() -> int:
|
||||||
|
"""Live smoke test: drive the first configured light so you can SEE it react,
|
||||||
|
then restore its original state."""
|
||||||
|
cfg = load_ha_config()
|
||||||
|
if not cfg:
|
||||||
|
return 1
|
||||||
|
if not cfg["entities"]:
|
||||||
|
log("HA self-test: no entities in config")
|
||||||
|
return 1
|
||||||
|
eid = cfg["entities"][0]
|
||||||
|
ha = HAClient(cfg["url"], cfg["token"])
|
||||||
|
try:
|
||||||
|
snap = await ha.light_snapshot(eid)
|
||||||
|
if snap is None:
|
||||||
|
log(f"HA self-test: could not read {eid} - check url / token / entity_id")
|
||||||
|
return 1
|
||||||
|
log(f"HA self-test: {eid} now -> {snap}")
|
||||||
|
min_k, max_k = snap["min_kelvin"], snap["max_kelvin"]
|
||||||
|
sequence = [
|
||||||
|
("brightness 30%", lambda: ha.set_brightness(eid, 30)),
|
||||||
|
("brightness 80%", lambda: ha.set_brightness(eid, 80)),
|
||||||
|
(f"warm {min_k}K", lambda: ha.set_color_temp(eid, min_k, min_k, max_k)),
|
||||||
|
(f"cool {max_k}K", lambda: ha.set_color_temp(eid, max_k, min_k, max_k)),
|
||||||
|
]
|
||||||
|
for label, action in sequence:
|
||||||
|
ok = await action()
|
||||||
|
log(f"HA self-test: {label} -> {'ok' if ok else 'FAIL'}")
|
||||||
|
await asyncio.sleep(1.3)
|
||||||
|
# Restore the original state.
|
||||||
|
if snap["on"]:
|
||||||
|
if snap["brightness_pct"]:
|
||||||
|
await ha.set_brightness(eid, snap["brightness_pct"])
|
||||||
|
if snap["color_temp_kelvin"]:
|
||||||
|
await ha.set_color_temp(eid, snap["color_temp_kelvin"], min_k, max_k)
|
||||||
|
else:
|
||||||
|
await ha.turn_off(eid)
|
||||||
|
log("HA self-test: done (original state restored)")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
await ha.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(asyncio.run(_self_test()))
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Home Assistant config for Clawdmeter Phase 6. COPY this file to %LOCALAPPDATA%\\Clawdmeter\\ha_config.json and fill in url + token. The real file lives OUTSIDE the repo so the token is never committed. token = a Home Assistant Long-Lived Access Token (HA -> your profile -> Security -> Create Token).",
|
||||||
|
"url": "http://homeassistant.local:8123",
|
||||||
|
"token": "PASTE_LONG_LIVED_ACCESS_TOKEN_HERE",
|
||||||
|
"entities": ["light.dimmer_v_spalne"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""WebView2 settings window for Clawdmeter (Phase 7, M1).
|
||||||
|
|
||||||
|
Opens a native Windows WebView2 window (via pywebview) onto the local control
|
||||||
|
panel the tray process already serves on 127.0.0.1. Launched as a SEPARATE
|
||||||
|
PROCESS from the tray — ``Clawdmeter.exe --panel`` when frozen, ``python -m
|
||||||
|
daemon.panel`` in source — because pywebview and pystray each need to own the
|
||||||
|
main thread and cannot coexist in one process (see tray_windows._on_settings).
|
||||||
|
|
||||||
|
The port comes from CLAWDMETER_PANEL_PORT (set by the tray when it spawns us)
|
||||||
|
and falls back to the config default, so the window always points at the server
|
||||||
|
the tray actually started.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Make the `daemon` package importable when run as a bare script or frozen exe,
|
||||||
|
# mirroring tray_windows.py's bootstrap (logon autostart starts us with cwd =
|
||||||
|
# System32, and the frozen exe loads the package from the bundle root).
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
_REPO_ROOT = sys._MEIPASS # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _REPO_ROOT)
|
||||||
|
|
||||||
|
_VENV_SITE = os.path.join(_REPO_ROOT, ".venv", "Lib", "site-packages")
|
||||||
|
if os.path.isdir(_VENV_SITE):
|
||||||
|
import site
|
||||||
|
site.addsitedir(_VENV_SITE)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from daemon import config as cfgmod
|
||||||
|
except ImportError: # running as a plain script (cwd on path)
|
||||||
|
import config as cfgmod
|
||||||
|
|
||||||
|
WINDOW_TITLE = "Clawdmeter"
|
||||||
|
WINDOW_W = 860
|
||||||
|
WINDOW_H = 720
|
||||||
|
WINDOW_MIN = (640, 560)
|
||||||
|
BRAND_BG = "#131211" # paint the chrome brand-dark so there's no white flash
|
||||||
|
|
||||||
|
|
||||||
|
def _port() -> int:
|
||||||
|
raw = os.environ.get("CLAWDMETER_PANEL_PORT")
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return cfgmod.DEFAULT_PORT
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_server(url: str, timeout: float = 6.0) -> bool:
|
||||||
|
"""Poll the server's status endpoint until it answers or the timeout elapses.
|
||||||
|
|
||||||
|
The tray starts the HTTP server in a thread a moment before it can spawn us,
|
||||||
|
so a freshly-clicked Settings might briefly beat the socket. Polling avoids a
|
||||||
|
blank window in that race; a miss just means we open anyway and the UI's own
|
||||||
|
fetch retries.
|
||||||
|
"""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
probe = url.rstrip("/") + "/api/status"
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(probe, timeout=1.0) as r:
|
||||||
|
if r.status == 200:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
"""Open the control-panel window and block until the user closes it."""
|
||||||
|
import webview # imported here so the tray never pays for it unless --panel
|
||||||
|
|
||||||
|
url = f"http://127.0.0.1:{_port()}/"
|
||||||
|
_wait_for_server(url)
|
||||||
|
webview.create_window(
|
||||||
|
WINDOW_TITLE, url,
|
||||||
|
width=WINDOW_W, height=WINDOW_H,
|
||||||
|
min_size=WINDOW_MIN,
|
||||||
|
background_color=BRAND_BG,
|
||||||
|
)
|
||||||
|
# gui defaults to auto-detect; on Windows 11 that resolves to EdgeChromium
|
||||||
|
# (WebView2), which ships with the OS — the modern engine the brand CSS needs.
|
||||||
|
# start() blocks on the native GUI loop until the window closes.
|
||||||
|
webview.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Provider registry (v3).
|
||||||
|
|
||||||
|
get_provider(id) returns a Provider instance for the daemon to poll. Anthropic
|
||||||
|
is real; OpenAI and z.ai return a StubProvider until M2/M3 land, so selecting
|
||||||
|
them never crashes the loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import Provider, ProviderStatus, StubProvider
|
||||||
|
from .anthropic import AnthropicProvider
|
||||||
|
from .openai_codex import OpenAICodexProvider
|
||||||
|
from .zai import ZaiProvider
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(pid: str) -> Provider:
|
||||||
|
if pid == "anthropic":
|
||||||
|
return AnthropicProvider()
|
||||||
|
if pid == "openai":
|
||||||
|
return OpenAICodexProvider()
|
||||||
|
if pid == "zai":
|
||||||
|
return ZaiProvider()
|
||||||
|
# Unknown id (a config from a newer version, say) — a safe placeholder.
|
||||||
|
return StubProvider(pid, label=pid, accent="d97757")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Provider", "ProviderStatus", "StubProvider",
|
||||||
|
"AnthropicProvider", "OpenAICodexProvider", "ZaiProvider", "get_provider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Anthropic (Claude Code) provider.
|
||||||
|
|
||||||
|
Wraps the daemon's existing OAuth + rate-limit poll + local-usage logic behind
|
||||||
|
the Provider interface. Behaviour is identical to pre-v3 Clawdmeter — this only
|
||||||
|
moves the Claude-specific work behind a seam so OpenAI/z.ai can slot in beside
|
||||||
|
it. The heavy lifting (compute_today_usage, poll_api, token refresh) still lives
|
||||||
|
in claude_usage_daemon_windows.py; this just orchestrates it into a
|
||||||
|
ProviderStatus.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import Provider, ProviderStatus
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicProvider(Provider):
|
||||||
|
id = "anthropic"
|
||||||
|
label = "Claude Code"
|
||||||
|
accent = "d97757" # brand terra-cotta
|
||||||
|
|
||||||
|
async def poll(self) -> ProviderStatus:
|
||||||
|
# Lazy import: the daemon module imports this package, so importing it
|
||||||
|
# back at module load would be circular. By poll() time it's resolved.
|
||||||
|
from daemon.claude_usage_daemon_windows import (
|
||||||
|
compute_today_usage, refresh_token_if_needed, read_token, poll_api,
|
||||||
|
AuthError, log,
|
||||||
|
)
|
||||||
|
|
||||||
|
fresh = compute_today_usage() # local token/cost — no network, always available
|
||||||
|
rl = None
|
||||||
|
auth_problem = False
|
||||||
|
|
||||||
|
# Proactively refresh the OAuth token; a DNS/network blip here must not
|
||||||
|
# crash the loop (SC#5) — it just means we poll with the current token.
|
||||||
|
try:
|
||||||
|
await refresh_token_if_needed()
|
||||||
|
except Exception as e: # belt-and-braces
|
||||||
|
log(f"Token refresh skipped: {e!r}")
|
||||||
|
|
||||||
|
token = read_token()
|
||||||
|
if not token:
|
||||||
|
auth_problem = True
|
||||||
|
log("No token; sending local usage only")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
rl = await poll_api(token)
|
||||||
|
except AuthError:
|
||||||
|
# Rejected despite the proactive refresh — force one refresh and
|
||||||
|
# retry the poll once before flagging the token as bad.
|
||||||
|
try:
|
||||||
|
forced = await refresh_token_if_needed(force=True)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Forced token refresh failed: {e!r}")
|
||||||
|
forced = False
|
||||||
|
if forced and (token := read_token()):
|
||||||
|
try:
|
||||||
|
rl = await poll_api(token)
|
||||||
|
except AuthError:
|
||||||
|
auth_problem = True
|
||||||
|
else:
|
||||||
|
auth_problem = True
|
||||||
|
|
||||||
|
status = ProviderStatus(
|
||||||
|
tokens_today=int(fresh.get("tk", 0) or 0),
|
||||||
|
output_today=int(fresh.get("to", 0) or 0),
|
||||||
|
cost_cents_today=int(fresh.get("tc", 0) or 0),
|
||||||
|
messages_today=int(fresh.get("tn", 0) or 0),
|
||||||
|
auth_problem=auth_problem,
|
||||||
|
)
|
||||||
|
if rl is not None:
|
||||||
|
status.s = rl.get("s", 0.0)
|
||||||
|
status.sr = rl.get("sr", -1)
|
||||||
|
status.w = rl.get("w", 0.0)
|
||||||
|
status.wr = rl.get("wr", -1)
|
||||||
|
status.st = rl.get("st", "unknown")
|
||||||
|
status.ok = True # rate-limit data is fresh
|
||||||
|
else:
|
||||||
|
status.ok = False # unknown → the watch usage view goes idle, not 0%
|
||||||
|
return status
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Provider abstraction (v3).
|
||||||
|
|
||||||
|
A ``Provider`` polls one AI coding service (Claude Code, OpenAI Codex, z.ai GLM
|
||||||
|
Coding Plan, ...) and returns a normalized ``ProviderStatus`` the daemon writes
|
||||||
|
to the watch. Each provider owns its own auth + usage source; the daemon loop
|
||||||
|
and the firmware stay provider-agnostic — the BLE payload just carries a ``pv``
|
||||||
|
id that selects which brand theme the watch wears.
|
||||||
|
|
||||||
|
The metric model mirrors Claude's: a short (5h) and long (7d/weekly) rate-limit
|
||||||
|
utilization percentage plus reset timers, with today's local token/cost as a
|
||||||
|
bonus when the provider can compute it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderStatus:
|
||||||
|
# Rate-limit utilization (0-100) + reset minutes — the two bars on the watch.
|
||||||
|
s: float = 0.0 # short window (5h) utilization %
|
||||||
|
sr: int = -1 # short window reset, minutes (-1 = unknown)
|
||||||
|
w: float = 0.0 # long window (7d) utilization %
|
||||||
|
wr: int = -1 # long window reset, minutes
|
||||||
|
st: str = "unknown" # status string ("allowed"/"limited"/...)
|
||||||
|
ok: bool = False # rate-limit data is fresh/valid this cycle
|
||||||
|
# Today's local usage (optional — providers that can read it fill these in).
|
||||||
|
tokens_today: int = 0
|
||||||
|
output_today: int = 0
|
||||||
|
cost_cents_today: int = 0
|
||||||
|
messages_today: int = 0
|
||||||
|
# True when the provider is configured but auth failed (expired token etc.).
|
||||||
|
# Distinct from a transient network blip, which leaves ok=False without this.
|
||||||
|
auth_problem: bool = False
|
||||||
|
|
||||||
|
def to_payload(self) -> dict:
|
||||||
|
"""Compact BLE field names the firmware parser already understands."""
|
||||||
|
return {
|
||||||
|
"s": self.s, "sr": self.sr, "w": self.w, "wr": self.wr,
|
||||||
|
"st": self.st, "ok": self.ok,
|
||||||
|
"tk": self.tokens_today, "to": self.output_today,
|
||||||
|
"tc": self.cost_cents_today, "tn": self.messages_today,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Provider(ABC):
|
||||||
|
"""One usage source. Subclasses set the class attributes and implement poll()."""
|
||||||
|
|
||||||
|
id: str = "" # payload `pv` id: anthropic / openai / zai
|
||||||
|
label: str = "" # human label ("Claude Code")
|
||||||
|
accent: str = "d97757" # brand accent hex — firmware theme hint
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def poll(self) -> ProviderStatus:
|
||||||
|
"""Return the current status. MUST NOT raise on a network/auth failure —
|
||||||
|
return a status with ok=False (and auth_problem=True on a genuine auth
|
||||||
|
error) so the daemon loop and the watch degrade gracefully."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Release any held resources (override when the provider owns a client)."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubProvider(Provider):
|
||||||
|
"""Placeholder for a provider id that isn't implemented yet (OpenAI/z.ai
|
||||||
|
before M2/M3). Reports nothing rather than crashing when selected."""
|
||||||
|
|
||||||
|
def __init__(self, pid: str, label: str = "", accent: str = "d97757") -> None:
|
||||||
|
self.id = pid
|
||||||
|
self.label = label or pid
|
||||||
|
self.accent = accent
|
||||||
|
|
||||||
|
async def poll(self) -> ProviderStatus:
|
||||||
|
return ProviderStatus(ok=False, st="not configured")
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""OpenAI Codex provider (v3 M2).
|
||||||
|
|
||||||
|
Primary source is the live ChatGPT usage endpoint (fresh every poll)::
|
||||||
|
|
||||||
|
GET https://chatgpt.com/backend-api/wham/usage
|
||||||
|
Authorization: Bearer <access_token from $CODEX_HOME/auth.json tokens>
|
||||||
|
ChatGPT-Account-Id: <account_id> # when present
|
||||||
|
-> {"plan_type": "...", "rate_limit": {"primary_window": {...}, "secondary_window": {...}}}
|
||||||
|
|
||||||
|
with each window carrying ``used_percent`` + ``reset_at`` — Claude's short (5h) /
|
||||||
|
weekly model. (Endpoint + auth discovered from github.com/rygel/AIUsageTracker,
|
||||||
|
MIT.) The access token is Codex's own OAuth token; we read the current one and do
|
||||||
|
NOT refresh it, so when it has expired (Codex not run in a while) we fall back to
|
||||||
|
the local session-rollout snapshot Codex writes every turn
|
||||||
|
(``$CODEX_HOME/sessions/**/rollout-*.jsonl`` -> ``payload.rate_limits``). The
|
||||||
|
rollout read needs no token but goes stale between sessions; a window whose
|
||||||
|
``resets_at`` has passed is reported as a fresh 0%. Live-first + rollout-fallback
|
||||||
|
gives fresh data when possible and last-known otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import Provider, ProviderStatus
|
||||||
|
|
||||||
|
USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
|
||||||
|
|
||||||
|
# Newest-first cap: the active session's rollout has the latest reading, so we
|
||||||
|
# rarely look past the first file — but scan a few in case the newest is a
|
||||||
|
# just-opened session with no turns (hence no rate_limits) yet.
|
||||||
|
_MAX_ROLLOUTS_SCANNED = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_home() -> Path:
|
||||||
|
return Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICodexProvider(Provider):
|
||||||
|
id = "openai"
|
||||||
|
label = "OpenAI Codex"
|
||||||
|
accent = "10a37f" # brand green
|
||||||
|
|
||||||
|
def __init__(self, codex_home: str | os.PathLike | None = None,
|
||||||
|
transport: httpx.BaseTransport | None = None) -> None:
|
||||||
|
self._home = Path(codex_home) if codex_home else _codex_home()
|
||||||
|
self._transport = transport # tests inject an httpx.MockTransport
|
||||||
|
|
||||||
|
async def poll(self) -> ProviderStatus:
|
||||||
|
from daemon.claude_usage_daemon_windows import log
|
||||||
|
# 1) live endpoint (fresh) — None if no token / expired / offline.
|
||||||
|
live = await self._live_usage(log)
|
||||||
|
if live is not None:
|
||||||
|
return live
|
||||||
|
# 2) fall back to the local rollout snapshot (no token needed).
|
||||||
|
try:
|
||||||
|
snap = await asyncio.to_thread(self._latest_rate_limits)
|
||||||
|
except Exception as e: # never break the loop (SC#5)
|
||||||
|
log(f"Codex poll error: {e!r}")
|
||||||
|
return ProviderStatus(ok=False, st="error")
|
||||||
|
if snap is None:
|
||||||
|
# Configured but nothing to read (Codex never run / logged out).
|
||||||
|
return ProviderStatus(ok=False, st="no data")
|
||||||
|
return self._to_status(snap)
|
||||||
|
|
||||||
|
# -- live usage endpoint ---------------------------------------------------
|
||||||
|
|
||||||
|
def _read_auth(self) -> dict | None:
|
||||||
|
"""access_token (+ account_id) from Codex's auth.json, or None."""
|
||||||
|
try:
|
||||||
|
data = json.loads((self._home / "auth.json").read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
toks = data.get("tokens") or {}
|
||||||
|
at = toks.get("access_token")
|
||||||
|
return {"access_token": at, "account_id": toks.get("account_id")} if at else None
|
||||||
|
|
||||||
|
async def _live_usage(self, log) -> ProviderStatus | None:
|
||||||
|
creds = await asyncio.to_thread(self._read_auth)
|
||||||
|
if not creds:
|
||||||
|
return None
|
||||||
|
headers = {"Authorization": f"Bearer {creds['access_token']}",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
if creds.get("account_id"):
|
||||||
|
headers["ChatGPT-Account-Id"] = creds["account_id"]
|
||||||
|
client_kw = {"timeout": 20.0}
|
||||||
|
if self._transport is not None:
|
||||||
|
client_kw["transport"] = self._transport
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(**client_kw) as http:
|
||||||
|
resp = await http.get(USAGE_URL, headers=headers)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
log(f"Codex live usage failed ({e}); using local snapshot")
|
||||||
|
return None
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
# 401/403 => token expired (Codex refreshes it when it runs); other
|
||||||
|
# 4xx/5xx are transient. Either way, fall back to the rollout read.
|
||||||
|
log(f"Codex usage HTTP {resp.status_code}; using local snapshot")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
root = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
rl = root.get("rate_limit")
|
||||||
|
return self._status_from_live(root, rl) if isinstance(rl, dict) else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _status_from_live(root: dict, rl: dict) -> ProviderStatus:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
def window(w: dict | None) -> tuple[float, int]:
|
||||||
|
if not isinstance(w, dict):
|
||||||
|
return 0.0, -1
|
||||||
|
used = float(w.get("used_percent") or 0.0)
|
||||||
|
ra = w.get("reset_at")
|
||||||
|
if isinstance(ra, (int, float)):
|
||||||
|
m = (ra - now) / 60.0
|
||||||
|
return used, (int(round(m)) if m > 0 else -1)
|
||||||
|
ras = w.get("reset_after_seconds")
|
||||||
|
if isinstance(ras, (int, float)) and ras > 0:
|
||||||
|
return used, int(ras // 60)
|
||||||
|
return used, -1
|
||||||
|
|
||||||
|
s, sr = window(rl.get("primary_window"))
|
||||||
|
w, wr = window(rl.get("secondary_window"))
|
||||||
|
reached = rl.get("limit_reached") or root.get("rate_limit_reached_type")
|
||||||
|
plan = root.get("plan_type")
|
||||||
|
st = "limited" if reached else (str(plan).capitalize() if plan else "allowed")
|
||||||
|
return ProviderStatus(s=s, sr=sr, w=w, wr=wr, st=st, ok=True)
|
||||||
|
|
||||||
|
# -- local rollout reading -------------------------------------------------
|
||||||
|
|
||||||
|
def _latest_rate_limits(self) -> dict | None:
|
||||||
|
"""The freshest non-empty ``rate_limits`` object from the most recently
|
||||||
|
written session rollout. Pure/sync — run via asyncio.to_thread."""
|
||||||
|
sessions = self._home / "sessions"
|
||||||
|
if not sessions.is_dir():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
files = sorted(sessions.rglob("rollout-*.jsonl"),
|
||||||
|
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
for path in files[:_MAX_ROLLOUTS_SCANNED]:
|
||||||
|
found = None
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8", errors="replace") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if '"rate_limits"' not in line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
# token_count events carry rate_limits as a sibling of
|
||||||
|
# info under payload; tolerate the info-nested shape too.
|
||||||
|
payload = obj.get("payload") or {}
|
||||||
|
rl = payload.get("rate_limits")
|
||||||
|
if not isinstance(rl, dict):
|
||||||
|
rl = (payload.get("info") or {}).get("rate_limits")
|
||||||
|
if isinstance(rl, dict) and (rl.get("primary") or rl.get("secondary")):
|
||||||
|
found = rl # keep the LAST one in the file
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if found is not None:
|
||||||
|
return found # newest file that has a reading wins
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- mapping ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _window(w: dict | None) -> tuple[float, int]:
|
||||||
|
"""(used_percent 0-100, minutes-to-reset) for one window. A reset time
|
||||||
|
already in the past means the window rolled over since the snapshot, so
|
||||||
|
report it as a fresh 0% with an unknown reset."""
|
||||||
|
if not isinstance(w, dict):
|
||||||
|
return 0.0, -1
|
||||||
|
used = float(w.get("used_percent") or 0.0)
|
||||||
|
resets_at = w.get("resets_at")
|
||||||
|
if isinstance(resets_at, (int, float)):
|
||||||
|
delta = resets_at - time.time()
|
||||||
|
if delta <= 0:
|
||||||
|
return 0.0, -1
|
||||||
|
return used, int(delta // 60)
|
||||||
|
return used, -1
|
||||||
|
|
||||||
|
def _to_status(self, rl: dict) -> ProviderStatus:
|
||||||
|
s, sr = self._window(rl.get("primary"))
|
||||||
|
w, wr = self._window(rl.get("secondary"))
|
||||||
|
reached = rl.get("rate_limit_reached_type")
|
||||||
|
plan = rl.get("plan_type")
|
||||||
|
if reached:
|
||||||
|
st = "limited"
|
||||||
|
elif plan:
|
||||||
|
st = str(plan).capitalize() # "Plus" / "Pro" / "Team" / ...
|
||||||
|
else:
|
||||||
|
st = "allowed"
|
||||||
|
# Subscription plan → no per-token cost; the two rate-limit bars are the
|
||||||
|
# metric (matches the locked v3 decision). tokens/cost left at 0.
|
||||||
|
return ProviderStatus(s=s, sr=sr, w=w, wr=wr, st=st, ok=True)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""z.ai (GLM Coding Plan) provider (v3 M3).
|
||||||
|
|
||||||
|
z.ai exposes a dedicated usage endpoint that returns the Coding Plan's rate-limit
|
||||||
|
windows directly — no need to send a (quota-consuming) chat request:
|
||||||
|
|
||||||
|
GET https://api.z.ai/api/monitor/usage/quota/limit
|
||||||
|
Authorization: <api_key> # raw key, NOT "Bearer …"
|
||||||
|
Accept-Language: en-US,en
|
||||||
|
|
||||||
|
{"code":200,"success":true,"data":{"level":"lite","limits":[
|
||||||
|
{"type":"TIME_LIMIT", "unit":5,"number":1, ...}, # monthly web-tool quota (ignored)
|
||||||
|
{"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":1,"nextResetTime":<ms>}, # 5h window
|
||||||
|
{"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":2,"nextResetTime":<ms>}]}} # weekly window
|
||||||
|
|
||||||
|
We surface the two TOKENS_LIMIT windows as Claude's short (5h) + weekly bars: of
|
||||||
|
the token windows, the shortest is the 5h bar and the longest the weekly bar.
|
||||||
|
``percentage`` is already 0-100; ``nextResetTime`` is epoch ms. The key + base URL
|
||||||
|
come from the config the control panel's z.ai field writes. This is a status GET,
|
||||||
|
so — unlike a chat call — it doesn't spend the prompt-metered plan, and can poll
|
||||||
|
on the normal cadence.
|
||||||
|
|
||||||
|
Discovered from github.com/rygel/AIUsageTracker (ZaiProvider.cs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import Provider, ProviderStatus
|
||||||
|
|
||||||
|
DEFAULT_HOST = "https://api.z.ai"
|
||||||
|
_MONITOR_PATH = "/api/monitor/usage/quota/limit"
|
||||||
|
|
||||||
|
# z.ai window "unit" enum → seconds, used only to rank the token windows by length
|
||||||
|
# (shortest = the 5h bar, longest = the weekly bar). Best-effort; unknown → hours.
|
||||||
|
_UNIT_SECONDS = {1: 1, 2: 60, 3: 3600, 4: 86400, 5: 2_592_000, 6: 604_800}
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_url(base_url: str) -> str:
|
||||||
|
"""The usage endpoint on the same host as the configured (Anthropic-compat)
|
||||||
|
base URL, or the public z.ai host when unset."""
|
||||||
|
parts = urlsplit(base_url or "")
|
||||||
|
if parts.scheme and parts.netloc:
|
||||||
|
return f"{parts.scheme}://{parts.netloc}{_MONITOR_PATH}"
|
||||||
|
return DEFAULT_HOST + _MONITOR_PATH
|
||||||
|
|
||||||
|
|
||||||
|
class ZaiProvider(Provider):
|
||||||
|
id = "zai"
|
||||||
|
label = "z.ai GLM"
|
||||||
|
accent = "3859ff" # brand blue
|
||||||
|
|
||||||
|
def __init__(self, transport: httpx.BaseTransport | None = None) -> None:
|
||||||
|
self._transport = transport # tests inject an httpx.MockTransport
|
||||||
|
|
||||||
|
async def poll(self) -> ProviderStatus:
|
||||||
|
from daemon.claude_usage_daemon_windows import log
|
||||||
|
try:
|
||||||
|
from daemon.config import load_config, provider_conf
|
||||||
|
except ImportError:
|
||||||
|
from config import load_config, provider_conf
|
||||||
|
|
||||||
|
conf = provider_conf(load_config(), "zai")
|
||||||
|
key = (conf.get("api_key") or "").strip()
|
||||||
|
base = (conf.get("base_url") or "").strip()
|
||||||
|
if not key:
|
||||||
|
return ProviderStatus(ok=False, st="no key", auth_problem=True)
|
||||||
|
|
||||||
|
url = _monitor_url(base)
|
||||||
|
headers = {"Authorization": key, "Accept-Language": "en-US,en"}
|
||||||
|
client_kw = {"timeout": 20.0}
|
||||||
|
if self._transport is not None:
|
||||||
|
client_kw["transport"] = self._transport
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(**client_kw) as http:
|
||||||
|
resp = await http.get(url, headers=headers)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
log(f"z.ai call failed: {e}") # transient — retry next tick
|
||||||
|
return ProviderStatus(ok=False, st="offline")
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
log(f"z.ai auth rejected: HTTP {resp.status_code}")
|
||||||
|
return ProviderStatus(ok=False, st="bad key", auth_problem=True)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
log(f"z.ai HTTP {resp.status_code}: {resp.text[:150]}")
|
||||||
|
return ProviderStatus(ok=False, st=f"http {resp.status_code}")
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
log("z.ai: non-JSON usage response")
|
||||||
|
return ProviderStatus(ok=False, st="error")
|
||||||
|
if not body.get("success") or not isinstance(body.get("data"), dict):
|
||||||
|
# e.g. {"code":401,...,"success":false} → treat as an auth/config problem
|
||||||
|
code = body.get("code")
|
||||||
|
log(f"z.ai usage error: code={code} msg={body.get('msg')!r}")
|
||||||
|
return ProviderStatus(ok=False, st="bad key",
|
||||||
|
auth_problem=code in (401, 403))
|
||||||
|
return self._to_status(body["data"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_status(data: dict) -> ProviderStatus:
|
||||||
|
limits = data.get("limits") or []
|
||||||
|
# Only the token windows are the coding rate limit; TIME_LIMIT is the
|
||||||
|
# separate monthly web-tool quota.
|
||||||
|
toks = [l for l in limits if isinstance(l, dict) and l.get("type") == "TOKENS_LIMIT"]
|
||||||
|
|
||||||
|
def win_seconds(l: dict) -> int:
|
||||||
|
return _UNIT_SECONDS.get(l.get("unit"), 3600) * int(l.get("number") or 1)
|
||||||
|
|
||||||
|
def pct(l: dict | None) -> float:
|
||||||
|
if not l:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
return round(float(l.get("percentage") or 0.0), 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def reset_min(l: dict | None) -> int:
|
||||||
|
ts = l.get("nextResetTime") if l else None
|
||||||
|
if not isinstance(ts, (int, float)):
|
||||||
|
return -1
|
||||||
|
if ts > 1e10: # epoch ms → s
|
||||||
|
ts = ts / 1000.0
|
||||||
|
m = (ts - time.time()) / 60.0
|
||||||
|
return int(round(m)) if m > 0 else -1
|
||||||
|
|
||||||
|
short = min(toks, key=win_seconds) if toks else None
|
||||||
|
weekly = max(toks, key=win_seconds) if len(toks) > 1 else None
|
||||||
|
|
||||||
|
level = str(data.get("level") or "").strip()
|
||||||
|
st = level.capitalize() if level else "allowed"
|
||||||
|
if pct(short) >= 100 or pct(weekly) >= 100:
|
||||||
|
st = "limited"
|
||||||
|
|
||||||
|
if not toks: # authenticated but no token windows reported
|
||||||
|
return ProviderStatus(ok=True, st=st or "allowed")
|
||||||
|
return ProviderStatus(
|
||||||
|
s=pct(short), sr=reset_min(short),
|
||||||
|
w=pct(weekly), wr=reset_min(weekly),
|
||||||
|
st=st, ok=True,
|
||||||
|
)
|
||||||
@@ -4,3 +4,15 @@ bleak
|
|||||||
httpx
|
httpx
|
||||||
pystray
|
pystray
|
||||||
Pillow
|
Pillow
|
||||||
|
# Now Playing (Phase 5): read the Windows "now playing" media session (SMTC).
|
||||||
|
# bleak already pulls winrt-runtime + the Bluetooth projections; these add the
|
||||||
|
# media-control projection. pip resolves them to the same winrt-runtime as bleak.
|
||||||
|
winrt-Windows.Media
|
||||||
|
winrt-Windows.Media.Control
|
||||||
|
# Phase 7 control panel: a local FastAPI server (bound to 127.0.0.1) serves the
|
||||||
|
# brand UI, and pywebview hosts it in a native Edge WebView2 window. pywebview
|
||||||
|
# pulls pythonnet (clr) on Windows for the EdgeChromium backend. httpx (above)
|
||||||
|
# is reused for the HA test/entities calls.
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
pywebview
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Local control-panel HTTP API for the Clawdmeter app (Phase 7, M1).
|
||||||
|
|
||||||
|
A FastAPI app bound to 127.0.0.1 only. Serves the brand-styled web UI (``web/``)
|
||||||
|
and a small REST API over the unified config (``config.py``). Runs in a daemon
|
||||||
|
thread alongside the tray and the BLE daemon — one process, one exe.
|
||||||
|
|
||||||
|
Security: bound to loopback; the HA token is masked in GET responses and never
|
||||||
|
logged (mirrors ha_client / config).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
try:
|
||||||
|
from daemon import config as cfgmod
|
||||||
|
except ImportError: # running as a plain script (cwd on path)
|
||||||
|
import config as cfgmod
|
||||||
|
|
||||||
|
MASK = "********" # what the UI sees instead of the real token; echo it back to keep it
|
||||||
|
|
||||||
|
|
||||||
|
def _web_dir() -> Path:
|
||||||
|
"""The static UI directory: next to this file in source, under the PyInstaller
|
||||||
|
bundle root when frozen (added via clawdmeter.spec datas)."""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return Path(sys._MEIPASS) / "daemon" / "web" # type: ignore[attr-defined]
|
||||||
|
return Path(__file__).parent / "web"
|
||||||
|
|
||||||
|
|
||||||
|
def _masked(cfg: dict) -> dict:
|
||||||
|
c = copy.deepcopy(cfg)
|
||||||
|
if c.get("ha", {}).get("token"):
|
||||||
|
c["ha"]["token"] = MASK
|
||||||
|
# Provider secrets (e.g. z.ai api_key) get the same treatment as the HA token:
|
||||||
|
# never leave this process. The UI echoes the mask back to keep the stored one.
|
||||||
|
for pconf in (c.get("providers") or {}).values():
|
||||||
|
if isinstance(pconf, dict) and pconf.get("api_key"):
|
||||||
|
pconf["api_key"] = MASK
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Clawdmeter")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config")
|
||||||
|
def get_config() -> dict:
|
||||||
|
return _masked(cfgmod.load_config())
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIn(BaseModel):
|
||||||
|
ha: dict | None = None
|
||||||
|
buttons: list | None = None
|
||||||
|
settings: dict | None = None
|
||||||
|
providers: dict | None = None # v3: per-provider {enabled, base_url, api_key}
|
||||||
|
active_provider: str | None = None # which provider the watch displays
|
||||||
|
display_order: list | None = None # on-watch cycle order
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/config")
|
||||||
|
def put_config(incoming: ConfigIn) -> dict:
|
||||||
|
cfg = cfgmod.load_config()
|
||||||
|
data = incoming.model_dump(exclude_none=True)
|
||||||
|
if "ha" in data:
|
||||||
|
ha = dict(data["ha"])
|
||||||
|
# Mask echoed back unchanged => keep the stored token (UI never holds it).
|
||||||
|
if ha.get("token") == MASK:
|
||||||
|
ha["token"] = cfg["ha"]["token"]
|
||||||
|
cfg["ha"].update(ha)
|
||||||
|
if "settings" in data:
|
||||||
|
cfg["settings"].update(data["settings"])
|
||||||
|
if "buttons" in data:
|
||||||
|
cfg["buttons"] = data["buttons"]
|
||||||
|
# v3 providers — merge per known id; a masked api_key echoed back keeps the
|
||||||
|
# stored secret (same discipline as the HA token).
|
||||||
|
if isinstance(data.get("providers"), dict):
|
||||||
|
for pid, pconf in data["providers"].items():
|
||||||
|
if pid not in cfg["providers"] or not isinstance(pconf, dict):
|
||||||
|
continue
|
||||||
|
pconf = dict(pconf)
|
||||||
|
if pconf.get("api_key") == MASK:
|
||||||
|
pconf["api_key"] = cfg["providers"][pid].get("api_key", "")
|
||||||
|
cfg["providers"][pid].update(pconf)
|
||||||
|
if isinstance(data.get("display_order"), list):
|
||||||
|
cfg["display_order"] = data["display_order"]
|
||||||
|
switched = None
|
||||||
|
if isinstance(data.get("active_provider"), str):
|
||||||
|
if data["active_provider"] != cfg.get("active_provider"):
|
||||||
|
switched = data["active_provider"]
|
||||||
|
cfg["active_provider"] = data["active_provider"]
|
||||||
|
cfgmod.save_config(cfg)
|
||||||
|
# Push the switch to a connected watch now (same path as the on-watch button)
|
||||||
|
# instead of waiting for the ~60s poll to notice the config change. Best-effort.
|
||||||
|
if switched is not None and _command_sink is not None:
|
||||||
|
try:
|
||||||
|
_command_sink(switched)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _masked(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/providers")
|
||||||
|
def get_providers() -> dict:
|
||||||
|
"""Provider list for the panel's Providers tab: brand label + accent come from
|
||||||
|
the daemon registry, enabled/creds state from config. Data-driven so adding a
|
||||||
|
provider (a daemon class + a config default) needs zero web-UI edits. Secrets
|
||||||
|
are never sent — only whether a key is stored (has_key)."""
|
||||||
|
cfg = cfgmod.load_config()
|
||||||
|
try:
|
||||||
|
from daemon.providers import get_provider
|
||||||
|
except ImportError:
|
||||||
|
from providers import get_provider
|
||||||
|
out = []
|
||||||
|
for pid in cfgmod.PROVIDER_IDS:
|
||||||
|
p = get_provider(pid)
|
||||||
|
pconf = cfg["providers"].get(pid, {})
|
||||||
|
out.append({
|
||||||
|
"id": pid,
|
||||||
|
"label": p.label,
|
||||||
|
"accent": p.accent,
|
||||||
|
"enabled": bool(pconf.get("enabled")),
|
||||||
|
"needs_key": "api_key" in pconf, # z.ai-style base_url + key creds
|
||||||
|
"base_url": pconf.get("base_url", ""),
|
||||||
|
"has_key": bool(pconf.get("api_key")),
|
||||||
|
})
|
||||||
|
return {"providers": out, "active": cfg["active_provider"], "order": cfg["display_order"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_token(token: str) -> str:
|
||||||
|
return cfgmod.load_config()["ha"]["token"] if token == MASK else token
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/ha/test")
|
||||||
|
async def ha_test(body: dict) -> dict:
|
||||||
|
url = (body.get("url") or "").strip().rstrip("/")
|
||||||
|
token = _resolve_token((body.get("token") or "").strip())
|
||||||
|
if not url or not token:
|
||||||
|
raise HTTPException(status_code=400, detail="url and token are required")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
resp = await client.get(f"{url}/api/",
|
||||||
|
headers={"Authorization": f"Bearer {token}"})
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
if resp.status_code == 200:
|
||||||
|
try:
|
||||||
|
msg = resp.json().get("message", "API running")
|
||||||
|
except ValueError:
|
||||||
|
msg = "API running"
|
||||||
|
return {"ok": True, "message": msg}
|
||||||
|
return {"ok": False, "error": f"HTTP {resp.status_code}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ha/entities")
|
||||||
|
async def ha_entities() -> dict:
|
||||||
|
ha = cfgmod.ha_settings()
|
||||||
|
if not ha:
|
||||||
|
return {"entities": [], "error": "Home Assistant not configured"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
resp = await client.get(f"{ha['url']}/api/states",
|
||||||
|
headers={"Authorization": f"Bearer {ha['token']}"})
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"entities": [], "error": str(e)}
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return {"entities": [], "error": f"HTTP {resp.status_code}"}
|
||||||
|
try:
|
||||||
|
states = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"entities": [], "error": "bad response"}
|
||||||
|
lights = [s["entity_id"] for s in states
|
||||||
|
if isinstance(s, dict) and str(s.get("entity_id", "")).startswith("light.")]
|
||||||
|
return {"entities": sorted(lights)}
|
||||||
|
|
||||||
|
|
||||||
|
_status_provider = None # set by the tray to expose live BLE/daemon state
|
||||||
|
_command_sink = None # set by the tray: called with a provider id to live-switch the watch
|
||||||
|
|
||||||
|
|
||||||
|
def set_status_provider(fn) -> None:
|
||||||
|
"""The tray injects a callable returning the live status dict (connected,
|
||||||
|
battery, state). Kept out of import-time so server.py runs standalone."""
|
||||||
|
global _status_provider
|
||||||
|
_status_provider = fn
|
||||||
|
|
||||||
|
|
||||||
|
def set_command_sink(fn) -> None:
|
||||||
|
"""The tray injects a callable(provider_id) that pushes an active-provider
|
||||||
|
switch to the live BLE session. Optional — a panel edit still persists to
|
||||||
|
config (and the daemon picks it up on its next poll) if this is unset."""
|
||||||
|
global _command_sink
|
||||||
|
_command_sink = fn
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
def get_status() -> dict:
|
||||||
|
if _status_provider is not None:
|
||||||
|
try:
|
||||||
|
return _status_provider()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"connected": False, "battery": None, "state": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
# Static UI mounted LAST so the /api/* routes above take precedence.
|
||||||
|
_wd = _web_dir()
|
||||||
|
if _wd.exists():
|
||||||
|
app.mount("/", StaticFiles(directory=str(_wd), html=True), name="web")
|
||||||
|
|
||||||
|
|
||||||
|
def serve_in_thread(port: int | None = None) -> threading.Thread:
|
||||||
|
"""Start uvicorn on 127.0.0.1 in a daemon thread; return the thread."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
p = port or cfgmod.DEFAULT_PORT
|
||||||
|
# log_config=None disables uvicorn's default dictConfig. In the frozen,
|
||||||
|
# windowed exe there is no console, so sys.stderr is None and uvicorn's
|
||||||
|
# default logging setup dies with "Unable to configure formatter 'default'",
|
||||||
|
# taking the whole control-panel server down. We don't need uvicorn's logs
|
||||||
|
# (the daemon has its own file logger), so skip its logging config entirely.
|
||||||
|
server = uvicorn.Server(uvicorn.Config(
|
||||||
|
app, host="127.0.0.1", port=p, log_level="warning", log_config=None))
|
||||||
|
t = threading.Thread(target=server.run, daemon=True, name="clawd-http")
|
||||||
|
t.start()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Dev: run the server in the foreground with autoreload-free uvicorn.
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=cfgmod.DEFAULT_PORT, log_level="info")
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""v3 M2 — OpenAI Codex provider: read rate-limit % from the Codex session rollouts.
|
||||||
|
|
||||||
|
Codex writes a token_count event per turn whose payload.rate_limits mirrors
|
||||||
|
Claude's model (primary=5h, secondary=weekly). The provider surfaces the freshest
|
||||||
|
such snapshot; a window whose reset time has passed is reported as a fresh 0%.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from daemon.providers.openai_codex import OpenAICodexProvider
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.get_event_loop().run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_rollout(home, name, rate_limits_list, mtime=None):
|
||||||
|
"""Write a rollout JSONL with one token_count event per rate_limits dict."""
|
||||||
|
d = home / "sessions" / "2026" / "07" / "10"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
p = d / f"rollout-{name}.jsonl"
|
||||||
|
lines = []
|
||||||
|
for i, rl in enumerate(rate_limits_list):
|
||||||
|
lines.append(json.dumps({
|
||||||
|
"timestamp": f"2026-07-10T00:00:0{i}Z",
|
||||||
|
"type": "event_msg",
|
||||||
|
"payload": {"type": "token_count",
|
||||||
|
"info": {"total_token_usage": {"total_tokens": 1}},
|
||||||
|
"rate_limits": rl},
|
||||||
|
}))
|
||||||
|
p.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
if mtime is not None:
|
||||||
|
os.utime(p, (mtime, mtime))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _rl(pri_used, pri_reset, sec_used, sec_reset, plan="plus", reached=None):
|
||||||
|
return {"limit_id": "codex",
|
||||||
|
"primary": {"used_percent": pri_used, "window_minutes": 300, "resets_at": pri_reset},
|
||||||
|
"secondary": {"used_percent": sec_used, "window_minutes": 10080, "resets_at": sec_reset},
|
||||||
|
"plan_type": plan, "rate_limit_reached_type": reached}
|
||||||
|
|
||||||
|
|
||||||
|
def test_maps_windows_with_future_reset(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(42.0, now + 3600, 10.0, now + 7200)])
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.ok is True
|
||||||
|
assert st.s == 42.0 and 58 <= st.sr <= 60
|
||||||
|
assert st.w == 10.0 and 118 <= st.wr <= 120
|
||||||
|
assert st.st == "Plus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_past_reset_is_fresh_zero(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
# primary window already elapsed => 0% fresh, unknown reset; secondary still open
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(99.0, now - 100, 55.0, now + 600)])
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.s == 0.0 and st.sr == -1
|
||||||
|
assert st.w == 55.0 and 8 <= st.wr <= 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_reached_marks_limited(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(100.0, now + 60, 80.0, now + 600, reached="primary")])
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.st == "limited" and st.ok is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_snapshot_in_file_wins(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(10.0, now + 600, 1.0, now + 600),
|
||||||
|
_rl(73.0, now + 600, 2.0, now + 600)])
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.s == 73.0 # the later event, not the first
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_file_wins(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "old", [_rl(11.0, now + 600, 0.0, now + 600)], mtime=now - 500)
|
||||||
|
_write_rollout(tmp_path, "new", [_rl(88.0, now + 600, 0.0, now + 600)], mtime=now)
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.s == 88.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_file_without_rate_limits(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
# newest file has a token_count with null rate_limits; older file has the data
|
||||||
|
_write_rollout(tmp_path, "older", [_rl(64.0, now + 600, 0.0, now + 600)], mtime=now - 500)
|
||||||
|
p = _write_rollout(tmp_path, "newer", [], mtime=now)
|
||||||
|
p.write_text(json.dumps({
|
||||||
|
"timestamp": "2026-07-10T01:00:00Z", "type": "event_msg",
|
||||||
|
"payload": {"type": "token_count", "info": {}, "rate_limits": None},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
os.utime(p, (now, now))
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.ok is True and st.s == 64.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_sessions_dir_reports_no_data(tmp_path):
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.ok is False and st.st == "no data"
|
||||||
|
|
||||||
|
|
||||||
|
# -- live ChatGPT usage endpoint (preferred over rollouts) --------------------
|
||||||
|
|
||||||
|
def _write_auth(home, access_token="tok", account_id="acct"):
|
||||||
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
|
(home / "auth.json").write_text(
|
||||||
|
json.dumps({"tokens": {"access_token": access_token, "account_id": account_id}}),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _live_resp(pri_pct, pri_reset, sec_pct, sec_reset, plan="plus", limit_reached=False):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"plan_type": plan,
|
||||||
|
"rate_limit": {"allowed": True, "limit_reached": limit_reached,
|
||||||
|
"primary_window": {"used_percent": pri_pct, "reset_at": pri_reset},
|
||||||
|
"secondary_window": {"used_percent": sec_pct, "reset_at": sec_reset}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_usage_preferred_over_rollout(tmp_path):
|
||||||
|
_write_auth(tmp_path)
|
||||||
|
# a rollout is also present, but the live endpoint must win
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(99.0, now + 600, 99.0, now + 600)])
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
seen["auth"] = req.headers.get("authorization")
|
||||||
|
seen["acct"] = req.headers.get("chatgpt-account-id")
|
||||||
|
seen["url"] = str(req.url)
|
||||||
|
return _live_resp(5, now + 3600, 20, now + 7200)
|
||||||
|
|
||||||
|
p = OpenAICodexProvider(codex_home=str(tmp_path), transport=httpx.MockTransport(handler))
|
||||||
|
st = _run(p.poll())
|
||||||
|
assert st.ok is True and st.st == "Plus"
|
||||||
|
assert st.s == 5.0 and 58 <= st.sr <= 60
|
||||||
|
assert st.w == 20.0 and 118 <= st.wr <= 120
|
||||||
|
assert seen["auth"] == "Bearer tok" and seen["acct"] == "acct"
|
||||||
|
assert seen["url"] == "https://chatgpt.com/backend-api/wham/usage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_limit_reached_is_limited(tmp_path):
|
||||||
|
_write_auth(tmp_path)
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
def handler(req):
|
||||||
|
return _live_resp(100, now + 60, 50, now + 600, limit_reached=True)
|
||||||
|
|
||||||
|
p = OpenAICodexProvider(codex_home=str(tmp_path), transport=httpx.MockTransport(handler))
|
||||||
|
st = _run(p.poll())
|
||||||
|
assert st.st == "limited" and st.ok is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_rollout_on_token_expiry(tmp_path):
|
||||||
|
_write_auth(tmp_path)
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(64.0, now + 600, 0.0, now + 600)])
|
||||||
|
|
||||||
|
def handler(req):
|
||||||
|
return httpx.Response(401, json={"detail": "unauthorized"})
|
||||||
|
|
||||||
|
p = OpenAICodexProvider(codex_home=str(tmp_path), transport=httpx.MockTransport(handler))
|
||||||
|
st = _run(p.poll())
|
||||||
|
assert st.ok is True and st.s == 64.0 # from the rollout snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_auth_uses_rollout_without_network(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(30.0, now + 600, 0.0, now + 600)])
|
||||||
|
# no auth.json => live path skipped entirely; no transport needed
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.ok is True and st.s == 30.0
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""v3 M1c — provider switching (on-watch cycle + control-panel offline hook).
|
||||||
|
|
||||||
|
The daemon owns the enabled set/order, so the watch's "switch" tap just asks it
|
||||||
|
to advance (provnext -> _cycle_provider) and the panel's active_provider edit is
|
||||||
|
mirrored into the live loop (request_provider_switch). Both funnel through the
|
||||||
|
same _set_active_provider path exercised here without a real BLE loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from daemon import config
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(tmp_path, monkeypatch, data: dict):
|
||||||
|
p = tmp_path / "config.json"
|
||||||
|
p.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_ids_defaults_to_anthropic(tmp_path, monkeypatch):
|
||||||
|
from daemon import claude_usage_daemon_windows as d
|
||||||
|
_cfg(tmp_path, monkeypatch, {"version": 2})
|
||||||
|
assert d._enabled_ids() == ["anthropic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_advances_and_wraps(tmp_path, monkeypatch):
|
||||||
|
from daemon import claude_usage_daemon_windows as d
|
||||||
|
_cfg(tmp_path, monkeypatch, {
|
||||||
|
"version": 2,
|
||||||
|
"display_order": ["anthropic", "openai", "zai"],
|
||||||
|
"providers": {"anthropic": {"enabled": True},
|
||||||
|
"openai": {"enabled": True},
|
||||||
|
"zai": {"enabled": True}},
|
||||||
|
"active_provider": "anthropic"})
|
||||||
|
sess = d.Session(MagicMock())
|
||||||
|
sess._cycle_provider()
|
||||||
|
assert config.active_provider_id() == "openai"
|
||||||
|
sess._cycle_provider()
|
||||||
|
assert config.active_provider_id() == "zai"
|
||||||
|
sess._cycle_provider() # wraps back to the start
|
||||||
|
assert config.active_provider_id() == "anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_skips_disabled(tmp_path, monkeypatch):
|
||||||
|
from daemon import claude_usage_daemon_windows as d
|
||||||
|
_cfg(tmp_path, monkeypatch, {
|
||||||
|
"version": 2,
|
||||||
|
"display_order": ["anthropic", "openai", "zai"],
|
||||||
|
"providers": {"anthropic": {"enabled": True},
|
||||||
|
"openai": {"enabled": False},
|
||||||
|
"zai": {"enabled": True}},
|
||||||
|
"active_provider": "anthropic"})
|
||||||
|
sess = d.Session(MagicMock())
|
||||||
|
sess._cycle_provider()
|
||||||
|
assert config.active_provider_id() == "zai" # openai skipped (disabled)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_single_enabled_is_noop(tmp_path, monkeypatch):
|
||||||
|
from daemon import claude_usage_daemon_windows as d
|
||||||
|
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
|
||||||
|
sess = d.Session(MagicMock())
|
||||||
|
sess._cycle_provider()
|
||||||
|
assert config.active_provider_id() == "anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_switch_offline_persists(tmp_path, monkeypatch):
|
||||||
|
from daemon import claude_usage_daemon_windows as d
|
||||||
|
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
|
||||||
|
monkeypatch.setattr(d, "_active_session", None)
|
||||||
|
d.request_provider_switch("zai")
|
||||||
|
assert config.active_provider_id() == "zai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_switch_ignores_bad_id(tmp_path, monkeypatch):
|
||||||
|
from daemon import claude_usage_daemon_windows as d
|
||||||
|
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
|
||||||
|
monkeypatch.setattr(d, "_active_session", None)
|
||||||
|
d.request_provider_switch("bogus")
|
||||||
|
assert config.active_provider_id() == "anthropic"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""v3 provider layer + config v2 tests.
|
||||||
|
|
||||||
|
Covers the framework M1 lands: config v1->v2 migration keeps Anthropic behaviour,
|
||||||
|
the provider registry hands out the right implementations, and ProviderStatus
|
||||||
|
maps to the compact BLE fields the firmware parser expects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from daemon import config
|
||||||
|
from daemon.providers import (
|
||||||
|
get_provider, AnthropicProvider, OpenAICodexProvider, ZaiProvider,
|
||||||
|
StubProvider, ProviderStatus)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
# Reuse the suite's shared event loop (never closed) like the other test
|
||||||
|
# modules — asyncio.run() would close it and set the current loop to None,
|
||||||
|
# breaking every later test that calls get_event_loop() (Python 3.13).
|
||||||
|
return asyncio.get_event_loop().run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path, monkeypatch, data: dict):
|
||||||
|
"""Point the config module at an isolated temp config (and LOCALAPPDATA, so
|
||||||
|
the legacy-ha_config migration can't reach the real machine's file)."""
|
||||||
|
p = tmp_path / "config.json"
|
||||||
|
p.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_config_migrates_to_provider_defaults(tmp_path, monkeypatch):
|
||||||
|
# A v1 config has no providers/active_provider; load_config fills them so
|
||||||
|
# behaviour is unchanged — Anthropic enabled and active.
|
||||||
|
_write(tmp_path, monkeypatch, {
|
||||||
|
"version": 1, "ha": {"url": "", "token": "", "entities": []},
|
||||||
|
"buttons": [], "settings": {}})
|
||||||
|
cfg = config.load_config()
|
||||||
|
assert cfg["active_provider"] == "anthropic"
|
||||||
|
assert cfg["providers"]["anthropic"]["enabled"] is True
|
||||||
|
assert config.enabled_providers(cfg) == ["anthropic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_active_provider_falls_back(tmp_path, monkeypatch):
|
||||||
|
_write(tmp_path, monkeypatch, {"version": 2, "active_provider": "bogus", "providers": {}})
|
||||||
|
cfg = config.load_config()
|
||||||
|
assert cfg["active_provider"] == "anthropic"
|
||||||
|
assert config.active_provider_id(cfg) == "anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_order_covers_all_and_filters_unknown(tmp_path, monkeypatch):
|
||||||
|
_write(tmp_path, monkeypatch, {"version": 2, "display_order": ["zai", "bogus", "openai"]})
|
||||||
|
cfg = config.load_config()
|
||||||
|
# "bogus" dropped; the missing "anthropic" appended at the end.
|
||||||
|
assert cfg["display_order"] == ["zai", "openai", "anthropic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_providers_respects_order_and_flag(tmp_path, monkeypatch):
|
||||||
|
_write(tmp_path, monkeypatch, {
|
||||||
|
"version": 2, "display_order": ["zai", "openai", "anthropic"],
|
||||||
|
"providers": {"anthropic": {"enabled": True},
|
||||||
|
"openai": {"enabled": True},
|
||||||
|
"zai": {"enabled": False}}})
|
||||||
|
cfg = config.load_config()
|
||||||
|
assert config.enabled_providers(cfg) == ["openai", "anthropic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_types():
|
||||||
|
a = get_provider("anthropic")
|
||||||
|
assert isinstance(a, AnthropicProvider) and a.id == "anthropic"
|
||||||
|
o = get_provider("openai")
|
||||||
|
assert isinstance(o, OpenAICodexProvider) and o.id == "openai" and o.accent == "10a37f"
|
||||||
|
z = get_provider("zai")
|
||||||
|
assert isinstance(z, ZaiProvider) and z.id == "zai" and z.accent == "3859ff"
|
||||||
|
stub = get_provider("mystery") # unknown id → safe placeholder, never crashes
|
||||||
|
assert isinstance(stub, StubProvider) and stub.id == "mystery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zai_poll_without_key_is_safe(tmp_path, monkeypatch):
|
||||||
|
# No api_key configured => "no key", no network call.
|
||||||
|
_write(tmp_path, monkeypatch, {"version": 2, "providers": {"zai": {"enabled": True}}})
|
||||||
|
st = _run(get_provider("zai").poll())
|
||||||
|
assert isinstance(st, ProviderStatus)
|
||||||
|
assert st.ok is False and st.st == "no key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_status_payload_keys():
|
||||||
|
st = ProviderStatus(s=50, w=10, ok=True, tokens_today=5, cost_cents_today=3)
|
||||||
|
p = st.to_payload()
|
||||||
|
assert set(p) == {"s", "sr", "w", "wr", "st", "ok", "tk", "to", "tc", "tn"}
|
||||||
|
assert p["s"] == 50 and p["tk"] == 5 and p["tc"] == 3 and p["ok"] is True
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""v3 M1c — control-panel provider API: secret masking + live-switch sink.
|
||||||
|
|
||||||
|
Provider api_keys (z.ai) get the same never-leave-the-process treatment as the
|
||||||
|
HA token, and flipping active_provider notifies the injected command sink so a
|
||||||
|
connected watch switches without waiting for the ~60s poll.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from daemon import config, server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
cfg = {
|
||||||
|
"version": 2,
|
||||||
|
"providers": {
|
||||||
|
"anthropic": {"enabled": True},
|
||||||
|
"openai": {"enabled": False},
|
||||||
|
"zai": {"enabled": False, "base_url": "https://api.z.ai", "api_key": "secret-key"},
|
||||||
|
},
|
||||||
|
"active_provider": "anthropic",
|
||||||
|
"display_order": ["anthropic", "openai", "zai"],
|
||||||
|
}
|
||||||
|
p = tmp_path / "config.json"
|
||||||
|
p.write_text(json.dumps(cfg), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||||
|
monkeypatch.setattr(server, "_command_sink", None)
|
||||||
|
return TestClient(server.app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zai_api_key_masked_in_get(client):
|
||||||
|
r = client.get("/api/config").json()
|
||||||
|
assert r["providers"]["zai"]["api_key"] == server.MASK
|
||||||
|
assert r["providers"]["zai"]["base_url"] == "https://api.z.ai" # non-secret shown
|
||||||
|
|
||||||
|
|
||||||
|
def test_masked_api_key_preserved_on_put(client):
|
||||||
|
# Echo the mask back unchanged => keep the stored secret; other fields apply.
|
||||||
|
client.put("/api/config",
|
||||||
|
json={"providers": {"zai": {"api_key": server.MASK, "enabled": True}}})
|
||||||
|
saved = config.load_config()["providers"]["zai"]
|
||||||
|
assert saved["api_key"] == "secret-key"
|
||||||
|
assert saved["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_api_key_stored_on_put(client):
|
||||||
|
client.put("/api/config", json={"providers": {"zai": {"api_key": "new-key"}}})
|
||||||
|
assert config.load_config()["providers"]["zai"]["api_key"] == "new-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_provider_switch_calls_sink(client, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid))
|
||||||
|
client.put("/api/config", json={"active_provider": "openai"})
|
||||||
|
assert calls == ["openai"]
|
||||||
|
assert config.active_provider_id() == "openai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_switch_when_active_unchanged(client, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid))
|
||||||
|
client.put("/api/config", json={"active_provider": "anthropic"}) # already active
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_provider_id_ignored_on_put(client):
|
||||||
|
client.put("/api/config", json={"providers": {"bogus": {"enabled": True}}})
|
||||||
|
assert "bogus" not in config.load_config()["providers"]
|
||||||
@@ -73,7 +73,7 @@ def test_poll_api_nominal(monkeypatch):
|
|||||||
assert payload["s"] == 42
|
assert payload["s"] == 42
|
||||||
assert payload["w"] == 10
|
assert payload["w"] == 10
|
||||||
assert payload["st"] == "allowed"
|
assert payload["st"] == "allowed"
|
||||||
assert payload["ok"] is True
|
# "ok" is added by the caller (connect_and_run), not by poll_api itself.
|
||||||
# reset_minutes allows ±1 minute tolerance
|
# reset_minutes allows ±1 minute tolerance
|
||||||
assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}"
|
assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}"
|
||||||
assert abs(payload["wr"] - 1440) <= 1, f"Expected ~1440, got {payload['wr']}"
|
assert abs(payload["wr"] - 1440) <= 1, f"Expected ~1440, got {payload['wr']}"
|
||||||
@@ -416,9 +416,10 @@ def test_wire_bytes_compact_json_shape(monkeypatch):
|
|||||||
assert ": " not in wire_str, f"Non-compact JSON detected: {wire_str!r}"
|
assert ": " not in wire_str, f"Non-compact JSON detected: {wire_str!r}"
|
||||||
assert ", " not in wire_str, f"Non-compact JSON detected: {wire_str!r}"
|
assert ", " not in wire_str, f"Non-compact JSON detected: {wire_str!r}"
|
||||||
|
|
||||||
# Must start with '{' and contain all required keys
|
# Must start with '{' and contain every key poll_api emits. ("ok" is added
|
||||||
|
# later by connect_and_run, so it is intentionally not part of poll_api output.)
|
||||||
assert wire_str.startswith("{")
|
assert wire_str.startswith("{")
|
||||||
for key in ("s", "sr", "w", "wr", "st", "ok"):
|
for key in ("s", "sr", "w", "wr", "st"):
|
||||||
assert f'"{key}"' in wire_str, f"Missing key {key!r} in wire bytes: {wire_str!r}"
|
assert f'"{key}"' in wire_str, f"Missing key {key!r} in wire bytes: {wire_str!r}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -599,8 +599,10 @@ def test_start_notify_oserror_does_not_crash_connect_and_run():
|
|||||||
# Must NOT raise OSError — graceful degradation into the poll loop.
|
# Must NOT raise OSError — graceful degradation into the poll loop.
|
||||||
result = _run(connect_and_run(device, stop_event))
|
result = _run(connect_and_run(device, stop_event))
|
||||||
|
|
||||||
# start_notify was actually attempted (and raised), but was swallowed.
|
# start_notify was actually attempted (and raised), but was swallowed — for
|
||||||
assert mock_client.start_notify.call_count == 1
|
# BOTH subscriptions the daemon sets up: the REQ refresh channel and the CMD
|
||||||
|
# command channel (battery + HA). Both degrade gracefully.
|
||||||
|
assert mock_client.start_notify.call_count == 2
|
||||||
# Function returned normally instead of propagating the OSError.
|
# Function returned normally instead of propagating the OSError.
|
||||||
assert result is False
|
assert result is False
|
||||||
# The link was cleaned up via the finally block.
|
# The link was cleaned up via the finally block.
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""v3 M3 — z.ai (GLM) provider: usage from the monitor/quota endpoint.
|
||||||
|
|
||||||
|
Uses httpx.MockTransport so no real z.ai call is made. Covers the token-window
|
||||||
|
mapping (5h -> s, weekly -> w), raw-key auth, the ignored monthly TIME_LIMIT,
|
||||||
|
and auth/error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from daemon.providers.zai import ZaiProvider, DEFAULT_HOST, _MONITOR_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
# test_zai sorts last; an earlier module's asyncio.run() can leave the shared
|
||||||
|
# loop closed, so fall back to a fresh loop rather than raising (Python 3.13).
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_closed():
|
||||||
|
raise RuntimeError
|
||||||
|
except RuntimeError:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _config(tmp_path, monkeypatch, zai: dict):
|
||||||
|
p = tmp_path / "config.json"
|
||||||
|
p.write_text(json.dumps({"version": 2, "providers": {"zai": zai}}), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _tok(pct, reset_ms, unit=3, number=5):
|
||||||
|
return {"type": "TOKENS_LIMIT", "unit": unit, "number": number,
|
||||||
|
"percentage": pct, "nextResetTime": reset_ms}
|
||||||
|
|
||||||
|
|
||||||
|
def _time_limit(pct=0):
|
||||||
|
return {"type": "TIME_LIMIT", "unit": 5, "number": 1, "usage": 100,
|
||||||
|
"currentValue": 0, "remaining": 100, "percentage": pct,
|
||||||
|
"nextResetTime": 9999999999999, "usageDetails": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_resp(limits, level="lite"):
|
||||||
|
return httpx.Response(200, json={"code": 200, "msg": "Operation successful",
|
||||||
|
"success": True, "data": {"level": level, "limits": limits}})
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_returning(resp_or_fn):
|
||||||
|
def handler(request):
|
||||||
|
return resp_or_fn(request) if callable(resp_or_fn) else resp_or_fn
|
||||||
|
return ZaiProvider(transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
def test_maps_token_windows(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk-zai",
|
||||||
|
"base_url": "https://api.z.ai/api/anthropic"})
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen["url"] = str(request.url)
|
||||||
|
seen["auth"] = request.headers.get("authorization")
|
||||||
|
seen["lang"] = request.headers.get("accept-language")
|
||||||
|
seen["method"] = request.method
|
||||||
|
return _ok_resp([
|
||||||
|
_time_limit(), # ignored monthly web-tool quota
|
||||||
|
_tok(1, now_ms + 3600_000, unit=3, number=5), # 5h window -> short
|
||||||
|
_tok(2, now_ms + 6 * 86400_000, unit=6, number=1), # weekly window -> weekly
|
||||||
|
])
|
||||||
|
|
||||||
|
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
|
||||||
|
assert st.ok is True
|
||||||
|
assert st.s == 1.0 and 58 <= st.sr <= 60
|
||||||
|
assert st.w == 2.0 and 8000 <= st.wr <= 8700 # ~6 days in minutes
|
||||||
|
assert st.st == "Lite"
|
||||||
|
# raw key auth (no "Bearer"), monitor endpoint on the configured host, GET
|
||||||
|
assert seen["method"] == "GET"
|
||||||
|
assert seen["url"] == f"{DEFAULT_HOST}{_MONITOR_PATH}"
|
||||||
|
assert seen["auth"] == "sk-zai"
|
||||||
|
assert seen["lang"] == "en-US,en"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_key(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": ""})
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
calls["n"] += 1
|
||||||
|
return _ok_resp([])
|
||||||
|
|
||||||
|
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
|
||||||
|
assert st.ok is False and st.st == "no key" and st.auth_problem is True
|
||||||
|
assert calls["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_401_is_bad_key(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "bad"})
|
||||||
|
p = _provider_returning(httpx.Response(401, json={"error": "unauthorized"}))
|
||||||
|
st = _run(p.poll())
|
||||||
|
assert st.ok is False and st.st == "bad key" and st.auth_problem is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_success_false_is_bad_key(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "bad"})
|
||||||
|
p = _provider_returning(httpx.Response(200, json={"code": 401, "msg": "auth", "success": False, "data": None}))
|
||||||
|
st = _run(p.poll())
|
||||||
|
assert st.ok is False and st.st == "bad key" and st.auth_problem is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_token_window(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
p = _provider_returning(_ok_resp([_tok(37, now_ms + 3600_000)]))
|
||||||
|
st = _run(p.poll())
|
||||||
|
assert st.s == 37.0 and st.w == 0.0 and st.wr == -1 and st.ok is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hundred_percent_is_limited(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
p = _provider_returning(_ok_resp([
|
||||||
|
_tok(100, now_ms + 60_000, unit=3, number=5),
|
||||||
|
_tok(40, now_ms + 6 * 86400_000, unit=6, number=1)]))
|
||||||
|
st = _run(p.poll())
|
||||||
|
assert st.st == "limited" and st.s == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_time_limit_reports_connected(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
|
||||||
|
p = _provider_returning(_ok_resp([_time_limit(5)], level="pro"))
|
||||||
|
st = _run(p.poll())
|
||||||
|
assert st.ok is True and st.st == "Pro" and st.s == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_transient_error_reported(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk"})
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
raise httpx.ConnectError("boom")
|
||||||
|
|
||||||
|
st = _run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
|
||||||
|
assert st.ok is False and st.st == "offline"
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_base_host(tmp_path, monkeypatch):
|
||||||
|
_config(tmp_path, monkeypatch, {"enabled": True, "api_key": "sk",
|
||||||
|
"base_url": "https://zzz.example/api/anthropic"})
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen["url"] = str(request.url)
|
||||||
|
return _ok_resp([])
|
||||||
|
|
||||||
|
_run(ZaiProvider(transport=httpx.MockTransport(handler)).poll())
|
||||||
|
assert seen["url"] == f"https://zzz.example{_MONITOR_PATH}" # monitor path on the same host
|
||||||
+127
-6
@@ -18,6 +18,8 @@ Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -27,6 +29,12 @@ import time
|
|||||||
# imports below and the brand-logo asset load work no matter what the current
|
# imports below and the brand-logo asset load work no matter what the current
|
||||||
# working directory is — critical for logon autostart, where the HKCU\Run entry
|
# working directory is — critical for logon autostart, where the HKCU\Run entry
|
||||||
# starts with cwd = System32, not the repo (APP-01 / SC#1).
|
# starts with cwd = System32, not the repo (APP-01 / SC#1).
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# PyInstaller bundle: the `daemon` package loads from the frozen archive and
|
||||||
|
# data files (the brand logo.h) live under sys._MEIPASS. Point _REPO_ROOT at
|
||||||
|
# the bundle root so the logo asset path below resolves inside the exe.
|
||||||
|
_REPO_ROOT = sys._MEIPASS # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
if _REPO_ROOT not in sys.path:
|
if _REPO_ROOT not in sys.path:
|
||||||
sys.path.insert(0, _REPO_ROOT)
|
sys.path.insert(0, _REPO_ROOT)
|
||||||
@@ -61,6 +69,8 @@ class TrayState:
|
|||||||
self.state: str = "scanning" # "connected" | "scanning" | "error"
|
self.state: str = "scanning" # "connected" | "scanning" | "error"
|
||||||
self.reason: str = "" # error reason string (D-04)
|
self.reason: str = "" # error reason string (D-04)
|
||||||
self.last_sync: float | None = None # time.time() of last successful write
|
self.last_sync: float | None = None # time.time() of last successful write
|
||||||
|
self.battery_pct: int | None = None # latest watch battery %, from the …0005 channel
|
||||||
|
self.toasts: "queue.Queue" = queue.Queue() # (title, message) toasts for the tray to show
|
||||||
|
|
||||||
# Populated by daemon main() at startup:
|
# Populated by daemon main() at startup:
|
||||||
self.loop = None # asyncio running loop (for call_soon_threadsafe)
|
self.loop = None # asyncio running loop (for call_soon_threadsafe)
|
||||||
@@ -156,6 +166,34 @@ def _acquire_single_instance():
|
|||||||
return handle
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# control-panel glue (Phase 7): live status feed + settings-window subprocess
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _status_dict(ts: TrayState) -> dict:
|
||||||
|
"""Live status for the control panel's GET /api/status. Shape matches what
|
||||||
|
web/index.html reads (connected / state / battery / last_sync); a pure read
|
||||||
|
of TrayState scalars, safe to call from the server's request thread."""
|
||||||
|
return {
|
||||||
|
"connected": ts.state == "connected",
|
||||||
|
"state": ts.state,
|
||||||
|
"reason": ts.reason,
|
||||||
|
"battery": ts.battery_pct,
|
||||||
|
"last_sync": ts.last_sync,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _panel_argv() -> list:
|
||||||
|
"""Command that launches the settings window as a SEPARATE process. Frozen:
|
||||||
|
re-invoke this same exe with --panel. Source: run panel.py by ABSOLUTE path —
|
||||||
|
not ``-m daemon.panel``, which would break under autostart (cwd = System32).
|
||||||
|
panel.py rebuilds its own sys.path from __file__, so cwd doesn't matter. Kept
|
||||||
|
a separate process because pywebview wants the main thread, which pystray owns."""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return [sys.executable, "--panel"]
|
||||||
|
return [sys.executable, os.path.join(_REPO_ROOT, "daemon", "panel.py")]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# main() — tray entry (pystray on main thread, daemon loop in bg thread)
|
# main() — tray entry (pystray on main thread, daemon loop in bg thread)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -167,6 +205,14 @@ def main() -> None:
|
|||||||
so the module can be imported on a GTK-less Linux dev box for unit tests
|
so the module can be imported on a GTK-less Linux dev box for unit tests
|
||||||
of the pure helpers (TrayState, header_text) without pystray failing.
|
of the pure helpers (TrayState, header_text) without pystray failing.
|
||||||
"""
|
"""
|
||||||
|
# --panel: we ARE the settings window, launched as a child of the tray. Open
|
||||||
|
# it and exit WITHOUT touching the single-instance mutex or the BLE daemon —
|
||||||
|
# pywebview owns this process's main thread; the tray owns the other one.
|
||||||
|
if "--panel" in sys.argv:
|
||||||
|
from daemon.panel import run as run_panel
|
||||||
|
run_panel()
|
||||||
|
return
|
||||||
|
|
||||||
# Single-instance guard FIRST — before icons, the daemon thread, or any BLE
|
# Single-instance guard FIRST — before icons, the daemon thread, or any BLE
|
||||||
# work. If another tray already owns the session mutex (e.g. ARSO restored a
|
# work. If another tray already owns the session mutex (e.g. ARSO restored a
|
||||||
# console instance and the headless autostart also fired), exit silently.
|
# console instance and the headless autostart also fired), exit silently.
|
||||||
@@ -180,7 +226,8 @@ def main() -> None:
|
|||||||
from pystray import Menu, MenuItem
|
from pystray import Menu, MenuItem
|
||||||
|
|
||||||
import daemon.autostart_windows as autostart
|
import daemon.autostart_windows as autostart
|
||||||
from daemon.claude_usage_daemon_windows import main as daemon_main, log as daemon_log
|
from daemon.claude_usage_daemon_windows import (
|
||||||
|
main as daemon_main, log as daemon_log, request_provider_switch)
|
||||||
from daemon.icon_assets import load_logo_rgba, build_state_icons
|
from daemon.icon_assets import load_logo_rgba, build_state_icons
|
||||||
|
|
||||||
# Build per-state icons once at startup; swap icon.icon per tick (never recomposite).
|
# Build per-state icons once at startup; swap icon.icon per tick (never recomposite).
|
||||||
@@ -190,23 +237,57 @@ def main() -> None:
|
|||||||
ts = TrayState()
|
ts = TrayState()
|
||||||
icon = pystray.Icon("Clawdmeter", images["scanning"], "Clawdmeter")
|
icon = pystray.Icon("Clawdmeter", images["scanning"], "Clawdmeter")
|
||||||
|
|
||||||
# --- background thread: asyncio loop ---
|
# quit_evt lets the supervisor below tell a clean shutdown (Quit) apart from
|
||||||
|
# an unexpected crash: restart the loop on a crash, but NOT after Quit.
|
||||||
|
quit_evt = threading.Event()
|
||||||
|
|
||||||
|
# --- background thread: supervised asyncio loop ---
|
||||||
def _run_daemon() -> None:
|
def _run_daemon() -> None:
|
||||||
# daemon=True thread: an unhandled exception here would vanish silently
|
# daemon=True thread. The inner loop (daemon_main) is now resilient to the
|
||||||
# and freeze the tray on its last state forever (the field "frozen tray"
|
# BLE adapter vanishing, but as a last resort we also supervise the whole
|
||||||
# failure mode). Surface it instead — log the traceback to the rotating
|
# asyncio.run: any unexpected crash is logged AND the loop is restarted
|
||||||
# file and flip the tray to an actionable error state.
|
# after a backoff, instead of the thread dying and freezing the tray
|
||||||
|
# forever (field SC: pulling the BT dongle killed the daemon and it never
|
||||||
|
# came back). A clean return means stop_event was set (Quit) — stop then.
|
||||||
|
backoff = 1
|
||||||
|
while not quit_evt.is_set():
|
||||||
try:
|
try:
|
||||||
_asyncio.run(daemon_main(tray_state=ts))
|
_asyncio.run(daemon_main(tray_state=ts))
|
||||||
|
break # clean return == Quit requested
|
||||||
except Exception as e: # last-resort thread guard
|
except Exception as e: # last-resort thread guard
|
||||||
import traceback
|
import traceback
|
||||||
daemon_log(f"Daemon thread crashed: {e!r}")
|
daemon_log(f"Daemon thread crashed: {e!r}")
|
||||||
daemon_log(traceback.format_exc())
|
daemon_log(traceback.format_exc())
|
||||||
ts.set_error(f"daemon crashed: {type(e).__name__}")
|
ts.set_error(f"daemon crashed: {type(e).__name__}")
|
||||||
|
if quit_evt.is_set():
|
||||||
|
break
|
||||||
|
daemon_log(f"Restarting daemon loop in {backoff}s")
|
||||||
|
time.sleep(backoff)
|
||||||
|
backoff = min(backoff * 2, 30)
|
||||||
|
|
||||||
daemon_thread = threading.Thread(target=_run_daemon, daemon=True)
|
daemon_thread = threading.Thread(target=_run_daemon, daemon=True)
|
||||||
daemon_thread.start()
|
daemon_thread.start()
|
||||||
|
|
||||||
|
# --- control-panel HTTP server (one process, one exe) ---
|
||||||
|
# Serve the brand UI + REST API on 127.0.0.1 in a daemon thread, and feed it
|
||||||
|
# live BLE/daemon status. Best-effort: a server failure must never stop the
|
||||||
|
# tray itself from coming up (the watch sync is the primary job).
|
||||||
|
panel_port = None
|
||||||
|
try:
|
||||||
|
from daemon import server as panel_server
|
||||||
|
from daemon.config import DEFAULT_PORT
|
||||||
|
panel_port = DEFAULT_PORT
|
||||||
|
panel_server.set_status_provider(lambda: _status_dict(ts))
|
||||||
|
panel_server.set_command_sink(request_provider_switch)
|
||||||
|
panel_server.serve_in_thread(panel_port)
|
||||||
|
daemon_log(f"Control panel: http://127.0.0.1:{panel_port}")
|
||||||
|
except Exception as e:
|
||||||
|
daemon_log(f"Control panel unavailable: {e!r}")
|
||||||
|
|
||||||
|
# Holds the settings-window child process so we don't stack windows and can
|
||||||
|
# tear it down on Quit. Mutated by _on_settings / _on_quit below.
|
||||||
|
_panel = {"proc": None}
|
||||||
|
|
||||||
# --- menu ---
|
# --- menu ---
|
||||||
def _on_quit(icon_ref, _item) -> None:
|
def _on_quit(icon_ref, _item) -> None:
|
||||||
# NEVER call ts.stop_event.set() directly from the tray thread;
|
# NEVER call ts.stop_event.set() directly from the tray thread;
|
||||||
@@ -219,9 +300,20 @@ def main() -> None:
|
|||||||
# the device sits frozen on stale data instead of returning to its waiting
|
# the device sits frozen on stale data instead of returning to its waiting
|
||||||
# screen (SC#3 field report). The timeout caps the block so Quit can never
|
# screen (SC#3 field report). The timeout caps the block so Quit can never
|
||||||
# hang if a WinRT disconnect wedges (rare) — we exit anyway as a fallback.
|
# hang if a WinRT disconnect wedges (rare) — we exit anyway as a fallback.
|
||||||
|
quit_evt.set() # tell the supervisor this is a clean stop, not a crash
|
||||||
if ts.loop is not None and ts.stop_event is not None:
|
if ts.loop is not None and ts.stop_event is not None:
|
||||||
|
try:
|
||||||
ts.loop.call_soon_threadsafe(ts.stop_event.set)
|
ts.loop.call_soon_threadsafe(ts.stop_event.set)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # loop already closed (e.g. mid-restart) — quit_evt handles it
|
||||||
daemon_thread.join(timeout=6.0)
|
daemon_thread.join(timeout=6.0)
|
||||||
|
# Close the settings window too, if the user left it open.
|
||||||
|
proc = _panel["proc"]
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
icon_ref.stop()
|
icon_ref.stop()
|
||||||
|
|
||||||
def _on_toggle(_icon_ref, _item) -> None:
|
def _on_toggle(_icon_ref, _item) -> None:
|
||||||
@@ -234,9 +326,30 @@ def main() -> None:
|
|||||||
autostart.enable(tray_script=os.path.abspath(__file__))
|
autostart.enable(tray_script=os.path.abspath(__file__))
|
||||||
icon.update_menu()
|
icon.update_menu()
|
||||||
|
|
||||||
|
def _on_settings(_icon_ref, _item) -> None:
|
||||||
|
# Open the WebView2 settings window as a child process. If one is already
|
||||||
|
# alive, leave it — re-spawning would stack duplicate windows.
|
||||||
|
proc = _panel["proc"]
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
return
|
||||||
|
env = dict(os.environ)
|
||||||
|
if panel_port:
|
||||||
|
env["CLAWDMETER_PANEL_PORT"] = str(panel_port)
|
||||||
|
kwargs = {}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# No phantom console window for the child (it's a GUI of its own).
|
||||||
|
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
try:
|
||||||
|
_panel["proc"] = subprocess.Popen(_panel_argv(), env=env, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
daemon_log(f"Could not open settings window: {e!r}")
|
||||||
|
|
||||||
icon.menu = Menu(
|
icon.menu = Menu(
|
||||||
# Non-clickable status header; text updates via update_menu() on state change.
|
# Non-clickable status header; text updates via update_menu() on state change.
|
||||||
MenuItem(lambda _item: header_text(ts), None, enabled=False),
|
MenuItem(lambda _item: header_text(ts), None, enabled=False),
|
||||||
|
# Settings = the WebView2 control panel. default=True opens it on a plain
|
||||||
|
# left-click of the tray icon (right-click still shows the full menu).
|
||||||
|
MenuItem("Settings", _on_settings, default=True),
|
||||||
# Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6).
|
# Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6).
|
||||||
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
|
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
|
||||||
MenuItem("Quit", _on_quit),
|
MenuItem("Quit", _on_quit),
|
||||||
@@ -266,6 +379,14 @@ def main() -> None:
|
|||||||
prev_state["state"] = current
|
prev_state["state"] = current
|
||||||
prev_state["last_sync"] = last_sync
|
prev_state["last_sync"] = last_sync
|
||||||
_icon.update_menu()
|
_icon.update_menu()
|
||||||
|
# Drain daemon-queued toasts (e.g. low watch battery) — runs every
|
||||||
|
# tick regardless of state change.
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
_title, _msg = ts.toasts.get_nowait()
|
||||||
|
_icon.notify(_msg, _title)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
||||||
# Blocks the main thread until icon.stop() is called from _on_quit.
|
# Blocks the main thread until icon.stop() is called from _on_quit.
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Clawdmeter</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#131211; --panel:#1f1f1e; --panel2:#232220; --text:#faf9f5; --dim:#b0aea5;
|
||||||
|
--accent:#d97757; --accent-text:#4a1b0c; --green:#788c5d; --red:#c0392b;
|
||||||
|
--border:rgba(255,255,255,.09);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;height:100%}
|
||||||
|
body{background:var(--bg);color:var(--text);font-family:"Segoe UI",system-ui,sans-serif;font-size:14px}
|
||||||
|
.app{display:flex;height:100vh}
|
||||||
|
.nav{width:188px;flex-shrink:0;background:#161514;border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:3px}
|
||||||
|
.brand{display:flex;align-items:center;gap:9px;padding:4px 10px 14px;font-weight:500}
|
||||||
|
.brand .dot{width:11px;height:11px;border-radius:50%;background:var(--accent)}
|
||||||
|
.nav button.tabbtn{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;border:none;background:transparent;color:var(--dim);font-size:14px;cursor:pointer;text-align:left;width:100%}
|
||||||
|
.nav button.tabbtn:hover{background:#1c1b1a}
|
||||||
|
.nav button.tabbtn.active{background:var(--panel2);color:var(--text)}
|
||||||
|
.nav button.tabbtn.active svg{color:var(--accent)}
|
||||||
|
.nav svg{width:18px;height:18px;flex-shrink:0}
|
||||||
|
.navstatus{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px;font-size:12px;color:var(--dim)}
|
||||||
|
.led{width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0}
|
||||||
|
.led.on{background:var(--green)}
|
||||||
|
.content{flex:1;overflow-y:auto;padding:22px 26px}
|
||||||
|
h1{font-size:18px;font-weight:500;margin:0}
|
||||||
|
.sub{font-size:13px;color:var(--dim);margin:3px 0 18px}
|
||||||
|
label{font-size:12px;color:var(--dim);display:block;margin:14px 0 6px}
|
||||||
|
input[type=text],input[type=password],input[type=number],select{width:100%;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--text);font-size:14px;outline:none}
|
||||||
|
input:focus,select:focus{border-color:var(--accent)}
|
||||||
|
button.primary{background:var(--accent);color:var(--accent-text);border:none;border-radius:8px;padding:9px 18px;font-size:14px;font-weight:500;cursor:pointer}
|
||||||
|
button.ghost{background:transparent;border:1px solid var(--border);color:var(--dim);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
|
||||||
|
button.ghost:hover{color:var(--text);border-color:rgba(255,255,255,.2)}
|
||||||
|
.row{display:flex;gap:10px;align-items:center}
|
||||||
|
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:4px 11px;font-size:12px;margin-top:8px}
|
||||||
|
.pill.ok{background:rgba(120,140,93,.16);color:#9bb074}
|
||||||
|
.pill.err{background:rgba(192,57,43,.16);color:#e3897f}
|
||||||
|
.chips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px}
|
||||||
|
.chip b{cursor:pointer;color:#7d7b74;font-weight:400}
|
||||||
|
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:10px}
|
||||||
|
.grid{display:grid;grid-template-columns:1.4fr 1fr 90px auto;gap:10px;align-items:end}
|
||||||
|
.tab{display:none}
|
||||||
|
.tab.active{display:block}
|
||||||
|
.actions{margin-top:22px;display:flex;justify-content:flex-end;gap:10px}
|
||||||
|
.statgrid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px}
|
||||||
|
.stat{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px}
|
||||||
|
.stat .k{font-size:12px;color:var(--dim)}
|
||||||
|
.stat .v{font-size:22px;font-weight:500;margin-top:4px}
|
||||||
|
.muted{color:var(--dim);font-size:12.5px}
|
||||||
|
.swatch{width:13px;height:13px;border-radius:50%;flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.12)}
|
||||||
|
label.inline{display:inline-flex;align-items:center;gap:7px;margin:0;color:var(--text);font-size:13px;cursor:pointer}
|
||||||
|
label.inline input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
|
||||||
|
label.inline.off{color:var(--dim);cursor:default}
|
||||||
|
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:10px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
|
||||||
|
#toast.show{opacity:1}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="brand"><span class="dot"></span>Clawdmeter</div>
|
||||||
|
<button class="tabbtn active" data-tab="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>Status</button>
|
||||||
|
<button class="tabbtn" data-tab="providers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>Providers</button>
|
||||||
|
<button class="tabbtn" data-tab="ha"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 1 4 10 4 4 0 0 0-1 3H9a4 4 0 0 0-1-3 6 6 0 0 1 4-10z"/></svg>Home Assistant</button>
|
||||||
|
<button class="tabbtn" data-tab="buttons"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Buttons</button>
|
||||||
|
<button class="tabbtn" data-tab="settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="8" cy="18" r="2" fill="var(--bg)"/></svg>Settings</button>
|
||||||
|
<div class="navstatus"><span class="led" id="navled"></span><span id="navstate">Connecting…</span></div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<!-- STATUS -->
|
||||||
|
<section class="tab active" data-tab="status">
|
||||||
|
<h1>Status</h1>
|
||||||
|
<div class="sub">Watch connection and live readings</div>
|
||||||
|
<div class="statgrid">
|
||||||
|
<div class="stat"><div class="k">Watch</div><div class="v" id="st-conn">—</div></div>
|
||||||
|
<div class="stat"><div class="k">Battery</div><div class="v" id="st-batt">—</div></div>
|
||||||
|
<div class="stat"><div class="k">Daemon</div><div class="v" id="st-state">—</div></div>
|
||||||
|
<div class="stat"><div class="k">Last update</div><div class="v" id="st-sync">—</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PROVIDERS -->
|
||||||
|
<section class="tab" data-tab="providers">
|
||||||
|
<h1>Providers</h1>
|
||||||
|
<div class="sub">Which usage source the watch shows — and its brand colour</div>
|
||||||
|
<div id="prov-list"></div>
|
||||||
|
<div class="actions"><button class="primary" id="prov-save">Save</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HOME ASSISTANT -->
|
||||||
|
<section class="tab" data-tab="ha">
|
||||||
|
<h1>Home Assistant</h1>
|
||||||
|
<div class="sub">Connection & devices</div>
|
||||||
|
<label>Server URL</label>
|
||||||
|
<input type="text" id="ha-url" placeholder="https://homeassistant.local:8123" autocomplete="off">
|
||||||
|
<label>Long-lived access token</label>
|
||||||
|
<div class="row">
|
||||||
|
<input type="password" id="ha-token" placeholder="Paste token" autocomplete="off">
|
||||||
|
<button class="ghost" id="ha-test">Test</button>
|
||||||
|
</div>
|
||||||
|
<div id="ha-testresult"></div>
|
||||||
|
<label>Controlled entities</label>
|
||||||
|
<div class="chips" id="ha-chips"></div>
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<select id="ha-picker"><option value="">Load devices to add…</option></select>
|
||||||
|
<button class="ghost" id="ha-load">Load devices</button>
|
||||||
|
<button class="ghost" id="ha-add">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button class="primary" id="ha-save">Save</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BUTTONS -->
|
||||||
|
<section class="tab" data-tab="buttons">
|
||||||
|
<h1>Buttons</h1>
|
||||||
|
<div class="sub">Actions you can fire (and, later, show on the watch)</div>
|
||||||
|
<div id="btn-list"></div>
|
||||||
|
<button class="ghost" id="btn-add">+ Add button</button>
|
||||||
|
<div class="actions"><button class="primary" id="btn-save">Save</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SETTINGS -->
|
||||||
|
<section class="tab" data-tab="settings">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<div class="sub">App behaviour</div>
|
||||||
|
<label>Low-battery warning at (%)</label>
|
||||||
|
<input type="number" id="set-lowbatt" min="1" max="100" step="1">
|
||||||
|
<label>Watch BLE address <span class="muted">(optional — for bonded, non-advertising watches)</span></label>
|
||||||
|
<input type="text" id="set-addr" placeholder="44:1B:F6:85:1E:51" autocomplete="off">
|
||||||
|
<div class="actions"><button class="primary" id="set-save">Save</button></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MASK = "********";
|
||||||
|
let cfg = null;
|
||||||
|
let entities = []; // current controlled entities (chips)
|
||||||
|
let lights = []; // available light.* from HA
|
||||||
|
let buttons = []; // current button defs
|
||||||
|
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
async function api(path, method="GET", body=null){
|
||||||
|
const opt = {method, headers:{}};
|
||||||
|
if(body){opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body);}
|
||||||
|
const r = await fetch(path, opt);
|
||||||
|
if(!r.ok) throw new Error("HTTP "+r.status);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
function toast(msg){const t=$("#toast");t.textContent=msg;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove("show"),1800);}
|
||||||
|
|
||||||
|
// ---- tabs ----
|
||||||
|
document.querySelectorAll(".tabbtn").forEach(b=>b.onclick=()=>{
|
||||||
|
document.querySelectorAll(".tabbtn").forEach(x=>x.classList.toggle("active",x===b));
|
||||||
|
const id=b.dataset.tab;
|
||||||
|
document.querySelectorAll(".tab").forEach(s=>s.classList.toggle("active",s.dataset.tab===id));
|
||||||
|
if(id==="providers") loadProviders().catch(e=>toast("Load failed: "+e.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- load ----
|
||||||
|
async function loadConfig(){
|
||||||
|
cfg = await api("/api/config");
|
||||||
|
$("#ha-url").value = cfg.ha.url || "";
|
||||||
|
$("#ha-token").value = cfg.ha.token || ""; // server sends MASK when a token is set
|
||||||
|
entities = (cfg.ha.entities||[]).slice();
|
||||||
|
buttons = (cfg.buttons||[]).map(b=>Object.assign({}, b));
|
||||||
|
$("#set-lowbatt").value = cfg.settings.low_battery_pct ?? 15;
|
||||||
|
$("#set-addr").value = cfg.settings.device_address || "";
|
||||||
|
renderChips(); renderButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HA ----
|
||||||
|
function renderChips(){
|
||||||
|
const c=$("#ha-chips"); c.innerHTML="";
|
||||||
|
if(!entities.length){c.innerHTML='<span class="muted">No devices yet — add some below.</span>';}
|
||||||
|
entities.forEach(e=>{
|
||||||
|
const s=document.createElement("span"); s.className="chip";
|
||||||
|
s.innerHTML = e+' <b title="Remove">✕</b>';
|
||||||
|
s.querySelector("b").onclick=()=>{entities=entities.filter(x=>x!==e);renderChips();renderButtons();};
|
||||||
|
c.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#ha-test").onclick = async ()=>{
|
||||||
|
const box=$("#ha-testresult"); box.innerHTML='<span class="muted">Testing…</span>';
|
||||||
|
try{
|
||||||
|
const r = await api("/api/ha/test","POST",{url:$("#ha-url").value.trim(), token:$("#ha-token").value});
|
||||||
|
box.innerHTML = r.ok ? '<span class="pill ok">✓ '+(r.message||"Connected")+'</span>'
|
||||||
|
: '<span class="pill err">✕ '+(r.error||"Failed")+'</span>';
|
||||||
|
}catch(e){box.innerHTML='<span class="pill err">✕ '+e.message+'</span>';}
|
||||||
|
};
|
||||||
|
$("#ha-load").onclick = async ()=>{
|
||||||
|
// Persist url+token first so the server can query HA with them.
|
||||||
|
await saveHA(true);
|
||||||
|
const r = await api("/api/ha/entities");
|
||||||
|
lights = r.entities||[];
|
||||||
|
const sel=$("#ha-picker"); sel.innerHTML="";
|
||||||
|
if(!lights.length){sel.innerHTML='<option value="">'+(r.error||"No lights found")+'</option>';return;}
|
||||||
|
sel.appendChild(new Option("Select a device…",""));
|
||||||
|
lights.forEach(l=>sel.appendChild(new Option(l,l)));
|
||||||
|
toast(lights.length+" devices loaded");
|
||||||
|
};
|
||||||
|
$("#ha-add").onclick = ()=>{
|
||||||
|
const v=$("#ha-picker").value; if(!v) return;
|
||||||
|
if(!entities.includes(v)){entities.push(v);renderChips();renderButtons();}
|
||||||
|
};
|
||||||
|
async function saveHA(silent){
|
||||||
|
const ha={url:$("#ha-url").value.trim(), token:$("#ha-token").value, entities};
|
||||||
|
cfg = await api("/api/config","PUT",{ha});
|
||||||
|
$("#ha-token").value = cfg.ha.token; // re-mask
|
||||||
|
if(!silent) toast("Home Assistant saved");
|
||||||
|
}
|
||||||
|
$("#ha-save").onclick = ()=>saveHA(false).catch(e=>toast("Error: "+e.message));
|
||||||
|
|
||||||
|
// ---- Buttons ----
|
||||||
|
const ACTIONS=[["toggle","Toggle on/off"],["bri","Set brightness %"],["ct","Set color temp K"]];
|
||||||
|
function renderButtons(){
|
||||||
|
const list=$("#btn-list"); list.innerHTML="";
|
||||||
|
if(!buttons.length){list.insertAdjacentHTML("beforeend",'<div class="muted" style="margin-bottom:10px">No buttons yet.</div>');}
|
||||||
|
buttons.forEach((b,i)=>{
|
||||||
|
const card=document.createElement("div"); card.className="card";
|
||||||
|
const entOpts = entities.map(e=>`<option value="${e}" ${e===b.entity?"selected":""}>${e}</option>`).join("");
|
||||||
|
const actOpts = ACTIONS.map(([v,l])=>`<option value="${v}" ${v===b.action?"selected":""}>${l}</option>`).join("");
|
||||||
|
const showVal = b.action==="bri"||b.action==="ct";
|
||||||
|
card.innerHTML=`
|
||||||
|
<div class="grid">
|
||||||
|
<div><label>Label</label><input type="text" data-i="${i}" data-f="label" value="${(b.label||"").replace(/"/g,'"')}"></div>
|
||||||
|
<div><label>Device</label><select data-i="${i}" data-f="entity">${entOpts||'<option value="">— add a device first —</option>'}</select></div>
|
||||||
|
<div><label>Action</label><select data-i="${i}" data-f="action">${actOpts}</select></div>
|
||||||
|
<div><button class="ghost" data-del="${i}">Remove</button></div>
|
||||||
|
</div>
|
||||||
|
<div data-val="${i}" style="${showVal?"":"display:none"};margin-top:8px">
|
||||||
|
<label>${b.action==="ct"?"Kelvin (2000–6500)":"Brightness %"}</label>
|
||||||
|
<input type="number" data-i="${i}" data-f="value" value="${b.value??(b.action==="ct"?3000:60)}" style="max-width:160px">
|
||||||
|
</div>`;
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
list.querySelectorAll("[data-f]").forEach(el=>el.oninput=()=>{
|
||||||
|
const i=+el.dataset.i, f=el.dataset.f;
|
||||||
|
buttons[i][f] = f==="value" ? +el.value : el.value;
|
||||||
|
if(f==="action"){ buttons[i].icon=el.value; renderButtons(); }
|
||||||
|
});
|
||||||
|
list.querySelectorAll("[data-del]").forEach(el=>el.onclick=()=>{buttons.splice(+el.dataset.del,1);renderButtons();});
|
||||||
|
}
|
||||||
|
$("#btn-add").onclick = ()=>{
|
||||||
|
buttons.push({id:"b"+Date.now().toString(36), label:"Light", icon:"toggle",
|
||||||
|
action:"toggle", entity:entities[0]||"", value:null});
|
||||||
|
renderButtons();
|
||||||
|
};
|
||||||
|
$("#btn-save").onclick = async ()=>{
|
||||||
|
try{ cfg = await api("/api/config","PUT",{buttons}); toast("Buttons saved"); }
|
||||||
|
catch(e){ toast("Error: "+e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Settings ----
|
||||||
|
$("#set-save").onclick = async ()=>{
|
||||||
|
try{
|
||||||
|
cfg = await api("/api/config","PUT",{settings:{
|
||||||
|
low_battery_pct:+$("#set-lowbatt").value, device_address:$("#set-addr").value.trim()}});
|
||||||
|
toast("Settings saved");
|
||||||
|
}catch(e){ toast("Error: "+e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Providers ----
|
||||||
|
let providers = []; // [{id,label,accent,enabled,needs_key,base_url,has_key,...}]
|
||||||
|
let activeProv = "anthropic";
|
||||||
|
async function loadProviders(){
|
||||||
|
const r = await api("/api/providers");
|
||||||
|
providers = r.providers; activeProv = r.active;
|
||||||
|
// Seed the editable secret from has_key: a stored key shows (and re-saves) as the
|
||||||
|
// mask, so leaving it untouched preserves it; typing over it sends the new key.
|
||||||
|
providers.forEach(p=>{ if(p.needs_key) p.api_key = p.has_key ? MASK : ""; });
|
||||||
|
renderProviders();
|
||||||
|
}
|
||||||
|
function esc(s){return (s||"").replace(/"/g,""");}
|
||||||
|
function renderProviders(){
|
||||||
|
const list=$("#prov-list"); list.innerHTML="";
|
||||||
|
providers.forEach(p=>{
|
||||||
|
const card=document.createElement("div"); card.className="card";
|
||||||
|
const keyBlock = p.needs_key ? `
|
||||||
|
<div data-keys="${p.id}" style="${p.enabled?"":"display:none"};margin-top:12px">
|
||||||
|
<label>Base URL</label>
|
||||||
|
<input type="text" data-pf="base_url" data-pid="${p.id}" value="${esc(p.base_url)}" placeholder="https://api.z.ai/api/anthropic" autocomplete="off">
|
||||||
|
<label>API key</label>
|
||||||
|
<input type="password" data-pf="api_key" data-pid="${p.id}" value="${esc(p.api_key)}" placeholder="Paste key" autocomplete="off">
|
||||||
|
</div>` : "";
|
||||||
|
const actCls = p.enabled?"inline":"inline off";
|
||||||
|
card.innerHTML=`
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div class="row" style="gap:10px"><span class="swatch" style="background:#${p.accent}"></span><b style="font-weight:500">${p.label}</b></div>
|
||||||
|
<div class="row" style="gap:18px">
|
||||||
|
<label class="inline"><input type="checkbox" data-pen="${p.id}" ${p.enabled?"checked":""}> Enabled</label>
|
||||||
|
<label class="${actCls}"><input type="radio" name="activeprov" data-pact="${p.id}" ${activeProv===p.id?"checked":""} ${p.enabled?"":"disabled"}> Show on watch</label>
|
||||||
|
</div>
|
||||||
|
</div>${keyBlock}`;
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
list.querySelectorAll("[data-pen]").forEach(el=>el.onchange=()=>{
|
||||||
|
const p=providers.find(x=>x.id===el.dataset.pen); p.enabled=el.checked;
|
||||||
|
if(!el.checked && activeProv===p.id){ // disabling the shown one → hand off
|
||||||
|
const alt=providers.find(x=>x.enabled); activeProv = alt?alt.id:"anthropic";
|
||||||
|
}
|
||||||
|
if(el.checked && !providers.some(x=>x.enabled&&x.id===activeProv)) activeProv=p.id;
|
||||||
|
renderProviders();
|
||||||
|
});
|
||||||
|
list.querySelectorAll("[data-pact]").forEach(el=>el.onchange=()=>{activeProv=el.dataset.pact;});
|
||||||
|
list.querySelectorAll("[data-pf]").forEach(el=>el.oninput=()=>{
|
||||||
|
providers.find(x=>x.id===el.dataset.pid)[el.dataset.pf]=el.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#prov-save").onclick = async ()=>{
|
||||||
|
const payload={providers:{}, active_provider:activeProv};
|
||||||
|
providers.forEach(p=>{
|
||||||
|
const pc={enabled:!!p.enabled};
|
||||||
|
if(p.needs_key){ pc.base_url=(p.base_url||"").trim(); pc.api_key=p.api_key||""; }
|
||||||
|
payload.providers[p.id]=pc;
|
||||||
|
});
|
||||||
|
try{ await api("/api/config","PUT",payload); toast("Providers saved"); await loadProviders(); }
|
||||||
|
catch(e){ toast("Error: "+e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Status poll ----
|
||||||
|
async function pollStatus(){
|
||||||
|
try{
|
||||||
|
const s = await api("/api/status");
|
||||||
|
const conn = s.connected;
|
||||||
|
$("#navled").classList.toggle("on",conn);
|
||||||
|
$("#navstate").textContent = conn?"Watch connected":(s.state==="unknown"?"Daemon off":"Scanning…");
|
||||||
|
$("#st-conn").textContent = conn?"Connected":"Disconnected";
|
||||||
|
$("#st-batt").textContent = (s.battery==null)?"—":s.battery+"%";
|
||||||
|
$("#st-state").textContent = s.state||"—";
|
||||||
|
$("#st-sync").textContent = s.last_sync? new Date(s.last_sync*1000).toLocaleTimeString():"—";
|
||||||
|
}catch(e){ $("#navstate").textContent="Panel only"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConfig().catch(e=>toast("Load failed: "+e.message));
|
||||||
|
pollStatus(); setInterval(pollStatus,3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Clawdmeter v2 — roadmap
|
||||||
|
|
||||||
|
v2 turns Clawdmeter from a single-purpose usage indicator into a small
|
||||||
|
multi-app wrist companion for the Waveshare AMOLED-2.06 watch.
|
||||||
|
|
||||||
|
- **Classic line** — the stable 2.06 port + working Windows BLE daemon. Frozen on
|
||||||
|
`main`, tagged `v1.0-classic`. Bugfix-only.
|
||||||
|
- **v2 development** — this branch (`v2-dev`). When it's ready it merges to `main`
|
||||||
|
and is tagged `v2.0.0`.
|
||||||
|
|
||||||
|
## Architecture decisions (locked)
|
||||||
|
|
||||||
|
- **Navigation:** tap the Claude logo (top-left) → app launcher. Each feature is an
|
||||||
|
"app" screen registered in a registry (`{id, icon, label, show/hide}`), mirroring
|
||||||
|
the board-HAL pattern: a new app = one registry entry + one screen module, no
|
||||||
|
edits to shared navigation code.
|
||||||
|
- **Home Assistant:** routed **through the desktop daemon** (watch → BLE → daemon →
|
||||||
|
HA REST API). The watch stays dumb; the HA URL/token live on the PC. No WiFi
|
||||||
|
provisioning or secrets on the watch.
|
||||||
|
- **Desktop companion:** a **local web UI** (FastAPI, opened from the tray) for
|
||||||
|
config, firmware flashing, and per-app setup (soundpad labels, HA entity map,
|
||||||
|
Now Playing).
|
||||||
|
- **Soundpad:** on-screen buttons emit **F13–F18** over the existing BLE HID
|
||||||
|
keyboard. Requires raising the HID report map's keycode ceiling (currently 101 /
|
||||||
|
0x65; F13–F24 are 0x68–0x73).
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
| # | Phase | Depends on | Daemon work |
|
||||||
|
|---|-------|-----------|-------------|
|
||||||
|
| 0 | Version split — freeze classic, branch `v2-dev`, version constant | — | no |
|
||||||
|
| 1 | Touch foundation — verify FT3168 coordinate accuracy, calibrate swap/mirror, logo hit-area | — | no |
|
||||||
|
| 2 | Menu / launcher — app registry, launcher screen, nav stack, back affordance | 1 | no |
|
||||||
|
| 3 | Soundpad — raise HID ceiling to F24, 6-button screen → F13–F18 | 2 | no |
|
||||||
|
| 4 | Session / tokens — daemon computes today's tokens/$; extend payload + parser + screen | 2 | data |
|
||||||
|
| 5 | Now Playing — daemon reads Windows media session; new screen | 2 | media |
|
||||||
|
| 6 | Home Assistant — watch→daemon command characteristic, HA bridge, control screen | 2 | HA bridge |
|
||||||
|
| 7 | Desktop companion — local web UI: config + one-click firmware flash (esptool, bins from Gitea Releases) | — | big upgrade |
|
||||||
|
| 8 | Release v2.0 — docs, icons, Gitea release with firmware binaries | all | — |
|
||||||
|
|
||||||
|
Each UI phase is verified on hardware via the `screenshot` serial command before
|
||||||
|
moving on.
|
||||||
|
|
||||||
|
## BLE data channel (current + planned)
|
||||||
|
|
||||||
|
Custom GATT service `4c41555a-…0001`:
|
||||||
|
|
||||||
|
| Char | Dir | Now | v2 plan |
|
||||||
|
|------|-----|-----|---------|
|
||||||
|
| `…0002` RX | PC→watch | JSON usage payload | + session/tokens, now-playing fields |
|
||||||
|
| `…0003` TX | watch→PC | ack/nack | (unchanged) |
|
||||||
|
| `…0004` REQ | watch→PC | refresh request (0x01) | (unchanged) |
|
||||||
|
| `…0005` CMD | watch→PC | — | **new:** app commands (HA toggle, etc.) |
|
||||||
|
|
||||||
|
Plus the standard BLE HID keyboard (0x1812) for Space / Shift+Tab / soundpad keys.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Clawdmeter v3 — multi-provider (alpha)
|
||||||
|
|
||||||
|
Extend the watch beyond Claude Code to show usage for several AI coding
|
||||||
|
providers, let you pick which one the display tracks, and recolor the whole UI
|
||||||
|
to the active provider's brand.
|
||||||
|
|
||||||
|
Branch: **`v3-dev`** (off v2 `d55dc4a`). v2 stays on `v2-dev`.
|
||||||
|
|
||||||
|
## Providers (initial set)
|
||||||
|
|
||||||
|
| id | provider | used via | brand accent |
|
||||||
|
| ----------- | --------------------- | -------------------------------- | ------------ |
|
||||||
|
| `anthropic` | Claude Code | Anthropic OAuth (today's daemon) | `#d97757` clay |
|
||||||
|
| `openai` | OpenAI Codex | Codex CLI subscription (`~/.codex`) | `#10a37f` green |
|
||||||
|
| `zai` | z.ai GLM Coding Plan | Anthropic-compatible endpoint | blue (TBD) |
|
||||||
|
|
||||||
|
## Locked decisions (2026-07-10)
|
||||||
|
|
||||||
|
- **Metric = rate-limit %** — the same two-bar 5h / weekly model as Claude, read
|
||||||
|
from each provider's CLI subscription. Fall back to spend / token counts for a
|
||||||
|
provider only if it genuinely doesn't expose limits.
|
||||||
|
- **Switch on both PC and watch** — pick the active provider in the desktop
|
||||||
|
panel *and* on the watch (cycle / selector); the theme recolors live.
|
||||||
|
- **Brand palette** (starting point, tuned on-device): Anthropic clay `#d97757`,
|
||||||
|
OpenAI `#10a37f`, z.ai blue (exact hex TBD).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Daemon — provider abstraction
|
||||||
|
- New `daemon/providers/` package:
|
||||||
|
- `base.py` — `Provider` ABC: `id`, `label`, `accent`, `async poll() -> ProviderStatus`.
|
||||||
|
- `anthropic.py` — today's logic moved here (`poll_api`, `compute_today_usage`,
|
||||||
|
OAuth refresh), behaviour-identical.
|
||||||
|
- `openai_codex.py`, `zai.py` — added in M2 / M3.
|
||||||
|
- `ProviderStatus` = normalized `{s, sr, w, wr, st, ok, tokens_today?, cost_cents_today?}`.
|
||||||
|
- **Config v2** (`config.py`): add `providers` (per-provider enable + creds),
|
||||||
|
`active_provider`, `display_order`. Migrate v1→v2 by wrapping the existing
|
||||||
|
setup as `anthropic`.
|
||||||
|
- Poll loop drives the **active** provider; for on-watch cycling it may poll all
|
||||||
|
enabled providers and cache, so switching is instant.
|
||||||
|
|
||||||
|
### Payload
|
||||||
|
- Add **`pv`** (provider id) so the watch knows which theme to wear; usage fields
|
||||||
|
stay normalized (`s/sr/w/wr/st/tk/tc/...`).
|
||||||
|
- On-watch switch: watch → PC command **`{"cmd":"prov","id":"..."}`** sets
|
||||||
|
`active_provider` (same channel as the dimmer / button commands, no new GATT).
|
||||||
|
|
||||||
|
### Firmware — runtime theming (the meaty part)
|
||||||
|
- Convert `theme.h` compile-time `#define`s into a runtime **`Palette`**
|
||||||
|
(`lv_color_t accent/green/amber/panel/bar_bg/...`) plus shared `lv_style_t`
|
||||||
|
objects for the accent / panel / text roles.
|
||||||
|
- `ui_set_theme(pv)` swaps the palette + updates the shared styles + invalidates
|
||||||
|
— **no widget recreation** (LVGL styles propagate to attached widgets).
|
||||||
|
- Per-provider **logo** on the usage screen (swap the image source).
|
||||||
|
- On-watch provider switch: a small selector (button on the usage screen, or a
|
||||||
|
Providers entry in the launcher) → sends `{"cmd":"prov",...}`.
|
||||||
|
|
||||||
|
### Panel (desktop)
|
||||||
|
- Provider selector (which one displays) + per-provider credential / enable
|
||||||
|
fields. Reuses the existing FastAPI + WebView2 settings panel.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
- **M1 — framework + Anthropic refactor + runtime theming.** Provider ABC +
|
||||||
|
`AnthropicProvider` (identical behaviour) + config v2 + `pv` in the payload.
|
||||||
|
Firmware runtime palette + shared styles + `ui_set_theme` + per-provider logo
|
||||||
|
slot + on-watch switch plumbing. Panel provider selector (only Anthropic active
|
||||||
|
yet). *Deliverable: behaviour unchanged, but themeable and switch-ready.* This
|
||||||
|
is the big refactor and de-risks M2/M3.
|
||||||
|
- **M2 — OpenAI (Codex) provider.** Read Codex rate-limit / usage. Source TBD —
|
||||||
|
Codex is installed at `~/.codex` (`auth.json`, `config.toml`, `state_*.sqlite`,
|
||||||
|
`logs_*.sqlite`, `sessions/`); investigate whether limits come from the state
|
||||||
|
DB or ChatGPT backend headers. Green theme + OpenAI logo. Fall back to
|
||||||
|
spend/tokens if the % model isn't available.
|
||||||
|
- **M3 — z.ai (GLM Coding Plan) provider.** `ANTHROPIC_BASE_URL` is already set on
|
||||||
|
the dev machine → z.ai is likely used through its Anthropic-compatible endpoint,
|
||||||
|
so reuse the Anthropic poll pointed at z.ai's base URL + z.ai key, reading the
|
||||||
|
same `anthropic-ratelimit-*` headers. Blue theme + z.ai logo.
|
||||||
|
- **M4 — polish.** On-watch switch UX, per-provider splash decision, panel niceties.
|
||||||
|
|
||||||
|
## Open items / assets
|
||||||
|
|
||||||
|
- Exact brand hexes (OpenAI `#10a37f` proposed; z.ai blue TBD — finalize on device).
|
||||||
|
- **Logos** — need official OpenAI + z.ai marks as RGB565 (user provides / points
|
||||||
|
to source, per the "don't hand-author brand art" preference). Claude logo already
|
||||||
|
in `logo.h`.
|
||||||
|
- Splash — currently Claude pixel-art (claudepix); per-provider splash is an open
|
||||||
|
choice (keep Claude, go neutral, or per-provider).
|
||||||
|
- Naming — keep **Clawdmeter**; multi-provider under the same identity.
|
||||||
@@ -42,6 +42,11 @@ build_flags =
|
|||||||
-DLV_USE_ANIMIMG=0
|
-DLV_USE_ANIMIMG=0
|
||||||
-DLV_TICK_CUSTOM=1
|
-DLV_TICK_CUSTOM=1
|
||||||
-DLV_USE_SNAPSHOT=1
|
-DLV_USE_SNAPSHOT=1
|
||||||
|
; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the
|
||||||
|
; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts
|
||||||
|
; are Latin-only and have no symbols. Shared ui.cpp needs these on every board.
|
||||||
|
-DLV_FONT_MONTSERRAT_20=1
|
||||||
|
-DLV_FONT_MONTSERRAT_28=1
|
||||||
|
|
||||||
lib_deps =
|
lib_deps =
|
||||||
moononournation/GFX Library for Arduino@^1.5.6
|
moononournation/GFX Library for Arduino@^1.5.6
|
||||||
@@ -99,6 +104,11 @@ build_flags =
|
|||||||
-DLV_USE_ANIMIMG=0
|
-DLV_USE_ANIMIMG=0
|
||||||
-DLV_TICK_CUSTOM=1
|
-DLV_TICK_CUSTOM=1
|
||||||
-DLV_USE_SNAPSHOT=1
|
-DLV_USE_SNAPSHOT=1
|
||||||
|
; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the
|
||||||
|
; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts
|
||||||
|
; are Latin-only and have no symbols. Shared ui.cpp needs these on every board.
|
||||||
|
-DLV_FONT_MONTSERRAT_20=1
|
||||||
|
-DLV_FONT_MONTSERRAT_28=1
|
||||||
|
|
||||||
lib_deps =
|
lib_deps =
|
||||||
; 1.6.4+ ships Arduino_SH8601 in mainline
|
; 1.6.4+ ships Arduino_SH8601 in mainline
|
||||||
@@ -157,6 +167,11 @@ build_flags =
|
|||||||
-DLV_USE_ANIMIMG=0
|
-DLV_USE_ANIMIMG=0
|
||||||
-DLV_TICK_CUSTOM=1
|
-DLV_TICK_CUSTOM=1
|
||||||
-DLV_USE_SNAPSHOT=1
|
-DLV_USE_SNAPSHOT=1
|
||||||
|
; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the
|
||||||
|
; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts
|
||||||
|
; are Latin-only and have no symbols.
|
||||||
|
-DLV_FONT_MONTSERRAT_20=1
|
||||||
|
-DLV_FONT_MONTSERRAT_28=1
|
||||||
|
|
||||||
lib_deps =
|
lib_deps =
|
||||||
; 1.6.4+ ships Arduino_CO5300 in mainline
|
; 1.6.4+ ships Arduino_CO5300 in mainline
|
||||||
@@ -225,6 +240,11 @@ build_flags =
|
|||||||
; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot
|
; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot
|
||||||
; prints SCREENSHOT_UNSUPPORTED on this board.
|
; prints SCREENSHOT_UNSUPPORTED on this board.
|
||||||
-DLV_USE_SNAPSHOT=0
|
-DLV_USE_SNAPSHOT=0
|
||||||
|
; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the
|
||||||
|
; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts
|
||||||
|
; are Latin-only and have no symbols. Shared ui.cpp needs these on every board.
|
||||||
|
-DLV_FONT_MONTSERRAT_20=1
|
||||||
|
-DLV_FONT_MONTSERRAT_28=1
|
||||||
|
|
||||||
lib_deps =
|
lib_deps =
|
||||||
; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300
|
; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#include "battery_est.h"
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
// Anchor-based, not a ring buffer (cf. usage_rate.cpp): we remember (time, pct)
|
||||||
|
// at the moment discharge began and project from the average rate since then.
|
||||||
|
// rate = (anchor_pct - pct_now) / minutes_elapsed
|
||||||
|
// minutes_left = pct_now / rate
|
||||||
|
// A long baseline beats a short ring for a signal that only ticks every few
|
||||||
|
// minutes — the estimate just keeps tightening as the session runs. We withhold
|
||||||
|
// a number until the cell has dropped a meaningful amount over a few minutes, so
|
||||||
|
// the first figure isn't built on fuel-gauge settling noise right after boot.
|
||||||
|
#define EST_MIN_DROP_PCT 3 // need >= 3% drained since the anchor
|
||||||
|
#define EST_MIN_ELAPSED_MS 300000UL // ...over at least 5 minutes
|
||||||
|
#define EST_MAX_MINUTES (100 * 60)
|
||||||
|
|
||||||
|
static bool have_anchor = false;
|
||||||
|
static uint32_t anchor_ms = 0;
|
||||||
|
static int anchor_pct = -1;
|
||||||
|
static int last_pct = -1;
|
||||||
|
static bool last_charging = false;
|
||||||
|
|
||||||
|
void battery_est_update(int percent, bool charging) {
|
||||||
|
last_charging = charging;
|
||||||
|
last_pct = percent;
|
||||||
|
|
||||||
|
if (charging || percent < 0) {
|
||||||
|
// On USB / charging / no reading the estimate is meaningless. Drop the
|
||||||
|
// anchor so the next discharge starts from a fresh baseline.
|
||||||
|
have_anchor = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Discharging. Start an anchor, or re-anchor if the gauge ticked UP (it
|
||||||
|
// relaxes upward when load drops) so we never compute a negative rate.
|
||||||
|
if (!have_anchor || percent > anchor_pct) {
|
||||||
|
have_anchor = true;
|
||||||
|
anchor_ms = millis();
|
||||||
|
anchor_pct = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int battery_est_minutes(void) {
|
||||||
|
if (last_charging || last_pct < 0) return -2;
|
||||||
|
if (!have_anchor) return -1;
|
||||||
|
|
||||||
|
int drop = anchor_pct - last_pct;
|
||||||
|
uint32_t dt = millis() - anchor_ms;
|
||||||
|
if (drop < EST_MIN_DROP_PCT || dt < EST_MIN_ELAPSED_MS) return -1;
|
||||||
|
|
||||||
|
// Minutes each 1% has been taking, extrapolated across the remaining pct.
|
||||||
|
float minutes_per_pct = (float)dt / 60000.0f / (float)drop;
|
||||||
|
float mins = (float)last_pct * minutes_per_pct;
|
||||||
|
if (mins < 0) return -1;
|
||||||
|
if (mins > EST_MAX_MINUTES) mins = EST_MAX_MINUTES;
|
||||||
|
return (int)(mins + 0.5f);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Rough "time left on battery" estimator. Watches the AXP fuel-gauge percentage
|
||||||
|
// while discharging and projects minutes-to-empty from the average drain rate
|
||||||
|
// since discharge began. Coarse by design — the gauge moves in slow 1% steps —
|
||||||
|
// so the UI shows it as "~Xh Ym left", emphasis on the ~.
|
||||||
|
//
|
||||||
|
// Feed it the latest percent + charging flag whenever a fresh battery reading
|
||||||
|
// lands (the daemon-independent battery poll in main.cpp).
|
||||||
|
void battery_est_update(int percent, bool charging);
|
||||||
|
|
||||||
|
// Estimated minutes remaining, or:
|
||||||
|
// -2 charging / on USB (no estimate to give)
|
||||||
|
// -1 not enough discharge history yet ("estimating…")
|
||||||
|
int battery_est_minutes(void);
|
||||||
+20
-2
@@ -10,6 +10,7 @@
|
|||||||
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
|
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
|
||||||
#define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies
|
#define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies
|
||||||
#define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request
|
#define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request
|
||||||
|
#define CMD_CHAR_UUID "4c41555a-4465-7669-6365-000000000005" // device → host command (Phase 6)
|
||||||
|
|
||||||
#define BLE_BUF_SIZE 512
|
#define BLE_BUF_SIZE 512
|
||||||
|
|
||||||
@@ -46,10 +47,10 @@ static const uint8_t HID_REPORT_MAP[] = {
|
|||||||
0x95, 0x06, // Report Count (6)
|
0x95, 0x06, // Report Count (6)
|
||||||
0x75, 0x08, // Report Size (8)
|
0x75, 0x08, // Report Size (8)
|
||||||
0x15, 0x00, // Logical Minimum (0)
|
0x15, 0x00, // Logical Minimum (0)
|
||||||
0x25, 0x65, // Logical Maximum (101)
|
0x25, 0x73, // Logical Maximum (115) - raised from 101 to cover F13..F24 (0x68..0x73)
|
||||||
0x05, 0x07, // Usage Page (Key Codes)
|
0x05, 0x07, // Usage Page (Key Codes)
|
||||||
0x19, 0x00, // Usage Minimum (0)
|
0x19, 0x00, // Usage Minimum (0)
|
||||||
0x29, 0x65, // Usage Maximum (101)
|
0x29, 0x73, // Usage Maximum (115) - the v2 soundpad sends F13..F18 (0x68..0x6D)
|
||||||
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
|
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
|
||||||
0xC0, // End Collection
|
0xC0, // End Collection
|
||||||
};
|
};
|
||||||
@@ -60,6 +61,7 @@ static NimBLECharacteristic* input_kbd = nullptr;
|
|||||||
static NimBLECharacteristic* tx_char = nullptr;
|
static NimBLECharacteristic* tx_char = nullptr;
|
||||||
static NimBLECharacteristic* rx_char = nullptr;
|
static NimBLECharacteristic* rx_char = nullptr;
|
||||||
static NimBLECharacteristic* req_char = nullptr;
|
static NimBLECharacteristic* req_char = nullptr;
|
||||||
|
static NimBLECharacteristic* cmd_char = nullptr;
|
||||||
|
|
||||||
static ble_state_t state = BLE_STATE_INIT;
|
static ble_state_t state = BLE_STATE_INIT;
|
||||||
static bool need_advertise = false;
|
static bool need_advertise = false;
|
||||||
@@ -201,6 +203,14 @@ void ble_init(void) {
|
|||||||
static ReqCallbacks reqCb;
|
static ReqCallbacks reqCb;
|
||||||
req_char->setCallbacks(&reqCb);
|
req_char->setCallbacks(&reqCb);
|
||||||
|
|
||||||
|
// Watch → host command channel (Phase 6). The daemon subscribes; the watch
|
||||||
|
// notifies a short JSON command which the daemon maps to a Home Assistant
|
||||||
|
// service call. Notify-only, same direction as the refresh char.
|
||||||
|
cmd_char = svc->createCharacteristic(
|
||||||
|
CMD_CHAR_UUID,
|
||||||
|
NIMBLE_PROPERTY::NOTIFY
|
||||||
|
);
|
||||||
|
|
||||||
svc->start();
|
svc->start();
|
||||||
server->start();
|
server->start();
|
||||||
start_advertising();
|
start_advertising();
|
||||||
@@ -272,6 +282,14 @@ void ble_request_refresh(void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ble_send_command(const char* json) {
|
||||||
|
if (state == BLE_STATE_CONNECTED && cmd_char) {
|
||||||
|
cmd_char->setValue(json);
|
||||||
|
cmd_char->notify();
|
||||||
|
Serial.printf("BLE: command sent %s\n", json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ble_keyboard_press(uint8_t key, uint8_t modifier) {
|
void ble_keyboard_press(uint8_t key, uint8_t modifier) {
|
||||||
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
|
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
|
||||||
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
|
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ void ble_send_ack(void);
|
|||||||
void ble_send_nack(void);
|
void ble_send_nack(void);
|
||||||
void ble_request_refresh(void);
|
void ble_request_refresh(void);
|
||||||
|
|
||||||
|
// Watch → PC command channel (Phase 6). Notifies a short JSON command (e.g.
|
||||||
|
// {"cmd":"toggle"}) to the host daemon on the CMD characteristic; the daemon
|
||||||
|
// turns it into a Home Assistant service call. No-op unless connected.
|
||||||
|
void ble_send_command(const char* json);
|
||||||
|
|
||||||
// BLE HID keyboard
|
// BLE HID keyboard
|
||||||
void ble_keyboard_press(uint8_t key, uint8_t modifier);
|
void ble_keyboard_press(uint8_t key, uint8_t modifier);
|
||||||
void ble_keyboard_release(void);
|
void ble_keyboard_release(void);
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
void imu_hal_init(void) {}
|
void imu_hal_init(void) {}
|
||||||
void imu_hal_tick(void) {}
|
void imu_hal_tick(void) {}
|
||||||
uint8_t imu_hal_rotation_quadrant(void) { return 0; }
|
uint8_t imu_hal_rotation_quadrant(void) { return 0; }
|
||||||
|
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ void power_hal_init(void) {}
|
|||||||
void power_hal_tick(void) {}
|
void power_hal_tick(void) {}
|
||||||
|
|
||||||
int power_hal_battery_pct(void) { return -1; }
|
int power_hal_battery_pct(void) { return -1; }
|
||||||
|
int power_hal_battery_mv(void) { return 0; } // 0 = no battery / unsupported
|
||||||
bool power_hal_is_charging(void) { return false; }
|
bool power_hal_is_charging(void) { return false; }
|
||||||
bool power_hal_is_vbus_in(void) { return false; }
|
bool power_hal_is_vbus_in(void) { return false; }
|
||||||
|
void power_hal_shutdown(void) {} // no controllable PMU
|
||||||
bool power_hal_pwr_pressed(void) { return false; }
|
bool power_hal_pwr_pressed(void) { return false; }
|
||||||
// Hold-to-pair gesture signals. Mirror the 216 (PMU PKEY long/positive IRQs)
|
// Hold-to-pair gesture signals. Mirror the 216 (PMU PKEY long/positive IRQs)
|
||||||
// or the 1.8" (software hold-timing off a polled GPIO) port. Stub = no gesture.
|
// or the 1.8" (software hold-timing off a polled GPIO) port. Stub = no gesture.
|
||||||
|
|||||||
@@ -23,3 +23,7 @@ void imu_hal_tick(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uint8_t imu_hal_rotation_quadrant(void) { return 0; }
|
uint8_t imu_hal_rotation_quadrant(void) { return 0; }
|
||||||
|
|
||||||
|
// Accelerometer is not configured on this board (only the bus is brought up),
|
||||||
|
// so there is no tilt data to report.
|
||||||
|
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
static XPowersPMU pmu;
|
static XPowersPMU pmu;
|
||||||
|
|
||||||
static int cached_pct = -1;
|
static int cached_pct = -1;
|
||||||
|
static int cached_mv = 0;
|
||||||
static bool cached_charging = false;
|
static bool cached_charging = false;
|
||||||
static bool cached_vbus = false;
|
static bool cached_vbus = false;
|
||||||
static bool pwr_pressed_flag = false;
|
static bool pwr_pressed_flag = false;
|
||||||
@@ -49,6 +50,7 @@ void power_hal_init(void) {
|
|||||||
cached_charging = pmu.isCharging();
|
cached_charging = pmu.isCharging();
|
||||||
cached_vbus = pmu.isVbusIn();
|
cached_vbus = pmu.isVbusIn();
|
||||||
cached_pct = pmu.getBatteryPercent();
|
cached_pct = pmu.getBatteryPercent();
|
||||||
|
cached_mv = pmu.getBattVoltage();
|
||||||
}
|
}
|
||||||
|
|
||||||
void power_hal_tick(void) {
|
void power_hal_tick(void) {
|
||||||
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
|
|||||||
if (now - last_battery_ms >= BATTERY_POLL_MS) {
|
if (now - last_battery_ms >= BATTERY_POLL_MS) {
|
||||||
last_battery_ms = now;
|
last_battery_ms = now;
|
||||||
cached_pct = pmu.getBatteryPercent();
|
cached_pct = pmu.getBatteryPercent();
|
||||||
|
cached_mv = pmu.getBattVoltage();
|
||||||
}
|
}
|
||||||
if (now - last_pwr_ms >= PWR_POLL_MS) {
|
if (now - last_pwr_ms >= PWR_POLL_MS) {
|
||||||
last_pwr_ms = now;
|
last_pwr_ms = now;
|
||||||
@@ -83,9 +86,12 @@ void power_hal_tick(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int power_hal_battery_pct(void) { return cached_pct; }
|
int power_hal_battery_pct(void) { return cached_pct; }
|
||||||
|
int power_hal_battery_mv(void) { return cached_mv; }
|
||||||
bool power_hal_is_charging(void) { return cached_charging; }
|
bool power_hal_is_charging(void) { return cached_charging; }
|
||||||
bool power_hal_is_vbus_in(void) { return cached_vbus; }
|
bool power_hal_is_vbus_in(void) { return cached_vbus; }
|
||||||
|
|
||||||
|
void power_hal_shutdown(void) { pmu.shutdown(); }
|
||||||
|
|
||||||
bool power_hal_pwr_pressed(void) {
|
bool power_hal_pwr_pressed(void) {
|
||||||
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
|
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,21 +5,34 @@
|
|||||||
#include <SensorQMI8658.hpp>
|
#include <SensorQMI8658.hpp>
|
||||||
|
|
||||||
// AMOLED-2.06 ships with QMI8658 populated, but the panel is non-square and
|
// AMOLED-2.06 ships with QMI8658 populated, but the panel is non-square and
|
||||||
// mounts in a fixed orientation, so rotation is disabled. We initialize the
|
// mounts in a fixed orientation, so screen rotation is disabled and the quadrant
|
||||||
// device anyway to keep the shared I2C bus healthy, but always report 0.
|
// is always 0. The accelerometer IS enabled, though: the tilt-dimmer screen
|
||||||
|
// reads raw g-vectors via imu_hal_read_accel() to derive wrist pitch/roll.
|
||||||
|
|
||||||
static SensorQMI8658 imu;
|
static SensorQMI8658 imu;
|
||||||
|
static bool imu_ok = false;
|
||||||
|
|
||||||
void imu_hal_init(void) {
|
void imu_hal_init(void) {
|
||||||
if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) {
|
if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) {
|
||||||
Serial.println("QMI8658 init failed");
|
Serial.println("QMI8658 init failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Serial.println("QMI8658 init OK (rotation disabled on this board)");
|
// 2g range is plenty for static tilt sensing (gravity is 1g); 62.5Hz keeps
|
||||||
|
// the dial responsive without flooding the I2C bus. LPF on (default) damps
|
||||||
|
// hand jitter so the brightness rate doesn't twitch.
|
||||||
|
imu.configAccelerometer(SensorQMI8658::ACC_RANGE_2G, SensorQMI8658::ACC_ODR_62_5Hz);
|
||||||
|
imu.enableAccelerometer();
|
||||||
|
imu_ok = true;
|
||||||
|
Serial.println("QMI8658 init OK (accel enabled, screen rotation disabled)");
|
||||||
}
|
}
|
||||||
|
|
||||||
void imu_hal_tick(void) {
|
void imu_hal_tick(void) {
|
||||||
// No-op — rotation is disabled.
|
// No-op — screen rotation is disabled; accel is polled on demand.
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t imu_hal_rotation_quadrant(void) { return 0; }
|
uint8_t imu_hal_rotation_quadrant(void) { return 0; }
|
||||||
|
|
||||||
|
bool imu_hal_read_accel(float* x, float* y, float* z) {
|
||||||
|
if (!imu_ok || !imu.getDataReady()) return false;
|
||||||
|
return imu.getAccelerometer(*x, *y, *z);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
static XPowersPMU pmu;
|
static XPowersPMU pmu;
|
||||||
|
|
||||||
static int cached_pct = -1;
|
static int cached_pct = -1;
|
||||||
|
static int cached_mv = 0;
|
||||||
static bool cached_charging = false;
|
static bool cached_charging = false;
|
||||||
static bool cached_vbus = false;
|
static bool cached_vbus = false;
|
||||||
static bool pwr_pressed_flag = false;
|
static bool pwr_pressed_flag = false;
|
||||||
@@ -66,6 +67,7 @@ void power_hal_init(void) {
|
|||||||
cached_charging = pmu.isCharging();
|
cached_charging = pmu.isCharging();
|
||||||
cached_vbus = pmu.isVbusIn();
|
cached_vbus = pmu.isVbusIn();
|
||||||
cached_pct = pmu.getBatteryPercent();
|
cached_pct = pmu.getBatteryPercent();
|
||||||
|
cached_mv = pmu.getBattVoltage();
|
||||||
}
|
}
|
||||||
|
|
||||||
void power_hal_tick(void) {
|
void power_hal_tick(void) {
|
||||||
@@ -79,6 +81,7 @@ void power_hal_tick(void) {
|
|||||||
if (now - last_battery_ms >= BATTERY_POLL_MS) {
|
if (now - last_battery_ms >= BATTERY_POLL_MS) {
|
||||||
last_battery_ms = now;
|
last_battery_ms = now;
|
||||||
cached_pct = pmu.getBatteryPercent();
|
cached_pct = pmu.getBatteryPercent();
|
||||||
|
cached_mv = pmu.getBattVoltage();
|
||||||
}
|
}
|
||||||
if (now - last_pwr_ms >= PWR_POLL_MS) {
|
if (now - last_pwr_ms >= PWR_POLL_MS) {
|
||||||
last_pwr_ms = now;
|
last_pwr_ms = now;
|
||||||
@@ -100,9 +103,12 @@ void power_hal_tick(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int power_hal_battery_pct(void) { return cached_pct; }
|
int power_hal_battery_pct(void) { return cached_pct; }
|
||||||
|
int power_hal_battery_mv(void) { return cached_mv; }
|
||||||
bool power_hal_is_charging(void) { return cached_charging; }
|
bool power_hal_is_charging(void) { return cached_charging; }
|
||||||
bool power_hal_is_vbus_in(void) { return cached_vbus; }
|
bool power_hal_is_vbus_in(void) { return cached_vbus; }
|
||||||
|
|
||||||
|
void power_hal_shutdown(void) { pmu.shutdown(); }
|
||||||
|
|
||||||
bool power_hal_pwr_pressed(void) {
|
bool power_hal_pwr_pressed(void) {
|
||||||
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
|
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
// reg 0x03 / 0x04: X1 high (low nibble) + X1 low
|
// reg 0x03 / 0x04: X1 high (low nibble) + X1 low
|
||||||
// reg 0x05 / 0x06: Y1 high (low nibble) + Y1 low
|
// reg 0x05 / 0x06: Y1 high (low nibble) + Y1 low
|
||||||
//
|
//
|
||||||
// Axis orientation: started with no swap/mirror (matches the portrait FT3168
|
// Axis orientation: verified on hardware (v2 Phase 1). The FT3168 reports
|
||||||
// on the 1.8 board). If touch coordinates come out flipped/swapped on this
|
// directly in the panel's pixel space (~0..409 x ~0..501), correctly oriented
|
||||||
// panel, mirror/swap them in touch_read_into_shared_state() below.
|
// (x grows left->right, y grows top->bottom, no axis swap). A center tap reads
|
||||||
|
// ~(209,228) vs the 410x502 panel center (205,251) — a 1:1 mapping within
|
||||||
|
// finger error. Raw values pass straight through; no mirror/swap/scale needed.
|
||||||
|
|
||||||
static volatile bool touch_data_ready = false;
|
static volatile bool touch_data_ready = false;
|
||||||
static volatile bool touch_pressed = false;
|
static volatile bool touch_pressed = false;
|
||||||
|
|||||||
@@ -64,3 +64,8 @@ void imu_hal_tick(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; }
|
uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; }
|
||||||
|
|
||||||
|
bool imu_hal_read_accel(float* x, float* y, float* z) {
|
||||||
|
if (!imu_ok || !imu.getDataReady()) return false;
|
||||||
|
return imu.getAccelerometer(*x, *y, *z);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
static XPowersPMU pmu;
|
static XPowersPMU pmu;
|
||||||
|
|
||||||
static int cached_pct = -1;
|
static int cached_pct = -1;
|
||||||
|
static int cached_mv = 0;
|
||||||
static bool cached_charging = false;
|
static bool cached_charging = false;
|
||||||
static bool cached_vbus = false;
|
static bool cached_vbus = false;
|
||||||
static bool pwr_pressed_flag = false;
|
static bool pwr_pressed_flag = false;
|
||||||
@@ -49,6 +50,7 @@ void power_hal_init(void) {
|
|||||||
cached_charging = pmu.isCharging();
|
cached_charging = pmu.isCharging();
|
||||||
cached_vbus = pmu.isVbusIn();
|
cached_vbus = pmu.isVbusIn();
|
||||||
cached_pct = pmu.getBatteryPercent();
|
cached_pct = pmu.getBatteryPercent();
|
||||||
|
cached_mv = pmu.getBattVoltage();
|
||||||
}
|
}
|
||||||
|
|
||||||
void power_hal_tick(void) {
|
void power_hal_tick(void) {
|
||||||
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
|
|||||||
if (now - last_battery_ms >= BATTERY_POLL_MS) {
|
if (now - last_battery_ms >= BATTERY_POLL_MS) {
|
||||||
last_battery_ms = now;
|
last_battery_ms = now;
|
||||||
cached_pct = pmu.getBatteryPercent();
|
cached_pct = pmu.getBatteryPercent();
|
||||||
|
cached_mv = pmu.getBattVoltage();
|
||||||
}
|
}
|
||||||
if (now - last_pwr_ms >= PWR_POLL_MS) {
|
if (now - last_pwr_ms >= PWR_POLL_MS) {
|
||||||
last_pwr_ms = now;
|
last_pwr_ms = now;
|
||||||
@@ -74,9 +77,12 @@ void power_hal_tick(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int power_hal_battery_pct(void) { return cached_pct; }
|
int power_hal_battery_pct(void) { return cached_pct; }
|
||||||
|
int power_hal_battery_mv(void) { return cached_mv; }
|
||||||
bool power_hal_is_charging(void) { return cached_charging; }
|
bool power_hal_is_charging(void) { return cached_charging; }
|
||||||
bool power_hal_is_vbus_in(void) { return cached_vbus; }
|
bool power_hal_is_vbus_in(void) { return cached_vbus; }
|
||||||
|
|
||||||
|
void power_hal_shutdown(void) { pmu.shutdown(); }
|
||||||
|
|
||||||
bool power_hal_pwr_pressed(void) {
|
bool power_hal_pwr_pressed(void) {
|
||||||
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
|
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -24,3 +24,7 @@ void imu_hal_tick(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uint8_t imu_hal_rotation_quadrant(void) { return 0; }
|
uint8_t imu_hal_rotation_quadrant(void) { return 0; }
|
||||||
|
|
||||||
|
// Accelerometer is not configured on this board (only the bus is brought up),
|
||||||
|
// so there is no tilt data to report.
|
||||||
|
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ extern XPowersPMU board_pmu;
|
|||||||
#define pmu board_pmu
|
#define pmu board_pmu
|
||||||
|
|
||||||
static int cached_pct = -1;
|
static int cached_pct = -1;
|
||||||
|
static int cached_mv = 0;
|
||||||
static bool cached_charging = false;
|
static bool cached_charging = false;
|
||||||
static bool cached_vbus = false;
|
static bool cached_vbus = false;
|
||||||
static bool pwr_pressed_flag = false;
|
static bool pwr_pressed_flag = false;
|
||||||
@@ -50,6 +51,7 @@ void power_hal_init(void) {
|
|||||||
cached_charging = pmu.isCharging();
|
cached_charging = pmu.isCharging();
|
||||||
cached_vbus = pmu.isVbusIn();
|
cached_vbus = pmu.isVbusIn();
|
||||||
cached_pct = pmu.getBatteryPercent();
|
cached_pct = pmu.getBatteryPercent();
|
||||||
|
cached_mv = pmu.getBattVoltage();
|
||||||
}
|
}
|
||||||
|
|
||||||
void power_hal_tick(void) {
|
void power_hal_tick(void) {
|
||||||
@@ -63,6 +65,7 @@ void power_hal_tick(void) {
|
|||||||
if (now - last_battery_ms >= BATTERY_POLL_MS) {
|
if (now - last_battery_ms >= BATTERY_POLL_MS) {
|
||||||
last_battery_ms = now;
|
last_battery_ms = now;
|
||||||
cached_pct = pmu.getBatteryPercent();
|
cached_pct = pmu.getBatteryPercent();
|
||||||
|
cached_mv = pmu.getBattVoltage();
|
||||||
}
|
}
|
||||||
if (now - last_pwr_ms >= PWR_POLL_MS) {
|
if (now - last_pwr_ms >= PWR_POLL_MS) {
|
||||||
last_pwr_ms = now;
|
last_pwr_ms = now;
|
||||||
@@ -75,9 +78,12 @@ void power_hal_tick(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int power_hal_battery_pct(void) { return cached_pct; }
|
int power_hal_battery_pct(void) { return cached_pct; }
|
||||||
|
int power_hal_battery_mv(void) { return cached_mv; }
|
||||||
bool power_hal_is_charging(void) { return cached_charging; }
|
bool power_hal_is_charging(void) { return cached_charging; }
|
||||||
bool power_hal_is_vbus_in(void) { return cached_vbus; }
|
bool power_hal_is_vbus_in(void) { return cached_vbus; }
|
||||||
|
|
||||||
|
void power_hal_shutdown(void) { pmu.shutdown(); }
|
||||||
|
|
||||||
bool power_hal_pwr_pressed(void) {
|
bool power_hal_pwr_pressed(void) {
|
||||||
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
|
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -9,4 +9,16 @@ struct UsageData {
|
|||||||
char status[16]; // "allowed" or "limited"
|
char status[16]; // "allowed" or "limited"
|
||||||
bool ok; // data parse succeeded
|
bool ok; // data parse succeeded
|
||||||
bool valid; // false until first successful parse
|
bool valid; // false until first successful parse
|
||||||
|
|
||||||
|
// Today's Claude Code usage (Phase 4 — computed by the daemon from local
|
||||||
|
// transcripts). Tokens can exceed 2^31 on a heavy day, so use 64-bit.
|
||||||
|
long long tokens_today; // total tokens today (input+output+cache)
|
||||||
|
long long output_today; // output tokens today (what Claude generated)
|
||||||
|
int cost_cents_today; // equivalent API cost today, US cents
|
||||||
|
int messages_today; // assistant requests (API turns) today
|
||||||
|
|
||||||
|
// Now Playing (Phase 5 — daemon relays the Windows media session).
|
||||||
|
int np_state; // 0 = nothing playing, 1 = playing, 2 = paused
|
||||||
|
char np_title[64]; // current track title (UTF-8, may be empty)
|
||||||
|
char np_artist[64]; // current track artist (UTF-8, may be empty)
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,3 +9,9 @@
|
|||||||
void imu_hal_init(void);
|
void imu_hal_init(void);
|
||||||
void imu_hal_tick(void);
|
void imu_hal_tick(void);
|
||||||
uint8_t imu_hal_rotation_quadrant(void);
|
uint8_t imu_hal_rotation_quadrant(void);
|
||||||
|
|
||||||
|
// Raw accelerometer read in g-units (gravity = 1.0). Used by the tilt-dimmer
|
||||||
|
// screen to derive wrist pitch/roll. Returns true and fills x/y/z when a fresh
|
||||||
|
// sample is available; returns false (leaving the outputs untouched) on boards
|
||||||
|
// without an accelerometer or when no new data is ready yet.
|
||||||
|
bool imu_hal_read_accel(float* x, float* y, float* z);
|
||||||
|
|||||||
@@ -12,9 +12,14 @@ void power_hal_init(void);
|
|||||||
void power_hal_tick(void);
|
void power_hal_tick(void);
|
||||||
|
|
||||||
int power_hal_battery_pct(void); // 0..100, or -1 if no battery (see BoardCaps.has_battery)
|
int power_hal_battery_pct(void); // 0..100, or -1 if no battery (see BoardCaps.has_battery)
|
||||||
|
int power_hal_battery_mv(void); // battery voltage in mV, or 0 if no battery / unsupported
|
||||||
bool power_hal_is_charging(void);
|
bool power_hal_is_charging(void);
|
||||||
bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery)
|
bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery)
|
||||||
|
|
||||||
|
// Power the device fully off (PMU shutdown). Used by the low-voltage protective
|
||||||
|
// cutoff in main.cpp. No-op on boards without a controllable PMU.
|
||||||
|
void power_hal_shutdown(void);
|
||||||
|
|
||||||
// Edge-triggered: returns true once per PWR short-press, then clears.
|
// Edge-triggered: returns true once per PWR short-press, then clears.
|
||||||
bool power_hal_pwr_pressed(void);
|
bool power_hal_pwr_pressed(void);
|
||||||
|
|
||||||
|
|||||||
+146
-8
@@ -7,8 +7,10 @@
|
|||||||
#include "data.h"
|
#include "data.h"
|
||||||
#include "ui.h"
|
#include "ui.h"
|
||||||
#include "ble.h"
|
#include "ble.h"
|
||||||
|
#include "version.h"
|
||||||
#include "splash.h"
|
#include "splash.h"
|
||||||
#include "usage_rate.h"
|
#include "usage_rate.h"
|
||||||
|
#include "battery_est.h"
|
||||||
#include "idle.h"
|
#include "idle.h"
|
||||||
#include "idle_cfg.h"
|
#include "idle_cfg.h"
|
||||||
#include "brightness.h"
|
#include "brightness.h"
|
||||||
@@ -22,6 +24,21 @@
|
|||||||
|
|
||||||
static UsageData usage = {};
|
static UsageData usage = {};
|
||||||
|
|
||||||
|
// ---- Low-voltage protective cutoff ----
|
||||||
|
// Below LOW_V_CUTOFF_MV on battery power (USB absent), the firmware warns on
|
||||||
|
// screen for LOW_V_WARN_MS then powers the PMU fully off, so the cell isn't
|
||||||
|
// driven into deep over-discharge. The reading must stay below for
|
||||||
|
// LOW_V_SUSTAIN_MS first: the cell voltage sags under the BLE + AMOLED load, so
|
||||||
|
// a momentary dip must not trigger a shutdown.
|
||||||
|
//
|
||||||
|
// 3000 mV (3.0 V) is a gentle Li-ion floor — well clear of the deep
|
||||||
|
// over-discharge zone (~2.5–2.8 V). Mirrored as the "Auto-off below 3.0 V"
|
||||||
|
// note on the battery screen (ui.cpp). Raise toward 3300 mV to be even kinder
|
||||||
|
// to the cell, or down to 2800 to squeeze out the last drops.
|
||||||
|
#define LOW_V_CUTOFF_MV 3000
|
||||||
|
#define LOW_V_SUSTAIN_MS 6000UL
|
||||||
|
#define LOW_V_WARN_MS 5000UL
|
||||||
|
|
||||||
// ---- LVGL draw buffers (partial render mode) ----
|
// ---- LVGL draw buffers (partial render mode) ----
|
||||||
// PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free
|
// PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free
|
||||||
// boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip
|
// boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip
|
||||||
@@ -111,7 +128,58 @@ static bool parse_json(const char* json, UsageData* out) {
|
|||||||
out->weekly_reset_mins = doc["wr"] | -1;
|
out->weekly_reset_mins = doc["wr"] | -1;
|
||||||
strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status));
|
strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status));
|
||||||
out->ok = doc["ok"] | false;
|
out->ok = doc["ok"] | false;
|
||||||
|
out->tokens_today = doc["tk"] | (long long)0;
|
||||||
|
out->output_today = doc["to"] | (long long)0;
|
||||||
|
out->cost_cents_today = doc["tc"] | 0;
|
||||||
|
out->messages_today = doc["tn"] | 0;
|
||||||
|
out->np_state = doc["np"] | 0;
|
||||||
|
strlcpy(out->np_title, doc["nt"] | "", sizeof(out->np_title));
|
||||||
|
strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist));
|
||||||
out->valid = true;
|
out->valid = true;
|
||||||
|
|
||||||
|
// Dynamic Home buttons (Phase 7 M2) — optional "btns" array of labels pushed
|
||||||
|
// by the daemon from the desktop config (only in the ~60s heartbeat write).
|
||||||
|
// Absent (e.g. the frequent now-playing-only writes) => leave the current
|
||||||
|
// buttons untouched; present => replace the whole set. The label strings live
|
||||||
|
// in `doc`, which is valid until this function returns, and ui_set_buttons
|
||||||
|
// copies them immediately.
|
||||||
|
JsonArray btns = doc["btns"].as<JsonArray>();
|
||||||
|
if (!btns.isNull()) {
|
||||||
|
const char* labels[UI_MAX_BUTTONS];
|
||||||
|
int n = 0;
|
||||||
|
for (JsonVariant v : btns) {
|
||||||
|
if (n >= UI_MAX_BUTTONS) break;
|
||||||
|
labels[n++] = v.as<const char*>();
|
||||||
|
}
|
||||||
|
ui_set_buttons(labels, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tilt-dimmer snapshot (Phase 6 step 3 / M3) — optional "dim" object carrying
|
||||||
|
// the controlled light's live state so the dial seeds from reality. Pushed on
|
||||||
|
// the ~60s heartbeat and on demand when the watch opens the Dimmer screen.
|
||||||
|
JsonObject dim = doc["dim"].as<JsonObject>();
|
||||||
|
if (!dim.isNull()) {
|
||||||
|
bool on = dim["on"] | 0;
|
||||||
|
int bri = dim["bri"] | -1; // -1 = unknown (light off)
|
||||||
|
int ct = dim["ct"] | -1;
|
||||||
|
int mink = dim["mink"] | 2000;
|
||||||
|
int maxk = dim["maxk"] | 6500;
|
||||||
|
ui_dimmer_set_snapshot(on, bri, ct, mink, maxk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3: provider theme — "pv" (id) selects the logo, "ac" (0xRRGGBB) the brand
|
||||||
|
// accent. Stamped on every usage payload; ui_set_theme is change-guarded so
|
||||||
|
// it only repaints on an actual provider switch.
|
||||||
|
const char* pv = doc["pv"] | (const char*)nullptr;
|
||||||
|
uint32_t ac = doc["ac"] | 0u;
|
||||||
|
if (pv != nullptr || ac != 0u) ui_set_theme(pv, ac);
|
||||||
|
|
||||||
|
// v3 Provider screen badge — optional "pnm" (name) + "pi"/"pc" (1-based
|
||||||
|
// position / count among enabled providers). Present on the ~60s heartbeat.
|
||||||
|
const char* pnm = doc["pnm"] | (const char*)nullptr;
|
||||||
|
int pi = doc["pi"] | 0;
|
||||||
|
int pc = doc["pc"] | 0;
|
||||||
|
if (pnm != nullptr || pc != 0) ui_set_provider_badge(pnm, pi, pc);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +248,7 @@ extern "C" void board_init(void);
|
|||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
delay(300);
|
delay(300);
|
||||||
Serial.println("{\"ready\":true}");
|
Serial.printf("{\"ready\":true,\"fw\":\"%s\"}\n", CLAWDMETER_VERSION);
|
||||||
|
|
||||||
board_init();
|
board_init();
|
||||||
|
|
||||||
@@ -219,7 +287,7 @@ void setup() {
|
|||||||
|
|
||||||
ui_init();
|
ui_init();
|
||||||
ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address());
|
ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address());
|
||||||
ui_update_battery(power_hal_battery_pct(), power_hal_is_charging());
|
ui_update_battery(power_hal_battery_pct(), power_hal_battery_mv(), -1, power_hal_is_charging());
|
||||||
ui_show_screen(SCREEN_SPLASH);
|
ui_show_screen(SCREEN_SPLASH);
|
||||||
|
|
||||||
Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n",
|
Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n",
|
||||||
@@ -299,13 +367,21 @@ void loop() {
|
|||||||
{
|
{
|
||||||
static bool primary_was = false;
|
static bool primary_was = false;
|
||||||
static bool primary_wake_swallowed = false;
|
static bool primary_wake_swallowed = false;
|
||||||
|
static bool primary_was_dimmer = false; // press consumed by the dimmer (no HID)
|
||||||
bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY);
|
bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY);
|
||||||
if (primary_now != primary_was) {
|
if (primary_now != primary_was) {
|
||||||
if (primary_now) {
|
if (primary_now) {
|
||||||
if (idle_consume_wake_press()) primary_wake_swallowed = true;
|
if (idle_consume_wake_press()) primary_wake_swallowed = true;
|
||||||
else ble_keyboard_press(0x2C, 0); // HID Space, no mods
|
else if (ui_get_current_screen() == SCREEN_DIMMER) {
|
||||||
|
ui_dimmer_arm(); // BOOT arms the tilt-dimmer (no HID here)
|
||||||
|
primary_was_dimmer = true;
|
||||||
|
} else {
|
||||||
|
ble_keyboard_press(0x2C, 0); // HID Space, no mods
|
||||||
|
primary_was_dimmer = false;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (primary_wake_swallowed) primary_wake_swallowed = false;
|
if (primary_wake_swallowed) primary_wake_swallowed = false;
|
||||||
|
else if (primary_was_dimmer) primary_was_dimmer = false;
|
||||||
else ble_keyboard_release();
|
else ble_keyboard_release();
|
||||||
}
|
}
|
||||||
primary_was = primary_now;
|
primary_was = primary_now;
|
||||||
@@ -329,9 +405,12 @@ void loop() {
|
|||||||
|
|
||||||
if (power_hal_pwr_pressed()) {
|
if (power_hal_pwr_pressed()) {
|
||||||
if (!idle_consume_wake_press()) {
|
if (!idle_consume_wake_press()) {
|
||||||
// On splash: cycle animations. On the usage view: cycle
|
// On splash: cycle animations. On the dimmer: switch the
|
||||||
// screen brightness (single non-splash view, no more screens).
|
// controlled parameter (brightness ⇄ temp). Elsewhere: cycle
|
||||||
if (ui_get_current_screen() == SCREEN_SPLASH) splash_next();
|
// screen brightness.
|
||||||
|
screen_t cs = ui_get_current_screen();
|
||||||
|
if (cs == SCREEN_SPLASH) splash_next();
|
||||||
|
else if (cs == SCREEN_DIMMER) ui_dimmer_switch_param();
|
||||||
else brightness_cycle();
|
else brightness_cycle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,20 +418,79 @@ void loop() {
|
|||||||
pair_tick();
|
pair_tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tilt-dimmer control loop (no-op unless the Dimmer screen is armed). Keep
|
||||||
|
// the panel awake while adjusting so the idle timer can't sleep mid-tilt.
|
||||||
|
ui_dimmer_tick();
|
||||||
|
if (ui_dimmer_is_armed()) idle_note_activity();
|
||||||
|
|
||||||
ble_state_t bs = ble_get_state();
|
ble_state_t bs = ble_get_state();
|
||||||
if (bs != last_ble_state) {
|
if (bs != last_ble_state) {
|
||||||
last_ble_state = bs;
|
last_ble_state = bs;
|
||||||
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
|
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Battery telemetry → UI + time-left estimate ----
|
||||||
static int last_pct = -2;
|
static int last_pct = -2;
|
||||||
|
static int last_mv_bucket = -2;
|
||||||
static bool last_charging = false;
|
static bool last_charging = false;
|
||||||
|
static uint32_t last_bat_ui_ms = 0;
|
||||||
int pct = power_hal_battery_pct();
|
int pct = power_hal_battery_pct();
|
||||||
|
int mv = power_hal_battery_mv();
|
||||||
bool charging = power_hal_is_charging();
|
bool charging = power_hal_is_charging();
|
||||||
if (pct != last_pct || charging != last_charging) {
|
int mv_bucket = (mv <= 0) ? -1 : (mv / 20); // 20 mV buckets — ignore sub-bucket jitter
|
||||||
|
uint32_t now_ms = millis();
|
||||||
|
bool bat_changed = (pct != last_pct) || (charging != last_charging) || (mv_bucket != last_mv_bucket);
|
||||||
|
bool bat_periodic = (now_ms - last_bat_ui_ms >= 2000); // keep "time left" counting down live
|
||||||
|
if (bat_changed || bat_periodic) {
|
||||||
last_pct = pct;
|
last_pct = pct;
|
||||||
|
last_mv_bucket = mv_bucket;
|
||||||
last_charging = charging;
|
last_charging = charging;
|
||||||
ui_update_battery(pct, charging);
|
last_bat_ui_ms = now_ms;
|
||||||
|
battery_est_update(pct, charging);
|
||||||
|
ui_update_battery(pct, mv, battery_est_minutes(), charging);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push battery state to the host (~60s, plus on charge-state flip) so the PC
|
||||||
|
// app can warn on low charge. Reuses the watch→PC command char (…0005), so no
|
||||||
|
// new GATT characteristic / re-pair. ble_send_command no-ops if disconnected.
|
||||||
|
{
|
||||||
|
static uint32_t last_bat_tx_ms = 0;
|
||||||
|
static bool last_tx_charging = false;
|
||||||
|
if (mv > 0 && (now_ms - last_bat_tx_ms >= 60000 || charging != last_tx_charging)) {
|
||||||
|
last_bat_tx_ms = now_ms;
|
||||||
|
last_tx_charging = charging;
|
||||||
|
char bm[64];
|
||||||
|
snprintf(bm, sizeof bm, "{\"bat\":%d,\"mv\":%d,\"chg\":%d}",
|
||||||
|
pct, mv, charging ? 1 : 0);
|
||||||
|
ble_send_command(bm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Low-voltage protective cutoff ----
|
||||||
|
// Sustained below the floor on battery power → warn, then power fully off.
|
||||||
|
{
|
||||||
|
static uint32_t low_v_since = 0;
|
||||||
|
bool batt_present = (mv > 0); // 0 == no battery / not measurable
|
||||||
|
if (batt_present && !power_hal_is_vbus_in() && mv < LOW_V_CUTOFF_MV) {
|
||||||
|
if (low_v_since == 0) low_v_since = now_ms;
|
||||||
|
if (now_ms - low_v_since >= LOW_V_SUSTAIN_MS) {
|
||||||
|
Serial.printf("Low battery %d mV < %d mV - protective shutdown\n", mv, LOW_V_CUTOFF_MV);
|
||||||
|
idle_note_activity(); // wake the panel so the warning is visible
|
||||||
|
ui_show_low_battery();
|
||||||
|
uint32_t t0 = millis();
|
||||||
|
while (millis() - t0 < LOW_V_WARN_MS) { // pump LVGL so the warning paints + fades in
|
||||||
|
idle_tick(); // drive the wake-from-sleep brightness ramp
|
||||||
|
lv_timer_handler();
|
||||||
|
if (!idle_is_asleep()) display_hal_tick();
|
||||||
|
delay(20);
|
||||||
|
}
|
||||||
|
power_hal_shutdown();
|
||||||
|
delay(3000); // let the rail collapse
|
||||||
|
low_v_since = 0; // bench-supply fallback: don't spin if still powered
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
low_v_since = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
check_serial_cmd();
|
check_serial_cmd();
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
#define THEME_PANEL lv_color_hex(0x1f1f1e) // card/zone fill
|
#define THEME_PANEL lv_color_hex(0x1f1f1e) // card/zone fill
|
||||||
#define THEME_TEXT lv_color_hex(0xfaf9f5) // primary text
|
#define THEME_TEXT lv_color_hex(0xfaf9f5) // primary text
|
||||||
#define THEME_DIM lv_color_hex(0xb0aea5) // secondary text
|
#define THEME_DIM lv_color_hex(0xb0aea5) // secondary text
|
||||||
#define THEME_ACCENT lv_color_hex(0xd97757) // brand terra-cotta
|
#define THEME_ACCENT_HEX 0xd97757 // brand terra-cotta (raw, for runtime palette)
|
||||||
|
#define THEME_ACCENT lv_color_hex(THEME_ACCENT_HEX) // brand terra-cotta
|
||||||
#define THEME_GREEN lv_color_hex(0x788c5d)
|
#define THEME_GREEN lv_color_hex(0x788c5d)
|
||||||
#define THEME_AMBER lv_color_hex(0xd97757)
|
#define THEME_AMBER lv_color_hex(0xd97757)
|
||||||
#define THEME_RED lv_color_hex(0xc0392b)
|
#define THEME_RED lv_color_hex(0xc0392b)
|
||||||
|
|||||||
+1238
-17
File diff suppressed because it is too large
Load Diff
+55
-3
@@ -3,16 +3,68 @@
|
|||||||
#include "ble.h"
|
#include "ble.h"
|
||||||
|
|
||||||
enum screen_t {
|
enum screen_t {
|
||||||
SCREEN_SPLASH,
|
SCREEN_SPLASH, // brand animation (boot) + "Animations" app
|
||||||
SCREEN_USAGE,
|
SCREEN_USAGE, // home: Claude usage
|
||||||
|
SCREEN_MENU, // app launcher (opened by tapping the Claude logo)
|
||||||
|
SCREEN_SOUNDPAD, // 6-key HID soundpad (Phase 3)
|
||||||
|
SCREEN_SESSION, // today's tokens / cost (Phase 4)
|
||||||
|
SCREEN_NOWPLAYING, // media now-playing (Phase 5)
|
||||||
|
SCREEN_HOMEASSIST, // Home Assistant controls (Phase 6)
|
||||||
|
SCREEN_DIMMER, // tilt-to-dim light control (Phase 6 step 3 / M3)
|
||||||
|
SCREEN_BLUETOOTH, // BLE connection info
|
||||||
|
SCREEN_PROVIDER, // which usage source the watch displays (v3) + Switch
|
||||||
|
SCREEN_BATTERY, // battery detail (voltage + time left) — opened by tapping the battery icon
|
||||||
SCREEN_COUNT,
|
SCREEN_COUNT,
|
||||||
};
|
};
|
||||||
|
|
||||||
void ui_init(void);
|
void ui_init(void);
|
||||||
|
|
||||||
|
// v3 multi-provider theming. The daemon tags each usage payload with the active
|
||||||
|
// provider id (`pv`, e.g. "anthropic"/"openai"/"zai") and its brand accent
|
||||||
|
// (`ac`, 0xRRGGBB). ui_set_theme recolors the UI live via a shared accent style
|
||||||
|
// and swaps the per-provider logo. accent_rgb == 0 keeps the current accent.
|
||||||
|
// Change-guarded, so it's safe to call on every payload.
|
||||||
|
void ui_set_theme(const char* provider_id, uint32_t accent_rgb);
|
||||||
|
|
||||||
|
// v3 Provider screen badge: the active provider's human name (`pnm`) and its
|
||||||
|
// 1-based position/count among the enabled providers (`pi`/`pc`), so the screen
|
||||||
|
// shows "Claude Code · 1 / 3" and hides the Switch button when only one exists.
|
||||||
|
// Safe to call on every payload. name==nullptr keeps the current name.
|
||||||
|
void ui_set_provider_badge(const char* name, int index, int count);
|
||||||
|
|
||||||
void ui_update(const UsageData* data);
|
void ui_update(const UsageData* data);
|
||||||
void ui_tick_anim(void);
|
void ui_tick_anim(void);
|
||||||
void ui_show_screen(screen_t screen);
|
void ui_show_screen(screen_t screen);
|
||||||
void ui_toggle_splash(void);
|
void ui_toggle_splash(void);
|
||||||
screen_t ui_get_current_screen(void);
|
screen_t ui_get_current_screen(void);
|
||||||
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac);
|
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac);
|
||||||
void ui_update_battery(int percent, bool charging);
|
|
||||||
|
// minutes_left: >=0 estimated minutes remaining, -1 estimating, -2 charging.
|
||||||
|
void ui_update_battery(int percent, int voltage_mv, int minutes_left, bool charging);
|
||||||
|
|
||||||
|
// Full-screen "battery critically low" overlay shown by the protective cutoff in
|
||||||
|
// main.cpp just before power_hal_shutdown(). Drawn on the top layer so it covers
|
||||||
|
// whatever screen was active.
|
||||||
|
void ui_show_low_battery(void);
|
||||||
|
|
||||||
|
// Dynamic Home-control buttons (Phase 7 M2). The desktop config defines each
|
||||||
|
// button's action/entity; the watch stays dumb — it only renders the labels and
|
||||||
|
// reports the pressed index (the daemon maps index → action). Labels arrive in
|
||||||
|
// the RX payload's optional "btns" array. Cap kept small for the 2-column grid.
|
||||||
|
#define UI_MAX_BUTTONS 6
|
||||||
|
void ui_set_buttons(const char* const* labels, int count);
|
||||||
|
|
||||||
|
// Tilt-dimmer (Phase 6 step 3 / M3). On the Dimmer screen the watch reads its
|
||||||
|
// IMU and ramps the brightness OR color temperature of the configured light
|
||||||
|
// while "armed", sending absolute {"cmd":"bri"|"ct","v":..} to the daemon
|
||||||
|
// (which maps it to the first HA entity). The wrist acts as a joystick: tilt
|
||||||
|
// past the deadzone to change the value at a rate proportional to the tilt,
|
||||||
|
// hold neutral to stop. BOOT arms and captures the neutral pose; PWR switches
|
||||||
|
// the controlled parameter; a tap (or a few seconds of stillness) commits and
|
||||||
|
// locks. The daemon pushes the light's current state as an RX "dim" object so
|
||||||
|
// the dial starts from reality.
|
||||||
|
void ui_dimmer_set_snapshot(bool on, int bri_pct, int ct_kelvin, int min_k, int max_k);
|
||||||
|
void ui_dimmer_arm(void); // BOOT press on the Dimmer screen
|
||||||
|
void ui_dimmer_switch_param(void); // PWR press on the Dimmer screen
|
||||||
|
void ui_dimmer_tick(void); // called every loop; no-op unless armed
|
||||||
|
bool ui_dimmer_is_armed(void); // main.cpp keeps the panel awake while true
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Clawdmeter firmware version.
|
||||||
|
//
|
||||||
|
// The "classic" line — the stable Waveshare AMOLED-2.06 port plus the working
|
||||||
|
// Windows BLE daemon — is frozen on the `main` branch and tagged v1.0-classic.
|
||||||
|
// Active development (multi-app launcher, on-screen soundpad, Now Playing,
|
||||||
|
// Home Assistant control, richer desktop companion) lives on `v2-dev`.
|
||||||
|
// See docs/v2-roadmap.md.
|
||||||
|
#define CLAWDMETER_VERSION "2.0.0-dev"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Patch lv_font_conv output for LVGL 9.
|
||||||
|
|
||||||
|
`lv_font_conv` emits font .c files wrapped in `#if LVGL_VERSION_MAJOR` guards and
|
||||||
|
(for v8) a `.cache` field. On LVGL 9 the project wants the plain, unguarded struct
|
||||||
|
with `.release_glyph` / `.kerning` / `.static_bitmap` present (see the "LVGL 9 font
|
||||||
|
patching" note in README.md). Without this the font compiles but renders invisible.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/patch_lvgl9_font.py firmware/src/font_foo.c [more.c ...]
|
||||||
|
|
||||||
|
Idempotent: running it on an already-patched file is a no-op.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SUBS = [
|
||||||
|
# drop the v8-only glyph cache declaration
|
||||||
|
(r'#if LVGL_VERSION_MAJOR == 8\n/\*Store all the custom data of the font\*/\n'
|
||||||
|
r'static lv_font_fmt_txt_glyph_cache_t cache;\n#endif\n\n', ''),
|
||||||
|
# collapse the font_dsc storage-class guard
|
||||||
|
(r'#if LVGL_VERSION_MAJOR >= 8\nstatic const lv_font_fmt_txt_dsc_t font_dsc = \{\n'
|
||||||
|
r'#else\nstatic lv_font_fmt_txt_dsc_t font_dsc = \{\n#endif\n',
|
||||||
|
'static const lv_font_fmt_txt_dsc_t font_dsc = {\n'),
|
||||||
|
# drop the v8 .cache member
|
||||||
|
(r'#if LVGL_VERSION_MAJOR == 8\n \.cache = &cache\n#endif\n', ''),
|
||||||
|
# collapse the public-font const guard
|
||||||
|
(r'#if LVGL_VERSION_MAJOR >= 8\n(const lv_font_t \w+ = \{)\n'
|
||||||
|
r'#else\nlv_font_t \w+ = \{\n#endif\n', r'\1\n'),
|
||||||
|
# keep .subpx and add the three LVGL 9 fields
|
||||||
|
(r'#if !\(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0\)\n'
|
||||||
|
r' \.subpx = LV_FONT_SUBPX_NONE,\n#endif\n',
|
||||||
|
' .subpx = LV_FONT_SUBPX_NONE,\n .release_glyph = NULL,\n'
|
||||||
|
' .kerning = 0,\n .static_bitmap = 0,\n'),
|
||||||
|
# unwrap underline fields
|
||||||
|
(r'#if LV_VERSION_CHECK\(7, 4, 0\) \|\| LVGL_VERSION_MAJOR >= 8\n'
|
||||||
|
r'( \.underline_position = -1,\n \.underline_thickness = 2,\n)#endif\n', r'\1'),
|
||||||
|
# unwrap .fallback
|
||||||
|
(r'#if LV_VERSION_CHECK\(8, 2, 0\) \|\| LVGL_VERSION_MAJOR >= 9\n'
|
||||||
|
r'( \.fallback = NULL,\n)#endif\n', r'\1'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def patch(path: str) -> None:
|
||||||
|
s = open(path, encoding="utf-8").read()
|
||||||
|
for pat, repl in SUBS:
|
||||||
|
s = re.sub(pat, repl, s)
|
||||||
|
open(path, "w", encoding="utf-8").write(s)
|
||||||
|
leftover = len(re.findall(r'LVGL_VERSION_MAJOR|LV_VERSION_CHECK|\.cache = &cache', s))
|
||||||
|
print(f"patched {path} leftover_guards={leftover}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
for p in sys.argv[1:]:
|
||||||
|
patch(p)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-off diagnostic for the z.ai GLM Coding Plan usage endpoint (the source
|
||||||
|
ZaiProvider reads). Dumps the raw quota JSON so the window mapping can be
|
||||||
|
checked against a live plan.
|
||||||
|
|
||||||
|
Usage (key via env so it never lands in shell history):
|
||||||
|
ZAI_API_KEY=... python tools/probe_zai.py [host]
|
||||||
|
|
||||||
|
Default host = https://api.z.ai . It's a status GET — it does NOT spend the
|
||||||
|
prompt-metered plan. The API key is read from the environment and never printed.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
HOST = (sys.argv[1] if len(sys.argv) > 1 else "https://api.z.ai").rstrip("/")
|
||||||
|
KEY = os.environ.get("ZAI_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")
|
||||||
|
|
||||||
|
if not KEY:
|
||||||
|
sys.exit("Set ZAI_API_KEY in the environment first (it is not printed).")
|
||||||
|
|
||||||
|
url = f"{HOST}/api/monitor/usage/quota/limit"
|
||||||
|
print(f"GET {url}")
|
||||||
|
try:
|
||||||
|
resp = httpx.get(url, headers={"Authorization": KEY, "Accept-Language": "en-US,en"}, timeout=30.0)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
sys.exit(f"request failed: {e}")
|
||||||
|
|
||||||
|
print(f"HTTP {resp.status_code}\n")
|
||||||
|
try:
|
||||||
|
print(json.dumps(resp.json(), indent=2, ensure_ascii=False))
|
||||||
|
except ValueError:
|
||||||
|
print(resp.text[:1000])
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture a screenshot from the watch over USB serial (Windows).
|
||||||
|
|
||||||
|
The Unix `screenshot.sh` shells out to ffmpeg; this is a self-contained Windows
|
||||||
|
equivalent that needs only pyserial + Pillow (both in the daemon venv). The
|
||||||
|
firmware's `screenshot` serial command dumps the LVGL framebuffer as raw RGB565LE
|
||||||
|
between `SCREENSHOT_START w h size` and `SCREENSHOT_END`.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/screenshot_win.py [COM_PORT] [out.png] (defaults: COM7 shot.png)
|
||||||
|
"""
|
||||||
|
import array
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import serial
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
port_name = sys.argv[1] if len(sys.argv) > 1 else "COM7"
|
||||||
|
out = sys.argv[2] if len(sys.argv) > 2 else "shot.png"
|
||||||
|
|
||||||
|
port = serial.Serial(port_name, 115200, timeout=3)
|
||||||
|
time.sleep(0.3)
|
||||||
|
port.reset_input_buffer()
|
||||||
|
port.write(b"screenshot\n")
|
||||||
|
port.flush()
|
||||||
|
|
||||||
|
w = h = raw_size = 0
|
||||||
|
deadline = time.time() + 15
|
||||||
|
while time.time() < deadline:
|
||||||
|
line = port.readline().decode("utf-8", errors="replace").strip()
|
||||||
|
if line.startswith("SCREENSHOT_START"):
|
||||||
|
_, sw, sh, ss = line.split()
|
||||||
|
w, h, raw_size = int(sw), int(sh), int(ss)
|
||||||
|
break
|
||||||
|
if line in ("SCREENSHOT_ERR", "SCREENSHOT_UNSUPPORTED"):
|
||||||
|
sys.exit(f"device error: {line}")
|
||||||
|
if not raw_size:
|
||||||
|
sys.exit("no SCREENSHOT_START (is the device booted and on COM port?)")
|
||||||
|
|
||||||
|
data = b""
|
||||||
|
while len(data) < raw_size:
|
||||||
|
chunk = port.read(min(8192, raw_size - len(data)))
|
||||||
|
if not chunk:
|
||||||
|
sys.exit(f"timeout: got {len(data)} of {raw_size} bytes")
|
||||||
|
data += chunk
|
||||||
|
port.close()
|
||||||
|
|
||||||
|
# RGB565 little-endian -> RGB888. array 'H' is native (LE on x86) == RGB565LE.
|
||||||
|
px = array.array("H")
|
||||||
|
px.frombytes(data)
|
||||||
|
rgb = bytearray(len(px) * 3)
|
||||||
|
for i, v in enumerate(px):
|
||||||
|
r = (v >> 11) & 0x1F
|
||||||
|
g = (v >> 5) & 0x3F
|
||||||
|
b = v & 0x1F
|
||||||
|
rgb[i * 3] = (r << 3) | (r >> 2)
|
||||||
|
rgb[i * 3 + 1] = (g << 2) | (g >> 4)
|
||||||
|
rgb[i * 3 + 2] = (b << 3) | (b >> 2)
|
||||||
|
Image.frombytes("RGB", (w, h), bytes(rgb)).save(out)
|
||||||
|
print(f"saved {out} {w}x{h} ({len(data)} bytes)")
|
||||||
Reference in New Issue
Block a user