Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df0f440dcb | |||
| 3af958b69f | |||
| 6f9d2912d3 | |||
| ece874a8ed | |||
| 14781b8c07 |
@@ -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
|
||||||
|
|||||||
@@ -5,35 +5,40 @@ Multi-target Arduino/ESP32 doorbell alert system using ntfy.sh. Default BOARD: `
|
|||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# All tasks use just (see justfile). Default BOARD: esp32-s3-lcd-43
|
||||||
|
|
||||||
# Set target board
|
# Set target board
|
||||||
mise set BOARD=esp32-32e-4 # ESP32-32E 4" (320x480 ST7796)
|
just BOARD=esp32-32e-4 compile # ESP32-32E 4" (320x480 ST7796)
|
||||||
mise set BOARD=esp32-32e # ESP32-32E 3.5" (320x240 ILI9341)
|
just BOARD=esp32-32e compile # ESP32-32E 3.5" (320x240 ILI9341)
|
||||||
mise set BOARD=esp32-s3-lcd-43 # ESP32-S3-Touch-LCD-4.3 (800x480 RGB)
|
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
|
# Core commands
|
||||||
mise run compile # compile for current BOARD
|
just compile # compile for current BOARD
|
||||||
mise run upload # upload (auto-kills monitor first)
|
just upload # upload (auto-kills monitor first)
|
||||||
mise run monitor # start JSON monitor daemon
|
just monitor # start JSON monitor daemon (background)
|
||||||
mise run kill # kill monitor/release serial port
|
just kill # kill monitor/release serial port
|
||||||
|
just full # compile + upload + monitor + log-tail (full feedback loop)
|
||||||
|
just watch # tail colored logs
|
||||||
|
|
||||||
# Formatting & cleanup
|
# Formatting & cleanup
|
||||||
mise run format # format code with clang-format
|
just format # format code with clang-format
|
||||||
mise run clean # remove build artifacts
|
just clean # remove build artifacts
|
||||||
|
just clean-temp # remove monitor logs/FIFOs/state files
|
||||||
|
|
||||||
# Debugging
|
# Debugging
|
||||||
mise run log-tail # tail colored logs
|
just cmd dashboard # send command to device
|
||||||
mise run cmd COMMAND=dashboard # send command to device
|
just state # show device state
|
||||||
mise run state # show device state
|
just monitor-raw # raw serial monitor (115200 baud)
|
||||||
mise run monitor-raw # raw serial monitor (115200 baud)
|
just monitor-tio # show tio command for terminal UI
|
||||||
mise run monitor-tio # show tio command for terminal UI
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
mise run install-libs-shared # shared libs (ArduinoJson, NTPClient)
|
just install-libs-shared # shared libs (ArduinoJson, NTPClient)
|
||||||
mise run install # shared + board-specific libs
|
just install # shared + board-specific libs
|
||||||
|
|
||||||
# LSP / IDE
|
# LSP / IDE
|
||||||
mise run gen-compile-commands # generate compile_commands.json
|
just gen-compile-commands # generate compile_commands.json
|
||||||
mise run gen-crush-config # generate .crush.json for BOARD
|
just gen-crush-config # generate .crush.json for BOARD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Serial debug commands** (115200 baud): `alert`, `silence`, `dashboard`, `off`, `status`, `reboot`
|
**Serial debug commands** (115200 baud): `alert`, `silence`, `dashboard`, `off`, `status`, `reboot`
|
||||||
@@ -47,7 +52,7 @@ mise run gen-crush-config # generate .crush.json for BOARD
|
|||||||
- 4-space indentation, no tabs
|
- 4-space indentation, no tabs
|
||||||
- Column limit: 100
|
- Column limit: 100
|
||||||
- Opening brace on same line (`BreakBeforeBraces: Attach`)
|
- Opening brace on same line (`BreakBeforeBraces: Attach`)
|
||||||
- Run `mise run format` to format code
|
- Run `just format` to format code
|
||||||
|
|
||||||
### Header Guards
|
### Header Guards
|
||||||
Use `#pragma once` (not `#ifndef` guards).
|
Use `#pragma once` (not `#ifndef` guards).
|
||||||
@@ -109,17 +114,22 @@ libraries/KlubhausCore/src/
|
|||||||
|
|
||||||
boards/{BOARD}/
|
boards/{BOARD}/
|
||||||
├── {BOARD}.ino # Main sketch
|
├── {BOARD}.ino # Main sketch
|
||||||
├── board_config.h # Board-specific config
|
├── board_config.h # Board-specific config (ESP32 boards)
|
||||||
├── secrets.h # WiFi credentials
|
├── secrets.h # WiFi credentials (optional, use env vars)
|
||||||
├── tft_user_setup.h # TFT_eSPI config (TFT boards)
|
├── tft_user_setup.h # TFT_eSPI config (TFT boards)
|
||||||
└── DisplayDriver*.{h,cpp} # Concrete IDisplayDriver
|
└── DisplayDriver*.{h,cpp} # Concrete IDisplayDriver (ESP32 boards)
|
||||||
|
|
||||||
|
boards/uno-r4-wifi/ # Uno R4 WiFi (LED matrix display)
|
||||||
|
├── uno-r4-wifi.ino # Main sketch (standalone)
|
||||||
|
├── test_messages.sh # Test script to send ntfy messages
|
||||||
|
└── README.md # Quick start guide
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
1. **secrets.h**: Boards with `-DLOCAL_SECRETS` use local `secrets.h`; others use `KlubhausCore/src/secrets.h`
|
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
|
2. **Vendored libs**: Each board links only its display lib — never TFT_eSPI + LovyanGFX together
|
||||||
3. **LSP errors**: Run `mise run gen-compile-commands` then restart LSP; build works regardless
|
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
|
4. **Serial port**: `upload`/`monitor` auto-depend on `kill` to release port
|
||||||
5. **State tags**: Use `[STATE] → DASHBOARD`, `[ADMIN]`, `[TOUCH]`, `[ALERT]` for monitor parsing
|
5. **State tags**: Use `[STATE] → DASHBOARD`, `[ADMIN]`, `[TOUCH]`, `[ALERT]` for monitor parsing
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +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];
|
||||||
display.setHeaderHeight(STYLE_HEADER_HEIGHT);
|
int gridCols, gridRows;
|
||||||
const TileLayout* layouts = display.getTileLayouts();
|
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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -443,10 +443,11 @@ 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];
|
||||||
display.setHeaderHeight(STYLE_HEADER_HEIGHT);
|
int gridCols, gridRows;
|
||||||
const TileLayout* layouts = display.getTileLayouts();
|
int tileCount = TileLayoutHelper::calculateLayouts(
|
||||||
|
DISP_W, DISP_H, 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 };
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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