Compare commits
4 Commits
1161733b36
...
1d249d2524
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d249d2524 | |||
| 64feddfd5d | |||
| 1f32fb8e16 | |||
| 92c93b82b4 |
1
libraries/KlubhausCore
Symbolic link
1
libraries/KlubhausCore
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/david/Arduino/sketches/doorbell-touch/libraries/KlubhausCore
|
||||||
2
libraries/LovyanGFX/.gitignore
vendored
Normal file
2
libraries/LovyanGFX/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
CompileFlags:
|
CompileFlags:
|
||||||
Add:
|
Add:
|
||||||
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.6/cores/esp32"
|
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.7/cores/esp32"
|
||||||
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.6/tools/sdk/esp32/include"
|
- "-I/home/david/.arduino15/packages/esp32/tools/esp32-libs/3.3.7/include"
|
||||||
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.6/tools/sdk/esp32/include/esp_hw_support"
|
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.7/libraries/WiFi/src"
|
||||||
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.6/libraries/WiFi/src"
|
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.7/libraries/EEPROM/src"
|
||||||
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.6/libraries/EEPROM/src"
|
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.7/libraries/HTTPClient/src"
|
||||||
- "-I/home/david/Arduino/sketches/doorbell-touch/vendor/esp32-s3-lcd-43/LovyanGFX/src"
|
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.7/libraries/Network/src"
|
||||||
|
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.7/libraries/NetworkClientSecure/src"
|
||||||
|
- "-I/home/david/.arduino15/packages/esp32/hardware/esp32/3.3.7/libraries/NTPClient/src"
|
||||||
|
- "-I/home/david/Arduino/libraries/ArduinoJson/src"
|
||||||
- "-I/home/david/Arduino/libraries/TFT_eSPI"
|
- "-I/home/david/Arduino/libraries/TFT_eSPI"
|
||||||
|
- "-I/home/david/Arduino/sketches/doorbell-touch/vendor/esp32-s3-lcd-43/LovyanGFX/src"
|
||||||
- "-I/home/david/Arduino/sketches/doorbell-touch/libraries/KlubhausCore/src"
|
- "-I/home/david/Arduino/sketches/doorbell-touch/libraries/KlubhausCore/src"
|
||||||
- "-I/home/david/Arduino/sketches/doorbell-touch/boards/esp32-32e-4"
|
- "-I/home/david/Arduino/sketches/doorbell-touch/boards/esp32-32e-4"
|
||||||
- "-DARDUINO=200"
|
- "-DARDUINO=200"
|
||||||
- "-DESC32"
|
- "-DESC32"
|
||||||
- "-DESP_PLATFORM"
|
- "-DESP_PLATFORM"
|
||||||
- "-Dcore_debug=0"
|
- "-Dcore_debug=0"
|
||||||
|
- "-DARDUINO_ESP32_DEV"
|
||||||
|
Remove:
|
||||||
|
- "-fno-tree-switch-conversion"
|
||||||
|
- "-fstrict-volatile-bitfields"
|
||||||
|
- "-mdisable-hardware-atomics"
|
||||||
|
- "-mlongcalls"
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
ClangTidy:
|
ClangTidy:
|
||||||
|
|||||||
@@ -33,3 +33,38 @@ BOARD=esp32-32e-4 mise run install # Install libs (shared + board)
|
|||||||
|
|
||||||
## Known Fixes
|
## Known Fixes
|
||||||
- dashboard/off admin commands now reset inactivity timer (DoorbellLogic.cpp:234,240)
|
- dashboard/off admin commands now reset inactivity timer (DoorbellLogic.cpp:234,240)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Neovim LSP Configuration
|
||||||
|
For Neovim projects:
|
||||||
|
- Use Mason + mason-lspconfig to manage LSP installations
|
||||||
|
- Add LSP configs to `~/.config/nvim/lua/plugins/` as separate plugin files
|
||||||
|
- Use `on_new_config` for dynamic settings (e.g., BOARD env var → FQBN mapping)
|
||||||
|
- Example: `~/.config/nvim/lua/plugins/arduino.lua` (arduino-language-server)
|
||||||
|
|
||||||
|
## Crush LSP Configuration
|
||||||
|
- Configure via `.crush.json` in project root (or `$HOME/.config/crush/crush.json` globally)
|
||||||
|
- Supports env var interpolation: `$BOARD` or `${BOARD}`
|
||||||
|
- Supports command substitution: `$(cat boards/$BOARD/board-config.sh | grep '^FQBN=' | cut -d'"' -f2)`
|
||||||
|
- Example for Arduino (auto-detects FQBN from BOARD):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lsp": {
|
||||||
|
"arduino": {
|
||||||
|
"command": "arduino-language-server",
|
||||||
|
"args": ["-fqbn", "$(cat boards/${BOARD:-esp32-32e-4}/board-config.sh | grep '^FQBN=' | cut -d'\"' -f2)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Run `mise run gen-crush-config` to regenerate static config
|
||||||
|
|||||||
@@ -2,15 +2,33 @@
|
|||||||
|
|
||||||
Multi-target Arduino/ESP32 doorbell alert system using ntfy.sh.
|
Multi-target Arduino/ESP32 doorbell alert system using ntfy.sh.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Default BOARD**: `esp32-32e-4` (set in mise.toml)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Three board targets share business logic via a common library:
|
Three board targets share business logic via a common library:
|
||||||
|
|
||||||
| Board | Display | Library | Build Target |
|
| Board | Display | Library | Build Command |
|
||||||
|-------|---------|---------|--------------|
|
|-------|---------|---------|--------------|
|
||||||
| ESP32-32E | SPI TFT 320x240 (ILI9341) | TFT_eSPI | `compile-32e` |
|
| ESP32-32E | SPI TFT 320x240 (ILI9341) | TFT_eSPI | `BOARD=esp32-32e mise run compile` |
|
||||||
| ESP32-32E-4" | SPI TFT 320x480 (ST7796) | TFT_eSPI | `compile-32e-4` |
|
| 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 | `compile-s3-43` |
|
| ESP32-S3-Touch-LCD-4.3 | 800x480 RGB parallel | LovyanGFX | `BOARD=esp32-s3-lcd-43 mise run compile` |
|
||||||
|
|
||||||
## Essential Commands
|
## Essential Commands
|
||||||
|
|
||||||
@@ -18,26 +36,62 @@ All commands run via **mise**:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install all dependencies (shared libs + vendored display libs)
|
# Install all dependencies (shared libs + vendored display libs)
|
||||||
mise run install-libs
|
mise run install-libs-shared
|
||||||
|
mise run install # install shared + board-specific libs (requires BOARD env)
|
||||||
|
|
||||||
# Build ESP32-32E
|
# Generic commands (set BOARD environment variable)
|
||||||
mise run compile-32e
|
BOARD=esp32-32e mise run compile # compile for ESP32-32E
|
||||||
mise run upload-32e
|
BOARD=esp32-32e mise run upload # upload to ESP32-32E
|
||||||
mise run monitor-32e
|
BOARD=esp32-32e mise run monitor # monitor ESP32-32E
|
||||||
|
|
||||||
# Build ESP32-S3-LCD-4.3
|
BOARD=esp32-32e-4 mise run compile # compile for ESP32-32E-4"
|
||||||
mise run compile-s3-43
|
BOARD=esp32-32e-4 mise run upload # upload to ESP32-32E-4"
|
||||||
mise run upload-s3-43
|
BOARD=esp32-32e-4 mise run monitor # monitor ESP32-32E-4"
|
||||||
mise run monitor-s3-43
|
|
||||||
|
|
||||||
# Format code
|
BOARD=esp32-s3-lcd-43 mise run compile # compile for ESP32-S3-LCD-4.3
|
||||||
mise run format
|
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
|
||||||
|
|
||||||
# Clean build artifacts
|
# Other useful tasks
|
||||||
mise run clean
|
mise run format # format code
|
||||||
|
mise run clean # clean build artifacts
|
||||||
|
mise run kill # kill running monitor/upload for BOARD
|
||||||
|
mise run log-tail # tail colored logs (requires BOARD)
|
||||||
|
mise run cmd COMMAND=dashboard # send command to device (requires BOARD)
|
||||||
|
mise run state # show device state (requires BOARD)
|
||||||
|
|
||||||
|
# LSP / IDE support
|
||||||
|
mise run gen-compile-commands # generate compile_commands.json for LSP
|
||||||
|
mise run gen-crush-config # generate .crush.json with BOARD-based FQBN
|
||||||
|
|
||||||
|
# Arduino maintenance
|
||||||
|
mise run arduino-clean # clear Arduino CLI cache (staging + packages)
|
||||||
|
|
||||||
|
# Raw serial access
|
||||||
|
mise run monitor-raw # raw serial monitor (arduino-cli)
|
||||||
|
mise run monitor-tio # show tio command for terminal UI
|
||||||
```
|
```
|
||||||
|
|
||||||
**Upload port override**: `PORT=/dev/ttyXXX mise run upload-32e`
|
# Board Configuration Files
|
||||||
|
|
||||||
|
Each board directory contains `board-config.sh` which defines:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `FQBN` | Fully Qualified Board Name for arduino-cli |
|
||||||
|
| `PORT` | Serial port for upload/monitoring (default: `/dev/ttyUSB0`) |
|
||||||
|
| `LIBS` | Vendor library path for `--libraries` flag |
|
||||||
|
| `OPTS` | Additional compiler flags (e.g., `-DDEBUG_MODE`) |
|
||||||
|
|
||||||
|
**Example** (`boards/esp32-32e-4/board-config.sh`):
|
||||||
|
```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.
|
**Prerequisites**: arduino-cli with `esp32:esp32` platform installed, mise.
|
||||||
|
|
||||||
@@ -60,6 +114,12 @@ boards/
|
|||||||
│ ├── secrets.h WiFi creds (gitignored, copy from .example)
|
│ ├── secrets.h WiFi creds (gitignored, copy from .example)
|
||||||
│ ├── tft_user_setup.h TFT_eSPI config
|
│ ├── tft_user_setup.h TFT_eSPI config
|
||||||
│ └── DisplayDriverTFT.* Concrete IDisplayDriver for TFT
|
│ └── DisplayDriverTFT.* Concrete IDisplayDriver for TFT
|
||||||
|
├── esp32-32e-4/
|
||||||
|
│ ├── esp32-32e-4.ino Main sketch
|
||||||
|
│ ├── board_config.h Board-specific config
|
||||||
|
│ ├── secrets.h WiFi creds (gitignored, copy from .example)
|
||||||
|
│ ├── tft_user_setup.h TFT_eSPI config
|
||||||
|
│ └── DisplayDriverTFT.* Concrete IDisplayDriver for TFT
|
||||||
└── esp32-s3-lcd-43/
|
└── esp32-s3-lcd-43/
|
||||||
├── esp32-s3-lcd-43.ino Main sketch
|
├── esp32-s3-lcd-43.ino Main sketch
|
||||||
├── board_config.h Board-specific config
|
├── board_config.h Board-specific config
|
||||||
@@ -69,6 +129,7 @@ boards/
|
|||||||
|
|
||||||
vendor/ Vendored display libs (recreated by install-libs)
|
vendor/ Vendored display libs (recreated by install-libs)
|
||||||
├── esp32-32e/TFT_eSPI/
|
├── esp32-32e/TFT_eSPI/
|
||||||
|
├── esp32-32e-4/TFT_eSPI/
|
||||||
└── esp32-s3-lcd-43/LovyanGFX/
|
└── esp32-s3-lcd-43/LovyanGFX/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -107,11 +168,42 @@ Use `#pragma once` (not `#ifndef` guards).
|
|||||||
| `status` | Print state + memory info |
|
| `status` | Print state + memory info |
|
||||||
| `reboot` | Restart device |
|
| `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
|
||||||
|
```
|
||||||
|
|
||||||
|
The monitor daemon automatically starts after upload and is killed before upload to avoid serial port conflicts.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
1. **secrets.h is gitignored**: Copy from `.example` before building:
|
1. **secrets.h is gitignored**: Copy from `.example` before building:
|
||||||
```bash
|
```bash
|
||||||
cp boards/esp32-32e/secrets.h.example boards/esp32-32e/secrets.h
|
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
|
cp boards/esp32-s3-lcd-43/secrets.h.example boards/esp32-s3-lcd-43/secrets.h
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -125,6 +217,110 @@ Use `#pragma once` (not `#ifndef` guards).
|
|||||||
|
|
||||||
6. **WiFi credentials are per-board**: Each board directory has its own `secrets.h` because boards may be on different networks.
|
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-32e-4` (set in mise.toml).
|
||||||
|
|
||||||
|
8. **Lockfile system**: The build system uses lockfiles (`/tmp/doorbell-$BOARD.lock`) to prevent concurrent upload/monitor operations. Use `mise run kill` to clean up stuck processes.
|
||||||
|
|
||||||
|
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. **Lockfile mechanism**: The build system uses `/tmp/doorbell-$BOARD.lock` to prevent concurrent operations. The lockfile script (`scripts/lockfile.sh`) provides `acquire_lock()` and `release_lock()` functions. Use `FORCE=1` to override locks.
|
||||||
|
|
||||||
|
## Config Constants
|
||||||
|
|
||||||
|
Key timing and configuration values in `Config.h`:
|
||||||
|
|
||||||
|
| Constant | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `FW_VERSION` | "5.1" | Firmware version string |
|
||||||
|
| `POLL_INTERVAL_MS` | 15000 | How often to poll ntfy.sh for new messages |
|
||||||
|
| `HEARTBEAT_INTERVAL_MS` | 300000 | NTP sync interval (5 min) |
|
||||||
|
| `ALERT_TIMEOUT_MS` | 120000 | Auto-clear alert after 2 min |
|
||||||
|
| `INACTIVITY_TIMEOUT_MS` | 30000 | Turn off display after 30s of inactivity |
|
||||||
|
| `HOLD_TO_SILENCE_MS` | 3000 | Hold duration to silence alert |
|
||||||
|
| `LOOP_YIELD_MS` | 10 | Yield to prevent Task Watchdog (configurable) |
|
||||||
|
|
||||||
|
## 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 | LSP cannot resolve Arduino types without arduino-cli; ignore clangd errors |
|
||||||
|
| 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++ and arduino-language-server for Arduino. The `.crush.json` contains dynamic FQBN resolution:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lsp": {
|
||||||
|
"arduino": {
|
||||||
|
"command": "arduino-language-server",
|
||||||
|
"args": ["-fqbn", "$(cat boards/${BOARD:-esp32-32e-4}/board-config.sh | grep '^FQBN=' | cut -d'\"' -f2)"]
|
||||||
|
},
|
||||||
|
"cpp": {
|
||||||
|
"command": "clangd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
## Hardware Research Log
|
||||||
|
|
||||||
### Hosyond ESP32-32E 4" (320x480) - Planned
|
### Hosyond ESP32-32E 4" (320x480) - Planned
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "DisplayDriverTFT.h"
|
#include "DisplayDriverTFT.h"
|
||||||
|
|
||||||
#include <KlubhausCore.h>
|
#include <KlubhausCore.h>
|
||||||
|
|
||||||
extern DisplayManager display;
|
extern DisplayManager display;
|
||||||
@@ -35,7 +36,8 @@ void DisplayDriverTFT::setBacklight(bool on) { digitalWrite(PIN_LCD_BL, on ? HIG
|
|||||||
// ── Rendering ───────────────────────────────────────────────
|
// ── Rendering ───────────────────────────────────────────────
|
||||||
|
|
||||||
void DisplayDriverTFT::render(const ScreenState& st) {
|
void DisplayDriverTFT::render(const ScreenState& st) {
|
||||||
if(st.screen != _lastScreen || (st.screen == ScreenID::BOOT && st.bootStage != _lastBootStage)) {
|
if(st.screen != _lastScreen
|
||||||
|
|| (st.screen == ScreenID::BOOT && st.bootStage != _lastBootStage)) {
|
||||||
_needsRedraw = true;
|
_needsRedraw = true;
|
||||||
_lastScreen = st.screen;
|
_lastScreen = st.screen;
|
||||||
_lastBootStage = st.bootStage;
|
_lastBootStage = st.bootStage;
|
||||||
@@ -162,7 +164,7 @@ void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
|
|||||||
_tft.setTextSize(2);
|
_tft.setTextSize(2);
|
||||||
int textLen = strlen(tileLabels[i]);
|
int textLen = strlen(tileLabels[i]);
|
||||||
int textW = textLen * 12;
|
int textW = textLen * 12;
|
||||||
_tft.setCursor(x + w/2 - textW/2, y + h/2 - 10);
|
_tft.setCursor(x + w / 2 - textW / 2, y + h / 2 - 10);
|
||||||
_tft.print(tileLabels[i]);
|
_tft.print(tileLabels[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,9 +204,7 @@ TouchEvent DisplayDriverTFT::readTouch() {
|
|||||||
return evt;
|
return evt;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint16_t DisplayDriverTFT::getRawTouchZ() {
|
uint16_t DisplayDriverTFT::getRawTouchZ() { return _tft.getTouchRawZ(); }
|
||||||
return _tft.getTouchRawZ();
|
|
||||||
}
|
|
||||||
|
|
||||||
void DisplayDriverTFT::transformTouch(int* x, int* y) {
|
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 coordinates
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ void DisplayDriverTFT::setBacklight(bool on) { digitalWrite(PIN_LCD_BL, on ? HIG
|
|||||||
// ── Rendering ───────────────────────────────────────────────
|
// ── Rendering ───────────────────────────────────────────────
|
||||||
|
|
||||||
void DisplayDriverTFT::render(const ScreenState& st) {
|
void DisplayDriverTFT::render(const ScreenState& st) {
|
||||||
if(st.screen != _lastScreen || (st.screen == ScreenID::BOOT && st.bootStage != _lastBootStage)) {
|
if(st.screen != _lastScreen
|
||||||
|
|| (st.screen == ScreenID::BOOT && st.bootStage != _lastBootStage)) {
|
||||||
_needsRedraw = true;
|
_needsRedraw = true;
|
||||||
_lastScreen = st.screen;
|
_lastScreen = st.screen;
|
||||||
_lastBootStage = st.bootStage;
|
_lastBootStage = st.bootStage;
|
||||||
|
|||||||
@@ -58,16 +58,137 @@ int DisplayDriverGFX::width() { return _gfx ? _gfx->width() : DISP_W; }
|
|||||||
|
|
||||||
int DisplayDriverGFX::height() { return _gfx ? _gfx->height() : DISP_H; }
|
int DisplayDriverGFX::height() { return _gfx ? _gfx->height() : DISP_H; }
|
||||||
|
|
||||||
// ── Touch handling ──
|
// 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) {
|
||||||
|
if(!x || !y)
|
||||||
|
return;
|
||||||
|
// Flip both axes: (0,0) becomes (width, height)
|
||||||
|
*x = DISP_W - *x;
|
||||||
|
*y = DISP_H - *y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test harness: parse serial commands to inject synthetic touches
|
||||||
|
// Commands:
|
||||||
|
// TEST:touch x y press - simulate press at (x, y) [raw panel coords]
|
||||||
|
// TEST:touch x y release - simulate release at (x, y)
|
||||||
|
// TEST:touch clear - clear test mode
|
||||||
|
bool DisplayDriverGFX::parseTestTouch(int* outX, int* outY, bool* outPressed) {
|
||||||
|
if(!Serial.available())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Only consume if it starts with 'T' - don't steal other commands
|
||||||
|
// Use "TEST:" prefix to avoid conflict with [CMD] echo
|
||||||
|
if(Serial.peek() != 'T') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String cmd = Serial.readStringUntil('\n');
|
||||||
|
cmd.trim();
|
||||||
|
|
||||||
|
if(!cmd.startsWith("TEST:touch"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Parse: touch x y press|release
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse x y state
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch handling
|
||||||
|
|
||||||
TouchEvent DisplayDriverGFX::readTouch() {
|
TouchEvent DisplayDriverGFX::readTouch() {
|
||||||
TouchEvent evt;
|
TouchEvent evt;
|
||||||
if(!_gfx)
|
if(!_gfx)
|
||||||
return evt;
|
return evt;
|
||||||
|
|
||||||
|
// Check for test injection via serial
|
||||||
|
int testX, testY;
|
||||||
|
bool testPressed;
|
||||||
|
if(parseTestTouch(&testX, &testY, &testPressed)) {
|
||||||
|
// Handle test touch with same logic as real touch
|
||||||
|
unsigned long now = millis();
|
||||||
|
|
||||||
|
if(testPressed && !_lastTouch.pressed) {
|
||||||
|
evt.pressed = true;
|
||||||
|
evt.downX = testX;
|
||||||
|
evt.downY = testY;
|
||||||
|
_lastTouch.downX = evt.downX;
|
||||||
|
_lastTouch.downY = evt.downY;
|
||||||
|
_touchBounced = false;
|
||||||
|
} else if(!testPressed && _lastTouch.pressed) {
|
||||||
|
evt.released = true;
|
||||||
|
evt.downX = _lastTouch.downX;
|
||||||
|
evt.downY = _lastTouch.downY;
|
||||||
|
_lastReleaseMs = now;
|
||||||
|
_touchBounced = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(testPressed) {
|
||||||
|
evt.x = testX;
|
||||||
|
evt.y = testY;
|
||||||
|
evt.downX = _lastTouch.downX;
|
||||||
|
evt.downY = _lastTouch.downY;
|
||||||
|
_pressStartMs = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(_touchBounced && now - _lastReleaseMs >= TOUCH_DEBOUNCE_MS) {
|
||||||
|
_touchBounced = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastTouch.pressed = testPressed;
|
||||||
|
if(testPressed) {
|
||||||
|
_lastTouch.x = evt.x;
|
||||||
|
_lastTouch.y = evt.y;
|
||||||
|
}
|
||||||
|
return evt;
|
||||||
|
}
|
||||||
|
|
||||||
int32_t x, y;
|
int32_t x, y;
|
||||||
bool pressed = _gfx->getTouch(&x, &y);
|
bool pressed = _gfx->getTouch(&x, &y);
|
||||||
|
|
||||||
|
// Debounce: ignore repeated press events within debounce window after release
|
||||||
|
unsigned long now = millis();
|
||||||
|
if(pressed && _touchBounced) {
|
||||||
|
// Within debounce window - ignore this press
|
||||||
|
_lastTouch.pressed = pressed;
|
||||||
|
return evt;
|
||||||
|
}
|
||||||
|
|
||||||
// Detect transitions (press/release)
|
// Detect transitions (press/release)
|
||||||
if(pressed && !_lastTouch.pressed) {
|
if(pressed && !_lastTouch.pressed) {
|
||||||
// Press transition: finger just touched down
|
// Press transition: finger just touched down
|
||||||
@@ -76,11 +197,15 @@ TouchEvent DisplayDriverGFX::readTouch() {
|
|||||||
evt.downY = static_cast<int>(y);
|
evt.downY = static_cast<int>(y);
|
||||||
_lastTouch.downX = evt.downX;
|
_lastTouch.downX = evt.downX;
|
||||||
_lastTouch.downY = evt.downY;
|
_lastTouch.downY = evt.downY;
|
||||||
|
_touchBounced = false;
|
||||||
} else if(!pressed && _lastTouch.pressed) {
|
} else if(!pressed && _lastTouch.pressed) {
|
||||||
// Release transition: finger just lifted
|
// Release transition: finger just lifted
|
||||||
evt.released = true;
|
evt.released = true;
|
||||||
evt.downX = _lastTouch.downX;
|
evt.downX = _lastTouch.downX;
|
||||||
evt.downY = _lastTouch.downY;
|
evt.downY = _lastTouch.downY;
|
||||||
|
// Start debounce window
|
||||||
|
_lastReleaseMs = now;
|
||||||
|
_touchBounced = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current position if still touched
|
// Current position if still touched
|
||||||
@@ -92,6 +217,11 @@ TouchEvent DisplayDriverGFX::readTouch() {
|
|||||||
_pressStartMs = millis();
|
_pressStartMs = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if debounce window has expired
|
||||||
|
if(_touchBounced && now - _lastReleaseMs >= TOUCH_DEBOUNCE_MS) {
|
||||||
|
_touchBounced = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Track previous state
|
// Track previous state
|
||||||
_lastTouch.pressed = pressed;
|
_lastTouch.pressed = pressed;
|
||||||
if(pressed) {
|
if(pressed) {
|
||||||
@@ -151,7 +281,8 @@ void DisplayDriverGFX::render(const ScreenState& state) {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
// Check if we need full redraw
|
// Check if we need full redraw
|
||||||
if(state.screen != _lastScreen || (state.screen == ScreenID::BOOT && state.bootStage != _lastBootStage)) {
|
if(state.screen != _lastScreen
|
||||||
|
|| (state.screen == ScreenID::BOOT && state.bootStage != _lastBootStage)) {
|
||||||
_needsRedraw = true;
|
_needsRedraw = true;
|
||||||
_lastScreen = state.screen;
|
_lastScreen = state.screen;
|
||||||
_lastBootStage = state.bootStage;
|
_lastBootStage = state.bootStage;
|
||||||
@@ -251,15 +382,15 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
|
|||||||
_gfx->fillScreen(0x001030); // Dark blue
|
_gfx->fillScreen(0x001030); // Dark blue
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
_gfx->fillRect(0, 0, DISP_W, 30, 0x1A1A); // Dark gray
|
_gfx->fillRect(0, 0, DISP_W, 40, 0x1A1A); // Dark gray
|
||||||
_gfx->setFont(&fonts::Font0); // Built-in minimal font
|
_gfx->setFont(&fonts::Font2);
|
||||||
_gfx->setTextColor(0xFFFF);
|
_gfx->setTextColor(0xFFFF);
|
||||||
_gfx->setTextSize(1);
|
_gfx->setTextSize(1);
|
||||||
_gfx->setCursor(5, 10);
|
_gfx->setCursor(10, 12);
|
||||||
_gfx->printf("KLUBHAUS");
|
_gfx->printf("KLUBHAUS");
|
||||||
|
|
||||||
// WiFi status
|
// WiFi status
|
||||||
_gfx->setCursor(DISP_W - 100, 10);
|
_gfx->setCursor(DISP_W - 120, 12);
|
||||||
_gfx->printf("WiFi:%s", state.wifiSsid.length() > 0 ? "ON" : "OFF");
|
_gfx->printf("WiFi:%s", state.wifiSsid.length() > 0 ? "ON" : "OFF");
|
||||||
|
|
||||||
// Get tile layouts from library helper
|
// Get tile layouts from library helper
|
||||||
@@ -284,7 +415,7 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
|
|||||||
// Tile label
|
// Tile label
|
||||||
_gfx->setTextColor(0xFFFF);
|
_gfx->setTextColor(0xFFFF);
|
||||||
_gfx->setTextSize(2);
|
_gfx->setTextSize(2);
|
||||||
_gfx->setCursor(x + w/2 - 10, y + h/2 - 10);
|
_gfx->setCursor(x + w / 2 - 10, y + h / 2 - 10);
|
||||||
_gfx->print(tileLabels[i]);
|
_gfx->print(tileLabels[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,19 @@ public:
|
|||||||
int width() override;
|
int width() override;
|
||||||
int height() override;
|
int height() override;
|
||||||
|
|
||||||
|
// Transform touch coordinates (handles rotated touch panels)
|
||||||
|
void transformTouch(int* x, int* y) override;
|
||||||
|
|
||||||
|
// Dashboard tile mapping
|
||||||
|
int dashboardTouch(int x, int y);
|
||||||
|
|
||||||
// ── Internal ──
|
// ── Internal ──
|
||||||
static DisplayDriverGFX& instance();
|
static DisplayDriverGFX& instance();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Test harness: parse serial commands to inject synthetic touches
|
||||||
|
bool parseTestTouch(int* outX, int* outY, bool* outPressed);
|
||||||
|
|
||||||
// Helper rendering functions
|
// Helper rendering functions
|
||||||
void drawBoot(const ScreenState& state);
|
void drawBoot(const ScreenState& state);
|
||||||
void drawAlert(const ScreenState& state);
|
void drawAlert(const ScreenState& state);
|
||||||
@@ -31,6 +40,9 @@ private:
|
|||||||
TouchEvent _lastTouch = { false, false, 0, 0, -1, -1 };
|
TouchEvent _lastTouch = { false, false, 0, 0, -1, -1 };
|
||||||
unsigned long _pressStartMs = 0;
|
unsigned long _pressStartMs = 0;
|
||||||
bool _isHolding = false;
|
bool _isHolding = false;
|
||||||
|
unsigned long _lastReleaseMs = 0;
|
||||||
|
bool _touchBounced = false;
|
||||||
|
bool _testMode = false;
|
||||||
|
|
||||||
// Screen tracking
|
// Screen tracking
|
||||||
ScreenID _lastScreen = ScreenID::BOOT;
|
ScreenID _lastScreen = ScreenID::BOOT;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FQBN="esp32:esp32:waveshare_esp32_s3_touch_lcd_43:PSRAM=enabled,FlashSize=16M,USBMode=hwcdc,PartitionScheme=app3M_fat9M_16MB"
|
FQBN="esp32:esp32:waveshare_esp32_s3_touch_lcd_43:PSRAM=enabled,FlashSize=16M,USBMode=hwcdc,PartitionScheme=app3M_fat9M_16MB"
|
||||||
PORT="/dev/ttyACM0"
|
PORT="/dev/ttyACM0"
|
||||||
LIBS="--libraries ./vendor/esp32-s3-lcd-43/LovyanGFX"
|
LIBS="--libraries ~/Arduino/libraries/LovyanGFX"
|
||||||
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM -DLGFX_USE_V1"
|
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM -DLGFX_USE_V1"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ void setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
// Read touch
|
// Read touch (includes test injection via serial "touch x y press/release")
|
||||||
TouchEvent evt = display.readTouch();
|
TouchEvent evt = display.readTouch();
|
||||||
|
|
||||||
// State machine tick
|
// State machine tick
|
||||||
|
|||||||
3458
sketches/doorbell-touch/compile_commands.json
Normal file
3458
sketches/doorbell-touch/compile_commands.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
|||||||
#define HINT_ANIMATION_MS 2000
|
#define HINT_ANIMATION_MS 2000
|
||||||
#define HINT_MIN_BRIGHTNESS 30
|
#define HINT_MIN_BRIGHTNESS 30
|
||||||
#define HINT_MAX_BRIGHTNESS 60
|
#define HINT_MAX_BRIGHTNESS 60
|
||||||
|
#define TOUCH_DEBOUNCE_MS 100
|
||||||
|
|
||||||
// ── Loop yield (prevents Task Watchdog on ESP32) ──
|
// ── Loop yield (prevents Task Watchdog on ESP32) ──
|
||||||
#ifndef LOOP_YIELD_MS
|
#ifndef LOOP_YIELD_MS
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "IDisplayDriver.h"
|
#include "IDisplayDriver.h"
|
||||||
#include "ScreenState.h"
|
#include "ScreenState.h"
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
/// Layout helper for dashboard tiles - computes positions based on constraints
|
/// Layout helper for dashboard tiles - computes positions based on constraints
|
||||||
@@ -10,15 +11,8 @@ public:
|
|||||||
/// Calculate tile layouts for the given display dimensions
|
/// Calculate tile layouts for the given display dimensions
|
||||||
/// Returns array of TileLayout (must have capacity for DASHBOARD_TILE_COUNT)
|
/// Returns array of TileLayout (must have capacity for DASHBOARD_TILE_COUNT)
|
||||||
/// Returns the number of columns and rows used
|
/// Returns the number of columns and rows used
|
||||||
static int calculateLayouts(
|
static int calculateLayouts(int displayW, int displayH, int headerH, int margin,
|
||||||
int displayW,
|
TileLayout* outLayouts, int* outCols, int* outRows) {
|
||||||
int displayH,
|
|
||||||
int headerH,
|
|
||||||
int margin,
|
|
||||||
TileLayout* outLayouts,
|
|
||||||
int* outCols,
|
|
||||||
int* outRows
|
|
||||||
) {
|
|
||||||
int contentH = displayH - headerH;
|
int contentH = displayH - headerH;
|
||||||
int tileCount = DASHBOARD_TILE_COUNT;
|
int tileCount = DASHBOARD_TILE_COUNT;
|
||||||
|
|
||||||
@@ -42,8 +36,10 @@ public:
|
|||||||
findNextPosition(outLayouts, i, cols, rows, &col, &row);
|
findNextPosition(outLayouts, i, cols, rows, &col, &row);
|
||||||
|
|
||||||
// Ensure tile fits within grid
|
// Ensure tile fits within grid
|
||||||
if(col + tileCols > cols) tileCols = cols - col;
|
if(col + tileCols > cols)
|
||||||
if(row + tileRows > rows) tileRows = rows - row;
|
tileCols = cols - col;
|
||||||
|
if(row + tileRows > rows)
|
||||||
|
tileRows = rows - row;
|
||||||
|
|
||||||
// Calculate pixel position
|
// Calculate pixel position
|
||||||
int x = col * cellW + margin;
|
int x = col * cellW + margin;
|
||||||
@@ -51,7 +47,7 @@ public:
|
|||||||
int w = tileCols * cellW - 2 * margin;
|
int w = tileCols * cellW - 2 * margin;
|
||||||
int h = tileRows * cellH - 2 * margin;
|
int h = tileRows * cellH - 2 * margin;
|
||||||
|
|
||||||
outLayouts[i] = {x, y, w, h, col, row, tileCols, tileRows};
|
outLayouts[i] = { x, y, w, h, col, row, tileCols, tileRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
*outCols = cols;
|
*outCols = cols;
|
||||||
@@ -61,14 +57,17 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
/// Calculate optimal grid dimensions based on display and tile constraints
|
/// Calculate optimal grid dimensions based on display and tile constraints
|
||||||
static void calculateGrid(int tileCount, int displayW, int contentH, int* outCols, int* outRows) {
|
static void calculateGrid(
|
||||||
|
int tileCount, int displayW, int contentH, int* outCols, int* outRows) {
|
||||||
// Calculate aspect ratio to determine preferred layout
|
// Calculate aspect ratio to determine preferred layout
|
||||||
float aspectRatio = (float)displayW / contentH;
|
float aspectRatio = (float)displayW / contentH;
|
||||||
|
|
||||||
// Start with simple square-ish grid
|
// Start with simple square-ish grid
|
||||||
int cols = (int)std::sqrt(tileCount * aspectRatio);
|
int cols = (int)std::sqrt(tileCount * aspectRatio);
|
||||||
if(cols < 1) cols = 1;
|
if(cols < 1)
|
||||||
if(cols > tileCount) cols = tileCount;
|
cols = 1;
|
||||||
|
if(cols > tileCount)
|
||||||
|
cols = tileCount;
|
||||||
|
|
||||||
int rows = (tileCount + cols - 1) / cols;
|
int rows = (tileCount + cols - 1) / cols;
|
||||||
|
|
||||||
@@ -88,15 +87,16 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find next available grid position
|
/// Find next available grid position
|
||||||
static void findNextPosition(const TileLayout* layouts, int count, int gridCols, int gridRows, int* outCol, int* outRow) {
|
static void findNextPosition(const TileLayout* layouts, int count, int gridCols, int gridRows,
|
||||||
|
int* outCol, int* outRow) {
|
||||||
// Simple: find first empty cell
|
// Simple: find first empty cell
|
||||||
// Could be enhanced to pack tightly based on tile sizes
|
// Could be enhanced to pack tightly based on tile sizes
|
||||||
for(int r = 0; r < gridRows; r++) {
|
for(int r = 0; r < gridRows; r++) {
|
||||||
for(int c = 0; c < gridCols; c++) {
|
for(int c = 0; c < gridCols; c++) {
|
||||||
bool occupied = false;
|
bool occupied = false;
|
||||||
for(int i = 0; i < count; i++) {
|
for(int i = 0; i < count; i++) {
|
||||||
if(layouts[i].col <= c && c < layouts[i].col + layouts[i].cols &&
|
if(layouts[i].col <= c && c < layouts[i].col + layouts[i].cols
|
||||||
layouts[i].row <= r && r < layouts[i].row + layouts[i].rows) {
|
&& layouts[i].row <= r && r < layouts[i].row + layouts[i].rows) {
|
||||||
occupied = true;
|
occupied = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,8 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
void render(const ScreenState& st) {
|
void render(const ScreenState& st) {
|
||||||
if(!_drv) return;
|
if(!_drv)
|
||||||
|
return;
|
||||||
_drv->render(st);
|
_drv->render(st);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,13 +147,15 @@ public:
|
|||||||
/// Show touch feedback - highlights the tile at given coordinates
|
/// Show touch feedback - highlights the tile at given coordinates
|
||||||
/// Returns true if a valid tile is being touched
|
/// Returns true if a valid tile is being touched
|
||||||
bool showTouchFeedback(int x, int y) {
|
bool showTouchFeedback(int x, int y) {
|
||||||
if(!_drv || _gridCols <= 0) return false;
|
if(!_drv || _gridCols <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
// Transform touch coordinates
|
// Transform touch coordinates
|
||||||
_drv->transformTouch(&x, &y);
|
_drv->transformTouch(&x, &y);
|
||||||
|
|
||||||
int headerH = 30;
|
int headerH = 30;
|
||||||
if(y < headerH) return false;
|
if(y < headerH)
|
||||||
|
return false;
|
||||||
|
|
||||||
// Calculate which cell
|
// Calculate which cell
|
||||||
int cellW = _drv->width() / _gridCols;
|
int cellW = _drv->width() / _gridCols;
|
||||||
@@ -167,10 +170,10 @@ public:
|
|||||||
// Find which tile is at this position
|
// Find which tile is at this position
|
||||||
for(int i = 0; i < _tileCount; i++) {
|
for(int i = 0; i < _tileCount; i++) {
|
||||||
const TileLayout& lay = _layouts[i];
|
const TileLayout& lay = _layouts[i];
|
||||||
if(lay.col <= col && col < lay.col + lay.cols &&
|
if(lay.col <= col && col < lay.col + lay.cols && lay.row <= row
|
||||||
lay.row <= row && lay.row + lay.rows > row) {
|
&& lay.row + lay.rows > row) {
|
||||||
// Found the tile - draw highlight via driver
|
// Found the tile - draw highlight via driver
|
||||||
_drv->updateHint(lay.x, lay.y, true); // active=true means show feedback
|
_drv->updateHint(lay.x, lay.y, true); // active=true means show feedback
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,12 +183,13 @@ public:
|
|||||||
/// Clear touch feedback
|
/// Clear touch feedback
|
||||||
void clearTouchFeedback() {
|
void clearTouchFeedback() {
|
||||||
if(_drv)
|
if(_drv)
|
||||||
_drv->updateHint(0, 0, false); // active=false means clear
|
_drv->updateHint(0, 0, false); // active=false means clear
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if current position is still in same tile as touch-down
|
/// Check if current position is still in same tile as touch-down
|
||||||
bool isSameTile(int downX, int downY, int currentX, int currentY) const {
|
bool isSameTile(int downX, int downY, int currentX, int currentY) const {
|
||||||
if(!_drv || _gridCols <= 0 || downX < 0) return false;
|
if(!_drv || _gridCols <= 0 || downX < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
int dx = downX, dy = downY;
|
int dx = downX, dy = downY;
|
||||||
int cx = currentX, cy = currentY;
|
int cx = currentX, cy = currentY;
|
||||||
@@ -210,7 +214,8 @@ public:
|
|||||||
|
|
||||||
/// Handle dashboard touch - returns action for tapped tile, or NONE
|
/// Handle dashboard touch - returns action for tapped tile, or NONE
|
||||||
TileAction handleDashboardTouch(int x, int y) const {
|
TileAction handleDashboardTouch(int x, int y) const {
|
||||||
if(!_drv || _gridCols <= 0) return TileAction::NONE;
|
if(!_drv || _gridCols <= 0)
|
||||||
|
return TileAction::NONE;
|
||||||
|
|
||||||
// Transform touch coordinates (handles rotated touch panels)
|
// Transform touch coordinates (handles rotated touch panels)
|
||||||
_drv->transformTouch(&x, &y);
|
_drv->transformTouch(&x, &y);
|
||||||
@@ -220,7 +225,8 @@ public:
|
|||||||
int headerH = 30;
|
int headerH = 30;
|
||||||
|
|
||||||
// Check if in header area
|
// Check if in header area
|
||||||
if(y < headerH) return TileAction::NONE;
|
if(y < headerH)
|
||||||
|
return TileAction::NONE;
|
||||||
|
|
||||||
// Calculate which tile was touched using grid
|
// Calculate which tile was touched using grid
|
||||||
int cellW = dispW / _gridCols;
|
int cellW = dispW / _gridCols;
|
||||||
@@ -237,8 +243,8 @@ public:
|
|||||||
// Find which tile occupies this cell
|
// Find which tile occupies this cell
|
||||||
for(int i = 0; i < _tileCount; i++) {
|
for(int i = 0; i < _tileCount; i++) {
|
||||||
const TileLayout& layout = _layouts[i];
|
const TileLayout& layout = _layouts[i];
|
||||||
if(layout.col <= col && col < layout.col + layout.cols &&
|
if(layout.col <= col && col < layout.col + layout.cols && layout.row <= row
|
||||||
layout.row <= row && row < layout.row + layout.rows) {
|
&& row < layout.row + layout.rows) {
|
||||||
return DASHBOARD_TILES[i].action;
|
return DASHBOARD_TILES[i].action;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,17 +255,11 @@ public:
|
|||||||
/// Calculate and store layouts for dashboard tiles
|
/// Calculate and store layouts for dashboard tiles
|
||||||
/// Called by drivers who want to use the layout helper
|
/// Called by drivers who want to use the layout helper
|
||||||
int calculateDashboardLayouts(int headerH = 30, int margin = 8) {
|
int calculateDashboardLayouts(int headerH = 30, int margin = 8) {
|
||||||
if(!_drv) return 0;
|
if(!_drv)
|
||||||
|
return 0;
|
||||||
|
|
||||||
_tileCount = TileLayoutHelper::calculateLayouts(
|
_tileCount = TileLayoutHelper::calculateLayouts(
|
||||||
_drv->width(),
|
_drv->width(), _drv->height(), headerH, margin, _layouts, &_gridCols, &_gridRows);
|
||||||
_drv->height(),
|
|
||||||
headerH,
|
|
||||||
margin,
|
|
||||||
_layouts,
|
|
||||||
&_gridCols,
|
|
||||||
&_gridRows
|
|
||||||
);
|
|
||||||
return _tileCount;
|
return _tileCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ void DoorbellLogic::update() {
|
|||||||
default:
|
default:
|
||||||
// Inactivity timeout: turn off screen after no activity
|
// Inactivity timeout: turn off screen after no activity
|
||||||
if(_state.screen != ScreenID::OFF && now - _lastActivityMs > INACTIVITY_TIMEOUT_MS) {
|
if(_state.screen != ScreenID::OFF && now - _lastActivityMs > INACTIVITY_TIMEOUT_MS) {
|
||||||
Serial.printf("[%lu] [STATE] Inactivity timeout (%lu ms) → OFF\n",
|
Serial.printf("[%lu] [STATE] Inactivity timeout (%lu ms) → OFF\n", millis(),
|
||||||
millis(), now - _lastActivityMs);
|
now - _lastActivityMs);
|
||||||
_state.screen = ScreenID::OFF;
|
_state.screen = ScreenID::OFF;
|
||||||
_display->setBacklight(false);
|
_display->setBacklight(false);
|
||||||
_state.backlightOn = false;
|
_state.backlightOn = false;
|
||||||
@@ -223,6 +223,15 @@ void DoorbellLogic::onSilence() {
|
|||||||
|
|
||||||
void DoorbellLogic::silenceAlert() { onSilence(); }
|
void DoorbellLogic::silenceAlert() { onSilence(); }
|
||||||
|
|
||||||
|
void DoorbellLogic::dismissAlert() {
|
||||||
|
Serial.printf("[%lu] [DISMISS] Alert dismissed by user\n", millis());
|
||||||
|
_state.deviceState = DeviceState::SILENT;
|
||||||
|
_state.screen = ScreenID::DASHBOARD;
|
||||||
|
_state.alertTitle = "";
|
||||||
|
_state.alertBody = "";
|
||||||
|
_display->render(_state);
|
||||||
|
}
|
||||||
|
|
||||||
void DoorbellLogic::onAdmin(const String& cmd) {
|
void DoorbellLogic::onAdmin(const String& cmd) {
|
||||||
Serial.printf("[ADMIN] %s\n", cmd.c_str());
|
Serial.printf("[ADMIN] %s\n", cmd.c_str());
|
||||||
if(cmd == "reboot") {
|
if(cmd == "reboot") {
|
||||||
@@ -231,13 +240,13 @@ void DoorbellLogic::onAdmin(const String& cmd) {
|
|||||||
ESP.restart();
|
ESP.restart();
|
||||||
} else if(cmd == "dashboard") {
|
} else if(cmd == "dashboard") {
|
||||||
Serial.printf("[%lu] [ADMIN] dashboard\n", millis());
|
Serial.printf("[%lu] [ADMIN] dashboard\n", millis());
|
||||||
_lastActivityMs = millis(); // Reset inactivity timer
|
_lastActivityMs = millis(); // Reset inactivity timer
|
||||||
_state.screen = ScreenID::DASHBOARD;
|
_state.screen = ScreenID::DASHBOARD;
|
||||||
_display->setBacklight(true);
|
_display->setBacklight(true);
|
||||||
_state.backlightOn = true;
|
_state.backlightOn = true;
|
||||||
} else if(cmd == "off") {
|
} else if(cmd == "off") {
|
||||||
Serial.printf("[%lu] [ADMIN] off\n", millis());
|
Serial.printf("[%lu] [ADMIN] off\n", millis());
|
||||||
_lastActivityMs = millis(); // Reset inactivity timer
|
_lastActivityMs = millis(); // Reset inactivity timer
|
||||||
_state.screen = ScreenID::OFF;
|
_state.screen = ScreenID::OFF;
|
||||||
_display->setBacklight(false);
|
_display->setBacklight(false);
|
||||||
_state.backlightOn = false;
|
_state.backlightOn = false;
|
||||||
@@ -364,7 +373,9 @@ int DoorbellLogic::handleTouch(const TouchEvent& evt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(_state.screen == ScreenID::ALERT) {
|
if(_state.screen == ScreenID::ALERT) {
|
||||||
Serial.println("[TOUCH] ALERT tap");
|
Serial.printf("[%lu] [TOUCH] ALERT → DASHBOARD (dismiss)\n", millis());
|
||||||
|
dismissAlert();
|
||||||
|
return (int)TileAction::DISMISS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public:
|
|||||||
|
|
||||||
/// Externally trigger silence (e.g. hold-to-silence gesture).
|
/// Externally trigger silence (e.g. hold-to-silence gesture).
|
||||||
void silenceAlert();
|
void silenceAlert();
|
||||||
|
/// Dismiss alert and return to dashboard (user tap on alert screen).
|
||||||
|
void dismissAlert();
|
||||||
void setScreen(ScreenID s);
|
void setScreen(ScreenID s);
|
||||||
/// Handle touch input — returns dashboard tile index if tapped, or -1.
|
/// Handle touch input — returns dashboard tile index if tapped, or -1.
|
||||||
int handleTouch(const TouchEvent& evt);
|
int handleTouch(const TouchEvent& evt);
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
#include "ScreenState.h"
|
#include "ScreenState.h"
|
||||||
|
|
||||||
struct TouchEvent {
|
struct TouchEvent {
|
||||||
bool pressed = false; // finger just touched down
|
bool pressed = false; // finger just touched down
|
||||||
bool released = false; // finger just lifted (after being pressed)
|
bool released = false; // finger just lifted (after being pressed)
|
||||||
int x = 0; // current x position
|
int x = 0; // current x position
|
||||||
int y = 0; // current y position
|
int y = 0; // current y position
|
||||||
int downX = -1; // x position where touch started
|
int downX = -1; // x position where touch started
|
||||||
int downY = -1; // y position where touch started
|
int downY = -1; // y position where touch started
|
||||||
};
|
};
|
||||||
|
|
||||||
struct HoldState {
|
struct HoldState {
|
||||||
|
|||||||
@@ -10,24 +10,25 @@ enum class BootStage { SPLASH, INIT_DISPLAY, INIT_NETWORK, CONNECTING_WIFI, READ
|
|||||||
/// Dashboard tile action handlers
|
/// Dashboard tile action handlers
|
||||||
enum class TileAction {
|
enum class TileAction {
|
||||||
NONE,
|
NONE,
|
||||||
ALERT, // Trigger alert
|
ALERT, // Trigger alert
|
||||||
SILENCE, // Silence alert
|
SILENCE, // Silence alert
|
||||||
STATUS, // Send heartbeat/status
|
DISMISS, // Dismiss alert and return to dashboard
|
||||||
REBOOT, // Reboot device
|
STATUS, // Send heartbeat/status
|
||||||
|
REBOOT, // Reboot device
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Dashboard tile layout constraints for flexible grid
|
/// Dashboard tile layout constraints for flexible grid
|
||||||
struct TileConstraint {
|
struct TileConstraint {
|
||||||
uint8_t minCols = 1; // minimum columns this tile needs
|
uint8_t minCols = 1; // minimum columns this tile needs
|
||||||
uint8_t minRows = 1; // minimum rows this tile needs
|
uint8_t minRows = 1; // minimum rows this tile needs
|
||||||
uint8_t weight = 1; // priority for growing/shrinking (higher = more flexible)
|
uint8_t weight = 1; // priority for growing/shrinking (higher = more flexible)
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Computed tile position in the grid
|
/// Computed tile position in the grid
|
||||||
struct TileLayout {
|
struct TileLayout {
|
||||||
int x, y; // pixel position
|
int x, y; // pixel position
|
||||||
int w, h; // pixel size
|
int w, h; // pixel size
|
||||||
int col, row; // grid position
|
int col, row; // grid position
|
||||||
int cols, rows; // grid span
|
int cols, rows; // grid span
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,10 +42,10 @@ struct DashboardTile {
|
|||||||
|
|
||||||
/// Standard dashboard tiles (auto-gridded based on count and constraints)
|
/// Standard dashboard tiles (auto-gridded based on count and constraints)
|
||||||
static constexpr DashboardTile DASHBOARD_TILES[] = {
|
static constexpr DashboardTile DASHBOARD_TILES[] = {
|
||||||
{ "Alert", 0x0280, TileAction::ALERT, {1, 1, 1} },
|
{ "Alert", 0x0280, TileAction::ALERT, { 1, 1, 1 } },
|
||||||
{ "Silent", 0x0400, TileAction::SILENCE, {1, 1, 1} },
|
{ "Silent", 0x0400, TileAction::SILENCE, { 1, 1, 1 } },
|
||||||
{ "Status", 0x0440, TileAction::STATUS, {1, 1, 1} },
|
{ "Status", 0x0440, TileAction::STATUS, { 1, 1, 1 } },
|
||||||
{ "Reboot", 0x0100, TileAction::REBOOT, {1, 1, 1} },
|
{ "Reboot", 0x0100, TileAction::REBOOT, { 1, 1, 1 } },
|
||||||
};
|
};
|
||||||
static constexpr int DASHBOARD_TILE_COUNT = sizeof(DASHBOARD_TILES) / sizeof(DASHBOARD_TILES[0]);
|
static constexpr int DASHBOARD_TILE_COUNT = sizeof(DASHBOARD_TILES) / sizeof(DASHBOARD_TILES[0]);
|
||||||
|
|
||||||
|
|||||||
@@ -28,27 +28,7 @@ arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-propert
|
|||||||
[tasks.upload]
|
[tasks.upload]
|
||||||
description = "Upload (uses BOARD env var)"
|
description = "Upload (uses BOARD env var)"
|
||||||
depends = ["compile"]
|
depends = ["compile"]
|
||||||
run = """
|
run = "source ./boards/$BOARD/board-config.sh && arduino-cli upload --fqbn $FQBN --port $PORT ./boards/$BOARD"
|
||||||
# Kill any processes using the serial port first
|
|
||||||
source ./boards/$BOARD/board-config.sh
|
|
||||||
PORT="${PORT:-$PORT}"
|
|
||||||
fuser -k "$PORT" 2>/dev/null || true
|
|
||||||
for pid in $(pgrep -f "monitor-agent.py" 2>/dev/null || true); do
|
|
||||||
kill "$pid" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
rm -f "/tmp/doorbell-${BOARD}.lock" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
source ./scripts/lockfile.sh
|
|
||||||
|
|
||||||
FORCE=1 TASK_NAME=upload acquire_lock || exit 1
|
|
||||||
|
|
||||||
arduino-cli upload --fqbn "$FQBN" --port "$PORT" ./boards/$BOARD
|
|
||||||
|
|
||||||
# Restart monitor in background
|
|
||||||
python3 ./scripts/monitor-agent.py "$BOARD" &
|
|
||||||
echo "[OK] Monitor restarted in background"
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.monitor-raw]
|
[tasks.monitor-raw]
|
||||||
run = """
|
run = """
|
||||||
@@ -80,15 +60,15 @@ set +e
|
|||||||
# Kill any processes using the serial port
|
# Kill any processes using the serial port
|
||||||
source ./boards/$BOARD/board-config.sh
|
source ./boards/$BOARD/board-config.sh
|
||||||
PORT="${PORT:-$PORT}"
|
PORT="${PORT:-$PORT}"
|
||||||
fuser -k "$PORT" 2>/dev/null
|
fuser -k "$PORT" 2>/dev/null || true
|
||||||
|
|
||||||
# Kill monitor-agent processes for this board
|
# Kill monitor-agent processes for this board
|
||||||
for pid in $(pgrep -f "monitor-agent.py"); do
|
for pid in $(pgrep -f "monitor-agent.py" 2>/dev/null || true); do
|
||||||
kill "$pid" 2>/dev/null
|
kill "$pid" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
|
||||||
# Also clean up lockfile
|
# Also clean up lockfile
|
||||||
rm -f "/tmp/doorbell-${BOARD}.lock" 2>/dev/null
|
rm -f "/tmp/doorbell-${BOARD}.lock" 2>/dev/null || true
|
||||||
|
|
||||||
sleep 1
|
sleep 1
|
||||||
echo "[OK] Killed processes for $BOARD"
|
echo "[OK] Killed processes for $BOARD"
|
||||||
@@ -193,5 +173,35 @@ clang-format -i --style=file \
|
|||||||
libraries/KlubhausCore/*.properties
|
libraries/KlubhausCore/*.properties
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
[tasks.gen-compile-commands]
|
||||||
|
description = "Generate compile_commands.json for LSP (uses BOARD env var)"
|
||||||
|
run = """
|
||||||
|
rm -rf /tmp/arduino-build
|
||||||
|
source ./boards/$BOARD/board-config.sh
|
||||||
|
arduino-cli compile --only-compilation-database --fqbn "$FQBN" --build-path /tmp/arduino-build ./boards/$BOARD
|
||||||
|
cp /tmp/arduino-build/compile_commands.json .
|
||||||
|
echo "[OK] compile_commands.json generated"
|
||||||
|
"""
|
||||||
|
|
||||||
|
[tasks.gen-crush-config]
|
||||||
|
description = "Generate .crush.json with BOARD-based FQBN"
|
||||||
|
run = """
|
||||||
|
source ./boards/$BOARD/board-config.sh
|
||||||
|
cat > .crush.json << EOF
|
||||||
|
{
|
||||||
|
"lsp": {
|
||||||
|
"arduino": {
|
||||||
|
"command": "arduino-language-server",
|
||||||
|
"args": ["-fqbn", "$FQBN"]
|
||||||
|
},
|
||||||
|
"cpp": {
|
||||||
|
"command": "clangd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "[OK] Generated .crush.json with FQBN: $FQBN"
|
||||||
|
"""
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
BOARD = "esp32-32e-4"
|
BOARD = "esp32-s3-lcd-43"
|
||||||
|
|||||||
Reference in New Issue
Block a user