20 KiB
AGENTS.md — Klubhaus Doorbell
Multi-target Arduino/ESP32 doorbell alert system using ntfy.sh.
Quick Reference
Default BOARD: esp32-s3-lcd-43 (set in mise.toml). To switch boards:
mise set BOARD=esp32-32e-4 # switch to ESP32-32E-4"
mise set BOARD=esp32-32e # switch to ESP32-32E
mise set BOARD=esp32-s3-lcd-43 # switch to ESP32-S3-LCD-4.3
Then run commands without BOARD= prefix:
mise run compile # compile
mise run upload # upload (auto-kills monitor)
mise run monitor # start JSON monitor daemon
mise run log-tail # watch colored logs
mise run cmd COMMAND=dashboard # send command
mise run state # show device state
# Install libs (run after cloning)
mise run install-libs-shared # shared libs (ArduinoJson, NTPClient)
mise run install # shared + board-specific libs
Project Overview
Three board targets share business logic via a common library:
| Board | Display | Library | Build Command |
|---|---|---|---|
| ESP32-32E | SPI TFT 320x240 (ILI9341) | TFT_eSPI | mise set BOARD=esp32-32e && mise run compile |
| ESP32-32E-4" | SPI TFT 320x480 (ST7796) | TFT_eSPI | mise set BOARD=esp32-32e-4 && mise run compile |
| ESP32-S3-Touch-LCD-4.3 | 800x480 RGB parallel | LovyanGFX | mise set BOARD=esp32-s3-lcd-43 && mise run compile |
Essential Commands
All commands run via mise:
# Install all dependencies (shared libs + vendored display libs)
mise run install-libs-shared
mise run install # install shared + board-specific libs (requires BOARD env)
# Generic commands (set BOARD with mise set first)
mise set BOARD=esp32-32e # switch to ESP32-32E
mise run compile # compile for ESP32-32E
mise run upload # upload to ESP32-32E
mise run monitor # monitor ESP32-32E
mise set BOARD=esp32-32e-4 # switch to ESP32-32E-4"
mise run compile # compile for ESP32-32E-4"
mise run upload # upload to ESP32-32E-4"
mise run monitor # monitor ESP32-32E-4"
mise set BOARD=esp32-s3-lcd-43 # switch to ESP32-S3-LCD-4.3
mise run compile # compile for ESP32-S3-LCD-4.3
mise run upload # upload to ESP32-S3-LCD-4.3
mise run monitor # monitor ESP32-S3-LCD-4.3
# Other useful tasks
mise run format # format code
mise run clean # clean build artifacts
mise run kill # kill running monitor/upload for BOARD
mise run log-tail # tail colored logs (requires BOARD)
mise run cmd COMMAND=dashboard # send command to device (requires BOARD)
mise run state # show device state (requires BOARD)
# LSP / IDE support
mise run gen-compile-commands # generate compile_commands.json for LSP
mise run gen-crush-config # generate .crush.json with BOARD-based FQBN
# Arduino maintenance
mise run arduino-clean # clear Arduino CLI cache (staging + packages)
# Raw serial access
mise run monitor-raw # raw serial monitor (arduino-cli)
mise run monitor-tio # show tio command for terminal UI
Board Configuration Files
Each board directory contains board-config.sh which defines:
| Variable | Description |
|---|---|
FQBN |
Fully Qualified Board Name for arduino-cli |
PORT |
Serial port for upload/monitoring (default: /dev/ttyUSB0) |
LIBS |
Vendor library path for --libraries flag |
OPTS |
Additional compiler flags (e.g., -DDEBUG_MODE) |
Example (boards/esp32-32e-4/board-config.sh):
FQBN="esp32:esp32:esp32:FlashSize=4M,PartitionScheme=default"
PORT="/dev/ttyUSB0"
LIBS="--libraries ./vendor/esp32-32e-4/TFT_eSPI"
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM"
Port override: mise set PORT=/dev/ttyXXX before running upload/monitor commands.
Prerequisites: arduino-cli with esp32:esp32 platform installed, mise.
Project Structure
libraries/KlubhausCore/src/ Shared Arduino library
├── KlubhausCore.h Umbrella include (board sketches use this)
├── Config.h Constants, timing, WiFiCred struct
├── ScreenState.h State enums/structs
├── IDisplayDriver.h Pure virtual display interface
├── DisplayManager.h Thin wrapper delegating to IDisplayDriver
├── NetManager.* WiFi, HTTP, NTP
└── DoorbellLogic.* State machine, ntfy polling
boards/
├── esp32-32e/
│ ├── esp32-32e.ino Main sketch
│ ├── board_config.h Board-specific config
│ ├── secrets.h.example WiFi creds template (copy to secrets.h)
│ ├── tft_user_setup.h TFT_eSPI config
│ └── DisplayDriverTFT.* Concrete IDisplayDriver for TFT
├── esp32-32e-4/
│ ├── esp32-32e-4.ino Main sketch
│ ├── board_config.h Board-specific config
│ ├── secrets.h.example WiFi creds template (copy to secrets.h)
│ ├── tft_user_setup.h TFT_eSPI config
│ └── DisplayDriverTFT.* Concrete IDisplayDriver for TFT
└── esp32-s3-lcd-43/
├── esp32-s3-lcd-43.ino Main sketch
├── board_config.h Board-specific config
├── secrets.h.example WiFi creds template (copy to secrets.h)
├── LovyanPins.h Pin definitions
└── DisplayDriverGFX.* Concrete IDisplayDriver for LovyanGFX
vendor/ Vendored display libs (recreated by install-libs)
├── esp32-32e/TFT_eSPI/
├── esp32-32e-4/TFT_eSPI/
└── esp32-s3-lcd-43/LovyanGFX/
Code Patterns
Header Guards
Use #pragma once (not #ifndef guards).
Formatting
- 4-space indentation, no tabs
- WebKit-based style (see
.clang-format) - Column limit: 100
- Opening brace stays on same line (
BreakBeforeBraces: Attach)
Naming Conventions
- Classes:
PascalCase(e.g.,DisplayManager,IDisplayDriver) - Constants/enums:
SCREAMING_SNAKE(e.g.,POLL_INTERVAL_MS,ScreenState::DASHBOARD) - Variables/functions:
camelCase(e.g.,currentState,updateDisplay) - Member variables: prefix with
_(e.g.,_screenWidth,_isConnected)
Types
- Arduino types:
int,uint8_t,uint16_t,size_t - Use fixed-width types for protocol/serialization (
uint8_t, notbyte) - Avoid
boolfor pin states - useuint8_torint - Use
size_tfor sizes and array indices
Imports Organization
- Arduino core headers first (
Arduino.h) - Standard C/C++ library (
<cstdint>,<String>,<vector>) - Third-party libraries (e.g.,
TFT_eSPI.h,ArduinoJson.h) - Local project headers (e.g.,
"Config.h","ScreenState.h")
Error Handling
- Serial logging pattern:
Serial.println("[ERROR] message") - Use
Serial.printf()for formatted debug output - Return error codes, not exceptions (exceptions not available in Arduino)
- Log state transitions with
[STATE] → STATEtags
Class Design
- Pure virtual
IDisplayDriverinterface in shared library - Each board implements a concrete driver (e.g.,
DisplayDriverTFT,DisplayDriverGFX) DisplayManagerdelegates toIDisplayDriver— no display-lib coupling in shared code
Arduino Patterns
setup()runs once at boot — callbegin()on managersloop()runs continuously — callupdate()on managers- Use
millis()for timing (notdelay()) - Serial console at 115200 baud for debug commands
Testing/Debugging
No unit tests exist - This is an embedded Arduino sketch. Verify changes by building and deploying to hardware:
mise set BOARD=esp32-s3-lcd-43 # compile for esp32-s3-lcd-43
mise set BOARD=esp32-32e-4 # compile for ESP32-32E-4"
Serial commands (type into serial monitor at 115200 baud):
| Command | Action |
|---|---|
alert |
Trigger a test alert |
silence |
Silence current alert |
dashboard |
Show dashboard screen |
off |
Turn off display |
status |
Print state + memory info |
reboot |
Restart device |
Touch Test Commands (ESP32-S3-LCD-4.3 only):
| Command | Action |
|---|---|
TEST:touch X Y press |
Inject synthetic press at raw panel coords (X,Y) |
TEST:touch X Y release |
Inject synthetic release at raw panel coords (X,Y) |
TEST:touch clear |
Clear test mode (required between touch sequences) |
Note: The S3 touch panel is rotated 180° relative to the display. Use raw panel coordinates:
- Display (100,140) → Raw (700, 340)
- Display (700,140) → Raw (100, 340)
Monitor Daemon and Logging
The build system includes a Python-based monitor agent that provides JSON logging and command pipes.
- JSON log:
/tmp/doorbell-$BOARD.jsonl– each line is{"ts":123.456,"line":"..."} - State file:
/tmp/doorbell-$BOARD-state.json– current device state (screen, alert status, etc.) - Command FIFO:
/tmp/doorbell-$BOARD-cmd.fifo– send commands viaecho 'dashboard' > /tmp/doorbell-$BOARD-cmd.fifo
Commands to interact with the daemon:
mise set BOARD=esp32-32e
mise run monitor # Start monitor daemon (background)
mise run log-tail # Tail colored logs
mise run cmd COMMAND=dashboard # Send command
mise run state # Show current device state
The monitor daemon automatically starts after upload and is killed before upload to avoid serial port conflicts.
Gotchas
-
secrets.h is gitignored: Copy from
.examplebefore building:cp boards/esp32-32e/secrets.h.example boards/esp32-32e/secrets.h cp boards/esp32-32e-4/secrets.h.example boards/esp32-32e-4/secrets.h cp boards/esp32-s3-lcd-43/secrets.h.example boards/esp32-s3-lcd-43/secrets.h -
Display libs are vendored: Each board uses a different display library. The build system uses
--librariesto link only the board's vendored lib — never link both TFT_eSPI and LovyanGFX in the same build. -
No unit tests: This is an embedded Arduino sketch — no test suite exists. Verify changes by building and deploying to hardware.
-
LSP errors are expected: The LSP may show errors about missing Arduino types until
compile_commands.jsonis generated. Runmise run gen-compile-commandsfirst, then restart the LSP. The build works correctly via arduino-cli. -
Build artifacts in board dirs: Build output goes to
boards/[board]/build/— cleaned bymise run clean. -
WiFi credentials are per-board: Each board directory has its own
secrets.hbecause boards may be on different networks. -
Use BOARD environment variable: All build commands require the
BOARDenvironment variable. The default BOARD isesp32-s3-lcd-43(set in mise.toml). Usemise set BOARD=xxxto switch boards. -
Serial port contention: The
upload,monitor, andmonitor-rawtasks automatically depend onkillwhich usesfuserto terminate any process using the serial port before starting. -
Monitor state parsing: The monitor-agent parses serial output for state updates:
[STATE] → DASHBOARD→ updates state file to"screen":"DASHBOARD"[STATE] → ALERT→ updates state file to"screen":"ALERT"[STATE] → OFF→ updates state file to"screen":"OFF"[STATE] → BOOT→ updates state file to"screen":"BOOT"[ADMIN]commands also update state accordingly
-
Serial baud rate: All serial communication uses 115200 baud.
-
Serial port contention: The
killtask usesfuserto release the serial port. Bothuploadandmonitortasks depend onkillto ensure the port is free.
Config Constants
Key timing and configuration values in Config.h:
| Constant | Default | Description |
|---|---|---|
FW_VERSION |
"5.1" | Firmware version string |
POLL_INTERVAL_MS |
15000 | How often to poll ntfy.sh for new messages |
HEARTBEAT_INTERVAL_MS |
300000 | NTP sync interval (5 min) |
ALERT_TIMEOUT_MS |
120000 | Auto-clear alert after 2 min |
INACTIVITY_TIMEOUT_MS |
30000 | Turn off display after 30s of inactivity |
HOLD_TO_SILENCE_MS |
3000 | Hold duration to silence alert |
LOOP_YIELD_MS |
10 | Yield to prevent Task Watchdog (configurable) |
BOOT_GRACE_MS |
5000 | Grace period at boot before polling starts |
SILENCE_DISPLAY_MS |
10000 | How long to show silence confirmation |
WIFI_CONNECT_TIMEOUT_MS |
15000 | WiFi connection timeout |
HTTP_TIMEOUT_MS |
10000 | HTTP request timeout |
HINT_ANIMATION_MS |
2000 | Hint animation duration |
HINT_MIN_BRIGHTNESS |
30 | Minimum brightness for hints |
HINT_MAX_BRIGHTNESS |
60 | Maximum brightness for hints |
TOUCH_DEBOUNCE_MS |
100 | Touch debounce delay |
Screen States
The device operates in these states (defined in ScreenState.h):
- BOOT — Initializing
- DASHBOARD — Normal operation, showing status
- ALERT — Doorbell ring detected, display on
- OFF — Display backlight off (but polling continues)
Serial Output Tags
The firmware outputs structured tags for the monitor agent:
[STATE] → DASHBOARD/ALERT/OFF/BOOT— State transitions[ADMIN]— Admin commands received (dashboard, off, alert, silence, status, reboot)[TOUCH]— Touch events (x, y, pressed/released)[ALERT]— Alert triggered
Reverted Changes Log
Track changes that were reverted to avoid flapping:
- 2025-02-18: Initially configured LSP via neovim/mason (
.config/nvim/lua/plugins/arduino.lua) — user clarified they wanted Crush-native LSP config instead
Troubleshooting
| Issue | Solution |
|---|---|
| "Another instance is running" error | Run mise run kill to stop monitor daemon and release port |
| Upload fails - port in use | Run mise run kill to stop monitor daemon and release port |
| Build fails - missing libraries | Run mise run install-libs-shared then mise run install |
| LSP shows errors but build works | Run mise run gen-compile-commands to generate compile_commands.json for your BOARD |
| No serial output | Check baud rate is set to 115200 in serial monitor |
| State file not updating | Ensure serial output contains [STATE] or [ADMIN] tags |
Known Fixes
- dashboard/off admin commands reset inactivity timer (DoorbellLogic.cpp:234,240) — Admin commands like
dashboardandoffnow reset the inactivity timer so the display doesn't turn off immediately after switching screens.
Documentation Lookup Rule
When uncertain about CLI tool flags or argument syntax:
- Run the tool with
-hor--helpfirst - If unclear, search for official documentation matching the tool version
- Prefer checking the tool's own help/docs over guessing
- For Arduino CLI, check
arduino-cli <command> --helpor official Arduino CLI docs
LSP / IDE Configuration
The project uses clangd for C++ via the compile_commands.json generated by arduino-cli:
mise run gen-compile-commandsregenerates the compile database (automatically run as part ofcompile)- The generated
compile_commands.jsonincludes all necessary includes, defines, and library paths for your BOARD
Generate compile_commands.json for accurate IDE diagnostics:
mise set BOARD=esp32-32e-4
mise run gen-compile-commands
Generate static .crush.json for a specific board:
mise set BOARD=esp32-32e-4
mise run gen-crush-config
Hardware Research Log
Hosyond ESP32-32E 4" (320x480) - Planned
Source: https://www.lcdwiki.com/4.0inch_ESP32-32E_Display
| Spec | Value |
|---|---|
| Display Controller | ST7796S |
| Resolution | 320x480 |
| Touch | XPT2046 (resistive) |
| Library | TFT_eSPI V2.5.43 (same as 32E) |
GPIO Pinout:
| Function | GPIO |
|---|---|
| LCD CS | 15 |
| LCD DC | 2 |
| LCD MOSI | 13 |
| LCD SCLK | 14 |
| LCD RST | EN |
| LCD BL | 27 |
| Touch CS | 33 |
| Touch IRQ | 36 |
Quirks:
- SPI pins shared between LCD and touch
- Touch IRQ on IO36 (input-only) triggers LOW on touch
- Backlight on IO27 (HIGH = on)
- Common anode RGB LEDs on IO16, IO17, IO22 (LOW = on)
RTK (Rust Token Killer) - Token-Optimized Commands
Golden Rule
Always prefix commands with rtk. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
Important: Even in command chains with &&, use rtk:
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
RTK Commands by Workflow
Build & Compile (80-90% savings)
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
Test (90-99% savings)
rtk cargo test # Cargo test failures only (90%)
rtk vitest run # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk test <cmd> # Generic test wrapper - failures only
Git (59-80% savings)
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
GitHub (26-87% savings)
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
JavaScript/TypeScript Tooling (70-90% savings)
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
Files & Search (60-75% savings)
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
Analysis & Debug (70-90% savings)
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
Infrastructure (85% savings)
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
Network (65-70% savings)
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
Meta Commands
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
rtk init # Add RTK instructions to CLAUDE.md
Token Savings Overview
| Category | Commands | Typical Savings |
|---|---|---|
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: 60-90% token reduction on common development operations.