diff --git a/.github/ISSUE_TEMPLATE/Bug Report.yml b/.github/ISSUE_TEMPLATE/Bug Report.yml
index bc77e8c1b..cdf482344 100644
--- a/.github/ISSUE_TEMPLATE/Bug Report.yml
+++ b/.github/ISSUE_TEMPLATE/Bug Report.yml
@@ -75,11 +75,11 @@ body:
- type: checkboxes
id: mui
attributes:
- label: Is this bug report about any UI component firmware like InkHUD or Meshtatic UI (MUI)?
+ label: Is this bug report about any UI (https://meshtastic.org/docs/configuration/device-uis/) component firmware?
options:
- - label: Meshtastic UI aka MUI colorTFT
- - label: InkHUD ePaper
- - label: OLED slide UI on any display
+ - label: Meshtastic UI aka MUI
+ - label: InkHUD
+ - label: BaseUI
- type: input
id: version
diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml
index d0cbaa8bc..f90f4f4ac 100644
--- a/.trunk/trunk.yaml
+++ b/.trunk/trunk.yaml
@@ -8,18 +8,18 @@ plugins:
uri: https://github.com/trunk-io/plugins
lint:
enabled:
- - checkov@3.2.517
- - renovate@43.110.9
- - prettier@3.8.1
- - trufflehog@3.94.3
+ - checkov@3.2.524
+ - renovate@43.141.0
+ - prettier@3.8.3
+ - trufflehog@3.95.2
- yamllint@1.38.0
- bandit@1.9.4
- - trivy@0.69.3
+ - trivy@0.70.0
- taplo@0.10.0
- - ruff@0.15.9
+ - ruff@0.15.11
- isort@8.0.1
- markdownlint@0.48.0
- - oxipng@10.1.0
+ - oxipng@10.1.1
- svgo@4.0.1
- actionlint@1.7.12
- flake8@7.3.0
diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py
index b75c66624..f1946770c 100644
--- a/bin/platformio-custom.py
+++ b/bin/platformio-custom.py
@@ -293,9 +293,12 @@ if ("HAS_TFT", 1) in env.get("CPPDEFINES", []):
board_arch = infer_architecture(env.BoardConfig())
should_skip_manifest = board_arch is None
-# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
-mtjson_deps = [] if should_skip_manifest else ["buildprog"]
-if not should_skip_manifest and platform.name == "espressif32":
+# Most platforms can generate the manifest as part of the default 'buildprog' target.
+# Typically this passes success/failure properly.
+mtjson_deps = ["buildprog"]
+if platform.name == "espressif32":
+ # On ESP32, we need to explicitly depend upon the binary to prevent fake-success upon failure.
+ mtjson_deps = ["$BUILD_DIR/${PROGNAME}.bin"]
# Build littlefs image as part of mtjson target
# Equivalent to `pio run -t buildfs`
target_lfs = env.DataToBin(
@@ -309,7 +312,8 @@ if should_skip_manifest:
env.AddCustomTarget(
name="mtjson",
- dependencies=mtjson_deps,
+ # For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
+ dependencies=[],
actions=[skip_manifest],
title="Meshtastic Manifest (skipped)",
description="mtjson generation is skipped for native environments",
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/extra_scripts/ld_response_file.py b/extra_scripts/ld_response_file.py
index 0b91cf862..e79475f19 100644
--- a/extra_scripts/ld_response_file.py
+++ b/extra_scripts/ld_response_file.py
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
+# trunk-ignore-all(ruff/F821)
+# trunk-ignore-all(flake8/F821): For SConstruct imports
+
# force linker response file instead of command line arguments
Import("env")
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/Power.cpp b/src/Power.cpp
index b37424b24..53c1d38c7 100644
--- a/src/Power.cpp
+++ b/src/Power.cpp
@@ -165,16 +165,31 @@ static bool initAdcCalibration()
#endif // BATTERY_PIN && ARCH_ESP32
+#ifdef EXT_PWR_DETECT
+#ifndef EXT_PWR_DETECT_MODE
+#define EXT_PWR_DETECT_MODE INPUT
+// If using internal pull resistors, we can infer EXT_PWR_DETECT_VALUE
+#elif EXT_PWR_DETECT_MODE == INPUT_PULLUP
+#define EXT_PWR_DETECT_VALUE LOW
+#elif EXT_PWR_DETECT_MODE == INPUT_PULLDOWN
+#define EXT_PWR_DETECT_VALUE HIGH
+#endif
+#ifndef EXT_PWR_DETECT_VALUE
+#define EXT_PWR_DETECT_VALUE HIGH
+#endif
+#endif
+
#ifdef EXT_CHRG_DETECT
#ifndef EXT_CHRG_DETECT_MODE
-static const uint8_t ext_chrg_detect_mode = INPUT;
-#else
-static const uint8_t ext_chrg_detect_mode = EXT_CHRG_DETECT_MODE;
+#define EXT_CHRG_DETECT_MODE INPUT
+// If using internal pull resistors, we can infer EXT_CHRG_DETECT_VALUE
+#elif EXT_CHRG_DETECT_MODE == INPUT_PULLUP
+#define EXT_CHRG_DETECT_VALUE LOW
+#elif EXT_CHRG_DETECT_MODE == INPUT_PULLDOWN
+#define EXT_CHRG_DETECT_VALUE HIGH
#endif
#ifndef EXT_CHRG_DETECT_VALUE
-static const uint8_t ext_chrg_detect_value = HIGH;
-#else
-static const uint8_t ext_chrg_detect_value = EXT_CHRG_DETECT_VALUE;
+#define EXT_CHRG_DETECT_VALUE HIGH
#endif
#endif
@@ -523,28 +538,14 @@ class AnalogBatteryLevel : public HasBatteryLevel
virtual bool isBatteryConnect() override { return getBatteryPercent() != -1; }
#endif
- /// If we see a battery voltage higher than physics allows - assume charger is
- /// pumping in power On some boards we don't have the power management chip
- /// (like AXPxxxx) so we use EXT_PWR_DETECT GPIO pin to detect external power
- /// source
+ // Detect if an external power source is connected if we don’t have a PMIC;
+ // Firstly prefer EXT_PWR_DETECT GPIO if available,
+ // secondly try an nRF52-specific routine on some variants,
+ // lastly provide a fallback to indicate external power when fully charged.
virtual bool isVbusIn() override
{
#ifdef EXT_PWR_DETECT
-#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
- // if external powered that pin will be pulled down
- if (digitalRead(EXT_PWR_DETECT) == LOW) {
- return true;
- }
- // if it's not LOW - check the battery
-#else
- // if external powered that pin will be pulled up
- if (digitalRead(EXT_PWR_DETECT) == HIGH) {
- return true;
- }
- // if it's not HIGH - check the battery
-#endif
- // If we have an EXT_PWR_DETECT pin and it indicates no external power, believe it.
- return false;
+ return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
// technically speaking this should work for all(?) NRF52 boards
// but needs testing across multiple devices. NRF52 USB would not even work if
@@ -565,9 +566,9 @@ class AnalogBatteryLevel : public HasBatteryLevel
}
#endif
#if defined(ELECROW_ThinkNode_M6)
- return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value || isVbusIn();
+ return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn();
#elif EXT_CHRG_DETECT
- return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
+ return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
#elif defined(BATTERY_CHARGING_INV)
return !digitalRead(BATTERY_CHARGING_INV);
#else
@@ -700,14 +701,10 @@ Power::Power() : OSThread("Power")
bool Power::analogInit()
{
#ifdef EXT_PWR_DETECT
-#if defined(HELTEC_CAPSULE_SENSOR_V3) || defined(HELTEC_SENSOR_HUB)
- pinMode(EXT_PWR_DETECT, INPUT_PULLUP);
-#else
- pinMode(EXT_PWR_DETECT, INPUT);
-#endif
+ pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
#endif
#ifdef EXT_CHRG_DETECT
- pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
+ pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
#endif
#ifdef BATTERY_PIN
@@ -789,37 +786,17 @@ bool Power::setup()
found = true;
#endif
}
-#ifdef EXT_PWR_DETECT
- attachInterrupt(
- EXT_PWR_DETECT,
- []() {
- power->setIntervalFromNow(0);
- runASAP = true;
- },
- CHANGE);
-#endif
-#ifdef BATTERY_CHARGING_INV
- attachInterrupt(
- BATTERY_CHARGING_INV,
- []() {
- power->setIntervalFromNow(0);
- runASAP = true;
- },
- CHANGE);
-#endif
-#ifdef EXT_CHRG_DETECT
- attachInterrupt(
- EXT_CHRG_DETECT,
- []() {
- power->setIntervalFromNow(0);
- runASAP = true;
- BaseType_t higherWake = 0;
- },
- CHANGE);
-#endif
+ attachPowerInterrupts();
enabled = found;
low_voltage_counter = 0;
+#ifdef ARCH_ESP32
+ // Register callbacks for before and after lightsleep
+ // Used to detach and reattach interrupts
+ lsObserver.observe(¬ifyLightSleep);
+ lsEndObserver.observe(¬ifyLightSleepEnd);
+#endif
+
return found;
}
@@ -1066,6 +1043,17 @@ int32_t Power::runOnce()
powerFSM.trigger(EVENT_POWER_CONNECTED);
}
+#ifdef T_WATCH_S3
+ /*
+ In the T-Watch S3 this code fragment reacts to the short press of the button by switching the
+ display on and off
+ */
+ if (PMU->isPekeyShortPressIrq()) {
+ LOG_INFO("Input: Corona Button Click");
+ InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0};
+ inputBroker->injectInputEvent(&event);
+ }
+#endif
/*
Other things we could check if we cared...
@@ -1098,6 +1086,97 @@ int32_t Power::runOnce()
return (statusHandler && statusHandler->isInitialized()) ? (1000 * 20) : RUN_SAME;
}
+#ifdef ARCH_ESP32
+
+// Detach our class' interrupts before lightsleep
+// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press
+int Power::beforeLightSleep(void *unused)
+{
+ LOG_WARN("Detaching power interrupts for sleep");
+ detachPowerInterrupts();
+ return 0; // Indicates success
+}
+
+// Reconfigure our interrupts
+// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep
+int Power::afterLightSleep(esp_sleep_wakeup_cause_t cause)
+{
+ attachPowerInterrupts();
+ return 0; // Indicates success
+}
+
+#endif
+
+/*
+ * Attach (or re-attach) hardware interrupts for power management
+ * Public method. Used outside class when waking from MCU sleep
+ */
+void Power::attachPowerInterrupts()
+{
+#ifdef EXT_PWR_DETECT
+ attachInterrupt(
+ EXT_PWR_DETECT,
+ []() {
+ power->setIntervalFromNow(0);
+ runASAP = true;
+ },
+ CHANGE);
+#endif
+#ifdef BATTERY_CHARGING_INV
+ attachInterrupt(
+ BATTERY_CHARGING_INV,
+ []() {
+ power->setIntervalFromNow(0);
+ runASAP = true;
+ },
+ CHANGE);
+#endif
+#ifdef EXT_CHRG_DETECT
+ attachInterrupt(
+ EXT_CHRG_DETECT,
+ []() {
+ power->setIntervalFromNow(0);
+ runASAP = true;
+ BaseType_t higherWake = 0;
+ },
+ CHANGE);
+#endif
+#ifdef PMU_IRQ
+ if (PMU) {
+ attachInterrupt(
+ PMU_IRQ,
+ [] {
+ pmu_irq = true;
+ power->setIntervalFromNow(0);
+ runASAP = true;
+ },
+ FALLING);
+ }
+#endif
+}
+
+/*
+ * Detach the "normal" button interrupts.
+ * Public method. Used before attaching a "wake-on-button" interrupt for MCU sleep
+ */
+void Power::detachPowerInterrupts()
+{
+#ifdef EXT_PWR_DETECT
+ detachInterrupt(EXT_PWR_DETECT);
+#endif
+#ifdef BATTERY_CHARGING_INV
+ detachInterrupt(BATTERY_CHARGING_INV);
+#endif
+#ifdef EXT_CHRG_DETECT
+ detachInterrupt(EXT_CHRG_DETECT);
+#endif
+#ifdef PMU_IRQ
+ if (PMU) {
+ detachInterrupt(PMU_IRQ);
+ }
+#endif
+}
+
/**
* Init the power manager chip
*
@@ -1375,8 +1454,6 @@ bool Power::axpChipInit()
}
pinMode(PMU_IRQ, INPUT);
- attachInterrupt(
- PMU_IRQ, [] { pmu_irq = true; }, FALLING);
// we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ
// because it occurs repeatedly while there is no battery also it could cause
@@ -1849,7 +1926,7 @@ class SerialBatteryLevel : public HasBatteryLevel
{
#if defined(EXT_CHRG_DETECT)
- return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
+ return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
#endif
return false;
@@ -1858,7 +1935,7 @@ class SerialBatteryLevel : public HasBatteryLevel
virtual bool isCharging() override
{
#ifdef EXT_CHRG_DETECT
- return digitalRead(EXT_CHRG_DETECT) == ext_chrg_detect_value;
+ return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
#endif
// by default, we check the battery voltage only
@@ -1880,10 +1957,10 @@ SerialBatteryLevel serialBatteryLevel;
bool Power::serialBatteryInit()
{
#ifdef EXT_PWR_DETECT
- pinMode(EXT_PWR_DETECT, INPUT);
+ pinMode(EXT_PWR_DETECT, EXT_PWR_DETECT_MODE);
#endif
#ifdef EXT_CHRG_DETECT
- pinMode(EXT_CHRG_DETECT, ext_chrg_detect_mode);
+ pinMode(EXT_CHRG_DETECT, EXT_CHRG_DETECT_MODE);
#endif
bool result = serialBatteryLevel.runOnce();
diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp
index b11f37cf0..a1610109c 100644
--- a/src/PowerFSM.cpp
+++ b/src/PowerFSM.cpp
@@ -58,6 +58,35 @@ static bool isPowered()
return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB());
}
+#if defined(T5_S3_EPAPER_PRO)
+static void t5BacklightOffForSleep()
+{
+ t5BacklightSetForcedBySleep(true);
+}
+
+static void t5BacklightWakeFromSleep()
+{
+ t5BacklightSetForcedBySleep(false);
+}
+
+static void t5BacklightOffForTimeout()
+{
+ t5BacklightSetForcedByTimeout(true);
+ t5TouchSetForcedByTimeout(true);
+}
+
+static void t5BacklightOnFromUserInput()
+{
+ t5BacklightHandleUserInput();
+ t5TouchHandleUserInput();
+}
+#else
+static void t5BacklightOffForSleep() {}
+static void t5BacklightWakeFromSleep() {}
+static void t5BacklightOffForTimeout() {}
+static void t5BacklightOnFromUserInput() {}
+#endif
+
static void sdsEnter()
{
LOG_POWERFSM("State: SDS");
@@ -87,6 +116,7 @@ static void lsEnter()
LOG_POWERFSM("lsEnter begin, ls_secs=%u", config.power.ls_secs);
if (screen)
screen->setOn(false);
+ t5BacklightOffForSleep();
secsSlept = 0; // How long have we been sleeping this time
// LOG_INFO("lsEnter end");
@@ -159,6 +189,8 @@ static void lsIdle()
static void lsExit()
{
LOG_POWERFSM("State: lsExit");
+ // Lift the light-sleep force-off gate when leaving LS.
+ t5BacklightWakeFromSleep();
}
static void nbEnter()
@@ -180,6 +212,8 @@ static void darkEnter()
setBluetoothEnable(true);
if (screen)
screen->setOn(false);
+ // Screen timeout enters DARK; ensure backlight also turns off.
+ t5BacklightOffForTimeout();
}
static void serialEnter()
@@ -289,12 +323,13 @@ void PowerFSM_setup()
powerFSM.add_transition(&stateNB, &stateNB, EVENT_PACKET_FOR_PHONE, NULL, "Received packet, resetting win wake");
// Handle press events - note: we ignore button presses when in API mode
- powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, NULL, "Press");
- powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, NULL, "Press");
- powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, NULL, "Press");
- powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, NULL, "Press");
- powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, NULL, "Press"); // reenter On to restart our timers
- powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, NULL,
+ powerFSM.add_transition(&stateLS, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
+ powerFSM.add_transition(&stateNB, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
+ powerFSM.add_transition(&stateDARK, isPowered() ? &statePOWER : &stateON, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
+ powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_PRESS, t5BacklightOnFromUserInput, "Press");
+ powerFSM.add_transition(&stateON, &stateON, EVENT_PRESS, t5BacklightOnFromUserInput,
+ "Press"); // reenter On to restart our timers
+ powerFSM.add_transition(&stateSERIAL, &stateSERIAL, EVENT_PRESS, t5BacklightOnFromUserInput,
"Press"); // Allow button to work while in serial API
// Handle critically low power battery by forcing deep sleep
@@ -314,11 +349,13 @@ void PowerFSM_setup()
powerFSM.add_transition(&stateSERIAL, &stateSHUTDOWN, EVENT_SHUTDOWN, NULL, "Shutdown");
// Inputbroker
- powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, NULL, "Input Device");
- powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, NULL, "Input Device");
- powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, NULL, "Input Device");
- powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
- powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, NULL, "Input Device"); // restarts the sleep timer
+ powerFSM.add_transition(&stateLS, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
+ powerFSM.add_transition(&stateNB, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
+ powerFSM.add_transition(&stateDARK, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput, "Input Device");
+ powerFSM.add_transition(&stateON, &stateON, EVENT_INPUT, t5BacklightOnFromUserInput,
+ "Input Device"); // restarts the sleep timer
+ powerFSM.add_transition(&statePOWER, &statePOWER, EVENT_INPUT, t5BacklightOnFromUserInput,
+ "Input Device"); // restarts the sleep timer
powerFSM.add_transition(&stateDARK, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
powerFSM.add_transition(&stateON, &stateON, EVENT_BLUETOOTH_PAIR, NULL, "Bluetooth pairing");
diff --git a/src/airtime.h b/src/airtime.h
index 3ed7b6d7c..8e3e6c557 100644
--- a/src/airtime.h
+++ b/src/airtime.h
@@ -19,8 +19,8 @@
TX_LOG + RX_LOG = Total air time for a particular meshtastic channel.
- TX_LOG + RX_LOG = Total air time for a particular meshtastic channel, including
- other lora radios.
+ TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including
+ other lora radios.
RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel.
*/
diff --git a/src/configuration.h b/src/configuration.h
index 84dabee4e..efd9ddcf7 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -499,6 +499,7 @@ along with this program. If not, see .
#define MESHTASTIC_EXCLUDE_PKI 1
#define MESHTASTIC_EXCLUDE_POWER_FSM 1
#define MESHTASTIC_EXCLUDE_TZ 1
+#define MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH 1
#endif
// Turn off all optional modules
diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp
index e298663a0..e3471c32a 100644
--- a/src/detect/ScanI2CTwoWire.cpp
+++ b/src/detect/ScanI2CTwoWire.cpp
@@ -415,30 +415,45 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
#if !defined(M5STACK_UNITC6L)
case INA_ADDR: // Same as SHT2X
case INA_ADDR_ALTERNATE:
- case INA_ADDR_WAVESHARE_UPS:
- registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
- LOG_DEBUG("Register MFG_UID: 0x%x", registerValue);
- if (registerValue == 0x5449) {
- registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
- LOG_DEBUG("Register DIE_UID: 0x%x", registerValue);
+ case INA_ADDR_WAVESHARE_UPS: {
+ uint16_t mfg = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
- if (registerValue == 0x2260) {
+ LOG_DEBUG("Register MFG_UID: 0x%x", mfg);
+
+ // Only read DIE_UID for vendors we recognize as INA-compatible to avoid
+ // an extra I2C transaction + delay on other devices sharing this address.
+ if (mfg == 0x5449 || mfg == 0x190F) {
+ uint16_t die = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
+ LOG_DEBUG("Register DIE_UID: 0x%x", die);
+
+ // TI INA226 or fully compatible clones (e.g. TPA626)
+ if (mfg == 0x5449 && die == 0x2260) {
logFoundDevice("INA226", (uint8_t)addr.address);
type = INA226;
- } else {
+ }
+ // Silergy SQ52201 (INA226-compatible with different IDs)
+ else if (mfg == 0x190F && die == 0x0000) {
+ logFoundDevice("INA226 (SQ52201)", (uint8_t)addr.address);
+ type = INA226;
+ }
+ // TI INA260
+ else if (mfg == 0x5449) {
logFoundDevice("INA260", (uint8_t)addr.address);
type = INA260;
}
+ }
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
- } else if (detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
+ if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
type = SHTXX;
+ }
#endif
- } else { // Assume INA219 if none of the above ones are found
+ else { // Assume INA219 if none of the above ones are found
logFoundDevice("INA219", (uint8_t)addr.address);
type = INA219;
}
break;
+ }
case INA3221_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2);
LOG_DEBUG("Register MFG_UID FE: 0x%x", registerValue);
diff --git a/src/graphics/niche/Drivers/EInk/ED047TC1.cpp b/src/graphics/niche/Drivers/EInk/ED047TC1.cpp
index f1189045b..2e283737c 100644
--- a/src/graphics/niche/Drivers/EInk/ED047TC1.cpp
+++ b/src/graphics/niche/Drivers/EInk/ED047TC1.cpp
@@ -25,6 +25,116 @@
using namespace NicheGraphics::Drivers;
+#if defined(T5_S3_EPAPER_PRO_V2)
+// FastEPD helper symbols are defined in FastEPD.inl with C++ linkage.
+extern void bbepPCA9535DigitalWrite(uint8_t pin, uint8_t value);
+extern uint8_t bbepPCA9535DigitalRead(uint8_t pin);
+extern int bbepI2CWrite(unsigned char iAddr, unsigned char *pData, int iLen);
+extern int bbepI2CReadRegister(unsigned char iAddr, unsigned char u8Register, unsigned char *pData, int iLen);
+#endif
+
+namespace
+{
+#if defined(T5_S3_EPAPER_PRO_V2)
+// FastEPD default V2 power callback blocks forever waiting for PWRGOOD.
+// Replace it with a timeout-safe version so boot never deadlocks.
+int safeEPDiyV7EinkPower(void *pBBEP, int bOn)
+{
+ static bool warnedPgood = false;
+ static bool warnedTpsPg = false;
+ static bool warnedTpsWrite = false;
+
+ FASTEPDSTATE *pState = static_cast(pBBEP);
+ if (!pState) {
+ return BBEP_ERROR_BAD_PARAMETER;
+ }
+
+ if (bOn == pState->pwr_on) {
+ return BBEP_SUCCESS;
+ }
+
+ if (bOn) {
+ bbepPCA9535DigitalWrite(8, 1); // OE on
+ bbepPCA9535DigitalWrite(9, 1); // GMOD on
+ bbepPCA9535DigitalWrite(13, 1); // WAKEUP on
+ bbepPCA9535DigitalWrite(11, 1); // PWRUP on
+ bbepPCA9535DigitalWrite(12, 1); // VCOM CTRL on
+ delay(1);
+
+ const uint32_t pgoodStart = millis();
+ bool pgoodSeen = false;
+ while (!bbepPCA9535DigitalRead(14)) { // CFG_PIN_PWRGOOD
+ if ((millis() - pgoodStart) > 1200) {
+ if (!warnedPgood) {
+ LOG_WARN("ED047TC1: PWRGOOD timeout, continuing with fallback power-on path");
+ warnedPgood = true;
+ }
+ break;
+ }
+ delay(1);
+ }
+ if (bbepPCA9535DigitalRead(14)) {
+ pgoodSeen = true;
+ }
+
+ uint8_t ucTemp[4] = {0};
+ ucTemp[0] = 0x01; // TPS_REG_ENABLE
+ ucTemp[1] = 0x3f; // enable rails
+ const int tpsEnableRc = bbepI2CWrite(0x68, ucTemp, 2);
+
+ const int vcom = pState->iVCOM / -10;
+ ucTemp[0] = 3; // VCOM registers 3+4 (L + H)
+ ucTemp[1] = static_cast(vcom);
+ ucTemp[2] = static_cast(vcom >> 8);
+ const int tpsVcomRc = bbepI2CWrite(0x68, ucTemp, 3);
+ if ((tpsEnableRc == 0 || tpsVcomRc == 0) && !warnedTpsWrite) {
+ LOG_WARN("ED047TC1: TPS write did not ACK, continuing with fallback");
+ warnedTpsWrite = true;
+ }
+
+ int iTimeout = 0;
+ uint8_t u8Value = 0;
+ while (iTimeout < 220 && ((u8Value & 0xfa) != 0xfa)) {
+ bbepI2CReadRegister(0x68, 0x0F, &u8Value, 1); // TPS_REG_PG
+ iTimeout++;
+ delay(1);
+ }
+ if (iTimeout >= 220 && !warnedTpsPg) {
+ if (pgoodSeen) {
+ LOG_WARN("ED047TC1: TPS power-good register timeout, panel may still work");
+ } else {
+ LOG_WARN("ED047TC1: TPS power-good register timeout after PWRGOOD fallback");
+ }
+ warnedTpsPg = true;
+ }
+
+ pState->pwr_on = 1;
+ } else {
+ bbepPCA9535DigitalWrite(8, 0); // OE off
+ bbepPCA9535DigitalWrite(9, 0); // GMOD off
+ bbepPCA9535DigitalWrite(11, 0); // PWRUP off
+ bbepPCA9535DigitalWrite(12, 0); // VCOM CTRL off
+ delay(1);
+ bbepPCA9535DigitalWrite(13, 0); // WAKEUP off
+ pState->pwr_on = 0;
+ }
+
+ return BBEP_SUCCESS;
+}
+#endif
+
+class SafeFastEPD : public FASTEPD
+{
+ public:
+ void installSafePowerHandler()
+ {
+#if defined(T5_S3_EPAPER_PRO_V2)
+ _state.pfnEinkPower = safeEPDiyV7EinkPower;
+#endif
+ }
+};
+} // namespace
+
void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
{
// Parallel display — SPI parameters are not used
@@ -34,24 +144,48 @@ void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_
(void)pin_busy;
(void)pin_rst;
- epaper = new FASTEPD;
+ SafeFastEPD *safeEpaper = new SafeFastEPD;
+ epaper = safeEpaper;
+ int initRc = BBEP_ERROR_BAD_PARAMETER;
#if defined(T5_S3_EPAPER_PRO_V1)
- epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
+ initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
- epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
+ initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
// Initialize all PCA9535 port-0 pins as outputs / HIGH
for (int i = 0; i < 8; i++) {
epaper->ioPinMode(i, OUTPUT);
epaper->ioWrite(i, HIGH);
}
+ // On this board, the physical side key is labeled IO48; electrically it maps to PCA9535 IO12 (bit 2 on port-1).
+ // FastEPD's generic V7 init drives 8..13 as outputs; force IO12 back to input
+ // so variant touch-control polling can read the key reliably.
+ epaper->ioPinMode(10, INPUT);
#else
#error "ED047TC1 driver: unsupported variant — define T5_S3_EPAPER_PRO_V1 or T5_S3_EPAPER_PRO_V2"
#endif
- epaper->setMode(BB_MODE_1BPP);
- epaper->clearWhite();
- epaper->fullUpdate(true); // Blocking initial clear
+ if (initRc != BBEP_SUCCESS) {
+ LOG_ERROR("ED047TC1 initPanel failed rc=%d", initRc);
+ return;
+ }
+
+ safeEpaper->installSafePowerHandler();
+
+ const int modeRc = epaper->setMode(BB_MODE_1BPP);
+ if (modeRc != BBEP_SUCCESS) {
+ LOG_WARN("ED047TC1 setMode failed rc=%d", modeRc);
+ }
+
+ const int clearRc = epaper->clearWhite();
+ if (clearRc != BBEP_SUCCESS) {
+ LOG_WARN("ED047TC1 clearWhite failed rc=%d", clearRc);
+ }
+
+ const int fullRc = epaper->fullUpdate(true); // Blocking initial clear
+ if (fullRc != BBEP_SUCCESS) {
+ LOG_WARN("ED047TC1 initial fullUpdate failed rc=%d", fullRc);
+ }
}
void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
@@ -111,9 +245,8 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
epaper->fullUpdate(CLEAR_SLOW, false);
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
} else {
- // FAST: true partial update — compares pCurrent vs pPrevious and only applies
- // the update waveform to rows that actually changed. Unchanged rows get a neutral
- // signal (no visible effect). partialUpdate() updates pPrevious internally.
+ // FAST: true partial update - compares pCurrent vs pPrevious and only applies
+ // update waveform to rows that changed. partialUpdate() updates pPrevious.
epaper->partialUpdate(false, 0, dstTotalRows - 1);
}
}
diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h
index 3c14c2607..6b727f273 100644
--- a/src/graphics/niche/InkHUD/Applet.h
+++ b/src/graphics/niche/InkHUD/Applet.h
@@ -104,6 +104,15 @@ class Applet : public GFX
virtual void onFreeText(char c) {}
virtual void onFreeTextDone() {}
virtual void onFreeTextCancel() {}
+ // Absolute display-space touch point, for touch-friendly UI interactions.
+ // Return true if consumed.
+ virtual bool onTouchPoint(uint16_t x, uint16_t y, bool longPress)
+ {
+ (void)x;
+ (void)y;
+ (void)longPress;
+ return false;
+ }
// List of inputs which can be subscribed to
enum InputMask { // | No Joystick | With Joystick |
BUTTON_SHORT = 1, // | Button Click | Joystick Center Click |
diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp
index 06ddd5bb0..63ccaa216 100644
--- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp
@@ -43,8 +43,8 @@ void InkHUD::MapApplet::onRender(bool full)
// Add white halo outline first
constexpr int outlinePad = 1;
- int boxSize = 11;
- int radius = 2; // rounded corner radius
+ int boxSize = fontSmall.lineHeight() + 2; // scale with font so digit fits
+ int radius = max(2, boxSize / 6);
// White halo background
fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE);
@@ -143,17 +143,19 @@ void InkHUD::MapApplet::onRender(bool full)
int16_t centerX = X(0.5) + (self.eastMeters * metersToPx);
int16_t centerY = Y(0.5) - (self.northMeters * metersToPx);
+ int16_t r = fontSmall.lineHeight() / 2; // scale marker with font
+
// White fill background + halo
- fillCircle(centerX, centerY, 8, WHITE); // big white base
- drawCircle(centerX, centerY, 8, WHITE); // crisp edge
+ fillCircle(centerX, centerY, r + 2, WHITE);
+ drawCircle(centerX, centerY, r + 2, WHITE);
// Black bullseye on top
- drawCircle(centerX, centerY, 6, BLACK);
- fillCircle(centerX, centerY, 2, BLACK);
+ drawCircle(centerX, centerY, r, BLACK);
+ fillCircle(centerX, centerY, max(2, r / 4), BLACK);
// Crosshairs
- drawLine(centerX - 8, centerY, centerX + 8, centerY, BLACK);
- drawLine(centerX, centerY - 8, centerX, centerY + 8, BLACK);
+ drawLine(centerX - r - 2, centerY, centerX + r + 2, centerY, BLACK);
+ drawLine(centerX, centerY - r - 2, centerX, centerY + r + 2, BLACK);
}
}
@@ -382,9 +384,9 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
constexpr uint16_t paddingH = 2;
constexpr uint16_t paddingW = 4;
- uint16_t paddingInnerW = 2; // Zero'd out if no text
- constexpr uint16_t markerSizeMax = 12; // Size of cross (if marker uses a cross)
- constexpr uint16_t markerSizeMin = 5;
+ uint16_t paddingInnerW = 2; // Zero'd out if no text
+ uint16_t markerSizeMax = fontSmall.lineHeight(); // Scale cross with font
+ uint16_t markerSizeMin = max(5, fontSmall.lineHeight() / 3);
int16_t textX;
int16_t textY;
diff --git a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp
new file mode 100644
index 000000000..005327bae
--- /dev/null
+++ b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp
@@ -0,0 +1,545 @@
+#ifdef MESHTASTIC_INCLUDE_INKHUD
+
+#include "./AppSwitcherApplet.h"
+
+#include "graphics/niche/InkHUD/InkHUD.h"
+#include "graphics/niche/InkHUD/Tile.h"
+
+#include
+#include
+
+using namespace NicheGraphics;
+
+namespace
+{
+static constexpr uint16_t BODY_MARGIN_X = 8;
+static constexpr uint16_t BODY_MARGIN_Y = 6;
+static constexpr uint16_t SLOT_GAP_X = 8;
+static constexpr uint16_t SLOT_GAP_Y = 8;
+static constexpr uint8_t ICON_RADIUS = 8;
+static constexpr uint16_t FOOTER_PAD = 4;
+static constexpr uint16_t LABEL_BOTTOM_PAD = 1;
+static constexpr uint16_t LABEL_GAP_Y = 1;
+static constexpr uint16_t TITLE_H_PAD = 8;
+
+static constexpr uint8_t GRID_COLS = 3;
+static constexpr uint8_t GRID_ROWS = 4;
+static constexpr uint8_t ICON_NATIVE_SIZE = 48;
+static constexpr uint8_t ICON_OUTLINE_STROKE = 1;
+
+enum class IconKind : uint8_t { GENERIC, ALL_MESSAGES, DMS, CHANNEL, POSITIONS, RECENTS, HEARD, FAVORITES };
+
+struct GridLayout {
+ uint16_t footerH = 0;
+ uint16_t bodyTop = 0;
+ uint16_t bodyBottom = 0;
+ uint16_t slotW = 0;
+ uint16_t slotH = 0;
+ uint16_t iconBox = 0;
+};
+
+/*
+ * Icons sourced from Material Design Icons PNG set (Apache 2.0):
+ * https://github.com/material-icons/material-icons-png
+ *
+ * Families used: outline-2x (48x48)
+ * apps, markunread, chat, forum, place, history, hearing, star_border
+ */
+static constexpr uint64_t icon_generic_apps[48] = {
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+ 0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
+ 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
+ 0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
+ 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
+ 0x000000000000ULL, 0x000000000000ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL,
+ 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x00FF0FF0FF00ULL, 0x000000000000ULL, 0x000000000000ULL,
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+};
+
+static constexpr uint64_t icon_all_messages[48] = {
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+ 0x000000000000ULL, 0x000000000000ULL, 0x07FFFFFFFFE0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL,
+ 0x0FC0000003F0ULL, 0x0FE0000007F0ULL, 0x0FF800001FF0ULL, 0x0FFE00007FF0ULL, 0x0FFF0000FFF0ULL, 0x0F7FC003FEF0ULL,
+ 0x0F1FE007F8F0ULL, 0x0F07F81FE0F0ULL, 0x0F03FE7FC0F0ULL, 0x0F00FFFF00F0ULL, 0x0F007FFE00F0ULL, 0x0F001FF800F0ULL,
+ 0x0F0007E000F0ULL, 0x0F0003C000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
+ 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
+ 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x07FFFFFFFFE0ULL, 0x000000000000ULL, 0x000000000000ULL,
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+};
+
+static constexpr uint64_t icon_dms[48] = {
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x07FFFFFFFFE0ULL, 0x0FFFFFFFFFF0ULL,
+ 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
+ 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
+ 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F0FFFFFF0F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
+ 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F0FFFF000F0ULL, 0x0F00000000F0ULL, 0x0F00000000F0ULL,
+ 0x0F00000000F0ULL, 0x0F00000000F0ULL, 0x0F7FFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFF0ULL, 0x0FFFFFFFFFE0ULL,
+ 0x0FF000000000ULL, 0x0FE000000000ULL, 0x0FC000000000ULL, 0x0F8000000000ULL, 0x0F0000000000ULL, 0x0E0000000000ULL,
+ 0x0C0000000000ULL, 0x080000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+};
+
+static constexpr uint64_t icon_channel[48] = {
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x0FFFFFFFC000ULL, 0x0FFFFFFFC000ULL,
+ 0x0FFFFFFFC000ULL, 0x0FFFFFFFC000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL, 0x0F000003C000ULL,
+ 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL,
+ 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F000003C3F0ULL, 0x0F7FFFFFC3F0ULL, 0x0FFFFFFFC3F0ULL,
+ 0x0FFFFFFFC3F0ULL, 0x0FFFFFFFC3F0ULL, 0x0FF0000003F0ULL, 0x0FE0000003F0ULL, 0x0FC0000003F0ULL, 0x0F80000003F0ULL,
+ 0x0F0FFFFFFFF0ULL, 0x0E0FFFFFFFF0ULL, 0x0C0FFFFFFFF0ULL, 0x080FFFFFFFF0ULL, 0x000FFFFFFFF0ULL, 0x000FFFFFFFF0ULL,
+ 0x000000000FF0ULL, 0x0000000007F0ULL, 0x0000000003F0ULL, 0x0000000001F0ULL, 0x0000000000F0ULL, 0x000000000070ULL,
+ 0x000000000030ULL, 0x000000000010ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+};
+
+static constexpr uint64_t icon_positions[48] = {
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x00001FF80000ULL, 0x00007FFE0000ULL,
+ 0x0001FFFF8000ULL, 0x0003FFFFC000ULL, 0x0007FFFFE000ULL, 0x000FF00FF000ULL, 0x000FC003F000ULL, 0x001F8001F800ULL,
+ 0x001F0000F800ULL, 0x003F07E0FC00ULL, 0x003E0FF07C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL,
+ 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E1FF87C00ULL, 0x003E0FF07C00ULL, 0x003E07E07C00ULL, 0x001F0000F800ULL,
+ 0x001F0000F800ULL, 0x001F8001F800ULL, 0x000F8001F000ULL, 0x000FC003F000ULL, 0x0007C003E000ULL, 0x0007E007E000ULL,
+ 0x0003F00FC000ULL, 0x0003F00FC000ULL, 0x0001F81F8000ULL, 0x0001FC3F8000ULL, 0x0000FC3F0000ULL, 0x00007E7E0000ULL,
+ 0x00003FFC0000ULL, 0x00003FFC0000ULL, 0x00001FF80000ULL, 0x00000FF00000ULL, 0x00000FF00000ULL, 0x000007E00000ULL,
+ 0x000003C00000ULL, 0x000001800000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+};
+
+static constexpr uint64_t icon_recents[48] = {
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+ 0x00000FFF0000ULL, 0x00003FFFC000ULL, 0x0000FFFFF000ULL, 0x0001FFFFF800ULL, 0x0007FFFFFE00ULL, 0x000FF801FF00ULL,
+ 0x000FE0007F00ULL, 0x001FC0003F80ULL, 0x003F00000FC0ULL, 0x003F00000FC0ULL, 0x007E00E007E0ULL, 0x007C00E003E0ULL,
+ 0x00FC00E003F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL, 0x00F800E001F0ULL,
+ 0x3FFFC0F001F0ULL, 0x1FFF80FC01F0ULL, 0x0FFF00FF01F0ULL, 0x07FE003F81F0ULL, 0x03FC001FC1F0ULL, 0x01F80007C3F0ULL,
+ 0x00F0000183E0ULL, 0x0060000007E0ULL, 0x000000000FC0ULL, 0x000000000FC0ULL, 0x0001C0003F80ULL, 0x0003E0007F00ULL,
+ 0x0007F801FF00ULL, 0x0007FFFFFE00ULL, 0x0001FFFFF800ULL, 0x0000FFFFF000ULL, 0x00003FFFC000ULL, 0x00000FFF0000ULL,
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+};
+
+static constexpr uint64_t icon_heard[48] = {
+ 0x000000000000ULL, 0x000000000000ULL, 0x000800000000ULL, 0x001C00000000ULL, 0x003E01FF8000ULL, 0x007F07FFE000ULL,
+ 0x007E1FFFF800ULL, 0x00FC3FFFFC00ULL, 0x00F87FFFFE00ULL, 0x01F8FF00FF00ULL, 0x01F0FC003F00ULL, 0x01F1F8001F80ULL,
+ 0x03E1F0000F80ULL, 0x03E3F07E0FC0ULL, 0x03E3E0FF07C0ULL, 0x03E3E1FF87C0ULL, 0x03E3E1FF87C0ULL, 0x03E3E1FF87C0ULL,
+ 0x03E3E1FF8000ULL, 0x03E3E1FF8000ULL, 0x03E3E1FF8000ULL, 0x03E3E0FF0000ULL, 0x03E3F07E0000ULL, 0x03E1F0000000ULL,
+ 0x01F1F8000000ULL, 0x01F1F8000000ULL, 0x01F8FC000000ULL, 0x00F87E000000ULL, 0x00FC7F800000ULL, 0x007E3FC00000ULL,
+ 0x007F1FE00000ULL, 0x003E0FF00000ULL, 0x001C03F00000ULL, 0x000801F80000ULL, 0x000000F80000ULL, 0x000000FC0000ULL,
+ 0x0000007C07C0ULL, 0x0000007E07C0ULL, 0x0000003F0FC0ULL, 0x0000003FFFC0ULL, 0x0000001FFF80ULL, 0x0000000FFF00ULL,
+ 0x00000007FE00ULL, 0x00000003FC00ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+};
+
+static constexpr uint64_t icon_favorites[48] = {
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000001800000ULL, 0x000001800000ULL,
+ 0x000003C00000ULL, 0x000003C00000ULL, 0x000003C00000ULL, 0x000007E00000ULL, 0x000007E00000ULL, 0x00000FF00000ULL,
+ 0x00000FF00000ULL, 0x00001FF80000ULL, 0x00001FF80000ULL, 0x00001E780000ULL, 0x00003E7C0000ULL, 0x003FFC3FFC00ULL,
+ 0x0FFFFC3FFFF0ULL, 0x0FFFF81FFFF0ULL, 0x03FFF81FFFC0ULL, 0x01F800001F80ULL, 0x00FC00003F00ULL, 0x007E00007E00ULL,
+ 0x003F8001FC00ULL, 0x001FC003F800ULL, 0x000FE007F000ULL, 0x0003E007C000ULL, 0x0003E007C000ULL, 0x0003C003C000ULL,
+ 0x0003C003C000ULL, 0x0003C3C3C000ULL, 0x0007CFF3E000ULL, 0x00079FF9E000ULL, 0x0007FFFFE000ULL, 0x0007FE7FE000ULL,
+ 0x000FFC3FF000ULL, 0x000FF00FF000ULL, 0x000FC003F000ULL, 0x000F8001F000ULL, 0x001E00007000ULL, 0x001800001800ULL,
+ 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL, 0x000000000000ULL,
+};
+
+using IconBitmap = const uint64_t *;
+
+IconBitmap iconBitmapForKind(IconKind kind)
+{
+ switch (kind) {
+ case IconKind::ALL_MESSAGES:
+ return icon_all_messages;
+ case IconKind::DMS:
+ return icon_dms;
+ case IconKind::CHANNEL:
+ return icon_channel;
+ case IconKind::POSITIONS:
+ return icon_positions;
+ case IconKind::RECENTS:
+ return icon_recents;
+ case IconKind::HEARD:
+ return icon_heard;
+ case IconKind::FAVORITES:
+ return icon_favorites;
+ case IconKind::GENERIC:
+ default:
+ return icon_generic_apps;
+ }
+}
+
+GridLayout computeLayout(const InkHUD::Applet *applet)
+{
+ GridLayout layout;
+
+ const uint16_t w = applet->width();
+ const uint16_t h = applet->height();
+
+ layout.footerH = InkHUD::Applet::fontSmall.lineHeight() + (FOOTER_PAD * 2);
+ layout.bodyTop = BODY_MARGIN_Y;
+ layout.bodyBottom = (h > (layout.footerH + BODY_MARGIN_Y)) ? (h - layout.footerH - BODY_MARGIN_Y) : layout.bodyTop;
+
+ const uint16_t bodyW = (w > (BODY_MARGIN_X * 2)) ? (w - (BODY_MARGIN_X * 2)) : 1;
+ const uint16_t bodyH = (layout.bodyBottom > layout.bodyTop) ? (layout.bodyBottom - layout.bodyTop) : 1;
+ const uint16_t gapsX = SLOT_GAP_X * (GRID_COLS - 1);
+ const uint16_t gapsY = SLOT_GAP_Y * (GRID_ROWS - 1);
+
+ layout.slotW = (bodyW > gapsX) ? ((bodyW - gapsX) / GRID_COLS) : 1;
+ layout.slotH = (bodyH > gapsY) ? ((bodyH - gapsY) / GRID_ROWS) : 1;
+
+ const uint16_t maxIconW = (layout.slotW > 6) ? (layout.slotW - 6) : layout.slotW;
+ const uint16_t maxIconH = (layout.slotH > (InkHUD::Applet::fontSmall.lineHeight() + LABEL_GAP_Y + LABEL_BOTTOM_PAD + 6))
+ ? (layout.slotH - InkHUD::Applet::fontSmall.lineHeight() - LABEL_GAP_Y - LABEL_BOTTOM_PAD - 6)
+ : layout.slotH / 2;
+ layout.iconBox = std::max(20, std::min(maxIconW, maxIconH));
+ return layout;
+}
+
+std::string lowercase(const char *name)
+{
+ if (!name)
+ return "";
+ std::string out(name);
+ std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return (char)std::tolower(c); });
+ return out;
+}
+
+IconKind iconKindForAppletName(const char *name)
+{
+ const std::string lower = lowercase(name);
+ if (lower.find("all message") != std::string::npos || lower.find("messages") != std::string::npos)
+ return IconKind::ALL_MESSAGES;
+ if (lower.find("dm") != std::string::npos)
+ return IconKind::DMS;
+ if (lower.find("channel") != std::string::npos)
+ return IconKind::CHANNEL;
+ if (lower.find("position") != std::string::npos)
+ return IconKind::POSITIONS;
+ if (lower.find("recent") != std::string::npos)
+ return IconKind::RECENTS;
+ if (lower.find("heard") != std::string::npos)
+ return IconKind::HEARD;
+ if (lower.find("favorite") != std::string::npos)
+ return IconKind::FAVORITES;
+ return IconKind::GENERIC;
+}
+
+void drawIconBitmapScaled(InkHUD::Applet *applet, IconBitmap bmp48, int16_t left, int16_t top, uint16_t boxSize, uint16_t color)
+{
+ if (!bmp48 || boxSize == 0)
+ return;
+
+ auto srcOn = [bmp48](int16_t sx, int16_t sy) -> bool {
+ if (sx < 0 || sy < 0 || sx >= ICON_NATIVE_SIZE || sy >= ICON_NATIVE_SIZE)
+ return false;
+ const uint64_t rowBits = bmp48[sy];
+ return (rowBits & (1ULL << (47 - sx))) != 0;
+ };
+
+ for (uint16_t y = 0; y < boxSize; y++) {
+ const uint8_t srcY = (uint8_t)((y * ICON_NATIVE_SIZE) / boxSize);
+ for (uint16_t x = 0; x < boxSize; x++) {
+ const uint8_t srcX = (uint8_t)((x * ICON_NATIVE_SIZE) / boxSize);
+ if (!srcOn(srcX, srcY))
+ continue;
+
+ const uint16_t w = std::min(ICON_OUTLINE_STROKE, boxSize - x);
+ const uint16_t h = std::min(ICON_OUTLINE_STROKE, boxSize - y);
+ applet->fillRect(left + x, top + y, w, h, color);
+ }
+ }
+}
+} // namespace
+
+InkHUD::AppSwitcherApplet::AppSwitcherApplet()
+{
+ alwaysRender = true;
+}
+
+void InkHUD::AppSwitcherApplet::rebuildActiveAppletList()
+{
+ activeAppletIndices.clear();
+
+ const auto &settings = inkhud->persistence->settings;
+ const uint8_t tileCount = std::min(settings.userTiles.count, Persistence::MAX_TILES_GLOBAL);
+ const uint8_t focusedTile = (tileCount > 0) ? std::min(settings.userTiles.focused, tileCount - 1) : 0;
+
+ // Applets displayed on other tiles should not be selectable here.
+ std::vector occupiedOnOtherTiles(inkhud->userApplets.size(), false);
+ for (uint8_t tile = 0; tile < tileCount; tile++) {
+ if (tile == focusedTile)
+ continue;
+
+ const uint8_t appletIndex = settings.userTiles.displayedUserApplet[tile];
+ if (appletIndex < occupiedOnOtherTiles.size())
+ occupiedOnOtherTiles[appletIndex] = true;
+ }
+
+ for (uint8_t i = 0; i < inkhud->userApplets.size(); i++) {
+ Applet *a = inkhud->userApplets.at(i);
+ if (a && a->isActive() && !occupiedOnOtherTiles[i])
+ activeAppletIndices.push_back(i);
+ }
+}
+
+uint8_t InkHUD::AppSwitcherApplet::cardsPerPage() const
+{
+ return GRID_COLS * GRID_ROWS;
+}
+
+uint8_t InkHUD::AppSwitcherApplet::currentPage() const
+{
+ const uint8_t cpp = cardsPerPage();
+ if (cpp == 0)
+ return 0;
+ return selectedIndex / cpp;
+}
+
+void InkHUD::AppSwitcherApplet::stepPage(int8_t delta)
+{
+ if (activeAppletIndices.empty())
+ return;
+
+ const uint8_t cpp = cardsPerPage();
+ const uint8_t pageCount = std::max(1, (activeAppletIndices.size() + cpp - 1) / cpp);
+ int16_t nextPage = (int16_t)currentPage() + delta;
+ while (nextPage < 0)
+ nextPage += pageCount;
+ while (nextPage >= pageCount)
+ nextPage -= pageCount;
+
+ selectedIndex = std::min((uint8_t)(nextPage * cpp), activeAppletIndices.size() - 1);
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+}
+
+void InkHUD::AppSwitcherApplet::clampSelection()
+{
+ if (activeAppletIndices.empty()) {
+ selectedIndex = 0;
+ return;
+ }
+
+ if (selectedIndex >= activeAppletIndices.size())
+ selectedIndex = activeAppletIndices.size() - 1;
+}
+
+void InkHUD::AppSwitcherApplet::activateSelectedApplet()
+{
+ if (activeAppletIndices.empty()) {
+ sendToBackground();
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+ return;
+ }
+
+ const uint8_t appletIndex = activeAppletIndices.at(selectedIndex);
+
+ sendToBackground();
+ inkhud->showApplet(appletIndex);
+}
+
+void InkHUD::AppSwitcherApplet::onForeground()
+{
+ rebuildActiveAppletList();
+ clampSelection();
+ handleInput = true;
+ lockRequests = true;
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+}
+
+void InkHUD::AppSwitcherApplet::onBackground()
+{
+ handleInput = false;
+ lockRequests = false;
+
+ if (borrowedTileOwner)
+ borrowedTileOwner->bringToForeground();
+
+ Tile *t = getTile();
+ if (t)
+ t->assignApplet(borrowedTileOwner);
+ borrowedTileOwner = nullptr;
+}
+
+void InkHUD::AppSwitcherApplet::show(Tile *t)
+{
+ if (!t)
+ return;
+
+ borrowedTileOwner = t->getAssignedApplet();
+ if (borrowedTileOwner)
+ borrowedTileOwner->sendToBackground();
+
+ t->assignApplet(this);
+ bringToForeground();
+}
+
+void InkHUD::AppSwitcherApplet::onRender(bool full)
+{
+ (void)full;
+
+ const GridLayout layout = computeLayout(this);
+ const uint8_t cpp = cardsPerPage();
+ const uint8_t page = currentPage();
+ const uint8_t pageStart = page * cpp;
+
+ setFont(fontMedium);
+ setTextColor(BLACK);
+
+ fillRect(0, 0, width(), height(), WHITE);
+ drawRect(0, 0, width(), height(), BLACK);
+
+ if (activeAppletIndices.empty()) {
+ setFont(fontSmall);
+ printAt(width() / 2, height() / 2, "No Available Applets", CENTER, MIDDLE);
+ return;
+ }
+
+ for (uint8_t i = 0; i < cpp; i++) {
+ const uint8_t idx = pageStart + i;
+ if (idx >= activeAppletIndices.size())
+ break;
+
+ const uint8_t row = i / GRID_COLS;
+ const uint8_t col = i % GRID_COLS;
+ const int16_t slotL = BODY_MARGIN_X + (col * (layout.slotW + SLOT_GAP_X));
+ const int16_t slotT = layout.bodyTop + (row * (layout.slotH + SLOT_GAP_Y));
+ const bool selected = (idx == selectedIndex);
+
+ const uint8_t appletIndex = activeAppletIndices.at(idx);
+ Applet *a = inkhud->userApplets.at(appletIndex);
+ if (!a)
+ continue;
+
+ const int16_t iconLeft = slotL + ((layout.slotW - layout.iconBox) / 2);
+ const int16_t iconTop = slotT + 1;
+
+ // Requested style: icon in outlined rounded square only (no filled box, no outer app card).
+ drawRoundRect(iconLeft, iconTop, layout.iconBox, layout.iconBox, ICON_RADIUS, BLACK);
+ if (selected)
+ drawRoundRect(iconLeft + 2, iconTop + 2, layout.iconBox - 4, layout.iconBox - 4, ICON_RADIUS, BLACK);
+
+ const IconBitmap bmp = iconBitmapForKind(iconKindForAppletName(a->name));
+ drawIconBitmapScaled(this, bmp, iconLeft + 3, iconTop + 3, layout.iconBox - 6, BLACK);
+
+ setFont(fontSmall);
+ std::string label = a->name ? a->name : "Applet";
+ const uint16_t maxLabelW = layout.slotW > 4 ? (layout.slotW - 4) : layout.slotW;
+ if (getTextWidth(label) > maxLabelW) {
+ while (!label.empty() && getTextWidth(label + "...") > maxLabelW)
+ label.pop_back();
+ label = label.empty() ? "..." : label + "...";
+ }
+ const int16_t labelY = iconTop + layout.iconBox + LABEL_GAP_Y;
+ setTextColor(BLACK);
+ printAt(slotL + (layout.slotW / 2), labelY, label.c_str(), CENTER, TOP);
+
+ if (a->isForeground())
+ fillCircle(iconLeft + layout.iconBox - 4, iconTop + 4, 2, BLACK);
+ }
+
+ const uint8_t pageCount = std::max(1, (activeAppletIndices.size() + cpp - 1) / cpp);
+ if (pageCount > 1) {
+ setFont(fontSmall);
+ setTextColor(BLACK);
+ const int16_t footerY = height() - layout.footerH + FOOTER_PAD;
+ printAt(TITLE_H_PAD, footerY, "<", LEFT, TOP);
+ printAt(width() - TITLE_H_PAD, footerY, ">", RIGHT, TOP);
+ const std::string pageText = std::to_string(page + 1) + "/" + std::to_string(pageCount);
+ printAt(width() / 2, footerY, pageText.c_str(), CENTER, TOP);
+ }
+}
+
+bool InkHUD::AppSwitcherApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
+{
+ (void)longPress;
+
+ Tile *t = getTile();
+ if (!t || activeAppletIndices.empty())
+ return true;
+
+ const uint16_t tileL = t->getLeft();
+ const uint16_t tileT = t->getTop();
+ const uint16_t tileR = tileL + t->getWidth();
+ const uint16_t tileB = tileT + t->getHeight();
+ if (x < tileL || x >= tileR || y < tileT || y >= tileB)
+ return false;
+
+ const GridLayout layout = computeLayout(this);
+ const uint8_t cpp = cardsPerPage();
+ const uint8_t page = currentPage();
+ const uint8_t pageStart = page * cpp;
+ const int16_t localX = (int16_t)x - (int16_t)tileL;
+ const int16_t localY = (int16_t)y - (int16_t)tileT;
+
+ for (uint8_t i = 0; i < cpp; i++) {
+ const uint8_t idx = pageStart + i;
+ if (idx >= activeAppletIndices.size())
+ break;
+
+ const uint8_t row = i / GRID_COLS;
+ const uint8_t col = i % GRID_COLS;
+ const int16_t slotL = BODY_MARGIN_X + (col * (layout.slotW + SLOT_GAP_X));
+ const int16_t slotT = layout.bodyTop + (row * (layout.slotH + SLOT_GAP_Y));
+
+ if (localX < slotL || localX >= (slotL + (int16_t)layout.slotW))
+ continue;
+ if (localY < slotT || localY >= (slotT + (int16_t)layout.slotH))
+ continue;
+
+ selectedIndex = idx;
+ clampSelection();
+ activateSelectedApplet();
+ return true;
+ }
+
+ const uint8_t pageCount = std::max(1, (activeAppletIndices.size() + cpp - 1) / cpp);
+ if (pageCount <= 1)
+ return true;
+
+ const int16_t footerTop = height() - layout.footerH;
+ if (localY >= footerTop) {
+ if (localX < (int16_t)(width() / 3))
+ stepPage(-1);
+ else if (localX >= (int16_t)((width() * 2) / 3))
+ stepPage(1);
+ }
+
+ return true;
+}
+
+void InkHUD::AppSwitcherApplet::onButtonShortPress()
+{
+ if (activeAppletIndices.empty())
+ return;
+
+ selectedIndex = (selectedIndex + 1) % activeAppletIndices.size();
+ clampSelection();
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+}
+
+void InkHUD::AppSwitcherApplet::onButtonLongPress()
+{
+ activateSelectedApplet();
+}
+
+void InkHUD::AppSwitcherApplet::onExitShort()
+{
+ sendToBackground();
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+}
+
+void InkHUD::AppSwitcherApplet::onNavUp()
+{
+ if (activeAppletIndices.empty())
+ return;
+
+ if (selectedIndex == 0)
+ selectedIndex = activeAppletIndices.size() - 1;
+ else
+ selectedIndex--;
+
+ clampSelection();
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+}
+
+void InkHUD::AppSwitcherApplet::onNavDown()
+{
+ if (activeAppletIndices.empty())
+ return;
+
+ selectedIndex = (selectedIndex + 1) % activeAppletIndices.size();
+ clampSelection();
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+}
+
+#endif
diff --git a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.h b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.h
new file mode 100644
index 000000000..01ea561ae
--- /dev/null
+++ b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.h
@@ -0,0 +1,51 @@
+#ifdef MESHTASTIC_INCLUDE_INKHUD
+
+#pragma once
+
+#include "configuration.h"
+
+#include "graphics/niche/InkHUD/SystemApplet.h"
+
+#include
+
+namespace NicheGraphics::InkHUD
+{
+
+class Tile;
+
+class AppSwitcherApplet : public SystemApplet
+{
+ public:
+ AppSwitcherApplet();
+
+ void onForeground() override;
+ void onBackground() override;
+ void onRender(bool full) override;
+
+ void onButtonShortPress() override;
+ void onButtonLongPress() override;
+ void onExitShort() override;
+ void onNavUp() override;
+ void onNavDown() override;
+ bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
+
+ // Open the app switcher on a user tile and temporarily replace the tile's owner.
+ void show(Tile *t);
+
+ private:
+ void rebuildActiveAppletList();
+ void clampSelection();
+ uint8_t cardsPerPage() const;
+ uint8_t currentPage() const;
+ void stepPage(int8_t delta);
+ void activateSelectedApplet();
+
+ std::vector activeAppletIndices;
+ uint8_t selectedIndex = 0;
+
+ Applet *borrowedTileOwner = nullptr;
+};
+
+} // namespace NicheGraphics::InkHUD
+
+#endif
diff --git a/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.cpp
index 57581d56b..cb2b59953 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.cpp
@@ -1,155 +1,100 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./KeyboardApplet.h"
+#include
+
using namespace NicheGraphics;
+namespace
+{
+bool usePortraitKeyboardSizing()
+{
+ InkHUD::InkHUD *inkhud = InkHUD::InkHUD::getInstance();
+ return inkhud && inkhud->height() > inkhud->width();
+}
+} // namespace
+
InkHUD::KeyboardApplet::KeyboardApplet()
{
- // Calculate row widths
- for (uint8_t row = 0; row < KBD_ROWS; row++) {
- rowWidths[row] = 0;
- for (uint8_t col = 0; col < KBD_COLS; col++)
- rowWidths[row] += keyWidths[row * KBD_COLS + col];
- }
+ mode = MODE_TEXT;
+ lastTypingMode = MODE_TEXT;
+ emotePage = 0;
+ selectedKey = 0;
+ prevSelectedKey = 0;
+ normalizeSelection();
}
void InkHUD::KeyboardApplet::onRender(bool full)
{
- uint16_t em = fontSmall.lineHeight(); // 16 pt
- uint16_t keyH = Y(1.0) / KBD_ROWS;
- int16_t keyTopPadding = (keyH - fontSmall.lineHeight()) / 2;
+ const bool showSelection = showSelectionHighlight();
- if (full) { // Draw full keyboard
- for (uint8_t row = 0; row < KBD_ROWS; row++) {
-
- // Calculate the remaining space to be used as padding
- int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
-
- // Draw keys
- uint16_t xPos = 0;
- for (uint8_t col = 0; col < KBD_COLS; col++) {
- Color fgcolor = BLACK;
- uint8_t index = row * KBD_COLS + col;
- uint16_t keyX = ((xPos * em) >> 4) + ((col * keyXPadding) / (KBD_COLS - 1));
- uint16_t keyY = row * keyH;
- uint16_t keyW = (keyWidths[index] * em) >> 4;
- if (index == selectedKey) {
- fgcolor = WHITE;
- fillRect(keyX, keyY, keyW, keyH, BLACK);
- }
- drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[index], fgcolor);
- xPos += keyWidths[index];
- }
- }
- } else { // Only draw the difference
- if (selectedKey != prevSelectedKey) {
- // Draw previously selected key
- uint8_t row = prevSelectedKey / KBD_COLS;
- int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
- uint16_t xPos = 0;
- for (uint8_t i = prevSelectedKey - (prevSelectedKey % KBD_COLS); i < prevSelectedKey; i++)
- xPos += keyWidths[i];
- uint16_t keyX = ((xPos * em) >> 4) + (((prevSelectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
- uint16_t keyY = row * keyH;
- uint16_t keyW = (keyWidths[prevSelectedKey] * em) >> 4;
- fillRect(keyX, keyY, keyW, keyH, WHITE);
- drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[prevSelectedKey], BLACK);
-
- // Draw newly selected key
- row = selectedKey / KBD_COLS;
- keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
- xPos = 0;
- for (uint8_t i = selectedKey - (selectedKey % KBD_COLS); i < selectedKey; i++)
- xPos += keyWidths[i];
- keyX = ((xPos * em) >> 4) + (((selectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
- keyY = row * keyH;
- keyW = (keyWidths[selectedKey] * em) >> 4;
- fillRect(keyX, keyY, keyW, keyH, BLACK);
- drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[selectedKey], WHITE);
- }
+ if (full) {
+ for (uint8_t i = 0; i < KBD_KEY_COUNT; i++)
+ drawKey(i, showSelection && i == selectedKey);
+ } else if (showSelection && selectedKey != prevSelectedKey) {
+ drawKey(prevSelectedKey, false);
+ drawKey(selectedKey, true);
}
prevSelectedKey = selectedKey;
}
-// Draw the key label corresponding to the char
-// for most keys it draws the character itself
-// for ['\b', '\n', ' ', '\x1b'] it draws special glyphs
-void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color)
+void InkHUD::KeyboardApplet::drawKey(uint8_t index, bool selected)
{
- if (key == '\b') {
- // Draw backspace glyph: 13 x 9 px
- /**
- * [][][][][][][][][]
- * [][] []
- * [][] [] [] []
- * [][] [] [] []
- * [][] [] []
- * [][] [] [] []
- * [][] [] [] []
- * [][] []
- * [][][][][][][][][]
- */
- const uint8_t bsBitmap[] = {0x0f, 0xf8, 0x18, 0x08, 0x32, 0x28, 0x61, 0x48, 0xc0,
- 0x88, 0x61, 0x48, 0x32, 0x28, 0x18, 0x08, 0x0f, 0xf8};
- uint16_t leftPadding = (width - 13) >> 1;
- drawBitmap(left + leftPadding, top + 1, bsBitmap, 13, 9, color);
- } else if (key == '\n') {
- // Draw done glyph: 12 x 9 px
- /**
- * [][]
- * [][]
- * [][]
- * [][]
- * [][]
- * [][] [][]
- * [][] [][]
- * [][][]
- * []
- */
- const uint8_t doneBitmap[] = {0x00, 0x30, 0x00, 0x60, 0x00, 0xc0, 0x01, 0x80, 0x03,
- 0x00, 0xc6, 0x00, 0x6c, 0x00, 0x38, 0x00, 0x10, 0x00};
- uint16_t leftPadding = (width - 12) >> 1;
- drawBitmap(left + leftPadding, top + 1, doneBitmap, 12, 9, color);
- } else if (key == ' ') {
- // Draw space glyph: 13 x 9 px
- /**
- *
- *
- *
- *
- * [] []
- * [] []
- * [][][][][][][][][][][][][]
- *
- *
- */
- const uint8_t spaceBitmap[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
- 0x08, 0x80, 0x08, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00};
- uint16_t leftPadding = (width - 13) >> 1;
- drawBitmap(left + leftPadding, top + 1, spaceBitmap, 13, 9, color);
- } else if (key == '\x1b') {
- setTextColor(color);
- std::string keyText = "ESC";
- uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
- printAt(left + leftPadding, top, keyText);
- } else {
- setTextColor(color);
- if (key >= 0x61)
- key -= 32; // capitalize
- std::string keyText = std::string(1, key);
- uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
- printAt(left + leftPadding, top, keyText);
+ uint16_t keyX = 0;
+ uint16_t keyY = 0;
+ uint16_t keyW = 0;
+ uint16_t keyH = 0;
+ if (!getKeyBounds(index, keyX, keyY, keyW, keyH))
+ return;
+ if (keyW == 0 || keyH == 0)
+ return;
+
+ // Translate absolute tile coordinates into applet-local coordinates.
+ const int16_t localX = keyX - getTile()->getLeft();
+ const int16_t localY = keyY - getTile()->getTop();
+ const bool enabled = isKeyEnabledAt(index);
+
+ // Clean background first so hidden keys never leave stale pixels when mode changes.
+ fillRect(localX, localY, keyW, keyH, WHITE);
+
+ if (!enabled)
+ return;
+
+ fillRoundRect(localX, localY, keyW, keyH, KEY_RADIUS, selected ? BLACK : WHITE);
+ drawRoundRect(localX, localY, keyW, keyH, KEY_RADIUS, BLACK);
+
+ const int16_t labelTop = localY + ((keyH - fontSmall.lineHeight()) / 2);
+ drawKeyLabel(localX, labelTop, keyW, getKeyLabelAt(index), selected ? WHITE : BLACK);
+}
+
+void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, const std::string &label, Color color)
+{
+ if (label.empty())
+ return;
+
+ setTextColor(color);
+ uint16_t textW = getTextWidth(label);
+ if (textW > width) {
+ // Keep labels readable in narrow keys.
+ textW = getTextWidth("..");
+ printAt(left + ((width - textW) >> 1), top, "..");
+ return;
}
+
+ uint16_t leftPadding = (width - textW) >> 1;
+ printAt(left + leftPadding, top, label);
}
void InkHUD::KeyboardApplet::onForeground()
{
- handleInput = true; // Intercept the button input for our applet
-
- // Select the first key
+ handleInput = true;
+ mode = MODE_TEXT;
+ lastTypingMode = MODE_TEXT;
+ emotePage = 0;
selectedKey = 0;
prevSelectedKey = 0;
+ normalizeSelection();
}
void InkHUD::KeyboardApplet::onBackground()
@@ -159,32 +104,12 @@ void InkHUD::KeyboardApplet::onBackground()
void InkHUD::KeyboardApplet::onButtonShortPress()
{
- char key = keys[selectedKey];
- if (key == '\n') {
- inkhud->freeTextDone();
- inkhud->closeKeyboard();
- } else if (key == '\x1b') {
- inkhud->freeTextCancel();
- inkhud->closeKeyboard();
- } else {
- inkhud->freeText(key);
- }
+ inputSelectedKey(false);
}
void InkHUD::KeyboardApplet::onButtonLongPress()
{
- char key = keys[selectedKey];
- if (key == '\n') {
- inkhud->freeTextDone();
- inkhud->closeKeyboard();
- } else if (key == '\x1b') {
- inkhud->freeTextCancel();
- inkhud->closeKeyboard();
- } else {
- if (key >= 0x61)
- key -= 32; // capitalize
- inkhud->freeText(key);
- }
+ inputSelectedKey(true);
}
void InkHUD::KeyboardApplet::onExitShort()
@@ -201,57 +126,377 @@ void InkHUD::KeyboardApplet::onExitLong()
void InkHUD::KeyboardApplet::onNavUp()
{
- if (selectedKey < KBD_COLS) // wrap
+ if (selectedKey < KBD_COLS)
selectedKey += KBD_COLS * (KBD_ROWS - 1);
- else // move 1 row back
+ else
selectedKey -= KBD_COLS;
- // Request rendering over the previously drawn render
- requestUpdate(EInk::UpdateTypes::FAST, false);
- // Force an update to bypass lockRequests
- inkhud->forceUpdate(EInk::UpdateTypes::FAST);
+ normalizeSelection();
+ requestFastKeyboardRefresh();
}
void InkHUD::KeyboardApplet::onNavDown()
{
selectedKey += KBD_COLS;
- selectedKey %= (KBD_COLS * KBD_ROWS);
-
- // Request rendering over the previously drawn render
- requestUpdate(EInk::UpdateTypes::FAST, false);
- // Force an update to bypass lockRequests
- inkhud->forceUpdate(EInk::UpdateTypes::FAST);
+ selectedKey %= KBD_KEY_COUNT;
+ normalizeSelection();
+ requestFastKeyboardRefresh();
}
void InkHUD::KeyboardApplet::onNavLeft()
{
- if (selectedKey % KBD_COLS == 0) // wrap
+ if (selectedKey % KBD_COLS == 0)
selectedKey += KBD_COLS - 1;
- else // move 1 column back
+ else
selectedKey--;
- // Request rendering over the previously drawn render
- requestUpdate(EInk::UpdateTypes::FAST, false);
- // Force an update to bypass lockRequests
- inkhud->forceUpdate(EInk::UpdateTypes::FAST);
+ normalizeSelection();
+ requestFastKeyboardRefresh();
}
void InkHUD::KeyboardApplet::onNavRight()
{
- if (selectedKey % KBD_COLS == KBD_COLS - 1) // wrap
+ if (selectedKey % KBD_COLS == KBD_COLS - 1)
selectedKey -= KBD_COLS - 1;
- else // move 1 column forward
+ else
selectedKey++;
- // Request rendering over the previously drawn render
- requestUpdate(EInk::UpdateTypes::FAST, false);
- // Force an update to bypass lockRequests
- inkhud->forceUpdate(EInk::UpdateTypes::FAST);
+ normalizeSelection();
+ requestFastKeyboardRefresh();
+}
+
+bool InkHUD::KeyboardApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
+{
+ // If touch is outside our tile, let other handlers process it.
+ if (!getTile())
+ return false;
+ const uint16_t tileL = getTile()->getLeft();
+ const uint16_t tileT = getTile()->getTop();
+ const uint16_t tileR = tileL + getTile()->getWidth();
+ const uint16_t tileB = tileT + getTile()->getHeight();
+ if (x < tileL || x >= tileR || y < tileT || y >= tileB)
+ return false;
+
+ const int16_t hitIndex = getKeyIndexAt(x, y);
+ // Consume touches that land in keyboard whitespace/disabled cells so we don't
+ // fall back to generic short-press behavior (which would type the old selection).
+ if (hitIndex < 0)
+ return true;
+
+ const uint8_t newSelected = (uint8_t)hitIndex;
+ if (selectedKey != newSelected) {
+ selectedKey = newSelected;
+ normalizeSelection();
+ if (showSelectionHighlight())
+ requestFastKeyboardRefresh();
+ }
+
+ if (!isKeyEnabledAt(selectedKey))
+ return true;
+
+ inputSelectedKey(longPress);
+ return true;
+}
+
+bool InkHUD::KeyboardApplet::getKeyBounds(uint8_t index, uint16_t &left, uint16_t &top, uint16_t &width, uint16_t &height)
+{
+ if (index >= KBD_KEY_COUNT || !getTile())
+ return false;
+
+ const uint16_t tileW = getTile()->getWidth();
+ const uint16_t tileH = getTile()->getHeight();
+ const uint16_t tileL = getTile()->getLeft();
+ const uint16_t tileT = getTile()->getTop();
+ const uint8_t row = index / KBD_COLS;
+ const uint8_t col = index % KBD_COLS;
+
+ const uint16_t totalGapY = KEY_GAP_Y * (KBD_ROWS + 1);
+ const uint16_t keyH = (tileH > totalGapY) ? ((tileH - totalGapY) / KBD_ROWS) : (tileH / KBD_ROWS);
+ top = tileT + KEY_GAP_Y + row * (keyH + KEY_GAP_Y);
+ height = keyH;
+
+ const uint16_t totalGapX = KEY_GAP_X * (KBD_COLS + 1);
+ const uint16_t rowSpace = (tileW > totalGapX) ? (tileW - totalGapX) : tileW;
+ uint32_t rowUnits = 0;
+ const uint8_t rowStart = row * KBD_COLS;
+ for (uint8_t i = 0; i < KBD_COLS; i++) {
+ rowUnits += getKeyWidthAt(rowStart + i);
+ }
+ if (rowUnits == 0)
+ return false;
+
+ uint32_t cursorX = tileL + KEY_GAP_X;
+ for (uint8_t i = 0; i < col; i++) {
+ const uint8_t rowIndex = rowStart + i;
+ const uint32_t keyW = ((uint32_t)rowSpace * getKeyWidthAt(rowIndex)) / rowUnits;
+ cursorX += keyW + KEY_GAP_X;
+ }
+
+ left = (uint16_t)cursorX;
+
+ if (col == (KBD_COLS - 1)) {
+ const uint32_t rightEdge = tileL + tileW - KEY_GAP_X;
+ width = (rightEdge > cursorX) ? (uint16_t)(rightEdge - cursorX) : 0;
+ } else {
+ width = (uint16_t)(((uint32_t)rowSpace * getKeyWidthAt(index)) / rowUnits);
+ }
+
+ return true;
+}
+
+int16_t InkHUD::KeyboardApplet::getKeyIndexAt(uint16_t x, uint16_t y)
+{
+ for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) {
+ uint16_t keyL = 0;
+ uint16_t keyT = 0;
+ uint16_t keyW = 0;
+ uint16_t keyH = 0;
+ if (!getKeyBounds(i, keyL, keyT, keyW, keyH))
+ return -1;
+
+ if (keyW == 0 || keyH == 0)
+ continue;
+
+ if (x >= keyL && x < (keyL + keyW) && y >= keyT && y < (keyT + keyH))
+ return i;
+ }
+
+ return -1;
+}
+
+void InkHUD::KeyboardApplet::inputSelectedKey(bool longPress)
+{
+ inputKeyCode(getKeyCodeAt(selectedKey), longPress);
+}
+
+void InkHUD::KeyboardApplet::inputKeyCode(int16_t keyCode, bool longPress)
+{
+ if (keyCode == KEY_NONE)
+ return;
+
+ if (keyCode >= KEY_EMOTE_SLOT_BASE) {
+ const uint8_t slot = (uint8_t)(keyCode - KEY_EMOTE_SLOT_BASE);
+ const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + slot;
+ if (emoteIndex < fontEmoteCount)
+ inkhud->freeText((char)fontEmotes[emoteIndex]);
+ return;
+ }
+
+ switch (keyCode) {
+ case KEY_BACKSPACE:
+ inkhud->freeText('\b');
+ return;
+ case KEY_SEND:
+ inkhud->freeTextDone();
+ inkhud->closeKeyboard();
+ return;
+ case KEY_EMOTE_TOGGLE:
+ toggleEmoteMode();
+ return;
+ case KEY_PUNCT_TOGGLE:
+ case KEY_ALPHA_TOGGLE:
+ togglePunctuationMode();
+ return;
+ case KEY_EMOTE_UP:
+ pageEmotes(false);
+ return;
+ case KEY_EMOTE_DOWN:
+ pageEmotes(true);
+ return;
+ default:
+ break;
+ }
+
+ if (keyCode >= 0 && keyCode <= 0xFF) {
+ char key = (char)keyCode;
+ if (longPress && key >= 'a' && key <= 'z')
+ key = (char)std::toupper((unsigned char)key);
+ inkhud->freeText(key);
+ }
+}
+
+int16_t InkHUD::KeyboardApplet::getKeyCodeAt(uint8_t index) const
+{
+ if (index >= KBD_KEY_COUNT)
+ return KEY_NONE;
+
+ if (mode == MODE_TEXT)
+ return textKeys[index];
+ if (mode == MODE_PUNCT)
+ return punctKeys[index];
+
+ // Emote mode
+ if (index < EMOTE_SLOT_COUNT) {
+ const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + index;
+ if (emoteIndex < fontEmoteCount)
+ return KEY_EMOTE_SLOT_BASE + index;
+ return KEY_NONE;
+ }
+
+ // Emote controls on the bottom row
+ switch (index - EMOTE_SLOT_COUNT) {
+ case 0:
+ return KEY_EMOTE_UP;
+ case 1:
+ return KEY_EMOTE_DOWN;
+ case 2:
+ return KEY_ALPHA_TOGGLE;
+ case 3:
+ return ',';
+ case 4:
+ return ' ';
+ case 5:
+ return '.';
+ case 6:
+ return KEY_SEND;
+ case 7:
+ return KEY_BACKSPACE;
+ default:
+ return KEY_NONE;
+ }
+}
+
+uint16_t InkHUD::KeyboardApplet::getKeyWidthAt(uint8_t index) const
+{
+ if (index >= KBD_KEY_COUNT)
+ return 0;
+
+ if (mode == MODE_EMOTE)
+ return emoteKeyWidths[index];
+ return typingKeyWidths[index];
+}
+
+std::string InkHUD::KeyboardApplet::getKeyLabelAt(uint8_t index) const
+{
+ const int16_t keyCode = getKeyCodeAt(index);
+ if (keyCode == KEY_NONE)
+ return "";
+
+ if (keyCode >= KEY_EMOTE_SLOT_BASE) {
+ const uint8_t slot = (uint8_t)(keyCode - KEY_EMOTE_SLOT_BASE);
+ const uint16_t emoteIndex = emotePage * EMOTE_SLOT_COUNT + slot;
+ if (emoteIndex < fontEmoteCount)
+ return std::string(1, (char)fontEmotes[emoteIndex]);
+ return "";
+ }
+
+ switch (keyCode) {
+ case KEY_BACKSPACE:
+ return "DEL";
+ case KEY_SEND:
+ return "SEND";
+ case KEY_EMOTE_TOGGLE:
+ return std::string(1, (char)0x03); // Smiling face icon from InkHUD emote font map
+ case KEY_PUNCT_TOGGLE:
+ return "!#1";
+ case KEY_ALPHA_TOGGLE:
+ return "ABC";
+ case KEY_EMOTE_UP:
+ return "UP";
+ case KEY_EMOTE_DOWN:
+ return "DN";
+ default:
+ break;
+ }
+
+ if (keyCode >= 0 && keyCode <= 0xFF) {
+ const char c = (char)keyCode;
+ if (c == ' ')
+ return "SPACE";
+ if (c >= 'a' && c <= 'z')
+ return std::string(1, (char)std::toupper((unsigned char)c));
+ return std::string(1, c);
+ }
+
+ return "";
+}
+
+bool InkHUD::KeyboardApplet::isKeyEnabledAt(uint8_t index) const
+{
+ return getKeyCodeAt(index) != KEY_NONE;
+}
+
+void InkHUD::KeyboardApplet::normalizeSelection()
+{
+ if (selectedKey >= KBD_KEY_COUNT)
+ selectedKey = 0;
+
+ if (isKeyEnabledAt(selectedKey))
+ return;
+
+ for (uint8_t i = 0; i < KBD_KEY_COUNT; i++) {
+ if (isKeyEnabledAt(i)) {
+ selectedKey = i;
+ return;
+ }
+ }
+}
+
+void InkHUD::KeyboardApplet::togglePunctuationMode()
+{
+ if (mode == MODE_EMOTE) {
+ mode = lastTypingMode;
+ } else {
+ mode = (mode == MODE_TEXT) ? MODE_PUNCT : MODE_TEXT;
+ lastTypingMode = mode;
+ }
+
+ normalizeSelection();
+ requestFastKeyboardRefresh(true);
+}
+
+void InkHUD::KeyboardApplet::toggleEmoteMode()
+{
+ if (mode == MODE_EMOTE) {
+ mode = lastTypingMode;
+ } else {
+ lastTypingMode = mode;
+ mode = MODE_EMOTE;
+ }
+
+ emotePage = 0;
+ normalizeSelection();
+ requestFastKeyboardRefresh(true);
+}
+
+void InkHUD::KeyboardApplet::pageEmotes(bool down)
+{
+ if (mode != MODE_EMOTE)
+ return;
+
+ const uint8_t maxPage = (fontEmoteCount == 0) ? 0 : (uint8_t)((fontEmoteCount - 1) / EMOTE_SLOT_COUNT);
+
+ if (down) {
+ if (emotePage < maxPage)
+ emotePage++;
+ } else {
+ if (emotePage > 0)
+ emotePage--;
+ }
+
+ normalizeSelection();
+ requestFastKeyboardRefresh(true);
+}
+
+void InkHUD::KeyboardApplet::requestFastKeyboardRefresh(bool full)
+{
+ requestUpdate(EInk::UpdateTypes::FAST, full);
+}
+
+bool InkHUD::KeyboardApplet::showSelectionHighlight() const
+{
+ // On touch-capable devices, prioritize input throughput over per-key highlight updates.
+ // E-ink refresh can lag rapid taps; skipping highlight avoids update-induced input latency.
+ return !inkhud->hasTouchEnabledProvider();
}
uint16_t InkHUD::KeyboardApplet::getKeyboardHeight()
{
- const uint16_t keyH = fontSmall.lineHeight() * 1.2;
- return keyH * KBD_ROWS;
+ // Keep touch keys tall and roomy for finger input.
+ // In portrait orientation we increase row height for larger touch targets.
+ const uint16_t rowUnit = fontSmall.lineHeight() + 8;
+ const uint8_t rowScale = usePortraitKeyboardSizing() ? 3 : 2;
+ const uint16_t keyH = rowUnit * rowScale;
+ return (keyH * KBD_ROWS) + (KEY_GAP_Y * (KBD_ROWS + 1));
}
#endif
diff --git a/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.h b/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.h
index 0ae181a2c..b71376459 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Keyboard/KeyboardApplet.h
@@ -12,6 +12,7 @@ System Applet to render an on-screen keyboard
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
#include
+
namespace NicheGraphics::InkHUD
{
@@ -31,34 +32,111 @@ class KeyboardApplet : public SystemApplet
void onNavDown() override;
void onNavLeft() override;
void onNavRight() override;
+ bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
static uint16_t getKeyboardHeight(); // used to set the keyboard tile height
private:
- void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color);
+ enum KeyCode : int16_t {
+ KEY_NONE = -1,
+ KEY_BACKSPACE = 256,
+ KEY_SEND,
+ KEY_EMOTE_TOGGLE,
+ KEY_PUNCT_TOGGLE,
+ KEY_ALPHA_TOGGLE,
+ KEY_EMOTE_UP,
+ KEY_EMOTE_DOWN,
+ KEY_EMOTE_SLOT_BASE = 512
+ };
+
+ enum KeyboardMode : uint8_t { MODE_TEXT = 0, MODE_PUNCT = 1, MODE_EMOTE = 2 };
+
+ void drawKey(uint8_t index, bool selected);
+ void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, const std::string &label, Color color);
+ bool getKeyBounds(uint8_t index, uint16_t &left, uint16_t &top, uint16_t &width, uint16_t &height);
+ int16_t getKeyIndexAt(uint16_t x, uint16_t y);
+ void inputSelectedKey(bool longPress);
+ void inputKeyCode(int16_t keyCode, bool longPress);
+ int16_t getKeyCodeAt(uint8_t index) const;
+ uint16_t getKeyWidthAt(uint8_t index) const;
+ std::string getKeyLabelAt(uint8_t index) const;
+ bool isKeyEnabledAt(uint8_t index) const;
+ void normalizeSelection();
+ void togglePunctuationMode();
+ void toggleEmoteMode();
+ void pageEmotes(bool down);
+ void requestFastKeyboardRefresh(bool full = false);
+ bool showSelectionHighlight() const;
static const uint8_t KBD_COLS = 11;
- static const uint8_t KBD_ROWS = 4;
+ static const uint8_t KBD_ROWS = 5;
+ static const uint8_t KBD_KEY_COUNT = KBD_COLS * KBD_ROWS;
+ static const uint8_t EMOTE_SLOT_COUNT = KBD_COLS * (KBD_ROWS - 1); // top 4 rows
+ static constexpr uint8_t fontEmotes[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0B, 0x0C, 0x0E, 0x0F, 0x10, 0x11,
+ 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F};
+ static constexpr uint8_t fontEmoteCount = sizeof(fontEmotes) / sizeof(fontEmotes[0]);
- const char keys[KBD_COLS * KBD_ROWS] = {
- '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '\b', // row 0
- 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '\n', // row 1
- 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '!', ' ', // row 2
- 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '?', '\x1b' // row 3
- };
+ // Text keyboard (requested layout):
+ // row 0: 1..0
+ // row 1: q..p
+ // row 2: a..l
+ // row 3: EMO, z..m, DEL
+ // row 4: !#1, comma, space, period, SEND
+ const int16_t textKeys[KBD_KEY_COUNT] = {
+ // row 0
+ '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', KEY_NONE,
+ // row 1
+ 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', KEY_NONE,
+ // row 2
+ 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', KEY_NONE, KEY_NONE,
+ // row 3
+ KEY_EMOTE_TOGGLE, 'z', 'x', 'c', 'v', 'b', 'n', 'm', KEY_BACKSPACE, KEY_NONE, KEY_NONE,
+ // row 4
+ KEY_PUNCT_TOGGLE, ',', ' ', '.', KEY_SEND, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE};
- // This array represents the widths of each key in points
- // 16 pt = line height of the text
- const uint16_t keyWidths[KBD_COLS * KBD_ROWS] = {
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 0
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 1
- 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 2
- 16, 16, 16, 16, 16, 16, 16, 10, 10, 12, 40 // row 3
- };
+ // Punctuation keyboard (toggle via !#1/ABC)
+ const int16_t punctKeys[KBD_KEY_COUNT] = {
+ // row 0
+ '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', KEY_NONE,
+ // row 1
+ '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', KEY_NONE,
+ // row 2
+ '-', '_', '=', '+', '[', ']', '{', '}', '/', '?', KEY_NONE,
+ // row 3
+ KEY_EMOTE_TOGGLE, ';', ':', '\'', '"', '<', '>', '\\', KEY_BACKSPACE, KEY_NONE, KEY_NONE,
+ // row 4
+ KEY_ALPHA_TOGGLE, ',', ' ', '.', KEY_SEND, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE, KEY_NONE};
- uint16_t rowWidths[KBD_ROWS];
- uint8_t selectedKey = 0; // selected key index
+ const uint16_t typingKeyWidths[KBD_KEY_COUNT] = {// row 0
+ 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 0,
+ // row 1
+ 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 0,
+ // row 2
+ 12, 12, 12, 12, 12, 12, 12, 12, 12, 0, 0,
+ // row 3
+ 18, 12, 12, 12, 12, 12, 12, 12, 20, 0, 0,
+ // row 4
+ 20, 12, 56, 12, 24, 0, 0, 0, 0, 0, 0};
+
+ const uint16_t emoteKeyWidths[KBD_KEY_COUNT] = {// row 0
+ 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
+ // row 1
+ 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
+ // row 2
+ 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
+ // row 3
+ 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
+ // row 4 controls
+ 14, 14, 18, 12, 40, 12, 20, 18, 0, 0, 0};
+
+ uint8_t selectedKey = 0;
uint8_t prevSelectedKey = 0;
+ uint8_t emotePage = 0;
+ KeyboardMode mode = MODE_TEXT;
+ KeyboardMode lastTypingMode = MODE_TEXT;
+ static constexpr uint8_t KEY_GAP_X = 3;
+ static constexpr uint8_t KEY_GAP_Y = 4;
+ static constexpr uint8_t KEY_RADIUS = 4;
};
} // namespace NicheGraphics::InkHUD
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
index 7ec76292b..e1f004d38 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
@@ -109,6 +109,7 @@ enum MenuAction {
TOGGLE_CHANNEL_POSITION,
SET_CHANNEL_PRECISION,
// Display
+ SET_DISPLAY_TIMEOUT,
TOGGLE_DISPLAY_UNITS,
// Network
TOGGLE_WIFI,
@@ -119,4 +120,4 @@ enum MenuAction {
} // namespace NicheGraphics::InkHUD
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
index d489d21ee..dfef4d085 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
@@ -8,6 +8,7 @@
#include "RTC.h"
#include "Router.h"
#include "airtime.h"
+#include "graphics/niche/Utils/FlashData.h"
#include "main.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "power.h"
@@ -27,6 +28,16 @@ static constexpr uint8_t MENU_TIMEOUT_SEC = 60; // How many seconds before menu
// These are offered to users as possible values for settings.recentlyActiveSeconds
static constexpr uint8_t RECENTS_OPTIONS_MINUTES[] = {2, 5, 10, 30, 60, 120};
+struct DisplayTimeoutOption {
+ uint32_t seconds;
+ const char *label;
+};
+
+static constexpr DisplayTimeoutOption DISPLAY_TIMEOUT_OPTIONS[] = {
+ {0, "Forever"}, {30, "30 secs"}, {60, "1 min"}, {5 * 60, "5 min"},
+ {15 * 60, "15 min"}, {30 * 60, "30 min"}, {60 * 60, "1 hr"},
+};
+
struct PositionPrecisionOption {
uint8_t value; // proto value
const char *metric;
@@ -39,6 +50,77 @@ static constexpr PositionPrecisionOption POSITION_PRECISION_OPTIONS[] = {
{12, "5.8 km", "3.6 mi"}, {11, "12 km", "7.3 mi"}, {10, "23 km", "15 mi"},
};
+static const char *getDisplayTimeoutLabel(uint32_t timeoutSeconds)
+{
+ constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
+ for (uint8_t i = 0; i < optionCount; i++) {
+ if (DISPLAY_TIMEOUT_OPTIONS[i].seconds == timeoutSeconds) {
+ return DISPLAY_TIMEOUT_OPTIONS[i].label;
+ }
+ }
+
+ return "Custom";
+}
+
+static bool supportsFreeTextKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings)
+{
+ return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider());
+}
+
+static bool useTouchFriendlyMenuLayout(const InkHUD::InkHUD *inkhud)
+{
+ return inkhud != nullptr && inkhud->hasTouchEnabledProvider();
+}
+
+static uint16_t getMenuItemHeightPx(const InkHUD::InkHUD *inkhud)
+{
+ const bool touchFriendly = useTouchFriendlyMenuLayout(inkhud);
+ const uint16_t lineH = touchFriendly ? InkHUD::Applet::fontMedium.lineHeight() : InkHUD::Applet::fontSmall.lineHeight();
+ const float rowScale = touchFriendly ? 1.9f : 1.6f;
+ uint16_t itemH = (uint16_t)(lineH * rowScale);
+ if (itemH == 0) {
+ itemH = 1;
+ }
+ return itemH;
+}
+
+#if defined(T5_S3_EPAPER_PRO)
+namespace
+{
+static constexpr uint32_t T5_BACKLIGHT_PREFS_VERSION = 1;
+
+struct T5BacklightPrefs {
+ uint32_t version = T5_BACKLIGHT_PREFS_VERSION;
+ bool keepOn = true;
+};
+
+T5BacklightPrefs t5BacklightPrefs;
+bool t5BacklightPrefsLoaded = false;
+
+bool loadT5BacklightKeepOn()
+{
+ if (!t5BacklightPrefsLoaded) {
+ T5BacklightPrefs loaded;
+ const bool ok = FlashData::load(&loaded, "t5_backlight");
+ if (ok && loaded.version == T5_BACKLIGHT_PREFS_VERSION) {
+ t5BacklightPrefs = loaded;
+ }
+ t5BacklightPrefsLoaded = true;
+ }
+
+ return t5BacklightPrefs.keepOn;
+}
+
+void saveT5BacklightKeepOn(bool keepOn)
+{
+ loadT5BacklightKeepOn();
+ t5BacklightPrefs.version = T5_BACKLIGHT_PREFS_VERSION;
+ t5BacklightPrefs.keepOn = keepOn;
+ FlashData::save(&t5BacklightPrefs, "t5_backlight");
+}
+} // namespace
+#endif
+
InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
{
// No timer tasks at boot
@@ -47,7 +129,11 @@ InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet")
// Note: don't get instance if we're not actually using the backlight,
// or else you will unintentionally instantiate it
if (settings->optionalMenuItems.backlight) {
+#if defined(T5_S3_EPAPER_PRO)
+ t5BacklightSetUserEnabled(loadT5BacklightKeepOn());
+#else
backlight = Drivers::LatchingBacklight::getInstance();
+#endif
}
// Initialize the Canned Message store
@@ -76,9 +162,11 @@ void InkHUD::MenuApplet::onForeground()
// backlight on always when menu opens.
// Courtesy to T-Echo users who removed the capacitive touch button
if (settings->optionalMenuItems.backlight) {
+#if !defined(T5_S3_EPAPER_PRO)
assert(backlight);
if (!backlight->isOn())
backlight->peek();
+#endif
}
// Prevent user applets requesting update while menu is open
@@ -106,9 +194,11 @@ void InkHUD::MenuApplet::onBackground()
// Item in options submenu allows keeping backlight on after menu is closed
// If this item is deselected we will turn backlight off again, now that menu is closing
if (settings->optionalMenuItems.backlight) {
+#if !defined(T5_S3_EPAPER_PRO)
assert(backlight);
if (!backlight->isLatched())
backlight->off();
+#endif
}
// Stop the auto-timeout
@@ -333,17 +423,14 @@ void InkHUD::MenuApplet::execute(MenuItem item)
handleFreeText = true;
cm.freeTextItem.rawText.erase(); // clear the previous freetext message
freeTextMode = true; // render input field instead of normal menu
- // Open the on-screen keyboard only for full joystick devices
- if (settings->joystick.enabled && !inkhud->twoWayRocker)
+ if (supportsFreeTextKeyboard(inkhud, settings))
inkhud->openKeyboard();
break;
- case STORE_CANNEDMESSAGE_SELECTION:
- if (!settings->joystick.enabled || inkhud->twoWayRocker)
- cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry
- else
- cm.selectedMessageItem = &cm.messageItems.at(cursor - 2); // Minus two: offset for the "Send Ping" and free text entry
- break;
+ case STORE_CANNEDMESSAGE_SELECTION: {
+ const uint8_t prefixItems = supportsFreeTextKeyboard(inkhud, settings) ? 2 : 1;
+ cm.selectedMessageItem = &cm.messageItems.at(cursor - prefixItems);
+ } break;
case SEND_CANNEDMESSAGE:
cm.selectedRecipientItem = &cm.recipientItems.at(cursor);
@@ -422,14 +509,27 @@ void InkHUD::MenuApplet::execute(MenuItem item)
break;
case TOGGLE_BACKLIGHT:
- // Note: backlight is already on in this situation
- // We're marking that it should *remain* on once menu closes
- assert(backlight);
+ // Note: backlight is already on in this situation.
+ // This toggle controls whether it should remain on when menu closes.
+#if defined(T5_S3_EPAPER_PRO)
+ {
+ const bool keepOn = !t5BacklightIsUserEnabled();
+ t5BacklightSetUserEnabled(keepOn);
+ saveT5BacklightKeepOn(keepOn);
+ if (item.checkState)
+ *(item.checkState) = keepOn;
+ }
+#else
+ if (!backlight)
+ backlight = Drivers::LatchingBacklight::getInstance();
if (backlight->isLatched())
backlight->off();
else
backlight->latch();
- break;
+ if (item.checkState)
+ *(item.checkState) = backlight->isLatched();
+#endif
+ break;
case TOGGLE_12H_CLOCK:
config.display.use_12h_clock = !config.display.use_12h_clock;
@@ -527,6 +627,17 @@ void InkHUD::MenuApplet::execute(MenuItem item)
}
// Display
+ case SET_DISPLAY_TIMEOUT: {
+ // cursor - 1 because index 0 is "Back"
+ const uint8_t index = cursor - 1;
+ constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
+ if (index < optionCount) {
+ config.display.screen_on_secs = DISPLAY_TIMEOUT_OPTIONS[index].seconds;
+ nodeDB->saveToDisk(SEGMENT_CONFIG);
+ }
+ break;
+ }
+
case TOGGLE_DISPLAY_UNITS:
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC;
@@ -893,11 +1004,16 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
previousPage = MenuPage::ROOT;
items.push_back(MenuItem("Back", previousPage));
// Optional: backlight
- if (settings->optionalMenuItems.backlight)
- items.push_back(MenuItem(backlight->isLatched() ? "Backlight Off" : "Keep Backlight On", // Label
- MenuAction::TOGGLE_BACKLIGHT, // Action
- MenuPage::EXIT // Exit once complete
- ));
+ if (settings->optionalMenuItems.backlight) {
+#if defined(T5_S3_EPAPER_PRO)
+ keepBacklightOn = t5BacklightIsUserEnabled();
+#else
+ if (!backlight)
+ backlight = Drivers::LatchingBacklight::getInstance();
+ keepBacklightOn = backlight->isLatched();
+#endif
+ items.push_back(MenuItem("Keep Backlight On", MenuAction::TOGGLE_BACKLIGHT, MenuPage::OPTIONS, &keepBacklightOn));
+ }
// Options Toggles
items.push_back(MenuItem("Applets", MenuPage::APPLETS));
@@ -1109,6 +1225,9 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
items.push_back(MenuItem("12-Hour Clock", MenuAction::TOGGLE_12H_CLOCK, MenuPage::NODE_CONFIG_DISPLAY,
&config.display.use_12h_clock));
+ nodeConfigLabels.emplace_back("Screen Timeout: " + std::string(getDisplayTimeoutLabel(config.display.screen_on_secs)));
+ items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::NO_ACTION, MenuPage::NODE_CONFIG_DISPLAY_TIMEOUT));
+
const char *unitsLabel =
(config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) ? "Units: Imperial" : "Units: Metric";
@@ -1118,6 +1237,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
break;
}
+ case NODE_CONFIG_DISPLAY_TIMEOUT:
+ previousPage = MenuPage::NODE_CONFIG_DISPLAY;
+ items.push_back(MenuItem("Back", previousPage));
+ populateDisplayTimeoutPage();
+ items.push_back(MenuItem("Exit", MenuPage::EXIT));
+ break;
+
case NODE_CONFIG_BLUETOOTH: {
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
@@ -1386,10 +1512,14 @@ void InkHUD::MenuApplet::onRender(bool full)
if (items.size() == 0)
LOG_ERROR("Empty Menu");
+ const bool touchFriendlyLayout = useTouchFriendlyMenuLayout(inkhud);
+ AppletFont menuItemFont = touchFriendlyLayout ? fontMedium : fontSmall;
+ setFont(menuItemFont);
+
// Dimensions for the slots where we will draw menuItems
const float padding = 0.05;
- const uint16_t itemH = fontSmall.lineHeight() * 1.6;
- const int16_t selectInsetY = 2;
+ const uint16_t itemH = getMenuItemHeightPx(inkhud);
+ const int16_t selectInsetY = touchFriendlyLayout ? 3 : 2;
const int16_t itemW = width() - X(padding) - X(padding);
const int16_t itemL = X(padding);
const int16_t itemR = X(1 - padding);
@@ -1422,6 +1552,11 @@ void InkHUD::MenuApplet::onRender(bool full)
itemT = max(siT + siH, 0); // Offset the first menu entry, so menu starts below the system info panel
}
+ // drawSystemInfoPanel() changes font state (clock/info text).
+ // Restore the active menu font so ROOT page item text matches other menu pages,
+ // including touch-friendly layouts.
+ setFont(menuItemFont);
+
// Draw menu items
// ===================
@@ -1449,8 +1584,6 @@ void InkHUD::MenuApplet::onRender(bool full)
// Header (non-selectable section label)
if (item.isHeader) {
- setFont(fontSmall);
-
// Header text (flush left)
printAt(itemL + X(padding), center, item.label, LEFT, MIDDLE);
@@ -1459,8 +1592,17 @@ void InkHUD::MenuApplet::onRender(bool full)
drawLine(itemL + X(padding), underlineY, itemR - X(padding), underlineY, BLACK);
} else {
// Box, if currently selected
- if (cursorShown && i == cursor)
- drawRect(itemL, itemT + selectInsetY, itemW, itemH - (selectInsetY * 2), BLACK);
+ if (cursorShown && i == cursor && (!touchFriendlyLayout || !hideTouchSelectionHighlight)) {
+ const int16_t selTop = itemT + selectInsetY;
+ const int16_t selH = itemH - (selectInsetY * 2);
+ drawRect(itemL, selTop, itemW, selH, BLACK);
+ // Touch layouts need a stronger visual cue than a thin outline.
+ if (touchFriendlyLayout) {
+ const int16_t markerInset = 3;
+ const int16_t markerW = 4;
+ fillRect(itemL + markerInset, selTop + markerInset, markerW, max(1, selH - (markerInset * 2)), BLACK);
+ }
+ }
// Indented normal item text
printAt(itemL + X(padding * 2), center, item.label, LEFT, MIDDLE);
@@ -1468,9 +1610,9 @@ void InkHUD::MenuApplet::onRender(bool full)
// Checkbox, if relevant
if (item.checkState) {
- const uint16_t cbWH = fontSmall.lineHeight(); // Checkbox: width / height
- const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left
- const int16_t cbT = center - (cbWH / 2); // Checkbox : top
+ const uint16_t cbWH = menuItemFont.lineHeight(); // Checkbox: width / height
+ const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left
+ const int16_t cbT = center - (cbWH / 2); // Checkbox : top
// Checkbox ticked
if (*(item.checkState)) {
drawRect(cbL, cbT, cbWH, cbWH, BLACK);
@@ -1499,13 +1641,102 @@ void InkHUD::MenuApplet::onRender(bool full)
}
}
+bool InkHUD::MenuApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
+{
+ (void)longPress;
+
+ if (freeTextMode || !getTile()) {
+ return false;
+ }
+
+ const uint16_t tileL = getTile()->getLeft();
+ const uint16_t tileT = getTile()->getTop();
+ const uint16_t tileR = tileL + getTile()->getWidth();
+ const uint16_t tileB = tileT + getTile()->getHeight();
+ if (x < tileL || x >= tileR || y < tileT || y >= tileB) {
+ return false;
+ }
+
+ if (items.empty()) {
+ return true;
+ }
+
+ // Direct touch controls should act as activity and keep the menu open.
+ OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
+
+ // If button-driven selection is active on touch-first layouts, clear it as soon as
+ // touch interaction resumes so touch behavior remains direct/tap-first.
+ if (useTouchFriendlyMenuLayout(inkhud)) {
+ cursorShown = false;
+ hideTouchSelectionHighlight = true;
+ }
+
+ const int16_t localY = (int16_t)y - (int16_t)tileT;
+
+ // Keep geometry in sync with onRender() so touch hit-testing matches what users see.
+ const uint16_t itemH = getMenuItemHeightPx(inkhud);
+ int16_t itemT = 0;
+ uint8_t slotCount = (height() - itemT) / itemH;
+ if (slotCount == 0) {
+ slotCount = 1;
+ }
+ const uint16_t &siH = systemInfoPanelHeight;
+ const uint8_t slotsObscured = ceilf(siH / (float)itemH);
+
+ if (currentPage == ROOT) {
+ int16_t siT = 0;
+ const int16_t scrollThreshold = (int16_t)slotCount - (int16_t)slotsObscured - 1;
+ if (scrollThreshold >= 0 && (int16_t)cursor >= scrollThreshold) {
+ siT = 0 - ((cursor - scrollThreshold) * itemH);
+ }
+ itemT = max((int16_t)(siT + siH), (int16_t)0);
+ }
+
+ const uint8_t firstItem = (cursor < slotCount) ? 0 : (cursor - (slotCount - 1));
+ uint16_t visibleEnd = (uint16_t)firstItem + (uint16_t)slotCount;
+ const uint8_t maxIndex = (uint8_t)items.size() - 1;
+ if (visibleEnd > maxIndex) {
+ visibleEnd = maxIndex;
+ }
+ const uint8_t lastItem = (uint8_t)visibleEnd;
+
+ for (uint8_t i = firstItem; i <= lastItem; i++) {
+ const int16_t rowTop = itemT;
+ const int16_t rowBottom = itemT + itemH;
+
+ if (localY >= rowTop && localY < rowBottom) {
+ if (items.at(i).isHeader) {
+ // Consume taps on headers so they don't fall back to button semantics.
+ return true;
+ }
+
+ cursor = i;
+ cursorShown = true;
+ execute(items.at(cursor));
+
+ if (!wantsToRender()) {
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+ }
+ return true;
+ }
+
+ itemT += itemH;
+ }
+
+ // Consume taps on menu whitespace so we don't trigger button-like fallback behavior.
+ return true;
+}
+
void InkHUD::MenuApplet::onButtonShortPress()
{
if (!freeTextMode) {
// Push the auto-close timer back
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
- if (!settings->joystick.enabled) {
+ // Touch-first nodes keep user-button short-press as "advance selection" in menus.
+ // Any button-driven navigation should restore visible highlight.
+ hideTouchSelectionHighlight = false;
+ if (!settings->joystick.enabled || useTouchFriendlyMenuLayout(inkhud)) {
if (!cursorShown) {
cursorShown = true;
// Select the first item that isn't a header
@@ -1566,6 +1797,32 @@ void InkHUD::MenuApplet::onNavUp()
if (!freeTextMode) {
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
+ // Touch-first menus: swipe up/down should scroll only.
+ // Keep cursor movement for scroll math, but selection box is hidden in onRender().
+ if (useTouchFriendlyMenuLayout(inkhud)) {
+ hideTouchSelectionHighlight = true;
+ if (!cursorShown) {
+ cursorShown = true;
+ cursor = items.size() - 1;
+ while (items.at(cursor).isHeader) {
+ if (cursor == 0) {
+ cursorShown = false;
+ break;
+ }
+ cursor--;
+ }
+ } else {
+ do {
+ if (cursor == 0)
+ cursor = items.size() - 1;
+ else
+ cursor--;
+ } while (items.at(cursor).isHeader);
+ }
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+ return;
+ }
+
if (!cursorShown) {
cursorShown = true;
// Select the last item that isn't a header
@@ -1595,6 +1852,29 @@ void InkHUD::MenuApplet::onNavDown()
if (!freeTextMode) {
OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL);
+ // Touch-first menus: swipe up/down should scroll only.
+ // Keep cursor movement for scroll math, but selection box is hidden in onRender().
+ if (useTouchFriendlyMenuLayout(inkhud)) {
+ hideTouchSelectionHighlight = true;
+ if (!cursorShown) {
+ cursorShown = true;
+ cursor = 0;
+ while (cursor < items.size() && items.at(cursor).isHeader) {
+ cursor++;
+ }
+ if (cursor >= items.size()) {
+ cursorShown = false;
+ cursor = 0;
+ }
+ } else {
+ do {
+ cursor = (cursor + 1) % items.size();
+ } while (items.at(cursor).isHeader);
+ }
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+ return;
+ }
+
if (!cursorShown) {
cursorShown = true;
// Select the first item that isn't a header
@@ -1727,6 +2007,17 @@ void InkHUD::MenuApplet::populateRecentsPage()
}
}
+void InkHUD::MenuApplet::populateDisplayTimeoutPage()
+{
+ constexpr uint8_t optionCount = sizeof(DISPLAY_TIMEOUT_OPTIONS) / sizeof(DISPLAY_TIMEOUT_OPTIONS[0]);
+ for (uint8_t i = 0; i < optionCount; i++) {
+ displayTimeoutSelected[i] = (config.display.screen_on_secs == DISPLAY_TIMEOUT_OPTIONS[i].seconds);
+ nodeConfigLabels.emplace_back(DISPLAY_TIMEOUT_OPTIONS[i].label);
+ items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_DISPLAY_TIMEOUT, MenuPage::NODE_CONFIG_DISPLAY,
+ &displayTimeoutSelected[i]));
+ }
+}
+
// MenuItem entries for the "send" page
// Dynamically creates menu items based on available canned messages
void InkHUD::MenuApplet::populateSendPage()
@@ -1734,8 +2025,8 @@ void InkHUD::MenuApplet::populateSendPage()
// Position / NodeInfo packet
items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT));
- // If joystick is available, include the Free Text option
- if (settings->joystick.enabled && !inkhud->twoWayRocker)
+ // Show the Free Text option on any node that supports the on-screen keyboard.
+ if (supportsFreeTextKeyboard(inkhud, settings))
items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND));
// One menu item for each canned message
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
index b5c1c86e4..2d7fbd398 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
@@ -35,6 +35,7 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void onFreeText(char c) override;
void onFreeTextDone() override;
void onFreeTextCancel() override;
+ bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
void onRender(bool full) override;
void show(Tile *t); // Open the menu, onto a user tile
@@ -48,11 +49,12 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void execute(MenuItem item); // Perform the MenuAction associated with a MenuItem, if any
void showPage(MenuPage page); // Load and display a MenuPage
- void populateSendPage(); // Dynamically create MenuItems including canned messages
- void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message
- void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets
- void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
- void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
+ void populateSendPage(); // Dynamically create MenuItems including canned messages
+ void populateRecipientPage(); // Dynamically create a page of possible destinations for a canned message
+ void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets
+ void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
+ void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
+ void populateDisplayTimeoutPage(); // Create menu items for config.display.screen_on_secs
void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height,
const std::string &text); // Draw input field for free text
@@ -65,8 +67,9 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
MenuPage startPageOverride = MenuPage::ROOT;
MenuPage currentPage = MenuPage::ROOT;
MenuPage previousPage = MenuPage::EXIT;
- uint8_t cursor = 0; // Which menu item is currently highlighted
- bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection)
+ uint8_t cursor = 0; // Which menu item is currently highlighted
+ bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection)
+ bool hideTouchSelectionHighlight = false; // Touch scrolling keeps cursor for paging math, but can hide highlight
bool freeTextMode = false;
uint16_t systemInfoPanelHeight = 0; // Need to know before we render
uint16_t menuTextLimit = 200;
@@ -80,6 +83,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
// Recents menu checkbox state (derived from settings.recentlyActiveSeconds)
static constexpr uint8_t RECENTS_COUNT = 6;
bool recentsSelected[RECENTS_COUNT] = {};
+ static constexpr uint8_t DISPLAY_TIMEOUT_COUNT = 7;
+ bool displayTimeoutSelected[DISPLAY_TIMEOUT_COUNT] = {};
// Data for selecting and sending canned messages via the menu
// Placed into a sub-class for organization only
@@ -116,7 +121,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
Applet *borrowedTileOwner = nullptr; // Which applet we have temporarily replaced while displaying menu
- bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options
+ bool invertedColors = false; // Helper to display current state of config.display.displaymode in InkHUD options
+ bool keepBacklightOn = false; // Helper to display current backlight latch state in InkHUD options
};
} // namespace NicheGraphics::InkHUD
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h
index 138c389be..9bc6bb0cc 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h
@@ -32,6 +32,7 @@ enum MenuPage : uint8_t {
NODE_CONFIG_POWER_ADC_CAL,
NODE_CONFIG_NETWORK,
NODE_CONFIG_DISPLAY,
+ NODE_CONFIG_DISPLAY_TIMEOUT,
NODE_CONFIG_BLUETOOTH,
NODE_CONFIG_POSITION,
NODE_CONFIG_ADMIN_RESET,
@@ -45,4 +46,4 @@ enum MenuPage : uint8_t {
} // namespace NicheGraphics::InkHUD
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.cpp
new file mode 100644
index 000000000..04475c803
--- /dev/null
+++ b/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.cpp
@@ -0,0 +1,30 @@
+#ifdef MESHTASTIC_INCLUDE_INKHUD
+
+#include "./TouchStatusApplet.h"
+
+using namespace NicheGraphics;
+
+InkHUD::TouchStatusApplet::TouchStatusApplet()
+{
+ alwaysRender = true;
+}
+
+void InkHUD::TouchStatusApplet::onRender(bool full)
+{
+ (void)full;
+
+ if (inkhud->isTouchEnabled()) {
+ return;
+ }
+
+ setFont(fontSmall);
+
+ const uint16_t barH = fontSmall.lineHeight() + 4;
+ const int16_t top = height() - barH;
+
+ fillRect(0, top, width(), barH, WHITE);
+ drawLine(0, top, width() - 1, top, BLACK);
+ printAt(width() / 2, top + (barH / 2), "TOUCH OFF", CENTER, MIDDLE);
+}
+
+#endif
diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.h b/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.h
new file mode 100644
index 000000000..1d3ddc816
--- /dev/null
+++ b/src/graphics/niche/InkHUD/Applets/System/Notification/TouchStatusApplet.h
@@ -0,0 +1,29 @@
+#ifdef MESHTASTIC_INCLUDE_INKHUD
+
+/*
+
+ Low-profile bottom-edge touch status indicator.
+ Shown only while touch input is disabled.
+
+*/
+
+#pragma once
+
+#include "configuration.h"
+
+#include "graphics/niche/InkHUD/SystemApplet.h"
+
+namespace NicheGraphics::InkHUD
+{
+
+class TouchStatusApplet : public SystemApplet
+{
+ public:
+ TouchStatusApplet();
+
+ void onRender(bool full) override;
+};
+
+} // namespace NicheGraphics::InkHUD
+
+#endif
diff --git a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp
index a45e8d9b5..ffc8f6f99 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp
@@ -47,6 +47,9 @@ InkHUD::TipsApplet::TipsApplet()
void InkHUD::TipsApplet::onRender(bool full)
{
+ const char *continuePrompt =
+ (inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue";
+
switch (tipQueue.front()) {
case Tip::WELCOME:
renderWelcome();
@@ -79,7 +82,7 @@ void InkHUD::TipsApplet::onRender(bool full)
cursorY += fontSmall.lineHeight() / 2;
drawBullet("More info at meshtastic.org");
- printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
+ printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::PICK_REGION: {
@@ -109,7 +112,7 @@ void InkHUD::TipsApplet::onRender(bool full)
printWrapped(0, cursorY, width(), body);
cursorY += bodyH + (fontSmall.lineHeight() / 2);
- printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
+ printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::CUSTOMIZATION: {
@@ -129,10 +132,13 @@ void InkHUD::TipsApplet::onRender(bool full)
printWrapped(0, cursorY, width(), body);
cursorY += bodyH + (fontSmall.lineHeight() / 2);
- printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
+ printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
} break;
case Tip::BUTTONS: {
+#if defined(T5_S3_EPAPER_PRO)
+ renderT5S3ButtonsTip();
+#else
setFont(fontMedium);
const char *title = "Tip: Buttons";
@@ -164,7 +170,8 @@ void InkHUD::TipsApplet::onRender(bool full)
drawBullet("- press: switch tile or close menu");
}
- printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
+ printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
+#endif
} break;
case Tip::ROTATION: {
@@ -189,7 +196,7 @@ void InkHUD::TipsApplet::onRender(bool full)
"To rotate the display, use the InkHUD menu. Press the user button > Options > Rotate.");
}
- printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
+ printAt(0, Y(1.0), continuePrompt, LEFT, BOTTOM);
// Revert the "flip screen" setting, preventing this message showing again
config.display.flip_screen = false;
@@ -198,6 +205,42 @@ void InkHUD::TipsApplet::onRender(bool full)
}
}
+#if defined(T5_S3_EPAPER_PRO)
+void InkHUD::TipsApplet::renderT5S3ButtonsTip()
+{
+ setFont(fontMedium);
+
+ const char *title = "Tip: T5-S3 Buttons";
+ uint16_t h = getWrappedTextHeight(0, width(), title);
+ printWrapped(0, 0, width(), title);
+
+ setFont(fontSmall);
+ int16_t cursorY = h + fontSmall.lineHeight();
+
+ auto drawBullet = [&](const char *text) {
+ uint16_t bh = getWrappedTextHeight(0, width(), text);
+ printWrapped(0, cursorY, width(), text);
+ cursorY += bh + (fontSmall.lineHeight() / 3);
+ };
+
+ drawBullet("BOOT button");
+ drawBullet("- short press: next");
+ drawBullet("- long press: open menu or select");
+
+ drawBullet("IO48 button");
+ drawBullet("- short press: toggle touch on/off");
+ drawBullet("- long press: toggle backlight on/off");
+
+ drawBullet("PWR button");
+ drawBullet("- Hold Press to wake after Shutdown");
+
+ drawBullet("HOME button");
+ drawBullet("- press: back/exit in InkHUD and open App switcher");
+
+ printAt(0, Y(1.0), "Tap screen to continue", LEFT, BOTTOM);
+}
+#endif
+
// This tip has its own render method, only because it's a big block of code
// Didn't want to clutter up the switch in onRender too much
void InkHUD::TipsApplet::renderWelcome()
@@ -244,7 +287,9 @@ void InkHUD::TipsApplet::renderWelcome()
// Block 3 - press to continue
// ============================
- printAt(X(0.5), Y(1), "Press button to continue", CENTER, BOTTOM);
+ const char *continuePrompt =
+ (inkhud && inkhud->hasTouchEnabledProvider()) ? "Tap screen to continue" : "Press button to continue";
+ printAt(X(0.5), Y(1), continuePrompt, CENTER, BOTTOM);
}
void InkHUD::TipsApplet::onForeground()
diff --git a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h
index 2e81d678b..ea2db228c 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h
@@ -41,6 +41,9 @@ class TipsApplet : public SystemApplet
protected:
void renderWelcome(); // Very first screen of tutorial
+#if defined(T5_S3_EPAPER_PRO)
+ void renderT5S3ButtonsTip();
+#endif
std::deque tipQueue; // List of tips to show, one after another
@@ -49,4 +52,4 @@ class TipsApplet : public SystemApplet
} // namespace NicheGraphics::InkHUD
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp
index 577a773bb..ddcc6781b 100644
--- a/src/graphics/niche/InkHUD/Events.cpp
+++ b/src/graphics/niche/InkHUD/Events.cpp
@@ -2,6 +2,7 @@
#include "./Events.h"
+#include "PowerFSM.h"
#include "RTC.h"
#include "buzz.h"
#include "modules/ExternalNotificationModule.h"
@@ -14,6 +15,19 @@
using namespace NicheGraphics;
+namespace
+{
+// When touch long-press opens menu, some panels report a delayed release/tap
+// if the finger stays down briefly. Keep this long enough to cover that release.
+constexpr uint32_t TOUCH_MENU_OPEN_TAP_SUPPRESS_MS = 1200;
+
+inline void noteInkHUDUserInteraction()
+{
+ // Keep power state and screen-timeout behavior in sync with InkHUD input activity.
+ powerFSM.trigger(EVENT_INPUT);
+}
+} // namespace
+
InkHUD::Events::Events()
{
// Get convenient references
@@ -38,6 +52,8 @@ void InkHUD::Events::begin()
void InkHUD::Events::onButtonShort()
{
+ noteInkHUDUserInteraction();
+
// Audio feedback (via buzzer)
// Short tone
playChirp();
@@ -74,6 +90,8 @@ void InkHUD::Events::onButtonShort()
void InkHUD::Events::onButtonLong()
{
+ noteInkHUDUserInteraction();
+
// Audio feedback (via buzzer)
// Slightly longer than playChirp
playBoop();
@@ -102,193 +120,295 @@ void InkHUD::Events::onButtonLong()
void InkHUD::Events::onExitShort()
{
- if (settings->joystick.enabled) {
- // Audio feedback (via buzzer)
- // Short tone
- playChirp();
- // Cancel any beeping, buzzing, blinking
- // Some button handling suppressed if we are dismissing an external notification (see below)
- bool dismissedExt = dismissExternalNotification();
+ // Preserve legacy behavior on non-touch builds:
+ // EXIT input is only active when joystick mode is enabled.
+ // Touch-capable builds intentionally bypass this joystick gate.
+ if (!settings->joystick.enabled && !inkhud->hasTouchEnabledProvider()) {
+ return;
+ }
- // Check which system applet wants to handle the button press (if any)
- SystemApplet *consumer = nullptr;
- for (SystemApplet *sa : inkhud->systemApplets) {
- if (sa->handleInput) {
- consumer = sa;
- break;
- }
+ noteInkHUDUserInteraction();
+
+ // Audio feedback (via buzzer)
+ // Short tone
+ playChirp();
+ // Cancel any beeping, buzzing, blinking
+ // Some button handling suppressed if we are dismissing an external notification module (see below)
+ bool dismissedExt = dismissExternalNotification();
+
+ // Check which system applet wants to handle the button press (if any)
+ SystemApplet *consumer = nullptr;
+ for (SystemApplet *sa : inkhud->systemApplets) {
+ if (sa->handleInput) {
+ consumer = sa;
+ break;
}
+ }
- // If no system applet is handling input, default behavior instead is change tiles
- if (consumer)
- consumer->onExitShort();
- else if (!dismissedExt) { // Don't change tile if this button press silenced the external notification module
- Applet *userConsumer = inkhud->getActiveApplet();
+ // Always let active system applets consume EXIT/HOME input (menu close, keyboard cancel, etc),
+ // including on touch-first nodes where joystick mode is disabled.
+ if (consumer) {
+ consumer->onExitShort();
+ return;
+ }
- if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT))
- userConsumer->onExitShort();
- else
- inkhud->nextTile();
+ // Touch-capable InkHUD nodes use EXIT/HOME as a quick app switcher launcher.
+ if (!dismissedExt && inkhud->hasTouchEnabledProvider()) {
+ inkhud->openAppSwitcher();
+ return;
+ }
+
+ if (!dismissedExt) {
+ Applet *userConsumer = inkhud->getActiveApplet();
+
+ if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT)) {
+ userConsumer->onExitShort();
+ } else if (settings->joystick.enabled) {
+ // Preserve existing joystick behavior when no applet handles EXIT.
+ inkhud->nextTile();
}
}
}
void InkHUD::Events::onExitLong()
{
- if (settings->joystick.enabled) {
- // Audio feedback (via buzzer)
- // Slightly longer than playChirp
- playBoop();
+ // Preserve legacy behavior on non-touch builds:
+ // EXIT input is only active when joystick mode is enabled.
+ // Touch-capable builds intentionally bypass this joystick gate.
+ if (!settings->joystick.enabled && !inkhud->hasTouchEnabledProvider()) {
+ return;
+ }
- // Check which system applet wants to handle the button press (if any)
- SystemApplet *consumer = nullptr;
- for (SystemApplet *sa : inkhud->systemApplets) {
- if (sa->handleInput) {
- consumer = sa;
- break;
- }
+ noteInkHUDUserInteraction();
+
+ // Audio feedback (via buzzer)
+ // Slightly longer than playChirp
+ playBoop();
+
+ // Check which system applet wants to handle the button press (if any)
+ SystemApplet *consumer = nullptr;
+ for (SystemApplet *sa : inkhud->systemApplets) {
+ if (sa->handleInput) {
+ consumer = sa;
+ break;
}
+ }
- if (consumer)
- consumer->onExitLong();
- else {
- Applet *userConsumer = inkhud->getActiveApplet();
+ // Always allow system applets to consume EXIT/HOME long-press.
+ if (consumer) {
+ consumer->onExitLong();
+ } else {
+ Applet *userConsumer = inkhud->getActiveApplet();
- if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG))
- userConsumer->onExitLong();
- // Nothing uses exit long yet
- }
+ if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG))
+ userConsumer->onExitLong();
+ // Nothing uses exit long yet
}
}
void InkHUD::Events::onNavUp()
{
- if (settings->joystick.enabled) {
- // Audio feedback (via buzzer)
- // Short tone
- playChirp();
- // Cancel any beeping, buzzing, blinking
- // Some button handling suppressed if we are dismissing an external notification (see below)
- bool dismissedExt = dismissExternalNotification();
-
- // Check which system applet wants to handle the button press (if any)
- SystemApplet *consumer = nullptr;
- for (SystemApplet *sa : inkhud->systemApplets) {
- if (sa->handleInput) {
- consumer = sa;
- break;
- }
- }
-
- if (consumer)
- consumer->onNavUp();
- else if (!dismissedExt) {
- Applet *userConsumer = inkhud->getActiveApplet();
-
- if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP))
- userConsumer->onNavUp();
- }
- }
+ if (settings->joystick.enabled)
+ onTouchNavUp();
}
void InkHUD::Events::onNavDown()
{
- if (settings->joystick.enabled) {
- // Audio feedback (via buzzer)
- // Short tone
- playChirp();
- // Cancel any beeping, buzzing, blinking
- // Some button handling suppressed if we are dismissing an external notification (see below)
- bool dismissedExt = dismissExternalNotification();
-
- // Check which system applet wants to handle the button press (if any)
- SystemApplet *consumer = nullptr;
- for (SystemApplet *sa : inkhud->systemApplets) {
- if (sa->handleInput) {
- consumer = sa;
- break;
- }
- }
-
- if (consumer)
- consumer->onNavDown();
- else if (!dismissedExt) {
- Applet *userConsumer = inkhud->getActiveApplet();
-
- if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN))
- userConsumer->onNavDown();
- }
- }
+ if (settings->joystick.enabled)
+ onTouchNavDown();
}
void InkHUD::Events::onNavLeft()
{
- if (settings->joystick.enabled) {
- // Audio feedback (via buzzer)
- // Short tone
- playChirp();
- // Cancel any beeping, buzzing, blinking
- // Some button handling suppressed if we are dismissing an external notification (see below)
- bool dismissedExt = dismissExternalNotification();
-
- // Check which system applet wants to handle the button press (if any)
- SystemApplet *consumer = nullptr;
- for (SystemApplet *sa : inkhud->systemApplets) {
- if (sa->handleInput) {
- consumer = sa;
- break;
- }
- }
-
- // If no system applet is handling input, default behavior instead is to cycle applets
- if (consumer)
- consumer->onNavLeft();
- else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
- Applet *userConsumer = inkhud->getActiveApplet();
-
- if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT))
- userConsumer->onNavLeft();
- else
- inkhud->prevApplet();
- }
- }
+ if (settings->joystick.enabled)
+ onTouchNavLeft();
}
void InkHUD::Events::onNavRight()
{
- if (settings->joystick.enabled) {
- // Audio feedback (via buzzer)
- // Short tone
- playChirp();
- // Cancel any beeping, buzzing, blinking
- // Some button handling suppressed if we are dismissing an external notification (see below)
- bool dismissedExt = dismissExternalNotification();
+ if (settings->joystick.enabled)
+ onTouchNavRight();
+}
- // Check which system applet wants to handle the button press (if any)
- SystemApplet *consumer = nullptr;
- for (SystemApplet *sa : inkhud->systemApplets) {
- if (sa->handleInput) {
- consumer = sa;
- break;
- }
- }
+void InkHUD::Events::onTouchNavUp()
+{
+ noteInkHUDUserInteraction();
- // If no system applet is handling input, default behavior instead is to cycle applets
- if (consumer)
- consumer->onNavRight();
- else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
- Applet *userConsumer = inkhud->getActiveApplet();
+ // Audio feedback (via buzzer)
+ // Short tone
+ playChirp();
+ // Cancel any beeping, buzzing, blinking
+ // Some button handling suppressed if we are dismissing an external notification (see below)
+ bool dismissedExt = dismissExternalNotification();
- if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT))
- userConsumer->onNavRight();
- else
- inkhud->nextApplet();
+ // Check which system applet wants to handle the button press (if any)
+ SystemApplet *consumer = nullptr;
+ for (SystemApplet *sa : inkhud->systemApplets) {
+ if (sa->handleInput) {
+ consumer = sa;
+ break;
}
}
+
+ if (consumer)
+ consumer->onNavUp();
+ else if (!dismissedExt) {
+ Applet *userConsumer = inkhud->getActiveApplet();
+
+ if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP))
+ userConsumer->onNavUp();
+ }
+}
+
+void InkHUD::Events::onTouchNavDown()
+{
+ noteInkHUDUserInteraction();
+
+ // Audio feedback (via buzzer)
+ // Short tone
+ playChirp();
+ // Cancel any beeping, buzzing, blinking
+ // Some button handling suppressed if we are dismissing an external notification (see below)
+ bool dismissedExt = dismissExternalNotification();
+
+ // Check which system applet wants to handle the button press (if any)
+ SystemApplet *consumer = nullptr;
+ for (SystemApplet *sa : inkhud->systemApplets) {
+ if (sa->handleInput) {
+ consumer = sa;
+ break;
+ }
+ }
+
+ if (consumer)
+ consumer->onNavDown();
+ else if (!dismissedExt) {
+ Applet *userConsumer = inkhud->getActiveApplet();
+
+ if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN))
+ userConsumer->onNavDown();
+ }
+}
+
+void InkHUD::Events::onTouchNavLeft()
+{
+ noteInkHUDUserInteraction();
+
+ // Audio feedback (via buzzer)
+ // Short tone
+ playChirp();
+ // Cancel any beeping, buzzing, blinking
+ // Some button handling suppressed if we are dismissing an external notification (see below)
+ bool dismissedExt = dismissExternalNotification();
+
+ // Check which system applet wants to handle the button press (if any)
+ SystemApplet *consumer = nullptr;
+ for (SystemApplet *sa : inkhud->systemApplets) {
+ if (sa->handleInput) {
+ consumer = sa;
+ break;
+ }
+ }
+
+ // If no system applet is handling input, default behavior instead is to cycle applets
+ if (consumer)
+ consumer->onNavLeft();
+ else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
+ Applet *userConsumer = inkhud->getActiveApplet();
+
+ if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT))
+ userConsumer->onNavLeft();
+ else
+ inkhud->prevApplet();
+ }
+}
+
+void InkHUD::Events::onTouchNavRight()
+{
+ noteInkHUDUserInteraction();
+
+ // Audio feedback (via buzzer)
+ // Short tone
+ playChirp();
+ // Cancel any beeping, buzzing, blinking
+ // Some button handling suppressed if we are dismissing an external notification (see below)
+ bool dismissedExt = dismissExternalNotification();
+
+ // Check which system applet wants to handle the button press (if any)
+ SystemApplet *consumer = nullptr;
+ for (SystemApplet *sa : inkhud->systemApplets) {
+ if (sa->handleInput) {
+ consumer = sa;
+ break;
+ }
+ }
+
+ // If no system applet is handling input, default behavior instead is to cycle applets
+ if (consumer)
+ consumer->onNavRight();
+ else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
+ Applet *userConsumer = inkhud->getActiveApplet();
+
+ if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT))
+ userConsumer->onNavRight();
+ else
+ inkhud->nextApplet();
+ }
+}
+
+void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress)
+{
+ const bool touchEnabledBuild = inkhud->hasTouchEnabledProvider();
+
+ // A long-press used to open the menu can be followed by a synthetic/queued tap at release.
+ // Ignore that brief follow-up window so touch-opened menus do not auto-select an item.
+ if (touchEnabledBuild && !longPress && suppressTouchTapUntilMs != 0) {
+ if ((int32_t)(millis() - suppressTouchTapUntilMs) < 0) {
+ noteInkHUDUserInteraction();
+ return;
+ }
+ suppressTouchTapUntilMs = 0;
+ }
+
+ // Give system applets (menu, keyboard, etc) first chance to consume direct touch input.
+ for (SystemApplet *sa : inkhud->systemApplets) {
+ if (sa->handleInput && sa->onTouchPoint(x, y, longPress)) {
+ noteInkHUDUserInteraction();
+ return;
+ }
+ }
+
+ // In split layouts, tapping a different tile changes focus.
+ // Consume this tap so selection does not also trigger button fallback behavior.
+ if (inkhud->selectTileAt(x, y)) {
+ noteInkHUDUserInteraction();
+ return;
+ }
+
+ // Allow foreground user applet to consume direct touch input if it wants.
+ Applet *userConsumer = inkhud->getActiveApplet();
+ if (userConsumer != nullptr && userConsumer->onTouchPoint(x, y, longPress)) {
+ noteInkHUDUserInteraction();
+ return;
+ }
+
+ // Fallback to existing button semantics so non-touch-aware applets keep working unchanged.
+ if (longPress) {
+ onButtonLong();
+
+ // Only arm suppression if the long-press actually opened menu foreground.
+ SystemApplet *menu = inkhud->getSystemApplet("Menu");
+ if (touchEnabledBuild && menu && menu->isForeground()) {
+ suppressTouchTapUntilMs = millis() + TOUCH_MENU_OPEN_TAP_SUPPRESS_MS;
+ }
+ } else
+ onButtonShort();
}
void InkHUD::Events::onFreeText(char c)
{
+ noteInkHUDUserInteraction();
+
// Trigger the first system applet that wants to handle the new character
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -300,6 +420,8 @@ void InkHUD::Events::onFreeText(char c)
void InkHUD::Events::onFreeTextDone()
{
+ noteInkHUDUserInteraction();
+
// Trigger the first system applet that wants to handle it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -311,6 +433,8 @@ void InkHUD::Events::onFreeTextDone()
void InkHUD::Events::onFreeTextCancel()
{
+ noteInkHUDUserInteraction();
+
// Trigger the first system applet that wants to handle it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
@@ -487,4 +611,4 @@ bool InkHUD::Events::dismissExternalNotification()
return true;
}
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/niche/InkHUD/Events.h b/src/graphics/niche/InkHUD/Events.h
index 873f53fd5..ceb298b3b 100644
--- a/src/graphics/niche/InkHUD/Events.h
+++ b/src/graphics/niche/InkHUD/Events.h
@@ -17,6 +17,7 @@ however this class handles general events which concern InkHUD as a whole, e.g.
#include "./InkHUD.h"
#include "./Persistence.h"
+#include
namespace NicheGraphics::InkHUD
{
@@ -30,12 +31,17 @@ class Events
void onButtonShort(); // User button: short press
void onButtonLong(); // User button: long press
void applyingChanges();
- void onExitShort(); // Exit button: short press
- void onExitLong(); // Exit button: long press
- void onNavUp(); // Navigate up
- void onNavDown(); // Navigate down
- void onNavLeft(); // Navigate left
- void onNavRight(); // Navigate right
+ void onExitShort(); // Exit button: short press
+ void onExitLong(); // Exit button: long press
+ void onNavUp(); // Navigate up
+ void onNavDown(); // Navigate down
+ void onNavLeft(); // Navigate left
+ void onNavRight(); // Navigate right
+ void onTouchNavUp(); // Navigate up from touch input
+ void onTouchNavDown(); // Navigate down from touch input
+ void onTouchNavLeft(); // Navigate left from touch input
+ void onTouchNavRight(); // Navigate right from touch input
+ void onTouchTap(uint16_t x, uint16_t y, bool longPress); // Touch tap/long-press with coordinates
// Free text typing events
void onFreeText(char c); // New freetext character input
@@ -79,8 +85,11 @@ class Events
// If set, InkHUD's data will be erased during onReboot
bool eraseOnReboot = false;
+
+ // Suppress follow-up tap generated immediately after a touch long-press opens menu.
+ uint32_t suppressTouchTapUntilMs = 0;
};
} // namespace NicheGraphics::InkHUD
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/niche/InkHUD/InkHUD.cpp b/src/graphics/niche/InkHUD/InkHUD.cpp
index edffda6b7..9ed88af5f 100644
--- a/src/graphics/niche/InkHUD/InkHUD.cpp
+++ b/src/graphics/niche/InkHUD/InkHUD.cpp
@@ -73,6 +73,24 @@ void InkHUD::InkHUD::begin()
// LogoApplet shows boot screen here
}
+void InkHUD::InkHUD::setTouchEnabledProvider(TouchEnabledProvider provider)
+{
+ touchEnabledProvider = provider;
+}
+
+bool InkHUD::InkHUD::hasTouchEnabledProvider() const
+{
+ return touchEnabledProvider != nullptr;
+}
+
+bool InkHUD::InkHUD::isTouchEnabled() const
+{
+ if (!touchEnabledProvider)
+ return true;
+
+ return touchEnabledProvider();
+}
+
// Call this when your user button gets a short press
// Should be connected to an input source in nicheGraphics.h (NicheGraphics::Inputs::TwoButton?)
void InkHUD::InkHUD::shortpress()
@@ -175,6 +193,92 @@ void InkHUD::InkHUD::navRight()
}
}
+// Call this when touch input needs joystick-like up navigation independent of joystick-enabled mode
+void InkHUD::InkHUD::touchNavUp()
+{
+ switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
+ case 1: // 90 deg
+ events->onTouchNavLeft();
+ break;
+ case 2: // 180 deg
+ events->onTouchNavDown();
+ break;
+ case 3: // 270 deg
+ events->onTouchNavRight();
+ break;
+ default: // 0 deg
+ events->onTouchNavUp();
+ break;
+ }
+}
+
+// Call this when touch input needs joystick-like down navigation independent of joystick-enabled mode
+void InkHUD::InkHUD::touchNavDown()
+{
+ switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
+ case 1: // 90 deg
+ events->onTouchNavRight();
+ break;
+ case 2: // 180 deg
+ events->onTouchNavUp();
+ break;
+ case 3: // 270 deg
+ events->onTouchNavLeft();
+ break;
+ default: // 0 deg
+ events->onTouchNavDown();
+ break;
+ }
+}
+
+// Call this when touch input needs joystick-like left navigation independent of joystick-enabled mode
+void InkHUD::InkHUD::touchNavLeft()
+{
+ switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
+ case 1: // 90 deg
+ events->onTouchNavDown();
+ break;
+ case 2: // 180 deg
+ events->onTouchNavRight();
+ break;
+ case 3: // 270 deg
+ events->onTouchNavUp();
+ break;
+ default: // 0 deg
+ events->onTouchNavLeft();
+ break;
+ }
+}
+
+// Call this when touch input needs joystick-like right navigation independent of joystick-enabled mode
+void InkHUD::InkHUD::touchNavRight()
+{
+ switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) {
+ case 1: // 90 deg
+ events->onTouchNavUp();
+ break;
+ case 2: // 180 deg
+ events->onTouchNavLeft();
+ break;
+ case 3: // 270 deg
+ events->onTouchNavDown();
+ break;
+ default: // 0 deg
+ events->onTouchNavRight();
+ break;
+ }
+}
+
+void InkHUD::InkHUD::touchTap(uint16_t x, uint16_t y)
+{
+ events->onTouchTap(x, y, false);
+}
+
+void InkHUD::InkHUD::touchLongPress(uint16_t x, uint16_t y)
+{
+ events->onTouchTap(x, y, true);
+}
+
// Call this for keyboard input
// The Keyboard Applet also calls this
void InkHUD::InkHUD::freeText(char c)
@@ -223,6 +327,12 @@ void InkHUD::InkHUD::openMenu()
windowManager->openMenu();
}
+// Show touch-friendly app switcher (on the focused tile)
+void InkHUD::InkHUD::openAppSwitcher()
+{
+ windowManager->openAppSwitcher();
+}
+
// Bring AlignStick applet to the foreground
void InkHUD::InkHUD::openAlignStick()
{
@@ -255,6 +365,16 @@ void InkHUD::InkHUD::prevTile()
windowManager->prevTile();
}
+bool InkHUD::InkHUD::showApplet(uint8_t appletIndex)
+{
+ return windowManager->showApplet(appletIndex);
+}
+
+bool InkHUD::InkHUD::selectTileAt(uint16_t x, uint16_t y)
+{
+ return windowManager->selectTileAt(x, y);
+}
+
// Rotate the display image by 90 degrees
void InkHUD::InkHUD::rotate()
{
@@ -376,4 +496,4 @@ void InkHUD::InkHUD::drawPixel(int16_t x, int16_t y, Color c)
renderer->handlePixel(x, y, c);
}
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/niche/InkHUD/InkHUD.h b/src/graphics/niche/InkHUD/InkHUD.h
index abd53951a..0c1682637 100644
--- a/src/graphics/niche/InkHUD/InkHUD.h
+++ b/src/graphics/niche/InkHUD/InkHUD.h
@@ -39,6 +39,8 @@ class WindowManager;
class InkHUD
{
public:
+ using TouchEnabledProvider = bool (*)();
+
static InkHUD *getInstance(); // Access to this singleton class
// Configuration
@@ -51,6 +53,11 @@ class InkHUD
void begin();
+ // Optional touch-state provider for reusable touch status indicators.
+ void setTouchEnabledProvider(TouchEnabledProvider provider);
+ bool hasTouchEnabledProvider() const;
+ bool isTouchEnabled() const;
+
// Handle user-button press
// - connected to an input source, in variant nicheGraphics.h
@@ -62,6 +69,12 @@ class InkHUD
void navDown();
void navLeft();
void navRight();
+ void touchNavUp();
+ void touchNavDown();
+ void touchNavLeft();
+ void touchNavRight();
+ void touchTap(uint16_t x, uint16_t y);
+ void touchLongPress(uint16_t x, uint16_t y);
// Freetext handlers
void freeText(char c);
@@ -76,11 +89,14 @@ class InkHUD
void prevApplet();
NicheGraphics::InkHUD::Applet *getActiveApplet();
void openMenu();
+ void openAppSwitcher();
void openAlignStick();
void openKeyboard();
void closeKeyboard();
void nextTile();
void prevTile();
+ bool showApplet(uint8_t appletIndex);
+ bool selectTileAt(uint16_t x, uint16_t y);
void rotate();
void rotateJoystick(uint8_t angle = 1); // rotate 90 deg by default
void toggleBatteryIcon();
@@ -129,6 +145,7 @@ class InkHUD
Events *events = nullptr; // Handle non-specific firmware events
Renderer *renderer = nullptr; // Co-ordinate display updates
WindowManager *windowManager = nullptr; // Multiplexing of applets
+ TouchEnabledProvider touchEnabledProvider = nullptr;
};
} // namespace NicheGraphics::InkHUD
diff --git a/src/graphics/niche/InkHUD/Persistence.h b/src/graphics/niche/InkHUD/Persistence.h
index 5054b7234..187af2129 100644
--- a/src/graphics/niche/InkHUD/Persistence.h
+++ b/src/graphics/niche/InkHUD/Persistence.h
@@ -142,4 +142,4 @@ class Persistence
} // namespace NicheGraphics::InkHUD
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/niche/InkHUD/WindowManager.cpp b/src/graphics/niche/InkHUD/WindowManager.cpp
index c4a0813d8..804ae8729 100644
--- a/src/graphics/niche/InkHUD/WindowManager.cpp
+++ b/src/graphics/niche/InkHUD/WindowManager.cpp
@@ -3,11 +3,13 @@
#include "./WindowManager.h"
#include "./Applets/System/AlignStick/AlignStickApplet.h"
+#include "./Applets/System/AppSwitcher/AppSwitcherApplet.h"
#include "./Applets/System/BatteryIcon/BatteryIconApplet.h"
#include "./Applets/System/Keyboard/KeyboardApplet.h"
#include "./Applets/System/Logo/LogoApplet.h"
#include "./Applets/System/Menu/MenuApplet.h"
#include "./Applets/System/Notification/NotificationApplet.h"
+#include "./Applets/System/Notification/TouchStatusApplet.h"
#include "./Applets/System/Pairing/PairingApplet.h"
#include "./Applets/System/Placeholder/PlaceholderApplet.h"
#include "./Applets/System/Tips/TipsApplet.h"
@@ -15,6 +17,14 @@
using namespace NicheGraphics;
+namespace
+{
+bool supportsOnScreenKeyboard(const InkHUD::InkHUD *inkhud, const InkHUD::Persistence::Settings *settings)
+{
+ return !inkhud->twoWayRocker && (settings->joystick.enabled || inkhud->hasTouchEnabledProvider());
+}
+} // namespace
+
InkHUD::WindowManager::WindowManager()
{
// Convenient references
@@ -132,6 +142,38 @@ void InkHUD::WindowManager::prevTile()
userTiles.at(settings->userTiles.focused)->requestHighlight();
}
+// Focus the user tile containing a touch coordinate.
+// Returns true only when the focused tile changes.
+bool InkHUD::WindowManager::selectTileAt(uint16_t x, uint16_t y)
+{
+ if (userTiles.size() < 2)
+ return false;
+
+ const int32_t tx = x;
+ const int32_t ty = y;
+
+ for (uint8_t i = 0; i < userTiles.size(); i++) {
+ Tile *tile = userTiles.at(i);
+
+ const int32_t left = tile->getLeft();
+ const int32_t top = tile->getTop();
+ const int32_t right = left + tile->getWidth();
+ const int32_t bottom = top + tile->getHeight();
+
+ if (tx < left || tx >= right || ty < top || ty >= bottom)
+ continue;
+
+ if (settings->userTiles.focused == i)
+ return false;
+
+ settings->userTiles.focused = i;
+ refocusTile();
+ return true;
+ }
+
+ return false;
+}
+
// Show the menu (on the the focused tile)
// The applet previously displayed there will be restored once the menu closes
void InkHUD::WindowManager::openMenu()
@@ -140,6 +182,18 @@ void InkHUD::WindowManager::openMenu()
menu->show(userTiles.at(settings->userTiles.focused));
}
+// Show touch-only app switcher on the focused tile
+void InkHUD::WindowManager::openAppSwitcher()
+{
+ if (!inkhud->hasTouchEnabledProvider())
+ return;
+
+ AppSwitcherApplet *switcher = static_cast(inkhud->getSystemApplet("AppSwitcher"));
+ if (switcher) {
+ switcher->show(userTiles.at(settings->userTiles.focused));
+ }
+}
+
// Bring the AlignStick applet to the foreground
void InkHUD::WindowManager::openAlignStick()
{
@@ -151,7 +205,7 @@ void InkHUD::WindowManager::openAlignStick()
void InkHUD::WindowManager::openKeyboard()
{
- if (!settings->joystick.enabled || inkhud->twoWayRocker)
+ if (!supportsOnScreenKeyboard(inkhud, settings))
return;
KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard");
@@ -165,7 +219,7 @@ void InkHUD::WindowManager::openKeyboard()
void InkHUD::WindowManager::closeKeyboard()
{
- if (!settings->joystick.enabled || inkhud->twoWayRocker)
+ if (!supportsOnScreenKeyboard(inkhud, settings))
return;
KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard");
@@ -279,6 +333,41 @@ void InkHUD::WindowManager::prevApplet()
inkhud->forceUpdate(EInk::UpdateTypes::FAST); // bringToForeground already requested, but we're manually forcing FAST
}
+// Show a specific applet on the focused tile, or focus the tile where it is already shown.
+bool InkHUD::WindowManager::showApplet(uint8_t appletIndex)
+{
+ if (appletIndex >= inkhud->userApplets.size())
+ return false;
+
+ Applet *target = inkhud->userApplets.at(appletIndex);
+ if (!target || !target->isActive())
+ return false;
+
+ // If target is already visible on another tile, just focus that tile.
+ for (uint8_t i = 0; i < userTiles.size(); i++) {
+ if (userTiles.at(i)->getAssignedApplet() == target) {
+ settings->userTiles.focused = i;
+ refocusTile();
+ if (!settings->optionalMenuItems.nextTile)
+ userTiles.at(settings->userTiles.focused)->requestHighlight();
+ inkhud->forceUpdate(EInk::UpdateTypes::FAST);
+ return true;
+ }
+ }
+
+ // Otherwise replace the focused tile's applet.
+ Tile *focused = userTiles.at(settings->userTiles.focused);
+ Applet *current = focused->getAssignedApplet();
+ if (current && current != target)
+ current->sendToBackground();
+
+ focused->assignApplet(target);
+ target->bringToForeground();
+ settings->userTiles.displayedUserApplet[settings->userTiles.focused] = appletIndex;
+ inkhud->forceUpdate(EInk::UpdateTypes::FAST);
+ return true;
+}
+
// Returns active applet
NicheGraphics::InkHUD::Applet *InkHUD::WindowManager::getActiveApplet()
{
@@ -485,14 +574,21 @@ void InkHUD::WindowManager::createSystemApplets()
addSystemApplet("Tips", new TipsApplet, new Tile);
if (settings->joystick.enabled && !inkhud->twoWayRocker) {
addSystemApplet("AlignStick", new AlignStickApplet, new Tile);
+ }
+ if (supportsOnScreenKeyboard(inkhud, settings)) {
addSystemApplet("Keyboard", new KeyboardApplet, new Tile);
}
+ if (inkhud->hasTouchEnabledProvider()) {
+ addSystemApplet("AppSwitcher", new AppSwitcherApplet, nullptr);
+ }
addSystemApplet("Menu", new MenuApplet, nullptr);
// Battery and notifications *behind* the menu
addSystemApplet("Notification", new NotificationApplet, new Tile);
addSystemApplet("BatteryIcon", new BatteryIconApplet, new Tile);
+ if (inkhud->hasTouchEnabledProvider())
+ addSystemApplet("TouchStatus", new TouchStatusApplet, new Tile);
// Special handling only, via Rendering::renderPlaceholders
addSystemApplet("Placeholder", new PlaceholderApplet, nullptr);
@@ -511,6 +607,8 @@ void InkHUD::WindowManager::placeSystemTiles()
inkhud->getSystemApplet("Tips")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height());
if (settings->joystick.enabled && !inkhud->twoWayRocker) {
inkhud->getSystemApplet("AlignStick")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height());
+ }
+ if (supportsOnScreenKeyboard(inkhud, settings)) {
const uint16_t keyboardHeight = KeyboardApplet::getKeyboardHeight();
inkhud->getSystemApplet("Keyboard")
->getTile()
@@ -527,6 +625,17 @@ void InkHUD::WindowManager::placeSystemTiles()
batteryIconWidth + 1, // width
batteryIconHeight + 2); // height
+ if (inkhud->hasTouchEnabledProvider()) {
+ const uint16_t touchStatusH = Applet::fontSmall.lineHeight() + 4;
+ inkhud->getSystemApplet("TouchStatus")
+ ->getTile()
+ ->setRegion(0, inkhud->height() - touchStatusH, inkhud->width(), touchStatusH);
+ if (inkhud->isTouchEnabled())
+ inkhud->getSystemApplet("TouchStatus")->sendToBackground();
+ else
+ inkhud->getSystemApplet("TouchStatus")->bringToForeground();
+ }
+
// Note: the tiles of placeholder and menu applets are manipulated specially
// - menuApplet borrows user tiles
// - placeholder applet is temporarily assigned to each user tile of WindowManager::getEmptyTiles
diff --git a/src/graphics/niche/InkHUD/WindowManager.h b/src/graphics/niche/InkHUD/WindowManager.h
index a11688cf5..1ab33c085 100644
--- a/src/graphics/niche/InkHUD/WindowManager.h
+++ b/src/graphics/niche/InkHUD/WindowManager.h
@@ -29,13 +29,16 @@ class WindowManager
void nextTile();
void prevTile();
+ bool selectTileAt(uint16_t x, uint16_t y);
Applet *getActiveApplet();
void openMenu();
void openAlignStick();
+ void openAppSwitcher();
void openKeyboard();
void closeKeyboard();
void nextApplet();
void prevApplet();
+ bool showApplet(uint8_t appletIndex);
void rotate();
void toggleBatteryIcon();
@@ -76,4 +79,4 @@ class WindowManager
} // namespace NicheGraphics::InkHUD
-#endif
\ No newline at end of file
+#endif
diff --git a/src/input/TouchScreenBase.cpp b/src/input/TouchScreenBase.cpp
index fceac74ba..a3d03d4ac 100644
--- a/src/input/TouchScreenBase.cpp
+++ b/src/input/TouchScreenBase.cpp
@@ -9,6 +9,35 @@
#define TIME_LONG_PRESS 400
#endif
+// Touch sampling cadence (milliseconds).
+// Can be overridden by board variants for faster touch panels.
+#ifndef TOUCH_POLL_INTERVAL_IDLE
+#define TOUCH_POLL_INTERVAL_IDLE 100
+#endif
+
+#ifndef TOUCH_POLL_INTERVAL_ACTIVE
+#define TOUCH_POLL_INTERVAL_ACTIVE 20
+#endif
+
+#ifndef TOUCH_POLL_INTERVAL_RELEASE
+#define TOUCH_POLL_INTERVAL_RELEASE 50
+#endif
+
+// Faster cadence used for keyboard-like tap-heavy UIs.
+#ifndef TOUCH_POLL_INTERVAL_ACTIVE_FAST
+#define TOUCH_POLL_INTERVAL_ACTIVE_FAST TOUCH_POLL_INTERVAL_ACTIVE
+#endif
+
+#ifndef TOUCH_POLL_INTERVAL_RELEASE_FAST
+#define TOUCH_POLL_INTERVAL_RELEASE_FAST TOUCH_POLL_INTERVAL_RELEASE
+#endif
+
+// Ignore very short "finger lifted" glitches from noisy touch controllers.
+// A release is only accepted once we've seen no-touch for at least this duration.
+#ifndef TOUCH_RELEASE_GRACE_MS
+#define TOUCH_RELEASE_GRACE_MS 35
+#endif
+
// move a minimum distance over the screen to detect a "swipe"
#ifndef TOUCH_THRESHOLD_X
#define TOUCH_THRESHOLD_X 30
@@ -20,7 +49,7 @@
TouchScreenBase::TouchScreenBase(const char *name, uint16_t width, uint16_t height)
: concurrency::OSThread(name), _display_width(width), _display_height(height), _first_x(0), _last_x(0), _first_y(0),
- _last_y(0), _start(0), _tapped(false), _originName(name)
+ _last_y(0), _start(0), _lastTouchSeenMs(0), _tapped(false), _originName(name)
{
}
@@ -28,7 +57,7 @@ void TouchScreenBase::init(bool hasTouch)
{
if (hasTouch) {
LOG_INFO("TouchScreen initialized %d %d", TOUCH_THRESHOLD_X, TOUCH_THRESHOLD_Y);
- this->setInterval(100);
+ this->setInterval(TOUCH_POLL_INTERVAL_IDLE);
} else {
disable();
this->setInterval(UINT_MAX);
@@ -39,6 +68,8 @@ int32_t TouchScreenBase::runOnce()
{
TouchEvent e;
e.touchEvent = static_cast(TOUCH_ACTION_NONE);
+ const bool fastTapMode = fastTapModeEnabled();
+ const bool allowLongPress = longPressEnabled();
// process touch events
int16_t x, y;
@@ -46,9 +77,13 @@ int32_t TouchScreenBase::runOnce()
if (x < 0 || y < 0) // T-deck can emit phantom touch events with a negative value when turning off the screen
touched = false;
if (touched) {
- this->setInterval(20);
+ _lastTouchSeenMs = millis();
+ this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_ACTIVE_FAST : TOUCH_POLL_INTERVAL_ACTIVE);
_last_x = x;
_last_y = y;
+ } else if (_touchedOld && ((uint32_t)millis() - _lastTouchSeenMs) < TOUCH_RELEASE_GRACE_MS) {
+ // Treat brief no-touch samples as continuous touch to preserve long-press detection.
+ touched = true;
}
if (touched != _touchedOld) {
if (touched) {
@@ -62,7 +97,7 @@ int32_t TouchScreenBase::runOnce()
time_t duration = millis() - _start;
x = _last_x;
y = _last_y;
- this->setInterval(50);
+ this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_RELEASE_FAST : TOUCH_POLL_INTERVAL_RELEASE);
// compute distance
int16_t dx = x - _first_x;
@@ -92,7 +127,7 @@ int32_t TouchScreenBase::runOnce()
}
// tap
else {
- if (duration > 0 && duration < TIME_LONG_PRESS) {
+ if (duration > 0 && (duration < TIME_LONG_PRESS || !allowLongPress)) {
if (_tapped) {
_tapped = false;
} else {
@@ -132,7 +167,7 @@ int32_t TouchScreenBase::runOnce()
#endif
// fire LONG_PRESS event without the need for release
- if (touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) {
+ if (allowLongPress && touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) {
// tricky: prevent reoccurring events and another touch event when releasing
_start = millis() + 30000;
e.touchEvent = static_cast(TOUCH_ACTION_LONG_PRESS);
@@ -157,3 +192,13 @@ void TouchScreenBase::hapticFeedback()
drv.go();
#endif
}
+
+bool TouchScreenBase::fastTapModeEnabled() const
+{
+ return false;
+}
+
+bool TouchScreenBase::longPressEnabled() const
+{
+ return true;
+}
diff --git a/src/input/TouchScreenBase.h b/src/input/TouchScreenBase.h
index 90314cf02..e08c4832b 100644
--- a/src/input/TouchScreenBase.h
+++ b/src/input/TouchScreenBase.h
@@ -35,6 +35,8 @@ class TouchScreenBase : public Observable, public concurrenc
virtual bool getTouch(int16_t &x, int16_t &y) = 0;
virtual void onEvent(const TouchEvent &event) = 0;
+ virtual bool fastTapModeEnabled() const;
+ virtual bool longPressEnabled() const;
volatile TouchScreenBaseStateType _state = TOUCH_EVENT_CLEARED;
volatile TouchScreenBaseEventType _action = TOUCH_ACTION_NONE;
@@ -49,6 +51,7 @@ class TouchScreenBase : public Observable, public concurrenc
int16_t _first_x, _last_x; // horizontal swipe direction
int16_t _first_y, _last_y; // vertical swipe direction
time_t _start; // for LONG_PRESS
+ uint32_t _lastTouchSeenMs; // helps suppress brief touch-controller dropouts
bool _tapped; // for DOUBLE_TAP
const char *_originName;
diff --git a/src/input/TouchScreenImpl1.cpp b/src/input/TouchScreenImpl1.cpp
index 14f95b73a..0f1c9d023 100644
--- a/src/input/TouchScreenImpl1.cpp
+++ b/src/input/TouchScreenImpl1.cpp
@@ -3,6 +3,12 @@
#include "PowerFSM.h"
#include "configuration.h"
#include "modules/ExternalNotificationModule.h"
+#include
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+#include "graphics/niche/InkHUD/InkHUD.h"
+#include "graphics/niche/InkHUD/SystemApplet.h"
+#endif
#if ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
@@ -39,6 +45,31 @@ bool TouchScreenImpl1::getTouch(int16_t &x, int16_t &y)
return _getTouch(&x, &y);
}
+bool TouchScreenImpl1::fastTapModeEnabled() const
+{
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+ const auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance();
+ if (!inkhud) {
+ return false;
+ }
+
+ for (auto *sa : inkhud->systemApplets) {
+ if (!sa || !sa->name) {
+ continue;
+ }
+ if (strcmp(sa->name, "Keyboard") == 0) {
+ return sa->isForeground();
+ }
+ }
+#endif
+ return false;
+}
+
+bool TouchScreenImpl1::longPressEnabled() const
+{
+ return !fastTapModeEnabled();
+}
+
/**
* @brief forward touchscreen event
*
@@ -83,4 +114,4 @@ void TouchScreenImpl1::onEvent(const TouchEvent &event)
return;
}
this->notifyObservers(&e);
-}
\ No newline at end of file
+}
diff --git a/src/input/TouchScreenImpl1.h b/src/input/TouchScreenImpl1.h
index 0c5338459..3629982ef 100644
--- a/src/input/TouchScreenImpl1.h
+++ b/src/input/TouchScreenImpl1.h
@@ -10,6 +10,8 @@ class TouchScreenImpl1 : public TouchScreenBase
protected:
virtual bool getTouch(int16_t &x, int16_t &y);
virtual void onEvent(const TouchEvent &event);
+ bool fastTapModeEnabled() const override;
+ bool longPressEnabled() const override;
bool (*_getTouch)(int16_t *, int16_t *);
};
diff --git a/src/main.cpp b/src/main.cpp
index 0b58c11a9..43338e7b3 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -339,138 +339,9 @@ void setup()
#ifdef BLE_LED
pinMode(BLE_LED, OUTPUT);
-#ifdef BLE_LED_INVERTED
- digitalWrite(BLE_LED, HIGH);
-#else
- digitalWrite(BLE_LED, LOW);
-#endif
-#endif
-
-#if defined(T_DECK)
- // GPIO10 manages all peripheral power supplies
- // Turn on peripheral power immediately after MUC starts.
- // If some boards are turned on late, ESP32 will reset due to low voltage.
- // ESP32-C3(Keyboard) , MAX98357A(Audio Power Amplifier) ,
- // TF Card , Display backlight(AW9364DNR) , AN48841B(Trackball) , ES7210(Decoder)
- pinMode(KB_POWERON, OUTPUT);
- digitalWrite(KB_POWERON, HIGH);
- // T-Deck has all three SPI peripherals (TFT, SD, LoRa) attached to the same SPI bus
- // We need to initialize all CS pins in advance otherwise there will be SPI communication issues
- // e.g. when detecting the SD card
- pinMode(LORA_CS, OUTPUT);
- digitalWrite(LORA_CS, HIGH);
- pinMode(SDCARD_CS, OUTPUT);
- digitalWrite(SDCARD_CS, HIGH);
- pinMode(TFT_CS, OUTPUT);
- digitalWrite(TFT_CS, HIGH);
- delay(100);
-#elif defined(T_DECK_PRO)
- pinMode(LORA_EN, OUTPUT);
- digitalWrite(LORA_EN, HIGH);
- pinMode(LORA_CS, OUTPUT);
- digitalWrite(LORA_CS, HIGH);
- pinMode(SDCARD_CS, OUTPUT);
- digitalWrite(SDCARD_CS, HIGH);
- pinMode(PIN_EINK_CS, OUTPUT);
- digitalWrite(PIN_EINK_CS, HIGH);
-#if PIN_EINK_RES >= 0
- pinMode(PIN_EINK_RES, OUTPUT);
- digitalWrite(PIN_EINK_RES, HIGH);
-#endif
- pinMode(CST328_PIN_RST, OUTPUT);
- digitalWrite(CST328_PIN_RST, HIGH);
-#elif defined(T_LORA_PAGER)
- pinMode(LORA_CS, OUTPUT);
- digitalWrite(LORA_CS, HIGH);
- pinMode(SDCARD_CS, OUTPUT);
- digitalWrite(SDCARD_CS, HIGH);
- pinMode(TFT_CS, OUTPUT);
- digitalWrite(TFT_CS, HIGH);
- pinMode(KB_INT, INPUT_PULLUP);
- // io expander
- io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL);
- io.pinMode(EXPANDS_DRV_EN, OUTPUT);
- io.digitalWrite(EXPANDS_DRV_EN, HIGH);
- io.pinMode(EXPANDS_AMP_EN, OUTPUT);
- io.digitalWrite(EXPANDS_AMP_EN, LOW);
- io.pinMode(EXPANDS_LORA_EN, OUTPUT);
- io.digitalWrite(EXPANDS_LORA_EN, HIGH);
- io.pinMode(EXPANDS_GPS_EN, OUTPUT);
- io.digitalWrite(EXPANDS_GPS_EN, HIGH);
- io.pinMode(EXPANDS_KB_EN, OUTPUT);
- io.digitalWrite(EXPANDS_KB_EN, HIGH);
- io.pinMode(EXPANDS_SD_EN, OUTPUT);
- io.digitalWrite(EXPANDS_SD_EN, HIGH);
- io.pinMode(EXPANDS_GPIO_EN, OUTPUT);
- io.digitalWrite(EXPANDS_GPIO_EN, HIGH);
- io.pinMode(EXPANDS_SD_PULLEN, INPUT);
-#elif defined(HACKADAY_COMMUNICATOR)
- pinMode(KB_INT, INPUT);
digitalWrite(BLE_LED, LED_STATE_OFF);
#endif
-#if defined(T_DECK)
- // GPIO10 manages all peripheral power supplies
- // Turn on peripheral power immediately after MUC starts.
- // If some boards are turned on late, ESP32 will reset due to low voltage.
- // ESP32-C3(Keyboard) , MAX98357A(Audio Power Amplifier) ,
- // TF Card , Display backlight(AW9364DNR) , AN48841B(Trackball) , ES7210(Decoder)
- pinMode(KB_POWERON, OUTPUT);
- digitalWrite(KB_POWERON, HIGH);
- // T-Deck has all three SPI peripherals (TFT, SD, LoRa) attached to the same SPI bus
- // We need to initialize all CS pins in advance otherwise there will be SPI communication issues
- // e.g. when detecting the SD card
- pinMode(LORA_CS, OUTPUT);
- digitalWrite(LORA_CS, HIGH);
- pinMode(SDCARD_CS, OUTPUT);
- digitalWrite(SDCARD_CS, HIGH);
- pinMode(TFT_CS, OUTPUT);
- digitalWrite(TFT_CS, HIGH);
- delay(100);
-#elif defined(T_DECK_PRO)
- pinMode(LORA_EN, OUTPUT);
- digitalWrite(LORA_EN, HIGH);
- pinMode(LORA_CS, OUTPUT);
- digitalWrite(LORA_CS, HIGH);
- pinMode(SDCARD_CS, OUTPUT);
- digitalWrite(SDCARD_CS, HIGH);
- pinMode(PIN_EINK_CS, OUTPUT);
- digitalWrite(PIN_EINK_CS, HIGH);
-#if PIN_EINK_RES >= 0
- pinMode(PIN_EINK_RES, OUTPUT);
- digitalWrite(PIN_EINK_RES, HIGH);
-#endif
- pinMode(CST328_PIN_RST, OUTPUT);
- digitalWrite(CST328_PIN_RST, HIGH);
-#elif defined(T_LORA_PAGER)
- pinMode(LORA_CS, OUTPUT);
- digitalWrite(LORA_CS, HIGH);
- pinMode(SDCARD_CS, OUTPUT);
- digitalWrite(SDCARD_CS, HIGH);
- pinMode(TFT_CS, OUTPUT);
- digitalWrite(TFT_CS, HIGH);
- pinMode(KB_INT, INPUT_PULLUP);
- // io expander
- io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL);
- io.pinMode(EXPANDS_DRV_EN, OUTPUT);
- io.digitalWrite(EXPANDS_DRV_EN, HIGH);
- io.pinMode(EXPANDS_AMP_EN, OUTPUT);
- io.digitalWrite(EXPANDS_AMP_EN, LOW);
- io.pinMode(EXPANDS_LORA_EN, OUTPUT);
- io.digitalWrite(EXPANDS_LORA_EN, HIGH);
- io.pinMode(EXPANDS_GPS_EN, OUTPUT);
- io.digitalWrite(EXPANDS_GPS_EN, HIGH);
- io.pinMode(EXPANDS_KB_EN, OUTPUT);
- io.digitalWrite(EXPANDS_KB_EN, HIGH);
- io.pinMode(EXPANDS_SD_EN, OUTPUT);
- io.digitalWrite(EXPANDS_SD_EN, HIGH);
- io.pinMode(EXPANDS_GPIO_EN, OUTPUT);
- io.digitalWrite(EXPANDS_GPIO_EN, HIGH);
- io.pinMode(EXPANDS_SD_PULLEN, INPUT);
-#elif defined(HACKADAY_COMMUNICATOR)
- pinMode(KB_INT, INPUT);
-#endif
-
concurrency::hasBeenSetup = true;
#if HAS_SCREEN
meshtastic_Config_DisplayConfig_OledType screen_model =
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/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp
index 13f948a7b..e8613d457 100644
--- a/src/mesh/NextHopRouter.cpp
+++ b/src/mesh/NextHopRouter.cpp
@@ -101,9 +101,12 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
if (origTx) {
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
// directly from the destination
- bool wasAlreadyRelayer = wasRelayer(p->relay_node, p->decoded.request_id, p->to);
+ // Single lookup for both relayer checks on the same (request_id, to) pair
+ bool wasAlreadyRelayer = false;
bool weWereSoleRelayer = false;
- bool weWereRelayer = wasRelayer(ourRelayID, p->decoded.request_id, p->to, &weWereSoleRelayer);
+ bool weWereRelayer = false;
+ checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
+ &weWereSoleRelayer);
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
if (origTx->next_hop != p->relay_node) { // Not already set
LOG_INFO("Update next hop of 0x%x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from,
diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp
index 845a936d4..8289f0078 100644
--- a/src/mesh/PacketHistory.cpp
+++ b/src/mesh/PacketHistory.cpp
@@ -1,6 +1,7 @@
#include "PacketHistory.h"
#include "configuration.h"
#include "mesh-pb-constants.h"
+#include "meshUtils.h"
#ifdef ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
@@ -23,6 +24,14 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa
size = PACKETHISTORY_MAX; // Use default size if invalid
}
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Ensure capacity fits in uint16_t hash index (HASH_EMPTY = 0xFFFF is the sentinel)
+ if (size >= HASH_EMPTY) {
+ LOG_WARN("Packet History - Clamping size %d to %d (hash index limit)", size, HASH_EMPTY - 1);
+ size = HASH_EMPTY - 1;
+ }
+#endif
+
// Allocate memory for the recent packets array
recentPacketsCapacity = size;
recentPackets = new PacketRecord[recentPacketsCapacity];
@@ -35,6 +44,20 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa
// Initialize the recent packets array to zero
memset(recentPackets, 0, sizeof(PacketRecord) * recentPacketsCapacity);
+
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Allocate hash index with load factor <= 0.5 for short probe chains
+ hashCapacity = nextPowerOf2(recentPacketsCapacity * 2);
+ hashMask = hashCapacity - 1;
+ hashIndex = new uint16_t[hashCapacity];
+ if (!hashIndex) {
+ LOG_ERROR("Packet History - Hash index allocation failed for %d entries", hashCapacity);
+ hashCapacity = 0;
+ hashMask = 0;
+ return;
+ }
+ memset(hashIndex, 0xFF, sizeof(uint16_t) * hashCapacity); // Fill with HASH_EMPTY (0xFFFF)
+#endif
}
PacketHistory::~PacketHistory()
@@ -42,6 +65,12 @@ PacketHistory::~PacketHistory()
recentPacketsCapacity = 0;
delete[] recentPackets;
recentPackets = NULL;
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ delete[] hashIndex;
+ hashIndex = NULL;
+ hashCapacity = 0;
+ hashMask = 0;
+#endif
}
/** Update recentPackets and return true if we have already seen this packet */
@@ -194,7 +223,78 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd
return seenRecently;
}
-/** Find a packet record in history.
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+// Hash function for (sender, id) pairs. Uses xor-shift mixing for good distribution.
+uint32_t PacketHistory::hashSlot(NodeNum sender, PacketId id) const
+{
+ uint32_t h = sender ^ (id * 0x9E3779B9); // Fibonacci hashing constant
+ h ^= h >> 16;
+ h *= 0x45d9f3b;
+ h ^= h >> 16;
+ return h & hashMask;
+}
+
+void PacketHistory::hashInsert(NodeNum sender, PacketId id, uint16_t slotIdx)
+{
+ if (!hashIndex)
+ return;
+ uint32_t bucket = hashSlot(sender, id);
+ // Guard against infinite loop if hash table is corrupted (no HASH_EMPTY slots)
+ for (uint32_t i = 0; i < hashCapacity; i++) {
+ if (hashIndex[bucket] == HASH_EMPTY) {
+ hashIndex[bucket] = slotIdx;
+ return;
+ }
+ bucket = (bucket + 1) & hashMask;
+ }
+ LOG_ERROR("Packet History - hashInsert: table full or corrupted, rebuilding");
+ hashRebuild();
+}
+
+void PacketHistory::hashRemove(NodeNum sender, PacketId id)
+{
+ if (!hashIndex)
+ return;
+ uint32_t bucket = hashSlot(sender, id);
+ for (uint32_t i = 0; i < hashCapacity; i++) {
+ if (hashIndex[bucket] == HASH_EMPTY)
+ return;
+ uint16_t idx = hashIndex[bucket];
+ if (idx < recentPacketsCapacity && recentPackets[idx].sender == sender && recentPackets[idx].id == id) {
+ // Found it — delete and re-insert subsequent entries to maintain probe chain integrity
+ hashIndex[bucket] = HASH_EMPTY;
+ uint32_t next = (bucket + 1) & hashMask;
+ for (uint32_t j = 0; j < hashCapacity; j++) {
+ if (hashIndex[next] == HASH_EMPTY)
+ break;
+ uint16_t displaced = hashIndex[next];
+ hashIndex[next] = HASH_EMPTY;
+ if (displaced < recentPacketsCapacity) {
+ const auto &rec = recentPackets[displaced];
+ hashInsert(rec.sender, rec.id, displaced);
+ }
+ next = (next + 1) & hashMask;
+ }
+ return;
+ }
+ bucket = (bucket + 1) & hashMask;
+ }
+}
+
+void PacketHistory::hashRebuild()
+{
+ if (!hashIndex)
+ return;
+ memset(hashIndex, 0xFF, sizeof(uint16_t) * hashCapacity);
+ for (uint32_t i = 0; i < recentPacketsCapacity; i++) {
+ if (recentPackets[i].rxTimeMsec != 0)
+ hashInsert(recentPackets[i].sender, recentPackets[i].id, (uint16_t)i);
+ }
+}
+#endif
+
+/** Find a packet record in history using the hash index for O(1) average lookup.
+ * Falls back to linear scan if hash index is unavailable.
* @return pointer to PacketRecord if found, NULL if not found */
PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
{
@@ -205,23 +305,40 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
return NULL;
}
- PacketRecord *it = NULL;
- for (it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) {
- if (it->id == id && it->sender == sender) {
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Use hash index for O(1) lookup when available
+ if (hashIndex) {
+ uint32_t bucket = hashSlot(sender, id);
+ for (uint32_t i = 0; i < hashCapacity; i++) {
+ if (hashIndex[bucket] == HASH_EMPTY)
+ break;
+ uint16_t idx = hashIndex[bucket];
+ if (idx < recentPacketsCapacity && recentPackets[idx].id == id && recentPackets[idx].sender == sender) {
#if VERBOSE_PACKET_HISTORY
- LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d", it->sender,
- it->id, it->next_hop, it->relayed_by[0], it->relayed_by[1], it->relayed_by[2], millis() - (it->rxTimeMsec),
- it - recentPackets, recentPacketsCapacity);
+ LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d",
+ recentPackets[idx].sender, recentPackets[idx].id, recentPackets[idx].next_hop,
+ recentPackets[idx].relayed_by[0], recentPackets[idx].relayed_by[1], recentPackets[idx].relayed_by[2],
+ millis() - (recentPackets[idx].rxTimeMsec), idx, recentPacketsCapacity);
#endif
- // only the first match is returned, so be careful not to create duplicate entries
- return it; // Return pointer to the found record
+ return &recentPackets[idx];
+ }
+ bucket = (bucket + 1) & hashMask;
+ }
+#if VERBOSE_PACKET_HISTORY
+ LOG_DEBUG("Packet History - find: s=%08x id=%08x NOT FOUND", sender, id);
+#endif
+ return NULL;
+ }
+#endif
+
+ // Linear scan (sole path when hash excluded, fallback when hash allocation failed)
+ for (PacketRecord *it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) {
+ if (it->id == id && it->sender == sender) {
+ return it;
}
}
-#if VERBOSE_PACKET_HISTORY
- LOG_DEBUG("Packet History - find: s=%08x id=%08x NOT FOUND", sender, id);
-#endif
- return NULL; // Not found
+ return NULL;
}
/** Insert/Replace oldest PacketRecord in recentPackets. */
@@ -327,8 +444,22 @@ void PacketHistory::insert(const PacketRecord &r)
return; // Return early if we can't update the history
}
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Maintain hash index: remove old entry if evicting a different packet, then insert new entry
+ bool isMatchingSlot = (tu->id == r.id && tu->sender == r.sender);
+ if (!isMatchingSlot && tu->rxTimeMsec != 0) {
+ hashRemove(tu->sender, tu->id);
+ }
+
*tu = r; // store the packet
+ if (!isMatchingSlot) {
+ hashInsert(r.sender, r.id, (uint16_t)(tu - recentPackets));
+ }
+#else
+ *tu = r; // store the packet
+#endif
+
#if VERBOSE_PACKET_HISTORY
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER",
tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1],
@@ -396,6 +527,31 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, boo
return found;
}
+// Check two relayers against the same packet record with a single find() call,
+// avoiding redundant O(N) lookups when both are checked for the same (id, sender) pair.
+void PacketHistory::checkRelayers(uint8_t relayer1, uint8_t relayer2, uint32_t id, NodeNum sender, bool *r1Result, bool *r2Result,
+ bool *r2WasSole)
+{
+ *r1Result = false;
+ *r2Result = false;
+ if (r2WasSole)
+ *r2WasSole = false;
+
+ if (!initOk()) {
+ LOG_ERROR("PacketHistory - checkRelayers: NOT INITIALIZED!");
+ return;
+ }
+
+ const PacketRecord *found = find(sender, id);
+ if (!found)
+ return;
+
+ if (relayer1 != 0)
+ *r1Result = wasRelayer(relayer1, *found);
+ if (relayer2 != 0)
+ *r2Result = wasRelayer(relayer2, *found, r2WasSole);
+}
+
// Remove a relayer from the list of relayers of a packet in the history given an ID and sender
void PacketHistory::removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender)
{
diff --git a/src/mesh/PacketHistory.h b/src/mesh/PacketHistory.h
index 9b6a93280..a11e2d038 100644
--- a/src/mesh/PacketHistory.h
+++ b/src/mesh/PacketHistory.h
@@ -28,6 +28,22 @@ class PacketHistory
0; // Can be set in constructor, no need to recompile. Used to allocate memory for mx_recentPackets.
PacketRecord *recentPackets = NULL; // Simple and fixed in size. Debloat.
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Open-addressing hash table for O(1) lookup in find(), replacing the O(N) linear scan.
+ // Maps (sender, id) -> index into recentPackets[]. Uses linear probing with a load factor <= 0.5.
+ // The load factor invariant holds permanently: hashCapacity = 2 * nextPowerOf2(recentPacketsCapacity),
+ // and at most recentPacketsCapacity entries can ever be live (one per recentPackets[] slot).
+ static constexpr uint16_t HASH_EMPTY = 0xFFFF;
+ uint16_t *hashIndex = NULL;
+ uint32_t hashCapacity = 0; // Always a power of 2
+ uint32_t hashMask = 0; // hashCapacity - 1, for fast modular indexing
+
+ uint32_t hashSlot(NodeNum sender, PacketId id) const;
+ void hashInsert(NodeNum sender, PacketId id, uint16_t slotIdx);
+ void hashRemove(NodeNum sender, PacketId id);
+ void hashRebuild();
+#endif
+
/** Find a packet record in history.
* @param sender NodeNum
* @param id PacketId
@@ -70,6 +86,16 @@ class PacketHistory
* @return true if node was indeed a relayer, false if not */
bool wasRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender, bool *wasSole = nullptr);
+ /**
+ * Check two relayers against the same packet record with a single lookup.
+ * Avoids redundant find() calls when checking multiple relayers for the same (id, sender) pair.
+ * @param r1Result set to true if relayer1 was a relayer
+ * @param r2Result set to true if relayer2 was a relayer
+ * @param r2WasSole if not nullptr, set to true if relayer2 was the sole relayer
+ */
+ void checkRelayers(uint8_t relayer1, uint8_t relayer2, uint32_t id, NodeNum sender, bool *r1Result, bool *r2Result,
+ bool *r2WasSole = nullptr);
+
// Remove a relayer from the list of relayers of a packet in the history given an ID and sender
void removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender);
diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp
index 714e61108..cb25efb77 100644
--- a/src/mesh/PhoneAPI.cpp
+++ b/src/mesh/PhoneAPI.cpp
@@ -470,8 +470,13 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
fromRadioScratch.moduleConfig.payload_variant.traffic_management = moduleConfig.traffic_management;
break;
+ case meshtastic_ModuleConfig_tak_tag:
+ LOG_DEBUG("Send module config: tak");
+ fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_tak_tag;
+ fromRadioScratch.moduleConfig.payload_variant.tak = moduleConfig.tak;
+ break;
default:
- LOG_ERROR("Unknown module config type %d", config_state);
+ LOG_DEBUG("Unhandled module config type %d", config_state);
}
config_state++;
diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp
index 7ef707e0d..6024d06b6 100644
--- a/src/mesh/RadioLibInterface.cpp
+++ b/src/mesh/RadioLibInterface.cpp
@@ -46,6 +46,16 @@ RadioLibInterface::RadioLibInterface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE c
#endif
}
+RadioLibInterface::~RadioLibInterface()
+{
+ // If the static `instance` pointer still references us, clear it.
+ // A later successful init() may have replaced `instance` with a newer
+ // interface — don't clobber that case.
+ if (instance == this) {
+ instance = nullptr;
+ }
+}
+
#ifdef ARCH_ESP32
// ESP32 doesn't use that flag
#define YIELD_FROM_ISR(x) portYIELD_FROM_ISR()
diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h
index 310ca76bb..2859558ed 100644
--- a/src/mesh/RadioLibInterface.h
+++ b/src/mesh/RadioLibInterface.h
@@ -136,6 +136,13 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified
RadioLibInterface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst,
RADIOLIB_PIN_TYPE busy, PhysicalLayer *iface = NULL);
+ /**
+ * Clear the static `instance` pointer if it still points at us, so callers
+ * that check `RadioLibInterface::instance != nullptr` don't dereference a
+ * freed object after a failed init() + unique_ptr reset.
+ */
+ virtual ~RadioLibInterface();
+
virtual ErrorCode send(meshtastic_MeshPacket *p) override;
/**
diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp
index 836cd1a22..e0473a14e 100644
--- a/src/mesh/Router.cpp
+++ b/src/mesh/Router.cpp
@@ -499,9 +499,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
meshtastic_Data decodedtmp;
memset(&decodedtmp, 0, sizeof(decodedtmp));
if (!pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp)) {
- LOG_ERROR("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)!", p->id);
+ LOG_DEBUG("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)", p->id);
} else if (decodedtmp.portnum == meshtastic_PortNum_UNKNOWN_APP) {
- LOG_ERROR("Invalid portnum (bad psk?)!");
+ LOG_DEBUG("Invalid portnum (bad psk?)");
#if !(MESHTASTIC_EXCLUDE_PKI)
} else if (!owner.is_licensed && isToUs(p) && decodedtmp.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP) {
LOG_WARN("Rejecting legacy DM");
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 da3a4593b..4c450b3c4 100644
--- a/src/meshUtils.h
+++ b/src/meshUtils.h
@@ -11,6 +11,24 @@ template constexpr const T &clamp(const T &v, const T &lo, const T &hi
return (v < lo) ? lo : (hi < v) ? hi : v;
}
+/// Return the smallest power of 2 >= n (undefined for n > 2^31)
+static inline uint32_t nextPowerOf2(uint32_t n)
+{
+ if (n <= 1)
+ return 1;
+#if defined(__GNUC__)
+ return 1U << (32 - __builtin_clz(n - 1));
+#else
+ n--;
+ n |= n >> 1;
+ n |= n >> 2;
+ n |= n >> 4;
+ n |= n >> 8;
+ n |= n >> 16;
+ return n + 1;
+#endif
+}
+
#if HAS_SCREEN
#define IF_SCREEN(X) \
if (screen) { \
@@ -38,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 a1ba7ee68..3dd790c6c 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/modules/PositionModule.cpp b/src/modules/PositionModule.cpp
index 0378d01e7..ac81e9c57 100644
--- a/src/modules/PositionModule.cpp
+++ b/src/modules/PositionModule.cpp
@@ -492,15 +492,24 @@ void PositionModule::sendLostAndFoundText()
{
meshtastic_MeshPacket *p = allocDataPacket();
p->to = NODENUM_BROADCAST;
- char *message = new char[60];
- sprintf(message, "🚨I'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7), (lastGpsLongitude * 1e-7));
+ char message[128];
+ int written = snprintf(message, sizeof(message), "🚨I'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7),
+ (lastGpsLongitude * 1e-7));
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p->want_ack = false;
- p->decoded.payload.size = strlen(message);
- memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
+ if (written < 0) {
+ // snprintf encoding error — send an empty payload rather than uninitialized bytes.
+ p->decoded.payload.size = 0;
+ } else {
+ // Clamp to buffer capacity (snprintf returns "would-have-written" which can exceed the buffer).
+ const size_t msg_len = std::min(static_cast(written), sizeof(message) - 1);
+ p->decoded.payload.size = msg_len;
+ if (msg_len > 0) {
+ memcpy(p->decoded.payload.bytes, message, msg_len);
+ }
+ }
service->sendToMesh(p, RX_SRC_LOCAL, true);
- delete[] message;
}
// Helper: return imprecise (truncated + centered) lat/lon as int32 using current precision
@@ -580,4 +589,4 @@ void PositionModule::handleNewPosition()
}
}
-#endif
\ No newline at end of file
+#endif
diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp
index 6df0e18f0..6c2efe83f 100644
--- a/src/modules/StoreForwardModule.cpp
+++ b/src/modules/StoreForwardModule.cpp
@@ -206,7 +206,7 @@ void StoreForwardModule::historyAdd(const meshtastic_MeshPacket &mp)
this->packetHistory[this->packetHistoryTotalCount].hop_limit = mp.hop_limit;
this->packetHistory[this->packetHistoryTotalCount].via_mqtt = mp.via_mqtt;
this->packetHistory[this->packetHistoryTotalCount].transport_mechanism = mp.transport_mechanism;
- memcpy(this->packetHistory[this->packetHistoryTotalCount].payload, p.payload.bytes, meshtastic_Constants_DATA_PAYLOAD_LEN);
+ memcpy(this->packetHistory[this->packetHistoryTotalCount].payload, p.payload.bytes, p.payload.size);
this->packetHistoryTotalCount++;
}
diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp
index 592151f0e..b894261df 100644
--- a/src/nimble/NimbleBluetooth.cpp
+++ b/src/nimble/NimbleBluetooth.cpp
@@ -318,7 +318,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread
/**
* Subclasses can use this as a hook to provide custom notifications for their transport (i.e. bluetooth notifies)
*/
- virtual void onNowHasData(uint32_t fromRadioNum)
+ virtual void onNowHasData(uint32_t fromRadioNum) override
{
PhoneAPI::onNowHasData(fromRadioNum);
@@ -337,7 +337,7 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread
}
/// Check the current underlying physical link to see if the client is currently connected
- virtual bool checkIsConnected() { return bleServer && bleServer->getConnectedCount() > 0; }
+ virtual bool checkIsConnected() override { return bleServer && bleServer->getConnectedCount() > 0; }
void requestHighThroughputConnection(uint16_t conn_handle)
{
@@ -720,6 +720,8 @@ void NimbleBluetooth::deinit()
#ifdef BLE_LED
digitalWrite(BLE_LED, LED_STATE_OFF);
#endif
+
+ BLEDevice::deinit(true);
#endif
}
@@ -816,7 +818,7 @@ void NimbleBluetooth::setup()
// name to default, so set it again.
int nameRc = ble_svc_gap_device_name_set(BLEDevice::getDeviceName().c_str());
if (nameRc != 0) {
- LOG_ERROR("ble_svc_gap_device_name_set: rc=%d %s", nameRc, BLEUtils::returnCodeToString(nameRc));
+ LOG_ERROR("ble_svc_gap_device_name_set: rc=%d %s", nameRc, BLEUtils::returnCodeToString(nameRc));
}
bleServer->setCallbacks(new NimbleBluetoothServerCallback(this));
diff --git a/src/nimble/NimbleBluetooth.h b/src/nimble/NimbleBluetooth.h
index 5c1ca0b26..22bcceee4 100644
--- a/src/nimble/NimbleBluetooth.h
+++ b/src/nimble/NimbleBluetooth.h
@@ -21,5 +21,4 @@ class NimbleBluetooth : public BluetoothApi
void setupService();
};
-void setBluetoothEnable(bool enable);
-void clearNVS();
\ No newline at end of file
+void setBluetoothEnable(bool enable);
\ No newline at end of file
diff --git a/src/platform/esp32/IramMemcpy.c b/src/platform/esp32/IramMemcpy.c
new file mode 100644
index 000000000..898f1bdc6
--- /dev/null
+++ b/src/platform/esp32/IramMemcpy.c
@@ -0,0 +1,42 @@
+#include
+#include
+#include
+
+#include "esp_attr.h"
+
+#ifdef ESP32_FORCE_IRAM_MEMSET
+
+/*
+ * T-Beam/classic ESP32 boot workaround
+ * ------------------------------------
+ * During early flash operations the ESP32 disables cache, but some IRAM flash
+ * code paths still reach libc memcpy/memset. If those resolve to flash-resident
+ * implementations, startup can panic with cache-disabled access errors.
+ *
+ * We wrap memcpy/memset for the T-Beam environment. Fast path uses the
+ * normal libc routines when cache is enabled; slow path uses IRAM-safe byte
+ * loops when cache is disabled.
+ */
+
+extern void *__real_memcpy(void *dst, const void *src, size_t n);
+
+static inline bool IRAM_ATTR cache_is_enabled(void)
+{
+ return (*(volatile uint32_t *)0x3FF00040u) & (1u << 3);
+}
+
+extern void *IRAM_ATTR __wrap_memcpy(void *dst, const void *src, size_t n)
+{
+ if (cache_is_enabled()) {
+ return __real_memcpy(dst, src, n);
+ }
+
+ uint8_t *d = (uint8_t *)dst;
+ const uint8_t *s = (const uint8_t *)src;
+ while (n--) {
+ *d++ = *s++;
+ }
+ return dst;
+}
+
+#endif
diff --git a/src/platform/esp32/IramMemset.c b/src/platform/esp32/IramMemset.c
new file mode 100644
index 000000000..66d74d8bc
--- /dev/null
+++ b/src/platform/esp32/IramMemset.c
@@ -0,0 +1,42 @@
+#include
+#include
+#include
+
+#include "esp_attr.h"
+
+#ifdef ESP32_FORCE_IRAM_MEMSET
+
+/*
+ * T-Beam/classic ESP32 boot workaround
+ * ------------------------------------
+ * During early flash operations the ESP32 disables cache, but some IRAM flash
+ * code paths still reach libc memcpy/memset. If those resolve to flash-resident
+ * implementations, startup can panic with cache-disabled access errors.
+ *
+ * We wrap memcpy/memset for the T-Beam environment. Fast path uses the
+ * normal libc routines when cache is enabled; slow path uses IRAM-safe byte
+ * loops when cache is disabled.
+ */
+
+extern void *__real_memset(void *dst, int c, size_t n);
+
+static inline bool IRAM_ATTR cache_is_enabled(void)
+{
+ return (*(volatile uint32_t *)0x3FF00040u) & (1u << 3);
+}
+
+extern void *IRAM_ATTR __wrap_memset(void *dst, int c, size_t n)
+{
+ if (cache_is_enabled()) {
+ return __real_memset(dst, c, n);
+ }
+
+ uint8_t *ptr = (uint8_t *)dst;
+ const uint8_t fill = (uint8_t)c;
+ while (n--) {
+ *ptr++ = fill;
+ }
+ return dst;
+}
+
+#endif
\ No newline at end of file
diff --git a/src/platform/esp32/align-text.handler_execute-4.ld b/src/platform/esp32/align-text.handler_execute-4.ld
index e747edcc4..fca4f27a2 100644
--- a/src/platform/esp32/align-text.handler_execute-4.ld
+++ b/src/platform/esp32/align-text.handler_execute-4.ld
@@ -1,7 +1,7 @@
/* Arduino fix: catch esp_event's orphaned .text.handler_execute section and align to 4 bytes */
SECTIONS
{
- .text.handler_execute :
+ .text.handler_execute ALIGN(4) :
{
KEEP(*(.text.handler_execute))
KEEP(*(.text.handler_execute.*))
diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp
index c45e0cdcd..5f3449554 100644
--- a/src/platform/esp32/main-esp32.cpp
+++ b/src/platform/esp32/main-esp32.cpp
@@ -185,7 +185,7 @@ void esp32Setup()
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)
esp_task_wdt_config_t *wdt_config = (esp_task_wdt_config_t *)malloc(sizeof(esp_task_wdt_config_t));
wdt_config->timeout_ms = APP_WATCHDOG_SECS * 1000;
- wdt_config->idle_core_mask = 1 << 1;
+ wdt_config->idle_core_mask = (1 << CONFIG_FREERTOS_NUMBER_OF_CORES) - 1;
wdt_config->trigger_panic = true;
res = esp_task_wdt_init(wdt_config);
assert(res == ESP_OK);
diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp
index 827b3f5bd..a829bb980 100644
--- a/src/platform/extra_variants/t5s3_epaper/variant.cpp
+++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp
@@ -5,12 +5,17 @@
#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,
@@ -18,8 +23,7 @@
// 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: shortpress (cycle applets / confirm in menu)
-// - Long press: longpress (open menu / back)
+// - Tap/long-press: direct touch point dispatch (with fallback to short/long button semantics)
class TouchInkHUDBridge : public Observer
{
int onNotify(const InputEvent *e) override
@@ -41,28 +45,28 @@ class TouchInkHUDBridge : public Observer
switch (e->inputEvent) {
case INPUT_BROKER_USER_PRESS:
- inkhud->shortpress();
+ inkhud->touchTap(e->touchX, e->touchY);
break;
case INPUT_BROKER_SELECT:
- inkhud->longpress();
+ inkhud->touchLongPress(e->touchX, e->touchY);
break;
case INPUT_BROKER_LEFT:
if (systemHandlingInput)
- inkhud->navUp();
+ inkhud->touchNavUp();
else
inkhud->prevApplet();
break;
case INPUT_BROKER_RIGHT:
if (systemHandlingInput)
- inkhud->navDown();
+ inkhud->touchNavDown();
else
inkhud->nextApplet();
break;
case INPUT_BROKER_UP:
- inkhud->navUp();
+ inkhud->touchNavUp();
break;
case INPUT_BROKER_DOWN:
- inkhud->navDown();
+ inkhud->touchNavDown();
break;
default:
break;
@@ -76,6 +80,430 @@ static TouchInkHUDBridge touchBridge;
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 {
@@ -87,8 +515,96 @@ struct TouchDeepSleepObserver {
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;
@@ -123,22 +639,71 @@ bool readTouch(int16_t *x, int16_t *y)
return true;
}
}
+
return false;
}
-// T5-S3-ePaper Pro specific (late-) init
-void lateInitVariant(void)
+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/src/power.h b/src/power.h
index 4ca36c2b6..993163c39 100644
--- a/src/power.h
+++ b/src/power.h
@@ -83,7 +83,7 @@ extern RAK9154Sensor rak9154Sensor;
extern XPowersLibInterface *PMU;
#endif
-class Power : private concurrency::OSThread
+class Power : public concurrency::OSThread
{
public:
@@ -97,6 +97,15 @@ class Power : private concurrency::OSThread
virtual int32_t runOnce() override;
void setStatusHandler(meshtastic::PowerStatus *handler) { statusHandler = handler; }
const uint16_t OCV[11] = {OCV_ARRAY};
+ bool isLowBattery() { return low_voltage_counter >= 10; };
+
+#ifdef ARCH_ESP32
+ int beforeLightSleep(void *unused);
+ int afterLightSleep(esp_sleep_wakeup_cause_t cause);
+#endif
+
+ void attachPowerInterrupts();
+ void detachPowerInterrupts();
protected:
meshtastic::PowerStatus *statusHandler;
@@ -122,6 +131,14 @@ class Power : private concurrency::OSThread
// open circuit voltage lookup table
uint8_t low_voltage_counter;
uint32_t lastLogTime = 0;
+
+#ifdef ARCH_ESP32
+ // Get notified when lightsleep begins and ends
+ CallbackObserver lsObserver = CallbackObserver(this, &Power::beforeLightSleep);
+ CallbackObserver lsEndObserver =
+ CallbackObserver(this, &Power::afterLightSleep);
+#endif
+
#ifdef DEBUG_HEAP
uint32_t lastheap;
#endif
diff --git a/src/sleep.cpp b/src/sleep.cpp
index 8da9be797..8c70a1668 100644
--- a/src/sleep.cpp
+++ b/src/sleep.cpp
@@ -330,9 +330,14 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN
#ifdef HAS_PPM
if (PPM) {
- LOG_INFO("PMM shutdown");
- console->flush();
- PPM->shutdown();
+ // BQ25896 PMIC shutdown is a hard power-off state.
+ // Only use it for "sleep forever" / explicit shutdown, because timed deep sleep
+ // must remain wakeable by RTC timer.
+ if (msecToWake == portMAX_DELAY) {
+ LOG_INFO("PPM shutdown");
+ console->flush();
+ PPM->shutdown();
+ }
}
#endif
@@ -441,6 +446,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r
#ifdef KB_INT
gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_LOW_LEVEL);
#endif
+#ifdef BOARD_PCA9535_INT
+ // Side-key interrupt line from PCA9535 expander (active low).
+ gpio_wakeup_enable((gpio_num_t)BOARD_PCA9535_INT, GPIO_INTR_LOW_LEVEL);
+#endif
#ifdef BUTTON_PIN
gpio_num_t pin = (gpio_num_t)(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN);
gpio_wakeup_enable(pin, GPIO_INTR_LOW_LEVEL);
@@ -488,6 +497,9 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r
#ifdef KB_INT
gpio_wakeup_disable((gpio_num_t)KB_INT);
#endif
+#ifdef BOARD_PCA9535_INT
+ gpio_wakeup_disable((gpio_num_t)BOARD_PCA9535_INT);
+#endif
#ifdef BUTTON_PIN
// Disable wake-on-button interrupt. Re-attach normal button-interrupts
gpio_wakeup_disable(pin);
@@ -513,13 +525,12 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
notifyLightSleepEnd.notifyObservers(cause); // Button interrupts are reattached here
-#ifdef BUTTON_PIN
if (cause == ESP_SLEEP_WAKEUP_GPIO) {
- LOG_INFO("Exit light sleep gpio: btn=%d",
- !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN));
- } else
-#endif
- {
+ LOG_INFO("Exit light sleep gpio");
+ // If we woke because of a GPIO, it's possible power needs to run to handle.
+ power->setIntervalFromNow(0);
+ runASAP = true;
+ } else {
LOG_INFO("Exit light sleep cause: %d", cause);
}
diff --git a/test/test_packet_history/test_main.cpp b/test/test_packet_history/test_main.cpp
new file mode 100644
index 000000000..2453956c5
--- /dev/null
+++ b/test/test_packet_history/test_main.cpp
@@ -0,0 +1,834 @@
+/*
+ * Unit tests for PacketHistory — the packet deduplication engine
+ * used by the mesh routing stack.
+ *
+ * PacketHistory maintains a fixed-size array of PacketRecords with an
+ * optional hash table for O(1) lookup. It tracks which nodes relayed
+ * each packet, supports LRU-style eviction, and detects fallback-to-
+ * flooding and hop-limit upgrades.
+ */
+
+#include "PacketHistory.h"
+
+#include "TestUtil.h"
+#include
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+static constexpr uint32_t OUR_NODE_NUM = 0xDEAD1234;
+static constexpr uint8_t OUR_RELAY_ID = 0x34; // getLastByteOfNodeNum(OUR_NODE_NUM)
+static constexpr uint32_t SMALL_CAPACITY = 8;
+
+// ---------------------------------------------------------------------------
+// Per-test state
+// ---------------------------------------------------------------------------
+static PacketHistory *ph = nullptr;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+static meshtastic_MeshPacket makePacket(uint32_t from, uint32_t id, uint8_t hop_limit = 3,
+ uint8_t next_hop = NO_NEXT_HOP_PREFERENCE, uint8_t relay_node = 0)
+{
+ meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
+ p.from = from;
+ p.id = id;
+ p.hop_limit = hop_limit;
+ p.next_hop = next_hop;
+ p.relay_node = relay_node;
+ return p;
+}
+
+// ---------------------------------------------------------------------------
+// setUp / tearDown — called before and after every test
+// ---------------------------------------------------------------------------
+void setUp(void)
+{
+ myNodeInfo.my_node_num = OUR_NODE_NUM;
+ ph = new PacketHistory(SMALL_CAPACITY);
+}
+
+void tearDown(void)
+{
+ delete ph;
+ ph = nullptr;
+}
+
+// ===========================================================================
+// Group 1 — Initialization
+// ===========================================================================
+
+void test_init_valid_size(void)
+{
+ PacketHistory h(8);
+ TEST_ASSERT_TRUE(h.initOk());
+}
+
+void test_init_minimum_size(void)
+{
+ PacketHistory h(4);
+ TEST_ASSERT_TRUE(h.initOk());
+}
+
+void test_init_too_small_falls_back(void)
+{
+ // Sizes < 4 or > PACKETHISTORY_MAX are clamped to PACKETHISTORY_MAX inside the constructor
+ PacketHistory h(2);
+ TEST_ASSERT_TRUE(h.initOk());
+}
+
+// ===========================================================================
+// Group 2 — Basic Deduplication
+// ===========================================================================
+
+void test_first_packet_not_seen(void)
+{
+ auto p = makePacket(0x1111, 100);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p));
+}
+
+void test_same_packet_seen_twice(void)
+{
+ auto p = makePacket(0x1111, 100);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p)); // first time
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p)); // duplicate
+}
+
+void test_different_id_not_confused(void)
+{
+ auto p1 = makePacket(0x1111, 100);
+ auto p2 = makePacket(0x1111, 200);
+ ph->wasSeenRecently(&p1);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p2));
+}
+
+void test_different_sender_not_confused(void)
+{
+ auto p1 = makePacket(0x1111, 100);
+ auto p2 = makePacket(0x2222, 100);
+ ph->wasSeenRecently(&p1);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p2));
+}
+
+void test_withUpdate_false_no_insert(void)
+{
+ auto p = makePacket(0x1111, 100);
+ // First call with withUpdate=false: should not store
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/false));
+ // Second call with withUpdate=true: still not found because first didn't store
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/true));
+}
+
+void test_withUpdate_true_inserts(void)
+{
+ auto p = makePacket(0x1111, 100);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/true));
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, /*withUpdate=*/false)); // found without inserting again
+}
+
+// ===========================================================================
+// Group 3 — LRU Eviction
+// ===========================================================================
+
+void test_fill_capacity_all_found(void)
+{
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ ph->wasSeenRecently(&p);
+ }
+ // All 8 should be found
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false));
+ }
+}
+
+void test_eviction_oldest_replaced(void)
+{
+ // Fill all 8 slots
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ ph->wasSeenRecently(&p);
+ }
+
+ // Advance time so the eviction logic can distinguish "oldest" from "newest".
+ // insert() uses (now_millis - rxTimeMsec) > OldtrxTimeMsec with strict >, so
+ // entries with identical timestamps all have age 0 and none gets selected.
+ delay(1);
+
+ // Insert a 9th packet — should evict the oldest
+ auto p9 = makePacket(0xAAAA, 9);
+ ph->wasSeenRecently(&p9);
+
+ // The 9th should be found
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p9, false));
+
+ // At least one of the originals should have been evicted
+ int evicted = 0;
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ if (!ph->wasSeenRecently(&p, false))
+ evicted++;
+ }
+ TEST_ASSERT_TRUE(evicted > 0);
+}
+
+void test_matching_slot_reused(void)
+{
+ // Insert packet, then re-insert same (sender, id) — should reuse slot, not evict others
+ auto p1 = makePacket(0xAAAA, 1);
+ auto p2 = makePacket(0xBBBB, 2);
+ ph->wasSeenRecently(&p1);
+ ph->wasSeenRecently(&p2);
+
+ // Re-observe p1 (triggers merge path)
+ ph->wasSeenRecently(&p1);
+
+ // Both should still be present
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p1, false));
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false));
+}
+
+void test_free_slot_preferred(void)
+{
+ // Insert 4 packets into capacity-8 history — next insert should use a free slot, not evict
+ for (uint32_t i = 1; i <= 4; i++) {
+ auto p = makePacket(0xAAAA, i);
+ ph->wasSeenRecently(&p);
+ }
+ auto p5 = makePacket(0xAAAA, 5);
+ ph->wasSeenRecently(&p5);
+
+ // All 5 should be present (no eviction needed)
+ for (uint32_t i = 1; i <= 5; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false));
+ }
+}
+
+void test_evict_all_old_packets(void)
+{
+ // Fill with packets 1..8
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ ph->wasSeenRecently(&p);
+ }
+
+ // Advance time so the replacement batch can evict the originals
+ delay(1);
+
+ // Replace all with packets 101..108
+ for (uint32_t i = 101; i <= 100 + SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xBBBB, i);
+ ph->wasSeenRecently(&p);
+ }
+ // None of the originals should be found
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, false));
+ }
+ // All new ones should be found
+ for (uint32_t i = 101; i <= 100 + SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xBBBB, i);
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false));
+ }
+}
+
+// ===========================================================================
+// Group 4 — Relayer Tracking
+// ===========================================================================
+
+void test_wasRelayer_true(void)
+{
+ // Non-us relay_nodes only enter relayed_by[] through the "heard-back" merge path:
+ // we must have relayed first, then observe the packet return at hop_limit-1.
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Heard-back from 0xCC at hop_limit=2 (ourTxHopLimit-1) triggers the merge
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xCC);
+ ph->wasSeenRecently(&p2);
+
+ TEST_ASSERT_TRUE(ph->wasRelayer(0xCC, 100, 0x1111));
+}
+
+void test_wasRelayer_false(void)
+{
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, 0xAA);
+ ph->wasSeenRecently(&p);
+ // 0xCC was never a relayer
+ TEST_ASSERT_FALSE(ph->wasRelayer(0xCC, 100, 0x1111));
+}
+
+void test_wasRelayer_zero_returns_false(void)
+{
+ auto p = makePacket(0x1111, 100);
+ ph->wasSeenRecently(&p);
+ TEST_ASSERT_FALSE(ph->wasRelayer(0, 100, 0x1111));
+}
+
+void test_wasRelayer_not_found(void)
+{
+ // Packet not in history at all
+ TEST_ASSERT_FALSE(ph->wasRelayer(0xAA, 999, 0x9999));
+}
+
+void test_wasRelayer_wasSole_true(void)
+{
+ // relay_node = ourRelayID → relayed_by[0] = ourRelayID
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ bool wasSole = false;
+ bool result = ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole);
+ TEST_ASSERT_TRUE(result);
+ TEST_ASSERT_TRUE(wasSole);
+}
+
+void test_wasRelayer_wasSole_false(void)
+{
+ // First observation: we relay
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Second observation: different relayer adds to record
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ bool wasSole = true;
+ bool result = ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole);
+ TEST_ASSERT_TRUE(result);
+ TEST_ASSERT_FALSE(wasSole);
+}
+
+void test_wasRelayer_all_six_slots(void)
+{
+ // First observation: we relay with hop_limit=3 (fills slot 0, ourTxHopLimit=3)
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ // Each heard-back must satisfy: hop_limit == ourTxHopLimit OR ourTxHopLimit-1.
+ // Using hop_limit=2 (ourTxHopLimit-1) for all, which triggers the heard-back
+ // merge path each time. Each new relay_node pushes to slot 0 and shifts existing
+ // relayers right, eventually filling all NUM_RELAYERS(6) slots.
+ uint8_t relayers[] = {0x11, 0x22, 0x33, 0x44, 0x55};
+ for (int i = 0; i < 5; i++) {
+ auto pn = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, relayers[i]);
+ ph->wasSeenRecently(&pn);
+ }
+
+ // All 6 should be detected
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+ for (int i = 0; i < 5; i++) {
+ TEST_ASSERT_TRUE(ph->wasRelayer(relayers[i], 100, 0x1111));
+ }
+}
+
+// ===========================================================================
+// Group 5 — removeRelayer
+// ===========================================================================
+
+void test_removeRelayer_removes(void)
+{
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+
+ ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111);
+ TEST_ASSERT_FALSE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+}
+
+void test_removeRelayer_compacts(void)
+{
+ // We relay first
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+ // Second relayer
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ // Remove us, 0xBB should still be found
+ ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111);
+ TEST_ASSERT_FALSE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+ TEST_ASSERT_TRUE(ph->wasRelayer(0xBB, 100, 0x1111));
+}
+
+void test_removeRelayer_nonexistent_safe(void)
+{
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+ // Removing a relayer that doesn't exist should not crash
+ ph->removeRelayer(0xFF, 100, 0x1111);
+ // Original should still be there
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+}
+
+void test_removeRelayer_packet_not_found_safe(void)
+{
+ // Packet not in history — should not crash
+ ph->removeRelayer(0xAA, 999, 0x9999);
+}
+
+// ===========================================================================
+// Group 6 — checkRelayers
+// ===========================================================================
+
+void test_checkRelayers_both_found(void)
+{
+ // We relay first
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+ // Second relayer
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ bool r1 = false, r2 = false;
+ ph->checkRelayers(OUR_RELAY_ID, 0xBB, 100, 0x1111, &r1, &r2);
+ TEST_ASSERT_TRUE(r1);
+ TEST_ASSERT_TRUE(r2);
+}
+
+void test_checkRelayers_one_found(void)
+{
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ bool r1 = false, r2 = false;
+ ph->checkRelayers(OUR_RELAY_ID, 0xCC, 100, 0x1111, &r1, &r2);
+ TEST_ASSERT_TRUE(r1);
+ TEST_ASSERT_FALSE(r2);
+}
+
+void test_checkRelayers_r2WasSole(void)
+{
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ bool r1 = false, r2 = false, r2Sole = false;
+ // relayer1=0xCC (not found), relayer2=OUR_RELAY_ID (sole relayer)
+ ph->checkRelayers(0xCC, OUR_RELAY_ID, 100, 0x1111, &r1, &r2, &r2Sole);
+ TEST_ASSERT_FALSE(r1);
+ TEST_ASSERT_TRUE(r2);
+ TEST_ASSERT_TRUE(r2Sole);
+}
+
+// ===========================================================================
+// Group 7 — wasSeenRecently Merge Logic
+// ===========================================================================
+
+void test_merge_preserves_original_next_hop(void)
+{
+ // First observation with next_hop=0x55
+ auto p1 = makePacket(0x1111, 100, 3, 0x55, 0xAA);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observation with different next_hop
+ auto p2 = makePacket(0x1111, 100, 2, 0x77, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ // The stored next_hop should still be 0x55 (the original)
+ // We verify via weWereNextHop: if we set original next_hop to ourRelayID, it should detect it
+ auto p3 = makePacket(0x1111, 200, 3, OUR_RELAY_ID, 0xAA);
+ ph->wasSeenRecently(&p3);
+ auto p4 = makePacket(0x1111, 200, 2, 0x99, 0xBB);
+ bool weWereNextHop = false;
+ ph->wasSeenRecently(&p4, true, nullptr, &weWereNextHop);
+ TEST_ASSERT_TRUE(weWereNextHop);
+}
+
+void test_merge_preserves_highest_hop_limit(void)
+{
+ // First observation with hop_limit=5
+ auto p1 = makePacket(0x1111, 100, 5);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observation with hop_limit=2 (lower)
+ auto p2 = makePacket(0x1111, 100, 2);
+ ph->wasSeenRecently(&p2);
+
+ // Third observation with hop_limit=3 should not trigger upgrade (highest was 5)
+ bool wasUpgraded = true;
+ auto p3 = makePacket(0x1111, 100, 3);
+ ph->wasSeenRecently(&p3, true, nullptr, nullptr, &wasUpgraded);
+ TEST_ASSERT_FALSE(wasUpgraded);
+}
+
+void test_merge_no_duplicate_relayers(void)
+{
+ // Observe with relayer 0xAA (stored via relay_node, but only slot 0 for ourRelayID)
+ // We need to use ourRelayID for the first observation to get it into slot 0
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observe with same relay_node=ourRelayID — should not create duplicates
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p2);
+
+ // ourRelayID should appear exactly once — wasSole should still be true
+ bool wasSole = false;
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole));
+ TEST_ASSERT_TRUE(wasSole);
+}
+
+void test_merge_adds_new_relayer(void)
+{
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+ TEST_ASSERT_TRUE(ph->wasRelayer(0xBB, 100, 0x1111));
+}
+
+void test_merge_we_relay_sets_slot_zero(void)
+{
+ // When relay_node == ourRelayID, relayed_by[0] should be set to ourRelayID
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+}
+
+void test_merge_heard_back_stores_relay_node(void)
+{
+ // First: we relay (hop_limit=3)
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Second: we hear the packet back with hop_limit=2 (one less), from relay_node=0xCC
+ // This triggers the "heard back" logic: weWereRelayer && hop_limit == ourTxHopLimit-1
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xCC);
+ ph->wasSeenRecently(&p2);
+
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+ TEST_ASSERT_TRUE(ph->wasRelayer(0xCC, 100, 0x1111));
+}
+
+// ===========================================================================
+// Group 8 — Fallback-to-Flooding Detection
+// ===========================================================================
+
+void test_fallback_detected(void)
+{
+ // The fallback condition requires wasRelayer(relay_node) && !wasRelayer(ourRelayID).
+ // Non-us relayers only enter relayed_by[] via the heard-back merge path, which
+ // also stores ourRelayID. So we must removeRelayer(ourRelayID) to satisfy both.
+ //
+ // Scenario: we relay a directed packet, hear it back from 0xAA, then the router
+ // removes us from the relayer list. Later the sender falls back to flooding.
+
+ // Step 1: We relay (directed to next_hop=0x55)
+ auto p1 = makePacket(0x1111, 100, 3, 0x55, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Step 2: Heard-back from 0xAA at hop_limit-1 → stores 0xAA in relayed_by
+ auto p2 = makePacket(0x1111, 100, 2, 0x55, 0xAA);
+ ph->wasSeenRecently(&p2);
+
+ // Step 3: Router removes us from the relayer list
+ ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111);
+
+ // Step 4: Sender falls back to flooding — same packet, NO_NEXT_HOP_PREFERENCE, from 0xAA
+ auto p3 = makePacket(0x1111, 100, 1, NO_NEXT_HOP_PREFERENCE, 0xAA);
+ bool wasFallback = false;
+ ph->wasSeenRecently(&p3, true, &wasFallback);
+ TEST_ASSERT_TRUE(wasFallback);
+}
+
+void test_fallback_not_when_we_relayed(void)
+{
+ // First observation: directed, we relayed it
+ auto p1 = makePacket(0x1111, 100, 3, 0x55, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Second observation: fallback to flooding from same relayer (us)
+ // But since we already relayed, wasFallback should be false
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ bool wasFallback = false;
+ ph->wasSeenRecently(&p2, true, &wasFallback);
+ TEST_ASSERT_FALSE(wasFallback);
+}
+
+void test_fallback_not_on_first_observation(void)
+{
+ // First time seen — can't be a fallback
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, 0xAA);
+ bool wasFallback = false;
+ ph->wasSeenRecently(&p, true, &wasFallback);
+ TEST_ASSERT_FALSE(wasFallback);
+}
+
+// ===========================================================================
+// Group 9 — Next-Hop and Upgrade Detection
+// ===========================================================================
+
+void test_weWereNextHop_true(void)
+{
+ // Packet directed to us (next_hop = ourRelayID)
+ auto p1 = makePacket(0x1111, 100, 3, OUR_RELAY_ID, 0xAA);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observe: check if we were the original next_hop
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ bool weWereNextHop = false;
+ ph->wasSeenRecently(&p2, true, nullptr, &weWereNextHop);
+ TEST_ASSERT_TRUE(weWereNextHop);
+}
+
+void test_weWereNextHop_false(void)
+{
+ // Packet directed to someone else
+ auto p1 = makePacket(0x1111, 100, 3, 0x99, 0xAA);
+ ph->wasSeenRecently(&p1);
+
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ bool weWereNextHop = false;
+ ph->wasSeenRecently(&p2, true, nullptr, &weWereNextHop);
+ TEST_ASSERT_FALSE(weWereNextHop);
+}
+
+void test_wasUpgraded_true(void)
+{
+ // First observation with hop_limit=3 → stored as highestHopLimit bits 0-2 = 3
+ auto p1 = makePacket(0x1111, 100, 3);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observation with hop_limit=5
+ // The upgrade check on line 122 compares the raw packed byte found->hop_limit against p->hop_limit.
+ // found->hop_limit has highestHopLimit=3 in bits 0-2 (and possibly ourTxHopLimit in bits 3-5).
+ // So the packed byte value is 3 (or more if ourTxHopLimit was set), and p->hop_limit is 5.
+ // Since 3 < 5 (with no ourTxHopLimit set), this should detect an upgrade.
+ auto p2 = makePacket(0x1111, 100, 5);
+ bool wasUpgraded = false;
+ ph->wasSeenRecently(&p2, true, nullptr, nullptr, &wasUpgraded);
+ TEST_ASSERT_TRUE(wasUpgraded);
+}
+
+void test_wasUpgraded_false(void)
+{
+ auto p1 = makePacket(0x1111, 100, 5);
+ ph->wasSeenRecently(&p1);
+
+ // Same or lower hop_limit
+ auto p2 = makePacket(0x1111, 100, 3);
+ bool wasUpgraded = false;
+ ph->wasSeenRecently(&p2, true, nullptr, nullptr, &wasUpgraded);
+ TEST_ASSERT_FALSE(wasUpgraded);
+}
+
+// ===========================================================================
+// Group 10 — Edge Cases
+// ===========================================================================
+
+void test_packet_id_zero_not_stored(void)
+{
+ auto p = makePacket(0x1111, 0);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p));
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p)); // still not found
+}
+
+void test_sender_zero_substituted(void)
+{
+ // from=0 means "from us" — getFrom() substitutes nodeDB->getNodeNum()
+ auto p = makePacket(0, 100);
+ ph->wasSeenRecently(&p);
+
+ // Should be stored under our node num, not 0
+ auto p2 = makePacket(OUR_NODE_NUM, 100);
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false));
+}
+
+void test_uninitialized_wasSeenRecently(void)
+{
+ // Simulate uninitialized state — create a PacketHistory that looks uninitialized
+ // We can't easily make allocation fail, but we can test the initOk guard with a destructed one
+ PacketHistory h(4);
+ TEST_ASSERT_TRUE(h.initOk()); // sanity check
+ h.~PacketHistory();
+
+ auto p = makePacket(0x1111, 100);
+ TEST_ASSERT_FALSE(h.wasSeenRecently(&p));
+
+ // Reconstruct in place to allow proper destruction
+ new (&h) PacketHistory(4);
+}
+
+void test_uninitialized_wasRelayer(void)
+{
+ PacketHistory h(4);
+ h.~PacketHistory();
+
+ TEST_ASSERT_FALSE(h.wasRelayer(0xAA, 100, 0x1111));
+
+ new (&h) PacketHistory(4);
+}
+
+void test_multiple_instances_independent(void)
+{
+ PacketHistory h2(SMALL_CAPACITY);
+
+ auto p = makePacket(0x1111, 100);
+ ph->wasSeenRecently(&p);
+
+ // h2 should NOT find it
+ TEST_ASSERT_FALSE(h2.wasSeenRecently(&p, false));
+
+ // ph should still find it
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false));
+}
+
+// ===========================================================================
+// Group 11 — Hash Table Stress
+// ===========================================================================
+
+void test_many_packets_no_false_negatives(void)
+{
+ PacketHistory big(64);
+ for (uint32_t i = 1; i <= 64; i++) {
+ auto p = makePacket(0xAAAA, i);
+ big.wasSeenRecently(&p);
+ }
+ for (uint32_t i = 1; i <= 64; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_TRUE_MESSAGE(big.wasSeenRecently(&p, false), "False negative in hash table");
+ }
+}
+
+void test_many_packets_no_false_positives(void)
+{
+ PacketHistory big(64);
+ for (uint32_t i = 1; i <= 64; i++) {
+ auto p = makePacket(0xAAAA, i);
+ big.wasSeenRecently(&p);
+ }
+ // IDs 65..128 were never inserted
+ for (uint32_t i = 65; i <= 128; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_FALSE_MESSAGE(big.wasSeenRecently(&p, false), "False positive in hash table");
+ }
+}
+
+void test_churn_correctness(void)
+{
+ // Insert 3x capacity to force heavy eviction.
+ // Advance time between each generation so eviction can distinguish old from new.
+ PacketHistory big(32);
+ uint32_t capacity = 32;
+ uint32_t generations = 3;
+
+ for (uint32_t gen = 0; gen < generations; gen++) {
+ if (gen > 0)
+ delay(1); // Ensure new generation has a newer timestamp than the old
+ for (uint32_t i = 1; i <= capacity; i++) {
+ auto p = makePacket(0xAAAA, gen * capacity + i);
+ big.wasSeenRecently(&p);
+ }
+ }
+
+ uint32_t total = capacity * generations;
+
+ // Only the most recent 32 should be present (due to LRU eviction)
+ for (uint32_t i = total - 31; i <= total; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_TRUE_MESSAGE(big.wasSeenRecently(&p, false), "Recent packet lost after churn");
+ }
+ // Older packets should be gone
+ int found = 0;
+ for (uint32_t i = 1; i <= total - capacity; i++) {
+ auto p = makePacket(0xAAAA, i);
+ if (big.wasSeenRecently(&p, false))
+ found++;
+ }
+ TEST_ASSERT_EQUAL_INT_MESSAGE(0, found, "Evicted packets should not be found");
+}
+
+// ===========================================================================
+// Test runner
+// ===========================================================================
+
+void setup()
+{
+ delay(10);
+ delay(2000);
+
+ initializeTestEnvironment();
+ UNITY_BEGIN();
+
+ // Group 1 — Initialization
+ RUN_TEST(test_init_valid_size);
+ RUN_TEST(test_init_minimum_size);
+ RUN_TEST(test_init_too_small_falls_back);
+
+ // Group 2 — Basic Deduplication
+ RUN_TEST(test_first_packet_not_seen);
+ RUN_TEST(test_same_packet_seen_twice);
+ RUN_TEST(test_different_id_not_confused);
+ RUN_TEST(test_different_sender_not_confused);
+ RUN_TEST(test_withUpdate_false_no_insert);
+ RUN_TEST(test_withUpdate_true_inserts);
+
+ // Group 3 — LRU Eviction
+ RUN_TEST(test_fill_capacity_all_found);
+ RUN_TEST(test_eviction_oldest_replaced);
+ RUN_TEST(test_matching_slot_reused);
+ RUN_TEST(test_free_slot_preferred);
+ RUN_TEST(test_evict_all_old_packets);
+
+ // Group 4 — Relayer Tracking
+ RUN_TEST(test_wasRelayer_true);
+ RUN_TEST(test_wasRelayer_false);
+ RUN_TEST(test_wasRelayer_zero_returns_false);
+ RUN_TEST(test_wasRelayer_not_found);
+ RUN_TEST(test_wasRelayer_wasSole_true);
+ RUN_TEST(test_wasRelayer_wasSole_false);
+ RUN_TEST(test_wasRelayer_all_six_slots);
+
+ // Group 5 — removeRelayer
+ RUN_TEST(test_removeRelayer_removes);
+ RUN_TEST(test_removeRelayer_compacts);
+ RUN_TEST(test_removeRelayer_nonexistent_safe);
+ RUN_TEST(test_removeRelayer_packet_not_found_safe);
+
+ // Group 6 — checkRelayers
+ RUN_TEST(test_checkRelayers_both_found);
+ RUN_TEST(test_checkRelayers_one_found);
+ RUN_TEST(test_checkRelayers_r2WasSole);
+
+ // Group 7 — Merge Logic
+ RUN_TEST(test_merge_preserves_original_next_hop);
+ RUN_TEST(test_merge_preserves_highest_hop_limit);
+ RUN_TEST(test_merge_no_duplicate_relayers);
+ RUN_TEST(test_merge_adds_new_relayer);
+ RUN_TEST(test_merge_we_relay_sets_slot_zero);
+ RUN_TEST(test_merge_heard_back_stores_relay_node);
+
+ // Group 8 — Fallback-to-Flooding Detection
+ RUN_TEST(test_fallback_detected);
+ RUN_TEST(test_fallback_not_when_we_relayed);
+ RUN_TEST(test_fallback_not_on_first_observation);
+
+ // Group 9 — Next-Hop and Upgrade Detection
+ RUN_TEST(test_weWereNextHop_true);
+ RUN_TEST(test_weWereNextHop_false);
+ RUN_TEST(test_wasUpgraded_true);
+ RUN_TEST(test_wasUpgraded_false);
+
+ // Group 10 — Edge Cases
+ RUN_TEST(test_packet_id_zero_not_stored);
+ RUN_TEST(test_sender_zero_substituted);
+ RUN_TEST(test_uninitialized_wasSeenRecently);
+ RUN_TEST(test_uninitialized_wasRelayer);
+ RUN_TEST(test_multiple_instances_independent);
+
+ // Group 11 — Hash Table Stress
+ RUN_TEST(test_many_packets_no_false_negatives);
+ RUN_TEST(test_many_packets_no_false_positives);
+ RUN_TEST(test_churn_correctness);
+
+ exit(UNITY_END());
+}
+
+void loop() {}
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/m5stack_coreink/platformio.ini b/variants/esp32/m5stack_coreink/platformio.ini
index e107bd893..70ada7bf3 100644
--- a/variants/esp32/m5stack_coreink/platformio.ini
+++ b/variants/esp32/m5stack_coreink/platformio.ini
@@ -19,7 +19,7 @@ build_flags =
lib_deps =
${esp32_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
# renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib
lewisxhe/SensorLib@0.3.4
lib_ignore =
diff --git a/variants/esp32/tbeam/platformio.ini b/variants/esp32/tbeam/platformio.ini
index 96e9879ce..b1367df87 100644
--- a/variants/esp32/tbeam/platformio.ini
+++ b/variants/esp32/tbeam/platformio.ini
@@ -16,6 +16,7 @@ board_check = true
build_flags = ${esp32_base.build_flags}
-D TBEAM_V10
-I variants/esp32/tbeam
+
upload_speed = 921600
[env:tbeam-displayshield]
diff --git a/variants/esp32c6/tlora_c6/platformio.ini b/variants/esp32c6/tlora_c6/platformio.ini
index 46d17c075..07585e79f 100644
--- a/variants/esp32c6/tlora_c6/platformio.ini
+++ b/variants/esp32c6/tlora_c6/platformio.ini
@@ -8,8 +8,9 @@ build_flags =
-I variants/esp32c6/tlora_c6
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
- ; Disable audio.
+ ; Disable screen + audio.
; ESP32-C6 with 4MB flash is TIGHT on space.
+ -DMESHTASTIC_EXCLUDE_SCREEN=1
-DMESHTASTIC_EXCLUDE_AUDIO=1
lib_ignore =
diff --git a/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini b/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini
index 9448a670c..f43f104d7 100644
--- a/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini
+++ b/variants/esp32s3/diy/my_esp32s3_diy_eink/platformio.ini
@@ -11,7 +11,10 @@ upload_speed = 921600
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
+build_unflags =
+ ${esp32s3_base.build_unflags}
+ -DARDUINO_USB_MODE=1
build_flags =
${esp32s3_base.build_flags}
-D PRIVATE_HW
diff --git a/variants/esp32s3/esp32-s3-pico/platformio.ini b/variants/esp32s3/esp32-s3-pico/platformio.ini
index 64f50f80e..b5ff66b85 100644
--- a/variants/esp32s3/esp32-s3-pico/platformio.ini
+++ b/variants/esp32s3/esp32-s3-pico/platformio.ini
@@ -23,4 +23,4 @@ build_flags = ${esp32s3_base.build_flags}
lib_deps = ${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
diff --git a/variants/esp32s3/hackaday-communicator/platformio.ini b/variants/esp32s3/hackaday-communicator/platformio.ini
index 8fd275c0e..cee393e48 100644
--- a/variants/esp32s3/hackaday-communicator/platformio.ini
+++ b/variants/esp32s3/hackaday-communicator/platformio.ini
@@ -16,5 +16,5 @@ build_flags = ${esp32s3_base.build_flags}
-I variants/esp32s3/hackaday-communicator
lib_deps = ${esp32s3_base.lib_deps}
- # renovate: datasource=git-refs depName=meshtastic-Arduino_GFX packageName=https://github.com/meshtastic/Arduino_GFX gitBranch=master
- https://github.com/meshtastic/Arduino_GFX/archive/054e81ffaf23784830a734e3c184346789349406.zip
\ No newline at end of file
+ # renovate: datasource=git-refs depName=moononournation-Arduino_GFX packageName=https://github.com/moononournation/Arduino_GFX gitBranch=master
+ https://github.com/moononournation/Arduino_GFX/archive/refs/tags/v1.6.5.zip
\ No newline at end of file
diff --git a/variants/esp32s3/heltec_capsule_sensor_v3/variant.h b/variants/esp32s3/heltec_capsule_sensor_v3/variant.h
index 0f71fb9e6..71c5f0743 100644
--- a/variants/esp32s3/heltec_capsule_sensor_v3/variant.h
+++ b/variants/esp32s3/heltec_capsule_sensor_v3/variant.h
@@ -1,6 +1,7 @@
#define LED_POWER 33
#define LED_POWER2 34
#define EXT_PWR_DETECT 35
+#define EXT_PWR_DETECT_MODE INPUT_PULLUP
#define BUTTON_PIN 18
#define BUTTON_ACTIVE_LOW false
diff --git a/variants/esp32s3/heltec_sensor_hub/variant.h b/variants/esp32s3/heltec_sensor_hub/variant.h
index 4bbefa616..64450e0ea 100644
--- a/variants/esp32s3/heltec_sensor_hub/variant.h
+++ b/variants/esp32s3/heltec_sensor_hub/variant.h
@@ -1,4 +1,5 @@
#define EXT_PWR_DETECT 20
+#define EXT_PWR_DETECT_MODE INPUT_PULLUP
#define BUTTON_PIN 17
#define BUTTON_ACTIVE_LOW false
diff --git a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
index 158e704ec..9b01033df 100644
--- a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
+++ b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini
@@ -14,7 +14,7 @@ custom_meshtastic_has_mui = true
extends = esp32s3_base
platform_packages =
; Version needs to match the pioarduino version used in esp32_common.ini
- platformio/framework-arduinoespressif32 @ https://github.com/mverch67/arduino-esp32#60946733b52c30e3d5b58a7158700d65fc1dddae
+ platformio/framework-arduinoespressif32 @ https://github.com/mverch67/arduino-esp32#82aee17619ea2940de03b0db4d082d5def657771
board = seeed-sensecap-indicator
board_check = true
@@ -24,7 +24,7 @@ upload_protocol = esptool
build_flags = ${esp32s3_base.build_flags}
-Ivariants/esp32s3/seeed-sensecap-indicator
-DSENSECAP_INDICATOR
- -DUSE_ARDUINO_HAL_GPIO
+ -DUSE_ARDUINO_HAL_GPIO ; patch for lovyanGFX to use digitalWrite instead of direct register access for GPIO control, which is necessary for the IO expander to work correctly
-DRADIOLIB_DEBUG_SPI=0
-DRADIOLIB_DEBUG_PROTOCOL=0
-DRADIOLIB_DEBUG_BASIC=0
@@ -33,20 +33,48 @@ build_flags = ${esp32s3_base.build_flags}
-DIO_EXPANDER=0x40
-DIO_EXPANDER_IRQ=42
;-DIO_EXPANDER_DEBUG
- ; mverch's hacks:
- -D lcd_periph_signals=lcd_periph_rgb_signals
lib_deps = ${esp32s3_base.lib_deps}
- ; TODO switch back to official LovyanGFX
- https://github.com/mverch67/LovyanGFX/archive/4c76238c1344162a234ae917b27651af146d6fb2.zip
+ https://github.com/mverch67/LovyanGFX/archive/9abe502add013f392a1898d7dc48d65ddc112754.zip
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
+custom_sdkconfig =
+ ${esp32s3_base.custom_sdkconfig}
+ CONFIG_AUTOSTART_ARDUINO=y
+ CONFIG_LOG_DEFAULT_LEVEL_INFO=y
+ CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y
+ CONFIG_SPIRAM_MODE_OCT=y
+; CONFIG_SPIRAM_SPEED_120M=y
+; CONFIG_SPIRAM_SPEED=120
+ CONFIG_SPIRAM_SPEED_80M=y
+ CONFIG_SPIRAM_SPEED=80
+ CONFIG_SPIRAM_XIP_FROM_PSRAM=y
+ CONFIG_LCD_RGB_ISR_IRAM_SAFE=y
+ CONFIG_GDMA_CTRL_FUNC_IN_IRAM=y
+ CONFIG_I2S_ISR_IRAM_SAFE=y
+ CONFIG_GDMA_ISR_IRAM_SAFE=y
+ CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
+ CONFIG_ESP32S3_DATA_CACHE_64KB=y
+ CONFIG_ESP32S3_DATA_CACHE_LINE_64B=y
+ CONFIG_I2C_SKIP_LEGACY_CONFLICT_CHECK=y
+ CONFIG_HAL_DEFAULT_ASSERTION_LEVEL=0
+ CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
+ CONFIG_ESPTOOLPY_FLASHSIZE="8MB"
+; CONFIG_ESPTOOLPY_FLASHFREQ_120M=y
+; CONFIG_ESPTOOLPY_FLASHFREQ="120m"
+ '# CONFIG_ESPTOOLPY_FLASHFREQ_120M is not set'
+ CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
+ CONFIG_ESPTOOLPY_FLASHFREQ="80m"
+ CONFIG_COMPILER_OPTIMIZATION_PERF=y
+ CONFIG_BOOTLOADER_LOG_LEVEL_NONE=n
+ CONFIG_BOOTLOADER_LOG_LEVEL_INFO=y
+
[env:seeed-sensecap-indicator-tft]
extends = env:seeed-sensecap-indicator
-board_level = pr
+board_check = true
upload_speed = 460800
build_flags =
@@ -79,5 +107,6 @@ build_flags =
lib_deps =
${env:seeed-sensecap-indicator.lib_deps}
${device-ui_base.lib_deps}
- ; TODO switch back to official bb_captouch
+ ;# renovate: datasource=github-tags depName=bb_captouch packageName=bitbank2/bb_captouch
+ ;https://github.com/bitbank2/bb_captouch/archive/refs/tags/1.3.1.zip ; not working
https://github.com/mverch67/bb_captouch/archive/8626412fe650d808a267791c0eae6e5860c85a5d.zip ; alternative touch library supporting FT6x36
diff --git a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini
index 1a9b20f76..22432d769 100644
--- a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini
+++ b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini
@@ -30,7 +30,7 @@ build_flags =
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
# renovate: datasource=git-refs depName=CSE_Touch packageName=https://github.com/CIRCUITSTATE/CSE_Touch gitBranch=main
https://github.com/CIRCUITSTATE/CSE_Touch/archive/b44f23b6f870b848f1fbe453c190879bc6cfaafa.zip
# renovate: datasource=github-tags depName=CSE_CST328 packageName=CIRCUITSTATE/CSE_CST328
diff --git a/variants/esp32s3/t-deck-pro/platformio.ini b/variants/esp32s3/t-deck-pro/platformio.ini
index 93ef8babf..d1a2398a4 100644
--- a/variants/esp32s3/t-deck-pro/platformio.ini
+++ b/variants/esp32s3/t-deck-pro/platformio.ini
@@ -34,7 +34,7 @@ build_flags =
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
# renovate: datasource=git-refs depName=CSE_Touch packageName=https://github.com/CIRCUITSTATE/CSE_Touch gitBranch=main
https://github.com/CIRCUITSTATE/CSE_Touch/archive/b44f23b6f870b848f1fbe453c190879bc6cfaafa.zip
# renovate: datasource=github-tags depName=CSE_CST328 packageName=CIRCUITSTATE/CSE_CST328
diff --git a/variants/esp32s3/t-watch-s3/variant.h b/variants/esp32s3/t-watch-s3/variant.h
index 507d6b7dc..aca491a6d 100644
--- a/variants/esp32s3/t-watch-s3/variant.h
+++ b/variants/esp32s3/t-watch-s3/variant.h
@@ -60,6 +60,8 @@
#define BUTTON_PIN 0 // only for Plus version
+#define PMU_IRQ 21 // Interrupt pin for the PMU
+
#define USE_SX1262
#define USE_SX1268
diff --git a/variants/esp32s3/t5s3_epaper/nicheGraphics.h b/variants/esp32s3/t5s3_epaper/nicheGraphics.h
index 18217800b..6b5f434ba 100644
--- a/variants/esp32s3/t5s3_epaper/nicheGraphics.h
+++ b/variants/esp32s3/t5s3_epaper/nicheGraphics.h
@@ -34,7 +34,6 @@ This is driven via the FastEPD library through the NicheGraphics ED047TC1 driver
// Shared NicheGraphics components
// --------------------------------
-#include "graphics/niche/Drivers/Backlight/LatchingBacklight.h"
#include "graphics/niche/Drivers/EInk/ED047TC1.h"
#include "graphics/niche/Inputs/TwoButton.h"
@@ -72,7 +71,7 @@ void setupNicheGraphics()
inkhud->persistence->settings.rotation = 3; // 270 degrees clockwise
inkhud->persistence->settings.userTiles.count = 1; // One tile only by default, keep things simple for new users
inkhud->persistence->settings.optionalFeatures.batteryIcon = true;
- inkhud->persistence->settings.optionalMenuItems.backlight = true;
+ inkhud->persistence->settings.optionalMenuItems.backlight = false;
// Alignment must cancel rotation for visual-frame touch input: (rotation + alignment) % 4 == 0.
inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4;
@@ -88,30 +87,32 @@ void setupNicheGraphics()
inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0
inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // Not Active, not autoshown
- // Backlight
- // ----------------------------
- Drivers::LatchingBacklight *backlight = Drivers::LatchingBacklight::getInstance();
- backlight->setPin(BOARD_BL_EN); // GPIO11 on V2
+ // Enable reusable InkHUD touch status indicator for this touch-capable board.
+ inkhud->setTouchEnabledProvider(isTouchInputEnabled);
// Start running InkHUD
inkhud->begin();
+ // Arm GT911 capacitive-home callback only after InkHUD startup is complete.
+ t5SetHomeCapButtonEventsEnabled(true);
- // Touch navigation requires joystick mode — enforce post-begin so flash cannot override.
- inkhud->persistence->settings.joystick.enabled = true;
- inkhud->persistence->settings.joystick.aligned = true;
+ // Keep Wireless Paper single-button semantics regardless of persisted settings:
+ // short press advances, long press opens menu/selects.
+ inkhud->persistence->settings.joystick.enabled = false;
// Buttons
// --------------------------
Inputs::TwoButton *buttons = Inputs::TwoButton::getInstance(); // A shared NicheGraphics component
- // Setup the main user button (boot button, GPIO 0)
+ // #0: BOOT button (primary user input for InkHUD navigation on T5-S3)
+#if defined(T5_S3_EPAPER_PRO_V1)
+ buttons->setWiring(0, PIN_BUTTON2);
+#else
buttons->setWiring(0, BUTTON_PIN);
+#endif
buttons->setHandlerShortPress(0, [inkhud]() { inkhud->shortpress(); });
buttons->setHandlerLongPress(0, [inkhud]() { inkhud->longpress(); });
- // No dedicated aux button on this board
-
buttons->start();
}
diff --git a/variants/esp32s3/t5s3_epaper/variant.cpp b/variants/esp32s3/t5s3_epaper/variant.cpp
index 6cae0e5c0..6599ced23 100644
--- a/variants/esp32s3/t5s3_epaper/variant.cpp
+++ b/variants/esp32s3/t5s3_epaper/variant.cpp
@@ -1,3 +1,6 @@
+// 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"
@@ -9,6 +12,9 @@ void earlyInitVariant()
pinMode(SDCARD_CS, OUTPUT);
digitalWrite(SDCARD_CS, HIGH);
pinMode(BOARD_BL_EN, OUTPUT);
+ // 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
@@ -31,9 +37,3 @@ void earlyInitVariant()
delay(10); // > 5 ms startup
pinMode(GT911_PIN_INT, INPUT); // release INT for interrupt use
}
-
-void variant_shutdown()
-{
- // Ensure frontlight is off during deep sleep
- digitalWrite(BOARD_BL_EN, LOW);
-}
diff --git a/variants/esp32s3/t5s3_epaper/variant.h b/variants/esp32s3/t5s3_epaper/variant.h
index 2459af967..fdf8589da 100644
--- a/variants/esp32s3/t5s3_epaper/variant.h
+++ b/variants/esp32s3/t5s3_epaper/variant.h
@@ -17,6 +17,11 @@
#define I2C_SCL SCL
#define HAS_TOUCHSCREEN 1
+#define TOUCH_POLL_INTERVAL_IDLE 25
+#define TOUCH_POLL_INTERVAL_ACTIVE 15
+#define TOUCH_POLL_INTERVAL_RELEASE 20
+#define TOUCH_POLL_INTERVAL_ACTIVE_FAST 8
+#define TOUCH_POLL_INTERVAL_RELEASE_FAST 8
#define GT911_PIN_SDA SDA
#define GT911_PIN_SCL SCL
#if defined(T5_S3_EPAPER_PRO_V1)
@@ -26,6 +31,30 @@
#define GT911_PIN_INT 3
#define GT911_PIN_RST 9
#endif
+// Do not use touch as a light-sleep wake source on T5-S3.
+// Wake should come from physical buttons/radio/timer only.
+#define SCREEN_TOUCH_INT GT911_PIN_INT
+
+// Touch control helpers for this variant
+bool isTouchInputEnabled();
+void setTouchInputEnabled(bool enabled, bool showIndicator);
+void toggleTouchInputEnabled();
+
+// Backlight control helpers for this variant (non-latching behavior)
+void t5BacklightSetUserEnabled(bool enabled);
+bool t5BacklightIsUserEnabled();
+void t5BacklightToggleUser();
+void t5BacklightSetForcedByTimeout(bool forced);
+void t5BacklightSetForcedBySleep(bool forced);
+void t5BacklightHandleUserInput();
+
+// Touch timeout/wake helpers for this variant
+void t5TouchSetForcedByTimeout(bool forced);
+bool t5TouchIsForcedByTimeout();
+void t5TouchHandleUserInput();
+
+// Gate GT911 capacitive-home callback delivery until InkHUD startup is complete.
+void t5SetHomeCapButtonEventsEnabled(bool enabled);
#define PCF8563_RTC 0x51
#define HAS_RTC 1
@@ -46,8 +75,10 @@
#define ALT_BUTTON_PIN PIN_BUTTON2
#else
#define BUTTON_PIN 0
+#define BOARD_PCA9535_ADDR 0x20
+#define BOARD_PCA9535_INT 38
+#define BOARD_PCA9535_BUTTON_MASK 0x04
#endif
-
// SD card
#define HAS_SDCARD
#define SDCARD_CS SPI_CS
diff --git a/variants/esp32s3/tbeam-s3-core/variant.h b/variants/esp32s3/tbeam-s3-core/variant.h
index 9ce4aade9..2637e7f78 100644
--- a/variants/esp32s3/tbeam-s3-core/variant.h
+++ b/variants/esp32s3/tbeam-s3-core/variant.h
@@ -9,8 +9,6 @@
#define BUTTON_PIN 0 // The middle button GPIO on the T-Beam S3
// #define EXT_NOTIFY_OUT 13 // Default pin to use for Ext Notify Module.
-#define LED_STATE_ON 0 // State when LED is lit
-
// TTGO uses a common pinout for their SX1262 vs RF95 modules - both can be enabled and we will probe at runtime for RF95 and if
// not found then probe for SX1262
#define USE_SX1262
@@ -76,4 +74,4 @@
// has 32768 Hz crystal
#define HAS_32768HZ 1
-#define USE_SH1106
\ No newline at end of file
+#define USE_SH1106
diff --git a/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini b/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini
index fd159a6d2..e0cdd73c5 100644
--- a/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini
+++ b/variants/nrf52840/Dongle_nRF52840-pca10059-v1/platformio.ini
@@ -12,5 +12,5 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/Dongle_
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
debug_tool = jlink
diff --git a/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h b/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h
index 2cfe948e3..2164bcedc 100644
--- a/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h
+++ b/variants/nrf52840/ELECROW-ThinkNode-M4/variant.h
@@ -85,7 +85,6 @@ static const uint8_t A0 = PIN_A0;
// charger status
#define EXT_CHRG_DETECT (32 + 6)
-#define EXT_CHRG_DETECT_VALUE HIGH
// SPI
#define SPI_INTERFACES_COUNT 1
diff --git a/variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp b/variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp
index a43755c06..f15a03f4d 100644
--- a/variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp
+++ b/variants/nrf52840/ELECROW-ThinkNode-M6/variant.cpp
@@ -20,6 +20,7 @@
#include "variant.h"
#include "nrf.h"
+#include "power.h"
#include "wiring_constants.h"
#include "wiring_digital.h"
@@ -65,7 +66,11 @@ void variant_shutdown()
nrf_gpio_pin_sense_t sense1 = NRF_GPIO_PIN_SENSE_LOW;
nrf_gpio_cfg_sense_set(PIN_BUTTON1, sense1);
- nrf_gpio_cfg_input(EXT_CHRG_DETECT, NRF_GPIO_PIN_PULLUP); // Configure the pin to be woken up as an input
- nrf_gpio_pin_sense_t sense2 = NRF_GPIO_PIN_SENSE_LOW;
- nrf_gpio_cfg_sense_set(EXT_CHRG_DETECT, sense2);
+ // If we are sleeping because of low battery, wake up when the solar charger detects power.
+ // But if the user intentionally put us to sleep with the button, don't wake up just because the lights are on
+ if (power->isLowBattery()) {
+ nrf_gpio_cfg_input(EXT_CHRG_DETECT, NRF_GPIO_PIN_PULLUP); // Configure the pin to be woken up as an input
+ nrf_gpio_pin_sense_t sense2 = NRF_GPIO_PIN_SENSE_LOW;
+ nrf_gpio_cfg_sense_set(EXT_CHRG_DETECT, sense2);
+ }
}
diff --git a/variants/nrf52840/ELECROW-ThinkNode-M6/variant.h b/variants/nrf52840/ELECROW-ThinkNode-M6/variant.h
index 2ebb79031..48b27c669 100644
--- a/variants/nrf52840/ELECROW-ThinkNode-M6/variant.h
+++ b/variants/nrf52840/ELECROW-ThinkNode-M6/variant.h
@@ -138,7 +138,7 @@ static const uint8_t A0 = PIN_A0;
#define HAS_SOLAR
-#define OCV_ARRAY 4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3450
+#define OCV_ARRAY 4080, 3990, 3935, 3880, 3825, 3770, 3715, 3660, 3605, 3550, 3490
#ifdef __cplusplus
}
diff --git a/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini b/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini
index 39b5dfbd4..217e0dd3a 100644
--- a/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini
+++ b/variants/nrf52840/ME25LS01-4Y10TD_e-ink/platformio.ini
@@ -16,7 +16,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ME25LS0
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm)
upload_protocol = nrfutil
;upload_port = /dev/ttyACM1
diff --git a/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini b/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini
index ebea1ce97..d89ef348d 100644
--- a/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini
+++ b/variants/nrf52840/MakePython_nRF52840_eink/platformio.ini
@@ -15,6 +15,6 @@ lib_deps =
# renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master
https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
debug_tool = jlink
;upload_port = /dev/ttyACM4
\ No newline at end of file
diff --git a/variants/nrf52840/TWC_mesh_v4/platformio.ini b/variants/nrf52840/TWC_mesh_v4/platformio.ini
index c529caa0b..8f7479f74 100644
--- a/variants/nrf52840/TWC_mesh_v4/platformio.ini
+++ b/variants/nrf52840/TWC_mesh_v4/platformio.ini
@@ -9,5 +9,5 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/TWC_mes
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
debug_tool = jlink
diff --git a/variants/nrf52840/rak4631_epaper/platformio.ini b/variants/nrf52840/rak4631_epaper/platformio.ini
index f71fb6301..c970baddd 100644
--- a/variants/nrf52840/rak4631_epaper/platformio.ini
+++ b/variants/nrf52840/rak4631_epaper/platformio.ini
@@ -16,7 +16,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
# renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028
melopero/Melopero RV3028@1.2.0
# renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library
diff --git a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini
index 670b2c415..af57ea3cd 100644
--- a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini
+++ b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini
@@ -18,7 +18,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
- zinggjm/GxEPD2@1.6.8
+ zinggjm/GxEPD2@1.6.9
# renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028
melopero/Melopero RV3028@1.2.0
# renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library