snapshot
This commit is contained in:
90
sketches/doorbell-touch-esp32-32e/Config.h
Normal file
90
sketches/doorbell-touch-esp32-32e/Config.h
Normal file
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
// =====================================================================
|
||||
// Debug
|
||||
// =====================================================================
|
||||
#define DEBUG_MODE 1
|
||||
|
||||
// =====================================================================
|
||||
// WiFi Credentials
|
||||
// =====================================================================
|
||||
struct WiFiCred { const char *ssid; const char *pass; };
|
||||
static WiFiCred wifiNetworks[] = {
|
||||
{ "Dobro Veče", "goodnight" },
|
||||
{ "berylpunk", "dhgwilliam" },
|
||||
};
|
||||
static const int NUM_WIFI = sizeof(wifiNetworks) / sizeof(wifiNetworks[0]);
|
||||
|
||||
// =====================================================================
|
||||
// ntfy.sh Topics
|
||||
// =====================================================================
|
||||
#define NTFY_BASE "https://ntfy.sh"
|
||||
|
||||
#if DEBUG_MODE
|
||||
#define TOPIC_SUFFIX "_test"
|
||||
#else
|
||||
#define TOPIC_SUFFIX ""
|
||||
#endif
|
||||
|
||||
#define ALERT_URL NTFY_BASE "/ALERT_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1"
|
||||
#define SILENCE_URL NTFY_BASE "/SILENCE_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1"
|
||||
#define ADMIN_URL NTFY_BASE "/ADMIN_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1"
|
||||
#define STATUS_URL NTFY_BASE "/STATUS_klubhaus_topic" TOPIC_SUFFIX
|
||||
|
||||
// =====================================================================
|
||||
// Timing
|
||||
// =====================================================================
|
||||
#define POLL_INTERVAL_MS 15000
|
||||
#define BLINK_INTERVAL_MS 500
|
||||
#define STALE_MSG_THRESHOLD_S 600
|
||||
#define NTP_SYNC_INTERVAL_MS 3600000
|
||||
#define ALERT_TIMEOUT_MS 300000
|
||||
#define WAKE_DISPLAY_MS 5000
|
||||
#define TOUCH_DEBOUNCE_MS 300
|
||||
#define HEARTBEAT_INTERVAL_MS 30000
|
||||
|
||||
#if DEBUG_MODE
|
||||
#define BOOT_GRACE_MS 5000
|
||||
#else
|
||||
#define BOOT_GRACE_MS 30000
|
||||
#endif
|
||||
|
||||
// =====================================================================
|
||||
// E32R35T Hardware Pins (hardwired on PCB — do not change)
|
||||
// =====================================================================
|
||||
// LCD (HSPI)
|
||||
#define PIN_LCD_CS 15
|
||||
#define PIN_LCD_DC 2
|
||||
#define PIN_LCD_MOSI 13
|
||||
#define PIN_LCD_SCLK 14
|
||||
#define PIN_LCD_MISO 12
|
||||
#define PIN_LCD_BL 27
|
||||
|
||||
// Touch (XPT2046, shares HSPI)
|
||||
#define PIN_TOUCH_CS 33
|
||||
#define PIN_TOUCH_IRQ 36
|
||||
|
||||
// SD Card (VSPI — for future use)
|
||||
#define PIN_SD_CS 5
|
||||
#define PIN_SD_MOSI 23
|
||||
#define PIN_SD_SCLK 18
|
||||
#define PIN_SD_MISO 19
|
||||
|
||||
// RGB LED (active low)
|
||||
#define PIN_LED_RED 22
|
||||
#define PIN_LED_GREEN 16
|
||||
#define PIN_LED_BLUE 17
|
||||
|
||||
// Audio
|
||||
#define PIN_AUDIO_EN 4
|
||||
#define PIN_AUDIO_DAC 26
|
||||
|
||||
// Battery ADC
|
||||
#define PIN_BAT_ADC 34
|
||||
|
||||
// =====================================================================
|
||||
// Display
|
||||
// =====================================================================
|
||||
#define SCREEN_WIDTH 480 // landscape
|
||||
#define SCREEN_HEIGHT 320
|
||||
|
||||
202
sketches/doorbell-touch-esp32-32e/DisplayManager.cpp
Normal file
202
sketches/doorbell-touch-esp32-32e/DisplayManager.cpp
Normal file
@@ -0,0 +1,202 @@
|
||||
#include "DisplayManager.h"
|
||||
#include "Config.h"
|
||||
|
||||
void DisplayManager::begin() {
|
||||
pinMode(PIN_LCD_BL, OUTPUT);
|
||||
setBacklight(true);
|
||||
|
||||
_tft.init();
|
||||
_tft.setRotation(1); // landscape: 480x320
|
||||
_tft.fillScreen(COL_BLACK);
|
||||
}
|
||||
|
||||
void DisplayManager::setBacklight(bool on) {
|
||||
digitalWrite(PIN_LCD_BL, on ? HIGH : LOW);
|
||||
}
|
||||
|
||||
TouchEvent DisplayManager::readTouch() {
|
||||
TouchEvent evt;
|
||||
uint16_t x, y;
|
||||
if (_tft.getTouch(&x, &y)) {
|
||||
evt.pressed = true;
|
||||
evt.x = x;
|
||||
evt.y = y;
|
||||
}
|
||||
return evt;
|
||||
}
|
||||
|
||||
void DisplayManager::render(const ScreenState& state) {
|
||||
// Detect screen change → force full redraw
|
||||
if (state.screen != _lastScreen) {
|
||||
_needsFullRedraw = true;
|
||||
_lastScreen = state.screen;
|
||||
}
|
||||
|
||||
switch (state.screen) {
|
||||
case ScreenID::BOOT_SPLASH:
|
||||
if (_needsFullRedraw) drawBootSplash(state);
|
||||
break;
|
||||
case ScreenID::WIFI_CONNECTING:
|
||||
if (_needsFullRedraw) drawWifiConnecting();
|
||||
break;
|
||||
case ScreenID::WIFI_CONNECTED:
|
||||
if (_needsFullRedraw) drawWifiConnected(state);
|
||||
break;
|
||||
case ScreenID::WIFI_FAILED:
|
||||
if (_needsFullRedraw) drawWifiFailed();
|
||||
break;
|
||||
case ScreenID::ALERT:
|
||||
// Alert redraws every blink cycle
|
||||
if (_needsFullRedraw || state.blinkPhase != _lastBlink) {
|
||||
drawAlertScreen(state);
|
||||
_lastBlink = state.blinkPhase;
|
||||
}
|
||||
break;
|
||||
case ScreenID::STATUS:
|
||||
if (_needsFullRedraw) drawStatusScreen(state);
|
||||
break;
|
||||
case ScreenID::OFF:
|
||||
break;
|
||||
}
|
||||
|
||||
_needsFullRedraw = false;
|
||||
}
|
||||
|
||||
// ----- Helpers -----
|
||||
|
||||
void DisplayManager::drawCentered(const char* txt, int y, int sz, uint16_t col) {
|
||||
_tft.setTextSize(sz);
|
||||
_tft.setTextColor(col);
|
||||
int w = strlen(txt) * 6 * sz;
|
||||
_tft.setCursor(max(0, (SCREEN_WIDTH - w) / 2), y);
|
||||
_tft.print(txt);
|
||||
}
|
||||
|
||||
void DisplayManager::drawInfoLine(int x, int y, uint16_t col, const char* text) {
|
||||
_tft.setTextSize(1);
|
||||
_tft.setTextColor(col);
|
||||
_tft.setCursor(x, y);
|
||||
_tft.print(text);
|
||||
}
|
||||
|
||||
void DisplayManager::drawHeaderBar(uint16_t col, const char* label, const char* timeStr) {
|
||||
_tft.setTextSize(2);
|
||||
_tft.setTextColor(col);
|
||||
_tft.setCursor(8, 8);
|
||||
_tft.print(label);
|
||||
|
||||
int tw = strlen(timeStr) * 12;
|
||||
_tft.setCursor(SCREEN_WIDTH - tw - 8, 8);
|
||||
_tft.print(timeStr);
|
||||
}
|
||||
|
||||
// ----- Screens -----
|
||||
|
||||
void DisplayManager::drawBootSplash(const ScreenState& s) {
|
||||
_tft.fillScreen(COL_BLACK);
|
||||
drawCentered("KLUBHAUS", 60, 4, COL_NEON_TEAL);
|
||||
drawCentered("ALERT", 110, 4, COL_HOT_FUCHSIA);
|
||||
drawCentered("v5.0 E32R35T", 180, 2, COL_DARK_GRAY);
|
||||
if (s.debugMode) {
|
||||
drawCentered("DEBUG MODE", 210, 2, COL_YELLOW);
|
||||
}
|
||||
}
|
||||
|
||||
void DisplayManager::drawWifiConnecting() {
|
||||
_tft.fillScreen(COL_BLACK);
|
||||
drawCentered("Connecting", 130, 3, COL_NEON_TEAL);
|
||||
drawCentered("to WiFi...", 170, 3, COL_NEON_TEAL);
|
||||
}
|
||||
|
||||
void DisplayManager::drawWifiConnected(const ScreenState& s) {
|
||||
_tft.fillScreen(COL_BLACK);
|
||||
drawCentered("Connected!", 100, 3, COL_GREEN);
|
||||
drawCentered(s.wifiSSID, 150, 2, COL_WHITE);
|
||||
drawCentered(s.wifiIP, 180, 2, COL_WHITE);
|
||||
}
|
||||
|
||||
void DisplayManager::drawWifiFailed() {
|
||||
_tft.fillScreen(COL_BLACK);
|
||||
drawCentered("WiFi FAILED", 140, 3, COL_RED);
|
||||
}
|
||||
|
||||
void DisplayManager::drawAlertScreen(const ScreenState& s) {
|
||||
uint16_t bg = s.blinkPhase ? COL_NEON_TEAL : COL_HOT_FUCHSIA;
|
||||
uint16_t fg = s.blinkPhase ? COL_BLACK : COL_WHITE;
|
||||
|
||||
_tft.fillScreen(bg);
|
||||
drawHeaderBar(fg, "ALERT", s.timeString);
|
||||
|
||||
// Scale text to fit 480px wide screen
|
||||
int sz = 5;
|
||||
int len = strlen(s.alertMessage);
|
||||
if (len > 10) sz = 4;
|
||||
if (len > 18) sz = 3;
|
||||
if (len > 30) sz = 2;
|
||||
|
||||
if (len > 12) {
|
||||
// Two-line split
|
||||
String msg(s.alertMessage);
|
||||
int mid = len / 2;
|
||||
int sp = msg.lastIndexOf(' ', mid);
|
||||
if (sp < 0) sp = mid;
|
||||
String l1 = msg.substring(0, sp);
|
||||
String l2 = msg.substring(sp + 1);
|
||||
int lh = 8 * sz + 8;
|
||||
int y1 = (SCREEN_HEIGHT - lh * 2) / 2;
|
||||
drawCentered(l1.c_str(), y1, sz, fg);
|
||||
drawCentered(l2.c_str(), y1 + lh, sz, fg);
|
||||
} else {
|
||||
drawCentered(s.alertMessage, (SCREEN_HEIGHT - 8 * sz) / 2, sz, fg);
|
||||
}
|
||||
|
||||
drawCentered("TAP TO SILENCE", SCREEN_HEIGHT - 25, 2, fg);
|
||||
}
|
||||
|
||||
void DisplayManager::drawStatusScreen(const ScreenState& s) {
|
||||
_tft.fillScreen(COL_BLACK);
|
||||
drawHeaderBar(COL_MINT, "KLUBHAUS", s.timeString);
|
||||
|
||||
drawCentered("MONITORING", 60, 3, COL_WHITE);
|
||||
|
||||
char buf[80];
|
||||
int y = 110;
|
||||
int sp = 22;
|
||||
int x = 20;
|
||||
|
||||
snprintf(buf, sizeof(buf), "WiFi: %s (%ddBm)",
|
||||
s.wifiConnected ? s.wifiSSID : "DOWN", s.wifiRSSI);
|
||||
drawInfoLine(x, y, COL_WHITE, buf); y += sp;
|
||||
|
||||
snprintf(buf, sizeof(buf), "IP: %s",
|
||||
s.wifiConnected ? s.wifiIP : "---");
|
||||
drawInfoLine(x, y, COL_WHITE, buf); y += sp;
|
||||
|
||||
snprintf(buf, sizeof(buf), "Up: %lu min Heap: %lu KB",
|
||||
s.uptimeMinutes, s.freeHeapKB);
|
||||
drawInfoLine(x, y, COL_WHITE, buf); y += sp;
|
||||
|
||||
snprintf(buf, sizeof(buf), "NTP: %s UTC",
|
||||
s.ntpSynced ? s.timeString : "not synced");
|
||||
drawInfoLine(x, y, COL_WHITE, buf); y += sp;
|
||||
|
||||
const char* stName = s.deviceState == DeviceState::SILENT ? "SILENT" :
|
||||
s.deviceState == DeviceState::ALERTING ? "ALERTING" : "WAKE";
|
||||
snprintf(buf, sizeof(buf), "State: %s Net: %s",
|
||||
stName, s.networkOK ? "OK" : "FAIL");
|
||||
uint16_t stCol = s.deviceState == DeviceState::ALERTING ? COL_RED :
|
||||
s.deviceState == DeviceState::SILENT ? COL_GREEN : COL_NEON_TEAL;
|
||||
drawInfoLine(x, y, stCol, buf); y += sp;
|
||||
|
||||
if (strlen(s.alertMessage) > 0) {
|
||||
snprintf(buf, sizeof(buf), "Last: %.40s", s.alertMessage);
|
||||
drawInfoLine(x, y, COL_DARK_GRAY, buf); y += sp;
|
||||
}
|
||||
|
||||
if (s.debugMode) {
|
||||
drawInfoLine(x, y, COL_YELLOW, "DEBUG MODE - _test topics");
|
||||
}
|
||||
|
||||
drawCentered("tap to dismiss", SCREEN_HEIGHT - 18, 1, COL_DARK_GRAY);
|
||||
}
|
||||
|
||||
42
sketches/doorbell-touch-esp32-32e/DisplayManager.h
Normal file
42
sketches/doorbell-touch-esp32-32e/DisplayManager.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
#include <TFT_eSPI.h>
|
||||
#include "ScreenData.h"
|
||||
|
||||
class DisplayManager {
|
||||
public:
|
||||
void begin();
|
||||
void render(const ScreenState& state);
|
||||
void setBacklight(bool on);
|
||||
TouchEvent readTouch();
|
||||
|
||||
private:
|
||||
TFT_eSPI _tft;
|
||||
ScreenID _lastScreen = ScreenID::BOOT_SPLASH;
|
||||
bool _needsFullRedraw = true;
|
||||
bool _lastBlink = false;
|
||||
|
||||
// Colors
|
||||
static constexpr uint16_t COL_NEON_TEAL = 0x07D7;
|
||||
static constexpr uint16_t COL_HOT_FUCHSIA = 0xF81F;
|
||||
static constexpr uint16_t COL_WHITE = 0xFFDF;
|
||||
static constexpr uint16_t COL_BLACK = 0x0000;
|
||||
static constexpr uint16_t COL_MINT = 0x67F5;
|
||||
static constexpr uint16_t COL_DARK_GRAY = 0x2104;
|
||||
static constexpr uint16_t COL_GREEN = 0x07E0;
|
||||
static constexpr uint16_t COL_RED = 0xF800;
|
||||
static constexpr uint16_t COL_YELLOW = 0xFFE0;
|
||||
|
||||
// Screen renderers
|
||||
void drawBootSplash(const ScreenState& s);
|
||||
void drawWifiConnecting();
|
||||
void drawWifiConnected(const ScreenState& s);
|
||||
void drawWifiFailed();
|
||||
void drawAlertScreen(const ScreenState& s);
|
||||
void drawStatusScreen(const ScreenState& s);
|
||||
|
||||
// Helpers
|
||||
void drawCentered(const char* txt, int y, int sz, uint16_t col);
|
||||
void drawInfoLine(int x, int y, uint16_t col, const char* text);
|
||||
void drawHeaderBar(uint16_t col, const char* label, const char* timeStr);
|
||||
};
|
||||
|
||||
453
sketches/doorbell-touch-esp32-32e/DoorbellLogic.cpp
Normal file
453
sketches/doorbell-touch-esp32-32e/DoorbellLogic.cpp
Normal file
@@ -0,0 +1,453 @@
|
||||
#include "DoorbellLogic.h"
|
||||
|
||||
// =====================================================================
|
||||
// Lifecycle
|
||||
// =====================================================================
|
||||
void DoorbellLogic::begin() {
|
||||
_bootTime = millis();
|
||||
_timeClient = new NTPClient(_ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS);
|
||||
|
||||
_screen.debugMode = DEBUG_MODE;
|
||||
_screen.screen = ScreenID::BOOT_SPLASH;
|
||||
updateScreenState();
|
||||
}
|
||||
|
||||
void DoorbellLogic::beginWiFi() {
|
||||
for (int i = 0; i < NUM_WIFI; i++) {
|
||||
_wifiMulti.addAP(wifiNetworks[i].ssid, wifiNetworks[i].pass);
|
||||
}
|
||||
|
||||
_screen.screen = ScreenID::WIFI_CONNECTING;
|
||||
updateScreenState();
|
||||
}
|
||||
|
||||
void DoorbellLogic::connectWiFiBlocking() {
|
||||
Serial.println("[WIFI] Connecting...");
|
||||
|
||||
int tries = 0;
|
||||
while (_wifiMulti.run() != WL_CONNECTED && tries++ < 40) {
|
||||
Serial.print(".");
|
||||
delay(500);
|
||||
}
|
||||
Serial.println();
|
||||
|
||||
if (WiFi.isConnected()) {
|
||||
Serial.printf("[WIFI] Connected: %s %s\n",
|
||||
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
|
||||
updateScreenState();
|
||||
_screen.screen = ScreenID::WIFI_CONNECTED;
|
||||
} else {
|
||||
Serial.println("[WIFI] FAILED — no networks reachable");
|
||||
_screen.screen = ScreenID::WIFI_FAILED;
|
||||
}
|
||||
updateScreenState();
|
||||
}
|
||||
|
||||
void DoorbellLogic::finishBoot() {
|
||||
if (WiFi.isConnected()) {
|
||||
_timeClient->begin();
|
||||
Serial.println("[NTP] Starting sync...");
|
||||
|
||||
for (int i = 0; i < 5 && !_ntpSynced; i++) {
|
||||
syncNTP();
|
||||
if (!_ntpSynced) delay(500);
|
||||
}
|
||||
|
||||
if (_ntpSynced) {
|
||||
Serial.printf("[NTP] Synced: %s UTC\n",
|
||||
_timeClient->getFormattedTime().c_str());
|
||||
} else {
|
||||
Serial.println("[NTP] Initial sync failed — will retry in update()");
|
||||
}
|
||||
|
||||
checkNetwork();
|
||||
|
||||
char bootMsg[80];
|
||||
snprintf(bootMsg, sizeof(bootMsg), "%s %s RSSI:%d",
|
||||
WiFi.SSID().c_str(),
|
||||
WiFi.localIP().toString().c_str(),
|
||||
WiFi.RSSI());
|
||||
queueStatus("BOOTED", bootMsg);
|
||||
flushStatus();
|
||||
}
|
||||
|
||||
transitionTo(DeviceState::SILENT);
|
||||
Serial.printf("[BOOT] Grace period: %d ms\n", BOOT_GRACE_MS);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Main Update Loop
|
||||
// =====================================================================
|
||||
void DoorbellLogic::update() {
|
||||
unsigned long now = millis();
|
||||
|
||||
if (_inBootGrace && (now - _bootTime >= BOOT_GRACE_MS)) {
|
||||
_inBootGrace = false;
|
||||
Serial.println("[BOOT] Grace period ended");
|
||||
}
|
||||
|
||||
syncNTP();
|
||||
|
||||
if (!WiFi.isConnected()) {
|
||||
if (_wifiMulti.run() == WL_CONNECTED) {
|
||||
Serial.println("[WIFI] Reconnected");
|
||||
queueStatus("RECONNECTED", WiFi.SSID().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
if (now - _lastPoll >= POLL_INTERVAL_MS) {
|
||||
_lastPoll = now;
|
||||
if (WiFi.isConnected() && _ntpSynced) {
|
||||
pollTopics();
|
||||
}
|
||||
}
|
||||
|
||||
flushStatus();
|
||||
|
||||
switch (_state) {
|
||||
case DeviceState::ALERTING:
|
||||
if (now - _alertStart > ALERT_TIMEOUT_MS) {
|
||||
Serial.println("[ALERT] Timeout — auto-silencing");
|
||||
handleSilence("timeout");
|
||||
break;
|
||||
}
|
||||
if (now - _lastBlink >= BLINK_INTERVAL_MS) {
|
||||
_lastBlink = now;
|
||||
_blinkState = !_blinkState;
|
||||
}
|
||||
break;
|
||||
|
||||
case DeviceState::WAKE:
|
||||
if (now - _wakeStart > WAKE_DISPLAY_MS) {
|
||||
transitionTo(DeviceState::SILENT);
|
||||
}
|
||||
break;
|
||||
|
||||
case DeviceState::SILENT:
|
||||
break;
|
||||
}
|
||||
|
||||
if (now - _lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
|
||||
_lastHeartbeat = now;
|
||||
Serial.printf("[%lus] %s | WiFi:%s | heap:%dKB\n",
|
||||
now / 1000,
|
||||
_state == DeviceState::SILENT ? "SILENT" :
|
||||
_state == DeviceState::ALERTING ? "ALERT" : "WAKE",
|
||||
WiFi.isConnected() ? "OK" : "DOWN",
|
||||
ESP.getFreeHeap() / 1024);
|
||||
}
|
||||
|
||||
updateScreenState();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Input Events
|
||||
// =====================================================================
|
||||
void DoorbellLogic::onTouch(const TouchEvent& evt) {
|
||||
if (!evt.pressed) return;
|
||||
|
||||
static unsigned long lastAction = 0;
|
||||
unsigned long now = millis();
|
||||
if (now - lastAction < TOUCH_DEBOUNCE_MS) return;
|
||||
lastAction = now;
|
||||
|
||||
Serial.printf("[TOUCH] x=%d y=%d state=%d\n", evt.x, evt.y, (int)_state);
|
||||
|
||||
switch (_state) {
|
||||
case DeviceState::ALERTING:
|
||||
handleSilence("touch");
|
||||
break;
|
||||
case DeviceState::SILENT:
|
||||
transitionTo(DeviceState::WAKE);
|
||||
break;
|
||||
case DeviceState::WAKE:
|
||||
transitionTo(DeviceState::SILENT);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DoorbellLogic::onSerialCommand(const String& cmd) {
|
||||
if (cmd == "CLEAR_DEDUP") {
|
||||
_lastAlertId = _lastSilenceId = _lastAdminId = "";
|
||||
Serial.println("[CMD] Dedup cleared");
|
||||
} else if (cmd == "NET") {
|
||||
checkNetwork();
|
||||
} else if (cmd == "STATUS") {
|
||||
Serial.printf("[CMD] State:%s WiFi:%s RSSI:%d Heap:%dKB NTP:%s\n",
|
||||
_state == DeviceState::SILENT ? "SILENT" :
|
||||
_state == DeviceState::ALERTING ? "ALERT" : "WAKE",
|
||||
WiFi.isConnected() ? WiFi.SSID().c_str() : "DOWN",
|
||||
WiFi.RSSI(),
|
||||
ESP.getFreeHeap() / 1024,
|
||||
_ntpSynced ? _timeClient->getFormattedTime().c_str() : "no");
|
||||
} else if (cmd == "WAKE") {
|
||||
transitionTo(DeviceState::WAKE);
|
||||
} else if (cmd == "TEST") {
|
||||
handleAlert("TEST ALERT");
|
||||
} else if (cmd == "REBOOT") {
|
||||
Serial.println("[CMD] Rebooting...");
|
||||
queueStatus("REBOOTING", "serial");
|
||||
flushStatus();
|
||||
delay(200);
|
||||
ESP.restart();
|
||||
} else {
|
||||
Serial.printf("[CMD] Unknown: %s\n", cmd.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// State Transitions
|
||||
// =====================================================================
|
||||
void DoorbellLogic::transitionTo(DeviceState newState) {
|
||||
_state = newState;
|
||||
unsigned long now = millis();
|
||||
|
||||
switch (newState) {
|
||||
case DeviceState::SILENT:
|
||||
_screen.screen = ScreenID::OFF;
|
||||
Serial.println("-> SILENT");
|
||||
break;
|
||||
case DeviceState::ALERTING:
|
||||
_alertStart = now;
|
||||
_lastBlink = now;
|
||||
_blinkState = false;
|
||||
_screen.screen = ScreenID::ALERT;
|
||||
Serial.printf("-> ALERTING: %s\n", _currentMessage.c_str());
|
||||
break;
|
||||
case DeviceState::WAKE:
|
||||
_wakeStart = now;
|
||||
_screen.screen = ScreenID::STATUS;
|
||||
Serial.println("-> WAKE");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Message Handlers
|
||||
// =====================================================================
|
||||
void DoorbellLogic::handleAlert(const String& msg) {
|
||||
if (_state == DeviceState::ALERTING && _currentMessage == msg) return;
|
||||
_currentMessage = msg;
|
||||
transitionTo(DeviceState::ALERTING);
|
||||
queueStatus("ALERTING", msg);
|
||||
}
|
||||
|
||||
void DoorbellLogic::handleSilence(const String& msg) {
|
||||
_currentMessage = "";
|
||||
transitionTo(DeviceState::SILENT);
|
||||
queueStatus("SILENT", "silenced");
|
||||
}
|
||||
|
||||
void DoorbellLogic::handleAdmin(const String& msg) {
|
||||
Serial.printf("[ADMIN] %s\n", msg.c_str());
|
||||
|
||||
if (msg == "SILENCE") handleSilence("admin");
|
||||
else if (msg == "PING") queueStatus("PONG", "ping");
|
||||
else if (msg == "test") handleAlert("TEST ALERT");
|
||||
else if (msg == "status") {
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf), "State:%s WiFi:%s RSSI:%d Heap:%dKB",
|
||||
_state == DeviceState::SILENT ? "SILENT" :
|
||||
_state == DeviceState::ALERTING ? "ALERT" : "WAKE",
|
||||
WiFi.SSID().c_str(), WiFi.RSSI(), ESP.getFreeHeap() / 1024);
|
||||
queueStatus("STATUS", buf);
|
||||
}
|
||||
else if (msg == "wake") {
|
||||
transitionTo(DeviceState::WAKE);
|
||||
}
|
||||
else if (msg == "REBOOT") {
|
||||
queueStatus("REBOOTING", "admin");
|
||||
flushStatus();
|
||||
delay(200);
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Screen State Sync
|
||||
// =====================================================================
|
||||
void DoorbellLogic::updateScreenState() {
|
||||
_screen.deviceState = _state;
|
||||
_screen.blinkPhase = _blinkState;
|
||||
strncpy(_screen.alertMessage, _currentMessage.c_str(), sizeof(_screen.alertMessage) - 1);
|
||||
_screen.alertMessage[sizeof(_screen.alertMessage) - 1] = '\0';
|
||||
|
||||
_screen.wifiConnected = WiFi.isConnected();
|
||||
if (_screen.wifiConnected) {
|
||||
strncpy(_screen.wifiSSID, WiFi.SSID().c_str(), sizeof(_screen.wifiSSID) - 1);
|
||||
_screen.wifiSSID[sizeof(_screen.wifiSSID) - 1] = '\0';
|
||||
strncpy(_screen.wifiIP, WiFi.localIP().toString().c_str(), sizeof(_screen.wifiIP) - 1);
|
||||
_screen.wifiIP[sizeof(_screen.wifiIP) - 1] = '\0';
|
||||
_screen.wifiRSSI = WiFi.RSSI();
|
||||
}
|
||||
|
||||
_screen.ntpSynced = _ntpSynced;
|
||||
if (_ntpSynced) {
|
||||
strncpy(_screen.timeString, _timeClient->getFormattedTime().c_str(),
|
||||
sizeof(_screen.timeString) - 1);
|
||||
_screen.timeString[sizeof(_screen.timeString) - 1] = '\0';
|
||||
}
|
||||
|
||||
_screen.uptimeMinutes = (millis() - _bootTime) / 60000;
|
||||
_screen.freeHeapKB = ESP.getFreeHeap() / 1024;
|
||||
_screen.networkOK = _networkOK;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// WiFi & Network
|
||||
// =====================================================================
|
||||
void DoorbellLogic::syncNTP() {
|
||||
if (_timeClient->update()) {
|
||||
_ntpSynced = true;
|
||||
_lastEpoch = _timeClient->getEpochTime();
|
||||
}
|
||||
}
|
||||
|
||||
void DoorbellLogic::checkNetwork() {
|
||||
Serial.printf("[NET] WiFi:%s RSSI:%d IP:%s\n",
|
||||
WiFi.SSID().c_str(), WiFi.RSSI(), WiFi.localIP().toString().c_str());
|
||||
|
||||
IPAddress ip;
|
||||
if (!WiFi.hostByName("ntfy.sh", ip)) {
|
||||
Serial.println("[NET] DNS FAILED");
|
||||
_networkOK = false;
|
||||
return;
|
||||
}
|
||||
Serial.printf("[NET] DNS OK: %s\n", ip.toString().c_str());
|
||||
|
||||
WiFiClientSecure tls;
|
||||
tls.setInsecure();
|
||||
if (tls.connect("ntfy.sh", 443, 15000)) {
|
||||
Serial.println("[NET] TLS OK");
|
||||
tls.stop();
|
||||
_networkOK = true;
|
||||
} else {
|
||||
Serial.println("[NET] TLS FAILED");
|
||||
_networkOK = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ntfy Polling
|
||||
// =====================================================================
|
||||
void DoorbellLogic::pollTopics() {
|
||||
pollTopic(ALERT_URL, &DoorbellLogic::handleAlert, "ALERT", _lastAlertId);
|
||||
pollTopic(SILENCE_URL, &DoorbellLogic::handleSilence, "SILENCE", _lastSilenceId);
|
||||
pollTopic(ADMIN_URL, &DoorbellLogic::handleAdmin, "ADMIN", _lastAdminId);
|
||||
}
|
||||
|
||||
void DoorbellLogic::pollTopic(const char* url,
|
||||
void (DoorbellLogic::*handler)(const String&),
|
||||
const char* name, String& lastId) {
|
||||
WiFiClientSecure client;
|
||||
client.setInsecure();
|
||||
|
||||
HTTPClient http;
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.setTimeout(10000);
|
||||
|
||||
if (!http.begin(client, url)) {
|
||||
Serial.printf("[%s] begin() failed\n", name);
|
||||
return;
|
||||
}
|
||||
|
||||
int code = http.GET();
|
||||
if (code == HTTP_CODE_OK) {
|
||||
String response = http.getString();
|
||||
if (response.length() > 0) {
|
||||
parseMessages(response, name, handler, lastId);
|
||||
}
|
||||
} else if (code < 0) {
|
||||
Serial.printf("[%s] HTTP error: %s\n", name, http.errorToString(code).c_str());
|
||||
}
|
||||
http.end();
|
||||
}
|
||||
|
||||
void DoorbellLogic::parseMessages(String& response, const char* name,
|
||||
void (DoorbellLogic::*handler)(const String&),
|
||||
String& lastId) {
|
||||
if (_inBootGrace || !_ntpSynced || _lastEpoch == 0) return;
|
||||
|
||||
int lineStart = 0;
|
||||
while (lineStart < (int)response.length()) {
|
||||
int lineEnd = response.indexOf('\n', lineStart);
|
||||
if (lineEnd == -1) lineEnd = response.length();
|
||||
|
||||
String line = response.substring(lineStart, lineEnd);
|
||||
line.trim();
|
||||
|
||||
if (line.length() > 0 && line.indexOf('{') >= 0) {
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, line)) {
|
||||
const char* event = doc["event"];
|
||||
const char* msgId = doc["id"];
|
||||
const char* message = doc["message"];
|
||||
time_t msgTime = doc["time"] | 0;
|
||||
|
||||
if (event && strcmp(event, "message") != 0) {
|
||||
lineStart = lineEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!message || strlen(message) == 0) {
|
||||
lineStart = lineEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
String idStr = msgId ? String(msgId) : "";
|
||||
if (idStr.length() > 0 && idStr == lastId) {
|
||||
lineStart = lineEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msgTime > 0 && (_lastEpoch - msgTime) > (time_t)STALE_MSG_THRESHOLD_S) {
|
||||
Serial.printf("[%s] Stale: %.30s (age %llds)\n",
|
||||
name, message, (long long)(_lastEpoch - msgTime));
|
||||
lineStart = lineEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
Serial.printf("[%s] %.50s\n", name, message);
|
||||
if (idStr.length() > 0) lastId = idStr;
|
||||
(this->*handler)(String(message));
|
||||
}
|
||||
}
|
||||
lineStart = lineEnd + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Status Publishing (deferred)
|
||||
// =====================================================================
|
||||
void DoorbellLogic::queueStatus(const char* st, const String& msg) {
|
||||
_pendingStatus = true;
|
||||
_pendStatusState = st;
|
||||
_pendStatusMsg = msg;
|
||||
Serial.printf("[STATUS] Queued: %s — %s\n", st, msg.c_str());
|
||||
}
|
||||
|
||||
void DoorbellLogic::flushStatus() {
|
||||
if (!_pendingStatus || !WiFi.isConnected()) return;
|
||||
_pendingStatus = false;
|
||||
|
||||
JsonDocument doc;
|
||||
doc["state"] = _pendStatusState;
|
||||
doc["message"] = _pendStatusMsg;
|
||||
doc["timestamp"] = _ntpSynced ? (long long)_timeClient->getEpochTime() * 1000LL : 0LL;
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
WiFiClientSecure client;
|
||||
client.setInsecure();
|
||||
|
||||
HTTPClient http;
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.begin(client, STATUS_URL);
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
int code = http.POST(payload);
|
||||
http.end();
|
||||
|
||||
Serial.printf("[STATUS] Sent (%d): %s\n", code, _pendStatusState.c_str());
|
||||
}
|
||||
|
||||
87
sketches/doorbell-touch-esp32-32e/DoorbellLogic.h
Normal file
87
sketches/doorbell-touch-esp32-32e/DoorbellLogic.h
Normal file
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
#include <WiFi.h>
|
||||
#include <WiFiMulti.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <NTPClient.h>
|
||||
#include <WiFiUdp.h>
|
||||
#include "Config.h"
|
||||
#include "ScreenData.h"
|
||||
|
||||
class DoorbellLogic {
|
||||
public:
|
||||
void begin();
|
||||
|
||||
// Boot sequence (called individually so .ino can render between steps)
|
||||
void beginWiFi();
|
||||
void connectWiFiBlocking();
|
||||
void finishBoot();
|
||||
|
||||
void update();
|
||||
|
||||
const ScreenState& getScreenState() const { return _screen; }
|
||||
|
||||
// Input events from the outside
|
||||
void onTouch(const TouchEvent& evt);
|
||||
void onSerialCommand(const String& cmd);
|
||||
|
||||
private:
|
||||
ScreenState _screen;
|
||||
|
||||
// State
|
||||
DeviceState _state = DeviceState::SILENT;
|
||||
String _currentMessage = "";
|
||||
unsigned long _bootTime = 0;
|
||||
bool _inBootGrace = true;
|
||||
bool _networkOK = false;
|
||||
|
||||
// Dedup
|
||||
String _lastAlertId;
|
||||
String _lastSilenceId;
|
||||
String _lastAdminId;
|
||||
|
||||
// Timing
|
||||
unsigned long _lastPoll = 0;
|
||||
unsigned long _lastBlink = 0;
|
||||
unsigned long _alertStart = 0;
|
||||
unsigned long _wakeStart = 0;
|
||||
unsigned long _lastHeartbeat = 0;
|
||||
bool _blinkState = false;
|
||||
|
||||
// Deferred status publish
|
||||
bool _pendingStatus = false;
|
||||
String _pendStatusState;
|
||||
String _pendStatusMsg;
|
||||
|
||||
// NTP
|
||||
WiFiUDP _ntpUDP;
|
||||
NTPClient _timeClient = nullptr;
|
||||
bool _ntpSynced = false;
|
||||
time_t _lastEpoch = 0;
|
||||
|
||||
// WiFi
|
||||
WiFiMulti _wifiMulti;
|
||||
|
||||
// Methods
|
||||
void checkNetwork();
|
||||
void syncNTP();
|
||||
|
||||
void pollTopics();
|
||||
void pollTopic(const char* url, void (DoorbellLogic::*handler)(const String&),
|
||||
const char* name, String& lastId);
|
||||
void parseMessages(String& response, const char* name,
|
||||
void (DoorbellLogic::*handler)(const String&),
|
||||
String& lastId);
|
||||
|
||||
void handleAlert(const String& msg);
|
||||
void handleSilence(const String& msg);
|
||||
void handleAdmin(const String& msg);
|
||||
|
||||
void transitionTo(DeviceState newState);
|
||||
void queueStatus(const char* state, const String& msg);
|
||||
void flushStatus();
|
||||
|
||||
void updateScreenState();
|
||||
};
|
||||
|
||||
58
sketches/doorbell-touch-esp32-32e/ScreenData.h
Normal file
58
sketches/doorbell-touch-esp32-32e/ScreenData.h
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
// =====================================================================
|
||||
// Shared enums and structs — NO library dependencies
|
||||
// =====================================================================
|
||||
|
||||
enum class DeviceState : uint8_t {
|
||||
SILENT,
|
||||
ALERTING,
|
||||
WAKE
|
||||
};
|
||||
|
||||
enum class ScreenID : uint8_t {
|
||||
BOOT_SPLASH,
|
||||
WIFI_CONNECTING,
|
||||
WIFI_CONNECTED,
|
||||
WIFI_FAILED,
|
||||
ALERT,
|
||||
STATUS,
|
||||
OFF // backlight off, nothing to draw
|
||||
};
|
||||
|
||||
// Everything the display needs to render any screen
|
||||
struct ScreenState {
|
||||
ScreenID screen = ScreenID::BOOT_SPLASH;
|
||||
DeviceState deviceState = DeviceState::SILENT;
|
||||
bool blinkPhase = false;
|
||||
|
||||
// Alert
|
||||
char alertMessage[64] = "";
|
||||
|
||||
// WiFi
|
||||
bool wifiConnected = false;
|
||||
char wifiSSID[33] = "";
|
||||
int wifiRSSI = 0;
|
||||
char wifiIP[16] = "";
|
||||
|
||||
// NTP
|
||||
bool ntpSynced = false;
|
||||
char timeString[12] = "";
|
||||
|
||||
// System
|
||||
uint32_t uptimeMinutes = 0;
|
||||
uint32_t freeHeapKB = 0;
|
||||
bool networkOK = false;
|
||||
|
||||
// Debug
|
||||
bool debugMode = false;
|
||||
};
|
||||
|
||||
// Touch event passed from display to logic
|
||||
struct TouchEvent {
|
||||
bool pressed = false;
|
||||
uint16_t x = 0;
|
||||
uint16_t y = 0;
|
||||
};
|
||||
|
||||
@@ -1,58 +1,78 @@
|
||||
#include <TFT_eSPI.h>
|
||||
#include <SPI.h>
|
||||
/*
|
||||
* KLUBHAUS ALERT v5.0 — E32R35T Edition
|
||||
*
|
||||
* Target: LCDWiki E32R35T (ESP32-WROOM-32E + 3.5" ST7796S + XPT2046)
|
||||
*
|
||||
* Refactored: business logic separated from display code.
|
||||
* Business logic knows nothing about TFT_eSPI.
|
||||
* Display knows nothing about ntfy/WiFi/state machine.
|
||||
* They communicate through ScreenState (plain struct).
|
||||
*/
|
||||
|
||||
TFT_eSPI tft = TFT_eSPI();
|
||||
#include "Config.h"
|
||||
#include "DisplayManager.h"
|
||||
#include "DoorbellLogic.h"
|
||||
|
||||
DisplayManager display;
|
||||
DoorbellLogic logic;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(2000);
|
||||
unsigned long t = millis();
|
||||
while (!Serial && millis() - t < 3000) delay(10);
|
||||
delay(500);
|
||||
|
||||
// Backlight ON
|
||||
pinMode(27, OUTPUT);
|
||||
digitalWrite(27, HIGH);
|
||||
|
||||
// Config verification
|
||||
Serial.println("\n=== E32R35T Config ===");
|
||||
#if defined(USER_SETUP_LOADED)
|
||||
Serial.println("User_Setup: LOADED");
|
||||
#else
|
||||
Serial.println("User_Setup: DEFAULT (BAD!)");
|
||||
Serial.println("\n========================================");
|
||||
Serial.println(" KLUBHAUS ALERT v5.0 — E32R35T");
|
||||
#if DEBUG_MODE
|
||||
Serial.println(" *** DEBUG MODE — _test topics ***");
|
||||
#endif
|
||||
#if defined(USE_HSPI_PORT)
|
||||
Serial.println("SPI Bus: HSPI");
|
||||
#else
|
||||
Serial.println("SPI Bus: VSPI");
|
||||
#endif
|
||||
Serial.printf("MOSI:%d SCLK:%d MISO:%d\n", TFT_MOSI, TFT_SCLK, TFT_MISO);
|
||||
Serial.printf("CS:%d DC:%d RST:%d BL:%d\n", TFT_CS, TFT_DC, TFT_RST, TFT_BL);
|
||||
Serial.printf("TOUCH_CS:%d\n", TOUCH_CS);
|
||||
Serial.printf("SPI_FREQ:%d\n", SPI_FREQUENCY);
|
||||
Serial.println("======================\n");
|
||||
Serial.println("========================================");
|
||||
|
||||
tft.init();
|
||||
tft.setRotation(1);
|
||||
// 1. Init display hardware
|
||||
display.begin();
|
||||
|
||||
uint16_t colors[] = {TFT_RED, TFT_GREEN, TFT_BLUE, TFT_WHITE, TFT_BLACK};
|
||||
const char* names[] = {"RED", "GREEN", "BLUE", "WHITE", "BLACK"};
|
||||
// 2. Init logic (sets boot splash screen state)
|
||||
logic.begin();
|
||||
display.render(logic.getScreenState());
|
||||
delay(1500);
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
tft.fillScreen(colors[i]);
|
||||
Serial.printf("Screen: %s\n", names[i]);
|
||||
delay(1500);
|
||||
}
|
||||
// 3. WiFi (logic updates screen state, we render each phase)
|
||||
// We need a small coupling here for the blocking WiFi connect
|
||||
// This could be made async later
|
||||
logic.beginWiFi(); // sets screen to WIFI_CONNECTING
|
||||
display.render(logic.getScreenState());
|
||||
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setTextSize(3);
|
||||
tft.drawString("E32R35T Works!", 30, 100);
|
||||
Serial.println("Done!");
|
||||
logic.connectWiFiBlocking(); // blocks, sets CONNECTED or FAILED
|
||||
display.render(logic.getScreenState());
|
||||
delay(1500);
|
||||
|
||||
// 4. Finish boot
|
||||
logic.finishBoot();
|
||||
display.setBacklight(false);
|
||||
|
||||
Serial.println("[BOOT] Ready — monitoring ntfy.sh\n");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
uint16_t x, y;
|
||||
if (tft.getTouch(&x, &y)) {
|
||||
Serial.printf("Touch: %d, %d\n", x, y);
|
||||
tft.fillCircle(x, y, 4, TFT_YELLOW);
|
||||
}
|
||||
}
|
||||
// 1. Read touch
|
||||
TouchEvent touch = display.readTouch();
|
||||
logic.onTouch(touch);
|
||||
|
||||
// 2. Read serial commands
|
||||
if (Serial.available()) {
|
||||
String cmd = Serial.readStringUntil('\n');
|
||||
cmd.trim();
|
||||
logic.onSerialCommand(cmd);
|
||||
}
|
||||
|
||||
// 3. Update business logic
|
||||
logic.update();
|
||||
|
||||
// 4. Render
|
||||
const ScreenState& state = logic.getScreenState();
|
||||
display.setBacklight(state.screen != ScreenID::OFF);
|
||||
display.render(state);
|
||||
|
||||
delay(20);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ lazygit = "latest"
|
||||
|
||||
[env]
|
||||
FQBN = "esp32:esp32:esp32:UploadSpeed=921600,CPUFreq=240,FlashFreq=80,FlashMode=qio,FlashSize=4M,PartitionScheme=default,DebugLevel=info,EraseFlash=none"
|
||||
SKETCH_DIR = "{{cwd}}"
|
||||
|
||||
[tasks.install-core]
|
||||
run = "arduino-cli core update-index && arduino-cli core install esp32:esp32"
|
||||
@@ -25,8 +24,8 @@ depends = ["compile"]
|
||||
run = "arduino-cli upload --fqbn $FQBN -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyUSB\\|CP210\\|CH340' | head -1 | awk '{print $1}') ."
|
||||
|
||||
[tasks.monitor]
|
||||
depends = ["upload"]
|
||||
run = "arduino-cli monitor -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyUSB\\|CP210\\|CH340' | head -1 | awk '{print $1}') -c baudrate=115200"
|
||||
run = "tio /dev/ttyUSB0"
|
||||
|
||||
[tasks.all]
|
||||
depends = ["monitor"]
|
||||
depends = ["upload"]
|
||||
run = "mise run monitor"
|
||||
|
||||
Reference in New Issue
Block a user