Merge branch 'pioarduino' into pioarduino-power

This commit is contained in:
Jonathan Bennett
2026-04-25 22:54:55 -05:00
committed by GitHub
17 changed files with 1190 additions and 732 deletions
+1 -1
View File
@@ -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
+118
View File
@@ -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
+2 -1
View File
@@ -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
+4 -2
View File
@@ -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;
}
+5
View File
@@ -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;
+30 -6
View File
@@ -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
+2
View File
@@ -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.
------------------------------------------------------------------------------------------------------------------------------------------ */
+89
View File
@@ -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;
}
+4
View File
@@ -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)
{
+8
View File
@@ -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;
@@ -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 <cstring>
#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<const InputEvent *>
{
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(&notifyLightSleep);
lsEndObserver.observe(&notifyLightSleepEnd);
#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<SideKeyInterruptThread, void *> lsObserver{this, &SideKeyInterruptThread::onLightSleep};
CallbackObserver<SideKeyInterruptThread, esp_sleep_wakeup_cause_t> 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<TouchDeepSleepObserver, void *> 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<TouchLightSleepObserver, void *> 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<TouchLightSleepEndObserver, esp_sleep_wakeup_cause_t> 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(&notifyDeepSleep);
#ifdef ARCH_ESP32
touchLightSleepObserver.observer.observe(&notifyLightSleep);
touchLightSleepEndObserver.observer.observe(&notifyLightSleepEnd);
#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
+8 -3
View File
@@ -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<uint8_t *>(&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());
+195
View File
@@ -0,0 +1,195 @@
#include "meshUtils.h"
#include <cstring>
#include <unity.h>
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();
}
+4
View File
@@ -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
+1 -7
View File
@@ -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
+9 -711
View File
@@ -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 <cstring>
#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<const InputEvent *>
{
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(&notifyLightSleep);
lsEndObserver.observe(&notifyLightSleepEnd);
#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<SideKeyInterruptThread, void *> lsObserver{this, &SideKeyInterruptThread::onLightSleep};
CallbackObserver<SideKeyInterruptThread, esp_sleep_wakeup_cause_t> 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<TouchDeepSleepObserver, void *> 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<TouchLightSleepObserver, void *> 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<TouchLightSleepEndObserver, esp_sleep_wakeup_cause_t> 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(&notifyDeepSleep);
#ifdef ARCH_ESP32
touchLightSleepObserver.observer.observe(&notifyLightSleep);
touchLightSleepEndObserver.observer.observe(&notifyLightSleepEnd);
#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