Compare commits

...

2 Commits

6 changed files with 258 additions and 415 deletions

496
AGENTS.md
View File

@@ -1,455 +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
**Default BOARD**: `esp32-s3-lcd-43` (set in mise.toml). To switch boards:
## Build Commands
```bash
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
```
# 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)
Then run commands without `BOARD=` prefix:
# 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
```bash
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
# Formatting & cleanup
mise run format # format code with clang-format
mise run clean # remove build artifacts
# 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**:
```bash
# Running multiple tasks - use && to chain them
mise run compile && mise run upload && mise run monitor
# 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)
# 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**: `mise set PORT=/dev/ttyXXX` before running upload/monitor commands.
**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
### 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`
The project uses a CSS-like styling system for consistent UI across different display sizes:
## Key Files
- **Style constants** in each board's `board_config.h`: `STYLE_SPACING_X`, `STYLE_HEADER_HEIGHT`, `STYLE_COLOR_BG`, etc.
- **Font abstraction** via `IDisplayDriver` methods: `setTitleFont()`, `setBodyFont()`, `setLabelFont()`, `setDefaultFont()`
- **Layout helpers** in `KlubhausCore/src/Style.h`: `Layout` and `TileMetrics` structs
## Testing/Debugging
**No unit tests exist** - This is an embedded Arduino sketch. Verify changes by building and deploying to hardware:
```bash
mise set BOARD=esp32-s3-lcd-43 # compile for esp32-s3-lcd-43
mise set BOARD=esp32-32e-4 # 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) |
**Debug Features** (only when `DEBUG_MODE` is enabled in board-config.sh):
| Feature | Description |
|---------|-------------|
| Red crosshair | Draws a red crosshair at exact touch coordinates on tap (DEBUG_MODE only) |
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
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
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 can be shared**: KlubhausCore/src/secrets.h provides default credentials. Boards with `LOCAL_SECRETS` defined in board-config.sh will use their local `secrets.h` instead.
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
# For boards without local secrets.h, the defaults will be used
# To use board-specific credentials, add -DLOCAL_SECRETS to OPTS in board-config.sh
```
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. The default BOARD is `esp32-s3-lcd-43` (set in mise.toml). Use `mise set BOARD=xxx` to switch boards.
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` 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 `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
mise set BOARD=esp32-32e-4
mise run gen-compile-commands
```
**Generate static .crush.json** for a specific board:
```bash
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-instructions v2 -->
# RTK (Rust Token Killer)
RTK filters command output to reduce tokens. It's optional but recommended for saving tokens.
## Installation Check
```bash
which rtk # Check if installed
```
If installed, use it before git commands:
```bash
rtk git status
rtk git diff
rtk git add .
rtk git commit -m "message"
rtk git push
```
RTK passes through unchanged if no filter exists, so it's always safe to use.
| 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)

View File

@@ -259,6 +259,7 @@ void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
// Get tile layouts from library helper
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" };
@@ -377,10 +378,12 @@ TouchEvent DisplayDriverTFT::readTouch() {
uint16_t tx, ty;
uint8_t touched = _tft.getTouch(&tx, &ty, 100);
// Debug: log touch state changes
// Debug: log touch state changes with transformed coords
if(touched != _touchWasPressed) {
Serial.printf("[TOUCH] raw touched=%d wasPressed=%d (x=%d,y=%d)\n", touched,
_touchWasPressed, tx, ty);
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)
@@ -422,7 +425,9 @@ TouchEvent DisplayDriverTFT::readTouch() {
}
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;

View File

@@ -194,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) {
@@ -330,6 +339,13 @@ void DisplayDriverGFX::render(const ScreenState& state) {
_needsRedraw = false;
}
break;
case ScreenID::STATUS:
if(_needsRedraw) {
drawStatus(state);
_needsRedraw = false;
}
break;
}
}
@@ -429,11 +445,13 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
// Get tile layouts from library helper
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;
@@ -441,7 +459,7 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
int h = lay.h;
// Tile background
_gfx->fillRoundRect(x, y, w, h, STYLE_TILE_RADIUS, 0x0220);
_gfx->fillRoundRect(x, y, w, h, STYLE_TILE_RADIUS, tileColors[i]);
// Tile border
_gfx->drawRoundRect(x, y, w, h, STYLE_TILE_RADIUS, STYLE_COLOR_FG);
@@ -449,11 +467,114 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
// Tile label
_gfx->setTextColor(STYLE_COLOR_FG);
setBodyFont();
_gfx->setCursor(x + w / 2 - 30, y + h / 2 - 10);
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::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;

View File

@@ -42,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 };

View File

@@ -166,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;
@@ -182,14 +182,42 @@ 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 {
HitResult hr = hitTest(x, y);
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;
}
/// 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 = _headerHeight;
// Check header
Rect headerRect = UIElements::header(dispW, headerH);
if(headerRect.contains(x, y)) {
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(x, y)) {
return HitResult(UIElementType::TILE, i, tileRect);
}
}
return HitResult();
}
/// Perform hit test at coordinates - returns element type, index, and bounds
HitResult hitTest(int x, int y) const {
if(!_drv)

View File

@@ -312,7 +312,7 @@ void DoorbellLogic::onSerialCommand(const String& cmd) {
if(comma > 0) {
int x = args.substring(0, comma).toInt();
int y = args.substring(comma + 1).toInt();
HitResult hr = _display->hitTest(x, y);
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(),