Motivation

Claude Code fires hook events at key moments: when it finishes a task, when it's waiting for your input, when it starts a long tool chain. These events are normally only visible in the terminal — you have to alt-tab to see what's going on. Clawd Tank makes those signals physical. A glance at the desk is enough to know whether Claude is idle, working, or waiting on you.

The secondary goal was a clean embedded systems exercise: LVGL 9, NimBLE, and FreeRTOS on the ESP32-C6, with a simulator that runs the exact same firmware rendering code on macOS for fast iteration.

Hardware

The entire build is a single off-the-shelf board: the Waveshare ESP32-C6-LCD-1.47 (~$12). Everything needed is onboard.

SoC: ESP32-C6FH8 — RISC-V single-core at up to 160 MHz, 8 MB flash (QIO), 4 MB PSRAM (octal). No FPU, so floating-point in ISRs or timer callbacks must be kept minimal.

Display: 1.47" ST7789V, 320×172 pixels, 16-bit RGB565, SPI. The panel has a 34-pixel offset in controller address space that must be applied via esp_lcd_panel_set_gap() on y_gap (not x_gap) in landscape mode. Backlight is PWM via LEDC at 5 kHz on GPIO22. SPI runs at 12 MHz with DMA, double-buffered.

Pin assignments: MOSI=6, SCLK=7, CS=14, DC=15, RST=21, BL=22, RGB LED=8

RGB LED: Onboard WS2812B on GPIO8 via Espressif's led_strip component. On each notification, cycles through a 6-color palette with linear interpolation and fade-out, managed by a non-blocking esp_timer at 30 ms intervals.

BLE: BLE 5.0 peripheral, advertising as "Clawd Tank". PCB trace antenna handles typical desktop distances fine.

Firmware Architecture

ESP-IDF 5.3.2. Modules communicate through a FreeRTOS queue. The main task creates a 16-event ble_evt_t queue, then spawns a single ui_task that owns all LVGL rendering.

Event flow: NimBLE callbacks → ble_evt_t queue → ui_manager → scene.c + notification_ui.c

ble_service.c is a NimBLE GATT server with two 128-bit UUID characteristics. The notification characteristic accepts JSON payloads (≤256-byte MTU), four actions: add, dismiss, clear, set_time.

scene.c manages the 172px-tall scene panel: dark gradient sky, six twinkling stars, a grass strip, and the Clawd sprite. When notifications arrive, the scene animates from 320px down to 107px via lv_anim ease-out. Five moods: idle, alert, happy, sleeping, disconnected.

notification_ui.c renders LVGL cards with a 2.5 s featured hero view that eases to compact height over 350 ms. LV_LABEL_LONG_SCROLL_CIRCULAR marquee on featured cards. 8-notification ring buffer; oldest evicted on overflow.

Design Decisions

Same-code simulator

The simulator compiles scene.c, notification_ui.c, ui_manager.c, notification.c, config_store.c, and rgb_led.c directly from firmware/main/ — zero copies. ESP-IDF APIs are replaced by shim headers in simulator/shims/. SDL2 provides the display; stb_image_write handles PNG output.

Every rendering change can be tested with a 2-second CMake build before touching hardware. The simulator exposes a TCP listener (--listen, port 19872) that accepts the same JSON protocol as BLE, so the full Claude Code → daemon → simulator pipeline works without hardware.

RLE sprite compression

All frames stored in flash as RLE-compressed RGB565. The pipeline (tools/png2rgb565.py) maps transparent pixels to key color 0x18C5, then run-length encodes each row into (uint16_t value, uint16_t count) pairs.

Five animations: idle (96 frames, 180×180), alert (40), happy (20, 160×160), sleeping (36), disconnected (36, 200×160). Idle alone: 96×180×180×2 = ~6.2 MB raw → ~94 KB compressed (66:1). All five: ~14 MB raw → ~330 KB (~42:1). Fits in 8 MB flash with no PSRAM needed for assets.

Sprite generation with Gemini

All five animations were generated in a single Gemini session. The key: the base character was provided as SVG source code — 21 lines of named <rect> elements — not a raster image. This gave the model exact geometry for every body...

Read more »