diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index ec6239207..f90f4f4ac 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.524 - - renovate@43.139.6 + - renovate@43.141.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 diff --git a/bin/show-unmerged-prs.sh b/bin/show-unmerged-prs.sh new file mode 100755 index 000000000..2a76f63d6 --- /dev/null +++ b/bin/show-unmerged-prs.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +# Script to show commits in develop that are not in master +# with their associated PR info and commit hashes +# +# Usage: +# ./show-unmerged-prs.sh # Show all unmerged commits +# ./show-unmerged-prs.sh --bugfix # Show only bugfix-labeled PRs + +set -e + +REPO="firmware" +OWNER="meshtastic" +BASE_BRANCH="master" +HEAD_BRANCH="develop" +LIMIT=100 +FILTER_LABEL="" + +# Parse arguments +for arg in "$@"; do + case $arg in + --bugfix) + FILTER_LABEL="bugfix" + shift + ;; + --feature) + FILTER_LABEL="feature" + shift + ;; + --help) + echo "Usage: $0 [OPTIONS]" + echo "Options:" + echo " --bugfix Show only PRs labeled with 'bugfix'" + echo " --feature Show only PRs labeled with 'feature'" + echo " --help Show this help message" + exit 0 + ;; + esac +done + +if [ -n "$FILTER_LABEL" ]; then + echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH (filtered by label: $FILTER_LABEL)..." +else + echo "Fetching commits in $HEAD_BRANCH that are not in $BASE_BRANCH..." +fi +echo "" + +# Check if gh CLI is available +if ! command -v gh &> /dev/null; then + echo "ERROR: GitHub CLI (gh) not found. Please install it first." + echo "Visit: https://cli.github.com/" + exit 1 +fi + +# Get commits in develop that are not in master +# For each commit, try to find associated PR +git fetch origin develop master 2>/dev/null || true + +# Use git to get the list of commits +commits=$(git log --pretty=format:"%H|%s" origin/master..origin/develop | head -n $LIMIT) + +count=0 +displayed=0 +echo "Commits in $HEAD_BRANCH not in $BASE_BRANCH:" +echo "==============================================" +echo "" + +while IFS='|' read -r hash subject; do + ((count++)) + + # Try to find the PR for this commit + # Extract PR number, title, description, and labels + pr_response=$(gh api -X GET "/repos/$OWNER/$REPO/commits/$hash/pulls" \ + -H "Accept: application/vnd.github.v3+json" 2>/dev/null | \ + jq -r '.[0] | "\(.number)|\(.title)|\(.body // "No description")|\(.labels | map(.name) | join(","))"' 2>/dev/null || echo "||||") + + if [ -z "$pr_response" ] || [ "$pr_response" = "||||" ]; then + # If no PR found, skip if filter is active, otherwise show the commit + if [ -z "$FILTER_LABEL" ]; then + ((displayed++)) + echo "[$displayed] Commit: $hash" + echo " Subject: $subject" + echo " PR: Not found in GitHub" + echo "" + fi + else + IFS='|' read -r pr_num pr_title pr_desc pr_labels <<< "$pr_response" + + # Check if filter matches + if [ -n "$FILTER_LABEL" ]; then + # Only show if the label is in the labels list + if ! echo "$pr_labels" | grep -q "$FILTER_LABEL"; then + continue + fi + fi + + ((displayed++)) + echo "[$displayed] PR #$pr_num - $pr_title" + echo " Commit: $hash" + if [ -n "$pr_desc" ] && [ "$pr_desc" != "No description" ]; then + # Truncate description to 200 chars + desc_short="${pr_desc:0:200}" + [ ${#pr_desc} -gt 200 ] && desc_short+="..." + echo " Description: $desc_short" + fi + if [ -n "$pr_labels" ] && [ "$pr_labels" != "" ]; then + echo " Labels: $pr_labels" + fi + echo "" + fi +done <<< "$commits" + +echo "" +if [ -n "$FILTER_LABEL" ]; then + echo "Done. Showing $displayed PRs with label '$FILTER_LABEL' from $displayed commits checked." +else + echo "Done. Showing $displayed commits from $HEAD_BRANCH not in $BASE_BRANCH." +fi diff --git a/platformio.ini b/platformio.ini index 67ff8bc6e..eae340578 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,6 +29,7 @@ build_flags = -Wno-missing-field-initializers -DUSE_THREAD_NAMES -DTINYGPS_OPTION_NO_CUSTOM_FIELDS -DPB_ENABLE_MALLOC=1 + -DPB_VALIDATE_UTF8=1 -DRADIOLIB_EXCLUDE_CC1101=1 -DRADIOLIB_EXCLUDE_NRF24=1 -DRADIOLIB_EXCLUDE_RF69=1 @@ -66,7 +67,7 @@ monitor_speed = 115200 monitor_filters = direct lib_deps = # renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master - https://github.com/meshtastic/esp8266-oled-ssd1306/archive/21e484f409cde18d44012caef84c244eb5ca28f3.zip + https://github.com/meshtastic/esp8266-oled-ssd1306/archive/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip # renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip # renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master diff --git a/protobufs b/protobufs index d004f503b..249a80855 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit d004f503bbf3498fd689013a794e2a0e384b3f19 +Subproject commit 249a80855a2adb76fb0904dac8bf6285d45f330f diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index f5a805487..da9fd468c 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -48,8 +48,10 @@ bool mixWithLoRaEntropy(uint8_t *buffer, size_t length) // and return false so callers know no extra mixing occurred. RadioLibInterface *radio = RadioLibInterface::instance; if (!radio) { - // Intentionally silent: this path runs during portduinoSetup() before the - // console/SerialConsole is initialized, so LOG_* here would dereference a null pointer. + // This path can run during portduinoSetup() before the console is initialized. +#ifndef PIO_UNIT_TESTING + LOG_ERROR("No radio instance available to provide entropy"); +#endif return false; } diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 201a703e2..3798daf28 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -1,6 +1,7 @@ #include "TypeConversions.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "mesh/generated/meshtastic/mesh.pb.h" +#include "meshUtils.h" meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfoLite *lite) { @@ -82,8 +83,10 @@ meshtastic_UserLite TypeConversions::ConvertToUserLite(meshtastic_User user) strncpy(lite.long_name, user.long_name, sizeof(lite.long_name)); lite.long_name[sizeof(lite.long_name) - 1] = '\0'; + sanitizeUtf8(lite.long_name, sizeof(lite.long_name)); strncpy(lite.short_name, user.short_name, sizeof(lite.short_name)); lite.short_name[sizeof(lite.short_name) - 1] = '\0'; + sanitizeUtf8(lite.short_name, sizeof(lite.short_name)); lite.hw_model = user.hw_model; lite.role = user.role; lite.is_licensed = user.is_licensed; @@ -102,8 +105,10 @@ meshtastic_User TypeConversions::ConvertToUser(uint32_t nodeNum, meshtastic_User snprintf(user.id, sizeof(user.id), "!%08x", nodeNum); strncpy(user.long_name, lite.long_name, sizeof(user.long_name)); user.long_name[sizeof(user.long_name) - 1] = '\0'; + sanitizeUtf8(user.long_name, sizeof(user.long_name)); strncpy(user.short_name, lite.short_name, sizeof(user.short_name)); user.short_name[sizeof(user.short_name) - 1] = '\0'; + sanitizeUtf8(user.short_name, sizeof(user.short_name)); user.hw_model = lite.hw_model; user.role = lite.role; user.is_licensed = lite.is_licensed; diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 7e71f3f7a..0e14334d5 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -289,7 +289,14 @@ typedef enum _meshtastic_Config_LoRaConfig_RegionCode { /* ITU Region 1 Amateur Radio 2m band (144-146 MHz) */ meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M = 27, /* ITU Region 2 / 3 Amateur Radio 2m band (144-148 MHz) */ - meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M = 28 + meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M = 28, + /* EU 866MHz band (Band no. 47b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD) */ + meshtastic_Config_LoRaConfig_RegionCode_EU_866 = 29, + /* EU 874MHz and 917MHz bands (Band no. 1 and 4 of 2022/172/EC and subsequent amendments) for Non-specific short-range devices (SRD) */ + meshtastic_Config_LoRaConfig_RegionCode_EU_874 = 30, + meshtastic_Config_LoRaConfig_RegionCode_EU_917 = 31, + /* EU 868MHz band, with narrow presets */ + meshtastic_Config_LoRaConfig_RegionCode_EU_N_868 = 32 } meshtastic_Config_LoRaConfig_RegionCode; /* Standard predefined channel settings @@ -319,7 +326,24 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset { meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO = 8, /* Long Range - Turbo This preset performs similarly to LongFast, but with 500Khz bandwidth. */ - meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9 + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9, + /* Lite Fast + Medium range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. + Comparable link budget to MEDIUM_FAST but compliant with Band no. 47b of 2006/771/EC. */ + meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST = 10, + /* Lite Slow + Medium-to-moderate range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. + Comparable link budget to LONG_FAST but compliant with Band no. 47b of 2006/771/EC. */ + meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW = 11, + /* Narrow Fast + Medium-to-moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. + Comparable link budget to SHORT_SLOW, but with half the data rate. + Intended to avoid interference with other devices. */ + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST = 12, + /* Narrow Slow + Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. + Comparable link budget and data rate to LONG_FAST. */ + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13 } meshtastic_Config_LoRaConfig_ModemPreset; typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode { @@ -706,12 +730,12 @@ extern "C" { #define _meshtastic_Config_DisplayConfig_CompassOrientation_ARRAYSIZE ((meshtastic_Config_DisplayConfig_CompassOrientation)(meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED+1)) #define _meshtastic_Config_LoRaConfig_RegionCode_MIN meshtastic_Config_LoRaConfig_RegionCode_UNSET -#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M -#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU23_2M+1)) +#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_EU_N_868 +#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868+1)) #define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST -#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO -#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO+1)) +#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW +#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW+1)) #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index d7ff32cb4..f22825030 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -315,6 +315,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_THINKNODE_M7 = 129, meshtastic_HardwareModel_THINKNODE_M8 = 130, meshtastic_HardwareModel_THINKNODE_M9 = 131, + /* The Heltec-V4-R8 uses an ESP32S3R8 chip, plus an SX1262. */ + meshtastic_HardwareModel_HELTEC_V4_R8 = 132, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ diff --git a/src/meshUtils.cpp b/src/meshUtils.cpp index 1a4497101..89c548887 100644 --- a/src/meshUtils.cpp +++ b/src/meshUtils.cpp @@ -117,4 +117,93 @@ size_t pb_string_length(const char *str, size_t max_len) } } return len; +} + +bool sanitizeUtf8(char *buf, size_t bufSize) +{ + if (!buf || bufSize == 0) + return false; + + // Ensure null-terminated within buffer + buf[bufSize - 1] = '\0'; + + bool replaced = false; + size_t i = 0; + size_t len = strlen(buf); + + while (i < len) { + uint8_t b = (uint8_t)buf[i]; + + // Determine expected sequence length from lead byte + size_t seqLen; + uint32_t minCodepoint; + if (b <= 0x7F) { + // ASCII — valid single byte + i++; + continue; + } else if ((b & 0xE0) == 0xC0) { + seqLen = 2; + minCodepoint = 0x80; // Reject overlong + } else if ((b & 0xF0) == 0xE0) { + seqLen = 3; + minCodepoint = 0x800; + } else if ((b & 0xF8) == 0xF0) { + seqLen = 4; + minCodepoint = 0x10000; + } else { + // Invalid lead byte (0x80-0xBF or 0xF8+) + buf[i] = '?'; + replaced = true; + i++; + continue; + } + + // Check that we have enough bytes remaining + if (i + seqLen > len) { + // Truncated sequence at end of string — replace remaining bytes + for (size_t j = i; j < len; j++) { + buf[j] = '?'; + } + replaced = true; + break; + } + + // Validate continuation bytes (must be 10xxxxxx) + bool valid = true; + for (size_t j = 1; j < seqLen; j++) { + if (((uint8_t)buf[i + j] & 0xC0) != 0x80) { + valid = false; + break; + } + } + + if (valid) { + // Decode codepoint to check for overlong encodings and surrogates + uint32_t cp = 0; + if (seqLen == 2) + cp = b & 0x1F; + else if (seqLen == 3) + cp = b & 0x0F; + else + cp = b & 0x07; + for (size_t j = 1; j < seqLen; j++) + cp = (cp << 6) | ((uint8_t)buf[i + j] & 0x3F); + + if (cp < minCodepoint || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) { + // Overlong encoding, out of Unicode range, or surrogate half + valid = false; + } + } + + if (valid) { + i += seqLen; + } else { + // Replace only the lead byte; continuation bytes will be caught on next iteration + buf[i] = '?'; + replaced = true; + i++; + } + } + + return replaced; } \ No newline at end of file diff --git a/src/meshUtils.h b/src/meshUtils.h index fe94ead2f..4c450b3c4 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -56,6 +56,10 @@ const std::string vformat(const char *const zcFormat, ...); // Get actual string length for nanopb char array fields. size_t pb_string_length(const char *str, size_t max_len); +// Sanitize a fixed-size char buffer in-place by replacing invalid UTF-8 sequences with '?'. +// Ensures the result is null-terminated within bufSize. Returns true if any bytes were replaced. +bool sanitizeUtf8(char *buf, size_t bufSize); + /// Calculate 2^n without calling pow() - used for spreading factor and other calculations inline uint32_t pow_of_2(uint32_t n) { diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 0d9d1b3b9..46411fe13 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -626,10 +626,14 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) if (*o.long_name) { changed |= strcmp(owner.long_name, o.long_name); strncpy(owner.long_name, o.long_name, sizeof(owner.long_name)); + owner.long_name[sizeof(owner.long_name) - 1] = '\0'; + sanitizeUtf8(owner.long_name, sizeof(owner.long_name)); } if (*o.short_name) { changed |= strcmp(owner.short_name, o.short_name); strncpy(owner.short_name, o.short_name, sizeof(owner.short_name)); + owner.short_name[sizeof(owner.short_name) - 1] = '\0'; + sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); } snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); @@ -1430,7 +1434,11 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) // Set call sign and override lora limitations for licensed use strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name)); + owner.long_name[sizeof(owner.long_name) - 1] = '\0'; + sanitizeUtf8(owner.long_name, sizeof(owner.long_name)); strncpy(owner.short_name, p.short_name, sizeof(owner.short_name)); + owner.short_name[sizeof(owner.short_name) - 1] = '\0'; + sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); owner.is_licensed = true; config.lora.override_duty_cycle = true; config.lora.tx_power = p.tx_power; diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp new file mode 100644 index 000000000..a829bb980 --- /dev/null +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -0,0 +1,709 @@ +#include "configuration.h" + +#ifdef T5_S3_EPAPER_PRO + +#include "Observer.h" +#include "TouchDrvGT911.hpp" +#include "Wire.h" +#include "buzz.h" +#include "concurrency/OSThread.h" +#include "input/InputBroker.h" +#include "input/TouchScreenImpl1.h" +#include "main.h" +#include "sleep.h" +#include + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +#include "graphics/niche/InkHUD/InkHUD.h" +#include "graphics/niche/InkHUD/Persistence.h" +#include "graphics/niche/InkHUD/SystemApplet.h" + +// Bridges touch events from TouchScreenImpl1 directly into InkHUD, +// bypassing the InputBroker (which is excluded in InkHUD builds). +// Routing mirrors the mini-epaper-s3 two-way rocker pattern: +// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) +// - Nav up/down: navUp/navDown always (menu scroll) +// - Tap/long-press: direct touch point dispatch (with fallback to short/long button semantics) +class TouchInkHUDBridge : public Observer +{ + int onNotify(const InputEvent *e) override + { + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + + // Keep alignment in sync with the current rotation so that visual-frame gestures + // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. + inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; + + // Check whether a system applet (e.g. menu) is currently handling input + bool systemHandlingInput = false; + for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + switch (e->inputEvent) { + case INPUT_BROKER_USER_PRESS: + inkhud->touchTap(e->touchX, e->touchY); + break; + case INPUT_BROKER_SELECT: + inkhud->touchLongPress(e->touchX, e->touchY); + break; + case INPUT_BROKER_LEFT: + if (systemHandlingInput) + inkhud->touchNavUp(); + else + inkhud->prevApplet(); + break; + case INPUT_BROKER_RIGHT: + if (systemHandlingInput) + inkhud->touchNavDown(); + else + inkhud->nextApplet(); + break; + case INPUT_BROKER_UP: + inkhud->touchNavUp(); + break; + case INPUT_BROKER_DOWN: + inkhud->touchNavDown(); + break; + default: + break; + } + return 0; + } +}; + +static TouchInkHUDBridge touchBridge; +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +TouchDrvGT911 touch; + +namespace +{ +constexpr uint8_t BACKLIGHT_ON_LEVEL = HIGH; +constexpr uint8_t BACKLIGHT_OFF_LEVEL = LOW; +volatile bool backlightUserEnabled = true; +volatile bool backlightForcedByTimeout = false; +volatile bool backlightForcedBySleep = false; + +void applyBacklightState() +{ + const bool shouldOn = backlightUserEnabled && !backlightForcedByTimeout && !backlightForcedBySleep; + digitalWrite(BOARD_BL_EN, shouldOn ? BACKLIGHT_ON_LEVEL : BACKLIGHT_OFF_LEVEL); +} + +volatile bool touchInputEnabled = true; +volatile bool touchForcedByTimeout = false; +volatile bool touchControllerReady = false; +volatile bool touchLightSleepActive = false; +volatile bool touchNeedsWake = false; +volatile bool touchIndicatorRefreshPending = false; +volatile uint32_t touchResumeBlockUntilMs = 0; +volatile uint32_t touchStateEpoch = 1; +volatile bool homeCapButtonEventsEnabled = false; +#if HAS_SCREEN +uint32_t lastTouchIndicatorMs = 0; +#endif + +void showTouchIndicator(const char *text) +{ +#if HAS_SCREEN +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // InkHUD builds render a dedicated bottom-edge "TOUCH OFF" overlay instead of popup banners. + (void)text; + return; +#else + // Keep repeated notifications low profile and non-spammy. + if ((millis() - lastTouchIndicatorMs) < 500) { + return; + } + lastTouchIndicatorMs = millis(); + if (screen) { + screen->showSimpleBanner(text, 1400); + } +#endif +#else + (void)text; +#endif +} + +#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) +bool readPca9535Port1(uint8_t *value) +{ + if (!value) { + return false; + } + + Wire.beginTransmission(BOARD_PCA9535_ADDR); + Wire.write((uint8_t)0x01); // input port 1 + if (Wire.endTransmission(false) != 0) { + return false; + } + if (Wire.requestFrom((uint8_t)BOARD_PCA9535_ADDR, (uint8_t)1) != 1) { + return false; + } + + *value = Wire.read(); + return true; +} + +bool isPca9535SideKeyPressed() +{ + uint8_t port1 = 0xFF; + if (!readPca9535Port1(&port1)) { + return false; + } + + return (port1 & BOARD_PCA9535_BUTTON_MASK) == 0; +} + +class SideKeyInterruptThread : public concurrency::OSThread +{ + public: + SideKeyInterruptThread() : concurrency::OSThread("t5s3SideKeyInt", SAMPLE_MS) + { + // Do not run unless an edge arrives. + OSThread::disable(); + instance = this; +#ifdef ARCH_ESP32 + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif + } + + void begin() + { + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); + } + + protected: + int32_t runOnce() override + { + const uint32_t now = millis(); + + if (now < touchResumeBlockUntilMs) { + resetStateAndStop(); + return OSThread::disable(); + } + + if (touchLightSleepActive) { + resetStateAndStop(); + return OSThread::disable(); + } + + // Ignore side-key handling while BOOT/user button is held. + if (digitalRead(BUTTON_PIN) == LOW) { + resetStateAndStop(); + return OSThread::disable(); + } + + switch (state) { + case State::IRQ_PENDING: + // Initial debounce after expander interrupt edge. + if ((uint32_t)(now - irqAtMs) < DEBOUNCE_MS) { + return SAMPLE_MS; + } + + if (isPca9535SideKeyPressed()) { + state = State::PRESSED; + pressStartMs = now; + return SAMPLE_MS; + } + + // Spurious/cleared edge. + resetStateAndStop(); + return OSThread::disable(); + + case State::PRESSED: { + if (isPca9535SideKeyPressed()) { + // Fire long-press action as soon as threshold is reached, without waiting for release. + if (!longPressFired && (uint32_t)(now - pressStartMs) >= LONG_PRESS_MIN_MS && + (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { + t5BacklightToggleUser(); + longPressFired = true; + lastActionMs = now; + } + return SAMPLE_MS; + } + + // Released: if long-press already fired, do nothing. Otherwise classify short press. + const uint32_t heldMs = now - pressStartMs; + if (!longPressFired && heldMs >= SHORT_PRESS_MIN_MS && (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { + // If timeout forced touch/backlight off, short-press acts as a wake action first. + if (t5TouchIsForcedByTimeout()) { + t5TouchHandleUserInput(); + t5BacklightHandleUserInput(); + } else { + toggleTouchInputEnabled(); + } + lastActionMs = now; + } + + resetStateAndStop(); + return OSThread::disable(); + } + + case State::REST: + default: + return OSThread::disable(); + } + } + + private: + enum class State : uint8_t { + REST, + IRQ_PENDING, + PRESSED, + }; + + static constexpr uint32_t SAMPLE_MS = 15; + static constexpr uint32_t DEBOUNCE_MS = 25; + static constexpr uint32_t SHORT_PRESS_MIN_MS = 30; + static constexpr uint32_t LONG_PRESS_MIN_MS = 450; + static constexpr uint32_t ACTION_COOLDOWN_MS = 180; + + static SideKeyInterruptThread *instance; + + static void isr() + { + if (instance) { + instance->onInterruptEdge(); + } + } + + void onInterruptEdge() + { + if (touchLightSleepActive) { + return; + } + const uint32_t now = millis(); + if (now < touchResumeBlockUntilMs) { + return; + } + if (state != State::REST) { + return; + } + + state = State::IRQ_PENDING; + irqAtMs = millis(); + startThread(); + } + + void startThread() + { + if (!OSThread::enabled) { + OSThread::setIntervalFromNow(0); + OSThread::enabled = true; + runASAP = true; + } + } + + void resetStateAndStop() + { + state = State::REST; + longPressFired = false; + if (OSThread::enabled) { + OSThread::disable(); + } + } + +#ifdef ARCH_ESP32 + int onLightSleep(void *) + { + detachInterrupt(BOARD_PCA9535_INT); + // Clear any latched PCA9535 interrupt before enabling GPIO wake. + // If INT is left asserted low, light sleep exits immediately. + uint8_t ignored = 0xFF; + (void)readPca9535Port1(&ignored); + resetStateAndStop(); + return 0; + } + + int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) + { + (void)cause; + // Consume any pending interrupt source before reattaching ISR. + uint8_t ignored = 0xFF; + (void)readPca9535Port1(&ignored); + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); + + return 0; + } + + CallbackObserver lsObserver{this, &SideKeyInterruptThread::onLightSleep}; + CallbackObserver lsEndObserver{this, + &SideKeyInterruptThread::onLightSleepEnd}; +#endif + + volatile State state = State::REST; + volatile uint32_t irqAtMs = 0; + uint32_t pressStartMs = 0; + bool longPressFired = false; + uint32_t lastActionMs = 0; +}; + +SideKeyInterruptThread *SideKeyInterruptThread::instance = nullptr; +SideKeyInterruptThread *sideKeyThread = nullptr; +#endif + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +void refreshTouchIndicatorInInkHUD(bool async = true) +{ + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + NicheGraphics::InkHUD::SystemApplet *touchStatus = nullptr; + for (auto *sa : inkhud->systemApplets) { + if (sa && sa->name && strcmp(sa->name, "TouchStatus") == 0) { + touchStatus = sa; + break; + } + } + + if (touchStatus) { + if (inkhud->isTouchEnabled()) + touchStatus->sendToBackground(); + else + touchStatus->bringToForeground(); + } + + // Re-render all applets so touch-status visibility changes are immediately reflected. + inkhud->forceUpdate(NicheGraphics::Drivers::EInk::UpdateTypes::FAST, true, async); +} +#endif + +} // namespace + +void t5BacklightSetUserEnabled(bool enabled) +{ + backlightUserEnabled = enabled; + if (enabled) { + // Manual ON should release auto-off gates. + backlightForcedByTimeout = false; + backlightForcedBySleep = false; + } + applyBacklightState(); +} + +bool t5BacklightIsUserEnabled() +{ + return backlightUserEnabled; +} + +void t5BacklightToggleUser() +{ + t5BacklightSetUserEnabled(!backlightUserEnabled); +} + +void t5BacklightSetForcedByTimeout(bool forced) +{ + backlightForcedByTimeout = forced; + applyBacklightState(); +} + +void t5BacklightSetForcedBySleep(bool forced) +{ + backlightForcedBySleep = forced; + applyBacklightState(); +} + +void t5BacklightHandleUserInput() +{ + // Screen-timeout should be lifted by direct user interaction. + backlightForcedByTimeout = false; + applyBacklightState(); +} + +void t5TouchSetForcedByTimeout(bool forced) +{ + touchForcedByTimeout = forced; + touchStateEpoch++; + touchIndicatorRefreshPending = true; + + if (forced) { + // While timeout-forced, keep controller asleep to avoid stale IRQ chatter. + touchNeedsWake = false; + if (touchControllerReady && !touchLightSleepActive) { + touch.sleep(); + } + } else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) { + // Defer wake until readTouch() so I2C settles post-state transition. + touchNeedsWake = true; + } + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (!touchLightSleepActive) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } +#endif +} + +bool t5TouchIsForcedByTimeout() +{ + return touchForcedByTimeout; +} + +void t5TouchHandleUserInput() +{ + t5TouchSetForcedByTimeout(false); +} + +void t5SetHomeCapButtonEventsEnabled(bool enabled) +{ + homeCapButtonEventsEnabled = enabled; +} + +bool isTouchInputEnabled() +{ + return touchInputEnabled && !touchForcedByTimeout && !touchLightSleepActive; +} + +void setTouchInputEnabled(bool enabled, bool showIndicator) +{ + if (touchInputEnabled == enabled) { + LOG_DEBUG("touchscreen1: setTouchInputEnabled no-op en=%d", enabled); + return; + } + + LOG_DEBUG("touchscreen1: setTouchInputEnabled %d -> %d (showIndicator=%d)", touchInputEnabled, enabled, showIndicator); + touchInputEnabled = enabled; + touchStateEpoch++; + + if (enabled) { + touchNeedsWake = touchControllerReady; + if (touchControllerReady && !touchLightSleepActive) { + LOG_DEBUG("touchscreen1: wakeup() on enable"); + touch.wakeup(); + touchNeedsWake = false; + } + } else { + touchNeedsWake = false; + if (touchControllerReady && !touchLightSleepActive) { + LOG_DEBUG("touchscreen1: sleep() on disable"); + touch.sleep(); + } + if (showIndicator) { + showTouchIndicator("Touch OFF"); + touchIndicatorRefreshPending = true; + } + } + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (showIndicator && !touchLightSleepActive) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } +#endif +} + +void toggleTouchInputEnabled() +{ + setTouchInputEnabled(!touchInputEnabled, true); +} + +// Commands the GT911 into standby before the Wire bus is torn down. +// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. +struct TouchDeepSleepObserver { + int onDeepSleep(void *) + { + touch.sleep(); + return 0; + } + CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; +} static touchDeepSleepObserver; + +#ifdef ARCH_ESP32 +struct TouchLightSleepObserver { + int onLightSleep(void *) + { + touchLightSleepActive = true; +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Render touch-off overlay before sleeping so user sees touch is unavailable. + touchIndicatorRefreshPending = true; + refreshTouchIndicatorInInkHUD(false); + touchIndicatorRefreshPending = false; +#endif + return 0; + } + + CallbackObserver observer{this, &TouchLightSleepObserver::onLightSleep}; +} static touchLightSleepObserver; + +struct TouchLightSleepEndObserver { + int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) + { + (void)cause; + touchLightSleepActive = false; + + if (!touchControllerReady) { + return 0; + } + + if (touchInputEnabled && !touchForcedByTimeout) { + touchNeedsWake = true; + } else { + touchNeedsWake = false; + } + + touchStateEpoch++; + touchResumeBlockUntilMs = millis() + 150; + touchIndicatorRefreshPending = !isTouchInputEnabled(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Clear sleep-time touch overlay after wake. + touchIndicatorRefreshPending = true; + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; +#endif + return 0; + } + + CallbackObserver observer{this, + &TouchLightSleepEndObserver::onLightSleepEnd}; +} static touchLightSleepEndObserver; +#endif + +bool readTouch(int16_t *x, int16_t *y) +{ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + static uint32_t suppressUntilMs = 0; + static uint32_t seenTouchStateEpoch = 0; + + // Reset transient gesture helpers whenever touch mode changes. + if (seenTouchStateEpoch != touchStateEpoch) { + seenTouchStateEpoch = touchStateEpoch; + suppressUntilMs = 0; + } + + // Let buses and peripherals settle briefly after light-sleep wake. + if (millis() < touchResumeBlockUntilMs) { + return false; + } + + if (touchIndicatorRefreshPending) { + refreshTouchIndicatorInInkHUD(); + touchIndicatorRefreshPending = false; + } + + if (!isTouchInputEnabled()) { + return false; + } + + if (touchNeedsWake && touchControllerReady) { + LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); + touch.wakeup(); + touchNeedsWake = false; + suppressUntilMs = millis() + 60; + return false; + } + + // After a recovery pulse, emit a brief "released" window so gesture state can reset. + if (suppressUntilMs != 0 && millis() < suppressUntilMs) { + return false; + } +#endif + + if (!digitalRead(GT911_PIN_INT)) { + int16_t raw_x; + int16_t raw_y; + if (touch.getPoint(&raw_x, &raw_y)) { +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. + // rotation=3 is the physical identity (device's default orientation). + switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { + default: + case 3: + *x = raw_x; + *y = raw_y; + break; // identity + case 2: + *x = (EPD_WIDTH - 1) - raw_y; + *y = raw_x; + break; // 90° CW tilt + case 1: + *x = (EPD_HEIGHT - 1) - raw_x; + *y = (EPD_WIDTH - 1) - raw_y; + break; // 180° flip + case 0: + *x = raw_y; + *y = (EPD_HEIGHT - 1) - raw_x; + break; // 90° CCW tilt + } +#else + *x = raw_x; + *y = raw_y; +#endif + LOG_DEBUG("touched(%d/%d)", *x, *y); + return true; + } + } + + return false; +} + +void variant_shutdown() +{ + // Ensure backlight is off during deep sleep. + t5BacklightSetForcedBySleep(true); +} + +void lateInitVariant() +{ + touch.setPins(GT911_PIN_RST, GT911_PIN_INT); + if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { + // Match LilyGO sample behavior: GT911 center/home capacitive key callback. + touch.setHomeButtonCallback( + [](void *user_data) { +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + if (!homeCapButtonEventsEnabled) { + return; + } + + static uint32_t lastHomeMs = 0; + const uint32_t now = millis(); + if ((uint32_t)(now - lastHomeMs) < 220) { + return; // debounce repeated key reports while still touched + } + lastHomeMs = now; + + auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); + if (inkhud) { + // Route through InkHUD EXIT/HOME path (menu close, etc). + inkhud->exitShort(); + } +#else + (void)user_data; +#endif + }, + nullptr); + touchControllerReady = true; + touchInputEnabled = true; + touchForcedByTimeout = false; + touchLightSleepActive = false; + touchStateEpoch++; + touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); +#ifdef ARCH_ESP32 + touchLightSleepObserver.observer.observe(¬ifyLightSleep); + touchLightSleepEndObserver.observer.observe(¬ifyLightSleepEnd); +#endif + touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); + touchScreenImpl1->init(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + touchBridge.observe(touchScreenImpl1); +#endif + } else { + touchControllerReady = false; + LOG_ERROR("Failed to find touch controller!"); + } + +#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) + // Start side-key interrupt handling after touch init is complete. + if (!sideKeyThread) { + sideKeyThread = new SideKeyInterruptThread(); + sideKeyThread->begin(); + } +#endif +} +#endif diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 307e35b0c..52e45cccc 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -1,6 +1,7 @@ #include "NRF52Bluetooth.h" #include "BLEDfuSecure.h" #include "BluetoothCommon.h" +#include "HardwareRNG.h" #include "PowerFSM.h" #include "configuration.h" #include "main.h" @@ -272,9 +273,13 @@ void NRF52Bluetooth::setup() Bluefruit.setTxPower(NRF52_BLE_TX_POWER); #endif if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { - configuredPasskey = config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN - ? config.bluetooth.fixed_pin - : random(100000, 999999); + if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN) { + configuredPasskey = config.bluetooth.fixed_pin; + } else { + uint32_t hwrand = 0; + HardwareRNG::fill(reinterpret_cast(&hwrand), sizeof(hwrand)); + configuredPasskey = hwrand % 900000u + 100000u; + } auto pinString = std::to_string(configuredPasskey); LOG_INFO("Bluetooth pin set to '%i'", configuredPasskey); Bluefruit.Security.setPIN(pinString.c_str()); diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp new file mode 100644 index 000000000..7ac64653d --- /dev/null +++ b/test/test_utf8/test_main.cpp @@ -0,0 +1,195 @@ +#include "meshUtils.h" +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// --- Valid UTF-8 should pass through unchanged --- + +void test_ascii_unchanged() +{ + char buf[32] = "Hello World"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("Hello World", buf); +} + +void test_valid_2byte_unchanged() +{ + // "café" — é is C3 A9 + char buf[16] = "caf\xC3\xA9"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("caf\xC3\xA9", buf); +} + +void test_valid_3byte_unchanged() +{ + // "€" is E2 82 AC + char buf[16] = "\xE2\x82\xAC"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("\xE2\x82\xAC", buf); +} + +void test_valid_4byte_emoji_unchanged() +{ + // 🌙 is F0 9F 8C 99 + char buf[16] = "\xF0\x9F\x8C\x99"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("\xF0\x9F\x8C\x99", buf); +} + +void test_valid_mixed_unchanged() +{ + // "Hi 🌙!" — mix of ASCII and 4-byte + char buf[16] = "Hi \xF0\x9F\x8C\x99!"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("Hi \xF0\x9F\x8C\x99!", buf); +} + +void test_empty_string() +{ + char buf[8] = ""; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("", buf); +} + +// --- Invalid sequences observed in the wild --- + +void test_truncated_4byte_at_end() +{ + // Name with valid emoji 🌙 followed by a truncated 4-byte sequence + ASCII + char buf[32] = "Lunar Tower \xF0\x9F\x8C\x99\xF0\x9F\x97" + "4"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // The 🌙 should be preserved; F0 9F 97 is an incomplete 4-byte sequence, + // '4' (0x34) is not a valid continuation byte + TEST_ASSERT_EQUAL_STRING("Lunar Tower \xF0\x9F\x8C\x99???4", buf); +} + +void test_lone_lead_bytes_without_continuations() +{ + // Mixed ASCII with stray multibyte lead bytes (E1, F3) lacking proper continuations + char buf[32] = "Mesht\xE1\xF3tic 37e2"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // E1 expects 2 continuation bytes, but F3 is not a continuation → E1 replaced + // F3 expects 3 continuation bytes, 't','i','c' are not continuations → F3 replaced + TEST_ASSERT_EQUAL_STRING("Mesht??tic 37e2", buf); +} + +// --- Edge cases --- + +void test_bare_continuation_byte() +{ + // 0x80 alone is invalid (continuation byte with no lead) + char buf[8] = "\x80"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("?", buf); +} + +void test_overlong_2byte() +{ + // C0 AF is an overlong encoding of U+002F '/' + char buf[8] = "\xC0\xAF"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // C0 is a 2-byte lead, AF is valid continuation, but codepoint 0x2F < 0x80 → overlong + // C0 replaced, AF (now bare continuation) also replaced + TEST_ASSERT_EQUAL_STRING("??", buf); +} + +void test_surrogate_half() +{ + // ED A0 80 encodes U+D800 (surrogate half — invalid in UTF-8) + char buf[8] = "\xED\xA0\x80"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("???", buf); +} + +void test_5byte_sequence_rejected() +{ + // F8 80 80 80 80 — 5-byte sequence, not valid UTF-8 + char buf[8] = "\xF8\x80\x80\x80\x80"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // F8 is invalid lead (>= 0xF8), each 0x80 is bare continuation + TEST_ASSERT_EQUAL_STRING("?????", buf); +} + +void test_truncated_3byte_at_buffer_end() +{ + // Buffer is exactly 4 bytes: E2 82 then forced null at [3] + char buf[4]; + buf[0] = '\xE2'; + buf[1] = '\x82'; + buf[2] = '\0'; // String ends before the 3-byte sequence completes + buf[3] = '\0'; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("??", buf); +} + +void test_null_termination_enforced() +{ + // Fill buffer completely with no null terminator + char buf[5]; + memset(buf, 'A', sizeof(buf)); + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); + // Should be null-terminated and content preserved (all ASCII) + TEST_ASSERT_EQUAL_STRING("AAAA", buf); +} + +void test_null_buffer() +{ + TEST_ASSERT_FALSE(sanitizeUtf8(nullptr, 10)); +} + +void test_zero_size() +{ + char buf[4] = "Hi"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, 0)); + // Buffer should be untouched + TEST_ASSERT_EQUAL_STRING("Hi", buf); +} + +void test_valid_max_codepoint() +{ + // U+10FFFF = F4 8F BF BF (maximum valid Unicode codepoint) + char buf[8] = "\xF4\x8F\xBF\xBF"; + TEST_ASSERT_FALSE(sanitizeUtf8(buf, sizeof(buf))); + TEST_ASSERT_EQUAL_STRING("\xF4\x8F\xBF\xBF", buf); +} + +void test_above_max_codepoint() +{ + // U+110000 = F4 90 80 80 (just above maximum valid Unicode) + char buf[8] = "\xF4\x90\x80\x80"; + TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); +} + +int main(int argc, char **argv) +{ + UNITY_BEGIN(); + + // Valid UTF-8 passthrough + RUN_TEST(test_ascii_unchanged); + RUN_TEST(test_valid_2byte_unchanged); + RUN_TEST(test_valid_3byte_unchanged); + RUN_TEST(test_valid_4byte_emoji_unchanged); + RUN_TEST(test_valid_mixed_unchanged); + RUN_TEST(test_empty_string); + + // Invalid sequences observed in the wild + RUN_TEST(test_truncated_4byte_at_end); + RUN_TEST(test_lone_lead_bytes_without_continuations); + + // Edge cases + RUN_TEST(test_bare_continuation_byte); + RUN_TEST(test_overlong_2byte); + RUN_TEST(test_surrogate_half); + RUN_TEST(test_5byte_sequence_rejected); + RUN_TEST(test_truncated_3byte_at_buffer_end); + RUN_TEST(test_null_termination_enforced); + RUN_TEST(test_null_buffer); + RUN_TEST(test_zero_size); + RUN_TEST(test_valid_max_codepoint); + RUN_TEST(test_above_max_codepoint); + + return UNITY_END(); +} diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 201669bee..50b475a6c 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -12,6 +12,9 @@ build_src_filter = build_flags = ${esp32_common.build_flags} -mtext-section-literals + -D ESP32_FORCE_IRAM_MEMSET + -Wl,--wrap=memset + -Wl,--wrap=memcpy -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_ACCELEROMETER=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 @@ -37,6 +40,7 @@ custom_sdkconfig = '# CONFIG_BTDM_CTRL_MODE_BTDM is not set' '# CONFIG_BT_BLUEDROID_ENABLED is not set' CONFIG_BT_NIMBLE_ENABLED=y + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y ; Override lib_deps to use environmental_extra_no_bsec instead of environmental_extra ; BSEC library uses ~3.5KB DRAM which causes overflow on original ESP32 targets diff --git a/variants/esp32/tbeam/platformio.ini b/variants/esp32/tbeam/platformio.ini index 4371dcc63..b1367df87 100644 --- a/variants/esp32/tbeam/platformio.ini +++ b/variants/esp32/tbeam/platformio.ini @@ -16,14 +16,8 @@ board_check = true build_flags = ${esp32_base.build_flags} -D TBEAM_V10 -I variants/esp32/tbeam - -D ESP32_FORCE_IRAM_MEMSET - -Wl,--wrap=memset - -Wl,--wrap=memcpy -upload_speed = 921600 -custom_sdkconfig = - ${esp32_base.custom_sdkconfig} - CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y +upload_speed = 921600 [env:tbeam-displayshield] extends = env:tbeam diff --git a/variants/esp32s3/t5s3_epaper/variant.cpp b/variants/esp32s3/t5s3_epaper/variant.cpp index 4b82be50a..6599ced23 100644 --- a/variants/esp32s3/t5s3_epaper/variant.cpp +++ b/variants/esp32s3/t5s3_epaper/variant.cpp @@ -1,647 +1,9 @@ -#include "configuration.h" - -#ifdef T5_S3_EPAPER_PRO - -#include "Observer.h" -#include "TouchDrvGT911.hpp" -#include "Wire.h" -#include "buzz.h" -#include "concurrency/OSThread.h" -#include "input/InputBroker.h" -#include "input/TouchScreenImpl1.h" -#include "main.h" -#include "sleep.h" -#include - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS -#include "graphics/niche/InkHUD/InkHUD.h" -#include "graphics/niche/InkHUD/Persistence.h" -#include "graphics/niche/InkHUD/SystemApplet.h" - -// Bridges touch events from TouchScreenImpl1 directly into InkHUD, -// bypassing the InputBroker (which is excluded in InkHUD builds). -// Routing mirrors the mini-epaper-s3 two-way rocker pattern: -// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu) -// - Nav up/down: navUp/navDown always (menu scroll) -// - Tap/long-press: direct touch point dispatch (with fallback to short/long button semantics) -class TouchInkHUDBridge : public Observer -{ - int onNotify(const InputEvent *e) override - { - auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); - - // Keep alignment in sync with the current rotation so that visual-frame gestures - // always pass through nav functions without remapping: (rotation + alignment) % 4 == 0. - inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4; - - // Check whether a system applet (e.g. menu) is currently handling input - bool systemHandlingInput = false; - for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { - if (sa->handleInput) { - systemHandlingInput = true; - break; - } - } - - switch (e->inputEvent) { - case INPUT_BROKER_USER_PRESS: - inkhud->touchTap(e->touchX, e->touchY); - break; - case INPUT_BROKER_SELECT: - inkhud->touchLongPress(e->touchX, e->touchY); - break; - case INPUT_BROKER_LEFT: - if (systemHandlingInput) - inkhud->touchNavUp(); - else - inkhud->prevApplet(); - break; - case INPUT_BROKER_RIGHT: - if (systemHandlingInput) - inkhud->touchNavDown(); - else - inkhud->nextApplet(); - break; - case INPUT_BROKER_UP: - inkhud->touchNavUp(); - break; - case INPUT_BROKER_DOWN: - inkhud->touchNavDown(); - break; - default: - break; - } - return 0; - } -}; - -static TouchInkHUDBridge touchBridge; -#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS - -TouchDrvGT911 touch; - -namespace -{ -constexpr uint8_t BACKLIGHT_ON_LEVEL = HIGH; -constexpr uint8_t BACKLIGHT_OFF_LEVEL = LOW; -volatile bool backlightUserEnabled = true; -volatile bool backlightForcedByTimeout = false; -volatile bool backlightForcedBySleep = false; - -void applyBacklightState() -{ - const bool shouldOn = backlightUserEnabled && !backlightForcedByTimeout && !backlightForcedBySleep; - digitalWrite(BOARD_BL_EN, shouldOn ? BACKLIGHT_ON_LEVEL : BACKLIGHT_OFF_LEVEL); -} - -volatile bool touchInputEnabled = true; -volatile bool touchForcedByTimeout = false; -volatile bool touchControllerReady = false; -volatile bool touchLightSleepActive = false; -volatile bool touchNeedsWake = false; -volatile bool touchIndicatorRefreshPending = false; -volatile uint32_t touchResumeBlockUntilMs = 0; -volatile uint32_t touchStateEpoch = 1; -volatile bool homeCapButtonEventsEnabled = false; -#if HAS_SCREEN -uint32_t lastTouchIndicatorMs = 0; -#endif - -void showTouchIndicator(const char *text) -{ -#if HAS_SCREEN -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // InkHUD builds render a dedicated bottom-edge "TOUCH OFF" overlay instead of popup banners. - (void)text; - return; -#else - // Keep repeated notifications low profile and non-spammy. - if ((millis() - lastTouchIndicatorMs) < 500) { - return; - } - lastTouchIndicatorMs = millis(); - if (screen) { - screen->showSimpleBanner(text, 1400); - } -#endif -#else - (void)text; -#endif -} - -#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) -bool readPca9535Port1(uint8_t *value) -{ - if (!value) { - return false; - } - - Wire.beginTransmission(BOARD_PCA9535_ADDR); - Wire.write((uint8_t)0x01); // input port 1 - if (Wire.endTransmission(false) != 0) { - return false; - } - if (Wire.requestFrom((uint8_t)BOARD_PCA9535_ADDR, (uint8_t)1) != 1) { - return false; - } - - *value = Wire.read(); - return true; -} - -bool isPca9535SideKeyPressed() -{ - uint8_t port1 = 0xFF; - if (!readPca9535Port1(&port1)) { - return false; - } - - return (port1 & BOARD_PCA9535_BUTTON_MASK) == 0; -} - -class SideKeyInterruptThread : public concurrency::OSThread -{ - public: - SideKeyInterruptThread() : concurrency::OSThread("t5s3SideKeyInt", SAMPLE_MS) - { - // Do not run unless an edge arrives. - OSThread::disable(); - instance = this; -#ifdef ARCH_ESP32 - lsObserver.observe(¬ifyLightSleep); - lsEndObserver.observe(¬ifyLightSleepEnd); -#endif - } - - void begin() - { - pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); - attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); - } - - protected: - int32_t runOnce() override - { - const uint32_t now = millis(); - - if (now < touchResumeBlockUntilMs) { - resetStateAndStop(); - return OSThread::disable(); - } - - if (touchLightSleepActive) { - resetStateAndStop(); - return OSThread::disable(); - } - - // Ignore side-key handling while BOOT/user button is held. - if (digitalRead(BUTTON_PIN) == LOW) { - resetStateAndStop(); - return OSThread::disable(); - } - - switch (state) { - case State::IRQ_PENDING: - // Initial debounce after expander interrupt edge. - if ((uint32_t)(now - irqAtMs) < DEBOUNCE_MS) { - return SAMPLE_MS; - } - - if (isPca9535SideKeyPressed()) { - state = State::PRESSED; - pressStartMs = now; - return SAMPLE_MS; - } - - // Spurious/cleared edge. - resetStateAndStop(); - return OSThread::disable(); - - case State::PRESSED: { - if (isPca9535SideKeyPressed()) { - // Fire long-press action as soon as threshold is reached, without waiting for release. - if (!longPressFired && (uint32_t)(now - pressStartMs) >= LONG_PRESS_MIN_MS && - (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { - t5BacklightToggleUser(); - longPressFired = true; - lastActionMs = now; - } - return SAMPLE_MS; - } - - // Released: if long-press already fired, do nothing. Otherwise classify short press. - const uint32_t heldMs = now - pressStartMs; - if (!longPressFired && heldMs >= SHORT_PRESS_MIN_MS && (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) { - // If timeout forced touch/backlight off, short-press acts as a wake action first. - if (t5TouchIsForcedByTimeout()) { - t5TouchHandleUserInput(); - t5BacklightHandleUserInput(); - } else { - toggleTouchInputEnabled(); - } - lastActionMs = now; - } - - resetStateAndStop(); - return OSThread::disable(); - } - - case State::REST: - default: - return OSThread::disable(); - } - } - - private: - enum class State : uint8_t { - REST, - IRQ_PENDING, - PRESSED, - }; - - static constexpr uint32_t SAMPLE_MS = 15; - static constexpr uint32_t DEBOUNCE_MS = 25; - static constexpr uint32_t SHORT_PRESS_MIN_MS = 30; - static constexpr uint32_t LONG_PRESS_MIN_MS = 450; - static constexpr uint32_t ACTION_COOLDOWN_MS = 180; - - static SideKeyInterruptThread *instance; - - static void isr() - { - if (instance) { - instance->onInterruptEdge(); - } - } - - void onInterruptEdge() - { - if (touchLightSleepActive) { - return; - } - const uint32_t now = millis(); - if (now < touchResumeBlockUntilMs) { - return; - } - if (state != State::REST) { - return; - } - - state = State::IRQ_PENDING; - irqAtMs = millis(); - startThread(); - } - - void startThread() - { - if (!OSThread::enabled) { - OSThread::setIntervalFromNow(0); - OSThread::enabled = true; - runASAP = true; - } - } - - void resetStateAndStop() - { - state = State::REST; - longPressFired = false; - if (OSThread::enabled) { - OSThread::disable(); - } - } - -#ifdef ARCH_ESP32 - int onLightSleep(void *) - { - detachInterrupt(BOARD_PCA9535_INT); - // Clear any latched PCA9535 interrupt before enabling GPIO wake. - // If INT is left asserted low, light sleep exits immediately. - uint8_t ignored = 0xFF; - (void)readPca9535Port1(&ignored); - resetStateAndStop(); - return 0; - } - - int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) - { - (void)cause; - // Consume any pending interrupt source before reattaching ISR. - uint8_t ignored = 0xFF; - (void)readPca9535Port1(&ignored); - pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); - attachInterrupt(BOARD_PCA9535_INT, SideKeyInterruptThread::isr, FALLING); - - return 0; - } - - CallbackObserver lsObserver{this, &SideKeyInterruptThread::onLightSleep}; - CallbackObserver lsEndObserver{this, - &SideKeyInterruptThread::onLightSleepEnd}; -#endif - - volatile State state = State::REST; - volatile uint32_t irqAtMs = 0; - uint32_t pressStartMs = 0; - bool longPressFired = false; - uint32_t lastActionMs = 0; -}; - -SideKeyInterruptThread *SideKeyInterruptThread::instance = nullptr; -SideKeyInterruptThread *sideKeyThread = nullptr; -#endif - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS -void refreshTouchIndicatorInInkHUD(bool async = true) -{ - auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); - NicheGraphics::InkHUD::SystemApplet *touchStatus = nullptr; - for (auto *sa : inkhud->systemApplets) { - if (sa && sa->name && strcmp(sa->name, "TouchStatus") == 0) { - touchStatus = sa; - break; - } - } - - if (touchStatus) { - if (inkhud->isTouchEnabled()) - touchStatus->sendToBackground(); - else - touchStatus->bringToForeground(); - } - - // Re-render all applets so touch-status visibility changes are immediately reflected. - inkhud->forceUpdate(NicheGraphics::Drivers::EInk::UpdateTypes::FAST, true, async); -} -#endif - -} // namespace - -void t5BacklightSetUserEnabled(bool enabled) -{ - backlightUserEnabled = enabled; - if (enabled) { - // Manual ON should release auto-off gates. - backlightForcedByTimeout = false; - backlightForcedBySleep = false; - } - applyBacklightState(); -} - -bool t5BacklightIsUserEnabled() -{ - return backlightUserEnabled; -} - -void t5BacklightToggleUser() -{ - t5BacklightSetUserEnabled(!backlightUserEnabled); -} - -void t5BacklightSetForcedByTimeout(bool forced) -{ - backlightForcedByTimeout = forced; - applyBacklightState(); -} - -void t5BacklightSetForcedBySleep(bool forced) -{ - backlightForcedBySleep = forced; - applyBacklightState(); -} - -void t5BacklightHandleUserInput() -{ - // Screen-timeout should be lifted by direct user interaction. - backlightForcedByTimeout = false; - applyBacklightState(); -} - -void t5TouchSetForcedByTimeout(bool forced) -{ - touchForcedByTimeout = forced; - touchStateEpoch++; - touchIndicatorRefreshPending = true; - - if (forced) { - // While timeout-forced, keep controller asleep to avoid stale IRQ chatter. - touchNeedsWake = false; - if (touchControllerReady && !touchLightSleepActive) { - touch.sleep(); - } - } else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) { - // Defer wake until readTouch() so I2C settles post-state transition. - touchNeedsWake = true; - } - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - if (!touchLightSleepActive) { - refreshTouchIndicatorInInkHUD(); - touchIndicatorRefreshPending = false; - } -#endif -} - -bool t5TouchIsForcedByTimeout() -{ - return touchForcedByTimeout; -} - -void t5TouchHandleUserInput() -{ - t5TouchSetForcedByTimeout(false); -} - -void t5SetHomeCapButtonEventsEnabled(bool enabled) -{ - homeCapButtonEventsEnabled = enabled; -} - -bool isTouchInputEnabled() -{ - return touchInputEnabled && !touchForcedByTimeout && !touchLightSleepActive; -} - -void setTouchInputEnabled(bool enabled, bool showIndicator) -{ - if (touchInputEnabled == enabled) { - LOG_DEBUG("touchscreen1: setTouchInputEnabled no-op en=%d", enabled); - return; - } - - LOG_DEBUG("touchscreen1: setTouchInputEnabled %d -> %d (showIndicator=%d)", touchInputEnabled, enabled, showIndicator); - touchInputEnabled = enabled; - touchStateEpoch++; - - if (enabled) { - touchNeedsWake = touchControllerReady; - if (touchControllerReady && !touchLightSleepActive) { - LOG_DEBUG("touchscreen1: wakeup() on enable"); - touch.wakeup(); - touchNeedsWake = false; - } - } else { - touchNeedsWake = false; - if (touchControllerReady && !touchLightSleepActive) { - LOG_DEBUG("touchscreen1: sleep() on disable"); - touch.sleep(); - } - if (showIndicator) { - showTouchIndicator("Touch OFF"); - touchIndicatorRefreshPending = true; - } - } - -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - if (showIndicator && !touchLightSleepActive) { - refreshTouchIndicatorInInkHUD(); - touchIndicatorRefreshPending = false; - } -#endif -} - -void toggleTouchInputEnabled() -{ - setTouchInputEnabled(!touchInputEnabled, true); -} - -// Commands the GT911 into standby before the Wire bus is torn down. -// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here. -struct TouchDeepSleepObserver { - int onDeepSleep(void *) - { - touch.sleep(); - return 0; - } - CallbackObserver observer{this, &TouchDeepSleepObserver::onDeepSleep}; -} static touchDeepSleepObserver; - -#ifdef ARCH_ESP32 -struct TouchLightSleepObserver { - int onLightSleep(void *) - { - touchLightSleepActive = true; -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // Render touch-off overlay before sleeping so user sees touch is unavailable. - touchIndicatorRefreshPending = true; - refreshTouchIndicatorInInkHUD(false); - touchIndicatorRefreshPending = false; -#endif - return 0; - } - - CallbackObserver observer{this, &TouchLightSleepObserver::onLightSleep}; -} static touchLightSleepObserver; - -struct TouchLightSleepEndObserver { - int onLightSleepEnd(esp_sleep_wakeup_cause_t cause) - { - (void)cause; - touchLightSleepActive = false; - - if (!touchControllerReady) { - return 0; - } - - if (touchInputEnabled && !touchForcedByTimeout) { - touchNeedsWake = true; - } else { - touchNeedsWake = false; - } - - touchStateEpoch++; - touchResumeBlockUntilMs = millis() + 150; - touchIndicatorRefreshPending = !isTouchInputEnabled(); -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // Clear sleep-time touch overlay after wake. - touchIndicatorRefreshPending = true; - refreshTouchIndicatorInInkHUD(); - touchIndicatorRefreshPending = false; -#endif - return 0; - } - - CallbackObserver observer{this, - &TouchLightSleepEndObserver::onLightSleepEnd}; -} static touchLightSleepEndObserver; -#endif - -bool readTouch(int16_t *x, int16_t *y) -{ -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - static uint32_t suppressUntilMs = 0; - static uint32_t seenTouchStateEpoch = 0; - - // Reset transient gesture helpers whenever touch mode changes. - if (seenTouchStateEpoch != touchStateEpoch) { - seenTouchStateEpoch = touchStateEpoch; - suppressUntilMs = 0; - } - - // Let buses and peripherals settle briefly after light-sleep wake. - if (millis() < touchResumeBlockUntilMs) { - return false; - } - - if (touchIndicatorRefreshPending) { - refreshTouchIndicatorInInkHUD(); - touchIndicatorRefreshPending = false; - } - - if (!isTouchInputEnabled()) { - return false; - } - - if (touchNeedsWake && touchControllerReady) { - LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); - touch.wakeup(); - touchNeedsWake = false; - suppressUntilMs = millis() + 60; - return false; - } - - // After a recovery pulse, emit a brief "released" window so gesture state can reset. - if (suppressUntilMs != 0 && millis() < suppressUntilMs) { - return false; - } -#endif - - if (!digitalRead(GT911_PIN_INT)) { - int16_t raw_x; - int16_t raw_y; - if (touch.getPoint(&raw_x, &raw_y)) { -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. - // rotation=3 is the physical identity (device's default orientation). - switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) { - default: - case 3: - *x = raw_x; - *y = raw_y; - break; // identity - case 2: - *x = (EPD_WIDTH - 1) - raw_y; - *y = raw_x; - break; // 90° CW tilt - case 1: - *x = (EPD_HEIGHT - 1) - raw_x; - *y = (EPD_WIDTH - 1) - raw_y; - break; // 180° flip - case 0: - *x = raw_y; - *y = (EPD_HEIGHT - 1) - raw_x; - break; // 90° CCW tilt - } -#else - *x = raw_x; - *y = raw_y; -#endif - LOG_DEBUG("touched(%d/%d)", *x, *y); - return true; - } - } - - return false; -} +// Pin-level early init only. All touch, backlight, and InkHUD code lives in +// src/platform/extra_variants/t5s3_epaper/variant.cpp where PlatformIO's +// library dependency finder can resolve headers like TouchDrvGT911.hpp. +#include "variant.h" +#include "Arduino.h" +#include "pins_arduino.h" void earlyInitVariant() { @@ -650,8 +12,9 @@ void earlyInitVariant() pinMode(SDCARD_CS, OUTPUT); digitalWrite(SDCARD_CS, HIGH); pinMode(BOARD_BL_EN, OUTPUT); - // Backlight uses active-HIGH brightness control. - applyBacklightState(); + // Backlight ON at boot (active-HIGH). Full backlight state management + // lives in src/platform/extra_variants/t5s3_epaper/variant.cpp. + digitalWrite(BOARD_BL_EN, HIGH); // Program GT911 touch controller to I2C address 0x14 (GT911_SLAVE_ADDRESS_H) before // the I2C bus scan runs. GPIO3 (INT) defaults LOW on ESP32-S3 cold boot, which would @@ -674,68 +37,3 @@ void earlyInitVariant() delay(10); // > 5 ms startup pinMode(GT911_PIN_INT, INPUT); // release INT for interrupt use } - -void variant_shutdown() -{ - // Ensure backlight is off during deep sleep. - t5BacklightSetForcedBySleep(true); -} - -void lateInitVariant() -{ - touch.setPins(GT911_PIN_RST, GT911_PIN_INT); - if (touch.begin(Wire, GT911_SLAVE_ADDRESS_H, GT911_PIN_SDA, GT911_PIN_SCL)) { - // Match LilyGO sample behavior: GT911 center/home capacitive key callback. - touch.setHomeButtonCallback( - [](void *user_data) { -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - if (!homeCapButtonEventsEnabled) { - return; - } - - static uint32_t lastHomeMs = 0; - const uint32_t now = millis(); - if ((uint32_t)(now - lastHomeMs) < 220) { - return; // debounce repeated key reports while still touched - } - lastHomeMs = now; - - auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance(); - if (inkhud) { - // Route through InkHUD EXIT/HOME path (menu close, etc). - inkhud->exitShort(); - } -#else - (void)user_data; -#endif - }, - nullptr); - touchControllerReady = true; - touchInputEnabled = true; - touchForcedByTimeout = false; - touchLightSleepActive = false; - touchStateEpoch++; - touchDeepSleepObserver.observer.observe(¬ifyDeepSleep); -#ifdef ARCH_ESP32 - touchLightSleepObserver.observer.observe(¬ifyLightSleep); - touchLightSleepEndObserver.observer.observe(¬ifyLightSleepEnd); -#endif - touchScreenImpl1 = new TouchScreenImpl1(EPD_WIDTH, EPD_HEIGHT, readTouch); - touchScreenImpl1->init(); -#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - touchBridge.observe(touchScreenImpl1); -#endif - } else { - touchControllerReady = false; - LOG_ERROR("Failed to find touch controller!"); - } - -#if defined(BOARD_PCA9535_ADDR) && defined(BOARD_PCA9535_BUTTON_MASK) - // Start side-key interrupt handling after touch init is complete. - if (!sideKeyThread) { - sideKeyThread = new SideKeyInterruptThread(); - sideKeyThread->begin(); - } -#endif -} -#endif