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>
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.
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>
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>
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>
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>
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>
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>
- Add complete board support for Waveshare ESP32-C6-Touch-AMOLED-2.16 with SH8601 display driver
- Implement board initialization with AXP2101 PMU rail configuration for LCD and touch power
- Add display driver for SH8601 QSPI panel (480×480 resolution)
- Add CST9217 touch controller support via I2C
- Add QMI8658 IMU initialization and sensor reading
- Add AXP2101 power management and battery monitoring
- Add input handling for BOOT button (GPIO 9)
- Configure PlatformIO environment with C6-specific build flags and 16 MB flash layout
- Update capability flags documentation to clarify BOARD_HAS_PSRAM build-flag macro usage
- C6 has no external PSRAM; shared code gates on this flag to use internal SRAM and reduce LVGL buffer sizes
- Screenshot capture disabled on this board due to internal SRAM constraints
Replaces the build-flag-driven #ifdef sprawl (~30 blocks across 6 files)
with a small HAL in firmware/src/hal/ and per-board folders under
firmware/src/boards/. Shared code (main.cpp, ui.cpp, splash.cpp) no
longer contains a single `#ifdef BOARD_*` — optional features are
guarded by BoardCaps (runtime) and BOARD_HAS_* macros (compile-time,
inside the board's own files).
Why: lets community contributors port to new ESP32 + AMOLED + touch
combos by dropping in a boards/<name>/ folder + a PlatformIO env,
without touching shared files. See docs/porting/adding-a-board.md.
Highlights:
- New HAL: display_hal, touch_hal, input_hal, power_hal, imu_hal,
board_caps. Each board provides display.cpp, touch.cpp, input.cpp,
power.cpp, imu.cpp, caps.cpp, board_init.cpp + private hardware
drivers (e.g. io_expander.{h,cpp} on AMOLED-1.8).
- PlatformIO build_src_filter selects each board's folder per env.
- ui.cpp picks fonts and layout from board_caps() via compute_layout()
with screen-height breakpoints (>= 460 → large, else compact).
- splash.cpp computes CELL = min(W,H)/20 — responsive instead of two
hardcoded values.
- idle.cpp (from #24) rewired through display_hal + power_hal — no
longer depends on the deleted display_cfg.h / power.h.
- power_hal gains power_hal_is_vbus_in() for idle's
IDLE_SLEEP_WHEN_CHARGING gate.
- boards/template/ + docs/porting/{adding-a-board,hal-contract,
capability-flags}.md to bootstrap new ports.
- display_cfg.h, power.{h,cpp}, imu.{h,cpp}, io_expander.{h,cpp}
deleted from src/ root (moved into boards/<name>/ or hal/).
Verification: both `pio run -e waveshare_amoled_216` and
`pio run -e waveshare_amoled_18` succeed unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port the firmware to a second Waveshare AMOLED board variant alongside
the original 2.16" / 480×480. Selection is at build time via a board
macro; both envs continue to build and flash independently.
Hardware delta vs AMOLED-2.16:
* Display: SH8601 QSPI (vs CO5300), 368×448 portrait (vs 480² square),
SCLK on GPIO 11 (vs 38), RST routed through an XCA9554 I/O expander
(vs direct GPIO).
* Touch: FT3168 @ 0x38 (vs CST9220 @ 0x5A). Driven by a ~30-line
inline FocalTech-protocol reader in main.cpp — Waveshare's
Arduino_DriveBus library is GPLv3 and would force the project's
license, which the README explicitly avoids.
* I/O expander: new XCA9554/PCA9554 @ I2C 0x20 gates LCD_RST, TP_RST,
audio amp enable, and the PWR button. Wrapped in io_expander.{h,cpp}.
Must initialize BEFORE display/touch or both stay in reset.
* PMU + IMU: same AXP2101 + QMI8658 as 2.16, so power.cpp/imu.cpp
largely reuse. IMU is initialized for I2C bus health but rotation
is fixed at 0° (the portrait panel doesn't benefit from auto-rotate
and the strip-rotation CPU path is excluded entirely).
* Buttons: only BOOT (GPIO 0) is a direct GPIO; PWR is read via
XCA9554 EXIO4 polling. No third button — the AMOLED-2.16's
Shift+Tab key (GPIO 18) is dropped for this board.
* Flash: 16MB (vs 8MB), partition table set to default_16MB.csv.
Code structure:
* display_cfg.h hosts a #ifdef BOARD_AMOLED_18 / #else / #endif split
with a PlatformDisplay typedef so call sites elsewhere stay clean.
* main.cpp, power.cpp, ui.cpp, splash.cpp gate board-specific bits on
the same macro. Layout constants (panel heights, content Y, font
choices on the Bluetooth screen) compress for the 368×448 portrait
footprint so two usage panels + bottom animation label fit without
horizontal overflow.
* Splash scales the 20×20 pixel-art grid to 360×360 (CELL=18 vs 24)
and centers in the portrait canvas.
* Old AMOLED-2.16 env is unchanged at the source level (gated under
#ifndef BOARD_AMOLED_18) and continues to compile + flash on the
original hardware.
CLAUDE.md updated with the two-board pin maps, build/flash commands
for both envs (macOS + Linux device paths), and the per-board gotchas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses review feedback on #24:
- New config IDLE_SLEEP_WHEN_CHARGING (default false): while USB is
plugged in we don't enter sleep and we wake immediately if power
comes back. Covers both "I like watching Clawd animations on my
plugged-in desk device" and "alternative hardware without a battery
is always on USB, sleep would be odd."
- New config IDLE_WAKE_ON_TOUCH (default true): a touch on the dark
panel wakes the device. The first touch is swallowed (mirrors the
button wake-consumption), so tapping a dark screen wakes without
also toggling splash<->usage. Set false to keep the original
pets/sleeves-safe behaviour where touch is fully ignored while
asleep.
power.{h,cpp} grow a power_is_vbus_in() helper backed by
pmu.isVbusIn() — distinct from isCharging() so the no-battery case
("plugged in, nothing to charge") is detected correctly.
AMOLED brightness fades to 0 after IDLE_TIMEOUT_MS without a physical
button press; any of the three buttons (BTN_BACK / BTN_FWD / PWR) fades
it back in. The first press from sleep is consumed for wake only —
Space / Shift+Tab / cycle-screen only fire on the second press, so
reaching for the device to glance at it doesn't accidentally send PTT
to Claude.
Touch never counts as activity and is fully swallowed while asleep:
pets and sleeves can't wake it, and LVGL can't secretly toggle
splash<->usage behind a black panel.
Rotation handling defers while asleep so its blank+ramp doesn't fight
the idle fade; a rotation that happens during sleep is detected and
ramped in on wake.
All tunables live in idle_cfg.h (timeout, fade durations, default
brightness) — nothing is hard-coded in main.cpp.
AMOLED brightness=0 is true 0 emission on these self-emissive panels,
so no extra power-management plumbing (light-sleep, AXP rail gating)
is needed to get the battery savings.
The detect-rotation/blank/redraw block and the brightness step-up block
were two stateful chunks in loop() glued by a brightness_ramp global.
Fold them into handle_rotation_change() with local static state — the
'on rotation change, flash to black then ramp back' invariant lives in
one place and the loop body shrinks.
touch_read() had two branches that both called getPoint() unconditionally
— the touch_data_ready flag served no purpose. Gate the I2C transaction
on the flag so the ISR finally pulls its weight: idle loops no longer
hit I2C, getPoint() runs only when the CST9220 actually raised the
TP_INT line.
Also scope touch_pressed/x/y to file-static (no cross-TU users) and
drop the stale 'permanent while we work on it' comment by the default
boot screen — splash-on-boot is documented in CLAUDE.md.
Group splash animations into four moods (idle/normal/active/heavy) and pick
which group plays based on a 5-min sliding rate of session_pct. Thresholds
sit at 0.10/0.20/0.33 %/min so Heavy fires when usage matches or beats the
5-hour session reset pace. Splash auto-rotates within the current group
every 20s; group recomputes on every BLE poll and the splash re-picks
immediately if it changes mid-display. 4-min minimum window prevents
single-sample bumps after boot from falsely landing in Heavy.
UX overhaul:
- Drop the Controller screen entirely (touch zones, swipe gestures, HID
gesture engine, four icon arrays, hand icon)
- Two persistent screens (Usage, Bluetooth) cycled by the middle PWR
button; splash becomes a touch-toggled welcome animation
- Left button (GPIO 0): hold to send Space (Claude Code voice-mode PTT)
- Right button (GPIO 18): press to send Shift+Tab (mode toggle)
- Middle button on splash cycles animations instead of screens
- Remove USB plug/unplug auto-switch (Controller was the unplug target)
- Hide battery indicator on splash when in LOW state — it's noisy over
the pixel-art animations
Implementation:
- Delete firmware/src/hid.{cpp,h} and touch.{cpp,h}; their roles fold
into main.cpp button handlers and LVGL CLICKED events respectively
- splash_get_root() exposes the splash container so ui.cpp can attach
a click handler for the dismiss-on-tap behavior
- Tap detection uses LVGL's built-in LV_EVENT_CLICKED with event
bubbling on the inner panels — debouncing is handled by LVGL
- On Bluetooth screen, only the Reset Bluetooth zone is clickable;
taps elsewhere are no-ops (no conflict with splash toggle)
Docs:
- README: drop Controller from screens table, rewrite Physical buttons
table, remove Gesture controls section
- CLAUDE.md: update file map, button assignments, drop gesture refs
Full hardware swap from Panlee SC01 Plus (480×320 IPS) to Waveshare 2.16"
square AMOLED (480×480, CO5300 + CST9220 + AXP2101 + QMI8658). Library
stack moves to Arduino_GFX, SensorLib, XPowersLib on the pioarduino
platform 55.03.38-1 (Arduino Core 3.x).
UI:
- 4 screens (splash, usage, controller, bluetooth) with 3-button physical
navigation: GPIO 0 = prev, AXP PKEY = cycle, GPIO 18 = next.
- IMU-driven 90° auto-rotation. CO5300 can't rotate via MADCTL, so flush
callback does CPU strip-rotation in PARTIAL render mode. Rotation
transitions use AMOLED brightness flash (instant black → redraw → ramp).
- Battery indicator (Lucide icons) in upper-right, RGB565A8 alpha so it
blends over the splash animations.
- USB plugged/unplugged auto-switches between Usage and Controller
screens (suppressed while on splash).
- Fonts and icons re-scaled ~1.9× for the higher-DPI panel; 20px margins
to clear rounded corners.
Splash:
- 13 × 20×20 pixel-art creature animations sourced from
claudepix.vercel.app via tools/scrape_claudepix.js (scraper handles
both PRESET creature-engine and standalone FRAMES+PAL formats).
tools/convert_to_c.js emits firmware/src/splash_animations.h.
Attribution preserved in README and the generated header.
Tooling:
- tools/png_to_lvgl.js converts alpha PNGs to LVGL RGB565A8 (planar
layout, with --tint to colorize Lucide black-on-transparent sources).
- tools/scrape_claudepix.js, tools/convert_to_c.js for the splash
pipeline.
Docs:
- New worktree CLAUDE.md with hardware pin map, file inventory, build
commands, and the 8 critical gotchas (CO5300 rotation, OPI PSRAM,
pioarduino requirement, LVGL 9 font patching, centralized touch read,
even-aligned flush regions, touch swap/mirror values, RGB565A8 layout).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move all communication from USB to BLE. The device now advertises as
"Claude Controller" and acts as both a BLE HID keyboard (for touch
gestures) and a GATT data server (for usage updates from the daemon).
- Add NimBLE-Arduino BLE module with custom GATT service + HID keyboard
- Add third screen (Bluetooth) with connection status, MAC, reset button
- Rewrite daemon in bash using bluetoothctl/busctl for BLE GATT writes
- Add screenshot capture via LVGL snapshot over serial
- Remove TinyUSB mode — normal pio upload works again
- Update README with BLE architecture, screenshots, gesture docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SC01 Plus now acts as a composite USB device (CDC serial + HID keyboard).
Touch gestures on the screen send keystrokes to the computer:
- Swipe up/down: arrow keys
- Swipe right: Enter
- Swipe left: Shift+Tab (change mode in Claude Code)
- Double-tap logo: Ctrl+Space (voice mode)
- Tap and hold: Space (held while touching)
Gesture-to-key mapping is defined as a simple config table in hid.cpp.
Touch detection uses a state machine with configurable thresholds in touch.cpp.
Switches to TinyUSB composite mode (ARDUINO_USB_MODE=0). Flashing now
requires flash.sh which handles the DTR/RTS bootloader reset.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Physical desk monitor that displays Claude Code usage limits (5-hour session
and 7-day weekly utilization) on an ESP32-S3 touchscreen via USB serial.
Firmware: LVGL 9 dashboard with LovyanGFX on SC01 Plus, Anthropic brand
colors and custom fonts (Tiempos, Styrene B, DejaVu Sans Mono), Claude
spinner animation with rotating status words.
Daemon: Pure bash script that reads the Claude Code OAuth token, makes a
minimal Haiku API call, extracts usage from rate-limit response headers,
and sends JSON to the ESP32 over serial. Uses inotifywait for instant
USB plug/unplug detection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>