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"