From d6eb2cd5611c54f6859fcc180eda7247c24fd5e9 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Wed, 18 Feb 2026 18:58:37 -0800 Subject: [PATCH] feat(esp32-s3-lcd-43): add touch test harness and coordinate transformation --- AGENTS.md | 11 ++ boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp | 140 ++++++++++++++++++- boards/esp32-s3-lcd-43/DisplayDriverGFX.h | 9 ++ boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino | 2 +- libraries/KlubhausCore/src/Config.h | 1 + libraries/KlubhausCore/src/DoorbellLogic.cpp | 13 +- libraries/KlubhausCore/src/DoorbellLogic.h | 2 + libraries/KlubhausCore/src/ScreenState.h | 1 + vendor/esp32-s3-lcd-43/LovyanGFX | 1 + 9 files changed, 173 insertions(+), 7 deletions(-) create mode 160000 vendor/esp32-s3-lcd-43/LovyanGFX diff --git a/AGENTS.md b/AGENTS.md index 2ad11e9..48cf42b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,17 @@ Use `#pragma once` (not `#ifndef` guards). | `status` | Print state + memory info | | `reboot` | Restart device | +**Touch Test Commands** (ESP32-S3-LCD-4.3 only): +| Command | Action | +|---------|--------| +| `TEST:touch X Y press` | Inject synthetic press at raw panel coords (X,Y) | +| `TEST:touch X Y release` | Inject synthetic release at raw panel coords (X,Y) | +| `TEST:touch clear` | Clear test mode (required between touch sequences) | + +Note: The S3 touch panel is rotated 180° relative to the display. Use raw panel coordinates: +- Display (100,140) → Raw (700, 340) +- Display (700,140) → Raw (100, 340) + ## Monitor Daemon and Logging The build system includes a Python-based monitor agent that provides JSON logging and command pipes. diff --git a/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp b/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp index aa6b8ab..c8df1b7 100644 --- a/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp +++ b/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp @@ -58,16 +58,137 @@ int DisplayDriverGFX::width() { return _gfx ? _gfx->width() : DISP_W; } int DisplayDriverGFX::height() { return _gfx ? _gfx->height() : DISP_H; } -// ── Touch handling ── +// Transform touch coordinates to match display orientation +// GT911 touch panel on this board is rotated 180° relative to display +void DisplayDriverGFX::transformTouch(int* x, int* y) { + if(!x || !y) + return; + // Flip both axes: (0,0) becomes (width, height) + *x = DISP_W - *x; + *y = DISP_H - *y; +} + +// Test harness: parse serial commands to inject synthetic touches +// Commands: +// TEST:touch x y press - simulate press at (x, y) [raw panel coords] +// TEST:touch x y release - simulate release at (x, y) +// TEST:touch clear - clear test mode +bool DisplayDriverGFX::parseTestTouch(int* outX, int* outY, bool* outPressed) { + if(!Serial.available()) + return false; + + // Only consume if it starts with 'T' - don't steal other commands + // Use "TEST:" prefix to avoid conflict with [CMD] echo + if(Serial.peek() != 'T') { + return false; + } + + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + + if(!cmd.startsWith("TEST:touch")) + return false; + + // Parse: touch x y press|release + int firstSpace = cmd.indexOf(' '); + if(firstSpace < 0) + return false; + + String args = cmd.substring(firstSpace + 1); + args.trim(); + + if(args.equals("clear")) { + _testMode = false; + Serial.println("[TEST] Test mode cleared"); + return false; + } + + // Parse x y state + int secondSpace = args.indexOf(' '); + if(secondSpace < 0) + return false; + + String xStr = args.substring(0, secondSpace); + String yState = args.substring(secondSpace + 1); + yState.trim(); + + int x = xStr.toInt(); + int y = yState.substring(0, yState.indexOf(' ')).toInt(); + String state = yState.substring(yState.indexOf(' ') + 1); + state.trim(); + + bool pressed = state.equals("press"); + + Serial.printf("[TEST] Injecting touch: (%d,%d) %s\n", x, y, pressed ? "press" : "release"); + + if(outX) *outX = x; + if(outY) *outY = y; + if(outPressed) *outPressed = pressed; + + _testMode = true; + return true; +} + +// Touch handling TouchEvent DisplayDriverGFX::readTouch() { TouchEvent evt; if(!_gfx) return evt; + // Check for test injection via serial + int testX, testY; + bool testPressed; + if(parseTestTouch(&testX, &testY, &testPressed)) { + // Handle test touch with same logic as real touch + unsigned long now = millis(); + + if(testPressed && !_lastTouch.pressed) { + evt.pressed = true; + evt.downX = testX; + evt.downY = testY; + _lastTouch.downX = evt.downX; + _lastTouch.downY = evt.downY; + _touchBounced = false; + } else if(!testPressed && _lastTouch.pressed) { + evt.released = true; + evt.downX = _lastTouch.downX; + evt.downY = _lastTouch.downY; + _lastReleaseMs = now; + _touchBounced = true; + } + + if(testPressed) { + evt.x = testX; + evt.y = testY; + evt.downX = _lastTouch.downX; + evt.downY = _lastTouch.downY; + _pressStartMs = millis(); + } + + if(_touchBounced && now - _lastReleaseMs >= TOUCH_DEBOUNCE_MS) { + _touchBounced = false; + } + + _lastTouch.pressed = testPressed; + if(testPressed) { + _lastTouch.x = evt.x; + _lastTouch.y = evt.y; + } + return evt; + } + int32_t x, y; bool pressed = _gfx->getTouch(&x, &y); + // Debounce: ignore repeated press events within debounce window after release + unsigned long now = millis(); + if(pressed && _touchBounced) { + // Within debounce window - ignore this press + _lastTouch.pressed = pressed; + return evt; + } + // Detect transitions (press/release) if(pressed && !_lastTouch.pressed) { // Press transition: finger just touched down @@ -76,11 +197,15 @@ TouchEvent DisplayDriverGFX::readTouch() { evt.downY = static_cast(y); _lastTouch.downX = evt.downX; _lastTouch.downY = evt.downY; + _touchBounced = false; } else if(!pressed && _lastTouch.pressed) { // Release transition: finger just lifted evt.released = true; evt.downX = _lastTouch.downX; evt.downY = _lastTouch.downY; + // Start debounce window + _lastReleaseMs = now; + _touchBounced = true; } // Current position if still touched @@ -92,6 +217,11 @@ TouchEvent DisplayDriverGFX::readTouch() { _pressStartMs = millis(); } + // Check if debounce window has expired + if(_touchBounced && now - _lastReleaseMs >= TOUCH_DEBOUNCE_MS) { + _touchBounced = false; + } + // Track previous state _lastTouch.pressed = pressed; if(pressed) { @@ -252,15 +382,15 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) { _gfx->fillScreen(0x001030); // Dark blue // Header - _gfx->fillRect(0, 0, DISP_W, 30, 0x1A1A); // Dark gray - _gfx->setFont(&fonts::Font0); // Built-in minimal font + _gfx->fillRect(0, 0, DISP_W, 40, 0x1A1A); // Dark gray + _gfx->setFont(&fonts::Font2); _gfx->setTextColor(0xFFFF); _gfx->setTextSize(1); - _gfx->setCursor(5, 10); + _gfx->setCursor(10, 12); _gfx->printf("KLUBHAUS"); // WiFi status - _gfx->setCursor(DISP_W - 100, 10); + _gfx->setCursor(DISP_W - 120, 12); _gfx->printf("WiFi:%s", state.wifiSsid.length() > 0 ? "ON" : "OFF"); // Get tile layouts from library helper diff --git a/boards/esp32-s3-lcd-43/DisplayDriverGFX.h b/boards/esp32-s3-lcd-43/DisplayDriverGFX.h index a683ba2..a33466b 100644 --- a/boards/esp32-s3-lcd-43/DisplayDriverGFX.h +++ b/boards/esp32-s3-lcd-43/DisplayDriverGFX.h @@ -18,6 +18,9 @@ public: int width() override; int height() override; + // Transform touch coordinates (handles rotated touch panels) + void transformTouch(int* x, int* y) override; + // Dashboard tile mapping int dashboardTouch(int x, int y); @@ -25,6 +28,9 @@ public: static DisplayDriverGFX& instance(); private: + // Test harness: parse serial commands to inject synthetic touches + bool parseTestTouch(int* outX, int* outY, bool* outPressed); + // Helper rendering functions void drawBoot(const ScreenState& state); void drawAlert(const ScreenState& state); @@ -34,6 +40,9 @@ private: TouchEvent _lastTouch = { false, false, 0, 0, -1, -1 }; unsigned long _pressStartMs = 0; bool _isHolding = false; + unsigned long _lastReleaseMs = 0; + bool _touchBounced = false; + bool _testMode = false; // Screen tracking ScreenID _lastScreen = ScreenID::BOOT; diff --git a/boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino b/boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino index dd0298c..7afaa23 100644 --- a/boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino +++ b/boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino @@ -21,7 +21,7 @@ void setup() { } void loop() { - // Read touch + // Read touch (includes test injection via serial "touch x y press/release") TouchEvent evt = display.readTouch(); // State machine tick diff --git a/libraries/KlubhausCore/src/Config.h b/libraries/KlubhausCore/src/Config.h index 5be1acb..27a4077 100644 --- a/libraries/KlubhausCore/src/Config.h +++ b/libraries/KlubhausCore/src/Config.h @@ -22,6 +22,7 @@ #define HINT_ANIMATION_MS 2000 #define HINT_MIN_BRIGHTNESS 30 #define HINT_MAX_BRIGHTNESS 60 +#define TOUCH_DEBOUNCE_MS 100 // ── Loop yield (prevents Task Watchdog on ESP32) ── #ifndef LOOP_YIELD_MS diff --git a/libraries/KlubhausCore/src/DoorbellLogic.cpp b/libraries/KlubhausCore/src/DoorbellLogic.cpp index 4577eb3..3bb5060 100644 --- a/libraries/KlubhausCore/src/DoorbellLogic.cpp +++ b/libraries/KlubhausCore/src/DoorbellLogic.cpp @@ -223,6 +223,15 @@ void DoorbellLogic::onSilence() { void DoorbellLogic::silenceAlert() { onSilence(); } +void DoorbellLogic::dismissAlert() { + Serial.printf("[%lu] [DISMISS] Alert dismissed by user\n", millis()); + _state.deviceState = DeviceState::SILENT; + _state.screen = ScreenID::DASHBOARD; + _state.alertTitle = ""; + _state.alertBody = ""; + _display->render(_state); +} + void DoorbellLogic::onAdmin(const String& cmd) { Serial.printf("[ADMIN] %s\n", cmd.c_str()); if(cmd == "reboot") { @@ -364,7 +373,9 @@ int DoorbellLogic::handleTouch(const TouchEvent& evt) { } if(_state.screen == ScreenID::ALERT) { - Serial.println("[TOUCH] ALERT tap"); + Serial.printf("[%lu] [TOUCH] ALERT → DASHBOARD (dismiss)\n", millis()); + dismissAlert(); + return (int)TileAction::DISMISS; } } diff --git a/libraries/KlubhausCore/src/DoorbellLogic.h b/libraries/KlubhausCore/src/DoorbellLogic.h index da56459..3c190be 100644 --- a/libraries/KlubhausCore/src/DoorbellLogic.h +++ b/libraries/KlubhausCore/src/DoorbellLogic.h @@ -26,6 +26,8 @@ public: /// Externally trigger silence (e.g. hold-to-silence gesture). void silenceAlert(); + /// Dismiss alert and return to dashboard (user tap on alert screen). + void dismissAlert(); void setScreen(ScreenID s); /// Handle touch input — returns dashboard tile index if tapped, or -1. int handleTouch(const TouchEvent& evt); diff --git a/libraries/KlubhausCore/src/ScreenState.h b/libraries/KlubhausCore/src/ScreenState.h index c72d337..4d0c10a 100644 --- a/libraries/KlubhausCore/src/ScreenState.h +++ b/libraries/KlubhausCore/src/ScreenState.h @@ -12,6 +12,7 @@ enum class TileAction { NONE, ALERT, // Trigger alert SILENCE, // Silence alert + DISMISS, // Dismiss alert and return to dashboard STATUS, // Send heartbeat/status REBOOT, // Reboot device }; diff --git a/vendor/esp32-s3-lcd-43/LovyanGFX b/vendor/esp32-s3-lcd-43/LovyanGFX new file mode 160000 index 0000000..4299835 --- /dev/null +++ b/vendor/esp32-s3-lcd-43/LovyanGFX @@ -0,0 +1 @@ +Subproject commit 42998359d8a2eaf188643da7813122c6c3efd2fd