Compare commits

..

17 Commits

Author SHA1 Message Date
3d17e7bab9 refactor(DisplayManager): add hitTestRaw() and remove touch coordinate filtering 2026-02-22 18:43:54 -08:00
4030048401 refactor(AGENTS.md): update docs for simplified build commands and board config 2026-02-22 03:19:05 -08:00
72636f9ecf feat(Display): Improve touch handling and visual feedback 2026-02-20 04:10:39 -08:00
688b1905e5 refactor(DisplayDriverTFT): simplify alert rendering logic for better performance 2026-02-20 03:02:13 -08:00
b68d36bb85 perf(DisplayDriverTFT): Throttle alert redraws to prevent tearing 2026-02-20 02:41:04 -08:00
ba8789797c feat: add status screen with system info display 2026-02-20 02:11:21 -08:00
fd04dea8dd fix(DisplayDriverTFT): right-align WiFi indicator in header 2026-02-20 00:49:57 -08:00
9c8f67dccb refactor: improve touch handling and UI element detection 2026-02-20 00:36:29 -08:00
6d51234f21 docs: Add justfile for build automation 2026-02-19 23:10:55 -08:00
66f09c3468 feat(boards): add device detection and text scrolling support 2026-02-19 21:46:33 -08:00
913373ca72 fix(build): change --libraries flag to --library in board configs 2026-02-19 20:48:56 -08:00
f367bd365b refactor(doorbell): check hold completion before release handling 2026-02-19 19:22:49 -08:00
dfd511e499 feat: add session notes for 2026-02-19 with build, code, and integration updates 2026-02-19 17:52:08 -08:00
4da32466a8 feat: add debug touch drawing function and fix dashboard layout calculations 2026-02-19 17:48:39 -08:00
a3164d722e docs(AGENTS.md): Update RTK section and add style system docs 2026-02-19 15:38:02 -08:00
dd1c13fbbc refactor: replace hint feedback with debug crosshair 2026-02-19 15:12:02 -08:00
ec8ec4cd18 refactor(Style): Add font abstraction and CSS-like styling constants 2026-02-19 14:48:25 -08:00
29 changed files with 1604 additions and 715 deletions

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ vendor/esp32-s3-lcd-43/GFX_Library_for_Arduino/
*~
.DS_Store
compile_commands.json
.board-last
.cache/

586
AGENTS.md
View File

@@ -1,543 +1,143 @@
# AGENTS.md — Klubhaus Doorbell
Multi-target Arduino/ESP32 doorbell alert system using ntfy.sh.
Multi-target Arduino/ESP32 doorbell alert system using ntfy.sh. Default BOARD: `esp32-s3-lcd-43`.
## Quick Reference
## Build Commands
```bash
# Compile, upload, monitor
BOARD=esp32-32e-4 mise run compile # compile
BOARD=esp32-32e-4 mise run upload # upload (auto-kills monitor)
BOARD=esp32-32e-4 mise run monitor # start JSON monitor daemon
BOARD=esp32-32e-4 mise run log-tail # watch colored logs
BOARD=esp32-32e-4 mise run cmd COMMAND=dashboard # send command
BOARD=esp32-32e-4 mise run state # show device state
# Set target board
mise set BOARD=esp32-32e-4 # ESP32-32E 4" (320x480 ST7796)
mise set BOARD=esp32-32e # ESP32-32E 3.5" (320x240 ILI9341)
mise set BOARD=esp32-s3-lcd-43 # ESP32-S3-Touch-LCD-4.3 (800x480 RGB)
# Install libs (run after cloning)
mise run install-libs-shared # shared libs (ArduinoJson, NTPClient)
BOARD=esp32-32e-4 mise run install # shared + board-specific libs
```
# Core commands
mise run compile # compile for current BOARD
mise run upload # upload (auto-kills monitor first)
mise run monitor # start JSON monitor daemon
mise run kill # kill monitor/release serial port
**Default BOARD**: `esp32-s3-lcd-43` (set in mise.toml)
# Formatting & cleanup
mise run format # format code with clang-format
mise run clean # remove build artifacts
## 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 | `BOARD=esp32-32e mise run compile` |
| ESP32-32E-4" | SPI TFT 320x480 (ST7796) | TFT_eSPI | `BOARD=esp32-32e-4 mise run compile` |
| ESP32-S3-Touch-LCD-4.3 | 800x480 RGB parallel | LovyanGFX | `BOARD=esp32-s3-lcd-43 mise run compile` |
## Essential Commands
All commands run via **mise**:
```bash
# 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 environment variable)
BOARD=esp32-32e mise run compile # compile for ESP32-32E
BOARD=esp32-32e mise run upload # upload to ESP32-32E
BOARD=esp32-32e mise run monitor # monitor ESP32-32E
BOARD=esp32-32e-4 mise run compile # compile for ESP32-32E-4"
BOARD=esp32-32e-4 mise run upload # upload to ESP32-32E-4"
BOARD=esp32-32e-4 mise run monitor # monitor ESP32-32E-4"
BOARD=esp32-s3-lcd-43 mise run compile # compile for ESP32-S3-LCD-4.3
BOARD=esp32-s3-lcd-43 mise run upload # upload to ESP32-S3-LCD-4.3
BOARD=esp32-s3-lcd-43 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)
# Debugging
mise run log-tail # tail colored logs
mise run cmd COMMAND=dashboard # send command to device
mise run state # show device state
mise run monitor-raw # raw serial monitor (115200 baud)
mise run monitor-tio # show tio command for terminal UI
# Install dependencies
mise run install-libs-shared # shared libs (ArduinoJson, NTPClient)
mise run install # shared + board-specific libs
# LSP / IDE
mise run gen-compile-commands # generate compile_commands.json
mise run gen-crush-config # generate .crush.json for BOARD
```
# Board Configuration Files
**Serial debug commands** (115200 baud): `alert`, `silence`, `dashboard`, `off`, `status`, `reboot`
Each board directory contains `board-config.sh` which defines:
**No unit tests exist** — verify changes by compiling and deploying to hardware.
| 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`) |
## Code Style
**Example** (`boards/esp32-32e-4/board-config.sh`):
```bash
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**: `PORT=/dev/ttyXXX BOARD=esp32-32e mise run upload`
**Prerequisites**: arduino-cli with `esp32:esp32` platform installed, mise.
## Project Structure
```text
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
### Formatting (.clang-format)
- BasedOnStyle: WebKit
- 4-space indentation, no tabs
- Column limit: 100
- Opening brace on same line (`BreakBeforeBraces: Attach`)
- Run `mise run format` to format code
### 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`)
| Type | Convention | Example |
|------|------------|---------|
| Classes | PascalCase | `DisplayManager`, `IDisplayDriver` |
| Constants/enums | SCREAMING_SNAKE | `POLL_INTERVAL_MS`, `ScreenState::DASHBOARD` |
| Variables/functions | camelCase | `currentState`, `updateDisplay` |
| Member variables | `_` prefix | `_screenWidth`, `_isConnected` |
### Types
- Arduino types: `int`, `uint8_t`, `uint16_t`, `size_t`
- Use fixed-width types for protocol/serialization (`uint8_t`, not `byte`)
- Avoid `bool` for pin states - use `uint8_t` or `int`
- Use `size_t` for sizes and array indices
- Avoid `bool` for pin states — use `uint8_t` or `int`
### 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"`)
### Imports Organization (in order)
1. Arduino core (`Arduino.h`)
2. Standard C/C++ (`<cstdint>`, `<String>`, `<vector>`)
3. Third-party libs (`TFT_eSPI.h`, `ArduinoJson.h`)
4. Local project (`"Config.h"`, `"ScreenState.h"`)
### Error Handling
- Serial logging: `Serial.println("[ERROR] message")`
- Use `Serial.printf()` for formatted debug
- Return error codes, not exceptions
- Log state: `[STATE] → DASHBOARD`
- 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] → STATE` tags
## Architecture Patterns
### Class Design
- Pure virtual `IDisplayDriver` interface in shared library
- Each board implements a concrete driver (e.g., `DisplayDriverTFT`, `DisplayDriverGFX`)
### Display Driver Interface
- Pure virtual `IDisplayDriver` in shared `KlubhausCore`
- Each board implements concrete driver (`DisplayDriverTFT`, `DisplayDriverGFX`)
- `DisplayManager` delegates to `IDisplayDriver` — no display-lib coupling in shared code
### Arduino Patterns
- `setup()` runs once at boot — call `begin()` on managers
- `loop()` runs continuously — call `update()` on managers
- `setup()` — call `begin()` on managers
- `loop()` — call `update()` on managers
- Use `millis()` for timing (not `delay()`)
- Serial console at 115200 baud for debug commands
- Serial baud: 115200
## Testing/Debugging
### Style System
- Style constants in board's `board_config.h`: `STYLE_SPACING_X`, `STYLE_COLOR_BG`, etc.
- Font abstraction via `IDisplayDriver`: `setTitleFont()`, `setBodyFont()`, etc.
- Layout helpers in `KlubhausCore/src/Style.h`
**No unit tests exist** - This is an embedded Arduino sketch. Verify changes by building and deploying to hardware:
## Key Files
```bash
BOARD=esp32-s3-lcd-43 mise run compile # compile for default board
BOARD=esp32-32e-4 mise run compile # compile for ESP32-32E-4"
```
libraries/KlubhausCore/src/
├── KlubhausCore.h # Umbrella include
├── Config.h # Timing, WiFiCred struct
├── ScreenState.h # State enums/structs
├── IDisplayDriver.h # Pure virtual interface
├── DisplayManager.h # Delegates to IDisplayDriver
├── NetManager.* # WiFi, HTTP, NTP
└── DoorbellLogic.* # State machine, ntfy polling
**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 via `echo 'dashboard' > /tmp/doorbell-$BOARD-cmd.fifo`
Commands to interact with the daemon:
```bash
BOARD=esp32-32e mise run monitor # Start monitor daemon (background)
BOARD=esp32-32e mise run log-tail # Tail colored logs
BOARD=esp32-32e mise run cmd COMMAND=dashboard # Send command
BOARD=esp32-32e mise run state # Show current device state
boards/{BOARD}/
├── {BOARD}.ino # Main sketch
├── board_config.h # Board-specific config
├── secrets.h # WiFi credentials
├── tft_user_setup.h # TFT_eSPI config (TFT boards)
└── DisplayDriver*.{h,cpp} # Concrete IDisplayDriver
```
The monitor daemon automatically starts after upload and is killed before upload to avoid serial port conflicts.
## Gotchas
1. **secrets.h is gitignored**: Copy from `.example` before building:
1. **secrets.h**: Boards with `-DLOCAL_SECRETS` use local `secrets.h`; others use `KlubhausCore/src/secrets.h`
2. **Vendored libs**: Each board links only its display lib — never TFT_eSPI + LovyanGFX together
3. **LSP errors**: Run `mise run gen-compile-commands` then restart LSP; build works regardless
4. **Serial port**: `upload`/`monitor` auto-depend on `kill` to release port
5. **State tags**: Use `[STATE] → DASHBOARD`, `[ADMIN]`, `[TOUCH]`, `[ALERT]` for monitor parsing
```bash
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
```
2. **Display libs are vendored**: Each board uses a different display library. The build system uses `--libraries` to link only the board's vendored lib — never link both TFT_eSPI and LovyanGFX in the same build.
3. **No unit tests**: This is an embedded Arduino sketch — no test suite exists. Verify changes by building and deploying to hardware.
4. **LSP errors are expected**: The LSP may show errors about missing Arduino types until `compile_commands.json` is generated. Run `mise run gen-compile-commands` first, then restart the LSP. The build works correctly via arduino-cli.
5. **Build artifacts in board dirs**: Build output goes to `boards/[board]/build/` — cleaned by `mise run clean`.
6. **WiFi credentials are per-board**: Each board directory has its own `secrets.h` because boards may be on different networks.
7. **Use BOARD environment variable**: All build commands require the `BOARD` environment variable (e.g., `BOARD=esp32-32e mise run compile`). The default BOARD is `esp32-s3-lcd-43` (set in mise.toml).
8. **Serial port contention**: The `upload`, `monitor`, and `monitor-raw` tasks automatically depend on `kill` which uses `fuser` to terminate any process using the serial port before starting.
9. **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
10. **Serial baud rate**: All serial communication uses 115200 baud.
11. **Serial port contention**: The `kill` task uses `fuser` to release the serial port. Both `upload` and `monitor` tasks depend on `kill` to ensure the port is free.
## Config Constants
Key timing and configuration values in `Config.h`:
## Config Constants (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 |
| `FW_VERSION` | "5.1" | Firmware version |
| `POLL_INTERVAL_MS` | 15000 | ntfy.sh poll interval |
| `ALERT_TIMEOUT_MS` | 120000 | Auto-clear alert |
| `INACTIVITY_TIMEOUT_MS` | 30000 | Display off timeout |
| `HOLD_TO_SILENCE_MS` | 3000 | Hold to silence |
| `WIFI_CONNECT_TIMEOUT_MS` | 15000 | WiFi 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 BOARD=<board>` or `FORCE=1 mise run <task> BOARD=<board>` |
| 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 `BOARD=<board> 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 `dashboard` and `off` now 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:
1. Run the tool with `-h` or `--help` first
2. If unclear, search for official documentation matching the tool version
3. Prefer checking the tool's own help/docs over guessing
4. For Arduino CLI, check `arduino-cli <command> --help` or 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-commands` regenerates the compile database (automatically run as part of `compile`)
- The generated `compile_commands.json` includes all necessary includes, defines, and library paths for your BOARD
**Generate compile_commands.json** for accurate IDE diagnostics:
```bash
BOARD=esp32-32e-4 mise run gen-compile-commands
```
**Generate static .crush.json** for a specific board:
```bash
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-instructions v2 -->
# 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`:
```bash
# ❌ 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)
```bash
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)
```bash
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)
```bash
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)
```bash
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)
```bash
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)
```bash
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)
```bash
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)
```bash
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)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
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.
<!-- /rtk-instructions -->
- **DASHBOARD** — Normal operation
- **ALERT** — Doorbell ring detected
- **OFF** — Display backlight off (polling continues)

77
SESSION_NOTES.md Normal file
View File

@@ -0,0 +1,77 @@
# Session Notes - 2026-02-19
## What's Been Done
### Build System
- Fixed mise.toml compile task (single quotes, proper shell escaping)
- Added upload task back
- All 3 boards now compile successfully
### Code Changes
- Added font abstraction (`setTitleFont`, `setBodyFont`, `setLabelFont`, `setDefaultFont`)
- Added CSS-like styling constants (`STYLE_*` in board_config.h)
- Added Layout helpers in `KlubhausCore/src/Style.h`
- Added `drawDebugTouch()` for red crosshair debug feature
- Added test harness for touch injection (`TEST:touch` commands)
- Fixed dashboard tile layout to use STYLE constants
### Neovim Integration
- Added keymaps in `~/.config/nvim/lua/config/keymaps.lua`
- Board auto-detection from current file path
- Notifications via `vim.notify()` on task completion/failure
- Keybindings: `<leader>mc`, `<leader>mu`, `<leader>mm`, `<leader>ma`, `<leader>mk`
### Documentation
- Updated AGENTS.md with Style System section
- Updated RTK section (slimmed down)
- Added note about multiple mise tasks with `&&`
## Current Issues / TODO
### High Priority
- [ ] Test touch coordinates on esp32-s3-lcd-43 - crosshair should show where user taps
- [ ] Debug why tiles overlap header (should be fixed with STYLE_HEADER_HEIGHT)
### Medium Priority
- [ ] Add theme support (dark/light mode)
- [ ] Fix esp32-32e and esp32-32e-4 dashboard rendering
### Low Priority
- [ ] Add more tile labels for esp32-s3-lcd-43 (currently just "1", "2", "3"...)
## Board Status
| Board | Display | Build | Notes |
|-------|---------|-------|-------|
| esp32-s3-lcd-43 | 800x480 | ✅ 35% | Main development board |
| esp32-32e-4 | 320x480 | ✅ 83% | |
| esp32-32e | 320x240 | ✅ 82% | |
## Style Constants Reference
### Spacing (in board_config.h)
- `STYLE_SPACING_X` - Base horizontal margin
- `STYLE_SPACING_Y` - Base vertical margin
- `STYLE_HEADER_HEIGHT` - Header bar height
- `STYLE_TILE_GAP` - Gap between tiles
- `STYLE_TILE_PADDING` - Tile internal padding
- `STYLE_TILE_RADIUS` - Tile border radius
### Colors
- `STYLE_COLOR_BG` - Screen background
- `STYLE_COLOR_HEADER` - Header background
- `STYLE_COLOR_FG` - Primary text color
- `STYLE_COLOR_ALERT` - Alert screen
- `STYLE_COLOR_TILE_1` through `STYLE_COLOR_TILE_4` - Tile colors
## Useful Commands
```bash
# Compile and upload
mise run compile && mise run upload && mise run monitor
# Debug touch (in serial monitor)
TEST:touch X Y press
TEST:touch X Y release
TEST:touch clear
```

View File

@@ -1,9 +1,86 @@
#include "DisplayDriverTFT.h"
#include <Arduino.h>
#include <KlubhausCore.h>
#include <TFT_eSPI.h>
extern DisplayManager display;
// ── Fonts ───────────────────────────────────────────────────
// TFT_eSPI built-in fonts for 320x480 display (scaled from 800x480)
// Using FreeFonts - scaled bitmap fonts via setTextSize would be too pixelated
// Note: FreeFonts are enabled via LOAD_GFXFF=1 in board-config.sh
void DisplayDriverTFT::setTitleFont() { _tft.setFreeFont(&FreeSansBold18pt7b); }
void DisplayDriverTFT::setBodyFont() { _tft.setFreeFont(&FreeSans12pt7b); }
void DisplayDriverTFT::setLabelFont() { _tft.setFreeFont(&FreeSans9pt7b); }
void DisplayDriverTFT::setDefaultFont() { _tft.setTextFont(2); }
// ── Test harness ───────────────────────────────────────────────
// Test harness: parse serial commands to inject synthetic touches
// Commands:
// TEST:touch x y press - simulate press at (x, y)
// TEST:touch x y release - simulate release at (x, y)
// TEST:touch clear - clear test mode
bool DisplayDriverTFT::parseTestTouch(int* outX, int* outY, bool* outPressed) {
if(!Serial.available())
return false;
if(Serial.peek() != 'T') {
return false;
}
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if(!cmd.startsWith("TEST:touch"))
return false;
int firstSpace = cmd.indexOf(' ');
if(firstSpace < 0)
return false;
String args = cmd.substring(firstSpace + 1);
args.trim();
if(args.equals("clear")) {
_testMode = false;
Serial.println("[TEST] Test mode cleared");
return false;
}
int secondSpace = args.indexOf(' ');
if(secondSpace < 0)
return false;
String xStr = args.substring(0, secondSpace);
String yState = args.substring(secondSpace + 1);
yState.trim();
int x = xStr.toInt();
int y = yState.substring(0, yState.indexOf(' ')).toInt();
String state = yState.substring(yState.indexOf(' ') + 1);
state.trim();
bool pressed = state.equals("press");
Serial.printf("[TEST] Injecting touch: (%d,%d) %s\n", x, y, pressed ? "press" : "release");
if(outX)
*outX = x;
if(outY)
*outY = y;
if(outPressed)
*outPressed = pressed;
_testMode = true;
return true;
}
void DisplayDriverTFT::begin() {
// Backlight
pinMode(PIN_LCD_BL, OUTPUT);
@@ -13,7 +90,8 @@ void DisplayDriverTFT::begin() {
_tft.setRotation(DISPLAY_ROTATION);
_tft.fillScreen(TFT_BLACK);
Serial.printf("[GFX] Display OK: %dx%d\n", DISPLAY_WIDTH, DISPLAY_HEIGHT);
Serial.printf("[GFX] Display OK: const %dx%d, tft %dx%d\n", DISPLAY_WIDTH, DISPLAY_HEIGHT,
_tft.width(), _tft.height());
Serial.flush();
// Debug: check if touch controller is responding
@@ -60,6 +138,12 @@ void DisplayDriverTFT::render(const ScreenState& st) {
_needsRedraw = false;
}
break;
case ScreenID::STATUS:
if(_needsRedraw) {
drawStatus(st);
_needsRedraw = false;
}
break;
case ScreenID::OFF:
if(_needsRedraw) {
_tft.fillScreen(TFT_BLACK);
@@ -74,15 +158,18 @@ void DisplayDriverTFT::drawBoot(const ScreenState& st) {
_tft.fillScreen(TFT_BLACK);
_tft.setTextColor(TFT_WHITE, TFT_BLACK);
_tft.setTextSize(2);
_tft.setCursor(10, 10);
_tft.printf("KLUBHAUS v%s", FW_VERSION);
_tft.setTextSize(1);
_tft.setCursor(10, 40);
setTitleFont();
_tft.setCursor(10, 28); // y=28 baseline accounts for ~18px font height above baseline
_tft.print("KLUBHAUS");
setBodyFont();
_tft.setCursor(10, 55); // y adjusted for ~12px font
_tft.print(BOARD_NAME);
// Show boot stage status
_tft.setCursor(10, 70);
setLabelFont();
_tft.setCursor(10, 85); // y adjusted for ~9px label font
switch(stage) {
case BootStage::SPLASH:
_tft.print("Initializing...");
@@ -106,41 +193,73 @@ void DisplayDriverTFT::drawBoot(const ScreenState& st) {
}
void DisplayDriverTFT::drawAlert(const ScreenState& st) {
// Static 2-color pulse - alternate every ~2 seconds
uint32_t elapsed = millis() - st.alertStartMs;
uint8_t pulse = 180 + (uint8_t)(75.0f * sinf(elapsed / 300.0f));
uint16_t bg = _tft.color565(pulse, 0, 0);
bool brightPhase = (elapsed / 2000) % 2 == 0;
_tft.fillScreen(bg);
_tft.setTextColor(TFT_WHITE, bg);
// Redraw when phase changes OR when touch was released (to clear fill)
bool needsRedraw = (brightPhase != _lastAlertPhase) || _alertNeedsRedraw;
_tft.setTextSize(3);
_tft.setCursor(10, 20);
_tft.print(st.alertTitle.length() > 0 ? st.alertTitle : "ALERT");
if(needsRedraw) {
_lastAlertPhase = brightPhase;
_alertNeedsRedraw = false; // clear the flag
uint16_t bg = brightPhase ? TFT_RED : _tft.color565(180, 0, 0);
_tft.setTextSize(2);
_tft.setCursor(10, 80);
_tft.print(st.alertBody);
_tft.fillScreen(bg);
_tft.setTextColor(TFT_WHITE, bg);
_tft.setTextSize(1);
_tft.setCursor(10, DISPLAY_HEIGHT - 20);
_tft.print("Hold to silence...");
setTitleFont();
_tft.setCursor(10, 28);
_tft.print(st.alertTitle.length() > 0 ? st.alertTitle : "ALERT");
setBodyFont();
_tft.setCursor(10, 70);
_tft.print(st.alertBody);
setLabelFont();
_tft.setCursor(10, _tft.height() - 10);
_tft.print("Hold to silence...");
}
// Progressive fill hint - while touch is held
if(_alertTouchDown) {
uint32_t touchElapsed = millis() - _alertTouchStartMs;
float progress = (float)touchElapsed / (float)ALERT_FILL_DURATION_MS;
if(progress > 1.0f)
progress = 1.0f;
int dispH = _tft.height();
int fillHeight = (int)(dispH * progress);
if(fillHeight > 0) {
uint16_t overlay = _tft.color565(80, 0, 0);
_tft.fillRect(0, dispH - fillHeight, _tft.width(), fillHeight, overlay);
}
}
}
void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
_tft.fillScreen(TFT_BLACK);
// Header
_tft.setTextColor(TFT_WHITE, TFT_BLACK);
_tft.setTextSize(1);
_tft.setCursor(5, 5);
_tft.printf("KLUBHAUS");
// Use actual display dimensions (after rotation)
int dispW = _tft.width();
int dispH = _tft.height();
// WiFi indicator
_tft.setCursor(DISPLAY_WIDTH - 50, 5);
_tft.printf("WiFi:%s", st.wifiSsid.length() > 0 ? "ON" : "OFF");
// Header - using standard bitmap font for reliable positioning
_tft.fillRect(0, 0, dispW, STYLE_HEADER_HEIGHT, 0x1A1A); // Dark gray header
_tft.setTextSize(1);
_tft.setTextColor(TFT_WHITE);
_tft.setCursor(5, 20); // y=28 is baseline, text sits above this
_tft.print("KLUBHAUS");
// WiFi indicator - right aligned in header
const char* wifiText = st.wifiSsid.length() > 0 ? "WiFi:ON" : "WiFi:OFF";
int wifiW = _tft.textWidth(wifiText);
_tft.setCursor(dispW - wifiW - 10, 20);
_tft.print(wifiText);
// Get tile layouts from library helper
int tileCount = display.calculateDashboardLayouts(30, 8);
int tileCount = display.calculateDashboardLayouts(STYLE_HEADER_HEIGHT, STYLE_TILE_GAP);
display.setHeaderHeight(STYLE_HEADER_HEIGHT);
const TileLayout* layouts = display.getTileLayouts();
const char* tileLabels[] = { "Alert", "Silent", "Status", "Reboot" };
@@ -160,8 +279,8 @@ void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
_tft.drawRoundRect(x, y, w, h, 8, TFT_WHITE);
// Tile label
setBodyFont();
_tft.setTextColor(TFT_WHITE);
_tft.setTextSize(2);
int textLen = strlen(tileLabels[i]);
int textW = textLen * 12;
_tft.setCursor(x + w / 2 - textW / 2, y + h / 2 - 10);
@@ -169,13 +288,104 @@ void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
}
}
void DisplayDriverTFT::drawStatus(const ScreenState& st) {
int dispW = _tft.width();
int dispH = _tft.height();
_tft.fillScreen(TFT_BLACK);
// Header
_tft.fillRect(0, 0, dispW, STYLE_HEADER_HEIGHT, 0x1A1A);
_tft.setTextSize(1);
_tft.setTextColor(TFT_WHITE);
_tft.setCursor(5, 20);
_tft.print("STATUS");
// Back button in lower right
_tft.setCursor(dispW - 60, dispH - 20);
_tft.print("[BACK]");
// Status info
setBodyFont();
int y = STYLE_HEADER_HEIGHT + 20;
// WiFi
_tft.setCursor(10, y);
_tft.printf("WiFi: %s", st.wifiSsid.length() > 0 ? st.wifiSsid.c_str() : "N/A");
y += 20;
_tft.setCursor(10, y);
_tft.printf("RSSI: %d dBm", st.wifiRssi);
y += 20;
_tft.setCursor(10, y);
_tft.printf("IP: %s", st.ipAddr.length() > 0 ? st.ipAddr.c_str() : "N/A");
y += 30;
// Uptime
uint32_t upSec = st.uptimeMs / 1000;
uint32_t upMin = upSec / 60;
uint32_t upHr = upMin / 60;
upSec = upSec % 60;
upMin = upMin % 60;
_tft.setCursor(10, y);
_tft.printf("Uptime: %02lu:%02lu:%02lu", upHr, upMin, upSec);
y += 20;
// Heap
_tft.setCursor(10, y);
_tft.printf("Heap: %d bytes", ESP.getFreeHeap());
y += 20;
// Last poll
uint32_t pollAgo = (millis() - st.lastPollMs) / 1000;
_tft.setCursor(10, y);
_tft.printf("Last poll: %lu sec ago", pollAgo);
}
// ── Touch ───────────────────────────────────────────────────
TouchEvent DisplayDriverTFT::readTouch() {
TouchEvent evt;
// Check for test injection via serial
int testX, testY;
bool testPressed;
if(parseTestTouch(&testX, &testY, &testPressed)) {
if(testPressed && !_touchWasPressed) {
evt.pressed = true;
_touchDownX = testX;
_touchDownY = testY;
evt.downX = _touchDownX;
evt.downY = _touchDownY;
} else if(!testPressed && _touchWasPressed) {
evt.released = true;
evt.downX = _touchDownX;
evt.downY = _touchDownY;
}
if(testPressed) {
evt.x = testX;
evt.y = testY;
evt.downX = _touchDownX;
evt.downY = _touchDownY;
}
_touchWasPressed = testPressed;
return evt;
}
uint16_t tx, ty;
uint8_t touched = _tft.getTouch(&tx, &ty, 100);
// Debug: log touch state changes with transformed coords
if(touched != _touchWasPressed) {
int tx_form = tx, ty_form = ty;
transformTouch(&tx_form, &ty_form);
Serial.printf("[TOUCH] raw touched=%d wasPressed=%d (raw=%d,%d trans=%d,%d)\n", touched,
_touchWasPressed, tx, ty, tx_form, ty_form);
}
// Detect transitions (press/release)
if(touched && !_touchWasPressed) {
// Press transition: finger just touched down
@@ -201,21 +411,32 @@ TouchEvent DisplayDriverTFT::readTouch() {
// Track previous state for next call
_touchWasPressed = touched;
// Track alert touch for progressive hint
if(evt.pressed) {
_alertTouchDown = true;
_alertTouchStartMs = millis();
} else if(evt.released) {
_alertTouchDown = false;
_alertNeedsRedraw = true; // force redraw to clear fill
}
return evt;
}
void DisplayDriverTFT::transformTouch(int* x, int* y) {
// Resistive touch panel is rotated 90° vs display - swap coordinates
// Resistive touch panel is rotated 90° vs display - swap and adjust
// Touch panel: 320x480 (portrait), Display: 480x320 (landscape)
// This was the original working transform
int temp = *x;
*x = *y;
*y = temp;
}
HoldState DisplayDriverTFT::updateHold(unsigned long holdMs) {
HoldState DisplayDriverTFT::updateHold(const TouchEvent& evt, unsigned long holdMs) {
HoldState h;
TouchEvent t = readTouch();
if(t.pressed) {
if(evt.pressed) {
if(!_holdActive) {
_holdActive = true;
_holdStartMs = millis();
@@ -227,23 +448,17 @@ HoldState DisplayDriverTFT::updateHold(unsigned long holdMs) {
h.completed = (held >= holdMs);
// Simple progress bar at bottom of screen
int barW = (int)(DISPLAY_WIDTH * h.progress);
_tft.fillRect(0, DISPLAY_HEIGHT - 8, barW, 8, TFT_WHITE);
_tft.fillRect(barW, DISPLAY_HEIGHT - 8, DISPLAY_WIDTH - barW, 8, TFT_DARKGREY);
int dispW = _tft.width();
int dispH = _tft.height();
int barW = (int)(dispW * h.progress);
_tft.fillRect(0, dispH - 8, barW, 8, TFT_WHITE);
_tft.fillRect(barW, dispH - 8, dispW - barW, 8, TFT_DARKGREY);
} else {
if(_holdActive) {
// Clear the progress bar when released
_tft.fillRect(0, DISPLAY_HEIGHT - 8, DISPLAY_WIDTH, 8, TFT_DARKGREY);
_tft.fillRect(0, _tft.height() - 8, _tft.width(), 8, TFT_DARKGREY);
}
_holdActive = false;
}
return h;
}
void DisplayDriverTFT::updateHint(int x, int y, bool active) {
float period = active ? 500.0f : 2000.0f;
float t = fmodf(millis(), period) / period;
uint8_t v = static_cast<uint8_t>(30.0f + 30.0f * sinf(t * 2.0f * PI));
uint16_t col = _tft.color565(v, v, v);
_tft.drawRect(x - 40, y - 20, 80, 40, col);
}

View File

@@ -11,18 +11,27 @@ public:
void setBacklight(bool on) override;
void render(const ScreenState& state) override;
TouchEvent readTouch() override;
HoldState updateHold(unsigned long holdMs) override;
void updateHint(int x, int y, bool active) override;
int width() override { return _tft.width(); }
HoldState updateHold(const TouchEvent& evt, unsigned long holdMs) override;
int width() override {
// Use TFT_eSPI's dimensions after rotation - it's more reliable
return _tft.width();
}
int height() override { return _tft.height(); }
// Dashboard - uses transform for touch coordinate correction
void transformTouch(int* x, int* y) override;
// Fonts
void setTitleFont() override;
void setBodyFont() override;
void setLabelFont() override;
void setDefaultFont() override;
private:
void drawBoot(const ScreenState& st);
void drawAlert(const ScreenState& st);
void drawDashboard(const ScreenState& st);
void drawStatus(const ScreenState& st);
TFT_eSPI _tft;
@@ -32,8 +41,19 @@ private:
BootStage _lastBootStage = BootStage::SPLASH;
bool _needsRedraw = true;
// Touch hint for alert - progressive fill from bottom
bool _alertTouchDown = false;
bool _alertNeedsRedraw = false; // force redraw after touch release
uint32_t _alertTouchStartMs = 0;
bool _lastAlertPhase = false; // tracks bright/dark phase for 2-color alert
static constexpr uint32_t ALERT_FILL_DURATION_MS = 3000;
// Touch tracking for press/release detection
bool _touchWasPressed = false;
int _touchDownX = -1;
int _touchDownY = -1;
// Test mode for touch injection
bool _testMode = false;
bool parseTestTouch(int* outX, int* outY, bool* outPressed);
};

View File

@@ -1,4 +1,4 @@
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"
LIBS="--library ./vendor/esp32-32e-4/TFT_eSPI"
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM -DLOAD_GFXFF=1"

View File

@@ -1,6 +1,6 @@
#pragma once
#define BOARD_NAME "WS_32E_4"
#define BOARD_NAME "esp32-32e-4"
// ══════════════════════════════════════════════════════════
// Hosyond ESP32-32E 4" (320x480) with ST7796 + XPT2046
@@ -15,4 +15,23 @@
#define PIN_LCD_BL 27
// Touch — XPT2046 configured in tft_user_setup.h
// Touch CS: GPIO33, Touch IRQ: GPIO36
// Touch CS: GPIO33, Touch IRQ: GPIO36
// ── Style Constants (CSS-like) ────────────────────────────────────────
// Spacing - scaled for 320x480
#define STYLE_SPACING_X 6
#define STYLE_SPACING_Y 6
#define STYLE_HEADER_HEIGHT 24
#define STYLE_TILE_GAP 4
#define STYLE_TILE_PADDING 8
#define STYLE_TILE_RADIUS 4
// Colors
#define STYLE_COLOR_BG TFT_BLACK
#define STYLE_COLOR_HEADER 0x1A1A
#define STYLE_COLOR_FG TFT_WHITE
#define STYLE_COLOR_ALERT TFT_RED
#define STYLE_COLOR_TILE_1 0x0280
#define STYLE_COLOR_TILE_2 0x0400
#define STYLE_COLOR_TILE_3 0x0440
#define STYLE_COLOR_TILE_4 0x0100

View File

@@ -4,7 +4,14 @@
#include "DisplayDriverTFT.h"
#include "board_config.h"
// Include local secrets.h if it exists (board-specific credentials),
// otherwise KlubhausCore/src/secrets.h provides defaults.
#ifdef LOCAL_SECRETS
#include "secrets.h"
#else
#include <secrets.h>
#endif
#include <KlubhausCore.h>

View File

@@ -1,5 +1,85 @@
#include "DisplayDriverTFT.h"
#include <Arduino.h>
#include <KlubhausCore.h>
#include <TFT_eSPI.h>
extern DisplayManager display;
// ── Fonts ───────────────────────────────────────────────────
// TFT_eSPI built-in fonts for 320x240 display (further scaled)
// Using FreeFonts for better readability
void DisplayDriverTFT::setTitleFont() { _tft.setFreeFont(&FreeSansBold12pt7b); }
void DisplayDriverTFT::setBodyFont() { _tft.setFreeFont(&FreeSans9pt7b); }
void DisplayDriverTFT::setLabelFont() { _tft.setFreeFont(&FreeSans9pt7b); }
void DisplayDriverTFT::setDefaultFont() { _tft.setTextFont(2); }
// ── Test harness ───────────────────────────────────────────────
// Test harness: parse serial commands to inject synthetic touches
// Commands:
// TEST:touch x y press - simulate press at (x, y)
// TEST:touch x y release - simulate release at (x, y)
// TEST:touch clear - clear test mode
bool DisplayDriverTFT::parseTestTouch(int* outX, int* outY, bool* outPressed) {
if(!Serial.available())
return false;
if(Serial.peek() != 'T') {
return false;
}
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if(!cmd.startsWith("TEST:touch"))
return false;
int firstSpace = cmd.indexOf(' ');
if(firstSpace < 0)
return false;
String args = cmd.substring(firstSpace + 1);
args.trim();
if(args.equals("clear")) {
_testMode = false;
Serial.println("[TEST] Test mode cleared");
return false;
}
int secondSpace = args.indexOf(' ');
if(secondSpace < 0)
return false;
String xStr = args.substring(0, secondSpace);
String yState = args.substring(secondSpace + 1);
yState.trim();
int x = xStr.toInt();
int y = yState.substring(0, yState.indexOf(' ')).toInt();
String state = yState.substring(yState.indexOf(' ') + 1);
state.trim();
bool pressed = state.equals("press");
Serial.printf("[TEST] Injecting touch: (%d,%d) %s\n", x, y, pressed ? "press" : "release");
if(outX)
*outX = x;
if(outY)
*outY = y;
if(outPressed)
*outPressed = pressed;
_testMode = true;
return true;
}
void DisplayDriverTFT::begin() {
// Backlight
pinMode(PIN_LCD_BL, OUTPUT);
@@ -120,7 +200,7 @@ void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
_tft.setTextColor(TFT_WHITE, TFT_BLACK);
_tft.setTextSize(1);
_tft.setCursor(5, 5);
_tft.setCursor(5, 10);
_tft.printf("KLUBHAUS — %s", deviceStateStr(st.deviceState));
int y = 30;
@@ -145,6 +225,34 @@ void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
TouchEvent DisplayDriverTFT::readTouch() {
TouchEvent evt;
// Check for test injection via serial
int testX, testY;
bool testPressed;
if(parseTestTouch(&testX, &testY, &testPressed)) {
if(testPressed && !_touchWasPressed) {
evt.pressed = true;
_touchDownX = testX;
_touchDownY = testY;
evt.downX = _touchDownX;
evt.downY = _touchDownY;
} else if(!testPressed && _touchWasPressed) {
evt.released = true;
evt.downX = _touchDownX;
evt.downY = _touchDownY;
}
if(testPressed) {
evt.x = testX;
evt.y = testY;
evt.downX = _touchDownX;
evt.downY = _touchDownY;
}
_touchWasPressed = testPressed;
return evt;
}
uint16_t tx, ty;
uint8_t touched = _tft.getTouch(&tx, &ty, 100);
@@ -190,11 +298,10 @@ int DisplayDriverTFT::dashboardTouch(int x, int y) {
return row * 2 + col; // 0, 1, 2, or 3
}
HoldState DisplayDriverTFT::updateHold(unsigned long holdMs) {
HoldState DisplayDriverTFT::updateHold(const TouchEvent& evt, unsigned long holdMs) {
HoldState h;
TouchEvent t = readTouch();
if(t.pressed) {
if(evt.pressed) {
if(!_holdActive) {
_holdActive = true;
_holdStartMs = millis();
@@ -219,10 +326,8 @@ HoldState DisplayDriverTFT::updateHold(unsigned long holdMs) {
return h;
}
void DisplayDriverTFT::updateHint(int x, int y, bool active) {
float period = active ? 500.0f : 2000.0f;
float t = fmodf(millis(), period) / period;
uint8_t v = static_cast<uint8_t>(30.0f + 30.0f * sinf(t * 2.0f * PI));
uint16_t col = _tft.color565(v, v, v);
_tft.drawRect(x - 40, y - 20, 80, 40, col);
void DisplayDriverTFT::drawDebugTouch(int x, int y) {
const int size = 20;
_tft.drawLine(x - size, y, x + size, y, TFT_RED);
_tft.drawLine(x, y - size, x, y + size, TFT_RED);
}

View File

@@ -11,12 +11,20 @@ public:
void setBacklight(bool on) override;
void render(const ScreenState& state) override;
TouchEvent readTouch() override;
int dashboardTouch(int x, int y) override;
HoldState updateHold(unsigned long holdMs) override;
void updateHint(int x, int y, bool active) override;
int dashboardTouch(int x, int y);
HoldState updateHold(const TouchEvent& evt, unsigned long holdMs) override;
int width() override { return DISPLAY_WIDTH; }
int height() override { return DISPLAY_HEIGHT; }
// Fonts
void setTitleFont() override;
void setBodyFont() override;
void setLabelFont() override;
void setDefaultFont() override;
// Debug
void drawDebugTouch(int x, int y) override;
private:
void drawBoot(const ScreenState& st);
void drawAlert(const ScreenState& st);
@@ -34,4 +42,8 @@ private:
bool _touchWasPressed = false;
int _touchDownX = -1;
int _touchDownY = -1;
// Test mode for touch injection
bool _testMode = false;
bool parseTestTouch(int* outX, int* outY, bool* outPressed);
};

View File

@@ -1,4 +1,4 @@
FQBN="esp32:esp32:esp32:FlashSize=4M,PartitionScheme=default"
PORT="/dev/ttyUSB0"
LIBS="--libraries ./vendor/esp32-32e/TFT_eSPI"
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM"
LIBS="--library ./vendor/esp32-32e/TFT_eSPI"
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM -DLOCAL_SECRETS -DLOAD_GFXFF=1"

View File

@@ -1,6 +1,6 @@
#pragma once
#define BOARD_NAME "WS_32E"
#define BOARD_NAME "esp32-32e"
// ══════════════════════════════════════════════════════════
// TODO: Set these to match YOUR display + wiring.
@@ -20,3 +20,22 @@
// If using capacitive touch (e.g. FT6236), configure I2C pins here:
// #define TOUCH_SDA 21
// #define TOUCH_SCL 22
// ── Style Constants (CSS-like) ────────────────────────────────────────
// Spacing - scaled for 320x240
#define STYLE_SPACING_X 4
#define STYLE_SPACING_Y 4
#define STYLE_HEADER_HEIGHT 20
#define STYLE_TILE_GAP 4
#define STYLE_TILE_PADDING 6
#define STYLE_TILE_RADIUS 4
// Colors
#define STYLE_COLOR_BG TFT_BLACK
#define STYLE_COLOR_HEADER 0x1A1A
#define STYLE_COLOR_FG TFT_WHITE
#define STYLE_COLOR_ALERT TFT_RED
#define STYLE_COLOR_TILE_1 0x0280
#define STYLE_COLOR_TILE_2 0x0400
#define STYLE_COLOR_TILE_3 0x0440
#define STYLE_COLOR_TILE_4 0x0100

View File

@@ -4,7 +4,14 @@
#include "DisplayDriverTFT.h"
#include "board_config.h"
// Include local secrets.h if it exists (board-specific credentials),
// otherwise KlubhausCore/src/secrets.h provides defaults.
#ifdef LOCAL_SECRETS
#include "secrets.h"
#else
#include <secrets.h>
#endif
#include <KlubhausCore.h>

View File

@@ -58,6 +58,16 @@ int DisplayDriverGFX::width() { return _gfx ? _gfx->width() : DISP_W; }
int DisplayDriverGFX::height() { return _gfx ? _gfx->height() : DISP_H; }
// ── Fonts ──
// LovyanGFX built-in fonts for 800x480 display
void DisplayDriverGFX::setTitleFont() { _gfx->setFont(&fonts::FreeSansBold24pt7b); }
void DisplayDriverGFX::setBodyFont() { _gfx->setFont(&fonts::FreeSans18pt7b); }
void DisplayDriverGFX::setLabelFont() { _gfx->setFont(&fonts::FreeSans12pt7b); }
void DisplayDriverGFX::setDefaultFont() { _gfx->setFont(&fonts::Font2); }
// Transform touch coordinates to match display orientation
// GT911 touch panel on this board is rotated 180° relative to display
void DisplayDriverGFX::transformTouch(int* x, int* y) {
@@ -121,9 +131,12 @@ bool DisplayDriverGFX::parseTestTouch(int* outX, int* outY, bool* outPressed) {
Serial.printf("[TEST] Injecting touch: (%d,%d) %s\n", x, y, pressed ? "press" : "release");
if(outX) *outX = x;
if(outY) *outY = y;
if(outPressed) *outPressed = pressed;
if(outX)
*outX = x;
if(outY)
*outY = y;
if(outPressed)
*outPressed = pressed;
_testMode = true;
return true;
@@ -181,6 +194,15 @@ TouchEvent DisplayDriverGFX::readTouch() {
int32_t x, y;
bool pressed = _gfx->getTouch(&x, &y);
// Filter out invalid coordinates (touch panel can return garbage on release)
// Ignore both press and release transitions when coordinates are out of bounds
bool validCoords = !(x < 0 || x > DISP_W || y < 0 || y > DISP_H);
if(!validCoords) {
pressed = false;
// Don't update _lastTouch.pressed - keep previous state to avoid false release
return evt;
}
// Debounce: ignore repeated press events within debounce window after release
unsigned long now = millis();
if(pressed && _touchBounced) {
@@ -248,10 +270,10 @@ int DisplayDriverGFX::dashboardTouch(int x, int y) {
return row * cols + col;
}
HoldState DisplayDriverGFX::updateHold(unsigned long holdMs) {
HoldState DisplayDriverGFX::updateHold(const TouchEvent& evt, unsigned long holdMs) {
HoldState state;
if(!_lastTouch.pressed) {
if(!evt.pressed) {
_isHolding = false;
return state;
}
@@ -317,24 +339,32 @@ void DisplayDriverGFX::render(const ScreenState& state) {
_needsRedraw = false;
}
break;
case ScreenID::STATUS:
if(_needsRedraw) {
drawStatus(state);
_needsRedraw = false;
}
break;
}
}
void DisplayDriverGFX::drawBoot(const ScreenState& state) {
BootStage stage = state.bootStage;
_gfx->fillScreen(0x000000);
_gfx->setTextColor(0xFFFF);
_gfx->setTextSize(2);
_gfx->setCursor(10, 10);
_gfx->fillScreen(TFT_BLACK);
_gfx->setTextColor(STYLE_COLOR_FG);
setTitleFont();
_gfx->setCursor(STYLE_SPACING_X, STYLE_SPACING_Y);
_gfx->print("KLUBHAUS");
_gfx->setTextSize(1);
_gfx->setCursor(10, 50);
setBodyFont();
_gfx->setCursor(STYLE_SPACING_X, STYLE_SPACING_Y + STYLE_HEADER_HEIGHT);
_gfx->print(BOARD_NAME);
// Show boot stage status
_gfx->setCursor(10, 80);
setLabelFont();
_gfx->setCursor(STYLE_SPACING_X, STYLE_SPACING_Y + STYLE_HEADER_HEIGHT + 30);
switch(stage) {
case BootStage::SPLASH:
_gfx->print("Initializing...");
@@ -363,43 +393,65 @@ void DisplayDriverGFX::drawAlert(const ScreenState& state) {
uint16_t bg = _gfx->color565(pulse, 0, 0);
_gfx->fillScreen(bg);
_gfx->setTextColor(0xFFFF, bg);
_gfx->setTextColor(STYLE_COLOR_FG, bg);
_gfx->setTextSize(3);
_gfx->setCursor(10, 20);
setTitleFont();
_gfx->setCursor(STYLE_SPACING_X, STYLE_HEADER_HEIGHT);
_gfx->print(state.alertTitle.length() > 0 ? state.alertTitle.c_str() : "ALERT");
_gfx->setTextSize(2);
_gfx->setCursor(10, 80);
setBodyFont();
_gfx->setCursor(STYLE_SPACING_X, STYLE_HEADER_HEIGHT + 50);
_gfx->print(state.alertBody);
_gfx->setTextSize(1);
_gfx->setCursor(10, DISP_H - 20);
setLabelFont();
_gfx->setCursor(STYLE_SPACING_X, DISP_H - STYLE_SPACING_Y);
_gfx->print("Hold to silence...");
}
void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
_gfx->fillScreen(0x001030); // Dark blue
_gfx->fillScreen(STYLE_COLOR_BG);
// Header
_gfx->fillRect(0, 0, DISP_W, 40, 0x1A1A); // Dark gray
_gfx->setFont(&fonts::Font2);
_gfx->setTextColor(0xFFFF);
_gfx->setTextSize(1);
_gfx->setCursor(10, 12);
_gfx->printf("KLUBHAUS");
// Header - use Layout for safe positioning
_gfx->fillRect(0, 0, DISP_W, STYLE_HEADER_HEIGHT, STYLE_COLOR_HEADER);
Layout header = Layout::header(DISP_W, STYLE_HEADER_HEIGHT);
Layout safeText = header.padded(STYLE_SPACING_X);
// WiFi status
_gfx->setCursor(DISP_W - 120, 12);
_gfx->printf("WiFi:%s", state.wifiSsid.length() > 0 ? "ON" : "OFF");
setBodyFont();
_gfx->setTextColor(STYLE_COLOR_FG);
// Title with scrolling support
_headerScroller.setText("KLUBHAUS");
_headerScroller.setScrollSpeed(80);
_headerScroller.setPauseDuration(2000);
_headerScroller.render(
[&](int16_t x, const char* s) {
_gfx->setCursor(safeText.x + x, safeText.y + 4);
_gfx->print(s);
},
safeText.w);
// WiFi status - right aligned with scrolling
Layout wifiArea(DISP_W - 150, 0, 140, STYLE_HEADER_HEIGHT);
Layout safeWifi = wifiArea.padded(4);
_wifiScroller.setText(state.wifiSsid.length() > 0 ? state.wifiSsid.c_str() : "WiFi: OFF");
_wifiScroller.setScrollSpeed(60);
_wifiScroller.setPauseDuration(1500);
_wifiScroller.render(
[&](int16_t x, const char* s) {
_gfx->setCursor(safeWifi.x + x, safeWifi.y + 4);
_gfx->print(s);
},
safeWifi.w);
// Get tile layouts from library helper
int tileCount = display.calculateDashboardLayouts(30, 8);
int tileCount = display.calculateDashboardLayouts(STYLE_HEADER_HEIGHT, STYLE_TILE_GAP);
display.setHeaderHeight(STYLE_HEADER_HEIGHT);
const TileLayout* layouts = display.getTileLayouts();
const char* tileLabels[] = { "1", "2", "3", "4", "5", "6", "7", "8" };
const char* tileLabels[] = { "Alert", "Silent", "Status", "Reboot" };
const uint16_t tileColors[] = { 0x0280, 0x0400, 0x0440, 0x0100 };
for(int i = 0; i < tileCount && i < 8; i++) {
for(int i = 0; i < tileCount && i < 4; i++) {
const TileLayout& lay = layouts[i];
int x = lay.x;
int y = lay.y;
@@ -407,34 +459,127 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
int h = lay.h;
// Tile background
_gfx->fillRoundRect(x, y, w, h, 8, 0x0220);
_gfx->fillRoundRect(x, y, w, h, STYLE_TILE_RADIUS, tileColors[i]);
// Tile border
_gfx->drawRoundRect(x, y, w, h, 8, 0xFFFF);
_gfx->drawRoundRect(x, y, w, h, STYLE_TILE_RADIUS, STYLE_COLOR_FG);
// Tile label
_gfx->setTextColor(0xFFFF);
_gfx->setTextSize(2);
_gfx->setCursor(x + w / 2 - 10, y + h / 2 - 10);
_gfx->setTextColor(STYLE_COLOR_FG);
setBodyFont();
int textLen = strlen(tileLabels[i]);
int textW = textLen * 14;
_gfx->setCursor(x + w / 2 - textW / 2, y + h / 2 - 10);
_gfx->print(tileLabels[i]);
}
}
void DisplayDriverGFX::updateHint(int x, int y, bool active) {
void DisplayDriverGFX::drawStatus(const ScreenState& st) {
_gfx->fillScreen(STYLE_COLOR_BG);
// Header with title and back button
_gfx->fillRect(0, 0, DISP_W, STYLE_HEADER_HEIGHT, STYLE_COLOR_HEADER);
Layout header = Layout::header(DISP_W, STYLE_HEADER_HEIGHT);
Layout safeText = header.padded(STYLE_SPACING_X);
setTitleFont();
_gfx->setTextColor(STYLE_COLOR_FG);
_gfx->setCursor(safeText.x, safeText.y + 4);
_gfx->print("STATUS");
// Back button in lower-right
setLabelFont();
_gfx->setCursor(DISP_W - 60, DISP_H - 20);
_gfx->print("[BACK]");
// Status items in 2-column layout
setBodyFont();
int colWidth = DISP_W / 2;
int startY = STYLE_HEADER_HEIGHT + 30;
int rowHeight = 35;
int labelX = STYLE_SPACING_X + 10;
int valueX = STYLE_SPACING_X + 120;
// Column 1
int y = startY;
// WiFi SSID
_gfx->setTextColor(0x8888);
_gfx->setCursor(labelX, y);
_gfx->print("WiFi:");
_gfx->setTextColor(STYLE_COLOR_FG);
_gfx->setCursor(valueX, y);
_gfx->print(st.wifiSsid.length() > 0 ? st.wifiSsid.c_str() : "N/A");
// RSSI
y += rowHeight;
_gfx->setTextColor(0x8888);
_gfx->setCursor(labelX, y);
_gfx->print("Signal:");
_gfx->setTextColor(STYLE_COLOR_FG);
_gfx->setCursor(valueX, y);
_gfx->printf("%d dBm", st.wifiRssi);
// IP Address
y += rowHeight;
_gfx->setTextColor(0x8888);
_gfx->setCursor(labelX, y);
_gfx->print("IP:");
_gfx->setTextColor(STYLE_COLOR_FG);
_gfx->setCursor(valueX, y);
_gfx->print(st.ipAddr.length() > 0 ? st.ipAddr.c_str() : "N/A");
// Column 2
y = startY;
// Uptime
uint32_t upSec = st.uptimeMs / 1000;
uint32_t upMin = upSec / 60;
uint32_t upHr = upMin / 60;
upSec = upSec % 60;
upMin = upMin % 60;
_gfx->setTextColor(0x8888);
_gfx->setCursor(colWidth + labelX, y);
_gfx->print("Uptime:");
_gfx->setTextColor(STYLE_COLOR_FG);
_gfx->setCursor(colWidth + valueX, y);
_gfx->printf("%02lu:%02lu:%02lu", upHr, upMin, upSec);
// Heap
y += rowHeight;
_gfx->setTextColor(0x8888);
_gfx->setCursor(colWidth + labelX, y);
_gfx->print("Heap:");
_gfx->setTextColor(STYLE_COLOR_FG);
_gfx->setCursor(colWidth + valueX, y);
_gfx->printf("%d KB", ESP.getFreeHeap() / 1024);
// Last Poll
y += rowHeight;
_gfx->setTextColor(0x8888);
_gfx->setCursor(colWidth + labelX, y);
_gfx->print("Last Poll:");
_gfx->setTextColor(STYLE_COLOR_FG);
_gfx->setCursor(colWidth + valueX, y);
uint32_t pollAgo = (millis() - st.lastPollMs) / 1000;
if(pollAgo < 60) {
_gfx->printf("%lu sec", pollAgo);
} else {
_gfx->printf("%lu min", pollAgo / 60);
}
// Footer with firmware version
setLabelFont();
_gfx->setTextColor(0x6666);
_gfx->setCursor(STYLE_SPACING_X, DISP_H - STYLE_SPACING_Y);
_gfx->print("v" FW_VERSION);
}
void DisplayDriverGFX::drawDebugTouch(int x, int y) {
if(!_gfx)
return;
static uint32_t lastTime = 0;
uint32_t now = millis();
if(now - lastTime < 100)
return;
lastTime = now;
// active=true: faster pulse (500ms), active=false: slower pulse (2000ms)
float period = active ? 500.0f : 2000.0f;
float t = fmodf(now, period) / period;
uint8_t v = static_cast<uint8_t>(30.0f + 30.0f * sinf(t * 2.0f * 3.14159f));
uint16_t col = _gfx->color565(v, v, v);
_gfx->drawCircle(x, y, 50, col);
const int size = 20;
_gfx->drawLine(x - size, y, x + size, y, TFT_RED);
_gfx->drawLine(x, y - size, x, y + size, TFT_RED);
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include "IDisplayDriver.h"
#include "Style.h"
#include <Arduino.h>
@@ -12,12 +13,18 @@ public:
void render(const ScreenState& state) override;
TouchEvent readTouch() override;
HoldState updateHold(unsigned long holdMs) override;
void updateHint(int x, int y, bool active) override;
HoldState updateHold(const TouchEvent& evt, unsigned long holdMs) override;
void drawDebugTouch(int x, int y) override;
int width() override;
int height() override;
// Fonts
void setTitleFont() override;
void setBodyFont() override;
void setLabelFont() override;
void setDefaultFont() override;
// Transform touch coordinates (handles rotated touch panels)
void transformTouch(int* x, int* y) override;
@@ -35,6 +42,7 @@ private:
void drawBoot(const ScreenState& state);
void drawAlert(const ScreenState& state);
void drawDashboard(const ScreenState& state);
void drawStatus(const ScreenState& state);
// Touch handling
TouchEvent _lastTouch = { false, false, 0, 0, -1, -1 };
@@ -48,4 +56,8 @@ private:
ScreenID _lastScreen = ScreenID::BOOT;
BootStage _lastBootStage = BootStage::SPLASH;
bool _needsRedraw = true;
// Text scrollers for scrolling elements
TextScroller _headerScroller;
TextScroller _wifiScroller;
};

View File

@@ -1,4 +1,4 @@
FQBN="esp32:esp32:waveshare_esp32_s3_touch_lcd_43:PSRAM=enabled,FlashSize=16M,USBMode=hwcdc,PartitionScheme=app3M_fat9M_16MB"
PORT="/dev/ttyACM0"
LIBS="--libraries ~/Arduino/libraries/LovyanGFX"
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM -DLGFX_USE_V1"
PORT="/dev/ttyUSB0"
LIBS="--library ./vendor/esp32-s3-lcd-43/LovyanGFX"
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM -DLGFX_USE_V1 -DLOCAL_SECRETS"

View File

@@ -1,6 +1,6 @@
#pragma once
#define BOARD_NAME "WS_S3_43"
#define BOARD_NAME "esp32-s3-lcd-43"
#define DISPLAY_WIDTH 800
#define DISPLAY_HEIGHT 480
#define DISPLAY_ROTATION 0
@@ -38,3 +38,22 @@
// ── GT911 Touch ──
#define GT911_ADDR 0x5D
// #define TOUCH_INT -1
// ── Style Constants (CSS-like) ────────────────────────────────────────
// Spacing
#define STYLE_SPACING_X 10
#define STYLE_SPACING_Y 10
#define STYLE_HEADER_HEIGHT 45
#define STYLE_TILE_GAP 8
#define STYLE_TILE_PADDING 16
#define STYLE_TILE_RADIUS 8
// Colors
#define STYLE_COLOR_BG 0x001030 // Dark blue
#define STYLE_COLOR_HEADER 0x1A1A // Dark gray
#define STYLE_COLOR_FG TFT_WHITE
#define STYLE_COLOR_ALERT TFT_RED
#define STYLE_COLOR_TILE_1 0x0280 // Green
#define STYLE_COLOR_TILE_2 0x0400 // Dark green
#define STYLE_COLOR_TILE_3 0x0440 // Teal
#define STYLE_COLOR_TILE_4 0x0100 // Dark red

189
justfile Normal file
View File

@@ -0,0 +1,189 @@
# Klubhaus Doorbell - justfile
# Run with: just <recipe>
# Set BOARD: just BOARD=esp32-32e-4 compile
# Default recipe - show help
default:
@just --list
# Set default BOARD
BOARD := "esp32-32e-4"
# Helper to source board config
# just passes args as positional, so we source in each recipe
# Compile firmware
compile:
#!/usr/bin/env bash
set -e
source ./boards/{{BOARD}}/board-config.sh
# Only regenerate compile_commands.json if needed (board changed or first run)
NEED_GEN=false
if [ ! -f compile_commands.json ]; then
NEED_GEN=true
elif [ ! -f .board-last ] || [ "$(cat .board-last)" != "{{BOARD}}" ]; then
NEED_GEN=true
fi
if [ "$NEED_GEN" = "true" ]; then
rm -rf /tmp/arduino-build
arduino-cli compile --only-compilation-database --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$OPTS" --build-path /tmp/arduino-build ./boards/{{BOARD}}
cp /tmp/arduino-build/compile_commands.json .
echo "{{BOARD}}" > .board-last
echo "[OK] Generated compile_commands.json for {{BOARD}}"
else
echo "[SKIP] compile_commands.json already up to date for {{BOARD}}"
fi
arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$OPTS" --warnings default ./boards/{{BOARD}}
# Upload firmware
upload: kill
#!/usr/bin/env bash
set -e
source ./boards/{{BOARD}}/board-config.sh
arduino-cli upload --fqbn "$FQBN" --port "$PORT" ./boards/{{BOARD}}
# Kill processes using serial port
kill:
#!/usr/bin/env bash
set +e
source ./boards/{{BOARD}}/board-config.sh
PORT="${PORT:-$PORT}"
echo "Killing processes on $PORT..."
fuser -k "$PORT" 2>/dev/null || true
for pid in $(pgrep -f "monitor-agent.py.*{{BOARD}}" 2>/dev/null || true); do
echo "Killing monitor-agent.py (PID: $pid)..."
kill "$pid" 2>/dev/null || true
done
rm -f "/tmp/doorbell-{{BOARD}}.lock" 2>/dev/null || true
sleep 1
echo "[OK] Killed processes for {{BOARD}}"
# Monitor raw serial
monitor-raw: kill
#!/usr/bin/env bash
source ./boards/{{BOARD}}/board-config.sh
PORT="${PORT:-$PORT}"
TARGET="$(readlink -f "$PORT" 2>/dev/null || echo "$PORT")"
arduino-cli monitor -p "$TARGET" --config baudrate=115200
# Show tio command
monitor-tio: kill
#!/usr/bin/env bash
source ./boards/{{BOARD}}/board-config.sh
PORT="${PORT:-$PORT}"
TARGET="$(readlink -f "$PORT" 2>/dev/null || echo "$PORT")"
echo "Run: tio --map INLCRNL $TARGET -e"
# Monitor with JSON logging
monitor: kill
#!/usr/bin/env bash
python3 ./scripts/monitor-agent.py "{{BOARD}}" &
# Tail colored logs
watch:
#!/usr/bin/env bash
tail -f "/tmp/doorbell-{{BOARD}}.jsonl" | while read -r line; do
ts=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['ts'])" 2>/dev/null)
txt=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['line'])" 2>/dev/null)
if [[ "$txt" == *"[STATE]"* ]]; then
echo -e "\033[1;35m[$ts]\033[0m $txt"
elif [[ "$txt" == *"[ADMIN]"* ]]; then
echo -e "\033[1;36m[$ts]\033[0m $txt"
elif [[ "$txt" == *"[TOUCH]"* ]]; then
echo -e "\033[1;33m[$ts]\033[0m $txt"
elif [[ "$txt" == *"ALERT"* ]]; then
echo -e "\033[1;31m[$ts]\033[0m $txt"
else
echo "[$ts] $txt"
fi
done
# Send command to device
cmd command:
#!/usr/bin/env bash
echo -n "{{command}}" > "/tmp/doorbell-{{BOARD}}-cmd.fifo"
echo "[SENT] {{command}}"
# Show device state
state:
@cat "/tmp/doorbell-{{BOARD}}-state.json"
# Detect connected board
detect:
#!/usr/bin/env bash
bash ./scripts/detect-device.sh
# Install shared libraries
install-libs-shared:
#!/usr/bin/env bash
./scripts/install-shared.sh
# Install all libraries
install: install-libs-shared
#!/usr/bin/env bash
./boards/{{BOARD}}/install.sh
arduino-cli core install esp32:esp32
# Clean build artifacts
clean:
#!/usr/bin/env bash
rm -rf vendor/
rm -rf .cache/
rm -rf boards/esp32-32e/build
rm -rf boards/esp32-32e-4/build
rm -rf boards/esp32-s3-lcd-43/build
rm -f .board-last
echo "[OK] Build artifacts cleaned"
# Clean temporary files (monitor logs, FIFOs, state files)
clean-temp:
#!/usr/bin/env bash
rm -f /tmp/doorbell-esp32-32e.jsonl /tmp/doorbell-esp32-32e-state.json /tmp/doorbell-esp32-32e-cmd.fifo
rm -f /tmp/doorbell-esp32-32e-4.jsonl /tmp/doorbell-esp32-32e-4-state.json /tmp/doorbell-esp32-32e-4-cmd.fifo
rm -f /tmp/doorbell-esp32-s3-lcd-43.jsonl /tmp/doorbell-esp32-s3-lcd-43-state.json /tmp/doorbell-esp32-s3-lcd-43-cmd.fifo
rm -f .board-last
echo "[OK] Temp files cleaned"
# Clean Arduino cache
arduino-clean:
#!/usr/bin/env bash
echo "Checking ~/.arduino15..."
du -sh ~/.arduino15/staging 2>/dev/null || echo "No staging folder"
du -sh ~/.arduino15/packages 2>/dev/null || echo "No packages folder"
read -p "Delete staging + packages folders? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
rm -rf ~/.arduino15/staging
rm -rf ~/.arduino15/packages
echo "[OK] Arduino staging + packages cleared"
else
echo "Aborted"
fi
# Format code
format:
#!/usr/bin/env bash
clang-format -i --style=file \
boards/esp32-32e/*.cpp \
boards/esp32-32e/*.h \
boards/esp32-32e/*.ino \
boards/esp32-32e-4/*.cpp \
boards/esp32-32e-4/*.h \
boards/esp32-32e-4/*.ino \
boards/esp32-s3-lcd-43/*.cpp \
boards/esp32-s3-lcd-43/*.h \
boards/esp32-s3-lcd-43/*.ino \
libraries/KlubhausCore/src/*.cpp \
libraries/KlubhausCore/src/*.h \
libraries/KlubhausCore/*.properties
# Generate crush config
gen-crush-config:
#!/usr/bin/env bash
set -e
source ./boards/{{BOARD}}/board-config.sh
printf '{\n "lsp": {\n "arduino": {\n "command": "arduino-language-server",\n "args": ["-fqbn", "%s"]\n },\n "cpp": {\n "command": "clangd"\n }\n }\n}\n' "$FQBN" > .crush.json
echo "[OK] Generated .crush.json with FQBN: $FQBN"

View File

@@ -13,7 +13,7 @@
#define POLL_INTERVAL_MS 15000
#define HEARTBEAT_INTERVAL_MS 300000
#define BOOT_GRACE_MS 5000
#define HOLD_TO_SILENCE_MS 3000
#define HOLD_TO_SILENCE_MS 2000
#define ALERT_TIMEOUT_MS 120000
#define SILENCE_DISPLAY_MS 10000
#define INACTIVITY_TIMEOUT_MS 30000

View File

@@ -2,6 +2,7 @@
#include "IDisplayDriver.h"
#include "ScreenState.h"
#include "Style.h"
#include <cmath>
@@ -59,6 +60,14 @@ private:
/// Calculate optimal grid dimensions based on display and tile constraints
static void calculateGrid(
int tileCount, int displayW, int contentH, int* outCols, int* outRows) {
// Use 2x2 grid for 4 tiles regardless of aspect ratio
// This provides better visual balance than a single row
if(tileCount == 4) {
*outCols = 2;
*outRows = 2;
return;
}
// Calculate aspect ratio to determine preferred layout
float aspectRatio = (float)displayW / contentH;
@@ -137,53 +146,13 @@ public:
TouchEvent readTouch() { return _drv ? _drv->readTouch() : TouchEvent {}; }
HoldState updateHold(unsigned long ms) { return _drv ? _drv->updateHold(ms) : HoldState {}; }
void updateHint(int x, int y, bool active) {
if(_drv)
_drv->updateHint(x, y, active);
HoldState updateHold(const TouchEvent& evt, unsigned long ms) {
return _drv ? _drv->updateHold(evt, ms) : HoldState {};
}
/// Show touch feedback - highlights the tile at given coordinates
/// Returns true if a valid tile is being touched
bool showTouchFeedback(int x, int y) {
if(!_drv || _gridCols <= 0)
return false;
// Transform touch coordinates
_drv->transformTouch(&x, &y);
int headerH = 30;
if(y < headerH)
return false;
// Calculate which cell
int cellW = _drv->width() / _gridCols;
int cellH = (_drv->height() - headerH) / _gridRows;
int col = x / cellW;
int row = (y - headerH) / cellH;
if(col < 0 || col >= _gridCols || row < 0 || row >= _gridRows)
return false;
// Find which tile is at this position
for(int i = 0; i < _tileCount; i++) {
const TileLayout& lay = _layouts[i];
if(lay.col <= col && col < lay.col + lay.cols && lay.row <= row
&& lay.row + lay.rows > row) {
// Found the tile - draw highlight via driver
_drv->updateHint(lay.x, lay.y, true); // active=true means show feedback
return true;
}
}
return false;
}
/// Clear touch feedback
void clearTouchFeedback() {
void drawDebugTouch(int x, int y) {
if(_drv)
_drv->updateHint(0, 0, false); // active=false means clear
_drv->drawDebugTouch(x, y);
}
/// Check if current position is still in same tile as touch-down
@@ -197,7 +166,7 @@ public:
_drv->transformTouch(&dx, &dy);
_drv->transformTouch(&cx, &cy);
int headerH = 30;
int headerH = _headerHeight;
int cellW = _drv->width() / _gridCols;
int cellH = (_drv->height() - headerH) / _gridRows;
@@ -213,51 +182,83 @@ public:
int height() { return _drv ? _drv->height() : 0; }
/// Handle dashboard touch - returns action for tapped tile, or NONE
/// Note: x,y are already in display coordinates (transformed by driver)
TileAction handleDashboardTouch(int x, int y) const {
if(!_drv || _gridCols <= 0)
return TileAction::NONE;
HitResult hr = hitTestRaw(x, y);
if(hr.type == UIElementType::TILE && hr.index >= 0 && hr.index < DASHBOARD_TILE_COUNT) {
return DASHBOARD_TILES[hr.index].action;
}
return TileAction::NONE;
}
// Transform touch coordinates (handles rotated touch panels)
_drv->transformTouch(&x, &y);
/// Perform hit test at coordinates (already in display space, no transform)
HitResult hitTestRaw(int x, int y) const {
if(!_drv)
return HitResult();
int dispW = _drv->width();
int dispH = _drv->height();
int headerH = 30;
int headerH = _headerHeight;
// Check if in header area
if(y < headerH)
return TileAction::NONE;
// Calculate which tile was touched using grid
int cellW = dispW / _gridCols;
int cellH = (dispH - headerH) / _gridRows;
int col = x / cellW;
int row = (y - headerH) / cellH;
// Bounds check
if(col < 0 || col >= _gridCols || row < 0 || row >= _gridRows) {
return TileAction::NONE;
// Check header
Rect headerRect = UIElements::header(dispW, headerH);
if(headerRect.contains(x, y)) {
return HitResult(UIElementType::HEADER, 0, headerRect);
}
// Find which tile occupies this cell
// Check tiles
for(int i = 0; i < _tileCount; i++) {
const TileLayout& layout = _layouts[i];
if(layout.col <= col && col < layout.col + layout.cols && layout.row <= row
&& row < layout.row + layout.rows) {
return DASHBOARD_TILES[i].action;
const TileLayout& lay = _layouts[i];
Rect tileRect(lay.x, lay.y, lay.w, lay.h);
if(tileRect.contains(x, y)) {
return HitResult(UIElementType::TILE, i, tileRect);
}
}
return TileAction::NONE;
return HitResult();
}
/// Perform hit test at coordinates - returns element type, index, and bounds
HitResult hitTest(int x, int y) const {
if(!_drv)
return HitResult();
int tx = x, ty = y;
_drv->transformTouch(&tx, &ty);
int dispW = _drv->width();
int dispH = _drv->height();
int headerH = _headerHeight;
// Check header
Rect headerRect = UIElements::header(dispW, headerH);
if(headerRect.contains(tx, ty)) {
return HitResult(UIElementType::HEADER, 0, headerRect);
}
// Check tiles
for(int i = 0; i < _tileCount; i++) {
const TileLayout& lay = _layouts[i];
Rect tileRect(lay.x, lay.y, lay.w, lay.h);
if(tileRect.contains(tx, ty)) {
return HitResult(UIElementType::TILE, i, tileRect);
}
}
return HitResult();
}
/// Set header height for hit testing (call after calculateDashboardLayouts or manually)
void setHeaderHeight(int h) { _headerHeight = h; }
int getHeaderHeight() const { return _headerHeight; }
/// Calculate and store layouts for dashboard tiles
/// Called by drivers who want to use the layout helper
int calculateDashboardLayouts(int headerH = 30, int margin = 8) {
if(!_drv)
return 0;
_headerHeight = headerH;
_tileCount = TileLayoutHelper::calculateLayouts(
_drv->width(), _drv->height(), headerH, margin, _layouts, &_gridCols, &_gridRows);
return _tileCount;
@@ -274,4 +275,5 @@ private:
int _tileCount = 0;
int _gridCols = 0;
int _gridRows = 0;
int _headerHeight = 30;
};

View File

@@ -304,8 +304,24 @@ void DoorbellLogic::onSerialCommand(const String& cmd) {
Serial.println();
Serial.printf("[NET] %s RSSI:%d IP:%s\n", _state.wifiSsid.c_str(), _state.wifiRssi,
_state.ipAddr.c_str());
} else if(cmd == "board") {
Serial.printf("[BOARD] %s\n", _board);
} else if(cmd.startsWith("hittest ")) {
String args = cmd.substring(8);
int comma = args.indexOf(',');
if(comma > 0) {
int x = args.substring(0, comma).toInt();
int y = args.substring(comma + 1).toInt();
HitResult hr = _display->hitTestRaw(x, y);
Serial.printf("[Hittest] raw:(%d,%d) type:%d index:%d bounds:(%d,%d,%d,%d)\n", x, y,
(int)hr.type, hr.index, hr.bounds.x, hr.bounds.y, hr.bounds.w, hr.bounds.h);
Serial.printf("[Display] w:%d h:%d headerH:%d\n", _display->width(), _display->height(),
_display->getHeaderHeight());
} else {
Serial.println("[Hittest] Usage: hittest x,y");
}
} else
Serial.println(F("[CMD] alert|silence|reboot|dashboard|off|status"));
Serial.println(F("[CMD] alert|silence|reboot|dashboard|off|status|board"));
}
void DoorbellLogic::setScreen(ScreenID s) {
@@ -319,8 +335,19 @@ void DoorbellLogic::setScreen(ScreenID s) {
}
int DoorbellLogic::handleTouch(const TouchEvent& evt) {
// Check hold completion FIRST - before any release handling
// This ensures hold-to-silence works on ALERT screen
if(evt.released && _state.deviceState == DeviceState::ALERTING) {
Serial.printf("[TOUCH] checking hold: deviceState=ALERTING\n");
if(updateHold(evt)) {
return (int)TileAction::SILENCE;
}
}
// Handle press - show visual feedback
if(evt.pressed) {
Serial.printf("[TOUCH] pressed screen=%d deviceState=%d\n", (int)_state.screen,
(int)_state.deviceState);
// Reset inactivity timer on any touch
_lastActivityMs = millis();
@@ -330,17 +357,31 @@ int DoorbellLogic::handleTouch(const TouchEvent& evt) {
return -1;
}
// Show touch feedback on press
if(_state.screen == ScreenID::DASHBOARD) {
_display->showTouchFeedback(evt.x, evt.y);
// Handle STATUS screen back button (lower right corner)
if(_state.screen == ScreenID::STATUS) {
int dispW = _display->width();
int dispH = _display->height();
// Back button area: lower right (dispW-60 to dispW, dispH-30 to dispH)
if(evt.x >= dispW - 60 && evt.y >= dispH - 30) {
Serial.printf("[%lu] [TOUCH] STATUS → DASHBOARD (back)\n", millis());
setScreen(ScreenID::DASHBOARD);
return -1;
}
}
// Draw debug crosshair at touch point
#ifdef DEBUG_MODE
if(_state.screen == ScreenID::DASHBOARD) {
_display->drawDebugTouch(evt.x, evt.y);
}
#endif
return -1;
}
// Handle release - fire action if same tile
if(evt.released) {
// Clear feedback
_display->clearTouchFeedback();
Serial.printf("[TOUCH] released screen=%d deviceState=%d\n", (int)_state.screen,
(int)_state.deviceState);
if(_state.screen == ScreenID::DASHBOARD) {
// Only fire action if finger stayed on same tile
@@ -357,7 +398,7 @@ int DoorbellLogic::handleTouch(const TouchEvent& evt) {
silenceAlert();
break;
case TileAction::STATUS:
heartbeat();
setScreen(ScreenID::STATUS);
break;
case TileAction::REBOOT:
flushStatus("REBOOT (tile)");
@@ -391,7 +432,7 @@ bool DoorbellLogic::updateHold(const TouchEvent& evt) {
static int holdStartX = -1;
static int holdStartY = -1;
HoldState h = _display->updateHold(HOLD_TO_SILENCE_MS);
HoldState h = _display->updateHold(evt, HOLD_TO_SILENCE_MS);
if(h.completed) {
silenceAlert();
@@ -405,10 +446,6 @@ bool DoorbellLogic::updateHold(const TouchEvent& evt) {
holdStartY = evt.y;
}
if(holdStartX >= 0) {
_display->updateHint(holdStartX, holdStartY, h.active);
}
return false;
}

View File

@@ -23,6 +23,7 @@ public:
void processSerial();
const ScreenState& getScreenState() const { return _state; }
const char* getBoard() const { return _board; }
/// Externally trigger silence (e.g. hold-to-silence gesture).
void silenceAlert();

View File

@@ -31,11 +31,17 @@ public:
// ── Touch ──
virtual TouchEvent readTouch() = 0;
virtual HoldState updateHold(unsigned long holdMs) = 0;
virtual void updateHint(int x, int y, bool active) = 0;
virtual HoldState updateHold(const TouchEvent& evt, unsigned long holdMs) = 0;
virtual void drawDebugTouch(int x, int y) { /* default: no-op */ }
virtual int width() = 0;
virtual int height() = 0;
// ── Fonts ──
virtual void setTitleFont() = 0; // Large titles (KLUBHAUS, ALERT)
virtual void setBodyFont() = 0; // Normal text (status, body)
virtual void setLabelFont() = 0; // Small text (hints, captions)
virtual void setDefaultFont() = 0; // Reset to default font
// ── Touch transform (for rotated panels) ──
virtual void transformTouch(int* x, int* y) { /* default: no transform */ }
};

View File

@@ -7,3 +7,4 @@
#include "IDisplayDriver.h"
#include "NetManager.h"
#include "ScreenState.h"
#include "Style.h"

View File

@@ -3,7 +3,7 @@
enum class DeviceState { BOOTED, SILENT, ALERTING, SILENCED };
enum class ScreenID { BOOT, OFF, ALERT, DASHBOARD };
enum class ScreenID { BOOT, OFF, ALERT, DASHBOARD, STATUS };
enum class BootStage { SPLASH, INIT_DISPLAY, INIT_NETWORK, CONNECTING_WIFI, READY, DONE };
@@ -94,6 +94,8 @@ inline const char* screenIdStr(ScreenID s) {
return "ALERT";
case ScreenID::DASHBOARD:
return "DASHBOARD";
case ScreenID::STATUS:
return "STATUS";
}
return "?";
}

View File

@@ -0,0 +1,229 @@
#pragma once
#include <Arduino.h>
#include <cstdint>
struct Layout {
uint16_t x, y, w, h;
Layout()
: x(0)
, y(0)
, w(0)
, h(0) { }
Layout(uint16_t x_, uint16_t y_, uint16_t w_, uint16_t h_)
: x(x_)
, y(y_)
, w(w_)
, h(h_) { }
static Layout fullScreen(uint16_t w, uint16_t h) { return Layout(0, 0, w, h); }
static Layout header(uint16_t screenW, uint16_t headerH, uint16_t padding = 10) {
return Layout(0, 0, screenW, headerH);
}
static Layout content(
uint16_t screenW, uint16_t screenH, uint16_t headerH, uint16_t padding = 10) {
return Layout(
padding, headerH + padding, screenW - 2 * padding, screenH - headerH - 2 * padding);
}
static Layout tile(uint8_t col, uint8_t row, uint8_t cols, uint8_t rows, uint16_t contentW,
uint16_t contentH, uint8_t gap = 8) {
uint16_t tileW = (contentW - (cols - 1) * gap) / cols;
uint16_t tileH = (contentH - (rows - 1) * gap) / rows;
uint16_t x = col * (tileW + gap);
uint16_t y = row * (tileH + gap);
return Layout(x, y, tileW, tileH);
}
Layout clamp(uint16_t maxW, uint16_t maxH) const {
return Layout(
x < maxW ? x : maxW, y < maxH ? y : maxH, w <= maxW ? w : maxW, h <= maxH ? h : maxH);
}
Layout padded(uint16_t padding) const {
uint16_t newX = x + padding;
uint16_t newY = y + padding;
uint16_t newW = w > 2 * padding ? w - 2 * padding : 0;
uint16_t newH = h > 2 * padding ? h - 2 * padding : 0;
return Layout(newX, newY, newW, newH);
}
bool contains(uint16_t px, uint16_t py) const {
return px >= x && px < x + w && py >= y && py < y + h;
}
};
class TextScroller {
public:
TextScroller()
: _text()
, _scrollOffset(0)
, _lastUpdateMs(0)
, _scrollSpeed(50)
, _pauseMs(2000) { }
void setText(const char* text) {
if(text != _text.c_str()) {
_text = text;
_scrollOffset = 0;
_lastUpdateMs = 0;
_paused = false;
}
}
void setScrollSpeed(uint16_t msPerPixel) { _scrollSpeed = msPerPixel; }
void setPauseDuration(uint16_t ms) { _pauseMs = ms; }
template <typename DrawFn> void render(DrawFn draw, uint16_t maxWidth) {
uint32_t now = millis();
if(_text.length() <= maxWidth / 8) {
draw(0, _text.c_str());
return;
}
if(!_paused && now - _lastUpdateMs > _scrollSpeed) {
_scrollOffset++;
_lastUpdateMs = now;
if(_scrollOffset > (int)_text.length() * 6) {
_paused = true;
_pauseStartMs = now;
}
}
if(_paused && now - _pauseStartMs > _pauseMs) {
_paused = false;
_scrollOffset = 0;
_lastUpdateMs = now;
}
int16_t x = -_scrollOffset;
char buf[2] = { 0 };
for(size_t i = 0; i < _text.length(); i++) {
buf[0] = _text.charAt(i);
if(x + 8 > 0 && x < (int)maxWidth) {
draw(x, buf);
}
x += 6;
}
}
private:
String _text;
int16_t _scrollOffset;
uint32_t _lastUpdateMs;
uint32_t _pauseStartMs;
uint16_t _scrollSpeed;
uint16_t _pauseMs;
bool _paused;
};
struct TileMetrics {
uint16_t tileW;
uint16_t tileH;
uint16_t gap;
uint8_t cols;
uint8_t rows;
static TileMetrics calculate(
uint16_t contentW, uint16_t contentH, uint8_t cols, uint8_t rows, uint8_t gap = 8) {
TileMetrics m;
m.cols = cols;
m.rows = rows;
m.gap = gap;
m.tileW = (contentW - (cols - 1) * gap) / cols;
m.tileH = (contentH - (rows - 1) * gap) / rows;
return m;
}
Layout get(uint8_t col, uint8_t row) const {
return Layout(col * (tileW + gap), row * (tileH + gap), tileW, tileH);
}
};
struct Rect {
int16_t x, y;
int16_t w, h;
Rect()
: x(0)
, y(0)
, w(0)
, h(0) { }
Rect(int16_t x_, int16_t y_, int16_t w_, int16_t h_)
: x(x_)
, y(y_)
, w(w_)
, h(h_) { }
int16_t left() const { return x; }
int16_t top() const { return y; }
int16_t right() const { return x + w; }
int16_t bottom() const { return y + h; }
bool contains(int16_t px, int16_t py) const {
return px >= x && px < x + w && py >= y && py < y + h;
}
bool intersects(const Rect& other) const {
return !(
right() <= other.x || other.right() <= x || bottom() <= other.y || other.bottom() <= y);
}
Rect expanded(int16_t dx, int16_t dy) const {
return Rect(x - dx, y - dy, w + 2 * dx, h + 2 * dy);
}
Rect translated(int16_t dx, int16_t dy) const { return Rect(x + dx, y + dy, w, h); }
};
enum class UIElementType { NONE, HEADER, TILE, BUTTON, ICON };
struct HitResult {
UIElementType type;
int16_t index;
Rect bounds;
HitResult()
: type(UIElementType::NONE)
, index(-1)
, bounds() { }
HitResult(UIElementType t, int16_t i, const Rect& b)
: type(t)
, index(i)
, bounds(b) { }
bool isValid() const { return type != UIElementType::NONE; }
operator bool() const { return isValid(); }
};
class UIElements {
public:
static Rect header(uint16_t screenW, uint16_t headerH) { return Rect(0, 0, screenW, headerH); }
static Rect contentArea(
uint16_t screenW, uint16_t screenH, uint16_t headerH, uint16_t padding = 10) {
return Rect(
padding, headerH + padding, screenW - 2 * padding, screenH - headerH - 2 * padding);
}
static Rect tile(uint8_t col, uint8_t row, uint8_t cols, uint8_t rows, uint16_t contentX,
uint16_t contentY, uint16_t contentW, uint16_t contentH, uint8_t gap = 8) {
uint16_t tileW = (contentW - (cols - 1) * gap) / cols;
uint16_t tileH = (contentH - (rows - 1) * gap) / rows;
int16_t x = contentX + col * (tileW + gap);
int16_t y = contentY + row * (tileH + gap);
return Rect(x, y, tileW, tileH);
}
static Rect button(int16_t x, int16_t y, int16_t w, int16_t h, int16_t padding = 8) {
return Rect(x - padding, y - padding, w + 2 * padding, h + 2 * padding);
}
static Rect icon(int16_t x, int16_t y, int16_t size) { return Rect(x, y, size, size); }
};

View File

@@ -21,18 +21,18 @@ pkl = "latest"
[tasks.compile]
description = "Compile (uses BOARD env var)"
depends = ["gen-compile-commands"]
run = """
run = '''
source ./boards/$BOARD/board-config.sh
arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$OPTS" --warnings default ./boards/$BOARD
"""
'''
[tasks.upload]
description = "Upload (uses BOARD env var)"
depends = ["compile", "kill"]
run = """
depends = ["kill"]
run = '''
source ./boards/$BOARD/board-config.sh
arduino-cli upload --fqbn $FQBN --port $PORT ./boards/$BOARD
"""
arduino-cli upload --fqbn "$FQBN" --port "$PORT" ./boards/$BOARD
'''
[tasks.monitor-raw]
depends = ["kill"]
@@ -86,7 +86,7 @@ exit 0
description = "Monitor agent with JSON log + command pipe (Python-based)"
depends = ["kill"]
run = """
python3 ./scripts/monitor-agent.py "$BOARD"
python3 ./scripts/monitor-agent.py "$BOARD" &
"""
[tasks.log-tail]
@@ -135,6 +135,10 @@ run = """
./boards/$BOARD/install.sh
"""
[tasks.detect]
description = "Detect connected doorbell board"
run = "bash ./scripts/detect-device.sh"
# Convenience
[tasks.clean]
@@ -215,4 +219,4 @@ echo "[OK] Generated .crush.json with FQBN: $FQBN"
run = "git add .; lumen draft | git commit -F - "
[env]
BOARD = "esp32-s3-lcd-43"
BOARD = "esp32-32e-4"

160
scripts/detect-device.sh Executable file
View File

@@ -0,0 +1,160 @@
#!/bin/bash
#
# detect-device.sh - Detect which doorbell board is connected
#
# Uses two methods:
# 1. Firmware query (if flashed): sends "board" command, expects "[BOARD] xxx"
# 2. Hardware probe (fallback): uses esptool to query chip type and flash size
#
# Usage: ./scripts/detect-device.sh [port]
# If no port specified, tries common ports: /dev/ttyUSB0 /dev/ttyACM0
set -e
# Configuration
TIMEOUT_FIRMWARE=2
TIMEOUT_HARDWARE=5
# Known boards and their characteristics
declare -A BOARD_CHIP=(
["esp32-s3-lcd-43"]="ESP32-S3"
["esp32-32e"]="ESP32"
["esp32-32e-4"]="ESP32"
)
# Find available serial ports
find_ports() {
local ports=()
for p in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyACM0 /dev/ttyACM1; do
if [ -e "$p" ]; then
ports+=("$p")
fi
done
printf '%s\n' "${ports[@]}"
}
# Query firmware for board name
query_firmware() {
local port="$1"
# Try using Python for more reliable serial communication
local output
output=$(python3 -c "
import serial
import time
try:
s = serial.Serial('$port', 115200, timeout=$TIMEOUT_FIRMWARE)
time.sleep(0.5)
s.write(b'board\r\n')
time.sleep($TIMEOUT_FIRMWARE)
print(s.read(200).decode('utf-8', errors='ignore'))
s.close()
except:
pass
" 2>/dev/null || true)
# Look for [BOARD] xxx pattern
if echo "$output" | grep -q '\[BOARD\]'; then
local board
board=$(echo "$output" | grep '\[BOARD\]' | sed 's/.*\[BOARD\] //' | tr -d '\r\n')
if [ -n "$board" ]; then
echo "$board"
return 0
fi
fi
return 1
}
# Hardware probe using esptool
probe_hardware() {
local port="$1"
# Get chip info
local chip_info
chip_info=$(timeout "$TIMEOUT_HARDWARE" esptool --port "$port" chip_id 2>/dev/null || true)
if [ -z "$chip_info" ]; then
return 1
fi
# Check for ESP32-S3
if echo "$chip_info" | grep -q "ESP32-S3"; then
echo "esp32-s3-lcd-43"
return 0
fi
# For ESP32, check flash size
if echo "$chip_info" | grep -q "ESP32"; then
local flash_size
flash_size=$(echo "$chip_info" | grep -i "flash size" | grep -oE '[0-9]+' | head -1 || echo "0")
if [ -n "$flash_size" ] && [ "$flash_size" -ge 8 ]; then
# 8MB+ flash - likely esp32-32e-4
echo "esp32-32e-4 (or esp32-32e)"
return 0
else
# 4MB or less - likely esp32-32e
echo "esp32-32e (or esp32-32e-4)"
return 0
fi
fi
return 1
}
# Main detection logic
detect() {
local port="$1"
# Try firmware query first (100% accurate if firmware responds)
echo "Trying firmware query on $port..." >&2
local result
result=$(query_firmware "$port" || true)
if [ -n "$result" ]; then
echo "Detected via firmware: $result"
return 0
fi
# Fall back to hardware probe
echo "Trying hardware probe on $port..." >&2
result=$(probe_hardware "$port" || true)
if [ -n "$result" ]; then
echo "Detected via hardware: $result"
return 0
fi
echo "No device detected on $port" >&2
return 1
}
# Entry point
main() {
local target_port="$1"
if [ -n "$target_port" ]; then
# Specific port requested
if [ ! -e "$target_port" ]; then
echo "Error: Port $target_port does not exist" >&2
exit 1
fi
detect "$target_port"
else
# Auto-detect: try all available ports
local found=0
for port in $(find_ports); do
if detect "$port"; then
found=1
break
fi
done
if [ "$found" -eq 0 ]; then
echo "No doorbell device detected" >&2
exit 1
fi
fi
}
main "$@"

Submodule vendor/esp32-s3-lcd-43/LovyanGFX deleted from 42998359d8