5 Commits

Author SHA1 Message Date
david df0f440dcb chore: add .ino.backup to gitignore 2026-05-29 20:18:25 -07:00
david 3af958b69f feat: add Arduino Uno R4 WiFi board
- LED matrix doorbell display with scrolling text
- Non-blocking HTTP poll state machine for ntfy.sh
- Ticker tape pressure gauge (12 cols, 3-min window, PWM decay)
- Unix timestamp message filtering (readyTime after NTP sync)
- BUILTIN LED sine-wave brightness heartbeat
- Pending: remove backup file before merge
2026-05-29 20:17:47 -07:00
david 6f9d2912d3 feat: add BUILD_TOKEN to firmware and consolidate build system
- Generate BUILD_TOKEN from 'git describe --always --dirty'
- Include via -include flag in justfile compile recipe
- Print BUILD_TOKEN in boot banners (DoorbellLogic + uno-r4-wifi)
- Remove all [tasks] from mise.toml; keep only [tools] + [env]
- Update AGENTS.md to use just commands as single source of truth
- Add 'just full' recipe (compile -> upload -> monitor -> watch)
- Switch BOARD var to env_var_or_default for mise set compatibility
- DRY clean/clean-temp with wildcard patterns
2026-05-29 20:17:42 -07:00
david ece874a8ed fix: monitor-agent.py parse board-config.sh safely
Replace exec() on shell scripts with regex line parsing to avoid
shell injection and only extract PORT from board-config.sh
2026-05-29 20:17:20 -07:00
david 14781b8c07 refactor: move TileLayoutHelper from DisplayManager.h to Style.h
- Pure static helper class moved from DisplayManager.h to Style.h
- Drivers now use TileLayoutHelper::calculateLayouts() directly
- Remove extern DisplayManager references from display drivers
- Drop LGFX_USE_V1 build flag (no longer needed)
- Add delay(LOOP_YIELD_MS) in S3 loop to prevent watchdog
2026-05-29 20:17:15 -07:00
17 changed files with 976 additions and 386 deletions
+1
View File
@@ -18,3 +18,4 @@ vendor/esp32-s3-lcd-43/GFX_Library_for_Arduino/
compile_commands.json
.board-last
.cache/
boards/uno-r4-wifi/uno-r4-wifi.ino.backup
+33 -23
View File
@@ -5,35 +5,40 @@ Multi-target Arduino/ESP32 doorbell alert system using ntfy.sh. Default BOARD: `
## Build Commands
```bash
# All tasks use just (see justfile). Default BOARD: esp32-s3-lcd-43
# Set target board
mise set BOARD=esp32-32e-4 # ESP32-32E 4" (320x480 ST7796)
mise set BOARD=esp32-32e # ESP32-32E 3.5" (320x240 ILI9341)
mise set BOARD=esp32-s3-lcd-43 # ESP32-S3-Touch-LCD-4.3 (800x480 RGB)
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
mise run compile # compile for current BOARD
mise run upload # upload (auto-kills monitor first)
mise run monitor # start JSON monitor daemon
mise run kill # kill monitor/release serial port
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
mise run format # format code with clang-format
mise run clean # remove build artifacts
just format # format code with clang-format
just clean # remove build artifacts
just clean-temp # remove monitor logs/FIFOs/state files
# Debugging
mise run log-tail # tail colored logs
mise run cmd COMMAND=dashboard # send command to device
mise run state # show device state
mise run monitor-raw # raw serial monitor (115200 baud)
mise run monitor-tio # show tio command for terminal UI
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
mise run install-libs-shared # shared libs (ArduinoJson, NTPClient)
mise run install # shared + board-specific libs
just install-libs-shared # shared libs (ArduinoJson, NTPClient)
just install # shared + board-specific libs
# LSP / IDE
mise run gen-compile-commands # generate compile_commands.json
mise run gen-crush-config # generate .crush.json for BOARD
just gen-compile-commands # generate compile_commands.json
just gen-crush-config # generate .crush.json for BOARD
```
**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
- Column limit: 100
- Opening brace on same line (`BreakBeforeBraces: Attach`)
- Run `mise run format` to format code
- Run `just format` to format code
### Header Guards
Use `#pragma once` (not `#ifndef` guards).
@@ -109,17 +114,22 @@ libraries/KlubhausCore/src/
boards/{BOARD}/
├── {BOARD}.ino # Main sketch
├── board_config.h # Board-specific config
├── secrets.h # WiFi credentials
├── 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
└── 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
1. **secrets.h**: Boards with `-DLOCAL_SECRETS` use local `secrets.h`; others use `KlubhausCore/src/secrets.h`
2. **Vendored libs**: Each board links only its display lib — never TFT_eSPI + LovyanGFX together
3. **LSP errors**: Run `mise run gen-compile-commands` then restart LSP; build works regardless
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
+6 -6
View File
@@ -3,8 +3,7 @@
#include <Arduino.h>
#include <KlubhausCore.h>
#include <TFT_eSPI.h>
extern DisplayManager display;
#include <Style.h>
// ── Fonts ───────────────────────────────────────────────────
// 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.print(wifiText);
// Get tile layouts from library helper
int tileCount = display.calculateDashboardLayouts(STYLE_HEADER_HEIGHT, STYLE_TILE_GAP);
display.setHeaderHeight(STYLE_HEADER_HEIGHT);
const TileLayout* layouts = display.getTileLayouts();
// Calculate tile layouts using TileLayoutHelper
TileLayout layouts[DASHBOARD_TILE_COUNT];
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 uint16_t tileColors[] = { 0x0280, 0x0400, 0x0440, 0x0100 };
+6 -5
View File
@@ -6,10 +6,10 @@
#include <Arduino.h>
#include <KlubhausCore.h>
#include <Style.h>
// ── Globals ──
static LGFX* _gfx = nullptr;
extern DisplayManager display;
// ── Forward declarations ──
static void initDisplay();
@@ -443,10 +443,11 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
},
safeWifi.w);
// Get tile layouts from library helper
int tileCount = display.calculateDashboardLayouts(STYLE_HEADER_HEIGHT, STYLE_TILE_GAP);
display.setHeaderHeight(STYLE_HEADER_HEIGHT);
const TileLayout* layouts = display.getTileLayouts();
// Calculate tile layouts using TileLayoutHelper
TileLayout layouts[DASHBOARD_TILE_COUNT];
int gridCols, gridRows;
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 uint16_t tileColors[] = { 0x0280, 0x0400, 0x0440, 0x0100 };
+1 -1
View File
@@ -1,4 +1,4 @@
FQBN="esp32:esp32:waveshare_esp32_s3_touch_lcd_43:PSRAM=enabled,FlashSize=16M,USBMode=hwcdc,PartitionScheme=app3M_fat9M_16MB"
PORT="/dev/ttyUSB0"
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
logic.processSerial();
// Yield to WiFi/BT stack
delay(LOOP_YIELD_MS);
}
+111
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
FQBN=arduino:renesas_uno:unor4wifi
PORT=/dev/ttyACM0
LIBS=""
OPTS="-DDEBUG_MODE"
+51
View File
@@ -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}"
+563
View File
@@ -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);
}
+54 -14
View File
@@ -6,18 +6,28 @@
default:
@just --list
# Set default BOARD
BOARD := "esp32-32e-4"
# Default BOARD — overridable via env var or CLI: BOARD=uno-r4-wifi just compile
BOARD := env_var_or_default('BOARD', 'esp32-s3-lcd-43')
# Helper to source board config
# 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:
#!/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
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)
NEED_GEN=false
if [ ! -f compile_commands.json ]; then
@@ -28,7 +38,7 @@ compile:
if [ "$NEED_GEN" = "true" ]; then
rm -rf /tmp/arduino-build
arduino-cli compile --only-compilation-database --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$OPTS" --build-path /tmp/arduino-build ./boards/{{BOARD}}
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
echo "[OK] Generated compile_commands.json for {{BOARD}}"
@@ -36,7 +46,7 @@ compile:
echo "[SKIP] compile_commands.json already up to date for {{BOARD}}"
fi
arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$OPTS" --warnings default ./boards/{{BOARD}}
arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$CFLAGS" --warnings default ./boards/{{BOARD}}
# Upload firmware
upload: kill
@@ -77,10 +87,16 @@ monitor-tio: kill
TARGET="$(readlink -f "$PORT" 2>/dev/null || echo "$PORT")"
echo "Run: tio --map INLCRNL $TARGET -e"
# Monitor with JSON logging
# Monitor with JSON logging (runs in background)
monitor: kill
#!/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
watch:
@@ -124,26 +140,31 @@ install-libs-shared:
# Install all libraries
install: install-libs-shared
#!/usr/bin/env bash
./boards/{{BOARD}}/install.sh
arduino-cli core install esp32:esp32
if [ -f ./boards/{{BOARD}}/install.sh ]; then
./boards/{{BOARD}}/install.sh
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:
#!/usr/bin/env bash
rm -rf vendor/
rm -rf .cache/
rm -rf boards/esp32-32e/build
rm -rf boards/esp32-32e-4/build
rm -rf boards/esp32-s3-lcd-43/build
rm -rf boards/*/build/
rm -f .board-last
rm -f /tmp/doorbell-build-token.h
echo "[OK] Build artifacts cleaned"
# Clean temporary files (monitor logs, FIFOs, state files)
clean-temp:
#!/usr/bin/env bash
rm -f /tmp/doorbell-esp32-32e.jsonl /tmp/doorbell-esp32-32e-state.json /tmp/doorbell-esp32-32e-cmd.fifo
rm -f /tmp/doorbell-esp32-32e-4.jsonl /tmp/doorbell-esp32-32e-4-state.json /tmp/doorbell-esp32-32e-4-cmd.fifo
rm -f /tmp/doorbell-esp32-s3-lcd-43.jsonl /tmp/doorbell-esp32-s3-lcd-43-state.json /tmp/doorbell-esp32-s3-lcd-43-cmd.fifo
rm -f /tmp/doorbell-*.jsonl /tmp/doorbell-*-state.json /tmp/doorbell-*-cmd.fifo
rm -f .board-last
echo "[OK] Temp files cleaned"
@@ -176,10 +197,29 @@ format:
boards/esp32-s3-lcd-43/*.cpp \
boards/esp32-s3-lcd-43/*.h \
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/*.h \
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
gen-crush-config:
#!/usr/bin/env bash
+5 -1
View File
@@ -1,6 +1,10 @@
#pragma once
#define FW_VERSION "5.1"
#define FW_VERSION "6.0"
#ifndef BUILD_TOKEN
#define BUILD_TOKEN "dev"
#endif
// ── ntfy.sh ──
#define NTFY_SERVER "ntfy.sh"
-119
View File
@@ -4,125 +4,6 @@
#include "ScreenState.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 {
public:
DisplayManager(IDisplayDriver* drv)
+1 -1
View File
@@ -21,7 +21,7 @@ void DoorbellLogic::begin(
#endif
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)
Serial.println(F(" *** DEBUG MODE — _test topics ***"));
Serial.println(F("========================================\n"));
+118
View File
@@ -2,6 +2,7 @@
#include <Arduino.h>
#include <cstdint>
#include <cmath>
struct Layout {
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); }
};
/// 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;
}
};
+6 -214
View File
@@ -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]
hk = "latest"
just = "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]
BOARD = "esp32-32e-4"
BOARD = "uno-r4-wifi"
+13 -2
View File
@@ -8,11 +8,22 @@ import time
import threading
import serial
import select
import re
BOARD = sys.argv[1] if len(sys.argv) > 1 else "esp32-32e-4"
# Load board config
exec(open(f"./boards/{BOARD}/board-config.sh").read().replace("export ", ""))
# Load board config - parse shell script format
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)
LOGFILE = f"/tmp/doorbell-{BOARD}.jsonl"