ZaiProvider replaces the zai stub. It reaches z.ai's Anthropic-compatible
endpoint (default https://api.z.ai/api/anthropic) exactly like Claude Code with
ANTHROPIC_BASE_URL pointed at z.ai: a /v1/messages POST with a Bearer key. The
key + base URL come from the config the control panel's z.ai field writes
(providers.zai.base_url/.api_key) — no OAuth, no local files.
Two z.ai-specific cares: (1) static API key, no self-refresh; (2) the GLM Coding
Plan meters by prompts, so a 60s poll would drain quota — the provider
self-throttles to one network call per poll_interval_s (default 15 min) and
serves a cached status in between. Rate-limit parsing assumes z.ai proxies
Anthropic's unified headers (5h->s/sr, 7d->w/wr); if a real response lacks them
it still reports connected and logs the limit-ish headers it did return, so the
mapping can be finalized against a live key.
tools/probe_zai.py dumps a real z.ai response's headers (key via env, never
printed) to pin the exact header names. Graceful without a key: selecting z.ai
shows the blue theme + "no key", never crashes the loop. Registry: all three
providers now real; unknown ids fall back to StubProvider. +7 z.ai tests (mocked
transport: header mapping, auth, throttle, fallback); 120 passed, 2 Linux-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OpenAICodexProvider replaces the openai stub. It reads the rate-limit snapshot
the Codex CLI already records locally (no second auth): each turn Codex writes a
token_count event into $CODEX_HOME/sessions/**/rollout-*.jsonl whose
payload.rate_limits mirrors Claude's model — primary (5h) + secondary (weekly),
each with used_percent + resets_at + plan_type. The provider surfaces the
freshest such snapshot; a window whose reset time has passed is reported as a
fresh 0% (local read, so current in-session and self-heals between sessions).
primary -> s/sr, secondary -> w/wr, plan_type -> status ("Plus"/…),
rate_limit_reached_type -> "limited". No per-token cost (subscription), so the
two rate-limit bars are the metric, per the locked v3 decision.
Verified against the real ~/.codex: full daemon payload with openai active =
{pv:openai, pnm:"OpenAI Codex", ac:0x10a37f, st:"Plus", ok:true, weekly reset}.
registry test updated (openai now real, zai still stub); +7 provider tests.
113 passed, 2 Linux-only failures (baseline). spec: hiddenimport added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New SCREEN_PROVIDER (Menu → "Provider"): shows the active provider's brand name
in the accent colour (recolors live on a theme switch via the shared accent
style) and its "i / c" position among the enabled providers. A Switch button
notifies {"cmd":"provnext"}; the daemon owns the enabled set/order and cycles to
the next one, so the watch stays dumb. When only one provider is enabled the
button is replaced by a hint.
main.cpp parses the optional pnm/pi/pc payload fields into ui_set_provider_badge.
Builds green on all four boards (206/216/18/216_c6); verified on 2.06 hardware
(OpenAI green theme + "2 / 3" + Switch).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Panel: new Providers tab (data-driven from GET /api/providers — labels/accents
from the daemon registry, enable/active/creds from config). z.ai base_url + key,
api_key masked and preserved on save like the HA token.
Daemon: request_provider_switch() lets the panel push an active-provider change
into the live BLE session (same _set_active_provider path as the watch) so it
takes effect now, not on the next 60s poll; _active_session exposes the session
to the panel thread. On-watch cycle groundwork: provnext -> _cycle_provider
(daemon owns the enabled set/order), and pnm/pi/pc pushed in the payload so the
watch's Provider screen can show the name + "1/3".
server.py: ConfigIn gains providers/active_provider/display_order; secrets masked
in _masked; active_provider change notifies the injected _command_sink.
tray wires set_command_sink(request_provider_switch).
Tests: test_server_providers (masking + sink), test_provider_switch (cycle +
offline persist). 106 passed, 2 Linux-only failures (baseline).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The watch recolors to the active provider's brand, live, and it's cheap to add
more providers later.
Firmware:
- theme.h: expose THEME_ACCENT_HEX; ui.cpp: COL_ACCENT becomes a runtime
lv_color_t backed by a shared style (s_accent_style). Static accent widgets
(launcher tile icons, usage status line, session tokens) attach the shared
style, so ui_set_theme() recolors them instantly via lv_obj_report_style_change
— no widget tracking, no recreation.
- ui_set_theme(pv, accent_rgb): swaps the accent + a small logo table keyed by
provider id (fallback to the Claude mark until a provider ships a logo). Change-
guarded, so the ~3s payload cadence doesn't churn.
- main.cpp: parse "pv" (id) + "ac" (0xRRGGBB) from the payload and apply.
- Adding a provider needs zero firmware color edits (accent is data-driven) and
at most one logo-table row.
Daemon: push "ac" = active provider's brand accent alongside "pv".
Test fix: test_providers used asyncio.run(), which closed the suite's shared
event loop and broke every later get_event_loop() test (Py3.13) — mirror the
repo's _run() helper instead.
Verified on the 2.06: forcing an OpenAI-green theme recolored the live launcher
icons (report_style_change path). 216 builds clean; daemon suite 94 passed.
Introduce the multi-provider seam on the host, behaviour-identical for Anthropic.
- config v2: `providers` (per-provider enable + creds), `active_provider`,
`display_order`; v1 configs migrate transparently (anthropic enabled+active).
New accessors: active_provider_id / provider_conf / enabled_providers.
- daemon/providers/: Provider ABC + normalized ProviderStatus (maps to the same
s/sr/w/wr/st/tk/to/tc/tn BLE fields), AnthropicProvider (wraps the existing
OAuth+poll+local-usage logic), StubProvider for openai/zai until M2/M3, and a
get_provider() registry.
- poll loop: polls the config-selected active provider each cycle and tags the
payload with `pv` (the brand-theme id the watch will wear). Watch->PC
`{"cmd":"prov","id":".."}` persists the active provider and repolls at once.
- clawdmeter.spec: bundle daemon.providers for the frozen exe.
- test: 7 new provider/config tests; fix a stale start_notify count assertion
(the M2 command channel means both REQ and CMD subscriptions are attempted).
Verified live: config loads as v2, active provider resolves to AnthropicProvider,
a real poll returns ok=True (s=53%, w=5%); full suite 87 passed (+7 new).
Kicks off v3-alpha on branch v3-dev. Locked decisions: rate-limit % via CLI
subscriptions, provider switch on both PC and watch, brand-colored themes
(clay / OpenAI green / z.ai blue). Architecture: daemon Provider abstraction,
`pv` payload field, firmware runtime palette + shared LVGL styles for live
recolor. Milestones M1 (framework+Anthropic+theming) → M2 (OpenAI) → M3 (z.ai).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The onefile build is windowed (no console), so sys.stderr is None and uvicorn's
default logging dictConfig fails with "Unable to configure formatter 'default'",
which took the whole settings-panel server down (the BLE side kept working, but
the Settings window couldn't load). Pass log_config=None so uvicorn skips its own
logging setup — the daemon has its own file logger and doesn't need uvicorn's.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold the Windows tray/daemon into one self-contained Clawdmeter.exe with a
settings UI, and wire the host side of the watch features:
- config.py: single %LOCALAPPDATA%\Clawdmeter\config.json (ha/buttons/settings),
atomic writes, auto-migration from the old ha_config.json.
- server.py: local FastAPI (127.0.0.1:8723) — GET/PUT /api/config (token masked),
POST /api/ha/test, GET /api/ha/entities, GET /api/status.
- web/index.html: brand-styled settings panel (Status/HA/Buttons/Settings tabs).
- panel.py: pywebview/WebView2 window, launched as its own process (pywebview and
pystray both want the main thread); tray "Settings" opens it via --panel.
- daemon: HA command dispatch (toggle/bri/ct), dynamic button labels + index→
action mapping, watch-battery low warning toast, and the dimmer "dim" snapshot
(dimreq → light_snapshot of the first entity) so the watch dial seeds from HA.
- clawdmeter.spec / requirements: bundle fastapi+uvicorn+pywebview+webview backend.
- build-exe.ps1: ASCII-only (Windows PowerShell 5.1 mangles em-dashes under cp1251).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Watch-side features developed on the AMOLED-2.06 and shared across all boards:
- BLE command channel (…0005, notify watch→PC) via ble_send_command(); the
watch stays "dumb" and the daemon maps commands to Home Assistant.
- Battery detail screen + protective low-voltage cutoff; power HAL gains
battery_mv() / shutdown(), with battery_est.{h,cpp} for the time-left anchor.
- Home screen: dynamic 2-column button grid driven by the desktop config
(RX "btns" labels); tap notifies {"cmd":"btn","i":N}.
- Dimmer screen: IMU tilt-joystick over the light's brightness / color temp.
imu_hal_read_accel() added to the HAL (real on 2.06/2.16, no-op elsewhere);
streams absolute {"cmd":"bri"|"ct","v":..}; daemon seeds the dial via "dim".
Per-board tilt axis is calibrated with the DIM_CALIB overlay (off by default).
Multi-board fix: enable LV_FONT_MONTSERRAT_20/28 on the 2.16, 1.8 and C6 envs.
Shared ui.cpp uses these glyphs for the launcher tiles and back chevron, so
those three boards had failed to compile since the Phase-2 UI landed. All four
envs now build clean. README: correct the Windows pairing name to "Clawdmeter".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
daemon/ha_client.py: HAClient (brightness, color_temp, on/off, state snapshot)
over the HA REST API with smooth `transition`; token is read from a local
%LOCALAPPDATA%\Clawdmeter\ha_config.json and is never logged. Verified
end-to-end against a real Gledopto GL-C-006P tunable-white dimmer.
ha_config.example.json is the committed template; the real config (with the
token) lives outside the repo and is .gitignored as a safety net.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gitea.wenil.tech is the old (internal-DNS-only) domain; the live host is
gitea.bvrdo.online. Use a direct release-asset link so download works even
while the instance ROOT_URL still points at the internal IP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Point README-windows.md at the published v2.0-beta.1 release so users
download Clawdmeter.exe directly from Gitea instead of building it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package the tray+daemon as a single PyInstaller onefile exe so it runs on a
fresh Windows 11 box with no Python and no pip install. Verified on hardware:
the frozen exe connects over BLE, reads the Windows media session, and pushes
now-playing (incl. Cyrillic) at the 3s / 60s cadence.
- clawdmeter.spec: onefile, windowed (no console; logs to daemon.log). collect_all
for winrt + bleak is the key bit — bleak loads its WinRT backend dynamically and
the winrt.windows.* projections are split distributions that PyInstaller's static
analysis misses, so BLE and the media session would otherwise fail at runtime.
upx off (lowers AV false-positive rate).
- build-exe.ps1: one-command build (venv + deps + pyinstaller + spec).
- tray_windows.py: resolve the brand-logo asset from sys._MEIPASS when frozen.
- autostart_windows.py: when frozen, the HKCU\Run "Start at login" value points at
the exe itself (sys.executable), not pythonw + script.
- .gitignore: ignore /build, /dist, /.venv — the exe ships via a Gitea release.
- README-windows.md: document the standalone-exe path (download / run / build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Decouple the two data sources that share the BLE link:
- Anthropic usage / rate-limit: still polled every 60s.
- Windows media session: read every 3s and pushed the moment the track or
play/pause state changes, so a song change reaches the watch in seconds
instead of at the next 60s poll. The last usage payload is cached and merged
into each now-playing write, so the firmware always gets one complete JSON
object and the usage screens never blank between polls.
Fix: long media titles (a 44-char Cyrillic title -> ~256 B once json escapes
each char to \uXXXX) overflowed the ATT MTU, so every write-without-response
failed with E_INVALIDARG and tripped the zombie-link break in a reconnect loop.
Switch the RX write to response=True (WinRT does a reliable long write; the RX
char already advertises WRITE and NimBLE reassembles into its 512 B buffer) and
serialize with ensure_ascii=False so Cyrillic goes as 2-byte UTF-8 instead of
6-byte escapes. Verified on hardware: stable link, writes succeed across the
60s heartbeat.
Firmware: only re-set the Now Playing labels when the track / state actually
changed, so the faster cadence does not restart the circular title-scroll
animation on every (often identical) payload.
requirements-windows.txt: add winrt-Windows.Media[.Control]. Phase 5 imports
them but they were never declared, so now-playing silently degraded to
"nothing playing" on a fresh machine.
tests: fix two stale poll_api assertions that expected an "ok" key poll_api has
not emitted since that flag moved to the caller (connect_and_run).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon reads the Windows "now playing" media session (WinRT SMTC) and
adds np/nt/na to the BLE payload; the watch shows a play/pause glyph, a
scrolling title, artist and status. Best-effort and token-independent, so
it works even when the rate-limit data is unavailable.
Cyrillic titles: Styrene B has no Cyrillic glyphs, so the title/artist use
new composite fonts (font_styrene_cyr_{28,20}) — Latin from Styrene B,
Cyrillic + typographic punctuation from Montserrat, merged by lv_font_conv.
Latin titles stay on-brand; only Cyrillic falls back to Montserrat.
- daemon: read_now_playing() via winrt-Windows.Media.Control (lazy import,
never raises; PLAYING->1, PAUSED->2, else 0; title/artist truncated,
empty fields omitted)
- firmware: UsageData np_state/np_title/np_artist + parser; the real Now
Playing screen replaces the shared stub (SCREEN_NOWPLAYING)
- fonts: assets/Montserrat-Medium.ttf, font_styrene_cyr_{28,20}.c
- tools: patch_lvgl9_font.py (automates the 4 LVGL 9 font patches),
screenshot_win.py (Windows serial screenshot via pyserial + Pillow)
- README: document Cyrillic composite font generation
Verified on hardware (waveshare_amoled_206): idle state, a live Latin
title (scrolling), and Cyrillic (Prohozhdenie / Dunduk) all render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In a managed Agent-SDK environment `claude login` is unavailable, so once
the stored access token expired the 5h/7d rate-limit % went dark forever.
The daemon now renews its own access token from the stored refreshToken via
the standard Claude Code OAuth refresh grant and writes the rotated tokens
back atomically (temp + os.replace, so a crash can't corrupt the file that
both the daemon and Claude Code read).
- proactive: refresh ~300s before expiry, checked each poll cycle
- reactive: on a genuine API 401, force one refresh + retry the poll once
- refresh tokens rotate on every use -> always persisted
- never raises: any refresh failure is logged and the loop continues
- secrets never logged (only lengths / expiry); error bodies are {"error":..}
Endpoint + client_id verified empirically against a live refresh
(HTTP 200, expires_in=28800).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Session screen's tokens/cost are computed locally from transcripts and need
no network, but they were gated behind a successful rate-limit API call — so an
expired OAuth token or API outage froze the whole watch.
Daemon: always compute and send local usage every cycle; rate-limit utilization
is now best-effort and merged on top when available, with ok=true only when it's
fresh (ok=false on expired token / API down). poll_api returns just the
rate-limit fields; connect_and_run owns the payload + ok flag.
Firmware: ui_update always refreshes the Session screen; the rate-limit bars and
the freshness clock update only when data->ok, so the usage view falls back to
its existing idle "Zzz" state instead of showing stale or zeroed percentages.
Verified: with the token expired (HTTP 401) the daemon still sends
tk/tc/tn/to + ok:false and the Session screen keeps updating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pulling the USB Bluetooth dongle made BleakScanner raise from inside the main
loop; the exception propagated out of daemon_main, the bg thread died, and the
tray froze on its last state with no recovery (reported: "daemon crashed and
doesn't come back").
Two layers of hardening:
1. main() loop now wraps scan+connect in try/except — adapter-gone errors are
logged, the tray shows Scanning, and it backs off and retries, so replugging
the adapter recovers automatically. CancelledError is re-raised.
2. The tray supervises the asyncio loop: an unexpected crash is logged and the
loop is RESTARTED after a backoff, gated by a quit_evt so Quit still stops
cleanly (and call_soon_threadsafe is guarded against a closed loop).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs made the Session numbers wildly inflated (~$300/122M when the real
figures were ~$46/57M):
1. No de-duplication. Claude Code copies prior history into a new transcript
file on every compaction/resume, so the same API turn appears in several
.jsonl files. Summing all lines counted each turn multiple times — ~2.7x
inflation today (a heavily-compacted session). De-dup by (messageId,
requestId), matching ccusage.
2. Wrong Opus pricing. Used the older $15/$75 in/out rates; claude-opus-4-x is
actually $5/$25 (cache-write $10, cache-read $0.50) — 3x too high. Rates
verified against ccusage/LiteLLM, which they now reproduce to 100%.
Verified end to end against a fresh ccusage run: total tokens, output tokens and
cost all match exactly (57,256,809 tok / $45.63). Daemon-only change; no firmware
reflash needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The total is ~97% cache reads, which reads as implausibly large on its own.
Show the cache-inclusive total as the headline token number and the output
tokens ("what Claude actually generated", ~1.1M) as a small line beneath it —
impressive total, believable sub-number. Daemon sends a new `to` field; firmware
parses output_today and renders it. Re-spaced the Session layout for the 5th row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The count is assistant API turns (each tool-use round is one), not user chat
messages — "messages" misled into reading it as conversation count / all-time.
"requests" reads correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Daemon: compute_today_usage() sums today's Claude Code token usage across the
local project transcripts (~/.claude/projects/**/*.jsonl), reading only files
modified today so the scan stays cheap, and prices it with Anthropic list rates
to show the equivalent API cost (the ccusage-style flex for subscription users).
The compact fields tk (total tokens), tc (cost in cents) and tn (message count)
piggyback onto the existing 60s BLE payload.
Firmware: UsageData gains tokens_today (64-bit) / cost_cents_today /
messages_today; parse_json reads tk/tc/tn; a new Session screen shows the cost
as the hero with total tokens and message count below, refreshed from ui_update
so it's current whenever opened. Wire SCREEN_SESSION to the real screen (was a
stub). Verified end to end: daemon sends e.g. tk=105374109 tc=26838 tn=663.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Soundpad app screen: a 2x3 grid of colored pads that each emit one of
F13..F18 over the existing BLE HID keyboard, for mapping to a host soundboard.
Key release is deferred via a one-shot LVGL timer so the UI thread never blocks.
Raise the HID report-map keycode ceiling from 101 (0x65) to 115 (0x73) in
ble.cpp so F13..F24 (0x68..0x73) are within the declared key-array range — they
were silently undeliverable before. Wire SCREEN_SOUNDPAD to the real screen
(was a stub).
NOTE: the HID report descriptor changed, so the host must re-pair to refresh its
cached map before F13..F18 are recognized.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tile grid used track_cross_place=CENTER, which vertically centers the
overflowing block of rows — hiding the top row under the title and springing
back to it after an elastic pull. Switch to START so rows stack from the top:
the first row sits under the title and the rest is reached by scrolling down.
Nudge the back chevron down/right so it clears the rounded top-left corner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the splash<->usage tap-toggle with a home + launcher model. Tapping the
Claude logo on the home (usage) screen opens a scrollable 2-column launcher
driven by an app registry — adding a screen is one registry line plus a builder,
no edits to shared navigation. A shared back chevron returns launcher->home and
app->launcher; tapping the splash returns to home.
Screens wired: Usage (home) and Animations (splash) are real; Bluetooth shows
live connection state + device name + MAC; Soundpad/Session/Now Playing/Home
point at a shared "coming soon" stub until their phases land. Enable Montserrat
20/28 for the LVGL symbol glyphs used by tile icons and the back chevron (the
brand Styrene/Tiempos fonts are Latin-only). Flash 20.3% -> 20.9%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-hardware calibration (5-point corner+center tap) confirms the FT3168 on the
2.06 panel reports directly in panel pixel space (~0..409 x ~0..501), correctly
oriented with no axis swap or mirror — a center tap reads ~(209,228) against the
410x502 panel center (205,251), a 1:1 mapping within finger error. The existing
raw passthrough is correct; record the verified result in the driver comment.
The temporary on-screen TOUCH_DEBUG overlay used to gather this has been removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Freeze the classic line on main (tagged v1.0-classic) and open the v2
development branch. Add a firmware version constant (2.0.0-dev) reported in
the boot banner so the daemon can tell classic from v2, and document the
phased v2 plan in docs/v2-roadmap.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/boards/waveshare-amoled-2.06.md (pins, CO5300 col offset 22,
Windows UTF-8 flashing gotcha, daemon name + address fixes) and list the
board in README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New board firmware/src/boards/waveshare_amoled_206/ (CO5300 410x502,
FT3168 touch, AXP2101 PMU, QMI8658 IMU). PlatformIO env
waveshare_amoled_206. CO5300_COL_OFFSET=22, fixed orientation,
PWR button on GPIO10, FocalTech inline touch reader.
- Windows daemon: fix advertised device name (Claude Controller ->
Clawdmeter) to match firmware ble.cpp, and add CLAWDMETER_ADDRESS env
fallback so it connects by address when the bonded HID device is not
advertising (Windows keeps it HID-connected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
platformio.ini and the board files labelled the Waveshare
ESP32-C6-Touch-AMOLED-2.16 as an SH8601 panel, when it actually uses a
CO5300 (per the official spec) -- the same controller as the S3
AMOLED-2.16. It still rendered because display.cpp grafted CO5300-style
manufacturer registers (a 0xFE page-switch writing the 0x19/0x1C
driving-voltage registers) onto the Arduino_SH8601 class, and both
controllers speak standard MIPI DCS for pixel writes.
Rebase onto Arduino_CO5300 (matching the S3 AMOLED-2.16 sibling) and keep
only the page-0x20 panel-driving registers (0x19/0x1C) the class init
omits; everything else (0xC4/0x36/0x53/0x51/0x63/0x29) is now covered by
Arduino_CO5300::begin().
Adopt the CO5300 rotation-0 default (MADCTL 0x00) instead of the old forced
0x30 transpose, orienting the board with the USB port on the side (the
preferred desk orientation). Touch axis mapping is re-derived and tap-tested
on hardware to match: swap=false, mirror=(false,false).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the host is connected over BLE but no usage update has landed recently
(~90s — token expired, daemon down, host asleep), show a calm idle screen
instead of leaving hours-old numbers on screen as if they were live: a shrunk,
animated "expression sleep" creature (reused claudepix art) centered between
the header and the status line.
The usage view resolves to three sub-screens via a data-freshness timer: live
panels, this idle screen, or the existing pairing hint. The bottom status line
animates present-participle gerunds: the idle screen alternates "Listening…" /
"No data…" (alive + explicit), the disconnected screen uses "Waiting…"
(replacing the static "Disconnected…").
splash_mini_create()/splash_mini_tick() render any claudepix animation shrunk
into an arbitrary parent, keeping the art data in the splash module.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Waveshare ships two hardware revisions under "ESP32-S3-Touch-AMOLED-1.8":
the original (SH8601 display + FT3168 touch @0x38) and a later one
(CO5300 display + CST816 touch @0x15). On the later board the SH8601
driver left the panel blank and the FT3168 probe spammed
ESP_ERR_INVALID_STATE because nothing answers at 0x38.
Detect the revision at boot by probing the touch address and pick the
matching drivers, so one binary drives both boards:
- board_rev.{h}/board_init.cpp: probe 0x15 (CST816) vs 0x38 (FT3168)
- display.cpp: instantiate Arduino_CO5300 or Arduino_SH8601 behind a
shared Arduino_OLED*; CO5300 gets a 16px column offset to center the
368-wide image (verified on hardware)
- touch.cpp: one FocalTech-style reader (regs 0x02..0x06) for both
chips, address chosen by revision
- board.h: add CST816_ADDR 0x15
- CLAUDE.md: document the dual revision
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The post-rotation brightness ramp targeted a hard-coded 200 (the old default),
which reset whatever the user had chosen via PWR-short-press after every IMU
rotation. Read brightness_get() instead and scale the 4-step ramp (30/60/85/100%)
toward that target.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brand consistency: the device now advertises as "Clawdmeter". Updated the
three functional spots that must agree for discovery-by-name to work —
ble.cpp DEVICE_NAME (advertised) and both daemons' DEVICE_NAME (scan match) —
plus the README/CLAUDE.md/install-script references (and fixed the stray
"Claudemeter" typo in the README).
Note: hosts cache the GATT name, so an already-paired device keeps showing the
old name until removed/re-paired (bluetoothctl remove <mac>).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- convert_to_c.js: remap the claudepix body color (#CD7F6A) to Anthropic brand
terracotta (#D97757) so the splash creature matches the brand; regenerate
splash_animations.h.
- README: document the 3-second hold-to-pair gesture, drop the now-removed
Bluetooth screen from the screenshots table + prose, and delete the
Bluetooth screenshots (216 + 1.8").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes the Bluetooth screen — the device now has just splash + Usage. Keeps
main's responsive Layout intact.
- ui: drop the Bluetooth screen, ui_cycle_screen, and SCREEN_BLUETOOTH. The
bottom spinner becomes a status line — Connected / Disconnected / Pairing…
(via ble_has_bonds()) plus the whimsical messages when connected and idle.
A "To pair / hold the power button / for 3 seconds, then release" hint shows
while disconnected so the screen isn't empty.
- main: hold-to-pair gesture (pair_tick) — hold PWR ~3s then RELEASE clears
bonds and re-advertises. Clearing on release (not while held) means holding
to power off (8s hardware shutdown) leaves the bond intact; disarms at 6s.
PWR short-press now cycles brightness on the Usage view (animations on splash).
- ble: add ble_has_bonds() for the status line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
idle owns the panel brightness (it fades between a "full" level and 0), so
user brightness control routes through a new idle_set_awake_brightness():
idle now fades to a runtime awake level instead of the hard-coded
DISPLAY_DEFAULT_BRIGHTNESS, and the chosen level survives idle fades + wake.
brightness.{h,cpp} holds a 4-step ramp {64,128,200,255} (default 200 == the
old constant, so default behavior is unchanged) persisted to NVS, and applies
the level through idle. Wired to the PWR button in the next commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds power_hal_pwr_long_pressed() and power_hal_pwr_released() to the HAL
contract so shared code can implement a hold-to-pair gesture board-agnostically.
- 216: enable the AXP2101 PKEY LONG + POSITIVE IRQs; bump press-off to 8s so a
~3-6s pair hold doesn't trip the hardware shutdown.
- 1.8": synthesize the same long/release edges in software from the polled
XCA9554 EXIO4 level (short-press now fires on release-if-short).
- template: stub the two new functions (no gesture) for new ports.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On macOS the firmware is auto-connected by the OS as a BLE HID keyboard,
and CoreBluetooth excludes already-connected peripherals from scan
results. bleak's connect-by-address path also scans internally, so the
daemon's scan loop never found the device ("Device not found" forever)
even though it was plainly visible in System Settings.
Discover the target on macOS via CoreBluetooth's
retrieveConnectedPeripheralsWithServices_ and connect to the returned
peripheral directly (no scan). The custom service UUID is matched first
(unambiguous); the generic HID service 0x1812 is only trusted on an exact
DEVICE_NAME match so it can't grab an unrelated keyboard/mouse. A
peripheral that fails to connect is skipped for one cycle so the scan
fallback stays reachable. The device's two connection slots let the OS
HID link and the daemon run simultaneously.
Linux/BlueZ path is unchanged (still scans + caches address); the
redundant per-reconnect address save was removed so caching happens only
on a fresh scan.
Adds daemon/test_macos_connect.py, a foreground smoke test for the macOS
connect path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>