Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8322fa21 | ||
|
|
650af4221b | ||
|
|
b8d4daa03f | ||
|
|
e682b43735 | ||
|
|
b188349262 | ||
|
|
4c1b63e645 | ||
|
|
26bc85ffe6 | ||
|
|
c2020dcea2 | ||
|
|
466e32fda1 | ||
|
|
56b49898a3 | ||
|
|
abe72eca0a | ||
|
|
d5d80e81c0 | ||
|
|
5bed8cbd1f | ||
|
|
8d82beaacf | ||
|
|
25b101d086 |
@@ -18,3 +18,12 @@ 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
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- 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
|
||||||
|
binaries = []
|
||||||
|
hiddenimports = [
|
||||||
|
# Imported lazily inside tray_windows.main(), so name them explicitly.
|
||||||
|
'daemon.claude_usage_daemon_windows',
|
||||||
|
'daemon.autostart_windows',
|
||||||
|
'daemon.icon_assets',
|
||||||
|
# The exact winrt media modules read_now_playing() pulls in.
|
||||||
|
'winrt.windows.media',
|
||||||
|
'winrt.windows.media.control',
|
||||||
|
]
|
||||||
|
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL'):
|
||||||
|
_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,46 @@ 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.
|
||||||
|
|
||||||
|
1. Pair the device with Windows once (see [Pair the device](#pair-the-device-one-time)).
|
||||||
|
2. Get `Clawdmeter.exe` — download it from the project's Gitea release, 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 +157,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 +263,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}"'
|
||||||
|
|||||||
@@ -33,8 +33,13 @@ 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"
|
||||||
|
|
||||||
POLL_INTERVAL = 60
|
POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
|
||||||
TICK = 5
|
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 +62,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 +269,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():
|
||||||
@@ -197,10 +317,21 @@ class Session:
|
|||||||
log(f"Refresh subscription unavailable: {e}")
|
log(f"Refresh subscription unavailable: {e}")
|
||||||
|
|
||||||
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 +443,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]
|
||||||
@@ -381,55 +664,124 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
session = Session(client)
|
session = Session(client)
|
||||||
await session.setup_refresh_subscription()
|
await session.setup_refresh_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)
|
||||||
|
auth_problem = False
|
||||||
|
|
||||||
|
if claude_due:
|
||||||
session.refresh_requested.clear()
|
session.refresh_requested.clear()
|
||||||
token = read_token() # D-09: fresh each cycle
|
|
||||||
|
# Local token usage (Session screen) needs no network — compute it
|
||||||
|
# every cycle so the watch keeps updating even with no/expired token.
|
||||||
|
fresh = compute_today_usage()
|
||||||
|
|
||||||
|
# Rate-limit utilization is best-effort. A genuine 401/403 flags the
|
||||||
|
# token; a transient failure (network/DNS/5xx) leaves the tray state
|
||||||
|
# alone (SC#5: a DNS blip must not read as "token expired").
|
||||||
|
rl = None
|
||||||
|
|
||||||
|
# Self-refresh the OAuth token before using it. In a managed
|
||||||
|
# (Agent SDK) environment `claude login` is unavailable, so the
|
||||||
|
# daemon renews its own access token from the refresh token —
|
||||||
|
# otherwise the rate-limit % goes dark forever once it expires.
|
||||||
|
try:
|
||||||
|
await refresh_token_if_needed()
|
||||||
|
except Exception as e: # belt-and-braces: never crash the loop
|
||||||
|
log(f"Token refresh skipped: {e!r}")
|
||||||
|
|
||||||
|
token = read_token() # D-09: fresh each cycle (post-refresh)
|
||||||
if not token:
|
if not token:
|
||||||
log("No token; skipping poll")
|
auth_problem = True
|
||||||
if tray_state:
|
log("No token; sending local usage only")
|
||||||
tray_state.set_error("token expired — run claude login")
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
payload = await poll_api(token)
|
rl = await poll_api(token)
|
||||||
except AuthError:
|
except AuthError:
|
||||||
# Real 401/403 — token genuinely needs a refresh.
|
# Rejected despite the proactive refresh (e.g. expiresAt
|
||||||
if tray_state:
|
# was stale/missing so we skipped it). Force one refresh
|
||||||
tray_state.set_error("token expired — run claude login")
|
# and retry the poll once before flagging the token bad.
|
||||||
payload = None
|
try:
|
||||||
if payload is not None:
|
forced = await refresh_token_if_needed(force=True)
|
||||||
if await session.write_payload(payload):
|
except Exception as e:
|
||||||
last_poll = time.time()
|
log(f"Forced token refresh failed: {e!r}")
|
||||||
used_successfully = True
|
forced = False
|
||||||
consecutive_failures = 0 # D-03: reset on success
|
if forced and (token := read_token()):
|
||||||
|
try:
|
||||||
|
rl = await poll_api(token)
|
||||||
|
except AuthError:
|
||||||
|
auth_problem = True
|
||||||
|
else:
|
||||||
|
auth_problem = True
|
||||||
|
|
||||||
|
if rl is not None:
|
||||||
|
fresh.update(rl)
|
||||||
|
fresh["ok"] = True # rate-limit data is fresh
|
||||||
|
else:
|
||||||
|
fresh["ok"] = False # rate-limit unknown -> watch usage view goes idle
|
||||||
|
|
||||||
|
cached = fresh
|
||||||
|
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:
|
||||||
|
np = await read_now_playing()
|
||||||
|
except Exception as e: # never let media reading break the poll loop
|
||||||
|
log(f"Now-playing skipped: {e!r}")
|
||||||
|
np = {"np": 0}
|
||||||
|
|
||||||
|
# Write when the usage data was just refreshed (this is also the ~60s
|
||||||
|
# heartbeat) or when the track / playback state changed since the last
|
||||||
|
# write — skip otherwise so the link, and the field log, stay quiet
|
||||||
|
# between changes instead of repeating an identical payload every 3s.
|
||||||
|
if claude_due or np != last_np_sent:
|
||||||
|
payload = dict(cached)
|
||||||
|
payload.update(np)
|
||||||
|
if await session.write_payload(payload):
|
||||||
|
used_successfully = True
|
||||||
|
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())
|
||||||
else:
|
elif auth_problem:
|
||||||
consecutive_failures += 1
|
if tray_state:
|
||||||
if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
|
tray_state.set_error("token expired — run claude login")
|
||||||
log(
|
# transient rate-limit failure: leave tray state unchanged
|
||||||
f"Zombie link detected ({consecutive_failures} consecutive"
|
else:
|
||||||
f" write failures); abandoning connection"
|
consecutive_failures += 1
|
||||||
)
|
if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
|
||||||
break
|
log(
|
||||||
# else: payload is None from a TRANSIENT failure (network/DNS,
|
f"Zombie link detected ({consecutive_failures} consecutive"
|
||||||
# timeout, rate-limit, 5xx). poll_api already logged it; do NOT
|
f" write failures); abandoning connection"
|
||||||
# toast "token expired" — that mislabeled a boot-time DNS blip
|
)
|
||||||
# as an auth problem (SC#5). Leave tray state unchanged; the next
|
break
|
||||||
# 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:
|
||||||
# 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,
|
||||||
@@ -490,34 +842,51 @@ 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():
|
||||||
device = await scan_for_device()
|
try:
|
||||||
if not device:
|
device = await scan_for_device()
|
||||||
# Slow-search regime: device was not found by scan — back off gently
|
if not device:
|
||||||
|
# Slow-search regime: device was not found by scan — back off gently
|
||||||
|
if tray_state:
|
||||||
|
tray_state.set_scanning()
|
||||||
|
log(f"Device not found, 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)
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok = await connect_and_run(device, stop_event, tray_state)
|
||||||
|
if not ok:
|
||||||
|
# Fast-reconnect regime: had/attempted a link that dropped — retry quickly
|
||||||
|
if tray_state:
|
||||||
|
tray_state.set_scanning()
|
||||||
|
log(f"Connection lost, reconnecting in {reconnect_backoff}s...")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop_event.wait(), timeout=reconnect_backoff)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
reconnect_backoff = _next_backoff(reconnect_backoff, RECONNECT_BACKOFF_CAP)
|
||||||
|
else:
|
||||||
|
# Successful session — reset reconnect counter to floor; search_backoff also reset
|
||||||
|
reconnect_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:
|
if tray_state:
|
||||||
tray_state.set_scanning()
|
tray_state.set_scanning()
|
||||||
log(f"Device not found, retrying in {search_backoff}s...")
|
log(f"BLE error ({type(e).__name__}: {e}); retrying in {search_backoff}s...")
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(stop_event.wait(), timeout=search_backoff)
|
await asyncio.wait_for(stop_event.wait(), timeout=search_backoff)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
search_backoff = _next_backoff(search_backoff, 60)
|
search_backoff = _next_backoff(search_backoff, 60)
|
||||||
continue
|
|
||||||
|
|
||||||
ok = await connect_and_run(device, stop_event, tray_state)
|
|
||||||
if not ok:
|
|
||||||
# Fast-reconnect regime: had/attempted a link that dropped — retry quickly
|
|
||||||
if tray_state:
|
|
||||||
tray_state.set_scanning()
|
|
||||||
log(f"Connection lost, reconnecting in {reconnect_backoff}s...")
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(stop_event.wait(), timeout=reconnect_backoff)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass
|
|
||||||
reconnect_backoff = _next_backoff(reconnect_backoff, RECONNECT_BACKOFF_CAP)
|
|
||||||
else:
|
|
||||||
# Successful session — reset reconnect counter to floor; search_backoff also reset
|
|
||||||
reconnect_backoff = 1
|
|
||||||
search_backoff = 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ 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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+38
-14
@@ -27,7 +27,13 @@ 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).
|
||||||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
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__)))
|
||||||
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)
|
||||||
|
|
||||||
@@ -190,19 +196,33 @@ 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
|
||||||
try:
|
# forever (field SC: pulling the BT dongle killed the daemon and it never
|
||||||
_asyncio.run(daemon_main(tray_state=ts))
|
# came back). A clean return means stop_event was set (Quit) — stop then.
|
||||||
except Exception as e: # last-resort thread guard
|
backoff = 1
|
||||||
import traceback
|
while not quit_evt.is_set():
|
||||||
daemon_log(f"Daemon thread crashed: {e!r}")
|
try:
|
||||||
daemon_log(traceback.format_exc())
|
_asyncio.run(daemon_main(tray_state=ts))
|
||||||
ts.set_error(f"daemon crashed: {type(e).__name__}")
|
break # clean return == Quit requested
|
||||||
|
except Exception as e: # last-resort thread guard
|
||||||
|
import traceback
|
||||||
|
daemon_log(f"Daemon thread crashed: {e!r}")
|
||||||
|
daemon_log(traceback.format_exc())
|
||||||
|
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()
|
||||||
@@ -219,8 +239,12 @@ 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:
|
||||||
ts.loop.call_soon_threadsafe(ts.stop_event.set)
|
try:
|
||||||
|
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)
|
||||||
icon_ref.stop()
|
icon_ref.stop()
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -157,6 +157,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
|
||||||
|
|||||||
@@ -46,10 +46,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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -7,6 +7,7 @@
|
|||||||
#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 "idle.h"
|
#include "idle.h"
|
||||||
@@ -111,6 +112,13 @@ 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;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -180,7 +188,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();
|
||||||
|
|
||||||
|
|||||||
+520
-14
@@ -1,6 +1,7 @@
|
|||||||
#include "ui.h"
|
#include "ui.h"
|
||||||
#include "splash.h"
|
#include "splash.h"
|
||||||
#include <lvgl.h>
|
#include <lvgl.h>
|
||||||
|
#include <string.h>
|
||||||
#include "logo.h"
|
#include "logo.h"
|
||||||
#include "icons.h"
|
#include "icons.h"
|
||||||
#include "hal/board_caps.h"
|
#include "hal/board_caps.h"
|
||||||
@@ -14,6 +15,10 @@ LV_FONT_DECLARE(font_styrene_24);
|
|||||||
LV_FONT_DECLARE(font_styrene_20);
|
LV_FONT_DECLARE(font_styrene_20);
|
||||||
LV_FONT_DECLARE(font_styrene_16);
|
LV_FONT_DECLARE(font_styrene_16);
|
||||||
LV_FONT_DECLARE(font_styrene_14);
|
LV_FONT_DECLARE(font_styrene_14);
|
||||||
|
// Composite Styrene (Latin) + Montserrat (Cyrillic) — used where user content
|
||||||
|
// (media titles) can be non-Latin; brand Styrene has no Cyrillic glyphs.
|
||||||
|
LV_FONT_DECLARE(font_styrene_cyr_28);
|
||||||
|
LV_FONT_DECLARE(font_styrene_cyr_20);
|
||||||
LV_FONT_DECLARE(font_mono_32);
|
LV_FONT_DECLARE(font_mono_32);
|
||||||
|
|
||||||
// Layout values computed from the active board's geometry. Populated once
|
// Layout values computed from the active board's geometry. Populated once
|
||||||
@@ -119,6 +124,46 @@ static lv_obj_t* battery_img;
|
|||||||
static lv_obj_t* logo_img;
|
static lv_obj_t* logo_img;
|
||||||
static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging
|
static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging
|
||||||
|
|
||||||
|
// ---- v2 navigation: launcher, stub & bluetooth screens ----
|
||||||
|
static lv_obj_t* nav_back_btn; // top-left back chevron (shown off the home screen)
|
||||||
|
static lv_obj_t* menu_container; // app launcher (scrollable tile grid)
|
||||||
|
static lv_obj_t* stub_container; // generic "coming soon" screen, retitled per app
|
||||||
|
static lv_obj_t* stub_title;
|
||||||
|
static lv_obj_t* stub_icon;
|
||||||
|
static lv_obj_t* bt_container; // BLE connection info
|
||||||
|
static lv_obj_t* bt_status_lbl;
|
||||||
|
static lv_obj_t* bt_name_lbl;
|
||||||
|
static lv_obj_t* bt_mac_lbl;
|
||||||
|
static lv_obj_t* session_container; // today's tokens / cost
|
||||||
|
static lv_obj_t* sess_cost_lbl;
|
||||||
|
static lv_obj_t* sess_tokens_lbl;
|
||||||
|
static lv_obj_t* sess_gen_lbl;
|
||||||
|
static lv_obj_t* sess_msgs_lbl;
|
||||||
|
static lv_obj_t* nowplaying_container; // media now-playing (Phase 5)
|
||||||
|
static lv_obj_t* np_icon_lbl; // play / pause / music glyph
|
||||||
|
static lv_obj_t* np_title_lbl; // track title (scrolls if long)
|
||||||
|
static lv_obj_t* np_artist_lbl; // track artist
|
||||||
|
static lv_obj_t* np_status_lbl; // "Playing" / "Paused"
|
||||||
|
|
||||||
|
// App registry — the launcher renders one tile per entry, so adding a screen is
|
||||||
|
// one line here plus its builder. Order = tile order. "Animations" reuses the
|
||||||
|
// splash; the four future apps open the shared stub until their phase lands.
|
||||||
|
struct AppEntry {
|
||||||
|
screen_t screen;
|
||||||
|
const char* label;
|
||||||
|
const char* symbol; // LVGL/Montserrat symbol glyph
|
||||||
|
};
|
||||||
|
static const AppEntry APPS[] = {
|
||||||
|
{ SCREEN_USAGE, "Usage", LV_SYMBOL_CHARGE },
|
||||||
|
{ SCREEN_SPLASH, "Animations", LV_SYMBOL_IMAGE },
|
||||||
|
{ SCREEN_SOUNDPAD, "Soundpad", LV_SYMBOL_AUDIO },
|
||||||
|
{ SCREEN_SESSION, "Session", LV_SYMBOL_LIST },
|
||||||
|
{ SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY },
|
||||||
|
{ SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME },
|
||||||
|
{ SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH },
|
||||||
|
};
|
||||||
|
#define APP_COUNT (sizeof(APPS) / sizeof(APPS[0]))
|
||||||
|
|
||||||
// ---- Live-data freshness → which usage sub-view to show ----
|
// ---- Live-data freshness → which usage sub-view to show ----
|
||||||
// usage panels when data is flowing, an idle "Zzz" screen when the host is
|
// usage panels when data is flowing, an idle "Zzz" screen when the host is
|
||||||
// connected but no usage update landed within DATA_FRESH_MS, the pairing hint
|
// connected but no usage update landed within DATA_FRESH_MS, the pairing hint
|
||||||
@@ -207,8 +252,17 @@ static void format_reset_time(int mins, char* buf, size_t len) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format a token count compactly: 94972115 -> "95.0M", 960621 -> "960.6K".
|
||||||
|
static void format_tokens(long long n, char* buf, size_t len) {
|
||||||
|
if (n >= 1000000) snprintf(buf, len, "%.1fM", (double)n / 1000000.0);
|
||||||
|
else if (n >= 1000) snprintf(buf, len, "%.1fK", (double)n / 1000.0);
|
||||||
|
else snprintf(buf, len, "%lld", n);
|
||||||
|
}
|
||||||
|
|
||||||
// Forward decls — callbacks defined near ui_show_screen below
|
// Forward decls — callbacks defined near ui_show_screen below
|
||||||
static void global_click_cb(lv_event_t* e);
|
static void global_click_cb(lv_event_t* e);
|
||||||
|
static void logo_click_cb(lv_event_t* e);
|
||||||
|
static void nav_back_cb(lv_event_t* e);
|
||||||
|
|
||||||
static lv_obj_t* make_panel(lv_obj_t* parent, int x, int y, int w, int h) {
|
static lv_obj_t* make_panel(lv_obj_t* parent, int x, int y, int w, int h) {
|
||||||
lv_obj_t* panel = lv_obj_create(parent);
|
lv_obj_t* panel = lv_obj_create(parent);
|
||||||
@@ -362,7 +416,6 @@ static void init_usage_screen(lv_obj_t* scr) {
|
|||||||
lv_obj_set_style_border_width(usage_container, 0, 0);
|
lv_obj_set_style_border_width(usage_container, 0, 0);
|
||||||
lv_obj_set_style_pad_all(usage_container, 0, 0);
|
lv_obj_set_style_pad_all(usage_container, 0, 0);
|
||||||
lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
lv_obj_add_event_cb(usage_container, global_click_cb, LV_EVENT_CLICKED, NULL);
|
|
||||||
|
|
||||||
lbl_title = lv_label_create(usage_container);
|
lbl_title = lv_label_create(usage_container);
|
||||||
lv_label_set_text(lbl_title, "Usage");
|
lv_label_set_text(lbl_title, "Usage");
|
||||||
@@ -400,6 +453,344 @@ static void init_usage_screen(lv_obj_t* scr) {
|
|||||||
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
|
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======== v2: launcher, stub & bluetooth screens ========
|
||||||
|
|
||||||
|
static const AppEntry* app_for_screen(screen_t s) {
|
||||||
|
for (size_t i = 0; i < APP_COUNT; i++)
|
||||||
|
if (APPS[i].screen == s) return &APPS[i];
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centered screen title for the navigation screens. The back chevron lives in
|
||||||
|
// the shared top-left chrome (nav_back_btn), so only the title is drawn here.
|
||||||
|
static lv_obj_t* make_screen_title(lv_obj_t* parent, const char* text) {
|
||||||
|
lv_obj_t* t = lv_label_create(parent);
|
||||||
|
lv_label_set_text(t, text);
|
||||||
|
lv_obj_set_style_text_font(t, &font_styrene_28, 0);
|
||||||
|
lv_obj_set_style_text_color(t, COL_TEXT, 0);
|
||||||
|
lv_obj_align(t, LV_ALIGN_TOP_MID, 16, L.title_y);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void tile_click_cb(lv_event_t* e) {
|
||||||
|
screen_t s = (screen_t)(intptr_t)lv_event_get_user_data(e);
|
||||||
|
ui_show_screen(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
static lv_obj_t* make_tile(lv_obj_t* parent, const AppEntry* app, int w, int h) {
|
||||||
|
lv_obj_t* tile = lv_obj_create(parent);
|
||||||
|
lv_obj_set_size(tile, w, h);
|
||||||
|
lv_obj_set_style_bg_color(tile, COL_PANEL, 0);
|
||||||
|
lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0);
|
||||||
|
lv_obj_set_style_radius(tile, 16, 0);
|
||||||
|
lv_obj_set_style_border_width(tile, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(tile, 6, 0);
|
||||||
|
lv_obj_set_flex_flow(tile, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_clear_flag(tile, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_add_flag(tile, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
lv_obj_add_event_cb(tile, tile_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)app->screen);
|
||||||
|
|
||||||
|
lv_obj_t* icon = lv_label_create(tile);
|
||||||
|
lv_label_set_text(icon, app->symbol);
|
||||||
|
lv_obj_set_style_text_font(icon, &lv_font_montserrat_28, 0);
|
||||||
|
lv_obj_set_style_text_color(icon, COL_ACCENT, 0);
|
||||||
|
|
||||||
|
lv_obj_t* lbl = lv_label_create(tile);
|
||||||
|
lv_label_set_text(lbl, app->label);
|
||||||
|
lv_obj_set_style_text_font(lbl, &font_styrene_16, 0);
|
||||||
|
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
|
||||||
|
lv_obj_set_style_pad_top(lbl, 6, 0);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void init_menu_screen(lv_obj_t* scr) {
|
||||||
|
menu_container = lv_obj_create(scr);
|
||||||
|
lv_obj_set_size(menu_container, L.scr_w, L.scr_h);
|
||||||
|
lv_obj_set_pos(menu_container, 0, 0);
|
||||||
|
lv_obj_set_style_bg_opa(menu_container, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(menu_container, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(menu_container, 0, 0);
|
||||||
|
lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
make_screen_title(menu_container, "Menu");
|
||||||
|
|
||||||
|
// Scrollable 2-column tile grid — wraps and scrolls vertically as the app
|
||||||
|
// registry grows, so new screens need no layout changes here.
|
||||||
|
lv_obj_t* grid = lv_obj_create(menu_container);
|
||||||
|
lv_obj_set_size(grid, L.content_w, L.scr_h - L.content_y - L.margin);
|
||||||
|
lv_obj_set_pos(grid, L.margin, L.content_y);
|
||||||
|
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(grid, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(grid, 0, 0);
|
||||||
|
lv_obj_set_style_pad_row(grid, 12, 0);
|
||||||
|
lv_obj_set_style_pad_column(grid, 12, 0);
|
||||||
|
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
|
||||||
|
// track_cross_place = START: stack rows from the top so the first row sits
|
||||||
|
// just under the title and the rest is reached by scrolling DOWN. (CENTER
|
||||||
|
// would vertically center the overflowing block, hiding the top row under
|
||||||
|
// the title and springing back to it after an elastic pull.)
|
||||||
|
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
|
||||||
|
lv_obj_set_scroll_dir(grid, LV_DIR_VER);
|
||||||
|
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_AUTO);
|
||||||
|
|
||||||
|
int tile_w = (L.content_w - 16) / 2; // 4px slack so two fit per row beside a scrollbar
|
||||||
|
int tile_h = 116;
|
||||||
|
for (size_t i = 0; i < APP_COUNT; i++)
|
||||||
|
make_tile(grid, &APPS[i], tile_w, tile_h);
|
||||||
|
|
||||||
|
lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One generic "coming soon" screen, retitled per app in stub_show_for(). The
|
||||||
|
// four future apps (Soundpad/Session/Now Playing/Home) point here until built.
|
||||||
|
static void init_stub_screen(lv_obj_t* scr) {
|
||||||
|
stub_container = lv_obj_create(scr);
|
||||||
|
lv_obj_set_size(stub_container, L.scr_w, L.scr_h);
|
||||||
|
lv_obj_set_pos(stub_container, 0, 0);
|
||||||
|
lv_obj_set_style_bg_opa(stub_container, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(stub_container, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(stub_container, 0, 0);
|
||||||
|
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
stub_title = make_screen_title(stub_container, "");
|
||||||
|
|
||||||
|
stub_icon = lv_label_create(stub_container);
|
||||||
|
lv_label_set_text(stub_icon, LV_SYMBOL_SETTINGS);
|
||||||
|
lv_obj_set_style_text_font(stub_icon, &lv_font_montserrat_28, 0);
|
||||||
|
lv_obj_set_style_text_color(stub_icon, COL_DIM, 0);
|
||||||
|
lv_obj_align(stub_icon, LV_ALIGN_CENTER, 0, -20);
|
||||||
|
|
||||||
|
lv_obj_t* soon = lv_label_create(stub_container);
|
||||||
|
lv_label_set_text(soon, "coming soon");
|
||||||
|
lv_obj_set_style_text_font(soon, &font_styrene_20, 0);
|
||||||
|
lv_obj_set_style_text_color(soon, COL_DIM, 0);
|
||||||
|
lv_obj_align(soon, LV_ALIGN_CENTER, 0, 30);
|
||||||
|
|
||||||
|
lv_obj_add_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void init_bt_screen(lv_obj_t* scr) {
|
||||||
|
bt_container = lv_obj_create(scr);
|
||||||
|
lv_obj_set_size(bt_container, L.scr_w, L.scr_h);
|
||||||
|
lv_obj_set_pos(bt_container, 0, 0);
|
||||||
|
lv_obj_set_style_bg_opa(bt_container, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(bt_container, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(bt_container, 0, 0);
|
||||||
|
lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
make_screen_title(bt_container, "Bluetooth");
|
||||||
|
|
||||||
|
bt_status_lbl = lv_label_create(bt_container);
|
||||||
|
lv_label_set_text(bt_status_lbl, "");
|
||||||
|
lv_obj_set_style_text_font(bt_status_lbl, &font_styrene_28, 0);
|
||||||
|
lv_obj_align(bt_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 20);
|
||||||
|
|
||||||
|
bt_name_lbl = lv_label_create(bt_container);
|
||||||
|
lv_label_set_text(bt_name_lbl, "");
|
||||||
|
lv_obj_set_style_text_font(bt_name_lbl, &font_styrene_20, 0);
|
||||||
|
lv_obj_set_style_text_color(bt_name_lbl, COL_DIM, 0);
|
||||||
|
lv_obj_align(bt_name_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 80);
|
||||||
|
|
||||||
|
bt_mac_lbl = lv_label_create(bt_container);
|
||||||
|
lv_label_set_text(bt_mac_lbl, "");
|
||||||
|
lv_obj_set_style_text_font(bt_mac_lbl, &font_styrene_20, 0);
|
||||||
|
lv_obj_set_style_text_color(bt_mac_lbl, COL_DIM, 0);
|
||||||
|
lv_obj_align(bt_mac_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
|
||||||
|
|
||||||
|
lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void bt_refresh(void) {
|
||||||
|
if (!bt_container) return;
|
||||||
|
lv_label_set_text(bt_status_lbl, s_ble_connected ? "Connected" : "Waiting");
|
||||||
|
lv_obj_set_style_text_color(bt_status_lbl, s_ble_connected ? COL_GREEN : COL_AMBER, 0);
|
||||||
|
lv_label_set_text_fmt(bt_name_lbl, "%s", ble_get_device_name());
|
||||||
|
lv_label_set_text_fmt(bt_mac_lbl, "%s", ble_get_mac_address());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void stub_show_for(screen_t s) {
|
||||||
|
const AppEntry* app = app_for_screen(s);
|
||||||
|
if (app) {
|
||||||
|
lv_label_set_text(stub_title, app->label);
|
||||||
|
lv_label_set_text(stub_icon, app->symbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Soundpad: 6 pads → HID F13..F18 (the host maps them to a soundboard) ----
|
||||||
|
// F13..F24 are HID usage IDs 0x68..0x73; the report-map keycode ceiling was
|
||||||
|
// raised to 0x73 in ble.cpp so these transmit.
|
||||||
|
struct PadDef { uint8_t keycode; const char* label; uint32_t color; };
|
||||||
|
static const PadDef PADS[6] = {
|
||||||
|
{ 0x68, "F13", 0xD85A30 }, // coral
|
||||||
|
{ 0x69, "F14", 0xBA7517 }, // amber
|
||||||
|
{ 0x6A, "F15", 0x639922 }, // green
|
||||||
|
{ 0x6B, "F16", 0x1D9E75 }, // teal
|
||||||
|
{ 0x6C, "F17", 0x534AB7 }, // purple
|
||||||
|
{ 0x6D, "F18", 0xD4537E }, // pink
|
||||||
|
};
|
||||||
|
static lv_obj_t* soundpad_container;
|
||||||
|
|
||||||
|
static void release_timer_cb(lv_timer_t* t) {
|
||||||
|
(void)t;
|
||||||
|
ble_keyboard_release();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void pad_click_cb(lv_event_t* e) {
|
||||||
|
uint8_t key = (uint8_t)(intptr_t)lv_event_get_user_data(e);
|
||||||
|
ble_keyboard_press(key, 0);
|
||||||
|
// Release shortly after so the host sees a discrete keypress, without
|
||||||
|
// blocking the UI thread. The one-shot timer auto-deletes once it fires.
|
||||||
|
lv_timer_t* t = lv_timer_create(release_timer_cb, 40, NULL);
|
||||||
|
lv_timer_set_repeat_count(t, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static lv_obj_t* make_pad(lv_obj_t* parent, const PadDef* pad, int w, int h) {
|
||||||
|
lv_obj_t* btn = lv_obj_create(parent);
|
||||||
|
lv_obj_set_size(btn, w, h);
|
||||||
|
lv_obj_set_style_bg_color(btn, lv_color_hex(pad->color), 0);
|
||||||
|
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
|
||||||
|
lv_obj_set_style_radius(btn, 16, 0);
|
||||||
|
lv_obj_set_style_border_width(btn, 0, 0);
|
||||||
|
lv_obj_set_style_border_width(btn, 3, LV_STATE_PRESSED); // white ring lights on press
|
||||||
|
lv_obj_set_style_border_color(btn, lv_color_white(), LV_STATE_PRESSED);
|
||||||
|
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
lv_obj_add_event_cb(btn, pad_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)pad->keycode);
|
||||||
|
|
||||||
|
lv_obj_t* lbl = lv_label_create(btn);
|
||||||
|
lv_label_set_text(lbl, pad->label);
|
||||||
|
lv_obj_set_style_text_font(lbl, &font_styrene_28, 0);
|
||||||
|
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
|
||||||
|
lv_obj_center(lbl);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void init_soundpad_screen(lv_obj_t* scr) {
|
||||||
|
soundpad_container = lv_obj_create(scr);
|
||||||
|
lv_obj_set_size(soundpad_container, L.scr_w, L.scr_h);
|
||||||
|
lv_obj_set_pos(soundpad_container, 0, 0);
|
||||||
|
lv_obj_set_style_bg_opa(soundpad_container, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(soundpad_container, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(soundpad_container, 0, 0);
|
||||||
|
lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
make_screen_title(soundpad_container, "Soundpad");
|
||||||
|
|
||||||
|
lv_obj_t* grid = lv_obj_create(soundpad_container);
|
||||||
|
int avail_h = L.scr_h - L.content_y - L.margin;
|
||||||
|
lv_obj_set_size(grid, L.content_w, avail_h);
|
||||||
|
lv_obj_set_pos(grid, L.margin, L.content_y);
|
||||||
|
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(grid, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(grid, 0, 0);
|
||||||
|
lv_obj_set_style_pad_row(grid, 12, 0);
|
||||||
|
lv_obj_set_style_pad_column(grid, 12, 0);
|
||||||
|
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
|
||||||
|
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
|
||||||
|
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
int pad_w = (L.content_w - 16) / 2; // 2 columns, 4px slack
|
||||||
|
int pad_h = (avail_h - 2 * 12) / 3 - 1; // 3 rows
|
||||||
|
for (int i = 0; i < 6; i++)
|
||||||
|
make_pad(grid, &PADS[i], pad_w, pad_h);
|
||||||
|
|
||||||
|
lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session: today's Claude Code usage — equivalent API cost (hero), total tokens,
|
||||||
|
// and message count. Labels are refreshed from ui_update() as data arrives.
|
||||||
|
static void init_session_screen(lv_obj_t* scr) {
|
||||||
|
session_container = lv_obj_create(scr);
|
||||||
|
lv_obj_set_size(session_container, L.scr_w, L.scr_h);
|
||||||
|
lv_obj_set_pos(session_container, 0, 0);
|
||||||
|
lv_obj_set_style_bg_opa(session_container, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(session_container, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(session_container, 0, 0);
|
||||||
|
lv_obj_clear_flag(session_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
make_screen_title(session_container, "Session");
|
||||||
|
|
||||||
|
sess_cost_lbl = lv_label_create(session_container);
|
||||||
|
lv_label_set_text(sess_cost_lbl, "$0.00");
|
||||||
|
lv_obj_set_style_text_font(sess_cost_lbl, &font_tiempos_56, 0);
|
||||||
|
lv_obj_set_style_text_color(sess_cost_lbl, COL_TEXT, 0);
|
||||||
|
lv_obj_align(sess_cost_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 25);
|
||||||
|
|
||||||
|
lv_obj_t* sub = lv_label_create(session_container);
|
||||||
|
lv_label_set_text(sub, "spent today");
|
||||||
|
lv_obj_set_style_text_font(sub, &font_styrene_20, 0);
|
||||||
|
lv_obj_set_style_text_color(sub, COL_DIM, 0);
|
||||||
|
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, L.content_y + 105);
|
||||||
|
|
||||||
|
sess_tokens_lbl = lv_label_create(session_container);
|
||||||
|
lv_label_set_text(sess_tokens_lbl, "\xE2\x80\x94 tokens");
|
||||||
|
lv_obj_set_style_text_font(sess_tokens_lbl, &font_styrene_28, 0);
|
||||||
|
lv_obj_set_style_text_color(sess_tokens_lbl, COL_ACCENT, 0);
|
||||||
|
lv_obj_align(sess_tokens_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 152);
|
||||||
|
|
||||||
|
sess_gen_lbl = lv_label_create(session_container);
|
||||||
|
lv_label_set_text(sess_gen_lbl, "\xE2\x80\x94 generated");
|
||||||
|
lv_obj_set_style_text_font(sess_gen_lbl, &font_styrene_16, 0);
|
||||||
|
lv_obj_set_style_text_color(sess_gen_lbl, COL_DIM, 0);
|
||||||
|
lv_obj_align(sess_gen_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 192);
|
||||||
|
|
||||||
|
sess_msgs_lbl = lv_label_create(session_container);
|
||||||
|
lv_label_set_text(sess_msgs_lbl, "\xE2\x80\x94 requests");
|
||||||
|
lv_obj_set_style_text_font(sess_msgs_lbl, &font_styrene_20, 0);
|
||||||
|
lv_obj_set_style_text_color(sess_msgs_lbl, COL_DIM, 0);
|
||||||
|
lv_obj_align(sess_msgs_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 232);
|
||||||
|
|
||||||
|
lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now Playing: the Windows media session relayed by the daemon. A play/pause glyph
|
||||||
|
// and status line reflect playback; the title scrolls if it overflows. Independent
|
||||||
|
// of rate-limit data, so it stays live even when the OAuth token is unavailable.
|
||||||
|
static void init_nowplaying_screen(lv_obj_t* scr) {
|
||||||
|
nowplaying_container = lv_obj_create(scr);
|
||||||
|
lv_obj_set_size(nowplaying_container, L.scr_w, L.scr_h);
|
||||||
|
lv_obj_set_pos(nowplaying_container, 0, 0);
|
||||||
|
lv_obj_set_style_bg_opa(nowplaying_container, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(nowplaying_container, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(nowplaying_container, 0, 0);
|
||||||
|
lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
make_screen_title(nowplaying_container, "Now Playing");
|
||||||
|
|
||||||
|
np_icon_lbl = lv_label_create(nowplaying_container);
|
||||||
|
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
|
||||||
|
lv_obj_set_style_text_font(np_icon_lbl, &lv_font_montserrat_28, 0);
|
||||||
|
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
|
||||||
|
lv_obj_align(np_icon_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 35);
|
||||||
|
|
||||||
|
np_title_lbl = lv_label_create(nowplaying_container);
|
||||||
|
lv_obj_set_width(np_title_lbl, L.content_w);
|
||||||
|
lv_label_set_long_mode(np_title_lbl, LV_LABEL_LONG_SCROLL_CIRCULAR);
|
||||||
|
lv_obj_set_style_text_align(np_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_obj_set_style_text_font(np_title_lbl, &font_styrene_cyr_28, 0);
|
||||||
|
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
|
||||||
|
lv_label_set_text(np_title_lbl, "Nothing playing");
|
||||||
|
lv_obj_align(np_title_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
|
||||||
|
|
||||||
|
np_artist_lbl = lv_label_create(nowplaying_container);
|
||||||
|
lv_obj_set_width(np_artist_lbl, L.content_w);
|
||||||
|
lv_label_set_long_mode(np_artist_lbl, LV_LABEL_LONG_DOT);
|
||||||
|
lv_obj_set_style_text_align(np_artist_lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_obj_set_style_text_font(np_artist_lbl, &font_styrene_cyr_20, 0);
|
||||||
|
lv_obj_set_style_text_color(np_artist_lbl, COL_DIM, 0);
|
||||||
|
lv_label_set_text(np_artist_lbl, "");
|
||||||
|
lv_obj_align(np_artist_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 170);
|
||||||
|
|
||||||
|
np_status_lbl = lv_label_create(nowplaying_container);
|
||||||
|
lv_obj_set_style_text_font(np_status_lbl, &font_styrene_16, 0);
|
||||||
|
lv_obj_set_style_text_color(np_status_lbl, COL_DIM, 0);
|
||||||
|
lv_label_set_text(np_status_lbl, "");
|
||||||
|
lv_obj_align(np_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 215);
|
||||||
|
|
||||||
|
lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
// ======== Public API ========
|
// ======== Public API ========
|
||||||
|
|
||||||
void ui_init(void) {
|
void ui_init(void) {
|
||||||
@@ -419,28 +810,105 @@ void ui_init(void) {
|
|||||||
lv_obj_add_event_cb(splash_get_root(), global_click_cb, LV_EVENT_CLICKED, NULL);
|
lv_obj_add_event_cb(splash_get_root(), global_click_cb, LV_EVENT_CLICKED, NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2 screens — created hidden, shown on navigation.
|
||||||
|
init_menu_screen(scr);
|
||||||
|
init_stub_screen(scr);
|
||||||
|
init_bt_screen(scr);
|
||||||
|
init_soundpad_screen(scr);
|
||||||
|
init_session_screen(scr);
|
||||||
|
init_nowplaying_screen(scr);
|
||||||
|
|
||||||
logo_img = lv_image_create(scr);
|
logo_img = lv_image_create(scr);
|
||||||
lv_image_set_src(logo_img, &logo_dsc);
|
lv_image_set_src(logo_img, &logo_dsc);
|
||||||
lv_obj_set_pos(logo_img, L.margin, L.title_y - 10);
|
lv_obj_set_pos(logo_img, L.margin, L.title_y - 10);
|
||||||
|
lv_obj_add_flag(logo_img, LV_OBJ_FLAG_CLICKABLE); // tap the Claude logo → launcher
|
||||||
|
lv_obj_set_ext_click_area(logo_img, 14);
|
||||||
|
lv_obj_add_event_cb(logo_img, logo_click_cb, LV_EVENT_CLICKED, NULL);
|
||||||
|
|
||||||
|
// Back chevron — shared across the menu/app screens, hidden on home/splash.
|
||||||
|
nav_back_btn = lv_label_create(scr);
|
||||||
|
lv_label_set_text(nav_back_btn, LV_SYMBOL_LEFT);
|
||||||
|
lv_obj_set_style_text_font(nav_back_btn, &lv_font_montserrat_28, 0);
|
||||||
|
lv_obj_set_style_text_color(nav_back_btn, COL_TEXT, 0);
|
||||||
|
lv_obj_set_pos(nav_back_btn, L.margin + 4, L.title_y + 10); // clear of the rounded top-left corner
|
||||||
|
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
lv_obj_set_ext_click_area(nav_back_btn, 18);
|
||||||
|
lv_obj_add_event_cb(nav_back_btn, nav_back_cb, LV_EVENT_CLICKED, NULL);
|
||||||
|
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
battery_img = lv_image_create(scr);
|
battery_img = lv_image_create(scr);
|
||||||
lv_image_set_src(battery_img, &battery_dscs[0]);
|
lv_image_set_src(battery_img, &battery_dscs[0]);
|
||||||
lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y);
|
lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ui_update(const UsageData* data) {
|
void ui_update(const UsageData* data) {
|
||||||
if (!data->valid) return;
|
if (!data->valid) return;
|
||||||
last_data_ms = lv_tick_get(); // a valid usage update just landed → dot goes green
|
|
||||||
|
char buf[48];
|
||||||
|
|
||||||
|
// Session screen — local token data, computed by the daemon from transcripts
|
||||||
|
// with no API/token needed. Always refresh it so it stays live even when the
|
||||||
|
// rate-limit data below is unavailable (data->ok == false).
|
||||||
|
if (sess_cost_lbl) {
|
||||||
|
lv_label_set_text_fmt(sess_cost_lbl, "$%d.%02d",
|
||||||
|
data->cost_cents_today / 100, data->cost_cents_today % 100);
|
||||||
|
format_tokens(data->tokens_today, buf, sizeof(buf));
|
||||||
|
lv_label_set_text_fmt(sess_tokens_lbl, "%s tokens", buf);
|
||||||
|
format_tokens(data->output_today, buf, sizeof(buf));
|
||||||
|
lv_label_set_text_fmt(sess_gen_lbl, "%s generated", buf);
|
||||||
|
lv_label_set_text_fmt(sess_msgs_lbl, "%d requests", data->messages_today);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now Playing — local media session relayed by the daemon. Like Session, it's
|
||||||
|
// independent of the rate-limit data, so refresh it before the ok==false bail.
|
||||||
|
// Only touch the labels when the track / playback state actually changed: the
|
||||||
|
// daemon pushes a payload every few seconds (fast "music card" cadence), and
|
||||||
|
// re-running lv_label_set_text() on an unchanged title restarts the circular
|
||||||
|
// scroll animation each time, so a long title would never get to scroll.
|
||||||
|
if (nowplaying_container) {
|
||||||
|
static int np_state_shown = -1;
|
||||||
|
static char np_title_shown[64] = {0};
|
||||||
|
static char np_artist_shown[64] = {0};
|
||||||
|
if (data->np_state != np_state_shown
|
||||||
|
|| strcmp(data->np_title, np_title_shown) != 0
|
||||||
|
|| strcmp(data->np_artist, np_artist_shown) != 0) {
|
||||||
|
np_state_shown = data->np_state;
|
||||||
|
strlcpy(np_title_shown, data->np_title, sizeof(np_title_shown));
|
||||||
|
strlcpy(np_artist_shown, data->np_artist, sizeof(np_artist_shown));
|
||||||
|
|
||||||
|
if (data->np_state == 0) {
|
||||||
|
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
|
||||||
|
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
|
||||||
|
lv_label_set_text(np_title_lbl, "Nothing playing");
|
||||||
|
lv_obj_set_style_text_color(np_title_lbl, COL_DIM, 0);
|
||||||
|
lv_label_set_text(np_artist_lbl, "");
|
||||||
|
lv_label_set_text(np_status_lbl, "");
|
||||||
|
} else {
|
||||||
|
bool playing = (data->np_state == 1);
|
||||||
|
lv_label_set_text(np_icon_lbl, playing ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE);
|
||||||
|
lv_obj_set_style_text_color(np_icon_lbl, COL_ACCENT, 0);
|
||||||
|
lv_label_set_text(np_title_lbl, data->np_title[0] ? data->np_title : "(no title)");
|
||||||
|
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
|
||||||
|
lv_label_set_text(np_artist_lbl, data->np_artist);
|
||||||
|
lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit utilization (5h / 7d). The daemon sets ok=false when this data
|
||||||
|
// is unavailable (expired OAuth token, API down). Skip the bars and DON'T
|
||||||
|
// bump the freshness clock then, so the usage view falls back to its idle
|
||||||
|
// "Zzz" state instead of showing stale or zeroed percentages.
|
||||||
|
if (!data->ok) return;
|
||||||
|
|
||||||
|
last_data_ms = lv_tick_get(); // fresh limit data just landed → dot goes green
|
||||||
data_received = true;
|
data_received = true;
|
||||||
|
|
||||||
int s_pct = (int)(data->session_pct + 0.5f);
|
int s_pct = (int)(data->session_pct + 0.5f);
|
||||||
|
|
||||||
lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct);
|
lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct);
|
||||||
lv_bar_set_value(bar_session, s_pct, LV_ANIM_ON);
|
lv_bar_set_value(bar_session, s_pct, LV_ANIM_ON);
|
||||||
lv_obj_set_style_bg_color(bar_session, pct_color(data->session_pct), LV_PART_INDICATOR);
|
lv_obj_set_style_bg_color(bar_session, pct_color(data->session_pct), LV_PART_INDICATOR);
|
||||||
|
|
||||||
char buf[48];
|
|
||||||
format_reset_time(data->session_reset_mins, buf, sizeof(buf));
|
format_reset_time(data->session_reset_mins, buf, sizeof(buf));
|
||||||
lv_label_set_text(lbl_session_reset, buf);
|
lv_label_set_text(lbl_session_reset, buf);
|
||||||
|
|
||||||
@@ -520,29 +988,67 @@ static void apply_battery_visibility(void) {
|
|||||||
else lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
|
else lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tapping the splash/animations screen returns to the home (usage) screen.
|
||||||
static void global_click_cb(lv_event_t* e) {
|
static void global_click_cb(lv_event_t* e) {
|
||||||
(void)e;
|
(void)e;
|
||||||
if (current_screen == SCREEN_SPLASH) ui_show_screen(prev_non_splash_screen);
|
if (current_screen == SCREEN_SPLASH) ui_show_screen(SCREEN_USAGE);
|
||||||
else ui_show_screen(SCREEN_SPLASH);
|
}
|
||||||
|
|
||||||
|
// The Claude logo on the home screen opens the launcher.
|
||||||
|
static void logo_click_cb(lv_event_t* e) {
|
||||||
|
(void)e;
|
||||||
|
ui_show_screen(SCREEN_MENU);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back chevron: from the launcher → home; from any app screen → launcher.
|
||||||
|
static void nav_back_cb(lv_event_t* e) {
|
||||||
|
(void)e;
|
||||||
|
ui_show_screen(current_screen == SCREEN_MENU ? SCREEN_USAGE : SCREEN_MENU);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-left chrome: the Claude logo (tap → menu) on home, the back chevron on the
|
||||||
|
// screens below it, neither on the splash.
|
||||||
|
static void update_nav_chrome(screen_t screen) {
|
||||||
|
bool show_logo = (screen == SCREEN_USAGE);
|
||||||
|
bool show_back = (screen != SCREEN_USAGE && screen != SCREEN_SPLASH);
|
||||||
|
if (logo_img) {
|
||||||
|
if (show_logo) lv_obj_clear_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
else lv_obj_add_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
if (nav_back_btn) {
|
||||||
|
if (show_back) lv_obj_clear_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
else lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ui_show_screen(screen_t screen) {
|
void ui_show_screen(screen_t screen) {
|
||||||
lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (menu_container) lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (stub_container) lv_obj_add_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (bt_container) lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (soundpad_container) lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (session_container) lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (nowplaying_container) lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
splash_hide();
|
splash_hide();
|
||||||
|
|
||||||
switch (screen) {
|
switch (screen) {
|
||||||
case SCREEN_SPLASH: splash_show(); break;
|
case SCREEN_SPLASH: splash_show(); break;
|
||||||
case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break;
|
case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||||
|
case SCREEN_MENU: lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||||
|
case SCREEN_SOUNDPAD: lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||||
|
case SCREEN_SESSION: lv_obj_clear_flag(session_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||||
|
case SCREEN_NOWPLAYING: lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||||
|
case SCREEN_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||||
|
case SCREEN_HOMEASSIST:
|
||||||
|
stub_show_for(screen);
|
||||||
|
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logo_img) {
|
|
||||||
if (screen == SCREEN_SPLASH) lv_obj_add_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
|
|
||||||
else lv_obj_clear_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (screen != SCREEN_SPLASH) prev_non_splash_screen = screen;
|
if (screen != SCREEN_SPLASH) prev_non_splash_screen = screen;
|
||||||
current_screen = screen;
|
current_screen = screen;
|
||||||
|
update_nav_chrome(screen);
|
||||||
apply_battery_visibility();
|
apply_battery_visibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -3,8 +3,14 @@
|
|||||||
#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_BLUETOOTH, // BLE connection info
|
||||||
SCREEN_COUNT,
|
SCREEN_COUNT,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,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