diff --git a/.gitignore b/.gitignore
index 2ab14023e..0161ce462 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,15 @@ firmware/.vscode/
# Node tooling deps (npm install --no-save)
tools/node_modules/
tools/package-lock.json
+
+# Mac daemon venv + local pio install
+daemon/.venv/
+.pio-venv/
+
+# Python bytecode cache
+__pycache__/
+*.pyc
+
+# IDEs
+.idea/
+.vscode/
diff --git a/daemon/claude_usage_daemon.py b/daemon/claude_usage_daemon.py
new file mode 100755
index 000000000..4451e8a4b
--- /dev/null
+++ b/daemon/claude_usage_daemon.py
@@ -0,0 +1,331 @@
+#!/usr/bin/env python3
+"""Claude Usage Tracker Daemon (BLE) — macOS port of claude-usage-daemon.sh.
+
+Polls Claude API rate-limit headers and writes a JSON payload to the
+ESP32 "Claude Controller" peripheral over a custom GATT service. Uses
+bleak (CoreBluetooth backend on macOS).
+"""
+
+import asyncio
+import getpass
+import json
+import os
+import re
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import httpx
+from bleak import BleakClient, BleakScanner
+from bleak.exc import BleakError
+
+DEVICE_NAME = "Claude Controller"
+SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
+RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
+REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
+
+POLL_INTERVAL = 60
+TICK = 5
+SCAN_TIMEOUT = 8.0
+
+# macOS: token lives in Keychain (service "Claude Code-credentials").
+# Linux: token lives in ~/.claude/.credentials.json.
+KEYCHAIN_SERVICE = "Claude Code-credentials"
+CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json"
+SAVED_ADDR_FILE = Path.home() / ".config" / "claude-usage-monitor" / "ble-address"
+
+API_URL = "https://api.anthropic.com/v1/messages"
+API_HEADERS_TEMPLATE = {
+ "anthropic-version": "2023-06-01",
+ "anthropic-beta": "oauth-2025-04-20",
+ "Content-Type": "application/json",
+ "User-Agent": "claude-code/2.1.5",
+}
+API_BODY = {
+ "model": "claude-haiku-4-5-20251001",
+ "max_tokens": 1,
+ "messages": [{"role": "user", "content": "hi"}],
+}
+
+
+def log(msg: str) -> None:
+ print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
+
+
+def _extract_access_token(blob: str) -> str | None:
+ """Pull the accessToken out of a credentials blob.
+
+ Claude Code stores credentials as a JSON object; the blob may also be
+ nested ({"claudeAiOauth": {"accessToken": "..."}}). Fall back to a
+ regex match so unexpected shapes still work, and finally treat the
+ blob as a raw token if nothing else matches.
+ """
+ blob = blob.strip()
+ if not blob:
+ return None
+ try:
+ data = json.loads(blob)
+ except json.JSONDecodeError:
+ data = None
+ if isinstance(data, dict):
+ # direct: {"accessToken": "..."}
+ if isinstance(data.get("accessToken"), str):
+ return data["accessToken"]
+ # nested: {"claudeAiOauth": {"accessToken": "..."}}
+ for v in data.values():
+ if isinstance(v, dict) and isinstance(v.get("accessToken"), str):
+ return v["accessToken"]
+ m = re.search(r'"accessToken"\s*:\s*"([^"]+)"', blob)
+ if m:
+ return m.group(1)
+ # Raw token (no JSON wrapper) — must look plausible (sk-ant-... etc.)
+ if re.fullmatch(r"[A-Za-z0-9_\-.~+/=]{20,}", blob):
+ return blob
+ return None
+
+
+def _read_token_keychain() -> str | None:
+ try:
+ out = subprocess.run(
+ [
+ "security",
+ "find-generic-password",
+ "-s",
+ KEYCHAIN_SERVICE,
+ "-a",
+ getpass.getuser(),
+ "-w",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ except subprocess.CalledProcessError as e:
+ log(f"Keychain read failed (rc={e.returncode}): {e.stderr.strip()}")
+ return None
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
+ log(f"Keychain access error: {e}")
+ return None
+ return _extract_access_token(out.stdout)
+
+
+def _read_token_file() -> str | None:
+ try:
+ raw = CREDENTIALS_PATH.read_text()
+ except OSError as e:
+ log(f"Error reading credentials: {e}")
+ return None
+ return _extract_access_token(raw)
+
+
+def read_token() -> str | None:
+ if sys.platform == "darwin":
+ return _read_token_keychain()
+ return _read_token_file()
+
+
+def load_cached_address() -> str | None:
+ if not SAVED_ADDR_FILE.exists():
+ return None
+ addr = SAVED_ADDR_FILE.read_text().strip()
+ # Accept both Linux MAC (AA:BB:CC:DD:EE:FF) and macOS CoreBluetooth UUID
+ # (E621E1F8-C36C-495A-93FC-0C247A3E6E5F).
+ if re.fullmatch(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}", addr) or re.fullmatch(
+ r"[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}", addr
+ ):
+ return addr
+ log("Cached address malformed, discarding")
+ SAVED_ADDR_FILE.unlink(missing_ok=True)
+ return None
+
+
+def save_address(addr: str) -> None:
+ SAVED_ADDR_FILE.parent.mkdir(parents=True, exist_ok=True)
+ SAVED_ADDR_FILE.write_text(addr)
+
+
+async def scan_for_device() -> str | None:
+ log(f"Scanning for '{DEVICE_NAME}' ({SCAN_TIMEOUT}s)...")
+ devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT)
+ for d in devices:
+ if d.name == DEVICE_NAME:
+ log(f"Found: {d.address}")
+ return d.address
+ return None
+
+
+async def poll_api(token: str) -> dict | None:
+ headers = dict(API_HEADERS_TEMPLATE)
+ headers["Authorization"] = f"Bearer {token}"
+ try:
+ async with httpx.AsyncClient(timeout=20.0) as http:
+ resp = await http.post(API_URL, headers=headers, json=API_BODY)
+ except httpx.HTTPError as e:
+ log(f"API call failed: {e}")
+ return None
+
+ def hdr(name: str, default: str = "0") -> str:
+ return resp.headers.get(name, default)
+
+ now = time.time()
+
+ def reset_minutes(reset_ts: str) -> int:
+ try:
+ r = float(reset_ts)
+ except ValueError:
+ return 0
+ mins = (r - now) / 60.0
+ return int(round(mins)) if mins > 0 else 0
+
+ def pct(util: str) -> int:
+ try:
+ return int(round(float(util) * 100))
+ except ValueError:
+ return 0
+
+ payload = {
+ "s": pct(hdr("anthropic-ratelimit-unified-5h-utilization")),
+ "sr": reset_minutes(hdr("anthropic-ratelimit-unified-5h-reset")),
+ "w": pct(hdr("anthropic-ratelimit-unified-7d-utilization")),
+ "wr": reset_minutes(hdr("anthropic-ratelimit-unified-7d-reset")),
+ "st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"),
+ "ok": True,
+ }
+ return payload
+
+
+class Session:
+ def __init__(self, client: BleakClient) -> None:
+ self.client = client
+ self.refresh_requested = asyncio.Event()
+
+ def _on_refresh(self, _char, _data: bytearray) -> None:
+ log("Refresh requested by device")
+ self.refresh_requested.set()
+
+ async def setup_refresh_subscription(self) -> None:
+ try:
+ await self.client.start_notify(REQ_CHAR_UUID, self._on_refresh)
+ except (BleakError, ValueError) as e:
+ log(f"Refresh subscription unavailable: {e}")
+
+ async def write_payload(self, payload: dict) -> bool:
+ data = json.dumps(payload, separators=(",", ":")).encode()
+ log(f"Sending: {data.decode()}")
+ try:
+ await self.client.write_gatt_char(RX_CHAR_UUID, data, response=False)
+ return True
+ except BleakError as e:
+ log(f"Write failed: {e}")
+ return False
+
+
+async def connect_and_run(address: str, stop_event: asyncio.Event) -> bool:
+ """Connect to a known address and poll until disconnected or stopped.
+
+ Returns True if the connection was used successfully (so the caller
+ keeps the cached address), False if the connection failed and the
+ cache should be invalidated.
+ """
+ log(f"Connecting to {address}...")
+ client = BleakClient(address)
+ try:
+ await client.connect()
+ except (BleakError, asyncio.TimeoutError) as e:
+ log(f"Connection failed: {e}")
+ return False
+
+ if not client.is_connected:
+ log("Connection failed (no error but not connected)")
+ return False
+
+ log("Connected")
+ session = Session(client)
+ await session.setup_refresh_subscription()
+
+ last_poll = 0.0
+ used_successfully = False
+ try:
+ while client.is_connected and not stop_event.is_set():
+ now = time.time()
+ elapsed = now - last_poll
+ if session.refresh_requested.is_set() or elapsed >= POLL_INTERVAL:
+ session.refresh_requested.clear()
+ token = read_token()
+ if not token:
+ log("No token; skipping poll")
+ else:
+ payload = await poll_api(token)
+ if payload is not None:
+ if await session.write_payload(payload):
+ last_poll = time.time()
+ used_successfully = True
+
+ try:
+ await asyncio.wait_for(session.refresh_requested.wait(), timeout=TICK)
+ except asyncio.TimeoutError:
+ pass
+ finally:
+ try:
+ await client.disconnect()
+ except BleakError:
+ pass
+
+ log("Device disconnected" if not stop_event.is_set() else "Stopping")
+ return used_successfully
+
+
+async def main() -> None:
+ stop_event = asyncio.Event()
+ loop = asyncio.get_running_loop()
+
+ def _stop(*_args: object) -> None:
+ log("Daemon stopping")
+ stop_event.set()
+
+ for sig in (signal.SIGINT, signal.SIGTERM):
+ try:
+ loop.add_signal_handler(sig, _stop)
+ except NotImplementedError:
+ signal.signal(sig, _stop)
+
+ log("=== Claude Usage Tracker Daemon (BLE, macOS) ===")
+ log(f"Poll interval: {POLL_INTERVAL}s")
+
+ backoff = 1
+ while not stop_event.is_set():
+ address = load_cached_address()
+ if not address:
+ address = await scan_for_device()
+ if address:
+ save_address(address)
+ else:
+ log(f"Device not found, retrying in {backoff}s...")
+ try:
+ await asyncio.wait_for(stop_event.wait(), timeout=backoff)
+ except asyncio.TimeoutError:
+ pass
+ backoff = min(backoff * 2, 60)
+ continue
+
+ ok = await connect_and_run(address, stop_event)
+ if not ok:
+ log("Invalidating cached address")
+ SAVED_ADDR_FILE.unlink(missing_ok=True)
+ try:
+ await asyncio.wait_for(stop_event.wait(), timeout=backoff)
+ except asyncio.TimeoutError:
+ pass
+ backoff = min(backoff * 2, 60)
+ else:
+ backoff = 1
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ sys.exit(0)
diff --git a/daemon/com.user.claude-usage-daemon.plist b/daemon/com.user.claude-usage-daemon.plist
new file mode 100644
index 000000000..1cdbe1c2e
--- /dev/null
+++ b/daemon/com.user.claude-usage-daemon.plist
@@ -0,0 +1,43 @@
+
+
+
+
+ Label
+ com.user.claude-usage-daemon
+
+ ProgramArguments
+
+ __PYTHON_BIN__
+ __DAEMON_PATH__
+
+
+ WorkingDirectory
+ __REPO_DIR__
+
+ RunAtLoad
+
+
+ KeepAlive
+
+ SuccessfulExit
+
+
+
+ ThrottleInterval
+ 10
+
+ StandardOutPath
+ __LOG_OUT__
+
+ StandardErrorPath
+ __LOG_ERR__
+
+ EnvironmentVariables
+
+ HOME
+ __HOME__
+ PATH
+ /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin
+
+
+
diff --git a/firmware/platformio.ini b/firmware/platformio.ini
index e87cd30f0..8852bec5d 100644
--- a/firmware/platformio.ini
+++ b/firmware/platformio.ini
@@ -1,4 +1,4 @@
-[env:waveshare_amoled_216]
+[common]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip
board = esp32-s3-devkitc-1
framework = arduino
@@ -32,6 +32,7 @@ build_flags =
-DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=1
-DLV_USE_SNAPSHOT=1
+ -DLV_USE_CANVAS=1
lib_deps =
moononournation/GFX Library for Arduino@^1.5.6
@@ -40,3 +41,25 @@ lib_deps =
lvgl/lvgl@^9.2.0
bblanchon/ArduinoJson@^7.0.0
h2zero/NimBLE-Arduino@^2.1.1
+
+[env:waveshare_amoled_216]
+platform = ${common.platform}
+board = ${common.board}
+framework = ${common.framework}
+board_build.arduino.memory_type = ${common.board_build.arduino.memory_type}
+upload_speed = ${common.upload_speed}
+monitor_speed = ${common.monitor_speed}
+build_flags = ${common.build_flags}
+lib_deps = ${common.lib_deps}
+
+[env:waveshare_amoled_18]
+platform = ${common.platform}
+board = ${common.board}
+framework = ${common.framework}
+board_build.arduino.memory_type = ${common.board_build.arduino.memory_type}
+upload_speed = ${common.upload_speed}
+monitor_speed = ${common.monitor_speed}
+build_flags =
+ ${common.build_flags}
+ -DBOARD_AMOLED_18
+lib_deps = ${common.lib_deps}
diff --git a/firmware/src/display_cfg.h b/firmware/src/display_cfg.h
index 4e6f41c56..6d9e4273a 100644
--- a/firmware/src/display_cfg.h
+++ b/firmware/src/display_cfg.h
@@ -1,37 +1,82 @@
#pragma once
#include
-#include
#include
#include
#include
-// ---- Display resolution ----
-#define LCD_WIDTH 480
-#define LCD_HEIGHT 480
-
-// ---- QSPI display pins (CO5300) ----
-#define LCD_CS 12
-#define LCD_SCLK 38
-#define LCD_SDIO0 4
-#define LCD_SDIO1 5
-#define LCD_SDIO2 6
-#define LCD_SDIO3 7
-#define LCD_RESET 2
-
-// ---- Touch pins (CST9220 via I2C) ----
-#define IIC_SDA 15
-#define IIC_SCL 14
-#define TP_INT 11
-#define TP_RST 2 // shared with LCD_RESET
-#define CST9220_ADDR 0x5A
-
-// ---- PMU (AXP2101 via same I2C) ----
-#define AXP2101_ADDR 0x34
+// ---- Per-board pin / driver configuration ----
+#ifdef BOARD_AMOLED_18
+ // Waveshare ESP32-S3-Touch-AMOLED-1.8 (368x448 SH8601, FT3168 touch,
+ // resets via TCA9554 I2C expander).
+ #include
+
+ #define LCD_W 368
+ #define LCD_H 448
+
+ // QSPI display pins (SH8601)
+ #define LCD_CS 12
+ #define LCD_SCLK 11
+ #define LCD_SDIO0 4
+ #define LCD_SDIO1 5
+ #define LCD_SDIO2 6
+ #define LCD_SDIO3 7
+ // Reset is on TCA9554 P0, not an ESP32 GPIO
+ #define LCD_RESET GFX_NOT_DEFINED
+
+ // Touch pins (FT3168 via I2C, shared bus with PMU)
+ #define IIC_SDA 15
+ #define IIC_SCL 14
+ #define TP_INT 21
+ #define TP_RST -1 // on TCA9554 P1
+ #define FT3168_ADDR 0x38
+
+ // TCA9554 I/O expander — drives LCD_RST (P0), TP_RST (P1), AUX_RST (P2)
+ #define TCA9554_ADDR 0x20
+ #define TCA9554_PIN_LCD_RST 0
+ #define TCA9554_PIN_TP_RST 1
+ #define TCA9554_PIN_AUX_RST 2
+
+ extern TouchDrvFT6X36 touch;
+
+#else
+ // Waveshare ESP32-S3-Touch-AMOLED-2.16 (480x480 CO5300, CST9220 touch).
+ #include
+
+ #define LCD_W 480
+ #define LCD_H 480
+
+ #define LCD_CS 12
+ #define LCD_SCLK 38
+ #define LCD_SDIO0 4
+ #define LCD_SDIO1 5
+ #define LCD_SDIO2 6
+ #define LCD_SDIO3 7
+ #define LCD_RESET 2
+
+ #define IIC_SDA 15
+ #define IIC_SCL 14
+ #define TP_INT 11
+ #define TP_RST 2 // shared with LCD_RESET
+ #define CST9220_ADDR 0x5A
+
+ extern TouchDrvCST92xx touch;
+#endif
+
+// Legacy aliases — much of the codebase still uses LCD_WIDTH/LCD_HEIGHT.
+// Keep both names valid; LCD_W/LCD_H is preferred for new code.
+#define LCD_WIDTH LCD_W
+#define LCD_HEIGHT LCD_H
+
+// ---- PMU (AXP2101 via I2C, same on both boards) ----
+#define AXP2101_ADDR 0x34
// ---- Global hardware objects (defined in main.cpp) ----
extern Arduino_DataBus *bus;
-extern Arduino_CO5300 *gfx;
-extern TouchDrvCST92xx touch;
+#ifdef BOARD_AMOLED_18
+ extern Arduino_SH8601 *gfx;
+#else
+ extern Arduino_CO5300 *gfx;
+#endif
extern XPowersPMU pmu;
extern SensorQMI8658 imu;
diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp
index 65552cf48..8a511da4a 100644
--- a/firmware/src/main.cpp
+++ b/firmware/src/main.cpp
@@ -20,13 +20,44 @@
// ---- Hardware objects ----
Arduino_DataBus *bus = new Arduino_ESP32QSPI(
LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3);
+#ifdef BOARD_AMOLED_18
+Arduino_SH8601 *gfx = new Arduino_SH8601(
+ bus, LCD_RESET, 0 /* rotation */,
+ LCD_W, LCD_H, 0, 0, 0, 0);
+TouchDrvFT6X36 touch;
+#else
Arduino_CO5300 *gfx = new Arduino_CO5300(
bus, LCD_RESET, 0 /* rotation */,
- LCD_WIDTH, LCD_HEIGHT, 0, 0, 0, 0);
+ LCD_W, LCD_H, 0, 0, 0, 0);
TouchDrvCST92xx touch;
+#endif
XPowersPMU pmu;
SensorQMI8658 imu;
+#ifdef BOARD_AMOLED_18
+// Minimal TCA9554 driver — drives reset lines for LCD, touch, and one
+// peripheral (P0/P1/P2). The Waveshare 1.8 board routes these resets
+// through the expander rather than direct GPIOs.
+static bool tca9554_write(uint8_t reg, uint8_t val) {
+ Wire.beginTransmission(TCA9554_ADDR);
+ Wire.write(reg);
+ Wire.write(val);
+ return Wire.endTransmission() == 0;
+}
+// Pulse P0/P1/P2 LOW for 20 ms then HIGH. Datasheet: reg 0x03 = config
+// (1=input, 0=output), reg 0x01 = output port.
+static bool tca9554_reset_panel(void) {
+ // Configure P0-P2 as outputs (rest as inputs)
+ if (!tca9554_write(0x03, 0xF8)) return false;
+ // Drive P0-P2 LOW
+ if (!tca9554_write(0x01, 0x00)) return false;
+ delay(20);
+ // Drive P0-P2 HIGH
+ if (!tca9554_write(0x01, 0x07)) return false;
+ return true;
+}
+#endif
+
static UsageData usage = {};
// ---- Touch interrupt + shared state ----
@@ -67,18 +98,16 @@ static uint32_t my_tick(void) {
return millis();
}
-// Rotate a w×h strip and compute destination coordinates on the 480×480 display.
+// Rotate a w×h strip and compute destination coordinates on the LCD_W×LCD_H display.
// src pixels are in row-major order for the rectangle (sx, sy, w, h).
// Output goes to rot_buf in row-major order for the destination rectangle.
static void rotate_strip(const uint16_t *src, int32_t w, int32_t h,
int32_t sx, int32_t sy, uint8_t r,
int32_t *dx, int32_t *dy, int32_t *dw, int32_t *dh) {
- const int S = LCD_WIDTH; // 480
-
switch (r) {
- case 1: { // 90° CW: (x,y) -> (S-1-y, x)
+ case 1: { // 90° CW
*dw = h; *dh = w;
- *dx = S - sy - h;
+ *dx = LCD_W - sy - h;
*dy = sx;
for (int32_t y = 0; y < h; y++) {
for (int32_t x = 0; x < w; x++) {
@@ -88,10 +117,10 @@ static void rotate_strip(const uint16_t *src, int32_t w, int32_t h,
}
break;
}
- case 2: { // 180°: (x,y) -> (S-1-x, S-1-y)
+ case 2: { // 180°
*dw = w; *dh = h;
- *dx = S - sx - w;
- *dy = S - sy - h;
+ *dx = LCD_W - sx - w;
+ *dy = LCD_H - sy - h;
for (int32_t y = 0; y < h; y++) {
for (int32_t x = 0; x < w; x++) {
rot_buf[(h - 1 - y) * w + (w - 1 - x)] = src[y * w + x];
@@ -99,10 +128,10 @@ static void rotate_strip(const uint16_t *src, int32_t w, int32_t h,
}
break;
}
- case 3: { // 270° CW: (x,y) -> (y, S-1-x)
+ case 3: { // 270° CW
*dw = h; *dh = w;
*dx = sy;
- *dy = S - sx - w;
+ *dy = LCD_H - sx - w;
for (int32_t y = 0; y < h; y++) {
for (int32_t x = 0; x < w; x++) {
// src(x,y) -> dst(y, w-1-x)
@@ -228,9 +257,19 @@ void setup() {
delay(300);
Serial.println("{\"ready\":true}");
- // Init I2C (shared by touch + PMU)
+ // Init I2C (shared by touch + PMU + on 1.8: TCA9554 expander)
Wire.begin(IIC_SDA, IIC_SCL);
+#ifdef BOARD_AMOLED_18
+ // Pulse LCD_RST / TP_RST / AUX_RST via TCA9554 expander. Without this
+ // the SH8601 stays dark.
+ if (!tca9554_reset_panel()) {
+ Serial.println("TCA9554 not responding at 0x20 — display may stay dark");
+ } else {
+ delay(120); // SH8601 reset settle
+ }
+#endif
+
// Init display
gfx->begin();
gfx->fillScreen(0x0000);
@@ -243,32 +282,48 @@ void setup() {
imu_init();
// Init touch
+#ifdef BOARD_AMOLED_18
+ touch.setPins(TP_RST, TP_INT);
+ if (!touch.begin(Wire, FT3168_ADDR, IIC_SDA, IIC_SCL)) {
+ Serial.println("Touch init failed");
+ } else {
+ touch.setMaxCoordinates(LCD_W, LCD_H);
+ // TBD on hardware — bisect through swap/mirror permutations during
+ // bring-up until corner taps report the expected coordinates.
+ touch.setSwapXY(false);
+ touch.setMirrorXY(false, false);
+ attachInterrupt(TP_INT, touch_isr, FALLING);
+ Serial.println("Touch init OK");
+ }
+#else
touch.setPins(TP_RST, TP_INT);
if (!touch.begin(Wire, CST9220_ADDR, IIC_SDA, IIC_SCL)) {
Serial.println("Touch init failed");
} else {
- touch.setMaxCoordinates(LCD_WIDTH, LCD_HEIGHT);
+ touch.setMaxCoordinates(LCD_W, LCD_H);
touch.setSwapXY(true);
touch.setMirrorXY(true, false);
attachInterrupt(TP_INT, touch_isr, FALLING);
Serial.println("Touch init OK");
}
+#endif
// Init LVGL
lv_init();
lv_tick_set_cb(my_tick);
// Allocate PSRAM-backed partial render buffers
- buf1 = (uint16_t*)heap_caps_malloc(LCD_WIDTH * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
- buf2 = (uint16_t*)heap_caps_malloc(LCD_WIDTH * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
- // rot_buf needs to hold the largest possible strip after rotation
- // A 480×40 strip rotated 90° becomes 40×480, same pixel count
- rot_buf = (uint16_t*)heap_caps_malloc(LCD_WIDTH * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
-
- lv_display_t* disp = lv_display_create(LCD_WIDTH, LCD_HEIGHT);
+ buf1 = (uint16_t*)heap_caps_malloc(LCD_W * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
+ buf2 = (uint16_t*)heap_caps_malloc(LCD_W * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
+ // rot_buf needs to hold the largest possible strip after rotation.
+ // Use the larger panel dimension so non-square displays still fit.
+ static const int32_t ROT_MAX_DIM = (LCD_W > LCD_H) ? LCD_W : LCD_H;
+ rot_buf = (uint16_t*)heap_caps_malloc(ROT_MAX_DIM * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
+
+ lv_display_t* disp = lv_display_create(LCD_W, LCD_H);
lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565);
lv_display_set_flush_cb(disp, my_flush_cb);
- lv_display_set_buffers(disp, buf1, buf2, LCD_WIDTH * BUF_LINES * 2,
+ lv_display_set_buffers(disp, buf1, buf2, LCD_W * BUF_LINES * 2,
LV_DISPLAY_RENDER_MODE_PARTIAL);
// CO5300 even-alignment rounder
diff --git a/firmware/src/splash.cpp b/firmware/src/splash.cpp
index 3b8d4b2cd..436ea56ee 100644
--- a/firmware/src/splash.cpp
+++ b/firmware/src/splash.cpp
@@ -2,13 +2,17 @@
#include "splash_animations.h"
#include "theme.h"
#include "usage_rate.h"
+#include "ui_layout.h"
+#include "display_cfg.h"
#include
#include
#include
-// 20x20 grid scaled 24x to fill 480x480
-#define GRID 20
-#define CELL 24
+// 20x20 grid of square cells; cell size is per-board (24 on 2.16, 18 on 1.8).
+// The canvas is square — lv_obj_center() places it inside the (possibly
+// non-square) splash_container so we get equal margins.
+#define GRID UI_SPLASH_GRID
+#define CELL UI_SPLASH_CELL
#define CANVAS_W (GRID * CELL)
#define CANVAS_H (GRID * CELL)
@@ -20,7 +24,7 @@ LV_FONT_DECLARE(font_styrene_28);
static lv_obj_t *splash_container = NULL;
static lv_obj_t *canvas = NULL;
static lv_obj_t *label_status = NULL; // shown only when no animations loaded
-static uint16_t *canvas_buf = NULL; // 480x480 RGB565 (PSRAM)
+static uint16_t *canvas_buf = NULL; // CANVAS_W x CANVAS_H RGB565 (PSRAM)
static uint16_t cur_anim = 0;
static uint16_t cur_frame = 0;
@@ -99,7 +103,7 @@ void splash_init(lv_obj_t *parent) {
}
splash_container = lv_obj_create(parent);
- lv_obj_set_size(splash_container, 480, 480);
+ lv_obj_set_size(splash_container, LCD_W, LCD_H);
lv_obj_set_pos(splash_container, 0, 0);
lv_obj_set_style_bg_color(splash_container, THEME_BG, 0);
lv_obj_set_style_bg_opa(splash_container, LV_OPA_COVER, 0);
diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp
index 14dcc47f3..956eda124 100644
--- a/firmware/src/ui.cpp
+++ b/firmware/src/ui.cpp
@@ -4,6 +4,7 @@
#include "logo.h"
#include "icons.h"
#include "display_cfg.h"
+#include "ui_layout.h"
// Custom fonts (scaled for 314 PPI, ~1.9x from original 165 PPI)
LV_FONT_DECLARE(font_tiempos_56);
@@ -25,13 +26,14 @@ LV_FONT_DECLARE(font_mono_32);
#define COL_RED THEME_RED
#define COL_BAR_BG THEME_BAR_BG
-// ---- Layout constants for 480x480 (scaled for 2.16" high-DPI + rounded corners) ----
-#define SCR_W 480
-#define SCR_H 480
-#define MARGIN 20 // wider margin for rounded display corners
-#define TITLE_Y 30
-#define CONTENT_Y 100
-#define CONTENT_W (SCR_W - 2 * MARGIN) // 440
+// Layout constants come from ui_layout.h (board-conditional). Map the
+// board-prefixed names to the short names this file already uses.
+#define SCR_W UI_SCR_W
+#define SCR_H UI_SCR_H
+#define MARGIN UI_MARGIN
+#define TITLE_Y UI_TITLE_Y
+#define CONTENT_Y UI_CONTENT_Y
+#define CONTENT_W UI_CONTENT_W
// ---- Usage screen widgets ----
static lv_obj_t* usage_container;
@@ -218,10 +220,10 @@ static void init_battery_icons(void) {
init_icon_dsc_rgb565a8(&battery_dscs[4], ICON_BATTERY_CHARGING_W, ICON_BATTERY_CHARGING_H, icon_battery_charging_data);
}
-// ======== Usage Screen (480x480) ========
+// ======== Usage Screen ========
-#define PANEL_H 150
-#define PANEL_GAP 16
+#define PANEL_H UI_PANEL_H
+#define PANEL_GAP UI_PANEL_GAP
// One Session/Weekly panel: big % label, pill on the right, bar, reset label.
// Pill y=1: symmetric inside the panel — panel-outer-top → pill-top equals
@@ -240,13 +242,13 @@ static void make_usage_panel(lv_obj_t* parent, int y, const char* pill_text,
*out_pill = make_pill(panel, pill_text);
lv_obj_align(*out_pill, LV_ALIGN_TOP_RIGHT, 0, 1);
- *out_bar = make_bar(panel, 0, 56, CONTENT_W - 32, 24);
+ *out_bar = make_bar(panel, 0, UI_BAR_Y_IN_PANEL, CONTENT_W - 32, UI_BAR_H);
*out_reset = lv_label_create(panel);
lv_label_set_text(*out_reset, "---");
lv_obj_set_style_text_font(*out_reset, &font_styrene_28, 0);
lv_obj_set_style_text_color(*out_reset, COL_DIM, 0);
- lv_obj_set_pos(*out_reset, 0, 94);
+ lv_obj_set_pos(*out_reset, 0, UI_RESET_Y_IN_PANEL);
}
static void init_usage_screen(lv_obj_t* scr) {
@@ -276,7 +278,7 @@ static void init_usage_screen(lv_obj_t* scr) {
lv_label_set_text(lbl_anim, "");
lv_obj_set_style_text_font(lbl_anim, &font_mono_32, 0);
lv_obj_set_style_text_color(lbl_anim, COL_ACCENT, 0);
- lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
+ lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, UI_ANIM_OFFSET_Y);
}
// ======== Bluetooth Screen (480x480) ========
@@ -395,12 +397,12 @@ void ui_init(void) {
// Logo on top of all containers (inset for rounded corners)
logo_img = lv_image_create(scr);
lv_image_set_src(logo_img, &logo_dsc);
- lv_obj_set_pos(logo_img, MARGIN, TITLE_Y - 10);
+ lv_obj_set_pos(logo_img, MARGIN, TITLE_Y + UI_LOGO_Y_OFFSET);
// Battery indicator on top of all containers (upper-right, inset)
battery_img = lv_image_create(scr);
lv_image_set_src(battery_img, &battery_dscs[0]);
- lv_obj_set_pos(battery_img, SCR_W - 48 - MARGIN, TITLE_Y);
+ lv_obj_set_pos(battery_img, SCR_W - UI_BATTERY_RIGHT - MARGIN, TITLE_Y);
}
void ui_update(const UsageData* data) {
diff --git a/firmware/src/ui_layout.h b/firmware/src/ui_layout.h
new file mode 100644
index 000000000..17d05b940
--- /dev/null
+++ b/firmware/src/ui_layout.h
@@ -0,0 +1,60 @@
+#pragma once
+
+// Per-board layout constants for ui.cpp. The 2.16" board (480x480) and
+// the 1.8" board (368x448) have different aspect ratios so absolute
+// positions can't simply scale — give each its own coordinate set.
+//
+// Origin is top-left of the panel. Y grows downward. All values are
+// in raw pixels.
+
+#include "display_cfg.h"
+
+#ifdef BOARD_AMOLED_18
+ // ESP32-S3-Touch-AMOLED-1.8 (368x448)
+ #define UI_SCR_W 368
+ #define UI_SCR_H 448
+ #define UI_MARGIN 12
+ #define UI_TITLE_Y 24
+ #define UI_CONTENT_Y 88
+
+ // Usage screen panels
+ #define UI_PANEL_H 138
+ #define UI_PANEL_GAP 12
+ #define UI_BAR_Y_IN_PANEL 52
+ #define UI_BAR_H 22
+ #define UI_RESET_Y_IN_PANEL 88
+
+ // Top overlay (logo + battery)
+ #define UI_BATTERY_RIGHT 48 // icon width
+ #define UI_LOGO_Y_OFFSET (-10)
+
+ // Bottom anim label
+ #define UI_ANIM_OFFSET_Y (-12)
+
+ // Splash canvas — 20x20 grid, smaller cells than 2.16
+ #define UI_SPLASH_GRID 20
+ #define UI_SPLASH_CELL 18 // 20*18 = 360, fits in 368x448 centered
+#else
+ // ESP32-S3-Touch-AMOLED-2.16 (480x480) — upstream layout
+ #define UI_SCR_W 480
+ #define UI_SCR_H 480
+ #define UI_MARGIN 20
+ #define UI_TITLE_Y 30
+ #define UI_CONTENT_Y 100
+
+ #define UI_PANEL_H 150
+ #define UI_PANEL_GAP 16
+ #define UI_BAR_Y_IN_PANEL 56
+ #define UI_BAR_H 24
+ #define UI_RESET_Y_IN_PANEL 94
+
+ #define UI_BATTERY_RIGHT 48
+ #define UI_LOGO_Y_OFFSET (-10)
+
+ #define UI_ANIM_OFFSET_Y (-15)
+
+ #define UI_SPLASH_GRID 20
+ #define UI_SPLASH_CELL 24 // 20*24 = 480
+#endif
+
+#define UI_CONTENT_W (UI_SCR_W - 2 * UI_MARGIN)
diff --git a/flash-mac.sh b/flash-mac.sh
new file mode 100755
index 000000000..2dc058c07
--- /dev/null
+++ b/flash-mac.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+# Build and flash Clawdmeter firmware on macOS.
+# Usage:
+# ./flash-mac.sh # auto-detect port, build env waveshare_amoled_18
+# ./flash-mac.sh -e waveshare_amoled_216
+# ./flash-mac.sh /dev/cu.usbmodem1101
+# ./flash-mac.sh -e /dev/cu.usbmodem1101
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ENV="waveshare_amoled_18"
+PORT=""
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -e|--env) ENV="$2"; shift 2 ;;
+ /dev/*) PORT="$1"; shift ;;
+ *) echo "Unknown arg: $1"; exit 1 ;;
+ esac
+done
+
+if [ -z "$PORT" ]; then
+ PORT=$(ls /dev/cu.usbmodem* 2>/dev/null | head -1)
+ if [ -z "$PORT" ]; then
+ echo "Error: no /dev/cu.usbmodem* device found. Plug in via USB-C."
+ exit 1
+ fi
+fi
+
+if ! command -v pio >/dev/null; then
+ echo "Error: 'pio' not found. Install with:"
+ echo " brew install platformio"
+ exit 1
+fi
+
+echo "=== Flashing Clawdmeter ==="
+echo "Env: $ENV"
+echo "Port: $PORT"
+echo ""
+
+cd "$SCRIPT_DIR/firmware"
+pio run -e "$ENV" -t upload --upload-port "$PORT"
+
+echo ""
+echo "=== Done ==="
+echo "Monitor with: pio device monitor -p $PORT -b 115200"
diff --git a/install-mac.sh b/install-mac.sh
new file mode 100755
index 000000000..c5942526c
--- /dev/null
+++ b/install-mac.sh
@@ -0,0 +1,85 @@
+#!/bin/bash
+# macOS installer for Clawdmeter daemon (Python + bleak + launchd).
+# Mirrors install.sh but uses LaunchAgents instead of systemd user units.
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SERVICE_LABEL="com.user.claude-usage-daemon"
+PLIST_SRC="$SCRIPT_DIR/daemon/$SERVICE_LABEL.plist"
+PLIST_DST="$HOME/Library/LaunchAgents/$SERVICE_LABEL.plist"
+VENV_DIR="$SCRIPT_DIR/daemon/.venv"
+DAEMON_PY="$SCRIPT_DIR/daemon/claude_usage_daemon.py"
+LOG_DIR="$HOME/Library/Logs"
+LOG_OUT="$LOG_DIR/claude-usage-daemon.out.log"
+LOG_ERR="$LOG_DIR/claude-usage-daemon.err.log"
+
+echo "=== Clawdmeter macOS install ==="
+echo ""
+
+echo "[1/5] Checking prerequisites..."
+for cmd in python3 curl; do
+ command -v "$cmd" >/dev/null || { echo "Error: $cmd is required"; exit 1; }
+done
+if [ ! -f "$HOME/.claude/.credentials.json" ]; then
+ echo "Warning: ~/.claude/.credentials.json not found."
+ echo " Sign in via Claude Code first, then re-run this installer."
+ echo " Continuing anyway — the daemon will retry on each poll."
+fi
+echo " OK"
+echo ""
+
+echo "[2/5] Creating Python virtualenv at daemon/.venv ..."
+if [ ! -d "$VENV_DIR" ]; then
+ python3 -m venv "$VENV_DIR"
+fi
+"$VENV_DIR/bin/pip" install --quiet --upgrade pip
+"$VENV_DIR/bin/pip" install --quiet "bleak>=0.22" "httpx>=0.27"
+PYTHON_BIN="$VENV_DIR/bin/python"
+echo " OK ($PYTHON_BIN)"
+echo ""
+
+echo "[3/5] Rendering launchd plist..."
+mkdir -p "$HOME/Library/LaunchAgents" "$LOG_DIR"
+sed \
+ -e "s|__PYTHON_BIN__|${PYTHON_BIN}|g" \
+ -e "s|__DAEMON_PATH__|${DAEMON_PY}|g" \
+ -e "s|__REPO_DIR__|${SCRIPT_DIR}|g" \
+ -e "s|__LOG_OUT__|${LOG_OUT}|g" \
+ -e "s|__LOG_ERR__|${LOG_ERR}|g" \
+ -e "s|__HOME__|${HOME}|g" \
+ "$PLIST_SRC" > "$PLIST_DST"
+echo " Installed: $PLIST_DST"
+echo ""
+
+echo "[4/5] Bluetooth permission check..."
+echo " On first run the daemon will trigger a Bluetooth permission prompt."
+echo " macOS only prompts for foreground processes — so we'll run it"
+echo " interactively once below. Press Ctrl+C after you see 'Scanning...'"
+echo " and grant permission when prompted. Then re-run this installer"
+echo " (or just continue) to enable launchd autostart."
+echo ""
+read -r -p "Run a permission-priming scan now? [Y/n] " ans
+if [[ ! "$ans" =~ ^[Nn]$ ]]; then
+ "$PYTHON_BIN" "$DAEMON_PY" || true
+fi
+echo ""
+
+echo "[5/5] Loading launchd service..."
+launchctl unload "$PLIST_DST" 2>/dev/null || true
+launchctl load -w "$PLIST_DST"
+echo " Loaded."
+echo ""
+
+echo "=== Done ==="
+echo ""
+echo "First-time Bluetooth pairing (after firmware is flashed):"
+echo " 1. Power on the device."
+echo " 2. Open System Settings → Bluetooth."
+echo " 3. Click 'Connect' next to 'Claude Controller'."
+echo " 4. The daemon will discover it within ~30 s and start polling."
+echo ""
+echo "Useful commands:"
+echo " launchctl list | grep claude-usage # check it's running"
+echo " tail -F $LOG_OUT # live logs"
+echo " launchctl unload $PLIST_DST # stop"
+echo " launchctl load -w $PLIST_DST # start"