Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df0f440dcb | |||
| 3af958b69f | |||
| 6f9d2912d3 | |||
| ece874a8ed | |||
| 14781b8c07 | |||
| 3d17e7bab9 | |||
| 4030048401 |
@@ -18,3 +18,4 @@ vendor/esp32-s3-lcd-43/GFX_Library_for_Arduino/
|
|||||||
compile_commands.json
|
compile_commands.json
|
||||||
.board-last
|
.board-last
|
||||||
.cache/
|
.cache/
|
||||||
|
boards/uno-r4-wifi/uno-r4-wifi.ino.backup
|
||||||
|
|||||||
@@ -1,455 +1,153 @@
|
|||||||
# AGENTS.md — Klubhaus Doorbell
|
# 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
|
||||||
|
|
||||||
**Default BOARD**: `esp32-s3-lcd-43` (set in mise.toml). To switch boards:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise set BOARD=esp32-32e-4 # switch to ESP32-32E-4"
|
# All tasks use just (see justfile). Default BOARD: esp32-s3-lcd-43
|
||||||
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
|
||||||
|
just BOARD=esp32-32e-4 compile # ESP32-32E 4" (320x480 ST7796)
|
||||||
|
just BOARD=esp32-32e compile # ESP32-32E 3.5" (320x240 ILI9341)
|
||||||
|
just BOARD=esp32-s3-lcd-43 compile # ESP32-S3-Touch-LCD-4.3 (800x480 RGB)
|
||||||
|
just BOARD=uno-r4-wifi compile # Arduino Uno R4 WiFi (12x8 LED matrix)
|
||||||
|
|
||||||
|
# Core commands
|
||||||
|
just compile # compile for current BOARD
|
||||||
|
just upload # upload (auto-kills monitor first)
|
||||||
|
just monitor # start JSON monitor daemon (background)
|
||||||
|
just kill # kill monitor/release serial port
|
||||||
|
just full # compile + upload + monitor + log-tail (full feedback loop)
|
||||||
|
just watch # tail colored logs
|
||||||
|
|
||||||
|
# Formatting & cleanup
|
||||||
|
just format # format code with clang-format
|
||||||
|
just clean # remove build artifacts
|
||||||
|
just clean-temp # remove monitor logs/FIFOs/state files
|
||||||
|
|
||||||
|
# Debugging
|
||||||
|
just cmd dashboard # send command to device
|
||||||
|
just state # show device state
|
||||||
|
just monitor-raw # raw serial monitor (115200 baud)
|
||||||
|
just monitor-tio # show tio command for terminal UI
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
just install-libs-shared # shared libs (ArduinoJson, NTPClient)
|
||||||
|
just install # shared + board-specific libs
|
||||||
|
|
||||||
|
# LSP / IDE
|
||||||
|
just gen-compile-commands # generate compile_commands.json
|
||||||
|
just gen-crush-config # generate .crush.json for BOARD
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run commands without `BOARD=` prefix:
|
**Serial debug commands** (115200 baud): `alert`, `silence`, `dashboard`, `off`, `status`, `reboot`
|
||||||
|
|
||||||
```bash
|
**No unit tests exist** — verify changes by compiling and deploying to hardware.
|
||||||
mise run compile # compile
|
|
||||||
mise run upload # upload (auto-kills monitor)
|
|
||||||
mise run monitor # start JSON monitor daemon
|
|
||||||
mise run log-tail # watch colored logs
|
|
||||||
mise run cmd COMMAND=dashboard # send command
|
|
||||||
mise run state # show device state
|
|
||||||
|
|
||||||
# Install libs (run after cloning)
|
## Code Style
|
||||||
mise run install-libs-shared # shared libs (ArduinoJson, NTPClient)
|
|
||||||
mise run install # shared + board-specific libs
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Overview
|
### Formatting (.clang-format)
|
||||||
|
- BasedOnStyle: WebKit
|
||||||
Three board targets share business logic via a common library:
|
- 4-space indentation, no tabs
|
||||||
|
- Column limit: 100
|
||||||
| Board | Display | Library | Build Command |
|
- Opening brace on same line (`BreakBeforeBraces: Attach`)
|
||||||
|-------|---------|---------|--------------|
|
- Run `just format` to format code
|
||||||
| 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)
|
|
||||||
mise run monitor-tio # show tio command for terminal UI
|
|
||||||
```
|
|
||||||
|
|
||||||
# Board Configuration Files
|
|
||||||
|
|
||||||
Each board directory contains `board-config.sh` which defines:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `FQBN` | Fully Qualified Board Name for arduino-cli |
|
|
||||||
| `PORT` | Serial port for upload/monitoring (default: `/dev/ttyUSB0`) |
|
|
||||||
| `LIBS` | Vendor library path for `--libraries` flag |
|
|
||||||
| `OPTS` | Additional compiler flags (e.g., `-DDEBUG_MODE`) |
|
|
||||||
|
|
||||||
**Example** (`boards/esp32-32e-4/board-config.sh`):
|
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
### Header Guards
|
### Header Guards
|
||||||
|
|
||||||
Use `#pragma once` (not `#ifndef` 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
|
### Naming Conventions
|
||||||
|
| Type | Convention | Example |
|
||||||
- Classes: `PascalCase` (e.g., `DisplayManager`, `IDisplayDriver`)
|
|------|------------|---------|
|
||||||
- Constants/enums: `SCREAMING_SNAKE` (e.g., `POLL_INTERVAL_MS`, `ScreenState::DASHBOARD`)
|
| Classes | PascalCase | `DisplayManager`, `IDisplayDriver` |
|
||||||
- Variables/functions: `camelCase` (e.g., `currentState`, `updateDisplay`)
|
| Constants/enums | SCREAMING_SNAKE | `POLL_INTERVAL_MS`, `ScreenState::DASHBOARD` |
|
||||||
- Member variables: prefix with `_` (e.g., `_screenWidth`, `_isConnected`)
|
| Variables/functions | camelCase | `currentState`, `updateDisplay` |
|
||||||
|
| Member variables | `_` prefix | `_screenWidth`, `_isConnected` |
|
||||||
|
|
||||||
### Types
|
### Types
|
||||||
|
|
||||||
- Arduino types: `int`, `uint8_t`, `uint16_t`, `size_t`
|
|
||||||
- Use fixed-width types for protocol/serialization (`uint8_t`, not `byte`)
|
- 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
|
- Use `size_t` for sizes and array indices
|
||||||
|
- Avoid `bool` for pin states — use `uint8_t` or `int`
|
||||||
|
|
||||||
### Imports Organization
|
### Imports Organization (in order)
|
||||||
|
1. Arduino core (`Arduino.h`)
|
||||||
- Arduino core headers first (`Arduino.h`)
|
2. Standard C/C++ (`<cstdint>`, `<String>`, `<vector>`)
|
||||||
- Standard C/C++ library (`<cstdint>`, `<String>`, `<vector>`)
|
3. Third-party libs (`TFT_eSPI.h`, `ArduinoJson.h`)
|
||||||
- Third-party libraries (e.g., `TFT_eSPI.h`, `ArduinoJson.h`)
|
4. Local project (`"Config.h"`, `"ScreenState.h"`)
|
||||||
- Local project headers (e.g., `"Config.h"`, `"ScreenState.h"`)
|
|
||||||
|
|
||||||
### Error Handling
|
### 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")`
|
## Architecture Patterns
|
||||||
- Use `Serial.printf()` for formatted debug output
|
|
||||||
- Return error codes, not exceptions (exceptions not available in Arduino)
|
|
||||||
- Log state transitions with `[STATE] → STATE` tags
|
|
||||||
|
|
||||||
### Class Design
|
### Display Driver Interface
|
||||||
|
- Pure virtual `IDisplayDriver` in shared `KlubhausCore`
|
||||||
- Pure virtual `IDisplayDriver` interface in shared library
|
- Each board implements concrete driver (`DisplayDriverTFT`, `DisplayDriverGFX`)
|
||||||
- Each board implements a concrete driver (e.g., `DisplayDriverTFT`, `DisplayDriverGFX`)
|
|
||||||
- `DisplayManager` delegates to `IDisplayDriver` — no display-lib coupling in shared code
|
- `DisplayManager` delegates to `IDisplayDriver` — no display-lib coupling in shared code
|
||||||
|
|
||||||
### Arduino Patterns
|
### Arduino Patterns
|
||||||
|
- `setup()` — call `begin()` on managers
|
||||||
- `setup()` runs once at boot — call `begin()` on managers
|
- `loop()` — call `update()` on managers
|
||||||
- `loop()` runs continuously — call `update()` on managers
|
|
||||||
- Use `millis()` for timing (not `delay()`)
|
- Use `millis()` for timing (not `delay()`)
|
||||||
- Serial console at 115200 baud for debug commands
|
- Serial baud: 115200
|
||||||
|
|
||||||
### Style System
|
### 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):
|
boards/{BOARD}/
|
||||||
|
├── {BOARD}.ino # Main sketch
|
||||||
|
├── board_config.h # Board-specific config (ESP32 boards)
|
||||||
|
├── secrets.h # WiFi credentials (optional, use env vars)
|
||||||
|
├── tft_user_setup.h # TFT_eSPI config (TFT boards)
|
||||||
|
└── DisplayDriver*.{h,cpp} # Concrete IDisplayDriver (ESP32 boards)
|
||||||
|
|
||||||
| Command | Action |
|
boards/uno-r4-wifi/ # Uno R4 WiFi (LED matrix display)
|
||||||
|---------|--------|
|
├── uno-r4-wifi.ino # Main sketch (standalone)
|
||||||
| `alert` | Trigger a test alert |
|
├── test_messages.sh # Test script to send ntfy messages
|
||||||
| `silence` | Silence current alert |
|
└── README.md # Quick start guide
|
||||||
| `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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The monitor daemon automatically starts after upload and is killed before upload to avoid serial port conflicts.
|
|
||||||
|
|
||||||
## Gotchas
|
## 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 `just 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
|
## Config Constants (Config.h)
|
||||||
# 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`:
|
|
||||||
|
|
||||||
| Constant | Default | Description |
|
| Constant | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `FW_VERSION` | "5.1" | Firmware version string |
|
| `FW_VERSION` | "5.1" | Firmware version |
|
||||||
| `POLL_INTERVAL_MS` | 15000 | How often to poll ntfy.sh for new messages |
|
| `POLL_INTERVAL_MS` | 15000 | ntfy.sh poll interval |
|
||||||
| `HEARTBEAT_INTERVAL_MS` | 300000 | NTP sync interval (5 min) |
|
| `ALERT_TIMEOUT_MS` | 120000 | Auto-clear alert |
|
||||||
| `ALERT_TIMEOUT_MS` | 120000 | Auto-clear alert after 2 min |
|
| `INACTIVITY_TIMEOUT_MS` | 30000 | Display off timeout |
|
||||||
| `INACTIVITY_TIMEOUT_MS` | 30000 | Turn off display after 30s of inactivity |
|
| `HOLD_TO_SILENCE_MS` | 3000 | Hold to silence |
|
||||||
| `HOLD_TO_SILENCE_MS` | 3000 | Hold duration to silence alert |
|
| `WIFI_CONNECT_TIMEOUT_MS` | 15000 | WiFi timeout |
|
||||||
| `LOOP_YIELD_MS` | 10 | Yield to prevent Task Watchdog (configurable) |
|
|
||||||
| `BOOT_GRACE_MS` | 5000 | Grace period at boot before polling starts |
|
|
||||||
| `SILENCE_DISPLAY_MS` | 10000 | How long to show silence confirmation |
|
|
||||||
| `WIFI_CONNECT_TIMEOUT_MS` | 15000 | WiFi connection timeout |
|
|
||||||
| `HTTP_TIMEOUT_MS` | 10000 | HTTP request timeout |
|
| `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
|
## Screen States
|
||||||
|
|
||||||
The device operates in these states (defined in `ScreenState.h`):
|
|
||||||
|
|
||||||
- **BOOT** — Initializing
|
- **BOOT** — Initializing
|
||||||
- **DASHBOARD** — Normal operation, showing status
|
- **DASHBOARD** — Normal operation
|
||||||
- **ALERT** — Doorbell ring detected, display on
|
- **ALERT** — Doorbell ring detected
|
||||||
- **OFF** — Display backlight off (but polling continues)
|
- **OFF** — Display backlight off (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 -->
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <KlubhausCore.h>
|
#include <KlubhausCore.h>
|
||||||
#include <TFT_eSPI.h>
|
#include <TFT_eSPI.h>
|
||||||
|
#include <Style.h>
|
||||||
extern DisplayManager display;
|
|
||||||
|
|
||||||
// ── Fonts ───────────────────────────────────────────────────
|
// ── Fonts ───────────────────────────────────────────────────
|
||||||
// TFT_eSPI built-in fonts for 320x480 display (scaled from 800x480)
|
// TFT_eSPI built-in fonts for 320x480 display (scaled from 800x480)
|
||||||
@@ -257,9 +256,11 @@ void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
|
|||||||
_tft.setCursor(dispW - wifiW - 10, 20);
|
_tft.setCursor(dispW - wifiW - 10, 20);
|
||||||
_tft.print(wifiText);
|
_tft.print(wifiText);
|
||||||
|
|
||||||
// Get tile layouts from library helper
|
// Calculate tile layouts using TileLayoutHelper
|
||||||
int tileCount = display.calculateDashboardLayouts(STYLE_HEADER_HEIGHT, STYLE_TILE_GAP);
|
TileLayout layouts[DASHBOARD_TILE_COUNT];
|
||||||
const TileLayout* layouts = display.getTileLayouts();
|
int gridCols, gridRows;
|
||||||
|
int tileCount = TileLayoutHelper::calculateLayouts(
|
||||||
|
dispW, dispH, STYLE_HEADER_HEIGHT, STYLE_TILE_GAP, layouts, &gridCols, &gridRows);
|
||||||
|
|
||||||
const char* tileLabels[] = { "Alert", "Silent", "Status", "Reboot" };
|
const char* tileLabels[] = { "Alert", "Silent", "Status", "Reboot" };
|
||||||
const uint16_t tileColors[] = { 0x0280, 0x0400, 0x0440, 0x0100 };
|
const uint16_t tileColors[] = { 0x0280, 0x0400, 0x0440, 0x0100 };
|
||||||
@@ -377,10 +378,12 @@ TouchEvent DisplayDriverTFT::readTouch() {
|
|||||||
uint16_t tx, ty;
|
uint16_t tx, ty;
|
||||||
uint8_t touched = _tft.getTouch(&tx, &ty, 100);
|
uint8_t touched = _tft.getTouch(&tx, &ty, 100);
|
||||||
|
|
||||||
// Debug: log touch state changes
|
// Debug: log touch state changes with transformed coords
|
||||||
if(touched != _touchWasPressed) {
|
if(touched != _touchWasPressed) {
|
||||||
Serial.printf("[TOUCH] raw touched=%d wasPressed=%d (x=%d,y=%d)\n", touched,
|
int tx_form = tx, ty_form = ty;
|
||||||
_touchWasPressed, tx, 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)
|
// Detect transitions (press/release)
|
||||||
@@ -422,7 +425,9 @@ TouchEvent DisplayDriverTFT::readTouch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 and adjust
|
||||||
|
// Touch panel: 320x480 (portrait), Display: 480x320 (landscape)
|
||||||
|
// This was the original working transform
|
||||||
int temp = *x;
|
int temp = *x;
|
||||||
*x = *y;
|
*x = *y;
|
||||||
*y = temp;
|
*y = temp;
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <KlubhausCore.h>
|
#include <KlubhausCore.h>
|
||||||
|
#include <Style.h>
|
||||||
|
|
||||||
// ── Globals ──
|
// ── Globals ──
|
||||||
static LGFX* _gfx = nullptr;
|
static LGFX* _gfx = nullptr;
|
||||||
extern DisplayManager display;
|
|
||||||
|
|
||||||
// ── Forward declarations ──
|
// ── Forward declarations ──
|
||||||
static void initDisplay();
|
static void initDisplay();
|
||||||
@@ -194,6 +194,15 @@ TouchEvent DisplayDriverGFX::readTouch() {
|
|||||||
int32_t x, y;
|
int32_t x, y;
|
||||||
bool pressed = _gfx->getTouch(&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
|
// Debounce: ignore repeated press events within debounce window after release
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
if(pressed && _touchBounced) {
|
if(pressed && _touchBounced) {
|
||||||
@@ -330,6 +339,13 @@ void DisplayDriverGFX::render(const ScreenState& state) {
|
|||||||
_needsRedraw = false;
|
_needsRedraw = false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case ScreenID::STATUS:
|
||||||
|
if(_needsRedraw) {
|
||||||
|
drawStatus(state);
|
||||||
|
_needsRedraw = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,13 +443,16 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
|
|||||||
},
|
},
|
||||||
safeWifi.w);
|
safeWifi.w);
|
||||||
|
|
||||||
// Get tile layouts from library helper
|
// Calculate tile layouts using TileLayoutHelper
|
||||||
int tileCount = display.calculateDashboardLayouts(STYLE_HEADER_HEIGHT, STYLE_TILE_GAP);
|
TileLayout layouts[DASHBOARD_TILE_COUNT];
|
||||||
const TileLayout* layouts = display.getTileLayouts();
|
int gridCols, gridRows;
|
||||||
|
int tileCount = TileLayoutHelper::calculateLayouts(
|
||||||
|
DISP_W, DISP_H, STYLE_HEADER_HEIGHT, STYLE_TILE_GAP, layouts, &gridCols, &gridRows);
|
||||||
|
|
||||||
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];
|
const TileLayout& lay = layouts[i];
|
||||||
int x = lay.x;
|
int x = lay.x;
|
||||||
int y = lay.y;
|
int y = lay.y;
|
||||||
@@ -441,7 +460,7 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
|
|||||||
int h = lay.h;
|
int h = lay.h;
|
||||||
|
|
||||||
// Tile background
|
// 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
|
// Tile border
|
||||||
_gfx->drawRoundRect(x, y, w, h, STYLE_TILE_RADIUS, STYLE_COLOR_FG);
|
_gfx->drawRoundRect(x, y, w, h, STYLE_TILE_RADIUS, STYLE_COLOR_FG);
|
||||||
@@ -449,11 +468,114 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
|
|||||||
// Tile label
|
// Tile label
|
||||||
_gfx->setTextColor(STYLE_COLOR_FG);
|
_gfx->setTextColor(STYLE_COLOR_FG);
|
||||||
setBodyFont();
|
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]);
|
_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) {
|
void DisplayDriverGFX::drawDebugTouch(int x, int y) {
|
||||||
if(!_gfx)
|
if(!_gfx)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ private:
|
|||||||
void drawBoot(const ScreenState& state);
|
void drawBoot(const ScreenState& state);
|
||||||
void drawAlert(const ScreenState& state);
|
void drawAlert(const ScreenState& state);
|
||||||
void drawDashboard(const ScreenState& state);
|
void drawDashboard(const ScreenState& state);
|
||||||
|
void drawStatus(const ScreenState& state);
|
||||||
|
|
||||||
// Touch handling
|
// Touch handling
|
||||||
TouchEvent _lastTouch = { false, false, 0, 0, -1, -1 };
|
TouchEvent _lastTouch = { false, false, 0, 0, -1, -1 };
|
||||||
|
|||||||
@@ -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/ttyUSB0"
|
PORT="/dev/ttyUSB0"
|
||||||
LIBS="--library ./vendor/esp32-s3-lcd-43/LovyanGFX"
|
LIBS="--library ./vendor/esp32-s3-lcd-43/LovyanGFX"
|
||||||
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM -DLGFX_USE_V1 -DLOCAL_SECRETS"
|
OPTS="-DDEBUG_MODE -DBOARD_HAS_PSRAM -DLOCAL_SECRETS"
|
||||||
|
|||||||
@@ -38,4 +38,7 @@ void loop() {
|
|||||||
|
|
||||||
// Serial console
|
// Serial console
|
||||||
logic.processSerial();
|
logic.processSerial();
|
||||||
|
|
||||||
|
// Yield to WiFi/BT stack
|
||||||
|
delay(LOOP_YIELD_MS);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Uno R4 WiFi Doorbell - Quick Start Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This is a simplified doorbell notification system for the Arduino Uno R4 WiFi that displays incoming ntfy.sh messages on the built-in 12x8 LED matrix.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Connects to WiFi and monitors a ntfy.sh topic
|
||||||
|
- Displays incoming messages as scrolling text on LED matrix
|
||||||
|
- Scrolls at 2 columns per second (configurable)
|
||||||
|
- Auto-silences after 60 seconds of display
|
||||||
|
- Simple state machine: CONNECTING → IDLE → DISPLAYING → SILENCED
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Configure WiFi Credentials
|
||||||
|
Edit `boards/uno-r4-wifi/uno-r4-wifi.ino`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#define WIFI_SSID "YOUR_WIFI_SSID"
|
||||||
|
#define WIFI_PASS "YOUR_WIFI_PASSWORD"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure ntfy.sh Topic
|
||||||
|
Edit `boards/uno-r4-wifi/uno-r4-wifi.ino`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#define NTFY_TOPIC "YOUR_TOPIC_HERE"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a free topic at https://ntfy.sh/ (e.g., "my-doorbell-12345")
|
||||||
|
|
||||||
|
### 3. Upload the Sketch
|
||||||
|
```bash
|
||||||
|
arduino-cli compile --fqbn arduino:renesas_uno:unor4wifi boards/uno-r4-wifi/uno-r4-wifi.ino
|
||||||
|
arduino-cli upload --fqbn arduino:renesas_uno:unor4wifi -p /dev/ttyACM0 boards/uno-r4-wifi/uno-r4-wifi.ino
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test the System
|
||||||
|
Open a terminal and run the test script:
|
||||||
|
```bash
|
||||||
|
chmod +x boards/uno-r4-wifi/test_messages.sh
|
||||||
|
./boards/uno-r4-wifi/test_messages.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually send a message:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://ntfy.sh/YOUR_TOPIC_HERE -d "Hello from ntfy!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### State Machine
|
||||||
|
1. **CONNECTING_WIFI** - Attempts to connect to WiFi
|
||||||
|
2. **IDLE** - Polls ntfy.sh every 5 seconds for new messages
|
||||||
|
3. **DISPLAYING_MESSAGE** - Shows scrolling text for 60 seconds
|
||||||
|
4. **SILENCED** - Clears matrix for 5 seconds before returning to IDLE
|
||||||
|
|
||||||
|
### LED Matrix Display
|
||||||
|
- Text scrolls from right to left across the 12x8 matrix
|
||||||
|
- Scroll rate: 2 columns per second (500ms per column)
|
||||||
|
- Characters are 5 pixels wide with 1 pixel spacing
|
||||||
|
- Only alphanumeric characters (A-Z, 0-9) and spaces are supported
|
||||||
|
|
||||||
|
### ntfy.sh Integration
|
||||||
|
- Uses HTTP GET with polling (`/json?poll=1`)
|
||||||
|
- Tracks last message ID to avoid duplicates
|
||||||
|
- Parses JSON response for message body
|
||||||
|
|
||||||
|
## Pinout Reference (Uno R4 WiFi)
|
||||||
|
- **D0-D13**: Standard digital pins
|
||||||
|
- **A0-A5**: Analog inputs
|
||||||
|
- **D26/D27**: Qwiic (I2C) connector (SCL/SDA)
|
||||||
|
- **LED Matrix**: Built-in 12x8 matrix (pins handled internally)
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Adjust Timing
|
||||||
|
Edit constants in `uno-r4-wifi.ino`:
|
||||||
|
```cpp
|
||||||
|
#define POLL_INTERVAL_MS 5000 // How often to check for messages
|
||||||
|
#define DISPLAY_DURATION_MS 60000 // How long to display each message
|
||||||
|
#define SCROLL_SPEED_MS 500 // Scroll speed (lower = faster)
|
||||||
|
#define SILENCE_DURATION_MS 5000 // Time between messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add More Characters
|
||||||
|
The font array in `drawCharToFrame()` can be extended to support additional characters.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### WiFi Won't Connect
|
||||||
|
- Check WiFi credentials
|
||||||
|
- Ensure 2.4GHz network (Uno R4 WiFi doesn't support 5GHz)
|
||||||
|
- Verify WiFi signal strength
|
||||||
|
|
||||||
|
### LED Matrix Not Showing Text
|
||||||
|
- Ensure `matrix.begin()` is called in setup()
|
||||||
|
- Check that `matrix.loadFrame()` is called with valid data
|
||||||
|
- Verify text contains only supported characters
|
||||||
|
|
||||||
|
### No Messages Received
|
||||||
|
- Check ntfy.sh topic name matches exactly
|
||||||
|
- Ensure the topic is publicly accessible
|
||||||
|
- Test with curl command manually
|
||||||
|
- Check serial monitor for error messages
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- Add support for more characters in the font
|
||||||
|
- Implement different message types (Alert, Silence, etc.)
|
||||||
|
- Add color coding with different LED patterns
|
||||||
|
- Integrate with the main doorbell codebase
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
FQBN=arduino:renesas_uno:unor4wifi
|
||||||
|
PORT=/dev/ttyACM0
|
||||||
|
LIBS=""
|
||||||
|
OPTS="-DDEBUG_MODE"
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test script for Uno R4 WiFi doorbell
|
||||||
|
# Sends test messages to ntfy.sh
|
||||||
|
|
||||||
|
# Update this with your actual topic
|
||||||
|
NTFY_TOPIC="ALERT_klubhaus_topic_test"
|
||||||
|
|
||||||
|
# Color codes for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Uno R4 WiFi Doorbell Test Script${NC}"
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
echo -e "Sending test messages to: https://ntfy.sh/$NTFY_TOPIC"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Send different test messages
|
||||||
|
echo "1. Sending simple alert..."
|
||||||
|
curl -s -X POST "https://ntfy.sh/$NTFY_TOPIC" \
|
||||||
|
-d "Doorbell pressed!" \
|
||||||
|
-H "Title: ALERT" \
|
||||||
|
-H "Priority: high"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo -e "\n2. Sending longer message..."
|
||||||
|
curl -s -X POST "https://ntfy.sh/$NTFY_TOPIC" \
|
||||||
|
-d "Front door visitor detected at main entrance" \
|
||||||
|
-H "Title: ALERT" \
|
||||||
|
-H "Priority: high"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo -e "\n3. Sending test with special characters..."
|
||||||
|
curl -s -X POST "https://ntfy.sh/$NTFY_TOPIC" \
|
||||||
|
-d "Package delivered! #12345" \
|
||||||
|
-H "Title: ALERT" \
|
||||||
|
-H "Priority: high"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo -e "\n4. Sending numeric message..."
|
||||||
|
curl -s -X POST "https://ntfy.sh/$NTFY_TOPIC" \
|
||||||
|
-d "Temperature: 72F" \
|
||||||
|
-H "Title: ALERT" \
|
||||||
|
-H "Priority: high"
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}Done! Check the LED matrix on your Uno R4 WiFi.${NC}"
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
//
|
||||||
|
// Klubhaus Doorbell - Uno R4 WiFi Edition
|
||||||
|
// Monitors ntfy.sh alert topic and displays messages on LED matrix
|
||||||
|
//
|
||||||
|
|
||||||
|
#include "Arduino_LED_Matrix.h"
|
||||||
|
#include "WiFiS3.h"
|
||||||
|
#include "ArduinoJson.h"
|
||||||
|
#include "RTC.h" // Add RTC library for time synchronization
|
||||||
|
|
||||||
|
// WiFi credentials
|
||||||
|
#define WIFI_SSID "iot-2GHz"
|
||||||
|
#define WIFI_PASS "lesson-greater"
|
||||||
|
|
||||||
|
// ntfy.sh topic
|
||||||
|
#define NTFY_TOPIC "ALERT_klubhaus_topic_test"
|
||||||
|
|
||||||
|
// Timing constants
|
||||||
|
#define POLL_INTERVAL_MS 5000 // Poll ntfy.sh every 5 seconds
|
||||||
|
#define DISPLAY_DURATION_MS 60000 // Show message for 60 seconds
|
||||||
|
#define SCROLL_SPEED_MS 100 // Scroll speed (lower = faster) - 4 columns/sec
|
||||||
|
#define SILENCE_DURATION_MS 5000 // Stay silenced for 5 seconds
|
||||||
|
#define GAUGE_MAX_AGE_MS 180000 // 3-minute window for gauge
|
||||||
|
#define GAUGE_BUCKET_SEC 15 // 15 seconds per column
|
||||||
|
#define MAX_GAUGE_ENTRIES 12 // Max message timestamps
|
||||||
|
|
||||||
|
long gaugeTimestamps[MAX_GAUGE_ENTRIES] = {0};
|
||||||
|
int gaugeCount = 0;
|
||||||
|
|
||||||
|
// LED matrix dimensions
|
||||||
|
#define MATRIX_COLS 12
|
||||||
|
#define MATRIX_ROWS 8
|
||||||
|
|
||||||
|
// Global objects
|
||||||
|
ArduinoLEDMatrix matrix;
|
||||||
|
WiFiClient client;
|
||||||
|
|
||||||
|
// State machine
|
||||||
|
enum AppState {
|
||||||
|
STATE_IDLE,
|
||||||
|
STATE_DISPLAYING_MESSAGE
|
||||||
|
};
|
||||||
|
AppState currentState = STATE_IDLE;
|
||||||
|
|
||||||
|
// Message state
|
||||||
|
String currentMessage = "";
|
||||||
|
String lastDisplayedMessage = ""; // Track last displayed message to avoid duplicates
|
||||||
|
String lastMessageId = ""; // Track last message ID for filtering
|
||||||
|
unsigned long messageStartTime = 0;
|
||||||
|
unsigned long lastScrollTime = 0;
|
||||||
|
unsigned long lastPollTime = 0;
|
||||||
|
unsigned long bootTime = 0;
|
||||||
|
unsigned long bootDelayEndTime = 0; // When to start processing messages
|
||||||
|
unsigned long lastDisplayedMessageTime = 0; // Unix timestamp of last displayed message
|
||||||
|
long currentMessageTime = 0; // Unix timestamp of current message being displayed
|
||||||
|
|
||||||
|
// Non-blocking poll state machine
|
||||||
|
enum PollState { POLL_IDLE, POLL_WAIT, POLL_CONNECT, POLL_SEND, POLL_READ, POLL_PARSE };
|
||||||
|
PollState pollState = POLL_IDLE;
|
||||||
|
unsigned long pollStepTime = 0;
|
||||||
|
String pollResponse = "";
|
||||||
|
long readyTime = 0; // Unix timestamp when device became ready (after NTP sync)
|
||||||
|
int queuedMessageCount = 0;
|
||||||
|
bool rtcSynced = false; // Track if RTC has been synced with NTP
|
||||||
|
|
||||||
|
// Scrolling state
|
||||||
|
int scrollOffset = 0;
|
||||||
|
int messageLength = 0;
|
||||||
|
|
||||||
|
// Frame buffer - 8 rows x 12 columns
|
||||||
|
uint8_t frameBuffer[MATRIX_ROWS][MATRIX_COLS] = {0};
|
||||||
|
|
||||||
|
int getBracketPeriod(long ageSec) {
|
||||||
|
if (ageSec < 15) return 150;
|
||||||
|
if (ageSec < 30) return 200;
|
||||||
|
if (ageSec < 45) return 250;
|
||||||
|
if (ageSec < 60) return 350;
|
||||||
|
if (ageSec < 75) return 500;
|
||||||
|
if (ageSec < 90) return 700;
|
||||||
|
if (ageSec < 105) return 1000;
|
||||||
|
if (ageSec < 120) return 1400;
|
||||||
|
if (ageSec < 135) return 1800;
|
||||||
|
if (ageSec < 150) return 2200;
|
||||||
|
if (ageSec < 165) return 2600;
|
||||||
|
return 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
int getBracketDuty(long ageSec) {
|
||||||
|
if (ageSec < 15) return 55;
|
||||||
|
if (ageSec < 30) return 48;
|
||||||
|
if (ageSec < 45) return 40;
|
||||||
|
if (ageSec < 60) return 35;
|
||||||
|
if (ageSec < 75) return 30;
|
||||||
|
if (ageSec < 90) return 25;
|
||||||
|
if (ageSec < 105) return 20;
|
||||||
|
if (ageSec < 120) return 16;
|
||||||
|
if (ageSec < 135) return 12;
|
||||||
|
if (ageSec < 150) return 10;
|
||||||
|
if (ageSec < 165) return 8;
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
void gaugeAddMessage(long unixTime) {
|
||||||
|
if (gaugeCount < MAX_GAUGE_ENTRIES) {
|
||||||
|
gaugeTimestamps[gaugeCount++] = unixTime;
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < MAX_GAUGE_ENTRIES - 1; i++) {
|
||||||
|
gaugeTimestamps[i] = gaugeTimestamps[i + 1];
|
||||||
|
}
|
||||||
|
gaugeTimestamps[MAX_GAUGE_ENTRIES - 1] = unixTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void gaugePruneOld(long nowUnix) {
|
||||||
|
int w = 0;
|
||||||
|
for (int i = 0; i < gaugeCount; i++) {
|
||||||
|
if (nowUnix - gaugeTimestamps[i] <= GAUGE_MAX_AGE_MS / 1000) {
|
||||||
|
gaugeTimestamps[w++] = gaugeTimestamps[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gaugeCount = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
void renderGauge(long nowUnix) {
|
||||||
|
unsigned long ms = millis();
|
||||||
|
for (int col = 0; col < MATRIX_COLS; col++) {
|
||||||
|
int bucketMin = col * GAUGE_BUCKET_SEC;
|
||||||
|
int bucketMax = bucketMin + GAUGE_BUCKET_SEC;
|
||||||
|
|
||||||
|
int messagesInBucket = 0;
|
||||||
|
long youngestAge = GAUGE_MAX_AGE_MS / 1000 + 1;
|
||||||
|
|
||||||
|
for (int i = 0; i < gaugeCount; i++) {
|
||||||
|
long age = nowUnix - gaugeTimestamps[i];
|
||||||
|
if (age >= bucketMin && age < bucketMax) {
|
||||||
|
messagesInBucket++;
|
||||||
|
if (age < youngestAge) youngestAge = age;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messagesInBucket == 0) {
|
||||||
|
frameBuffer[MATRIX_ROWS - 1][col] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int periodMs = getBracketPeriod(youngestAge);
|
||||||
|
int dutyPct = getBracketDuty(youngestAge);
|
||||||
|
|
||||||
|
int phase = ms % periodMs;
|
||||||
|
int onThreshold = periodMs * dutyPct / 100;
|
||||||
|
frameBuffer[MATRIX_ROWS - 1][col] = (phase < onThreshold) ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(1000);
|
||||||
|
Serial.println("\n=== Klubhaus Doorbell - Uno R4 WiFi ===");
|
||||||
|
Serial.print("Build token: ");
|
||||||
|
Serial.println(BUILD_TOKEN);
|
||||||
|
Serial.println("Starting up...");
|
||||||
|
|
||||||
|
// Initialize RTC
|
||||||
|
RTC.begin();
|
||||||
|
Serial.println("RTC initialized");
|
||||||
|
|
||||||
|
bootTime = millis();
|
||||||
|
bootDelayEndTime = bootTime + 10000; // Wait 10 seconds after boot before processing
|
||||||
|
Serial.print("Boot time: ");
|
||||||
|
Serial.print(bootTime);
|
||||||
|
Serial.print(", will start processing at: ");
|
||||||
|
Serial.println(bootDelayEndTime);
|
||||||
|
|
||||||
|
Serial.println("Initializing LED matrix...");
|
||||||
|
matrix.begin();
|
||||||
|
delay(100);
|
||||||
|
matrix.clear();
|
||||||
|
Serial.println("LED matrix initialized");
|
||||||
|
|
||||||
|
Serial.println("Displaying OK...");
|
||||||
|
displayStaticText("OK");
|
||||||
|
delay(500); // Display OK for 500ms
|
||||||
|
matrix.clear();
|
||||||
|
|
||||||
|
Serial.println("Starting WiFi connection...");
|
||||||
|
connectToWiFi();
|
||||||
|
|
||||||
|
// Sync RTC with NTP after WiFi connection
|
||||||
|
syncRTCWithNTP();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
unsigned long now = millis();
|
||||||
|
|
||||||
|
// Heartbeat LED
|
||||||
|
static unsigned long lastHeartbeat = 0;
|
||||||
|
static const unsigned long FADE_PERIOD_MS = 10000;
|
||||||
|
static const int MIN_BRIGHTNESS = 51;
|
||||||
|
static const int MAX_BRIGHTNESS = 204;
|
||||||
|
if (now - lastHeartbeat >= 50) {
|
||||||
|
float phase = (float)(now % FADE_PERIOD_MS) / (float)FADE_PERIOD_MS * 2.0 * PI;
|
||||||
|
analogWrite(LED_BUILTIN, MIN_BRIGHTNESS + (int)((MAX_BRIGHTNESS - MIN_BRIGHTNESS) / 2.0 * (1.0 + sin(phase))));
|
||||||
|
lastHeartbeat = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One RTC read per loop
|
||||||
|
RTCTime ct;
|
||||||
|
RTC.getTime(ct);
|
||||||
|
long nowUnix = ct.getUnixTime();
|
||||||
|
|
||||||
|
// Non-blocking poll — one step per loop
|
||||||
|
if (rtcSynced && now >= bootDelayEndTime) {
|
||||||
|
pollNtfy(nowUnix);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear frame, then always render gauge on row 7
|
||||||
|
memset(frameBuffer, 0, sizeof(frameBuffer));
|
||||||
|
gaugePruneOld(nowUnix);
|
||||||
|
renderGauge(nowUnix);
|
||||||
|
|
||||||
|
switch (currentState) {
|
||||||
|
case STATE_IDLE:
|
||||||
|
if (currentMessage.length() > 0) {
|
||||||
|
Serial.print("[DISPLAY] ");
|
||||||
|
Serial.println(currentMessage);
|
||||||
|
currentState = STATE_DISPLAYING_MESSAGE;
|
||||||
|
messageStartTime = now;
|
||||||
|
lastScrollTime = now;
|
||||||
|
scrollOffset = -MATRIX_COLS;
|
||||||
|
messageLength = currentMessage.length();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case STATE_DISPLAYING_MESSAGE:
|
||||||
|
// Always draw text at current scroll position (not just on tick)
|
||||||
|
drawScrollingText();
|
||||||
|
|
||||||
|
// Advance scroll on timer tick
|
||||||
|
if (now - lastScrollTime >= SCROLL_SPEED_MS) {
|
||||||
|
scrollOffset += 1;
|
||||||
|
if (scrollOffset > messageLength * 6 + MATRIX_COLS) {
|
||||||
|
scrollOffset = -MATRIX_COLS;
|
||||||
|
}
|
||||||
|
lastScrollTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - messageStartTime >= DISPLAY_DURATION_MS) {
|
||||||
|
lastDisplayedMessage = currentMessage;
|
||||||
|
lastDisplayedMessageTime = currentMessageTime;
|
||||||
|
currentState = STATE_IDLE;
|
||||||
|
currentMessage = "";
|
||||||
|
scrollOffset = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-blocking poll — start cycle on timer, advance one step per loop
|
||||||
|
static unsigned long lastPollStart = 0;
|
||||||
|
if (pollState == POLL_IDLE && now - lastPollStart >= POLL_INTERVAL_MS) {
|
||||||
|
if (rtcSynced && now >= bootDelayEndTime && WiFi.status() == WL_CONNECTED) {
|
||||||
|
pollState = POLL_CONNECT;
|
||||||
|
lastPollStart = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pollState != POLL_IDLE) {
|
||||||
|
pollNtfy(nowUnix);
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix.renderBitmap(frameBuffer, MATRIX_ROWS, MATRIX_COLS);
|
||||||
|
}
|
||||||
|
|
||||||
|
void connectToWiFi() {
|
||||||
|
Serial.print("Connecting to WiFi: ");
|
||||||
|
Serial.println(WIFI_SSID);
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_NO_MODULE) {
|
||||||
|
Serial.println("ERROR: Communication with WiFi module failed!");
|
||||||
|
displayStaticText("ERR");
|
||||||
|
while (true);
|
||||||
|
}
|
||||||
|
|
||||||
|
String fv = WiFi.firmwareVersion();
|
||||||
|
Serial.print("WiFi firmware version: ");
|
||||||
|
Serial.println(fv);
|
||||||
|
|
||||||
|
Serial.print("Attempting WiFi connection...");
|
||||||
|
WiFi.begin(WIFI_SSID, WIFI_PASS);
|
||||||
|
|
||||||
|
int attempts = 0;
|
||||||
|
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||||
|
Serial.print(".");
|
||||||
|
delay(1000);
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
|
Serial.println("\nERROR: Failed to connect to WiFi");
|
||||||
|
displayStaticText("WIFI ERR");
|
||||||
|
while (true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("\nWiFi connected!");
|
||||||
|
Serial.print("IP address: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
}
|
||||||
|
|
||||||
|
void syncRTCWithNTP() {
|
||||||
|
Serial.println("Syncing RTC with NTP...");
|
||||||
|
|
||||||
|
unsigned long epochTime = WiFi.getTime();
|
||||||
|
|
||||||
|
if (epochTime > 0) {
|
||||||
|
// Set timezone to UTC (no offset)
|
||||||
|
RTCTime timeToSet = RTCTime(epochTime);
|
||||||
|
RTC.setTime(timeToSet);
|
||||||
|
|
||||||
|
// Get current time to verify
|
||||||
|
RTCTime currentTime;
|
||||||
|
RTC.getTime(currentTime);
|
||||||
|
Serial.print("RTC synced to: ");
|
||||||
|
Serial.println(String(currentTime));
|
||||||
|
|
||||||
|
rtcSynced = true;
|
||||||
|
|
||||||
|
readyTime = epochTime; // Reject messages older than this boot
|
||||||
|
Serial.print("Ready time: ");
|
||||||
|
Serial.println((unsigned long)readyTime);
|
||||||
|
} else {
|
||||||
|
Serial.println("ERROR: Failed to get NTP time");
|
||||||
|
Serial.println("Make sure WiFi firmware version is at least 0.5.0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void pollNtfy(long nowUnix) {
|
||||||
|
unsigned long now = millis();
|
||||||
|
|
||||||
|
switch (pollState) {
|
||||||
|
case POLL_CONNECT:
|
||||||
|
if (client.connect("ntfy.sh", 80)) {
|
||||||
|
pollState = POLL_SEND;
|
||||||
|
} else {
|
||||||
|
pollState = POLL_IDLE;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case POLL_SEND: {
|
||||||
|
String url;
|
||||||
|
if (lastMessageId.length() > 0) {
|
||||||
|
url = "/" + String(NTFY_TOPIC) + "/json?poll=1&since=" + lastMessageId;
|
||||||
|
} else {
|
||||||
|
url = "/" + String(NTFY_TOPIC) + "/json?poll=1&since=latest";
|
||||||
|
}
|
||||||
|
client.print(String("GET ") + url + " HTTP/1.1\r\n" +
|
||||||
|
"Host: ntfy.sh\r\n" +
|
||||||
|
"Connection: close\r\n\r\n");
|
||||||
|
pollStepTime = now;
|
||||||
|
pollResponse = "";
|
||||||
|
pollState = POLL_READ;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case POLL_READ: {
|
||||||
|
// Non-blocking read — only consume what's available, max 20ms per call
|
||||||
|
unsigned long t = millis();
|
||||||
|
while (client.available() > 0 && (millis() - t) < 20) {
|
||||||
|
pollResponse += (char)client.read();
|
||||||
|
}
|
||||||
|
// Done when disconnected and we have content, or timeout
|
||||||
|
if (!client.connected() || (pollResponse.length() > 0 && now - pollStepTime > 3000)) {
|
||||||
|
client.stop();
|
||||||
|
pollState = POLL_PARSE;
|
||||||
|
} else if (now - pollStepTime > 8000) {
|
||||||
|
client.stop();
|
||||||
|
pollState = POLL_IDLE;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case POLL_PARSE: {
|
||||||
|
int js = pollResponse.indexOf("{");
|
||||||
|
if (js >= 0) {
|
||||||
|
StaticJsonDocument<1024> doc;
|
||||||
|
if (!deserializeJson(doc, pollResponse.substring(js))) {
|
||||||
|
processMessage(doc, nowUnix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pollState = POLL_IDLE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void processMessage(JsonDocument& doc, long nowUnix) {
|
||||||
|
const char* message = doc["message"];
|
||||||
|
const char* id = doc["id"];
|
||||||
|
long msgTime = doc["time"];
|
||||||
|
|
||||||
|
if (!message || strlen(message) == 0 || msgTime <= 0) return;
|
||||||
|
|
||||||
|
String msgStr = String(message);
|
||||||
|
|
||||||
|
if (msgStr == lastDisplayedMessage) {
|
||||||
|
if (id) lastMessageId = String(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastDisplayedMessageTime > 0 && msgTime <= lastDisplayedMessageTime) {
|
||||||
|
if (id) lastMessageId = String(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (readyTime > 0 && msgTime < readyTime) {
|
||||||
|
if (id) lastMessageId = String(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nowUnix - msgTime > 3600) {
|
||||||
|
if (id) lastMessageId = String(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.print("[ACCEPTED] ");
|
||||||
|
Serial.println(msgStr);
|
||||||
|
|
||||||
|
gaugePruneOld(nowUnix);
|
||||||
|
gaugeAddMessage(msgTime);
|
||||||
|
|
||||||
|
if (currentState == STATE_IDLE) {
|
||||||
|
currentMessage = msgStr;
|
||||||
|
currentMessageTime = msgTime;
|
||||||
|
}
|
||||||
|
if (id) lastMessageId = String(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawScrollingText() {
|
||||||
|
int charWidth = 5;
|
||||||
|
int charSpacing = 1;
|
||||||
|
|
||||||
|
for (int i = 0; i < messageLength; i++) {
|
||||||
|
char c = currentMessage[i];
|
||||||
|
int charX = i * (charWidth + charSpacing) - scrollOffset;
|
||||||
|
if (charX > -charWidth && charX < MATRIX_COLS) {
|
||||||
|
drawCharToFrame(c, charX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawCharToFrame(char c, int x) {
|
||||||
|
static const uint8_t font[][5] = {
|
||||||
|
// Uppercase A-Z (indices 0-25)
|
||||||
|
{0x7F, 0x41, 0x41, 0x41, 0x7F}, // 'A'
|
||||||
|
{0x7F, 0x49, 0x49, 0x49, 0x36}, // 'B'
|
||||||
|
{0x3E, 0x41, 0x41, 0x41, 0x22}, // 'C'
|
||||||
|
{0x7F, 0x41, 0x41, 0x22, 0x1C}, // 'D'
|
||||||
|
{0x7F, 0x49, 0x49, 0x49, 0x41}, // 'E'
|
||||||
|
{0x7F, 0x09, 0x09, 0x09, 0x01}, // 'F'
|
||||||
|
{0x3E, 0x41, 0x49, 0x49, 0x7A}, // 'G'
|
||||||
|
{0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'H'
|
||||||
|
{0x00, 0x41, 0x7F, 0x41, 0x00}, // 'I'
|
||||||
|
{0x20, 0x40, 0x41, 0x3F, 0x00}, // 'J'
|
||||||
|
{0x7F, 0x08, 0x14, 0x22, 0x41}, // 'K'
|
||||||
|
{0x7F, 0x40, 0x40, 0x40, 0x40}, // 'L'
|
||||||
|
{0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'M'
|
||||||
|
{0x7F, 0x04, 0x08, 0x10, 0x7F}, // 'N'
|
||||||
|
{0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'O'
|
||||||
|
{0x7F, 0x09, 0x09, 0x09, 0x06}, // 'P'
|
||||||
|
{0x3E, 0x41, 0x51, 0x21, 0x5E}, // 'Q'
|
||||||
|
{0x7F, 0x09, 0x19, 0x29, 0x46}, // 'R'
|
||||||
|
{0x46, 0x49, 0x49, 0x49, 0x31}, // 'S'
|
||||||
|
{0x01, 0x01, 0x7F, 0x01, 0x01}, // 'T'
|
||||||
|
{0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'U'
|
||||||
|
{0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'V'
|
||||||
|
{0x7F, 0x20, 0x18, 0x20, 0x7F}, // 'W'
|
||||||
|
{0x63, 0x14, 0x08, 0x14, 0x63}, // 'X'
|
||||||
|
{0x07, 0x08, 0x70, 0x08, 0x07}, // 'Y'
|
||||||
|
{0x61, 0x51, 0x49, 0x45, 0x43}, // 'Z'
|
||||||
|
// Lowercase a-z (indices 26-51)
|
||||||
|
{0x7F, 0x09, 0x09, 0x09, 0x7F}, // 'a'
|
||||||
|
{0x7F, 0x49, 0x49, 0x49, 0x36}, // 'b'
|
||||||
|
{0x3E, 0x41, 0x41, 0x41, 0x22}, // 'c'
|
||||||
|
{0x7F, 0x41, 0x41, 0x22, 0x1C}, // 'd'
|
||||||
|
{0x7F, 0x49, 0x49, 0x49, 0x41}, // 'e'
|
||||||
|
{0x7F, 0x09, 0x09, 0x09, 0x01}, // 'f'
|
||||||
|
{0x3E, 0x41, 0x49, 0x49, 0x7A}, // 'g'
|
||||||
|
{0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'h'
|
||||||
|
{0x00, 0x41, 0x7F, 0x41, 0x00}, // 'i'
|
||||||
|
{0x20, 0x40, 0x41, 0x3F, 0x00}, // 'j'
|
||||||
|
{0x7F, 0x08, 0x14, 0x22, 0x41}, // 'k'
|
||||||
|
{0x7F, 0x40, 0x40, 0x40, 0x40}, // 'l'
|
||||||
|
{0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'm'
|
||||||
|
{0x7F, 0x04, 0x08, 0x10, 0x7F}, // 'n'
|
||||||
|
{0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'o'
|
||||||
|
{0x7F, 0x09, 0x09, 0x09, 0x06}, // 'p'
|
||||||
|
{0x3E, 0x41, 0x51, 0x21, 0x5E}, // 'q'
|
||||||
|
{0x7F, 0x09, 0x19, 0x29, 0x46}, // 'r'
|
||||||
|
{0x46, 0x49, 0x49, 0x49, 0x31}, // 's'
|
||||||
|
{0x01, 0x01, 0x7F, 0x01, 0x01}, // 't'
|
||||||
|
{0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'u'
|
||||||
|
{0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'v'
|
||||||
|
{0x7F, 0x20, 0x18, 0x20, 0x7F}, // 'w'
|
||||||
|
{0x63, 0x14, 0x08, 0x14, 0x63}, // 'x'
|
||||||
|
{0x07, 0x08, 0x70, 0x08, 0x07}, // 'y'
|
||||||
|
{0x61, 0x51, 0x49, 0x45, 0x43}, // 'z'
|
||||||
|
// Numbers and space (indices 52-62)
|
||||||
|
{0x3E, 0x51, 0x49, 0x45, 0x3E}, // '0'
|
||||||
|
{0x00, 0x42, 0x7F, 0x40, 0x00}, // '1'
|
||||||
|
{0x42, 0x61, 0x51, 0x49, 0x46}, // '2'
|
||||||
|
{0x21, 0x41, 0x45, 0x4B, 0x31}, // '3'
|
||||||
|
{0x18, 0x14, 0x12, 0x7F, 0x10}, // '4'
|
||||||
|
{0x27, 0x45, 0x45, 0x45, 0x39}, // '5'
|
||||||
|
{0x3C, 0x4A, 0x49, 0x49, 0x30}, // '6'
|
||||||
|
{0x01, 0x71, 0x09, 0x05, 0x03}, // '7'
|
||||||
|
{0x36, 0x49, 0x49, 0x49, 0x36}, // '8'
|
||||||
|
{0x06, 0x49, 0x49, 0x29, 0x1E}, // '9'
|
||||||
|
{0x00, 0x00, 0x00, 0x00, 0x00}, // ' ' (space)
|
||||||
|
};
|
||||||
|
|
||||||
|
int fontIndex = -1;
|
||||||
|
|
||||||
|
if (c >= 'A' && c <= 'Z') {
|
||||||
|
fontIndex = c - 'A';
|
||||||
|
}
|
||||||
|
else if (c >= 'a' && c <= 'z') {
|
||||||
|
fontIndex = 26 + (c - 'a');
|
||||||
|
}
|
||||||
|
else if (c >= '0' && c <= '9') {
|
||||||
|
fontIndex = 52 + (c - '0');
|
||||||
|
}
|
||||||
|
else if (c == ' ') {
|
||||||
|
fontIndex = 62;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontIndex >= 0) {
|
||||||
|
for (int col = 0; col < 5; col++) {
|
||||||
|
uint8_t colData = font[fontIndex][col];
|
||||||
|
int drawX = x + col;
|
||||||
|
|
||||||
|
if (drawX >= 0 && drawX < MATRIX_COLS) {
|
||||||
|
for (int row = 0; row < MATRIX_ROWS; row++) {
|
||||||
|
if (colData & (1 << row)) {
|
||||||
|
frameBuffer[row][drawX] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void displayStaticText(String text) {
|
||||||
|
memset(frameBuffer, 0, sizeof(frameBuffer));
|
||||||
|
|
||||||
|
int textWidth = text.length() * 6;
|
||||||
|
int startX = max(0, (MATRIX_COLS - textWidth) / 2);
|
||||||
|
|
||||||
|
for (int i = 0; i < text.length(); i++) {
|
||||||
|
drawCharToFrame(text[i], startX + i * 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix.renderBitmap(frameBuffer, MATRIX_ROWS, MATRIX_COLS);
|
||||||
|
}
|
||||||
@@ -6,18 +6,28 @@
|
|||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
|
|
||||||
# Set default BOARD
|
# Default BOARD — overridable via env var or CLI: BOARD=uno-r4-wifi just compile
|
||||||
BOARD := "esp32-32e-4"
|
BOARD := env_var_or_default('BOARD', 'esp32-s3-lcd-43')
|
||||||
|
|
||||||
# Helper to source board config
|
# Helper to source board config
|
||||||
# just passes args as positional, so we source in each recipe
|
# just passes args as positional, so we source in each recipe
|
||||||
|
|
||||||
|
# Build token header for -include (generated fresh each compile)
|
||||||
|
BUILD_TOKEN_HEADER := "/tmp/doorbell-build-token.h"
|
||||||
|
|
||||||
# Compile firmware
|
# Compile firmware
|
||||||
compile:
|
compile:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
source ./boards/{{BOARD}}/board-config.sh
|
source ./boards/{{BOARD}}/board-config.sh
|
||||||
|
|
||||||
|
BUILD_TOKEN=$(git describe --always --dirty 2>/dev/null || echo "unknown")
|
||||||
|
echo "#pragma once" > /tmp/doorbell-build-token.h
|
||||||
|
echo "#define BUILD_TOKEN \"$BUILD_TOKEN\"" >> /tmp/doorbell-build-token.h
|
||||||
|
echo "Build token: $BUILD_TOKEN"
|
||||||
|
|
||||||
|
CFLAGS="$OPTS -include /tmp/doorbell-build-token.h"
|
||||||
|
|
||||||
# Only regenerate compile_commands.json if needed (board changed or first run)
|
# Only regenerate compile_commands.json if needed (board changed or first run)
|
||||||
NEED_GEN=false
|
NEED_GEN=false
|
||||||
if [ ! -f compile_commands.json ]; then
|
if [ ! -f compile_commands.json ]; then
|
||||||
@@ -28,7 +38,7 @@ compile:
|
|||||||
|
|
||||||
if [ "$NEED_GEN" = "true" ]; then
|
if [ "$NEED_GEN" = "true" ]; then
|
||||||
rm -rf /tmp/arduino-build
|
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}}
|
arduino-cli compile --only-compilation-database --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$CFLAGS" --build-path /tmp/arduino-build ./boards/{{BOARD}}
|
||||||
cp /tmp/arduino-build/compile_commands.json .
|
cp /tmp/arduino-build/compile_commands.json .
|
||||||
echo "{{BOARD}}" > .board-last
|
echo "{{BOARD}}" > .board-last
|
||||||
echo "[OK] Generated compile_commands.json for {{BOARD}}"
|
echo "[OK] Generated compile_commands.json for {{BOARD}}"
|
||||||
@@ -36,7 +46,7 @@ compile:
|
|||||||
echo "[SKIP] compile_commands.json already up to date for {{BOARD}}"
|
echo "[SKIP] compile_commands.json already up to date for {{BOARD}}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$OPTS" --warnings default ./boards/{{BOARD}}
|
arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$CFLAGS" --warnings default ./boards/{{BOARD}}
|
||||||
|
|
||||||
# Upload firmware
|
# Upload firmware
|
||||||
upload: kill
|
upload: kill
|
||||||
@@ -77,10 +87,16 @@ monitor-tio: kill
|
|||||||
TARGET="$(readlink -f "$PORT" 2>/dev/null || echo "$PORT")"
|
TARGET="$(readlink -f "$PORT" 2>/dev/null || echo "$PORT")"
|
||||||
echo "Run: tio --map INLCRNL $TARGET -e"
|
echo "Run: tio --map INLCRNL $TARGET -e"
|
||||||
|
|
||||||
# Monitor with JSON logging
|
# Monitor with JSON logging (runs in background)
|
||||||
monitor: kill
|
monitor: kill
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
python3 ./scripts/monitor-agent.py "{{BOARD}}" &
|
nohup python3 ./scripts/monitor-agent.py "{{BOARD}}" > /dev/null 2>&1 &
|
||||||
|
PID=$!
|
||||||
|
echo "Monitor agent started for {{BOARD}} (PID: $PID)"
|
||||||
|
echo " Log: /tmp/doorbell-{{BOARD}}.jsonl"
|
||||||
|
echo " State: /tmp/doorbell-{{BOARD}}-state.json"
|
||||||
|
echo " Cmd: echo 'dashboard' > /tmp/doorbell-{{BOARD}}-cmd.fifo"
|
||||||
|
echo " Stop: kill $PID"
|
||||||
|
|
||||||
# Tail colored logs
|
# Tail colored logs
|
||||||
watch:
|
watch:
|
||||||
@@ -124,26 +140,31 @@ install-libs-shared:
|
|||||||
# Install all libraries
|
# Install all libraries
|
||||||
install: install-libs-shared
|
install: install-libs-shared
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
if [ -f ./boards/{{BOARD}}/install.sh ]; then
|
||||||
./boards/{{BOARD}}/install.sh
|
./boards/{{BOARD}}/install.sh
|
||||||
arduino-cli core install esp32:esp32
|
arduino-cli core install esp32:esp32
|
||||||
|
else
|
||||||
|
echo "[SKIP] No board-specific install script for {{BOARD}}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Full build-compile-monitor loop
|
||||||
|
full: compile upload monitor
|
||||||
|
@just watch
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
rm -rf vendor/
|
rm -rf vendor/
|
||||||
rm -rf .cache/
|
rm -rf .cache/
|
||||||
rm -rf boards/esp32-32e/build
|
rm -rf boards/*/build/
|
||||||
rm -rf boards/esp32-32e-4/build
|
|
||||||
rm -rf boards/esp32-s3-lcd-43/build
|
|
||||||
rm -f .board-last
|
rm -f .board-last
|
||||||
|
rm -f /tmp/doorbell-build-token.h
|
||||||
echo "[OK] Build artifacts cleaned"
|
echo "[OK] Build artifacts cleaned"
|
||||||
|
|
||||||
# Clean temporary files (monitor logs, FIFOs, state files)
|
# Clean temporary files (monitor logs, FIFOs, state files)
|
||||||
clean-temp:
|
clean-temp:
|
||||||
#!/usr/bin/env bash
|
#!/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-*.jsonl /tmp/doorbell-*-state.json /tmp/doorbell-*-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
|
rm -f .board-last
|
||||||
echo "[OK] Temp files cleaned"
|
echo "[OK] Temp files cleaned"
|
||||||
|
|
||||||
@@ -176,10 +197,29 @@ format:
|
|||||||
boards/esp32-s3-lcd-43/*.cpp \
|
boards/esp32-s3-lcd-43/*.cpp \
|
||||||
boards/esp32-s3-lcd-43/*.h \
|
boards/esp32-s3-lcd-43/*.h \
|
||||||
boards/esp32-s3-lcd-43/*.ino \
|
boards/esp32-s3-lcd-43/*.ino \
|
||||||
|
boards/uno-r4-wifi/*.cpp \
|
||||||
|
boards/uno-r4-wifi/*.h \
|
||||||
|
boards/uno-r4-wifi/*.ino \
|
||||||
libraries/KlubhausCore/src/*.cpp \
|
libraries/KlubhausCore/src/*.cpp \
|
||||||
libraries/KlubhausCore/src/*.h \
|
libraries/KlubhausCore/src/*.h \
|
||||||
libraries/KlubhausCore/*.properties
|
libraries/KlubhausCore/*.properties
|
||||||
|
|
||||||
|
# Generate compile_commands.json for LSP (no full compile)
|
||||||
|
gen-compile-commands:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
source ./boards/{{BOARD}}/board-config.sh
|
||||||
|
BUILD_TOKEN=$(git describe --always --dirty 2>/dev/null || echo "unknown")
|
||||||
|
echo "#pragma once" > /tmp/doorbell-build-token.h
|
||||||
|
echo "#define BUILD_TOKEN \"$BUILD_TOKEN\"" >> /tmp/doorbell-build-token.h
|
||||||
|
CFLAGS="$OPTS -include /tmp/doorbell-build-token.h"
|
||||||
|
rm -rf /tmp/arduino-build
|
||||||
|
arduino-cli compile --only-compilation-database --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$CFLAGS" --build-path /tmp/arduino-build ./boards/{{BOARD}}
|
||||||
|
cp /tmp/arduino-build/compile_commands.json .
|
||||||
|
echo "{{BOARD}}" > .board-last
|
||||||
|
rm -f /tmp/doorbell-build-token.h
|
||||||
|
echo "[OK] Generated compile_commands.json for {{BOARD}}"
|
||||||
|
|
||||||
# Generate crush config
|
# Generate crush config
|
||||||
gen-crush-config:
|
gen-crush-config:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#define FW_VERSION "5.1"
|
#define FW_VERSION "6.0"
|
||||||
|
|
||||||
|
#ifndef BUILD_TOKEN
|
||||||
|
#define BUILD_TOKEN "dev"
|
||||||
|
#endif
|
||||||
|
|
||||||
// ── ntfy.sh ──
|
// ── ntfy.sh ──
|
||||||
#define NTFY_SERVER "ntfy.sh"
|
#define NTFY_SERVER "ntfy.sh"
|
||||||
|
|||||||
@@ -4,125 +4,6 @@
|
|||||||
#include "ScreenState.h"
|
#include "ScreenState.h"
|
||||||
#include "Style.h"
|
#include "Style.h"
|
||||||
|
|
||||||
#include <cmath>
|
|
||||||
|
|
||||||
/// Layout helper for dashboard tiles - computes positions based on constraints
|
|
||||||
class TileLayoutHelper {
|
|
||||||
public:
|
|
||||||
/// Calculate tile layouts for the given display dimensions
|
|
||||||
/// Returns array of TileLayout (must have capacity for DASHBOARD_TILE_COUNT)
|
|
||||||
/// Returns the number of columns and rows used
|
|
||||||
static int calculateLayouts(int displayW, int displayH, int headerH, int margin,
|
|
||||||
TileLayout* outLayouts, int* outCols, int* outRows) {
|
|
||||||
int contentH = displayH - headerH;
|
|
||||||
int tileCount = DASHBOARD_TILE_COUNT;
|
|
||||||
|
|
||||||
// Determine base grid based on display aspect ratio
|
|
||||||
int cols, rows;
|
|
||||||
calculateGrid(tileCount, displayW, contentH, &cols, &rows);
|
|
||||||
|
|
||||||
// Calculate base cell sizes
|
|
||||||
int cellW = displayW / cols;
|
|
||||||
int cellH = contentH / rows;
|
|
||||||
|
|
||||||
// Simple first-fit: place tiles in order, respecting min sizes
|
|
||||||
// For more complex layouts, tiles could specify preferred positions
|
|
||||||
for(int i = 0; i < tileCount; i++) {
|
|
||||||
const DashboardTile& tile = DASHBOARD_TILES[i];
|
|
||||||
int tileCols = tile.constraint.minCols;
|
|
||||||
int tileRows = tile.constraint.minRows;
|
|
||||||
|
|
||||||
// Find next available position
|
|
||||||
int col = 0, row = 0;
|
|
||||||
findNextPosition(outLayouts, i, cols, rows, &col, &row);
|
|
||||||
|
|
||||||
// Ensure tile fits within grid
|
|
||||||
if(col + tileCols > cols)
|
|
||||||
tileCols = cols - col;
|
|
||||||
if(row + tileRows > rows)
|
|
||||||
tileRows = rows - row;
|
|
||||||
|
|
||||||
// Calculate pixel position
|
|
||||||
int x = col * cellW + margin;
|
|
||||||
int y = headerH + row * cellH + margin;
|
|
||||||
int w = tileCols * cellW - 2 * margin;
|
|
||||||
int h = tileRows * cellH - 2 * margin;
|
|
||||||
|
|
||||||
outLayouts[i] = { x, y, w, h, col, row, tileCols, tileRows };
|
|
||||||
}
|
|
||||||
|
|
||||||
*outCols = cols;
|
|
||||||
*outRows = rows;
|
|
||||||
return tileCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Start with simple square-ish grid
|
|
||||||
int cols = (int)std::sqrt(tileCount * aspectRatio);
|
|
||||||
if(cols < 1)
|
|
||||||
cols = 1;
|
|
||||||
if(cols > tileCount)
|
|
||||||
cols = tileCount;
|
|
||||||
|
|
||||||
int rows = (tileCount + cols - 1) / cols;
|
|
||||||
|
|
||||||
// For wide displays (landscape), prefer more columns
|
|
||||||
if(aspectRatio > 1.5f && tileCount <= 6) {
|
|
||||||
cols = tileCount;
|
|
||||||
rows = 1;
|
|
||||||
}
|
|
||||||
// For tall displays (portrait), prefer more rows
|
|
||||||
else if(aspectRatio < 0.8f && tileCount <= 6) {
|
|
||||||
rows = tileCount;
|
|
||||||
cols = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
*outCols = cols;
|
|
||||||
*outRows = rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find next available grid position
|
|
||||||
static void findNextPosition(const TileLayout* layouts, int count, int gridCols, int gridRows,
|
|
||||||
int* outCol, int* outRow) {
|
|
||||||
// Simple: find first empty cell
|
|
||||||
// Could be enhanced to pack tightly based on tile sizes
|
|
||||||
for(int r = 0; r < gridRows; r++) {
|
|
||||||
for(int c = 0; c < gridCols; c++) {
|
|
||||||
bool occupied = false;
|
|
||||||
for(int i = 0; i < count; i++) {
|
|
||||||
if(layouts[i].col <= c && c < layouts[i].col + layouts[i].cols
|
|
||||||
&& layouts[i].row <= r && r < layouts[i].row + layouts[i].rows) {
|
|
||||||
occupied = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(!occupied) {
|
|
||||||
*outCol = c;
|
|
||||||
*outRow = r;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: just use count position
|
|
||||||
*outCol = count % gridCols;
|
|
||||||
*outRow = count / gridCols;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class DisplayManager {
|
class DisplayManager {
|
||||||
public:
|
public:
|
||||||
DisplayManager(IDisplayDriver* drv)
|
DisplayManager(IDisplayDriver* drv)
|
||||||
@@ -166,7 +47,7 @@ public:
|
|||||||
_drv->transformTouch(&dx, &dy);
|
_drv->transformTouch(&dx, &dy);
|
||||||
_drv->transformTouch(&cx, &cy);
|
_drv->transformTouch(&cx, &cy);
|
||||||
|
|
||||||
int headerH = 30;
|
int headerH = _headerHeight;
|
||||||
int cellW = _drv->width() / _gridCols;
|
int cellW = _drv->width() / _gridCols;
|
||||||
int cellH = (_drv->height() - headerH) / _gridRows;
|
int cellH = (_drv->height() - headerH) / _gridRows;
|
||||||
|
|
||||||
@@ -182,14 +63,42 @@ public:
|
|||||||
int height() { return _drv ? _drv->height() : 0; }
|
int height() { return _drv ? _drv->height() : 0; }
|
||||||
|
|
||||||
/// Handle dashboard touch - returns action for tapped tile, or NONE
|
/// 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 {
|
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) {
|
if(hr.type == UIElementType::TILE && hr.index >= 0 && hr.index < DASHBOARD_TILE_COUNT) {
|
||||||
return DASHBOARD_TILES[hr.index].action;
|
return DASHBOARD_TILES[hr.index].action;
|
||||||
}
|
}
|
||||||
return TileAction::NONE;
|
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
|
/// Perform hit test at coordinates - returns element type, index, and bounds
|
||||||
HitResult hitTest(int x, int y) const {
|
HitResult hitTest(int x, int y) const {
|
||||||
if(!_drv)
|
if(!_drv)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ void DoorbellLogic::begin(
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
Serial.println(F("========================================"));
|
Serial.println(F("========================================"));
|
||||||
Serial.printf(" KLUBHAUS ALERT v%s — %s\n", _version, _board);
|
Serial.printf(" KLUBHAUS ALERT v%s (%s) — %s\n", _version, BUILD_TOKEN, _board);
|
||||||
if(_debug)
|
if(_debug)
|
||||||
Serial.println(F(" *** DEBUG MODE — _test topics ***"));
|
Serial.println(F(" *** DEBUG MODE — _test topics ***"));
|
||||||
Serial.println(F("========================================\n"));
|
Serial.println(F("========================================\n"));
|
||||||
@@ -312,7 +312,7 @@ void DoorbellLogic::onSerialCommand(const String& cmd) {
|
|||||||
if(comma > 0) {
|
if(comma > 0) {
|
||||||
int x = args.substring(0, comma).toInt();
|
int x = args.substring(0, comma).toInt();
|
||||||
int y = args.substring(comma + 1).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,
|
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);
|
(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(),
|
Serial.printf("[Display] w:%d h:%d headerH:%d\n", _display->width(), _display->height(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
struct Layout {
|
struct Layout {
|
||||||
uint16_t x, y, w, h;
|
uint16_t x, y, w, h;
|
||||||
@@ -227,3 +228,120 @@ public:
|
|||||||
|
|
||||||
static Rect icon(int16_t x, int16_t y, int16_t size) { return Rect(x, y, size, size); }
|
static Rect icon(int16_t x, int16_t y, int16_t size) { return Rect(x, y, size, size); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Layout helper for dashboard tiles - computes positions based on constraints
|
||||||
|
class TileLayoutHelper {
|
||||||
|
public:
|
||||||
|
/// Calculate tile layouts for the given display dimensions
|
||||||
|
/// Returns array of TileLayout (must have capacity for DASHBOARD_TILE_COUNT)
|
||||||
|
/// Returns the number of columns and rows used
|
||||||
|
static int calculateLayouts(int displayW, int displayH, int headerH, int margin,
|
||||||
|
TileLayout* outLayouts, int* outCols, int* outRows) {
|
||||||
|
int contentH = displayH - headerH;
|
||||||
|
int tileCount = DASHBOARD_TILE_COUNT;
|
||||||
|
|
||||||
|
// Determine base grid based on display aspect ratio
|
||||||
|
int cols, rows;
|
||||||
|
calculateGrid(tileCount, displayW, contentH, &cols, &rows);
|
||||||
|
|
||||||
|
// Calculate base cell sizes
|
||||||
|
int cellW = displayW / cols;
|
||||||
|
int cellH = contentH / rows;
|
||||||
|
|
||||||
|
// Simple first-fit: place tiles in order, respecting min sizes
|
||||||
|
// For more complex layouts, tiles could specify preferred positions
|
||||||
|
for(int i = 0; i < tileCount; i++) {
|
||||||
|
const DashboardTile& tile = DASHBOARD_TILES[i];
|
||||||
|
int tileCols = tile.constraint.minCols;
|
||||||
|
int tileRows = tile.constraint.minRows;
|
||||||
|
|
||||||
|
// Find next available position
|
||||||
|
int col = 0, row = 0;
|
||||||
|
findNextPosition(outLayouts, i, cols, rows, &col, &row);
|
||||||
|
|
||||||
|
// Ensure tile fits within grid
|
||||||
|
if(col + tileCols > cols)
|
||||||
|
tileCols = cols - col;
|
||||||
|
if(row + tileRows > rows)
|
||||||
|
tileRows = rows - row;
|
||||||
|
|
||||||
|
// Calculate pixel position
|
||||||
|
int x = col * cellW + margin;
|
||||||
|
int y = headerH + row * cellH + margin;
|
||||||
|
int w = tileCols * cellW - 2 * margin;
|
||||||
|
int h = tileRows * cellH - 2 * margin;
|
||||||
|
|
||||||
|
outLayouts[i] = { x, y, w, h, col, row, tileCols, tileRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
*outCols = cols;
|
||||||
|
*outRows = rows;
|
||||||
|
return tileCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Start with simple square-ish grid
|
||||||
|
int cols = (int)std::sqrt(tileCount * aspectRatio);
|
||||||
|
if(cols < 1)
|
||||||
|
cols = 1;
|
||||||
|
if(cols > tileCount)
|
||||||
|
cols = tileCount;
|
||||||
|
|
||||||
|
int rows = (tileCount + cols - 1) / cols;
|
||||||
|
|
||||||
|
// For wide displays (landscape), prefer more columns
|
||||||
|
if(aspectRatio > 1.5f && tileCount <= 6) {
|
||||||
|
cols = tileCount;
|
||||||
|
rows = 1;
|
||||||
|
}
|
||||||
|
// For tall displays (portrait), prefer more rows
|
||||||
|
else if(aspectRatio < 0.8f && tileCount <= 6) {
|
||||||
|
rows = tileCount;
|
||||||
|
cols = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*outCols = cols;
|
||||||
|
*outRows = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find next available grid position
|
||||||
|
static void findNextPosition(const TileLayout* layouts, int count, int gridCols, int gridRows,
|
||||||
|
int* outCol, int* outRow) {
|
||||||
|
// Simple: find first empty cell
|
||||||
|
// Could be enhanced to pack tightly based on tile sizes
|
||||||
|
for(int r = 0; r < gridRows; r++) {
|
||||||
|
for(int c = 0; c < gridCols; c++) {
|
||||||
|
bool occupied = false;
|
||||||
|
for(int i = 0; i < count; i++) {
|
||||||
|
if(layouts[i].col <= c && c < layouts[i].col + layouts[i].cols
|
||||||
|
&& layouts[i].row <= r && r < layouts[i].row + layouts[i].rows) {
|
||||||
|
occupied = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!occupied) {
|
||||||
|
*outCol = c;
|
||||||
|
*outRow = r;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: just use count position
|
||||||
|
*outCol = count % gridCols;
|
||||||
|
*outRow = count / gridCols;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,222 +1,14 @@
|
|||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
# Klubhaus Doorbell — Multi-Target Build Harness
|
# Klubhaus Doorbell — Tool Versions
|
||||||
|
#
|
||||||
|
# Tasks are managed by just (see justfile), not mise.
|
||||||
|
# Run `just --list` for available commands.
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# Required tools
|
|
||||||
[tools]
|
[tools]
|
||||||
hk = "latest"
|
hk = "latest"
|
||||||
|
just = "latest"
|
||||||
pkl = "latest"
|
pkl = "latest"
|
||||||
# screen = "latest" # Install via system package manager (brew install screen / apt install screen)
|
|
||||||
# zellij = "latest" # Install manually if needed (brew install zellij)
|
|
||||||
|
|
||||||
# Usage:
|
|
||||||
# BOARD=esp32-32e-4 mise run compile # compile
|
|
||||||
# BOARD=esp32-32e-4 mise run upload # upload
|
|
||||||
# BOARD=esp32-32e-4 mise run monitor # monitor
|
|
||||||
# BOARD=esp32-32e-4 mise run monitor-screen # shows tio command to run manually
|
|
||||||
#
|
|
||||||
# Valid BOARD: esp32-32e, esp32-32e-4, esp32-s3-lcd-43
|
|
||||||
# Board config lives in: boards/{BOARD}/board-config.sh
|
|
||||||
|
|
||||||
[tasks.compile]
|
|
||||||
description = "Compile (uses BOARD env var)"
|
|
||||||
depends = ["gen-compile-commands"]
|
|
||||||
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 = ["kill"]
|
|
||||||
run = '''
|
|
||||||
source ./boards/$BOARD/board-config.sh
|
|
||||||
arduino-cli upload --fqbn "$FQBN" --port "$PORT" ./boards/$BOARD
|
|
||||||
'''
|
|
||||||
|
|
||||||
[tasks.monitor-raw]
|
|
||||||
depends = ["kill"]
|
|
||||||
run = """
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.monitor-tio]
|
|
||||||
description = "Show tio command to run in separate terminal"
|
|
||||||
depends = ["kill"]
|
|
||||||
run = """
|
|
||||||
source ./boards/$BOARD/board-config.sh
|
|
||||||
|
|
||||||
PORT="${PORT:-$PORT}"
|
|
||||||
TARGET="$(readlink -f "$PORT" 2>/dev/null || echo "$PORT")"
|
|
||||||
tio --map INLCRNL "$TARGET" -e
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.kill]
|
|
||||||
description = "Kill any process using the serial port for BOARD"
|
|
||||||
run = '''
|
|
||||||
set +e
|
|
||||||
|
|
||||||
# Load board config
|
|
||||||
source ./boards/$BOARD/board-config.sh
|
|
||||||
PORT="${PORT:-$PORT}"
|
|
||||||
|
|
||||||
# Kill any processes using the serial port
|
|
||||||
echo "Killing processes on $PORT..."
|
|
||||||
fuser -k "$PORT" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Kill monitor-agent Python processes for this board
|
|
||||||
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
|
|
||||||
|
|
||||||
# Clean up any stale lockfiles (legacy)
|
|
||||||
rm -f "/tmp/doorbell-${BOARD}.lock" 2>/dev/null || true
|
|
||||||
|
|
||||||
sleep 1
|
|
||||||
echo "[OK] Killed processes for $BOARD"
|
|
||||||
exit 0
|
|
||||||
'''
|
|
||||||
|
|
||||||
[tasks.monitor]
|
|
||||||
description = "Monitor agent with JSON log + command pipe (Python-based)"
|
|
||||||
depends = ["kill"]
|
|
||||||
run = """
|
|
||||||
python3 ./scripts/monitor-agent.py "$BOARD" &
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.log-tail]
|
|
||||||
description = "Tail the logs with human-readable formatting"
|
|
||||||
run = '''
|
|
||||||
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
|
|
||||||
'''
|
|
||||||
|
|
||||||
[tasks.cmd]
|
|
||||||
description = "Send command to device (COMMAND=dashboard mise run cmd)"
|
|
||||||
run = """
|
|
||||||
echo -n "$COMMAND" > "/tmp/doorbell-$BOARD-cmd.fifo"
|
|
||||||
echo "[SENT] $COMMAND"
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.state]
|
|
||||||
description = "Show current device state"
|
|
||||||
run = """
|
|
||||||
cat "/tmp/doorbell-$BOARD-state.json"
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.install-libs-shared]
|
|
||||||
description = "Install shared Arduino libraries"
|
|
||||||
run = """
|
|
||||||
./scripts/install-shared.sh
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.install]
|
|
||||||
description = "Install libraries for BOARD (shared + board-specific)"
|
|
||||||
run = """
|
|
||||||
./scripts/install-shared.sh
|
|
||||||
./boards/$BOARD/install.sh
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.detect]
|
|
||||||
description = "Detect connected doorbell board"
|
|
||||||
run = "bash ./scripts/detect-device.sh"
|
|
||||||
|
|
||||||
# Convenience
|
|
||||||
|
|
||||||
[tasks.clean]
|
|
||||||
description = "Remove build artifacts"
|
|
||||||
run = """
|
|
||||||
rm -rf vendor/
|
|
||||||
rm -rf boards/esp32-32e/build
|
|
||||||
rm -rf boards/esp32-32e-4/build
|
|
||||||
rm -rf boards/esp32-s3-lcd-43/build
|
|
||||||
echo "[OK] Build artifacts cleaned"
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.arduino-clean]
|
|
||||||
description = "Clean Arduino CLI cache (staging + packages)"
|
|
||||||
run = """
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.format]
|
|
||||||
run = """
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
[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" --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 "[OK] Generated compile_commands.json for $BOARD"
|
|
||||||
"""
|
|
||||||
|
|
||||||
[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"
|
|
||||||
"""
|
|
||||||
|
|
||||||
[tasks.snap]
|
|
||||||
run = "git add .; lumen draft | git commit -F - "
|
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
BOARD = "esp32-32e-4"
|
BOARD = "uno-r4-wifi"
|
||||||
|
|||||||
@@ -8,11 +8,22 @@ import time
|
|||||||
import threading
|
import threading
|
||||||
import serial
|
import serial
|
||||||
import select
|
import select
|
||||||
|
import re
|
||||||
|
|
||||||
BOARD = sys.argv[1] if len(sys.argv) > 1 else "esp32-32e-4"
|
BOARD = sys.argv[1] if len(sys.argv) > 1 else "esp32-32e-4"
|
||||||
|
|
||||||
# Load board config
|
# Load board config - parse shell script format
|
||||||
exec(open(f"./boards/{BOARD}/board-config.sh").read().replace("export ", ""))
|
board_config_path = f"./boards/{BOARD}/board-config.sh"
|
||||||
|
PORT = "/dev/ttyACM0" # Default
|
||||||
|
try:
|
||||||
|
with open(board_config_path, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('PORT='):
|
||||||
|
PORT = line.split('=', 1)[1].strip()
|
||||||
|
break
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Warning: Board config not found: {board_config_path}")
|
||||||
|
|
||||||
SERIAL_PORT = os.environ.get("PORT", PORT)
|
SERIAL_PORT = os.environ.get("PORT", PORT)
|
||||||
LOGFILE = f"/tmp/doorbell-{BOARD}.jsonl"
|
LOGFILE = f"/tmp/doorbell-{BOARD}.jsonl"
|
||||||
|
|||||||
Reference in New Issue
Block a user