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