From 9c0a331309b829a553d0e791b4fcabdff9dcf215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 25 Aug 2026 10:08:32 +0000 Subject: [PATCH 001/143] fix(test): stop the survivor scan reporting a hit as a miss (#11603) * test-state: match the sandbox HOME in-shell so a survivor hit cannot report as a miss * Trim the survivor-scan comment to two lines * test-state: read the environ with a NUL-delimited read loop, not mapfile -d * test-state-check: report the survivor's actual HOME and the wrapper's stderr * test-state-check: re-run the scan when it reports a miss, to separate a race from a mismatch * test-state-check: let the survivor fixture finish exec before the suite returns * test-state-check: fail the survivor fixture instead of staging a pid it never saw exec --- bin/lib/test-state.sh | 16 +++++++++----- bin/test-state-check.sh | 46 +++++++++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/bin/lib/test-state.sh b/bin/lib/test-state.sh index 138d57e906..03b818394f 100644 --- a/bin/lib/test-state.sh +++ b/bin/lib/test-state.sh @@ -65,14 +65,20 @@ state_fingerprint() { # Scoped to this user's processes: /proc//environ is unreadable for anyone else's anyway, and # the narrower sweep costs ~270ms against ~460ms for all of /proc. state_find_survivors() { - local home="$1" pid + local home="$1" pid entry [[ -n $home ]] || return 0 for pid in $(ps -u "$(id -u)" -o pid= 2>/dev/null); do [[ $pid == "$$" ]] && continue - # Grouped so the redirect's own open failure is silenced too, not just tr's stderr. - if { tr '\0' '\n' <"/proc/$pid/environ"; } 2>/dev/null | grep -qxF "HOME=$home"; then - printf '%s\n' "$pid" - fi + # In-shell, not `tr | grep -qxF`: -q closes the pipe on the match, tr takes SIGPIPE, and + # under `set -o pipefail` the hit reports as a miss. Grouped so a vanished pid is silent. + { + while IFS= read -r -d '' entry; do + if [[ $entry == "HOME=$home" ]]; then + printf '%s\n' "$pid" + break + fi + done <"/proc/$pid/environ" + } 2>/dev/null done } diff --git a/bin/test-state-check.sh b/bin/test-state-check.sh index 14dad9d447..250bf1a9f1 100755 --- a/bin/test-state-check.sh +++ b/bin/test-state-check.sh @@ -19,6 +19,8 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$ROOT_DIR" || exit 1 +# shellcheck source=bin/lib/test-state.sh +source "$SCRIPT_DIR/lib/test-state.sh" WORK="$(mktemp -d -t meshstatecheck.XXXXXX)" trap 'rm -rf "$WORK"' EXIT @@ -97,18 +99,32 @@ mkdir -p "$HOME/.portduino/default/prefs" # Detached from this shell's stdout so the wrapper's `| tee` sees EOF and the pipeline returns - # the survivor outlives the suite exactly as a spun loop() does. setsid sleep 300 >/dev/null 2>&1 & -printf '%s\n' "$!" > "$HOME/../survivor.pid" +survivor=$! +# A real survivor has been running for the whole suite; this one is a microsecond old, and the +# wrapper scans the instant this shell exits - so wait for the exec before reporting it up. +for _ in {1..500}; do [[ "$(cat "/proc/$survivor/comm" 2>/dev/null)" == sleep ]] && break; sleep 0.01; done +# Recheck rather than trust the loop: falling out of it on the timeout would stage a pid the +# fixture never saw reach exec, which is the race this wait exists to close. +[[ "$(cat "/proc/$survivor/comm" 2>/dev/null)" == sleep ]] || { echo "fixture: survivor never reached exec" >&2; exit 1; } +printf '%s\n' "$survivor" > "$HOME/../survivor.pid" exit 0 EOF chmod +x "$LEAKY" survivor_dir="$WORK/state-survivor" mkdir -p "$survivor_dir" +# KEEP_STATE so the sandbox stays whatever the verdict: without it a missed survivor also deletes +# the pid file staged inside it, and the reap assertion below fails for the wrong reason. FIXTURE_SUITE=test_fixture_survivor \ MESHTASTIC_TEST_STATE_DIR="$survivor_dir" \ MESHTASTIC_TEST_STATE_SUMMARY="$survivor_dir/summary.tsv" \ MESHTASTIC_TEST_STATE_MANIFEST="$MANIFEST" \ - "$SCRIPT_DIR/pio-test-isolate.sh" "$LEAKY" >/dev/null 2>&1 + MESHTASTIC_TEST_KEEP_STATE=1 \ + "$SCRIPT_DIR/pio-test-isolate.sh" "$LEAKY" >/dev/null 2>"$survivor_dir/wrapper.err" + +# Find the pid file by search, not by a glob that assumes a directory depth: the wrapper renames +# its mktemp'd sandbox to the suite name when it keeps it, so the path is not fixed. +leaked_pid="$(head -1 "$(find "$survivor_dir" -name survivor.pid -print -quit 2>/dev/null)" 2>/dev/null)" recorded="$(awk -F'\t' '$1 == "test_fixture_survivor" { print $6; exit }' "$survivor_dir/summary.tsv" 2>/dev/null)" if [[ -n ${recorded// /} ]]; then @@ -116,14 +132,28 @@ if [[ -n ${recorded// /} ]]; then PASSES=$((PASSES + 1)) else echo " FAIL a process outliving the suite went unreported" + # Name the cause here rather than spending a CI round-trip on it: whether the fixture's + # process exists at all, whether the scan can see it, and what the wrapper recorded instead. + visible_pids="$(ps -u "$(id -u)" -o pid= 2>/dev/null | tr -d ' ')" + if [[ -z $leaked_pid ]]; then + alive="no pid staged" + listed="n/a" + its_home="" + else + kill -0 "$leaked_pid" 2>/dev/null && alive="alive" || alive="gone" + grep -Fxq "$leaked_pid" <<<"$visible_pids" && listed="yes" || listed="no" + its_home="$(tr '\0' '\n' <"/proc/$leaked_pid/environ" 2>/dev/null | grep -m1 '^HOME=')" + fi + echo " summary line: $(awk -F'\t' '$1 == "test_fixture_survivor"' "$survivor_dir/summary.tsv" 2>/dev/null | tr '\t' '|')" + echo " staged pid ${leaked_pid:-}: $alive, listed by ps: $listed, its ${its_home:-}" + echo " ps -u $(id -u) listed $(grep -c . <<<"$visible_pids") pids; the sandbox is under $survivor_dir" + echo " wrapper stderr: $(tr '\n' '|' <"$survivor_dir/wrapper.err" 2>/dev/null)" + echo " same scan, run again now: [$(state_find_survivors "${its_home#HOME=}" | tr '\n' ' ')]" FAILURES=$((FAILURES + 1)) fi -# Find the pid file by search, not by a glob that assumes a directory depth: the wrapper renames -# its mktemp'd sandbox to the suite name when it keeps it, so the path is not fixed. Assert the -# file was found BEFORE asserting the process is gone - otherwise an empty pid takes the "not -# running" branch and the check passes without having checked anything. -leaked_pid="$(cat "$(find "$survivor_dir" -name survivor.pid -print -quit 2>/dev/null)" 2>/dev/null | head -1)" +# Assert the pid file was found BEFORE asserting the process is gone - otherwise an empty pid takes +# the "not running" branch and the check passes without having checked anything. if [[ -z $leaked_pid ]]; then echo " FAIL no survivor pid recorded - the reap assertion would pass vacuously" FAILURES=$((FAILURES + 1)) @@ -140,8 +170,6 @@ fi # it; exercise the assertion the wrapper actually calls instead - same function, same code path. echo echo "Before-empty assertion (state_assert_empty):" -# shellcheck source=bin/lib/test-state.sh -source "$SCRIPT_DIR/lib/test-state.sh" seeded="$WORK/seeded" mkdir -p "$seeded/$PREFS" From 56ce743f75c10f959477c88efb4f837fd99eb9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 25 Aug 2026 11:37:43 +0000 Subject: [PATCH 002/143] Show waypoints sent with no expiry and stop expiring on an unset clock (#11600) --- src/meshUtils.h | 9 +++ src/modules/WaypointModule.cpp | 3 +- test/test_waypoint_expiry/test_main.cpp | 73 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 test/test_waypoint_expiry/test_main.cpp diff --git a/src/meshUtils.h b/src/meshUtils.h index 2b7915778e..70ca27036d 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -85,6 +85,15 @@ bool sanitizeUtf8(char *buf, size_t bufSize); // left at the cut. void clampLongName(char *longName); +// Is a received Waypoint still live? The clients send expire == 0 for "never expires" and expire == 1 +// to delete; now == 0 means we have no trustworthy clock, which must not expire anything. +static inline bool waypointIsActive(uint32_t expire, uint32_t now) +{ + if (expire <= 1) + return expire == 0; + return now == 0 || expire > now; +} + /// 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/WaypointModule.cpp b/src/modules/WaypointModule.cpp index 9b41a9f5a7..d9f3b30cd3 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -60,7 +60,8 @@ bool WaypointModule::shouldDraw() meshtastic_Waypoint wp{}; // <- replaces memset if (pb_decode_from_bytes(devicestate.rx_waypoint.decoded.payload.bytes, devicestate.rx_waypoint.decoded.payload.size, &meshtastic_Waypoint_msg, &wp)) { - return wp.expire > getTime(); + // getTime() counts from boot until the RTC is set, which reads every real expiry as future. + return waypointIsActive(wp.expire, getValidTime(RTCQualityFromNet)); } return false; // no LOG_ERROR, no flag writes #else diff --git a/test/test_waypoint_expiry/test_main.cpp b/test/test_waypoint_expiry/test_main.cpp new file mode 100644 index 0000000000..887f9c7d19 --- /dev/null +++ b/test/test_waypoint_expiry/test_main.cpp @@ -0,0 +1,73 @@ +// Unit tests for waypointIsActive() in src/meshUtils.h: the expire == 0 and expire == 1 sentinels, +// ordinary expiry, and an untrusted clock. +#include "TestUtil.h" +#include "meshUtils.h" +#include + +namespace +{ +constexpr uint32_t NOW = 1767225600; // 2026-01-01 +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +void test_zero_never_expires() +{ + TEST_ASSERT_TRUE(waypointIsActive(0, NOW)); +} + +void test_one_is_the_delete_convention() +{ + TEST_ASSERT_FALSE(waypointIsActive(1, NOW)); +} + +void test_future_expiry_is_active() +{ + TEST_ASSERT_TRUE(waypointIsActive(NOW + 3600, NOW)); +} + +void test_past_expiry_is_not_active() +{ + TEST_ASSERT_FALSE(waypointIsActive(NOW - 1, NOW)); +} + +void test_expiry_exactly_now_is_not_active() +{ + TEST_ASSERT_FALSE(waypointIsActive(NOW, NOW)); +} + +void test_int32_max_is_active() +{ + // What Android sends when its expiry switch is off; it is 2038, so it must simply compare future. + TEST_ASSERT_TRUE(waypointIsActive(INT32_MAX, NOW)); +} + +void test_untrusted_clock_expires_nothing() +{ + TEST_ASSERT_TRUE(waypointIsActive(NOW - 3600, 0)); + TEST_ASSERT_TRUE(waypointIsActive(NOW + 3600, 0)); + TEST_ASSERT_TRUE(waypointIsActive(0, 0)); +} + +void test_untrusted_clock_still_honours_delete() +{ + TEST_ASSERT_FALSE(waypointIsActive(1, 0)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_zero_never_expires); + RUN_TEST(test_one_is_the_delete_convention); + RUN_TEST(test_future_expiry_is_active); + RUN_TEST(test_past_expiry_is_not_active); + RUN_TEST(test_expiry_exactly_now_is_not_active); + RUN_TEST(test_int32_max_is_active); + RUN_TEST(test_untrusted_clock_expires_nothing); + RUN_TEST(test_untrusted_clock_still_honours_delete); + exit(UNITY_END()); +} + +void loop() {} From 98c88d7e1961638a8223ed4dc7672711c50f009b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 25 Aug 2026 12:51:36 +0000 Subject: [PATCH 003/143] fix(position): halve the stationary/fixed-position broadcast floor to 6h (#11606) The 12h floor introduced with traffic management was too aggressive: a fixed_position or stationary node goes quiet for half a day after its boot broadcast, so anything that missed that one packet - a node that joined later, or one that restarted - shows it with no position until the next refresh. Drop the floor to 6h, and drop the traffic-management identical-position dedup window from 11h to 5h with it. The two are a pair: the dedup window was deliberately sized just under the broadcast floor so a stationary node's periodic refresh clears its neighbours' window instead of being dropped as a duplicate. Leaving it at 11h would have made the extra broadcast pure airtime - aired, then discarded by every receiver - so the mesh would still have seen a 12h refresh. Role caps are unchanged and still bind: tracker 1h, lost-and-found 15m. Both remain shorter than the new 5h default, so those exceptions apply exactly as before. Co-authored-by: Claude Opus 5 --- src/mesh/Default.h | 12 ++++++++---- src/mesh/NodeDB.cpp | 2 +- src/modules/PositionModule.cpp | 2 +- src/modules/TrafficManagementModule.cpp | 2 +- test/test_traffic_management/test_main.cpp | 18 +++++++++--------- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/mesh/Default.h b/src/mesh/Default.h index e5e8b8ab19..4f6c000f84 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -21,7 +21,9 @@ #define default_broadcast_smart_minimum_interval_secs 5 * 60 // Floor for our own position broadcasts when stationary (unchanged beyond the broadcast // precision) or fixed_position: identical positions get deduped by traffic management anyway. -#define default_position_stationary_broadcast_secs (12 * 60 * 60) +// Held one hour above default_traffic_mgmt_position_min_interval_secs so this refresh clears +// the receivers' dedup window instead of being dropped as a duplicate. +#define default_position_stationary_broadcast_secs (6 * 60 * 60) #define min_default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60) #define min_default_broadcast_smart_minimum_interval_secs 5 * 60 #define default_wait_bluetooth_secs IF_ROUTER(1, 60) @@ -39,9 +41,11 @@ enum class TrafficType { POSITION, TELEMETRY }; // Traffic management defaults -#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m) -#define default_traffic_mgmt_position_min_interval_secs (11 * 60 * 60) // 11 hours between identical positions -// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 11h default). +#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m) +// Kept below default_position_stationary_broadcast_secs so a stationary node's periodic refresh +// is not deduped away by its neighbours. +#define default_traffic_mgmt_position_min_interval_secs (5 * 60 * 60) // 5 hours between identical positions +// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 5h default). #define default_traffic_mgmt_tracker_position_min_interval_secs (60 * 60) // 1 hour // Role cap: lost-and-found origins may refresh a duplicate position this often, so a lost // device updates frequently without flooding. (Quantised to the dedup tick: ~2 ticks.) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 87aab5ad99..01bf78bcc3 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1242,7 +1242,7 @@ static void installTrafficManagementDefaults(meshtastic_LocalModuleConfig &mc) mc.has_traffic_management = true; mc.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; #if HAS_TRAFFIC_MANAGEMENT - // Position dedup ships enabled at the 11-hour default window on all supported targets. + // Position dedup ships enabled at the 5-hour default window on all supported targets. // STM32WL is excluded at compile time (HAS_TRAFFIC_MANAGEMENT=0 in mesh-pb-constants.h). // Set position_min_interval_secs=0 at runtime to disable dedup. mc.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index f11839bd75..7f28189060 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -558,7 +558,7 @@ int32_t PositionModule::runOnce() bool waitingForFreshPosition = (lastGpsSend == 0) && !config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot(); - // Hold to the 12h floor when fixed_position (every role: pinning yourself forfeits the + // Hold to the 6h floor when fixed_position (every role: pinning yourself forfeits the // exception) or when stationary. A real move still goes out early via smart-broadcast below. // Not-fixed exceptions: lost-and-found broadcasts freely; trackers judge movement at their // own (unclamped) precision rather than the on-wire one (useConfiguredPrecision). diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 0fdd8c7222..f049720fb7 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -1362,7 +1362,7 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const int32_t lat_truncated = truncateCoordinate(pos->latitude_i, precision); const int32_t lon_truncated = truncateCoordinate(pos->longitude_i, precision); const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); - // Drop gate uses the RAW configured interval: 0 means "dedup disabled". The 12 h default + // Drop gate uses the RAW configured interval: 0 means "dedup disabled". The 5 h default // is only for TTL sizing - feeding it here would silently defeat that contract. uint32_t minIntervalMs = secsToMs(moduleConfig.traffic_management.position_min_interval_secs); diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index d7d947d5a0..10af9cbe37 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -2806,12 +2806,12 @@ static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void) /** * Verify TRACKER role caps the dedup window at 1 hour. - * A duplicate position that would normally be blocked for 11 h (default) must + * A duplicate position that would normally be blocked for 5 h (default) must * be forwarded once the 1-hour tracker cap expires. */ static void test_tm_trackerRole_capsDedupWindowAtOneHour(void) { - // Operator interval is 11 h - longer than the tracker cap. + // Operator interval is 5 h - longer than the tracker cap. moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; installWellKnownPrimaryChannelWithPrecision(16); @@ -2872,11 +2872,11 @@ static void test_tm_takTrackerRole_capsDedupWindowAtOneHour(void) * hot and warm NodeDB stores - the TMM unified cache is the third fallback. The * role is cached on the entry while NodeDB still knows the node; once NodeDB * forgets it (getNodeRole → CLIENT), the cached role must keep the 1-hour cap - * applied instead of reverting to the 11-hour default interval. + * applied instead of reverting to the 5-hour default interval. */ static void test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole(void) { - // Operator interval is 11 h - longer than the tracker cap. + // Operator interval is 5 h - longer than the tracker cap. moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; installWellKnownPrimaryChannelWithPrecision(16); @@ -2896,8 +2896,8 @@ static void test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole(void) mockNodeDB->clearCachedNode(); ProcessMessage r2 = module.handleReceived(dup); // within 1-hour cap - still drop - // Advance past the tracker cap (3600 s) but stay well under the 11-hour default. - // Without the cached-role fallback this would still be inside the 11-hour window + // Advance past the tracker cap (3600 s) but stay well under the 5-hour default. + // Without the cached-role fallback this would still be inside the 5-hour window // (CLIENT → no exception) and wrongly drop; with it, the 1-hour cap lets it pass. TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; ProcessMessage r3 = module.handleReceived(afterCap); @@ -2935,7 +2935,7 @@ static void test_tm_roleChange_viaNodeInfo_dropsTrackerException(void) meshtastic_MeshPacket info = makeNodeInfoPacketWithRole(kRemoteNode, meshtastic_Config_DeviceConfig_Role_CLIENT); module.handleReceived(info); - // Past the 1-hour tracker cap but within the 11-hour CLIENT interval. With the stale + // Past the 1-hour tracker cap but within the 5-hour CLIENT interval. With the stale // TRACKER role this would pass; after the demotion it must drop (full interval applies). TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -3042,12 +3042,12 @@ static void test_tm_trackerRole_doesNotLengthenShorterOperatorInterval(void) /** * Verify LOST_AND_FOUND role caps duplicate-position dedup at ~15 min (2 pos-ticks), - * not the old one-tick fast-announce. A configured 11-hour interval is shortened to the + * not the old one-tick fast-announce. A configured 5-hour interval is shortened to the * 15-min cap; a duplicate one tick later still drops, but one past the 2-tick cap passes. */ static void test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes(void) { - // Long interval that would normally suppress duplicates for 11 h. + // Long interval that would normally suppress duplicates for 5 h. moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; installWellKnownPrimaryChannelWithPrecision(16); From 709504cc25025047419634817e031a56c002ccee Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 25 Aug 2026 10:46:58 -0500 Subject: [PATCH 004/143] Revert "Skip Bluetooth wait when Bluetooth is disabled (#10571)" (#11608) This reverts commit f1b1e35a79d4966f9b593ad90dc18b3a06f74483. --- src/PowerFSM.cpp | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 2ef5b2ea1c..5367293f22 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -58,23 +58,6 @@ static bool isPowered() return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB()); } -static bool isBluetoothEnabledForPowerFSM() -{ -#if HAS_BLUETOOTH && !MESHTASTIC_EXCLUDE_BLUETOOTH - return config.bluetooth.enabled; -#else - return false; -#endif -} - -static uint32_t getBluetoothWaitMs() -{ - if (!isBluetoothEnabledForPowerFSM()) - return 0; - - return Default::getConfiguredOrDefaultMs(config.power.wait_bluetooth_secs, default_wait_bluetooth_secs); -} - #if defined(T5_S3_EPAPER_PRO) static void t5BacklightOffForSleep() { @@ -448,7 +431,10 @@ void PowerFSM_setup() // If ESP32 and using power-saving, timer mover from DARK to light-sleep // Also serves purpose of the old DARK to DARK transition(?) See https://github.com/meshtastic/firmware/issues/3517 - powerFSM.add_timed_transition(&stateDARK, &stateLS, getBluetoothWaitMs(), NULL, "Bluetooth timeout"); + powerFSM.add_timed_transition( + &stateDARK, &stateLS, + Default::getConfiguredOrDefaultMs(config.power.wait_bluetooth_secs, default_wait_bluetooth_secs), NULL, + "Bluetooth timeout"); } else { // If ESP32, but not using power-saving, check periodically if config has drifted out of stateDark powerFSM.add_timed_transition(&stateDARK, &stateDARK, From 7e11bde8c8d14bda624ea9e57eeffd6978fa7822 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:29:34 +0000 Subject: [PATCH 005/143] fix(beacon): repair the MeshBeacon radio switch/restore regression from #11573 (#11596) * fix(radio): put the beacon restore back inside completeSending's if (p) Reverts the RadioLibInterface and RadioInterface changes from #11573 (ac330e6a6). Hoisting MeshBeaconModule::reconfigureForBeaconTX() out of the if (p) block changed its meaning from "a send completed" to "the radio went to standby, for any reason" - and every driver's setStandby() calls completeSending() unconditionally: on the pre-TX LBT scan, on startReceive(), and inside reconfigure(). Two shipping faults followed, both confirmed on hardware the next day. Every beacon transmitted on the wrong preset. isChannelActive() standbys the radio immediately before each transmit, so the restore ran between the switch and the key-up. The packet went out carrying the beacon channel hash with home modem settings - inaudible to listeners on the target preset, an unknown hash to listeners on the home one. Inert in both directions. And unbounded recursion: the restore calls iface->reconfigure(), which standbys, which calls completeSending(), which restores again, each level running a full applyModemConfig(). It terminated in a HardFault and a silent reboot (Reset reason 0x4 on nRF52, no panic output). The crash masked the misdirection - the node died before Started Tx, so the wrong preset was invisible until the recursion was fixed. completeSending() clears sendingPacket at the top, so any nested call sees p == NULL. The if (p) block was an accidental re-entrancy guard, and nothing named it as such; removing it created both faults at once. Name it now. This also reverts the beginSending() failure return that motivated the move, and the startSend() scaffolding built to reach the restore on that path. The payload bounds check it replaced is reinstated in the next commit, at a point where refusing a packet is already a supported outcome. * fix(radio): bound the payload at the radio queue, not mid-transmit #11573 replaced beginSending()'s assert with a runtime check that logged, released the packet and returned 0. beginSending() had never returned 0 before, so startSend() gained a failure path it had to unwind - and the release moved ownership of the packet out of the caller that held it. That new return value is what made hoisting the beacon restore look necessary. The check itself is worth keeping. MeshPacket.encrypted has a nanopb maximum of 256 bytes against a 240-byte radio buffer, and beginSending() is on the path for relayed frames and phone-sourced packets, neither under our control. Asserts are commonly compiled out in release builds, so what shipped was an unchecked 256-into-240 memcpy driven by remote input. Move it to Router::send(), immediately before iface->send(p) - the single funnel for every over-the-air transmit. Refusing a packet there is already a supported outcome: it returns TOO_LARGE, which is what perhapsEncode() already returns for the same condition on the decoded path, and releases or NAKs exactly as the duty-cycle limit above it does. Nothing radio-side has happened at that point, so there is no half-started transmit to tear back down. perhapsEncode()'s existing check does not cover this case: relayed and phone-sourced frames arrive already encrypted and never reach it. beginSending() keeps a last line of defence, but clamps rather than failing, so it stays a call that always succeeds. Adds MAX_RADIO_PAYLOAD_LEN so both sites name the same number instead of recomputing it. Nothing about a beacon can trigger any of this - broadcast_message is admin-truncated to 100 bytes, the whole MeshBeacon protobuf tops out at 180, and observed beacons run to 106 - which is why this is separated from the beacon changes rather than carried with them. Tests: Router::send() refuses an oversized payload and still sends one that exactly fills the buffer; beginSending() clamps instead of rejecting, and leaves ordinary traffic whole. * fix(beacon): guard the radio switch/restore against re-entry and early restore Two checks in reconfigureForBeaconTX(), both independent of radio state, so the switch/restore state machine no longer rests on sendingPacket's lifetime - which is exactly the implicit coupling that let #11573 through. A re-entrancy guard. Both branches end in iface->reconfigure(), whose setStandby() runs completeSending(), which calls straight back in here. While one call is applying a config, a nested call returns false and leaves it alone. This covers the switch branch too, which had the same exposure with a quieter symptom: a second switch before the restore would take the re-entrant call as a restore and undo the switch still being applied, sending the beacon on the home channel instead of its target. A restore gate. The restore now waits for the packet that armed the switch to actually finish, tracked by id against our own target table rather than by asking the radio. Every caller that completes or abandons a beacon clears that packet's target settings first, so a live entry means the TX has not happened yet. cancelSending() now clears too, which is what keeps a cancelled beacon from pinning the radio on the beacon config. Together these make explicit the invariant completeSending()'s if (p) block was carrying by accident: a future hoist of that call gets a logged no-op instead of a crash and a misdirected beacon. Also sets radioSwitched before reconfigure() rather than after, in both branches, so the flag never describes a radio state that is not yet true. Diagnostics, because every step of this dance was previously silent about its own state. Count consecutive switches with no restore between them and log the depth on both sides, so a change-change-change-restore run reads off the log; switch #2 onwards prints the held home snapshot, which is the value that has to survive a second switch. The restore names the config it is restoring to, so a stale snapshot is visible directly. The re-entrancy guard logs when it fires - expected exactly twice per beacon, so a burst means something new is re-entering rather than a silent reboot. And setTargetRadioSettings() now warns on the slot eviction that previously left a packet to key up on whatever config was running - no crash, no log, wrong channel. Reachable only with beacon broadcast enabled (the default flags are LISTEN_ENABLED | LEGACY_SPLIT, so broadcast is off) and a target differing from the running config; an identical target takes the early return and never switches. Tests: three re-entrancy cases against a RadioInterface whose reconfigure() re-enters exactly as completeSending() does - bounded, so a regression fails an assertion instead of overflowing the stack and taking the runner with it - plus a restore that must defer until the beacon it switched for completes. * fix(beacon,radio): address review findings on #11596 Payload ceiling was one byte too generous. RadioBuffer::payload is 240 bytes because the buffer reserves MAX_LORA_PAYLOAD_LEN + 1, but the PHY caps a whole frame at 255 and beginSending() adds a 16-byte header - so a 240-byte payload produced a 256-byte frame. Define the ceiling as MAX_LORA_PAYLOAD_LEN - sizeof(PacketHeader), matching what perhapsEncode() already enforces, with a static_assert that it still fits the buffer. Target-table eviction could unblock the restore gate. With every slot live, setTargetRadioSettings() overwrote slot 0 - and if that slot held the packet the outstanding switch is gated on, the restore came unblocked and put the home config back under a beacon that had not keyed up. Skip that entry when choosing a victim, and refuse the target outright if every slot is in flight. Needs radioSwitched/switchedForId at file scope so the setter can see them. Restore on every abandon path, not just the clear. cancelSending() dropped a queued packet's target without restoring, so a beacon pre-switched by onNotify() and then cancelled left the radio receiving on the beacon config; removePendingTXPacket() did neither. Both now route through abandonBeaconTarget(), as does startSend()'s tx-disabled branch. The restore gate makes it a no-op when the abandoned packet is not the one we switched for. No NAK on the oversize drop. p->channel is a wire hash by that point, not an index, and Channels::getIndexByHash() is declared but never defined. Only already-encrypted ingress can reach the gate anyway - perhapsEncode() bounds everything it encodes - and those carry no index to answer on. Release and log. Tests clear sendingPacket before releasing their packet, and assert against the payload ceiling rather than the buffer size. * fix(beacon): route the invalid-target drop through abandonBeaconTarget onNotify()'s invalid-config drop was the one packet-abandonment path still clearing the target directly instead of going through abandonBeaconTarget(), so a packet that armed the radio switch and then failed validation would be released with the radio left on the beacon config and nothing to restore it. The helper's restore gate (targetRadioSettingsLive(switchedForId)) makes the call a no-op for any packet that did not arm the switch, so this closes the gap without risking a premature restore. Also trims the switch-state comment to the two-line limit. * fix(radio): take the abandoned packet as a pointer to const cppcheck's constParameterPointer failed the check matrix on every board: abandonBeaconTarget() only forwards the packet to clearTargetRadioSettings(), which already takes a const pointer, so the parameter should be const too. * refactor(radio): drive the beacon radio switch through TX hooks RadioLibInterface named MeshBeaconModule at six call sites behind MESHTASTIC_EXCLUDE_BEACON guards, so the driver carried per-packet beacon state: when to switch preset, when a target config was invalid mid-transmit, and when not to listen on a busy channel. Review on #11596 asked for the module dependency to come out. RadioTxHook is what the driver knows instead - beforeTransmit() returning send/defer/drop, holdsRadio(), packetReleased() - on a self-registering intrusive list, so nothing is allocated and a build without the beacon module registers nothing and every call is a no-op. The four abandon paths (cancel, remove-pending, TX disabled, completeSending) collapse onto one packetReleased(), and the tri-state means the driver no longer has to know why a packet wanted a re-delay or a drop. MeshBeaconTxHook wraps the existing statics; the switch/restore logic, its re-entrancy guard and its restore gate are untouched. It is created in Modules.cpp inside the existing exclusion guard, so MESHTASTIC_EXCLUDE_BEACON now works by nothing registering rather than by #ifdefs in the driver. Behaviour is unchanged. The invalid-config LOG_DEBUG moves into the module and the driver logs a generic refusal. Four tests cover the send/defer/drop mapping and that an empty hook list is a no-op; native:test_mesh_beacon is 59/59. Also notes in sendBeaconPacket that beacons uplink to MQTT on the primary slot's uplink_enabled, and that the topic follows the beacon channel under the crypto-override swap - both intentional. * fix(beacon): restore the home config for a packet that jumps the queue The restore gate added in 9cb7b96c9 refused to put the home config back while the beacon that armed the switch was still live. That is right for a release - completeSending() runs on every setStandby(), and restoring there would undo the switch before the beacon had keyed up - but it also caught the case where the driver is asking about a different packet it is about to transmit. MeshPacketQueue::enqueue() inserts by priority (std::upper_bound over CompareMeshPacketFunc), so an ACK or routing packet queued during the beacon's deferred transmit delay lands ahead of it. beforeTransmit() then saw an untagged packet, found the beacon still queued, skipped the restore and returned PRETX_SEND - and the packet transmitted on the beacon's preset, slot and region. It was encrypted and hashed for the home channel, so no receiver on either preset could use it. Apply the gate only to a null p. A non-null untagged packet is the driver about to key up, which always restores; the restore returns PRETX_DEFER, so the driver re-runs the delay and the channel scan on the config it will actually transmit on. beforeTransmit() is the only caller that passes a non-null untagged packet, so nothing else changes. Found by CodeRabbit on #11596. native:test_mesh_beacon 60/60, including a regression test for the queue transition; the four re-entrancy tests still cover the null-p gate. --------- Co-authored-by: Ben Meadors --- src/mesh/RadioInterface.cpp | 18 +- src/mesh/RadioInterface.h | 5 + src/mesh/RadioLibInterface.cpp | 74 ++--- src/mesh/RadioTxHook.cpp | 43 +++ src/mesh/RadioTxHook.h | 50 ++++ src/mesh/Router.cpp | 9 + src/modules/MeshBeaconModule.cpp | 122 +++++++-- src/modules/MeshBeaconModule.h | 19 +- src/modules/Modules.cpp | 1 + test/test_mesh_beacon/test_main.cpp | 343 ++++++++++++++++++++++++ test/test_nexthop_routing/test_main.cpp | 20 ++ test/test_radio/test_main.cpp | 41 ++- 12 files changed, 656 insertions(+), 89 deletions(-) create mode 100644 src/mesh/RadioTxHook.cpp create mode 100644 src/mesh/RadioTxHook.h diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index c0212f4ded..7da9325d46 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1518,15 +1518,17 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) // if the sender nodenum is zero, that means uninitialized assert(radioBuffer.header.from); - // Runtime packet payload size bounds check against radioBuffer to prevent overflow in memcpy() - if (static_cast(p->encrypted.size) > sizeof(radioBuffer.payload)) { - LOG_ERROR("Packet payload size %u exceeds radioBuffer capacity %u", static_cast(p->encrypted.size), - static_cast(sizeof(radioBuffer.payload))); - packetPool.release(p); - return 0; + + // Oversize is rejected at the radio queue in Router::send(); clamp rather than fail here so this + // stays a call that always succeeds, with no failure return for startSend() to unwind. + size_t payloadLen = p->encrypted.size; + if (payloadLen > MAX_RADIO_PAYLOAD_LEN) { + LOG_ERROR("Payload %u exceeds radioBuffer capacity %u, truncate", (unsigned)payloadLen, (unsigned)MAX_RADIO_PAYLOAD_LEN); + payloadLen = MAX_RADIO_PAYLOAD_LEN; } - memcpy(radioBuffer.payload, p->encrypted.bytes, p->encrypted.size); + + memcpy(radioBuffer.payload, p->encrypted.bytes, payloadLen); sendingPacket = p; - return p->encrypted.size + sizeof(PacketHeader); + return payloadLen + sizeof(PacketHeader); } diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index eb9315ac12..e76b977bf4 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -67,6 +67,11 @@ typedef struct { } RadioBuffer; +/// On-air ceiling for MeshPacket.encrypted. RadioBuffer holds one byte more, but the PHY caps a whole +/// frame at MAX_LORA_PAYLOAD_LEN, so the header comes out of the same budget (matches perhapsEncode). +constexpr size_t MAX_RADIO_PAYLOAD_LEN = MAX_LORA_PAYLOAD_LEN - sizeof(PacketHeader); +static_assert(MAX_RADIO_PAYLOAD_LEN < sizeof(RadioBuffer::payload), "payload ceiling must fit the buffer"); + /** * Basic operations all radio chipsets must implement. * diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 3018a34dd3..da0f58d6ec 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -2,6 +2,7 @@ #include "MeshTypes.h" #include "NodeDB.h" #include "PowerMon.h" +#include "RadioTxHook.h" #include "SPILock.h" #include "Throttle.h" #include "UptimeClock.h" @@ -9,9 +10,6 @@ #include "error.h" #include "main.h" #include "mesh-pb-constants.h" -#if !MESHTASTIC_EXCLUDE_BEACON -#include "modules/MeshBeaconModule.h" -#endif #include #include @@ -248,8 +246,10 @@ bool RadioLibInterface::isSending() bool RadioLibInterface::cancelSending(NodeNum from, PacketId id) { auto p = txQueue.remove(from, id); - if (p) + if (p) { + RadioTxHooks::packetReleased(this, p); packetPool.release(p); // free the packet we just removed + } bool result = (p != NULL); LOG_DEBUG("cancelSending id=0x%08x, removed=%d", id, result); @@ -408,14 +408,10 @@ void RadioLibInterface::onNotify(uint32_t notification) switch (notification) { case ISR_TX: handleTransmitInterrupt(); // completeSending() already restored the radio to the home config -#if !MESHTASTIC_EXCLUDE_BEACON - // Pre-switch the radio to the NEXT queued packet's beacon config (no-op for normal traffic). - // Not required for correctness - TRANSMIT_DELAY_COMPLETED would switch before CAD anyway - but - // doing it here lets the next beacon skip the switch-only delay cycle and, more importantly, - // keeps the post-TX listen window (and the CAD/LBT that follows) on the channel we're about to - // transmit on. Only engages when the next packet is itself a beacon - exactly when we want it. - MeshBeaconModule::reconfigureForBeaconTX(this, txQueue.getFront()); -#endif + // Let the hooks pre-stage the radio for the NEXT queued packet. Not required for correctness - + // TRANSMIT_DELAY_COMPLETED asks again before the scan, which is where the answer is acted on - + // but it keeps the post-TX listen window on the channel we are about to transmit on. + (void)RadioTxHooks::beforeTransmit(this, txQueue.getFront()); startReceive(); setTransmitDelay(); break; @@ -443,25 +439,20 @@ void RadioLibInterface::onNotify(uint32_t notification) if (txp->tx_after && !Throttle::deadlinePassedAt(now, txp->tx_after)) { // There's still some delay pending on this packet, so resume waiting for it to elapse notifyLater(txp->tx_after - now, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); -#if !MESHTASTIC_EXCLUDE_BEACON - } else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) { - // The beacon's target radio config is invalid (bad preset/region, or an - // unlicensed node keying up on a ham-only region). Drop the packet - never - // transmit it on the current (home) config - and move on to the next queued packet. - LOG_DEBUG("Beacon: invalid TX radio config, drop packet 0x%08x", txp->id); + } else if (const RadioTxHook::PreTxAction action = RadioTxHooks::beforeTransmit(this, txp); + action == RadioTxHook::PRETX_DROP) { + // A module refuses this packet on the radio config we are holding: drop it rather + // than transmit it, and move on to the next queued packet. meshtastic_MeshPacket *bad = txQueue.dequeue(); - MeshBeaconModule::clearTargetRadioSettings(bad); + LOG_DEBUG("Drop Tx packet 0x%08x, refused before transmit", bad->id); + RadioTxHooks::packetReleased(this, bad); packetPool.release(bad); setTransmitDelay(); - } else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) { - setTransmitDelay(); -#endif + } else if (action == RadioTxHook::PRETX_DEFER) { + setTransmitDelay(); // the radio config moved, so re-run the delay and scan on it } else { if (isChannelActive()) { // check if there is currently a LoRa packet on the channel -#if !MESHTASTIC_EXCLUDE_BEACON - if (!MeshBeaconModule::hasTargetRadioSettings(txp)) -#endif - { + if (!RadioTxHooks::holdsRadio(txp)) { startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again } setTransmitDelay(); @@ -561,6 +552,7 @@ bool RadioLibInterface::removePendingTXPacket(NodeNum from, PacketId id, uint32_ meshtastic_MeshPacket *p = txQueue.remove(from, id, true, true, hop_limit_lt); if (p) { LOG_DEBUG("Drop pending-TX packet 0x%08x, hop limit %d", p->id, p->hop_limit); + RadioTxHooks::packetReleased(this, p); packetPool.release(p); return true; } @@ -595,15 +587,13 @@ void RadioLibInterface::completeSending() if (!isFromUs(p)) txRelay++; printPacket("Completed sending", p); -#if !MESHTASTIC_EXCLUDE_BEACON - MeshBeaconModule::clearTargetRadioSettings(p); -#endif + // Keep this inside `if (p)`: completeSending() also runs on every setStandby(), where a hook + // undoing its own pre-TX switch would recurse back through reconfigure(). + RadioTxHooks::packetReleased(this, p); + // We are done sending that packet, release it packetPool.release(p); } -#if !MESHTASTIC_EXCLUDE_BEACON - MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); -#endif } void RadioLibInterface::handleReceiveInterrupt() @@ -771,30 +761,14 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) channel scan and actual transmit as low as possible to avoid collisions. */ if (disabled || !config.lora.tx_enabled) { LOG_WARN("Drop Tx packet: LoRa Tx disabled"); -#if !MESHTASTIC_EXCLUDE_BEACON - // This packet may have already triggered a beacon radio switch in TRANSMIT_DELAY_COMPLETED; - // since it never reaches completeSending() here, restore the radio so it isn't left on the - // beacon config (which would also break RX on the home channel). - MeshBeaconModule::clearTargetRadioSettings(txp); - MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); -#endif + // Never reaches completeSending(), so any per-packet radio state has to be released here. + RadioTxHooks::packetReleased(this, txp); packetPool.release(txp); return false; } else { configHardwareForSend(); // must be after setStandby -#if !MESHTASTIC_EXCLUDE_BEACON - MeshBeaconModule::clearTargetRadioSettings(txp); -#endif size_t numbytes = beginSending(txp); - if (numbytes == 0) { - if (!sendingPacket) { - completeSending(); - powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); - startReceive(); - } - return false; - } int res = iface->startTransmit((uint8_t *)&radioBuffer, numbytes); if (res != RADIOLIB_ERR_NONE) { diff --git a/src/mesh/RadioTxHook.cpp b/src/mesh/RadioTxHook.cpp new file mode 100644 index 0000000000..f1fc9e8aca --- /dev/null +++ b/src/mesh/RadioTxHook.cpp @@ -0,0 +1,43 @@ +#include "RadioTxHook.h" + +RadioTxHook *RadioTxHook::hookList = nullptr; + +RadioTxHook::RadioTxHook() +{ + nextHook = hookList; + hookList = this; +} + +RadioTxHook::~RadioTxHook() +{ + for (RadioTxHook **slot = &hookList; *slot; slot = &(*slot)->nextHook) { + if (*slot == this) { + *slot = nextHook; + break; + } + } +} + +RadioTxHook::PreTxAction RadioTxHooks::beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p) +{ + for (RadioTxHook *h = RadioTxHook::hookList; h; h = h->nextHook) { + const RadioTxHook::PreTxAction action = h->beforeTransmit(iface, p); + if (action != RadioTxHook::PRETX_SEND) + return action; + } + return RadioTxHook::PRETX_SEND; +} + +bool RadioTxHooks::holdsRadio(const meshtastic_MeshPacket *p) +{ + for (RadioTxHook *h = RadioTxHook::hookList; h; h = h->nextHook) + if (h->holdsRadio(p)) + return true; + return false; +} + +void RadioTxHooks::packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p) +{ + for (RadioTxHook *h = RadioTxHook::hookList; h; h = h->nextHook) + h->packetReleased(iface, p); +} diff --git a/src/mesh/RadioTxHook.h b/src/mesh/RadioTxHook.h new file mode 100644 index 0000000000..3681d59d61 --- /dev/null +++ b/src/mesh/RadioTxHook.h @@ -0,0 +1,50 @@ +#pragma once + +#include "MeshTypes.h" + +class RadioInterface; + +/** + * A module's hook into the radio driver's per-packet TX lifecycle. + * + * The driver knows this interface and nothing about who implements it: a module needing per-packet + * radio state (MeshBeacon's preset switch) subclasses this, and one instance registers itself for + * the life of the program. + */ +class RadioTxHook +{ + friend class RadioTxHooks; + + RadioTxHook *nextHook = nullptr; + static RadioTxHook *hookList; + + public: + /// What the driver should do with the packet at the head of the TX queue. + enum PreTxAction { + PRETX_SEND, ///< nothing pending, transmit as usual + PRETX_DEFER, ///< the radio config changed, re-run the transmit delay before sending + PRETX_DROP ///< this packet must not go out on the current radio config + }; + + RadioTxHook(); + virtual ~RadioTxHook(); + + /// The driver is about to transmit p (NULL when the queue is empty): set up any state it needs. + virtual PreTxAction beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p) { return PRETX_SEND; } + + /// True while p needs the radio left on its own config, so the driver must not listen instead. + virtual bool holdsRadio(const meshtastic_MeshPacket *p) { return false; } + + /// The driver is done with p - sent, cancelled or dropped. Release anything held for it. + virtual void packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p) {} +}; + +/// Driver-side fan-out over the registered hooks; every call is a no-op when none are registered. +class RadioTxHooks +{ + public: + /// The first hook not returning PRETX_SEND decides, and the rest are not consulted. + static RadioTxHook::PreTxAction beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p); + static bool holdsRadio(const meshtastic_MeshPacket *p); + static void packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p); +}; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 34f477438c..fbb0f9bc91 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -602,6 +602,15 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) } #endif + // Only already-encrypted frames (relayed, phone-sourced) reach here oversized; perhapsEncode() + // bounds everything it encodes. No NAK: p->channel is a wire hash by now, not an index. + if (p->encrypted.size > MAX_RADIO_PAYLOAD_LEN) { + LOG_WARN("Drop 0x%08x: payload %u exceeds radio capacity %u", p->id, (unsigned)p->encrypted.size, + (unsigned)MAX_RADIO_PAYLOAD_LEN); + packetPool.release(p); + return meshtastic_Routing_Error_TOO_LARGE; + } + assert(iface); // This should have been detected already in sendLocal (or we just received a packet from outside) return iface->send(p); } diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 9982f8d157..1976f054d7 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -19,6 +19,11 @@ meshtastic_ChannelSettings MeshBeaconModule::originalPrimaryChannel; static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; +// Explicit switch state, not inferred: "live config differs from the snapshot" missed name/PSK-only +// swaps and fired on legitimate channel edits. +static bool radioSwitched = false; +static uint32_t switchedForId = 0; + static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset *preset, uint16_t *slot, bool *legacyHopOverride = nullptr, meshtastic_Config_LoRaConfig_RegionCode *region = nullptr, bool *has_channel = nullptr, @@ -46,6 +51,16 @@ static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Co return false; } +// Is a target entry still live for this packet id? Unlike sendingPacket or the radio's standby +// state, this is our own bookkeeping - it answers "has that beacon finished" without asking the radio. +static bool targetRadioSettingsLive(uint32_t id) +{ + for (const auto &entry : targetRadioSettings) + if (entry.inUse && entry.id == id) + return true; + return false; +} + // --------------------------------------------------------------------------- // MeshBeaconModule base // --------------------------------------------------------------------------- @@ -74,8 +89,22 @@ void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, me if (!target && !entry.inUse) target = &entry; } - if (!target) - target = &targetRadioSettings[0]; + if (!target) { + // Table full. Never evict the entry the outstanding switch is gated on: dropping it would + // unblock the restore and put the home config back under a beacon that has not keyed up. + for (auto &entry : targetRadioSettings) { + if (!radioSwitched || entry.id != switchedForId) { + target = &entry; + break; + } + } + if (!target) { + LOG_WARN("Beacon: target table full and every slot is in flight, drop target for 0x%08x", p->id); + return; + } + LOG_WARN("Beacon: target table full (%u slots), evicting packet 0x%08x for 0x%08x", + (unsigned)(sizeof(targetRadioSettings) / sizeof(targetRadioSettings[0])), target->id, p->id); + } target->inUse = true; target->id = p->id; target->preset = preset; @@ -151,13 +180,23 @@ meshtastic_ChannelSettings MeshBeaconModule::beaconChannelSettings(const meshtas bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p) { - // True while a beacon radio switch is in effect and still needs undoing. We track the switch - // explicitly rather than inferring it from "live config differs from the snapshot", because that - // heuristic both missed cases (a channel name/PSK swap that left preset/slot/region unchanged would - // never be restored) and fired falsely (a legitimate non-beacon channel edit would be reverted on - // the next TX). With the flag the restore fires for ANY field we changed and only when we changed - // it - including on TX-failure paths, which route through this same restore call. - static bool radioSwitched = false; + // Consecutive switches with no restore between them, so a multi-target run can be read off the log + // and the held home snapshot is attributable to a specific switch. + static uint8_t switchDepth = 0; + + // Both branches end in iface->reconfigure(), whose setStandby() runs completeSending() and calls + // straight back in here. Ignore that re-entry: the outer call owns the config it is applying. + static bool applying = false; + if (applying) { + // Expected once per switch and once per restore. A burst of these means something new re-enters. + LOG_DEBUG("Beacon: ignore re-entrant reconfigure while a radio config is being applied"); + return false; + } + struct ApplyingScope { + bool &flag; + explicit ApplyingScope(bool &f) : flag(f) { flag = true; } + ~ApplyingScope() { flag = false; } + } applyingScope(applying); meshtastic_ChannelSettings *primaryCh = &channels.getByIndex(channels.getPrimaryIndex()).settings; meshtastic_Config_LoRaConfig_ModemPreset targetPreset; @@ -202,18 +241,22 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ return false; } - // Snapshot current (non-beacon) settings so we restore to the latest config. Skip while a - // switch is already active, so a second switch before the restore can't capture the beacon - // config as the "home" we later restore to. + // Snapshot the live (non-beacon) config as "home". Skipped while a switch is already active, + // so a second switch before the restore cannot capture the beacon config instead. if (!radioSwitched) { originalModemPreset = config.lora.modem_preset; originalLoraChannel = config.lora.channel_num; originalRegion = config.lora.region; originalPrimaryChannel = *primaryCh; + switchDepth = 0; } + switchDepth++; - LOG_INFO("Beacon: switch radio for packet 0x%08x to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot, - targetRegion); + LOG_INFO("Beacon: switch #%u radio for packet 0x%08x to preset=%d slot=%u region=%d", switchDepth, p->id, targetPreset, + targetSlot, targetRegion); + if (switchDepth > 1) + LOG_WARN("Beacon: switching again with no restore between; home preset=%d slot=%u region=%d still held", + originalModemPreset, originalLoraChannel, originalRegion); config.lora.modem_preset = targetPreset; config.lora.channel_num = targetSlot; if (targetRegion != config.lora.region) @@ -222,13 +265,22 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ channels.fixupChannel(channels.getPrimaryIndex()); p->channel = channels.getHash(channels.getPrimaryIndex()); + radioSwitched = true; // set before reconfigure(), so the flag never lags the radio it describes + switchedForId = p->id; iface->reconfigure(); - radioSwitched = true; return true; } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && radioSwitched) { - LOG_INFO("Beacon: restore radio config after TX"); + // Null p is "release if nothing holds it": hold off until the arming beacon has finished. A + // non-null untagged p is the driver about to transmit it, so that always restores. + if (!p && targetRadioSettingsLive(switchedForId)) { + LOG_DEBUG("Beacon: skip restore, packet 0x%08x has not finished sending", switchedForId); + return false; + } + + LOG_INFO("Beacon: restore radio config after TX, undoing %u switch(es) -> preset=%d slot=%u region=%d", switchDepth, + originalModemPreset, originalLoraChannel, originalRegion); config.lora.modem_preset = originalModemPreset; config.lora.channel_num = originalLoraChannel; config.lora.region = originalRegion; @@ -236,13 +288,47 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ primaryCh->name[sizeof(primaryCh->name) - 1] = '\0'; channels.fixupChannel(channels.getPrimaryIndex()); + radioSwitched = false; // cleared before reconfigure(), so the flag never lags the radio it describes + switchDepth = 0; + switchedForId = 0; iface->reconfigure(); - radioSwitched = false; return true; } return false; } +// --------------------------------------------------------------------------- +// MeshBeaconTxHook +// --------------------------------------------------------------------------- + +MeshBeaconTxHook *meshBeaconTxHook; + +RadioTxHook::PreTxAction MeshBeaconTxHook::beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p) +{ + // Invalid target config (bad preset/region, or an unlicensed node keying up on a ham-only + // region): the packet must never fall through onto the current (home) config, so drop it. + if (MeshBeaconModule::beaconTxConfigInvalid(p)) { + LOG_DEBUG("Beacon: invalid TX radio config, drop packet 0x%08x", p->id); + return PRETX_DROP; + } + // A switch leaves the radio on a channel we have not scanned yet, so the driver owes us a + // fresh transmit delay before it keys up. + return MeshBeaconModule::reconfigureForBeaconTX(iface, p) ? PRETX_DEFER : PRETX_SEND; +} + +bool MeshBeaconTxHook::holdsRadio(const meshtastic_MeshPacket *p) +{ + return MeshBeaconModule::hasTargetRadioSettings(p); +} + +void MeshBeaconTxHook::packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p) +{ + // Clear first: the restore is gated on the switching packet still being live, so dropping our + // claim before asking is what lets the home config come back. + MeshBeaconModule::clearTargetRadioSettings(p); + MeshBeaconModule::reconfigureForBeaconTX(iface, nullptr); +} + // --------------------------------------------------------------------------- // MeshBeaconBroadcastModule // --------------------------------------------------------------------------- @@ -283,6 +369,8 @@ void MeshBeaconBroadcastModule::rebuildCache() void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset, bool has_channel, const meshtastic_ChannelSettings *overrideChannel) { + // Beacons uplink to MQTT like any other primary-slot packet - Router::send() publishes on slot 0's + // uplink_enabled, and under the swap below the topic is the beacon channel's. Both intentional. const bool cryptoOverride = has_channel && overrideChannel && (overrideChannel->name[0] != '\0' || overrideChannel->psk.size > 0); if (!cryptoOverride) { diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index e9faeea4cf..bed9e882c6 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -3,6 +3,7 @@ #include "Observer.h" #include "ProtobufModule.h" #include "RadioInterface.h" +#include "RadioTxHook.h" #include "concurrency/OSThread.h" #include "mesh/generated/meshtastic/mesh_beacon.pb.h" #include "mesh/generated/meshtastic/module_config.pb.h" @@ -55,13 +56,13 @@ class MeshBeaconModule /** * Returns true if the sidecar table contains an entry for this packet's ID. - * Used by RadioLibInterface to gate the channel-active check. + * Used via MeshBeaconTxHook to keep the driver from listening while a beacon is queued. */ static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p); /** * Remove the sidecar entry for this packet after it has been sent. - * Called from RadioLibInterface::completeSending(). + * Called via MeshBeaconTxHook once the driver is done with the packet. */ static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); @@ -90,6 +91,20 @@ class MeshBeaconModule static meshtastic_ChannelSettings originalPrimaryChannel; }; +/** + * Carries the beacon's radio switching into the radio driver's TX lifecycle, so the driver holds no + * beacon-specific code. One instance is created with the beacon modules and registers itself. + */ +class MeshBeaconTxHook : public RadioTxHook +{ + public: + PreTxAction beforeTransmit(RadioInterface *iface, meshtastic_MeshPacket *p) override; + bool holdsRadio(const meshtastic_MeshPacket *p) override; + void packetReleased(RadioInterface *iface, const meshtastic_MeshPacket *p) override; +}; + +extern MeshBeaconTxHook *meshBeaconTxHook; + /** * Broadcaster: periodically sends MeshBeacon packets on the configured preset/channel. * Active only when the FLAG_BROADCAST_ENABLED bit is set in moduleConfig.mesh_beacon.flags. diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index a2de555e90..34700ed07f 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -152,6 +152,7 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_BEACON meshBeaconBroadcastModule = new MeshBeaconBroadcastModule(); meshBeaconListenerModule = new MeshBeaconListenerModule(); + meshBeaconTxHook = new MeshBeaconTxHook(); // registers itself with the radio driver's TX hooks #endif #if !MESHTASTIC_EXCLUDE_GPS positionModule = new PositionModule(); diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index bb1a0abc2f..4c2ddad954 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -1338,6 +1338,334 @@ static void test_broadcaster_distinctTargets_bothSent(void) TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "distinct targets must each be sent"); } +// --------------------------------------------------------------------------- +// Radio switch/restore re-entrancy +// --------------------------------------------------------------------------- + +/** + * Stands in for a real driver on the restore path. RadioLibInterface::reconfigure() standbys the + * chip, setStandby() calls completeSending(), and completeSending() calls back into + * reconfigureForBeaconTX(iface, nullptr) - so reconfigure() re-entering is the normal case, not an + * exotic one. Bounded, so a regression fails an assertion instead of overflowing the stack. + */ +class ReentrantRadioInterface : public RadioInterface +{ + public: + static constexpr int kReentryLimit = 16; + int reconfigureCalls = 0; + bool reenterOnReconfigure = false; + + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } + + bool reconfigure() override + { + reconfigureCalls++; + if (reenterOnReconfigure && reconfigureCalls < kReentryLimit) + MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); + return true; + } +}; + +/** + * The restore must clear its guard before reconfiguring, or completeSending() re-enters the restore + * branch and it reconfigures the radio once per level until the stack runs out. Seen in the field as + * a run of "Beacon: restore radio config after TX" with a full applyModemConfig() between each. + */ +static void test_beaconRestore_isNotReenteredByCompleteSending(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x5EED0001; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + + // Switch to the beacon config. Not the case under test, so leave re-entry off. + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "switch must leave the radio on the beacon preset"); + + // Now restore, with reconfigure() re-entering exactly as completeSending() does. + MeshBeaconModule::clearTargetRadioSettings(&pkt); + radio.reconfigureCalls = 0; + radio.reenterOnReconfigure = true; + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + + TEST_ASSERT_EQUAL_INT_MESSAGE(1, radio.reconfigureCalls, "restore must reconfigure the radio exactly once"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "restore must put the home preset back"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "restore must put the home channel back"); +} + +/** + * A second switch before the restore has run must survive the same re-entry. completeSending() calls + * in with a null packet, which reads as "restore" - so without the guard it would undo the switch that + * is still being applied, leaving the beacon to transmit on the home channel instead of its target. + */ +static void test_beaconSwitch_isNotUndoneByCompleteSending(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + ReentrantRadioInterface radio; + meshtastic_MeshPacket first = meshtastic_MeshPacket_init_zero; + first.id = 0x5EED0002; + MeshBeaconModule::setTargetRadioSettings(&first, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &first), "first switch should have applied"); + + // Second switch with the restore still outstanding, and reconfigure() re-entering. + meshtastic_MeshPacket second = meshtastic_MeshPacket_init_zero; + second.id = 0x5EED0003; + MeshBeaconModule::setTargetRadioSettings(&second, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + radio.reconfigureCalls = 0; + radio.reenterOnReconfigure = true; + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &second), "second switch should have applied"); + + TEST_ASSERT_EQUAL_INT_MESSAGE(1, radio.reconfigureCalls, "second switch must reconfigure the radio exactly once"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, config.lora.modem_preset, + "second switch must not be undone mid-flight"); + + // The home config must still be recoverable afterwards - the snapshot survives a second switch. + radio.reenterOnReconfigure = false; + MeshBeaconModule::clearTargetRadioSettings(&first); + MeshBeaconModule::clearTargetRadioSettings(&second); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "restore must return to the home preset, not the first beacon target"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "restore must return to the home channel"); +} + +/** A restore with nothing switched must do nothing at all - the guard is what makes re-entry safe. */ +static void test_beaconRestore_withoutSwitch_isNoOp(void) +{ + resetConfig(); + ReentrantRadioInterface radio; + + // reconfigureForBeaconTX() keeps its switched/not-switched state in a function-local static, so an + // earlier test that aborted mid-way can leave a switch outstanding. Drain it before asserting. + MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr); + + radio.reconfigureCalls = 0; + radio.reenterOnReconfigure = true; + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore without a switch is a no-op"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "no-op restore must not touch the radio"); +} + +/** + * The restore is driven by "our beacon finished", not by "the radio changed state". completeSending() + * clears a packet's target settings before restoring, so a caller that arrives without that - the + * pre-TX channel scan standbys the radio, and setStandby() calls completeSending() - must be refused. + * Otherwise the home config goes back under a beacon that has not keyed up yet, and it transmits on + * the wrong preset with the beacon channel hash already stamped on it. + */ +static void test_beaconRestore_deferredUntilPacketCompletes(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x5EED0004; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); + + // The packet has not been sent yet, so its target settings are still live. + radio.reconfigureCalls = 0; + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), + "restore must be refused while the beacon is still outstanding"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "a refused restore must not touch the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "the beacon preset must still be in place when the packet keys up"); + + // completeSending() clears the target settings first; only then is the restore ours to make. + MeshBeaconModule::clearTargetRadioSettings(&pkt); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "restore must put the home preset back"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "restore must put the home channel back"); +} + +// --------------------------------------------------------------------------- +// MeshBeaconTxHook (the radio driver's view of the beacon) +// --------------------------------------------------------------------------- + +/** + * Normal traffic must pass straight through the hook: no radio switch, no drop, and nothing claimed + * that would stop the driver listening on a busy channel. + */ +static void test_txHook_normalPacket_isSend(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000001; + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_SEND, RadioTxHooks::beforeTransmit(&radio, &pkt), + "a packet with no beacon target must transmit as usual"); + TEST_ASSERT_FALSE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "normal traffic must not claim the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "normal traffic must not reconfigure the radio"); +} + +/** + * A beacon asks the driver for a fresh transmit delay, because the switch leaves the radio on a + * channel the last channel scan never covered. + */ +static void test_txHook_beaconPacket_isDefer(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000002; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &pkt), + "a beacon switch must defer the transmit"); + TEST_ASSERT_TRUE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "a queued beacon must claim the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "the hook must leave the radio on the beacon preset"); + + // packetReleased() is what the driver calls once the packet is sent, cancelled or dropped. + RadioTxHooks::packetReleased(&radio, &pkt); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "releasing the packet must put the home preset back"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "releasing the packet must put the home channel back"); +} + +/** + * SHORT_TURBO is not legal on EU_868, so the beacon has nowhere valid to transmit. The driver must be + * told to drop it rather than let it fall through onto the home config. + */ +static void test_txHook_invalidTarget_isDrop(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000003; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DROP, RadioTxHooks::beforeTransmit(&radio, &pkt), + "an invalid target config must be dropped, not transmitted"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "a dropped beacon must not reconfigure the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "a dropped beacon must leave the home preset alone"); + + RadioTxHooks::packetReleased(&radio, &pkt); // the driver's drop path, so the sidecar entry is freed + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::hasTargetRadioSettings(&pkt), "the dropped packet's target must be released"); +} + +/** + * The hook list is what keeps the driver free of module includes: with nothing registered every call + * is a no-op, so a build without the beacon module behaves exactly as one with beacons idle. + */ +static void test_txHook_unregistered_isNoOp(void) +{ + resetConfig(); + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000004; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_SEND, RadioTxHooks::beforeTransmit(&radio, &pkt), + "with no hook registered even a beacon is ordinary traffic to the driver"); + TEST_ASSERT_FALSE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "with no hook registered nothing claims the radio"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "with no hook registered the radio is never reconfigured"); + + MeshBeaconModule::clearTargetRadioSettings(&pkt); +} + +/** + * A higher-priority packet can enqueue ahead of a still-queued beacon, so the driver asks the hook about + * an untagged packet while the beacon that armed the switch is live. The restore gate is right to hold + * off a release then, and wrong to hold off this: the untagged packet is about to key up, and without + * the restore it transmits on the beacon's preset, slot and region. + */ +static void test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + + // The beacon reaches the head of the queue and the hook switches the radio for it. + meshtastic_MeshPacket beacon = meshtastic_MeshPacket_init_zero; + beacon.id = 0x7A000005; + MeshBeaconModule::setTargetRadioSettings(&beacon, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &beacon), + "the beacon switch should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "the radio must be on the beacon preset before the queue jump"); + + // An ordinary packet now jumps the queue. The beacon is still queued, so its target is still live. + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::hasTargetRadioSettings(&beacon), + "the queued beacon must still hold its target, or this is not the case under test"); + meshtastic_MeshPacket ordinary = meshtastic_MeshPacket_init_zero; + ordinary.id = 0x7A000006; + + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &ordinary), + "restoring the radio owes the driver a fresh delay and scan"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset, + "an untagged packet must never transmit on the beacon preset"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name, + "an untagged packet must never transmit on the beacon channel"); + + // The beacon is not lost by the restore: it switches the radio back when it next reaches the head. + TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &beacon), + "the beacon must switch back when it reaches the head again"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, + "the beacon must be back on its target preset"); + + MeshBeaconModule::clearTargetRadioSettings(&beacon); + RadioTxHooks::packetReleased(&radio, &beacon); +} + } // namespace // =========================================================================== @@ -1462,6 +1790,21 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_duplicateTargets_dedupedToOnePacket); RUN_TEST(test_broadcaster_distinctTargets_bothSent); + printf("\n=== Radio switch/restore re-entrancy ===\n"); + + RUN_TEST(test_beaconRestore_isNotReenteredByCompleteSending); + RUN_TEST(test_beaconSwitch_isNotUndoneByCompleteSending); + RUN_TEST(test_beaconRestore_withoutSwitch_isNoOp); + RUN_TEST(test_beaconRestore_deferredUntilPacketCompletes); + + printf("\n=== MeshBeaconTxHook ===\n"); + + RUN_TEST(test_txHook_normalPacket_isSend); + RUN_TEST(test_txHook_beaconPacket_isDefer); + RUN_TEST(test_txHook_invalidTarget_isDrop); + RUN_TEST(test_txHook_unregistered_isNoOp); + RUN_TEST(test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome); + exit(UNITY_END()); } diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index c4891056cd..891504c71f 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -987,6 +987,25 @@ void test_rebroadcast_declined_send_releases_packet(void) TEST_ASSERT_EQUAL_MESSAGE(1, mockIface->sendCount, "the copy must have reached the mock radio"); } +// An already-encrypted packet never reaches perhapsEncode's TOO_LARGE check, so Router::send() is the +// last gate before the radio queue: MeshPacket.encrypted holds 256 bytes, the radio buffer 240. +void test_send_rejects_payload_larger_than_radio_buffer(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.id = 0x51000010; + p.encrypted.size = MAX_RADIO_PAYLOAD_LEN + 1; + + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_TOO_LARGE, shim->send(packetPool.allocCopy(p)), + "a payload larger than the radio buffer must be refused"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockIface->sendCount, "the oversized packet must never reach the radio"); + + p.id = 0x51000011; + p.encrypted.size = MAX_RADIO_PAYLOAD_LEN; + TEST_ASSERT_EQUAL_MESSAGE(ERRNO_OK, shim->send(packetPool.allocCopy(p)), "a payload at the radio ceiling must still be sent"); + TEST_ASSERT_EQUAL_MESSAGE(1, mockIface->sendCount, "the fitting packet must reach the radio"); +} + #if USERPREFS_EVENT_MODE void test_event_mode_hop_behavior(void) { @@ -1114,6 +1133,7 @@ void setup() RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed); RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed); RUN_TEST(test_rebroadcast_declined_send_releases_packet); + RUN_TEST(test_send_rejects_payload_larger_than_radio_buffer); #if USERPREFS_EVENT_MODE RUN_TEST(test_event_mode_hop_behavior); #endif diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index d9e81c8715..27e74237e8 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -64,7 +64,7 @@ class TestableRadioInterface : public RadioInterface size_t beginSendingPublic(meshtastic_MeshPacket *p) { return beginSending(p); } meshtastic_MeshPacket *getSendingPacket() const { return sendingPacket; } - size_t getRadioBufferPayloadCapacity() const { return sizeof(radioBuffer.payload); } + void clearSendingPacketForTest() { sendingPacket = nullptr; } // Override reconfigure to call the base which invokes applyModemConfig() bool reconfigure() override { return RadioInterface::reconfigure(); } @@ -430,7 +430,9 @@ static int32_t packetPoolLiveBytes() return 0; } -static void test_beginSending_oversizedPayloadAbortsSafely() +// Oversize is refused at the radio queue in Router::send(). If one ever gets this far the memcpy is +// clamped instead of failing, and the packet stays the caller's to release. +static void test_beginSending_oversizedPayloadIsClamped() { const int32_t liveBefore = packetPoolLiveBytes(); @@ -443,20 +445,34 @@ static void test_beginSending_oversizedPayloadAbortsSafely() p->to = 0x87654321; p->id = 0x10203040; p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p->encrypted.size = MAX_RADIO_PAYLOAD_LEN + 10; - // Set encrypted size larger than sizeof(radioBuffer.payload) (which is 256 - sizeof(PacketHeader)) - p->encrypted.size = testRadio->getRadioBufferPayloadCapacity() + 10; + TEST_ASSERT_EQUAL_UINT_MESSAGE(MAX_LORA_PAYLOAD_LEN, testRadio->beginSendingPublic(p), + "an oversized payload must be clamped to the PHY limit, not rejected"); + TEST_ASSERT_EQUAL_PTR_MESSAGE(p, testRadio->getSendingPacket(), "beginSending must still take the packet"); - size_t result = testRadio->beginSendingPublic(p); - - TEST_ASSERT_EQUAL_UINT(0, result); - TEST_ASSERT_NULL(testRadio->getSendingPacket()); - - // The rejected packet went back to the pool. Not pointer identity: the native pool is - // malloc-backed and ASan quarantines the freed block, so the next alloc moves. + // beginSending has no failure path that releases, so the packet is ours to free. + testRadio->clearSendingPacketForTest(); + packetPool.release(p); TEST_ASSERT_EQUAL_INT32(liveBefore, packetPoolLiveBytes()); } +// The clamp must not shorten ordinary traffic, and a maximum-size frame must still fit the PHY. +static void test_beginSending_fittingPayloadIsSentWhole() +{ + meshtastic_MeshPacket *p = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(p); + p->from = 0x12345678; + p->to = 0x87654321; + p->id = 0x10203041; + p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p->encrypted.size = MAX_RADIO_PAYLOAD_LEN; + + TEST_ASSERT_EQUAL_UINT_MESSAGE(MAX_LORA_PAYLOAD_LEN, testRadio->beginSendingPublic(p), + "the largest allowed payload must produce a frame at the PHY limit"); + testRadio->clearSendingPacketForTest(); + packetPool.release(p); +} void setUp(void) { mockMeshService = new MockMeshService(); @@ -507,7 +523,8 @@ void setup() RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds); RUN_TEST(test_regionPresetMap_matchesRegionTable); RUN_TEST(test_regionPresetMap_unsetCarriesUserprefsIntent); - RUN_TEST(test_beginSending_oversizedPayloadAbortsSafely); + RUN_TEST(test_beginSending_oversizedPayloadIsClamped); + RUN_TEST(test_beginSending_fittingPayloadIsSentWhole); exit(UNITY_END()); } From 8eda86045b12203da23fb39dca12a0a4b5f346a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 25 Aug 2026 20:40:39 +0000 Subject: [PATCH 006/143] fix(audio): amp settle window, and start melody after codec init (#11604) * fix(audio): amp settle window, and start melody after codec init (#11597) * chore(audio): condense the new code comments to two lines --- src/AudioThread.h | 31 ++++++++++++++--------- src/main.cpp | 25 +++++++++--------- variants/esp32s3/meshnology-w10/variant.h | 3 +++ 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/AudioThread.h b/src/AudioThread.h index f4f5781fcf..fb63a48922 100644 --- a/src/AudioThread.h +++ b/src/AudioThread.h @@ -33,9 +33,7 @@ class AudioThread : public concurrency::OSThread void beginRttl(const void *data, uint32_t len) { -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(true); -#endif + ampEnable(true); setCPUFast(true); rtttlFile = std::unique_ptr(new AudioFileSourcePROGMEM(data, len)); i2sRtttl = std::unique_ptr(new AudioGeneratorRTTTL()); @@ -61,9 +59,7 @@ class AudioThread : public concurrency::OSThread rtttlFile = nullptr; setCPUFast(false); -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(false); -#endif + ampEnable(false); } void readAloud(const char *text) @@ -73,16 +69,12 @@ class AudioThread : public concurrency::OSThread i2sRtttl = nullptr; } -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(true); -#endif + ampEnable(true); auto sam = std::unique_ptr(new ESP8266SAM); sam->Say(audioOut.get(), text); setCPUFast(false); audioOut->stop(); -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(false); -#endif + ampEnable(false); } protected: @@ -97,6 +89,21 @@ class AudioThread : public concurrency::OSThread } private: + // Amps like the NS4150 need time to leave shutdown, longer when the enable is an I/O expander write. + // Without a variant's AUDIO_AMP_SETTLE_MS the short system tones are over before any audio gets out. + static void ampEnable(bool on) + { +#ifdef AUDIO_AMP_ENABLE + AUDIO_AMP_ENABLE(on); +#ifdef AUDIO_AMP_SETTLE_MS + if (on) + delay(AUDIO_AMP_SETTLE_MS); +#endif +#else + (void)on; +#endif + } + void initOutput() { audioOut = std::unique_ptr(new AudioOutputI2S(1, AudioOutputI2S::EXTERNAL_I2S)); diff --git a/src/main.cpp b/src/main.cpp index 3f606706f9..63f259b949 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -882,18 +882,10 @@ void setup() router = new ReliableRouter(); - // only play start melody when role is not tracker or sensor - if (config.power.is_power_saving == true && - IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_TRACKER, - meshtastic_Config_DeviceConfig_Role_TAK_TRACKER, meshtastic_Config_DeviceConfig_Role_SENSOR)) - LOG_DEBUG("Tracker/Sensor: Skip start melody"); - else - playStartMelody(); - #if HAS_SCREEN - // fixed screen override? - // The geometry picks below are skipped on variants that pin the panel size with - // OLED_GEOMETRY_OVERRIDE (see the end of this block) - there they would only be dead stores. + // fixed screen override? + // The geometry picks below are skipped on variants that pin the panel size with + // OLED_GEOMETRY_OVERRIDE (see the end of this block) - there they would only be dead stores. #if defined(USE_SH1107) screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // set dimension of 128x128 #ifndef OLED_GEOMETRY_OVERRIDE @@ -994,7 +986,7 @@ void setup() SPI.begin(); #endif #else - // ESP32 + // ESP32 #if defined(HW_SPI1_DEVICE) SPI1.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); LOG_DEBUG("SPI1.begin(SCK=%d, MISO=%d, MOSI=%d, NSS=%d)", LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS); @@ -1178,6 +1170,15 @@ void setup() lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation) + // Must follow lateInitVariant(): on I2S boards audioThread and the codec are only up by this point. + // Skipped for power-saving tracker/sensor roles. + if (config.power.is_power_saving == true && + IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_TRACKER, + meshtastic_Config_DeviceConfig_Role_TAK_TRACKER, meshtastic_Config_DeviceConfig_Role_SENSOR)) + LOG_DEBUG("Tracker/Sensor: Skip start melody"); + else + playStartMelody(); + #if !MESHTASTIC_EXCLUDE_MQTT mqttInit(); #endif diff --git a/variants/esp32s3/meshnology-w10/variant.h b/variants/esp32s3/meshnology-w10/variant.h index 232ec163db..6af82897eb 100644 --- a/variants/esp32s3/meshnology-w10/variant.h +++ b/variants/esp32s3/meshnology-w10/variant.h @@ -176,6 +176,9 @@ #define DAC_I2S_DIN 3 // pg3: record data (ES8311 -> ESP32) // AudioThread powers the NS4150 amp on/off around playback via this (opt-in) hook. #define AUDIO_AMP_ENABLE(on) mcpIoExpander.digitalWrite(EXIO_PA_CTRL, (on) ? HIGH : LOW) +// NS4150 wake-up, slower here because the enable is an I2C write to the expander, not a GPIO toggle. +// Without it the short system tones finish before the amp passes audio. +#define AUDIO_AMP_SETTLE_MS 250 // ─── On-board peripherals not wired up yet ──────────────────────────────────── // SHT41 temp/humidity (0x44) and QMI8658 IMU (0x6B): auto-detected on the I2C scan From cd6ac90f7e8ccee31e18bbc59d83938c0dd4f572 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:17:28 +0000 Subject: [PATCH 007/143] Add waypoint & geofence support with notifications for BaseUI and InkHUD (#10920) * Implement GeofenceModule for waypoint crossing notifications and integrate with existing modules * Waypoint Applet Initial Support on InkHUD * undo tile change * Update screen when Waypoint shows or dissapears * Merge branch 'develop' into waypoint-geofence * Geofence on InkHUD * Update MapTile.h * Update WaypointStore.cpp * Notifications * remove GF from waypoint screen * Prevent Focus from closing the notifiaction banner * Trunk fix * cleanup * undo merge conflix mistake * Waypoint screen on BaseUI * Focus preserve fix * UI bugs * Allow Inkhud to remove waypoint * Respect Locked Waypoints * Trunk fix * Update WaypointStore.cpp * Use 8-digit hex formatting for waypoint IDs. 0x%x was inconsistent with the repo's own convention (0x%08x for 32-bit IDs, used elsewhere in this file). Fixed here and in two other spots I found with the same issue (WaypointModule.cpp, GeofenceModule.cpp). * Update ExternalNotificationModule.cpp * Reject invalid surrogate codepoints in waypoint icon rendering * Update WaypointModule.cpp * Update WaypointStore.cpp * Update WaypointStore.cpp * Update WaypointStore.cpp * trunk fix * fix warnings * power.h rename to Power.h * Update Power.h * Fix executable bit on bin/lint-ifdef-complexity.sh Lost during a prior merge from develop (Windows checkout doesn't preserve file mode), causing "execve failed: Permission denied" in the Trunk Check Runner CI job. develop has this file at 100755; restoring that here. Co-Authored-By: Claude Sonnet 5 * Update README.md * Clean up waypoint and geofence integration * Minimize waypoint and geofence implementation * removed unnecessary gating * Geofence alert * trunk fix * Update test_main.cpp * Update WaypointStore.cpp --------- Co-authored-by: Ben Meadors Co-authored-by: Claude Sonnet 5 --- src/Power.cpp | 7 + src/WaypointStore.cpp | 403 ++++++++++++ src/WaypointStore.h | 75 +++ src/WaypointUtils.h | 38 ++ src/gps/RTC.cpp | 5 + src/graphics/Screen.cpp | 15 +- src/graphics/draw/MenuHandler.cpp | 173 ++++++ src/graphics/draw/MenuHandler.h | 8 + src/graphics/draw/NodeListRenderer.cpp | 10 +- src/graphics/draw/NodeListRenderer.h | 1 + src/graphics/niche/InkHUD/Applet.h | 1 + .../InkHUD/Applets/Bases/Map/MapApplet.cpp | 304 +++++++++- .../InkHUD/Applets/Bases/Map/MapApplet.h | 28 +- .../System/AppSwitcher/AppSwitcherApplet.cpp | 2 + .../InkHUD/Applets/System/Menu/MenuAction.h | 6 + .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 119 ++++ .../InkHUD/Applets/System/Menu/MenuApplet.h | 7 +- .../InkHUD/Applets/System/Menu/MenuPage.h | 3 + .../System/Notification/Notification.h | 13 +- .../Notification/NotificationApplet.cpp | 97 ++- .../System/Notification/NotificationApplet.h | 17 +- .../Applets/User/Positions/PositionsApplet.h | 2 +- .../User/Waypoints/WaypointListApplet.cpp | 572 ++++++++++++++++++ .../User/Waypoints/WaypointListApplet.h | 78 +++ src/graphics/niche/InkHUD/Events.cpp | 3 + src/main.cpp | 11 + src/mesh/NodeDB.cpp | 6 + src/modules/ExternalNotificationModule.cpp | 104 +++- src/modules/ExternalNotificationModule.h | 10 +- src/modules/GeofenceModule.cpp | 214 +++++++ src/modules/GeofenceModule.h | 68 +++ src/modules/Modules.cpp | 2 + src/modules/PositionModule.cpp | 11 +- src/modules/WaypointModule.cpp | 487 ++++++++++----- src/modules/WaypointModule.h | 7 +- test/test_geofence/test_main.cpp | 276 +++++++++ test/test_utf8/test_main.cpp | 16 + .../heltec_vision_master_e213/nicheGraphics.h | 2 + .../heltec_vision_master_e290/nicheGraphics.h | 2 + .../heltec_wireless_paper/nicheGraphics.h | 2 + .../esp32s3/mini-epaper-s3/nicheGraphics.h | 2 + variants/esp32s3/t5s3_epaper/nicheGraphics.h | 2 + .../esp32s3/tlora_t3s3_epaper/nicheGraphics.h | 2 + .../ELECROW-ThinkNode-M1/nicheGraphics.h | 2 + .../nrf52_promicro_diy_tcxo/nicheGraphics.h | 2 + .../nicheGraphics.h | 2 + .../heltec_mesh_pocket/nicheGraphics.h | 2 + .../heltec_mesh_solar/nicheGraphics.h | 2 + .../seeed_wio_tracker_L1_eink/nicheGraphics.h | 2 + variants/nrf52840/t-echo-plus/nicheGraphics.h | 2 + variants/nrf52840/t-echo/nicheGraphics.h | 2 + 51 files changed, 3005 insertions(+), 222 deletions(-) create mode 100644 src/WaypointStore.cpp create mode 100644 src/WaypointStore.h create mode 100644 src/WaypointUtils.h create mode 100644 src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp create mode 100644 src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h create mode 100644 src/modules/GeofenceModule.cpp create mode 100644 src/modules/GeofenceModule.h create mode 100644 test/test_geofence/test_main.cpp diff --git a/src/Power.cpp b/src/Power.cpp index 2cb73296b7..780f7ba74c 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -19,6 +19,7 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "Throttle.h" +#include "WaypointStore.h" #include "buzz/buzz.h" #include "configuration.h" #include "main.h" @@ -852,6 +853,9 @@ void Power::powerCommandsCheck() void Power::reboot() { notifyReboot.notifyObservers(NULL); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.saveToFlash(); +#endif #if defined(ARCH_ESP32) ESP.restart(); #elif defined(ARCH_NRF52) @@ -916,6 +920,9 @@ void Power::shutdown() #if HAS_SCREEN messageStore.saveToFlash(); #endif +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.saveToFlash(); +#endif #if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_STM32WL) #ifdef PIN_LED1 ledOff(PIN_LED1); diff --git a/src/WaypointStore.cpp b/src/WaypointStore.cpp new file mode 100644 index 0000000000..6fb180617d --- /dev/null +++ b/src/WaypointStore.cpp @@ -0,0 +1,403 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "Throttle.h" +#include "UptimeClock.h" +#include "WaypointStore.h" +#include "concurrency/LockGuard.h" +#include "gps/RTC.h" +#include +#include +#include + +namespace +{ + +constexpr uint8_t WAYPOINT_STORE_VERSION = 3; +constexpr const char *WAYPOINT_STORE_FILENAME = "/Waypoints_default.wpts"; + +#ifndef WAYPOINT_AUTOSAVE_INTERVAL_SEC +#define WAYPOINT_AUTOSAVE_INTERVAL_SEC (2 * 60 * 60) +#endif + +struct __attribute__((packed)) StoredWaypointRecord { + uint32_t creatorNodeNum; + uint32_t receivedTime; + uint8_t notificationPreferences; + uint16_t payloadLength; + uint8_t payload[meshtastic_Waypoint_size]; +}; + +bool decodeWaypointPayload(const uint8_t *payload, size_t payloadLength, meshtastic_Waypoint &wp) +{ + memset(&wp, 0, sizeof(wp)); + return pb_decode_from_bytes(payload, payloadLength, &meshtastic_Waypoint_msg, &wp); +} + +uint16_t encodeWaypointPayload(const meshtastic_Waypoint &wp, uint8_t *payload, size_t payloadCapacity) +{ + return (uint16_t)pb_encode_to_bytes(payload, payloadCapacity, &meshtastic_Waypoint_msg, &wp); +} + +static bool g_waypointStoreHasUnsavedChanges = false; +static uint32_t g_lastWaypointAutoSaveMs = 0; + +uint32_t autosaveIntervalMs() +{ + uint32_t sec = (uint32_t)WAYPOINT_AUTOSAVE_INTERVAL_SEC; + if (sec < 60) + sec = 60; + return sec * 1000UL; +} + +void markWaypointStoreUnsaved() +{ + g_waypointStoreHasUnsavedChanges = true; + if (g_lastWaypointAutoSaveMs == 0) + g_lastWaypointAutoSaveMs = Time::getMillis(); +} + +void persistWaypointStore() +{ + LOG_INFO("Autosaving WaypointStore to flash"); + waypointStore.saveToFlash(); +} + +} // namespace + +WaypointStore waypointStore; + +void WaypointStore::notifyChanged() +{ + notifyObservers(this); +} + +bool WaypointStore::isExpired(const meshtastic_Waypoint &wp, uint32_t now) +{ + if (wp.expire == 0) + return false; + + if (now == 0) + now = getTime(); + + return now != 0 && wp.expire <= now; +} + +bool WaypointStore::isExpired(const StoredWaypoint &entry, uint32_t now) +{ + return isExpired(entry.waypoint, now); +} + +uint8_t WaypointStore::notificationPreferencesFromWaypoint(const meshtastic_Waypoint &wp) +{ + uint8_t preferences = 0; + if (wp.notify_on_enter) + preferences |= WAYPOINT_NOTIFY_ENTER; + if (wp.notify_on_exit) + preferences |= WAYPOINT_NOTIFY_EXIT; + if (wp.notify_favorites_only) + preferences |= WAYPOINT_NOTIFY_FAVORITES_ONLY; + return preferences; +} + +uint8_t WaypointStore::mergeNotificationPreferences(bool locallyAuthored, bool hasExisting, uint8_t existingPreferences, + const meshtastic_Waypoint &incoming) +{ + if (locallyAuthored) + return notificationPreferencesFromWaypoint(incoming); + return hasExisting ? existingPreferences : 0; +} + +void WaypointStore::clearWireNotificationPreferences(meshtastic_Waypoint &wp) +{ + wp.notify_on_enter = false; + wp.notify_on_exit = false; + wp.notify_favorites_only = false; +} + +const StoredWaypoint *WaypointStore::findWaypoint(uint32_t id) const +{ + for (const StoredWaypoint &entry : waypoints) { + if (entry.waypoint.id == id) + return &entry; + } + return nullptr; +} + +bool WaypointStore::removeWaypointById(uint32_t id) +{ + for (auto it = waypoints.begin(); it != waypoints.end(); ++it) { + if (it->waypoint.id == id) { + waypoints.erase(it); + return true; + } + } + + return false; +} + +bool WaypointStore::removeWaypoint(uint32_t id) +{ + const bool removed = removeWaypointById(id); + if (!removed) + return false; + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + + return true; +} + +bool WaypointStore::setNotificationPreference(uint32_t id, WaypointNotificationPreference preference, bool enabled) +{ + for (StoredWaypoint &entry : waypoints) { + if (entry.waypoint.id != id) + continue; + + const uint8_t previous = entry.notificationPreferences; + if (enabled) + entry.notificationPreferences |= preference; + else + entry.notificationPreferences &= ~preference; + if (entry.notificationPreferences == previous) + return true; + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + return true; + } + + return false; +} + +void WaypointStore::addStoredWaypoint(const StoredWaypoint &entry) +{ + removeWaypointById(entry.waypoint.id); + + waypoints.push_front(entry); + while (waypoints.size() > WAYPOINT_HISTORY_LIMIT) + waypoints.pop_back(); +} + +bool WaypointStore::addFromPacket(const meshtastic_MeshPacket &packet, bool locallyAuthored, StoredWaypoint *stored) +{ + StoredWaypoint entry; + if (!decodeWaypointPayload(packet.decoded.payload.bytes, packet.decoded.payload.size, entry.waypoint)) + return false; + + const StoredWaypoint *existing = findWaypoint(entry.waypoint.id); + entry.notificationPreferences = mergeNotificationPreferences( + locallyAuthored, existing != nullptr, existing ? existing->notificationPreferences : 0, entry.waypoint); + clearWireNotificationPreferences(entry.waypoint); + entry.receivedTime = packet.rx_time ? packet.rx_time : getTime(); + entry.creatorNodeNum = getFrom(&packet); + + if (stored) + *stored = entry; + + if (isExpired(entry, entry.receivedTime)) { + // Respect the lock: only the node a waypoint is locked to may delete it on our device. + // An unauthorized deletion attempt is ignored entirely, rather than applied locally. + for (const auto &storedEntry : waypoints) { + if (storedEntry.waypoint.id != entry.waypoint.id) + continue; + if (storedEntry.waypoint.locked_to != 0 && storedEntry.waypoint.locked_to != entry.creatorNodeNum) + return true; // Packet handled, but the deletion is not honored + break; + } + + removeWaypoint(entry.waypoint.id); + return true; + } + + addStoredWaypoint(entry); + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + + return true; +} + +bool WaypointStore::purgeExpired(uint32_t now) +{ + if (now == 0) + now = getTime(); + if (now == 0) + return false; + + bool changed = false; + for (auto it = waypoints.begin(); it != waypoints.end();) { + if (!isExpired(*it, now)) { + ++it; + continue; + } + + it = waypoints.erase(it); + changed = true; + } + + if (changed) { +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + } + + return changed; +} + +void WaypointStore::saveToFlash() +{ + purgeExpired(); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + if (!g_waypointStoreHasUnsavedChanges) + return; + + spiLock->lock(); + FSCom.mkdir("/"); + spiLock->unlock(); + + SafeFile f(WAYPOINT_STORE_FILENAME, false); + + spiLock->lock(); + const uint8_t version = WAYPOINT_STORE_VERSION; + size_t countFull = waypoints.size(); + if (countFull > WAYPOINT_HISTORY_LIMIT) + countFull = WAYPOINT_HISTORY_LIMIT; + if (countFull > UINT8_MAX) + countFull = UINT8_MAX; + const uint8_t count = (uint8_t)countFull; + + f.write(&version, 1); + f.write(&count, 1); + + for (uint8_t i = 0; i < count; ++i) { + StoredWaypointRecord rec = {}; + rec.creatorNodeNum = waypoints[i].creatorNodeNum; + rec.receivedTime = waypoints[i].receivedTime; + rec.notificationPreferences = waypoints[i].notificationPreferences; + rec.payloadLength = encodeWaypointPayload(waypoints[i].waypoint, rec.payload, sizeof(rec.payload)); + f.write(reinterpret_cast(&rec), sizeof(rec)); + } + spiLock->unlock(); + f.close(); +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif +} + +void WaypointStore::loadFromFlash() +{ + std::deque().swap(waypoints); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + { + concurrency::LockGuard guard(spiLock); + + if (FSCom.exists(WAYPOINT_STORE_FILENAME)) { + auto f = FSCom.open(WAYPOINT_STORE_FILENAME, FILE_O_READ); + if (f) { + uint8_t version = 0; + uint8_t count = 0; + f.readBytes(reinterpret_cast(&version), 1); + f.readBytes(reinterpret_cast(&count), 1); + + if (version != WAYPOINT_STORE_VERSION) { + LOG_WARN("WaypointStore version mismatch (%u)", version); + f.close(); + } else { + if (count > WAYPOINT_HISTORY_LIMIT) + count = WAYPOINT_HISTORY_LIMIT; + + for (uint8_t i = 0; i < count; ++i) { + StoredWaypoint entry; + StoredWaypointRecord rec = {}; + if (f.readBytes(reinterpret_cast(&rec), sizeof(rec)) != sizeof(rec)) + break; + if (rec.payloadLength == 0 || rec.payloadLength > sizeof(rec.payload)) { + LOG_WARN("WaypointStore skipping corrupt record %u", i); + continue; + } + if (!decodeWaypointPayload(rec.payload, rec.payloadLength, entry.waypoint)) + continue; + entry.receivedTime = rec.receivedTime; + entry.creatorNodeNum = rec.creatorNodeNum; + entry.notificationPreferences = rec.notificationPreferences; + + if (isExpired(entry.waypoint)) + continue; + waypoints.push_back(entry); + } + f.close(); + } + } + } + } +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif +} + +void WaypointStore::clearAllWaypoints() +{ + const bool hadWaypoints = !waypoints.empty(); + + std::deque().swap(waypoints); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + SafeFile f(WAYPOINT_STORE_FILENAME, false); + { + concurrency::LockGuard guard(spiLock); + const uint8_t version = WAYPOINT_STORE_VERSION; + const uint8_t count = 0; + f.write(&version, 1); + f.write(&count, 1); + } + f.close(); +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif + + if (hadWaypoints) + notifyChanged(); +} + +#if ENABLE_WAYPOINT_PERSISTENCE +void waypointStoreAutosaveTick() +{ + if (!g_waypointStoreHasUnsavedChanges) { + if (g_lastWaypointAutoSaveMs == 0) + g_lastWaypointAutoSaveMs = Time::getMillis(); + return; + } + + if (g_lastWaypointAutoSaveMs == 0) { + g_lastWaypointAutoSaveMs = Time::getMillis(); + return; + } + + Throttle::execute(&g_lastWaypointAutoSaveMs, autosaveIntervalMs(), persistWaypointStore); +} +#endif + +#endif diff --git a/src/WaypointStore.h b/src/WaypointStore.h new file mode 100644 index 0000000000..1dbab78e6c --- /dev/null +++ b/src/WaypointStore.h @@ -0,0 +1,75 @@ +#pragma once + +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#ifndef ENABLE_WAYPOINT_PERSISTENCE +#define ENABLE_WAYPOINT_PERSISTENCE 1 +#endif + +#ifndef WAYPOINT_HISTORY_LIMIT +#define WAYPOINT_HISTORY_LIMIT 10 +#endif + +#include "Observer.h" +#include "mesh/MeshTypes.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +enum WaypointNotificationPreference : uint8_t { + WAYPOINT_NOTIFY_ENTER = 1 << 0, + WAYPOINT_NOTIFY_EXIT = 1 << 1, + WAYPOINT_NOTIFY_FAVORITES_ONLY = 1 << 2, +}; + +struct StoredWaypoint { + meshtastic_Waypoint waypoint = meshtastic_Waypoint_init_zero; + uint32_t receivedTime = 0; + NodeNum creatorNodeNum = 0; + uint8_t notificationPreferences = 0; + + bool notificationEnabled(WaypointNotificationPreference preference) const + { + return (notificationPreferences & preference) != 0; + } +}; + +class WaypointStore : public Observable +{ + public: + bool addFromPacket(const meshtastic_MeshPacket &packet, bool locallyAuthored, StoredWaypoint *stored = nullptr); + bool purgeExpired(uint32_t now = 0); + bool removeWaypoint(uint32_t id); + bool setNotificationPreference(uint32_t id, WaypointNotificationPreference preference, bool enabled); + + const std::deque &getWaypoints() const { return waypoints; } + const StoredWaypoint *findWaypoint(uint32_t id) const; + + void saveToFlash(); + void loadFromFlash(); + void clearAllWaypoints(); + + static bool isExpired(const meshtastic_Waypoint &wp, uint32_t now = 0); + static bool isExpired(const StoredWaypoint &entry, uint32_t now = 0); + static uint8_t notificationPreferencesFromWaypoint(const meshtastic_Waypoint &wp); + static uint8_t mergeNotificationPreferences(bool locallyAuthored, bool hasExisting, uint8_t existingPreferences, + const meshtastic_Waypoint &incoming); + static void clearWireNotificationPreferences(meshtastic_Waypoint &wp); + + private: + void addStoredWaypoint(const StoredWaypoint &entry); + bool removeWaypointById(uint32_t id); + void notifyChanged(); + + std::deque waypoints; +}; + +#if ENABLE_WAYPOINT_PERSISTENCE +void waypointStoreAutosaveTick(); +#endif + +extern WaypointStore waypointStore; + +#endif diff --git a/src/WaypointUtils.h b/src/WaypointUtils.h new file mode 100644 index 0000000000..ec78c214e2 --- /dev/null +++ b/src/WaypointUtils.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace WaypointUtils +{ + +inline std::string utf8FromCodepoint(uint32_t codepoint) +{ + if (codepoint == 0 || (codepoint >= 0xD800 && codepoint <= 0xDFFF) || codepoint > 0x10FFFF) + return ""; + + char buf[4]; + if (codepoint <= 0x7F) { + buf[0] = static_cast(codepoint); + return std::string(buf, 1); + } + if (codepoint <= 0x7FF) { + buf[0] = static_cast(0xC0 | (codepoint >> 6)); + buf[1] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 2); + } + if (codepoint <= 0xFFFF) { + buf[0] = static_cast(0xE0 | (codepoint >> 12)); + buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[2] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 3); + } + + buf[0] = static_cast(0xF0 | (codepoint >> 18)); + buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[3] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 4); +} + +} // namespace WaypointUtils diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 93e59e31e6..6423c1b00f 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -8,6 +8,7 @@ #include "mesh/MeshService.h" #include "mesh/NodeDB.h" #include "modules/NodeInfoModule.h" +#include "modules/WaypointModule.h" #include #include #include @@ -35,6 +36,10 @@ static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQual if (nodeDB) nodeDB->backfillHeardAt(); } +#if !MESHTASTIC_EXCLUDE_WAYPOINT && HAS_SCREEN + if (waypointModule && oldQuality != newQuality) + waypointModule->onDeviceTimeChanged(); +#endif } RTCQuality getRTCQuality() diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8cf3f8c926..97f8d72786 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1372,6 +1372,7 @@ void Screen::setFrames(FrameFocus focus) return; } + const FramesetInfo previousFramesetInfo = framesetInfo; uint8_t originalPosition = ui->getUiState()->currentFrame; uint8_t previousFrameCount = framesetInfo.frameCount; FramesetInfo fsi; // Location of specific frames, for applying focus parameter @@ -1624,8 +1625,15 @@ void Screen::setFrames(FrameFocus focus) break; case FOCUS_PRESERVE: - // No more adjustment - force stay on same index - if (previousFrameCount > fsi.frameCount) { + if (previousFramesetInfo.positions.waypoint == 255 && fsi.positions.waypoint != 255) { + const uint8_t target = originalPosition >= fsi.positions.waypoint ? originalPosition + 1 : originalPosition; + ui->switchToFrame(target); + } else if (previousFramesetInfo.positions.waypoint != 255 && fsi.positions.waypoint == 255) { + const uint8_t target = originalPosition > previousFramesetInfo.positions.waypoint + ? originalPosition - 1 + : std::min(originalPosition, fsi.frameCount - 1); + ui->switchToFrame(target); + } else if (previousFrameCount > fsi.frameCount) { ui->switchToFrame(originalPosition - 1); } else if (previousFrameCount < fsi.frameCount) { ui->switchToFrame(originalPosition + 1); @@ -2360,6 +2368,9 @@ int Screen::handleInputEvent(const InputEvent *event) menuHandler::nodeListMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.wifi) { menuHandler::wifiBaseMenu(); + } else if (framesetInfo.positions.waypoint != 255 && + this->ui->getUiState()->currentFrame == framesetInfo.positions.waypoint) { + menuHandler::waypointBaseMenu(); } } else if (event->inputEvent == INPUT_BROKER_BACK) { showFrame(FrameDirection::PREVIOUS); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index b7eb83a20e..6aec0ff654 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -28,11 +28,16 @@ #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/ExternalNotificationModule.h" +#include "modules/GeofenceModule.h" #include "modules/KeyVerificationModule.h" #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #include "modules/Telemetry/EnvironmentTelemetry.h" #endif #include "modules/TraceRouteModule.h" +#include "modules/WaypointModule.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "WaypointStore.h" +#endif #include #include #include @@ -45,6 +50,10 @@ namespace graphics namespace { +#if !MESHTASTIC_EXCLUDE_WAYPOINT +uint32_t selectedGeofenceWaypointId = 0; +#endif + // Caller must ensure the provided options array outlives the banner callback. template BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOption (&options)[N], @@ -2400,6 +2409,158 @@ void menuHandler::removeFavoriteMenu() screen->showOverlayBanner(bannerOptions); } +void menuHandler::waypointBaseMenu() +{ + enum optionsNumbers { Back, GeofenceAlerts, RemoveWaypoint }; + static const char *optionsArray[] = {"Back", "Geofence Alerts", "Remove Waypoint"}; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Waypoint Action"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == GeofenceAlerts) { + menuQueue = GeofenceWaypointMenu; + screen->runNow(); + } else if (selected == RemoveWaypoint) { + menuQueue = RemoveWaypointMenu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::geofenceWaypointMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + static const char *optionsArray[WAYPOINT_HISTORY_LIMIT + 1]; + static uint32_t waypointIds[WAYPOINT_HISTORY_LIMIT + 1]; + static std::string labelStorage[WAYPOINT_HISTORY_LIMIT + 1]; + + optionsArray[0] = "Back"; + int options = 1; + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (options > WAYPOINT_HISTORY_LIMIT || !GeofenceModule::hasGeofence(entry.waypoint)) + continue; + std::string name = sanitizeString(entry.waypoint.name); + if (name.empty()) + name = "Unnamed Geofence"; + labelStorage[options] = name.substr(0, 20); + optionsArray[options] = labelStorage[options].c_str(); + waypointIds[options] = entry.waypoint.id; + options++; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = options > 1 ? "Geofence Alerts" : "No Geofences"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = WaypointBaseMenu; + } else { + selectedGeofenceWaypointId = waypointIds[selected]; + menuQueue = GeofenceOptionsMenu; + } + screen->runNow(); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + +void menuHandler::geofenceOptionsMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) { + menuQueue = GeofenceWaypointMenu; + screen->runNow(); + return; + } + + static std::string labels[4]; + static const char *optionsArray[4]; + labels[0] = "Back"; + labels[1] = std::string("Enter Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_ENTER) ? "On" : "Off"); + labels[2] = std::string("Exit Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_EXIT) ? "On" : "Off"); + labels[3] = std::string("Favorites Only: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY) ? "On" : "Off"); + for (size_t i = 0; i < 4; ++i) + optionsArray[i] = labels[i].c_str(); + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Geofence Alerts"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 4; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = GeofenceWaypointMenu; + } else { + const StoredWaypoint *current = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (current) { + const WaypointNotificationPreference preference = + selected == 1 ? WAYPOINT_NOTIFY_ENTER + : (selected == 2 ? WAYPOINT_NOTIFY_EXIT : WAYPOINT_NOTIFY_FAVORITES_ONLY); + waypointStore.setNotificationPreference(selectedGeofenceWaypointId, preference, + !current->notificationEnabled(preference)); + } + menuQueue = GeofenceOptionsMenu; + } + screen->runNow(); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + +void menuHandler::removeWaypointMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + static const char *optionsArray[WAYPOINT_HISTORY_LIMIT + 1]; + static uint32_t waypointIds[WAYPOINT_HISTORY_LIMIT + 1]; + static std::string labelStorage[WAYPOINT_HISTORY_LIMIT + 1]; + + optionsArray[0] = "Back"; + int options = 1; + + for (const auto &entry : waypointStore.getWaypoints()) { + if (options > WAYPOINT_HISTORY_LIMIT) + break; + std::string name = sanitizeString(entry.waypoint.name); + if (name.empty()) + name = "Unnamed Waypoint"; + labelStorage[options] = name.substr(0, 20); + optionsArray[options] = labelStorage[options].c_str(); + waypointIds[options] = entry.waypoint.id; + options++; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Remove Waypoint"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = WaypointBaseMenu; + screen->runNow(); + return; + } + const uint32_t waypointId = waypointIds[selected]; + LOG_INFO("Removing waypoint 0x%08x", waypointId); + if (waypointModule) + waypointModule->broadcastDelete(waypointId); + else + waypointStore.removeWaypoint(waypointId); + screen->setFrames(graphics::Screen::FOCUS_DEFAULT); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + void menuHandler::traceRouteMenu() { screen->showNodePicker("Node to Trace", 30000, [](uint32_t nodenum) -> void { @@ -3037,6 +3198,18 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case RemoveFavorite: removeFavoriteMenu(); break; + case WaypointBaseMenu: + waypointBaseMenu(); + break; + case GeofenceWaypointMenu: + geofenceWaypointMenu(); + break; + case GeofenceOptionsMenu: + geofenceOptionsMenu(); + break; + case RemoveWaypointMenu: + removeWaypointMenu(); + break; case TraceRouteMenu: traceRouteMenu(); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 9650932232..7ffbfae1c9 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -37,6 +37,10 @@ class menuHandler NodePickerMenu, ManageNodeMenu, RemoveFavorite, + WaypointBaseMenu, + GeofenceWaypointMenu, + GeofenceOptionsMenu, + RemoveWaypointMenu, TestMenu, NumberTest, EnvironmentTelemetryMenu, @@ -108,6 +112,10 @@ class menuHandler static void manageNodeMenu(); static void addFavoriteMenu(); static void removeFavoriteMenu(); + static void waypointBaseMenu(); + static void geofenceWaypointMenu(); + static void geofenceOptionsMenu(); + static void removeWaypointMenu(); static void traceRouteMenu(); static void testMenu(); static void numberTest(); diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 24a5eeaf63..6f2f6ff650 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -566,9 +566,7 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon); float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian); float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing); - // Shrink size by 2px - int size = FONT_HEIGHT_SMALL - 5; - CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg); + drawRelativeCompassArrow(display, centerX, centerY, relativeBearingDeg); /* float angle = relativeBearing * DEG_TO_RAD; float halfSize = size / 2.0; @@ -613,6 +611,12 @@ void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int display->drawString(centerX, y, "?"); } +void drawRelativeCompassArrow(OLEDDisplay *display, int16_t centerX, int16_t centerY, float relativeBearingDeg) +{ + const int size = FONT_HEIGHT_SMALL - 5; + CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg); +} + // ============================= // Main Screen Functions // ============================= diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index d1e8bac1dd..3d14b6466f 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -45,6 +45,7 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 // Extras renderers void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeadingRadian, double userLat, double userLon); +void drawRelativeCompassArrow(OLEDDisplay *display, int16_t centerX, int16_t centerY, float relativeBearingDeg); // Screen frame functions void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h index 84891b1fec..0f48039588 100644 --- a/src/graphics/niche/InkHUD/Applet.h +++ b/src/graphics/niche/InkHUD/Applet.h @@ -129,6 +129,7 @@ class Applet : public GFX virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification virtual class MapApplet *asMapApplet() { return nullptr; } // Returns non-null only for MapApplet and its subclasses + virtual class WaypointListApplet *asWaypointListApplet() { return nullptr; } // Returns non-null only for WaypointListApplet static uint16_t getHeaderHeight(); // How tall the "standard" applet header is diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp index 5c8d78cb80..795447b749 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp @@ -2,6 +2,8 @@ #include "./MapApplet.h" #include "./MapTile.h" +#include "WaypointStore.h" +#include "WaypointUtils.h" #include #include @@ -21,11 +23,95 @@ static int tileTyAt(int tileIndex); static int tileMetadataZoomCount(); static int tileMetadataZoomAt(int index); -// Observe GPS position updates so the map redraws whenever a new location arrives. +namespace +{ + +bool waypointHasAnchor(const meshtastic_Waypoint &waypoint) +{ + return waypoint.has_latitude_i && waypoint.has_longitude_i; +} + +bool waypointHasMapGeometry(const meshtastic_Waypoint &waypoint) +{ + return waypointHasAnchor(waypoint) || waypoint.has_bounding_box; +} + +void includeMapPoint(float latNode, float lngNode, float lngCenter, float &northernmost, float &southernmost, float &easternmost, + float &westernmost) +{ + northernmost = max(northernmost, latNode); + southernmost = min(southernmost, latNode); + + const float degEastward = fmodf(((lngNode - lngCenter) + 360.0f), 360.0f); + const float degWestward = fabsf(fmodf(((lngNode - lngCenter) - 360.0f), 360.0f)); + if (degEastward < degWestward) + easternmost = max(easternmost, lngCenter + degEastward); + else + westernmost = min(westernmost, lngCenter - degWestward); +} + +} // namespace + +bool InkHUD::MapApplet::mapWaypointIconGlyph(uint32_t codepoint, std::string &glyph) +{ + if (!codepoint) + return false; + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(codepoint); + if (utf8.empty()) + return false; + + glyph = getFont().decodeUTF8(utf8); + return glyph.size() == 1 && glyph[0] != '\x1A' && glyph[0] != '\x7F'; +} + +static int16_t markerScreenX(float eastMeters, float metersToPx, uint16_t width) +{ + return (width * 0.5f) + (eastMeters * metersToPx); +} + +static int16_t markerScreenY(float northMeters, float metersToPx, uint16_t height) +{ + return (height * 0.5f) - (northMeters * metersToPx); +} + +uint8_t InkHUD::MapApplet::fallbackBadgeNumber(const WaypointMarker &entry) +{ + uint8_t badge = 0; + + for (auto it = waypointMarkers.rbegin(); it != waypointMarkers.rend(); ++it) { + if (!it->hasMarker) + continue; + + std::string glyph; + if (mapWaypointIconGlyph(it->icon, glyph)) + continue; + + if (it->id == entry.id) + return badge; + + if (badge < 9) + ++badge; + } + + return 0; +} + +void InkHUD::MapApplet::drawWaypointFallbackMarker(const WaypointMarker &entry, int16_t x, int16_t y) +{ + char badgeText[3]; + snprintf(badgeText, sizeof(badgeText), "%u", (unsigned)fallbackBadgeNumber(entry)); + + // Keep fallback digits centered so they read like map markers. + setFont(fontSmall); + printAt(x, y + 1, badgeText, CENTER, MIDDLE); +} + InkHUD::MapApplet::MapApplet() { if (gpsStatus) gpsStatusObserver.observe(&gpsStatus->onNewStatus); + waypointStoreObserver.observe(&waypointStore); } int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status) @@ -39,6 +125,17 @@ int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status) return 0; } +int InkHUD::MapApplet::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + + if (!isActive()) + return 0; + + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return 0; +} + // Zoom in one step from the current display zoom. void InkHUD::MapApplet::zoomIn() { @@ -72,6 +169,19 @@ void InkHUD::MapApplet::resetZoom() { s_zoomLocked = false; s_lockedZoom = -1; + focusedWaypointId = 0; +} + +bool InkHUD::MapApplet::focusWaypoint(uint32_t waypointId) +{ + const StoredWaypoint *entry = waypointStore.findWaypoint(waypointId); + if (!entry || WaypointStore::isExpired(*entry) || !waypointHasMapGeometry(entry->waypoint)) + return false; + + s_zoomLocked = false; + s_lockedZoom = -1; + focusedWaypointId = waypointId; + return true; } bool InkHUD::MapApplet::canZoomIn() const @@ -405,7 +515,7 @@ void InkHUD::MapApplet::onRender(bool full) chosenZoom = s_lockedZoom; float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad); chosenMetersToPx = 1.0f / mpp; - } else if ((markers.empty() || metersToPxFit <= 0.0f) && nzooms > 0) { + } else if (((markers.empty() && waypointMarkers.empty()) || metersToPxFit <= 0.0f) && nzooms > 0) { // No spread to fit (own node only, or single remote node at map center). Use highest zoom at native scale. chosenZoom = zooms[0]; float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad); @@ -490,6 +600,48 @@ void InkHUD::MapApplet::onRender(bool full) setTextColor(BLACK); } + // Draw waypoint markers after nodes so the boxed icons stay legible. + for (const WaypointMarker &m : waypointMarkers) { + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + const int16_t radiusPx = std::max(1, (int16_t)lroundf(m.geofenceRadiusMeters * metersToPx)); + const int16_t centerX = markerScreenX(m.eastMeters, metersToPx, width()); + const int16_t centerY = markerScreenY(m.northMeters, metersToPx, height()); + drawCircle(centerX, centerY, radiusPx, BLACK); + } + + if (m.hasBoundingBox) { + const int16_t westX = markerScreenX(m.boxWestMeters, metersToPx, width()); + const int16_t eastX = markerScreenX(m.boxEastMeters, metersToPx, width()); + const int16_t northY = markerScreenY(m.boxNorthMeters, metersToPx, height()); + const int16_t southY = markerScreenY(m.boxSouthMeters, metersToPx, height()); + const int16_t left = std::min(westX, eastX); + const int16_t right = std::max(westX, eastX); + const int16_t top = std::min(northY, southY); + const int16_t bottom = std::max(northY, southY); + drawRect(left, top, std::max(1, right - left + 1), std::max(1, bottom - top + 1), BLACK); + } + + if (!m.hasMarker) + continue; + + int16_t x = markerScreenX(m.eastMeters, metersToPx, width()); + int16_t y = markerScreenY(m.northMeters, metersToPx, height()); + constexpr int outlinePad = 1; + const int boxSize = fontSmall.lineHeight() + 2; + const int radius = max(2, boxSize / 6); + + fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE); + drawRoundRect(x - (boxSize / 2), y - (boxSize / 2), boxSize, boxSize, radius, BLACK); + + std::string glyph; + if (mapWaypointIconGlyph(m.icon, glyph)) { + setFont(fontSmall); + printAt(x, y + 1, glyph, CENTER, MIDDLE); + } else { + drawWaypointFallbackMarker(m, x, y); + } + } + // Dual map scale bars if (metersToPx <= 0.0f) return; @@ -588,6 +740,25 @@ void InkHUD::MapApplet::onRender(bool full) void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) { + if (focusedWaypointId != 0) { + const StoredWaypoint *entry = waypointStore.findWaypoint(focusedWaypointId); + if (entry && !WaypointStore::isExpired(*entry) && waypointHasMapGeometry(entry->waypoint)) { + *lat = waypointHasAnchor(entry->waypoint) + ? entry->waypoint.latitude_i * 1e-7f + : ((float)entry->waypoint.bounding_box.latitude_south_i + entry->waypoint.bounding_box.latitude_north_i) * + 0.5e-7f; + *lng = waypointHasAnchor(entry->waypoint) + ? entry->waypoint.longitude_i * 1e-7f + : ((float)entry->waypoint.bounding_box.longitude_west_i + entry->waypoint.bounding_box.longitude_east_i) * + 0.5e-7f; + latCenter = *lat; + lngCenter = *lng; + centerIsOurNode = false; + return; + } + focusedWaypointId = 0; + } + // If we have a valid position for our own node, use that as the anchor const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); meshtastic_PositionLite ourSelfPos; @@ -646,6 +817,34 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) positionCount++; } + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (!waypointHasMapGeometry(entry.waypoint)) + continue; + + const float latDeg = + waypointHasAnchor(entry.waypoint) + ? (entry.waypoint.latitude_i * 1e-7f) + : ((float)entry.waypoint.bounding_box.latitude_south_i + entry.waypoint.bounding_box.latitude_north_i) * + 0.5e-7f; + const float lngDeg = + waypointHasAnchor(entry.waypoint) + ? (entry.waypoint.longitude_i * 1e-7f) + : ((float)entry.waypoint.bounding_box.longitude_west_i + entry.waypoint.bounding_box.longitude_east_i) * + 0.5e-7f; + float latRad = latDeg * DEG_TO_RAD; + float lngRad = lngDeg * DEG_TO_RAD; + float x = cos(latRad) * cos(lngRad); + float y = cos(latRad) * sin(lngRad); + float z = sin(latRad); + + xAvg += x; + yAvg += y; + zAvg += z; + positionCount++; + } + // All NodeDB processed, find mean values if (positionCount == 0) return; @@ -759,15 +958,24 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) float latNode = pos.latitude_i * 1e-7; float lngNode = pos.longitude_i * 1e-7; - northernmost = max(northernmost, latNode); - southernmost = min(southernmost, latNode); + includeMapPoint(latNode, lngNode, lngCenter, northernmost, southernmost, easternmost, westernmost); + } - float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees east from center to node - float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees west from center to node - if (degEastward < degWestward) - easternmost = max(easternmost, lngCenter + degEastward); - else - westernmost = min(westernmost, lngCenter - degWestward); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (waypointHasAnchor(entry.waypoint)) + includeMapPoint(entry.waypoint.latitude_i * 1e-7f, entry.waypoint.longitude_i * 1e-7f, lngCenter, northernmost, + southernmost, easternmost, westernmost); + + if (entry.waypoint.has_bounding_box) { + includeMapPoint(entry.waypoint.bounding_box.latitude_south_i * 1e-7f, + entry.waypoint.bounding_box.longitude_west_i * 1e-7f, lngCenter, northernmost, southernmost, + easternmost, westernmost); + includeMapPoint(entry.waypoint.bounding_box.latitude_north_i * 1e-7f, + entry.waypoint.bounding_box.longitude_east_i * 1e-7f, lngCenter, northernmost, southernmost, + easternmost, westernmost); + } } // Todo: check for issues with map spans >180 deg. MQTT only.. @@ -787,12 +995,48 @@ void InkHUD::MapApplet::getMapSize(uint32_t *widthMeters, uint32_t *heightMeters *widthMeters = 0; *heightMeters = 0; + if (focusedWaypointId != 0) { + for (const WaypointMarker &m : waypointMarkers) { + if (m.id != focusedWaypointId) + continue; + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + *widthMeters = m.geofenceRadiusMeters * 2; + *heightMeters = m.geofenceRadiusMeters * 2; + } + if (m.hasBoundingBox) { + *widthMeters = max(*widthMeters, (uint32_t)std::max(fabsf(m.boxWestMeters), fabsf(m.boxEastMeters)) * 2); + *heightMeters = max(*heightMeters, (uint32_t)std::max(fabsf(m.boxSouthMeters), fabsf(m.boxNorthMeters)) * 2); + } + *widthMeters *= 1.1; + *heightMeters *= 1.1; + return; + } + } + // Find the greatest distance horizontally and vertically from map center for (Marker m : markers) { *widthMeters = max(*widthMeters, (uint32_t)abs(m.eastMeters) * 2); *heightMeters = max(*heightMeters, (uint32_t)abs(m.northMeters) * 2); } + // Waypoints contribute to the fit-all bounding box just like nodes, including geofence extents. + for (const WaypointMarker &m : waypointMarkers) { + if (m.hasMarker) { + *widthMeters = max(*widthMeters, (uint32_t)fabsf(m.eastMeters) * 2); + *heightMeters = max(*heightMeters, (uint32_t)fabsf(m.northMeters) * 2); + } + + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + *widthMeters = max(*widthMeters, (uint32_t)(fabsf(m.eastMeters) + m.geofenceRadiusMeters) * 2); + *heightMeters = max(*heightMeters, (uint32_t)(fabsf(m.northMeters) + m.geofenceRadiusMeters) * 2); + } + + if (m.hasBoundingBox) { + *widthMeters = max(*widthMeters, (uint32_t)std::max(fabsf(m.boxWestMeters), fabsf(m.boxEastMeters)) * 2); + *heightMeters = max(*heightMeters, (uint32_t)std::max(fabsf(m.boxSouthMeters), fabsf(m.boxNorthMeters)) * 2); + } + } + // Add padding *widthMeters *= 1.1; *heightMeters *= 1.1; @@ -928,6 +1172,13 @@ bool InkHUD::MapApplet::enoughMarkers() if (nodeDB->hasValidPosition(node) && shouldDrawNode(node)) return true; } + + // Any live waypoint with a marker or box is enough to justify showing the map. + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (!WaypointStore::isExpired(entry) && waypointHasMapGeometry(entry.waypoint)) + return true; + } + return false; } @@ -937,6 +1188,8 @@ void InkHUD::MapApplet::calculateAllMarkers() { // Clear old markers markers.clear(); + waypointMarkers.clear(); + waypointMarkers.reserve(waypointStore.getWaypoints().size()); // For each node in db for (uint32_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { @@ -965,6 +1218,37 @@ void InkHUD::MapApplet::calculateAllMarkers() markers.push_back(calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away)); } + + // Cache waypoint markers once per render pass to avoid repeated geo math below. + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (!waypointHasMapGeometry(entry.waypoint)) + continue; + + WaypointMarker marker; + marker.id = entry.waypoint.id; + marker.icon = entry.waypoint.icon; + marker.geofenceRadiusMeters = entry.waypoint.geofence_radius; + marker.hasMarker = waypointHasAnchor(entry.waypoint); + marker.hasBoundingBox = entry.waypoint.has_bounding_box; + if (marker.hasMarker) { + Marker base = calculateMarker(entry.waypoint.latitude_i * 1e-7, entry.waypoint.longitude_i * 1e-7, 0); + marker.eastMeters = base.eastMeters; + marker.northMeters = base.northMeters; + } + if (entry.waypoint.has_bounding_box) { + Marker southWest = calculateMarker(entry.waypoint.bounding_box.latitude_south_i * 1e-7, + entry.waypoint.bounding_box.longitude_west_i * 1e-7, 0); + Marker northEast = calculateMarker(entry.waypoint.bounding_box.latitude_north_i * 1e-7, + entry.waypoint.bounding_box.longitude_east_i * 1e-7, 0); + marker.boxWestMeters = southWest.eastMeters; + marker.boxSouthMeters = southWest.northMeters; + marker.boxEastMeters = northEast.eastMeters; + marker.boxNorthMeters = northEast.northMeters; + } + waypointMarkers.push_back(marker); + } } void InkHUD::MapApplet::calculateMapScale() diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h index b447273df9..fa90c0c5dd 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h @@ -16,7 +16,9 @@ The base applet doesn't handle any events; this is left to the derived applets. #include "configuration.h" #include +#include +#include "WaypointStore.h" #include "graphics/niche/InkHUD/Applet.h" #include "GPSStatus.h" @@ -41,6 +43,7 @@ class MapApplet : public Applet void zoomIn(); void zoomOut(); void resetZoom(); + bool focusWaypoint(uint32_t waypointId); bool isZoomLocked() const { return s_zoomLocked; } bool canZoomIn() const; bool canZoomOut() const; @@ -57,6 +60,9 @@ class MapApplet : public Applet int onGpsStatusUpdate(const meshtastic::Status *status); CallbackObserver gpsStatusObserver = CallbackObserver(this, &MapApplet::onGpsStatusUpdate); + int onWaypointStoreChanged(const WaypointStore *store); + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &MapApplet::onWaypointStoreChanged); static bool s_zoomLocked; static int s_lockedZoom; @@ -69,6 +75,24 @@ class MapApplet : public Applet uint8_t hopsAway = 0; // Determines marker size }; + struct WaypointMarker { + float eastMeters = 0; + float northMeters = 0; + uint32_t id = 0; + uint32_t icon = 0; + uint32_t geofenceRadiusMeters = 0; + bool hasMarker = false; + bool hasBoundingBox = false; + float boxWestMeters = 0; + float boxEastMeters = 0; + float boxSouthMeters = 0; + float boxNorthMeters = 0; + }; + + bool mapWaypointIconGlyph(uint32_t codepoint, std::string &glyph); + uint8_t fallbackBadgeNumber(const WaypointMarker &entry); + void drawWaypointFallbackMarker(const WaypointMarker &entry, int16_t x, int16_t y); + Marker calculateMarker(float lat, float lng, uint8_t hopsAway); void calculateAllMarkers(); void calculateMapScale(); // Conversion factor for meters to pixels @@ -81,10 +105,12 @@ class MapApplet : public Applet bool centerIsOurNode = false; // True if map is centered on our own position (GPS or phone) std::list markers; + std::vector waypointMarkers; uint32_t widthMeters = 0; // Map width: meters uint32_t heightMeters = 0; // Map height: meters + uint32_t focusedWaypointId = 0; }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp index 005327baea..de4e2ee85e 100644 --- a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp @@ -205,6 +205,8 @@ IconKind iconKindForAppletName(const char *name) return IconKind::CHANNEL; if (lower.find("position") != std::string::npos) return IconKind::POSITIONS; + if (lower.find("waypoint") != std::string::npos) + return IconKind::POSITIONS; if (lower.find("recent") != std::string::npos) return IconKind::RECENTS; if (lower.find("heard") != std::string::npos) diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index 3b7606e123..b2a5f5ee67 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -141,6 +141,12 @@ enum MenuAction { MAP_ZOOM_IN, MAP_ZOOM_OUT, MAP_ZOOM_RESET, + // Waypoints (WaypointListApplet) + REMOVE_WAYPOINT, + SELECT_GEOFENCE_WAYPOINT, + TOGGLE_GEOFENCE_ENTER, + TOGGLE_GEOFENCE_EXIT, + TOGGLE_GEOFENCE_FAVORITES_ONLY, }; } // namespace NicheGraphics::InkHUD diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 863c1e85d4..6f003d8b24 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -12,9 +12,12 @@ #include "airtime.h" #include "gps/RTC.h" #include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/Utils/FlashData.h" #include "main.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "modules/GeofenceModule.h" +#include "modules/WaypointModule.h" #include #include #if defined(ARCH_ESP32) && HAS_WIFI @@ -1157,6 +1160,35 @@ void InkHUD::MenuApplet::execute(MenuItem item) break; } + case REMOVE_WAYPOINT: { + // cursor - 2 because index 0 is "Back" and index 1 is the "Select to Remove" header + const size_t index = cursor - 2; + if (waypointModule && index < removeWaypointIds.size()) + waypointModule->broadcastDelete(removeWaypointIds.at(index)); + break; + } + + case SELECT_GEOFENCE_WAYPOINT: { + const size_t index = cursor - 2; + if (index < geofenceWaypointIds.size()) + selectedGeofenceWaypointId = geofenceWaypointIds.at(index); + break; + } + + case TOGGLE_GEOFENCE_ENTER: + case TOGGLE_GEOFENCE_EXIT: + case TOGGLE_GEOFENCE_FAVORITES_ONLY: { + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) + break; + const WaypointNotificationPreference preference = + item.action == TOGGLE_GEOFENCE_ENTER + ? WAYPOINT_NOTIFY_ENTER + : (item.action == TOGGLE_GEOFENCE_EXIT ? WAYPOINT_NOTIFY_EXIT : WAYPOINT_NOTIFY_FAVORITES_ONLY); + waypointStore.setNotificationPreference(selectedGeofenceWaypointId, preference, !entry->notificationEnabled(preference)); + break; + } + default: LOG_WARN("Action not implemented"); } @@ -1173,6 +1205,8 @@ void InkHUD::MenuApplet::showPage(MenuPage page) items.clear(); items.shrink_to_fit(); nodeConfigLabels.clear(); + removeWaypointIds.clear(); + geofenceWaypointIds.clear(); switch (page) { case ROOT: @@ -1196,6 +1230,20 @@ void InkHUD::MenuApplet::showPage(MenuPage page) } } + // Remove Waypoint - only when viewing the waypoint list applet + { + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (waypointListApplet && waypointListApplet->waypointCount() > 0) { + items.push_back(MenuItem("Remove Waypoint", MenuPage::REMOVE_WAYPOINT_LIST)); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (GeofenceModule::hasGeofence(entry.waypoint)) { + items.push_back(MenuItem("Geofence Alerts", MenuPage::GEOFENCE_WAYPOINT_LIST)); + break; + } + } + } + } + items.push_back(MenuItem("Options", MenuPage::OPTIONS)); // items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG)); @@ -1213,6 +1261,43 @@ void InkHUD::MenuApplet::showPage(MenuPage page) previousPage = MenuPage::SEND; break; + case REMOVE_WAYPOINT_LIST: + previousPage = MenuPage::ROOT; + populateRemoveWaypointPage(); // must be first + items.insert(items.begin(), MenuItem::Header("Select to Remove")); + items.insert(items.begin(), MenuItem("Back", previousPage)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case GEOFENCE_WAYPOINT_LIST: + previousPage = MenuPage::ROOT; + populateGeofenceWaypointPage(); + items.insert(items.begin(), MenuItem::Header("Select Geofence")); + items.insert(items.begin(), MenuItem("Back", previousPage)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case GEOFENCE_OPTIONS: { + previousPage = MenuPage::GEOFENCE_WAYPOINT_LIST; + items.push_back(MenuItem("Back", previousPage)); + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) { + items.push_back(MenuItem::Header("Geofence unavailable")); + break; + } + const std::string enterLabel = + std::string("Enter Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_ENTER) ? "On" : "Off"); + const std::string exitLabel = + std::string("Exit Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_EXIT) ? "On" : "Off"); + const std::string favoritesLabel = + std::string("Favorites Only: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY) ? "On" : "Off"); + items.push_back(MenuItem(enterLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_ENTER, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem(exitLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_EXIT, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem(favoritesLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_FAVORITES_ONLY, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + } + case OPTIONS: previousPage = MenuPage::ROOT; items.push_back(MenuItem("Back", previousPage)); @@ -2442,6 +2527,40 @@ void InkHUD::MenuApplet::populateRecipientPage() items.push_back(MenuItem("Exit", MenuPage::EXIT)); } +// Dynamically create MenuItem entries for each waypoint in the active WaypointListApplet +void InkHUD::MenuApplet::populateRemoveWaypointPage() +{ + assert(items.size() == 0); + + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (waypointListApplet) { + for (size_t i = 0; i < waypointListApplet->waypointCount(); i++) { + removeWaypointIds.push_back(waypointListApplet->waypointIdAt(i)); + items.push_back(MenuItem(waypointListApplet->waypointLabelAt(i).c_str(), MenuAction::REMOVE_WAYPOINT, + MenuPage::REMOVE_WAYPOINT_LIST)); + } + } +} + +void InkHUD::MenuApplet::populateGeofenceWaypointPage() +{ + assert(items.empty()); + + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (!waypointListApplet) + return; + + for (size_t i = 0; i < waypointListApplet->waypointCount(); ++i) { + const uint32_t id = waypointListApplet->waypointIdAt(i); + const StoredWaypoint *entry = waypointStore.findWaypoint(id); + if (!entry || !GeofenceModule::hasGeofence(entry->waypoint)) + continue; + geofenceWaypointIds.push_back(id); + items.push_back(MenuItem(waypointListApplet->waypointLabelAt(i).c_str(), MenuAction::SELECT_GEOFENCE_WAYPOINT, + MenuPage::GEOFENCE_OPTIONS)); + } +} + void InkHUD::MenuApplet::drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text) { setFont(fontSmall); diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h index 2d7fbd398b..28289203df 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h @@ -55,6 +55,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread 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 populateRemoveWaypointPage(); // Create menu items: one per waypoint in the active WaypointListApplet + void populateGeofenceWaypointPage(); void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text); // Draw input field for free text @@ -74,7 +76,10 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread uint16_t systemInfoPanelHeight = 0; // Need to know before we render uint16_t menuTextLimit = 200; - std::vector items; // MenuItems for the current page. Filled by ShowPage + std::vector items; // MenuItems for the current page. Filled by ShowPage + std::vector removeWaypointIds; // Parallel to items, for REMOVE_WAYPOINT page + std::vector geofenceWaypointIds; + uint32_t selectedGeofenceWaypointId = 0; std::vector nodeConfigLabels; // Persistent labels for Node Config pages uint8_t selectedChannelIndex = 0; // Currently selected LoRa channel (Node Config → Radio → Channel) bool channelPositionEnabled = false; diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h index 925aa70872..655a823d84 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h @@ -19,6 +19,9 @@ enum MenuPage : uint8_t { ROOT, // Initial menu page SEND, CANNEDMESSAGE_RECIPIENT, // Select destination for a canned message + REMOVE_WAYPOINT_LIST, // Pick a waypoint to delete (WaypointListApplet only) + GEOFENCE_WAYPOINT_LIST, + GEOFENCE_OPTIONS, OPTIONS, NODE_CONFIG, NODE_CONFIG_LORA, diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h b/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h index d8c4f83662..7e7fe80912 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h @@ -19,7 +19,12 @@ namespace NicheGraphics::InkHUD class Notification { public: - enum Type : uint8_t { NOTIFICATION_MESSAGE_BROADCAST, NOTIFICATION_MESSAGE_DIRECT, NOTIFICATION_BATTERY } type; + enum Type : uint8_t { + NOTIFICATION_MESSAGE_BROADCAST, + NOTIFICATION_MESSAGE_DIRECT, + NOTIFICATION_BATTERY, + NOTIFICATION_GEOFENCE + } type; uint32_t timestamp; @@ -33,8 +38,12 @@ class Notification uint8_t channel; uint32_t sender; uint8_t batteryPercentage; + char geofenceNodeName[sizeof(meshtastic_User::long_name)]; + char geofenceName[sizeof(meshtastic_Waypoint::name)]; + uint32_t geofenceWaypointId; + bool geofenceEntered; }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp index e2090269ea..d4f900c9dc 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -4,7 +4,12 @@ #include "./Notification.h" #include "MessageStore.h" +#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" #include "graphics/niche/InkHUD/Persistence.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "modules/GeofenceModule.h" +#include +#endif #include "meshUtils.h" #include "modules/TextMessageModule.h" @@ -16,6 +21,27 @@ using namespace NicheGraphics; InkHUD::NotificationApplet::NotificationApplet() { textMessageObserver.observe(textMessageModule); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + if (geofenceModule) + geofenceObserver.observe(geofenceModule); +#endif +} + +void InkHUD::NotificationApplet::showNotification(const Notification &n) +{ + assert(isActive()); + + if (!settings->optionalFeatures.notifications) + return; + + dismiss(); + hasNotification = true; + currentNotification = n; + if (isApproved()) { + bringToForeground(); + inkhud->forceUpdate(); + } else + hasNotification = false; } // Collect meta-info about the text message, and ask for approval for the notification @@ -25,11 +51,6 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket // System applets are always active assert(isActive()); - // Abort if feature disabled - // This is a bit clumsy, but avoids complicated handling when the feature is enabled / disabled - if (!settings->optionalFeatures.notifications) - return 0; - // Abort if this is an outgoing message if (getFrom(p) == nodeDB->getNodeNum()) return 0; @@ -49,23 +70,35 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket n.sender = p->from; } - // Close an old notification, if shown - dismiss(); - - // Check if we should display the notification - // A foreground applet might already be displaying this info - hasNotification = true; - currentNotification = n; - if (isApproved()) { - bringToForeground(); - inkhud->forceUpdate(); - } else - hasNotification = false; // Clear the pending notification: it was rejected + showNotification(n); // Return zero: no issues here, carry on notifying other observers! return 0; } +#if !MESHTASTIC_EXCLUDE_WAYPOINT +int InkHUD::NotificationApplet::onGeofenceEvent(const GeofenceNotificationEvent *event) +{ + assert(isActive()); + + if (!event) + return 0; + + Notification n; + n.type = Notification::Type::NOTIFICATION_GEOFENCE; + n.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); + n.geofenceWaypointId = event->waypointId; + strncpy(n.geofenceName, event->geofenceName, sizeof(n.geofenceName) - 1); + n.geofenceName[sizeof(n.geofenceName) - 1] = '\0'; + strncpy(n.geofenceNodeName, event->nodeName, sizeof(n.geofenceNodeName) - 1); + n.geofenceNodeName[sizeof(n.geofenceNodeName) - 1] = '\0'; + n.geofenceEntered = event->entered; + + showNotification(n); + return 0; +} +#endif + void InkHUD::NotificationApplet::onRender(bool full) { // Clear the region beneath the tile @@ -145,7 +178,10 @@ void InkHUD::NotificationApplet::onBackground() void InkHUD::NotificationApplet::onButtonShortPress() { - dismiss(); + if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) + openGeofenceOnMap(); + else + dismiss(); } void InkHUD::NotificationApplet::onButtonLongPress() @@ -180,6 +216,25 @@ void InkHUD::NotificationApplet::onNavLeft() void InkHUD::NotificationApplet::onNavRight() { + if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) + openGeofenceOnMap(); + else + dismiss(); +} + +void InkHUD::NotificationApplet::openGeofenceOnMap() +{ + for (uint8_t i = 0; i < inkhud->userApplets.size(); ++i) { + Applet *applet = inkhud->userApplets.at(i); + MapApplet *map = applet ? applet->asMapApplet() : nullptr; + if (!map || !applet->isActive() || !map->focusWaypoint(currentNotification.geofenceWaypointId)) + continue; + + dismiss(); + inkhud->showApplet(i); + return; + } + dismiss(); } @@ -267,6 +322,12 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila } } + else if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) { + text += currentNotification.geofenceNodeName; + text += currentNotification.geofenceEntered ? " IN " : " OUT "; + text += currentNotification.geofenceName; + } + // Parse any non-ascii characters and return return parse(text); } diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h index d398a36f32..02f6a89139 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h @@ -15,8 +15,10 @@ Feature should be optional; enable disable via on-screen menu #include "configuration.h" #include "concurrency/OSThread.h" - #include "graphics/niche/InkHUD/SystemApplet.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +struct GeofenceNotificationEvent; +#endif namespace NicheGraphics::InkHUD { @@ -39,6 +41,9 @@ class NotificationApplet : public SystemApplet void onNavRight() override; int onReceiveTextMessage(const meshtastic_MeshPacket *p); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + int onGeofenceEvent(const GeofenceNotificationEvent *event); +#endif bool isApproved(); // Does a foreground applet make notification redundant? void dismiss(); // Close the Notification Popup @@ -47,13 +52,19 @@ class NotificationApplet : public SystemApplet // Get notified when a new text message arrives CallbackObserver textMessageObserver = CallbackObserver(this, &NotificationApplet::onReceiveTextMessage); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + CallbackObserver geofenceObserver = + CallbackObserver(this, &NotificationApplet::onGeofenceEvent); +#endif + void showNotification(const Notification &n); + void openGeofenceOnMap(); std::string getNotificationText(uint16_t widthAvailable); // Get text for notification, to suit screen width - bool hasNotification = false; // Only used for assert. Todo: remove? + bool hasNotification = false; Notification currentNotification = Notification(); // Set when something notification-worthy happens. Used by render() }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h index 5cb20ed3b2..a5b675bf00 100644 --- a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h +++ b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h @@ -42,4 +42,4 @@ class PositionsApplet : public MapApplet, public SinglePortModule } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp new file mode 100644 index 0000000000..4023482b43 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp @@ -0,0 +1,572 @@ +#if defined(MESHTASTIC_INCLUDE_INKHUD) + +#include "./WaypointListApplet.h" + +#include "GeoCoord.h" +#include "NodeDB.h" +#include "RTC.h" +#include "WaypointStore.h" +#include "WaypointUtils.h" +#include "modules/WaypointModule.h" + +#include +#include +#include + +using namespace NicheGraphics; + +namespace +{ + +uint32_t fnv1aAppend(uint32_t hash, const char *text) +{ + while (*text) { + hash ^= (uint8_t)*text++; + hash *= 16777619u; + } + + return hash; +} + +} // namespace + +InkHUD::WaypointListApplet::WaypointListApplet() : concurrency::OSThread("WaypointListApplet") +{ + OSThread::disable(); +} + +void InkHUD::WaypointListApplet::onActivate() +{ + setInputsSubscribed(NAV_UP | NAV_DOWN, true); + scrollOffset = 0; + hasRenderHash = false; + waypointStoreObserver.observe(&waypointStore); + waypointStore.purgeExpired(); + updateRefreshTimer(); +} + +void InkHUD::WaypointListApplet::onDeactivate() +{ + waypointStoreObserver.unobserve(&waypointStore); + setInputsSubscribed(NAV_UP | NAV_DOWN, false); + OSThread::disable(); +} + +int32_t InkHUD::WaypointListApplet::runOnce() +{ + bool needsUpdate = false; + if (isActive()) + waypointStore.purgeExpired(); + + if (isActive() && !waypointStore.getWaypoints().empty()) { + const uint32_t renderHash = buildRenderHash(); + if (!hasRenderHash || renderHash != lastRenderHash) { + lastRenderHash = renderHash; + hasRenderHash = true; + needsUpdate = true; + } + + updateRefreshTimer(); + } + + if (isActive() && needsUpdate) + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + + return OSThread::interval; +} + +void InkHUD::WaypointListApplet::updateRefreshTimer() +{ + if (waypointStore.getWaypoints().empty()) { + OSThread::disable(); + return; + } + + OSThread::enabled = true; + OSThread::setIntervalFromNow(nextRefreshIntervalMs()); +} + +bool InkHUD::WaypointListApplet::hasDescription(const meshtastic_Waypoint &waypoint) +{ + return waypoint.description[0] != '\0'; +} + +uint8_t InkHUD::WaypointListApplet::rowHeight(const meshtastic_Waypoint &waypoint) +{ + const uint8_t lines = hasDescription(waypoint) ? 3 : 2; + return (fontSmall.lineHeight() * lines) + lines; +} + +uint8_t InkHUD::WaypointListApplet::visibleRows(uint8_t start) +{ + const auto &waypoints = waypointStore.getWaypoints(); + const int16_t contentTop = getHeaderHeight() + 2; + const uint16_t availableH = (height() > contentTop) ? (height() - contentTop) : 1; + if (waypoints.empty() || start >= waypoints.size()) + return 0; + + uint16_t usedH = 0; + uint8_t count = 0; + for (uint8_t i = start; i < waypoints.size(); ++i) { + const uint8_t nextH = rowHeight(waypoints.at(i).waypoint); + if (count > 0 && usedH + nextH > availableH) + break; + + usedH += nextH; + ++count; + + if (usedH >= availableH) + break; + } + + return count; +} + +uint8_t InkHUD::WaypointListApplet::maxScrollOffset() +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return 0; + + const int16_t contentTop = getHeaderHeight() + 2; + const uint16_t availableH = (height() > contentTop) ? (height() - contentTop) : 1; + uint16_t usedH = 0; + uint8_t start = (uint8_t)waypoints.size(); + + while (start > 0) { + const uint8_t nextH = rowHeight(waypoints.at(start - 1).waypoint); + if (usedH > 0 && usedH + nextH > availableH) + break; + + usedH += nextH; + --start; + + if (usedH >= availableH) + break; + } + + return start; +} + +bool InkHUD::WaypointListApplet::rowIndexAt(int16_t y, uint8_t &indexOut) +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return false; + + const uint8_t start = std::min(scrollOffset, (uint8_t)waypoints.size() - 1); + const uint8_t rows = visibleRows(start); + const uint8_t end = std::min((uint8_t)waypoints.size(), start + rows); + + // Walk the same row layout used by onRender, stopping at whichever row contains y + int16_t rowTop = getHeaderHeight() + 2; + for (uint8_t i = start; i < end; ++i) { + const uint8_t rowH = rowHeight(waypoints.at(i).waypoint); + if (y < rowTop + rowH) { + indexOut = i; + return true; + } + rowTop += rowH; + } + + return false; +} + +void InkHUD::WaypointListApplet::scrollBy(int delta) +{ + const int next = std::clamp((int)scrollOffset + delta, 0, maxScrollOffset()); + if (next == scrollOffset) + return; + + scrollOffset = (uint8_t)next; + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::WaypointListApplet::onNavUp() +{ + scrollBy(-1); +} + +void InkHUD::WaypointListApplet::onNavDown() +{ + scrollBy(1); +} + +bool InkHUD::WaypointListApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress) +{ + (void)x; + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty() || y < getHeaderHeight()) + return false; + + // Long press a row to delete that waypoint (broadcasts the deletion to the mesh too) + if (longPress) { + uint8_t index = 0; + if (!rowIndexAt(y, index)) + return false; + + if (waypointModule) + waypointModule->broadcastDelete(waypoints.at(index).waypoint.id); + return true; + } + + const uint16_t midpoint = getHeaderHeight() + ((height() - getHeaderHeight()) / 2); + scrollBy(y < midpoint ? -1 : 1); + return true; +} + +int InkHUD::WaypointListApplet::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + if (!isActive()) + return 0; + + syncListState(); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return 0; +} + +std::string InkHUD::WaypointListApplet::headerText() +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return "Waypoints"; + + const uint8_t rows = visibleRows(scrollOffset); + const uint8_t first = scrollOffset + 1; + const uint8_t last = std::min((uint8_t)waypoints.size(), scrollOffset + rows); + + char buf[32]; + snprintf(buf, sizeof(buf), "Waypoints %u-%u/%u", first, last, (unsigned)waypoints.size()); + return buf; +} + +std::string InkHUD::WaypointListApplet::waypointName(const meshtastic_Waypoint &waypoint) +{ + if (waypoint.name[0]) + return parse(waypoint.name); + + char buf[20]; + snprintf(buf, sizeof(buf), "Waypoint 0x%x", (unsigned)waypoint.id); + return buf; +} + +std::string InkHUD::WaypointListApplet::waypointDescription(const meshtastic_Waypoint &waypoint) +{ + if (!hasDescription(waypoint)) + return ""; + + return parse(waypoint.description); +} + +std::string InkHUD::WaypointListApplet::coordinateText(const meshtastic_Waypoint &waypoint, bool landscape) +{ + if (!waypoint.has_latitude_i || !waypoint.has_longitude_i) + return "--"; + + const uint8_t decimals = landscape ? (width() >= 220 ? 4 : 3) : (width() >= 140 ? 3 : 2); + const double lat = waypoint.latitude_i * 1e-7; + const double lon = waypoint.longitude_i * 1e-7; + + char buf[40]; + snprintf(buf, sizeof(buf), "%.*f,%.*f", decimals, lat, decimals, lon); + return buf; +} + +bool InkHUD::WaypointListApplet::tryGetOwnPosition(meshtastic_PositionLite &out) +{ + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + return ourNode && nodeDB->copyNodePosition(ourNode->num, out) && (out.latitude_i != 0 || out.longitude_i != 0); +} + +std::string InkHUD::WaypointListApplet::distanceText(const meshtastic_Waypoint &waypoint) +{ + if (!waypoint.has_latitude_i || !waypoint.has_longitude_i) + return ""; + + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; + if (!tryGetOwnPosition(ownPos)) + return ""; + + const float meters = GeoCoord::latLongToMeter(waypoint.latitude_i * 1e-7, waypoint.longitude_i * 1e-7, + ownPos.latitude_i * 1e-7, ownPos.longitude_i * 1e-7); + if (meters < 0) + return ""; + + return localizeDistance((uint32_t)std::lround(meters)); +} + +std::string InkHUD::WaypointListApplet::expireText(uint32_t expireEpoch) +{ + if (expireEpoch == 0) + return "--"; + + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now == 0) + return ""; + if (expireEpoch <= now) + return "exp"; + + const uint32_t left = expireEpoch - now; + char buf[12]; + if (left < 3600) + snprintf(buf, sizeof(buf), "%lum", (unsigned long)((left + 59) / 60)); + else if (left < 86400) + snprintf(buf, sizeof(buf), "%luh", (unsigned long)((left + 3599) / 3600)); + else + snprintf(buf, sizeof(buf), "%lud", (unsigned long)((left + 86399) / 86400)); + return buf; +} + +uint32_t InkHUD::WaypointListApplet::nextExpiryUpdateMs(uint32_t secondsLeft) +{ + if (secondsLeft < 60) + return secondsLeft * 1000UL; + + const uint32_t step = (secondsLeft < 3600) ? 60UL : (secondsLeft < 86400 ? 3600UL : 86400UL); + return ((((secondsLeft - 1) % step) + 1) * 1000UL); +} + +uint32_t InkHUD::WaypointListApplet::nextRefreshIntervalMs() +{ + static constexpr uint32_t WAITING_STATE_REFRESH_MS = 1000UL; + static constexpr uint32_t GPS_DISTANCE_REFRESH_MS = 5000UL; + static constexpr uint32_t IDLE_REFRESH_MS = 60000UL; + + uint32_t intervalMs = UINT32_MAX; + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; + const bool haveOwnPos = tryGetOwnPosition(ownPos); + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &waypoint = entry.waypoint; + if (waypoint.expire != 0) { + if (now == 0) { + intervalMs = std::min(intervalMs, WAITING_STATE_REFRESH_MS); + } else if (waypoint.expire > now) { + intervalMs = std::min(intervalMs, nextExpiryUpdateMs(waypoint.expire - now)); + } + } + + if (waypoint.has_latitude_i && waypoint.has_longitude_i) { + if (!haveOwnPos) { + intervalMs = std::min(intervalMs, WAITING_STATE_REFRESH_MS); + } else if (!config.position.fixed_position) { + intervalMs = std::min(intervalMs, GPS_DISTANCE_REFRESH_MS); + } + } + } + + if (intervalMs == UINT32_MAX) + return IDLE_REFRESH_MS; + + return intervalMs; +} + +uint32_t InkHUD::WaypointListApplet::buildRenderHash() +{ + uint32_t hash = 2166136261u; + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &waypoint = entry.waypoint; + char idBuf[11]; + snprintf(idBuf, sizeof(idBuf), "%lu", (unsigned long)waypoint.id); + hash = fnv1aAppend(hash, idBuf); + hash = fnv1aAppend(hash, "|"); + + const std::string distance = distanceText(waypoint); + hash = fnv1aAppend(hash, distance.c_str()); + hash = fnv1aAppend(hash, "|"); + + const std::string expire = expireText(waypoint.expire); + hash = fnv1aAppend(hash, expire.c_str()); + hash = fnv1aAppend(hash, ";"); + } + + return hash; +} + +void InkHUD::WaypointListApplet::syncListState() +{ + scrollOffset = std::min(scrollOffset, maxScrollOffset()); + hasRenderHash = false; + // Re-arm the timer whenever visible waypoint state changes. + updateRefreshTimer(); +} + +bool InkHUD::WaypointListApplet::canRenderWaypointIcon(const meshtastic_Waypoint &waypoint, std::string *mapped) +{ + if (!waypoint.icon) + return false; + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(waypoint.icon); + if (utf8.empty()) + return false; + + const std::string glyph = getFont().decodeUTF8(utf8); + if (glyph.size() != 1 || glyph[0] == '\x1A' || glyph[0] == '\x7F') + return false; + + if (mapped) + *mapped = glyph; + return true; +} + +uint8_t InkHUD::WaypointListApplet::fallbackBadgeNumber(const meshtastic_Waypoint &waypoint) +{ + uint8_t badge = 0; + + for (auto it = waypointStore.getWaypoints().rbegin(); it != waypointStore.getWaypoints().rend(); ++it) { + const meshtastic_Waypoint &candidate = it->waypoint; + if (canRenderWaypointIcon(candidate)) + continue; + + if (candidate.id == waypoint.id) + return badge; + + if (badge < 9) + ++badge; + } + + return 0; +} + +bool InkHUD::WaypointListApplet::drawWaypointIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t centerY, + uint16_t boxSize) +{ + std::string mappedGlyph; + if (!canRenderWaypointIcon(waypoint, &mappedGlyph)) + return false; + + printAt(left + (boxSize / 2), centerY, mappedGlyph, CENTER, MIDDLE); + return true; +} + +void InkHUD::WaypointListApplet::drawFallbackIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t rowTop, + uint16_t boxWidth, uint16_t rowHeight) +{ + char badgeText[3]; + snprintf(badgeText, sizeof(badgeText), "%u", (unsigned)fallbackBadgeNumber(waypoint)); + + setFont(fontSmall); + + const int16_t cx = left + (boxWidth / 2); + const uint16_t badgeTextW = std::max(getTextWidth(badgeText), getTextWidth("0")); + const int16_t centerY = rowTop + (fontSmall.lineHeight() / 2) + 1; + const int16_t boxSize = std::max((int16_t)boxWidth, (int16_t)(badgeTextW + 5)); + const int16_t radius = std::max(2, boxSize / 6); + const int16_t boxLeft = cx - (boxSize / 2); + const int16_t boxTop = centerY - (boxSize / 2); + + // Match the boxed fallback marker style used on the map. + fillRoundRect(boxLeft, boxTop, boxSize, boxSize, radius, WHITE); + drawRoundRect(boxLeft, boxTop, boxSize, boxSize, radius, BLACK); + + setCrop(left - 1, rowTop, boxWidth + 3, rowHeight); + printAt(cx, centerY + 1, badgeText, CENTER, MIDDLE); + resetCrop(); +} + +void InkHUD::WaypointListApplet::onRender(bool full) +{ + (void)full; + + const bool landscape = width() > height(); + const auto &waypoints = waypointStore.getWaypoints(); + drawHeader(headerText()); + + if (waypoints.empty()) { + setFont(fontMedium); + printAt(X(0.5f), Y(0.5f), "No Waypoints", CENTER, MIDDLE); + return; + } + + setFont(fontSmall); + + const int16_t contentTop = getHeaderHeight() + 2; + const uint8_t start = std::min(scrollOffset, (uint8_t)waypoints.size() - 1); + const uint8_t rows = visibleRows(start); + const uint8_t end = std::min((uint8_t)waypoints.size(), start + rows); + const uint16_t iconW = fontSmall.lineHeight(); + const uint16_t gap = 2; + + auto ellipsizeToWidth = [this](std::string text, uint16_t maxWidth) { + constexpr const char *ellipsis = "..."; + const uint16_t ellipsisW = getTextWidth(ellipsis); + uint16_t textW = getTextWidth(text); + if (maxWidth == 0) + return std::string(); + if (textW <= maxWidth) + return text; + if (ellipsisW > maxWidth) + return std::string(); + while (!text.empty() && (textW + ellipsisW > maxWidth)) { + text.pop_back(); + textW = getTextWidth(text); + } + return text + ellipsis; + }; + + int16_t rowTop = contentTop; + for (uint8_t i = start; i < end; ++i) { + const meshtastic_Waypoint &waypoint = waypoints.at(i).waypoint; + const uint8_t rowH = rowHeight(waypoint); + const int16_t line1Y = rowTop + (fontSmall.lineHeight() / 2) + 1; + const int16_t line2Y = rowTop + fontSmall.lineHeight() + 1; + const int16_t metaY = + rowTop + (hasDescription(waypoint) ? ((fontSmall.lineHeight() * 2) + 2) : (fontSmall.lineHeight() + 1)); + + if (!drawWaypointIcon(waypoint, 1, line1Y, iconW - 1)) + drawFallbackIcon(waypoint, 0, rowTop, iconW, rowH); + + const std::string name = waypointName(waypoint); + const std::string description = waypointDescription(waypoint); + const std::string distance = distanceText(waypoint); + const std::string coord = coordinateText(waypoint, landscape); + const std::string expire = expireText(waypoint.expire); + + const int16_t nameLeft = iconW + gap; + int16_t nameRight = width() - 1; + if (!distance.empty()) { + printAt(nameRight, line1Y, distance, RIGHT, MIDDLE); + nameRight -= getTextWidth(distance) + gap; + } + + const uint16_t nameWidth = (nameRight >= nameLeft) ? ((nameRight - nameLeft) + 1) : 0; + const std::string shown = ellipsizeToWidth(name, nameWidth); + const uint16_t shownWidth = getTextWidth(shown); + setCrop(nameLeft, rowTop, nameWidth, fontSmall.lineHeight() + 2); + printThick(nameLeft + (shownWidth / 2), line1Y, shown, 2, 1); + resetCrop(); + + if (!description.empty()) { + const std::string descShown = ellipsizeToWidth(description, nameWidth); + setCrop(nameLeft, line2Y - 1, nameWidth, fontSmall.lineHeight() + 2); + printAt(nameLeft, line2Y, descShown, LEFT, TOP); + resetCrop(); + } + + int16_t metaRight = width() - 1; + if (!expire.empty()) { + printAt(metaRight, metaY, expire, RIGHT, TOP); + metaRight -= getTextWidth(expire) + gap; + } + + const uint16_t coordWidth = (metaRight >= nameLeft) ? ((metaRight - nameLeft) + 1) : 0; + const std::string coordShown = ellipsizeToWidth(coord, coordWidth); + setCrop(nameLeft, metaY - 1, coordWidth, fontSmall.lineHeight() + 2); + printAt(nameLeft, metaY, coordShown, LEFT, TOP); + resetCrop(); + + const int16_t separatorY = rowTop + rowH - 1; + if (separatorY < height() - 1 && i + 1 < end) { + for (int16_t x = 0; x < width(); x += 2) + drawPixel(x, separatorY, BLACK); + } + + rowTop += rowH; + } +} + +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h new file mode 100644 index 0000000000..73bfd26e3d --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h @@ -0,0 +1,78 @@ +#if defined(MESHTASTIC_INCLUDE_INKHUD) + +#pragma once + +#include "configuration.h" + +#include "Observer.h" +#include "WaypointStore.h" +#include "concurrency/OSThread.h" +#include "graphics/niche/InkHUD/Applet.h" +#include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "mesh/generated/meshtastic/mesh.pb.h" + +#include + +namespace NicheGraphics::InkHUD +{ + +class WaypointListApplet : public Applet, public concurrency::OSThread +{ + public: + WaypointListApplet(); + + void onActivate() override; + void onDeactivate() override; + void onRender(bool full) override; + void onNavUp() override; + void onNavDown() override; + bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override; + + WaypointListApplet *asWaypointListApplet() override { return this; } // Identify as WaypointListApplet without RTTI + + // Read-only access for MenuApplet's "Remove Waypoint" page + size_t waypointCount() const { return waypointStore.getWaypoints().size(); } + uint32_t waypointIdAt(size_t index) const { return waypointStore.getWaypoints().at(index).waypoint.id; } + std::string waypointLabelAt(size_t index) { return waypointName(waypointStore.getWaypoints().at(index).waypoint); } + + protected: + int32_t runOnce() override; + + private: + void updateRefreshTimer(); + uint8_t visibleRows(uint8_t start); + uint8_t rowHeight(const meshtastic_Waypoint &waypoint); + uint8_t maxScrollOffset(); + void scrollBy(int delta); + bool rowIndexAt(int16_t y, uint8_t &indexOut); // Which waypoint row is at this y, if any + bool tryGetOwnPosition(meshtastic_PositionLite &out); + uint32_t nextExpiryUpdateMs(uint32_t secondsLeft); + uint32_t nextRefreshIntervalMs(); + uint32_t buildRenderHash(); + void syncListState(); + + std::string headerText(); + std::string waypointName(const meshtastic_Waypoint &waypoint); + std::string waypointDescription(const meshtastic_Waypoint &waypoint); + std::string coordinateText(const meshtastic_Waypoint &waypoint, bool landscape); + std::string distanceText(const meshtastic_Waypoint &waypoint); + std::string expireText(uint32_t expireEpoch); + bool canRenderWaypointIcon(const meshtastic_Waypoint &waypoint, std::string *mapped = nullptr); + uint8_t fallbackBadgeNumber(const meshtastic_Waypoint &waypoint); + bool drawWaypointIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t centerY, uint16_t boxSize); + void drawFallbackIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t rowTop, uint16_t boxWidth, + uint16_t rowHeight); + bool hasDescription(const meshtastic_Waypoint &waypoint); + + int onWaypointStoreChanged(const WaypointStore *store); + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &WaypointListApplet::onWaypointStoreChanged); + + uint8_t scrollOffset = 0; + uint32_t lastRenderHash = 0; + bool hasRenderHash = false; +}; + +} // namespace NicheGraphics::InkHUD + +#endif diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index 1a5e438d93..505cb37384 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -4,6 +4,7 @@ #include "MessageStore.h" #include "PowerFSM.h" +#include "WaypointStore.h" #include "buzz.h" #include "gps/RTC.h" #include "modules/ExternalNotificationModule.h" @@ -468,6 +469,7 @@ int InkHUD::Events::beforeDeepSleep(void *unused) inkhud->persistence->saveSettings(); inkhud->persistence->saveLatestMessage(); + waypointStore.saveToFlash(); // LogoApplet::onShutdown attempted to heal the display by drawing a "shutting down" screen twice, // then prepared a final powered-off screen for us, which shows device shortname. @@ -516,6 +518,7 @@ int InkHUD::Events::beforeReboot(void *unused) } else { NicheGraphics::clearFlashData(); messageStore.clearAllMessages(); // also wipe the shared message store + waypointStore.clearAllWaypoints(); } // Note: no forceUpdate call here diff --git a/src/main.cpp b/src/main.cpp index 63f259b949..89ff9de383 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,7 @@ #include "Power.h" #include "SPILock.h" #include "Throttle.h" +#include "WaypointStore.h" #include "concurrency/OSThread.h" #include "concurrency/Periodic.h" #include "detect/ScanI2C.h" @@ -1095,6 +1096,10 @@ void setup() // Now that the mesh service is created, create any modules setupModules(); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.loadFromFlash(); +#endif + #if !MESHTASTIC_EXCLUDE_I2C // Inform modules about I2C devices ScanI2CCompleted(i2cScanner.get()); @@ -1541,6 +1546,12 @@ void loop() #endif #if (HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)) && ENABLE_MESSAGE_PERSISTENCE messageStoreAutosaveTick(); +#endif +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.purgeExpired(); +#endif +#if !MESHTASTIC_EXCLUDE_WAYPOINT && ENABLE_WAYPOINT_PERSISTENCE + waypointStoreAutosaveTick(); #endif long delayMsec = mainController.runOrDelay(); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 01bf78bcc3..9239a1d1ca 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -20,6 +20,9 @@ #include "TransmitHistory.h" #include "TypeConversions.h" #include "UptimeClock.h" +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT +#include "WaypointStore.h" +#endif #include "error.h" #include "gps/RTC.h" #include "main.h" @@ -839,6 +842,9 @@ bool NodeDB::factoryReset(bool eraseBleBonds) #if HAS_SCREEN messageStore.clearAllMessages(); #endif +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.clearAllWaypoints(); +#endif #if WARM_NODE_COUNT > 0 // On nRF52840 the warm tier lives in raw flash outside /prefs, so rmDir diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 420697689f..f0e22d485d 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -449,11 +449,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP (moduleConfig.external_notification.alert_message_buzzer && !is_muted))); if (genericShouldAlert || vibraShouldAlert || buzzerShouldAlert) { - nagCycleCutoff = millis() + (moduleConfig.external_notification.nag_timeout - ? (moduleConfig.external_notification.nag_timeout * 1000) - : moduleConfig.external_notification.output_ms); - LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); - isNagging = true; + armNagCycle(); } if (genericShouldAlert) { @@ -463,19 +459,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP if (vibraShouldAlert) { LOG_INFO("externalNotificationModule - Vibra alert"); -#ifdef HAS_DRV2605 - // Set DRV2605 waveform when vibration alert is triggered - drv.setWaveform(0, 16); // Long buzzer 100% - drv.setWaveform(1, 0); // Pause - drv.setWaveform(2, 16); - drv.setWaveform(3, 0); - drv.setWaveform(4, 16); - drv.setWaveform(5, 0); - drv.setWaveform(6, 16); - drv.setWaveform(7, 0); - drv.go(); -#endif - setExternalState(1, true); + triggerVibraOutput(); } if (buzzerShouldAlert) { @@ -484,15 +468,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP LOG_INFO("Buzzer suppressed: mode DIRECT_MSG_ONLY"); } else { // Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us - if (moduleConfig.external_notification.use_i2s_as_buzzer) { -#ifdef HAS_I2S - audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); -#endif - } else if (moduleConfig.external_notification.use_pwm) { - rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); - } else { - setExternalState(2, true); - } + triggerBuzzerOutput(); } } @@ -505,6 +481,80 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP return ProcessMessage::CONTINUE; // Let others look at this message also if they want } +void ExternalNotificationModule::triggerBuzzerOutput() +{ + if (moduleConfig.external_notification.use_i2s_as_buzzer) { +#ifdef HAS_I2S + audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); +#endif + } else if (moduleConfig.external_notification.use_pwm) { + rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); + } else { + setExternalState(2, true); + } +} + +void ExternalNotificationModule::triggerVibraOutput() +{ +#ifdef HAS_DRV2605 + drv.setWaveform(0, 16); + drv.setWaveform(1, 0); + drv.setWaveform(2, 16); + drv.setWaveform(3, 0); + drv.setWaveform(4, 16); + drv.setWaveform(5, 0); + drv.setWaveform(6, 16); + drv.setWaveform(7, 0); + drv.go(); +#endif + setExternalState(1, true); +} + +void ExternalNotificationModule::armNagCycle() +{ + const uint32_t durationMs = moduleConfig.external_notification.nag_timeout + ? moduleConfig.external_notification.nag_timeout * 1000UL + : moduleConfig.external_notification.output_ms; + nagCycleCutoff = millis() + durationMs; + LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); + isNagging = true; +} + +void ExternalNotificationModule::startNotification() +{ + if (!moduleConfig.external_notification.enabled || isSilenced) + return; + + // Waypoint and geofence events are neither direct messages nor bells. + const bool buzzerModeIsDirectOnly = (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY); + + const bool generic = moduleConfig.external_notification.alert_message; + const bool vibra = moduleConfig.external_notification.alert_message_vibra; + const bool buzzer = canBuzz() && moduleConfig.external_notification.alert_message_buzzer && !buzzerModeIsDirectOnly; + if (canBuzz() && moduleConfig.external_notification.alert_message_buzzer && buzzerModeIsDirectOnly) + LOG_INFO("Non-message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY"); + if (!generic && !vibra && !buzzer) + return; + + buzzerShouldAlert |= buzzer; + + armNagCycle(); + + if (generic) { + LOG_INFO("externalNotificationModule - Generic alert"); + setExternalState(0, true); + } + if (vibra) { + LOG_INFO("externalNotificationModule - Vibra alert"); + triggerVibraOutput(); + } + if (buzzer) { + LOG_INFO("externalNotificationModule - Buzzer alert"); + triggerBuzzerOutput(); + } + + setIntervalFromNow(0); // run once so the nag/stop lifecycle in runOnce() takes over +} /** * @brief An admin message arrived to AdminModule. We are asked whether we want to handle that. * diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index a5b9f68da7..75f831d04c 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -73,6 +73,9 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: void stopNow(); + // Fire the configured message outputs for a non-message event such as a geofence crossing. + void startNotification(); + void handleGetRingtone(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response); void handleSetRingtone(const char *from_msg); @@ -87,6 +90,11 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: virtual bool wantPacket(const meshtastic_MeshPacket *p) override; + // Drive the configured buzzer output (I2S, PWM ringtone, or plain GPIO). + void triggerBuzzerOutput(); + void triggerVibraOutput(); + void armNagCycle(); + bool isNagging = false; bool isSilenced = false; @@ -97,4 +105,4 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: meshtastic_AdminMessage *response) override; }; -extern ExternalNotificationModule *externalNotificationModule; \ No newline at end of file +extern ExternalNotificationModule *externalNotificationModule; diff --git a/src/modules/GeofenceModule.cpp b/src/modules/GeofenceModule.cpp new file mode 100644 index 0000000000..ff7d2a5f7a --- /dev/null +++ b/src/modules/GeofenceModule.cpp @@ -0,0 +1,214 @@ +#include "GeofenceModule.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "WaypointStore.h" +#include "gps/GeoCoord.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" +#include + +#if HAS_SCREEN +#include "PowerFSM.h" +#include "graphics/Screen.h" +#include "main.h" // screen +#endif + +#include "modules/ExternalNotificationModule.h" + +GeofenceModule *geofenceModule; + +static constexpr size_t GEOFENCE_MAX_CROSSING = 256; + +GeofenceModule::GeofenceModule() +{ + crossingInside.reserve(GEOFENCE_MAX_CROSSING); + waypointStoreObserver.observe(&waypointStore); +} + +bool GeofenceModule::insideRadius(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters) +{ + if (radiusMeters == 0) + return false; + float meters = GeoCoord::latLongToMeter((double)ptLat_i * 1e-7, (double)ptLon_i * 1e-7, (double)ctrLat_i * 1e-7, + (double)ctrLon_i * 1e-7); + return meters <= (float)radiusMeters; +} + +bool GeofenceModule::insideBox(int32_t ptLat_i, int32_t ptLon_i, const meshtastic_BoundingBox &box) +{ + return ptLat_i >= box.latitude_south_i && ptLat_i <= box.latitude_north_i && ptLon_i >= box.longitude_west_i && + ptLon_i <= box.longitude_east_i; +} + +bool GeofenceModule::insideAny(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters, + bool hasBox, const meshtastic_BoundingBox &box) +{ + if (insideRadius(ptLat_i, ptLon_i, ctrLat_i, ctrLon_i, radiusMeters)) + return true; + if (hasBox && insideBox(ptLat_i, ptLon_i, box)) + return true; + return false; +} + +bool GeofenceModule::inside(const meshtastic_Waypoint &wp, int32_t ptLat_i, int32_t ptLon_i) +{ + return insideAny(ptLat_i, ptLon_i, wp.latitude_i, wp.longitude_i, wp.geofence_radius, wp.has_bounding_box, wp.bounding_box); +} + +bool GeofenceModule::hasGeofence(const meshtastic_Waypoint &wp) +{ + return wp.geofence_radius > 0 || wp.has_bounding_box; +} + +GeofenceModule::Crossing GeofenceModule::classify(bool firstSighting, bool wasInside, bool isInside, bool notifyOnEnter, + bool notifyOnExit) +{ + if (firstSighting) + return Crossing::None; // baseline only + if (wasInside == isInside) + return Crossing::None; // no transition + if (isInside) + return notifyOnEnter ? Crossing::Enter : Crossing::None; + return notifyOnExit ? Crossing::Exit : Crossing::None; +} + +GeofenceModule::CrossingState *GeofenceModule::findCrossingState(uint64_t key) +{ + for (auto &state : crossingInside) { + if (state.key == key) + return &state; + } + + return nullptr; +} + +bool GeofenceModule::shouldTrack(const meshtastic_Waypoint &wp, uint8_t notificationPreferences, uint32_t now) +{ + if (!hasGeofence(wp)) + return false; + if ((notificationPreferences & (WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_EXIT)) == 0) + return false; + // Only the circle is centred on the waypoint; the bounding box carries its own absolute + // corners, so a box-only geofence does not need a latitude/longitude pin. + if (wp.geofence_radius > 0 && !(wp.has_latitude_i && wp.has_longitude_i)) + return false; + // Expired/deleted? (now == 0 means we have no trustworthy clock, so treat it as still live.) + if (now != 0 && wp.expire != 0 && wp.expire <= now) + return false; + return true; +} + +int GeofenceModule::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + crossingInside.clear(); + return 0; +} + +void GeofenceModule::evaluatePosition(NodeNum node, const meshtastic_Position &p) +{ + if (waypointStore.getWaypoints().empty()) + return; + if (!p.has_latitude_i || !p.has_longitude_i) + return; + if (p.latitude_i == 0 && p.longitude_i == 0) + return; // treat the null island as "no fix" + if (node == nodeDB->getNodeNum()) + return; // judge other nodes' positions only (per design#114) + + const int32_t lat = p.latitude_i; + const int32_t lon = p.longitude_i; + const uint32_t now = getTime(); + bool favoriteResolved = false; + bool isFavorite = false; + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &wp = entry.waypoint; + if (!shouldTrack(wp, entry.notificationPreferences, now)) + continue; + + const bool isInside = inside(wp, lat, lon); + const uint64_t key = crossingKey(wp.id, node); + CrossingState *state = findCrossingState(key); + const bool hasTrackedState = (state != nullptr); + const Crossing crossing = + classify(!hasTrackedState, hasTrackedState ? state->inside : false, isInside, + entry.notificationEnabled(WAYPOINT_NOTIFY_ENTER), entry.notificationEnabled(WAYPOINT_NOTIFY_EXIT)); + + // Record/baseline the current state (bounded - drop new pairs once the map is full). + if (!hasTrackedState) { + if (crossingInside.size() < GEOFENCE_MAX_CROSSING) { + crossingInside.push_back(CrossingState{key, isInside}); + } else { + static bool warnedCrossingFull = false; + if (!warnedCrossingFull) { + LOG_WARN("Geofence crossing-state full (%u); new (waypoint,node) pairs will not alert until space frees", + (unsigned)GEOFENCE_MAX_CROSSING); + warnedCrossingFull = true; + } + } + } else { + state->inside = isInside; + } + + if (crossing == Crossing::None) + continue; + + if (entry.notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY)) { + if (!favoriteResolved) { + isFavorite = nodeDB->isFavorite(node); + favoriteResolved = true; + } + if (!isFavorite) + continue; + } + + notify(wp, node, crossing == Crossing::Enter); + } +} + +void GeofenceModule::notify(const meshtastic_Waypoint &wp, NodeNum node, bool entered) +{ + // Resolve a display name for the crossing node. + char who[40]; + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node); + if (info && info->long_name[0]) { + strncpy(who, info->long_name, sizeof(who) - 1); + who[sizeof(who) - 1] = '\0'; + } else if (info && info->short_name[0]) { + strncpy(who, info->short_name, sizeof(who) - 1); + who[sizeof(who) - 1] = '\0'; + } else { + snprintf(who, sizeof(who), "!%08x", (unsigned)node); + } + + LOG_INFO("Geofence: %s %s '%s'", who, entered ? "entered" : "left", wp.name); + +#if HAS_SCREEN + if (screen) + powerFSM.trigger(EVENT_RECEIVED_MSG); // wake the screen so the banner is seen +#endif + + GeofenceNotificationEvent event; + event.waypointId = wp.id; + strncpy(event.nodeName, who, sizeof(event.nodeName) - 1); + event.nodeName[sizeof(event.nodeName) - 1] = '\0'; + event.entered = entered; + strncpy(event.geofenceName, wp.name, sizeof(event.geofenceName) - 1); + event.geofenceName[sizeof(event.geofenceName) - 1] = '\0'; + notifyObservers(&event); + +#if HAS_SCREEN && !defined(MESHTASTIC_INCLUDE_INKHUD) + if (screen) { + char banner[120]; + snprintf(banner, sizeof(banner), "%s %s %s", who, entered ? "IN" : "OUT", wp.name); + screen->showSimpleBanner(banner, 5000); + } +#endif + + if (externalNotificationModule) + externalNotificationModule->startNotification(); +} + +#endif diff --git a/src/modules/GeofenceModule.h b/src/modules/GeofenceModule.h new file mode 100644 index 0000000000..8ef0ca0620 --- /dev/null +++ b/src/modules/GeofenceModule.h @@ -0,0 +1,68 @@ +#pragma once + +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "Observer.h" +#include "WaypointStore.h" +#include "mesh/MeshTypes.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +struct GeofenceNotificationEvent { + uint32_t waypointId = 0; + char nodeName[sizeof(meshtastic_User::long_name)] = {}; + bool entered = false; + char geofenceName[sizeof(meshtastic_Waypoint::name)] = {}; +}; + +// Tracks other nodes crossing waypoint geofences and emits enter/exit notifications on the waypoint creator's device. +class GeofenceModule : public Observable +{ + public: + GeofenceModule(); + + enum class Crossing { None, Enter, Exit }; + + static bool insideRadius(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters); + + static bool insideBox(int32_t ptLat_i, int32_t ptLon_i, const meshtastic_BoundingBox &box); + + static bool insideAny(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters, + bool hasBox, const meshtastic_BoundingBox &box); + + static bool inside(const meshtastic_Waypoint &wp, int32_t ptLat_i, int32_t ptLon_i); + + static bool hasGeofence(const meshtastic_Waypoint &wp); + + // Box-only geofences do not require a waypoint center because their corners are absolute. + static bool shouldTrack(const meshtastic_Waypoint &wp, uint8_t notificationPreferences, uint32_t now); + + // The first sighting establishes a baseline without notifying. + static Crossing classify(bool firstSighting, bool wasInside, bool isInside, bool notifyOnEnter, bool notifyOnExit); + + void evaluatePosition(NodeNum node, const meshtastic_Position &p); + + private: + struct CrossingState { + uint64_t key; + bool inside; + }; + + static uint64_t crossingKey(uint32_t waypointId, NodeNum node) { return ((uint64_t)waypointId << 32) | node; } + + CrossingState *findCrossingState(uint64_t key); + void notify(const meshtastic_Waypoint &wp, NodeNum node, bool entered); + int onWaypointStoreChanged(const WaypointStore *store); + + // Bounded (waypointId, nodeNum) state; new pairs are skipped until an old waypoint frees space. + std::vector crossingInside; + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &GeofenceModule::onWaypointStoreChanged); +}; + +extern GeofenceModule *geofenceModule; + +#endif diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 34700ed07f..d05ac35a34 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -55,6 +55,7 @@ #include "modules/TraceRouteModule.h" #endif #if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "modules/GeofenceModule.h" #include "modules/WaypointModule.h" #endif #if ARCH_PORTDUINO @@ -159,6 +160,7 @@ void setupModules() #endif #if !MESHTASTIC_EXCLUDE_WAYPOINT waypointModule = new WaypointModule(); + geofenceModule = new GeofenceModule(); #endif #if !MESHTASTIC_EXCLUDE_TEXTMESSAGE textMessageModule = new TextMessageModule(); diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 7f28189060..416c65f6e4 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -2,6 +2,7 @@ #include "PositionModule.h" #include "Default.h" #include "GPS.h" +#include "GeofenceModule.h" #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" @@ -55,6 +56,7 @@ PositionModule::PositionModule() bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_Position *pptr) { auto p = *pptr; + const NodeNum sender = getFrom(&mp); const auto transport = mp.transport_mechanism; if (isFromUs(&mp) && !IS_ONE_OF(transport, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_INTERNAL, @@ -90,7 +92,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes // Log packet size and data fields LOG_TRACE("POSITION node=0x%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d " "time=%d", - getFrom(&mp), mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae, + sender, mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae, p.altitude_geoidal_separation, p.PDOP, p.HDOP, p.VDOP, p.sats_in_view, p.fix_quality, p.fix_type, p.timestamp, p.time); @@ -107,9 +109,14 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes trySetRtc(p, isLocal, force); } - nodeDB->updatePosition(getFrom(&mp), p); + nodeDB->updatePosition(sender, p); precision = getPositionPrecisionForChannel(mp.channel); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + if (geofenceModule && !isLocal) + geofenceModule->evaluatePosition(sender, p); +#endif + return false; // Let others look at this message also if they want } diff --git a/src/modules/WaypointModule.cpp b/src/modules/WaypointModule.cpp index d9f3b30cd3..8f31294843 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -1,222 +1,409 @@ #include "WaypointModule.h" #include "NodeDB.h" #include "PowerFSM.h" +#include "WaypointUtils.h" #include "configuration.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/CompassRenderer.h" #include "meshUtils.h" +#include +#include +#include +#include -#if HAS_SCREEN +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "ExternalNotificationModule.h" +#include "MeshService.h" +#include "WaypointStore.h" +#include "mesh/Router.h" +#include +#endif + +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT #include "gps/RTC.h" #include "graphics/Screen.h" #include "graphics/TimeFormatters.h" #include "graphics/draw/NodeListRenderer.h" +#include "graphics/draw/UIRenderer.h" #include "main.h" #endif WaypointModule *waypointModule; +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT +namespace +{ + +constexpr int16_t WAYPOINT_ROW_GAP = 2; + +void drawFallbackWaypointIcon(OLEDDisplay *display, int16_t left, int16_t top, uint16_t boxSize) +{ + const int16_t cx = left + (boxSize / 2); + const int16_t circleY = top + std::max(2, boxSize / 3); + const int16_t r = std::max(1, boxSize / 4); + display->drawCircle(cx, circleY, r); + display->drawLine(cx, circleY + r, cx, top + boxSize - 2); + display->setPixel(cx - 1, top + boxSize - 2); + display->setPixel(cx + 1, top + boxSize - 2); +} + +void drawWaypointIcon(OLEDDisplay *display, const meshtastic_Waypoint &wp, int16_t left, int16_t top, uint16_t boxSize) +{ + if (!wp.icon) { + drawFallbackWaypointIcon(display, left, top, boxSize); + return; + } + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(wp.icon); + if (utf8.empty()) { + drawFallbackWaypointIcon(display, left, top, boxSize); + return; + } + + graphics::UIRenderer::drawStringWithEmotes(display, left, top, utf8, FONT_HEIGHT_SMALL, 1, false); +} + +void formatWaypointDistance(char *out, size_t outSize, float meters) +{ + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + const float feet = meters * METERS_TO_FEET; + snprintf(out, outSize, feet < (2 * MILES_TO_FEET) ? "%.0fft" : "%.1fmi", + feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET); + } else { + snprintf(out, outSize, meters < 2000 ? "%.0fm" : "%.1fkm", meters < 2000 ? meters : meters / 1000); + } +} + +void formatWaypointCoordinates(char *out, size_t outSize, const meshtastic_Waypoint &wp) +{ + if (!(wp.has_latitude_i && wp.has_longitude_i)) { + snprintf(out, outSize, "--"); + return; + } + + snprintf(out, outSize, "%.4f,%.4f", wp.latitude_i * 1e-7, wp.longitude_i * 1e-7); +} + +void formatWaypointExpire(char *out, size_t outSize, const meshtastic_Waypoint &wp) +{ + if (wp.expire == 0) { + out[0] = '\0'; + return; + } + + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now == 0) { + out[0] = '\0'; + return; + } + if (wp.expire <= now) { + snprintf(out, outSize, "0m"); + return; + } + + const uint32_t left = wp.expire - now; + if (left < 3600) + snprintf(out, outSize, "%lum", (unsigned long)((left + 59) / 60)); + else if (left < 86400) + snprintf(out, outSize, "%luh", (unsigned long)((left + 3599) / 3600)); + else + snprintf(out, outSize, "%lud", (unsigned long)((left + 86399) / 86400)); +} + +std::string trimmedWaypointText(const char *text) +{ + if (!text) + return ""; + + std::string value(text); + const auto first = std::find_if(value.begin(), value.end(), [](unsigned char c) { return !std::isspace(c); }); + if (first == value.end()) + return ""; + + const auto last = std::find_if(value.rbegin(), value.rend(), [](unsigned char c) { return !std::isspace(c); }).base(); + return std::string(first, last); +} + +size_t collectDrawableWaypoints(const StoredWaypoint *entries[], size_t maxEntries) +{ + size_t count = 0; + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (count >= maxEntries) + break; + entries[count] = &entry; + ++count; + } + + return count; +} + +void drawDottedHorizontalDivider(OLEDDisplay *display, int16_t xStart, int16_t xEnd, int16_t y) +{ + for (int16_t x = xStart; x <= xEnd; x += 2) { + display->setPixel(x, y); + } +} + +void notifyWaypointReceived(const StoredWaypoint &stored) +{ + if (screen) { + const std::string waypointName = trimmedWaypointText(stored.waypoint.name); + if (!waypointName.empty()) { + char banner[96]; + snprintf(banner, sizeof(banner), "New Waypoint\n%s", waypointName.c_str()); + screen->showSimpleBanner(banner, 3000); + } else { + screen->showSimpleBanner("New Waypoint", 3000); + } + } + + if (externalNotificationModule) + externalNotificationModule->startNotification(); +} + +} // namespace +#endif + ProcessMessage WaypointModule::handleReceived(const meshtastic_MeshPacket &mp) { #if defined(DEBUG_PORT) && !defined(DEBUG_MUTE) auto &p = mp.decoded; LOG_INFO("Received waypoint msg from=0x%08x, id=0x%08x, msg=%.*s", mp.from, mp.id, p.payload.size, p.payload.bytes); #endif - // We only store/display messages destined for us. - // Keep a copy of the most recent text message. - devicestate.rx_waypoint = mp; - devicestate.has_rx_waypoint = true; +#if MESHTASTIC_EXCLUDE_WAYPOINT + (void)mp; + return ProcessMessage::CONTINUE; +#else + StoredWaypoint stored; + if (!waypointStore.addFromPacket(mp, isFromUs(&mp), &stored)) + return ProcessMessage::CONTINUE; powerFSM.trigger(EVENT_RECEIVED_MSG); #if HAS_SCREEN + if (!isFromUs(&mp) && !WaypointStore::isExpired(stored)) + notifyWaypointReceived(stored); UIFrameEvent e; - - // New or updated waypoint: focus on this frame next time Screen::setFrames runs - if (shouldDraw()) { - requestFocus(); - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; - } - - // Deleting an old waypoint: remove the frame quietly, don't change frame position if possible - else - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; + // Refresh the waypoint frame list quietly; new waypoints alert via banner/sound but do not + // steal focus from the screen the user is already on. + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; notifyObservers(&e); #endif return ProcessMessage::CONTINUE; // Let others look at this message also if they want +#endif } +#if !MESHTASTIC_EXCLUDE_WAYPOINT +bool WaypointModule::broadcastDelete(uint32_t waypointId) +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + bool found = false; + for (const auto &entry : waypointStore.getWaypoints()) { + if (entry.waypoint.id == waypointId) { + wp = entry.waypoint; + found = true; + break; + } + } + if (!found) + return false; + + // Respect the waypoint's lock: we may remove a locked waypoint from our own device, but + // we're not the owner, so we have no authority to delete it mesh-wide. + const NodeNum localNodeNum = nodeDB ? nodeDB->getNodeNum() : 0; + if (wp.locked_to != 0 && wp.locked_to != localNodeNum) { + LOG_INFO("Waypoint 0x%08x is locked to 0x%08x; removing locally only", waypointId, wp.locked_to); + waypointStore.removeWaypoint(waypointId); + return true; + } + + // Already-expired = the mesh convention for "delete this waypoint". + wp.expire = 1; + + if (!service) + return false; + + meshtastic_MeshPacket *p = router ? router->allocForSending() : nullptr; + if (!p) + return false; + + p->decoded.portnum = meshtastic_PortNum_WAYPOINT_APP; + p->decoded.payload.size = + pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Waypoint_msg, &wp); + if (p->decoded.payload.size == 0) { + packetPool.release(p); + return false; + } + + service->sendToMesh(p, RX_SRC_USER); + + waypointStore.removeWaypoint(waypointId); + + return true; +} +#endif + #if HAS_SCREEN bool WaypointModule::shouldDraw() { #if !MESHTASTIC_EXCLUDE_WAYPOINT - if (!screen || !devicestate.has_rx_waypoint) + if (!screen || waypointStore.getWaypoints().empty()) return false; - meshtastic_Waypoint wp{}; // <- replaces memset - if (pb_decode_from_bytes(devicestate.rx_waypoint.decoded.payload.bytes, devicestate.rx_waypoint.decoded.payload.size, - &meshtastic_Waypoint_msg, &wp)) { - // getTime() counts from boot until the RTC is set, which reads every real expiry as future. - return waypointIsActive(wp.expire, getValidTime(RTCQualityFromNet)); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (!WaypointStore::isExpired(entry)) + return true; } - return false; // no LOG_ERROR, no flag writes + return false; #else return false; #endif } -/// Draw the last waypoint we received -void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +void WaypointModule::onDeviceTimeChanged() { +#if !MESHTASTIC_EXCLUDE_WAYPOINT if (!screen) return; + + // Refresh only; never steal focus. + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; + notifyObservers(&e); +#endif +} + +/// Draw the newest non-expired waypoints we received +void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + (void)state; +#if MESHTASTIC_EXCLUDE_WAYPOINT + (void)display; + (void)x; + (void)y; + return; +#else + if (!screen) + return; + display->clear(); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - int line = 1; + const StoredWaypoint *entries[WAYPOINT_HISTORY_LIMIT]; + const size_t totalWaypoints = collectDrawableWaypoints(entries, WAYPOINT_HISTORY_LIMIT); + if (totalWaypoints == 0) + return; - // === Set Title - const char *titleStr = "Waypoint"; - - // === Header === + const char *titleStr = (totalWaypoints == 1) ? "Waypoint" : "Waypoints"; graphics::drawCommonHeader(display, x, y, titleStr); const int *textPos = graphics::getTextPositions(display); - // Decode the waypoint - const meshtastic_MeshPacket &mp = devicestate.rx_waypoint; - meshtastic_Waypoint wp{}; - if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Waypoint_msg, &wp)) { - devicestate.has_rx_waypoint = false; - return; - } - - // Sanitize before these reach the OLED renderer (defense-in-depth vs PB_VALIDATE_UTF8). - sanitizeUtf8(wp.name, sizeof(wp.name)); - sanitizeUtf8(wp.description, sizeof(wp.description)); - - // Get timestamp info. Will pass as a field to drawColumns - char lastStr[20]; - getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr)); - - // Will contain distance information, passed as a field to drawColumns - char distStr[20] = ""; - - // Get our node, to use our own position const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - - // Match compass sizing/placement to favorite node screen logic. - const int w = display->getWidth(); - int16_t compassRadius = 8; - int16_t compassX = x + w - compassRadius - 8; - int16_t compassY = y + display->getHeight() / 2; - - if (SCREEN_WIDTH > SCREEN_HEIGHT) { - const int16_t topY = textPos[1]; - const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); - const int16_t usableHeight = bottomY - topY - 5; - compassRadius = usableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - compassX = x + SCREEN_WIDTH - compassRadius - 8; - compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - } else { - // Waypoint content uses rows 1..4, so place the compass below that block. - const int yBelowContent = textPos[4] + FONT_HEIGHT_SMALL + 2; - const int margin = 4; -#if defined(USE_EINK) - const int iconSize = (graphics::currentResolution == graphics::ScreenResolution::High) ? 16 : 8; - const int navBarHeight = iconSize + 6; -#else - const int navBarHeight = 0; -#endif - const int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; - if (availableHeight > 0) { - compassRadius = availableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - if (compassRadius * 2 > SCREEN_WIDTH - 16) - compassRadius = (SCREEN_WIDTH - 16) / 2; - if (compassRadius < 8) - compassRadius = 8; - compassX = x + SCREEN_WIDTH / 2; - compassY = yBelowContent + availableHeight / 2; - } - } - const uint16_t compassDiam = compassRadius * 2; - const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode)); - const char *statusLine1 = nullptr; - const char *statusLine2 = nullptr; - - // Distance only needs our own position fix; compass/bearing additionally needs heading. - meshtastic_PositionLite ownPos; + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; const bool haveOwnPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ownPos); - if (hasOwnPositionFix && haveOwnPos) { - const meshtastic_PositionLite &op = ownPos; - const float d = - GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); - // Always show distance once we have an own-position fix, even without heading. - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - float feet = d * METERS_TO_FEET; - snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft" : "%.1fmi", - feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET); - } else { - snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm" : "%.1fkm", d < 2000 ? d : d / 1000); - } + const uint16_t iconWidth = FONT_HEIGHT_SMALL; + const uint16_t iconGap = 3; + const uint16_t nameX = iconWidth + iconGap; + const int16_t contentBottom = display->getHeight() - 1; + int16_t rowTop = textPos[1]; + for (size_t i = 0; i < totalWaypoints; ++i) { + const StoredWaypoint &entry = *entries[i]; + const meshtastic_Waypoint &wp = entry.waypoint; + + char safeName[sizeof(wp.name)]; + memcpy(safeName, wp.name, sizeof(safeName)); + safeName[sizeof(safeName) - 1] = '\0'; + sanitizeUtf8(safeName, sizeof(safeName)); + + char safeDescription[sizeof(wp.description)]; + memcpy(safeDescription, wp.description, sizeof(safeDescription)); + safeDescription[sizeof(safeDescription) - 1] = '\0'; + sanitizeUtf8(safeDescription, sizeof(safeDescription)); + + char distStr[20] = ""; + char coordStr[40]; + char expireStr[16]; + formatWaypointCoordinates(coordStr, sizeof(coordStr), wp); + formatWaypointExpire(expireStr, sizeof(expireStr), wp); + + const std::string description = trimmedWaypointText(safeDescription); + const bool hasDescription = !description.empty(); + const int16_t row1Y = rowTop; + const int16_t row2Y = row1Y + FONT_HEIGHT_SMALL + 1; + const int16_t rowMetaY = hasDescription ? (row2Y + FONT_HEIGHT_SMALL + 1) : row2Y; + const int16_t cardBottom = rowMetaY + FONT_HEIGHT_SMALL; + if (cardBottom > contentBottom) + break; + + bool showCompass = false; float myHeading = 0.0f; - const bool hasHeading = - graphics::CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading); - if (hasHeading) { - // Draw compass circle - display->drawCircle(compassX, compassY, compassRadius); - graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + float bearingToOther = 0.0f; + if (hasOwnPositionFix && haveOwnPos && wp.has_latitude_i && wp.has_longitude_i) { + const float d = GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(ownPos.latitude_i), + DegD(ownPos.longitude_i)); + formatWaypointDistance(distStr, sizeof(distStr), d); - // Compass bearing to waypoint - float bearingToOther = - GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(wp.latitude_i), DegD(wp.longitude_i)); - bearingToOther = graphics::CompassRenderer::adjustBearingForCompassMode(bearingToOther, myHeading); - graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); - - const float bearingToOtherDegrees = graphics::CompassRenderer::radiansToDegrees360(bearingToOther); - - // Distance to waypoint with relative bearing when heading is available. - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - float feet = d * METERS_TO_FEET; - snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft %.0f°" : "%.1fmi %.0f°", - feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET, bearingToOtherDegrees); - } else { - snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm %.0f°" : "%.1fkm %.0f°", d < 2000 ? d : d / 1000, - bearingToOtherDegrees); + if (graphics::CompassRenderer::getHeadingRadians(DegD(ownPos.latitude_i), DegD(ownPos.longitude_i), myHeading)) { + showCompass = true; + bearingToOther = GeoCoord::bearing(DegD(ownPos.latitude_i), DegD(ownPos.longitude_i), DegD(wp.latitude_i), + DegD(wp.longitude_i)); + bearingToOther = graphics::CompassRenderer::adjustBearingForCompassMode(bearingToOther, myHeading); } - - } else { - statusLine1 = "No"; - statusLine2 = "Heading"; } - } else { - // No own fix yet, so compass/bearing data would be misleading. - statusLine1 = "No"; - statusLine2 = "Fix"; - } - if (statusLine1) { - display->drawCircle(compassX, compassY, compassRadius); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(compassX, compassY - FONT_HEIGHT_SMALL, statusLine1); - display->drawString(compassX, compassY, statusLine2); - } + const int16_t compactArrowCenterX = display->getWidth() - ((FONT_HEIGHT_SMALL > 10) ? 9 : 7); + const int16_t compactArrowCenterY = (hasDescription ? row2Y : row1Y) + (FONT_HEIGHT_SMALL / 2); + const int16_t compactContentRight = compactArrowCenterX - 8; + const char *distanceLabel = distStr[0] ? distStr : "--"; + const char *expireLabel = expireStr[0] ? expireStr : "--"; + const uint16_t metaWidth = + std::max(display->getStringWidth(distanceLabel), display->getStringWidth(expireLabel)) + 4; + const int16_t metaLeft = std::max(nameX + 16, compactContentRight - metaWidth); + const int16_t textRight = metaLeft - 4; + const uint16_t nameWidth = (textRight > nameX) ? (textRight - nameX) : 0; + const std::string shownName = graphics::UIRenderer::truncateStringWithEmotes(display, safeName, nameWidth); + const std::string shownDescription = + hasDescription ? graphics::UIRenderer::truncateStringWithEmotes(display, description, nameWidth) : std::string(); - display->setTextAlignment(TEXT_ALIGN_LEFT); // Something above me changes to a different alignment, forcing a fix here! - display->drawString(0, textPos[line++], lastStr); - display->drawString(0, textPos[line++], wp.name); - display->drawString(0, textPos[line++], wp.description); - if (distStr[0]) - display->drawString(0, textPos[line++], distStr); + drawWaypointIcon(display, wp, 0, row1Y, iconWidth); + graphics::UIRenderer::drawStringWithEmotes(display, nameX, row1Y, shownName, FONT_HEIGHT_SMALL, 1, false); + const int16_t underlineY = row1Y + FONT_HEIGHT_SMALL; + const int16_t underlineRight = + std::min(textRight, nameX + graphics::UIRenderer::measureStringWithEmotes(display, shownName) - 1); + if (underlineRight >= nameX) + display->drawLine(nameX, underlineY, underlineRight, underlineY); + + if (hasDescription) + graphics::UIRenderer::drawStringWithEmotes(display, nameX, row2Y, shownDescription, FONT_HEIGHT_SMALL, 1, false); + + if (showCompass) + graphics::NodeListRenderer::drawRelativeCompassArrow(display, compactArrowCenterX, compactArrowCenterY, + graphics::CompassRenderer::radiansToDegrees360(bearingToOther)); + + display->drawStringMaxWidth(nameX, rowMetaY, nameWidth, coordStr); + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(metaLeft + metaWidth - 1, row1Y, distanceLabel); + display->drawString(metaLeft + metaWidth - 1, rowMetaY, expireLabel); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const int16_t separatorY = cardBottom + 1; + const int16_t nextRowTop = separatorY + WAYPOINT_ROW_GAP; + if (i + 1 < totalWaypoints && nextRowTop + ((FONT_HEIGHT_SMALL * 2) + 1) <= contentBottom) { + drawDottedHorizontalDivider(display, 0, display->getWidth() - 1, separatorY); + rowTop = nextRowTop; + } else { + break; + } + } +#endif } #endif diff --git a/src/modules/WaypointModule.h b/src/modules/WaypointModule.h index 4c9c7b86b0..fc811c7139 100644 --- a/src/modules/WaypointModule.h +++ b/src/modules/WaypointModule.h @@ -14,6 +14,11 @@ class WaypointModule : public SinglePortModule, public Observable + +// These cover pure geofence helpers without device globals or a fake clock. +// Store and notification plumbing are not covered here. + +using Crossing = GeofenceModule::Crossing; + +// 0.001 deg of longitude at the equator is ~111 m; 0.001 deg = 10000 in degrees x 1e-7 units. +static const int32_t kLon111m = 10000; + +static void test_insideRadius_centreIsInside() +{ + TEST_ASSERT_TRUE(GeofenceModule::insideRadius(123456, 654321, 123456, 654321, 10)); +} + +static void test_insideRadius_withinRadius() +{ + // ~111 m east of the centre, radius 200 m -> inside. + TEST_ASSERT_TRUE(GeofenceModule::insideRadius(0, kLon111m, 0, 0, 200)); +} + +static void test_insideRadius_outsideRadius() +{ + // ~111 m east of the centre, radius 50 m -> outside. + TEST_ASSERT_FALSE(GeofenceModule::insideRadius(0, kLon111m, 0, 0, 50)); +} + +static void test_insideRadius_zeroRadiusNeverInside() +{ + // radius 0 means "no circle" -> never inside, even exactly at the centre. + TEST_ASSERT_FALSE(GeofenceModule::insideRadius(123456, 654321, 123456, 654321, 0)); +} + +static meshtastic_BoundingBox makeBox() +{ + meshtastic_BoundingBox box = meshtastic_BoundingBox_init_zero; + box.longitude_west_i = -1000; + box.latitude_south_i = -2000; + box.longitude_east_i = 1000; + box.latitude_north_i = 2000; + return box; +} + +static void test_insideBox_centreInside() +{ + TEST_ASSERT_TRUE(GeofenceModule::insideBox(0, 0, makeBox())); +} + +static void test_insideBox_edgesInclusive() +{ + meshtastic_BoundingBox box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::insideBox(box.latitude_north_i, box.longitude_east_i, box)); + TEST_ASSERT_TRUE(GeofenceModule::insideBox(box.latitude_south_i, box.longitude_west_i, box)); +} + +static void test_insideBox_outsideLat() +{ + TEST_ASSERT_FALSE(GeofenceModule::insideBox(2001, 0, makeBox())); +} + +static void test_insideBox_outsideLon() +{ + TEST_ASSERT_FALSE(GeofenceModule::insideBox(0, 1001, makeBox())); +} + +static void test_inside_circleOnly() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.latitude_i = 0; + wp.longitude_i = 0; + wp.geofence_radius = 200; + wp.has_bounding_box = false; + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 0, kLon111m)); + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 0, kLon111m * 4)); // ~444 m east +} + +static void test_inside_boxOnly() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 0; + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 0, 0)); + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 5000, 0)); +} + +static void test_inside_eitherShapeCounts() +{ + // Circle is tiny (point far from centre is outside it) but the box still contains the point. + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 1; // 1 m circle + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 1500, 0)); // outside circle, inside box +} + +static void test_inside_noGeofenceNeverInside() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 0; + wp.has_bounding_box = false; + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 0, 0)); + TEST_ASSERT_FALSE(GeofenceModule::hasGeofence(wp)); +} + +static meshtastic_Waypoint makeNotifyingWaypoint() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + return wp; +} + +static void test_shouldTrack_circleWithCentre() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_circleWithoutCentreRejected() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; // circle needs a centre + wp.has_latitude_i = false; + wp.has_longitude_i = false; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_boxOnlyWithoutCentreAccepted() +{ + // A box-only geofence carries absolute corners, so it needs no waypoint centre. + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 0; + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + wp.has_latitude_i = false; + wp.has_longitude_i = false; + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_noGeofenceRejected() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); // notify set, but no geofence shape + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_noLocalPreferencesRejected() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + wp.notify_on_enter = true; + wp.notify_on_exit = true; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, 0, 0)); +} + +static void test_shouldTrack_expiredRejectedButLiveAccepted() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + wp.expire = 1000; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 2000)); // expire <= now -> expired + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 500)); // expire > now -> live + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); // no clock -> treat as live +} + +static meshtastic_Waypoint makeWireNotificationWaypoint() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.notify_on_enter = true; + wp.notify_on_exit = true; + wp.notify_favorites_only = true; + return wp; +} + +static void test_localWaypointInitializesPreferencesFromWireFields() +{ + const meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + const uint8_t preferences = WaypointStore::mergeNotificationPreferences(true, false, 0, wp); + TEST_ASSERT_EQUAL_UINT8(WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_EXIT | WAYPOINT_NOTIFY_FAVORITES_ONLY, preferences); +} + +static void test_newRemoteWaypointIgnoresWirePreferences() +{ + const meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + TEST_ASSERT_EQUAL_UINT8(0, WaypointStore::mergeNotificationPreferences(false, false, 0, wp)); +} + +static void test_remoteWaypointUpdatePreservesLocalPreferences() +{ + meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + wp.notify_on_enter = false; + const uint8_t existing = WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_FAVORITES_ONLY; + TEST_ASSERT_EQUAL_UINT8(existing, WaypointStore::mergeNotificationPreferences(false, true, existing, wp)); +} + +static void test_storedWaypoint_clearsWirePreferences() +{ + meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + WaypointStore::clearWireNotificationPreferences(wp); + TEST_ASSERT_FALSE(wp.notify_on_enter); + TEST_ASSERT_FALSE(wp.notify_on_exit); + TEST_ASSERT_FALSE(wp.notify_favorites_only); +} + +static void test_first_sighting_no_notification() +{ + // First sighting only baselines, regardless of inside state or notify flags. + TEST_ASSERT_TRUE(GeofenceModule::classify(true, false, true, true, true) == Crossing::None); + TEST_ASSERT_TRUE(GeofenceModule::classify(true, false, false, true, true) == Crossing::None); +} + +static void test_classify_noTransitionNeverNotifies() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, true, true, true) == Crossing::None); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, false, true, true) == Crossing::None); +} + +static void test_classify_enterFiresOnlyWhenEnabled() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, true, true, false) == Crossing::Enter); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, true, false, false) == Crossing::None); +} + +static void test_classify_exitFiresOnlyWhenEnabled() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, false, false, true) == Crossing::Exit); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, false, false, false) == Crossing::None); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_insideRadius_centreIsInside); + RUN_TEST(test_insideRadius_withinRadius); + RUN_TEST(test_insideRadius_outsideRadius); + RUN_TEST(test_insideRadius_zeroRadiusNeverInside); + RUN_TEST(test_insideBox_centreInside); + RUN_TEST(test_insideBox_edgesInclusive); + RUN_TEST(test_insideBox_outsideLat); + RUN_TEST(test_insideBox_outsideLon); + RUN_TEST(test_inside_circleOnly); + RUN_TEST(test_inside_boxOnly); + RUN_TEST(test_inside_eitherShapeCounts); + RUN_TEST(test_inside_noGeofenceNeverInside); + RUN_TEST(test_shouldTrack_circleWithCentre); + RUN_TEST(test_shouldTrack_circleWithoutCentreRejected); + RUN_TEST(test_shouldTrack_boxOnlyWithoutCentreAccepted); + RUN_TEST(test_shouldTrack_noGeofenceRejected); + RUN_TEST(test_shouldTrack_noLocalPreferencesRejected); + RUN_TEST(test_shouldTrack_expiredRejectedButLiveAccepted); + RUN_TEST(test_localWaypointInitializesPreferencesFromWireFields); + RUN_TEST(test_newRemoteWaypointIgnoresWirePreferences); + RUN_TEST(test_remoteWaypointUpdatePreservesLocalPreferences); + RUN_TEST(test_storedWaypoint_clearsWirePreferences); + RUN_TEST(test_first_sighting_no_notification); + RUN_TEST(test_classify_noTransitionNeverNotifies); + RUN_TEST(test_classify_enterFiresOnlyWhenEnabled); + RUN_TEST(test_classify_exitFiresOnlyWhenEnabled); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp index ebf47be9b2..b29c55341a 100644 --- a/test/test_utf8/test_main.cpp +++ b/test/test_utf8/test_main.cpp @@ -3,6 +3,7 @@ // would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup // would add portduino globals it otherwise never touches. Suite-level state cleanliness is still // checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. +#include "WaypointUtils.h" #include "meshUtils.h" #include #include @@ -168,6 +169,19 @@ void test_above_max_codepoint() TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); } +void test_waypoint_codepoint_encoding() +{ + TEST_ASSERT_EQUAL_STRING("A", WaypointUtils::utf8FromCodepoint('A').c_str()); + TEST_ASSERT_EQUAL_STRING("\xF0\x9F\x93\x8D", WaypointUtils::utf8FromCodepoint(0x1F4CD).c_str()); +} + +void test_waypoint_codepoint_rejects_invalid_scalars() +{ + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0).empty()); + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0xD800).empty()); + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0x110000).empty()); +} + // --- clampLongName: local 24-byte cap over wider wire buffers --- void test_clamp_long_name_short_unchanged() @@ -236,6 +250,8 @@ void setup() RUN_TEST(test_zero_size); RUN_TEST(test_valid_max_codepoint); RUN_TEST(test_above_max_codepoint); + RUN_TEST(test_waypoint_codepoint_encoding); + RUN_TEST(test_waypoint_codepoint_rejects_invalid_scalars); // clampLongName RUN_TEST(test_clamp_long_name_short_unchanged); diff --git a/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h b/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h index fb0744bc3b..42e57f9a69 100644 --- a/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h +++ b/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -88,6 +89,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h b/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h index a90500b150..60584ee58a 100644 --- a/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h +++ b/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h @@ -29,6 +29,7 @@ Different NicheGraphics UIs and different hardware variants will each have their #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -85,6 +86,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h b/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h index 9e84a541e1..d33db47f75 100644 --- a/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h +++ b/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -86,6 +87,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - 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); // - diff --git a/variants/esp32s3/mini-epaper-s3/nicheGraphics.h b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h index 0cbd6a1922..a330869d8e 100644 --- a/variants/esp32s3/mini-epaper-s3/nicheGraphics.h +++ b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/InkHUD/SystemApplet.h" // Shared NicheGraphics components @@ -67,6 +68,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet, false, false); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, false, false); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/t5s3_epaper/nicheGraphics.h b/variants/esp32s3/t5s3_epaper/nicheGraphics.h index 032ea100ec..62c22f1ef9 100644 --- a/variants/esp32s3/t5s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/t5s3_epaper/nicheGraphics.h @@ -31,6 +31,7 @@ This is driven via the FastEPD library through the NicheGraphics ED047TC1 driver #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -83,6 +84,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // Not Active, not autoshown inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false); // Activated, not autoshown + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet, false, false); // Not Active, not autoshown inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false); // Activated, not autoshown 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 diff --git a/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h b/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h index faa9a41b95..06db0f0896 100644 --- a/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h @@ -17,6 +17,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h b/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h index 242e5ae495..da44eb47c5 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h +++ b/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -77,6 +78,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h index 0a01b613e9..37d0171563 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -74,6 +75,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, false, 2); // Default on tile 2 inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false, 1); // Default on tile 1 + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false, 0); // Default on tile 0 inkhud->addApplet("Heard", new InkHUD::HeardApplet, true); // Background diff --git a/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h b/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h index ad17e74572..551f7f07b8 100644 --- a/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -75,6 +76,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, false, 2); // Default on tile 2 inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false, 1); // Default on tile 1 + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false, 0); // Default on tile 0 inkhud->addApplet("Heard", new InkHUD::HeardApplet, true); // Background diff --git a/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h b/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h index 187022ea71..02a59298ac 100644 --- a/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h b/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h index 0f41319167..c4e97827f1 100644 --- a/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h b/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h index 2a2967f5ec..f118651e67 100644 --- a/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h +++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h @@ -17,6 +17,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -74,6 +75,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/t-echo-plus/nicheGraphics.h b/variants/nrf52840/t-echo-plus/nicheGraphics.h index 73067d7a75..1d408f40be 100644 --- a/variants/nrf52840/t-echo-plus/nicheGraphics.h +++ b/variants/nrf52840/t-echo-plus/nicheGraphics.h @@ -13,6 +13,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/InkHUD/InkHUD.h" #include "graphics/niche/Inputs/TwoButton.h" @@ -44,6 +45,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); diff --git a/variants/nrf52840/t-echo/nicheGraphics.h b/variants/nrf52840/t-echo/nicheGraphics.h index c0b24dea77..1f9873f054 100644 --- a/variants/nrf52840/t-echo/nicheGraphics.h +++ b/variants/nrf52840/t-echo/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -80,6 +81,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 From 576a1bb0085f104756a6a209de377bd100ecbeaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 26 Aug 2026 20:00:42 +0000 Subject: [PATCH 008/143] Fix trackball dropping short presses and losing the click when tilted (#11599) * Fix trackball dropping short presses and losing the click when tilted * Do not let a direction counter overwrite an emitted press event * Accept the first press interrupt when the clock still reads zero * Classify a press released before the first poll by its latched time --- src/input/TrackballInterruptBase.cpp | 169 ++++++++++++++---------- src/input/TrackballInterruptBase.h | 13 ++ test/test_trackball_press/test_main.cpp | 164 +++++++++++++++++++++++ 3 files changed, 275 insertions(+), 71 deletions(-) create mode 100644 test/test_trackball_press/test_main.cpp diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index 1bbe756296..77fa2ff88b 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -1,11 +1,48 @@ #include "TrackballInterruptBase.h" #include "Throttle.h" +#include "UptimeClock.h" #include "configuration.h" extern bool osk_found; TrackballInterruptBase::TrackballInterruptBase(const char *name) : concurrency::OSThread(name), _originName(name) {} +TrackballInterruptBase::PressResult TrackballInterruptBase::updatePress(bool irqLatched, uint32_t irqTimeMs, bool pinLow) +{ + if (pressDetected) { + if (!pinLow) { + const bool wasShort = Throttle::isWithinTimespanMs(pressStartTime, LONG_PRESS_DURATION); + pressDetected = false; + pressStartTime = 0; + lastLongPressEventTime = 0; + longPressRepeatSent = false; + return wasShort ? PressResult::Short : PressResult::None; + } + // Tracked by a flag, not by lastLongPressEventTime == 0, which is a valid instant at rollover. + if (Throttle::hasElapsed(pressStartTime, LONG_PRESS_DURATION) && + (!longPressRepeatSent || Throttle::hasElapsed(lastLongPressEventTime, LONG_PRESS_REPEAT_INTERVAL))) { + lastLongPressEventTime = Time::getMillis(); + longPressRepeatSent = true; + return PressResult::LongRepeat; + } + return PressResult::None; + } + + if (!irqLatched) + return PressResult::None; + + // Already released by the time we polled: classify from the latched interrupt time, since a + // delayed poll can hide a long hold behind the same latch. + if (!pinLow) + return Throttle::isWithinTimespanMs(irqTimeMs, LONG_PRESS_DURATION) ? PressResult::Short : PressResult::None; + + pressDetected = true; + pressStartTime = irqTimeMs; + lastLongPressEventTime = 0; + longPressRepeatSent = false; + return PressResult::None; +} + void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, input_broker_event eventRight, input_broker_event eventPressed, @@ -72,33 +109,21 @@ int32_t TrackballInterruptBase::runOnce() #endif #endif - // Handle long press detection for press button - if (pressDetected && pressStartTime > 0) { - uint32_t pressDuration = millis() - pressStartTime; - bool buttonStillPressed = false; - - buttonStillPressed = !digitalRead(_pinPress); - - if (!buttonStillPressed) { - // Button released - if (pressDuration < LONG_PRESS_DURATION) { - // Short press - e.inputEvent = this->_eventPressed; - } - // Reset state - pressDetected = false; - pressStartTime = 0; - lastLongPressEventTime = 0; - this->action = TB_ACTION_NONE; - } else if (pressDuration >= LONG_PRESS_DURATION) { - // Long press detected - uint32_t currentTime = millis(); - // Only trigger long press event if enough time has passed since the last one - if (lastLongPressEventTime == 0 || (currentTime - lastLongPressEventTime) >= LONG_PRESS_REPEAT_INTERVAL) { - e.inputEvent = this->_eventPressedLong; - lastLongPressEventTime = currentTime; - } - this->action = TB_ACTION_PRESSED_LONG; + bool pressLatched = false; + if (_pinPress != 255) { + const uint32_t irqSeq = pressIrqSeq; + const uint32_t irqTimeMs = pressIrqTime; + pressLatched = irqSeq != pressIrqSeen; + pressIrqSeen = irqSeq; + switch (updatePress(pressLatched, irqTimeMs, !digitalRead(_pinPress))) { + case PressResult::Short: + e.inputEvent = this->_eventPressed; + break; + case PressResult::LongRepeat: + e.inputEvent = this->_eventPressedLong; + break; + case PressResult::None: + break; } } @@ -125,8 +150,9 @@ int32_t TrackballInterruptBase::runOnce() directionStartTime = 0; directionInterval = 0; this->action = TB_ACTION_NONE; - } else if (directionDuration >= LONG_PRESS_DURATION && directionInterval >= DIRECTION_REPEAT_THRESHOLD) { - // repeat event when long press these direction. + } else if (directionDuration >= LONG_PRESS_DURATION && directionInterval >= DIRECTION_REPEAT_THRESHOLD && + e.inputEvent == INPUT_BROKER_NONE) { + // repeat event when long press these direction, unless a press event already claimed this poll. switch (directionPressedNow) { case TB_ACTION_UP: e.inputEvent = this->_eventUp; @@ -147,55 +173,52 @@ int32_t TrackballInterruptBase::runOnce() } #if TB_THRESHOLD - if (this->action == TB_ACTION_PRESSED && (!pressDetected || pressStartTime == 0)) { - // Start long press detection - pressDetected = true; - pressStartTime = millis(); - // Don't send event yet, wait to see if it's a long press - } else if (up_counter >= TB_THRESHOLD) { + // A starting press suppresses direction events as it always has, and a press event already + // emitted this poll (a release classified on a later poll) must not be overwritten either. + if (!pressLatched && e.inputEvent == INPUT_BROKER_NONE) { + if (up_counter >= TB_THRESHOLD) { #ifdef INPUT_DEBUG - LOG_DEBUG("Trackball event UP %u", millis()); + LOG_DEBUG("Trackball event UP %u", millis()); #endif - e.inputEvent = this->_eventUp; - } else if (down_counter >= TB_THRESHOLD) { + e.inputEvent = this->_eventUp; + } else if (down_counter >= TB_THRESHOLD) { #ifdef INPUT_DEBUG - LOG_DEBUG("Trackball event DOWN %u", millis()); + LOG_DEBUG("Trackball event DOWN %u", millis()); #endif - e.inputEvent = this->_eventDown; - } else if (left_counter >= TB_THRESHOLD) { + e.inputEvent = this->_eventDown; + } else if (left_counter >= TB_THRESHOLD) { #ifdef INPUT_DEBUG - LOG_DEBUG("Trackball event LEFT %u", millis()); + LOG_DEBUG("Trackball event LEFT %u", millis()); #endif - e.inputEvent = this->_eventLeft; - } else if (right_counter >= TB_THRESHOLD) { + e.inputEvent = this->_eventLeft; + } else if (right_counter >= TB_THRESHOLD) { #ifdef INPUT_DEBUG - LOG_DEBUG("Trackball event RIGHT %u", millis()); + LOG_DEBUG("Trackball event RIGHT %u", millis()); #endif - e.inputEvent = this->_eventRight; + e.inputEvent = this->_eventRight; + } } #else - if (this->action == TB_ACTION_PRESSED && !digitalRead(_pinPress) && !pressDetected) { - // Start long press detection - pressDetected = true; - pressStartTime = millis(); - // Don't send event yet, wait to see if it's a long press - } else if (this->action == TB_ACTION_UP && !digitalRead(_pinUp) && !directionDetected) { - directionDetected = true; - directionStartTime = millis(); - e.inputEvent = this->_eventUp; - // send event first,will automatically trigger every 50ms * 3 after 500ms - } else if (this->action == TB_ACTION_DOWN && !digitalRead(_pinDown) && !directionDetected) { - directionDetected = true; - directionStartTime = millis(); - e.inputEvent = this->_eventDown; - } else if (this->action == TB_ACTION_LEFT && !digitalRead(_pinLeft) && !directionDetected) { - directionDetected = true; - directionStartTime = millis(); - e.inputEvent = this->_eventLeft; - } else if (this->action == TB_ACTION_RIGHT && !digitalRead(_pinRight) && !directionDetected) { - directionDetected = true; - directionStartTime = millis(); - e.inputEvent = this->_eventRight; + // A press event already claimed this poll: a direction IRQ must not overwrite the tap. + if (e.inputEvent == INPUT_BROKER_NONE) { + if (this->action == TB_ACTION_UP && !digitalRead(_pinUp) && !directionDetected) { + directionDetected = true; + directionStartTime = millis(); + e.inputEvent = this->_eventUp; + // send event first,will automatically trigger every 50ms * 3 after 500ms + } else if (this->action == TB_ACTION_DOWN && !digitalRead(_pinDown) && !directionDetected) { + directionDetected = true; + directionStartTime = millis(); + e.inputEvent = this->_eventDown; + } else if (this->action == TB_ACTION_LEFT && !digitalRead(_pinLeft) && !directionDetected) { + directionDetected = true; + directionStartTime = millis(); + e.inputEvent = this->_eventLeft; + } else if (this->action == TB_ACTION_RIGHT && !digitalRead(_pinRight) && !directionDetected) { + directionDetected = true; + directionStartTime = millis(); + e.inputEvent = this->_eventRight; + } } #endif @@ -224,9 +247,13 @@ int32_t TrackballInterruptBase::runOnce() void TrackballInterruptBase::intPressHandler() { - if (!Throttle::isWithinTimespanMs(lastInterruptTime, 10)) - this->action = TB_ACTION_PRESSED; - lastInterruptTime = millis(); + // pressIrqSeq == 0 means nothing recorded yet, so a press at clock 0 is not read as a cooldown. + if (pressIrqSeq != 0 && Throttle::isWithinTimespanMs(lastPressInterruptTime, 10)) + return; + lastPressInterruptTime = Time::getMillis(); + pressIrqTime = lastPressInterruptTime; + pressIrqSeq++; + this->action = TB_ACTION_PRESSED; } void TrackballInterruptBase::intDownHandler() diff --git a/src/input/TrackballInterruptBase.h b/src/input/TrackballInterruptBase.h index 908f62769c..100207d8ce 100644 --- a/src/input/TrackballInterruptBase.h +++ b/src/input/TrackballInterruptBase.h @@ -49,6 +49,12 @@ class TrackballInterruptBase : public Observable, public con volatile TrackballInterruptBaseActionType action = TB_ACTION_NONE; + enum class PressResult : uint8_t { None, Short, LongRepeat }; + + /// Press state machine, hardware-free so it can be unit tested. irqLatched/irqTimeMs come from + /// intPressHandler(), pinLow is the debounced pin state now (pull-up: pressed reads low). + PressResult updatePress(bool irqLatched, uint32_t irqTimeMs, bool pinLow); + // Long press detection for press button uint32_t pressStartTime = 0; uint32_t directionStartTime = 0; @@ -70,6 +76,13 @@ class TrackballInterruptBase : public Observable, public con const char *_originName; TrackballInterruptBaseActionType lastEvent = TB_ACTION_NONE; volatile uint32_t lastInterruptTime = 0; + // Own debounce clock so a tilt cannot swallow the click. A sequence rather than a flag, so an + // interrupt landing mid-poll is seen next poll instead of being cleared unread. + volatile uint32_t pressIrqSeq = 0; + volatile uint32_t pressIrqTime = 0; + volatile uint32_t lastPressInterruptTime = 0; + uint32_t pressIrqSeen = 0; + bool longPressRepeatSent = false; #if TB_THRESHOLD volatile uint8_t left_counter = 0; diff --git a/test/test_trackball_press/test_main.cpp b/test/test_trackball_press/test_main.cpp new file mode 100644 index 0000000000..717f38426f --- /dev/null +++ b/test/test_trackball_press/test_main.cpp @@ -0,0 +1,164 @@ +// Unit tests for TrackballInterruptBase::updatePress(): short/long classification, the interrupt +// latch that keeps a tap released between polls, and repeat timing across the millis() rollover. +#include "TestUtil.h" +#include "UptimeClock.h" +#include "input/TrackballInterruptBase.h" +#include + +namespace +{ +// updatePress() is protected: expose it without touching any GPIO. +class PressProbe : public TrackballInterruptBase +{ + public: + PressProbe() : TrackballInterruptBase("tbpress") {} + using TrackballInterruptBase::PressResult; + using TrackballInterruptBase::updatePress; +}; + +constexpr uint32_t LONG_PRESS_MS = 500; +constexpr uint32_t LONG_REPEAT_MS = 300; +constexpr uint32_t START_MS = 100000; +} // namespace + +void setUp(void) +{ + Time::setTestMillis(START_MS); +} + +void tearDown(void) +{ + Time::useRealClock(); +} + +void test_tap_released_before_poll_emits_short() +{ + PressProbe tb; + TEST_ASSERT_TRUE(PressProbe::PressResult::Short == tb.updatePress(true, START_MS, false)); +} + +void test_held_then_released_under_threshold_emits_short() +{ + PressProbe tb; + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(true, START_MS, true)); + + Time::advanceTestMillis(LONG_PRESS_MS - 1); + TEST_ASSERT_TRUE(PressProbe::PressResult::Short == tb.updatePress(false, 0, false)); +} + +void test_still_held_under_threshold_emits_nothing() +{ + PressProbe tb; + tb.updatePress(true, START_MS, true); + + Time::advanceTestMillis(LONG_PRESS_MS / 2); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, true)); +} + +void test_hold_emits_long_repeats_at_the_repeat_interval() +{ + PressProbe tb; + tb.updatePress(true, START_MS, true); + + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(LONG_REPEAT_MS - 1); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(1); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); +} + +void test_release_after_long_press_emits_nothing() +{ + PressProbe tb; + tb.updatePress(true, START_MS, true); + + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(10); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, false)); +} + +void test_press_after_release_is_tracked_again() +{ + PressProbe tb; + tb.updatePress(true, START_MS, true); + Time::advanceTestMillis(10); + TEST_ASSERT_TRUE(PressProbe::PressResult::Short == tb.updatePress(false, 0, false)); + + Time::advanceTestMillis(10); + const uint32_t secondIrq = Time::getMillis(); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(true, secondIrq, true)); + + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); +} + +void test_idle_poll_emits_nothing() +{ + PressProbe tb; + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, false)); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, true)); +} + +void test_hold_across_the_millis_wrap() +{ + PressProbe tb; + const uint32_t nearWrap = 0xFFFFFF00u; + Time::setTestMillis(nearWrap); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(true, nearWrap, true)); + + // Crosses the 32-bit rollover mid-press; a raw millis() compare would fire or stall here. + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); +} + +// A delayed first poll must not report a long hold as a tap just because the pin is already high. +void test_long_hold_released_before_first_poll_is_not_short() +{ + PressProbe tb; + const uint32_t irq = Time::getMillis(); + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(true, irq, false)); +} + +// A repeat emitted exactly when the clock reads 0 must not be mistaken for "no repeat sent yet". +void test_repeat_emitted_at_clock_zero_still_waits() +{ + PressProbe tb; + const uint32_t beforeWrap = 0u - LONG_PRESS_MS; // start + LONG_PRESS_MS wraps to exactly 0 + Time::setTestMillis(beforeWrap); + tb.updatePress(true, beforeWrap, true); + + Time::advanceTestMillis(LONG_PRESS_MS); + TEST_ASSERT_EQUAL_UINT32(0, Time::getMillis()); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(LONG_REPEAT_MS - 1); + TEST_ASSERT_TRUE(PressProbe::PressResult::None == tb.updatePress(false, 0, true)); + + Time::advanceTestMillis(1); + TEST_ASSERT_TRUE(PressProbe::PressResult::LongRepeat == tb.updatePress(false, 0, true)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_tap_released_before_poll_emits_short); + RUN_TEST(test_held_then_released_under_threshold_emits_short); + RUN_TEST(test_still_held_under_threshold_emits_nothing); + RUN_TEST(test_hold_emits_long_repeats_at_the_repeat_interval); + RUN_TEST(test_release_after_long_press_emits_nothing); + RUN_TEST(test_press_after_release_is_tracked_again); + RUN_TEST(test_idle_poll_emits_nothing); + RUN_TEST(test_hold_across_the_millis_wrap); + RUN_TEST(test_long_hold_released_before_first_poll_is_not_short); + RUN_TEST(test_repeat_emitted_at_clock_zero_still_waits); + exit(UNITY_END()); +} + +void loop() {} From ad2d27ab5b8af47e4f0ef47b7e3c3e3635352052 Mon Sep 17 00:00:00 2001 From: Ixitxachitl Date: Wed, 26 Aug 2026 20:21:50 +0000 Subject: [PATCH 009/143] =?UTF-8?q?feat(emotes):=20add=20the=20?= =?UTF-8?q?=F0=9F=93=8D=20pushpin=20emote=20(#11618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds U+1F4CD as a 16x16 emote, so waypoint text carrying the pushpin renders a glyph instead of falling back to the replacement box. --- src/graphics/emotes.cpp | 7 ++++++- src/graphics/emotes.h | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/graphics/emotes.cpp b/src/graphics/emotes.cpp index 9fb26fa1fd..9bb807d56f 100644 --- a/src/graphics/emotes.cpp +++ b/src/graphics/emotes.cpp @@ -188,7 +188,8 @@ const Emote emotes[] = { {"\u2603\uFE0F", snow_man, snow_man_width, snow_man_height}, // ☃️ Snowman // --- Misc --- - {"\U0001F4A8", dashing_away, dashing_away_width, dashing_away_height} // 💨 Dashing Away + {"\U0001F4A8", dashing_away, dashing_away_width, dashing_away_height}, // 💨 Dashing Away + {"\U0001F4CD", pushpin, pushpin_width, pushpin_height} // 📍 Pushpin #endif }; @@ -606,6 +607,10 @@ const unsigned char wood[] PROGMEM = {0xF0, 0x0F, 0x08, 0x10, 0x04, 0x20, 0x0C, const unsigned char beer[] PROGMEM = {0x00, 0x00, 0x50, 0x05, 0xA8, 0x0A, 0x5C, 0x1D, 0xE4, 0x13, 0x1C, 0x7C, 0xE4, 0x73, 0x04, 0x50, 0x04, 0x51, 0x14, 0x50, 0x44, 0x54, 0x04, 0x71, 0x44, 0x70, 0x04, 0x10, 0x18, 0x0C, 0xE0, 0x03}; + +const unsigned char pushpin[] PROGMEM = {0x00, 0x00, 0xC0, 0x07, 0xE0, 0x0F, 0xF0, 0x1F, 0xF0, 0x1F, 0xF0, + 0x17, 0xF0, 0x13, 0xF0, 0x11, 0x60, 0x08, 0xC0, 0x07, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00}; #endif } // namespace graphics diff --git a/src/graphics/emotes.h b/src/graphics/emotes.h index 9646ac78ef..cb00f5d0ed 100644 --- a/src/graphics/emotes.h +++ b/src/graphics/emotes.h @@ -425,6 +425,10 @@ extern const unsigned char wood[] PROGMEM; #define beer_width 16 #define beer_height 16 extern const unsigned char beer[] PROGMEM; + +#define pushpin_width 16 +#define pushpin_height 16 +extern const unsigned char pushpin[] PROGMEM; #endif // EXCLUDE_EMOJI } // namespace graphics From 514b476189b3a87a09b827da61c8d78c33ee1299 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 26 Aug 2026 20:34:02 +0000 Subject: [PATCH 010/143] feat(admin): append the optional ham long_name to the call sign (#11612) * feat(admin): append the optional ham long_name to the call sign HamParameters gained a long_name field (meshtastic/protobufs#941) that handleSetHamMode never read, so a client that sent one still ended up with a node named after the bare call sign. Join it behind the call sign with the "//" separator hams already use on the air: call_sign "N0CALL" plus long_name "Attic Heltec" becomes "N0CALL//Attic Heltec". An absent long_name keeps the previous call-sign-only name, which is what the on-device region picker still sends. Being cosmetic, long_name stays out of the whitespace-only rejection that guards call_sign and short_name: a blank one is dropped rather than costing the operator the whole licensing request over a stray space, which that path would report only as a LOG_WARN and so would be invisible from the app. The composed name is finished with clampLongName() rather than a bare sanitizeUtf8(), matching handleSetOwner and NodeDB: the proto caps the parts at 7 + 2 + 14 bytes, inside the 24-byte local budget, and clampLongName is the backstop if either cap moves. Co-Authored-By: Claude Opus 5 * feat(admin): enhance handleSetHamMode to return status for request validation --------- Co-authored-by: Claude Opus 5 --- src/modules/AdminModule.cpp | 56 ++++--- src/modules/AdminModule.h | 4 +- test/test_admin_radio/test_main.cpp | 221 ++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 22 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 55b029f031..0af7ed9e05 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -368,7 +368,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; case meshtastic_AdminMessage_set_ham_mode_tag: LOG_DEBUG("Client set ham mode"); - handleSetHamMode(r->set_ham_mode); + // Without this a rejected request falls through to the generic Routing_Error_NONE ack below, + // so a client would report ham mode as enabled on a node that changed nothing. + if (!handleSetHamMode(r->set_ham_mode)) + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; case meshtastic_AdminMessage_get_ui_config_request_tag: { LOG_DEBUG("Client is getting device-ui config"); @@ -1920,30 +1923,40 @@ void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uic #endif } -void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) +// Unset, or set to nothing but whitespace - the two ways a client can leave a name field empty. +static bool isBlankName(const char *start) { - // Validate ham parameters before setting since this would bypass validation in the owner struct - const char *fieldsToCheck[] = {p.call_sign, p.short_name}; - const char *fieldNames[] = {"call_sign", "short_name"}; - for (int i = 0; i < 2; i++) { - if (*fieldsToCheck[i]) { - const char *start = fieldsToCheck[i]; - while (*start && isspace((unsigned char)*start)) - start++; - if (*start == '\0') { - LOG_WARN("Rejected ham %s: needs 1+ non-whitespace char", fieldNames[i]); - return; - } - } + while (*start && isspace((unsigned char)*start)) + start++; + return *start == '\0'; +} + +bool AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) +{ + // Validate ham parameters before setting since this would bypass validation in the owner struct. + + // The call sign is the station ID the whole licensed mode is built around, so it is required; + // without it we would license a node that never identifies itself on the air. + if (isBlankName(p.call_sign)) { + LOG_WARN("Rejected ham call_sign: needs 1+ non-whitespace char"); + return false; } - // Set call sign and override lora limitations for licensed use - strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name)); + // Set call sign and override lora limitations for licensed use. An optional long_name rides + // behind the call sign with the "//" separator hams already use on the air. + // e.g. call_sign "N0CALL" plus long_name "Attic Heltec" becomes "N0CALL//Attic Heltec". + if (!isBlankName(p.long_name)) + snprintf(owner.long_name, sizeof(owner.long_name), "%s//%s", p.call_sign, p.long_name); + else + 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)); + clampLongName(owner.long_name); + // short_name is optional per the schema, so a blank one keeps the name the node already had + if (!isBlankName(p.short_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; @@ -1977,6 +1990,7 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) service->reloadOwner(false); saveChanges(SEGMENT_CONFIG | SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); + return true; } AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_APP, &meshtastic_AdminMessage_msg) diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index b8e8c59ade..0fbc8ac3b1 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -95,7 +95,9 @@ class AdminModule : public ProtobufModule, public Obser void handleSetChannel(); public: - void handleSetHamMode(const meshtastic_HamParameters &req); + /// Applies licensed-operator settings. False if the request was rejected and nothing changed, + /// so the caller can answer a want_response client with an error instead of an implicit success. + bool handleSetHamMode(const meshtastic_HamParameters &req); /// Note an admin request leaving this node for a remote, so that remote's response is /// accepted. Called from the client-to-mesh path (MeshService::handleToRadio). diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 004037c9ad..8faebd16a6 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -21,6 +21,7 @@ #include "TestUtil.h" #include "graphics/draw/MenuHandler.h" #include "mesh/Channels.h" +#include "mesh/Router.h" // router global: allocErrorResponse() allocates the reply through it #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include // crc32Buffer(), for the my_node_num == crc32(public_key) invariant @@ -1000,6 +1001,10 @@ static meshtastic_DeviceState savedDeviceState; static meshtastic_User savedOwner; static meshtastic_LocalConfig savedConfig; static meshtastic_ChannelFile savedChannelFile; +// Only the ham dispatcher test installs a router (allocErrorResponse() allocates through it). +// Saved/torn down for every test so a failed assertion's longjmp cannot leave one dangling. +static Router *savedRouter; +static Router *hamMockRouter; // Called from setUp/tearDown for every test, not opted into by a handful. A shared NodeDB plus // unrestored config/owner/devicestate/channelFile means each test inherits whatever its @@ -1008,6 +1013,7 @@ static void replaceAdminRadioGlobals() { savedNodeDB = nodeDB; savedNodeInfoModule = nodeInfoModule; + savedRouter = router; savedDeviceState = devicestate; savedOwner = owner; savedConfig = config; @@ -1020,6 +1026,9 @@ static void restoreAdminRadioGlobals() { nodeInfoModule = savedNodeInfoModule; nodeDB = savedNodeDB; + router = savedRouter; + delete hamMockRouter; + hamMockRouter = nullptr; delete replacementNodeDB; replacementNodeDB = nullptr; devicestate = savedDeviceState; @@ -1084,6 +1093,210 @@ static void test_handleSetOwner_persistsLicensedChannelSanitation() TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write"); } +// ----------------------------------------------------------------------- +// handleSetHamMode() name assembly: the ham long_name rides behind the call +// sign with the "//" separator hams already use on the air. +// ----------------------------------------------------------------------- + +// Licensing a node touches channels, the NodeDB and the owner struct; an UNSET region keeps the +// keygen/identity-migration path out of these name-only assertions. +static void primeHamModeTest() +{ + owner = meshtastic_User_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + channels.initDefaults(); + nodeInfoModule = reinterpret_cast(1); // reloadOwner(false) only checks presence + testAdmin->deferSaves(); +} + +static void test_handleSetHamMode_appendsLongNameToCallSign() +{ + primeHamModeTest(); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1); + strncpy(p.short_name, "ABC", sizeof(p.short_name) - 1); + strncpy(p.long_name, "Attic Heltec", sizeof(p.long_name) - 1); + TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p)); + + TEST_ASSERT_EQUAL_STRING("KD2ABC//Attic Heltec", owner.long_name); + TEST_ASSERT_EQUAL_STRING("ABC", owner.short_name); + TEST_ASSERT_TRUE(owner.is_licensed); +} + +// The widest pair the proto can carry (7 + 2 + 14) still has to arrive whole, or the operator +// silently loses the tail of the name they typed. +static void test_handleSetHamMode_widestPairSurvivesTheLongNameCap() +{ + primeHamModeTest(); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABCD", sizeof(p.call_sign) - 1); + strncpy(p.long_name, "Attic Heltec 3", sizeof(p.long_name) - 1); + TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p)); + + TEST_ASSERT_EQUAL_STRING("KD2ABCD//Attic Heltec 3", owner.long_name); + TEST_ASSERT_LESS_OR_EQUAL(MAX_LONG_NAME_BYTES, strlen(owner.long_name)); +} + +static void test_handleSetHamMode_omittedLongNameKeepsCallSignAlone() +{ + primeHamModeTest(); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1); + testAdmin->handleSetHamMode(p); + + TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name); + TEST_ASSERT_TRUE(owner.is_licensed); +} + +// long_name is optional both ways a client can leave it empty: a whitespace-only one is dropped +// (no dangling "//" on the air) instead of costing the operator the whole licensing request. +static void test_handleSetHamMode_blankLongNameIsIgnoredNotRejected() +{ + primeHamModeTest(); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1); + strncpy(p.long_name, " ", sizeof(p.long_name) - 1); + testAdmin->handleSetHamMode(p); + + TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name); + TEST_ASSERT_TRUE(owner.is_licensed); +} + +// The call sign is required, unlike the two optional name fields: an empty one would license a +// node that never identifies itself, and once a long_name is set it would compose to a dangling +// "//Attic Heltec". +static void test_handleSetHamMode_blankCallSignIsRejected() +{ + primeHamModeTest(); + + meshtastic_HamParameters missing = meshtastic_HamParameters_init_zero; + strncpy(missing.long_name, "Attic Heltec", sizeof(missing.long_name) - 1); + TEST_ASSERT_FALSE(testAdmin->handleSetHamMode(missing)); + + TEST_ASSERT_EQUAL_STRING("", owner.long_name); + TEST_ASSERT_FALSE(owner.is_licensed); + + primeHamModeTest(); + + meshtastic_HamParameters whitespace = meshtastic_HamParameters_init_zero; + strncpy(whitespace.call_sign, " ", sizeof(whitespace.call_sign) - 1); + TEST_ASSERT_FALSE(testAdmin->handleSetHamMode(whitespace)); + + TEST_ASSERT_EQUAL_STRING("", owner.long_name); + TEST_ASSERT_FALSE(owner.is_licensed); +} + +// short_name is optional too, so a blank one keeps whatever the node was already called instead of +// blanking it - licensing the node must not cost the operator their existing short name. +static void test_handleSetHamMode_blankShortNameKeepsTheExistingOne() +{ + for (const char *blank : {"", " "}) { + primeHamModeTest(); + strncpy(owner.short_name, "OLD", sizeof(owner.short_name) - 1); + + meshtastic_HamParameters p = meshtastic_HamParameters_init_zero; + strncpy(p.call_sign, "KD2ABC", sizeof(p.call_sign) - 1); + strncpy(p.short_name, blank, sizeof(p.short_name) - 1); + TEST_ASSERT_TRUE(testAdmin->handleSetHamMode(p)); + + TEST_ASSERT_EQUAL_STRING("OLD", owner.short_name); + TEST_ASSERT_EQUAL_STRING("KD2ABC", owner.long_name); + TEST_ASSERT_TRUE(owner.is_licensed); + } +} + +// A rejection has to reach the client, not just the log: allocErrorResponse() builds the reply +// through the router, so this is the one ham test that needs one. +class HamModeMockRouter : public Router +{ + public: + ~HamModeMockRouter() + { + delete cryptLock; // the Router ctor asserts this is clear, so a later suite can construct one + cryptLock = nullptr; + } + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } +}; + +// Pull the Routing error out of the ack/nak a handler queued in myReply. +static bool decodeRoutingError(meshtastic_MeshPacket *reply, meshtastic_Routing_Error &out) +{ + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (reply->decoded.portnum != meshtastic_PortNum_ROUTING_APP) + return false; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_Routing_msg, &routing)) + return false; + if (routing.which_variant != meshtastic_Routing_error_reason_tag) + return false; + out = routing.error_reason; + return true; +} + +// Handler-level rejection is invisible to a want_response client on its own: with no reply queued, +// handleReceivedProtobuf() falls through to its generic "ACK" and answers Routing_Error_NONE, so the +// app reports ham mode as enabled on a node that changed nothing. The dispatcher has to say +// BAD_REQUEST before that fallback runs. +static void test_handleSetHamMode_blankCallSignRepliesBadRequest() +{ + primeHamModeTest(); + hamMockRouter = new HamModeMockRouter(); + router = hamMockRouter; + + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_ham_mode_tag; + strncpy(m.set_ham_mode.long_name, "Attic Heltec", sizeof(m.set_ham_mode.long_name) - 1); + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; // local client, so the passkey gate is bypassed and the switch body runs + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.want_response = true; + testAdmin->handleReceivedProtobuf(mp, &m); + + meshtastic_Routing_Error err = meshtastic_Routing_Error_NONE; + TEST_ASSERT_TRUE_MESSAGE(decodeRoutingError(testAdmin->reply(), err), "a rejected request must queue an error reply"); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_BAD_REQUEST, err); + TEST_ASSERT_FALSE(owner.is_licensed); + testAdmin->drainReply(); +} + +// The other half of the pair: an accepted request still answers Routing_Error_NONE. Asserting both +// sides is the point - NONE is what the rejection path used to borrow, so a test that only checked +// the reject case could pass against a handler that answered NONE to everything. +static void test_handleSetHamMode_acceptedRequestAcksSuccess() +{ + primeHamModeTest(); + hamMockRouter = new HamModeMockRouter(); + router = hamMockRouter; + + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_ham_mode_tag; + strncpy(m.set_ham_mode.call_sign, "KD2ABC", sizeof(m.set_ham_mode.call_sign) - 1); + strncpy(m.set_ham_mode.long_name, "Attic Heltec", sizeof(m.set_ham_mode.long_name) - 1); + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.want_response = true; + testAdmin->handleReceivedProtobuf(mp, &m); + + meshtastic_Routing_Error err = meshtastic_Routing_Error_BAD_REQUEST; + TEST_ASSERT_TRUE_MESSAGE(decodeRoutingError(testAdmin->reply(), err), "want_response must be answered"); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, err); + TEST_ASSERT_EQUAL_STRING("KD2ABC//Attic Heltec", owner.long_name); + TEST_ASSERT_TRUE(owner.is_licensed); + testAdmin->drainReply(); +} + static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() { owner = meshtastic_User_init_zero; @@ -2001,6 +2214,14 @@ void setup() // getRegion() RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); + RUN_TEST(test_handleSetHamMode_appendsLongNameToCallSign); + RUN_TEST(test_handleSetHamMode_widestPairSurvivesTheLongNameCap); + RUN_TEST(test_handleSetHamMode_omittedLongNameKeepsCallSignAlone); + RUN_TEST(test_handleSetHamMode_blankLongNameIsIgnoredNotRejected); + RUN_TEST(test_handleSetHamMode_blankShortNameKeepsTheExistingOne); + RUN_TEST(test_handleSetHamMode_blankCallSignIsRejected); + RUN_TEST(test_handleSetHamMode_blankCallSignRepliesBadRequest); + RUN_TEST(test_handleSetHamMode_acceptedRequestAcksSuccess); RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity); RUN_TEST(test_handleSetConfig_persistsUnlicensedFirstRegionIdentity); RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); From d2eb6b0a2cd35fda71033747b421c5338f828b47 Mon Sep 17 00:00:00 2001 From: Jim C K Flaten Date: Wed, 26 Aug 2026 21:09:03 +0000 Subject: [PATCH 011/143] Fix incorrectly placed newline in CSV export (#11535) * Fix incorrectly placed newline in CSV export * Appease our AI overlords * No comma for you! --- src/modules/RangeTestModule.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index 46475a3ae2..5510ec0d67 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -310,9 +310,10 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) fileToAppend.printf("%d,", mp.hop_limit); // Packet Hop Limit // TODO: If quotes are found in the payload, it has to be escaped. - fileToAppend.printf("\"%.*s\"\n", (int)p.payload.size, p.payload.bytes); - fileToAppend.printf("%i,", mp.rx_rssi); // RX RSSI + fileToAppend.printf("\"%.*s\",", (int)p.payload.size, p.payload.bytes); + fileToAppend.printf("%i", mp.rx_rssi); // RX RSSI + fileToAppend.printf("\n"); fileToAppend.flush(); fileToAppend.close(); From 9461670f49d8ae5bb1497092fb443a6256718e01 Mon Sep 17 00:00:00 2001 From: Alec Perkins <99231+alecperkins@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:01:57 +0000 Subject: [PATCH 012/143] Add relaying node and RSSI/SNR to the implicit-ack routing packet (#10767) When we overhear another node rebroadcast one of our own packets, we generate an implicit-ack ROUTING packet for the local sending process. That ack is delivered locally to the phone, so pass the overheard rebroadcast as a relay source and copy its relay_node and the rx_rssi / rx_snr we heard it at onto the ack. This lets the connected client see which node relayed our packet and the link quality, instead of only learning that the packet was repeated. allocAckNak / sendAckNak gain an optional relaySource parameter. The ack is sent to ourselves (to == us), so Router::send() is bypassed and does not overwrite these fields with our own. Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Ben Meadors --- src/mesh/MeshModule.cpp | 10 +++++++++- src/mesh/MeshModule.h | 2 +- src/mesh/ReliableRouter.cpp | 4 +++- src/mesh/Router.cpp | 4 ++-- src/mesh/Router.h | 2 +- src/modules/RoutingModule.cpp | 8 ++++---- src/modules/RoutingModule.h | 4 ++-- 7 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index 71da37145b..d615f45fcf 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -52,7 +52,7 @@ int32_t MeshModule::setStartDelay() } meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, - uint8_t hopLimit) + uint8_t hopLimit, const meshtastic_MeshPacket *relaySource) { meshtastic_Routing c = meshtastic_Routing_init_default; @@ -75,6 +75,14 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod p->to = to; p->decoded.request_id = idFrom; p->channel = chIndex; + // When this ack reports an overheard rebroadcast of our own packet, carry that copy's relaying node + // and the link metrics (RSSI/SNR) we heard it at, so the phone can attribute them to the relayer. The + // ack is delivered locally (to == us), so Router::send() is bypassed and won't overwrite these. + if (relaySource) { + p->relay_node = relaySource->relay_node; + p->rx_rssi = relaySource->rx_rssi; + p->rx_snr = relaySource->rx_snr; + } if (err != meshtastic_Routing_Error_NONE) LOG_WARN("Alloc an err=%d,to=0x%08x,idFrom=0x%08x,id=0x%08x", err, to, idFrom, p->id); diff --git a/src/mesh/MeshModule.h b/src/mesh/MeshModule.h index 3dc6414a51..da7be49ba7 100644 --- a/src/mesh/MeshModule.h +++ b/src/mesh/MeshModule.h @@ -187,7 +187,7 @@ class MeshModule virtual Observable *getUIFrameObservable() { return NULL; } meshtastic_MeshPacket *allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, - uint8_t hopLimit = 0); + uint8_t hopLimit = 0, const meshtastic_MeshPacket *relaySource = nullptr); /// Send an error response for the specified packet. meshtastic_MeshPacket *allocErrorResponse(meshtastic_Routing_Error err, const meshtastic_MeshPacket *p); diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 4e8d2c2e90..8be48f105e 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -74,7 +74,9 @@ void ReliableRouter::perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_ LOG_DEBUG("Generate implicit ack"); // NOTE: we do NOT check p->wantAck here because p is the INCOMING rebroadcast and that packet is not expected to be // marked as wantAck - sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel); + // Pass the overheard rebroadcast as the relay source so the ack carries the relaying node's id + // and the RSSI/SNR we heard it at. + sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel, 0, false, p); // Only stop retransmissions if the rebroadcast came via LoRa if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) { diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index fbb0f9bc91..0d40ae715f 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -384,9 +384,9 @@ meshtastic_MeshPacket *Router::allocForSending() * Send an ack or a nak packet back towards whoever sent idFrom */ void Router::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit, - bool ackWantsAck) + bool ackWantsAck, const meshtastic_MeshPacket *relaySource) { - routingModule->sendAckNak(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + routingModule->sendAckNak(err, to, idFrom, chIndex, hopLimit, ackWantsAck, relaySource); } void Router::abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p) diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 069b4ede0a..d4ffe85c15 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -171,7 +171,7 @@ class Router : protected concurrency::OSThread, protected PacketHistory * Send an ack or a nak packet back towards whoever sent idFrom */ void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false); + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr); private: /** diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index 3d0812e5ea..1ed9b0fe09 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -48,9 +48,9 @@ meshtastic_MeshPacket *RoutingModule::allocReply() } void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit, - bool ackWantsAck) + bool ackWantsAck, const meshtastic_MeshPacket *relaySource) { - auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit); + auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit, relaySource); if (!p) return; @@ -81,9 +81,9 @@ uint8_t RoutingModule::getHopLimitForResponse(const meshtastic_MeshPacket &mp) } meshtastic_MeshPacket *RoutingModule::allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, - uint8_t hopLimit) + uint8_t hopLimit, const meshtastic_MeshPacket *relaySource) { - return MeshModule::allocAckNak(err, to, idFrom, chIndex, hopLimit); + return MeshModule::allocAckNak(err, to, idFrom, chIndex, hopLimit, relaySource); } RoutingModule::RoutingModule() : ProtobufModule("routing", meshtastic_PortNum_ROUTING_APP, &meshtastic_Routing_msg) diff --git a/src/modules/RoutingModule.h b/src/modules/RoutingModule.h index 2ac42f447c..bd4dc8a2a0 100644 --- a/src/modules/RoutingModule.h +++ b/src/modules/RoutingModule.h @@ -14,10 +14,10 @@ class RoutingModule : public ProtobufModule RoutingModule(); virtual void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false); + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr); meshtastic_MeshPacket *allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, - uint8_t hopLimit = 0); + uint8_t hopLimit = 0, const meshtastic_MeshPacket *relaySource = nullptr); // Given the hopStart and hopLimit upon reception of a request, return the hop limit to use for the response uint8_t getHopLimitForResponse(const meshtastic_MeshPacket &mp); From a89f1920e199080333fdf914ae3ea073838ed048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 27 Aug 2026 07:37:20 +0000 Subject: [PATCH 013/143] fix(traffic): don't re-stamp dropped duplicate positions, which slid the dedup window indefinitely (#11620) * fix(traffic): don't re-stamp dropped duplicate positions, which slid the dedup window indefinitely * test(traffic): trim the regression test comment and derive its counts --- src/modules/TrafficManagementModule.cpp | 15 +++++++---- test/test_traffic_management/test_main.cpp | 29 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index f049720fb7..f12fbfbcdc 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -1406,12 +1406,17 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, TM_LOG_TRACE("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, entry->pos_fingerprint, samePosition, withinInterval, isNew); - // Update cache entry (raw tick; 0 is a valid tick value) - entry->pos_fingerprint = fingerprint; - entry->pos_time = nowPosTick; - // Drop only if same position AND within the minimum interval - return samePosition && withinInterval; + const bool drop = samePosition && withinInterval; + + // Stamp only what we let through: re-stamping a dropped duplicate slides the window forward on + // every repeat, muting a node that broadcasts faster than the window instead of refreshing it. + if (!drop) { + entry->pos_fingerprint = fingerprint; + entry->pos_time = nowPosTick; + } + + return drop; #endif } diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 10af9cbe37..086f47a51d 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -2382,6 +2382,34 @@ static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); } +/** + * Verify a dropped duplicate does not re-stamp the entry: re-stamping slides the window forward on + * every repeat, muting a node that broadcasts faster than the window instead of refreshing it. + */ +static void test_tm_positionDedup_continuousDuplicatesStillRefresh(void) +{ + constexpr uint32_t kPosTickMs = 360000; // mirrors the module's private kPosTimeTickMs + constexpr uint32_t kWindowTicks = 2; + constexpr uint32_t kTicksFed = kWindowTicks * 3; // a duplicate every tick, over whole windows + constexpr uint32_t kExpectedPasses = kTicksFed / kWindowTicks; + constexpr uint32_t kExpectedDrops = kTicksFed - kExpectedPasses; + + moduleConfig.traffic_management.position_min_interval_secs = (kWindowTicks * kPosTickMs) / 1000; + installWellKnownPrimaryChannelWithPrecision(16); + TrafficManagementModuleTestShim module; + + uint32_t passed = 0; + for (uint32_t tick = 0; tick < kTicksFed; tick++) { + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + if (module.handleReceived(dup) == ProcessMessage::CONTINUE) + passed++; + TrafficManagementModule::s_testNowMs += kPosTickMs; + } + + TEST_ASSERT_EQUAL_UINT32(kExpectedPasses, passed); + TEST_ASSERT_EQUAL_UINT32(kExpectedDrops, module.getStats().position_dedup_drops); +} + /** * Verify interval=0 disables position deduplication. * Important because this is an explicit configuration escape hatch. @@ -3357,6 +3385,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires); + RUN_TEST(test_tm_positionDedup_continuousDuplicatesStillRefresh); RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops); RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision); RUN_TEST(test_tm_positionDedup_distinctAtClampedChannelPrecision); From 5b787fc636e07b0c3a6ed4af6d1354357d749291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 27 Aug 2026 08:56:23 +0000 Subject: [PATCH 014/143] Remove the docs directory (#11622) The firmware design docs were published to meshtastic/meshtastic in #11488 and the directory was deleted. bme680_iaq_replay.md re-added it. The replay harness build command moves into the header comment of bin/bme680_iaq_replay.cpp, the only file that referenced the document. --- bin/bme680_iaq_replay.cpp | 3 ++- docs/bme680_iaq_replay.md | 54 --------------------------------------- 2 files changed, 2 insertions(+), 55 deletions(-) delete mode 100644 docs/bme680_iaq_replay.md diff --git a/bin/bme680_iaq_replay.cpp b/bin/bme680_iaq_replay.cpp index 62d0a5286e..aa65c43c94 100644 --- a/bin/bme680_iaq_replay.cpp +++ b/bin/bme680_iaq_replay.cpp @@ -1,5 +1,6 @@ // Replays a captured BME680 CSV trace (gas_ohms,rh[,bsec_iaq]) through -// BME680IaqEstimator for offline tuning. See docs/bme680_iaq_replay.md. +// BME680IaqEstimator for offline tuning. Build from the repo root: +// c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp #include "modules/Telemetry/Sensor/BME680IaqEstimator.h" diff --git a/docs/bme680_iaq_replay.md b/docs/bme680_iaq_replay.md deleted file mode 100644 index 5fc8d96df3..0000000000 --- a/docs/bme680_iaq_replay.md +++ /dev/null @@ -1,54 +0,0 @@ -# BME680 IAQ replay harness - -`bin/bme680_iaq_replay.cpp` replays a captured sensor trace through the in-tree -`BME680IaqEstimator` on a dev machine, for tuning the estimator's constants -against recorded Bosch BSEC output. The estimator is pure math with no platform -dependencies, so a trace replays in milliseconds - edit the constants in -`src/modules/Telemetry/Sensor/BME680IaqEstimator.h`, recompile, rerun. - -## Build - -From the repo root: - -```bash -c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay \ - bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp -``` - -## Input - -CSV on stdin or as a file argument, one sample per line: - -```text -gas_ohms,relative_humidity[,bsec_iaq] -``` - -Lines starting with `#` are ignored; a single non-numeric header row is -tolerated; any other malformed line is reported on stderr and skipped. - -## Capturing a trace - -On a firmware build that still links BSEC (any release tag before the BSEC -removal), add one log line to `BME680Sensor::getMetrics` in the BSEC branch: - -```cpp -LOG_INFO("IAQCSV,%.0f,%.2f,%.0f", bme680.getData(BSEC_OUTPUT_RAW_GAS).signal, - bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal, - bme680.getData(BSEC_OUTPUT_IAQ).signal); -``` - -then extract the columns from the serial log: - -```bash -grep -o 'IAQCSV,.*' serial.log | cut -d, -f2- > trace.csv -``` - -BSEC's `RAW_GAS` and heat-compensated humidity are exactly the estimator's -inputs, so one physical sensor feeds both algorithms identically. - -## Output - -Per-sample CSV `n,gas_ohms,rh,est_iaq,bsec_iaq` on stdout (empty `est_iaq` -during the estimator's warm-up/burn-in window), plus a stderr summary with the -mean absolute error and UI-band agreement against the `bsec_iaq` column, using -the same 0-500 band thresholds the device screen applies. From e3aa86b3e7dd4b8c488bbb8c18fb742034e92492 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:30:43 +0000 Subject: [PATCH 015/143] fix(power): let a configured INA outrank the board's charge-status pin (#11510) * fix(power): let a configured INA outrank the board's charge-status pin AnalogBatteryLevel::isCharging() chose its source with a preprocessor chain that put EXT_CHRG_DETECT / BATTERY_CHARGING_INV ahead of the INA current check, so on any board defining a charge-status pin the INA arm was compiled out entirely. Setting device_battery_ina_address changed only the reported voltage, never the charging state, even though getBattVoltage() has always let a configured INA outrank BATTERY_PIN. The INA path has sat inside that #else since it was introduced in #5271, but it only started biting the Seeed Xiao nRF52840 Kit when 070deb290 (#6930, v2.6.11) gave the variant EXT_CHRG_DETECT for the onboard BQ25101 ~CHG line. Charging through an external charger leaves that pin idle, so the node reports "not charging" forever and the UI never shows the charging icon. Move the INA check ahead of the pin arms, keeping the SGM41562 and RAK9154 checks first since those report real charger state. hasINA() is false unless the user set a non-zero device_battery_ina_address matching a detected INA, so stock boards are unaffected, and DISABLE_INA_CHARGING_DETECTION remains the per-board opt-out. The no-pin and telemetry-disabled arms keep their previous behaviour exactly. Fixes #11485 * fix(power): report INA readiness from the sensor, not runOnce()'s delay hasINA() read runOnce()'s return as a success flag, but it is a poll interval: initI2CSensor() hands back the same DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS whether or not the device answered, and it has done so since the helper was written in #1498, a year before hasINA() arrived in #2536. So a detected-but-unusable INA reported as present. With a real sensor that failed begin() this self-corrected on the next call, because initI2CSensor() clears the nodeTelemetrySensorsMap entry on failure. With a NullSensor - what these globals resolve to when no driver is compiled in - it never did: NullSensor::runOnce() returns INT32_MAX and never sets status or initialized, so hasINA() stayed true, getINAVoltage() returned 0 mV, and getBatteryPercent() sat at -1 with the battery reported absent. Open the sensor if needed, then return isRunning(), which is the status the sensor actually tracks. Collapsing the four arms onto that helper lets the repeated config lookup become a local. * fix(power): route INA260 current into charging detection INA260Sensor exposed bus voltage but not current, and getINACurrent() had no INA260 arm, so a configured INA260 read 0 mA and isCharging() always answered false. Now that a configured INA outranks the board's charge-status pin, that became a regression on boards defining one. Implement CurrentSensor on INA260Sensor, where the reading was already being taken for telemetry via readCurrent(), and add the matching arm to getINACurrent(). This also fixes the pre-existing always-false result on boards with no charge-status pin at all. --- src/Power.cpp | 61 +++++++++++-------- src/modules/Telemetry/Sensor/INA260Sensor.cpp | 5 ++ src/modules/Telemetry/Sensor/INA260Sensor.h | 4 +- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 780f7ba74c..8e0f740914 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -595,13 +595,8 @@ class AnalogBatteryLevel : public HasBatteryLevel return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse; } #endif -#if defined(ELECROW_ThinkNode_M6) - return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn(); -#elif defined(EXT_CHRG_DETECT) - return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE; -#elif defined(BATTERY_CHARGING_INV) - return !digitalRead(BATTERY_CHARGING_INV); -#else + // A configured INA outranks the board's own charge-status pin, as it does BATTERY_PIN in + // getBattVoltage(): an external charger leaves that pin idle, reading "not charging" forever. #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !defined(DISABLE_INA_CHARGING_DETECTION) if (hasINA()) { // get current flow from INA sensor - negative value means power flowing @@ -614,6 +609,16 @@ class AnalogBatteryLevel : public HasBatteryLevel return getINACurrent() < 0; #endif } +#endif +#if defined(ELECROW_ThinkNode_M6) + return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn(); +#elif defined(EXT_CHRG_DETECT) + return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE; +#elif defined(BATTERY_CHARGING_INV) + return !digitalRead(BATTERY_CHARGING_INV); +#else +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !defined(DISABLE_INA_CHARGING_DETECTION) + // No charge-status pin and no INA: infer from battery presence plus external power. return isBatteryConnect() && isVbusIn(); #endif #endif @@ -680,6 +685,9 @@ class AnalogBatteryLevel : public HasBatteryLevel } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first == config.power.device_battery_ina_address) { return ina226Sensor.getCurrentMa(); + } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first == + config.power.device_battery_ina_address) { + return ina260Sensor.getCurrentMa(); } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first == config.power.device_battery_ina_address) { return ina3221Sensor.getCurrentMa(); @@ -687,30 +695,29 @@ class AnalogBatteryLevel : public HasBatteryLevel return 0; } + // Open the sensor if it isn't open yet, then report whether it is actually running. runOnce() + // answers with a poll interval, so only isRunning() tells us the device replied. + static bool sensorReady(TelemetrySensor &sensor) + { + if (!sensor.isInitialized()) + sensor.runOnce(); + return sensor.isRunning(); + } + bool hasINA() { - if (!config.power.device_battery_ina_address) { + const uint8_t inaAddress = config.power.device_battery_ina_address; + if (!inaAddress) { return false; } - if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA219].first == config.power.device_battery_ina_address) { - if (!ina219Sensor.isInitialized()) - return ina219Sensor.runOnce() > 0; - return ina219Sensor.isRunning(); - } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first == - config.power.device_battery_ina_address) { - if (!ina226Sensor.isInitialized()) - return ina226Sensor.runOnce() > 0; - return ina226Sensor.isRunning(); - } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first == - config.power.device_battery_ina_address) { - if (!ina260Sensor.isInitialized()) - return ina260Sensor.runOnce() > 0; - return ina260Sensor.isRunning(); - } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first == - config.power.device_battery_ina_address) { - if (!ina3221Sensor.isInitialized()) - return ina3221Sensor.runOnce() > 0; - return ina3221Sensor.isRunning(); + if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA219].first == inaAddress) { + return sensorReady(ina219Sensor); + } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first == inaAddress) { + return sensorReady(ina226Sensor); + } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first == inaAddress) { + return sensorReady(ina260Sensor); + } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first == inaAddress) { + return sensorReady(ina3221Sensor); } return false; } diff --git a/src/modules/Telemetry/Sensor/INA260Sensor.cpp b/src/modules/Telemetry/Sensor/INA260Sensor.cpp index 9d9a99c00b..5062a8028e 100644 --- a/src/modules/Telemetry/Sensor/INA260Sensor.cpp +++ b/src/modules/Telemetry/Sensor/INA260Sensor.cpp @@ -40,4 +40,9 @@ uint16_t INA260Sensor::getBusVoltageMv() return lround(ina260.readBusVoltage()); } +int16_t INA260Sensor::getCurrentMa() +{ + return lround(ina260.readCurrent()); +} + #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/INA260Sensor.h b/src/modules/Telemetry/Sensor/INA260Sensor.h index ea71c24e0c..fcff125148 100644 --- a/src/modules/Telemetry/Sensor/INA260Sensor.h +++ b/src/modules/Telemetry/Sensor/INA260Sensor.h @@ -3,11 +3,12 @@ #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "CurrentSensor.h" #include "TelemetrySensor.h" #include "VoltageSensor.h" #include -class INA260Sensor : public TelemetrySensor, VoltageSensor +class INA260Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor { private: Adafruit_INA260 ina260 = Adafruit_INA260(); @@ -20,6 +21,7 @@ class INA260Sensor : public TelemetrySensor, VoltageSensor virtual int32_t runOnce() override; virtual bool getMetrics(meshtastic_Telemetry *measurement) override; virtual uint16_t getBusVoltageMv() override; + virtual int16_t getCurrentMa() override; }; #endif \ No newline at end of file From eb6df1c649294747c94d6eb5e623b12160d136ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 27 Aug 2026 09:31:47 +0000 Subject: [PATCH 016/143] ci: resolve the PR diff base without a shallow refetch (#11623) The setup job checks out with fetch-depth: 0, then refetched the base branch with --depth=1 before calling merge-base. A depth-limited fetch into a complete clone writes .git/shallow and grafts the fetched tip as parentless, so merge-base finds no common commit once the base branch has moved past the pull request merge commit. The step then failed under set -e with a bare exit 1 and no message. Use the origin/ ref the checkout already provides. --- .github/workflows/main_matrix.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 59cc44c8c8..16badf8562 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -73,8 +73,11 @@ jobs: # first env of each ADDED variant config. A new env in an existing one does not count. DIFF_BASE="" if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then - git fetch --no-tags --depth=1 origin "$BASE_REF" - DIFF_BASE=$(git merge-base FETCH_HEAD HEAD) + # checkout above already fetched every branch (fetch-depth: 0), so the base ref + # is local. Do not re-fetch it with --depth=1: that grafts the fetched tip as + # parentless, and merge-base then finds no common commit whenever the base + # branch has moved past this merge commit, which is every re-run of an older run. + DIFF_BASE=$(git merge-base "origin/$BASE_REF" HEAD) elif [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then DIFF_BASE="$MERGE_GROUP_BASE_SHA" fi From a8934a16d4e85792182e6c2ef609071508094222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 27 Aug 2026 09:46:34 +0000 Subject: [PATCH 017/143] Route waypoint expiry through waypointIsActive instead of a raw getTime compare (#11621) * Route waypoint expiry through waypointIsActive instead of a raw getTime compare * Let isExpired own the zero-clock policy for purgeExpired too * Resolve the clock in isExpired when a packet carries no valid rx_time --- src/WaypointStore.cpp | 19 +++++------ test/test_waypoint_expiry/test_main.cpp | 45 +++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/WaypointStore.cpp b/src/WaypointStore.cpp index 6fb180617d..b5a0208740 100644 --- a/src/WaypointStore.cpp +++ b/src/WaypointStore.cpp @@ -10,6 +10,7 @@ #include "WaypointStore.h" #include "concurrency/LockGuard.h" #include "gps/RTC.h" +#include "meshUtils.h" #include #include #include @@ -78,13 +79,11 @@ void WaypointStore::notifyChanged() bool WaypointStore::isExpired(const meshtastic_Waypoint &wp, uint32_t now) { - if (wp.expire == 0) - return false; - + // getTime() counts from boot until the RTC is set, which reads every real expiry as future. if (now == 0) - now = getTime(); + now = getValidTime(RTCQuality::RTCQualityDevice); - return now != 0 && wp.expire <= now; + return !waypointIsActive(wp.expire, now); } bool WaypointStore::isExpired(const StoredWaypoint &entry, uint32_t now) @@ -203,7 +202,9 @@ bool WaypointStore::addFromPacket(const meshtastic_MeshPacket &packet, bool loca if (stored) *stored = entry; - if (isExpired(entry, entry.receivedTime)) { + // rx_time holds uptime, not an epoch, when has_rx_time is false; pass 0 so isExpired() resolves + // the clock itself rather than comparing an expiry against seconds since boot. + if (isExpired(entry, packet.has_rx_time ? packet.rx_time : 0)) { // Respect the lock: only the node a waypoint is locked to may delete it on our device. // An unauthorized deletion attempt is ignored entirely, rather than applied locally. for (const auto &storedEntry : waypoints) { @@ -230,11 +231,7 @@ bool WaypointStore::addFromPacket(const meshtastic_MeshPacket &packet, bool loca bool WaypointStore::purgeExpired(uint32_t now) { - if (now == 0) - now = getTime(); - if (now == 0) - return false; - + // No local clock normalization: isExpired() owns that policy, including the delete convention. bool changed = false; for (auto it = waypoints.begin(); it != waypoints.end();) { if (!isExpired(*it, now)) { diff --git a/test/test_waypoint_expiry/test_main.cpp b/test/test_waypoint_expiry/test_main.cpp index 887f9c7d19..b765207199 100644 --- a/test/test_waypoint_expiry/test_main.cpp +++ b/test/test_waypoint_expiry/test_main.cpp @@ -1,7 +1,9 @@ -// Unit tests for waypointIsActive() in src/meshUtils.h: the expire == 0 and expire == 1 sentinels, -// ordinary expiry, and an untrusted clock. +// Unit tests for waypointIsActive() and its caller WaypointStore::isExpired(): the expire == 0 and +// expire == 1 sentinels, ordinary expiry, and an untrusted clock. #include "TestUtil.h" +#include "WaypointStore.h" #include "meshUtils.h" +#include #include namespace @@ -55,6 +57,43 @@ void test_untrusted_clock_still_honours_delete() TEST_ASSERT_FALSE(waypointIsActive(1, 0)); } +// The production caller: an explicit now keeps these off the wall clock. +void test_store_expiry_matches_the_predicate() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + + wp.expire = 0; + TEST_ASSERT_FALSE(WaypointStore::isExpired(wp, NOW)); + wp.expire = 1; + TEST_ASSERT_TRUE(WaypointStore::isExpired(wp, NOW)); + wp.expire = NOW + 3600; + TEST_ASSERT_FALSE(WaypointStore::isExpired(wp, NOW)); + wp.expire = NOW - 1; + TEST_ASSERT_TRUE(WaypointStore::isExpired(wp, NOW)); +} + +// rx_time carries uptime rather than an epoch when has_rx_time is false (Router::computeRxTimeStamp), +// so an already-expired waypoint must still be rejected rather than compared against seconds of uptime. +void test_packet_without_rx_time_still_expires() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.id = 4242; + wp.expire = 1600000000; // September 2020 + + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = 0x11223344; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.payload.size = (uint16_t)pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Waypoint_msg, &wp); + packet.has_rx_time = false; + packet.rx_time = 300; // uptime seconds, not an epoch + + waypointStore.clearAllWaypoints(); + TEST_ASSERT_TRUE(waypointStore.addFromPacket(packet, false)); + TEST_ASSERT_NULL(waypointStore.findWaypoint(wp.id)); + waypointStore.clearAllWaypoints(); +} + void setup() { initializeTestEnvironment(); @@ -67,6 +106,8 @@ void setup() RUN_TEST(test_int32_max_is_active); RUN_TEST(test_untrusted_clock_expires_nothing); RUN_TEST(test_untrusted_clock_still_honours_delete); + RUN_TEST(test_store_expiry_matches_the_predicate); + RUN_TEST(test_packet_without_rx_time_still_expires); exit(UNITY_END()); } From 9a59e9088d7d31f102295bf976230ba63e806249 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 27 Aug 2026 06:32:06 -0500 Subject: [PATCH 018/143] fix(test): restore the sendAckNak overrides broken by #10767 (#11626) #10767 added a relaySource parameter to the RoutingModule::sendAckNak virtual, but the five test mocks that derive from RoutingModule still declared the six-parameter signature with `override`. Nothing overrides the new virtual, so all five suites fail to compile and the native test job has been red on develop since the merge: test/test_reliable_ack_matrix/test_main.cpp:167:10: error: 'void MockRoutingModule::sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t, bool)' marked 'override', but does not override Widen the five mocks to the new signature. Also carry has_rx_rssi with rx_rssi in allocAckNak(). rx_rssi has explicit presence, so copying only the value left has_rx_rssi false and nanopb dropped the field at encode time - the phone never saw the relayer's RSSI that #10767 set out to deliver. Cover both: test_reliable_ack_matrix asserts the overheard rebroadcast is handed through as the relay source on the decodable path and the opaque #11502 ingress path, and that no other ACK/NAK claims a relayer; test_mesh_module drives a real RoutingModule and asserts the relay fields, has_rx_rssi included, survive all the way to the phone. --- src/mesh/MeshModule.cpp | 2 + test/test_mesh_module/test_main.cpp | 50 ++++++++++++++++++- test/test_mqtt/MQTT.cpp | 4 +- test/test_nexthop_routing/test_main.cpp | 3 +- test/test_packet_signing/test_main.cpp | 6 ++- test/test_reliable_ack_matrix/test_main.cpp | 55 ++++++++++++++++++++- 6 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index d615f45fcf..059f90ee81 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -80,6 +80,8 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod // ack is delivered locally (to == us), so Router::send() is bypassed and won't overwrite these. if (relaySource) { p->relay_node = relaySource->relay_node; + // rx_rssi has explicit presence: has_rx_rssi has to travel with it or the reading never encodes + p->has_rx_rssi = relaySource->has_rx_rssi; p->rx_rssi = relaySource->rx_rssi; p->rx_snr = relaySource->rx_snr; } diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index 9cc0d18116..a0a23245de 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -81,10 +81,11 @@ class MockRoutingModule : public RoutingModule { public: void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false) override + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override { (void)hopLimit; (void)ackWantsAck; + (void)relaySource; ackNaks.push_back({err, to, idFrom, chIndex}); } @@ -703,6 +704,51 @@ static void test_localAckNak_reachesPhoneViaRealRoutingModule() mockService->releaseToPool(toPhone); } +// #10767: the implicit ACK for an overheard rebroadcast of our own packet carries that copy's +// relaying node and the link metrics we heard it at, so the phone can attribute them to the relayer. +// rx_rssi has explicit presence, so has_rx_rssi has to travel with it or the reading never encodes. +static void test_localAck_carriesRelaySourceToPhone() +{ + installRealRoutingModule(); + + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = LOCAL_NODE; + overheard.to = REMOTE_NODE; + overheard.id = 0xFEEDBEEF; + overheard.relay_node = 0xAB; + overheard.has_rx_rssi = true; + overheard.rx_rssi = -93; + overheard.rx_snr = 4.75f; + + realRoutingModule->sendAckNak(meshtastic_Routing_Error_NONE, LOCAL_NODE, overheard.id, 0, /*hopLimit=*/0, + /*ackWantsAck=*/false, &overheard); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0xFEEDBEEF, toPhone->decoded.request_id); + TEST_ASSERT_EQUAL_HEX8(0xAB, toPhone->relay_node); + TEST_ASSERT_TRUE(toPhone->has_rx_rssi); + TEST_ASSERT_EQUAL_INT32(-93, toPhone->rx_rssi); + TEST_ASSERT_EQUAL_FLOAT(4.75f, toPhone->rx_snr); + mockService->releaseToPool(toPhone); +} + +// Every other ACK/NAK passes no relay source and must reach the phone with the relay fields clear, +// so the client is never told a relayer we did not hear. +static void test_localAck_withoutRelaySource_leavesRelayFieldsUnset() +{ + installRealRoutingModule(); + + realRoutingModule->sendAckNak(meshtastic_Routing_Error_NONE, LOCAL_NODE, 0x0BADF00D, 0); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0x0BADF00D, toPhone->decoded.request_id); + TEST_ASSERT_EQUAL_HEX8(NO_RELAY_NODE, toPhone->relay_node); + TEST_ASSERT_FALSE(toPhone->has_rx_rssi); + mockService->releaseToPool(toPhone); +} + // The mirror of the above: a broadcast we originated, heard back off the mesh, must not reach the // phone even though it travels the same RoutingModule path. static void test_ownBroadcastEcho_isDroppedByRealRoutingModule() @@ -855,6 +901,8 @@ void setup() RUN_TEST(test_handleFromRadio_ownPacketIsNotEchoedToPhone); RUN_TEST(test_handleFromRadio_ownPacketAddressedToUsReachesPhone); RUN_TEST(test_localAckNak_reachesPhoneViaRealRoutingModule); + RUN_TEST(test_localAck_carriesRelaySourceToPhone); + RUN_TEST(test_localAck_withoutRelaySource_leavesRelayFieldsUnset); RUN_TEST(test_ownBroadcastEcho_isDroppedByRealRoutingModule); RUN_TEST(test_phoneRequest_replyReachesPhone); RUN_TEST(test_nestedLocalSend_isDeferred_notReentrant); diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 64ea0cd4dd..4d2ea6c268 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -103,8 +103,10 @@ class MockRoutingModule : public RoutingModule { public: void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false) override + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override { + (void)ackWantsAck; + (void)relaySource; ackNacks_.emplace_back(err, to, idFrom, chIndex, hopLimit); } std::list> diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index 891504c71f..c5915bfc99 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -281,8 +281,9 @@ class MockRoutingModule : public RoutingModule { public: void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false) override + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override { + (void)relaySource; ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); } diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 2bfd2b6e66..47dabdbda8 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -191,7 +191,11 @@ class AuthPipelineRouter : public ReliableRouter class AuthPipelineRoutingModule : public RoutingModule { public: - void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false) override { ackCalls++; } + void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false, + const meshtastic_MeshPacket * = nullptr) override + { + ackCalls++; + } uint32_t ackCalls = 0; }; diff --git a/test/test_reliable_ack_matrix/test_main.cpp b/test/test_reliable_ack_matrix/test_main.cpp index 635b979039..1dcd5ab123 100644 --- a/test/test_reliable_ack_matrix/test_main.cpp +++ b/test/test_reliable_ack_matrix/test_main.cpp @@ -164,13 +164,29 @@ class TimedCaptureRadio : public RadioInterface class MockRoutingModule : public RoutingModule { public: + // The relaying copy the caller handed us, flattened to the fields allocAckNak() forwards onto + // the ack. One entry per sendAckNak() call, so it stays aligned with ackNaks. + struct RelaySource { + bool present; + uint8_t relayNode; + bool hasRxRssi; + int32_t rxRssi; + float rxSnr; + }; + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, - bool ackWantsAck = false) override + bool ackWantsAck = false, const meshtastic_MeshPacket *relaySource = nullptr) override { ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + if (relaySource) + relaySources.push_back( + {true, relaySource->relay_node, relaySource->has_rx_rssi, relaySource->rx_rssi, relaySource->rx_snr}); + else + relaySources.push_back({false, NO_RELAY_NODE, false, 0, 0.0f}); } std::list> ackNaks; + std::vector relaySources; }; class ScopedAirTimeFixture @@ -245,6 +261,26 @@ static void expectSingleAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI TEST_ASSERT_EQUAL(ackWantsAck, std::get<5>(ack)); } +// #10767: only the implicit ack for an overheard rebroadcast of our own packet carries a relay +// source; every other ACK/NAK must leave it unset so the phone is never told a relayer we did not +// hear. +static void expectRelaySource(uint8_t relayNode, int32_t rxRssi, float rxSnr) +{ + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->relaySources.size()); + const auto &relay = mockRoutingModule->relaySources.front(); + TEST_ASSERT_TRUE(relay.present); + TEST_ASSERT_EQUAL_HEX8(relayNode, relay.relayNode); + TEST_ASSERT_TRUE(relay.hasRxRssi); + TEST_ASSERT_EQUAL_INT32(rxRssi, relay.rxRssi); + TEST_ASSERT_EQUAL_FLOAT(rxSnr, relay.rxSnr); +} + +static void expectNoRelaySource() +{ + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->relaySources.size()); + TEST_ASSERT_FALSE(mockRoutingModule->relaySources.front().present); +} + static void configureChannels() { memset(&channelFile, 0, sizeof(channelFile)); @@ -286,6 +322,7 @@ void setUp(void) reliableShim->resetRouteHealthForTest(); radio->reset(); mockRoutingModule->ackNaks.clear(); + mockRoutingModule->relaySources.clear(); configureChannels(); } @@ -298,12 +335,16 @@ void tearDown(void) {} void test_text_dm_want_ack_gets_want_ack_ack(void) { auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.relay_node = 0x77; // this ACK travels the mesh for someone else's DM; it must claim no relayer + p.has_rx_rssi = true; + p.rx_rssi = -55; uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); TEST_ASSERT_NOT_EQUAL(0, expectedHop); // must be distinguishable from the 0-hop ACK branch reliableShim->sniffForTest(&p, nullptr); expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true); + expectNoRelaySource(); } void test_text_reply_still_gets_want_ack_ack(void) @@ -610,11 +651,17 @@ void test_overheard_own_dm_rebroadcast_mints_implicit_ack(void) overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; overheard.encrypted.size = 32; + overheard.relay_node = 0x99; + overheard.has_rx_rssi = true; + overheard.rx_rssi = -87; + overheard.rx_snr = 6.25f; reliableShim->filterForTest(&overheard); // ACK is addressed to us (so it reaches the phone) on the pending copy's channel. expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + // The overheard copy is the relay source, so the phone learns who relayed and at what quality. + expectRelaySource(0x99, -87, 6.25f); TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); } @@ -685,6 +732,10 @@ static meshtastic_MeshPacket makeOpaqueOwnOverheard(PacketId id, meshtastic_Mesh p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; p.encrypted.size = 32; memset(p.encrypted.bytes, 0xC3, p.encrypted.size); + p.relay_node = 0x4D; + p.has_rx_rssi = true; + p.rx_rssi = -112; + p.rx_snr = -3.5f; return p; } @@ -704,6 +755,8 @@ void test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries(void) ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA)); expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + // Relay attribution survives the opaque short-circuit too: the header fields are all it needs. + expectRelaySource(0x4D, -112, -3.5f); TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); } From a4e22a46b57bbf4e8509d2463f186233374aa4a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:49:22 +0000 Subject: [PATCH 019/143] chore(deps): update meshtastic/device-ui digest to 6813f38 (#11614) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 2300847467..ec82fce34d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -137,7 +137,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/27443d05de4678cdf6a9f4ef35aa30c0aa5f4e29.zip + https://github.com/meshtastic/device-ui/archive/6813f3803e88892b66594fd2332c30e0538e5f21.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 3666ec9c6cf9a24c1035054af38957543c3d2126 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:49:53 +0000 Subject: [PATCH 020/143] chore(deps): update nanopb to v0.4.92 (#11595) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index ec82fce34d..67ab247032 100644 --- a/platformio.ini +++ b/platformio.ini @@ -89,7 +89,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic-ArduinoThread packageName=https://github.com/meshtastic/ArduinoThread gitBranch=master https://github.com/meshtastic/ArduinoThread/archive/b841b0415721f1341ea41cccfb4adccfaf951567.zip # renovate: datasource=github-tags depName=Nanopb packageName=nanopb/nanopb - https://github.com/nanopb/nanopb/archive/refs/tags/nanopb-0.4.9.1.zip + https://github.com/nanopb/nanopb/archive/0.4.92.zip # renovate: datasource=github-tags depName=ErriezCRC32 packageName=Erriez/ErriezCRC32 https://github.com/Erriez/ErriezCRC32/archive/refs/tags/1.0.1.zip From dbba2b3f6c616782cb3cae5e523fd408bb9bfdd8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 27 Aug 2026 11:23:02 -0500 Subject: [PATCH 021/143] feat(baseui): default US to LongTurbo on first region selection Selecting US in the BaseUI region chooser now installs LongTurbo instead of LongFast, but only for out-of-box setup: the outgoing region must be UNSET, so a later switch to US leaves whatever preset the node is running alone. Scoped to the menu on purpose. The US entry in regions[] keeps LongFast as its default preset, so preset repair, admin/phone writes and every other route onto US are unchanged. A build pinning USERPREFS_LORACONFIG_MODEM_PRESET, a preset already moved off the install default, or use_preset=false all outrank it. The decision is lifted into menuHandler::presetForRegionSelection() so it is reachable without a Screen, following toggleNodeMuted(). --- src/graphics/draw/MenuHandler.cpp | 25 ++++++++ src/graphics/draw/MenuHandler.h | 5 ++ test/test_admin_radio/test_main.cpp | 96 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 6aec0ff654..284ac0e0c1 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -227,8 +227,33 @@ void menuHandler::OnboardMessage() screen->showOverlayBanner(bannerOptions); } +// Out-of-box US setup starts on LongTurbo rather than the region table's LongFast. Menu-only: the +// US entry in `regions[]` keeps LongFast, so no other route onto US changes. Anything that already +// states a preset - a pinned userpref, or a preset moved off the install default - outranks it. +meshtastic_Config_LoRaConfig_ModemPreset menuHandler::presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, + meshtastic_Config_LoRaConfig_RegionCode selected) +{ +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + (void)selected; // the pinned preset wins outright; nothing to decide +#else + if (lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && selected == meshtastic_Config_LoRaConfig_RegionCode_US && + lora.use_preset && lora.modem_preset == meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST) { + return meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + } +#endif + return lora.modem_preset; +} + static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam) { + // Decided first: it keys off the *outgoing* region being UNSET. + const meshtastic_Config_LoRaConfig_ModemPreset selectionPreset = menuHandler::presetForRegionSelection(config.lora, region); + if (selectionPreset != config.lora.modem_preset) { + LOG_INFO("First region is %s, default preset to %s", getRegion(region)->name, + DisplayFormatters::getModemPresetDisplayName(selectionPreset, false, true)); + config.lora.modem_preset = selectionPreset; + } + config.lora.region = region; config.lora.channel_num = 0; // Reset to default channel diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 7ffbfae1c9..a2cc14e677 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -141,6 +141,11 @@ class menuHandler // ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable. static void toggleNodeMuted(uint32_t nodeNum); // uint32_t, matching pickedNodeNum above + // Preset a region selection should leave installed. `lora` is the config as it stands *before* + // the selection is written. + static meshtastic_Config_LoRaConfig_ModemPreset presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, + meshtastic_Config_LoRaConfig_RegionCode selected); + private: static void saveUIConfig(); static void keyVerificationInitMenu(); diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 8faebd16a6..8a332b7a92 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -2178,6 +2178,91 @@ static void test_toggleNodeMuted_currentlyRewritesEverySegment() for (const char *f : segmentFiles) TEST_ASSERT_TRUE_MESSAGE(FSCom.exists(f), f); } + +// ----------------------------------------------------------------------- +// BaseUI region chooser preset default (graphics::menuHandler::presetForRegionSelection) +// ----------------------------------------------------------------------- +// +// Out-of-box US setup starts on LongTurbo. Each guard below is load-bearing: widening the rule past +// "first region ever chosen, US, no preset on record" re-presets nodes that already have an opinion. + +// `region` is the region still in place when the user highlights `selected`. +static meshtastic_Config_LoRaConfig loraAt(meshtastic_Config_LoRaConfig_RegionCode region, + meshtastic_Config_LoRaConfig_ModemPreset preset, bool usePreset = true) +{ + meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_default; + lora.region = region; + lora.modem_preset = preset; + lora.use_preset = usePreset; + return lora; +} + +#ifndef USERPREFS_LORACONFIG_MODEM_PRESET +static void test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); + + // Unusable unless US offers it: applyLoraRegion()'s reconciliation would throw it straight back. + TEST_ASSERT_TRUE_MESSAGE(getRegion(meshtastic_Config_LoRaConfig_RegionCode_US) + ->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO), + "US no longer supports LongTurbo"); +} +#else +// A pinned preset owns the decision outright. +static void test_presetForRegionSelection_pinnedUserprefWins() +{ + const meshtastic_Config_LoRaConfig_ModemPreset pinned = USERPREFS_LORACONFIG_MODEM_PRESET; + const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, pinned); + + TEST_ASSERT_EQUAL(pinned, graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} +#endif + +// US on a node that already has a region is a region change, not first-time setup. +static void test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} + +// The default is US-only; no other region's first selection is touched. +static void test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + for (auto region : {meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_RegionCode_ANZ, + meshtastic_Config_LoRaConfig_RegionCode_JP}) + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, region)); +} + +// A preset off the install default is a preference on record (phone app, admin, preset menu). +static void test_presetForRegionSelection_respectsAPresetAlreadyChosen() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} + +// use_preset false means raw bandwidth/SF/CR: rewriting modem_preset only misleads the preset menu. +static void test_presetForRegionSelection_ignoresNodesOnRawModemSettings() +{ + const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, /*usePreset=*/false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} #endif // HAS_SCREEN // ----------------------------------------------------------------------- @@ -2342,6 +2427,17 @@ void setup() RUN_TEST(test_toggleNodeMuted_flipsBitAndSkipsRadioReload); RUN_TEST(test_toggleNodeMuted_unknownNodeDoesNothing); RUN_TEST(test_toggleNodeMuted_currentlyRewritesEverySegment); + + // BaseUI region chooser preset default +#ifndef USERPREFS_LORACONFIG_MODEM_PRESET + RUN_TEST(test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo); +#else + RUN_TEST(test_presetForRegionSelection_pinnedUserprefWins); +#endif + RUN_TEST(test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset); + RUN_TEST(test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset); + RUN_TEST(test_presetForRegionSelection_respectsAPresetAlreadyChosen); + RUN_TEST(test_presetForRegionSelection_ignoresNodesOnRawModemSettings); #endif exit(UNITY_END()); From 122ec0e9f4b5e9e1d5dc9434715debeb9ea84b26 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 27 Aug 2026 11:43:20 -0500 Subject: [PATCH 022/143] Revert "feat(baseui): default US to LongTurbo on first region selection" This reverts commit dbba2b3f6c616782cb3cae5e523fd408bb9bfdd8. --- src/graphics/draw/MenuHandler.cpp | 25 -------- src/graphics/draw/MenuHandler.h | 5 -- test/test_admin_radio/test_main.cpp | 96 ----------------------------- 3 files changed, 126 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 284ac0e0c1..6aec0ff654 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -227,33 +227,8 @@ void menuHandler::OnboardMessage() screen->showOverlayBanner(bannerOptions); } -// Out-of-box US setup starts on LongTurbo rather than the region table's LongFast. Menu-only: the -// US entry in `regions[]` keeps LongFast, so no other route onto US changes. Anything that already -// states a preset - a pinned userpref, or a preset moved off the install default - outranks it. -meshtastic_Config_LoRaConfig_ModemPreset menuHandler::presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, - meshtastic_Config_LoRaConfig_RegionCode selected) -{ -#ifdef USERPREFS_LORACONFIG_MODEM_PRESET - (void)selected; // the pinned preset wins outright; nothing to decide -#else - if (lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && selected == meshtastic_Config_LoRaConfig_RegionCode_US && - lora.use_preset && lora.modem_preset == meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST) { - return meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; - } -#endif - return lora.modem_preset; -} - static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam) { - // Decided first: it keys off the *outgoing* region being UNSET. - const meshtastic_Config_LoRaConfig_ModemPreset selectionPreset = menuHandler::presetForRegionSelection(config.lora, region); - if (selectionPreset != config.lora.modem_preset) { - LOG_INFO("First region is %s, default preset to %s", getRegion(region)->name, - DisplayFormatters::getModemPresetDisplayName(selectionPreset, false, true)); - config.lora.modem_preset = selectionPreset; - } - config.lora.region = region; config.lora.channel_num = 0; // Reset to default channel diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index a2cc14e677..7ffbfae1c9 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -141,11 +141,6 @@ class menuHandler // ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable. static void toggleNodeMuted(uint32_t nodeNum); // uint32_t, matching pickedNodeNum above - // Preset a region selection should leave installed. `lora` is the config as it stands *before* - // the selection is written. - static meshtastic_Config_LoRaConfig_ModemPreset presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, - meshtastic_Config_LoRaConfig_RegionCode selected); - private: static void saveUIConfig(); static void keyVerificationInitMenu(); diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 8a332b7a92..8faebd16a6 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -2178,91 +2178,6 @@ static void test_toggleNodeMuted_currentlyRewritesEverySegment() for (const char *f : segmentFiles) TEST_ASSERT_TRUE_MESSAGE(FSCom.exists(f), f); } - -// ----------------------------------------------------------------------- -// BaseUI region chooser preset default (graphics::menuHandler::presetForRegionSelection) -// ----------------------------------------------------------------------- -// -// Out-of-box US setup starts on LongTurbo. Each guard below is load-bearing: widening the rule past -// "first region ever chosen, US, no preset on record" re-presets nodes that already have an opinion. - -// `region` is the region still in place when the user highlights `selected`. -static meshtastic_Config_LoRaConfig loraAt(meshtastic_Config_LoRaConfig_RegionCode region, - meshtastic_Config_LoRaConfig_ModemPreset preset, bool usePreset = true) -{ - meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_default; - lora.region = region; - lora.modem_preset = preset; - lora.use_preset = usePreset; - return lora; -} - -#ifndef USERPREFS_LORACONFIG_MODEM_PRESET -static void test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo() -{ - const meshtastic_Config_LoRaConfig lora = - loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); - - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, - graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); - - // Unusable unless US offers it: applyLoraRegion()'s reconciliation would throw it straight back. - TEST_ASSERT_TRUE_MESSAGE(getRegion(meshtastic_Config_LoRaConfig_RegionCode_US) - ->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO), - "US no longer supports LongTurbo"); -} -#else -// A pinned preset owns the decision outright. -static void test_presetForRegionSelection_pinnedUserprefWins() -{ - const meshtastic_Config_LoRaConfig_ModemPreset pinned = USERPREFS_LORACONFIG_MODEM_PRESET; - const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, pinned); - - TEST_ASSERT_EQUAL(pinned, graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); -} -#endif - -// US on a node that already has a region is a region change, not first-time setup. -static void test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset() -{ - const meshtastic_Config_LoRaConfig lora = - loraAt(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); - - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, - graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); -} - -// The default is US-only; no other region's first selection is touched. -static void test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset() -{ - const meshtastic_Config_LoRaConfig lora = - loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); - - for (auto region : {meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_RegionCode_ANZ, - meshtastic_Config_LoRaConfig_RegionCode_JP}) - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, - graphics::menuHandler::presetForRegionSelection(lora, region)); -} - -// A preset off the install default is a preference on record (phone app, admin, preset menu). -static void test_presetForRegionSelection_respectsAPresetAlreadyChosen() -{ - const meshtastic_Config_LoRaConfig lora = - loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); - - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, - graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); -} - -// use_preset false means raw bandwidth/SF/CR: rewriting modem_preset only misleads the preset menu. -static void test_presetForRegionSelection_ignoresNodesOnRawModemSettings() -{ - const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, - meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, /*usePreset=*/false); - - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, - graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); -} #endif // HAS_SCREEN // ----------------------------------------------------------------------- @@ -2427,17 +2342,6 @@ void setup() RUN_TEST(test_toggleNodeMuted_flipsBitAndSkipsRadioReload); RUN_TEST(test_toggleNodeMuted_unknownNodeDoesNothing); RUN_TEST(test_toggleNodeMuted_currentlyRewritesEverySegment); - - // BaseUI region chooser preset default -#ifndef USERPREFS_LORACONFIG_MODEM_PRESET - RUN_TEST(test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo); -#else - RUN_TEST(test_presetForRegionSelection_pinnedUserprefWins); -#endif - RUN_TEST(test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset); - RUN_TEST(test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset); - RUN_TEST(test_presetForRegionSelection_respectsAPresetAlreadyChosen); - RUN_TEST(test_presetForRegionSelection_ignoresNodesOnRawModemSettings); #endif exit(UNITY_END()); From 9fbc176e919c36df66fe880b1248792164a39aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 27 Aug 2026 15:00:59 +0000 Subject: [PATCH 023/143] Extend userPrefs coverage to the whole channel table and the missing config fields (#11624) * Extend userPrefs coverage to the whole channel table and the missing config fields initDefaultChannel() handled only indices 0-2, so USERPREFS_CHANNELS_TO_WRITE above 3 produced live secondary channels carrying the public default PSK; it now covers all eight slots, with bin/platformio-custom.py completing every field of a configured index so indices 0-2 stay byte-identical. Adds USERPREFS_CHANNEL__IS_MUTED, USERPREFS_CONFIG_DEVICE_REBROADCAST_MODE, USERPREFS_CONFIG_DEVICE_NODE_INFO_BROADCAST_SECS, USERPREFS_CONFIG_LORA_CONFIG_OK_TO_MQTT, USERPREFS_CONFIG_SECURITY_IS_MANAGED and USERPREFS_CANNED_MESSAGES, applied after installRoleDefaults() and validated the way AdminModule validates a set-config. Adds test_userprefs_channels, covering the configured table under coverage-channel-table and the stock defaults under every other env. * Address review: hex channel count, PSK width assert, canned-message termination USERPREFS_CHANNELS_TO_WRITE now parses 0x-prefixed hex, matching the format userPrefs.jsonc documents, without int(x, 0)'s rejection of a leading-zero decimal such as "03". A static_assert rejects a USERPREFS_CHANNEL__PSK literal wider than psk.bytes, which memcpy would otherwise write over the fields after it. The USERPREFS_CANNED_MESSAGES copy keeps strncpy's zero-padding and terminates explicitly, rather than shortening the length, which would have left the last byte unwritten. --- .github/workflows/test_native.yml | 11 ++ bin/platformio-custom.py | 26 +++ src/mesh/Channels.cpp | 105 +++++----- src/mesh/NodeDB.cpp | 30 +++ src/modules/CannedMessageModule.cpp | 5 + test/support/userprefs_event_channel.h | 14 ++ test/test_userprefs_channels/test_main.cpp | 179 ++++++++++++++++++ .../userprefs_fixture.h | 50 +++++ userPrefs.jsonc | 22 +++ variants/native/portduino/platformio.ini | 27 ++- 10 files changed, 417 insertions(+), 52 deletions(-) create mode 100644 test/support/userprefs_event_channel.h create mode 100644 test/test_userprefs_channels/test_main.cpp create mode 100644 test/test_userprefs_channels/userprefs_fixture.h diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 2167e29564..8ee8fa0f0b 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -461,6 +461,17 @@ jobs: ./bin/check-test-attribution.py --label coverage-event-policy \ --expect "$expect" event-policy-testreport.xml + - name: Channel table userPrefs tests + run: platformio test -e coverage-channel-table -v --junit-output-path channel-table-testreport.xml + + - name: Verify the channel-table suite ran its own tests + run: | + set -euo pipefail + expect=$(python3 -c "from platformio.project.config import ProjectConfig; \ + print(' '.join(ProjectConfig().get('env:coverage-channel-table', 'test_filter', [])))") + ./bin/check-test-attribution.py --label coverage-channel-table \ + --expect "$expect" channel-table-testreport.xml + - name: Save test results if: always() # run this step even if previous step failed uses: actions/upload-artifact@v7 diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py index 08d010e086..77017d2fcb 100644 --- a/bin/platformio-custom.py +++ b/bin/platformio-custom.py @@ -299,6 +299,30 @@ with open(jsonLoc) as f: jsonStr = re.sub("//.*","", f.read(), flags=re.MULTILINE) userPrefs = json.loads(jsonStr) +# Channels::initDefaultChannel() applies a configured index as a whole, so resolve per-field +# optionality here: any field the vendor left out gets the value that function would have kept. +MAX_NUM_CHANNELS = 8 +CHANNEL_FIELD_DEFAULTS = { + "PSK": "{ 0x01 }", # short-form index into the well-known default PSK + "NAME": "", + "PRECISION": "0", + "IS_MUTED": "false", + "UPLINK_ENABLED": "false", + "DOWNLINK_ENABLED": "false", +} +channelsToWriteRaw = userPrefs.get("USERPREFS_CHANNELS_TO_WRITE", "1") +channelsToWrite = int(channelsToWriteRaw, 16 if channelsToWriteRaw.lower().startswith("0x") else 10) +if channelsToWrite > MAX_NUM_CHANNELS: + sys.exit( + f"userPrefs.jsonc: USERPREFS_CHANNELS_TO_WRITE is {channelsToWrite}, " + f"the channel table holds {MAX_NUM_CHANNELS}" + ) +for i in range(MAX_NUM_CHANNELS): + prefix = f"USERPREFS_CHANNEL_{i}_" + if any(k.startswith(prefix) for k in list(userPrefs)): + for field, default in CHANNEL_FIELD_DEFAULTS.items(): + userPrefs.setdefault(prefix + field, default) + pref_flags = [] # Pre-process the userPrefs for pref in userPrefs: @@ -306,6 +330,8 @@ for pref in userPrefs: pref_flags.append("-D" + pref + "=" + userPrefs[pref]) elif userPrefs[pref].lstrip("-").replace(".", "").isdigit(): pref_flags.append("-D" + pref + "=" + userPrefs[pref]) + elif re.fullmatch(r"0[xX][0-9a-fA-F]+", userPrefs[pref]): + pref_flags.append("-D" + pref + "=" + userPrefs[pref]) elif userPrefs[pref] == "true" or userPrefs[pref] == "false": pref_flags.append("-D" + pref + "=" + userPrefs[pref]) elif userPrefs[pref].startswith("meshtastic_"): diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 5860c6fc74..770213b4a7 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -166,67 +166,70 @@ void Channels::initDefaultChannel(ChannelIndex chIndex) ch.has_settings = true; ch.role = chIndex == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; + static_assert(MAX_NUM_CHANNELS == 8, "the userPrefs switch below covers indices 0-7"); + +// bin/platformio-custom.py completes every field of an index the vendor configured, so no field is +// individually optional here and a new index costs one case rather than eighteen lines. +#define USERPREFS_APPLY_CHANNEL(n) \ + do { \ + static const uint8_t userprefsPsk[] = USERPREFS_CHANNEL_##n##_PSK; \ + static_assert(sizeof(userprefsPsk) <= sizeof(channelSettings.psk.bytes), \ + "USERPREFS_CHANNEL_" #n "_PSK is wider than psk.bytes"); \ + memcpy(channelSettings.psk.bytes, userprefsPsk, sizeof(userprefsPsk)); \ + channelSettings.psk.size = sizeof(userprefsPsk); \ + strncpy(channelSettings.name, (const char *)USERPREFS_CHANNEL_##n##_NAME, sizeof(channelSettings.name) - 1); \ + channelSettings.module_settings.position_precision = USERPREFS_CHANNEL_##n##_PRECISION; \ + channelSettings.module_settings.is_muted = USERPREFS_CHANNEL_##n##_IS_MUTED; \ + channelSettings.uplink_enabled = USERPREFS_CHANNEL_##n##_UPLINK_ENABLED; \ + channelSettings.downlink_enabled = USERPREFS_CHANNEL_##n##_DOWNLINK_ENABLED; \ + } while (0) + switch (chIndex) { - case 0: #ifdef USERPREFS_CHANNEL_0_PSK - static const uint8_t defaultpsk0[] = USERPREFS_CHANNEL_0_PSK; - memcpy(channelSettings.psk.bytes, defaultpsk0, sizeof(defaultpsk0)); - channelSettings.psk.size = sizeof(defaultpsk0); -#endif -#ifdef USERPREFS_CHANNEL_0_NAME - strcpy(channelSettings.name, (const char *)USERPREFS_CHANNEL_0_NAME); -#endif -#ifdef USERPREFS_CHANNEL_0_PRECISION - channelSettings.module_settings.position_precision = USERPREFS_CHANNEL_0_PRECISION; -#endif -#ifdef USERPREFS_CHANNEL_0_UPLINK_ENABLED - channelSettings.uplink_enabled = USERPREFS_CHANNEL_0_UPLINK_ENABLED; -#endif -#ifdef USERPREFS_CHANNEL_0_DOWNLINK_ENABLED - channelSettings.downlink_enabled = USERPREFS_CHANNEL_0_DOWNLINK_ENABLED; -#endif + case 0: + USERPREFS_APPLY_CHANNEL(0); break; - case 1: +#endif #ifdef USERPREFS_CHANNEL_1_PSK - static const uint8_t defaultpsk1[] = USERPREFS_CHANNEL_1_PSK; - memcpy(channelSettings.psk.bytes, defaultpsk1, sizeof(defaultpsk1)); - channelSettings.psk.size = sizeof(defaultpsk1); -#endif -#ifdef USERPREFS_CHANNEL_1_NAME - strcpy(channelSettings.name, (const char *)USERPREFS_CHANNEL_1_NAME); -#endif -#ifdef USERPREFS_CHANNEL_1_PRECISION - channelSettings.module_settings.position_precision = USERPREFS_CHANNEL_1_PRECISION; -#endif -#ifdef USERPREFS_CHANNEL_1_UPLINK_ENABLED - channelSettings.uplink_enabled = USERPREFS_CHANNEL_1_UPLINK_ENABLED; -#endif -#ifdef USERPREFS_CHANNEL_1_DOWNLINK_ENABLED - channelSettings.downlink_enabled = USERPREFS_CHANNEL_1_DOWNLINK_ENABLED; -#endif + case 1: + USERPREFS_APPLY_CHANNEL(1); break; - case 2: +#endif #ifdef USERPREFS_CHANNEL_2_PSK - static const uint8_t defaultpsk2[] = USERPREFS_CHANNEL_2_PSK; - memcpy(channelSettings.psk.bytes, defaultpsk2, sizeof(defaultpsk2)); - channelSettings.psk.size = sizeof(defaultpsk2); -#endif -#ifdef USERPREFS_CHANNEL_2_NAME - strcpy(channelSettings.name, (const char *)USERPREFS_CHANNEL_2_NAME); -#endif -#ifdef USERPREFS_CHANNEL_2_PRECISION - channelSettings.module_settings.position_precision = USERPREFS_CHANNEL_2_PRECISION; -#endif -#ifdef USERPREFS_CHANNEL_2_UPLINK_ENABLED - channelSettings.uplink_enabled = USERPREFS_CHANNEL_2_UPLINK_ENABLED; -#endif -#ifdef USERPREFS_CHANNEL_2_DOWNLINK_ENABLED - channelSettings.downlink_enabled = USERPREFS_CHANNEL_2_DOWNLINK_ENABLED; -#endif + case 2: + USERPREFS_APPLY_CHANNEL(2); break; +#endif +#ifdef USERPREFS_CHANNEL_3_PSK + case 3: + USERPREFS_APPLY_CHANNEL(3); + break; +#endif +#ifdef USERPREFS_CHANNEL_4_PSK + case 4: + USERPREFS_APPLY_CHANNEL(4); + break; +#endif +#ifdef USERPREFS_CHANNEL_5_PSK + case 5: + USERPREFS_APPLY_CHANNEL(5); + break; +#endif +#ifdef USERPREFS_CHANNEL_6_PSK + case 6: + USERPREFS_APPLY_CHANNEL(6); + break; +#endif +#ifdef USERPREFS_CHANNEL_7_PSK + case 7: + USERPREFS_APPLY_CHANNEL(7); + break; +#endif default: break; } + +#undef USERPREFS_APPLY_CHANNEL } CryptoKey Channels::getKey(ChannelIndex chIndex) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 9239a1d1ca..4d3a73231d 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1010,6 +1010,9 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #else config.lora.ignore_mqtt = false; #endif +#ifdef USERPREFS_CONFIG_LORA_CONFIG_OK_TO_MQTT + config.lora.config_ok_to_mqtt = USERPREFS_CONFIG_LORA_CONFIG_OK_TO_MQTT; +#endif // Initialize admin_key_count to zero byte numAdminKeys = 0; @@ -1043,6 +1046,16 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.security.admin_key_count = numAdminKeys; +#ifdef USERPREFS_CONFIG_SECURITY_IS_MANAGED + // is_managed is the supported way for a vendor to lock configuration, but without an admin key + // it locks the vendor out too and only a factory reset recovers it. + if (USERPREFS_CONFIG_SECURITY_IS_MANAGED && numAdminKeys == 0) { + LOG_WARN("USERPREFS is_managed needs an admin key, ignored"); + } else { + config.security.is_managed = USERPREFS_CONFIG_SECURITY_IS_MANAGED; + } +#endif + // Left at COMPATIBLE when signature checking is compiled out, so we never report a policy // nothing enforces (mirrors the set-config guard in AdminModule). #if defined(USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY) && !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) @@ -1204,6 +1217,23 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) installRoleDefaults(config.device.role); #endif +#ifdef USERPREFS_CONFIG_DEVICE_REBROADCAST_MODE + config.device.rebroadcast_mode = USERPREFS_CONFIG_DEVICE_REBROADCAST_MODE; + // Same restriction AdminModule enforces on a set-config; apply it here so a vendor build can't + // ship a combination the device would silently refuse later. + if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_NONE && + IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE)) { + LOG_WARN("Rebroadcast mode can't be NONE for a router role, use ALL"); + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + } +#endif +#ifdef USERPREFS_CONFIG_DEVICE_NODE_INFO_BROADCAST_SECS + // Clamped to the same window AdminModule enforces on a set-config + config.device.node_info_broadcast_secs = clamp((uint32_t)USERPREFS_CONFIG_DEVICE_NODE_INFO_BROADCAST_SECS, + (uint32_t)min_node_info_broadcast_secs, (uint32_t)MAX_INTERVAL); +#endif + initConfigIntervals(); variantDefaultConfig(); variantDefaultModuleConfig(); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 7551ac7bbe..33d0816004 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -2310,7 +2310,12 @@ bool CannedMessageModule::saveProtoForModule() */ void CannedMessageModule::installDefaultCannedMessageModuleConfig() { +#ifdef USERPREFS_CANNED_MESSAGES + strncpy(cannedMessageModuleConfig.messages, USERPREFS_CANNED_MESSAGES, sizeof(cannedMessageModuleConfig.messages)); + cannedMessageModuleConfig.messages[sizeof(cannedMessageModuleConfig.messages) - 1] = '\0'; +#else strncpy(cannedMessageModuleConfig.messages, "Hi|Bye|Yes|No|Ok", sizeof(cannedMessageModuleConfig.messages)); +#endif } /** diff --git a/test/support/userprefs_event_channel.h b/test/support/userprefs_event_channel.h new file mode 100644 index 0000000000..0ecea522b6 --- /dev/null +++ b/test/support/userprefs_event_channel.h @@ -0,0 +1,14 @@ +// Channel 0 as [env:coverage-event-policy] configures it. initDefaultChannel() applies a configured +// index as a whole, so a build setting the macros by hand supplies every field, as the generator does. + +#pragma once + +#define USERPREFS_CHANNEL_0_PSK \ + { \ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f \ + } +#define USERPREFS_CHANNEL_0_NAME "" +#define USERPREFS_CHANNEL_0_PRECISION 0 +#define USERPREFS_CHANNEL_0_IS_MUTED false +#define USERPREFS_CHANNEL_0_UPLINK_ENABLED false +#define USERPREFS_CHANNEL_0_DOWNLINK_ENABLED false diff --git a/test/test_userprefs_channels/test_main.cpp b/test/test_userprefs_channels/test_main.cpp new file mode 100644 index 0000000000..653f0563cd --- /dev/null +++ b/test/test_userprefs_channels/test_main.cpp @@ -0,0 +1,179 @@ +// Channels::initDefaults() must populate every index named by USERPREFS_CHANNELS_TO_WRITE, and an +// index configured in part must keep the firmware's own value for every field left out. +// +// Under coverage-channel-table / native-windows-channel-table userprefs_fixture.h is -include'd and +// the configured cases run; under any other env the suite asserts the stock defaults instead. + +#include "Channels.h" +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isBroadcast, etc.) +#include "NodeDB.h" // channelFile +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include +#include +#include + +#if defined(ARCH_PORTDUINO) +#define UPC_TEST_ENTRY extern "C" +#else +#define UPC_TEST_ENTRY +#endif + +// The well-known default PSK in its 1-byte short form, i.e. what initDefaultChannel() leaves on an +// index no userPref touched. +static const uint8_t kDefaultPskShortForm = 0x01; + +void setUp(void) +{ + memset(&channelFile, 0, sizeof(channelFile)); + channels.initDefaults(); +} + +void tearDown(void) {} + +static void expectShortFormDefaultPsk(const meshtastic_ChannelSettings &s) +{ + TEST_ASSERT_EQUAL_UINT(1, s.psk.size); + TEST_ASSERT_EQUAL_UINT8(kDefaultPskShortForm, s.psk.bytes[0]); +} + +// An index initDefaultChannel() wrote but no userPref configured: default PSK, empty name, position +// sharing off, nothing muted, no MQTT. +static void expectStockChannel(uint8_t idx) +{ + const meshtastic_Channel &ch = channels.getByIndex(idx); + TEST_ASSERT_TRUE(ch.has_settings); + TEST_ASSERT_TRUE(ch.settings.has_module_settings); + expectShortFormDefaultPsk(ch.settings); + TEST_ASSERT_EQUAL_STRING("", ch.settings.name); + TEST_ASSERT_EQUAL_UINT(0, ch.settings.module_settings.position_precision); + TEST_ASSERT_FALSE(ch.settings.module_settings.is_muted); + TEST_ASSERT_FALSE(ch.settings.uplink_enabled); + TEST_ASSERT_FALSE(ch.settings.downlink_enabled); +} + +#ifdef USERPREFS_CHANNELS_TO_WRITE + +static const uint8_t kChannel0Psk[] = USERPREFS_CHANNEL_0_PSK; +static const uint8_t kChannel3Psk[] = USERPREFS_CHANNEL_3_PSK; + +// The bug this table replaces: USERPREFS_CHANNELS_TO_WRITE past 3 produced live secondary channels +// carrying the public default PSK instead of the vendor's, because initDefaultChannel()'s switch +// stopped at case 2. +static void test_index_beyond_two_gets_its_configured_psk() +{ + const meshtastic_ChannelSettings &s = channels.getByIndex(3).settings; + TEST_ASSERT_EQUAL_UINT(sizeof(kChannel3Psk), s.psk.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(kChannel3Psk, s.psk.bytes, sizeof(kChannel3Psk)); + TEST_ASSERT_EQUAL_STRING(USERPREFS_CHANNEL_3_NAME, s.name); +} + +static void test_index_zero_matches_its_userprefs() +{ + const meshtastic_ChannelSettings &s = channels.getByIndex(0).settings; + TEST_ASSERT_EQUAL_UINT(sizeof(kChannel0Psk), s.psk.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(kChannel0Psk, s.psk.bytes, sizeof(kChannel0Psk)); + TEST_ASSERT_EQUAL_STRING(USERPREFS_CHANNEL_0_NAME, s.name); + TEST_ASSERT_EQUAL_UINT(USERPREFS_CHANNEL_0_PRECISION, s.module_settings.position_precision); + TEST_ASSERT_TRUE(s.module_settings.is_muted); + TEST_ASSERT_TRUE(s.uplink_enabled); + TEST_ASSERT_FALSE(s.downlink_enabled); +} + +static void test_every_written_index_has_a_role() +{ + for (int i = 0; i < USERPREFS_CHANNELS_TO_WRITE; i++) { + const meshtastic_Channel &ch = channels.getByIndex(i); + TEST_ASSERT_TRUE(ch.has_settings); + TEST_ASSERT_TRUE(ch.settings.has_module_settings); + TEST_ASSERT_EQUAL(i == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY, ch.role); + } +} + +// Index 1 is inside CHANNELS_TO_WRITE but has no userPref of its own. +static void test_unconfigured_index_inside_range_is_stock() +{ + expectStockChannel(1); +} + +// Index 4 sets only a name upstream; the generator fills the rest with the values +// initDefaultChannel() would otherwise have left in place, so a partial config is not a way to +// accidentally ship uplink or a raised position precision. +static void test_partly_configured_index_keeps_firmware_defaults() +{ + const meshtastic_ChannelSettings &s = channels.getByIndex(4).settings; + TEST_ASSERT_EQUAL_STRING(USERPREFS_CHANNEL_4_NAME, s.name); + expectShortFormDefaultPsk(s); + TEST_ASSERT_EQUAL_UINT(0, s.module_settings.position_precision); + TEST_ASSERT_FALSE(s.module_settings.is_muted); + TEST_ASSERT_FALSE(s.uplink_enabled); + TEST_ASSERT_FALSE(s.downlink_enabled); +} + +// Indices past USERPREFS_CHANNELS_TO_WRITE are never handed to initDefaultChannel(), so they must +// stay zeroed rather than picking up a neighbour's key. +static void test_index_past_channels_to_write_is_untouched() +{ + for (int i = USERPREFS_CHANNELS_TO_WRITE; i < MAX_NUM_CHANNELS; i++) { + const meshtastic_ChannelSettings &s = channels.getByIndex(i).settings; + TEST_ASSERT_EQUAL_STRING("", s.name); + TEST_ASSERT_EQUAL_UINT(0, s.psk.size); + } +} + +// The switch this table replaces used strcpy() into a char[12]. +static void test_over_long_name_is_truncated_and_terminated() +{ + const meshtastic_ChannelSettings &s = channels.getByIndex(2).settings; + TEST_ASSERT_TRUE(strlen(USERPREFS_CHANNEL_2_NAME) >= sizeof(s.name)); + TEST_ASSERT_EQUAL_UINT(sizeof(s.name) - 1, strlen(s.name)); + TEST_ASSERT_EQUAL_CHAR('\0', s.name[sizeof(s.name) - 1]); + TEST_ASSERT_EQUAL_MEMORY(USERPREFS_CHANNEL_2_NAME, s.name, sizeof(s.name) - 1); +} + +#else // no channel userPrefs: the baseline the table must not have moved + +static void test_stock_build_writes_only_index_zero() +{ + expectStockChannel(0); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, channels.getByIndex(0).role); + for (int i = 1; i < MAX_NUM_CHANNELS; i++) { + const meshtastic_ChannelSettings &s = channels.getByIndex(i).settings; + TEST_ASSERT_EQUAL_STRING("", s.name); + TEST_ASSERT_EQUAL_UINT(0, s.psk.size); + } +} + +static void test_stock_build_fills_the_whole_table() +{ + TEST_ASSERT_EQUAL_UINT(MAX_NUM_CHANNELS, channelFile.channels_count); +} + +#endif + +UPC_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + +#ifdef USERPREFS_CHANNELS_TO_WRITE + printf("\n=== channel table beyond index 2 ===\n"); + RUN_TEST(test_index_beyond_two_gets_its_configured_psk); + RUN_TEST(test_index_zero_matches_its_userprefs); + RUN_TEST(test_every_written_index_has_a_role); + + printf("\n=== omitted fields and bounds ===\n"); + RUN_TEST(test_unconfigured_index_inside_range_is_stock); + RUN_TEST(test_partly_configured_index_keeps_firmware_defaults); + RUN_TEST(test_index_past_channels_to_write_is_untouched); + RUN_TEST(test_over_long_name_is_truncated_and_terminated); +#else + printf("\n=== stock defaults (no channel userPrefs) ===\n"); + RUN_TEST(test_stock_build_writes_only_index_zero); + RUN_TEST(test_stock_build_fills_the_whole_table); +#endif + + exit(UNITY_END()); +} + +UPC_TEST_ENTRY void loop() {} diff --git a/test/test_userprefs_channels/userprefs_fixture.h b/test/test_userprefs_channels/userprefs_fixture.h new file mode 100644 index 0000000000..a8d6c3f9bb --- /dev/null +++ b/test/test_userprefs_channels/userprefs_fixture.h @@ -0,0 +1,50 @@ +// What bin/platformio-custom.py emits for a userPrefs.jsonc configuring five channels. -include'd +// rather than passed as -D flags, which would overrun the Windows command-line limit. + +#pragma once + +#define USERPREFS_CHANNELS_TO_WRITE 5 + +// Every field set, with is_muted and uplink on but downlink off, so a test can tell an applied +// value from a zeroed one. +#define USERPREFS_CHANNEL_0_PSK \ + { \ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 \ + } +#define USERPREFS_CHANNEL_0_NAME "Ops" +#define USERPREFS_CHANNEL_0_PRECISION 14 +#define USERPREFS_CHANNEL_0_IS_MUTED true +#define USERPREFS_CHANNEL_0_UPLINK_ENABLED true +#define USERPREFS_CHANNEL_0_DOWNLINK_ENABLED false + +// Name longer than ChannelSettings.name (char[12]); the switch this table replaces used strcpy(). +#define USERPREFS_CHANNEL_2_PSK \ + { \ + 1 \ + } +#define USERPREFS_CHANNEL_2_NAME "LongerThanTwelve" +#define USERPREFS_CHANNEL_2_PRECISION 0 +#define USERPREFS_CHANNEL_2_IS_MUTED false +#define USERPREFS_CHANNEL_2_UPLINK_ENABLED false +#define USERPREFS_CHANNEL_2_DOWNLINK_ENABLED false + +// The index the old switch could not reach at all. +#define USERPREFS_CHANNEL_3_PSK \ + { \ + 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 \ + } +#define USERPREFS_CHANNEL_3_NAME "Three" +#define USERPREFS_CHANNEL_3_PRECISION 0 +#define USERPREFS_CHANNEL_3_IS_MUTED false +#define USERPREFS_CHANNEL_3_UPLINK_ENABLED false +#define USERPREFS_CHANNEL_3_DOWNLINK_ENABLED false + +#define USERPREFS_CHANNEL_4_PSK \ + { \ + 1 \ + } +#define USERPREFS_CHANNEL_4_NAME "Four" +#define USERPREFS_CHANNEL_4_PRECISION 0 +#define USERPREFS_CHANNEL_4_IS_MUTED false +#define USERPREFS_CHANNEL_4_UPLINK_ENABLED false +#define USERPREFS_CHANNEL_4_DOWNLINK_ENABLED false diff --git a/userPrefs.jsonc b/userPrefs.jsonc index eb9ff3faf9..8daddd8f21 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -1,28 +1,49 @@ { + // A value here is a FACTORY DEFAULT. It is applied on first boot and on factory reset, and never + // again - once the device has a saved config, that config wins. There is no per-field lock. To + // stop a user changing a setting, ship USERPREFS_USE_ADMIN_KEY_0 and set + // USERPREFS_CONFIG_SECURITY_IS_MANAGED. + // + // Numbers must be plain decimal or 0x-prefixed hex; a shift expression such as "(1 << 15)" is + // passed to the compiler as a string, not evaluated. + // + // Channels: indices 0 to 7 are all supported. Setting any USERPREFS_CHANNEL__* key configures + // index ; the fields you leave out keep the values the firmware would have used anyway (default + // PSK, empty name, precision 0, not muted, no uplink or downlink). USERPREFS_CHANNELS_TO_WRITE is + // how many indices are written on first boot and cannot exceed 8. // "USERPREFS_BUTTON_PIN": "36", // "USERPREFS_CHANNELS_TO_WRITE": "3", // "USERPREFS_CHANNEL_0_DOWNLINK_ENABLED": "false", + // "USERPREFS_CHANNEL_0_IS_MUTED": "false", // "USERPREFS_CHANNEL_0_NAME": "REPLACEME", // "USERPREFS_CHANNEL_0_PRECISION": "14", // "USERPREFS_CHANNEL_0_PSK": "{ 0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1 }", // "USERPREFS_CHANNEL_0_UPLINK_ENABLED": "true", // "USERPREFS_CHANNEL_1_DOWNLINK_ENABLED": "false", + // "USERPREFS_CHANNEL_1_IS_MUTED": "false", // "USERPREFS_CHANNEL_1_NAME": "NodeChat", // "USERPREFS_CHANNEL_1_PRECISION": "14", // "USERPREFS_CHANNEL_1_PSK": "{ 0x4e, 0x22, 0x1d, 0x8b, 0xc3, 0x09, 0x1b, 0xe2, 0x11, 0x9c, 0x89, 0x12, 0xf2, 0x25, 0x19, 0x5d, 0x15, 0x3e, 0x30, 0x7b, 0x86, 0xb6, 0xec, 0xc4, 0x6a, 0xc3, 0x96, 0x5e, 0x9e, 0x10, 0x9d, 0xd5 }", // "USERPREFS_CHANNEL_1_UPLINK_ENABLED": "false", // "USERPREFS_CHANNEL_2_DOWNLINK_ENABLED": "false", + // "USERPREFS_CHANNEL_2_IS_MUTED": "false", // "USERPREFS_CHANNEL_2_NAME": "YardSale", // "USERPREFS_CHANNEL_2_PRECISION": "14", // "USERPREFS_CHANNEL_2_PSK": "{ 0x15, 0x6f, 0xfe, 0x46, 0xd4, 0x56, 0x63, 0x8a, 0x54, 0x43, 0x13, 0xf2, 0xef, 0x6c, 0x63, 0x89, 0xf0, 0x06, 0x30, 0x52, 0xce, 0x36, 0x5e, 0xb1, 0xe8, 0xbb, 0x86, 0xe6, 0x26, 0x5b, 0x1d, 0x58 }", // "USERPREFS_CHANNEL_2_UPLINK_ENABLED": "false", + // "USERPREFS_CHANNEL_3_NAME": "Ops", + // "USERPREFS_CHANNEL_3_PSK": "{ 0x01 }", // "USERPREFS_CONFIG_GPS_MODE": "meshtastic_Config_PositionConfig_GpsMode_ENABLED", // "USERPREFS_CONFIG_LORA_IGNORE_MQTT": "true", + // "USERPREFS_CONFIG_LORA_CONFIG_OK_TO_MQTT": "false", // "USERPREFS_LORA_TX_DISABLED": "1", // If set, forces config.lora.tx_enabled=false during lora bootstrap // "USERPREFS_CONFIG_LORA_REGION": "meshtastic_Config_LoRaConfig_RegionCode_US", // "USERPREFS_CONFIG_OWNER_LONG_NAME": "My Long Name", // "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN", // "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted. + // "USERPREFS_CONFIG_DEVICE_REBROADCAST_MODE": "meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY", // NONE falls back to ALL on a router role + // "USERPREFS_CONFIG_DEVICE_NODE_INFO_BROADCAST_SECS": "10800", // Clamped to 3600 .. INT32_MAX + // "USERPREFS_CANNED_MESSAGES": "Hi|Bye|Yes|No|Ok", // "USERPREFS_EVENT_MODE": "1", // "USERPREFS_EVENT_MODE_HOP_LIMIT": "3", // Event-mode default and firmware-generated/relay hop cap (0-7; default 3) // "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL": "1", // Block location TX + discard inbound location on channels keyed with USERPREFS_CHANNEL_0_PSK. Defaults off, and must be set explicitly. @@ -50,6 +71,7 @@ // "USERPREFS_USE_ADMIN_KEY_0": "{ 0xcd, 0xc0, 0xb4, 0x3c, 0x53, 0x24, 0xdf, 0x13, 0xca, 0x5a, 0xa6, 0x0c, 0x0d, 0xec, 0x85, 0x5a, 0x4c, 0xf6, 0x1a, 0x96, 0x04, 0x1a, 0x3e, 0xfc, 0xbb, 0x8e, 0x33, 0x71, 0xe5, 0xfc, 0xff, 0x3c }", // "USERPREFS_USE_ADMIN_KEY_1": "{}", // "USERPREFS_USE_ADMIN_KEY_2": "{}", + // "USERPREFS_CONFIG_SECURITY_IS_MANAGED": "true", // Ignored unless an admin key is also set // "USERPREFS_OEM_TEXT": "Caterham Car Club", // "USERPREFS_OEM_FONT_SIZE": "0", // "USERPREFS_OEM_IMAGE_WIDTH": "50", diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 81b96f3e84..1eaff83434 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -144,11 +144,20 @@ test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s +; Channel-table userPrefs: initDefaults() must reach every index, not just 0-2. The fixture header +; stands in for what bin/platformio-custom.py emits for a vendor who configured five channels. +[env:coverage-channel-table] +extends = env:coverage +build_flags = ${env:coverage.build_flags} + -include test/test_userprefs_channels/userprefs_fixture.h +test_filter = + test_userprefs_channels + [env:coverage-event-policy] extends = env:coverage build_flags = ${env:coverage.build_flags} -DUSERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL=1 - -DUSERPREFS_CHANNEL_0_PSK='{0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f}' + -include test/support/userprefs_event_channel.h test_filter = test_position_precision test_event_channel_router @@ -362,6 +371,22 @@ lib_ignore = LovyanGFX Pine libch341-spi Userspace library +; Windows counterpart of [env:coverage-channel-table]; same flags, runnable on this host. +[env:native-windows-channel-table] +extends = env:native-windows +build_flags = ${env:native-windows.build_flags} + -include test/test_userprefs_channels/userprefs_fixture.h +test_filter = + test_userprefs_channels + +; Windows counterpart of [env:coverage-event-policy]; same flags, runnable on this host. +[env:native-windows-event-policy] +extends = env:native-windows +build_flags = ${env:native-windows.build_flags} + -DUSERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL=1 + -include test/support/userprefs_event_channel.h +test_filter = ${env:coverage-event-policy.test_filter} + ; --------------------------------------------------------------------------- ; WASM (Emscripten) - the portduino node compiled to WebAssembly, driving a real ; LoRa radio over WebUSB through a CH341 (src/platform/portduino/wasm/). The same From 260cf903e8694d31ce032394417b55bda28ebbdc Mon Sep 17 00:00:00 2001 From: Bob Reese Date: Thu, 27 Aug 2026 15:23:23 +0000 Subject: [PATCH 024/143] Check for ambientLightingThread non-null before use (#11590) --- src/modules/ExternalNotificationModule.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index f0e22d485d..425d0b33ee 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -247,7 +247,9 @@ void ExternalNotificationModule::setExternalState(uint8_t index, bool on) blue = 0; white = 0; } - ambientLightingThread->setLighting(moduleConfig.ambient_lighting.current, red, green, blue); + if (ambientLightingThread) { + ambientLightingThread->setLighting(moduleConfig.ambient_lighting.current, red, green, blue); + } #endif #ifdef HAS_DRV2605 From 335d778fae9a256a6aabb75166a954e3b5500833 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 27 Aug 2026 15:48:04 +0000 Subject: [PATCH 025/143] Flatpak: rename Meshtastic -> MeshtasticD (#11629) resubmitted against `develop` --- bin/org.meshtastic.meshtasticd.desktop | 4 ++-- bin/org.meshtastic.meshtasticd.metainfo.xml | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bin/org.meshtastic.meshtasticd.desktop b/bin/org.meshtastic.meshtasticd.desktop index 215c7ee054..00b59f3c83 100644 --- a/bin/org.meshtastic.meshtasticd.desktop +++ b/bin/org.meshtastic.meshtasticd.desktop @@ -1,6 +1,6 @@ [Desktop Entry] -Name=Meshtastic -Comment=Meshtastic App +Name=MeshtasticD +Comment=Meshtastic Daemon + MUI Exec=meshtasticd Icon=org.meshtastic.meshtasticd Terminal=true diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml index ed5338af64..05f6fd401c 100644 --- a/bin/org.meshtastic.meshtasticd.metainfo.xml +++ b/bin/org.meshtastic.meshtasticd.metainfo.xml @@ -2,7 +2,7 @@ org.meshtastic.meshtasticd - Meshtastic + MeshtasticD Decentralized mesh communication CC-BY-4.0 @@ -13,6 +13,9 @@ +

+ MeshtasticD is an app that allows you to use your Linux computer as a Meshtastic node (using a CH341 USB LoRa radio). +

Meshtastic is an open source project for creating off-grid, affordable, and resilient communication with LoRa mesh networks.

From 63f0f1edd087b9d19c1a050104f60ce872aaa1f2 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 27 Aug 2026 17:21:14 +0000 Subject: [PATCH 026/143] fix(nodedb): clear the whole LocalModuleConfig when installing defaults (#11627) installDefaultModuleConfig() memset sizeof(meshtastic_ModuleConfig) - the 368-byte union-backed wire oneof - over `moduleConfig`, which is a meshtastic_LocalModuleConfig: 1092 bytes with every submessage inlined. The function assigns only the fields it cares about and relies on that memset to zero the rest, so every byte past offset 368 that it never assigns kept its previous value across what is supposed to be a full reset. installDefaultConfig() directly above already used the correct sizeof(meshtastic_LocalConfig); only the module variant was wrong. statusmessage is the field this shows up on. It sits at offset 609 and is never assigned by the defaults installer, so it survives both routes into installDefaultModuleConfig(): - moduleConfig.version < DEVICESTATE_MIN_VER -> "old, discard". The decode succeeded, so the complete old config is in RAM and its statusmessage survives the discard verbatim. - loadProto() failure -> whatever a partial decode wrote there survives (loadProto itself clears correctly, using the caller's objSize). node_status is char[80]. When the surviving bytes carry no NUL, nanopb refuses the field ("unterminated string"), pb_encode_to_bytes() returns 0 and PhoneAPI::getFromRadio() returns 0. config_state has already advanced, so the frame is never retried - and 0 is the client's end-of-data sentinel, so the rest of the config dump goes with it and the client never receives StatusMessageConfig. traffic_management is not affected: installDefaultModuleConfig() calls installTrafficManagementDefaults(), which reassigns the whole submessage and its has_ flag regardless of the memset size. Also add has_traffic_management to the has_* list in saveToDiskNoRetry() for consistency - it was the only module config missing from it. --- src/mesh/NodeDB.cpp | 3 +- test/test_nodedb_boot_recovery/test_main.cpp | 30 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 4d3a73231d..cf1f19cb99 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1318,7 +1318,7 @@ void optInDisableTelemetryBroadcast(meshtastic_LocalModuleConfig &mc) void NodeDB::installDefaultModuleConfig() { LOG_INFO("Install default ModuleConfig"); - memset(&moduleConfig, 0, sizeof(meshtastic_ModuleConfig)); + memset(&moduleConfig, 0, sizeof(meshtastic_LocalModuleConfig)); moduleConfig.version = DEVICESTATE_CUR_VER; moduleConfig.has_mqtt = true; @@ -3292,6 +3292,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) moduleConfig.has_audio = true; moduleConfig.has_paxcounter = true; moduleConfig.has_statusmessage = true; + moduleConfig.has_traffic_management = true; moduleConfig.has_tak = true; #if !MESHTASTIC_EXCLUDE_BEACON moduleConfig.has_mesh_beacon = true; diff --git a/test/test_nodedb_boot_recovery/test_main.cpp b/test/test_nodedb_boot_recovery/test_main.cpp index 0c2f537df5..2d6466e82f 100644 --- a/test/test_nodedb_boot_recovery/test_main.cpp +++ b/test/test_nodedb_boot_recovery/test_main.cpp @@ -350,6 +350,33 @@ static void test_loadProto_classifiesFailuresDistinctly(void) FSCom.remove(scratchPath); // leave nothing behind } +// installDefaultModuleConfig() sized its memset to meshtastic_ModuleConfig (the 368-byte oneof) +// instead of the 1092-byte LocalModuleConfig, so submessages it never assigns kept their old values. +static void test_oldModuleConfig_discardClearsTailSubmessages(void) +{ + // statusmessage sits at offset 609, past the old 368-byte clear, and the defaults installer + // never assigns it - so only the full-struct memset can drop this marker. + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + moduleConfig.version = DEVICESTATE_MIN_VER - 1; + moduleConfig.has_statusmessage = true; + strncpy(moduleConfig.statusmessage.node_status, "STALE-TAIL", sizeof(moduleConfig.statusmessage.node_status) - 1); + + uint8_t buf[meshtastic_LocalModuleConfig_size]; + size_t len = pb_encode_to_bytes(buf, sizeof(buf), &meshtastic_LocalModuleConfig_msg, &moduleConfig); + TEST_ASSERT_GREATER_THAN_size_t(0, len); + writeFileBytes(moduleConfigFileName, buf, len); + + rebootNodeDB(); // decodes, sees version < DEVICESTATE_MIN_VER, discards + + // The opt-in migration re-stamps the version after the discard, so assert the floor, not equality. + TEST_ASSERT_GREATER_OR_EQUAL_UINT32_MESSAGE(DEVICESTATE_CUR_VER, moduleConfig.version, "discard did not reinstall defaults"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", moduleConfig.statusmessage.node_status, + "statusmessage survived the moduleConfig discard"); + + FSCom.remove(moduleConfigFileName); // leave the sandbox as we found it + rebootNodeDB(); +} + NBR_TEST_ENTRY void setup() { initializeTestEnvironment(); @@ -374,6 +401,9 @@ NBR_TEST_ENTRY void setup() RUN_TEST(test_oldDevicestate_recoversOwnerFromNodeDb); RUN_TEST(test_loadProto_classifiesFailuresDistinctly); + printf("\n=== ModuleConfig discard completeness ===\n"); + RUN_TEST(test_oldModuleConfig_discardClearsTailSubmessages); + exit(UNITY_END()); } From 7aa8ad351069e640e7c52cafc491f8a151293f0c Mon Sep 17 00:00:00 2001 From: Ixitxachitl Date: Thu, 27 Aug 2026 18:27:02 +0000 Subject: [PATCH 027/143] fix(t-watch-ultra): build with the esp32s3 flags, not the classic-ESP32 ones (#11619) * fix(t-watch-ultra): build with the esp32s3 flags, not the classic-ESP32 ones The env was the only esp32s3 variant extending ${esp32_base.build_flags} (since #8171). That base adds -D ESP32_FORCE_IRAM_MEMSET -Wl,--wrap=memset -Wl,--wrap=memcpy, and the wrappers in IramMemcpy.c/IramMemset.c decide whether the cache is on by reading 0x3FF00040 - DPORT_PRO_CACHE_CTRL_REG on the classic ESP32, an address the S3 does not map at all (soc.h: DRAM 0x3FC88000-0x3FD00000, DROM 0x3C000000-0x3E000000, IRAM 0x40370000-0x403E0000, peripherals 0x60000000). --wrap is link-wide, so every memcpy/memset in the image - including inside the precompiled WiFi, lwIP and flash driver libraries - branched on that undefined read. Two long-standing board-specific bugs came from it, both dating to #8171, which introduced the wrong base and the first workaround in the same commit: * WPA2 networks associated and completed the 4-way handshake, then never got a DHCP lease, while open networks worked normally (#11513). * Direct flash reads returned 0x00 for data that was correct on flash, so NVS came up empty every boot and dropped BLE bonds (#11530). Switching the env to esp32s3_base fixes both on hardware: WPA2 gets a lease, and NVS survives a reboot with the bond intact. The read workaround that #11530 needed - -Wl,--wrap=esp_partition_read, -Wl,--wrap=esp_flash_read and esp_partition_read_mmap_wrap.c - is therefore removed as well. The module excludes the env inherited from esp32_base go with it, so the board now matches every other esp32s3 variant: web server and paxcounter are built (paxcounter still only runs when enabled in config), and MESHTASTIC_EXCLUDE_AUDIO was already inert here because AudioModule additionally requires USE_SX1280. -UMESHTASTIC_EXCLUDE_ACCELEROMETER goes too, having only existed to undo an inherited -D. Also guards ESP32_FORCE_IRAM_MEMSET behind CONFIG_IDF_TARGET_ESP32, so a variant cannot enable the classic-ESP32 probe on another target again. * Update platformio.ini added missing ${device-ui_base.custom_sdkconfig} --------- Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> --- src/platform/esp32/IramMemcpy.c | 8 ++ src/platform/esp32/IramMemset.c | 8 ++ .../esp32/esp_partition_read_mmap_wrap.c | 81 ------------------- variants/esp32s3/t-watch-ultra/platformio.ini | 16 ++-- 4 files changed, 24 insertions(+), 89 deletions(-) delete mode 100644 src/platform/esp32/esp_partition_read_mmap_wrap.c diff --git a/src/platform/esp32/IramMemcpy.c b/src/platform/esp32/IramMemcpy.c index 898f1bdc61..d4e2a8ef76 100644 --- a/src/platform/esp32/IramMemcpy.c +++ b/src/platform/esp32/IramMemcpy.c @@ -3,9 +3,14 @@ #include #include "esp_attr.h" +#include "sdkconfig.h" #ifdef ESP32_FORCE_IRAM_MEMSET +#if !defined(CONFIG_IDF_TARGET_ESP32) +#error "ESP32_FORCE_IRAM_MEMSET is classic-ESP32 only: cache_is_enabled() reads a DPORT register that other targets do not map." +#endif + /* * T-Beam/classic ESP32 boot workaround * ------------------------------------ @@ -16,6 +21,9 @@ * 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. + * + * Classic ESP32 only: the probe below reads DPORT_PRO_CACHE_CTRL_REG, an address + * no other target maps, so the guard above keeps this off the S3/C3/C6. */ extern void *__real_memcpy(void *dst, const void *src, size_t n); diff --git a/src/platform/esp32/IramMemset.c b/src/platform/esp32/IramMemset.c index 66d74d8bc5..fb8155c1f0 100644 --- a/src/platform/esp32/IramMemset.c +++ b/src/platform/esp32/IramMemset.c @@ -3,9 +3,14 @@ #include #include "esp_attr.h" +#include "sdkconfig.h" #ifdef ESP32_FORCE_IRAM_MEMSET +#if !defined(CONFIG_IDF_TARGET_ESP32) +#error "ESP32_FORCE_IRAM_MEMSET is classic-ESP32 only: cache_is_enabled() reads a DPORT register that other targets do not map." +#endif + /* * T-Beam/classic ESP32 boot workaround * ------------------------------------ @@ -16,6 +21,9 @@ * 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. + * + * Classic ESP32 only: the probe below reads DPORT_PRO_CACHE_CTRL_REG, an address + * no other target maps, so the guard above keeps this off the S3/C3/C6. */ extern void *__real_memset(void *dst, int c, size_t n); diff --git a/src/platform/esp32/esp_partition_read_mmap_wrap.c b/src/platform/esp32/esp_partition_read_mmap_wrap.c deleted file mode 100644 index a685b43113..0000000000 --- a/src/platform/esp32/esp_partition_read_mmap_wrap.c +++ /dev/null @@ -1,81 +0,0 @@ -// Workaround for the IDF 5.5 manual esp_flash read regression on t-watch-ultra. -// -// On this board (Winbond W25Q128JW, ef:8018), the IDF 5.5 *direct* flash read path -// (esp_flash_read / esp_partition_read) returns 0x00 for data that is physically -// correct on flash. We proved the *memory-mapped* (cache) read returns the right -// data, writes work, and it's not read-mode/HPM/timing-tuning/PSRAM. So route every -// esp_partition_read through esp_partition_mmap + memcpy, which uses the working -// cache path. Activated by `-Wl,--wrap=esp_partition_read` (t-watch-ultra only). -// -// That alone isn't enough: nvs_flash reads through the lower-level esp_flash_read, -// which hits the same 0x00 regression, so NVS came up empty every boot (0 entries) -// and silently dropped BLE bonds and everything else stored via Preferences. Wrap -// esp_flash_read too, via raw spi_flash_mmap, for callers that bypass esp_partition_t. -#if defined(T_WATCH_ULTRA) - -#include "esp_flash.h" -#include "esp_flash_encrypt.h" -#include "esp_partition.h" -#include "spi_flash_mmap.h" -#include - -extern esp_err_t __real_esp_partition_read(const esp_partition_t *partition, size_t src_offset, void *dst, size_t size); -extern esp_err_t __real_esp_flash_read(esp_flash_t *chip, void *buffer, uint32_t address, uint32_t length); - -esp_err_t __wrap_esp_partition_read(const esp_partition_t *partition, size_t src_offset, void *dst, size_t size) -{ - if (partition == NULL || dst == NULL) - return ESP_ERR_INVALID_ARG; - if (size == 0) - return ESP_OK; - - // mmap requires a 64KB-aligned start; map the containing page span and copy - // out from the requested offset. - const size_t PAGE = 0x10000; - size_t aligned = src_offset & ~(PAGE - 1); - size_t delta = src_offset - aligned; - - const void *ptr = NULL; - esp_partition_mmap_handle_t handle; - esp_err_t err = esp_partition_mmap(partition, aligned, delta + size, ESP_PARTITION_MMAP_DATA, &ptr, &handle); - if (err != ESP_OK) { - // Encrypted partitions / regions mmap can't serve: fall back to the real - // read (may be wrong on this board, but better than failing the call). - return __real_esp_partition_read(partition, src_offset, dst, size); - } - memcpy(dst, (const uint8_t *)ptr + delta, size); - esp_partition_munmap(handle); - return ESP_OK; -} - -esp_err_t __wrap_esp_flash_read(esp_flash_t *chip, void *buffer, uint32_t address, uint32_t length) -{ - if (buffer == NULL) - return ESP_ERR_INVALID_ARG; - if (length == 0) - return ESP_OK; - // spi_flash_mmap only maps the default (main) chip's address space - anything - // else (e.g. a second chip on another bus) falls back to the real call. - if (chip != NULL && chip != esp_flash_default_chip) - return __real_esp_flash_read(chip, buffer, address, length); - // esp_flash_read is specified to return raw bytes, but the cache decrypts transparently. - // With flash encryption on, the mmap path would hand back plaintext - so don't take it. - if (esp_flash_encryption_enabled()) - return __real_esp_flash_read(chip, buffer, address, length); - - const size_t PAGE = 0x10000; - size_t aligned = address & ~(PAGE - 1); - size_t delta = address - aligned; - - const void *ptr = NULL; - spi_flash_mmap_handle_t handle; - esp_err_t err = spi_flash_mmap(aligned, delta + length, SPI_FLASH_MMAP_DATA, &ptr, &handle); - if (err != ESP_OK) - return __real_esp_flash_read(chip, buffer, address, length); - - memcpy(buffer, (const uint8_t *)ptr + delta, length); - spi_flash_munmap(handle); - return ESP_OK; -} - -#endif // T_WATCH_ULTRA diff --git a/variants/esp32s3/t-watch-ultra/platformio.ini b/variants/esp32s3/t-watch-ultra/platformio.ini index 20702a2498..56b47cae2b 100644 --- a/variants/esp32s3/t-watch-ultra/platformio.ini +++ b/variants/esp32s3/t-watch-ultra/platformio.ini @@ -24,17 +24,13 @@ custom_sdkconfig = CONFIG_LITTLEFS_MALLOC_STRATEGY_INTERNAL=y CONFIG_SPI_FLASH_SHARE_SPI1_BUS=y -build_flags = ${esp32_base.build_flags} -Ivariants/esp32s3/t-watch-ultra - ; Route flash reads through the cache/mmap path (esp_partition_read_mmap_wrap.c) - ; to dodge the IDF 5.5 manual-read regression on this board's flash. esp_flash_read - ; is wrapped too - nvs_flash reads through it directly, bypassing esp_partition_read. - -Wl,--wrap=esp_partition_read - -Wl,--wrap=esp_flash_read +; esp32s3_base, not esp32_base: the classic-ESP32 base adds -Wl,--wrap=memcpy/memset, +; whose cache probe reads a DPORT register the S3 does not map (see IramMemcpy.c). +build_flags = ${esp32s3_base.build_flags} -Ivariants/esp32s3/t-watch-ultra -D T_WATCH_ULTRA -D RADIOLIB_EXCLUDE_SX128X=1 -D RADIOLIB_EXCLUDE_SX127X=1 -D RADIOLIB_EXCLUDE_LR11X0=1 - -UMESHTASTIC_EXCLUDE_ACCELEROMETER -D HAS_SDCARD -D SDCARD_USE_SPI1 -D SD_SPI_FREQUENCY=75000000 @@ -98,4 +94,8 @@ build_flags = lib_deps = ${env:t-watch-ultra.lib_deps} - ${device-ui_base.lib_deps} \ No newline at end of file + ${device-ui_base.lib_deps} + +custom_sdkconfig = + ${env:t-watch-ultra.custom_sdkconfig} + ${device-ui_base.custom_sdkconfig} From 7e9525ad8343cdf6bceb78b2e9bd78404bbea8c8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 28 Aug 2026 11:55:20 +0000 Subject: [PATCH 028/143] feat(baseui): default US to LongTurbo on first region selection (#11637) Selecting US in the BaseUI region chooser now installs LongTurbo instead of LongFast, but only for out-of-box setup: the outgoing region must be UNSET, so a later switch to US leaves whatever preset the node is running alone. Scoped to the menu on purpose. The US entry in regions[] keeps LongFast as its default preset, so preset repair, admin/phone writes and every other route onto US are unchanged. A build pinning USERPREFS_LORACONFIG_MODEM_PRESET, a preset already moved off the install default, or use_preset=false all outrank it. The decision is lifted into menuHandler::presetForRegionSelection() so it is reachable without a Screen, following toggleNodeMuted(). --- src/graphics/draw/MenuHandler.cpp | 25 ++++++++ src/graphics/draw/MenuHandler.h | 5 ++ test/test_admin_radio/test_main.cpp | 96 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 6aec0ff654..284ac0e0c1 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -227,8 +227,33 @@ void menuHandler::OnboardMessage() screen->showOverlayBanner(bannerOptions); } +// Out-of-box US setup starts on LongTurbo rather than the region table's LongFast. Menu-only: the +// US entry in `regions[]` keeps LongFast, so no other route onto US changes. Anything that already +// states a preset - a pinned userpref, or a preset moved off the install default - outranks it. +meshtastic_Config_LoRaConfig_ModemPreset menuHandler::presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, + meshtastic_Config_LoRaConfig_RegionCode selected) +{ +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + (void)selected; // the pinned preset wins outright; nothing to decide +#else + if (lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && selected == meshtastic_Config_LoRaConfig_RegionCode_US && + lora.use_preset && lora.modem_preset == meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST) { + return meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + } +#endif + return lora.modem_preset; +} + static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam) { + // Decided first: it keys off the *outgoing* region being UNSET. + const meshtastic_Config_LoRaConfig_ModemPreset selectionPreset = menuHandler::presetForRegionSelection(config.lora, region); + if (selectionPreset != config.lora.modem_preset) { + LOG_INFO("First region is %s, default preset to %s", getRegion(region)->name, + DisplayFormatters::getModemPresetDisplayName(selectionPreset, false, true)); + config.lora.modem_preset = selectionPreset; + } + config.lora.region = region; config.lora.channel_num = 0; // Reset to default channel diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 7ffbfae1c9..a2cc14e677 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -141,6 +141,11 @@ class menuHandler // ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable. static void toggleNodeMuted(uint32_t nodeNum); // uint32_t, matching pickedNodeNum above + // Preset a region selection should leave installed. `lora` is the config as it stands *before* + // the selection is written. + static meshtastic_Config_LoRaConfig_ModemPreset presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora, + meshtastic_Config_LoRaConfig_RegionCode selected); + private: static void saveUIConfig(); static void keyVerificationInitMenu(); diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 8faebd16a6..8a332b7a92 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -2178,6 +2178,91 @@ static void test_toggleNodeMuted_currentlyRewritesEverySegment() for (const char *f : segmentFiles) TEST_ASSERT_TRUE_MESSAGE(FSCom.exists(f), f); } + +// ----------------------------------------------------------------------- +// BaseUI region chooser preset default (graphics::menuHandler::presetForRegionSelection) +// ----------------------------------------------------------------------- +// +// Out-of-box US setup starts on LongTurbo. Each guard below is load-bearing: widening the rule past +// "first region ever chosen, US, no preset on record" re-presets nodes that already have an opinion. + +// `region` is the region still in place when the user highlights `selected`. +static meshtastic_Config_LoRaConfig loraAt(meshtastic_Config_LoRaConfig_RegionCode region, + meshtastic_Config_LoRaConfig_ModemPreset preset, bool usePreset = true) +{ + meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_default; + lora.region = region; + lora.modem_preset = preset; + lora.use_preset = usePreset; + return lora; +} + +#ifndef USERPREFS_LORACONFIG_MODEM_PRESET +static void test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); + + // Unusable unless US offers it: applyLoraRegion()'s reconciliation would throw it straight back. + TEST_ASSERT_TRUE_MESSAGE(getRegion(meshtastic_Config_LoRaConfig_RegionCode_US) + ->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO), + "US no longer supports LongTurbo"); +} +#else +// A pinned preset owns the decision outright. +static void test_presetForRegionSelection_pinnedUserprefWins() +{ + const meshtastic_Config_LoRaConfig_ModemPreset pinned = USERPREFS_LORACONFIG_MODEM_PRESET; + const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, pinned); + + TEST_ASSERT_EQUAL(pinned, graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} +#endif + +// US on a node that already has a region is a region change, not first-time setup. +static void test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} + +// The default is US-only; no other region's first selection is touched. +static void test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + for (auto region : {meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_RegionCode_ANZ, + meshtastic_Config_LoRaConfig_RegionCode_JP}) + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, region)); +} + +// A preset off the install default is a preference on record (phone app, admin, preset menu). +static void test_presetForRegionSelection_respectsAPresetAlreadyChosen() +{ + const meshtastic_Config_LoRaConfig lora = + loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} + +// use_preset false means raw bandwidth/SF/CR: rewriting modem_preset only misleads the preset menu. +static void test_presetForRegionSelection_ignoresNodesOnRawModemSettings() +{ + const meshtastic_Config_LoRaConfig lora = loraAt(meshtastic_Config_LoRaConfig_RegionCode_UNSET, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, /*usePreset=*/false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + graphics::menuHandler::presetForRegionSelection(lora, meshtastic_Config_LoRaConfig_RegionCode_US)); +} #endif // HAS_SCREEN // ----------------------------------------------------------------------- @@ -2342,6 +2427,17 @@ void setup() RUN_TEST(test_toggleNodeMuted_flipsBitAndSkipsRadioReload); RUN_TEST(test_toggleNodeMuted_unknownNodeDoesNothing); RUN_TEST(test_toggleNodeMuted_currentlyRewritesEverySegment); + + // BaseUI region chooser preset default +#ifndef USERPREFS_LORACONFIG_MODEM_PRESET + RUN_TEST(test_presetForRegionSelection_firstUsSelectionDefaultsToLongTurbo); +#else + RUN_TEST(test_presetForRegionSelection_pinnedUserprefWins); +#endif + RUN_TEST(test_presetForRegionSelection_laterUsSelectionKeepsCurrentPreset); + RUN_TEST(test_presetForRegionSelection_firstNonUsSelectionKeepsCurrentPreset); + RUN_TEST(test_presetForRegionSelection_respectsAPresetAlreadyChosen); + RUN_TEST(test_presetForRegionSelection_ignoresNodesOnRawModemSettings); #endif exit(UNITY_END()); From 467dc44cfa86f590da1771eba5b6203c5cbef123 Mon Sep 17 00:00:00 2001 From: Jason P Date: Fri, 28 Aug 2026 17:20:02 +0000 Subject: [PATCH 029/143] Update applyLoraRegion to enable TX on set (#11643) * Update applyLoraRegion to enable TX on set * Don't enable TX if in HamMode. User must set callsign first * Don't use isHam, use owner.is_licensed --- src/graphics/draw/MenuHandler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 284ac0e0c1..8627dd2f89 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -297,6 +297,10 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool if (gps != nullptr && !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) gps->enable(); #endif + if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && !config.lora.tx_enabled && !owner.is_licensed) { + LOG_WARN("Setting config.lora.tx_enabled to true"); + config.lora.tx_enabled = true; + } service->reloadConfig(changes); } From 57d17cfd44d41b92ad18ebffcb7b8038000db734 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 28 Aug 2026 17:37:32 +0000 Subject: [PATCH 030/143] fix(stm32wl): recover from littlefs internal corruption instead of hanging (#11230) LFS_ASSERT (src/platform/stm32wl/littlefs/lfs_util.h) was a plain assert(), which on STM32WL hangs forever with no diagnostic (__wrap___assert_func is while(true);, see main-stm32wl.cpp). STM32_LittleFS::begin() is already designed to treat corruption as recoverable - format and retry, see fsFormat()/NodeDB::saveToDisk() - but that only works if lfs_mount() cleanly returns an error. An internal littlefs consistency check failing (metadata pair/CRC/block-allocator invariants) never returns at all, so a bad flash sector or power loss mid-write could permanently brick a device that would otherwise have recovered via the existing reformat path. nRF52 already hit this and fixed it (LFS_NO_ASSERT + a custom lfs_assert() that reboots into a reformat, see meshtastic/firmware#3818). Port the same approach to STM32WL: LFS_NO_ASSERT routes LFS_ASSERT through a custom lfs_assert() instead of disabling the check outright, and lfs_assert() requests a reformat-on-next-boot via a .noinit SRAM magic value (the same mechanism already used for the DFU bootloader redirect in this file, chosen specifically because backup/TAMP registers don't reliably survive a soft reset in this toolchain) and reboots, rather than trying to reformat littlefs from inside its own possibly-mid-operation callback. Unlike nRF52 (a third-party Adafruit library patched via a -include override so as not to fork it), STM32WL's littlefs copy is already a project-owned vendored file, so lfs_util.h is edited directly. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> --- src/platform/stm32wl/littlefs/lfs_util.h | 8 ++++- src/platform/stm32wl/main-stm32wl.cpp | 37 ++++++++++++++++++++++++ variants/stm32/stm32.ini | 1 + 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/platform/stm32wl/littlefs/lfs_util.h b/src/platform/stm32wl/littlefs/lfs_util.h index 5c8469f88d..ef51fa8556 100644 --- a/src/platform/stm32wl/littlefs/lfs_util.h +++ b/src/platform/stm32wl/littlefs/lfs_util.h @@ -72,7 +72,13 @@ extern "C" { #ifndef LFS_NO_ASSERT #define LFS_ASSERT(test) assert(test) #else -#define LFS_ASSERT(test) +// assert() hangs forever on STM32WL (see main-stm32wl.cpp); route through a recoverable handler instead. +extern void lfs_assert(const char *reason); +#define LFS_ASSERT(test) \ + do { \ + if (!(test)) \ + lfs_assert(#test); \ + } while (0) #endif // Builtin functions, these may be replaced by more efficient diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 50f7ae3d81..4d54f22895 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,4 +1,6 @@ +#include "FSCommon.h" #include "configuration.h" +#include "error.h" #include "gps/RTC.h" #include #include @@ -162,6 +164,41 @@ void cpuDeepSleep(uint32_t msecToWake) // ─── Linker hacks to reduce code size ───────────────────────────────────────── +// Requests a reformat-on-next-boot instead of reformatting mid-callback (see lfs_assert() below). +// Same .noinit survives-NVIC_SystemReset() trick as g_bootloaderMagic above. +#define LFS_CORRUPT_MAGIC 0xC0FFEEEEUL + +__attribute__((section(".noinit"), used)) static volatile uint32_t g_lfsCorruptMagic; + +// Not .noinit - resets every boot, so this only throttles reformats within one power-on session. +static constexpr uint32_t LFS_CORRUPTION_RETRY_DELAY_MS = 20 * 60 * 1000; +static uint32_t lastLfsFormatMs = 0; + +extern "C" void lfs_assert(const char *reason) +{ + LOG_ERROR("LittleFS corruption detected: %s", reason); + if (lastLfsFormatMs != 0 && Throttle::isWithinTimespanMs(lastLfsFormatMs, LFS_CORRUPTION_RETRY_DELAY_MS)) { + uint32_t msRemain = LFS_CORRUPTION_RETRY_DELAY_MS - (millis() - lastLfsFormatMs); + LOG_WARN("Pausing %u seconds to avoid wearing the flash with repeated reformats", msRemain / 1000); + delay(msRemain); + } + LOG_INFO("Rebooting to reformat LittleFS"); + g_lfsCorruptMagic = LFS_CORRUPT_MAGIC; + HAL_NVIC_SystemReset(); +} + +// Weak hook in FSCommon.cpp, called before FSBegin(). Reformats if the last boot's lfs_assert() requested it. +void preFSBegin() +{ + if (g_lfsCorruptMagic != LFS_CORRUPT_MAGIC) + return; + g_lfsCorruptMagic = 0; + lastLfsFormatMs = millis(); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE); + fsFormat(); + LOG_INFO("LittleFS format complete; restoring default settings"); +} + // By default strerror has a lot of strings we probably don't use. Make it return an empty string instead. char empty = 0; extern "C" char *__wrap_strerror(int) diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 02955ff2a0..6f7477882f 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -43,6 +43,7 @@ build_flags = -DMESHTASTIC_DYNAMIC_SBRK_HEAP -DHAL_DAC_MODULE_ONLY -DHAL_RNG_MODULE_ENABLED + -DLFS_NO_ASSERT ; Recoverable lfs_assert() instead of assert() (hangs forever here). Same as nRF52, see #3818 -Wl,--wrap=__assert_func -Wl,--wrap=strerror -Wl,--wrap=_tzset_unlocked_r From 78219e09cb0a285988203aacd4d7d20874aab01f Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 28 Aug 2026 18:21:58 +0000 Subject: [PATCH 031/143] fix(stm32wl): add TCXO-optional support and fix hardcoded TCXO voltage (#10964) * stm32wl: consult SX126X_DIO3_TCXO_VOLTAGE instead of hardcoding 1.7V Every STM32WL variant except rak3172 got setTCXOVoltage(1.7) unconditionally, regardless of what the board's hardware actually needs, and rak3172 got no TCXO configuration at all - so a real RAK3172-T (populated TCXO) failed radio init outright. Read SX126X_DIO3_TCXO_VOLTAGE per variant instead. When TCXO_OPTIONAL is also defined, retry once on XTAL if the TCXO attempt fails, mirroring the existing pattern in LR11x0Interface.cpp, LR20x0Interface.cpp, and the SX1262/SX1268 paths in RadioInterface.cpp. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * stm32wl(rak3172): support both non-T and -T hardware via TCXO-optional RAK3172 is XTAL-only; RAK3172-T has a populated 3.0V TCXO, matching RAK's own reference radio_conf.h. One PlatformIO environment now serves both: tries the TCXO first, falls back to XTAL if not populated. Hardware-verified on a TCXO-equipped board electrically equivalent to RAK3172-T. Genuine non-T hardware not available to re-verify the fallback path; reasoned from RadioLib source instead (see PR description). Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * stm32wl(wio-e5): declare the module's 1.7V TCXO explicitly Matches Seeed's own reference radio driver. wio-e5 previously relied on the hardcoded 1.7V fallback being removed by the preceding commit, which would have broken it - declare the voltage explicitly instead. Hardware-verified via SWD: without this define, the radio interface fails to come up at all (sendtext NAKs with NO_INTERFACE, meaning rIf is null). With it, NO_INTERFACE goes away and the device sends/receives normally. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * stm32wl(CDEBYTE_E77-MBL): mark TCXO voltage optional, hardware varies by unit EByte changed the E77-MBL hardware in early 2024: units with serial number >= 3202995 have a TCXO, older units have a ceramic crystal oscillator instead. Both ship under the same module name, so probe for the TCXO and fall back to XTAL rather than assuming either. https://github.com/olliw42/mLRS-docu/blob/main/docs/EBYTE_E77_MBL.md Not hardware-tested - no E77-MBL board available this session. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * stm32wl: trim TCXO comment blocks to repo's 1-2 line guideline Per review feedback on PR #10964 (CodeRabbit nitpicks) - the rak3172 and CDEBYTE_E77-MBL variant.h comments were 4-line blocks, exceeding the repo's comment-length convention. Condensed to one line each, same information and links retained. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 --------- Signed-off-by: Andrew Yong --- src/mesh/STM32WLE5JCInterface.cpp | 15 +++++++++++++-- variants/stm32/CDEBYTE_E77-MBL/variant.h | 7 +++++++ variants/stm32/rak3172/variant.h | 6 ++++++ variants/stm32/wio-e5/variant.h | 4 ++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/mesh/STM32WLE5JCInterface.cpp b/src/mesh/STM32WLE5JCInterface.cpp index f6e4b3512a..567cd4c00f 100644 --- a/src/mesh/STM32WLE5JCInterface.cpp +++ b/src/mesh/STM32WLE5JCInterface.cpp @@ -19,8 +19,8 @@ bool STM32WLE5JCInterface::init() RadioLibInterface::init(); // https://github.com/Seeed-Studio/LoRaWan-E5-Node/blob/main/Middlewares/Third_Party/SubGHz_Phy/stm32_radio_driver/radio_driver.c -#if (!defined(_VARIANT_RAK3172_)) - setTCXOVoltage(1.7); +#if defined(SX126X_DIO3_TCXO_VOLTAGE) + setTCXOVoltage(SX126X_DIO3_TCXO_VOLTAGE); #endif lora.setRfSwitchTable(rfswitch_pins, rfswitch_table); @@ -29,6 +29,17 @@ bool STM32WLE5JCInterface::init() int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); +#if defined(TCXO_OPTIONAL) + // If a TCXO was requested but isn't actually populated (e.g. non-T RAK3172), retry on XTAL + if (res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) { + LOG_WARN("STM32WLx init failed with TCXO Vref %fV (err %d), retrying without TCXO", tcxoVoltage, res); + setTCXOVoltage(0); + res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + if (res == RADIOLIB_ERR_NONE) + LOG_INFO("STM32WLx init success without TCXO (XTAL mode)"); + } +#endif + LOG_INFO("STM32WLx init result %d", res); LOG_INFO("Frequency set to %f", getFreq()); diff --git a/variants/stm32/CDEBYTE_E77-MBL/variant.h b/variants/stm32/CDEBYTE_E77-MBL/variant.h index 686326137a..b2f84f2353 100644 --- a/variants/stm32/CDEBYTE_E77-MBL/variant.h +++ b/variants/stm32/CDEBYTE_E77-MBL/variant.h @@ -22,4 +22,11 @@ Do not expect a working Meshtastic device with this target. #define SERIAL_PRINT_PORT 1 #define EBYTE_E77_MBL + +// LoRa +// Hardware varies by unit: SN >= 3202995 has a TCXO, older units have XTAL only - +// https://github.com/olliw42/mLRS-docu/blob/main/docs/EBYTE_E77_MBL.md +#define TCXO_OPTIONAL +#define SX126X_DIO3_TCXO_VOLTAGE 1.7 + #endif diff --git a/variants/stm32/rak3172/variant.h b/variants/stm32/rak3172/variant.h index b7afefc3f7..f18448f0d9 100644 --- a/variants/stm32/rak3172/variant.h +++ b/variants/stm32/rak3172/variant.h @@ -27,4 +27,10 @@ Do not expect a working Meshtastic device with this target. #define HAS_LSE 1 #define STM32WL_LSE_DRIVE RCC_LSEDRIVE_LOW +// LoRa +// RAK3172: no TCXO, RAK3172-T: 3.0V TCXO - +// https://github.com/RAKWireless/RAK-STM32-RUI/blob/e5a28be8fab1a492bd9223dd425ca33a8a297d90/variants/WisDuo_RAK3172-T_Board/radio_conf.h#L91 +#define TCXO_OPTIONAL +#define SX126X_DIO3_TCXO_VOLTAGE 3.0 + #endif diff --git a/variants/stm32/wio-e5/variant.h b/variants/stm32/wio-e5/variant.h index da2c623fb3..ad733ff5f4 100644 --- a/variants/stm32/wio-e5/variant.h +++ b/variants/stm32/wio-e5/variant.h @@ -19,4 +19,8 @@ Do not expect a working Meshtastic device with this target. #define WIO_E5 +// LoRa +// https://github.com/Seeed-Studio/LoRaWan-E5-Node/blob/163c05379b1805dd8f2c061d4557a69985acc953/Middlewares/Third_Party/SubGHz_Phy/stm32_radio_driver/radio_driver.c#L94 +#define SX126X_DIO3_TCXO_VOLTAGE 1.7 + #endif From 7afd270f3982a232a5fc8cdf5c2a7eae066ab0a5 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 28 Aug 2026 19:51:43 +0000 Subject: [PATCH 032/143] Gut beacon send-as-node and consolidate TX onto broadcast_targets (#11646) * Gut beacon send-as-node and consolidate TX onto broadcast_targets Two MeshBeaconConfig changes, both against fields that never reached a tagged release, so there is no migration for existing nodes. broadcast_send_as_node let a client name a node ID to send beacons AS, rewriting the packet's `from`. Firmware never applied it - the assignment was commented out, so `from` was always the local node and the field was a settable, persisted no-op. It was also unsound as designed: rewriting `from` forges no signature, it only makes isFromUs() false, so perhapsEncode() skips XEdDSA signing and receivers get an unsigned packet attributed to another node. broadcast_on_channel / broadcast_on_region / broadcast_on_preset were a second way to name a beacon destination alongside broadcast_targets, chosen silently on whether broadcast_targets was empty. The comments claimed the two were equivalent; they were not. An inline ChannelSettings carries name and PSK, so broadcast_on_channel could transmit on a channel absent from the node's channel table, which channel_index cannot express. That is dropped deliberately - the channel must exist on the node. Empty broadcast_targets now synthesises one target on the running preset and region over the primary channel, matching what the scalar path produced when left unset, so an otherwise unconfigured node still beacons. The USERPREFS_MESH_BEACON_ON_* keys go with the fields. A preconfigured build that still defines one now fails at compile time with a pointer to the USERPREFS_MESH_BEACON_TARGET_0_* equivalents, rather than silently losing its beacon channel. The replacement names a channel-table slot, so such a build must also provision that channel. MeshBeaconConfig shrinks 324 -> 240 bytes and ModuleConfig 328 -> 244, against the 512-byte MAX_TO_FROM_RADIO_SIZE ceiling that FromRadio sits 2 bytes under. The protobufs submodule points at a branch carrying both proto changes; it needs re-pointing to master once meshtastic/protobufs#1047 and #1048 merge. * Point protobufs submodule at master now that the beacon protos are merged meshtastic/protobufs#1047 and #1048 are in master, so drop the temporary beacon-proto-integration pin. MeshBeaconConfig stays 240 bytes and ModuleConfig 244, unchanged from the integration branch. The bump also picks up master's unrelated additions: the MESHNOLOGY_W12 and MESHPAGER_X2 hardware models, and a ground-speed unit correction in Position. --- protobufs | 2 +- src/mesh/NodeDB.cpp | 32 +--- src/mesh/PhoneAPI.cpp | 6 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 24 +-- .../generated/meshtastic/module_config.pb.h | 58 ++----- src/modules/AdminModule.cpp | 28 +--- src/modules/MeshBeaconModule.cpp | 45 ++---- src/modules/MeshBeaconModule.h | 4 +- test/test_mesh_beacon/test_main.cpp | 144 ++++++------------ userPrefs.jsonc | 12 +- 12 files changed, 106 insertions(+), 253 deletions(-) diff --git a/protobufs b/protobufs index aca181b97b..7b2464c9b8 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit aca181b97b7db047d76e9f000220a11a234cd389 +Subproject commit 7b2464c9b8c1521f93852261e4123826e5b25e11 diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index cf1f19cb99..f6bf9829d9 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1514,30 +1514,14 @@ void NodeDB::installDefaultModuleConfig() memcpy(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes, beaconOfferPsk, sizeof(beaconOfferPsk)); moduleConfig.mesh_beacon.broadcast_offer_channel.psk.size = sizeof(beaconOfferPsk); #endif -#ifdef USERPREFS_MESH_BEACON_ON_PRESET - moduleConfig.mesh_beacon.has_broadcast_on_preset = true; - moduleConfig.mesh_beacon.broadcast_on_preset = USERPREFS_MESH_BEACON_ON_PRESET; -#endif -#ifdef USERPREFS_MESH_BEACON_ON_REGION - moduleConfig.mesh_beacon.broadcast_on_region = USERPREFS_MESH_BEACON_ON_REGION; -#endif -#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NAME - moduleConfig.mesh_beacon.has_broadcast_on_channel = true; - strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, USERPREFS_MESH_BEACON_ON_CHANNEL_NAME, - sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); - moduleConfig.mesh_beacon.broadcast_on_channel.name[sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1] = '\0'; -#endif -#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_PSK - moduleConfig.mesh_beacon.has_broadcast_on_channel = true; - static const uint8_t beaconOnPsk[] = USERPREFS_MESH_BEACON_ON_CHANNEL_PSK; - static_assert(sizeof(beaconOnPsk) <= sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes), - "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK exceeds the 32-byte channel PSK buffer"); - memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconOnPsk, sizeof(beaconOnPsk)); - moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconOnPsk); -#endif -#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NUM - moduleConfig.mesh_beacon.has_broadcast_on_channel = true; - moduleConfig.mesh_beacon.broadcast_on_channel.channel_num = USERPREFS_MESH_BEACON_ON_CHANNEL_NUM; +// The USERPREFS_MESH_BEACON_ON_* keys were removed with the broadcast_on_* config fields. Fail the +// build rather than silently dropping a preconfigured beacon channel: define the equivalent +// USERPREFS_MESH_BEACON_TARGET_0_{PRESET,REGION,CHANNEL_INDEX} keys instead. CHANNEL_INDEX names a +// slot in the device's channel table, so the channel must also be provisioned on the node. +#if defined(USERPREFS_MESH_BEACON_ON_PRESET) || defined(USERPREFS_MESH_BEACON_ON_REGION) || \ + defined(USERPREFS_MESH_BEACON_ON_CHANNEL_NAME) || defined(USERPREFS_MESH_BEACON_ON_CHANNEL_PSK) || \ + defined(USERPREFS_MESH_BEACON_ON_CHANNEL_NUM) +#error "USERPREFS_MESH_BEACON_ON_* removed; use USERPREFS_MESH_BEACON_TARGET_0_* (channel must be in the channel table)" #endif #ifdef USERPREFS_MESH_BEACON_LEGACY_SPLIT BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_LEGACY_SPLIT, meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT); diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index fdffd0c260..f6757206c5 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -920,9 +920,9 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL if (!getAdminAuthorized()) { // Unauthenticated: emit an empty MeshBeaconConfig (zero-init from - // the top-of-loop memset). The embedded ChannelSettings - // (broadcast_offer_channel / broadcast_on_channel) carry PSKs that - // must not be visible to an unauth client. + // the top-of-loop memset). The embedded broadcast_offer_channel + // ChannelSettings carries a PSK that must not be visible to an + // unauth client. } else #endif { diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 51e43526e0..ea6286fac5 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -455,7 +455,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2740 +#define meshtastic_BackupPreferences_size 2656 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 231 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index c560d5447e..35a0d43d55 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -212,7 +212,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size #define meshtastic_LocalConfig_size 759 -#define meshtastic_LocalModuleConfig_size 1126 +#define meshtastic_LocalModuleConfig_size 1042 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index c59001f105..375ff4861b 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -341,6 +341,10 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_HELTEC_RCC6 = 143, /* Seeed Wio Tracker L1 Pro 1W, nRF52840 + SX1262 with 1 W external PA */ meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_PRO_1W = 144, + /* Meshnology W12 */ + meshtastic_HardwareModel_MESHNOLOGY_W12 = 145, + /* Seeed Studio MeshPager X2 */ + meshtastic_HardwareModel_MESHPAGER_X2 = 146, /* ------------------------------------------------------------------------------------------------------------------------------------------ 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. ------------------------------------------------------------------------------------------------------------------------------------------ */ @@ -738,7 +742,7 @@ typedef struct _meshtastic_Position { multiplied with DOP to calculate positional accuracy Default: "'bout three meters-ish" :) */ uint32_t gps_accuracy; - /* Ground speed in m/s and True North TRACK in 1/100 degrees + /* Ground speed in km/h and True North TRACK in 1/100 degrees Clarification of terms: - "track" is the direction of motion (measured in horizontal plane) - "heading" is where the fuselage points (measured in horizontal plane) @@ -1273,15 +1277,15 @@ typedef struct _meshtastic_LockdownStatus { /* Current lockdown state being reported. */ meshtastic_LockdownStatus_State state; /* For LOCKED: machine-readable reason. Known values: - "needs_auth" — storage already unlocked, client must auth - "token_missing" — no boot token on flash - "token_expired" — boot token wall-clock TTL elapsed - "token_boots_zero" — boot token boot-count TTL exhausted - "token_hmac_fail" — token tampered or wrong device - "token_dek_fail" — token DEK decrypt failed - "token_wrong_size" — token file corrupted - "token_bad_magic" — token file corrupted - "not_provisioned" — should generally use NEEDS_PROVISION state instead + "needs_auth" - storage already unlocked, client must auth + "token_missing" - no boot token on flash + "token_expired" - boot token wall-clock TTL elapsed + "token_boots_zero" - boot token boot-count TTL exhausted + "token_hmac_fail" - token tampered or wrong device + "token_dek_fail" - token DEK decrypt failed + "token_wrong_size" - token file corrupted + "token_bad_magic" - token file corrupted + "not_provisioned" - should generally use NEEDS_PROVISION state instead Other values may be added; clients should treat unknown values as "locked, ask for passphrase". */ char lock_reason[32]; diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index b04c358fc4..5d6585f036 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -457,8 +457,8 @@ typedef struct _meshtastic_ModuleConfig_StatusMessageConfig { char node_status[80]; } meshtastic_ModuleConfig_StatusMessageConfig; -/* One entry in the multi-target broadcast list. - The broadcaster transmits one beacon copy per entry, each on its own radio settings. */ +/* One entry in the broadcast destination list. + Each entry names one set of radio settings to send a beacon copy on. */ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget { /* Modem preset to use for this target. Falls back to the running config preset if unset. */ @@ -478,12 +478,6 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget { typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { /* Bitwise-OR of Flags values (listen / broadcast / legacy-split toggles). */ uint32_t flags; - /* Optional: node ID to send beacon messages AS. - When set, the `from` field of outgoing beacon packets is set to this node ID, - making beacons appear to originate from that node. - When unset (0), beacons are sent as the local node. - A remote admin can only set this field to their own node ID. */ - uint32_t broadcast_send_as_node; /* Message to include in each beacon broadcast. Max 100 bytes enforced by firmware. */ char broadcast_message[101]; /* Optional channel (name + PSK) to advertise in the MeshBeacon offer_channel field. */ @@ -494,30 +488,15 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { /* Optional modem preset to advertise in the MeshBeacon offer_preset field. */ bool has_broadcast_offer_preset; meshtastic_Config_LoRaConfig_ModemPreset broadcast_offer_preset; - /* Single-target TX channel: channel settings (name + PSK) to send beacons on. - If unset, beacons go out on the primary channel. Used only when broadcast_targets is empty. - NOTE: the single-target path embeds the ChannelSettings inline here, whereas a - broadcast_targets entry references a channel-table slot by channel_index instead — see - BroadcastTarget. The two paths are equal, first-class options; only this representation differs. */ - bool has_broadcast_on_channel; - meshtastic_ChannelSettings broadcast_on_channel; - /* Region to use when sending beacons on broadcast_on_preset. */ - meshtastic_Config_LoRaConfig_RegionCode broadcast_on_region; - /* Modem preset to use when sending beacons. - If different from current config, the radio is temporarily switched for TX. */ - bool has_broadcast_on_preset; - meshtastic_Config_LoRaConfig_ModemPreset broadcast_on_preset; /* How often to broadcast, in seconds. Min 3600 (1 h), default 3600. */ uint32_t broadcast_interval_secs; - /* Multi-target broadcast list. - When non-empty the broadcaster transmits one beacon copy per entry in sequence, - each temporarily switching the radio to that entry's preset/region/channel. - When empty, the broadcaster uses the scalar broadcast_on_preset / broadcast_on_region / - broadcast_on_channel fields instead (the single-target path). - Single- and multi-target are equal, first-class options — neither is preferred or - deprecated. They differ only in how the TX channel is named: broadcast_on_channel embeds a - ChannelSettings inline, while a target references an existing channel-table slot by - channel_index (see BroadcastTarget). */ + /* Broadcast destination list. + The broadcaster sends one beacon copy per distinct destination, in sequence, temporarily + switching the radio to that entry's preset/region/channel for each. + When empty, a single beacon is sent on the node's running preset and region over the + primary channel. + Entries that resolve to the same effective preset, region and channel are deduplicated, so + a duplicate entry does not produce a second transmission. */ pb_size_t broadcast_targets_count; meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget broadcast_targets[4]; } meshtastic_ModuleConfig_MeshBeaconConfig; @@ -654,8 +633,6 @@ extern "C" { #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode @@ -684,7 +661,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_default {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_default {""} -#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, 0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, {meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default}} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, {meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default}} #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_default {false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, 0} #define meshtastic_ModuleConfig_TAKConfig_init_default {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_default {0, "", _meshtastic_RemoteHardwarePinType_MIN} @@ -705,7 +682,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_zero {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_zero {""} -#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, 0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, {meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero}} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, {meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero, meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero}} #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_init_zero {false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, 0} #define meshtastic_ModuleConfig_TAKConfig_init_zero {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_zero {0, "", _meshtastic_RemoteHardwarePinType_MIN} @@ -821,14 +798,10 @@ extern "C" { #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_region_tag 2 #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_channel_index_tag 4 #define meshtastic_ModuleConfig_MeshBeaconConfig_flags_tag 1 -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_send_as_node_tag 3 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_message_tag 4 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_channel_tag 5 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_region_tag 6 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_preset_tag 7 -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_channel_tag 8 -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_region_tag 9 -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_preset_tag 10 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_interval_secs_tag 11 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_targets_tag 13 #define meshtastic_ModuleConfig_TAKConfig_team_tag 1 @@ -1073,20 +1046,15 @@ X(a, STATIC, SINGULAR, STRING, node_status, 1) #define meshtastic_ModuleConfig_MeshBeaconConfig_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, flags, 1) \ -X(a, STATIC, SINGULAR, UINT32, broadcast_send_as_node, 3) \ X(a, STATIC, SINGULAR, STRING, broadcast_message, 4) \ X(a, STATIC, OPTIONAL, MESSAGE, broadcast_offer_channel, 5) \ X(a, STATIC, SINGULAR, UENUM, broadcast_offer_region, 6) \ X(a, STATIC, OPTIONAL, UENUM, broadcast_offer_preset, 7) \ -X(a, STATIC, OPTIONAL, MESSAGE, broadcast_on_channel, 8) \ -X(a, STATIC, SINGULAR, UENUM, broadcast_on_region, 9) \ -X(a, STATIC, OPTIONAL, UENUM, broadcast_on_preset, 10) \ X(a, STATIC, SINGULAR, UINT32, broadcast_interval_secs, 11) \ X(a, STATIC, REPEATED, MESSAGE, broadcast_targets, 13) #define meshtastic_ModuleConfig_MeshBeaconConfig_CALLBACK NULL #define meshtastic_ModuleConfig_MeshBeaconConfig_DEFAULT NULL #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_channel_MSGTYPE meshtastic_ChannelSettings -#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_channel_MSGTYPE meshtastic_ChannelSettings #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_targets_MSGTYPE meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_FIELDLIST(X, a) \ @@ -1164,7 +1132,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_MQTTConfig_size 224 #define meshtastic_ModuleConfig_MapReportSettings_size 14 #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_size 10 -#define meshtastic_ModuleConfig_MeshBeaconConfig_size 324 +#define meshtastic_ModuleConfig_MeshBeaconConfig_size 240 #define meshtastic_ModuleConfig_NeighborInfoConfig_size 10 #define meshtastic_ModuleConfig_PaxcounterConfig_size 30 #define meshtastic_ModuleConfig_RangeTestConfig_size 12 @@ -1175,7 +1143,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_TAKConfig_size 4 #define meshtastic_ModuleConfig_TelemetryConfig_size 50 #define meshtastic_ModuleConfig_TrafficManagementConfig_size 30 -#define meshtastic_ModuleConfig_size 328 +#define meshtastic_ModuleConfig_size 244 #define meshtastic_RemoteHardwarePin_size 21 #ifdef __cplusplus diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 0af7ed9e05..07d55b7483 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -343,17 +343,6 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta case meshtastic_AdminMessage_set_module_config_tag: LOG_DEBUG("Client set module config"); -#if !MESHTASTIC_EXCLUDE_BEACON - // broadcast_send_as_node: remote admins may only set this to their own node ID. - if (mp.from != 0 && r->set_module_config.which_payload_variant == meshtastic_ModuleConfig_mesh_beacon_tag) { - auto &b = r->set_module_config.payload_variant.mesh_beacon; - if (b.broadcast_send_as_node != 0 && b.broadcast_send_as_node != mp.from) { - LOG_WARN("Beacon: rejecting broadcast_send_as_node 0x%08x from node 0x%08x (must match sender)", - b.broadcast_send_as_node, mp.from); - b.broadcast_send_as_node = moduleConfig.mesh_beacon.broadcast_send_as_node; - } - } -#endif if (!handleSetModuleConfig(r->set_module_config)) { myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); } @@ -1371,19 +1360,6 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) if (beaconCfg.broadcast_interval_secs != 0 && beaconCfg.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs) beaconCfg.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs; - // Validate broadcast_on_preset against broadcast_on_region (or current region if unset). - if (beaconCfg.has_broadcast_on_preset) { - meshtastic_Config_LoRaConfig probe = config.lora; - probe.use_preset = true; - probe.modem_preset = beaconCfg.broadcast_on_preset; - if (beaconCfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) - probe.region = beaconCfg.broadcast_on_region; - if (!RadioInterface::validateConfigLora(probe)) { - LOG_WARN("Beacon: broadcast_on_preset %d invalid for region, clearing", beaconCfg.broadcast_on_preset); - beaconCfg.has_broadcast_on_preset = false; - beaconCfg.has_broadcast_on_channel = false; - } - } // Validate broadcast_offer_preset against broadcast_offer_region (or current region if unset). if (beaconCfg.has_broadcast_offer_preset) { meshtastic_Config_LoRaConfig probe = config.lora; @@ -1404,8 +1380,8 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) beaconCfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; } } - // Validate each multi-target entry the same way as the single-target broadcast_on_* fields, - // so a bad preset/region is cleared on write rather than relying on the runtime TX drop. + // Validate each broadcast target so a bad preset/region is cleared on write rather than + // relying on the runtime TX drop. for (pb_size_t i = 0; i < beaconCfg.broadcast_targets_count; i++) { auto &t = beaconCfg.broadcast_targets[i]; // Region must be a known region code (UNSET = use running config at TX time). diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 1976f054d7..9842de2549 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -417,22 +417,6 @@ void MeshBeaconBroadcastModule::sendBeacon() const auto stampPacket = [&](meshtastic_MeshPacket *p) { p->to = NODENUM_BROADCAST; p->from = nodeDB->getNodeNum(); - // broadcast_send_as_node: commented out pending further review. - // Spoof notes preserved for when this is re-enabled: - // broadcast_send_as_node overrides the source NodeNum. NOTE: this is a *node-ID* spoof - // only - it rewrites the 'from' field but does NOT forge any signature. Once 'from' is - // not us, the packet is no longer isFromUs(), so Router::perhapsEncode() skips XEdDSA - // signing and receivers get an unsigned packet attributed to another node. - // When broadcast_send_as_node == 0 the beacon is genuinely from us and Router::perhapsEncode() - // signs it under the same XEdDSA broadcast policy as normal channel messages. - // When broadcast_send_as_node rewrites p->from, perhapsEncode() sees isFromUs()=false and - // skips setting has_bitfield - must be set explicitly so receivers can classify hop_start - // correctly and so ok_to_mqtt is honoured on the spoofed packet. - // if (bcfg.broadcast_send_as_node != 0) { - // p->from = bcfg.broadcast_send_as_node; - // p->decoded.has_bitfield = true; - // p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT); - // } p->hop_limit = 0; // all beacon packets are zero hopped to limit spamming. p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; p->want_ack = false; @@ -476,10 +460,9 @@ void MeshBeaconBroadcastModule::sendBeacon() // ── Per-target loop ────────────────────────────────────────────────────── // - // If broadcast_targets is populated, iterate over those. Otherwise use the single-target - // broadcast_on_preset / broadcast_on_region / broadcast_on_channel fields. The two paths are - // equal options; they differ only in how the TX channel is named (single-target embeds a - // ChannelSettings inline; a target references a channel-table slot by channel_index). + // Every destination comes from broadcast_targets. An entry names its TX channel by + // channel_index, a slot in the device's channel table, so the channel must already be + // configured on the node - its key is needed to encrypt. struct EffTarget { meshtastic_Config_LoRaConfig_ModemPreset preset; uint16_t slot; @@ -488,8 +471,9 @@ void MeshBeaconBroadcastModule::sendBeacon() meshtastic_ChannelSettings channel; }; - const bool useTargetList = bcfg.broadcast_targets_count > 0; - const int targetCount = useTargetList ? (int)bcfg.broadcast_targets_count : 1; + // An empty list still beacons once, on the node's running preset and region over the primary + // channel. Each entry below overrides only what it sets. + const int targetCount = bcfg.broadcast_targets_count > 0 ? (int)bcfg.broadcast_targets_count : 1; // Dedup state: the beacon payload is identical across targets, so two targets that resolve to // the same effective radio config (preset + resolved region + channel) would just re-broadcast @@ -510,17 +494,19 @@ void MeshBeaconBroadcastModule::sendBeacon() }; for (int ti = 0; ti < targetCount; ti++) { + // Defaults: running radio config, primary channel. A target entry overrides from here. EffTarget tgt = {}; - if (useTargetList) { + tgt.preset = config.lora.modem_preset; + tgt.slot = config.lora.channel_num; + if (ti < (int)bcfg.broadcast_targets_count) { const auto &bt = bcfg.broadcast_targets[ti]; - tgt.preset = bt.has_preset ? bt.preset : config.lora.modem_preset; + if (bt.has_preset) + tgt.preset = bt.preset; tgt.region = bt.region; // Resolve the channel from the device's channel table by index. A slot is only usable // if it is actually configured (has a name or PSK - its key is needed to encrypt). An // out-of-range index, or a blank slot, falls back to the default channel for the target // preset (see beaconChannelSettings), exactly as an unset channel_index would. - tgt.has_channel = false; - tgt.slot = config.lora.channel_num; if (bt.has_channel_index) { if (bt.channel_index >= (uint32_t)channels.getNumChannels()) { LOG_WARN("Beacon: target %d channel_index %u out of range, use preset default", ti, bt.channel_index); @@ -535,13 +521,6 @@ void MeshBeaconBroadcastModule::sendBeacon() } } } - } else { - tgt.preset = bcfg.has_broadcast_on_preset ? bcfg.broadcast_on_preset : config.lora.modem_preset; - tgt.region = bcfg.broadcast_on_region; - tgt.has_channel = bcfg.has_broadcast_on_channel; - if (tgt.has_channel) - tgt.channel = bcfg.broadcast_on_channel; - tgt.slot = tgt.has_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num; } // Skip a target whose effective radio config duplicates one already sent this cycle. diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index bed9e882c6..9ae6d4ddda 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -40,7 +40,7 @@ class MeshBeaconModule /** * Reconfigure the radio for beacon TX, or restore to original config if p is NULL. * Returns true if the radio was reconfigured (caller must re-run transmit delay for CCA). - * Driven by broadcast_on_preset / broadcast_on_channel from MeshBeaconConfig. + * Driven by the broadcast_targets entry associated with the packet. */ static bool reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p); @@ -77,7 +77,7 @@ class MeshBeaconModule protected: /** * Build the ChannelSettings the beacon transmits on: the base (primary) channel overlaid with - * any broadcast_on_channel overrides, defaulting an empty name to the target preset's display + * the target's channel-table slot, defaulting an empty name to the target preset's display * name. Shared by the encrypt-time channel swap and the radio-thread RF swap so the channel * key + hash are identical at both points. */ diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 4c2ddad954..a7b5dbe202 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -205,13 +205,14 @@ static void test_adminValidation_turboPresetOnEU868_isCleared(void) meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); - TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.has_broadcast_on_preset, "SHORT_TURBO must be cleared for EU_868"); + TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset, "SHORT_TURBO must be cleared for EU_868"); } /** @@ -223,12 +224,13 @@ static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); } /** @@ -242,13 +244,14 @@ static void test_adminValidation_turboPresetOnUS_isAccepted(void) initRegion(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_targets[0].preset); } /** @@ -260,13 +263,14 @@ static void test_adminValidation_mediumTurboPresetOnEU868_isCleared(void) resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); - TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); } /** @@ -280,13 +284,15 @@ static void test_adminValidation_mediumTurboPresetOnUS_isAccepted(void) initRegion(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - bcfg.has_broadcast_on_preset = true; - bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, + moduleConfig.mesh_beacon.broadcast_targets[0].preset); } /** @@ -324,8 +330,7 @@ static void test_adminValidation_validOfferRegion_isPreserved(void) /** * Verify an out-of-range region in a multi-target entry is sanitised to UNSET on write. - * Important because broadcast_targets entries are validated independently of the single-target - * broadcast_on_* fields, and an invalid enum must never reach the radio-switch path. + * Important because an invalid enum must never reach the radio-switch path. */ static void test_adminValidation_targetUnknownRegion_isCleared(void) { @@ -342,8 +347,8 @@ static void test_adminValidation_targetUnknownRegion_isCleared(void) } /** - * Verify a preset that is illegal for a multi-target entry's region clears that entry's preset - * (and its channel), matching the single-target broadcast_on_preset rule. + * Verify a preset that is illegal for a broadcast target's region clears that entry's preset + * and its channel. */ static void test_adminValidation_targetInvalidPresetForRegion_isCleared(void) { @@ -647,7 +652,7 @@ static void test_broadcaster_rebuildCache_idempotent(void) // =========================================================================== /** - * Verify the 'from' field defaults to the local node number when broadcast_send_as_node is 0. + * Verify the 'from' field is the local node number. * Important for correct source attribution in peer node tables that receive the beacon. */ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) @@ -655,7 +660,6 @@ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; - moduleConfig.mesh_beacon.broadcast_send_as_node = 0; strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-local", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); MeshBeaconBroadcastModuleTestShim bcast; @@ -665,27 +669,6 @@ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); } -/** - * Verify broadcast_send_as_node is currently disabled: 'from' is always the local node - * even when broadcast_send_as_node is set to a remote node number. - * (broadcast_send_as_node is commented out as "not suitable right now".) - */ -static void test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet(void) -{ - resetConfig(); - moduleConfig.has_mesh_beacon = true; - moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; - moduleConfig.mesh_beacon.broadcast_send_as_node = kRemoteNode; - strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-remote", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); - - MeshBeaconBroadcastModuleTestShim bcast; - bcast.sendBeacon(); - - TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); - // broadcast_send_as_node is disabled; from is always the local node - TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); -} - /** * Verify the 'to' field is always NODENUM_BROADCAST regardless of other settings. * Important because beacons are mesh-wide announcements and must never be addressed to a single peer. @@ -723,8 +706,8 @@ static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) } /** - * Verify TEXT_MESSAGE_APP portnum is used when no offer content is present, even if - * broadcast_on_preset is set (that field governs which radio config to use for TX, not portnum). + * Verify TEXT_MESSAGE_APP portnum is used when no offer content is present, even if a + * broadcast target preset is set (that governs which radio config to use for TX, not portnum). * Important so standard clients display plain-text beacons without needing a MESH_BEACON_APP decoder. */ static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) @@ -733,9 +716,10 @@ static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) moduleConfig.has_mesh_beacon = true; const char *msg = "plain-text-beacon"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); - // broadcast_on_preset set, but no offer - should still be TEXT_MESSAGE_APP - moduleConfig.mesh_beacon.has_broadcast_on_preset = true; - moduleConfig.mesh_beacon.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + // Target preset set, but no offer - should still be TEXT_MESSAGE_APP + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); @@ -1155,52 +1139,11 @@ static void test_broadcaster_legacySplit_secondPacketIsTextMessage(void) } // =========================================================================== -// Group 7: Beacon-channel PSK swap (broadcast_on_channel override) +// Group 7: Beacon-channel PSK swap (target channel-table slot) // =========================================================================== /** - * When broadcast_on_channel overrides the primary channel's name/PSK, the packet must be encrypted - * on the BEACON channel, not the primary. perhapsEncode keys off the primary slot, so sendBeaconPacket - * temporarily installs the beacon channel there for the send and restores it after. Verify both: the - * primary slot IS the beacon channel during send(), and it is restored afterwards (no leak). - */ -static void test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores(void) -{ - resetConfig(); - static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; - installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); - - moduleConfig.has_mesh_beacon = true; - moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; // gives the beacon radio content to send - moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; - moduleConfig.mesh_beacon.has_broadcast_on_channel = true; - strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, "BeaconCh", - sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); - static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, - 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; - moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconPsk); - memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconPsk, sizeof(beaconPsk)); - - MeshBeaconBroadcastModuleTestShim bcast; - bcast.sendBeacon(); - - // During send(), the primary slot must hold the BEACON channel (so encryption uses its PSK). - TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); - const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0]; - TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconCh", atSend.name, "primary must be the beacon channel during send"); - TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size); - TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the beacon channel PSK"); - - // After send(), the primary channel must be restored to the original (no leak into normal traffic). - const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings; - TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send"); - TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size); - TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]); -} - -/** - * Without a broadcast_on_channel override, the beacon must transmit on the primary channel unchanged + * With no target channel_index, the beacon must transmit on the primary channel unchanged * (no swap). Guards against the swap firing - and churning the channel table - when it isn't needed. */ static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) @@ -1213,7 +1156,7 @@ static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; - // No broadcast_on_channel override. + // No target channel_index. MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); @@ -1249,10 +1192,15 @@ static void test_broadcaster_targetChannelIndex_usesTableSlot(void) bcast.sendBeacon(); TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); - TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", mockRouter->primaryAtSend[0].name, - "beacon must be encrypted on the referenced slot's channel"); - // Primary slot restored to home after send (no leak). - TEST_ASSERT_EQUAL_STRING("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name); + const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0]; + TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", atSend.name, "beacon must be encrypted on the referenced slot's channel"); + TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the slot's PSK"); + // Primary slot restored to home after send (no leak into normal traffic). + const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings; + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send"); + TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size); + TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]); } /** @@ -1753,7 +1701,6 @@ BEACON_TEST_ENTRY void setup() printf("\n=== Broadcaster sendBeacon ===\n"); RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset); - RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast); RUN_TEST(test_broadcaster_sendBeacon_usesBeaconPortnum); RUN_TEST(test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum); @@ -1783,7 +1730,6 @@ BEACON_TEST_ENTRY void setup() printf("\n=== Beacon-channel PSK swap ===\n"); - RUN_TEST(test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores); RUN_TEST(test_broadcaster_noChannelOverride_doesNotSwapPrimary); RUN_TEST(test_broadcaster_targetChannelIndex_usesTableSlot); RUN_TEST(test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset); diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 8daddd8f21..f50bbaf0f8 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -96,15 +96,11 @@ // "USERPREFS_MESH_BEACON_OFFER_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_N_868", // Region advertised in the beacon payload // "USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME": "'MyChannel'", // Channel name advertised in the beacon payload // "USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK": "{ 0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1 }", // PSK for the offered channel (32-byte AES-256) - // "USERPREFS_MESH_BEACON_ON_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST", // Modem preset to use when transmitting beacons (radio temporarily switched for TX) - // "USERPREFS_MESH_BEACON_ON_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", // Region to use when transmitting beacons - // "USERPREFS_MESH_BEACON_ON_CHANNEL_NAME": "'LongFast'", // Channel name to use on the TX radio config - uses default if unset. - // "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK": "{ 0x01 }", // PSK for the TX channel (0x01 = Meshtastic default PSK) - // "USERPREFS_MESH_BEACON_ON_CHANNEL_NUM": "0", // LoRa channel/frequency slot to use on the TX radio config - zero is default, 20 is standard for US LongFast, etc. // "USERPREFS_MESH_BEACON_LEGACY_SPLIT": "true", // When both text and offer are present, split into a separate MESH_BEACON_APP (offer) and TEXT_MESSAGE_APP (text) for legacy client compatibility - // Multi-target broadcast: when any TARGET__* key is set, broadcast_targets overrides the - // single-target broadcast_on_* fields above. Each target transmits its own copy of the beacon. - // Up to 4 targets (0-3). Only TARGET_0 is used here; uncomment TARGET_1 to add a second preset. + // Broadcast targets: every beacon destination is a TARGET__* entry. With none set, the node + // beacons once on its running preset and region over the primary channel. Up to 4 targets (0-3); + // only TARGET_0 is used here, uncomment TARGET_1 to add a second preset. Targets that resolve to + // the same preset, region and channel are deduplicated. // CHANNEL_INDEX references a slot in the device's channel table (0..MAX_NUM_CHANNELS-1); that // channel must already be configured on the node (its key is needed to encrypt the beacon). // "USERPREFS_MESH_BEACON_TARGET_0_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST", From db84bdf3b446b8230a45d9acd2cc12b36a064d5f Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 28 Aug 2026 19:58:23 +0000 Subject: [PATCH 033/143] Reduce ExternalNotificationModule flash usage (RTTTL + InputBroker) (#10989) * Generalize RTTTL exclusion into MESHTASTIC_EXCLUDE_RTTTL ExternalNotificationModule already stubbed out RTTTL playback for STM32WL/portduino/ESP32C6 via a raw ARCH/CONFIG_IDF check, but the ringtone config plumbing around it (protobuf message, encode/decode tables, /prefs/ringtone.proto persistence, admin get/set-ringtone handlers) still compiled in even though it can never do anything on those platforms. Introduce MESHTASTIC_EXCLUDE_RTTTL and gate the dead ringtone plumbing behind it too. The flag is set in each architecture's *_base build_flags (stm32_base, esp32c6_base, portduino_base) rather than in the module itself - this matches how every other MESHTASTIC_EXCLUDE_* flag in the tree is set (e.g. stm32_base already sets ten of them directly, and esp32c6_base already excludes PAXCOUNTER for an analogous platform-can't-support-this reason), rather than introducing a new per-architecture C header pattern. Behavior is unchanged on all three platforms; overridable via -D like every other MESHTASTIC_EXCLUDE_* flag. Also guard the two HAS_I2S ringtone-playback call sites with !MESHTASTIC_EXCLUDE_RTTTL alongside HAS_I2S, since rtttlConfig itself is now only declared when RTTTL is not excluded. No current platform defines both HAS_I2S and MESHTASTIC_EXCLUDE_RTTTL simultaneously, so this has no effect today, but prevents a future HAS_I2S platform that also excludes RTTTL from failing to compile. Saves 368 bytes flash / 236 bytes RAM on wio-e5 with no loss to the GPIO on/off notification toggle itself, which does not depend on RTTTL. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * Skip unused InputBroker observer in ExternalNotificationModule The inputObserver CallbackObserver member was declared unconditionally, even though its only use site was already gated behind MESHTASTIC_EXCLUDE_INPUTBROKER (set for all of stm32 in stm32.ini). Because it's a non-trivial member, the compiler still generated its constructor/destructor as part of ExternalNotificationModule's own lifecycle even when InputBroker is compiled out entirely. Gate the member and its only consumer, handleInputEvent(), behind the same flag as their use site, and match the codebase's dominant !MESHTASTIC_EXCLUDE_X style (used ~330 times) rather than !defined(MESHTASTIC_EXCLUDE_X) (used ~20 times) while touching this flag's other call site. Saves an additional 288 bytes flash on wio-e5, no RAM change, no functional impact since InputBroker was already unused on this platform. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * fix(native-wasm): exclude RTTTL to unbreak build The browser node builds its own build_flags from arduino_base rather than inheriting portduino_base, so it did not pick up the MESHTASTIC_EXCLUDE_RTTTL flag added to portduino_base. With the inline ARCH_PORTDUINO stub in ExternalNotificationModule.h now replaced by that flag, native-wasm tried to include the unavailable NonBlockingRtttl.h. Set MESHTASTIC_EXCLUDE_RTTTL=1 directly in the native-wasm env alongside its other exclusion flags. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong --------- Signed-off-by: Andrew Yong Co-authored-by: Ben Meadors --- src/modules/ExternalNotificationModule.cpp | 25 ++++++++++++++++------ src/modules/ExternalNotificationModule.h | 10 +++++++-- variants/native/portduino.ini | 1 + variants/native/portduino/platformio.ini | 2 ++ variants/stm32/stm32.ini | 19 ++++++++-------- 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 425d0b33ee..0c97bf3575 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -62,7 +62,10 @@ bool ascending = true; #define ASCII_BELL 0x07 +#if !MESHTASTIC_EXCLUDE_RTTTL meshtastic_RTTTLConfig rtttlConfig; +static const char *rtttlConfigFile = "/prefs/ringtone.proto"; +#endif ExternalNotificationModule *externalNotificationModule; @@ -70,8 +73,6 @@ bool externalCurrentState[3] = {}; uint32_t externalTurnedOn[3] = {}; -static const char *rtttlConfigFile = "/prefs/ringtone.proto"; - int32_t ExternalNotificationModule::runOnce() { if (!moduleConfig.external_notification.enabled) { @@ -147,7 +148,7 @@ int32_t ExternalNotificationModule::runOnce() } // Play RTTTL over i2s audio interface if enabled as buzzer -#ifdef HAS_I2S +#if defined(HAS_I2S) && !MESHTASTIC_EXCLUDE_RTTTL if (moduleConfig.external_notification.use_i2s_as_buzzer) { if (audioThread->isPlaying()) { // Continue playing @@ -158,7 +159,7 @@ int32_t ExternalNotificationModule::runOnce() delay = EXT_NOTIFICATION_FAST_THREAD_MS; } #endif -#if defined(HAS_I2S_SPEAKER_NRF52) +#if defined(HAS_I2S_SPEAKER_NRF52) && !MESHTASTIC_EXCLUDE_RTTTL // Play RTTTL over the I2S speaker (no piezo on this board). if (canBuzz() && buzzerShouldAlert) { if (nrf52RtttlPlayer.isPlaying()) { @@ -169,6 +170,7 @@ int32_t ExternalNotificationModule::runOnce() delay = EXT_NOTIFICATION_FAST_THREAD_MS; } #endif +#if !MESHTASTIC_EXCLUDE_RTTTL // now let the PWM buzzer play if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz() && buzzerShouldAlert) { if (rtttl::isPlaying()) { @@ -180,6 +182,7 @@ int32_t ExternalNotificationModule::runOnce() // we need fast updates to play the RTTTL delay = EXT_NOTIFICATION_FAST_THREAD_MS; } +#endif return delay; } @@ -348,16 +351,18 @@ ExternalNotificationModule::ExternalNotificationModule() // moduleConfig.external_notification.alert_message_buzzer = true; if (moduleConfig.external_notification.enabled) { -#if !defined(MESHTASTIC_EXCLUDE_INPUTBROKER) +#if !MESHTASTIC_EXCLUDE_INPUTBROKER if (inputBroker) // put our callback in the inputObserver list inputObserver.observe(inputBroker); #endif +#if !MESHTASTIC_EXCLUDE_RTTTL if (nodeDB->loadProto(rtttlConfigFile, meshtastic_RTTTLConfig_size, sizeof(meshtastic_RTTTLConfig), &meshtastic_RTTTLConfig_msg, &rtttlConfig) != LoadFileResult::LOAD_SUCCESS) { memset(rtttlConfig.ringtone, 0, sizeof(rtttlConfig.ringtone)); // The default ringtone is always loaded from userPrefs.jsonc strncpy(rtttlConfig.ringtone, USERPREFS_RINGTONE_RTTTL, sizeof(rtttlConfig.ringtone)); } +#endif LOG_INFO("Init External Notification Module"); @@ -486,11 +491,13 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP void ExternalNotificationModule::triggerBuzzerOutput() { if (moduleConfig.external_notification.use_i2s_as_buzzer) { -#ifdef HAS_I2S +#if defined(HAS_I2S) && !MESHTASTIC_EXCLUDE_RTTTL audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); #endif } else if (moduleConfig.external_notification.use_pwm) { +#if !MESHTASTIC_EXCLUDE_RTTTL rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); +#endif } else { setExternalState(2, true); } @@ -573,6 +580,7 @@ AdminMessageHandleResult ExternalNotificationModule::handleAdminMessageForModule AdminMessageHandleResult result; switch (request->which_payload_variant) { +#if !MESHTASTIC_EXCLUDE_RTTTL case meshtastic_AdminMessage_get_ringtone_request_tag: LOG_INFO("Client getting ringtone"); this->handleGetRingtone(mp, response); @@ -584,6 +592,7 @@ AdminMessageHandleResult ExternalNotificationModule::handleAdminMessageForModule this->handleSetRingtone(request->set_canned_message_module_messages); result = AdminMessageHandleResult::HANDLED; break; +#endif default: result = AdminMessageHandleResult::NOT_HANDLED; @@ -592,6 +601,7 @@ AdminMessageHandleResult ExternalNotificationModule::handleAdminMessageForModule return result; } +#if !MESHTASTIC_EXCLUDE_RTTTL void ExternalNotificationModule::handleGetRingtone(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response) { LOG_INFO("*** handleGetRingtone"); @@ -615,7 +625,9 @@ void ExternalNotificationModule::handleSetRingtone(const char *from_msg) nodeDB->saveProto(rtttlConfigFile, meshtastic_RTTTLConfig_size, &meshtastic_RTTTLConfig_msg, &rtttlConfig); } } +#endif +#if !MESHTASTIC_EXCLUDE_INPUTBROKER int ExternalNotificationModule::handleInputEvent(const InputEvent *event) { if (nagCycleCutoff != UINT32_MAX) { @@ -624,3 +636,4 @@ int ExternalNotificationModule::handleInputEvent(const InputEvent *event) } return 0; } +#endif diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index 75f831d04c..969638583c 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -23,10 +23,10 @@ extern AmbientLightingThread *ambientLightingThread; #endif #endif -#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) +#if !MESHTASTIC_EXCLUDE_RTTTL #include #else -// Noop class for portduino. +// Noop class for portduino/STM32WL/ESP32C6 - none can drive PWM RTTTL playback. class rtttl { public: @@ -47,8 +47,10 @@ class rtttl */ class ExternalNotificationModule : public SinglePortModule, private concurrency::OSThread { +#if !MESHTASTIC_EXCLUDE_INPUTBROKER CallbackObserver inputObserver = CallbackObserver(this, &ExternalNotificationModule::handleInputEvent); +#endif uint32_t output = 0; #ifdef NEOPIXEL_STATUS_NOTIFICATION_PIN @@ -58,7 +60,9 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: public: ExternalNotificationModule(); +#if !MESHTASTIC_EXCLUDE_INPUTBROKER int handleInputEvent(const InputEvent *arg); +#endif uint32_t nagCycleCutoff = 1; @@ -76,8 +80,10 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: // Fire the configured message outputs for a non-message event such as a geofence crossing. void startNotification(); +#if !MESHTASTIC_EXCLUDE_RTTTL void handleGetRingtone(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response); void handleSetRingtone(const char *from_msg); +#endif protected: /** Called to handle a particular incoming message diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 33e0a8b8bb..5068973e24 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -49,6 +49,7 @@ build_flags_common = -fPIC -Isrc/platform/portduino -DRADIOLIB_EEPROM_UNSUPPORTED + -DMESHTASTIC_EXCLUDE_RTTTL ; No PWM/RTTTL ringtone playback support on this platform. -lpthread -lyaml-cpp -ljsoncpp diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 1eaff83434..0a9db5d99a 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -444,6 +444,8 @@ build_flags = ${arduino_base.build_flags} -DMESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION=1 -DMESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 -DMESHTASTIC_EXCLUDE_STOREFORWARD=1 -DMESHTASTIC_EXCLUDE_SERIAL=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 -DMESHTASTIC_EXCLUDE_WAYPOINT=1 + ; No PWM/RTTTL ringtone playback support in the browser node. + -DMESHTASTIC_EXCLUDE_RTTTL=1 ; The firmware-specific emcc *link* settings (exported fns, runtime methods, the ; WebUSB Asyncify import seam, the ES-module factory name) can't ride in diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 6f7477882f..fd6ca8babe 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -16,19 +16,20 @@ build_flags = ${arduino_base.build_flags} -flto -Isrc/platform/stm32wl -g - -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_ATAK=1 ; ATAK is quite big, disable it for big flash savings. - -DMESHTASTIC_EXCLUDE_INPUTBROKER=1 - -DMESHTASTIC_EXCLUDE_POWERMON=1 - -DMESHTASTIC_EXCLUDE_SCREEN=1 - -DMESHTASTIC_EXCLUDE_MQTT=1 + -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_BLUETOOTH=1 - -DMESHTASTIC_EXCLUDE_WIFI=1 - -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. - -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; Individual STM32WL variants opt in after size validation. + -DMESHTASTIC_EXCLUDE_INPUTBROKER=1 + -DMESHTASTIC_EXCLUDE_MQTT=1 -DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1 - -DMESHTASTIC_EXCLUDE_WAYPOINT=1 + -DMESHTASTIC_EXCLUDE_POWERMON=1 -DMESHTASTIC_EXCLUDE_POWER_TELEMETRY=1 + -DMESHTASTIC_EXCLUDE_RTTTL=1 ; No PWM/RTTTL ringtone playback support on this platform. + -DMESHTASTIC_EXCLUDE_SCREEN=1 + -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. + -DMESHTASTIC_EXCLUDE_WAYPOINT=1 + -DMESHTASTIC_EXCLUDE_WIFI=1 + -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; Individual STM32WL variants opt in after size validation. -DSERIAL_RX_BUFFER_SIZE=256 ; For GPS - the default of 64 is too small. -DHAS_SCREEN=0 ; Always disable screen for STM32, it is not supported. ;-DPIO_FRAMEWORK_ARDUINO_NANOLIB_FLOAT_PRINTF ; Enable this if enabling debugg logging. It is REQUIRED for at least traceroute debug prints - without it the length returned by printf ends up uninitialized. From f8a8d1247786fe19f19cd07dce75e702d9d463f7 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 28 Aug 2026 22:32:01 +0000 Subject: [PATCH 034/143] fix(ble): stop BLE from coming back up during the pre-reboot window (#11650) * fix(ble): stop BLE from coming back up during the pre-reboot window Saving a reboot-requiring config over BLE (e.g. screen timeout) made the node disconnect, re-advertise, let the phone reconnect, and then drop it again at the reset. Two causes: nRF52: admin messages from the phone run synchronously on Bluefruit's BLE event task, so the BLE_GAP_EVT_DISCONNECTED caused by shutdown() is only processed after we return - and that handler restarts advertising because restartOnDisconnect(true) was never cleared. Stopping advertising first is a no-op while a connection is live (the SoftDevice isn't advertising), so the deferred event brought it straight back. Clear the restart flag and stop advertising before dropping the link, mirroring nRF54L15's ble_enabled gate. This also closes a main-thread race on the shutdown path where Advertising.stop() could land between connection teardown and Bluefruit's auto-restart within the same event dispatch. PowerFSM (all platforms): darkEnter/onEnter/powerEnter/powerExit/serialExit unconditionally re-enable BLE, so any state transition inside the reboot window - a button press while the banner is up, the screen timeout, USB plug/unplug - turned BLE back on after AdminModule had deliberately torn it down. Route them through a helper that skips the re-enable while rebootAtMsec/shutdownAtMsec is armed; every writer of those deadlines is an imminent restart. * style: trim rationale comments to house 1-2 line limit The full mechanism is in the original commit message and PR description. --- src/PowerFSM.cpp | 21 ++++++++++++++++----- src/platform/nrf52/NRF52Bluetooth.cpp | 6 +++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 5367293f22..400aabc676 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -195,6 +195,17 @@ static void lsExit() t5BacklightWakeFromSleep(); } +/// Skip the BLE re-enable while a reboot/shutdown is armed: AdminModule tears BLE down before +/// scheduling the restart, and a state transition in that window would otherwise bring it back up. +static void setBluetoothEnableUnlessRestarting() +{ + if (rebootAtMsec || shutdownAtMsec) { + LOG_POWERFSM("Skip BLE enable, restart pending"); + return; + } + setBluetoothEnable(true); +} + static void nbEnter() { LOG_POWERFSM("State: nbEnter"); @@ -211,7 +222,7 @@ static void nbEnter() static void darkEnter() { LOG_POWERFSM("State: darkEnter"); - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); if (screen) screen->setOn(false); // Screen timeout enters DARK; ensure backlight also turns off. @@ -235,7 +246,7 @@ static void serialExit() { LOG_POWERFSM("State: serialExit"); // Turn bluetooth back on when we leave serial stream API - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); } static void powerEnter() @@ -248,7 +259,7 @@ static void powerEnter() } else { if (screen) screen->setOn(true); - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); // within enter() the function getState() returns the state we came from } } @@ -266,7 +277,7 @@ static void powerIdle() static void powerExit() { LOG_POWERFSM("State: powerExit"); - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); } static void onEnter() @@ -274,7 +285,7 @@ static void onEnter() LOG_POWERFSM("State: onEnter"); if (screen) screen->setOn(true); - setBluetoothEnable(true); + setBluetoothEnableUnlessRestarting(); } static void onIdle() diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 85a29e05a9..608fb1b7bb 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -245,8 +245,12 @@ void NRF52Bluetooth::shutdown() // Shutdown bluetooth for minimum power draw LOG_INFO("Disable NRF52 bluetooth"); Bluefruit.Security.setPairPasskeyCallback(NRF52Bluetooth::onUnwantedPairing); // Actively refuse (during factory reset) - disconnect(); + + // Clear the auto-restart flag before dropping the link: our DISCONNECTED event is only processed + // after this callback returns and would re-start advertising. startAdv()/resumeAdvertising() re-set it. + Bluefruit.Advertising.restartOnDisconnect(false); Bluefruit.Advertising.stop(); + disconnect(); } void NRF52Bluetooth::startDisabled() { From 36c89fa3a7c8fc78d41df07579b6d6a9fe21b393 Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:30:27 +0000 Subject: [PATCH 035/143] feat: Support Seeed Wio Tracker L2 (#10909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial commit * enable power save * implement mesh LED * add ADS1115+AW35615 for wio tracker L2 * add ES8311, GT911, AW35615, LP5814 to I2C scanner * update commit references * move variant.cpp to extras * update hw_model * update lovyanGFX * point to device-ui commit * trunk fmt * fix IO expander (have to take from SensorLib for now as long as AudioThread has the limitation to only support SensorLib and the previous IO expander clashes with duplicate names in arduino-audio-driver) * workaround duplicate defined symbol * remove SensorLib; add lightweight Pca9555 class and use unified USE_PCA95X5; add wake button detection * keep TP_INT disabled(OUTPUT) as we use wake button for wakeup * PA off by default, enabled when playing sound; add some delay because typical class-D amps (NS4150 family) spec 20–50ms for the output stage to reach full swing after power-on * refactored AW35615 into new external library * local revert of PR10571 as this PR completely breaks the alert sound * fix detection of ADS1115 * update device-ui commit reference * add synchronisation to IO expander and call toggleDisplay() on wake button press * add battery curve, fix io expander sync * add SPILock, simplify macro usage * update device-ui * fix wakeup from sleep * revert because of #11604 * use new AUDIO_AMP_SETTLE_MS * remove test logs * enable BaseUI * use touch screen * refactor wakekey thread * fix wake button toggle screen on/off * fix battery percentage and plugIn state * Update src/graphics/TFTDisplay.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * consider to return I2C errors to make coderabbi happy * fix warnings * use Throttle for millis comparison * fix endTransmission in write * make the rabbit happy * spli targets -tft / non-tft * fix compile * revert forced use of Throttle * remove MeshLED * add HW_MODEL --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- boards/seeed_wio_tracker_L2.json | 42 ++++ src/AudioThread.h | 7 +- src/Pca9555.h | 97 +++++++++ src/Power.cpp | 119 +++++++++++ src/Power.h | 4 + src/configuration.h | 14 +- src/detect/ScanI2C.h | 7 +- src/detect/ScanI2CTwoWire.cpp | 76 +++++-- src/graphics/TFTDisplay.cpp | 180 ++++++++++++++++- src/main.cpp | 6 +- src/mesh/NodeDB.cpp | 2 +- src/platform/esp32/architecture.h | 2 + .../seeed_wio_tracker_l2/WakeKey.cpp | 190 ++++++++++++++++++ .../seeed_wio_tracker_l2/WakeKey.h | 47 +++++ .../seeed_wio_tracker_l2/variant.cpp | 109 ++++++++++ src/sleep.cpp | 6 +- .../seeed_wio_tracker_L2/pins_arduino.h | 15 ++ .../seeed_wio_tracker_L2/platformio.ini | 83 ++++++++ .../esp32s3/seeed_wio_tracker_L2/variant.h | 120 +++++++++++ variants/esp32s3/t-watch-ultra/variant.h | 4 +- variants/esp32s3/tlora-pager/variant.h | 4 +- 21 files changed, 1101 insertions(+), 33 deletions(-) create mode 100644 boards/seeed_wio_tracker_L2.json create mode 100644 src/Pca9555.h create mode 100644 src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.cpp create mode 100644 src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.h create mode 100644 src/platform/extra_variants/seeed_wio_tracker_l2/variant.cpp create mode 100644 variants/esp32s3/seeed_wio_tracker_L2/pins_arduino.h create mode 100644 variants/esp32s3/seeed_wio_tracker_L2/platformio.ini create mode 100644 variants/esp32s3/seeed_wio_tracker_L2/variant.h diff --git a/boards/seeed_wio_tracker_L2.json b/boards/seeed_wio_tracker_L2.json new file mode 100644 index 0000000000..1421c19a76 --- /dev/null +++ b/boards/seeed_wio_tracker_L2.json @@ -0,0 +1,42 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-D BOARD_HAS_PSRAM", + "-D ARDUINO_USB_CDC_ON_BOOT=1", + "-D ARDUINO_USB_MODE=1", + "-D ARDUINO_RUNNING_CORE=1", + "-D ARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "seeed_wio_tracker_L2 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.seeedstudio.com/", + "vendor": "Seeed Studio" +} diff --git a/src/AudioThread.h b/src/AudioThread.h index fb63a48922..6d4b26dad4 100644 --- a/src/AudioThread.h +++ b/src/AudioThread.h @@ -1,5 +1,6 @@ #pragma once #include "PowerFSM.h" +#include "SPILock.h" #include "concurrency/OSThread.h" #include "configuration.h" #include "main.h" @@ -15,9 +16,9 @@ // A board with an I2S amplifier opts in by defining AUDIO_AMP_ENABLE(on) in its variant.h to power the // amp on/off around playback (e.g. an enable pin on an I/O expander). The includes below expose the // expander instances (io / mcpIoExpander) those macros typically reference. -#ifdef USE_XL9555 -#include "ExtensionIOXL9555.hpp" -extern ExtensionIOXL9555 io; +#ifdef USE_PCA95X5 +#include PCA95X5_INC +extern PCA95X5_CLS io; #endif #ifdef USE_MCP23017 diff --git a/src/Pca9555.h b/src/Pca9555.h new file mode 100644 index 0000000000..b29cbdda82 --- /dev/null +++ b/src/Pca9555.h @@ -0,0 +1,97 @@ +#pragma once +#include + +// Lightweight Wire-based TCA9555/PCA9555/XL9555 16-bit I/O expander driver. +// Opt in via USE_PCA95X5 / PCA95X5_CLS / PCA95X5_INC in the board's variant.h. +class Pca9555 +{ + public: + bool begin(TwoWire &wire, uint8_t addr, int sda = -1, int scl = -1) + { + _wire = &wire; + _addr = addr; + if (sda >= 0 && scl >= 0) + wire.begin(sda, scl); + wire.beginTransmission(addr); + return wire.endTransmission() == 0; + } + + bool pinMode(int pin, int mode) + { + if (pin < 0 || pin > 15 || !_wire) + return false; + uint8_t port = pin / 8, bit = pin % 8; + uint8_t cfg; + uint8_t res = readReg(0x06 + port, cfg); + if (res) { + bool isInput = (mode == INPUT || mode == INPUT_PULLUP); + if (isInput) + cfg |= (1u << bit); + else + cfg &= ~(1u << bit); + return writeReg(0x06 + port, cfg); + } + return res; + } + + bool digitalWrite(int pin, int value) + { + if (pin < 0 || pin > 15 || !_wire) + return false; + uint8_t port = pin / 8, bit = pin % 8; + uint8_t out; + bool res = readReg(0x02 + port, out); + if (res) { + if (value) + out |= (1u << bit); + else + out &= ~(1u << bit); + return writeReg(0x02 + port, out); + } + return res; + } + + bool digitalRead(int pin) + { + if (pin < 0 || pin > 15 || !_wire) + return false; + uint8_t port = pin / 8, bit = pin % 8; + uint8_t reg; + if (readReg(0x00 + port, reg)) + return (reg & (1u << bit)) != 0; + else + return 0; + } + + private: + TwoWire *_wire = nullptr; + uint8_t _addr = 0x20; + + bool readReg(uint8_t reg, uint8_t &out) + { + _wire->beginTransmission(_addr); + _wire->write(reg); + if (_wire->endTransmission(false) != 0) { + _wire->end(); + _wire->begin(); + return false; + } + if (_wire->requestFrom((uint8_t)_addr, (uint8_t)1) != 1) + return false; + out = _wire->read(); + return true; + } + + bool writeReg(uint8_t reg, uint8_t val) + { + _wire->beginTransmission(_addr); + _wire->write(reg); + _wire->write(val); + if (_wire->endTransmission() != 0) { + _wire->end(); + _wire->begin(); + return false; + } + return true; + } +}; diff --git a/src/Power.cpp b/src/Power.cpp index 8e0f740914..5b7f17e289 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -41,6 +41,10 @@ #include "input/LinuxJoystick.h" #endif +#ifdef HAS_ADS1115 +#include +#endif + // Working USB detection for powered/charging states on the RAK platform #ifdef NRF_APM #include "nrfx_power.h" @@ -726,6 +730,117 @@ class AnalogBatteryLevel : public HasBatteryLevel static AnalogBatteryLevel analogLevel; +#ifdef HAS_ADS1115 +#include "SPILock.h" +#include + +/** + * @brief Battery level sensor using an ADS1115 16-bit ADC on I2C. + * Channel 0 measures battery voltage through a 1:2 resistive divider. + * USB / Charging status is managed via an AW35615 USB-C CC controller. + */ +class ADS1115BatteryLevel : public AnalogBatteryLevel +{ + public: + bool init() + { + { + concurrency::LockGuard guard(spiLock); + if (!_ads.begin(ADS1115_ADDR, &Wire)) { + LOG_WARN("ADS1115 not found on I2C bus - battery sensor unavailable"); + return false; + } + _ads.setGain(GAIN_ONE); // ±4.096 V FSR matches standard 1:2 voltage-divider + _ads.setDataRate(RATE_ADS1115_860SPS); // Maximize conversion speed to keep bus locking minimal + } + + initialized = true; + LOG_INFO("[ADS1115] battery sensor initialized"); + + if (_aw35615.begin(Wire)) { + LOG_INFO("[AW35615] USB-C CC controller initialized"); + } else { + LOG_WARN("[AW35615] not found at 0x22"); + } + return true; + } + + virtual uint16_t getBattVoltage() override + { + if (!initialized) + return 0; + + static constexpr uint32_t MIN_READ_INTERVAL_MS = 30000; + if (!initial_read_done || !Throttle::isWithinTimespanMs(last_read_ms, MIN_READ_INTERVAL_MS)) { + last_read_ms = millis(); + float sum = 0; + { + concurrency::LockGuard guard(spiLock); + for (uint8_t i = 0; i < SAMPLE_COUNT; i++) { + int16_t raw = _ads.readADC_SingleEnded(0); + sum += _ads.computeVolts(raw); + } + } + + // Voltage divider scales by 2.0; convert volts to millivolts + float v = (sum / (float)SAMPLE_COUNT) * 2.0f * 1000.0f; + + if (!initial_read_done) { + cached_mv = static_cast(v); + initial_read_done = true; + } else { + // Exponential moving average filter (50% smoothing) + cached_mv = static_cast(cached_mv + (v - cached_mv) * 0.5f); + } + } + return cached_mv; + } + + virtual bool isVbusIn() override + { + if (_aw35615.isReady()) { + concurrency::LockGuard guard(spiLock); + return _aw35615.isVbusPresent(); + } + // Fallback to base GPIO/board checks (or false) if CC chip is absent + return false; + } + + virtual bool isCharging() override + { + if (!isBatteryConnect()) + return false; + + if (_aw35615.isReady()) { + concurrency::LockGuard guard(spiLock); + return _aw35615.isSinkAttached(); + } + return isVbusIn(); + } + + private: + static constexpr uint8_t SAMPLE_COUNT = 3; + Adafruit_ADS1115 _ads; + AW35615 _aw35615; + + bool initialized = false; + bool initial_read_done = false; + uint16_t cached_mv = 0; + uint32_t last_read_ms = 0; +}; + +static ADS1115BatteryLevel ads1115BattLevel; + +bool Power::ads1115Init() +{ + if (ads1115BattLevel.init()) { + batteryLevel = &ads1115BattLevel; + return true; + } + return false; +} +#endif // HAS_ADS1115 + Power::Power() : OSThread("Power") { statusHandler = {}; @@ -822,6 +937,10 @@ bool Power::setup() found = true; } else if (meshSolarInit()) { found = true; +#ifdef HAS_ADS1115 + } else if (ads1115Init()) { + found = true; +#endif } else if (analogInit()) { found = true; } else { diff --git a/src/Power.h b/src/Power.h index b47d66aff2..f21511ec08 100644 --- a/src/Power.h +++ b/src/Power.h @@ -127,6 +127,10 @@ class Power : public concurrency::OSThread bool meshSolarInit(); /// Setup a serial battery sensor bool serialBatteryInit(); +#ifdef HAS_ADS1115 + /// Setup ADS1115 I2C battery level sensor + bool ads1115Init(); +#endif private: void shutdown(); diff --git a/src/configuration.h b/src/configuration.h index b55ce262f7..0c99b6631f 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -353,6 +353,15 @@ along with this program. If not, see . // ----------------------------------------------------------------------------- #define NCP5623_ADDR 0x38 #define LP5562_ADDR 0x30 +#define LP5814_ADDR 0x2C + +// ----------------------------------------------------------------------------- +// Audio Codec +// ----------------------------------------------------------------------------- +#if not __has_include("Codecs/es8311/ES8311.h") +#define ES8311_ADDR 0x18 // same address as MCP9808_ADDR / STK8BXX_ADDR / LIS3DH_ADDR +#endif +#define ES7243E_ADDR 0x14 // ----------------------------------------------------------------------------- // Security @@ -367,10 +376,11 @@ along with this program. If not, see . // ----------------------------------------------------------------------------- // Touchscreen // ----------------------------------------------------------------------------- -#define FT6336U_ADDR 0x48 -#define CST328_ADDR 0x1A // same address as CST226SE +#define FT6336U_ADDR 0x48 // same address as ADS1115 +#define CST328_ADDR 0x1A // same address as CST226SE #define CHSC6X_ADDR 0x2E #define CST226SE_ADDR_ALT 0x5A +#define GT911_ADDR 0x5D // same address as SFA30_ADDR / LPS22HB_ADDR_ALT // ----------------------------------------------------------------------------- // RAK12035VB Soil Monitor (using RAK12023 up to 3 RAK12035 monitors can be connected) diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 4bb141722a..be42b1652f 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -51,6 +51,7 @@ class ScanI2C BMA423, BQ24295, LSM6DS3, + AW35615, TCA9535, TCA9555, VEML7700, @@ -109,7 +110,11 @@ class ScanI2C STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9) DS248X, HM330X, - AS3935 + AS3935, + GT911, + LP5814, + ES8311, + ES7243E, } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index d9c9d77179..3a6563c2ad 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -448,8 +448,18 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) SCAN_SIMPLE_CASE(ST7567_ADDRESS, SCREEN_ST7567, "ST7567", (uint8_t)addr.address); #ifdef HAS_NCP5623 SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address); +#endif +#ifdef HAS_LP5562 + SCAN_SIMPLE_CASE(LP5562_ADDR, LP5562, "LP5562", (uint8_t)addr.address); +#endif +#ifdef HAS_LP5814 + SCAN_SIMPLE_CASE(LP5814_ADDR, LP5814, "LP5814", (uint8_t)addr.address); +#endif +#ifdef HAS_ES7243E + SCAN_SIMPLE_CASE(ES7243E_ADDR, ES7243E, "ES7243E", (uint8_t)addr.address); #endif case XPOWERS_AXP192_AXP2101_ADDRESS: +#ifndef SEEED_WIO_TRACKER_L2 // false positive on Wio Tracker L2 // Do we have the axp2101/192 or the TCA8418 registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x90), 1); if (registerValue == 0x0) { @@ -459,6 +469,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("AXP192/AXP2101", (uint8_t)addr.address); type = PMU_AXP192_AXP2101; } +#endif break; case BME_ADDR: case BME_ADDR_ALTERNATE: @@ -612,6 +623,13 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) // We need to check for STK8BAXX first, since register 0x07 is new data flag for the z-axis and can produce some // weird result. and register 0x00 doesn't seems to be colliding with MCP9808 and LIS3DH chips. { + // Check register 0xFD for 0x83 to ID ES8311 audio codec. + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFD), 1); + if (registerValue == 0x83) { + type = ES8311; + logFoundDevice("ES8311", (uint8_t)addr.address); + break; + } #ifdef HAS_STK8XXX // Check register 0x00 for 0x8700 response to ID STK8BA53 chip. registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 2); @@ -672,6 +690,22 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; case LPS22HB_ADDR_ALT: + // GT911 touchscreen: product ID register 0x8140 returns "911" + { + uint8_t gt911_reg[] = {0x81, 0x40}; + uint8_t gt911_buf[4] = {0}; + i2cBus->beginTransmission(addr.address); + i2cBus->write(gt911_reg, 2); + if (i2cBus->endTransmission() == 0) { + i2cBus->requestFrom((int)addr.address, 4); + i2cBus->readBytes(gt911_buf, 4); + if (gt911_buf[0] == '9' && gt911_buf[1] == '1' && gt911_buf[2] == '1') { + type = GT911; + logFoundDevice("GT911", (uint8_t)addr.address); + break; + } + } + } // SFA30 detection: send 2-byte command 0xD060 (Get Device Marking) and check for 48-byte response if (i2cCommandResponseLength(addr, 0xD060, 48)) { type = SFA30; @@ -803,9 +837,18 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("BMA423", (uint8_t)addr.address); break; + case RAK120353_ADDR: { // AW35615 USB-C CC controller - must be checked before + // RAK120353_ADDR which shares 0x22 but is a TCA9535 variant + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 1); + if ((registerValue & 0xF0) == 0x90) { // DEVICE_ID upper nibble = 0x9 for AW35615 + type = AW35615; + logFoundDevice("AW35615", (uint8_t)addr.address); + break; + } + // Fall through to TCA9535/RAK check + } case TCA9535_ADDR: case RAK120352_ADDR: - case RAK120353_ADDR: registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x02), 1); if (registerValue == addr.address) { // RAK12035 returns its I2C address at 0x02 (eg 0x20) type = RAK12035; @@ -1029,6 +1072,14 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; case 0x48: { + // Check ADS1X15 FIRST - the SE050 probe writes 5 bytes which corrupts the ADS1X15 Lo_thresh register. + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2); + if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700 || registerValue == 0xc580) { + type = ADS1X15; + logFoundDevice("ADS1X15 ADC", (uint8_t)addr.address); + break; + } + // T=1oI2C soft reset; an SE050 answers A5 E0 00 3F 19. requestFrom() is // required: readBytes() only drains the RX buffer requestFrom() fills. const uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC}; @@ -1047,24 +1098,15 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) info[i] = i2cBus->read(); isSE050 = (memcmp(expectedInfo, info, sizeof(info)) == 0); } - } - if (isSE050) { - LOG_INFO("NXP SE050 crypto chip found"); - type = NXP_SE050; - break; + if (isSE050) { + LOG_INFO("NXP SE050 crypto chip found"); + type = NXP_SE050; + } else { + LOG_INFO("FT6336U touchscreen found"); + type = FT6336U; + } } - - // ADS1X15 default config register is 8583h - registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2); - if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700) { - type = ADS1X15; - logFoundDevice("ADS1X15 ADC", (uint8_t)addr.address); - break; - } - - LOG_INFO("FT6336U touchscreen found"); - type = FT6336U; break; } diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 445d5c8960..07bb7e6350 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -830,6 +830,183 @@ class LGFX : public lgfx::LGFX_Device static LGFX *tft = nullptr; +#elif defined(SEEED_WIO_TRACKER_L2) // Inline LGFX: NV3031B panel + SPI3 + GT911 touch + LP5814 backlight + +#include + +// LP5814 4-channel LED driver (backlight), I2C 0x2C +class Wio_Tracker_Light : public lgfx::v1::ILight +{ + static constexpr uint8_t REG_DEVICE_CONFIG0 = 0x00; + static constexpr uint8_t REG_MAX_CURRENT = 0x01; + static constexpr uint8_t REG_ENABLE_CONTROL = 0x02; + static constexpr uint8_t REG_DIM_MODE = 0x04; + static constexpr uint8_t REG_ENGINE_MODE = 0x05; + static constexpr uint8_t REG_UPDATE = 0x0F; + static constexpr uint8_t REG_LED0_DC = 0x14; + static constexpr uint8_t REG_LED0_PWM = 0x18; + + public: + struct config_t { + uint8_t brightness = 153; + }; // 60% + + const config_t &config(void) const { return _cfg; } + void config(const config_t &cfg) { _cfg = cfg; } + + bool init(uint8_t brightness) override + { + Wire.beginTransmission(0x2c); + if (Wire.endTransmission() != 0) { + LOG_ERROR("LP5814 not found at 0x2c"); + return false; + } + bool result = true; + result &= writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable + result &= writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current + result &= writeReg(REG_ENABLE_CONTROL, 0x00); // disable outputs while configuring + result &= writeReg(REG_DIM_MODE, 0x4E); // dim mode config + result &= writeReg(REG_ENGINE_MODE, 0xF0); // engine mode config + // Set DC current for all 4 channels (registers 0x14..0x17) + for (uint8_t i = 0; i < 4; i++) { + result &= writeReg(REG_LED0_DC + i, 200); + } + result &= writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels + result &= writeReg(REG_UPDATE, 0x55); // latch parameters (LP5814 requires 0x55) + delay(5); // LP5814 engine startup settling time + + setBrightness(brightness); + return result; + } + + void setBrightness(uint8_t brightness) override + { + // Write PWM to all 4 channels (registers 0x18..0x1B). + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_PWM + i, brightness); + } + _cfg.brightness = brightness; + } + + uint8_t getBrightness(void) const { return _cfg.brightness; } + virtual ~Wio_Tracker_Light(void) = default; + + private: + bool writeReg(uint8_t reg, uint8_t value) + { + Wire.beginTransmission(0x2c); + Wire.write(reg); + Wire.write(value); + uint8_t error = Wire.endTransmission(); + if (error != 0) { + LOG_ERROR("LP5814 write reg 0x%02x failed: %d", reg, error); + return false; + } + return true; + } + config_t _cfg; +}; + +class LGFX : public lgfx::LGFX_Device +{ + lgfx::Panel_NV3031B _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Touch_GT911 _touch_instance; + Wio_Tracker_Light _light_instance; + + public: + const uint32_t screenWidth = 320; + const uint32_t screenHeight = 240; + + bool hasButton(void) { return true; } + + bool init_impl(bool use_reset, bool use_clear) override + { + _light_instance.init(_light_instance.config().brightness); + bool result = LGFX_Device::init_impl(use_reset, use_clear); + // GT911 probe leaves I2C BUSY flag stuck; reset peripheral for LP5814 setBrightness + Wire.end(); + Wire.begin(47, 48); + Wire.setClock(100000); + return result; + } + + lgfx::ILight *light(void) const { return (lgfx::ILight *)&_light_instance; } + + LGFX(void) + { + // Bus: SPI3 quad pins (mode 3, 75 MHz write / 16 MHz read) + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI3_HOST; + cfg.spi_mode = 3; + cfg.freq_write = 75000000; + cfg.freq_read = 16000000; + cfg.pin_sclk = 42; + cfg.pin_io0 = 41; + cfg.pin_io1 = 40; + cfg.pin_io2 = 39; + cfg.pin_io3 = 38; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + // Panel: NV3031B (CS=46) + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = 46; + cfg.pin_rst = -1; + cfg.pin_busy = -1; + cfg.panel_width = screenHeight; // NV3031B native: 240 wide × 320 tall + cfg.panel_height = screenWidth; + cfg.memory_width = screenHeight; + cfg.memory_height = screenWidth; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 1; // Landscape with setRotation(0); matches MUI + cfg.invert = true; // NV3031B requires invert, otherwise screen shows green grid + cfg.rgb_order = true; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + // Touch: GT911 (I2C 0x5D on SDA=47 SCL=48) + { + auto cfg = _touch_instance.config(); + cfg.pin_cs = -1; + cfg.x_min = 0; + cfg.x_max = screenHeight - 1; + cfg.y_min = 0; + cfg.y_max = screenWidth - 1; + cfg.pin_int = -1; + cfg.offset_rotation = 2; + cfg.i2c_port = 0; + cfg.i2c_addr = 0x5D; + cfg.pin_sda = 47; + cfg.pin_scl = 48; + cfg.bus_shared = false; + cfg.freq = 100000; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + _panel_instance.setLight(&_light_instance); + setPanel(&_panel_instance); + } + + void sleep(void) + { + _panel->setSleep(true); + _light_instance.setBrightness(0); + } + + void wakeup(void) + { + _panel->setSleep(false); + _light_instance.setBrightness(_light_instance.config().brightness); + } +}; + +static LGFX *tft = nullptr; + #elif defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) #include // Graphics and font library for ILI9341/ILI9342 driver chip @@ -1874,7 +2051,8 @@ bool TFTDisplay::connect() tft->setRotation(1); // T-Deck has the TFT in landscape #elif defined(T_WATCH_S3) tft->setRotation(2); // T-Watch S3 left-handed orientation -#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) || defined(T_WATCH_ULTRA) +#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) || defined(T_WATCH_ULTRA) || \ + defined(SEEED_WIO_TRACKER_L2) tft->setRotation(0); // use config.yaml to set rotation #else tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label diff --git a/src/main.cpp b/src/main.cpp index 89ff9de383..80b8e4f4a9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -178,9 +178,9 @@ MagnetometerThread *magnetometerThread = nullptr; AudioThread *audioThread = nullptr; #endif -#ifdef USE_XL9555 -#include "ExtensionIOXL9555.hpp" -ExtensionIOXL9555 io; +#ifdef USE_PCA95X5 +#include PCA95X5_INC +PCA95X5_CLS io; #endif #ifdef USE_MCP23017 diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index f6bf9829d9..96b7ae81bb 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1107,7 +1107,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \ defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2) || \ - defined(ELECROW_ThinkNode_M9) || defined(T_WATCH_ULTRA)) && \ + defined(ELECROW_ThinkNode_M9) || defined(SEEED_WIO_TRACKER_L2) || defined(T_WATCH_ULTRA)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 2c409b0b87..7f35db49b4 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -225,6 +225,8 @@ #define HW_VENDOR meshtastic_HardwareModel_HELTEC_RC32 #elif defined(HELTEC_RCC6) #define HW_VENDOR meshtastic_HardwareModel_HELTEC_RCC6 +#elif defined(SEEED_WIO_TRACKER_L2) +#define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L2 #else #define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW #endif diff --git a/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.cpp b/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.cpp new file mode 100644 index 0000000000..4be29fd103 --- /dev/null +++ b/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.cpp @@ -0,0 +1,190 @@ +#ifdef SEEED_WIO_TRACKER_L2 + +#include "WakeKey.h" +#include "DebugConfiguration.h" +#include "PowerFSM.h" +#include "SPILock.h" +#include "graphics/Screen.h" // BaseUI +#include "graphics/TFTDisplay.h" // BaseUI +#include "main.h" + +#if defined(HAS_TFT) && HAS_TFT +#include "graphics/DeviceScreen.h" // MUI +extern DeviceScreen *deviceScreen; +#endif + +#include PCA95X5_INC +extern PCA95X5_CLS io; + +// wake button handling +static bool isPca9535WakeKeyPressed() +{ + concurrency::LockGuard guard(spiLock); + return !io.digitalRead(EXPANDS_BTN_WAKE_UP); +} + +WakeKeyInterruptThread *WakeKeyInterruptThread::wakeKey = nullptr; + +WakeKeyInterruptThread *WakeKeyInterruptThread::instance(void) +{ + if (!wakeKey) + wakeKey = new WakeKeyInterruptThread(); + return wakeKey; +} + +WakeKeyInterruptThread::WakeKeyInterruptThread() : concurrency::OSThread("WioL2WakeKeyInt", SAMPLE_MS) +{ + // Do not run unless an edge arrives. + OSThread::disable(); +#ifdef ARCH_ESP32 + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif +} + +void WakeKeyInterruptThread::begin(void) +{ + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, WakeKeyInterruptThread::isr, FALLING); +} + +int32_t WakeKeyInterruptThread::runOnce(void) +{ + const uint32_t now = millis(); + + // Safe, sequential handling of the edge flag outside the ISR context + if (rawIrqSignaled) { + rawIrqSignaled = false; + if (state == State::REST) { + state = State::IRQ_PENDING; + irqAtMs = now; + } + } + + // Ignore side-key handling while BOOT/user button is held. + if (digitalRead(BUTTON_PIN) == LOW) { + LOG_WARN("ignore wake button press"); + 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 (isPca9535WakeKeyPressed()) { + LOG_DEBUG("wake button pressed"); +#if defined(HAS_TFT) && HAS_TFT + if (deviceScreen) + deviceScreen->toggleDisplay(); +#endif +#if defined(HAS_SCREEN) && HAS_SCREEN + if (screen) + screen->setOn(sleeping); +#endif + if (sleeping) { + powerFSM.trigger(EVENT_PRESS); + } + sleeping = !sleeping; + state = State::PRESSED; + return SAMPLE_MS; + } + + // Spurious/cleared edge. + resetStateAndStop(); + return OSThread::disable(); + + case State::PRESSED: { + if (isPca9535WakeKeyPressed()) { + return SAMPLE_MS; + } + + resetStateAndStop(); + return OSThread::disable(); + } + + case State::REST: + default: + return OSThread::disable(); + } +} + +void IRAM_ATTR WakeKeyInterruptThread::isr(void) +{ + if (wakeKey) { + wakeKey->rawIrqSignaled = true; + wakeKey->enabled = true; + wakeKey->setInterval(0); // TODO + BaseType_t higherWake = 0; + concurrency::mainDelay.interruptFromISR(&higherWake); + runASAP = true; + } +} + +void WakeKeyInterruptThread::onInterruptEdge(void) +{ + if (state != State::REST) { + return; + } + + state = State::IRQ_PENDING; + irqAtMs = millis(); + startThread(); +} + +void WakeKeyInterruptThread::startThread(void) +{ + if (!OSThread::enabled) { + OSThread::setIntervalFromNow(0); + OSThread::enabled = true; + runASAP = true; + } +} + +void WakeKeyInterruptThread::resetStateAndStop(void) +{ + state = State::REST; + if (OSThread::enabled) { + OSThread::disable(); + } +} + +#ifdef ARCH_ESP32 +int WakeKeyInterruptThread::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. + spiLock->lock(); + volatile bool dummy = io.digitalRead(EXPANDS_BTN_WAKE_UP); + (void)dummy; + spiLock->unlock(); + resetStateAndStop(); + sleeping = true; + return 0; +} + +int WakeKeyInterruptThread::onLightSleepEnd(esp_sleep_wakeup_cause_t cause) +{ + (void)cause; + // Consume any pending interrupt source before reattaching ISR. + // Check BEFORE clearing: if INT is still asserted the button may still be held, + // and no new falling edge will fire until it is released. + bool intAsserted = (digitalRead(BOARD_PCA9535_INT) == LOW); + spiLock->lock(); + (void)io.digitalRead(EXPANDS_BTN_WAKE_UP); + spiLock->unlock(); + pinMode(BOARD_PCA9535_INT, INPUT_PULLUP); + attachInterrupt(BOARD_PCA9535_INT, WakeKeyInterruptThread::isr, FALLING); + if (intAsserted) { + onInterruptEdge(); + } + return 0; +} + +#endif + +#endif \ No newline at end of file diff --git a/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.h b/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.h new file mode 100644 index 0000000000..05d96496e8 --- /dev/null +++ b/src/platform/extra_variants/seeed_wio_tracker_l2/WakeKey.h @@ -0,0 +1,47 @@ +#pragma once +#include "concurrency/OSThread.h" +#include "sleep.h" + +class WakeKeyInterruptThread : public concurrency::OSThread +{ + public: + static WakeKeyInterruptThread *instance(void); + void begin(void); + + protected: + int32_t runOnce() override; + + 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 void IRAM_ATTR isr(void); + void onInterruptEdge(void); + + void startThread(void); + void resetStateAndStop(void); + +#ifdef ARCH_ESP32 + int onLightSleep(void *); + int onLightSleepEnd(esp_sleep_wakeup_cause_t cause); + + CallbackObserver lsObserver{this, &WakeKeyInterruptThread::onLightSleep}; + CallbackObserver lsEndObserver{this, + &WakeKeyInterruptThread::onLightSleepEnd}; +#endif + + bool sleeping = false; + volatile bool rawIrqSignaled = false; + volatile State state = State::REST; + volatile uint32_t irqAtMs = 0; + + WakeKeyInterruptThread(void); + static WakeKeyInterruptThread *wakeKey; + WakeKeyInterruptThread *wakeKeyThread = nullptr; +}; diff --git a/src/platform/extra_variants/seeed_wio_tracker_l2/variant.cpp b/src/platform/extra_variants/seeed_wio_tracker_l2/variant.cpp new file mode 100644 index 0000000000..ce42e9b5d8 --- /dev/null +++ b/src/platform/extra_variants/seeed_wio_tracker_l2/variant.cpp @@ -0,0 +1,109 @@ +#include "configuration.h" + +#ifdef SEEED_WIO_TRACKER_L2 + +#include "AudioBoard.h" +#include "DebugConfiguration.h" +#include "SPILock.h" +#include "WakeKey.h" +#include "input/InputBroker.h" + +#include PCA95X5_INC +extern PCA95X5_CLS io; + +DriverPins PinsAudioBoardES8311; +AudioBoard board(AudioDriverES8311, PinsAudioBoardES8311); + +static bool initOK = false; + +void earlyInitVariant() +{ + Wire.begin(I2C_SDA, I2C_SCL); + Wire.setClock(100000); // Pin main I2C0 bus to 100 kHz (TCA9535, ADS1115, AW35615, ES8311 share this Wire) + if (io.begin(Wire, BOARD_PCA9535_ADDR, I2C_SDA, I2C_SCL)) { + io.pinMode(EXPANDS_BTN_WAKE_UP, INPUT); // wakeup button + io.pinMode(EXPANDS_I2C_0_INT, INPUT); // I2C IRQ + io.pinMode(EXPANDS_SD_DETECT, INPUT); // SD detect + + io.pinMode(EXPANDS_EXP_OTG_EN, OUTPUT); // OTG EN + io.digitalWrite(EXPANDS_EXP_OTG_EN, LOW); // OTG EN low + delay(10); + io.pinMode(EXPANDS_PA_PWR_EN, OUTPUT); // PA EN + io.digitalWrite(EXPANDS_PA_PWR_EN, LOW); // PA EN low, controlled by AudioThread + delay(10); + io.pinMode(EXPANDS_GNSS_PWR_EN, OUTPUT); // GNSS EN + io.digitalWrite(EXPANDS_GNSS_PWR_EN, HIGH); // GNSS EN high + delay(10); + io.pinMode(EXPANDS_SD_PWR_EN, OUTPUT); // TF EN + io.digitalWrite(EXPANDS_SD_PWR_EN, HIGH); // TF EN high + delay(10); + io.pinMode(EXPANDS_BAT_ADC_EN, OUTPUT); // BAT ADC EN + io.digitalWrite(EXPANDS_BAT_ADC_EN, HIGH); // BAT ADC EN high + delay(10); + io.pinMode(EXPANDS_GNSS_RST, OUTPUT); // GNSS RST (active HIGH on this board) + // Expander output defaults to HIGH, so module is already in reset. + // Hold reset for 10ms, then release LOW so the module starts running. + delay(10); + io.digitalWrite(EXPANDS_GNSS_RST, LOW); // release reset - module starts running + io.pinMode(EXPANDS_LED_USER, OUTPUT); // User LED + io.digitalWrite(EXPANDS_LED_USER, LOW); // User LED + delay(10); + io.pinMode(EXPANDS_GROVE_PWR_EN, OUTPUT); // GROVE EN + io.digitalWrite(EXPANDS_GROVE_PWR_EN, HIGH); // GROVE EN high + delay(10); + + io.pinMode(EXPANDS_LCD_PWR_EN, OUTPUT); // LCD EN + io.digitalWrite(EXPANDS_LCD_PWR_EN, HIGH); // LCD EN high + delay(50); + io.pinMode(EXPANDS_LCD_RST, OUTPUT); // LCD RST + io.digitalWrite(EXPANDS_LCD_RST, HIGH); // LCD RST high + delay(5); + io.digitalWrite(EXPANDS_LCD_RST, LOW); // LCD RST low + delay(10); + io.digitalWrite(EXPANDS_LCD_RST, HIGH); // LCD RST high + delay(500); + io.pinMode(EXPANDS_LCD_CS, OUTPUT); // LCD CS + io.digitalWrite(EXPANDS_LCD_CS, HIGH); // LCD CS high + delay(10); + + io.pinMode(EXPANDS_TP_RST, OUTPUT); // TP RST + io.digitalWrite(EXPANDS_TP_RST, LOW); // TP RST low + io.pinMode(EXPANDS_TP_INT, OUTPUT); // TP INT: disable (we use wake button for wakeup) + io.digitalWrite(EXPANDS_TP_INT, LOW); // TP INT low + delay(10); + io.digitalWrite(EXPANDS_TP_INT, LOW); // TP INT low + delay(1); + io.digitalWrite(EXPANDS_TP_RST, HIGH); // TP RST high + delay(60); + initOK = true; + } +} + +void lateInitVariant() +{ + if (!initOK) { + LOG_ERROR("PCA9555 initialization failed"); + return; + } + + // wake button initialization + WakeKeyInterruptThread::instance()->begin(); + + // AudioDriverLogger.begin(Serial, AudioDriverLogLevel::Debug); + // I2C: function, scl, sda + PinsAudioBoardES8311.addI2C(PinFunction::CODEC, Wire); + // I2S: function, mclk, bck, ws, data_out, data_in + PinsAudioBoardES8311.addI2S(PinFunction::CODEC, DAC_I2S_MCLK, DAC_I2S_BCK, DAC_I2S_WS, DAC_I2S_DOUT, DAC_I2S_DIN); + + // configure codec + CodecConfig cfg; + cfg.input_device = ADC_INPUT_LINE1; + cfg.output_device = DAC_OUTPUT_ALL; + cfg.i2s.bits = BIT_LENGTH_16BITS; + cfg.i2s.rate = RATE_44K; + board.begin(cfg); + board.setVolume(75); // 75% volume + LOG_INFO("ES8311 Audio board initialized"); +} + +#endif \ No newline at end of file diff --git a/src/sleep.cpp b/src/sleep.cpp index 6ed3084e12..31c91e80d0 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -31,9 +31,9 @@ esp_sleep_source_t wakeCause; // the reason we booted this time #endif #include "Throttle.h" -#ifdef USE_XL9555 -#include "ExtensionIOXL9555.hpp" -extern ExtensionIOXL9555 io; +#ifdef USE_PCA95X5 +#include PCA95X5_INC +extern PCA95X5_CLS io; #endif #ifdef HAS_PPM diff --git a/variants/esp32s3/seeed_wio_tracker_L2/pins_arduino.h b/variants/esp32s3/seeed_wio_tracker_L2/pins_arduino.h new file mode 100644 index 0000000000..7237090ca2 --- /dev/null +++ b/variants/esp32s3/seeed_wio_tracker_L2/pins_arduino.h @@ -0,0 +1,15 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +static const uint8_t SDA = 47; +static const uint8_t SCL = 48; + +// Default SPI will be mapped to Radio +static const uint8_t SS = 21; +static const uint8_t MOSI = 6; +static const uint8_t MISO = 5; +static const uint8_t SCK = 4; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini new file mode 100644 index 0000000000..9aacb3e19c --- /dev/null +++ b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini @@ -0,0 +1,83 @@ +[env:seeed_wio_tracker_L2] +custom_meshtastic_hw_model = 137 +custom_meshtastic_hw_model_slug = SEEED_WIO_TRACKER_L2 +custom_meshtastic_architecture = esp32s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Seeed Wio Tracker L2 +custom_meshtastic_images = wio_tracker_l2_case.svg +custom_meshtastic_tags = Seeed +custom_meshtastic_requires_dfu = true + +extends = esp32s3_base +board_level = extra +board = seeed_wio_tracker_L2 +board_build.partitions = default_16MB.csv +upload_protocol = esptool + +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/seeed_wio_tracker_L2> + +build_flags = ${esp32s3_base.build_flags} + -I variants/esp32s3/seeed_wio_tracker_L2 + -D SEEED_WIO_TRACKER_L2 + -D CONFIG_ARDUHAL_ESP_LOG + -D CONFIG_ARDUHAL_LOG_COLORS=1 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D MESHTASTIC_EXCLUDE_WEBSERVER=1 + -D HAS_SCREEN=1 + -D HAS_SDCARD + -D HAS_SD_MMC ; SDIO mode 1-bit + -D SD_SCLK_PIN=2 + -D SD_MOSI_PIN=3 ; CMD + -D SD_MISO_PIN=1 ; D0 + -D SDCARD_CS=-1 + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.27 + # 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 + # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip + # renovate: datasource=github-tags depName=Adafruit_ADS1X15 packageName=adafruit/Adafruit_ADS1X15 + https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip + # renovate: datasource=github-tags depName=AW35615 packageName=meshtastic/AW35615 + https://github.com/mverch67/AW35615/archive/refs/tags/1.0.0.zip + +[env:seeed_wio_tracker_L2-tft] +extends = env:seeed_wio_tracker_L2 +board_level = release + +build_flags = ${env:seeed_wio_tracker_L2.build_flags} + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D INPUTDRIVER_BUTTON_TYPE=0 + -D LV_USE_LOG=0 + -D LV_BUILD_TEST=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D RADIOLIB_SPI_PARANOID=0 + -D HAS_TFT=1 + -D USE_I2S_BUZZER + -D RAM_SIZE=5120 + -D LGFX_BUFSIZE=153600 + -D LGFX_DRIVER=LGFX_WIO_TRACKER_L2 + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_WIO_TRACKER_L2.h\" + -D DISPLAY_SIZE=320x240 ; landscape mode + -D VIEW_320x240 + -D MAP_FULL_REDRAW + -D USE_PACKET_API + +lib_deps = + ${env:seeed_wio_tracker_L2.lib_deps} + ${device-ui_base.lib_deps} + +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + ${device-ui_base.custom_sdkconfig} diff --git a/variants/esp32s3/seeed_wio_tracker_L2/variant.h b/variants/esp32s3/seeed_wio_tracker_L2/variant.h new file mode 100644 index 0000000000..5ddad0db46 --- /dev/null +++ b/variants/esp32s3/seeed_wio_tracker_L2/variant.h @@ -0,0 +1,120 @@ +#pragma once + +#define LED_POWER 46 +#define LED_STATE_ON 1 + +#define BUTTON_PIN 0 +#define BUTTON_NEED_PULLUP + +#define I2C_SDA 47 +#define I2C_SCL 48 + +#define USE_POWERSAVE +#define SLEEP_TIME 120 + +#define HAS_ADS1115 +#define ADS1115_ADDR 0x48 + +// LED controller +#define HAS_LP5814 + +// ES8311 DAC / AMP +#define HAS_I2S +#define HAS_ES8311 +#define DAC_I2S_BCK 11 // SCLK +#define DAC_I2S_WS 12 // LRLK +#define DAC_I2S_DOUT 16 +#define DAC_I2S_DIN -1 +#define DAC_I2S_MCLK 10 + +#define HAS_ES7243E +#define ADC_I2S_BCK 11 +#define ADC_I2S_WS 12 +#define ADC_I2S_DOUT -1 +#define ADC_I2S_DIN 15 +#define ADC_I2S_MCLK 10 + +// External expansion chip TCA9555/PCA9555 +#define USE_PCA95X5 +#define PCA95X5_CLS Pca9555 +#define PCA95X5_INC "Pca9555.h" +#define BOARD_PCA9535_ADDR 0x21 +#define BOARD_PCA9535_INT 45 // wake from esp light sleep +// Button +#define EXPANDS_BTN_WAKE_UP (0) // INPUT +// I2C +#define EXPANDS_I2C_0_INT (1) // INPUT +// LED +#define EXPANDS_LED_USER (10) +// Display +#define EXPANDS_LCD_PWR_EN (5) +#define EXPANDS_LCD_RST (6) +#define EXPANDS_LCD_CS (4) +#define EXPANDS_TP_RST (8) +#define EXPANDS_TP_INT (3) // INPUT +// SD card +#define EXPANDS_SD_PWR_EN (14) +#define EXPANDS_SD_DETECT (2) // INPUT +// GNSS +#define EXPANDS_GNSS_PWR_EN (13) +#define EXPANDS_GNSS_RST (9) +// USB +#define EXPANDS_EXP_OTG_EN (11) +// Audio +#define EXPANDS_PA_PWR_EN (12) +#define AUDIO_AMP_SETTLE_MS 250 +#define AUDIO_AMP_ENABLE(on) \ + spiLock->lock(); \ + io.digitalWrite(EXPANDS_PA_PWR_EN, (on) ? HIGH : LOW); \ + spiLock->unlock(); + +// Battery +#define EXPANDS_BAT_ADC_EN (15) +// Grove +#define EXPANDS_GROVE_PWR_EN (7) + +// SX1262 LoRa Module Pins +#define USE_SX1262 +#define LORA_SCK 4 +#define LORA_MISO 5 +#define LORA_MOSI 6 +#define LORA_CS 21 +#define LORA_RESET 7 + +#define LORA_DIO1 9 +#define LORA_DIO0 -1 +#define LORA_DIO2 8 +#define LORA_DIO3 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +#define SX126X_DIO2_AS_RF_SWITCH + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// GPS L76KB +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define GPS_L76K +#ifdef GPS_L76K +#define GPS_TX_PIN 17 +#define GPS_RX_PIN 18 +#define HAS_GPS 1 +#define GPS_THREAD_INTERVAL 50 +#endif + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Display (NV3031B + QSPI via SPI3) - BaseUI adaptation +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#if HAS_TFT +#define HAS_SPI_TFT 1 +#define HAS_TOUCHSCREEN 1 +#define USE_TFTDISPLAY 1 // Enable legacy BaseUI TFTDisplay.cpp build +#define TFT_WIDTH 320 // Required by BaseUI setGeometry +#define TFT_HEIGHT 240 // Required by BaseUI setGeometry +#define USE_VIRTUAL_KEYBOARD 1 +#endif + +// Battery +#define OCV_ARRAY 4180, 4040, 3864, 3800, 3745, 3710, 3687, 3663, 3623, 3482, 3300 diff --git a/variants/esp32s3/t-watch-ultra/variant.h b/variants/esp32s3/t-watch-ultra/variant.h index 22f4f62d07..ed9431b6c8 100644 --- a/variants/esp32s3/t-watch-ultra/variant.h +++ b/variants/esp32s3/t-watch-ultra/variant.h @@ -42,7 +42,9 @@ #define SLEEP_TIME 120 // External expansion chip XL9555 -#define USE_XL9555 +#define USE_PCA95X5 +#define PCA95X5_CLS ExtensionIOXL9555 +#define PCA95X5_INC "ExtensionIOXL9555.hpp" // PCF85063 RTC Module #define PCF85063_RTC 0x51 diff --git a/variants/esp32s3/tlora-pager/variant.h b/variants/esp32s3/tlora-pager/variant.h index 52a060dd10..a152fdddf5 100644 --- a/variants/esp32s3/tlora-pager/variant.h +++ b/variants/esp32s3/tlora-pager/variant.h @@ -88,7 +88,9 @@ #define NFC_CS 39 // External expansion chip XL9555 -#define USE_XL9555 +#define USE_PCA95X5 +#define PCA95X5_CLS ExtensionIOXL9555 +#define PCA95X5_INC "ExtensionIOXL9555.hpp" #define EXPANDS_DRV_EN (0) #define EXPANDS_AMP_EN (1) #define EXPANDS_KB_RST (2) From 2afe097be677c4cb11430ced2040408ce1f950df Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:50:11 +0000 Subject: [PATCH 036/143] chore(deps): update meshtastic/device-ui digest to 9d9b9df (#11653) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 67ab247032..c819994102 100644 --- a/platformio.ini +++ b/platformio.ini @@ -137,7 +137,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/6813f3803e88892b66594fd2332c30e0538e5f21.zip + https://github.com/meshtastic/device-ui/archive/9d9b9df81fcde646811a10942d00d5f45f72af7b.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 9625c6bebf83d2a359dcd82299a84f5e2579e2a5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:47:28 -0500 Subject: [PATCH 037/143] nrf52840: prevent duplicate I2C switch case for LP5562/MMC5983MA (#11658) * Initial plan * fix: avoid duplicate I2C switch case for LP5562/MMC5983MA Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- src/detect/ScanI2CTwoWire.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 3a6563c2ad..ec691e43ff 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -449,7 +449,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) #ifdef HAS_NCP5623 SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address); #endif -#ifdef HAS_LP5562 +#if defined(HAS_LP5562) && (LP5562_ADDR != MMC5983MA_ADDR) SCAN_SIMPLE_CASE(LP5562_ADDR, LP5562, "LP5562", (uint8_t)addr.address); #endif #ifdef HAS_LP5814 From 7239fe886a30fa13cd35946fa5ae1a46a2807eeb Mon Sep 17 00:00:00 2001 From: Ixitxachitl Date: Sat, 29 Aug 2026 09:31:55 -0700 Subject: [PATCH 038/143] fix(BaseUI): let a module frame with no menu fall through the SELECT dispatch (#11659) #11209 added a module-frame branch to the SELECT chain that claims the press for any non-null moduleFrames entry, but its body acts only on the environmental telemetry frame. Every other module frame lands there and the press dies: the branches below it - waypoint among them - are unreachable. #11358 already patched one casualty by excluding the nullptr padding, which restored the node list. Real module frames stayed swallowed, so the waypoint menu #10920 appended to the end of the chain has never opened on BaseUI. Enter the branch only when a module frame actually has a menu, so anything without one falls through to the frames matched after it. Co-authored-by: Ben Meadors --- src/graphics/Screen.cpp | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 97f8d72786..689004dff8 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -2117,6 +2117,21 @@ int Screen::handleUIFrameEvent(const UIFrameEvent *event) return 0; } +// Only the environmental telemetry frame answers SELECT with a menu. A module frame that has none +// must not claim the press, or every frame matched after it in the dispatch chain is unreachable. +static bool moduleFrameHasMenu(size_t frame) +{ +#if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + // moduleFrames bounds the module-frame region, before favorites are appended; its leading slots + // are nullptr padding for the built-in frames, so only a non-null entry is a real module frame. + const MeshModule *module = frame < moduleFrames.size() ? moduleFrames.at(frame) : nullptr; + return module != nullptr && environmentTelemetryModule != nullptr && environmentTelemetryModule->ownsFrame(module); +#else + (void)frame; + return false; +#endif +} + int Screen::handleInputEvent(const InputEvent *event) { LOG_INPUT("Screen Input event %u! kb %u", event->inputEvent, event->kbchar); @@ -2344,16 +2359,8 @@ int Screen::handleInputEvent(const InputEvent *event) menuHandler::textMessageBaseMenu(); } } - // moduleFrames.size() bounds the module-frame region, before favorites are appended; its leading - // slots are nullptr padding for the built-in frames, so only a non-null entry is a real module frame. - } else if (this->ui->getUiState()->currentFrame < moduleFrames.size() && - moduleFrames.at(this->ui->getUiState()->currentFrame) != nullptr) { -#if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR - const MeshModule *currentModule = moduleFrames.at(this->ui->getUiState()->currentFrame); - if (environmentTelemetryModule != nullptr && environmentTelemetryModule->ownsFrame(currentModule)) { - menuHandler::environmentTelemetryMenu(); - } -#endif + } else if (moduleFrameHasMenu(this->ui->getUiState()->currentFrame)) { + menuHandler::environmentTelemetryMenu(); } else if (framesetInfo.positions.firstFavorite != 255 && this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { From 52d521426b161dd5dbf1c0d0917a9081a0c6d5d2 Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:34:12 +0000 Subject: [PATCH 039/143] Wio Tracker L2: try-fix battery percentage (#11668) * try-fix battery percentage * initialize cached_mv * use AnalogBatteryLevel class to calculate percentage level --- src/Power.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 5b7f17e289..84bf12aaf5 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -762,9 +762,11 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel } else { LOG_WARN("[AW35615] not found at 0x22"); } + getBattVoltage(); // initial read cached_mv return true; } + virtual bool isBatteryConnect() override { return true; } virtual uint16_t getBattVoltage() override { if (!initialized) @@ -800,7 +802,7 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel { if (_aw35615.isReady()) { concurrency::LockGuard guard(spiLock); - return _aw35615.isVbusPresent(); + return _aw35615.isVbusPresent() && cached_mv >= 4200; } // Fallback to base GPIO/board checks (or false) if CC chip is absent return false; @@ -813,7 +815,7 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel if (_aw35615.isReady()) { concurrency::LockGuard guard(spiLock); - return _aw35615.isSinkAttached(); + return _aw35615.isSinkAttached() && cached_mv >= 4200; } return isVbusIn(); } From 7dffd66c59b8ab45933f68192898ae01c012c990 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 31 Aug 2026 16:47:04 +0000 Subject: [PATCH 040/143] fix(radio): recover a chip that lost its state instead of assert-crashing in reconfigure() (#11676) * fix(lr11x0): recover a chip that lost its state instead of assert-crashing in reconfigure() * fix(radio): extend chip-state-loss recovery to SX126x, SX128x, RF95, and LR20x0 --- src/mesh/LR11x0Interface.cpp | 124 +++++++++++++++++++++----- src/mesh/LR11x0Interface.h | 13 +++ src/mesh/LR20x0Interface.cpp | 168 +++++++++++++++++++++-------------- src/mesh/LR20x0Interface.h | 7 ++ src/mesh/RF95Interface.cpp | 123 ++++++++++++++++++------- src/mesh/RF95Interface.h | 9 ++ src/mesh/SX126xInterface.cpp | 119 ++++++++++++++++++------- src/mesh/SX126xInterface.h | 9 ++ src/mesh/SX128xInterface.cpp | 132 ++++++++++++++++++--------- src/mesh/SX128xInterface.h | 10 +++ 10 files changed, 522 insertions(+), 192 deletions(-) diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 8be0b64139..3786dbd678 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -172,6 +172,8 @@ template bool LR11x0Interface::init() res = tryBegin(3, attemptVoltage); } + resolvedTcxoVoltage = attemptVoltage; + // \todo Display actual typename of the adapter, not just `LR11x0` LOG_INFO("LR11x0 init result %d", res); @@ -269,28 +271,32 @@ template bool LR11x0Interface::init() return res == RADIOLIB_ERR_NONE; } -template bool LR11x0Interface::reconfigure() +template int16_t LR11x0Interface::programModemParams() { - RadioLibInterface::reconfigure(); - - // set mode to standby - setStandby(); - // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + int16_t err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + return err; + } err = lora.setBandwidth(bw, wideLora() && (getFreq() > 1000.0f)); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + return err; + } err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setCodingRate(%u) %s%d", cr, radioLibErr, err); + return err; + } err = lora.setSyncWord(syncWord); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setSyncWord %s%d", radioLibErr, err); + return err; + } if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range limitPower(LR1120_MAX_POWER); @@ -299,20 +305,89 @@ template bool LR11x0Interface::reconfigure() } err = lora.setPreambleLength(preambleLength); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + return err; + } err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err); + return err; + } err = lora.setOutputPower(power); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setOutputPower(%d) %s%d", power, radioLibErr, err); + return err; + } // Apply RX gain mode - valid in STDBY, matches resetAGC() pattern err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); if (err != RADIOLIB_ERR_NONE) LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err); + return RADIOLIB_ERR_NONE; +} + +template bool LR11x0Interface::reinitChip() +{ + // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw + // config value, and the recovery path reaches begin() without passing through the params clamp + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + limitPower(LR1120_MAX_POWER); + } else { + limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range + } + + int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, resolvedTcxoVoltage); + if (res == RADIOLIB_ERR_NONE) + res = lora.setCRC(2); + if (res == RADIOLIB_ERR_NONE) + res = lora.setRegulatorDCDC(); + +#ifdef LR11X0_DIO_AS_RF_SWITCH + bool dioAsRfSwitch = true; +#elif defined(ARCH_PORTDUINO) + bool dioAsRfSwitch = portduino_config.has_rfswitch_table; +#else + bool dioAsRfSwitch = false; +#endif + + // setRfSwitchTable() pushed the DIO switch config to the chip when init() called it; a reset chip has + // lost it and begin() does not restore it + if (res == RADIOLIB_ERR_NONE && dioAsRfSwitch) + lora.setRfSwitchTable(rfswitch_dio_pins, rfswitch_table); + + if (res != RADIOLIB_ERR_NONE) + LOG_ERROR("LR11x0 re-init failed %s%d", radioLibErr, res); + return res == RADIOLIB_ERR_NONE; +} + +template bool LR11x0Interface::reconfigure() +{ + RadioLibInterface::reconfigure(); + + // set mode to standby - a chip that lost its state to a reset/brownout can time out here (-707), + // so don't let setStandby()'s assert fire before the recovery below gets a chance + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = programModemParams(); + + if (err != RADIOLIB_ERR_NONE) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration - packet type included - to a chip-internal reset or brownout. + // Recover in place: begin() hardware-resets the chip and restores the LoRa packet type. Crashing + // here instead would reboot before MeshService persists the config change that triggered us. + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + LOG_ERROR("LR11x0 rejected modem params, chip state lost? Full re-init"); + if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 unrecoverable %s%d, radio down until reboot", radioLibErr, err); + return false; + } + LOG_INFO("LR11x0 recovered after re-init"); + } + startReceive(); // restart receiving return true; @@ -323,23 +398,28 @@ template void LR11x0Interface::clearRadioIsr() lora.clearIrqAction(); } -template void LR11x0Interface::setStandby() +template int16_t LR11x0Interface::trySetStandby() { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + int16_t err = lora.standby(); if (err != RADIOLIB_ERR_NONE) { LOG_DEBUG("LR11x0 standby failed, err %d", err); } - assert(err == RADIOLIB_ERR_NONE); - isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void LR11x0Interface::setStandby() +{ + int16_t err = trySetStandby(); + assert(err == RADIOLIB_ERR_NONE); } /** diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index 9280c05dee..c1e48ae72c 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -78,5 +78,18 @@ template class LR11x0Interface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + + private: + /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ + int16_t programModemParams(); + + /** Reset and re-begin() a chip that lost its runtime configuration (reset/brownout) */ + bool reinitChip(); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); + + /// The TCXO Vref that init() settled on, so reinitChip() can begin() with the same oscillator setup + float resolvedTcxoVoltage = 0; }; #endif \ No newline at end of file diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index dcc514041e..587bead397 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -192,8 +192,101 @@ template bool LR20x0Interface::reconfigure() if (bandHop) { LOG_INFO("LR20x0 LF/HF band hop %.1f -> %.1f MHz, full begin()", lr20x0LastFreqMHz, freq); - setStandby(); + // fullBegin() hardware-resets the chip, so a standby failure is survivable here + (void)trySetStandby(); + if (!fullBegin(freq)) + return false; + + startReceive(); + return true; + } + + // Same-band reconfigure (previous incremental path) + int16_t standbyErr = trySetStandby(); + if (standbyErr != RADIOLIB_ERR_NONE) + success = false; + + if (standbyErr == RADIOLIB_ERR_NONE) { + int err = lora.setFrequency(freq); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setFrequency %.3f MHz %s%d", freq, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + } + + err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + } + + err = lora.setBandwidth(bw); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + } + + err = lora.setCodingRate(cr, cr != 7); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setCodingRate(%u) %s%d", cr, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + } + + err = lora.setSyncWord(syncWord); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setSyncWord %s%d", radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + } + + err = lora.setPreambleLength(preambleLength); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + } + + err = lora.setOutputPower(power); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setOutputPower %d dBm @ %.3f MHz %s%d", power, freq, radioLibErr, err); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + } + + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + if (err != RADIOLIB_ERR_NONE) { + LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err); + success = false; + } + } + + if (!success) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration to a chip-internal reset or brownout. Recover in place with the + // same full begin() the band-hop path uses - it hardware-resets the chip. Crashing here instead + // would reboot before MeshService persists the config change that triggered us. + LOG_ERROR("LR20x0 rejected modem params, chip state lost? Full re-init"); + if (!fullBegin(freq)) { + LOG_ERROR("LR20x0 unrecoverable, radio down until reboot"); + return false; + } + LOG_INFO("LR20x0 recovered after re-init"); + } + + startReceive(); + lr20x0LastFreqMHz = freq; + return true; +} + +// The chip-side re-init the band-hop and recovery paths share: front-end switch GPIOs for the target +// band, a fresh begin() (which hardware-resets the chip), CRC, DIO RF-switch table, and RX gain. +template bool LR20x0Interface::fullBegin(float freq) +{ + { // Match init(): external LF/HF front-end GPIOs (if board defines them). #ifdef LR2021_RF_SWITCH_SUBGHZ pinMode(LR2021_RF_SWITCH_SUBGHZ, OUTPUT); @@ -259,68 +352,8 @@ template bool LR20x0Interface::reconfigure() return false; } - startReceive(); return true; } - - // Same-band reconfigure (previous incremental path) - setStandby(); - - int err = lora.setFrequency(freq); - if (err != RADIOLIB_ERR_NONE) { - LOG_ERROR("LR20x0 setFrequency %.3f MHz %s%d", freq, radioLibErr, err); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setCodingRate(cr, cr != 7); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) { - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setOutputPower(power); - if (err != RADIOLIB_ERR_NONE) { - LOG_ERROR("LR20x0 setOutputPower %d dBm @ %.3f MHz %s%d", power, freq, radioLibErr, err); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; - } - - err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); - if (err != RADIOLIB_ERR_NONE) { - LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err); - success = false; - } - - if (success) { - startReceive(); - lr20x0LastFreqMHz = freq; - } - return success; } template void LR20x0Interface::clearRadioIsr() @@ -328,23 +361,28 @@ template void LR20x0Interface::clearRadioIsr() lora.clearIrqAction(); } -template void LR20x0Interface::setStandby() +template int16_t LR20x0Interface::trySetStandby() { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + int16_t err = lora.standby(); if (err != RADIOLIB_ERR_NONE) { LOG_DEBUG("LR20x0 standby failed, err %d", err); } - assert(err == RADIOLIB_ERR_NONE); - isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void LR20x0Interface::setStandby() +{ + int16_t err = trySetStandby(); + assert(err == RADIOLIB_ERR_NONE); } /** diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index ed04dfb0e1..df69bf1a2d 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -73,5 +73,12 @@ template class LR20x0Interface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + + private: + /** Chip-side re-init shared by the band-hop and recovery paths: front-end GPIOs, begin(), CRC, RF switch, RX gain */ + bool fullBegin(float freq); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); }; #endif diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 909d47e23e..0be5feee92 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -127,8 +127,6 @@ bool RF95Interface::init() power = dacDbValues.db; #endif - limitPower(RF95_MAX_POWER); - lora.reset(new RadioLibRF95(&module)); iface = lora.get(); @@ -181,6 +179,26 @@ bool RF95Interface::init() #endif setTransmitEnable(false); +#if defined(RADIOMASTER_900_BANDIT_NANO) || defined(RADIOMASTER_900_BANDIT) + LOG_INFO("DAC output set to %d", powerDAC); +#endif + + if (!reinitChip()) + return false; + + startReceive(); // start receiving + + return true; +} + +// begin() and the chip-side setup that a reset chip loses. Shared by init() and by reconfigure()'s +// recovery of a chip that lost its state. +bool RF95Interface::reinitChip() +{ + // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw + // config value, and the recovery path reaches begin() without passing through the params clamp + limitPower(RF95_MAX_POWER); + int res = lora->begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength); LOG_INFO("RF95 init result %d", res); if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED) @@ -189,16 +207,12 @@ bool RF95Interface::init() LOG_INFO("Frequency set to %f", getFreq()); LOG_INFO("Bandwidth set to %f", bw); LOG_INFO("Power output set to %d", power); -#if defined(RADIOMASTER_900_BANDIT_NANO) || defined(RADIOMASTER_900_BANDIT) - LOG_INFO("DAC output set to %d", powerDAC); -#endif if (res == RADIOLIB_ERR_NONE) res = lora->setCRC(RADIOLIB_SX126X_LORA_CRC_ON); - if (res == RADIOLIB_ERR_NONE) - startReceive(); // start receiving - + if (res != RADIOLIB_ERR_NONE) + LOG_ERROR("RF95 re-init failed %s%d", radioLibErr, res); return res == RADIOLIB_ERR_NONE; } @@ -207,44 +221,50 @@ void RF95Interface::clearRadioIsr() lora->clearDio0Action(); } -bool RF95Interface::reconfigure() +int16_t RF95Interface::programModemParams() { - RadioLibInterface::reconfigure(); - - // set mode to standby - setStandby(); - // configure publicly accessible settings - int err = lora->setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + int16_t err = lora->setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + return err; + } err = lora->setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + return err; + } err = lora->setCodingRate(cr); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setCodingRate(%u) %s%d", cr, radioLibErr, err); + return err; + } err = lora->setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("RF95 setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; + } err = lora->setCurrentLimit(currentLimit); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("RF95 setCurrentLimit %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; + } err = lora->setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("RF95 setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + return err; + } err = lora->setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err); + return err; + } limitPower(RF95_MAX_POWER); @@ -253,8 +273,37 @@ bool RF95Interface::reconfigure() #else err = lora->setOutputPower(power); #endif - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 setOutputPower(%d) %s%d", power, radioLibErr, err); + return err; + } + + return RADIOLIB_ERR_NONE; +} + +bool RF95Interface::reconfigure() +{ + RadioLibInterface::reconfigure(); + + // set mode to standby - a chip that lost its state to a reset/brownout can fail here, + // so don't let setStandby()'s assert fire before the recovery below gets a chance + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = programModemParams(); + + if (err != RADIOLIB_ERR_NONE) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration to a chip-internal reset or brownout. Recover in place: + // begin() reprograms the chip. Crashing here instead would reboot before MeshService persists + // the config change that triggered us. RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + LOG_ERROR("RF95 rejected modem params, chip state lost? Full re-init"); + if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 unrecoverable %s%d, radio down until reboot", radioLibErr, err); + return false; + } + LOG_INFO("RF95 recovered after re-init"); + } startReceive(); // restart receiving @@ -272,17 +321,23 @@ void RF95Interface::addReceiveMetadata(meshtastic_MeshPacket *mp) LOG_DEBUG("Corrected frequency offset: %f", lora->getFrequencyError()); } -void RF95Interface::setStandby() +int16_t RF95Interface::trySetStandby() { - int err = lora->standby(); + int16_t err = lora->standby(); if (err != RADIOLIB_ERR_NONE) LOG_ERROR("RF95 standby %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); isReceiving = false; // If we were receiving, not any more disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +void RF95Interface::setStandby() +{ + int16_t err = trySetStandby(); + assert(err == RADIOLIB_ERR_NONE); } /** We override to turn on transmitter power as needed. diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index 2cd4835720..b1dd383197 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -78,5 +78,14 @@ class RF95Interface : public RadioLibInterface private: /** Some boards require GPIO control of tx vs rx paths */ void setTransmitEnable(bool txon); + + /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ + int16_t programModemParams(); + + /** begin() and chip-side setup, shared by init() and by reconfigure()'s recovery of a chip that lost its state */ + bool reinitChip(); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); }; #endif diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 2400a8e03f..7bc6bfb66f 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -81,16 +81,32 @@ template bool SX126xInterface::init() else LOG_DEBUG("SX126X_DIO3_TCXO_VOLTAGE defined, DIO3 as TCXO Vref %f V", tcxoVoltage); setTransmitEnable(false); - // FIXME: May want to set depending on a definition, currently all SX126x variant files use the DC-DC regulator option - bool useRegulatorLDO = false; // Seems to depend on the connection to pin 9/DCC_SW - if an inductor DCDC? RadioLibInterface::init(); + if (!reinitChip()) + return false; + + startReceive(); // start receiving + + return true; +} + +// begin() and the chip-side setup that a reset chip loses: begin() hardware-resets the chip, then +// PA ramp, OCP limit, DIO2-as-RF-switch, RF switch pins, RX gain, the 0x8B5 RX patch, and CRC are +// reprogrammed. Shared by init() and by reconfigure()'s recovery path. +template bool SX126xInterface::reinitChip() +{ + // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw + // config value, and the recovery path reaches begin() without passing through the params clamp limitPower(SX126X_MAX_POWER); // Make sure we reach the minimum power supported to turn the chip on (-9dBm) if (power < -9) power = -9; + // FIXME: May want to set depending on a definition, currently all SX126x variant files use the DC-DC regulator option + bool useRegulatorLDO = false; // Seems to depend on the connection to pin 9/DCC_SW - if an inductor DCDC? + int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage, useRegulatorLDO); #ifdef SX126X_PA_RAMP_US @@ -182,50 +198,55 @@ template bool SX126xInterface::init() res = lora.setOutputPower(power, false); #endif - if (res == RADIOLIB_ERR_NONE) - startReceive(); // start receiving - + if (res != RADIOLIB_ERR_NONE) + LOG_ERROR("SX126x re-init failed %s%d", radioLibErr, res); return res == RADIOLIB_ERR_NONE; } -template bool SX126xInterface::reconfigure() +template int16_t SX126xInterface::programModemParams() { - RadioLibInterface::reconfigure(); - - // set mode to standby - setStandby(); - // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + int16_t err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + return err; + } err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + return err; + } err = lora.setCodingRate(cr); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setCodingRate(%u) %s%d", cr, radioLibErr, err); + return err; + } err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; + } err = lora.setCurrentLimit(currentLimit); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X setCurrentLimit %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; + } err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX126X setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + return err; + } err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err); + return err; + } limitPower(SX126X_MAX_POWER); // Make sure we reach the minimum power supported to turn the chip on (-9dBm) @@ -249,6 +270,33 @@ template bool SX126xInterface::reconfigure() if (err != RADIOLIB_ERR_NONE) LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err); + return RADIOLIB_ERR_NONE; +} + +template bool SX126xInterface::reconfigure() +{ + RadioLibInterface::reconfigure(); + + // set mode to standby - a chip that lost its state to a reset/brownout can time out here (-707), + // so don't let setStandby()'s assert fire before the recovery below gets a chance + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = programModemParams(); + + if (err != RADIOLIB_ERR_NONE) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration - packet type included - to a chip-internal reset or brownout. + // Recover in place: begin() hardware-resets the chip and restores the LoRa packet type. Crashing + // here instead would reboot before MeshService persists the config change that triggered us. + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + LOG_ERROR("SX126x rejected modem params, chip state lost? Full re-init"); + if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126x unrecoverable %s%d, radio down until reboot", radioLibErr, err); + return false; + } + LOG_INFO("SX126x recovered after re-init"); + } + startReceive(); // restart receiving return true; @@ -316,25 +364,34 @@ template void SX126xInterface::handleSoftwareLoraIrqPoll() } #endif -template void SX126xInterface::setStandby() +template int16_t SX126xInterface::trySetStandby() { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + int16_t err = lora.standby(); if (err != RADIOLIB_ERR_NONE) LOG_DEBUG("SX126x standby %s%d", radioLibErr, err); #ifdef ARCH_PORTDUINO if (err != RADIOLIB_ERR_NONE) portduino_status.LoRa_in_error = true; -#else - assert(err == RADIOLIB_ERR_NONE); #endif isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void SX126xInterface::setStandby() +{ + int16_t err = trySetStandby(); +#ifdef ARCH_PORTDUINO + (void)err; +#else + assert(err == RADIOLIB_ERR_NONE); +#endif } /** diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index 9465064b8a..b683eff136 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -90,5 +90,14 @@ template class SX126xInterface : public RadioLibInterface #endif /** Some boards require GPIO control of tx vs rx paths */ void setTransmitEnable(bool txon); + + /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ + int16_t programModemParams(); + + /** begin() and chip-side setup, shared by init() and by reconfigure()'s recovery of a chip that lost its state */ + bool reinitChip(); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); }; #endif \ No newline at end of file diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index bb1d890247..a73f0b44d3 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -62,6 +62,20 @@ template bool SX128xInterface::init() RadioLibInterface::init(); + if (!reinitChip()) + return false; + + startReceive(); // start receiving + + return true; +} + +// begin() and the chip-side setup that a reset chip loses. Shared by init() and by reconfigure()'s +// recovery of a chip that lost its state. +template bool SX128xInterface::reinitChip() +{ + // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw + // config value, and the recovery path reaches begin() without passing through the params clamp limitPower(SX128X_MAX_POWER); preambleLength = 12; // 12 is the default for this chip, 32 does not RX at all @@ -104,52 +118,84 @@ template bool SX128xInterface::init() if (res == RADIOLIB_ERR_NONE) res = lora.setCRC(2); - if (res == RADIOLIB_ERR_NONE) - startReceive(); // start receiving - + if (res != RADIOLIB_ERR_NONE) + LOG_ERROR("SX128x re-init failed %s%d", radioLibErr, res); return res == RADIOLIB_ERR_NONE; } +template int16_t SX128xInterface::programModemParams() +{ + // configure publicly accessible settings + int16_t err = lora.setSpreadingFactor(sf); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); + return err; + } + + err = lora.setBandwidth(bw); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setBandwidth(%.1f) %s%d", bw, radioLibErr, err); + return err; + } + + err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setCodingRate(%u) %s%d", cr, radioLibErr, err); + return err; + } + + err = lora.setSyncWord(syncWord); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setSyncWord %s%d", radioLibErr, err); + return err; + } + + err = lora.setPreambleLength(preambleLength); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); + return err; + } + + err = lora.setFrequency(getFreq()); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err); + return err; + } + + limitPower(SX128X_MAX_POWER); + + err = lora.setOutputPower(power); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X setOutputPower(%d) %s%d", power, radioLibErr, err); + return err; + } + + return RADIOLIB_ERR_NONE; +} + template bool SX128xInterface::reconfigure() { RadioLibInterface::reconfigure(); - // set mode to standby - setStandby(); + // set mode to standby - a chip that lost its state to a reset/brownout can time out here, + // so don't let setStandby()'s assert fire before the recovery below gets a chance + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = programModemParams(); - // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { + // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has + // lost its runtime configuration - packet type included - to a chip-internal reset or brownout. + // Recover in place: begin() hardware-resets the chip and restores the LoRa packet type. Crashing + // here instead would reboot before MeshService persists the config change that triggered us. RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); - - err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); - - err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - limitPower(SX128X_MAX_POWER); - - err = lora.setOutputPower(power); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setOutputPower %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + LOG_ERROR("SX128x rejected modem params, chip state lost? Full re-init"); + if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128x unrecoverable %s%d, radio down until reboot", radioLibErr, err); + return false; + } + LOG_INFO("SX128x recovered after re-init"); + } startReceive(); // restart receiving @@ -166,15 +212,14 @@ template bool SX128xInterface::wideLora() return true; } -template void SX128xInterface::setStandby() +template int16_t SX128xInterface::trySetStandby() { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + int16_t err = lora.standby(); if (err != RADIOLIB_ERR_NONE) LOG_ERROR("SX128x standby %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); #if ARCH_PORTDUINO if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { digitalWrite(portduino_config.lora_rxen_pin.pin, LOW); @@ -195,6 +240,13 @@ template void SX128xInterface::setStandby() disableInterrupt(); completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void SX128xInterface::setStandby() +{ + int16_t err = trySetStandby(); + assert(err == RADIOLIB_ERR_NONE); } /** diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index 3b9015249e..1857d4ced5 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -74,4 +74,14 @@ template class SX128xInterface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + + private: + /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ + int16_t programModemParams(); + + /** begin() and chip-side setup, shared by init() and by reconfigure()'s recovery of a chip that lost its state */ + bool reinitChip(); + + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ + int16_t trySetStandby(); }; From b8faaaf54b949d2e049b3f8b2ae18c0a8b596995 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 31 Aug 2026 18:56:05 +0000 Subject: [PATCH 041/143] fix(fs): size the files manifest with a malloc probe, not a heap walk (#11667) heap_caps_get_largest_free_block() walks every TLSF block of every matching heap while holding the allocator lock. On ESP32-S3 boards with PSRAM in the malloc pool, that walk runs long enough during the config handshake that WiFi RX on the other core blocks in wifi_malloc() and the interrupt watchdog reboots the node. Use the bounded malloc() probe (already the non-ESP32 path) on every target instead: TLSF malloc is O(1), so the allocator lock is only held momentarily. Touch the probe through a volatile pointer so LTO cannot elide the malloc()/free() pair. Fixes #11666 --- src/FSCommon.cpp | 37 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index ef0d5841ad..0bba740116 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -133,9 +133,6 @@ bool renameFile(const char *pathFrom, const char *pathTo) #include #include #include -#ifdef ARCH_ESP32 -#include -#endif /** * @brief Platform-agnostic filesystem format / wipe. @@ -253,12 +250,6 @@ void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vec } // namespace #endif -#ifdef ARCH_ESP32 -// Headroom kept below the allocator's largest free block when sizing the manifest: the block reported -// includes the allocator's own bookkeeping, and other tasks keep allocating while the SPI lock is held. -static constexpr size_t FILES_MANIFEST_HEAP_MARGIN = 1024; -#endif - /** * @brief Get the list of files in a directory. * @@ -286,32 +277,24 @@ std::vector getFiles(const char *dirname, uint8_t levels, s // Cap at what a vector of FileInfo can hold at all: it keeps the probe's byte count from wrapping // for a huge maxCount, and it is also the bound reserve() would otherwise reject with a throw. size_t reservedCount = std::min(maxCount, filenames.max_size()); -#ifdef ARCH_ESP32 - // Ask the allocator for the largest contiguous block malloc() could hand out. MALLOC_CAP_DEFAULT - // is the capability heap_caps_malloc_default() (what operator new resolves to) falls back to - // across every region, internal and PSRAM alike, so this is the "will new succeed" question - // asked directly. Nothing is freed before the reserve, so there is no hole for another task to - // take between the probe and the allocation. - const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); - // Leave a margin below the largest block: the allocator's own overhead sits inside it, and other - // threads keep allocating while we hold the SPI lock. - const size_t usable = largest > FILES_MANIFEST_HEAP_MARGIN ? largest - FILES_MANIFEST_HEAP_MARGIN : 0; - reservedCount = std::min(reservedCount, usable / sizeof(meshtastic_FileInfo)); -#else - // Other targets have no largest-block query. Probe with malloc() - the allocation that returns - // nullptr on failure under every build (new(std::nothrow) is not that: libstdc++ implements it as - // a try/catch around the throwing form) - free the probe, and reserve the size that fit. Not - // airtight against a concurrent allocator, but the SPI lock the caller holds serialises the usual - // competitors and it is strictly better than letting reserve() be the first to find out. + // Probe with malloc() - the allocation that returns nullptr on failure under every build (new(std::nothrow) + // is not that: libstdc++ implements it as a try/catch around the throwing form) - free the probe, and + // reserve the size that fit. Not airtight against a concurrent allocator, but the SPI lock the caller holds + // serialises the usual competitors and it is strictly better than letting reserve() be the first to find + // out. On ESP32 do NOT replace this with heap_caps_get_largest_free_block(): it walks every TLSF block of + // every matching heap while holding the allocator lock, and on PSRAM boards that walk blocks wifi_malloc() + // on the other core long enough to trip the interrupt watchdog (#11666). while (reservedCount > 0) { void *probe = malloc(reservedCount * sizeof(meshtastic_FileInfo)); if (probe) { + // Observable access so LTO cannot elide the malloc()/free() pair and turn the probe + // into a compile-time yes. + *static_cast(probe) = 0; free(probe); break; } reservedCount /= 2; } -#endif if (reservedCount == 0) { if (wasLimited) *wasLimited = true; From 47db0e3020a608e06fb65cce70cd2f093021bd82 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 31 Aug 2026 19:38:35 +0000 Subject: [PATCH 042/143] fix(admin): don't disable BLE on config paths that never reboot (#11651) Three places took BLE down and left nothing to bring it back. The nRF52 auto-re-advertise bug masked them by restoring advertising ~1s later; with that fixed (#11650) the outage is real, lasting until the next PowerFSM transition - up to screen_on_secs, 10 minutes on a default client. - restore_preferences passed 1000 to reboot(), which takes seconds, arming the reset ~16.7 minutes out instead of the intended ~1s. With BLE disabled for a pending reboot the node was unreachable for that whole window. Use DEFAULT_REBOOT_SECONDS and disable before arming, matching the factory and nodedb reset paths. - mesh_beacon sets shouldReboot=false but was not in the list that spares a variant from the blanket disable, unlike statusmessage. Add it. - MQTT and Serial disable BLE inside their own case, bypassing the transaction check above them. Inside an edit transaction saveChanges() defers the reboot, so BLE went down with no restore - reachable today by importing a device profile containing either module config. Build: heltec-mesh-node-t096. Tests: test_module_config 3/3. --- src/modules/AdminModule.cpp | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 07d55b7483..4995f3c822 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -653,8 +653,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta SEGMENT_DEVICESTATE | SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_CHANNELS)) { myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp); LOG_DEBUG("Rebooting after preferences restore"); - reboot(1000); disableBluetooth(); + reboot(DEFAULT_REBOOT_SECONDS); } else { myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); } @@ -1233,10 +1233,12 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) { bool shouldReboot = true; - // If we are in an open transaction or configuring MQTT or Serial (which have validation), defer disabling Bluetooth - // Otherwise, disable Bluetooth to prevent the phone from interfering with the config - if (!hasOpenEditTransaction && !IS_ONE_OF(c.which_payload_variant, meshtastic_ModuleConfig_mqtt_tag, - meshtastic_ModuleConfig_serial_tag, meshtastic_ModuleConfig_statusmessage_tag)) { + // Skip the variants that must not lose BLE here: MQTT and Serial validate first and disable it + // themselves, and statusmessage/mesh_beacon never reboot, so a disable would strand BLE until the + // next PowerFSM transition. Everything else reboots, so take BLE down before the phone interferes. + if (!hasOpenEditTransaction && + !IS_ONE_OF(c.which_payload_variant, meshtastic_ModuleConfig_mqtt_tag, meshtastic_ModuleConfig_serial_tag, + meshtastic_ModuleConfig_statusmessage_tag, meshtastic_ModuleConfig_mesh_beacon_tag)) { disableBluetooth(); } @@ -1250,8 +1252,10 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) if (!MQTT::isValidConfig(c.payload_variant.mqtt)) { return false; } - // Disable Bluetooth to prevent interference during MQTT configuration - disableBluetooth(); + // Disable Bluetooth to prevent interference during MQTT configuration, except inside an edit + // transaction: saveChanges() defers the reboot there, so nothing would bring BLE back. + if (!hasOpenEditTransaction) + disableBluetooth(); moduleConfig.has_mqtt = true; { char prevPass[sizeof(moduleConfig.mqtt.password)]; @@ -1269,7 +1273,9 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) LOG_ERROR("Invalid serial config"); return false; } - disableBluetooth(); // Disable Bluetooth to prevent interference during Serial configuration + // Same transaction caveat as MQTT above: a deferred reboot would leave BLE down with no restore. + if (!hasOpenEditTransaction) + disableBluetooth(); // Disable Bluetooth to prevent interference during Serial configuration moduleConfig.has_serial = true; moduleConfig.serial = c.payload_variant.serial; break; From 3683566f62575f96ba47e2ee5b51b4fbc27e2d10 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:45:42 +0000 Subject: [PATCH 043/143] Don't show new message banner on message screen (#11671) * message banner * Update MessageRenderer.cpp * Fix message banner suppression race on Portduino --- src/graphics/Screen.cpp | 10 ++++++++++ src/graphics/Screen.h | 5 +++++ src/graphics/draw/MessageRenderer.cpp | 3 ++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 689004dff8..634f1e4279 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1083,6 +1083,7 @@ int32_t Screen::runOnce() { // If we don't have a screen, don't ever spend any CPU for us. if (!useDisplay) { + textMessageFrameShown = false; enabled = false; return RUN_SAME; } @@ -1223,6 +1224,7 @@ int32_t Screen::runOnce() if (!screenOn) { // If we didn't just wake and the screen is still off, then // stop updating until it is on again + textMessageFrameShown = false; enabled = false; return 0; } @@ -1276,6 +1278,9 @@ int32_t Screen::runOnce() } } + textMessageFrameShown = showingNormalScreen && framesetInfo.positions.textMessage != 255 && ui && + ui->getUiState()->currentFrame == framesetInfo.positions.textMessage; + // LOG_DEBUG("want fps %d, fixed=%d", targetFramerate, // ui->getUiState()->frameState); If we are scrolling we need to be called // soon, otherwise just 1 fps (to save CPU) We also ask to be called twice @@ -2411,6 +2416,11 @@ bool Screen::isOverlayBannerShowing() return NotificationRenderer::isOverlayBannerShowing(); } +bool Screen::isTextMessageFrameShown() const +{ + return textMessageFrameShown.load(); +} + bool Screen::isGamesFrameShown() { return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games; diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index e7a77942f5..18aea4fe4c 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -5,6 +5,7 @@ #include "detect/ScanI2C.h" #include "mesh/generated/meshtastic/config.pb.h" #include +#include #include #include #include @@ -277,6 +278,9 @@ class Screen : public concurrency::OSThread bool isOverlayBannerShowing(); + // Thread-safe snapshot of whether the text-message frame is currently shown. + bool isTextMessageFrameShown() const; + // True if the always-present games frame is the one currently on screen. Lets the games module // ignore D-pad input when the player has navigated to a different frame. bool isGamesFrameShown(); @@ -801,6 +805,7 @@ class Screen : public concurrency::OSThread // Whether we are showing the regular screen (as opposed to booth screen or // Bluetooth PIN screen) bool showingNormalScreen = false; + std::atomic textMessageFrameShown{false}; /// Track USB power state to only wake screen on actual power state changes bool lastPowerUSBState = false; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index acfc4d11b6..957c3e6cbd 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -1132,7 +1132,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht { if (packet.from != 0) { hasUnreadMessage = true; - const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive(); + const bool suppressBanner = + (cannedMessageModule && cannedMessageModule->isFreeTextActive()) || (screen && screen->isTextMessageFrameShown()); // Don't let the pop-up clobber a menu/picker the user is interacting with; the wake below // still happens so a message can light the screen back up. const bool menuShowing = NotificationRenderer::isMenuShowing(); From b823c8d7fe9f11880f1a2b92f9a3b55565881cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 1 Sep 2026 08:50:39 +0000 Subject: [PATCH 044/143] Let a module hold the screen (#11631) * Let a module hold the screen Screen::setModalModule() marks one module as owning the screen, honoured at the three places that would otherwise take it away: the carousel advance in runOnce(), the new-message banner in handleNewMessage(), and Cmd::STOP_ALERT_FRAME, which any caller can currently fire to cancel any alert frame regardless of who started it. Only the owning pointer can release it, so a module with a modal state no longer has to patch Screen.cpp to keep an alert from vanishing when a chat message arrives. The default is nullptr and no in-tree caller sets it, so every existing build behaves exactly as before. * Address review: clear pauseBanner even while a module holds the screen START_ALERT_FRAME sets NotificationRenderer::pauseBanner and STOP_ALERT_FRAME is the only thing that clears it, so swallowing the whole command left banners suppressed for good once a module took the screen. Only the setFrames() teardown is now gated on the modal owner. * Take the modal owner as a pointer to const Screen never dereferences it; the pointer is only stored and compared, so const is what the parameter and the member both mean. Fixes the cppcheck constParameterPointer defect on clearModalModule(). * Add isShowingModuleFrame() so a module can claim keys on its own frame Input observers registered by modules run before Screen's, so a module that handles UP/DOWN has to know whether its own frame is the one being looked at, or it takes the key away from the frame that is. moduleFrames is already index-aligned with the frame list for drawModuleFrame(), so the check is a lookup against the current frame. * Address review: match drawModuleFrame's frame selection, trim the comment Mid-transition drawModuleFrame() renders transitionFrameTarget, so comparing only currentFrame reported false while the module's frame was actually on screen and its input observer would have ignored keys. The header comment is back inside the two-line limit. --- src/graphics/Screen.cpp | 19 ++++++++++++++++++- src/graphics/Screen.h | 23 +++++++++++++++++++++++ src/graphics/draw/MessageRenderer.cpp | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 634f1e4279..b9309f5308 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1203,7 +1203,11 @@ int32_t Screen::runOnce() handleStartFirmwareUpdateScreen(); break; case Cmd::STOP_ALERT_FRAME: + // Cleared even while a module holds the screen: START_ALERT_FRAME set it and nothing + // else would, so swallowing it here would leave banners suppressed for good. NotificationRenderer::pauseBanner = false; + if (hasModalModule()) + break; // only the owning module may take the screen back off its own frame // Return from one-off alert mode back to regular frames. if (!showingNormalScreen && NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { setFrames(); @@ -1262,7 +1266,7 @@ int32_t Screen::runOnce() // standard screen switching is stopped. if (showingNormalScreen) { // standard screen loop handling here - if (config.display.auto_screen_carousel_secs > 0 && + if (config.display.auto_screen_carousel_secs > 0 && !hasModalModule() && NotificationRenderer::current_notification_type != notificationTypeEnum::text_input && !Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) { @@ -1861,6 +1865,19 @@ void Screen::applyHiddenFramesMask(uint32_t mask) hiddenFrames.chirpy = getBit(mask, FVBIT_CHIRPY); } +bool Screen::isShowingModuleFrame(const MeshModule *m) const +{ + if (!m || !showingNormalScreen) + return false; + // Same effective frame drawModuleFrame() picks: mid-transition the incoming frame is the one + // being rendered, so comparing currentFrame would report false while the module is on screen. + const OLEDDisplayUiState *state = ui->getUiState(); + uint8_t frame = state->currentFrame; + if (state->frameState == IN_TRANSITION && state->transitionFrameRelationship == TransitionRelationship_INCOMING) + frame = state->transitionFrameTarget; + return frame < moduleFrames.size() && moduleFrames.at(frame) == m; +} + void Screen::loadFrameVisibility() { #ifdef FSCom diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 18aea4fe4c..584238170c 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -47,6 +47,8 @@ struct BannerOverlayOptions { bool shouldWakeOnReceivedMessage(); +class MeshModule; + #if !HAS_SCREEN #include "Power.h" namespace graphics @@ -74,6 +76,10 @@ class Screen void increaseBrightness() {} void decreaseBrightness() {} void startAlert(const char *) {} + void setModalModule(const MeshModule *) {} + void clearModalModule(const MeshModule *) {} + bool hasModalModule() const { return false; } + bool isShowingModuleFrame(const MeshModule *) const { return false; } void showSimpleBanner(const char *message, uint32_t durationMs = 0) {} void showOverlayBanner(BannerOverlayOptions) {} void setFrames(FrameFocus focus) {} @@ -342,6 +348,20 @@ class Screen : public concurrency::OSThread enqueueCmd(cmd); } + // Holds the screen against the carousel, the new-message banner and a foreign endAlert(). + // Only the owner can release it, unlike endAlert(), which any caller can fire. + void setModalModule(const MeshModule *owner) { modalModule = owner; } + void clearModalModule(const MeshModule *owner) + { + if (modalModule == owner) + modalModule = nullptr; + } + bool hasModalModule() const { return modalModule != nullptr; } + + // True while this module's own frame is on screen. Modules observe input before Screen does, + // so one handling keys needs this or it takes them from the frame the user is looking at. + bool isShowingModuleFrame(const MeshModule *m) const; + void showSimpleBanner(const char *message, uint32_t durationMs = 0); void showOverlayBanner(BannerOverlayOptions); @@ -684,6 +704,9 @@ class Screen : public concurrency::OSThread uint16_t displayHeight = 0; private: + // nullptr for every build with no modal module, which is why the three sites are unchanged. + const MeshModule *modalModule = nullptr; + FrameCallback alertFrames[1]; struct ScreenCmd { Cmd cmd; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 957c3e6cbd..2e023dc843 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -1231,7 +1231,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht screen->setOn(true); } - if (!suppressBanner && !menuShowing) { + if (!suppressBanner && !menuShowing && !screen->hasModalModule()) { screen->showSimpleBanner(banner, inThread ? 1000 : 3000); } } From 427ed0f1a04e8454bd20e626d4b72158ebca56ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 1 Sep 2026 11:13:30 +0000 Subject: [PATCH 045/143] Load optional modules dropped into src/modules/optional/ (#11673) * Load optional modules dropped into src/modules/optional/ bin/optional-modules.py scans src/modules/optional/ for a directory / holding .h and generates $BUILD_DIR/OptionalModules.h with an include and a setup() call for each, which Modules.cpp picks up through __has_include. The directory does not exist in a stock checkout, so a stock build generates a header that defines nothing, OPTIONAL_MODULES_SETUP compiles away, and nothing is registered. Sources under the directory are already covered by the default recursive build_src_filter, so dropping a module in needs no platformio.ini edit. * Address review: skip a module directory that is not a usable identifier The directory name becomes a setup() call, so foo-bar/ would have generated setupfoo-bar() and failed to compile with the error pointing at generated code rather than at the directory. Names that cannot form an identifier are now skipped with a message that names the directory. --- bin/optional-modules.py | 66 +++++++++++++++++++++++++++++++++++++++++ platformio.ini | 1 + src/modules/Modules.cpp | 10 +++++++ 3 files changed, 77 insertions(+) create mode 100644 bin/optional-modules.py diff --git a/bin/optional-modules.py b/bin/optional-modules.py new file mode 100644 index 0000000000..443a608084 --- /dev/null +++ b/bin/optional-modules.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# trunk-ignore-all(ruff/F821) +# trunk-ignore-all(flake8/F821): For SConstruct imports +# +# Registers optional modules dropped into src/modules/optional/. +# +# A module is a directory / holding .h, which declares `void setup();`. This +# writes $BUILD_DIR/OptionalModules.h with an include and a setup call for each one found; +# Modules.cpp picks that header up through __has_include and calls OPTIONAL_MODULES_SETUP(). +# +# src/modules/optional/ does not exist in a stock checkout, so a stock build generates a header that +# defines nothing and registers nothing. Sources under the directory are compiled by the default +# recursive build_src_filter, so dropping a module in needs no platformio.ini edit. +import os +import re + +Import("env") + +# The directory name becomes a C++ call, so it has to be a usable identifier. +identifier = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +optionalDir = os.path.join(env["PROJECT_DIR"], "src", "modules", "optional") + +names = [] +if os.path.isdir(optionalDir): + for entry in sorted(os.listdir(optionalDir)): + entryDir = os.path.join(optionalDir, entry) + if not os.path.isdir(entryDir): + continue + if not identifier.match(entry): + print(f"optional-modules: skipping {entry}/, setup{entry}() is not a valid identifier") + elif os.path.isfile(os.path.join(entryDir, entry + ".h")): + names.append(entry) + else: + print(f"optional-modules: skipping {entry}/, no {entry}.h") + +lines = ["// Generated by bin/optional-modules.py. Do not edit.", "#pragma once", ""] +for name in names: + lines.append(f'#include "modules/optional/{name}/{name}.h"') +if names: + lines.append("") + lines.append("#define OPTIONAL_MODULES_SETUP() \\") + lines.append(" do { \\") + for name in names: + lines.append(f" setup{name}(); \\") + lines.append(" } while (0)") +lines.append("") +content = "\n".join(lines) + +buildDir = env.subst("$BUILD_DIR") +os.makedirs(buildDir, exist_ok=True) +header = os.path.join(buildDir, "OptionalModules.h") + +# Rewrite only on a change, so an unchanged set of modules does not keep rebuilding Modules.cpp. +previous = None +if os.path.isfile(header): + with open(header, encoding="utf-8") as f: + previous = f.read() +if previous != content: + with open(header, "w", encoding="utf-8") as f: + f.write(content) + +env.Append(CPPPATH=[buildDir]) + +if names: + print("optional-modules: " + ", ".join(names)) diff --git a/platformio.ini b/platformio.ini index c819994102..64d0306aef 100644 --- a/platformio.ini +++ b/platformio.ini @@ -25,6 +25,7 @@ build_flags = test_build_src = true extra_scripts = pre:bin/platformio-pre.py + pre:bin/optional-modules.py bin/platformio-custom.py post:extra_scripts/nrf54l15_linker.py ; note: we add src to our include search path so that lmic_project_config can override diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index d05ac35a34..1c32a0ab2e 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -34,6 +34,11 @@ #if !MESHTASTIC_EXCLUDE_BEACON #include "modules/MeshBeaconModule.h" #endif +// Generated by bin/optional-modules.py from whatever sits in src/modules/optional/. Absent from a +// stock checkout, and empty unless a module has been dropped in. +#if __has_include("OptionalModules.h") +#include "OptionalModules.h" +#endif #if !MESHTASTIC_EXCLUDE_GPS #include "modules/PositionModule.h" #endif @@ -283,6 +288,11 @@ void setupModules() #endif #if defined(HAS_HARDWARE_WATCHDOG) watchdogThread = new WatchdogThread(); +#endif + // Anything dropped into src/modules/optional/. Undefined, so compiled away, unless a module is + // actually present. +#ifdef OPTIONAL_MODULES_SETUP + OPTIONAL_MODULES_SETUP(); #endif // NOTE! This module must be added LAST because it likes to check for replies from other modules and avoid sending extra // acks From 14eaa5587d571b76326f0e63c33c6d72d1f6fa38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 1 Sep 2026 11:52:40 +0000 Subject: [PATCH 046/143] Honor mute when waking the screen for a received message (#11688) * fix(ui): honor mute when waking the screen for a received message TextMessageModule fired powerFSM.trigger(EVENT_RECEIVED_MSG) for every text packet, gated only by shouldWakeOnReceivedMessage(), which checks external notification, device role and battery level but never the mute flags. A muted channel therefore suppressed the banner and still lit the screen. MessageRenderer::handleNewMessage() only computed mute for MessageType::BROADCAST, so a DM from a muted node produced a banner and a wake. Add isMutedForPacket() in Channels: a DM addressed to us reads the sender's NodeInfoLite mute bit, every other packet reads the mute bit of the channel it arrived on. This is the predicate ExternalNotificationModule already applied to the buzzer, vibra and LED outputs, hoisted so all three call sites share it. Bell and alert messages still break through mute on both paths, unchanged. No protobuf or config change: ChannelSettings.module_settings.is_muted and the NodeInfoLite mute bit already exist and are already settable from the device menu and via AdminMessage.toggle_muted_node. Closes #11674 * fix(ui): let an alert break through mute on the screen wake path In COLOR display mode TextMessageModule skips handleNewMessage(), so powerFSM.trigger(EVENT_RECEIVED_MSG) is the only wake an alert gets. Gating it on mute alone dropped that wake for a bell on a muted channel. Add MeshService::isAlertPayload(): an ASCII BEL in the payload while at least one alert_bell_* output is enabled. The wake gate is now "not muted, or an alert". MessageRenderer uses the same predicate instead of its own inline bell scan, which also lifts that scan's arbitrary 100 byte cap. Rename three test cases. Their names carried exactly 35 characters after the test_ prefix, which matches the Lob API key format and tripped trufflehog in the trunk check gate. --- src/graphics/draw/MessageRenderer.cpp | 28 +-- src/mesh/Channels.cpp | 9 + src/mesh/Channels.h | 4 + src/mesh/MeshService.cpp | 15 ++ src/mesh/MeshService.h | 4 + src/modules/ExternalNotificationModule.cpp | 10 +- src/modules/TextMessageModule.cpp | 7 +- test/state-manifest.tsv | 1 + test/test_muted_source/test_main.cpp | 239 +++++++++++++++++++++ 9 files changed, 285 insertions(+), 32 deletions(-) create mode 100644 test/test_muted_source/test_main.cpp diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 2e023dc843..284bfc6c4f 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -3,6 +3,8 @@ #include "MessageRenderer.h" // Core includes +#include "Channels.h" +#include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" #include "UIRenderer.h" @@ -1138,13 +1140,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht // still happens so a message can light the screen back up. const bool menuShowing = NotificationRenderer::isMenuShowing(); - // Determine if message belongs to a muted channel - bool isChannelMuted = false; - if (sm.type == MessageType::BROADCAST) { - const meshtastic_Channel channel = channels.getByIndex(packet.channel ? packet.channel : channels.getPrimaryIndex()); - if (channel.settings.has_module_settings && channel.settings.module_settings.is_muted) - isChannelMuted = true; - } + const bool isMuted = isMutedForPacket(packet); // Banner logic const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from); @@ -1164,21 +1160,9 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht char truncatedLongName[64]; graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName), availWidth); - const char *msgRaw = reinterpret_cast(packet.decoded.payload.bytes); char banner[256]; - bool isAlert = false; - - // Check if alert detection is enabled via external notification module - if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra || - moduleConfig.external_notification.alert_bell_buzzer) { - for (size_t i = 0; i < packet.decoded.payload.size && i < 100; i++) { - if (msgRaw[i] == '\x07') { - isAlert = true; - break; - } - } - } + const bool isAlert = MeshService::isAlertPayload(packet); if (isAlert) { if (truncatedLongName[0]) @@ -1186,8 +1170,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht else strcpy(banner, "Alert Received"); } else { - // Skip muted channels unless it's an alert - if (isChannelMuted) + // Skip muted channels/senders unless it's an alert + if (isMuted) return; if (truncatedLongName[0]) { diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 770213b4a7..ec1a418eae 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -584,3 +584,12 @@ int16_t Channels::setActiveByIndex(ChannelIndex channelIndex) { return setCrypto(channelIndex); } + +bool isMutedForPacket(const meshtastic_MeshPacket &mp) +{ + if (!isBroadcast(mp.to) && isToUs(&mp)) + return nodeInfoLiteIsMuted(nodeDB->getMeshNode(mp.from)); + + const meshtastic_Channel &ch = channels.getByIndex(mp.channel ? mp.channel : channels.getPrimaryIndex()); + return ch.settings.has_module_settings && ch.settings.module_settings.is_muted; +} diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 27833130c0..a7bbd2277a 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -160,6 +160,10 @@ extern Channels channels; static const uint8_t defaultpsk[] = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01}; +/// True if the user muted the source of this packet: the sender for a DM addressed to us, +/// otherwise the channel it arrived on. +bool isMutedForPacket(const meshtastic_MeshPacket &mp); + /// True if a getKey()-resolved key offers no privacy: length 0 (off) or the public defaultpsk family. Pure; for tests. bool cryptoKeyIsPublic(const CryptoKey &key); diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index cfe213ff9b..707d292a94 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -445,6 +445,21 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) return false; } +// ASCII BEL, the in-band alert marker. Numeric so no control byte sits in the source, and +// file-local because ASCII_BELL is already a macro in Screen.cpp and ExternalNotificationModule.cpp. +static const uint8_t kAsciiBell = 7; + +bool MeshService::isAlertPayload(const meshtastic_MeshPacket &p) +{ + if (!moduleConfig.external_notification.alert_bell && !moduleConfig.external_notification.alert_bell_vibra && + !moduleConfig.external_notification.alert_bell_buzzer) + return false; + for (pb_size_t i = 0; i < p.decoded.payload.size; i++) + if (p.decoded.payload.bytes[i] == kAsciiBell) + return true; + return false; +} + // Re-decode nested string-bearing payloads before local phone delivery so PB_VALIDATE_UTF8 rejects // malformed NodeInfo/Waypoint data a strict phone decoder could crash on. Mesh relay is unaffected. bool MeshService::phonePayloadIsDecodable(const meshtastic_Data &d) diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 7adcdb7c6d..fb93370cc2 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -101,6 +101,10 @@ class MeshService p->decoded.portnum == meshtastic_PortNum_ALERT_APP; } + /// True if the sender flagged this text as an alert: an ASCII BEL in the payload while at least + /// one alert_bell_* output is enabled. Alerts deliberately break through a mute. + static bool isAlertPayload(const meshtastic_MeshPacket &p); + /// Returns false when a decoded NodeInfo/Waypoint payload fails nested protobuf decode (invalid /// UTF-8 under PB_VALIDATE_UTF8, etc.); other portnums pass through. Callers gate on the variant. static bool phonePayloadIsDecodable(const meshtastic_Data &decoded); diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 0c97bf3575..ef0627f06b 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -14,6 +14,7 @@ * @date [Insert Date] */ #include "ExternalNotificationModule.h" +#include "Channels.h" #include "MeshService.h" #include "NodeDB.h" #include "Router.h" @@ -421,15 +422,8 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP } } - const meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(mp.from); - meshtastic_Channel ch = channels.getByIndex(mp.channel ? mp.channel : channels.getPrimaryIndex()); - - // If we receive a broadcast message, apply channel mute setting - // If we receive a direct message and the receipent is us, apply DM mute setting - // Else we just handle it as not muted. const bool isDmToUs = !isBroadcast(mp.to) && isToUs(&mp); - bool is_muted = isDmToUs ? nodeInfoLiteIsMuted(sender) - : (ch.settings.has_module_settings && ch.settings.module_settings.is_muted); + const bool is_muted = isMutedForPacket(mp); const bool buzzerModeIsDirectOnly = (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY); diff --git a/src/modules/TextMessageModule.cpp b/src/modules/TextMessageModule.cpp index 818e39a948..843311a125 100644 --- a/src/modules/TextMessageModule.cpp +++ b/src/modules/TextMessageModule.cpp @@ -1,4 +1,5 @@ #include "TextMessageModule.h" +#include "Channels.h" #include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" @@ -34,8 +35,10 @@ ProcessMessage TextMessageModule::handleReceived(const meshtastic_MeshPacket &mp auto *display = screen ? screen->getDisplayDevice() : nullptr; graphics::MessageRenderer::handleNewMessage(display, *sm, mp); }) - // Only trigger screen wake if configuration allows it - if (shouldWakeOnReceivedMessage()) { + // Only trigger screen wake if configuration allows it and the channel/sender isn't muted. + // An alert breaks through the mute: in COLOR display mode handleNewMessage() above never runs, + // so this trigger is the only wake an alert would get. + if (shouldWakeOnReceivedMessage() && (!isMutedForPacket(mp) || MeshService::isAlertPayload(mp))) { powerFSM.trigger(EVENT_RECEIVED_MSG); } diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index 4c8f56bbb1..f2ef9ff4c1 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -58,6 +58,7 @@ test_hop_start_policy writes=config.proto,module.proto,device.proto,channels.pro test_mesh_beacon writes=module.proto exercises the beacon's module-config save path test_mesh_module writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat module framework tests construct a NodeDB test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=1000..12000 constructs a NodeDB for node lookups in the MQTT paths +test_muted_source writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (isToUs needs nodeDB->getNodeNum(), and the DM branch looks the sender up), whose constructor persists a default set when the prefs directory is empty test_nexthop_routing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto next-hop selection reads and updates the node DB test_nodedb_blocked state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat saturates the DB with MAX_NUM_NODES-2 favourited nodes to test the protected cap; a later test's removeNodeByNum() persists that state, and the cap test depends on the fill from the test before it test_nodedb_boot_recovery state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto deliberate boot-recovery ladder: corrupts/deletes/restores the pref files and reboots a NodeDB per test to pin the DECODE_FAILED identity freeze, so each test observes the previous test's on-disk state diff --git a/test/test_muted_source/test_main.cpp b/test/test_muted_source/test_main.cpp new file mode 100644 index 0000000000..9cc2eedeef --- /dev/null +++ b/test/test_muted_source/test_main.cpp @@ -0,0 +1,239 @@ +// isMutedForPacket() source resolution - src/mesh/Channels.cpp. A DM addressed to us reads the +// sender's mute bit; every other packet reads the mute bit of the channel it arrived on. +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isToUs, isBroadcast) +#include "TestUtil.h" +#include + +#include "mesh/Channels.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include +#include + +static constexpr NodeNum kLocalNode = 0x11111111; +static constexpr NodeNum kPeer = 0x22222222; +static constexpr NodeNum kThirdParty = 0x33333333; +static constexpr NodeNum kStranger = 0x44444444; // deliberately never added to the DB + +// isToUs() reads nodeDB->getNodeNum() and the DM branch looks the sender up, so a real NodeDB +// must be live. +static NodeDB *testNodeDB = nullptr; + +static meshtastic_MeshPacket makePacket(NodeNum from, NodeNum to, uint8_t channel) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = to; + p.channel = channel; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + return p; +} + +static void setSlot(ChannelIndex idx, meshtastic_Channel_Role role, bool muted) +{ + meshtastic_Channel &ch = channels.getByIndex(idx); + ch.index = idx; + ch.has_settings = true; + ch.role = role; + ch.settings.has_module_settings = true; + ch.settings.module_settings.is_muted = muted; +} + +// Append straight into the hot store: getOrCreateMeshNode() would drag in the cap and +// eviction machinery, which this predicate has nothing to do with. +static void setNodeMuted(NodeNum num, bool muted) +{ + meshtastic_NodeInfoLite *n = nodeDB->getMeshNode(num); + if (!n) { + nodeDB->meshNodes->resize(nodeDB->numMeshNodes + 1); + n = &nodeDB->meshNodes->at(nodeDB->numMeshNodes++); + memset(n, 0, sizeof(*n)); + n->num = num; + } + nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, muted); +} + +// --------------------------------------------------------------------------- +// Broadcast: the arrival channel decides +// --------------------------------------------------------------------------- + +void test_broadcast_on_unmuted_channel_is_not_muted() +{ + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 0))); +} + +void test_broadcast_on_muted_channel() +{ + setSlot(0, meshtastic_Channel_Role_PRIMARY, true); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 0))); +} + +// A channel with no module_settings at all has never been muted. +void test_channel_without_module_settings_is_not_muted() +{ + meshtastic_Channel &ch = channels.getByIndex(0); + ch.settings.has_module_settings = false; + ch.settings.module_settings.is_muted = true; // stale payload behind the presence flag + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 0))); +} + +// Mute is per channel, not global: a muted secondary must not silence the others. +void test_broadcast_reads_its_own_channel() +{ + setSlot(2, meshtastic_Channel_Role_SECONDARY, true); + setSlot(1, meshtastic_Channel_Role_SECONDARY, false); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 2))); + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 1))); +} + +// channel == 0 means "the primary", which is not always slot 0. +void test_channel_zero_resolves_to_primary_slot() +{ + setSlot(0, meshtastic_Channel_Role_SECONDARY, false); + setSlot(3, meshtastic_Channel_Role_PRIMARY, true); + channels.onConfigChanged(); + TEST_ASSERT_EQUAL_UINT8(3, channels.getPrimaryIndex()); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, NODENUM_BROADCAST, 0))); +} + +// --------------------------------------------------------------------------- +// DM addressed to us: the sender decides +// --------------------------------------------------------------------------- + +void test_dm_to_us_from_muted_sender() +{ + setNodeMuted(kPeer, true); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, kLocalNode, 0))); +} + +void test_dm_to_us_from_unmuted_sender_is_not_muted() +{ + setNodeMuted(kPeer, false); + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, kLocalNode, 0))); +} + +// The discriminator: a DM must not inherit its channel's mute state. +void test_dm_to_us_ignores_channel_mute() +{ + setSlot(0, meshtastic_Channel_Role_PRIMARY, true); + setNodeMuted(kPeer, false); + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, kLocalNode, 0))); +} + +// A sender we have never heard of has no mute bit to read. +void test_dm_to_us_from_unknown_sender_is_not_muted() +{ + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kStranger, kLocalNode, 0))); +} + +// Not addressed to us: overheard traffic falls back to the channel, sender mute is irrelevant. +void test_dm_to_third_party_uses_channel() +{ + setSlot(0, meshtastic_Channel_Role_PRIMARY, true); + setNodeMuted(kPeer, false); + TEST_ASSERT_TRUE(isMutedForPacket(makePacket(kPeer, kThirdParty, 0))); + + setSlot(0, meshtastic_Channel_Role_PRIMARY, false); + setNodeMuted(kPeer, true); + TEST_ASSERT_FALSE(isMutedForPacket(makePacket(kPeer, kThirdParty, 0))); +} + +// --------------------------------------------------------------------------- +// Alert payloads, which break through a mute +// --------------------------------------------------------------------------- + +// ASCII BEL, the in-band alert marker. Numeric so no control byte sits in the source. +static const uint8_t kAsciiBell = 7; + +static meshtastic_MeshPacket withText(meshtastic_MeshPacket p, const char *text, bool bell) +{ + p.decoded.payload.size = (pb_size_t)strlen(text); + memcpy(p.decoded.payload.bytes, text, p.decoded.payload.size); + if (bell) + p.decoded.payload.bytes[p.decoded.payload.size++] = kAsciiBell; + return p; +} + +void test_bell_is_an_alert_when_a_bell_output_is_on() +{ + moduleConfig.external_notification.alert_bell = true; + TEST_ASSERT_TRUE(MeshService::isAlertPayload(withText(makePacket(kPeer, NODENUM_BROADCAST, 0), "wake up", true))); +} + +void test_bell_is_not_an_alert_when_every_bell_output_is_off() +{ + TEST_ASSERT_FALSE(MeshService::isAlertPayload(withText(makePacket(kPeer, NODENUM_BROADCAST, 0), "wake up", true))); +} + +void test_plain_text_is_never_an_alert() +{ + moduleConfig.external_notification.alert_bell = true; + TEST_ASSERT_FALSE(MeshService::isAlertPayload(withText(makePacket(kPeer, NODENUM_BROADCAST, 0), "wake up", false))); +} + +// The wake gate is "not muted, or an alert": a bell must survive a muted channel. +void test_alert_survives_a_muted_channel() +{ + moduleConfig.external_notification.alert_bell = true; + setSlot(0, meshtastic_Channel_Role_PRIMARY, true); + const meshtastic_MeshPacket p = withText(makePacket(kPeer, NODENUM_BROADCAST, 0), "wake up", true); + TEST_ASSERT_TRUE(isMutedForPacket(p)); + TEST_ASSERT_TRUE(!isMutedForPacket(p) || MeshService::isAlertPayload(p)); +} + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- + +void setUp(void) +{ + if (!testNodeDB) + testNodeDB = new NodeDB(); // its constructor overwrites my_node_num, so claim ours after + + config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + myNodeInfo.my_node_num = kLocalNode; + nodeDB = testNodeDB; + + // Start from an empty hot store so kStranger is genuinely unknown. + nodeDB->meshNodes->clear(); + nodeDB->numMeshNodes = 0; + + memset(&channelFile, 0, sizeof(channelFile)); + channels.initDefaults(); + channels.onConfigChanged(); +} + +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + + UNITY_BEGIN(); + + printf("\n=== Broadcast: channel mute ===\n"); + RUN_TEST(test_broadcast_on_unmuted_channel_is_not_muted); + RUN_TEST(test_broadcast_on_muted_channel); + RUN_TEST(test_channel_without_module_settings_is_not_muted); + RUN_TEST(test_broadcast_reads_its_own_channel); + RUN_TEST(test_channel_zero_resolves_to_primary_slot); + + printf("\n=== Direct message: sender mute ===\n"); + RUN_TEST(test_dm_to_us_from_muted_sender); + RUN_TEST(test_dm_to_us_from_unmuted_sender_is_not_muted); + RUN_TEST(test_dm_to_us_ignores_channel_mute); + RUN_TEST(test_dm_to_us_from_unknown_sender_is_not_muted); + RUN_TEST(test_dm_to_third_party_uses_channel); + + printf("\n=== Alerts break through mute ===\n"); + RUN_TEST(test_bell_is_an_alert_when_a_bell_output_is_on); + RUN_TEST(test_bell_is_not_an_alert_when_every_bell_output_is_off); + RUN_TEST(test_plain_text_is_never_an_alert); + RUN_TEST(test_alert_survives_a_muted_channel); + + exit(UNITY_END()); +} + +void loop() {} From df34ef1081ff5ff2f7ed23cb8319b6219353196c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 1 Sep 2026 16:08:33 +0000 Subject: [PATCH 047/143] fix(radio): recover from chip state loss in the RX/TX hot paths too (#11678) * fix(radio): recover from chip state loss in the RX/TX hot paths too * fix(radio): address CodeRabbit findings on the hot-path recovery PR (#11680) * fix(radio): address CodeRabbit findings on the hot-path recovery PR SX128x: startReceive() still called the old asserting setStandby() before the new trySetStandby(). The assert fired first, so the recovery path added below it could never run - the exact chip-state-loss crash this PR exists to fix was still live on SX128x. Remove the stale call. LR11x0: resolvedTcxoVoltage was set once after the primary begin() attempts, but two later paths - firmware recovery and the one-shot firmware update - call begin() again with tcxoVoltage and never updated it. On a TCXO_OPTIONAL board that only came up via one of those paths, reinitChip() would recover with the wrong oscillator setting. Update resolvedTcxoVoltage after each of those begin() calls too. LR20x0: reconfigure() discarded RadioLibInterface::reconfigure()'s result - the band-hop path always returned true regardless, and the same-band path reused the same flag for chip-programming errors, so a base-class failure could both mask itself as success and wrongly trigger a full re-init. Track the base-class result (reconfigureSuccess) separately from the chip result (standbySuccess), and return the former. Also shortens the recovery-rationale comments in RadioLibInterface.h and SX126xInterface.cpp to 1-2 lines per the repo's comment convention, the rationale now covered once in the base class. * fix(radio): finish the recovery ladder and stop recovery from rebooting Follow-up to the CodeRabbit findings, plus two gaps found auditing the branch against its own intent (never reboot on chip state loss; recover in place). RX left off was unrecoverable on an idle node. Every startReceive() call site is event-driven - RX/TX ISR, the CAD-busy branch, startSend()'s failure path, init(), reconfigure() - and a radio with RX off cannot raise an RX interrupt, so nothing re-arms it unless the node happens to transmit or the user changes config. A listen-only or quiet node stayed deaf for good, which is worse than the reboot this replaced. main.cpp's existing 60 s AGC tick now calls periodicRadioMaintenance(), which re-arms RX when rxOffline is set and otherwise does the AGC reset as before. In-place repair now gives up rather than retrying forever. After MAX_CHIP_RECOVERY_FAILURES consecutive failures - a throttle window apart, so minutes of a provably dead chip - schedule rebootAtMsec, the same deliberate reboot Portduino already uses for LoRa_in_error. A reboot re-runs init(), which redoes the power-enable GPIOs, settle delays and TCXO probing that begin() alone skips. Both counters reset in RadioLibInterface::startReceive(), the one point every driver reaches only once the chip accepts the RX start. SX128x: reconfigure()'s recovery reached reinitChip()'s region-mismatch branch, which rewrites config.lora.region, saves, and calls ESP.restart() / NVIC_SystemReset(). A runtime recovery must never reboot - that is the crash this path exists to prevent, and it would fire with a config save pending. Gated to the boot-time call via a fromInit parameter. LR20x0: a rejected setRxBoostedGainMode cleared the success flag and so forced a full fullBegin() chip reset. It is a warn-level cosmetic setting, treated as warn-only in LR11x0's equivalent, and not a lost-state signature. Also logs suppressed recovery attempts at debug level; previously a chip that stayed dead recorded one critical error and then went completely silent. * fix(radio): count RX re-arms, not re-inits, in the recovery ladder LR20x0's recoverChipStateLoss() is fullBegin(), which re-arms RX itself but reports success on begin() alone. A re-init that came back with RX still dead therefore reset chipRecoveryFailures, so a chip that could be re-inited forever while never receiving again held the ladder at zero and never reached the reboot. The other drivers had the same hole from the other side: the caller's retry startReceive() runs after the reset, so a retry that failed again left the count cleared. RadioLibInterface::startReceive() is now the only place the ladder clears, and it only runs once the chip actually accepted RX. The threshold is judged at the top of the next attempt - a throttle window later, after that attempt's retry (the caller's, or fullBegin's own) has had its chance to clear it. That also drops the old false positive where the reboot was armed before the retry that would have succeeded. RF95Interface::startReceive() set isReceiving directly instead of calling the base, so on RF95 nothing ever cleared rxOffline or the ladder: the first failed RX start left periodicRadioMaintenance() re-initing forever, and with the count now advancing it would have rebooted a working radio. --------- Co-authored-by: Ben Meadors --------- Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> --- src/main.cpp | 4 +- src/mesh/LR11x0Interface.cpp | 54 +++++++++++++++------- src/mesh/LR11x0Interface.h | 3 ++ src/mesh/LR20x0Interface.cpp | 84 +++++++++++++++++++++------------- src/mesh/LR20x0Interface.h | 3 ++ src/mesh/RF95Interface.cpp | 50 ++++++++++++-------- src/mesh/RF95Interface.h | 3 ++ src/mesh/RadioLibInterface.cpp | 47 +++++++++++++++++++ src/mesh/RadioLibInterface.h | 18 ++++++++ src/mesh/SX126xInterface.cpp | 70 +++++++++++++++++----------- src/mesh/SX126xInterface.h | 3 ++ src/mesh/SX128xInterface.cpp | 54 ++++++++++++++-------- src/mesh/SX128xInterface.h | 7 ++- 13 files changed, 285 insertions(+), 115 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 80b8e4f4a9..b5db79b6bb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1481,11 +1481,11 @@ void loop() RadioLibInterface::instance->pollMissedIrqs(); } - // Periodic AGC reset - warm sleep + recalibrate to prevent stuck AGC gain + // Periodic radio upkeep - re-arms RX if it was left off, else AGC reset (stuck-gain prevention) static uint32_t lastAgcReset; if (!Throttle::isWithinTimespanMs(lastAgcReset, AGC_RESET_INTERVAL_MS)) { lastAgcReset = millis(); - RadioLibInterface::instance->resetAGC(); + RadioLibInterface::instance->periodicRadioMaintenance(); } } diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 3786dbd678..fdad36fca8 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -185,6 +185,8 @@ template bool LR11x0Interface::init() if (lora.updateFirmware(lr11xx_firmware_image, LR11XX_FIRMWARE_IMAGE_SIZE, true) == RADIOLIB_ERR_NONE) { LOG_INFO("LR1110 firmware recovery OK, re-init radio"); res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + if (res == RADIOLIB_ERR_NONE) + resolvedTcxoVoltage = tcxoVoltage; } #endif if (res != RADIOLIB_ERR_NONE) @@ -222,6 +224,7 @@ template bool LR11x0Interface::init() LOG_ERROR("LR11x0 re-init after firmware update failed %s%d", radioLibErr, res); return false; } + resolvedTcxoVoltage = tcxoVoltage; if (lora.getVersionInfo(&version) == RADIOLIB_ERR_NONE) { transceiverFw = ((uint16_t)version.fwMajor << 8) | version.fwMinor; @@ -450,16 +453,31 @@ template void LR11x0Interface::startReceive() sleep(); #else - setStandby(); + int16_t err = trySetStandby(); - lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. + if (err == RADIOLIB_ERR_NONE) { + lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. - // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. - int err = - lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - if (err) + // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. + err = + lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("StartReceive error: %d", err); - assert(err == RADIOLIB_ERR_NONE); + if (maybeRecoverChipStateLoss()) { + lora.setPreambleLength(preambleLength); + err = lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, + RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + } + + if (err != RADIOLIB_ERR_NONE) { + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("LR11x0 RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; + } RadioLibInterface::startReceive(); @@ -480,16 +498,18 @@ template bool LR11x0Interface::isChannelActive() .timeout = 0, .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; - int16_t result; + int16_t result = trySetStandby(); + if (result == RADIOLIB_ERR_NONE) { + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; + } - setStandby(); - result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) - return true; - - assert(result != RADIOLIB_ERR_WRONG_MODEM); - - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -537,7 +557,7 @@ template bool LR11x0Interface::sleep() { // \todo Display actual typename of the adapter, not just `LR11x0` LOG_DEBUG("LR11x0 entering sleep mode"); - setStandby(); // Stop any pending operations + (void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash // turn off TCXO if it was powered lora.setTCXO(0); diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index c1e48ae72c..e3b4f392af 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -89,6 +89,9 @@ template class LR11x0Interface : public RadioLibInterface /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ int16_t trySetStandby(); + /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ + bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } + /// The TCXO Vref that init() settled on, so reinitChip() can begin() with the same oscillator setup float resolvedTcxoVoltage = 0; }; diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 587bead397..f6936afe73 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -179,7 +179,9 @@ template bool LR20x0Interface::init() template bool LR20x0Interface::reconfigure() { - bool success = RadioLibInterface::reconfigure(); + // Propagated to the return value below, separately from the chip-programming outcome, so a + // base-class failure isn't masked as success. + const bool reconfigureSuccess = RadioLibInterface::reconfigure(); if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { limitPower(LR2021_MAX_POWER_HF); @@ -199,72 +201,73 @@ template bool LR20x0Interface::reconfigure() return false; startReceive(); - return true; + return reconfigureSuccess; } // Same-band reconfigure (previous incremental path) + bool standbySuccess = true; int16_t standbyErr = trySetStandby(); if (standbyErr != RADIOLIB_ERR_NONE) - success = false; + standbySuccess = false; if (standbyErr == RADIOLIB_ERR_NONE) { int err = lora.setFrequency(freq); if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 setFrequency %.3f MHz %s%d", freq, radioLibErr, err); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; + standbySuccess = false; } err = lora.setSpreadingFactor(sf); if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 setSpreadingFactor(%u) %s%d", sf, radioLibErr, err); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; + standbySuccess = false; } err = lora.setBandwidth(bw); if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 setBandwidth(%.1f) %s%d", bw, radioLibErr, err); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; + standbySuccess = false; } err = lora.setCodingRate(cr, cr != 7); if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 setCodingRate(%u) %s%d", cr, radioLibErr, err); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; + standbySuccess = false; } err = lora.setSyncWord(syncWord); if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 setSyncWord %s%d", radioLibErr, err); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; + standbySuccess = false; } err = lora.setPreambleLength(preambleLength); if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; + standbySuccess = false; } err = lora.setOutputPower(power); if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 setOutputPower %d dBm @ %.3f MHz %s%d", power, freq, radioLibErr, err); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - success = false; + standbySuccess = false; } + // Warn-only, as in LR11x0: a rejected gain mode is cosmetic and not a lost-state signature, so + // it must not drag reconfigure() into a full chip reset. err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); - if (err != RADIOLIB_ERR_NONE) { + if (err != RADIOLIB_ERR_NONE) LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err); - success = false; - } } - if (!success) { + if (!standbySuccess) { // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has // lost its runtime configuration to a chip-internal reset or brownout. Recover in place with the // same full begin() the band-hop path uses - it hardware-resets the chip. Crashing here instead @@ -279,7 +282,7 @@ template bool LR20x0Interface::reconfigure() startReceive(); lr20x0LastFreqMHz = freq; - return true; + return reconfigureSuccess; } // The chip-side re-init the band-hop and recovery paths share: front-end switch GPIOs for the target @@ -414,16 +417,31 @@ template void LR20x0Interface::startReceive() sleep(); #else - setStandby(); + int16_t err = trySetStandby(); - lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. + if (err == RADIOLIB_ERR_NONE) { + lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. - // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. - int err = - lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - if (err) + // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. + err = + lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("StartReceive error: %d", err); - assert(err == RADIOLIB_ERR_NONE); + if (maybeRecoverChipStateLoss()) { + lora.setPreambleLength(preambleLength); + err = lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, + RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + } + + if (err != RADIOLIB_ERR_NONE) { + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("LR20x0 RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; + } RadioLibInterface::startReceive(); @@ -444,16 +462,18 @@ template bool LR20x0Interface::isChannelActive() .timeout = 0, .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; - int16_t result; + int16_t result = trySetStandby(); + if (result == RADIOLIB_ERR_NONE) { + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; + } - setStandby(); - result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) - return true; - - assert(result != RADIOLIB_ERR_WRONG_MODEM); - - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -500,7 +520,7 @@ template bool LR20x0Interface::sleep() { // \todo Display actual typename of the adapter, not just `LR20x0` LOG_DEBUG("LR20x0 entering sleep mode"); - setStandby(); // Stop any pending operations + (void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash // turn off TCXO if it was powered lora.setTCXO(0); diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index df69bf1a2d..5150e9ab8c 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -80,5 +80,8 @@ template class LR20x0Interface : public RadioLibInterface /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state via the same full begin() the band-hop path uses */ + bool recoverChipStateLoss() override { return fullBegin(getFreq()); } }; #endif diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 0be5feee92..5f2322417f 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -352,13 +352,24 @@ void RF95Interface::configHardwareForSend() void RF95Interface::startReceive() { setTransmitEnable(false); - setStandby(); - int err = lora->startReceive(); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("RF95 startReceive %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = lora->startReceive(); - isReceiving = true; + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 startReceive %s%d", radioLibErr, err); + if (maybeRecoverChipStateLoss()) + err = lora->startReceive(); + } + + if (err != RADIOLIB_ERR_NONE) { + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("RF95 RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; + } + + RadioLibInterface::startReceive(); // Must be done AFTER, starting receive, because startReceive clears (possibly stale) interrupt pending register bits enableInterrupt(isrRxLevel0); @@ -368,21 +379,24 @@ void RF95Interface::startReceive() bool RF95Interface::isChannelActive() { // check if we can detect a LoRa preamble on the current channel - int16_t result; setTransmitEnable(false); - setStandby(); // needed for smooth transition - result = lora->scanChannel(); + int16_t result = trySetStandby(); // needed for smooth transition + if (result == RADIOLIB_ERR_NONE) { + result = lora->scanChannel(); - if (result == RADIOLIB_PREAMBLE_DETECTED) { - // LOG_DEBUG("Channel is busy"); - return true; + if (result == RADIOLIB_PREAMBLE_DETECTED) { + // LOG_DEBUG("Channel is busy"); + return true; + } + if (result != RADIOLIB_CHANNEL_FREE) + LOG_ERROR("RF95 isChannelActive %s%d", radioLibErr, result); + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; } - if (result != RADIOLIB_CHANNEL_FREE) - LOG_ERROR("RF95 isChannelActive %s%d", radioLibErr, result); - assert(result != RADIOLIB_ERR_WRONG_MODEM); - // LOG_DEBUG("Channel is free"); - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -394,7 +408,7 @@ bool RF95Interface::isActivelyReceiving() bool RF95Interface::sleep() { // put chipset into sleep mode - setStandby(); // First cancel any active receiving/sending + (void)trySetStandby(); // First cancel any active receiving/sending - going to sleep, a failure must not crash lora->sleep(); #ifdef RF95_POWER_EN diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index b1dd383197..4536dbd502 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -87,5 +87,8 @@ class RF95Interface : public RadioLibInterface /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ + bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } }; #endif diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index da0f58d6ec..4a5fb86a25 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -707,6 +707,10 @@ void RadioLibInterface::handleReceiveInterrupt() void RadioLibInterface::startReceive() { isReceiving = true; + // Drivers only reach here once the chip actually accepted the RX start, so the radio is alive again. + // This is the sole place the recovery ladder is cleared - nothing short of an armed RX counts as fixed. + rxOffline = false; + chipRecoveryFailures = 0; powerMon->setState(meshtastic_PowerMon_State_Lora_RXOn); } @@ -726,6 +730,49 @@ void RadioLibInterface::resetAGC() // Base implementation: no-op. Override in chip-specific subclasses. } +void RadioLibInterface::periodicRadioMaintenance() +{ + // Every startReceive() call site is event-driven (RX/TX ISR, the CAD-busy branch, reconfigure), and a + // radio left with RX off can no longer raise an RX interrupt - on a node with nothing to transmit + // nothing would ever re-arm it. This periodic tick is that retry; maybeRecoverChipStateLoss() throttles. + if (rxOffline) { + LOG_WARN("Radio RX offline, retrying"); + if (maybeRecoverChipStateLoss()) + startReceive(); + return; // a chip just re-inited (or still dead) has no use for an AGC reset this tick + } + + resetAGC(); +} + +bool RadioLibInterface::maybeRecoverChipStateLoss() +{ + // One attempt per window: the transient resets this recovers from need a single re-init, and a + // chip that stays dead must not stall the TX/RX paths with a begin() attempt on every call + if (lastChipRecoveryMs && Throttle::isWithinTimespanMs(lastChipRecoveryMs, 30 * 1000UL)) { + LOG_DEBUG("Radio recovery suppressed, %us since the last attempt", (millis() - lastChipRecoveryMs) / 1000); + return false; + } + + // The ladder counts re-arms, not re-inits: only RadioLibInterface::startReceive() clears the count, and + // only once the chip really accepted RX. Judging the previous attempt here - a throttle window later, + // after its retry - is what stops a begin() that succeeded while leaving RX dead from crediting itself. + if (chipRecoveryFailures >= MAX_CHIP_RECOVERY_FAILURES && rebootAtMsec == 0) { + // Attempts are a throttle window apart, so this is minutes of a provably deaf chip. begin() alone + // clearly isn't reviving it; reboot to re-run init(), which redoes the power-on sequence it skips. + LOG_ERROR("Radio still deaf after %u re-inits, rebooting", chipRecoveryFailures); + rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + } + chipRecoveryFailures++; + + lastChipRecoveryMs = millis(); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + LOG_ERROR("Radio chip state lost mid-operation, re-init"); + bool recovered = recoverChipStateLoss(); + LOG_INFO("Radio re-init %s", recovered ? "succeeded" : "failed"); + return recovered; +} + void RadioLibInterface::checkRxDoneIrqFlag() { if (iface->checkIrq(RADIOLIB_IRQ_RX_DONE)) { diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 0142721789..6dcd0876f7 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -180,6 +180,24 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ virtual void resetAGC(); + /** Periodic radio upkeep: re-arms RX if a failed startReceive() left it off, otherwise resets AGC. */ + void periodicRadioMaintenance(); + + /** Chip-specific recovery of a chip that lost its state to a reset/brownout. Returns true if reprogrammed. */ + virtual bool recoverChipStateLoss() { return false; } + + /** Throttled recoverChipStateLoss(), so a dead chip can't stall the RX/TX hot paths with repeated begin(). */ + bool maybeRecoverChipStateLoss(); + + uint32_t lastChipRecoveryMs = 0; + + /// Consecutive recovery attempts that never got RX armed again, before rebooting to re-run init() + static constexpr uint8_t MAX_CHIP_RECOVERY_FAILURES = 5; + uint8_t chipRecoveryFailures = 0; + + /// Set by a driver's startReceive() when it gives up and leaves RX off; cleared once RX is armed again. + bool rxOffline = false; + /** * Debugging counts */ diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 7bc6bfb66f..aa55335b44 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -284,10 +284,8 @@ template bool SX126xInterface::reconfigure() err = programModemParams(); if (err != RADIOLIB_ERR_NONE) { - // A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has - // lost its runtime configuration - packet type included - to a chip-internal reset or brownout. - // Recover in place: begin() hardware-resets the chip and restores the LoRa packet type. Crashing - // here instead would reboot before MeshService persists the config change that triggered us. + // Chip likely lost its state (reset/brownout); recover in place rather than crash - see + // RadioLibInterface::recoverChipStateLoss(). RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); LOG_ERROR("SX126x rejected modem params, chip state lost? Full re-init"); if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) { @@ -424,26 +422,43 @@ template void SX126xInterface::startReceive() #else setTransmitEnable(false); - setStandby(); #ifdef ARCH_PORTDUINO_WASM - // Continuous RX in the browser: duty-cycle sleep parks BUSY high between RX - // windows and stalls the slow WebUSB SPI link. No battery to save here. - int err = lora.startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); const char *rxMethod = "startReceive"; #else - // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. - int err = lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); const char *rxMethod = "startReceiveDutyCycleAuto"; #endif - if (err != RADIOLIB_ERR_NONE) + auto tryStartRx = [&]() -> int16_t { +#ifdef ARCH_PORTDUINO_WASM + // Continuous RX in the browser: duty-cycle sleep parks BUSY high between RX + // windows and stalls the slow WebUSB SPI link. No battery to save here. + return lora.startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); +#else + // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. + return lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); +#endif + }; + + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = tryStartRx(); + + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X %s %s%d", rxMethod, radioLibErr, err); + if (maybeRecoverChipStateLoss()) + err = tryStartRx(); + } + + if (err != RADIOLIB_ERR_NONE) { #ifdef ARCH_PORTDUINO - if (err != RADIOLIB_ERR_NONE) portduino_status.LoRa_in_error = true; #else - assert(err == RADIOLIB_ERR_NONE); + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("SX126X RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; #endif + } RadioLibInterface::startReceive(); @@ -464,22 +479,23 @@ template bool SX126xInterface::isChannelActive() .timeout = 0, .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; - int16_t result; setTransmitEnable(false); - setStandby(); - result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) - return true; - if (result != RADIOLIB_CHANNEL_FREE) - LOG_ERROR("SX126X scanChannel %s%d", radioLibErr, result); + int16_t result = trySetStandby(); + if (result == RADIOLIB_ERR_NONE) { + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + if (result != RADIOLIB_CHANNEL_FREE) + LOG_ERROR("SX126X scanChannel %s%d", radioLibErr, result); + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; + } #ifdef ARCH_PORTDUINO - if (result == RADIOLIB_ERR_WRONG_MODEM) - portduino_status.LoRa_in_error = true; -#else - assert(result != RADIOLIB_ERR_WRONG_MODEM); + portduino_status.LoRa_in_error = true; #endif - - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -495,7 +511,7 @@ template bool SX126xInterface::sleep() // Not keeping config is busted - next time nrf52 board boots lora sending fails tcxo related? - see datasheet // \todo Display actual typename of the adapter, not just `SX126x` LOG_DEBUG("SX126x entering sleep mode"); // (FIXME, don't keep config) - setStandby(); // Stop any pending operations + (void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash // turn off TCXO if it was powered // FIXME - this isn't correct diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index b683eff136..eb1080d8b9 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -99,5 +99,8 @@ template class SX126xInterface : public RadioLibInterface /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ + bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } }; #endif \ No newline at end of file diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index a73f0b44d3..3de65fad0e 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -62,7 +62,7 @@ template bool SX128xInterface::init() RadioLibInterface::init(); - if (!reinitChip()) + if (!reinitChip(/*fromInit=*/true)) return false; startReceive(); // start receiving @@ -72,7 +72,7 @@ template bool SX128xInterface::init() // begin() and the chip-side setup that a reset chip loses. Shared by init() and by reconfigure()'s // recovery of a chip that lost its state. -template bool SX128xInterface::reinitChip() +template bool SX128xInterface::reinitChip(bool fromInit) { // Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw // config value, and the recovery path reaches begin() without passing through the params clamp @@ -87,6 +87,12 @@ template bool SX128xInterface::reinitChip() return false; if ((config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) && (res == RADIOLIB_ERR_INVALID_FREQUENCY)) { + // Boot-time only: rebooting out of a runtime recovery would reintroduce exactly the crash this + // recovery path exists to avoid, and would do it while a config save is still pending. + if (!fromInit) { + LOG_ERROR("SX128x rejected the frequency during recovery; leaving region alone"); + return false; + } LOG_WARN("Radio only supports 2.4GHz LoRa. Adjusting Region and rebooting"); config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; nodeDB->saveToDisk(SEGMENT_CONFIG); @@ -294,8 +300,6 @@ template void SX128xInterface::startReceive() sleep(); #else - setStandby(); - #if ARCH_PORTDUINO if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { digitalWrite(portduino_config.lora_rxen_pin.pin, HIGH); @@ -313,11 +317,22 @@ template void SX128xInterface::startReceive() #endif #endif - int err = lora.startReceive(RADIOLIB_SX128X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); + int16_t err = trySetStandby(); + if (err == RADIOLIB_ERR_NONE) + err = lora.startReceive(RADIOLIB_SX128X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX128X startReceive %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + if (maybeRecoverChipStateLoss()) + err = lora.startReceive(RADIOLIB_SX128X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); + } + + if (err != RADIOLIB_ERR_NONE) { + // No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled + LOG_ERROR("SX128X RX offline %s%d", radioLibErr, err); + rxOffline = true; + return; + } RadioLibInterface::startReceive(); @@ -338,17 +353,20 @@ template bool SX128xInterface::isChannelActive() .timeout = 0, .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; - int16_t result; + int16_t result = trySetStandby(); + if (result == RADIOLIB_ERR_NONE) { + result = lora.scanChannel(cfg); + if (result == RADIOLIB_LORA_DETECTED) + return true; + if (result != RADIOLIB_CHANNEL_FREE) + LOG_ERROR("SX128X scanChannel %s%d", radioLibErr, result); + if (result != RADIOLIB_ERR_WRONG_MODEM) + return false; + } - setStandby(); - result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) - return true; - if (result != RADIOLIB_CHANNEL_FREE) - LOG_ERROR("SX128X scanChannel %s%d", radioLibErr, result); - assert(result != RADIOLIB_ERR_WRONG_MODEM); - - return false; + // standby failed or the LoRa modem type is gone - the chip lost its runtime state + maybeRecoverChipStateLoss(); + return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely } /** Could we send right now (i.e. either not actively receiving or transmitting)? */ @@ -362,7 +380,7 @@ template bool SX128xInterface::sleep() // Not keeping config is busted - next time nrf52 board boots lora sending fails tcxo related? - see datasheet // \todo Display actual typename of the adapter, not just `SX128x` LOG_DEBUG("SX128x entering sleep mode"); // (FIXME, don't keep config) - setStandby(); // Stop any pending operations + (void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash // turn off TCXO if it was powered // FIXME - this isn't correct diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index 1857d4ced5..967142c49a 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -80,8 +80,13 @@ template class SX128xInterface : public RadioLibInterface int16_t programModemParams(); /** begin() and chip-side setup, shared by init() and by reconfigure()'s recovery of a chip that lost its state */ - bool reinitChip(); + /** @param fromInit true only for the boot-time call, which may adjust region and reboot on a + * 2.4GHz-only part; a runtime recovery must never reboot the node. */ + bool reinitChip(bool fromInit = false); /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ int16_t trySetStandby(); + + /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ + bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } }; From 39383b9fcfa34a7131d7238ab3f236ca3d5fe1e0 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 1 Sep 2026 14:49:31 -0400 Subject: [PATCH 048/143] Actions: Also upload release / nightly builds to R2 (#11689) --- .github/workflows/main_matrix.yml | 82 +++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 16badf8562..9c91f75386 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -35,7 +35,8 @@ on: #- "**.yml" schedule: - # Nightly develop build, published to meshtastic.github.io firmware-nightly/ (no GitHub release). + # Nightly develop build, published to meshtastic.github.io firmware-nightly/ and to the + # meshtastic-firmware-nightly R2 bucket (no GitHub release). # Scheduled runs execute on the default branch (develop). 07:00 UTC avoids the 00:00 tests # and 02:00 daily_packaging crons. - cron: 0 7 * * * # Nightly develop build/publish (default branch is develop) @@ -44,7 +45,7 @@ on: inputs: # trunk-ignore(checkov/CKV_GHA_7): intentional manual-test switch for the nightly publish path nightly: - description: "Nightly mode: build + publish develop to github.io firmware-nightly/ (skips creating a GitHub release)" + description: "Nightly mode: build + publish develop to github.io firmware-nightly/ and R2 (skips creating a GitHub release)" type: boolean default: false @@ -649,6 +650,7 @@ jobs: env: targets: |- esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 + r2_bucket: meshtastic-firmware-release steps: - name: Checkout uses: actions/checkout@v7 @@ -697,10 +699,38 @@ jobs: commit_message: ${{ needs.version.outputs.long }} enable_jekyll: true + # Mirror the same staged directory to Cloudflare R2, under a version prefix + # at the bucket root. Additive (no --delete), matching keep_files:true + # above: release_channels.yml later publishes an updated release_notes.md + # into this same prefix, and a --delete sync on a re-run would remove it. + - name: Publish firmware to Cloudflare R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + # R2 is single-region; the S3 API still requires a region to be set. + AWS_DEFAULT_REGION: auto + # Cloudflare's documented aws-cli settings for R2. Left at the AWS + # default, CLI >= 2.23 attaches CRC32 request checksums that R2 can + # reject, so both of these must stay at when_required. + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + # Same event/* prefixing as the github.io publish above. + DEST_PREFIX: ${{ contains(github.ref_name, 'event/') && format('{0}/', github.ref_name) || '' }} + VERSION: ${{ needs.version.outputs.long }} + run: | + set -euo pipefail + aws --version + aws s3 sync ./publish "s3://${r2_bucket}/${DEST_PREFIX}${VERSION}/" \ + --endpoint-url "$R2_ENDPOINT" \ + --no-progress \ + --cache-control 'public, max-age=3600' + # Nightly publish: refresh the single, stable firmware-nightly/ folder on - # meshtastic.github.io with the current develop build. Runs on the cron schedule - # (or a manual nightly=true dispatch) and never creates a GitHub release. The - # folder's release_notes.md is maintained by hand and deliberately left untouched. + # meshtastic.github.io, and the root of the meshtastic-firmware-nightly R2 + # bucket, with the current develop build. Runs on the cron schedule (or a manual + # nightly=true dispatch) and never creates a GitHub release. The folder's + # release_notes.md is maintained by hand and deliberately left untouched. publish-nightly: runs-on: ubuntu-24.04 if: github.repository_owner == 'meshtastic' && (github.event_name == 'schedule' || github.event.inputs.nightly == 'true') @@ -708,6 +738,7 @@ jobs: env: targets: |- esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 + r2_bucket: meshtastic-firmware-nightly steps: - name: Get firmware artifacts uses: actions/download-artifact@v8 @@ -755,6 +786,21 @@ jobs: - name: Display structure of files to publish run: ls -lR ./stage + - name: Verify the staged nightly is not empty + # Both publishes below refresh their destination in place (keep_files:false + # for github.io, --delete for the R2 sync), so an empty ./stage would clear + # the live nightly rather than replace it. A failed or pattern-mismatched + # artifact download is the way that happens, so fail closed here instead. + run: | + set -euo pipefail + images=$(find ./stage -type f \ + \( -name 'firmware-*.bin' -o -name 'firmware-*.uf2' -o -name 'firmware-*.hex' \) | wc -l) + echo "Staged firmware images: $images" + if [ "$images" -eq 0 ]; then + echo "::error::No firmware images in ./stage; refusing to publish an empty nightly." + exit 1 + fi + - name: Publish nightly to meshtastic.github.io uses: peaceiris/actions-gh-pages@v4 with: @@ -771,3 +817,29 @@ jobs: user_email: github-actions[bot]@users.noreply.github.com commit_message: Nightly ${{ needs.version.outputs.long }} enable_jekyll: true + + # Mirror the same staged directory to Cloudflare R2. --delete at the bucket + # root is the counterpart of keep_files:false above - it clears stale nightly + # binaries - and is in scope for the whole bucket because this bucket holds + # nothing but the nightly build. release_notes.md is carried into ./stage by + # the step above, so the sync preserves it here too. + - name: Publish nightly to Cloudflare R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + # R2 is single-region; the S3 API still requires a region to be set. + AWS_DEFAULT_REGION: auto + # Cloudflare's documented aws-cli settings for R2. Left at the AWS + # default, CLI >= 2.23 attaches CRC32 request checksums that R2 can + # reject, so both of these must stay at when_required. + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + run: | + set -euo pipefail + aws --version + aws s3 sync ./stage "s3://${r2_bucket}/" \ + --endpoint-url "$R2_ENDPOINT" \ + --no-progress \ + --delete \ + --cache-control 'public, max-age=300' From fca4fa88c542bdcfdba8794a9450e3646c00ddcf Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 1 Sep 2026 18:00:06 -0400 Subject: [PATCH 049/143] Bump version (2.8.1) (#11695) Bump version to 2.8.1 Fix broken meshtasticd version bumps while we're in here --- .github/workflows/release_channels.yml | 4 +-- bin/org.meshtastic.meshtasticd.metainfo.xml | 12 ++++++++ debian/changelog | 32 +++++++++++++++------ version.properties | 2 +- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release_channels.yml b/.github/workflows/release_channels.yml index f301030e42..d66cac2bd7 100644 --- a/.github/workflows/release_channels.yml +++ b/.github/workflows/release_channels.yml @@ -93,8 +93,8 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - # Always use master branch for version bumps - ref: master + # Always use the default branch for version bumps + ref: ${{ github.event.repository.default_branch }} - name: Setup Python uses: actions/setup-python@v6 diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml index 05f6fd401c..9b762720bc 100644 --- a/bin/org.meshtastic.meshtasticd.metainfo.xml +++ b/bin/org.meshtastic.meshtasticd.metainfo.xml @@ -90,6 +90,18 @@ + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.8.1 + + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.8.0 + + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.26 + + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.25 + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24 diff --git a/debian/changelog b/debian/changelog index 6b9d0668ef..ab4b5c11a1 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,27 @@ +meshtasticd (2.8.1.0) unstable; urgency=medium + + * Version 2.8.1 + + -- GitHub Actions Tue, 01 Sep 2026 10:59:38 +0000 + +meshtasticd (2.8.0.0) unstable; urgency=medium + + * Version 2.8.0 + + -- GitHub Actions Wed, 24 Jun 2026 11:20:05 +0000 + +meshtasticd (2.7.26.0) unstable; urgency=medium + + * Version 2.7.26 + + -- GitHub Actions Wed, 10 Jun 2026 00:19:23 +0000 + +meshtasticd (2.7.25.0) unstable; urgency=medium + + * Version 2.7.25 + + -- GitHub Actions Sat, 23 May 2026 01:16:20 +0000 + meshtasticd (2.7.24.0) unstable; urgency=medium * Version 2.7.24 @@ -73,14 +97,6 @@ meshtasticd (2.7.13.0) unstable; urgency=medium meshtasticd (2.7.12.0) unstable; urgency=medium - [ Austin Lane ] - * Initial packaging - * Version 2.5.19 - - [ ] - * GitHub Actions Automatic version bump - - [ GitHub Actions ] * Version 2.7.12 -- GitHub Actions Wed, 01 Oct 2025 19:51:41 +0000 diff --git a/version.properties b/version.properties index c08db24b82..01ea06ed73 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ [VERSION] major = 2 minor = 8 -build = 0 +build = 1 From a2c919d2a2735c8918fe9714ac62c61bd8b9927d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:25:58 +0000 Subject: [PATCH 050/143] chore(deps): update platformio/nordicnrf52 to v11 (#11684) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/nrf52840/nrf52.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index 471db3c8d3..c9bf15c51c 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -2,7 +2,7 @@ ; Instead of the standard nordicnrf52 platform, we use our fork which has our added variant files platform = # renovate: datasource=custom.pio depName=platformio/nordicnrf52 packageName=platformio/platform/nordicnrf52 - platformio/nordicnrf52@10.12.0 + platformio/nordicnrf52@11.0.0 extends = arduino_base platform_packages = ; our custom Git version with C++17 support in platform.txt From 1ed10f48832b739076d57cff6982b74bff5346db Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 1 Sep 2026 23:41:52 +0000 Subject: [PATCH 051/143] fix(raspihttp): build against OpenSSL 4.0's const X509 name getters (#11523) Ubuntu 26.10 ships OpenSSL 4.0, which const-qualified the return of X509_get_subject_name() and X509_get_issuer_name(): 3.5/3.6: X509_NAME *X509_get_subject_name(const X509 *a); 4.0: const X509_NAME *X509_get_subject_name(const X509 *a); generate_self_signed_x509() grabbed the certificate's own subject name and mutated it in place, so the assignment to a non-const X509_NAME * now fails to compile. Unlike notBefore/notAfter there is no X509_getm_ mutable variant to fall back on. Build the X509_NAME standalone instead and hand it to X509_set_subject_ name()/X509_set_issuer_name(), which take a const name and copy it on every OpenSSL from 1.1.0 through 4.0. The setters dup the name, so ours is freed on both the success and failure paths. This also lets the X509_NAME_add_entry_by_txt() calls be error-checked, which they were not before; the caller already X509_free()s the partially built cert when we return -1. Co-authored-by: Claude Opus 5 --- src/mesh/raspihttp/PiWebServer.cpp | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 5dae68a736..75b6458f1c 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -357,13 +357,21 @@ int generate_self_signed_x509(EVP_PKEY *pkey, X509 **x509) X509_set_pubkey(*x509, pkey); - // SET Subject Name - X509_NAME *name = X509_get_subject_name(*x509); - X509_NAME_add_entry_by_txt(name, "C", MBSTRING_ASC, (unsigned char *)"DE", -1, -1, 0); - X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC, (unsigned char *)"Meshtastic", -1, -1, 0); - X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (unsigned char *)"meshtastic.local", -1, -1, 0); - // Selfsigned, Issuer = Subject - X509_set_issuer_name(*x509, name); + // SET Subject Name. Build the name standalone instead of mutating the cert's own in place: + // OpenSSL 4.0 made X509_get_subject_name() return a const pointer, and the setters (which take + // a const name and copy it) are the spelling that compiles against every supported version. + X509_NAME *name = X509_NAME_new(); + if (!name) + return -1; + if (X509_NAME_add_entry_by_txt(name, "C", MBSTRING_ASC, (unsigned char *)"DE", -1, -1, 0) != 1 || + X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC, (unsigned char *)"Meshtastic", -1, -1, 0) != 1 || + X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (unsigned char *)"meshtastic.local", -1, -1, 0) != 1 || + // Selfsigned, Issuer = Subject + X509_set_subject_name(*x509, name) != 1 || X509_set_issuer_name(*x509, name) != 1) { + X509_NAME_free(name); + return -1; + } + X509_NAME_free(name); // Certificate signed with our privte key if (X509_sign(*x509, pkey, EVP_sha256()) <= 0) From 80c6d2f8da00cd9b4639171d1cbda9925ecfc74d Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:10:54 +0000 Subject: [PATCH 052/143] Update device-ui library version in platformio.ini (#11694) --- variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini index 7b43b40b5a..b0c0111ba5 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini @@ -91,9 +91,9 @@ build_flags = lib_deps = ${thinknode_m9_base.lib_deps} - https://github.com/meshtastic/device-ui/archive/e8a5ff337d1ead20b290307fb2159ad27fe47f86.zip ; PR314 input-policy + https://github.com/meshtastic/device-ui/archive/70a9967f202a69390460c908a1321e475d3d4cdf.zip ; PR314 input-policy https://github.com/mverch67/MultiFTPServer/archive/0e854335b9916ed9f2d3bcfe68975ce746992ccd.zip custom_sdkconfig = ${esp32s3_base.custom_sdkconfig} - ${device-ui_base.custom_sdkconfig} \ No newline at end of file + ${device-ui_base.custom_sdkconfig} From 8c4b69c3bc16925420afa3b8a52b9871d2feaffc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:44:50 +0000 Subject: [PATCH 053/143] chore(deps): update meshtastic/device-ui digest to 5870d3a (#11700) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 64d0306aef..7b1ed77827 100644 --- a/platformio.ini +++ b/platformio.ini @@ -138,7 +138,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/9d9b9df81fcde646811a10942d00d5f45f72af7b.zip + https://github.com/meshtastic/device-ui/archive/5870d3a55254aa46bbcab13a5a700d533ab304e3.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 97d916ede8415fab18ed088995f441efc64e3f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 2 Sep 2026 09:02:30 +0200 Subject: [PATCH 054/143] Update supported versions in SECURITY.md --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 34977e15a2..f9b59896fa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Firmware Version | Supported | | ---------------- | ------------------ | -| 2.7.x | :white_check_mark: | -| <= 2.6.x | :x: | +| 2.8.x | :white_check_mark: | +| <= 2.7.x | :x: | ## Reporting a Vulnerability From 4cb912ff55ba225a0037352320e026d09de005b2 Mon Sep 17 00:00:00 2001 From: Lynxie Date: Wed, 2 Sep 2026 08:18:51 +0000 Subject: [PATCH 055/143] fix(gps): remember valid fixes across search cycle (#11697) --- src/gps/GPS.cpp | 5 +++-- src/gps/GPSUpdateScheduling.cpp | 13 +++++++++++++ src/gps/GPSUpdateScheduling.h | 5 ++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 9f29c41f0c..58dadcf530 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1363,7 +1363,7 @@ void GPS::up() // We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially. void GPS::down() { - if (hasValidLocation) + if (scheduling.hasValidFixSinceSearchStarted()) scheduling.informGotLock(); else scheduling.informSearchFailed(); @@ -1545,6 +1545,7 @@ int32_t GPS::runOnce() // 2. Got a lock for the first time, or 3. Got a lock after turning back on bool gotLoc = lookForLocation(); if (gotLoc) { + scheduling.informValidFix(); #if GPS_DEBUG if (!hasValidLocation) { // declare that we have location ASAP LOG_DEBUG("hasValidLocation RISING EDGE"); @@ -1567,7 +1568,7 @@ int32_t GPS::runOnce() } bool tooLong = scheduling.searchedTooLong(); - if (tooLong && !gotLoc) { + if (tooLong && !scheduling.hasValidFixSinceSearchStarted()) { LOG_WARN("Can't publish valid location: no GPS lock in time"); // we didn't get a location during this ack window, therefore declare loss of lock if (hasValidLocation) { diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index 7f37e100c9..9f6cc6695f 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -32,9 +32,16 @@ uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs) void GPSUpdateScheduling::informSearching() { searching = true; + validFixReceived = false; searchStartedMs = Time::getMillis(); } +void GPSUpdateScheduling::informValidFix() +{ + if (searching) + validFixReceived = true; +} + // Mark the time when searching for GPS is complete, // then update the predicted lock-time void GPSUpdateScheduling::informGotLock() @@ -64,6 +71,7 @@ void GPSUpdateScheduling::informSearchFailed() void GPSUpdateScheduling::reset() { searching = false; + validFixReceived = false; searchStartedMs = 0; searchEndedMs = 0; searchCount = 0; @@ -152,6 +160,11 @@ bool GPSUpdateScheduling::searchedTooLong() return false; } +bool GPSUpdateScheduling::hasValidFixSinceSearchStarted() const +{ + return searching && validFixReceived; +} + // Updates the predicted time-to-get-lock, by exponentially smoothing the latest observation void GPSUpdateScheduling::updateLockTimePrediction() { diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h index d7e11ad1ab..b8de3ff654 100644 --- a/src/gps/GPSUpdateScheduling.h +++ b/src/gps/GPSUpdateScheduling.h @@ -12,12 +12,14 @@ class GPSUpdateScheduling public: // Marks the time of these events, for calculation use void informSearching(); + void informValidFix(); void informGotLock(); // Predicted lock-time is recalculated here void informSearchFailed(); // Search ended without a fix; prediction is left untouched void reset(); // Reset the prediction - after GPS::disable() / GPS::enable() bool isUpdateDue(); // Is it time to begin searching for a GPS position? bool searchedTooLong(); // Have we been searching for too long? + bool hasValidFixSinceSearchStarted() const; uint32_t msUntilNextSearch(); // How long until we need to begin searching for a GPS? Info provided to GPS hardware for sleep uint32_t elapsedSearchMs(); // How long have we been searching so far? @@ -26,6 +28,7 @@ class GPSUpdateScheduling private: void updateLockTimePrediction(); // Called from informGotLock bool searching = false; // Set by the inform*() calls; never inferred from stamp ordering + bool validFixReceived = false; uint32_t searchStartedMs = 0; uint32_t searchEndedMs = 0; uint32_t searchCount = 0; @@ -33,4 +36,4 @@ class GPSUpdateScheduling uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time". -}; \ No newline at end of file +}; From fa65b0797ef8e80ccd7a1c35ee34411f344f4b52 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:58:56 +0000 Subject: [PATCH 056/143] chore(deps): update lovyangfx to v1.2.28 (#11615) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32/chatter2/platformio.ini | 2 +- variants/esp32/m5stack_core/platformio.ini | 2 +- variants/esp32/wiphone/platformio.ini | 2 +- variants/esp32s3/elecrow_panel/platformio.ini | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 2 +- variants/esp32s3/heltec_v4_r8/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini | 2 +- variants/esp32s3/mesh-tab/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/platformio.ini | 2 +- variants/esp32s3/rak_wismesh_tap_v2/platformio.ini | 2 +- variants/esp32s3/seeed_wio_tracker_L2/platformio.ini | 2 +- variants/esp32s3/t-deck/platformio.ini | 2 +- variants/esp32s3/t-watch-s3/platformio.ini | 2 +- variants/esp32s3/t-watch-ultra/platformio.ini | 2 +- variants/esp32s3/tlora-pager/platformio.ini | 2 +- variants/esp32s3/tracksenger/platformio.ini | 4 ++-- variants/esp32s3/unphone/platformio.ini | 2 +- variants/native/portduino.ini | 2 +- 20 files changed, 21 insertions(+), 21 deletions(-) diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index 723ecdf04b..0a1a3f41e7 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -13,4 +13,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index 16d562a90d..8ca9462a12 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -36,4 +36,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index 9a5f503210..f8a93460c9 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index 800aafe8bc..2ce5951483 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -50,7 +50,7 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534 hideakitai/TCA9534@0.1.1 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 custom_sdkconfig = ${esp32s3_base.custom_sdkconfig} diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 4e38a34e77..c0e231903e 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -133,7 +133,7 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini index a984b9154d..d693cbce8e 100644 --- a/variants/esp32s3/heltec_v4_r8/platformio.ini +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -141,7 +141,7 @@ build_flags = lib_deps = ${heltec_v4_r8_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 771d070811..cf23f2813d 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -27,4 +27,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index 1a721a9bed..312fc6fe08 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index b86d606705..a6aafbac27 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index c0b678615d..5a88b273d8 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index 3fb177f3e2..23beed76d2 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index af5140102c..3acdf7b254 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini index 9aacb3e19c..12b2a26669 100644 --- a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini +++ b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini @@ -37,7 +37,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # 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 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 1b443582f2..e9d0cd3418 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # 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 diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index ca9195786b..46c41ac5fd 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/t-watch-ultra/platformio.ini b/variants/esp32s3/t-watch-ultra/platformio.ini index 56b47cae2b..8564ec079f 100644 --- a/variants/esp32s3/t-watch-ultra/platformio.ini +++ b/variants/esp32s3/t-watch-ultra/platformio.ini @@ -46,7 +46,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=LovyanGFX packageName=lovyan03/LovyanGFX - https://github.com/lovyan03/LovyanGFX/archive/tags/1.2.27.zip + https://github.com/lovyan03/LovyanGFX/archive/1.2.28.zip # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library adafruit/Adafruit DRV2605 Library@1.2.4 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index c753e8836d..31ff1593ee 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # 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 diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index 5acdd9130c..fbad6347d2 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -23,7 +23,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -50,7 +50,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index fb6988e711..f59048af09 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -37,7 +37,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 5068973e24..5645806321 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -26,7 +26,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.27 + lovyan03/LovyanGFX@1.2.28 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/meshtastic/libch341-spi-userspace gitBranch=main https://github.com/meshtastic/libch341-spi-userspace/archive/03bf505d6e5904092c1c389c45b01098f7a302fe.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library From 3c04a7903120f16b2ee7f5b64be02c2fd87756d3 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Wed, 2 Sep 2026 10:47:23 +0000 Subject: [PATCH 057/143] fix(stm32wl): improve reboot-to-DFU reliability (#11698) - In enter_dfu, arm enterDfuAtMsec = millis() + 5s and return instead of resetting inline; the want_response ACK then goes out the normal path and Power::powerCommandsCheck() calls enterDfuMode() at the deadline. Nudge the deadline off 0 in the rare case the addition wraps to it, since powerCommandsCheck() reads 0 as unarmed. The delay is the client's detach window - and the margin a WebSerial web flasher needs (meshtastic/web-flasher#426). - In enterDfuMode(), stop the GPS and drain/end every configured UART before the reset. The ROM bootloader autobauds off the first byte on USART1 (PB6/PB7) or USART2 (PA2/PA3), and on every WL variant a console UART or the GPS stream sits on those pins. Factor the drain into quiesceSerial() and reuse it in cpuDeepSleep(). - Move earlyBootCheck from constructor(101) to .preinit_array, ahead of the core's premain()/SystemClock_Config() whatever the link order, and reset RCC before jumping to system memory. The handler used to reset the MCU inline, before the ACK was sent and while the client still held the console UART. The STM32WL ROM bootloader autobauds off the first byte received; a stray byte during the handoff (a trailing protobuf frame, a port-close DTR/RTS glitch) desynced it and left the device unreachable at any baud until a hard reset. STM32WL only: every hunk is behind #if defined(ARCH_STM32) or lives in main-stm32wl.cpp. nrf52, rp2040 and the rest are unchanged. Known limitation: gps->disable() only issues a UBX sleep command, so a non-u-blox or otherwise free-running GPS with no hardware enable/standby pin keeps transmitting on its UART past this point. If that UART is USART1 (PB6/PB7) or USART2 (PA2/PA3), the ROM bootloader can still autobaud onto the GPS stream instead of the host. New STM32WL hardware designs should keep GPS UARTs off those two bootloader-autobaud pins, or provide a way to power down or hold the GPS in reset before DFU. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong --- src/Power.cpp | 8 +++ src/main.cpp | 3 + src/main.h | 3 + src/mesh/Throttle.h | 4 +- src/modules/AdminModule.cpp | 15 ++++- src/platform/stm32wl/main-stm32wl.cpp | 85 +++++++++++++++++++-------- 6 files changed, 90 insertions(+), 28 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 84bf12aaf5..09b20f6ff6 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -976,6 +976,14 @@ void Power::powerCommandsCheck() shutdownAtMsec = 0; shutdown(); } + +#ifdef ARCH_STM32 + // Deferred DFU entry; the delay is armed by AdminModule's enter_dfu handler (rationale there). + if (enterDfuAtMsec && Throttle::deadlinePassed(enterDfuAtMsec)) { + enterDfuAtMsec = 0; + enterDfuMode(); // never returns + } +#endif } void Power::reboot() diff --git a/src/main.cpp b/src/main.cpp index b5db79b6bb..7fde17346e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1283,6 +1283,9 @@ void setup() uint32_t rebootAtMsec; // If not zero we will reboot at this time (used to reboot shortly after the update completes) uint32_t shutdownAtMsec; // If not zero we will shutdown at this time (used to shutdown from python or mobile client) bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for OTA handoff) +#ifdef ARCH_STM32 +uint32_t enterDfuAtMsec; // If not zero, enter DFU mode at this millis() deadline (see main.h) +#endif #if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) volatile bool lockdownReloadPending; // see main.h - deferred NodeDB reload after lockdown unlock diff --git a/src/main.h b/src/main.h index 19b1bace0d..49a63365bc 100644 --- a/src/main.h +++ b/src/main.h @@ -92,6 +92,9 @@ extern uint32_t timeLastPowered; extern uint32_t rebootAtMsec; extern uint32_t shutdownAtMsec; extern bool suppressRebootBanner; +#ifdef ARCH_STM32 +extern uint32_t enterDfuAtMsec; // 0 = unset; else millis() deadline for the deferred DFU jump +#endif #if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) // Set by PhoneAPI::handleLockdownAuthInline after a successful unlock. diff --git a/src/mesh/Throttle.h b/src/mesh/Throttle.h index f9d68a4143..7df932dfab 100644 --- a/src/mesh/Throttle.h +++ b/src/mesh/Throttle.h @@ -34,8 +34,8 @@ class Throttle /// "passed" - the split that has to survive, because which way "inactive" falls is the caller's /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the four /// meanings they give the sentinel today: - /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec (the cheapest pair to convert), and - /// GPS.cpp fixHoldEnds, whose arm site remaps a 0 result to 1 by hand. + /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec, GPS.cpp fixHoldEnds, AdminModule.cpp + /// enterDfuAtMsec - the last two remap a 0 result to 1 at the arm site by hand. /// 0 = forever - NotificationRenderer.cpp alertBannerUntil. Every read spells its own `> 0` /// guard, so this third state wants naming rather than repeating. /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 4995f3c822..abc71034e6 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -68,6 +68,11 @@ AdminModule *adminModule; +#ifdef ARCH_STM32 +// Client detach window before the DFU jump (see the enter_dfu case). +static constexpr uint32_t STM32_DFU_DETACH_DELAY_MS = 5000; +#endif + #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) static bool licensedIdentityWillMigrate() { @@ -619,7 +624,15 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #if HAS_SCREEN IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0)); #endif -#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32) +#if defined(ARCH_STM32) + // Delay the jump so this ACK reaches the client and it releases the port before the + // STM32WL ROM bootloader takes the UART and autobauds off the next byte it sees. + LOG_INFO("Entering DFU in %us - disconnect now", (STM32_DFU_DETACH_DELAY_MS + 999) / 1000); + enterDfuAtMsec = millis() + STM32_DFU_DETACH_DELAY_MS; + // Guard against enterDfuAtMsec rolling over to 0, the sentinel powerCommandsCheck() reads as unarmed. + if (enterDfuAtMsec == 0) + enterDfuAtMsec = 1; +#elif defined(ARCH_NRF52) || defined(ARCH_RP2040) enterDfuMode(); #endif break; diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 4d54f22895..1f363edfa9 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,6 +1,7 @@ #include "FSCommon.h" #include "configuration.h" #include "error.h" +#include "gps/GPS.h" #include "gps/RTC.h" #include #include @@ -20,23 +21,27 @@ static bool stm32wlRtcValid = false; #endif // ─── Bootloader redirect ────────────────────────────────────────────────────── -// Uses .noinit SRAM instead of TAMP backup registers: STM32duino's clock init can wipe the -// backup domain via __HAL_RCC_BACKUPRESET_FORCE/RELEASE before setup() runs, but .noinit -// survives NVIC_SystemReset() and this constructor fires before HAL_Init() touches anything. +// Magic word in .noinit, not TAMP backup regs (STM32duino clock init can wipe those): it +// survives NVIC_SystemReset(), and the .preinit_array hook below runs before HAL_Init(). +// STM32WLxx system-memory bootloader base, AN2606 "STM32WLxx bootloader": +// https://www.st.com/resource/en/application_note/an2606-stm32-microcontroller-system-memory-boot-mode-stmicroelectronics.pdf #define BOOTLOADER_MAGIC 0xD00DB007UL #define SYS_MEM_BASE 0x1FFF0000UL -// Placed in .noinit - not zeroed at startup, survives NVIC_SystemReset(). +// .noinit - not zeroed at startup, survives NVIC_SystemReset(). __attribute__((section(".noinit"), used)) volatile uint32_t g_bootloaderMagic; -// Fires before main() / HAL_Init(). Must use only core Cortex-M registers. -__attribute__((constructor(101), used)) static void earlyBootCheck(void) +// Runs from .preinit_array (below), before every constructor incl. the core's premain()/init(), +// so RCC/SysTick/HAL are still at reset. Core Cortex-M / CMSIS registers only. +__attribute__((used)) static void earlyBootCheck(void) { if (g_bootloaderMagic != BOOTLOADER_MAGIC) return; g_bootloaderMagic = 0; + // Return SysTick/NVIC/RCC to reset state before the jump - ST's system bootloader expects it. + // https://community.st.com/t5/stm32-mcus/how-to-jump-to-system-bootloader-from-application-code-on-stm32/ta-p/49424 SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; @@ -44,20 +49,56 @@ __attribute__((constructor(101), used)) static void earlyBootCheck(void) NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } + + // Same writes CMSIS system_stm32wlxx.c SystemInit() makes, repeated here in case a hook + // between reset and .preinit_array moved SYSCLK onto the PLL (RM0461 reset values). + RCC->CR |= 0x00000061U; // MSION, MSI range 4 MHz + RCC->CFGR = 0x00070000U; // SYSCLK=MSI, AHB/APB prescalers /1, MCO off + RCC->CR = 0x00000061U; // HSEON/HSEBYP/CSSON/PLLON off + RCC->PLLCFGR = 0x22040100U; + RCC->CIER = 0x00000000U; + RCC->CICR = 0x0000033FU; // clear all RCC interrupt flags + __DSB(); __ISB(); SCB->VTOR = SYS_MEM_BASE; __set_MSP(*(volatile uint32_t *)SYS_MEM_BASE); ((void (*)(void))(*(volatile uint32_t *)(SYS_MEM_BASE + 4)))(); - // Should never be reached: the bootloader ROM does not return. A bare reset - // (rather than returning normally) avoids unwinding through this function's - // epilogue, which would restore registers relative to the now-repointed MSP. + // Not reached: the bootloader ROM does not return. Reset rather than return, to avoid + // unwinding this function's epilogue against the now-repointed MSP. NVIC_SystemReset(); } +// __libc_init_array runs .preinit_array entries before any .init_array constructor, so this +// precedes the core's premain() (constructor(101)) regardless of link order. +__attribute__((section(".preinit_array"), used)) static void (*const earlyBootCheckEntry)(void) = &earlyBootCheck; + +// Drain and release a UART before the reset, as cpuDeepSleep() does. +static void quiesceSerial(HardwareSerial &port) +{ + if (port) { + port.flush(); + port.end(); + } +} + void enterDfuMode() { g_bootloaderMagic = BOOTLOADER_MAGIC; + + // The ROM bootloader autobauds off the first byte on USART1 (PB6/PB7) or USART2 (PA2/PA3), + // and every WL UART and GPS sits on those pins. Silence them all before the reset. +#if !MESHTASTIC_EXCLUDE_GPS + if (gps) + gps->disable(); +#endif + quiesceSerial(Serial); +#ifdef ENABLE_HWSERIAL1 + quiesceSerial(Serial1); +#endif +#ifdef ENABLE_HWSERIAL2 + quiesceSerial(Serial2); +#endif HAL_NVIC_SystemReset(); } @@ -140,25 +181,19 @@ void cpuDeepSleep(uint32_t msecToWake) // Hardware can't shutdown, but firmware has already prepared itself for shutdown // Do not leave the device unresponsive, reset instead LOG_WARN("STM32WL: hardware RTC failed, can't deep sleep/shutdown"); - if (Serial) { - Serial.flush(); - Serial.end(); + quiesceSerial(Serial); + HAL_NVIC_SystemReset(); + } else { + quiesceSerial(Serial); + + if (msecToWake != portMAX_DELAY) { + LowPower.shutdown(msecToWake); + } else { + LowPower.shutdown(); } + // RTC wakes from shutdown into MCU reset, so this code should never be reached HAL_NVIC_SystemReset(); } - - if (Serial) { - Serial.flush(); - Serial.end(); - } - - if (msecToWake != portMAX_DELAY) { - LowPower.shutdown(msecToWake); - } else { - LowPower.shutdown(); - } - // RTC wakes from shutdown into MCU reset, so this code should never be reached - HAL_NVIC_SystemReset(); #endif } From a6ef5d1ce685eda4ab2cc47ff9fe08bdbb6a3b9c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 2 Sep 2026 12:48:03 +0000 Subject: [PATCH 058/143] fix(esp32s3): drop the SenseCAP Indicator's 120 MHz boot clock (#11705) boards/seeed-sensecap-indicator.json was the only board file carrying "f_boot": "120000000L". Under platformio/espressif32 6.x that key only selected a prebuilt bootloader image. Under pioarduino HybridCompile, which this board uses since it moved to the 3.3.11-based core (#11238), f_boot becomes the compile-time clock for both flash and PSRAM, so every 2.8 build of the Indicator is compiled with CONFIG_ESPTOOLPY_FLASHFREQ_120M, CONFIG_SPI_FLASH_HPM_ON and CONFIG_SPIRAM_SPEED_120M (octal PSRAM at 120 MHz is an experimental ESP-IDF feature). The device hangs in early flash/PSRAM init before the boot watchdog is disarmed and reset-loops with RTCWDT_RTC_RST and no bootloader output, also after a full erase and install. Without the key the build falls back to f_flash (80 MHz) like every other ESP32-S3 board, reports "80MHz for both Flash and PSRAM", and produces a bootloader byte-identical to the T-Deck's 2.8 bootloader. Fixes #11691 --- boards/seeed-sensecap-indicator.json | 1 - 1 file changed, 1 deletion(-) diff --git a/boards/seeed-sensecap-indicator.json b/boards/seeed-sensecap-indicator.json index 37a97cdf19..861b5bafe9 100644 --- a/boards/seeed-sensecap-indicator.json +++ b/boards/seeed-sensecap-indicator.json @@ -15,7 +15,6 @@ ], "f_cpu": "240000000L", "f_flash": "80000000L", - "f_boot": "120000000L", "boot": "qio", "flash_mode": "qio", "psram_type": "opi", From 9850b76351118605e7ed2605a6d7ce6cb74ed9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 2 Sep 2026 21:05:03 +0000 Subject: [PATCH 059/143] ci(test): shard the native test suite across a matrix (#11706) * ci(test): shard the native test suite across a matrix Replace the single sequential runner with a matrix populated by bin/test-shards.py from the test/ tree: areas over --max-suites are split, smaller ones packed, and a --max-shards budget bounds the fan-out. A collector job merges the per-shard JUnit reports, checks the union against the canonical suite set, and states the verdict. Native PlatformIO Tests remains as the single required check over the matrix. Drop the --without-testing warm build. PlatformIO links every native test program to the same $BUILD_DIR/$PROGNAME, so the area run relinked each suite regardless. ccache carries the shared src objects between shards instead; one shard is flagged cache_writer so a single entry is saved. The coverage-event-policy and coverage-channel-table envs and the attribution canary move into their own matrix rows and job. Harden the new paths: bound the matrix row count so a branch cannot size the fan-out, reject multi-line or empty $GITHUB_OUTPUT values, fail the whole-run attribution gate on an empty expected set, upload exact report and tracefile names instead of globs, and pass the repo path to bin/lib/shuffle.sh as an argument rather than into bash -c source text. 12 shards, largest 9 suites. * ci(test): minimal test toolchain, cap shard runtime, fix pack overflow Add .github/actions/setup-native-test, used by the shard and canary jobs in place of setup-native. It drops the redundant second checkout, both submodules (src/mesh/generated is tracked, meshtestic is the hardware harness), cppcheck, and the adafruit-nrfutil, poetry and meshtastic pip installs, and folds in ccache and lcov. setup-base and setup-native are unchanged, so the firmware matrix and every other consumer keep theirs. Cap the shard job at 30 minutes. A lost runner held one for 48 of the 360 GitHub allows by default, and there are twelve of them. pack() could exceed --max-suites: ceil(total / cap) is a lower bound and whole areas do not divide, so three areas of 6 at cap 10 put 12 in one of two bins. Grow the bin count until every bin fits. Validate the fixed-env test_filter tokens against SUITE_RE. PlatformIO accepts globs there, and those tokens reach the same word-split and the same attribution gate as discovered names. Split with read -ra so a token cannot glob against the workspace either. Report the suite count rather than the length of the -f argument array, which counted every name twice. Trim comments to the one or two lines AGENTS.md asks for. * ci(test): quote the $GITHUB_OUTPUT redirects Applied to all five, including the three that predate this branch, so the file is consistent rather than half-converted. --- .github/actions/setup-native-test/action.yml | 52 ++ .github/copilot-instructions.md | 4 +- .github/workflows/test_native.yml | 565 ++++++++++++------- bin/check-test-attribution.py | 8 +- bin/test-shards.py | 267 +++++++++ 5 files changed, 679 insertions(+), 217 deletions(-) create mode 100644 .github/actions/setup-native-test/action.yml create mode 100755 bin/test-shards.py diff --git a/.github/actions/setup-native-test/action.yml b/.github/actions/setup-native-test/action.yml new file mode 100644 index 0000000000..0c52ef11dc --- /dev/null +++ b/.github/actions/setup-native-test/action.yml @@ -0,0 +1,52 @@ +name: Setup native test build +description: >- + Minimal toolchain for the native test suites. Separate from setup-native, which is shared with + the firmware matrix and is not the place to trim things only the test path can spare. + +runs: + using: composite + steps: + # No checkout: the caller must already have one to reference this action at all. + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: 3.x + cache: pip + cache-dependency-path: | + .github/actions/** + **.ini + + - name: Install build and test dependencies + shell: bash + # C libraries are the full setup-native list: they are real link deps of the portduino HAL, + # the web server and the config parser. cppcheck is check_tool for `pio check`, not `pio test`. + run: | + set -euo pipefail + sudo apt-get -y update --fix-missing + sudo apt-get install -y ccache lcov \ + libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \ + libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libcurl4-gnutls-dev + + - name: Install PlatformIO + shell: bash + # No adafruit-nrfutil (nRF52 DFU), poetry, or meshtastic (the client, needed only by the + # simulator job), and no `pio upgrade` right after installing the current release. + run: | + set -euo pipefail + python -m pip install --upgrade pip + pip install -U --no-build-isolation --no-cache-dir "setuptools<72" + pip install -U platformio --no-build-isolation + + - name: Configure ccache + shell: bash + # /usr/lib/ccache holds compiler-named symlinks, so PATH is the whole wiring. time_macros: + # ccache otherwise refuses any TU mentioning __DATE__/__TIME__, which BUILD_EPOCH disables. + run: | + set -euo pipefail + echo "/usr/lib/ccache" >> "$GITHUB_PATH" + { + echo "CCACHE_DIR=$HOME/.ccache" + echo "CCACHE_COMPRESS=1" + echo "CCACHE_MAXSIZE=400M" + echo "CCACHE_SLOPPINESS=time_macros" + } >> "$GITHUB_ENV" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3d20ca974f..97a1495045 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -632,7 +632,7 @@ The project uses GitHub Actions extensively for CI/CD. Key workflows are in `.gi - Includes native tests and hardware-in-the-loop testing - **`test_native.yml`** - Native platform unit tests - - Runs `pio test -e native` + - Runs the `test/test_*` suites under `[env:coverage]`, sharded across a matrix. `bin/test-shards.py` derives the matrix from the tree - it groups suites into named areas, splits an area too big for one runner and packs the ones too small to fill one - so adding a suite needs no CI change. The `generate-reports` job collects every shard's JUnit report, checks the union against the canonical `test/test_*` set, and states the verdict; `Native PlatformIO Tests` is the single required check over the fan-out. ### Release Workflows @@ -719,7 +719,7 @@ Unit tests in `test/` directory. The canonical suite count is detected on the fl **A signal name from the runner is not a crash.** `exit(UNITY_END())` returns the failure count, and PlatformIO's native runner renders a non-zero exit code as a POSIX signal - 4 failures prints `Program received signal SIGILL`, 5 prints `SIGTRAP`, and the suite is reported `[ERRORED]` instead of `[FAILED]`. Check the exit code against the failure count before theorising about memory bugs; confirm any real crash under a debugger. -**Suite order is randomisable.** `./bin/run-tests.sh --shuffle` runs suites in a seeded random order; `--seed ` replays one. The seed defaults to the commit SHA (deterministic per commit, varied across commits), is printed at the start and on the `RESULT:` line, and the full order is printed on failure. CI shuffles its area order the same way, seeded from `GITHUB_SHA`. A single green seed is not evidence of order independence. +**Suite order is randomisable.** `./bin/run-tests.sh --shuffle` runs suites in a seeded random order; `--seed ` replays one. The seed defaults to the commit SHA (deterministic per commit, varied across commits), is printed at the start and on the `RESULT:` line, and the full order is printed on failure. CI seeds from `GITHUB_SHA` the same way, but its shards run in parallel, so there the seed varies which suites _share_ a shard rather than the order they run in; `pull_request` keeps the declared arrangement so a contributor's PR never goes red for a pairing they did not choose. A single green seed is not evidence of order independence. **`-f` is not a gate.** A filtered run can pass while a full run fails, because filtering removes the suites that _create_ the state a later suite trips over. Iterate with `-f`; gate on a full run. diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 8ee8fa0f0b..4af381abd0 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -5,22 +5,32 @@ on: inputs: suite_order_seed: description: >- - Seed for shuffling the test-area order. Empty (the default) means: fixed declared order on - pull_request, so a contributor's PR never turns red because of an order they did not - choose; commit-SHA-derived elsewhere. Set a number to force that exact order anywhere - - that is how you replay a shuffled failure. + Seed varying which suites share a shard. Empty (the default) means: the fixed declared + arrangement on pull_request, so a contributor's PR never turns red because of a pairing + they did not choose; commit-SHA-derived elsewhere. Set a number to force that exact + arrangement anywhere - that is how you replay a shuffled failure. type: string required: false default: "" + max_suites_per_shard: + description: >- + Largest shard, in suites. Lower splits the matrix further: faster wall clock, more + runners. The floor per shard is checkout + toolchain + one src build, so below about 8 + the fixed cost starts to dominate what is being parallelised. + type: number + required: false + default: 10 workflow_dispatch: permissions: {} env: - # Only pushes to the default branch (develop) populate the cache; PR / merge_group runs + # Only pushes to the default branch (develop) populate the caches; PR / merge_group runs # restore it but never save, so they stop filling up the repo's Actions cache storage. SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }} - LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}" + # No --directory: callers add it, since shards capture from .pio/build//src. Keeping the + # include/exclude filters shared is what stops a shard capturing a different file set. + LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --base-directory "${PWD}" jobs: # Tripwire against the native suite set shrinking by accident. `platformio test` discovers and @@ -184,7 +194,7 @@ jobs: shell: bash run: | sudo apt-get install -y lcov - lcov ${{ env.LCOV_CAPTURE_FLAGS }} --initial --output-file coverage_base.info + lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory .pio/build/coverage/src --initial --output-file coverage_base.info sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative. - name: Config check tests @@ -234,12 +244,12 @@ jobs: - name: Capture coverage information if: always() # run this step even if previous step failed run: | - lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name integration --output-file coverage_integration.info + lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory .pio/build/coverage/src --test-name integration --output-file coverage_integration.info sed -i -e "s#${PWD}#.#" coverage_integration.info # Make paths relative. - name: Get release version string if: always() # run this step even if previous step failed - run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT" id: version - name: Save coverage information @@ -250,24 +260,102 @@ jobs: overwrite: true path: ./coverage_*.info - platformio-tests: - name: Native PlatformIO Tests - runs-on: ubuntu-24.04-arm + # bin/test-shards.py derives the matrix from test/, so adding a suite needs no CI change. + # Cheap by design: a checkout and a python run, sitting on every shard critical path. + discover: + name: Native Test Shards + # ubuntu-latest, not the slim image: this needs a python3 to run bin/test-shards.py, and it is + # on the critical path of every shard, so it must not have to install one. + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.shards.outputs.matrix }} + suites: ${{ steps.shards.outputs.suites }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - submodules: recursive + persist-credentials: false - - name: Setup native build + - name: Build the shard matrix + id: shards + shell: bash + # Both inputs reach the script through env: rather than ${{ }} inside run:, so nothing from + # the event payload is ever spliced into the shell text. + env: + SUITE_ORDER_SEED: ${{ inputs.suite_order_seed }} + MAX_SUITES: ${{ inputs.max_suites_per_shard || 10 }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + + # pull_request keeps the declared arrangement so a PR never reds for a pairing its author did not + # choose; elsewhere the SHA seeds it. Varies co-location, not order within a shard. + if [ -n "${SUITE_ORDER_SEED:-}" ]; then + seed="$SUITE_ORDER_SEED" + echo "shard arrangement: shuffled with explicitly supplied seed $seed" + elif [ "${EVENT_NAME:-}" = "pull_request" ]; then + seed="" + echo "shard arrangement: declared order (pull_request)" + echo " to exercise a different arrangement, re-run this workflow with a suite_order_seed input" + else + seed=$((16#${GITHUB_SHA:0:8})) + echo "shard arrangement: shuffled with seed $seed (from ${GITHUB_SHA:0:8})" + fi + + matrix=$(./bin/test-shards.py --max-suites "$MAX_SUITES" --seed "$seed" --summary) + # The canonical suite set, passed to the collector so its whole-run gate checks against + # the same walk this matrix was built from rather than a second one. + suites=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort | tr '\n' ' ') + + # A newline in a value writes a second entry, setting outputs this step never declared. Both feed + # control flow, so assert single-line rather than assume it. + for value in "$matrix" "$suites"; do + if [ "$value" != "${value%%$'\n'*}" ]; then + echo "::error title=Multi-line step output::bin/test-shards.py or the suite walk produced a value spanning lines. Refusing to write it to \$GITHUB_OUTPUT - a newline there sets outputs this step did not declare." + exit 1 + fi + done + [ -n "$matrix" ] && [ -n "$suites" ] || { + echo "::error title=Empty shard matrix::the matrix or the suite list came out empty; a downstream gate that expects nothing passes on anything." + exit 1 + } + + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + echo "suites=$suites" >> "$GITHUB_OUTPUT" + + # No build-then-run split: PlatformIO relinks each suite regardless, so the warm build bought a + # shared src build and ~75 throwaway links. ccache carries src objects between shards instead. + platformio-tests: + name: Suites (${{ matrix.shard }}) + needs: discover + runs-on: ubuntu-24.04-arm + # Measured cold-cache shards run 5-12 minutes. Without this a hung suite, or a runner that + # stops reporting, holds a runner until GitHub's 6-hour default - times twelve shards. + timeout-minutes: 30 + permissions: + contents: read + # fail-fast off: a cancelled sibling stops the collector telling "failed" from "never ran". + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} + steps: + # No submodules: src/mesh/generated is tracked and meshtestic is the hardware harness, so neither + # is reachable from . + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup native test build id: base - uses: ./.github/actions/setup-native + uses: ./.github/actions/setup-native-test - name: Get release version string - run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT" id: version - # Disable (comment-out) BUILD_EPOCH. It causes a full rebuild between tests and resets the - # coverage information each time. + # Disable (comment-out) BUILD_EPOCH. It forces a full rebuild between tests, resets coverage each + # time, and would put a fresh timestamp in every TU, which is also what would defeat ccache. - name: Disable BUILD_EPOCH run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini @@ -280,214 +368,163 @@ jobs: restore-keys: | pio-coverage-tests- - - name: Warm the shared test build - # Compiles src + every test program once so no single area absorbs the whole src build in - # its reported duration; gcov then accumulates counts into this shared - # .pio/build/coverage/src as the areas run. NOT a substitute for building in the run step: - # PlatformIO links every test program to the one .pio/build/coverage/meshtasticd path, so a - # --without-building run executes whichever suite was linked last under every suite's name. - run: platformio test -e coverage --without-testing + # Every shard compiles the same ~450 src TUs; unshared, that is the cost of fanning out. + - name: Restore ccache + id: ccache-restore + uses: actions/cache/restore@v6 + with: + path: ~/.ccache + # One lineage for all shards: src objects dominate and are identical. run_id only makes each save + # a fresh entry; the prefix restore-key selects the newest. + key: ccache-native-tests-${{ github.run_id }} + restore-keys: | + ccache-native-tests- + + - name: Run this shard's suites + id: run + shell: bash + # Suite names come from bin/test-shards.py, which refuses any name outside + # ^test_[A-Za-z0-9_]+$ - so the word-split below cannot pick up shell metacharacters. + env: + PIO_ENV: ${{ matrix.env }} + SHARD: ${{ matrix.shard }} + SUITES: ${{ matrix.suites }} + run: | + set -uo pipefail + # read -ra, not an unquoted expansion: word-splits without letting a token glob against + # the workspace. bin/test-shards.py holds every name to ^test_[A-Za-z0-9_]+$ as well. + read -ra suites <<<"$SUITES" + filters=() + for suite in "${suites[@]}"; do filters+=(-f "$suite"); done + echo "shard $SHARD: ${#suites[@]} suite(s) under [env:$PIO_ENV] -> $SUITES" + + # Log to a file for platformio real exit status, then drop the per-variant SKIPPED rows: suites + # outside this shard are reported SKIPPED by design. + rc=0 + platformio test -e "$PIO_ENV" -v "${filters[@]}" \ + --junit-output-path "testreport-$SHARD.xml" > shard.log 2>&1 || rc=$? + grep -v "[[:space:]]SKIPPED$" shard.log || true + exit $rc + + - name: Verify this shard ran its own tests + # Not conditional on the run passing: a suite that reported another suite's test cases is a + # different, worse finding than a failing assertion, and it must not be hidden behind one. + if: always() + env: + SHARD: ${{ matrix.shard }} + SUITES: ${{ matrix.suites }} + run: ./bin/check-test-attribution.py --label "shard $SHARD" --expect "$SUITES" "testreport-$SHARD.xml" + + - name: Capture coverage information + if: always() # run this step even if previous step failed + env: + PIO_ENV: ${{ matrix.env }} + SHARD: ${{ matrix.shard }} + run: | + sudo apt-get install -y lcov + # One tracefile per shard; the collector sums them with --add-tracefile into the union. + lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory ".pio/build/$PIO_ENV/src" \ + --test-name "$SHARD" --output-file "coverage_tests_$SHARD.info" + sed -i -e "s#${PWD}#.#" "coverage_tests_$SHARD.info" # Make paths relative. + + - name: ccache statistics + # Printed, not asserted. A cache that has silently stopped hitting shows up here as the + # shards getting slower, which is the symptom worth being able to explain. + if: always() + run: ccache --show-stats || true + + - name: Save ccache + # Exactly one shard saves (cache_writer): every shard needs the same src objects, and letting all + # of them save would race for the key. + if: always() && env.SAVE_CACHE == 'true' && matrix.cache_writer + uses: actions/cache/save@v6 + with: + path: ~/.ccache + key: ccache-native-tests-${{ github.run_id }} - name: Save PlatformIO cache - if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true' + if: env.SAVE_CACHE == 'true' && matrix.cache_writer && steps.pio-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v6 with: path: ~/.platformio/.cache key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} - - name: Run tests one area at a time - shell: bash - # Both values reach the script through env: rather than ${{ }} inside run:, so nothing from - # the event payload is ever spliced into the shell text. - env: - SUITE_ORDER_SEED: ${{ inputs.suite_order_seed }} - EVENT_NAME: ${{ github.event_name }} - run: | - set -uo pipefail - # One runner, no matrix, no concurrency. Group the test_* suites by area and run each - # area sequentially, reusing the single build above (--without-building). Each area gets - # its own JUnit report and its own collapsible log, so a failure lands in a small named - # section instead of being buried past the log limit. Sequential runs share one build - # dir, so gcov coverage accumulates and the single capture in the next step has the union. - - # Ordered area rules "name:ERE"; first match wins. Anything unmatched falls to "misc", so - # a newly added suite always runs even before it is placed. Add a suite to an area by - # extending that area's regex; add a new area by inserting a rule line. - area_rules=( - "admin:^test_(admin|pki)_" - "crypto:^test_(crypto|packet_signing)$" - "routing:^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_" - "position:^test_position_" - "fuzz:^test_fuzz_" - "packets:^test_(packet|transmit|meshpacket)_" - "io:^test_(serial|stream|xmodem|http|mqtt)" - ) - - mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) - declare -A group - for s in "${suites[@]}"; do - a="misc" - for rule in "${area_rules[@]}"; do - if [[ "$s" =~ ${rule#*:} ]]; then a="${rule%%:*}"; break; fi - done - group[$a]="${group[$a]:-} -f $s" - done - - run_order=() - for rule in "${area_rules[@]}"; do run_order+=("${rule%%:*}"); done - run_order+=("misc") - - # Area order. The rule order above is an accident of how the areas were written, and - # running it fixed forever means order dependence between areas is never observed - but - # randomising it on a contributor's PR would turn their run red for an order they did not - # choose, which is how a randomisation gets reverted instead of the coupling fixed. - # - # So: pull_request keeps the fixed declared order. Everywhere else (push, schedule) the - # order is shuffled, seeded from the commit SHA - deterministic per commit, replayable, - # attributable, and it never blocks someone else's PR. An explicit seed input overrides - # both, which is how you replay a specific failing order anywhere. - # - # Intra-area order stays PlatformIO's: filters select suites, they do not order them - # (list_test_names() walks test/ with os.walk()), so controlling it needs one invocation - # per suite. bin/run-tests.sh --shuffle does exactly that locally. - seed_input="${SUITE_ORDER_SEED:-}" - if [ -n "$seed_input" ]; then - seed="$seed_input" - echo "area order: shuffled with explicitly supplied seed $seed" - elif [ "${EVENT_NAME:-}" = "pull_request" ]; then - seed="" - echo "area order: fixed declared order (pull_request) - ${run_order[*]}" - echo " to exercise a different order, re-run this workflow with a suite_order_seed input" - else - seed=$((16#${GITHUB_SHA:0:8})) - echo "area order: shuffled with seed $seed (from ${GITHUB_SHA:0:8})" - fi - - if [ -n "$seed" ]; then - # Same shuffle_suites() bin/run-tests.sh uses, so the replay hint below is true by - # construction rather than by two copies happening to agree. - source bin/lib/shuffle.sh - mapfile -t run_order < <(shuffle_suites "$seed" "${run_order[@]}") - echo "area order: ${run_order[*]}" - echo " replay locally: ./bin/run-tests.sh --shuffle --seed $seed" - fi - - fail=0 - for a in "${run_order[@]}"; do - [ -n "${group[$a]:-}" ] || continue - echo "::group::area $a (${group[$a]# })" - # Capture platformio's real exit status (not grep's) via a log file, then show the log - # with the noisy per-variant SKIPPED rows filtered out. - if ! platformio test -e coverage -v ${group[$a]# } \ - --junit-output-path "testreport-$a.xml" > "area-$a.log" 2>&1; then - fail=1 - echo "::error::area $a had test failures" - fi - # Suites outside this area are reported SKIPPED by design (PlatformIO lists every suite - # in the env and marks the unselected ones finished), so those rows are noise here. The - # attribution check below is what catches a suite that was selected and did not run. - grep -v "[[:space:]]SKIPPED$" "area-$a.log" || true - # Per area, so a mismatch names the area it happened in rather than the whole run. - if ! ./bin/check-test-attribution.py --label "area $a" \ - --expect "${group[$a]# }" "testreport-$a.xml"; then - fail=1 - echo "::error::area $a ran suites that did not match their own test binaries" - fi - echo "::endgroup::" - done - exit $fail - - - name: Merge per-area reports into testreport.xml - # Preserve the single-file JUnit contract that downstream consumers rely on - # (pr_tests.yml's summary and generate-reports' Test Report both read testreport.xml). - # The per-area split is only for readable logs; the report stays consolidated. - if: always() # run even when a chunk failed, so the report captures the failures - shell: bash - run: | - python3 - <<'PY' - import glob, xml.etree.ElementTree as ET - out = ET.Element('testsuites') - for f in sorted(glob.glob('testreport-*.xml')): - try: - root = ET.parse(f).getroot() - except ET.ParseError: - continue - # PlatformIO writes a root; fold in a bare too, just in case. - out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root]) - ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True) - PY - - - name: Verify every suite ran its own tests - # Whole-run gate over the merged report: every test_* directory must appear with at least - # one test case, and every case must come from the suite that reported it. The per-area - # check above cannot see an area that never executed - this can. - if: always() # a suite going missing is the finding; do not hide it behind an earlier failure - shell: bash - run: | - set -euo pipefail - mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) - ./bin/check-test-attribution.py --label "coverage (all areas)" \ - --expect "${suites[*]}" testreport.xml - - - name: Capture coverage information - if: always() # run this step even if previous step failed - run: | - sudo apt-get install -y lcov - lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info - sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative. - - - name: Attribution canary - # Guards the guard above: runs two suites the broken way (--without-building, so PlatformIO - # does not relink and both execute the same leftover binary) and requires the checker to - # catch it. Fails if the checker regressed, or if the reproduction stops reproducing - in - # which case the reason both harnesses stopped passing that flag no longer holds. - # - # Lives in this job, not simulator-tests: it relinks $BUILD_DIR/$PROGNAME, and there that - # replaced the daemon binary with a test suite, so the integration test waited for a socket - # a test binary never opens. Here the binary is already per-suite and nothing later needs it. - timeout-minutes: 15 - run: ./bin/test-attribution-canary.sh -e coverage - - - name: Event channel policy tests - run: platformio test -e coverage-event-policy -v --junit-output-path event-policy-testreport.xml - - - name: Verify the event-policy suites ran their own tests - # Expected set read through PlatformIO's own config parser, so it cannot drift from the - # env's test_filter the way a second hand-maintained list would. - run: | - set -euo pipefail - expect=$(python3 -c "from platformio.project.config import ProjectConfig; \ - print(' '.join(ProjectConfig().get('env:coverage-event-policy', 'test_filter', [])))") - ./bin/check-test-attribution.py --label coverage-event-policy \ - --expect "$expect" event-policy-testreport.xml - - - name: Channel table userPrefs tests - run: platformio test -e coverage-channel-table -v --junit-output-path channel-table-testreport.xml - - - name: Verify the channel-table suite ran its own tests - run: | - set -euo pipefail - expect=$(python3 -c "from platformio.project.config import ProjectConfig; \ - print(' '.join(ProjectConfig().get('env:coverage-channel-table', 'test_filter', [])))") - ./bin/check-test-attribution.py --label coverage-channel-table \ - --expect "$expect" channel-table-testreport.xml - - name: Save test results if: always() # run this step even if previous step failed uses: actions/upload-artifact@v7 with: - name: platformio-test-report-${{ steps.version.outputs.long }} + name: platformio-test-report-${{ matrix.shard }}-${{ steps.version.outputs.long }} overwrite: true - path: ./*testreport.xml + # Named, not globbed: a test suite can write to the workspace, and the collector merges whatever + # arrives into the report its gate reads. + path: ./testreport-${{ matrix.shard }}.xml - name: Save coverage information - uses: actions/upload-artifact@v7 if: always() # run this step even if previous step failed + uses: actions/upload-artifact@v7 with: - name: lcov-coverage-info-native-platformio-tests-${{ steps.version.outputs.long }} + name: lcov-coverage-info-native-shard-${{ matrix.shard }}-${{ steps.version.outputs.long }} overwrite: true - path: ./coverage_*.info + # Named exactly, for the same reason as the report above: everything uploaded here is + # merged into the published coverage report. + path: ./coverage_tests_${{ matrix.shard }}.info + # Reproduces the false green on purpose (--without-building) and requires the checker to catch it. + # Its own job because it relinks $BUILD_DIR/$PROGNAME, which no shard build dir can survive. + attribution-canary: + name: Attribution Canary + runs-on: ubuntu-24.04-arm + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup native test build + uses: ./.github/actions/setup-native-test + + - name: Disable BUILD_EPOCH + run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini + + - name: Restore PlatformIO cache + uses: actions/cache/restore@v6 + with: + path: ~/.platformio/.cache + key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} + restore-keys: | + pio-coverage-tests- + + - name: Restore ccache + uses: actions/cache/restore@v6 + with: + path: ~/.ccache + key: ccache-native-tests-${{ github.run_id }} + restore-keys: | + ccache-native-tests- + + - name: Attribution canary + timeout-minutes: 15 + run: ./bin/test-attribution-canary.sh -e coverage + + # Load-bearing name: branch protection matches it, and matrix rows are named per shard so none of + # them can carry it. + platformio-tests-gate: + name: Native PlatformIO Tests + needs: platformio-tests + if: ${{ !cancelled() }} + runs-on: ubuntu-slim + steps: + - name: Report the matrix result + env: + RESULT: ${{ needs.platformio-tests.result }} + run: | + set -euo pipefail + echo "shard matrix: $RESULT" + [ "$RESULT" = "success" ] + + # The collector. A shard knows only its own suites and one that never started reports nothing, so + # only here can the union be checked against the canonical set. generate-reports: name: Generate Test Reports runs-on: ubuntu-latest @@ -496,32 +533,85 @@ jobs: actions: read checks: write needs: + - discover - simulator-tests - platformio-tests + - attribution-canary # Run this job even if the previous jobs failed, but skip if the workflow was cancelled. if: ${{ !cancelled() }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Get release version string - run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT" id: version - - name: Download test artifacts + - name: Download per-shard test artifacts uses: actions/download-artifact@v8 with: - name: platformio-test-report-${{ steps.version.outputs.long }} + pattern: platformio-test-report-*-${{ steps.version.outputs.long }} merge-multiple: true + - name: Merge the shard reports into testreport.xml + # Preserve the single-file JUnit contract downstream consumers rely on (pr_tests.yml summary, and + # the Test Report below). The split is only how the run executes; the report stays consolidated. + if: always() # run even when a shard failed, so the report captures the failures + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import glob, xml.etree.ElementTree as ET + out = ET.Element('testsuites') + files = sorted(glob.glob('testreport-*.xml')) + for f in files: + try: + root = ET.parse(f).getroot() + except ET.ParseError: + print(f"WARNING: {f} is not parseable, skipping") + continue + # PlatformIO writes a root; fold in a bare too, just in case. + out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root]) + ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True) + print(f"merged {len(files)} shard report(s) into testreport.xml") + PY + + - name: Verdict - every suite ran, and ran its own tests + # Only here is the union compared against the canonical test_* set, which is what catches a shard + # that failed to start or was cancelled. + if: always() # a suite going missing is the finding; do not hide it behind an earlier failure + env: + SUITES: ${{ needs.discover.outputs.suites }} + run: | + set -euo pipefail + # --expect "" passes over anything, and it is empty exactly when discover failed, which is one of + # the situations this gate exists to catch. + if [ -z "${SUITES// /}" ]; then + echo "::error title=No expected suite set::the discover job produced no suite list, so the whole-run attribution gate has nothing to check against. Treating that as a failure - a gate with an empty expectation passes vacuously." + exit 1 + fi + ./bin/check-test-attribution.py --label "all shards" --expect "$SUITES" testreport.xml + + - name: Save merged test results + # Same artifact name the single-runner job used to publish, so pr_tests.yml's summary keeps + # finding it. + if: always() + uses: actions/upload-artifact@v7 + with: + name: platformio-test-report-${{ steps.version.outputs.long }} + overwrite: true + path: ./testreport.xml + - name: Drop no-status testsuites from the report # PlatformIO emits a self-closing row for every test_* dir # crossed with every hardware variant it cannot run on the native host (~4900 rows). # They carry no pass/fail/skip status and bury the suites that actually ran. Strip # them so the Test Report lists only suites with a real status. Only the copy the # reporter renders is trimmed; the uploaded artifact keeps the full XML. + if: always() run: sed -i -E 's#]*tests="0"[^>]*/>##g' testreport.xml - name: Test Report + if: always() uses: dorny/test-reporter@v3.0.0 with: name: PlatformIO Tests @@ -529,6 +619,7 @@ jobs: reporter: java-junit - name: Download coverage artifacts + if: always() uses: actions/download-artifact@v8 with: pattern: lcov-coverage-info-native-*-${{ steps.version.outputs.long }} @@ -537,9 +628,10 @@ jobs: - name: Generate Code Coverage Report # Merge every tracefile the jobs produced: coverage_base.info (zeroed baseline), - # coverage_integration.info, and one coverage_tests_.info per chunk. lcov - # sums hit counts across them, so the merged report is the union of all chunks - + # coverage_integration.info, and one coverage_tests_.info per shard. lcov + # sums hit counts across them, so the merged report is the union of all shards - # identical to running the whole suite in one job. + if: always() run: | sudo apt-get install -y lcov args=() @@ -550,7 +642,54 @@ jobs: genhtml --quiet --legend --prefix "${PWD}" code-coverage-report/coverage_src.info --output-directory code-coverage-report - name: Save Code Coverage Report + if: always() uses: actions/upload-artifact@v7 with: name: code-coverage-report-${{ steps.version.outputs.long }} path: code-coverage-report + + - name: Final verdict + # States the run result in one place, rather than leaving it reconstructed from a dozen shard logs. + if: always() + env: + SHARDS: ${{ needs.platformio-tests.result }} + SIMULATOR: ${{ needs.simulator-tests.result }} + CANARY: ${{ needs.attribution-canary.result }} + run: | + set -uo pipefail + { + echo "## Native tests" + echo "" + echo "| Part | Result |" + echo "| --- | --- |" + echo "| Suite shards | \`$SHARDS\` |" + echo "| Simulator | \`$SIMULATOR\` |" + echo "| Attribution canary | \`$CANARY\` |" + } >> "$GITHUB_STEP_SUMMARY" + + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import xml.etree.ElementTree as ET + cases = fails = skips = 0 + failed = [] + for suite in ET.parse('testreport.xml').getroot().iter('testsuite'): + n = int(suite.get('tests', '0')) + bad = int(suite.get('failures', '0')) + int(suite.get('errors', '0')) + cases += n + fails += bad + skips += int(suite.get('skipped', '0')) + if bad: + failed.append(f"{suite.get('name', '?')} ({bad})") + print("") + print(f"{cases} test case(s), {fails} failed, {skips} skipped.") + if failed: + print("") + print("Failing suites: " + ", ".join(sorted(failed))) + PY + + for result in "$SHARDS" "$SIMULATOR" "$CANARY"; do + [ "$result" = "success" ] || { + echo "::error title=Native tests failed::shards=$SHARDS simulator=$SIMULATOR canary=$CANARY - see the job summary." + exit 1 + } + done + echo "RESULT: GREEN - every shard, the simulator, and the canary passed." diff --git a/bin/check-test-attribution.py b/bin/check-test-attribution.py index 2d3d258e46..311086f93f 100755 --- a/bin/check-test-attribution.py +++ b/bin/check-test-attribution.py @@ -55,8 +55,12 @@ def collect(paths): cases = {} for path in paths: try: - # The input is the JUnit report PlatformIO just wrote in this same run, not untrusted - # data, and defusedxml is not installed for this job. + # The input is a JUnit report PlatformIO wrote, not untrusted data, and defusedxml is + # not installed for this job. In CI the collector reads these back from artifacts + # rather than off the same disk that produced them, so what keeps that true is the + # upload step naming the one file the shard was told to write instead of globbing: + # a test suite can write to the workspace, and under a glob its own XML would ride + # along into the merged report. See .github/workflows/test_native.yml. # nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse root = ET.parse(path).getroot() except (ET.ParseError, OSError) as exc: diff --git a/bin/test-shards.py b/bin/test-shards.py new file mode 100755 index 0000000000..4e02093942 --- /dev/null +++ b/bin/test-shards.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Emit the native-test CI matrix: one shard per matrix row, derived from test/. + +Shards are safe to run in parallel because isolation is per suite, not per run: every suite gets +its own scratch $HOME via bin/pio-test-isolate.sh. + +Two kinds of row come out: + + * general - a slice of the test_* tree under [env:coverage]. AREA_RULES place each suite, first + match wins, unmatched to "misc". Areas over --max-suites split, smaller ones pack together. + + * fixed-env - one row per SPECIAL_ENVS entry, whose suite list is read from its test_filter in + platformio.ini rather than restated here. + +Usage: + bin/test-shards.py # matrix JSON on stdout + bin/test-shards.py --summary # ... plus a human-readable table on stderr + bin/test-shards.py --max-suites 8 # smaller shards, more of them + bin/test-shards.py --seed 12345 # vary which suites share a shard + +Exit: 0 ok, 2 on a malformed tree or a fixed env whose test_filter went missing. +""" + +from __future__ import annotations + +import argparse +import configparser +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# Ordered "area name" -> regex; first match wins. Extend an area by widening its regex, add an area +# by inserting a line. Anything unmatched lands in FALLBACK_AREA. +AREA_RULES = [ + ("admin", r"^test_(admin|pki)_"), + ("crypto", r"^test_(crypto|packet_signing)$"), + ("routing", r"^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_"), + ("position", r"^test_position_"), + ("fuzz", r"^test_fuzz_"), + ("packets", r"^test_(packet|transmit|meshpacket)_"), + ("io", r"^test_(serial|stream|xmodem|http|mqtt)"), +] +FALLBACK_AREA = "misc" + +# Envs that rebuild a fixed set of suites with different build flags. Their test_filter lives in +# the ini and is read from there. +NATIVE_INI = REPO / "variants" / "native" / "portduino" / "platformio.ini" +SPECIAL_ENVS = ["coverage-event-policy", "coverage-channel-table"] + +# Suite names reach a shell as `-f `. Constrained here, the one place the list is produced, +# so a creatively named directory cannot become shell text. +SUITE_RE = re.compile(r"^test_[A-Za-z0-9_]+$") + + +def discover_suites(): + """Every test_* directory directly under test/, sorted. The canonical set.""" + suites = sorted( + p.name for p in (REPO / "test").iterdir() if p.is_dir() and p.name.startswith("test_") + ) + bad = [s for s in suites if not SUITE_RE.match(s)] + if bad: + sys.exit(f"test-shards: refusing to shard, unusable suite name(s): {' '.join(bad)}") + if not suites: + sys.exit("test-shards: no test_* directories under test/ - the tree is not what it should be") + return suites + + +def shuffle(seed, items): + """Reorder via bin/lib/shuffle.sh, the one implementation of the seeded shuffle. + + Two copies of a Fisher-Yates would drift, and announce it as a replay reproducing a different + arrangement. The repo path goes in as $1 so a checkout directory never becomes shell source. + """ + script = 'source "$1"; shift; shuffle_suites "$@"' + out = subprocess.run( + ["bash", "-c", script, "_", str(REPO / "bin" / "lib" / "shuffle.sh"), seed, *items], + capture_output=True, + text=True, + check=True, + ) + return out.stdout.split() + + +def areas_of(suites): + """Bucket suites into areas, preserving AREA_RULES order and putting misc last.""" + grouped = {name: [] for name, _ in AREA_RULES} + grouped[FALLBACK_AREA] = [] + for suite in suites: + area = next((name for name, rule in AREA_RULES if re.search(rule, suite)), FALLBACK_AREA) + grouped[area].append(suite) + return {name: members for name, members in grouped.items() if members} + + +def split(members, cap): + """Split into the fewest chunks of at most `cap`, sized as evenly as the count allows. + + Wall clock is the slowest shard, so 11 suites at cap 10 becomes 6+5, not 10+1. + """ + chunks = -(-len(members) // cap) # ceil + base, extra = divmod(len(members), chunks) + out, start = [], 0 + for i in range(chunks): + size = base + (1 if i < extra else 0) + out.append(members[start : start + size]) + start += size + return out + + +def pack(areas, cap): + """Pack whole areas into the fewest shards of at most `cap`, keeping the loads even. + + Longest-processing-time-first: within 4/3 of optimal, and unlike first-fit it will not leave + one shard holding a single two-suite area. + """ + load = lambda b: sum(len(areas[a]) for a in b) # noqa: E731 + ranked = sorted(areas, key=lambda a: len(areas[a]), reverse=True) + # ceil(total / cap) is a lower bound, not a guarantee - whole areas do not divide, so three + # areas of 6 at cap 10 would put 12 in one of two bins. Grow the count until every bin fits. + for count in range(-(-sum(len(m) for m in areas.values()) // cap), len(areas) + 1): + bins = [[] for _ in range(count)] + for area in ranked: + min(bins, key=load).append(area) + if all(load(b) <= cap for b in bins): + break + # Report each shard's areas in declared order, so a name reads the same way the rules do. + order = list(areas) + return [sorted(b, key=order.index) for b in bins if b] + + +def build(areas, cap): + """Lay the areas out into shards of at most `cap` suites. Returns (rows, suites placed).""" + rows, placed, small = [], [], {} + # Oversized areas become numbered shards of their own; what is left is packed together. + for area, members in areas.items(): + if len(members) <= cap: + small[area] = members + continue + for i, chunk in enumerate(split(members, cap), start=1): + rows.append({"shard": f"{area}-{i}", "env": "coverage", "suites": " ".join(chunk)}) + placed += chunk + for group in pack(small, cap): + members = [suite for area in group for suite in small[area]] + rows.append({"shard": "+".join(group), "env": "coverage", "suites": " ".join(members)}) + placed += members + return rows, placed + + +def fixed_env_filter(env): + """The suites [env:] pins in its own test_filter.""" + # interpolation=None: platformio.ini interpolates with ${section.option}, not configparser's + # %(name)s, so a bare % in any value elsewhere in the file would otherwise abort the parse. + parser = configparser.ConfigParser(strict=False, interpolation=None) + parser.read(NATIVE_INI, encoding="utf-8") + section = f"env:{env}" + if not parser.has_option(section, "test_filter"): + sys.exit( + f"test-shards: [{section}] in {NATIVE_INI.name} has no test_filter. It had one when this " + f"matrix was written; either restore it or drop {env} from SPECIAL_ENVS - silently " + f"emitting an empty filter would run every suite under the wrong build flags." + ) + # test_filter accepts globs, and these tokens reach the same unquoted word-split and the same + # attribution gate as discovered names. Hold them to SUITE_RE too, at the producer. + names = parser.get(section, "test_filter").split() + bad = [n for n in names if not SUITE_RE.match(n)] + if bad: + sys.exit( + f"test-shards: [{section}] test_filter names something that is not a literal suite: " + f"{' '.join(bad)}. The matrix and the attribution gate both need exact names." + ) + return names + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument( + "--max-suites", + type=int, + default=10, + help="largest shard, in suites (default: 10). Lower is faster and costs more runners; the " + "per-shard floor is checkout + toolchain + one src build, so past ~8 the fixed cost wins.", + ) + ap.add_argument( + "--max-shards", + type=int, + default=24, + help="hard ceiling on matrix rows (default: 24). A budget, not a preference: --max-suites " + "is raised until the matrix fits, so no branch can size the fan-out by adding directories.", + ) + ap.add_argument( + "--seed", + default="", + help="vary which suites share a shard. Co-location, not order - PlatformIO picks the order " + "within a shard either way. Empty means the declared alphabetical arrangement.", + ) + ap.add_argument("--summary", action="store_true", help="also print the shard table to stderr") + args = ap.parse_args() + + if args.max_suites < 1: + sys.exit("test-shards: --max-suites must be at least 1") + if args.max_shards <= len(SPECIAL_ENVS): + sys.exit(f"test-shards: --max-shards must leave room for the {len(SPECIAL_ENVS)} fixed envs") + + suites = discover_suites() + areas = areas_of(suites) + if args.seed: + areas = {area: shuffle(args.seed, members) for area, members in areas.items()} + + # Shard size is a preference, shard count is a budget: without this a branch could size the + # fan-out by adding directories. --max-suites gives way so the runner count stays bounded. + cap = args.max_suites + while True: + rows, placed = build(areas, cap) + if len(rows) + len(SPECIAL_ENVS) <= args.max_shards: + break + cap += 1 + if cap != args.max_suites: + print( + f"test-shards: {len(suites)} suites would need more than {args.max_shards} shards at " + f"--max-suites {args.max_suites}; using {cap} per shard instead.", + file=sys.stderr, + ) + + # Prove nothing fell out, rather than discovering an unrun suite from a coverage graph later. + if sorted(placed) != suites: + missing = sorted(set(suites) - set(placed)) + sys.exit(f"test-shards: {len(missing)} suite(s) reached no shard: {' '.join(missing)}") + + for env in SPECIAL_ENVS: + rows.append( + { + "shard": env.removeprefix("coverage-"), + "env": env, + "suites": " ".join(fixed_env_filter(env)), + } + ) + + # Exactly one shard writes the shared compiler cache: all of them compile the same src/ tree, + # and letting each save would race for the key and store the same objects a dozen times. + for row in rows: + row["cache_writer"] = False + rows[0]["cache_writer"] = True + + if args.summary: + width = max(len(row["shard"]) for row in rows) + for row in rows: + count = len(row["suites"].split()) + print( + f" {row['shard']:<{width}} {row['env']:<24} {count:>2} suite(s)", file=sys.stderr + ) + print( + f" {len(rows)} shard(s), {len(suites)} suite(s) in test/, " + f"largest shard {max(len(row['suites'].split()) for row in rows)}", + file=sys.stderr, + ) + + print(json.dumps({"include": rows})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d5873ead0a901874202d6772085bf42ea8ef5abd Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 2 Sep 2026 21:02:31 -0400 Subject: [PATCH 060/143] R2: Cache harder! (#11710) Increase the cache-control age limits for R2 uploads. Releases: Cache for 1 day in browser, 1 month on CDN Nightly: Cache for 1 hour in browser, 1 day on CDN Add metadata to R2 uploads so we can track which commit and which GitHub Actions run produced the upload. --- .github/workflows/main_matrix.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 9c91f75386..098c5834d8 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -721,10 +721,12 @@ jobs: run: | set -euo pipefail aws --version + # Cache for 1 day in browser, 1 month on CDN. aws s3 sync ./publish "s3://${r2_bucket}/${DEST_PREFIX}${VERSION}/" \ --endpoint-url "$R2_ENDPOINT" \ --no-progress \ - --cache-control 'public, max-age=3600' + --metadata "commit=${{ github.sha }},run=${{ github.run_id }},version=${VERSION}" \ + --cache-control 'public, max-age=86400, s-maxage=2592000' # Nightly publish: refresh the single, stable firmware-nightly/ folder on # meshtastic.github.io, and the root of the meshtastic-firmware-nightly R2 @@ -838,8 +840,17 @@ jobs: run: | set -euo pipefail aws --version + # Cache for 1 hour in browser, 1 day on CDN. aws s3 sync ./stage "s3://${r2_bucket}/" \ --endpoint-url "$R2_ENDPOINT" \ --no-progress \ --delete \ + --exclude 'index.json' \ + --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ + --cache-control 'public, max-age=3600, s-maxage=86400' + # index.json is the pointer to the current nightly, don't cache it so hard (5 minutes). + aws s3 cp ./stage/index.json "s3://${r2_bucket}/index.json" \ + --endpoint-url "$R2_ENDPOINT" \ + --no-progress \ + --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ --cache-control 'public, max-age=300' From 077fe4823d399905405e6091d09c45e3273b31fd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:13:05 +0000 Subject: [PATCH 061/143] chore(deps): update esp32-ch390 to v1.2 (#11712) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini | 2 +- variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini index 92236f638d..53944e1f9d 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini @@ -28,4 +28,4 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 - https://github.com/meshtastic/ESP32-CH390/archive/v1.1.1.zip + https://github.com/meshtastic/ESP32-CH390/archive/v1.2.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini index c1a872f89e..9db318b15b 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -28,4 +28,4 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 - https://github.com/meshtastic/ESP32-CH390/archive/v1.1.1.zip + https://github.com/meshtastic/ESP32-CH390/archive/v1.2.zip From c4ff3963995e7c9563d1b66dd24bfaaa5c78ea03 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:13:24 +0000 Subject: [PATCH 062/143] chore(deps): update meshtastic/device-ui digest to cbd92ac (#11711) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 7b1ed77827..f3343fe4aa 100644 --- a/platformio.ini +++ b/platformio.ini @@ -138,7 +138,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/5870d3a55254aa46bbcab13a5a700d533ab304e3.zip + https://github.com/meshtastic/device-ui/archive/cbd92ac4f40f203aa97828ec455de9395033bee3.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 51ed88100e27f68d12e16be5a1756f6e659646ac Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:13:48 +0000 Subject: [PATCH 063/143] chore(deps): update platformio/ststm32 to v20 (#11685) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/stm32/stm32.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index fd6ca8babe..81f01720e5 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -2,7 +2,7 @@ extends = arduino_base platform = # renovate: datasource=custom.pio depName=platformio/ststm32 packageName=platformio/platform/ststm32 - platformio/ststm32@19.7.1 + platformio/ststm32@20.0.0 platform_packages = # renovate: datasource=github-tags depName=Arduino_Core_STM32 packageName=stm32duino/Arduino_Core_STM32 platformio/framework-arduinoststm32@https://github.com/stm32duino/Arduino_Core_STM32/archive/2.10.1.zip From 43155f3f9088f972ba48e0f9ba2345587d4bb4cf Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 3 Sep 2026 08:52:55 +0000 Subject: [PATCH 064/143] feat: log heap watermark, largest free block and subsystem breakdown (#11660) * feat: log heap watermark, largest free block and subsystem breakdown The periodic heap line reported only free/total, which cannot distinguish a leak from fragmentation, and the MemAudit per-subsystem breakdown was only ever printed at boot. Add ESP.getMinFreeHeap()/getMaxAllocHeap() wrappers to MemGet (0 on platforms that cannot report them) and include both in the 5-minute line, then log the MemAudit breakdown on the same tick. A falling watermark is a leak; a steady watermark with a shrinking largest block is fragmentation, and the breakdown names the tagged subsystem that moved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E9ZacpGtsA6DWavqr5Ty2i * docs: correct the watermark interpretation in logHeapUsage comment A single step down in the minimum-free watermark is a transient allocation, not proof of a leak; it takes repeated new lows across samples. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E9ZacpGtsA6DWavqr5Ty2i --------- Co-authored-by: Claude --- src/Power.cpp | 16 ++++++++++++++-- src/memGet.cpp | 26 ++++++++++++++++++++++++++ src/memGet.h | 4 ++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 09b20f6ff6..b792bf1813 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -23,6 +23,7 @@ #include "buzz/buzz.h" #include "configuration.h" #include "main.h" +#include "memory/MemAudit.h" #include "meshUtils.h" #include "power/PowerHAL.h" #include "power/SGM41562.h" @@ -1246,12 +1247,23 @@ void Power::logHeapUsage() // The first line has no earlier sample to difference against const int32_t delta = lastHeapLogTime ? (int32_t)(heapFree - lastHeapLogFree) : 0; + // min only ever falls: one step down is a transient alloc, repeated new lows are a leak. + // A steady min with a shrinking largest block is fragmentation. Empty where unsupported. + char detail[64] = ""; + const uint32_t minFree = memGet.getMinFreeHeap(); + const uint32_t maxAlloc = memGet.getMaxAllocHeap(); + if (minFree || maxAlloc) + snprintf(detail, sizeof(detail), ", min %u, largest block %u", minFree, maxAlloc); + const uint32_t psramTotal = memGet.getPsramSize(); if (psramTotal) - LOG_INFO("Heap: %u/%u bytes free (%d since last), PSRAM: %u/%u bytes free", heapFree, heapTotal, delta, + LOG_INFO("Heap: %u/%u bytes free (%d since last)%s, PSRAM: %u/%u bytes free", heapFree, heapTotal, delta, detail, memGet.getFreePsram(), psramTotal); else - LOG_INFO("Heap: %u/%u bytes free (%d since last)", heapFree, heapTotal, delta); + LOG_INFO("Heap: %u/%u bytes free (%d since last)%s", heapFree, heapTotal, delta, detail); + + // Which tagged subsystem moved since boot + memaudit::logBreakdown("periodic"); lastHeapLogFree = heapFree; lastHeapLogTime = millis(); diff --git a/src/memGet.cpp b/src/memGet.cpp index 49d1953c25..4283bb2659 100644 --- a/src/memGet.cpp +++ b/src/memGet.cpp @@ -79,6 +79,32 @@ uint32_t MemGet::getHeapSize() #endif } +/** + * Returns the lowest the free heap has ever been since boot. + * @return uint32_t Low watermark in bytes, or 0 if the platform can't report it. + */ +uint32_t MemGet::getMinFreeHeap() +{ +#ifdef ARCH_ESP32 + return ESP.getMinFreeHeap(); +#else + return 0; +#endif +} + +/** + * Returns the largest contiguous block malloc() could still return. + * @return uint32_t Block size in bytes, or 0 if the platform can't report it. + */ +uint32_t MemGet::getMaxAllocHeap() +{ +#ifdef ARCH_ESP32 + return ESP.getMaxAllocHeap(); +#else + return 0; +#endif +} + /** * Returns the amount of free psram memory in bytes. * diff --git a/src/memGet.h b/src/memGet.h index 130d2ca7ea..69348b4dad 100644 --- a/src/memGet.h +++ b/src/memGet.h @@ -9,6 +9,10 @@ class MemGet public: uint32_t getFreeHeap(); uint32_t getHeapSize(); + // Lowest free heap seen since boot, or 0 where the platform can't report it + uint32_t getMinFreeHeap(); + // Largest block malloc() could still return, or 0 where the platform can't report it + uint32_t getMaxAllocHeap(); uint32_t getFreePsram(); uint32_t getPsramSize(); }; From 76e45c5b44ac0dce6b5ce490659cb959e759b909 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:28:01 +0000 Subject: [PATCH 065/143] Update actions/setup-python action to v7 (#11081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Thomas Göttgens --- .github/actions/setup-base/action.yml | 2 +- .github/workflows/build_one_target.yml | 2 +- .github/workflows/build_windows_bin.yml | 2 +- .github/workflows/main_matrix.yml | 8 ++++---- .github/workflows/package_pio_deps.yml | 2 +- .github/workflows/release_channels.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml index 99f9e62cfe..4c1a2ff792 100644 --- a/.github/actions/setup-base/action.yml +++ b/.github/actions/setup-base/action.yml @@ -16,7 +16,7 @@ runs: sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x cache: pip diff --git a/.github/workflows/build_one_target.yml b/.github/workflows/build_one_target.yml index b6ec92edb7..b66d701fb8 100644 --- a/.github/workflows/build_one_target.yml +++ b/.github/workflows/build_one_target.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7.0.0 with: python-version: 3.x cache: pip diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml index 3f0515ea6c..40d878cced 100644 --- a/.github/workflows/build_windows_bin.yml +++ b/.github/workflows/build_windows_bin.yml @@ -54,7 +54,7 @@ jobs: git - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: "3.14" cache: pip diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 098c5834d8..6ae725e20e 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -59,7 +59,7 @@ jobs: with: # Needed to diff against the base branch for newly added variants. fetch-depth: 0 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7.0.0 with: python-version: 3.x cache: pip @@ -501,7 +501,7 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x @@ -600,7 +600,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x @@ -658,7 +658,7 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x diff --git a/.github/workflows/package_pio_deps.yml b/.github/workflows/package_pio_deps.yml index bf2576a53e..ec42634169 100644 --- a/.github/workflows/package_pio_deps.yml +++ b/.github/workflows/package_pio_deps.yml @@ -28,7 +28,7 @@ jobs: submodules: recursive - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x diff --git a/.github/workflows/release_channels.yml b/.github/workflows/release_channels.yml index d66cac2bd7..719e0ce623 100644 --- a/.github/workflows/release_channels.yml +++ b/.github/workflows/release_channels.yml @@ -97,7 +97,7 @@ jobs: ref: ${{ github.event.repository.default_branch }} - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7.0.0 with: python-version: 3.x From 3d1d1ef392ac31c965ee07c857d5cc553a96d05a Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 3 Sep 2026 09:54:24 +0000 Subject: [PATCH 066/143] fix(stm32wl): advertise canShutdown if HAS_LSE (#11707) * fix(stm32wl): advertise canShutdown if HAS_LSE Define HAS_CPU_SHUTDOWN on HAS_LSE STM32WL builds and, on that path, report canShutdown in getDeviceMetadata() from the runtime stm32wlRtcAvailable() check. canShutdown was always false on STM32WL: HAS_CPU_SHUTDOWN was never set for the architecture and pmu_found is never set there, so apps hid the shutdown control even though deep-sleep shutdown works on HAS_LSE builds via cpuDeepSleep() -> STM32LowPower::shutdown(). Reading the runtime check keeps the report accurate when the LSE crystal fails to lock, where cpuDeepSleep() resets instead of sleeping. The #else branch is untouched, so non-STM32WL and non-HAS_LSE builds report canShutdown exactly as before. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong * fix(stm32wl): reject HAS_CPU_SHUTDOWN without HAS_LSE Add an #error in architecture.h when an STM32WL build has HAS_CPU_SHUTDOWN set but HAS_LSE unset. getDeviceMetadata() takes the stm32wlRtcAvailable() branch under HAS_CPU_SHUTDOWN, but that function is compiled only under HAS_LSE, so a build forcing HAS_CPU_SHUTDOWN=1 with HAS_LSE=0 would reference it with no declaration or definition. No current variant does this, and architecture.h derives HAS_CPU_SHUTDOWN from HAS_LSE in the same block, so ordinary builds are unaffected. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong --------- Signed-off-by: Andrew Yong --- src/main.cpp | 4 ++++ src/platform/stm32wl/architecture.h | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index 7fde17346e..386b9edf91 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1302,7 +1302,11 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() meshtastic_DeviceMetadata deviceMetadata = meshtastic_DeviceMetadata_init_default; strncpy(deviceMetadata.firmware_version, optstr(APP_VERSION), sizeof(deviceMetadata.firmware_version)); deviceMetadata.device_state_version = DEVICESTATE_CUR_VER; +#if defined(ARCH_STM32WL) && HAS_CPU_SHUTDOWN + deviceMetadata.canShutdown = stm32wlRtcAvailable(); +#else deviceMetadata.canShutdown = pmu_found || HAS_CPU_SHUTDOWN; +#endif deviceMetadata.hasBluetooth = HAS_BLUETOOTH; deviceMetadata.hasWifi = HAS_WIFI; deviceMetadata.hasEthernet = HAS_ETHERNET; diff --git a/src/platform/stm32wl/architecture.h b/src/platform/stm32wl/architecture.h index 0aa59ff70c..83800af7af 100644 --- a/src/platform/stm32wl/architecture.h +++ b/src/platform/stm32wl/architecture.h @@ -33,6 +33,12 @@ "HAS_LSE is set but STM32WL_LSE_DRIVE is not defined - set it in the variant's variant.h to one of RCC_LSEDRIVE_LOW/MEDIUMLOW/MEDIUMHIGH/HIGH" #endif +#if HAS_LSE && !defined(HAS_CPU_SHUTDOWN) +#define HAS_CPU_SHUTDOWN 1 +#elif HAS_CPU_SHUTDOWN && !HAS_LSE +#error "STM32WL: HAS_CPU_SHUTDOWN requires HAS_LSE (RTC wake path)" +#endif + // // set HW_VENDOR // From 3f4b96c24ded49bbb50dc9b013fac88936e479db Mon Sep 17 00:00:00 2001 From: rcarteraz Date: Thu, 3 Sep 2026 10:25:22 +0000 Subject: [PATCH 067/143] Fix architecture name for Seeed Wio Tracker L2 (#11713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix architecture name for Seeed Wio Tracker L2 * Normalize custom_meshtastic_architecture against the board MCU The declared value reached the manifest unchecked, which is how esp32s3 shipped here and in the -tft env that extends it. infer_architecture() already derives the canonical spelling from the board MCU, so prefer it when the two disagree and print the override. Scanned all 113 envs declaring an architecture; this variant was the only mismatch. * Simplify the architecture override --------- Co-authored-by: Thomas Göttgens --- bin/platformio-custom.py | 8 ++++++-- variants/esp32s3/seeed_wio_tracker_L2/platformio.ini | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py index 77017d2fcb..bd2d3fc82e 100644 --- a/bin/platformio-custom.py +++ b/bin/platformio-custom.py @@ -255,8 +255,12 @@ def manifest_write(files, env, ram_bytes=None, flash_bytes=None): if parsed is not None and parsed != "": device_meta[manifest_key] = parsed - # Determine architecture once; if we can't infer it, skip manifest generation - board_arch = device_meta.get("architecture") or infer_architecture(env.BoardConfig()) + # Board MCU wins over a hand-typed custom_meshtastic_architecture: only the + # spellings infer_architecture emits are recognized downstream. + declared = device_meta.get("architecture") + board_arch = infer_architecture(env.BoardConfig()) or declared + if declared and declared != board_arch: + print(f"{pioenv}: architecture '{declared}' overridden with '{board_arch}'") if not board_arch: print(f"Skipping mtjson write for unknown architecture (env={env.get('PIOENV')})") return diff --git a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini index 12b2a26669..d5529b5f19 100644 --- a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini +++ b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini @@ -1,7 +1,7 @@ [env:seeed_wio_tracker_L2] custom_meshtastic_hw_model = 137 custom_meshtastic_hw_model_slug = SEEED_WIO_TRACKER_L2 -custom_meshtastic_architecture = esp32s3 +custom_meshtastic_architecture = esp32-s3 custom_meshtastic_actively_supported = true custom_meshtastic_support_level = 1 custom_meshtastic_display_name = Seeed Wio Tracker L2 From 83198c1cbb982e6cdbf641ecc9c67063889435a6 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Thu, 3 Sep 2026 11:20:21 +0000 Subject: [PATCH 068/143] fix(pki): reject a restored pre-2.8 low-entropy key at set time, explain the swap (#11686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pki): reject a restored pre-2.8 low-entropy key at set time, explain the swap Restoring/setting a private key is a private-key change: the public key is *generated* from it. The low-entropy blacklist check in generateCryptoKeyPair runs against the stored public_key at entry, which is empty on a bare key restore — so a known pre-2.8 weak key derived from the provided private key was never caught at set time. It was only detected on the next boot (once the weak public key had been persisted and re-checked), which looks to the user like their saved key silently "did not stick", and their node number (== crc32(public_key)) had quietly changed too. - NodeDB::generateCryptoKeyPair: in the provided-private-key branch, re-check the *derived* public key against LOW_ENTROPY_HASHES. If it matches, replace it with a fresh secure keypair and set keyIsLowEntropy so the reason is surfaced. - AdminModule set-config(security): when the restore path regenerated a rejected low-entropy key, send a client warning at set time explaining the key can't be restored and the node number changed. Scoped to that branch so a stale flag from a boot-time regeneration can't fire on unrelated security sets. No protobuf changes; reuses the existing ClientNotification warning path. Signed-off-by: Garth Vander Houwen * fix(pki): gate low-entropy restore warning on successful keygen generateCryptoKeyPair returns false on an unset LoRa region before resetting keyIsLowEntropy, so the set-time warning could fire on a stale flag. Capture the return value and require both. Shorten the rationale comments to two lines each. * fix(pki): clear key sizes when a restored private key derives nothing The provided-private-key branch sets private_key.size and public_key.size to 32 before regeneratePublicKey() runs. On failure it returned false with both sizes still set, and AdminModule persisted that pair; every later keygen then re-derived from the same dead key. Clear both on the failure path so the next keygen mints a fresh identity. Add test_admin_radio coverage for the set-time restore path: a derived low-entropy key warns and rotates, a stale keyIsLowEntropy flag with keygen blocked does not warn, and a failed derivation clears both sizes. * fix(pki): validate a restored public key that is itself blacklisted A restore supplying both private_key and public_key reached neither keygen branch, so a whole pre-2.8 low-entropy pair was accepted and persisted at set time and only caught on the next boot. Re-derive when the supplied public key is blacklisted, which routes it through the same rejection and warning as the bare-private-key restore. A non-blacklisted keypair import is unaffected. Install the test crypto stub through a helper and drop it in restoreAdminRadioGlobals(), so a failed assertion's longjmp cannot leak a freed engine into later tests. * fix(pki): only warn about a swapped key when one was actually swapped keyIsLowEntropy is set from the stored public key at function entry, so a restore whose supplied public key is blacklisted set it even when keygen merely re-derived the public key from a private key that was kept. The warning then claimed a new key had been generated and the node number changed, which was only half true. Gate it on the private key actually being replaced. * fix(pki): re-check a freshly minted keypair against the blacklist Both mint sites called crypto->generateKeyPair() once and trusted the result, so an entropy source still producing known-weak keys could persist another blacklisted identity. Route both through a helper that re-checks and retries a bounded number of times, then logs if it cannot do better. Pass the caller's own copy of the private key to generateCryptoKeyPair() instead of config.security.private_key.bytes, which aliased the memcpy destination inside it. * fix(pki): fail keygen when every replacement stays blacklisted generateBlacklistCheckedKeyPair() logged an error after exhausting its retries but left the compromised keypair in place and its callers marked the keygen successful, persisting exactly the identity the check exists to reject. Return a flag, clear both key sizes on exhaustion, and abort both callers so the next keygen starts clean. Match the declaration guard to the definition's, and derive the expected mint count in the retry test from the configured one. * refactor(pki): drop the keygen retry loop, fail on the first weak mint Retrying cannot help: an entropy source that lands on one of the twelve blacklisted keys is broken, and a second call to it produces the same result. With real entropy the odds are ~2^-250, so the loop never runs twice in practice either. Check once and fail, which is the same guarantee in a third of the code. * fix(pki): check the derived key on the stored-private-key path too factory_reset_config keeps the private key and clears the public one, so the entry check sees no stored key, reports "not low entropy" and takes the regenerate branch, which adopted whatever it derived. A preserved pre-2.8 key was therefore accepted for a whole boot cycle before the next boot caught it - the same silent revert this PR exists to remove. Hoist the post-derive blacklist check into a helper and use it on both derive paths. * fix(pki): clear key sizes when stored-private derivation fails too The stored-private-key path set public_key.size to 32 up front and left it there when regeneratePublicKey() failed, so config claimed a pair the node never got - the same defect already fixed on the provided-key path. Both paths now derive through one helper that clears on failure and vets the derived key, replacing the separate blacklist-replace helper. --------- Signed-off-by: Garth Vander Houwen Co-authored-by: Thomas Göttgens --- src/mesh/NodeDB.cpp | 52 ++++-- src/mesh/NodeDB.h | 9 + src/modules/AdminModule.cpp | 14 +- test/test_admin_radio/test_main.cpp | 251 +++++++++++++++++++++++++++- 4 files changed, 311 insertions(+), 15 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 96b7ae81bb..9867a848c6 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -4417,6 +4417,39 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub } #endif +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) +// A freshly minted keypair must not itself land on the blacklist. Fail with no key rather than persist +// a known-weak identity: only a broken entropy source can land here, and retrying would not fix that. +bool NodeDB::generateBlacklistCheckedKeyPair() +{ + crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); + if (!checkLowEntropyPublicKey(config.security.public_key)) + return true; + LOG_ERROR("PKI keygen produced a known low-entropy key; entropy source is broken"); + config.security.public_key.size = 0; + config.security.private_key.size = 0; + return false; +} + +// Derive the public key from the stored private key and vet it. The entry check cannot see a weak key +// when the stored public key is absent, and a failed derivation must not leave sizes claiming a pair. +bool NodeDB::derivePublicKeyFromPrivate() +{ + config.security.public_key.size = 32; + if (!crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { + LOG_ERROR("Can't generate public key from private key"); + config.security.public_key.size = 0; + config.security.private_key.size = 0; + return false; + } + if (!checkLowEntropyPublicKey(config.security.public_key)) + return true; + keyIsLowEntropy = true; + LOG_WARN("Private key derives a known low-entropy public key; generating a new keypair"); + return generateBlacklistCheckedKeyPair(); +} +#endif + bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) @@ -4442,29 +4475,24 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) LOG_INFO("Using provided private key for PKI"); memcpy(config.security.private_key.bytes, privateKey, 32); config.security.private_key.size = 32; - config.security.public_key.size = 32; - // Generate public key from the provided private key - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } else { - LOG_ERROR("Can't generate public key from private key"); + if (!derivePublicKeyFromPrivate()) return false; - } + keygenSuccess = true; } // Try to regenerate public key from existing private key if it's valid and not low entropy else if (config.security.private_key.size == 32 && !keyIsLowEntropy) { - config.security.public_key.size = 32; LOG_DEBUG("Regenerate PKI public key from private key"); - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } + if (!derivePublicKeyFromPrivate()) + return false; + keygenSuccess = true; } else { // Generate a new key pair LOG_INFO("Generate new PKI keys"); config.security.public_key.size = 32; config.security.private_key.size = 32; - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); + if (!generateBlacklistCheckedKeyPair()) + return false; keygenSuccess = true; } diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 0e669cca54..4ab9655c31 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -80,6 +80,11 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = { {0xcc, 0x11, 0xfb, 0x1a, 0xab, 0xa1, 0x31, 0x87, 0x6a, 0xc6, 0xde, 0x88, 0x87, 0xa9, 0xb9, 0x59, 0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}}; static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated."; +// Shown when a user tries to restore/set a known pre-2.8 low-entropy key: explains why the saved +// key did not persist and that the node's identity (NodeNum == crc32(public_key)) changed with it. +static const char LOW_ENTROPY_RESTORE_WARNING[] = + "That key is a known pre-2.8 low-entropy key and can't be restored. A new secure key was " + "generated; your node number has changed."; #endif static const char LICENSED_IDENTITY_MIGRATION_WARNING[] = "Licensed signing generated a new identity key; this node identity changed."; @@ -587,6 +592,10 @@ class NodeDB #if !defined(MESHTASTIC_EXCLUDE_PKI) bool checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest); #endif +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + bool generateBlacklistCheckedKeyPair(); + bool derivePublicKeyFromPrivate(); +#endif /// Consolidate crypto key generation logic used across multiple modules /// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys. diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index abc71034e6..4f22ef6738 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1207,10 +1207,20 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.security = incoming; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI) // First provisioning (no key) generates one; a private key supplied without its public key derives it. + // A supplied public key that is itself blacklisted is re-derived too, so a restore carrying a whole + // low-entropy pair cannot skip the check just by populating both fields. if (config.security.private_key.size != 32) { nodeDB->generateCryptoKeyPair(); - } else if (config.security.public_key.size == 0) { - nodeDB->generateCryptoKeyPair(config.security.private_key.bytes); + } else if (config.security.public_key.size == 0 || nodeDB->checkLowEntropyPublicKey(config.security.public_key)) { + // Warn at set time, not after the next reboot, and only when the key really was replaced: a + // blacklisted public key whose private key derives a clean one is re-derived, and that stuck. + uint8_t priorPrivateKey[32]; + memcpy(priorPrivateKey, config.security.private_key.bytes, 32); + const bool keygenSucceeded = nodeDB->generateCryptoKeyPair(priorPrivateKey); + const bool keyWasReplaced = memcmp(priorPrivateKey, config.security.private_key.bytes, 32) != 0; + if (keygenSucceeded && keyWasReplaced && nodeDB->keyIsLowEntropy) { + sendWarning(LOW_ENTROPY_RESTORE_WARNING); + } } #endif if (config.security.is_managed && !(config.security.admin_key[0].size == 32 || config.security.admin_key[1].size == 32 || diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 8a332b7a92..a0cfb416f2 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -21,7 +21,8 @@ #include "TestUtil.h" #include "graphics/draw/MenuHandler.h" #include "mesh/Channels.h" -#include "mesh/Router.h" // router global: allocErrorResponse() allocates the reply through it +#include "mesh/CryptoEngine.h" // crypto global: the tests swap in a stub engine to drive key derivation +#include "mesh/Router.h" // router global: allocErrorResponse() allocates the reply through it #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include // crc32Buffer(), for the my_node_num == crc32(public_key) invariant @@ -1022,8 +1023,13 @@ static void replaceAdminRadioGlobals() nodeDB = replacementNodeDB; } +// Defined with the crypto stub below; tearDown must undo an install even when a failed assertion +// longjmped out of the test body before it could. +static void dropRestoreCryptoStub(); + static void restoreAdminRadioGlobals() { + dropRestoreCryptoStub(); nodeInfoModule = savedNodeInfoModule; nodeDB = savedNodeDB; router = savedRouter; @@ -1703,6 +1709,241 @@ static void test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged() TEST_ASSERT_EQUAL_UINT(0, config.security.admin_key[0].size); } +// No low-entropy private key is published, so stand in for the engine to derive a blacklisted public +// key on demand. hash() is left real: the blacklist lookup runs through it. +static const uint8_t COMPROMISED_PUBLIC_KEY[32] = {0xac, 0xaf, 0x8c, 0x1c, 0x3c, 0x1c, 0x37, 0xac, 0x4f, 0x03, 0xa1, + 0xe9, 0xfc, 0x37, 0x23, 0x29, 0xc8, 0xa3, 0x5d, 0x7f, 0x05, 0x26, + 0xeb, 0x00, 0xbd, 0x26, 0xb8, 0x2e, 0xb1, 0x94, 0x7d, 0x24}; + +class RestoreDerivingCryptoEngine : public CryptoEngine +{ + public: + bool regenerateSucceeds = true; + bool derivesLowEntropy = true; + bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey) override + { + if (!regenerateSucceeds) + return false; + if (derivesLowEntropy) + memcpy(pubKey, COMPROMISED_PUBLIC_KEY, 32); + else + memset(pubKey, 0x7C, 32); + return true; + } + bool mintsLowEntropy = false; + void generateKeyPair(uint8_t *pubKey, uint8_t *privKey) override + { + if (mintsLowEntropy) + memcpy(pubKey, COMPROMISED_PUBLIC_KEY, 32); + else + memset(pubKey, 0x5E, 32); + memset(privKey, 0x5F, 32); + } +}; + +static CryptoEngine *savedCrypto; +static RestoreDerivingCryptoEngine *restoreCrypto; + +// Installed here and torn down in restoreAdminRadioGlobals(), not at the end of the test body: a failed +// TEST_ASSERT longjmps straight out, which would leave later tests running against a freed stub. +static RestoreDerivingCryptoEngine *installRestoreCrypto() +{ + savedCrypto = crypto; + restoreCrypto = new RestoreDerivingCryptoEngine(); + crypto = restoreCrypto; + return restoreCrypto; +} + +static void dropRestoreCryptoStub() +{ + if (!restoreCrypto) + return; + crypto = savedCrypto; + delete restoreCrypto; + restoreCrypto = nullptr; +} + +// Arms a bare private-key restore: region set so keygen runs, private key present, public key absent. +static meshtastic_Config makeBareKeyRestoreConfig() +{ + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + return c; +} + +static bool capturedWarningsContain(const char *needle) +{ + for (const std::string &w : capturedWarnings) + if (w.find(needle) != std::string::npos) + return true; + return false; +} + +// A restored private key deriving a blacklisted public key is rejected and rotated at set time, and +// the client is told why - not left to discover it after the next reboot. +static void test_handleSetConfig_security_lowEntropyRestoreWarnsAndRotates() +{ + installRestoreCrypto(); + + const meshtastic_Config c = makeBareKeyRestoreConfig(); + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_TRUE(nodeDB->keyIsLowEntropy); + TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size); + TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_TRUE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// A restore carrying a whole blacklisted pair must not skip validation just because it populated the +// public key too - that path reaches neither keygen branch, so the weak identity used to be kept. +static void test_handleSetConfig_security_lowEntropyFullKeypairRestoreIsRejected() +{ + installRestoreCrypto(); + + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + c.payload_variant.security.public_key.size = 32; + memcpy(c.payload_variant.security.public_key.bytes, COMPROMISED_PUBLIC_KEY, 32); + + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size); + TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_TRUE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// A blacklisted public key whose private key derives a clean one is only re-derived - the user's key +// does stick, so the "a new secure key was generated" warning would be a lie here. +static void test_handleSetConfig_security_reDerivedCleanKeyDoesNotWarn() +{ + installRestoreCrypto()->derivesLowEntropy = false; + + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + c.payload_variant.security.public_key.size = 32; + memcpy(c.payload_variant.security.public_key.bytes, COMPROMISED_PUBLIC_KEY, 32); + + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + // The supplied private key survives, and the blacklisted public key is replaced by its derivation. + uint8_t expectedPriv[32]; + memset(expectedPriv, 0x11, 32); + TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32); + TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// A replacement that is itself blacklisted leaves no identity behind - persisting a known-weak key +// would defeat the rejection this whole path exists for. +static void test_handleSetConfig_security_blacklistedMintLeavesNoKey() +{ + installRestoreCrypto()->mintsLowEntropy = true; + + const meshtastic_Config c = makeBareKeyRestoreConfig(); + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size); + TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// factory_reset_config keeps the private key and clears the public one, so the entry check sees no key +// and the boot-time derive path used to adopt whatever it produced - including a known-weak key. +static void test_generateCryptoKeyPair_derivedFromStoredPrivateIsChecked() +{ + installRestoreCrypto(); + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0x11, 32); + config.security.public_key.size = 0; // as installDefaultConfig(preserveKey = true) leaves it + + TEST_ASSERT_TRUE(nodeDB->generateCryptoKeyPair()); + + TEST_ASSERT_TRUE(nodeDB->keyIsLowEntropy); + TEST_ASSERT_TRUE(memcmp(COMPROMISED_PUBLIC_KEY, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_FALSE(nodeDB->checkLowEntropyPublicKey(config.security.public_key)); +} + +// Same clear-and-fail on the boot path: a stored private key that derives nothing must not leave both +// sizes at 32, claiming a pair the node never got. +static void test_generateCryptoKeyPair_failedDerivationFromStoredPrivateClearsKeySizes() +{ + installRestoreCrypto()->regenerateSucceeds = false; + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0x11, 32); + config.security.public_key.size = 0; + + TEST_ASSERT_FALSE(nodeDB->generateCryptoKeyPair()); + + TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size); +} + +// keyIsLowEntropy survives from a boot-time regeneration, and generateCryptoKeyPair returns early on +// an unset region without clearing it. The restore warning must stay gated on this keygen running. +static void test_handleSetConfig_security_staleLowEntropyFlagDoesNotWarn() +{ + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + initRegion(); + nodeDB->keyIsLowEntropy = true; + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + +// A private key that derives nothing usable must not leave sizes claiming a 32-byte pair behind: +// that state gets persisted, and every later keygen re-derives from the same dead key. +static void test_handleSetConfig_security_failedDerivationClearsKeySizes() +{ + installRestoreCrypto()->regenerateSucceeds = false; + + const meshtastic_Config c = makeBareKeyRestoreConfig(); + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(0, config.security.public_key.size); + TEST_ASSERT_FALSE(capturedWarningsContain(LOW_ENTROPY_RESTORE_WARNING)); +} + static void test_regionInfo_supportsPreset() { const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); @@ -2397,6 +2638,14 @@ void setup() RUN_TEST(test_handleSetConfig_security_acceptsSuppliedKeypair); RUN_TEST(test_handleSetConfig_security_rotationPreservesAdminKeys); RUN_TEST(test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged); + RUN_TEST(test_handleSetConfig_security_lowEntropyRestoreWarnsAndRotates); + RUN_TEST(test_handleSetConfig_security_lowEntropyFullKeypairRestoreIsRejected); + RUN_TEST(test_handleSetConfig_security_reDerivedCleanKeyDoesNotWarn); + RUN_TEST(test_handleSetConfig_security_blacklistedMintLeavesNoKey); + RUN_TEST(test_generateCryptoKeyPair_derivedFromStoredPrivateIsChecked); + RUN_TEST(test_generateCryptoKeyPair_failedDerivationFromStoredPrivateClearsKeySizes); + RUN_TEST(test_handleSetConfig_security_staleLowEntropyFlagDoesNotWarn); + RUN_TEST(test_handleSetConfig_security_failedDerivationClearsKeySizes); RUN_TEST(test_regionInfo_supportsPreset); RUN_TEST(test_checkConfigRegion_quietCheckReportsReason); RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner); From 104923730f907925ba30e45a159b9751ee0ba0c8 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 3 Sep 2026 11:25:03 +0000 Subject: [PATCH 069/143] fix(metadata): report all compiled-out module configs (#11709) * fix(metadata): report all compiled-out module configs Add MQTT_CONFIG, NEIGHBORINFO_CONFIG, STOREFORWARD_CONFIG and TELEMETRY_CONFIG bits to getDeviceMetadata().excluded_modules, guarded by the same macros that gate the modules in src/modules/Modules.cpp (MESHTASTIC_EXCLUDE_MQTT, MESHTASTIC_EXCLUDE_NEIGHBORINFO, MESHTASTIC_EXCLUDE_STOREFORWARD, HAS_TELEMETRY). Widen three existing conditions: PAXCOUNTER_CONFIG now also reports when an ESP32 build sets MESHTASTIC_EXCLUDE_PAXCOUNTER; BLUETOOTH_CONFIG now also reports when HAS_BLUETOOTH is 0 on nRF52/ESP32; NETWORK_CONFIG collapses the per-arch nRF52/RP2040 arms into a single !HAS_NETWORKING check. Clients read excluded_modules to decide which module config screens to show. Four bits were never set when the module was compiled out, and three were set only for a subset of the affected builds, so clients offered config screens for modules absent from the firmware: MQTT on every STM32WL target, TELEMETRY on nrf54l15 and minimize builds, NEIGHBORINFO on russell and several nRF52 RAK boards. The change is confined to getDeviceMetadata(). The bitmask is advisory: clients use it to hide menu entries and it does not touch the module config wire protocol. No PhoneAPI, NodeDB or AdminModule changes. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong * fix(metadata): exclude Store & Forward on unsupported architectures Define MESHTASTIC_EXCLUDE_STOREFORWARD for any build that is neither ARCH_ESP32 nor ARCH_PORTDUINO. StoreForwardModule registers only on those two architectures, but the macro was previously set only by minimize builds and a few variant flags, so getDeviceMetadata() still advertised STOREFORWARD_CONFIG on nRF52, RP2040 and STM32WL. Every other use of the macro is already nested in an ARCH_ESP32/ARCH_PORTDUINO block, so ESP32 and Portduino builds are unaffected. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong --------- Signed-off-by: Andrew Yong --- src/configuration.h | 7 ++++++- src/main.cpp | 22 ++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/configuration.h b/src/configuration.h index 0c99b6631f..85a085053b 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -602,7 +602,12 @@ along with this program. If not, see . #define MESHTASTIC_EXCLUDE_ADMIN 1 #endif -// // Turn off wifi even if HW supports wifi (webserver relies on wifi and is also disabled) +// Store & Forward is implemented only for ESP32 and Portduino +#if !defined(ARCH_ESP32) && !defined(ARCH_PORTDUINO) && !defined(MESHTASTIC_EXCLUDE_STOREFORWARD) +#define MESHTASTIC_EXCLUDE_STOREFORWARD 1 +#endif + +// Turn off wifi even if HW supports wifi (webserver relies on wifi and is also disabled) #ifdef MESHTASTIC_EXCLUDE_WIFI #define MESHTASTIC_EXCLUDE_WEBSERVER 1 #undef HAS_WIFI diff --git a/src/main.cpp b/src/main.cpp index 386b9edf91..6cf24cf583 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1321,6 +1321,18 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if MESHTASTIC_EXCLUDE_AUDIO deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AUDIO_CONFIG; #endif +#if MESHTASTIC_EXCLUDE_MQTT + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_MQTT_CONFIG; +#endif +#if MESHTASTIC_EXCLUDE_NEIGHBORINFO + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_NEIGHBORINFO_CONFIG; +#endif +#if MESHTASTIC_EXCLUDE_STOREFORWARD + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_STOREFORWARD_CONFIG; +#endif +#if !HAS_TELEMETRY + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_TELEMETRY_CONFIG; +#endif // Option to explicitly include canned messages for edge cases, e.g. niche graphics #if ((!HAS_SCREEN || NO_EXT_GPIO) || MESHTASTIC_EXCLUDE_CANNEDMESSAGES) && !defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_CANNEDMSG_CONFIG; @@ -1337,7 +1349,7 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if NO_EXT_GPIO && NO_GPS || MESHTASTIC_EXCLUDE_SERIAL deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_SERIAL_CONFIG; #endif -#ifndef ARCH_ESP32 +#if !defined(ARCH_ESP32) || MESHTASTIC_EXCLUDE_PAXCOUNTER deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_PAXCOUNTER_CONFIG; #endif #if !defined(HAS_RGB_LED) && !RAK_4631 @@ -1349,14 +1361,12 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() // No bluetooth on these targets (yet): // Pico W / 2W may get it at some point // Portduino and ESP32-C6 are excluded because we don't have a working bluetooth stacks integrated yet. -#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6) +#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6) || !HAS_BLUETOOTH deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_BLUETOOTH_CONFIG; #endif -#if defined(ARCH_NRF52) && !HAS_ETHERNET // nrf52 doesn't have network unless it's a RAK ethernet gateway currently - deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_NETWORK_CONFIG; // No network on nRF52 -#elif defined(ARCH_RP2040) && !HAS_WIFI && !HAS_ETHERNET - deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_NETWORK_CONFIG; // No network on RP2040 +#if !HAS_NETWORKING // covers nRF52 (non-ethernet RAK) and RP2040 without WiFi/ethernet + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_NETWORK_CONFIG; #endif #if !(MESHTASTIC_EXCLUDE_PKI) From fb8319efaa3472b49dbd5bf5c4b89b5fa9cce483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 3 Sep 2026 11:29:52 +0000 Subject: [PATCH 070/143] fix(t-deck-pro-v1_1): link variant.cpp so the LoRa radio is powered on (#11715) The T-Deck Pro V1.1 boots into Critical Fault #3 (NoRadio) because LORA_EN (GPIO 46) is never driven high, so the SX1262 never receives power and is never detected. Before #9438 this pin was driven from src/main.cpp under `#elif defined(T_DECK_PRO)`, which covered both the t-deck-pro and the t-deck-pro-v1_1 environments, since both build with -D T_DECK_PRO. #9438 moved that block into variants/esp32s3/t-deck-pro/variant.cpp and linked it with a build_src_filter added only to the t-deck-pro environment. t-deck-pro-v1_1 had been added five days earlier and has neither a variant.cpp nor a build_src_filter, so it silently lost the pin setup: earlyInitVariant() is a weak symbol with an empty default in main.cpp, so a missing strong override produces no compile or link error. Give t-deck-pro-v1_1 the same variant.cpp as t-deck-pro, plus the build_src_filter needed to actually link it. This also restores the LORA_CS, SDCARD_CS and PIN_EINK_CS pre-init lost at the same time; all three share the SPI bus and need their chip selects deasserted before the bus is used. Fixes #11708 Co-authored-by: Claude --- variants/esp32s3/t-deck-pro-v1_1/platformio.ini | 4 ++++ variants/esp32s3/t-deck-pro-v1_1/variant.cpp | 14 ++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 variants/esp32s3/t-deck-pro-v1_1/variant.cpp diff --git a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini index c5dc14a7cc..f1670b0023 100644 --- a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini +++ b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini @@ -15,6 +15,10 @@ board_level = release board = t-deck-pro upload_protocol = esptool +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/t-deck-pro-v1_1> + build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/t-deck-pro-v1_1 -D T_DECK_PRO diff --git a/variants/esp32s3/t-deck-pro-v1_1/variant.cpp b/variants/esp32s3/t-deck-pro-v1_1/variant.cpp new file mode 100644 index 0000000000..bb69a1d416 --- /dev/null +++ b/variants/esp32s3/t-deck-pro-v1_1/variant.cpp @@ -0,0 +1,14 @@ +#include "variant.h" +#include "Arduino.h" + +void earlyInitVariant() +{ + 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); +} From be2f68b5af806332c9d877d1643007771bd10c36 Mon Sep 17 00:00:00 2001 From: Sheng_L <87348555+Sheng2216@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:51:07 +0000 Subject: [PATCH 071/143] feat(meshtasticd): add RAK19714 USB SX1262 pinmap (#11616) * feat(meshtasticd): add RAK19714 USB SX1262 pinmap Add a CH341 USB preset so meshtasticd can use the RAK19714 without a hand-written config. * change filename to lowercase(lora-usb-rak19714.yaml) so autoconf can find it. Remove redundant power limit * Rename lora-usb-RAK19714.yaml to lora-usb-rak19714.yaml change filename to lowercase(lora-usb-rak19714.yaml) so autoconf can find it. --------- Co-authored-by: Jonathan Bennett --- bin/config.d/lora-usb-rak19714.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 bin/config.d/lora-usb-rak19714.yaml diff --git a/bin/config.d/lora-usb-rak19714.yaml b/bin/config.d/lora-usb-rak19714.yaml new file mode 100644 index 0000000000..9d4a3fed28 --- /dev/null +++ b/bin/config.d/lora-usb-rak19714.yaml @@ -0,0 +1,18 @@ +Meta: + name: rak19714 + support: official + compatible: + - usb + +Lora: + Module: sx1262 + CS: 0 + IRQ: 6 + Reset: 2 + Busy: 4 + RXen: 1 + DIO2_AS_RF_SWITCH: true + spidev: ch341 + DIO3_TCXO_VOLTAGE: true + USB_PID: 0x5512 + USB_VID: 0x1A86 From 904f193f151024c8e5eb71d37631482bcb153a8d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:13:19 +0000 Subject: [PATCH 072/143] chore(deps): update meshtastic/device-ui digest to 9c97e42 (#11726) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index f3343fe4aa..54f83cd0a2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -138,7 +138,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/cbd92ac4f40f203aa97828ec455de9395033bee3.zip + https://github.com/meshtastic/device-ui/archive/9c97e4260cf31bd642485bf5153735828459ba4e.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 5b987d489755959353b3d3e53670afffa521a5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 4 Sep 2026 13:59:32 +0000 Subject: [PATCH 073/143] docs(agents): document the test naming rules and why camelCase does not apply (#11734) * docs(agents): document that test names are snake_case, not camelCase Automated reviewers repeatedly ask for test suite directories and test_* functions to be renamed to camelCase to match the src/ convention. That rename breaks the harness and the rule was never written down. Authoritative statement lives in the "Test naming" section of .github/copilot-instructions.md, anchored at #test-naming. AGENTS.md and CLAUDE.md link to it rather than restate it. bin/run-tests.sh enumerates suites with -name 'test_*' but matches PlatformIO verdict lines against test_[a-z0-9_]+, lowercase only, so an uppercase suite directory is counted missing and the run reports AMBER instead of GREEN. RUN_TEST in test/TestUtil.h passes #func to Unity and to the state-checkpoint hooks, making the function name the only attribution a CI failure carries. .coderabbit.yaml gains a test/** path_instruction stating the rule inline, since YAML cannot follow the link. * docs(agents): separate the suite-directory and test-function naming rules Review feedback on the previous commit was correct on both points. The section called the test-function form snake_case while every example used camelCase segments. The tree holds 743 test functions with an uppercase segment and 675 without, so snake_case was wrong for more than half of them. Split the two rules that were conflated: suite directories are strictly test_[a-z0-9_]+, while test functions require only the test_ prefix and underscore separators, with segment case free. States what is actually forbidden - dropping the prefix, or collapsing the segments into one camelCase identifier. The canonical-copy policy forbade restating the rule anywhere, then restated it in AGENTS.md and .coderabbit.yaml. Name the YAML entry as the single permitted copy, since a YAML instruction cannot follow a link, and reduce the AGENTS.md bullet to a pointer. * docs(agents): make the AGENTS.md and CLAUDE.md pointers neutral Both still carried the "snake_case, not camelCase" label that b4fdd9168 corrected in the canonical section, so an agent reading either pointer got the whole-name rule the canonical section now rejects. Describe the scope instead of restating the rule: the src/ naming rule does not apply under test/. --- .coderabbit.yaml | 18 ++++++++++++++++++ .github/copilot-instructions.md | 28 ++++++++++++++++++++++++++++ AGENTS.md | 1 + CLAUDE.md | 15 ++++++++------- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index cdcd43f3ae..0b5651bab2 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -31,6 +31,24 @@ reviews: instructions: > meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging. Ensure configurations include metadata found in other configs. + - path: test/** + instructions: > + Native C++ unit tests. The camelCase convention used in src/ does NOT apply here, + and this is deliberate, not drift. Two separate rules. Suite directories are + strictly test_[a-z0-9_]+, lowercase only. Test functions take a test_ prefix + followed by underscore-separated segments, and the case WITHIN a segment is free: + test_5byte_sequence_rejected and test_getRegion_returnsCorrectRegion_US are both + correct and both common. Only two things are forbidden for a test function - + dropping the test_ prefix, and collapsing the segments into a single camelCase + identifier. Rationale: bin/run-tests.sh matches suite verdict lines against + test_[a-z0-9_]+, so an uppercase suite directory is reported as missing and + downgrades the run to AMBER; RUN_TEST in test/TestUtil.h passes #func to Unity, so + the function name is the only attribution a CI failure carries. Do NOT raise + naming-convention comments on suite directories or test_* functions, and do NOT + flag a camelCase segment inside an otherwise underscore-separated test name. + Helpers and fixtures inside a suite do follow the normal src/ conventions. + Authoritative rule, which this entry mirrors: the "Test naming" section of + .github/copilot-instructions.md. - path: "**/*.md" instructions: > Documentation does not live in this repo; it lives in diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 97a1495045..c5b894aafc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -354,12 +354,40 @@ firmware/ ### Naming Conventions +These apply to firmware source under `src/`. Code under `test/` is a deliberate exception - see [Test naming](#test-naming) below. + - Classes: `PascalCase` (e.g., `PositionModule`, `NodeDB`) - Functions/Methods: `camelCase` (e.g., `sendOurPosition`, `getNodeNum`) - Constants/Defines: `UPPER_SNAKE_CASE` (e.g., `MAX_INTERVAL`, `ONE_DAY`) - Member variables: `camelCase` (e.g., `lastGpsSend`, `nodeDB`) - Config defines: `USERPREFS_*` for user-configurable options + + +#### Test naming - `test_` prefix and underscores, never one `camelCase` identifier + +**This section is the single authoritative statement of the rule. `AGENTS.md` and `CLAUDE.md` link here and must not restate it. The one permitted copy is the `test/**` entry in `.coderabbit.yaml`, because a YAML instruction cannot follow a link; keep it in sync with this section.** + +Code under `test/` does not follow the `camelCase` rule above, and this is neither drift nor an oversight - the harness and Unity both depend on it. **A review comment asking for `camelCase` on a test suite directory or a `test_*` function is wrong, and should be rejected rather than acted on.** + +Two distinct rules, often conflated: + +| Thing | Rule | Examples | +| ------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Suite directory | Strictly `test_[a-z0-9_]+` - lowercase `snake_case`, no exceptions | `test_gps_fix_hold/`, `test_admin_radio/` | +| Test function | `test_` prefix, then `_`-separated segments. Case _within_ a segment is free | `test_5byte_sequence_rejected()`, `test_getRegion_returnsCorrectRegion_US()` | +| Helpers/fixtures inside a suite | normal `src/` conventions | `makeFakePacket()`, `class FakeRadio` | + +For functions, what is fixed is the `test_` prefix and the underscores between segments - not the case inside a segment. Both `test_validateConfigRegion_unsetRegionReturnsTrue` (segment mirrors the `camelCase` symbol under test) and `test_5byte_sequence_rejected` (all lowercase) are correct and both are common in the tree. What is forbidden is dropping the prefix or collapsing the segments into a single `camelCase` identifier (`testValidateConfigRegionUnsetRegionReturnsTrue`). + +Why it is fixed: + +- **The harness discovers suites by prefix and parses their verdicts by regex.** `bin/run-tests.sh` enumerates suites with `find test -maxdepth 1 -type d -name 'test_*'`, then matches PlatformIO's per-suite result lines against `test_[a-z0-9_]+` - lowercase only. A suite directory with an uppercase letter is enumerated but never matched, so it is reported as _missing_ and the whole run downgrades from GREEN to AMBER. +- **The function name is the failure message.** `RUN_TEST` in `test/TestUtil.h` passes `#func` to `UnityDefaultTestRun()`, `testAssertEnvironmentIntact()` and `testStateCheckpoint()`, so the identifier is the only attribution a CI log carries for a failed assertion or a dirtied sandbox. The underscores are what make it readable there; a single `camelCase` run-on is not. +- **It is Unity's own convention**, shared with every other PlatformIO C++ project. + +Renaming a suite directory to `camelCase` breaks the harness's suite accounting; renaming the functions destroys the readability of CI output. Leave both alone. + ### Key Patterns #### Module System diff --git a/AGENTS.md b/AGENTS.md index 66a8ca6847..97d555c42b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor - **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI - see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure. - **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) repo by the `update_protobufs.yml` workflow (entry point: `bin/regen-protos.sh`). Local edits will be overwritten and create merge conflicts. If a `.proto` change is needed, open a PR against the protobufs repo first, then let the workflow re-sync this repo. - **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. +- **The `src/` naming rule does not apply under `test/`.** Suite directories and `test_*` functions follow their own rule and must not be renamed to match `src/`. What that rule is, and why: [**Test naming** in `.github/copilot-instructions.md`](.github/copilot-instructions.md#test-naming) - authoritative there, not restated here. - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. - **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. - **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). diff --git a/CLAUDE.md b/CLAUDE.md index a7dbf6991e..054b497932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,14 @@ > > **Need this? It's here.** > -> | | | -> | --------------------------------------------------------- | ---------------------------------------------------------- | -> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | -> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | -> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` | -> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | -> | Observer / event wiring | `src/Observer.h` | +> | | | +> | --------------------------------------------------------- | ---------------------------------------------------------------------------------- | +> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | +> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | +> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` | +> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | +> | Observer / event wiring | `src/Observer.h` | +> | Test naming (the `src/` rule does **not** apply) | [copilot-instructions.md#test-naming](.github/copilot-instructions.md#test-naming) | **Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions, the build system, CI/CD, the native C++ test suite, and the MCP Server & Hardware Test Harness. Read it top-to-bottom before starting any non-trivial change. From 0221fc804456fbe97118287d0399bf5e0493b2e4 Mon Sep 17 00:00:00 2001 From: Jason P Date: Fri, 4 Sep 2026 19:16:35 +0000 Subject: [PATCH 074/143] Address TFT color overlaps in BaseUI (#11735) --- src/graphics/SharedUIDisplay.cpp | 19 +++++++++++++------ src/graphics/draw/UIRenderer.cpp | 4 +++- src/modules/Telemetry/PowerTelemetry.cpp | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 5e6567206c..762b618239 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -557,13 +557,20 @@ const int *getTextPositions(OLEDDisplay *display) textPositions[5] = textFifthLine_medium; textPositions[6] = textSixthLine_medium; } else { + int bodyShift = 0; + if (isTFTColoringEnabled()) { + const int headerBottom = FONT_HEIGHT_SMALL + 1 + BASEUI_HEADER_MARGIN; + const int overlap = headerBottom - textFirstLine; + if (overlap > 0 && (textSixthLine + FONT_HEIGHT_SMALL + overlap) <= SCREEN_HEIGHT) + bodyShift = overlap; + } textPositions[0] = textZeroLine; - textPositions[1] = textFirstLine; - textPositions[2] = textSecondLine; - textPositions[3] = textThirdLine; - textPositions[4] = textFourthLine; - textPositions[5] = textFifthLine; - textPositions[6] = textSixthLine; + textPositions[1] = textFirstLine + bodyShift; + textPositions[2] = textSecondLine + bodyShift; + textPositions[3] = textThirdLine + bodyShift; + textPositions[4] = textFourthLine + bodyShift; + textPositions[5] = textFifthLine + bodyShift; + textPositions[6] = textSixthLine + bodyShift; } return textPositions; } diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 804f949ffb..9a7638280c 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -545,7 +545,9 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht // Draw satellite image if (currentResolution == ScreenResolution::High) { - NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display); + const int iconSlack = FONT_HEIGHT_SMALL - (imgGPS_height * 2); + const int iconY = y + (iconSlack > 0 ? iconSlack / 2 : 0); + NodeListRenderer::drawScaledXBitmap16x16(x, iconY, imgGPS_width, imgGPS_height, imgGPS, display); } else { display->drawXbm(x + 1, y + 3, imgGPS_width, imgGPS_height, imgGPS); } diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 60fe00c381..684ce20133 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -165,7 +165,7 @@ void PowerTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *s // Display current and voltage based on ...power_metrics.has_[channel/voltage/current]... flags const auto &m = lastMeasurement.variant.power_metrics; - int lineY = textSecondLine; + int lineY = graphics::getTextPositions(display)[line]; auto drawLine = [&](const char *label, float voltage, float current) { char lineStr[64]; From fdb67309aa8fb9a019e07160ac72024c3d25ce2d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:17:56 -0500 Subject: [PATCH 075/143] Update protobufs (#11741) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- .../meshtastic/lorawan_bridge.pb.cpp | 26 ++ .../generated/meshtastic/lorawan_bridge.pb.h | 271 ++++++++++++++++++ src/mesh/generated/meshtastic/mesh.pb.h | 18 +- src/mesh/generated/meshtastic/portnums.pb.h | 8 +- src/mesh/generated/meshtastic/telemetry.pb.h | 85 +++++- 7 files changed, 395 insertions(+), 17 deletions(-) create mode 100644 src/mesh/generated/meshtastic/lorawan_bridge.pb.cpp create mode 100644 src/mesh/generated/meshtastic/lorawan_bridge.pb.h diff --git a/protobufs b/protobufs index 7b2464c9b8..970fb19a44 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 7b2464c9b8c1521f93852261e4123826e5b25e11 +Subproject commit 970fb19a44f89f8beab02991adb349a4b8d6c48f diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index ea6286fac5..561b9ce01f 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -458,7 +458,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_BackupPreferences_size 2656 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 -#define meshtastic_NodeEnvironmentEntry_size 231 +#define meshtastic_NodeEnvironmentEntry_size 321 #define meshtastic_NodeInfoLite_size 112 #define meshtastic_NodePositionEntry_size 42 #define meshtastic_NodeStatusEntry_size 89 diff --git a/src/mesh/generated/meshtastic/lorawan_bridge.pb.cpp b/src/mesh/generated/meshtastic/lorawan_bridge.pb.cpp new file mode 100644 index 0000000000..5847396530 --- /dev/null +++ b/src/mesh/generated/meshtastic/lorawan_bridge.pb.cpp @@ -0,0 +1,26 @@ +/* Automatically generated nanopb constant definitions */ +/* Generated by nanopb-0.4.9.1 */ + +#include "meshtastic/lorawan_bridge.pb.h" +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +PB_BIND(meshtastic_LoRaWANBridge, meshtastic_LoRaWANBridge, AUTO) + + +PB_BIND(meshtastic_LoRaWANBridge_Uplink, meshtastic_LoRaWANBridge_Uplink, AUTO) + + +PB_BIND(meshtastic_LoRaWANBridge_Downlink, meshtastic_LoRaWANBridge_Downlink, AUTO) + + +PB_BIND(meshtastic_LoRaWANBridge_PayloadChunk, meshtastic_LoRaWANBridge_PayloadChunk, AUTO) + + +PB_BIND(meshtastic_LoRaWANBridge_TxResult, meshtastic_LoRaWANBridge_TxResult, AUTO) + + + + + diff --git a/src/mesh/generated/meshtastic/lorawan_bridge.pb.h b/src/mesh/generated/meshtastic/lorawan_bridge.pb.h new file mode 100644 index 0000000000..933db600cc --- /dev/null +++ b/src/mesh/generated/meshtastic/lorawan_bridge.pb.h @@ -0,0 +1,271 @@ +/* Automatically generated nanopb header */ +/* Generated by nanopb-0.4.9.1 */ + +#ifndef PB_MESHTASTIC_MESHTASTIC_LORAWAN_BRIDGE_PB_H_INCLUDED +#define PB_MESHTASTIC_MESHTASTIC_LORAWAN_BRIDGE_PB_H_INCLUDED +#include + +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +/* Enum definitions */ +/* Values 0 to 7 mirror the Semtech TX_ACK error field. Keep every value non + negative; enums encode as int32. */ +typedef enum _meshtastic_LoRaWANBridge_TxResult_Status { + meshtastic_LoRaWANBridge_TxResult_Status_NONE = 0, + meshtastic_LoRaWANBridge_TxResult_Status_TOO_LATE = 1, + meshtastic_LoRaWANBridge_TxResult_Status_TOO_EARLY = 2, + meshtastic_LoRaWANBridge_TxResult_Status_COLLISION_PACKET = 3, + meshtastic_LoRaWANBridge_TxResult_Status_COLLISION_BEACON = 4, + meshtastic_LoRaWANBridge_TxResult_Status_TX_FREQ = 5, + meshtastic_LoRaWANBridge_TxResult_Status_TX_POWER = 6, + meshtastic_LoRaWANBridge_TxResult_Status_GPS_UNLOCKED = 7, + /* Held by the gateway for a later receive window. */ + meshtastic_LoRaWANBridge_TxResult_Status_DEFERRED = 8, + /* Discarded, either undeferrable or refused by an authorization check. */ + meshtastic_LoRaWANBridge_TxResult_Status_DROPPED = 9 +} meshtastic_LoRaWANBridge_TxResult_Status; + +/* Struct definitions */ +typedef PB_BYTES_ARRAY_T(183) meshtastic_LoRaWANBridge_Uplink_payload_t; +/* An uplink heard by the gateway, travelling towards the network server. */ +typedef struct _meshtastic_LoRaWANBridge_Uplink { + /* Receive frequency in Hz. */ + uint32_t freq_hz; + /* Concentrator receive timestamp in microseconds. Free running, wraps about + every 72 minutes. */ + uint32_t tmst; + /* Received signal strength in tenths of a dBm. Fits signed 16 bit. */ + int16_t rssi_x10; + /* Signal to noise ratio in tenths of a dB. Fits signed 16 bit. */ + int16_t snr_x10; + /* Packed radio settings, 0 to 255. Meaningless when fsk_bitrate is set. + bits 7-5 0 to 7 for SF5 to SF12 + bits 4-2 bandwidth in kHz: 0 125, 1 250, 2 500, 3 203.125, 4 406.25, + 5 812.5, 6 1625, 7 reserved. Codes 0 to 2 are the sub-GHz set + and 3 to 6 the 2.4 GHz set; the two do not overlap. Any future + bandwidth takes the next free code rather than sorting in. + bits 1-0 0 to 3 for 4/5 to 4/8 */ + uint8_t radio_params; + /* PHY payload, or its first part when chunk_count is 2. */ + meshtastic_LoRaWANBridge_Uplink_payload_t payload; + /* Groups this head with its continuation. 1 to 255, or 0 when not chunked. */ + uint8_t payload_id; + /* 0 when the frame fits one packet, otherwise 2. */ + uint8_t chunk_count; + /* FSK bit rate in bits per second. Non-zero only for an FSK frame, in which + case radio_params does not apply. Fits unsigned 16 bit. */ + uint16_t fsk_bitrate; +} meshtastic_LoRaWANBridge_Uplink; + +typedef PB_BYTES_ARRAY_T(183) meshtastic_LoRaWANBridge_Downlink_payload_t; +/* A downlink from the network server, travelling towards the gateway. */ +typedef struct _meshtastic_LoRaWANBridge_Downlink { + /* Transmit frequency in Hz. */ + uint32_t freq_hz; + /* Concentrator timestamp to transmit at. Ignored when immediate is set. */ + uint32_t tmst; + /* Packed radio settings, encoded as in Uplink.radio_params. FSK downlink is + not supported, so there is no bit rate or frequency deviation here. */ + uint8_t radio_params; + /* Transmit power in dBm, 0 to 255. The gateway clamps it to its region. */ + uint8_t power_dbm; + /* Transmit as soon as possible instead of at tmst. Used for Class C. */ + bool immediate; + /* Invert LoRa polarity. True for every LoRaWAN downlink. */ + bool invert_polarity; + /* Disable the physical layer CRC. */ + bool no_crc; + /* Allow the gateway to send this in a later receive window if it arrives too + late. Leave clear for anything tied to a specific uplink. */ + bool may_defer; + /* PHY payload, or its first part when chunk_count is 2. */ + meshtastic_LoRaWANBridge_Downlink_payload_t payload; + /* Groups this head with its continuation. 1 to 255, or 0 when not chunked. */ + uint8_t payload_id; + /* 0 when the frame fits one packet, otherwise 2. */ + uint8_t chunk_count; + /* Identifies this downlink so its TxResult can be matched to it. 1 to 255. */ + uint8_t request_id; +} meshtastic_LoRaWANBridge_Downlink; + +typedef PB_BYTES_ARRAY_T(183) meshtastic_LoRaWANBridge_PayloadChunk_payload_chunk_t; +/* The remainder of a chunked Uplink or Downlink. Carries no RF metadata. */ +typedef struct _meshtastic_LoRaWANBridge_PayloadChunk { + /* Matches payload_id in the head message. Reassembly keys on the sending + node and this value. */ + uint8_t payload_id; + /* Position of this chunk. Always 1. */ + uint8_t chunk_index; + /* This part of the PHY payload. */ + meshtastic_LoRaWANBridge_PayloadChunk_payload_chunk_t payload_chunk; +} meshtastic_LoRaWANBridge_PayloadChunk; + +/* The outcome of a downlink, reported back towards the network server. */ +typedef struct _meshtastic_LoRaWANBridge_TxResult { + /* The tmst the downlink was scheduled for. Zero when it was immediate, so + diagnostic only; correlate on request_id. */ + uint32_t tmst; + /* What happened to it. */ + meshtastic_LoRaWANBridge_TxResult_Status status; + /* Echoes Downlink.request_id. */ + uint8_t request_id; +} meshtastic_LoRaWANBridge_TxResult; + +/* Payload for LORAWAN_BRIDGE packets. Tunnels raw LoRaWAN PHY payloads and their + RF metadata so a gateway can reach a network server over a mesh. */ +typedef struct _meshtastic_LoRaWANBridge { + pb_size_t which_variant; + union { + meshtastic_LoRaWANBridge_Uplink uplink; + meshtastic_LoRaWANBridge_Downlink downlink; + meshtastic_LoRaWANBridge_TxResult tx_result; + meshtastic_LoRaWANBridge_PayloadChunk chunk; + } variant; +} meshtastic_LoRaWANBridge; + + +#ifdef __cplusplus +extern "C" { +#endif + +/* Helper constants for enums */ +#define _meshtastic_LoRaWANBridge_TxResult_Status_MIN meshtastic_LoRaWANBridge_TxResult_Status_NONE +#define _meshtastic_LoRaWANBridge_TxResult_Status_MAX meshtastic_LoRaWANBridge_TxResult_Status_DROPPED +#define _meshtastic_LoRaWANBridge_TxResult_Status_ARRAYSIZE ((meshtastic_LoRaWANBridge_TxResult_Status)(meshtastic_LoRaWANBridge_TxResult_Status_DROPPED+1)) + + + + + +#define meshtastic_LoRaWANBridge_TxResult_status_ENUMTYPE meshtastic_LoRaWANBridge_TxResult_Status + + +/* Initializer values for message structs */ +#define meshtastic_LoRaWANBridge_init_default {0, {meshtastic_LoRaWANBridge_Uplink_init_default}} +#define meshtastic_LoRaWANBridge_Uplink_init_default {0, 0, 0, 0, 0, {0, {0}}, 0, 0, 0} +#define meshtastic_LoRaWANBridge_Downlink_init_default {0, 0, 0, 0, 0, 0, 0, 0, {0, {0}}, 0, 0, 0} +#define meshtastic_LoRaWANBridge_PayloadChunk_init_default {0, 0, {0, {0}}} +#define meshtastic_LoRaWANBridge_TxResult_init_default {0, _meshtastic_LoRaWANBridge_TxResult_Status_MIN, 0} +#define meshtastic_LoRaWANBridge_init_zero {0, {meshtastic_LoRaWANBridge_Uplink_init_zero}} +#define meshtastic_LoRaWANBridge_Uplink_init_zero {0, 0, 0, 0, 0, {0, {0}}, 0, 0, 0} +#define meshtastic_LoRaWANBridge_Downlink_init_zero {0, 0, 0, 0, 0, 0, 0, 0, {0, {0}}, 0, 0, 0} +#define meshtastic_LoRaWANBridge_PayloadChunk_init_zero {0, 0, {0, {0}}} +#define meshtastic_LoRaWANBridge_TxResult_init_zero {0, _meshtastic_LoRaWANBridge_TxResult_Status_MIN, 0} + +/* Field tags (for use in manual encoding/decoding) */ +#define meshtastic_LoRaWANBridge_Uplink_freq_hz_tag 1 +#define meshtastic_LoRaWANBridge_Uplink_tmst_tag 2 +#define meshtastic_LoRaWANBridge_Uplink_rssi_x10_tag 3 +#define meshtastic_LoRaWANBridge_Uplink_snr_x10_tag 4 +#define meshtastic_LoRaWANBridge_Uplink_radio_params_tag 5 +#define meshtastic_LoRaWANBridge_Uplink_payload_tag 6 +#define meshtastic_LoRaWANBridge_Uplink_payload_id_tag 7 +#define meshtastic_LoRaWANBridge_Uplink_chunk_count_tag 8 +#define meshtastic_LoRaWANBridge_Uplink_fsk_bitrate_tag 9 +#define meshtastic_LoRaWANBridge_Downlink_freq_hz_tag 1 +#define meshtastic_LoRaWANBridge_Downlink_tmst_tag 2 +#define meshtastic_LoRaWANBridge_Downlink_radio_params_tag 3 +#define meshtastic_LoRaWANBridge_Downlink_power_dbm_tag 4 +#define meshtastic_LoRaWANBridge_Downlink_immediate_tag 5 +#define meshtastic_LoRaWANBridge_Downlink_invert_polarity_tag 6 +#define meshtastic_LoRaWANBridge_Downlink_no_crc_tag 7 +#define meshtastic_LoRaWANBridge_Downlink_may_defer_tag 8 +#define meshtastic_LoRaWANBridge_Downlink_payload_tag 9 +#define meshtastic_LoRaWANBridge_Downlink_payload_id_tag 10 +#define meshtastic_LoRaWANBridge_Downlink_chunk_count_tag 11 +#define meshtastic_LoRaWANBridge_Downlink_request_id_tag 12 +#define meshtastic_LoRaWANBridge_PayloadChunk_payload_id_tag 1 +#define meshtastic_LoRaWANBridge_PayloadChunk_chunk_index_tag 2 +#define meshtastic_LoRaWANBridge_PayloadChunk_payload_chunk_tag 3 +#define meshtastic_LoRaWANBridge_TxResult_tmst_tag 1 +#define meshtastic_LoRaWANBridge_TxResult_status_tag 2 +#define meshtastic_LoRaWANBridge_TxResult_request_id_tag 3 +#define meshtastic_LoRaWANBridge_uplink_tag 1 +#define meshtastic_LoRaWANBridge_downlink_tag 2 +#define meshtastic_LoRaWANBridge_tx_result_tag 3 +#define meshtastic_LoRaWANBridge_chunk_tag 4 + +/* Struct field encoding specification for nanopb */ +#define meshtastic_LoRaWANBridge_FIELDLIST(X, a) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,uplink,variant.uplink), 1) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,downlink,variant.downlink), 2) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,tx_result,variant.tx_result), 3) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,chunk,variant.chunk), 4) +#define meshtastic_LoRaWANBridge_CALLBACK NULL +#define meshtastic_LoRaWANBridge_DEFAULT NULL +#define meshtastic_LoRaWANBridge_variant_uplink_MSGTYPE meshtastic_LoRaWANBridge_Uplink +#define meshtastic_LoRaWANBridge_variant_downlink_MSGTYPE meshtastic_LoRaWANBridge_Downlink +#define meshtastic_LoRaWANBridge_variant_tx_result_MSGTYPE meshtastic_LoRaWANBridge_TxResult +#define meshtastic_LoRaWANBridge_variant_chunk_MSGTYPE meshtastic_LoRaWANBridge_PayloadChunk + +#define meshtastic_LoRaWANBridge_Uplink_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, FIXED32, freq_hz, 1) \ +X(a, STATIC, SINGULAR, FIXED32, tmst, 2) \ +X(a, STATIC, SINGULAR, SINT32, rssi_x10, 3) \ +X(a, STATIC, SINGULAR, SINT32, snr_x10, 4) \ +X(a, STATIC, SINGULAR, UINT32, radio_params, 5) \ +X(a, STATIC, SINGULAR, BYTES, payload, 6) \ +X(a, STATIC, SINGULAR, UINT32, payload_id, 7) \ +X(a, STATIC, SINGULAR, UINT32, chunk_count, 8) \ +X(a, STATIC, SINGULAR, UINT32, fsk_bitrate, 9) +#define meshtastic_LoRaWANBridge_Uplink_CALLBACK NULL +#define meshtastic_LoRaWANBridge_Uplink_DEFAULT NULL + +#define meshtastic_LoRaWANBridge_Downlink_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, FIXED32, freq_hz, 1) \ +X(a, STATIC, SINGULAR, FIXED32, tmst, 2) \ +X(a, STATIC, SINGULAR, UINT32, radio_params, 3) \ +X(a, STATIC, SINGULAR, UINT32, power_dbm, 4) \ +X(a, STATIC, SINGULAR, BOOL, immediate, 5) \ +X(a, STATIC, SINGULAR, BOOL, invert_polarity, 6) \ +X(a, STATIC, SINGULAR, BOOL, no_crc, 7) \ +X(a, STATIC, SINGULAR, BOOL, may_defer, 8) \ +X(a, STATIC, SINGULAR, BYTES, payload, 9) \ +X(a, STATIC, SINGULAR, UINT32, payload_id, 10) \ +X(a, STATIC, SINGULAR, UINT32, chunk_count, 11) \ +X(a, STATIC, SINGULAR, UINT32, request_id, 12) +#define meshtastic_LoRaWANBridge_Downlink_CALLBACK NULL +#define meshtastic_LoRaWANBridge_Downlink_DEFAULT NULL + +#define meshtastic_LoRaWANBridge_PayloadChunk_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, payload_id, 1) \ +X(a, STATIC, SINGULAR, UINT32, chunk_index, 2) \ +X(a, STATIC, SINGULAR, BYTES, payload_chunk, 3) +#define meshtastic_LoRaWANBridge_PayloadChunk_CALLBACK NULL +#define meshtastic_LoRaWANBridge_PayloadChunk_DEFAULT NULL + +#define meshtastic_LoRaWANBridge_TxResult_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, FIXED32, tmst, 1) \ +X(a, STATIC, SINGULAR, UENUM, status, 2) \ +X(a, STATIC, SINGULAR, UINT32, request_id, 3) +#define meshtastic_LoRaWANBridge_TxResult_CALLBACK NULL +#define meshtastic_LoRaWANBridge_TxResult_DEFAULT NULL + +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_msg; +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_Uplink_msg; +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_Downlink_msg; +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_PayloadChunk_msg; +extern const pb_msgdesc_t meshtastic_LoRaWANBridge_TxResult_msg; + +/* Defines for backwards compatibility with code written before nanopb-0.4.0 */ +#define meshtastic_LoRaWANBridge_fields &meshtastic_LoRaWANBridge_msg +#define meshtastic_LoRaWANBridge_Uplink_fields &meshtastic_LoRaWANBridge_Uplink_msg +#define meshtastic_LoRaWANBridge_Downlink_fields &meshtastic_LoRaWANBridge_Downlink_msg +#define meshtastic_LoRaWANBridge_PayloadChunk_fields &meshtastic_LoRaWANBridge_PayloadChunk_msg +#define meshtastic_LoRaWANBridge_TxResult_fields &meshtastic_LoRaWANBridge_TxResult_msg + +/* Maximum encoded size of messages (where known) */ +#define MESHTASTIC_MESHTASTIC_LORAWAN_BRIDGE_PB_H_MAX_SIZE meshtastic_LoRaWANBridge_size +#define meshtastic_LoRaWANBridge_Downlink_size 219 +#define meshtastic_LoRaWANBridge_PayloadChunk_size 192 +#define meshtastic_LoRaWANBridge_TxResult_size 10 +#define meshtastic_LoRaWANBridge_Uplink_size 217 +#define meshtastic_LoRaWANBridge_size 222 + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 375ff4861b..ebefe6118c 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1277,15 +1277,15 @@ typedef struct _meshtastic_LockdownStatus { /* Current lockdown state being reported. */ meshtastic_LockdownStatus_State state; /* For LOCKED: machine-readable reason. Known values: - "needs_auth" - storage already unlocked, client must auth - "token_missing" - no boot token on flash - "token_expired" - boot token wall-clock TTL elapsed - "token_boots_zero" - boot token boot-count TTL exhausted - "token_hmac_fail" - token tampered or wrong device - "token_dek_fail" - token DEK decrypt failed - "token_wrong_size" - token file corrupted - "token_bad_magic" - token file corrupted - "not_provisioned" - should generally use NEEDS_PROVISION state instead + "needs_auth" — storage already unlocked, client must auth + "token_missing" — no boot token on flash + "token_expired" — boot token wall-clock TTL elapsed + "token_boots_zero" — boot token boot-count TTL exhausted + "token_hmac_fail" — token tampered or wrong device + "token_dek_fail" — token DEK decrypt failed + "token_wrong_size" — token file corrupted + "token_bad_magic" — token file corrupted + "not_provisioned" — should generally use NEEDS_PROVISION state instead Other values may be added; clients should treat unknown values as "locked, ask for passphrase". */ char lock_reason[32]; diff --git a/src/mesh/generated/meshtastic/portnums.pb.h b/src/mesh/generated/meshtastic/portnums.pb.h index 32da931034..d6652530cb 100644 --- a/src/mesh/generated/meshtastic/portnums.pb.h +++ b/src/mesh/generated/meshtastic/portnums.pb.h @@ -103,6 +103,12 @@ typedef enum _meshtastic_PortNum { Periodically broadcast by nodes in beacon mode; received by nodes with MeshBeaconConfig.FLAG_LISTEN_ENABLED. Carries a text message plus optional channel/preset offers for client apps. */ meshtastic_PortNum_MESH_BEACON_APP = 37, + /* Acknowledged paging: alerts a person is expected to physically acknowledge, and the + acknowledgements themselves. + ENCODING: protobuf PagingPacket + Distinct from ALERT_APP, which is a text message the recipient never confirms, and from a + routing or delivery ACK, which says the packet arrived rather than that someone saw it. */ + meshtastic_PortNum_PAGING_APP = 38, /* Provides a hardware serial interface to send and receive from the Meshtastic network. Connect to the RX/TX pins of a device with 38400 8N1. Packets received from the Meshtastic network is forwarded to the RX pin while sending a packet to TX will go out to the Mesh network. @@ -148,7 +154,7 @@ typedef enum _meshtastic_PortNum { /* PowerStress based monitoring support (for automated power consumption testing) */ meshtastic_PortNum_POWERSTRESS_APP = 74, /* LoraWAN Payload Transport - ENCODING: compact binary LoRaWAN uplink (10-byte RF metadata + PHY payload) - see LoRaWANBridgeModule */ + ENCODING: LoRaWANBridge protobuf, see lorawan_bridge.proto */ meshtastic_PortNum_LORAWAN_BRIDGE = 75, /* Reticulum Network Stack Tunnel App ENCODING: Fragmented RNS Packet. Handled by Meshtastic RNS interface */ diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index bfac3b038a..8cae56f921 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -274,6 +274,51 @@ typedef struct _meshtastic_EnvironmentMetrics { /* Estimated distance to the leading edge of the storm, in km */ bool has_lightning_distance_km; float lightning_distance_km; + /* Soil pH, 0-14 */ + bool has_soil_ph; + float soil_ph; + /* pH of water or other solution, 0-14 */ + bool has_ph; + float ph; + /* Electrical conductivity in mS/cm */ + bool has_electrical_conductivity; + float electrical_conductivity; + /* Salinity in mg/l */ + bool has_salinity; + float salinity; + /* Nitrogen concentration in mg/kg */ + bool has_nitrogen; + float nitrogen; + /* Phosphorus concentration in mg/kg */ + bool has_phosphorus; + float phosphorus; + /* Potassium concentration in mg/kg */ + bool has_potassium; + float potassium; + /* Dissolved oxygen in mg/l */ + bool has_dissolved_oxygen; + float dissolved_oxygen; + /* Oxidation-reduction potential (ORP) in mV */ + bool has_orp; + float orp; + /* Chemical oxygen demand in mg/l */ + bool has_chemical_oxygen_demand; + float chemical_oxygen_demand; + /* Turbidity in NTU */ + bool has_turbidity; + float turbidity; + /* Nitrate concentration in ppm */ + bool has_nitrate; + float nitrate; + /* Ammonium concentration in ppm */ + bool has_ammonium; + float ammonium; + /* Biochemical oxygen demand in mg/l */ + bool has_biochemical_oxygen_demand; + float biochemical_oxygen_demand; + /* Solar irradiance in W/m^2 (distinct from the radiation field's uR/h) */ + bool has_solar_irradiance; + float solar_irradiance; } meshtastic_EnvironmentMetrics; /* Power Metrics (voltage / current / etc) */ @@ -610,7 +655,7 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_DeviceMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -623,7 +668,7 @@ extern "C" { #define meshtastic_SEN5XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN6XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_DeviceMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -682,6 +727,21 @@ extern "C" { #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch7_tag 39 #define meshtastic_EnvironmentMetrics_lightning_strike_count_1h_tag 40 #define meshtastic_EnvironmentMetrics_lightning_distance_km_tag 41 +#define meshtastic_EnvironmentMetrics_soil_ph_tag 42 +#define meshtastic_EnvironmentMetrics_ph_tag 43 +#define meshtastic_EnvironmentMetrics_electrical_conductivity_tag 44 +#define meshtastic_EnvironmentMetrics_salinity_tag 45 +#define meshtastic_EnvironmentMetrics_nitrogen_tag 46 +#define meshtastic_EnvironmentMetrics_phosphorus_tag 47 +#define meshtastic_EnvironmentMetrics_potassium_tag 48 +#define meshtastic_EnvironmentMetrics_dissolved_oxygen_tag 49 +#define meshtastic_EnvironmentMetrics_orp_tag 50 +#define meshtastic_EnvironmentMetrics_chemical_oxygen_demand_tag 51 +#define meshtastic_EnvironmentMetrics_turbidity_tag 52 +#define meshtastic_EnvironmentMetrics_nitrate_tag 53 +#define meshtastic_EnvironmentMetrics_ammonium_tag 54 +#define meshtastic_EnvironmentMetrics_biochemical_oxygen_demand_tag 55 +#define meshtastic_EnvironmentMetrics_solar_irradiance_tag 56 #define meshtastic_PowerMetrics_ch1_voltage_tag 1 #define meshtastic_PowerMetrics_ch1_current_tag 2 #define meshtastic_PowerMetrics_ch2_voltage_tag 3 @@ -833,7 +893,22 @@ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch5, 37) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch6, 38) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch7, 39) \ X(a, STATIC, OPTIONAL, UINT32, lightning_strike_count_1h, 40) \ -X(a, STATIC, OPTIONAL, FLOAT, lightning_distance_km, 41) +X(a, STATIC, OPTIONAL, FLOAT, lightning_distance_km, 41) \ +X(a, STATIC, OPTIONAL, FLOAT, soil_ph, 42) \ +X(a, STATIC, OPTIONAL, FLOAT, ph, 43) \ +X(a, STATIC, OPTIONAL, FLOAT, electrical_conductivity, 44) \ +X(a, STATIC, OPTIONAL, FLOAT, salinity, 45) \ +X(a, STATIC, OPTIONAL, FLOAT, nitrogen, 46) \ +X(a, STATIC, OPTIONAL, FLOAT, phosphorus, 47) \ +X(a, STATIC, OPTIONAL, FLOAT, potassium, 48) \ +X(a, STATIC, OPTIONAL, FLOAT, dissolved_oxygen, 49) \ +X(a, STATIC, OPTIONAL, FLOAT, orp, 50) \ +X(a, STATIC, OPTIONAL, FLOAT, chemical_oxygen_demand, 51) \ +X(a, STATIC, OPTIONAL, FLOAT, turbidity, 52) \ +X(a, STATIC, OPTIONAL, FLOAT, nitrate, 53) \ +X(a, STATIC, OPTIONAL, FLOAT, ammonium, 54) \ +X(a, STATIC, OPTIONAL, FLOAT, biochemical_oxygen_demand, 55) \ +X(a, STATIC, OPTIONAL, FLOAT, solar_irradiance, 56) #define meshtastic_EnvironmentMetrics_CALLBACK NULL #define meshtastic_EnvironmentMetrics_DEFAULT NULL @@ -1023,7 +1098,7 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; #define meshtastic_AS3935Config_size 6 #define meshtastic_AirQualityMetrics_size 157 #define meshtastic_DeviceMetrics_size 27 -#define meshtastic_EnvironmentMetrics_size 222 +#define meshtastic_EnvironmentMetrics_size 312 #define meshtastic_HealthMetrics_size 11 #define meshtastic_HostMetrics_size 264 #define meshtastic_LocalStats_size 87 @@ -1031,7 +1106,7 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; #define meshtastic_PowerMetrics_size 81 #define meshtastic_SEN5XState_size 27 #define meshtastic_SEN6XState_size 27 -#define meshtastic_Telemetry_size 272 +#define meshtastic_Telemetry_size 320 #define meshtastic_TrafficManagementStats_size 42 #ifdef __cplusplus From 868604514a282f5aa10fc2462e0d6a549fd3f8d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:32:55 +0200 Subject: [PATCH 076/143] Update protobufs (#11750) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 4 +++- src/mesh/generated/meshtastic/mesh.pb.h | 21 +++++++++++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/protobufs b/protobufs index 970fb19a44..3808a392e3 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 970fb19a44f89f8beab02991adb349a4b8d6c48f +Subproject commit 3808a392e3317a7cbf1e6ded00bb3c570e3cfc7e diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 561b9ce01f..a1b8df59b2 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -81,7 +81,9 @@ typedef struct _meshtastic_NodeInfoLite { uint8_t hops_away; /* Last byte of the node number of the node that should be used as the next hop to reach this node. */ uint8_t next_hop; - /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. */ + /* Bitfield for storing booleans. See NODEINFO_BITFIELD_* in src/mesh/NodeDB.h. + Bit 11 is NODEINFO_BITFIELD_HEARD_ON_CURRENT_LORA, mirrored on the wire as + NodeInfo.heard_on_current_lora. */ uint32_t bitfield; /* A full name for this user, i.e. "Kevin Hester". */ char long_name[25]; diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index ebefe6118c..9d379158ae 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -345,6 +345,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_MESHNOLOGY_W12 = 145, /* Seeed Studio MeshPager X2 */ meshtastic_HardwareModel_MESHPAGER_X2 = 146, + /* Lilygo T-CONNECT PRO */ + meshtastic_HardwareModel_T_CONNECT_PRO = 147, /* ------------------------------------------------------------------------------------------------------------------------------------------ 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. ------------------------------------------------------------------------------------------------------------------------------------------ */ @@ -1215,6 +1217,15 @@ typedef struct _meshtastic_NodeInfo { Persists between NodeDB internal clean ups LSB 1 of the bitfield */ bool has_xeddsa_signed; + /* True if we have heard this node over RF since our current LoRa + configuration took effect. Cleared for every node whenever the region, + modem preset (or the custom bandwidth/spread factor/coding rate when + use_preset is false), override_frequency, channel_num or the primary + channel name changes - the frequency slot is derived from that name. + Not set for nodes heard over MQTT, which reach us over the internet + rather than over our own radio - see via_mqtt. + LSB 11 of the bitfield */ + bool heard_on_current_lora; } meshtastic_NodeInfo; typedef PB_BYTES_ARRAY_T(16) meshtastic_MyNodeInfo_device_id_t; @@ -1756,7 +1767,7 @@ extern "C" { #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} #define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, false, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} -#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_default {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_default {0, 0, 0, 0} @@ -1795,7 +1806,7 @@ extern "C" { #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} #define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, false, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} -#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0} +#define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_zero {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} #define meshtastic_QueueStatus_init_zero {0, 0, 0, 0} @@ -1953,6 +1964,7 @@ extern "C" { #define meshtastic_NodeInfo_is_key_manually_verified_tag 12 #define meshtastic_NodeInfo_is_muted_tag 13 #define meshtastic_NodeInfo_has_xeddsa_signed_tag 14 +#define meshtastic_NodeInfo_heard_on_current_lora_tag 15 #define meshtastic_MyNodeInfo_my_node_num_tag 1 #define meshtastic_MyNodeInfo_reboot_count_tag 8 #define meshtastic_MyNodeInfo_min_app_version_tag 11 @@ -2248,7 +2260,8 @@ X(a, STATIC, SINGULAR, BOOL, is_favorite, 10) \ X(a, STATIC, SINGULAR, BOOL, is_ignored, 11) \ X(a, STATIC, SINGULAR, BOOL, is_key_manually_verified, 12) \ X(a, STATIC, SINGULAR, BOOL, is_muted, 13) \ -X(a, STATIC, SINGULAR, BOOL, has_xeddsa_signed, 14) +X(a, STATIC, SINGULAR, BOOL, has_xeddsa_signed, 14) \ +X(a, STATIC, SINGULAR, BOOL, heard_on_current_lora, 15) #define meshtastic_NodeInfo_CALLBACK NULL #define meshtastic_NodeInfo_DEFAULT NULL #define meshtastic_NodeInfo_user_MSGTYPE meshtastic_User @@ -2604,7 +2617,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_MyNodeInfo_size 83 #define meshtastic_NeighborInfo_size 258 #define meshtastic_Neighbor_size 22 -#define meshtastic_NodeInfo_size 327 +#define meshtastic_NodeInfo_size 329 #define meshtastic_NodeRemoteHardwarePin_size 29 #define meshtastic_Position_size 144 #define meshtastic_QueueStatus_size 23 From ef4bfff092d966acf9d07c384f0ebac7d9a34d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 6 Sep 2026 14:04:43 +0000 Subject: [PATCH 077/143] fix(nodeinfo): consume the radio-generation change only on a nodeinfo send that went out (#11752) --- src/modules/NodeInfoModule.cpp | 13 +++++++------ src/modules/NodeInfoModule.h | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 7c4096959c..57a7540c47 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -95,7 +95,7 @@ void NodeInfoModule::alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes), &meshtastic_User_msg, p); } -void NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t channel, bool _shorterTimeout) +bool NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t channel, bool _shorterTimeout) { // cancel any not yet sent (now stale) position packets if (prevPacketId) // if we wrap around to zero, we'll simply fail to cancel in that rare case (no big deal) @@ -125,7 +125,9 @@ void NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t cha service->sendToMesh(p); shorterTimeout = false; + return true; } + return false; } void NodeInfoModule::triggerImmediateNodeInfoCheck() @@ -225,13 +227,12 @@ NodeInfoModule::NodeInfoModule() int32_t NodeInfoModule::runOnce() { - // If we changed channels, ask everyone else for their latest info - bool requestReplies = currentGeneration != radioGeneration; - currentGeneration = radioGeneration; - if (airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { + // If we changed channels, ask everyone else for their latest info + bool requestReplies = currentGeneration != radioGeneration; LOG_INFO("Send our nodeinfo to mesh (wantReplies=%d)", requestReplies); - sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies) + if (sendOurNodeInfo(NODENUM_BROADCAST, requestReplies)) + currentGeneration = radioGeneration; // only a send that went out consumes the channel change } return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); } diff --git a/src/modules/NodeInfoModule.h b/src/modules/NodeInfoModule.h index 8653c71ebc..6cdb8caa74 100644 --- a/src/modules/NodeInfoModule.h +++ b/src/modules/NodeInfoModule.h @@ -19,9 +19,9 @@ class NodeInfoModule : public ProtobufModule, private concurren NodeInfoModule(); /** - * Send our NodeInfo into the mesh + * Send our NodeInfo into the mesh. True only when a packet was handed to the router. */ - void sendOurNodeInfo(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false, uint8_t channel = 0, + bool sendOurNodeInfo(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false, uint8_t channel = 0, bool _shorterTimeout = false); /** From 51e45b39194c206997c872d9e04ef10e5d01ca5e Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 6 Sep 2026 14:21:03 +0000 Subject: [PATCH 078/143] fix(telemetry): restore the noise floor feeder and stop shipping its default (#11749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(telemetry): don't broadcast the noise floor default as a real reading LocalStats.noise_floor has no has_/presence bit, so RadioLibInterface's NOISE_FLOOR_DEFAULT (-120 dBm) placeholder was indistinguishable on the wire from a genuine -120 dBm reading whenever no valid RSSI sample had ever been collected (radio not idle when sampled, or every reading falling outside the plausible bounds). Gate the assignment on hasNoiseFloorSamples() and log a warning instead of shipping the placeholder. The field stays at its zero-init default in that case. Co-Authored-By: Claude Fable 5.1 * Address review: trim the noise_floor comment and demote the log to debug An empty sample buffer is expected during early boot, so LOG_WARN overstated it. * Restore the periodic noise floor feeder lost in merge e55947595 updateNoiseFloor() shipped in #9347 with three call sites. Review removed the onNotify() one because sampling on the ISR path can overflow the 256-byte radio FIFO; the maintainer's guidance was to keep a periodic call from thread context instead. The remaining completeSending() and startReceive() calls then vanished in merge commit e55947595 "Merge upstream develop into noise-floor", which took develop's rewrite of both functions wholesale. No commit since has called it. That left DeviceTelemetry::getLocalStatsTelemetry() as the only caller, so the 20-sample window advanced at most once per local stats send: one sample every 15 minutes, five hours to fill, and the internal 5s throttle never reached. Sample from the existing AGC maintenance tick, before periodicRadioMaintenance() because resetAGC() recalibrates the frontend and biases an RSSI read taken right after it. NOISE_FLOOR_UPDATE_INTERVAL_MS stays at 5s as an inner floor; the 60s call site sets the real rate. The window now fills in about 20 minutes. * Trim the noise floor change Drop the updateNoiseFloor() call on the telemetry path: the 60s tick feeds the window now, so it contributed about one sample in fifteen and cost an SPI-gated read on the local stats path. The else-branch existed only to log; the "Sending local stats" LOG_INFO in the same function already reports noise_floor=0 when no sample exists. --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Thomas Göttgens --- src/main.cpp | 2 ++ src/modules/Telemetry/DeviceTelemetry.cpp | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 6cf24cf583..db087d863c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1502,6 +1502,8 @@ void loop() static uint32_t lastAgcReset; if (!Throttle::isWithinTimespanMs(lastAgcReset, AGC_RESET_INTERVAL_MS)) { lastAgcReset = millis(); + // Sample before resetAGC(): recalibrating the frontend biases an RSSI read taken right after it. + RadioLibInterface::instance->updateNoiseFloor(); RadioLibInterface::instance->periodicRadioMaintenance(); } } diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index e3ef3f0950..8d3834137e 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -131,8 +131,10 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry() telemetry.variant.local_stats.num_online_nodes = numOnlineNodes; telemetry.variant.local_stats.num_total_nodes = nodeDB->getNumMeshNodes(); if (RadioLibInterface::instance) { - RadioLibInterface::instance->updateNoiseFloor(); - telemetry.variant.local_stats.noise_floor = RadioLibInterface::instance->getAverageNoiseFloor(); + // No presence bit: leave zero-init when no valid sample exists instead of publishing + // NOISE_FLOOR_DEFAULT as a real reading. + if (RadioLibInterface::instance->hasNoiseFloorSamples()) + telemetry.variant.local_stats.noise_floor = RadioLibInterface::instance->getAverageNoiseFloor(); telemetry.variant.local_stats.num_packets_tx = RadioLibInterface::instance->txGood; telemetry.variant.local_stats.num_packets_rx = RadioLibInterface::instance->rxGood + RadioLibInterface::instance->rxBad; telemetry.variant.local_stats.num_packets_rx_bad = RadioLibInterface::instance->rxBad; From 9fe0360f4ed57aff84298c57c9323cf0d03f6d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 6 Sep 2026 14:40:57 +0000 Subject: [PATCH 079/143] fix(position): stamp the broadcast cadence only when a position packet was actually sent (#11751) * fix(position): stamp the broadcast cadence only when a position packet was actually sent * fix(position): honor the router verdict and always fall back to nodeinfo when no position goes out * fix(position): snapshot Time::getMillis() for the throttle stamps and trim the added comments * fix(position): consume the radio-generation change only on a position send that went out * refactor(position): share one smart-broadcast path and drop the duplicated fresh-position guard * fix(position): persist smart broadcasts to the transmit history --- src/mesh/MeshService.cpp | 43 ++++++--------- src/mesh/MeshService.h | 3 +- src/modules/PositionModule.cpp | 99 +++++++++++++++++----------------- src/modules/PositionModule.h | 9 ++-- 4 files changed, 73 insertions(+), 81 deletions(-) diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 707d292a94..73fb7f6004 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -368,7 +368,7 @@ ErrorCode MeshService::sendQueueStatusToPhone(const meshtastic_QueueStatus &qs, return res ? ERRNO_OK : ERRNO_UNKNOWN; } -void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPhone) +ErrorCode MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPhone) { uint32_t mesh_packet_id = p->id; nodeDB->updateFrom(*p); // update our local DB for this packet (because phone might have sent position packets etc...) @@ -404,6 +404,8 @@ void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPh if (res == ERRNO_SHOULD_RELEASE) { releaseToPool(p); } + + return res; } bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) @@ -412,35 +414,22 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) assert(node); - if (nodeDB->hasValidPosition(node)) { #if HAS_GPS && !MESHTASTIC_EXCLUDE_GPS - if (positionModule) { - if (!config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot()) { - LOG_DEBUG("Skip position ping; no fresh position since boot"); - return false; - } - // Prefer the node's current channel, but fall back to the position channel - // (matching PositionModule::sendOurPosition() behavior). - uint8_t sendChan = node->channel; - if (getPositionPrecisionForChannel(sendChan) == 0 && !findPositionChannel(sendChan)) { - // No channel with position enabled: fall back to sending nodeinfo, as before. - if (nodeInfoModule) { - LOG_INFO("No position-enabled channel; send nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", dest, - wantReplies, node->channel); - nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); - } - return false; - } - LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan); - positionModule->sendOurPosition(dest, wantReplies, sendChan); + // Prefer the node's current channel, but fall back to the position channel + // (matching PositionModule::sendOurPosition() behavior). + uint8_t sendChan = node->channel; + if (nodeDB->hasValidPosition(node) && positionModule && + (config.position.fixed_position || nodeDB->hasLocalPositionSinceBoot()) && + (getPositionPrecisionForChannel(sendChan) != 0 || findPositionChannel(sendChan))) { + LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan); + if (positionModule->sendOurPosition(dest, wantReplies, sendChan)) return true; - } - } else { + } #endif - if (nodeInfoModule) { - LOG_INFO("Send nodeinfo ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel); - nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); - } + // No position went out, so a false return tells the callers the nodeinfo fallback was used. + if (nodeInfoModule) { + LOG_INFO("Send nodeinfo ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel); + nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); } return false; } diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index fb93370cc2..85d09058cc 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -189,7 +189,8 @@ class MeshService /// Send a packet into the mesh - note p must have been allocated from packetPool. We will return it to that pool after /// sending. This is the ONLY function you should use for sending messages into the mesh, because it also updates the nodedb /// cache - void sendToMesh(meshtastic_MeshPacket *p, RxSource src = RX_SRC_LOCAL, bool ccToPhone = false); + /// Returns the router's verdict: ERRNO_OK / ERRNO_SHOULD_RELEASE accepted, anything else released unsent. + ErrorCode sendToMesh(meshtastic_MeshPacket *p, RxSource src = RX_SRC_LOCAL, bool ccToPhone = false); /** Attempt to cancel a previously sent packet from this _local_ node. Returns true if a packet was found we could cancel */ bool cancelSending(PacketId id); diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 416c65f6e4..55167d472c 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -9,6 +9,7 @@ #include "Router.h" #include "TransmitHistory.h" #include "TypeConversions.h" +#include "UptimeClock.h" #include "airtime.h" #include "configuration.h" #include "gps/GPSLog.h" @@ -291,7 +292,7 @@ meshtastic_MeshPacket *PositionModule::allocReply() meshtastic_MeshPacket *reply = allocPositionPacket(precision); if (reply) { - lastSentReply = millis(); // Track when we sent this reply + lastSentReply = Time::getMillis(); // Track when we sent this reply } return reply; } @@ -396,19 +397,21 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli() return mp; } -void PositionModule::sendOurPosition() +bool PositionModule::sendOurPosition() { bool requestReplies = currentGeneration != radioGeneration; - currentGeneration = radioGeneration; // If we changed channels, ask everyone else for their latest info uint8_t positionChannel; if (findPositionChannel(positionChannel)) { LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); - sendOurPosition(NODENUM_BROADCAST, requestReplies, positionChannel); - return; + if (!sendOurPosition(NODENUM_BROADCAST, requestReplies, positionChannel)) + return false; + currentGeneration = radioGeneration; // only a send that went out consumes the channel change + return true; } LOG_INFO("Skip pos@%x:6 broadcast; position sharing disabled on all channels", localPosition.timestamp); + return false; } // Position broadcasts are opt-in per channel in 2.8, but our own position still plays to the @@ -431,11 +434,11 @@ bool PositionModule::sendOurPositionToPhone() return true; } -void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t channel) +bool PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t channel) { if (!config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot()) { LOG_DEBUG("Skip position send; no fresh position since boot"); - return; + return false; } // cancel any not yet sent (now stale) position packets @@ -448,7 +451,7 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha meshtastic_MeshPacket *p = allocPositionPacket(precision); if (p == nullptr) { LOG_DEBUG("allocPositionPacket returned a nullptr"); - return; + return false; } p->to = dest; @@ -463,7 +466,12 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha if (channel > 0) p->channel = channel; - service->sendToMesh(p, RX_SRC_LOCAL, true); + // Rejected (full TX queue, duty cycle abort) means released unsent: never stamp the cadence. + ErrorCode res = service->sendToMesh(p, RX_SRC_LOCAL, true); + if (res != ERRNO_OK && res != ERRNO_SHOULD_RELEASE) { + LOG_WARN("Position send rejected by router: 0x%x", res); + return false; + } if (IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_TAK_TRACKER) && @@ -481,6 +489,8 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha LOG_DEBUG("Start next execution in 5s, then sleep"); setIntervalFromNow(FIVE_SECONDS_MS); } + + return true; } #define RUNONCE_INTERVAL 5000; @@ -540,7 +550,7 @@ int32_t PositionModule::runOnce() if (node == nullptr) return RUNONCE_INTERVAL; - uint32_t now = millis(); + uint32_t now = Time::getMillis(); // Local-only delivery, so it runs regardless of mesh opt-in state or channel utilization. // Only send while the queue is empty (phone assumed connected), like telemetry. The cadence @@ -563,8 +573,6 @@ int32_t PositionModule::runOnce() return RUNONCE_INTERVAL; } - bool waitingForFreshPosition = (lastGpsSend == 0) && !config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot(); - // Hold to the 6h floor when fixed_position (every role: pinning yourself forfeits the // exception) or when stationary. A real move still goes out early via smart-broadcast below. // Not-fixed exceptions: lost-and-found broadcasts freely; trackers judge movement at their @@ -582,9 +590,7 @@ int32_t PositionModule::runOnce() effectiveBroadcastIntervalMs(intervalMs, stationary, (uint32_t)default_position_stationary_broadcast_secs * 1000UL); if (lastGpsSend == 0 || msSinceLastSend >= effectiveIntervalMs) { - if (waitingForFreshPosition) { - LOG_DEBUG_GPS("Skip initial position send; no fresh position since boot"); - } else if (nodeDB->hasValidPosition(node)) { + if (nodeDB->hasValidPosition(node) && sendOurPosition()) { lastGpsSend = now; meshtastic_PositionLite selfPos; @@ -595,7 +601,6 @@ int32_t PositionModule::runOnce() if (transmitHistory) transmitHistory->setLastSentToMesh(meshtastic_PortNum_POSITION_APP); - sendOurPosition(); if (config.device.role == meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND) { sendLostAndFoundText(); } @@ -604,28 +609,10 @@ int32_t PositionModule::runOnce() const meshtastic_NodeInfoLite *node2 = service->refreshLocalMeshNode(); // should guarantee there is now a position if (nodeDB->hasValidPosition(node2)) { - // The minimum time (in seconds) that would pass before we are able to send a new position packet. - meshtastic_PositionLite selfPos; if (!nodeDB->copyNodePosition(node->num, selfPos)) return RUNONCE_INTERVAL; // Defensive: hasValidPosition should imply this is non-null - auto smartPosition = getDistanceTraveledSinceLastSend(selfPos); - msSinceLastSend = now - lastGpsSend; - - if (smartPosition.hasTraveledOverThreshold && - Throttle::execute( - &lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); }, - []() { LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); })) { - - LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " - "minTimeInterval=%ims)", - localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, - msSinceLastSend, minimumTimeThreshold); - - // Set the current coords as our last ones, after we've compared distance with current and decided to send - lastGpsLatitude = selfPos.latitude_i; - lastGpsLongitude = selfPos.longitude_i; - } + trySmartBroadcast(selfPos, now); } } @@ -707,6 +694,32 @@ struct SmartPosition PositionModule::getDistanceTraveledSinceLastSend(meshtastic .hasTraveledOverThreshold = distanceTraveled >= distanceTravelThreshold}; } +void PositionModule::trySmartBroadcast(const meshtastic_PositionLite &selfPos, uint32_t nowMs) +{ + auto smartPosition = getDistanceTraveledSinceLastSend(selfPos); + if (!smartPosition.hasTraveledOverThreshold) + return; + + if (!Throttle::hasElapsed(lastGpsSend, minimumTimeThreshold)) { + LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); + return; + } + + uint32_t msSinceLastSend = nowMs - lastGpsSend; + if (!sendOurPosition()) + return; + + lastGpsSend = nowMs; + if (transmitHistory) + transmitHistory->setLastSentToMesh(meshtastic_PortNum_POSITION_APP); + LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " + "minTimeInterval=%ims)", + localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, msSinceLastSend, + minimumTimeThreshold); + lastGpsLatitude = selfPos.latitude_i; + lastGpsLongitude = selfPos.longitude_i; +} + void PositionModule::handleNewPosition() { const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum()); @@ -716,21 +729,7 @@ void PositionModule::handleNewPosition() meshtastic_PositionLite selfPos; if (!nodeDB->copyNodePosition(node->num, selfPos)) return; - auto smartPosition = getDistanceTraveledSinceLastSend(selfPos); - uint32_t msSinceLastSend = millis() - lastGpsSend; - if (smartPosition.hasTraveledOverThreshold && - Throttle::execute( - &lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); }, - []() { LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); })) { - LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " - "minTimeInterval=%ims)", - localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, msSinceLastSend, - minimumTimeThreshold); - - // Set the current coords as our last ones, after we've compared distance with current and decided to send - lastGpsLatitude = selfPos.latitude_i; - lastGpsLongitude = selfPos.longitude_i; - } + trySmartBroadcast(selfPos, Time::getMillis()); } } diff --git a/src/modules/PositionModule.h b/src/modules/PositionModule.h index c5a3d47add..74d486f8df 100644 --- a/src/modules/PositionModule.h +++ b/src/modules/PositionModule.h @@ -31,10 +31,10 @@ class PositionModule : public ProtobufModule, private concu PositionModule(); /** - * Send our position into the mesh + * Send our position into the mesh. True only when the router took the packet. */ - void sendOurPosition(NodeNum dest, bool wantReplies = false, uint8_t channel = 0); - void sendOurPosition(); + bool sendOurPosition(NodeNum dest, bool wantReplies = false, uint8_t channel = 0); + bool sendOurPosition(); /** * Answer a position request that arrived on a channel we never share position on (the event channel): @@ -85,6 +85,9 @@ class PositionModule : public ProtobufModule, private concu uint32_t lastPhoneSendMs = 0; static constexpr uint32_t sendToPhoneIntervalMs = 60 * 1000; // Matches telemetry's local cadence struct SmartPosition getDistanceTraveledSinceLastSend(meshtastic_PositionLite currentPosition); + // Broadcast early when we have moved far enough since the last send, subject to the minimum + // interval. Stamps the cadence and the last-sent coords only on a send that went out. + void trySmartBroadcast(const meshtastic_PositionLite &selfPos, uint32_t nowMs); // True when our position is unchanged since the last broadcast: it truncates to the same // precision grid cell, so re-sending would be a duplicate that traffic management dedups // downstream anyway. Used to hold stationary broadcasts to a 12h floor. useConfiguredPrecision From bd19fa8e4831d6717eccc198aef071b940995554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 6 Sep 2026 15:12:58 +0000 Subject: [PATCH 080/143] feat(t-connect-pro): add LilyGo T-Connect-Pro variant (#11746) * feat(t-connect-pro): add LilyGo T-Connect-Pro variant ESP32-S3R8, 16MB flash, 8MB octal PSRAM. SX1262 LoRa, 480x222 ST7796 LCD with CST226SE touch, W5500 ethernet and a 10A relay on EXT_NOTIFY_OUT. LoRa, display and ethernet share one SPI bus (SCK 12 / MISO 13 / MOSI 11), so every peripheral stays on SPI2_HOST. board_level is extra and HW_VENDOR falls through to PRIVATE_HW until a HardwareModel enum value is allocated. * fix(w5500): serialize shared-bus SPI access with spiLock Arduino's ETHClass reaches SPI through SPIClass, whose mutex is invisible to LovyanGFX. On a board where both share a bus the MAC reads glitched frame headers, and the resulting ESP_LOGE flood blocks the W5500 RX task on the console UART until the task watchdog reboots the device. SharedBusEthernet installs esp_eth directly so its custom_spi_driver callbacks can take spiLock, the mutex the radio, display, SD and sensors already share. It derives from NetworkInterface, so localIP(), connected(), config() and the GOT_IP events are unchanged. Selected by ETH_SHARED_SPI; boards without it keep the stock ETHClass path. Measured on T-Connect-Pro under a 150 x 1472 byte flood with the display active: 34948 truncated frames, 3 reboots and 20% packet loss before, none after. * fix(cst226se): honour reset pin, screen rotation and skip wrong-model probes Drive TOUCH_RST when the variant defines one, and stop passing I2C pins to begin() so SensorLib does not re-init a bus the scan already owns. Derive touch geometry from SCREEN_ROTATE the way TFTDisplay does, so a rotated panel maps to the landscape UI rather than the raw panel size. Use TouchDrvCST226 rather than the TouchDrvCSTXXX wrapper. Pinning the model does not stop the wrapper walking CST816 and CST92xx, whose retries cost about 3.5s of boot. T-Beam behaviour is unchanged. * style: trim comments to the two-line limit Follows the comment rule in .github/copilot-instructions.md, which the original commits missed. * feat(t-connect-pro): use the T_CONNECT_PRO hardware model Depends on meshtastic/protobufs#1062. Does not build until that merges and the generated headers are synced, since meshtastic_HardwareModel_T_CONNECT_PRO does not exist yet. Drops -D PRIVATE_HW, which becomes a no-op once HW_VENDOR resolves, and promotes board_level to release. * chore(t-connect-pro): mark as community supported Support level 3, matching the other unlicensed LilyGo boards. * fix(w5500): roll back partial init when begin() fails begin() returns early when ethHandle is set, so a failure after esp_eth_driver_install() left the handle populated and every later call returned true with no working driver. teardown() releases the event handler, netif glue, netif, driver, PHY and MAC in reverse creation order, and every failure path now uses it. * refactor(w5500): drop config the custom SPI driver never reads spi_devcfg and spi_host_id are only read by w5500_spi_init, which esp_eth skips when custom_spi_driver is set, so the device config fields were dead. Also drops the handle() accessor, its only caller is the class's own event handler, the eventRegistered flag, since unregistering an unregistered handler is safe, the redundant _esp_netif guard around destroyNetif(), and the TFT_CS indirection, which this panel path does not read. * fix(w5500): fail begin() when the event handler cannot register The return value was ignored, so a failed registration still started Ethernet and returned true while onEthEvent never fired. WiFiAPClient would then miss ETH_CONNECTED, GOT_IP and DISCONNECTED, leaving the link up with the firmware believing it was down. --- boards/t-connect-pro.json | 40 ++++ src/mesh/udp/UdpMulticastHandler.h | 3 + src/mesh/wifi/WiFiAPClient.cpp | 11 + src/mqtt/MQTT.cpp | 3 + src/platform/esp32/SharedBusEthernet.cpp | 222 ++++++++++++++++++ src/platform/esp32/SharedBusEthernet.h | 38 +++ src/platform/esp32/architecture.h | 2 + .../tbeam_displayshield/variant.cpp | 32 ++- variants/esp32s3/t-connect-pro/platformio.ini | 37 +++ variants/esp32s3/t-connect-pro/variant.h | 75 ++++++ 10 files changed, 457 insertions(+), 6 deletions(-) create mode 100644 boards/t-connect-pro.json create mode 100644 src/platform/esp32/SharedBusEthernet.cpp create mode 100644 src/platform/esp32/SharedBusEthernet.h create mode 100644 variants/esp32s3/t-connect-pro/platformio.ini create mode 100644 variants/esp32s3/t-connect-pro/variant.h diff --git a/boards/t-connect-pro.json b/boards/t-connect-pro.json new file mode 100644 index 0000000000..a10c9183ad --- /dev/null +++ b/boards/t-connect-pro.json @@ -0,0 +1,40 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-D BOARD_HAS_PSRAM", + "-D ARDUINO_USB_CDC_ON_BOOT=1", + "-D ARDUINO_USB_MODE=1", + "-D ARDUINO_RUNNING_CORE=1", + "-D ARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth", "ethernet", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "LilyGo T-Connect-Pro (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://lilygo.cc/products/t-connect-pro", + "vendor": "LilyGo" +} diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 4a05d1292c..0e35aedef2 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -14,6 +14,9 @@ #if HAS_ETHERNET && defined(ARCH_ESP32) #include +#if HAS_ETHERNET && defined(ETH_SHARED_SPI) +#include "platform/esp32/SharedBusEthernet.h" +#endif #endif // HAS_ETHERNET #define UDP_MULTICAST_DEFAUL_PORT 4403 // Default port for UDP multicast is same as TCP api server diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 8bb80cd96a..35cb5653eb 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -12,8 +12,13 @@ #if HAS_ETHERNET && defined(ARCH_ESP32) #include +#include #endif // HAS_ETHERNET +#if HAS_ETHERNET && defined(ETH_SHARED_SPI) +#include "platform/esp32/SharedBusEthernet.h" +#endif + #if HAS_ETHERNET && defined(USE_CH390D) #include "ESP32_CH390.h" #include "hal/spi_types.h" @@ -123,8 +128,14 @@ bool initEthernet() // Register before begin(): static config can fire ETH_GOT_IP immediately WiFi.onEvent(WiFiEvent); +#ifdef ETH_SHARED_SPI + // SharedBusEthernet takes spiLock around every W5500 transfer; Arduino's ETH cannot. + if (!ETH.begin()) + return false; +#else if (!ETH.begin(ETH_PHY_W5500, 1, ETH_CS_PIN, ETH_INT_PIN, ETH_RST_PIN, SPI3_HOST, ETH_SCLK_PIN, ETH_MISO_PIN, ETH_MOSI_PIN)) return false; +#endif applyEthStaticIp(); #if !MESHTASTIC_EXCLUDE_WEBSERVER diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 6bd2f3688f..1c6cd57a0c 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -22,6 +22,9 @@ #endif #if HAS_ETHERNET && defined(ARCH_ESP32) #include +#if HAS_ETHERNET && defined(ETH_SHARED_SPI) +#include "platform/esp32/SharedBusEthernet.h" +#endif #endif // HAS_ETHERNET #if HAS_ETHERNET && defined(USE_CH390D) #include "ESP32_CH390.h" diff --git a/src/platform/esp32/SharedBusEthernet.cpp b/src/platform/esp32/SharedBusEthernet.cpp new file mode 100644 index 0000000000..2b30361ac5 --- /dev/null +++ b/src/platform/esp32/SharedBusEthernet.cpp @@ -0,0 +1,222 @@ +#include "SharedBusEthernet.h" + +#if defined(ARCH_ESP32) && defined(USE_WS5500) && defined(ETH_SHARED_SPI) + +#include "SPILock.h" +#include "concurrency/LockGuard.h" +#include +#include + +#ifndef ETH_SHARED_SPI_MHZ +#define ETH_SHARED_SPI_MHZ 20 +#endif + +SharedBusEthernet sharedBusEthernet; + +// Called from esp_eth's RX task. Lock order is spiLock -> SPI transaction, matching +// LockingArduinoHal, so the radio path cannot deadlock against this. +static void *ethSpiInit(const void *ctx) +{ + return (void *)ctx; +} + +static esp_err_t ethSpiDeinit(void *) +{ + return ESP_OK; +} + +static void ethSpiSelect(uint32_t cmd, uint32_t addr) +{ + ETH_SHARED_SPI.beginTransaction(SPISettings(ETH_SHARED_SPI_MHZ * 1000000, MSBFIRST, SPI_MODE0)); + digitalWrite(ETH_CS_PIN, LOW); + ETH_SHARED_SPI.write16(cmd); + ETH_SHARED_SPI.write(addr); +} + +static void ethSpiRelease() +{ + digitalWrite(ETH_CS_PIN, HIGH); + ETH_SHARED_SPI.endTransaction(); +} + +static esp_err_t ethSpiRead(void *, uint32_t cmd, uint32_t addr, void *data, uint32_t len) +{ + concurrency::LockGuard g(spiLock); + ethSpiSelect(cmd, addr); + ETH_SHARED_SPI.transferBytes(nullptr, (uint8_t *)data, len); + ethSpiRelease(); + return ESP_OK; +} + +static esp_err_t ethSpiWrite(void *, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) +{ + concurrency::LockGuard g(spiLock); + ethSpiSelect(cmd, addr); + ETH_SHARED_SPI.writeBytes((const uint8_t *)data, len); + ethSpiRelease(); + return ESP_OK; +} + +void SharedBusEthernet::onEthEvent(void *arg, esp_event_base_t, int32_t id, void *) +{ + SharedBusEthernet *self = (SharedBusEthernet *)arg; + arduino_event_t event; + event.event_id = ARDUINO_EVENT_MAX; + + switch (id) { + case ETHERNET_EVENT_CONNECTED: + event.event_id = ARDUINO_EVENT_ETH_CONNECTED; + event.event_info.eth_connected = self->ethHandle; + self->setStatusBits(ESP_NETIF_CONNECTED_BIT); + break; + case ETHERNET_EVENT_DISCONNECTED: + event.event_id = ARDUINO_EVENT_ETH_DISCONNECTED; + self->clearStatusBits(ESP_NETIF_CONNECTED_BIT | ESP_NETIF_HAS_IP_BIT | ESP_NETIF_HAS_LOCAL_IP6_BIT | + ESP_NETIF_HAS_GLOBAL_IP6_BIT); + break; + case ETHERNET_EVENT_START: + event.event_id = ARDUINO_EVENT_ETH_START; + self->setStatusBits(ESP_NETIF_STARTED_BIT); + break; + case ETHERNET_EVENT_STOP: + event.event_id = ARDUINO_EVENT_ETH_STOP; + self->clearStatusBits(ESP_NETIF_STARTED_BIT | ESP_NETIF_CONNECTED_BIT | ESP_NETIF_HAS_IP_BIT | + ESP_NETIF_HAS_LOCAL_IP6_BIT | ESP_NETIF_HAS_GLOBAL_IP6_BIT | ESP_NETIF_HAS_STATIC_IP_BIT); + break; + default: + return; + } + + Network.postEvent(&event); +} + +// Reverse of begin()'s creation order, so a failed begin() leaves no handle set and can be retried. +void SharedBusEthernet::teardown() +{ + esp_event_handler_unregister(ETH_EVENT, ESP_EVENT_ANY_ID, onEthEvent); + if (glueHandle) { + esp_eth_del_netif_glue(glueHandle); + glueHandle = nullptr; + } + destroyNetif(); + if (ethHandle) { + esp_eth_driver_uninstall(ethHandle); + ethHandle = nullptr; + } + if (ethPhy) { + ethPhy->del(ethPhy); + ethPhy = nullptr; + } + if (ethMac) { + ethMac->del(ethMac); + ethMac = nullptr; + } +} + +bool SharedBusEthernet::begin() +{ + if (ethHandle) + return true; + + Network.begin(); + + pinMode(ETH_CS_PIN, OUTPUT); + digitalWrite(ETH_CS_PIN, HIGH); + + spi_device_interface_config_t devcfg = {}; + + // spi_host_id and devcfg go unused once custom_spi_driver is set, but the config macro wants them. + eth_w5500_config_t w5500Config = ETH_W5500_DEFAULT_CONFIG(SPI2_HOST, &devcfg); + w5500Config.int_gpio_num = ETH_INT_PIN; + w5500Config.custom_spi_driver.config = this; + w5500Config.custom_spi_driver.init = ethSpiInit; + w5500Config.custom_spi_driver.deinit = ethSpiDeinit; + w5500Config.custom_spi_driver.read = ethSpiRead; + w5500Config.custom_spi_driver.write = ethSpiWrite; + + eth_mac_config_t macConfig = ETH_MAC_DEFAULT_CONFIG(); + eth_phy_config_t phyConfig = ETH_PHY_DEFAULT_CONFIG(); + phyConfig.phy_addr = 1; + phyConfig.reset_gpio_num = ETH_RST_PIN; + + ethMac = esp_eth_mac_new_w5500(&w5500Config, &macConfig); + ethPhy = esp_eth_phy_new_w5500(&phyConfig); + if (!ethMac || !ethPhy) { + LOG_ERROR("W5500 MAC/PHY alloc failed"); + teardown(); + return false; + } + + esp_eth_config_t ethConfig = ETH_DEFAULT_CONFIG(ethMac, ethPhy); + if (esp_eth_driver_install(ðConfig, ðHandle) != ESP_OK || !ethHandle) { + LOG_ERROR("W5500 driver install failed"); + ethHandle = nullptr; + teardown(); + return false; + } + + uint8_t macAddress[6]; + if (esp_read_mac(macAddress, ESP_MAC_ETH) == ESP_OK) + esp_eth_ioctl(ethHandle, ETH_CMD_S_MAC_ADDR, macAddress); + + esp_netif_inherent_config_t netifBase = ESP_NETIF_INHERENT_DEFAULT_ETH(); + esp_netif_config_t netifConfig = ESP_NETIF_DEFAULT_ETH(); + netifConfig.base = &netifBase; + _esp_netif = esp_netif_new(&netifConfig); + if (!_esp_netif) { + LOG_ERROR("W5500 netif alloc failed"); + teardown(); + return false; + } + if (!initNetif(ESP_NETIF_ID_ETH)) { + LOG_ERROR("W5500 netif init failed"); + teardown(); + return false; + } + + glueHandle = esp_eth_new_netif_glue(ethHandle); + if (!glueHandle || esp_netif_attach(_esp_netif, glueHandle) != ESP_OK) { + LOG_ERROR("W5500 netif attach failed"); + teardown(); + return false; + } + + // Registered before start so the START event is not missed. + if (esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, onEthEvent, this) != ESP_OK) { + LOG_ERROR("W5500 event handler register failed"); + teardown(); + return false; + } + + if (esp_eth_start(ethHandle) != ESP_OK) { + LOG_ERROR("W5500 start failed"); + teardown(); + return false; + } + + LOG_INFO("W5500 on shared SPI bus, %u MHz", (unsigned)ETH_SHARED_SPI_MHZ); + return true; +} + +uint16_t SharedBusEthernet::linkSpeed() const +{ + eth_speed_t speed = ETH_SPEED_100M; + if (ethHandle) + esp_eth_ioctl(ethHandle, ETH_CMD_G_SPEED, &speed); + return speed == ETH_SPEED_10M ? 10 : 100; +} + +bool SharedBusEthernet::fullDuplex() const +{ + eth_duplex_t duplex = ETH_DUPLEX_FULL; + if (ethHandle) + esp_eth_ioctl(ethHandle, ETH_CMD_G_DUPLEX_MODE, &duplex); + return duplex == ETH_DUPLEX_FULL; +} + +size_t SharedBusEthernet::printDriverInfo(Print &out) const +{ + return out.print(",W5500"); +} + +#endif diff --git a/src/platform/esp32/SharedBusEthernet.h b/src/platform/esp32/SharedBusEthernet.h new file mode 100644 index 0000000000..7c9f1823ba --- /dev/null +++ b/src/platform/esp32/SharedBusEthernet.h @@ -0,0 +1,38 @@ +#pragma once + +#include "configuration.h" + +#if defined(ARCH_ESP32) && defined(USE_WS5500) && defined(ETH_SHARED_SPI) + +#include +#include + +// W5500 driver for boards whose MAC shares its SPI bus. Installs esp_eth directly so the SPI +// callbacks can take spiLock, which Arduino's ETHClass cannot. +class SharedBusEthernet : public NetworkInterface +{ + public: + bool begin(); + uint16_t linkSpeed() const; + bool fullDuplex() const; + + protected: + size_t printDriverInfo(Print &out) const override; + + private: + static void onEthEvent(void *arg, esp_event_base_t base, int32_t id, void *data); + void teardown(); + + esp_eth_handle_t ethHandle = nullptr; + esp_eth_netif_glue_handle_t glueHandle = nullptr; + esp_eth_mac_t *ethMac = nullptr; + esp_eth_phy_t *ethPhy = nullptr; +}; + +extern SharedBusEthernet sharedBusEthernet; + +// Route the firmware's ETH.* calls at this driver instead of Arduino's global, matching how +// USE_CH390D swaps in its own class. +#define ETH sharedBusEthernet + +#endif diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 7f35db49b4..648c8dfe99 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -227,6 +227,8 @@ #define HW_VENDOR meshtastic_HardwareModel_HELTEC_RCC6 #elif defined(SEEED_WIO_TRACKER_L2) #define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L2 +#elif defined(T_CONNECT_PRO) +#define HW_VENDOR meshtastic_HardwareModel_T_CONNECT_PRO #else #define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW #endif diff --git a/src/platform/extra_variants/tbeam_displayshield/variant.cpp b/src/platform/extra_variants/tbeam_displayshield/variant.cpp index 7beac22934..a6bb2f86ab 100644 --- a/src/platform/extra_variants/tbeam_displayshield/variant.cpp +++ b/src/platform/extra_variants/tbeam_displayshield/variant.cpp @@ -6,7 +6,26 @@ #include "input/TouchScreenImpl1.h" #include -TouchDrvCSTXXX tsPanel; +#ifndef TOUCH_RST +#define TOUCH_RST -1 +#endif +#ifndef SCREEN_TOUCH_INT +#define SCREEN_TOUCH_INT -1 +#endif + +// The panel reports raw coordinates in its portrait frame. Rotated boards run the UI landscape, +// so mirror the swap TFTDisplay does for them (setGeometry(TFT_HEIGHT, TFT_WIDTH)). +#ifdef SCREEN_ROTATE +static constexpr int16_t screenWidth = TFT_HEIGHT; +static constexpr int16_t screenHeight = TFT_WIDTH; +#else +static constexpr int16_t screenWidth = TFT_WIDTH; +static constexpr int16_t screenHeight = TFT_HEIGHT; +#endif + +// Concrete CST226 driver, not the TouchDrvCSTXXX wrapper: the wrapper walks CST816 and CST92xx +// too, and its two 1s retries cost ~2.4s of boot probing chips this panel never is. +TouchDrvCST226 tsPanel; static constexpr uint8_t PossibleAddresses[2] = {CST328_ADDR, CST226SE_ADDR_ALT}; uint8_t i2cAddress = 0; @@ -16,9 +35,9 @@ bool readTouch(int16_t *x, int16_t *y) uint8_t touched = tsPanel.getPoint(x_array, y_array, 1); if (touched > 0) { *y = x_array[0]; - *x = (TFT_WIDTH - y_array[0]); + *x = (screenWidth - y_array[0]); // Check bounds - if (*x < 0 || *x >= TFT_WIDTH || *y < 0 || *y >= TFT_HEIGHT) { + if (*x < 0 || *x >= screenWidth || *y < 0 || *y >= screenHeight) { return false; } return true; // Valid touch detected @@ -28,12 +47,13 @@ bool readTouch(int16_t *x, int16_t *y) void lateInitVariant() { - tsPanel.setTouchDrvModel(TouchDrv_CST226); + tsPanel.setPins(TOUCH_RST, SCREEN_TOUCH_INT); for (uint8_t addr : PossibleAddresses) { - if (tsPanel.begin(Wire, addr, I2C_SDA, I2C_SCL)) { + // -1 pins: Wire is already begun by the I2C scan, re-initializing it only logs warnings + if (tsPanel.begin(Wire, addr, -1, -1)) { i2cAddress = addr; LOG_DEBUG("CST226SE init OK at address 0x%02X", addr); - touchScreenImpl1 = new TouchScreenImpl1(TFT_WIDTH, TFT_HEIGHT, readTouch); + touchScreenImpl1 = new TouchScreenImpl1(screenWidth, screenHeight, readTouch); touchScreenImpl1->init(); return; } diff --git a/variants/esp32s3/t-connect-pro/platformio.ini b/variants/esp32s3/t-connect-pro/platformio.ini new file mode 100644 index 0000000000..3a8692c235 --- /dev/null +++ b/variants/esp32s3/t-connect-pro/platformio.ini @@ -0,0 +1,37 @@ +; LilyGo T-Connect-Pro +[env:t-connect-pro] +custom_meshtastic_hw_model = 147 +custom_meshtastic_hw_model_slug = T_CONNECT_PRO +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = LILYGO T-Connect Pro +custom_meshtastic_tags = LilyGo +custom_meshtastic_partition_scheme = 16MB + +extends = esp32s3_base +board_level = release +board = t-connect-pro +board_build.partitions = default_16MB.csv +upload_protocol = esptool + +build_flags = + ${esp32s3_base.build_flags} + -I variants/esp32s3/t-connect-pro + -D T_CONNECT_PRO + -D HAS_UDP_MULTICAST=1 + -D RADIOLIB_EXCLUDE_SX127X=1 + -D RADIOLIB_EXCLUDE_SX128X=1 + -D RADIOLIB_EXCLUDE_LR11X0=1 + +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.28 + # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.3.4 + +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + CONFIG_ETH_ENABLED=y + CONFIG_ARDUINO_SELECTIVE_Ethernet=y diff --git a/variants/esp32s3/t-connect-pro/variant.h b/variants/esp32s3/t-connect-pro/variant.h new file mode 100644 index 0000000000..73fb9a43ee --- /dev/null +++ b/variants/esp32s3/t-connect-pro/variant.h @@ -0,0 +1,75 @@ +// LilyGo T-Connect-Pro (ESP32-S3R8). LoRa, the ST7796 LCD and the W5500 share one SPI bus +// (SCK 12 / MISO 13 / MOSI 11), so every peripheral stays on SPI2_HOST. + +#define I2C_SDA 39 +#define I2C_SCL 40 + +#define BUTTON_PIN 0 // BOOT +#define BUTTON_NEED_PULLUP + +#define EXT_NOTIFY_OUT 8 // 10A relay + +#define GPS_DEFAULT_NOT_PRESENT 1 + +// ST7796 LCD, 2.33" 480x222 - the same panel as the T-Lora Pager +#define HAS_SPI_TFT 1 +#define ST7796_CS 21 +#define ST7796_RS 41 // DC +#define ST7796_SDA 11 // MOSI +#define ST7796_SCK 12 +#define ST7796_MISO 13 +#define ST7796_RESET -1 +#define ST7796_BUSY -1 +#define ST7796_BL 46 +#define ST7796_SPI_HOST SPI2_HOST +#define TFT_BL 46 +#define SPI_FREQUENCY 75000000 +#define SPI_READ_FREQUENCY 16000000 +#define TFT_WIDTH 222 +#define TFT_HEIGHT 480 +#define TFT_OFFSET_X 49 +#define TFT_OFFSET_Y 0 +// Landscape comes from TFTDisplay's default setRotation(3), which aligns the UI with the +// silkscreen - 180 degrees from LilyGo's test firmware. Deliberate, don't "fix" it. +#define TFT_OFFSET_ROTATION 0 +#define SCREEN_ROTATE +#define SCREEN_TRANSITION_FRAMERATE 30 +#define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness +#define USE_TFTDISPLAY 1 + +// CST226SE touch - driver in src/platform/extra_variants/tbeam_displayshield/variant.cpp +#define HAS_CST226SE 1 +#define HAS_TOUCHSCREEN 1 +#define VARIANT_TOUCHSCREEN 1 +#define USE_VIRTUAL_KEYBOARD 1 +#define TOUCH_RST 47 +#define SCREEN_TOUCH_INT 3 +#define ENABLE_TOUCH_INT + +// LoRa - HPD16A (SX1262) +#define USE_SX1262 + +#define LORA_SCK 12 +#define LORA_MISO 13 +#define LORA_MOSI 11 +#define LORA_CS 14 +#define LORA_RESET 42 +#define LORA_DIO1 45 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY 38 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// W5500 ethernet, sharing the LoRa SPI bus +#define HAS_ETHERNET 1 +#define USE_WS5500 1 +#define ETH_SHARED_SPI SPI + +#define ETH_CS_PIN 10 +#define ETH_INT_PIN 9 +#define ETH_RST_PIN 48 + +// Isolated connectors, for the Serial module: RS232 TX 4 / RX 5, RS485 TX 17 / RX 18, CAN TX 6 / RX 7. From 3520e4fa8c50f97f3397489cb518fa092f1306d3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:31:10 +0000 Subject: [PATCH 081/143] chore(deps): update pschatzmann_arduino-audio-driver to v0.3.1 (#11738) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32s3/m5stack_cardputer_adv/platformio.ini | 2 +- variants/esp32s3/seeed_wio_tracker_L2/platformio.ini | 2 +- variants/esp32s3/tlora-pager/platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini index dcd5973022..ca3f625ccd 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini +++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini @@ -16,7 +16,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver - https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.1.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 diff --git a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini index d5529b5f19..be437c34a2 100644 --- a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini +++ b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini @@ -43,7 +43,7 @@ lib_deps = # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM earlephilhower/ESP8266SAM@1.1.0 # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver - https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.1.zip # renovate: datasource=github-tags depName=Adafruit_ADS1X15 packageName=adafruit/Adafruit_ADS1X15 https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip # renovate: datasource=github-tags depName=AW35615 packageName=meshtastic/AW35615 diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index 31ff1593ee..732ab40402 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -45,7 +45,7 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver - https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.1.zip # TODO renovate https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip # TODO renovate From 22022fe6491ee70f72d19ed544c18ba2e6054738 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:31:18 +0000 Subject: [PATCH 082/143] chore(deps): update meshtastic/device-ui digest to 69d7000 (#11737) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 54f83cd0a2..6d7242c9c5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -138,7 +138,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/9c97e4260cf31bd642485bf5153735828459ba4e.zip + https://github.com/meshtastic/device-ui/archive/69d7000134f28cd70f404608e52364239804afc7.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 5920d05f5bbcfc82939d6fd5bf7d6b27decee955 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:00:21 +0000 Subject: [PATCH 083/143] chore(deps): update actions/setup-python action to v7 (#11727) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/actions/setup-native-test/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-native-test/action.yml b/.github/actions/setup-native-test/action.yml index 0c52ef11dc..247832d722 100644 --- a/.github/actions/setup-native-test/action.yml +++ b/.github/actions/setup-native-test/action.yml @@ -8,7 +8,7 @@ runs: steps: # No checkout: the caller must already have one to reference this action at all. - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: 3.x cache: pip From a8912b1eb59bc0814680a9a847a5416dd3d204d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 7 Sep 2026 10:44:51 +0200 Subject: [PATCH 084/143] fix(ci): pin tool-scons to 4.8.1 for ESP targets (#11756) PlatformIO Core now pulls tool-scons ~4.41101.0 (SCons 4.11.1), which overrides the 4.8.1 the pioarduino espressif32 platform asks for. In 4.11.1 the lazy "import SCons.Tool.FortranCommon" that smart_link() uses to pick a linker raises ModuleNotFoundError, so every ESP environment fails at link-action resolution, before a single file is compiled: *** [.pio/build//firmware-.elf] ModuleNotFoundError : No module named 'SCons.Tool.FortranCommon' The package is not at fault; FortranCommon.py is present in tool-scons-4.41101.0 and imports cleanly outside SCons. The failure comes from the module state SCons's own tool loader leaves behind. Scoped to esp32_common, which all six ESP architectures extend. nRF52, STM32 and rp2040 are unaffected and keep the toolchain they have. --- variants/esp32/esp32-common.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 78977756e1..211abeb734 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -12,6 +12,10 @@ platform = platform_packages = # renovate: datasource=custom.pio depName=platformio/tool-mklittlefs packageName=platformio/tool/tool-mklittlefs platformio/tool-mklittlefs@1.203.210628 + ; Hold at 4.8.1: in 4.11.1 the lazy FortranCommon import in smart_link() kills every + ; ESP build before it compiles. Drop once PlatformIO ships a tool-scons that imports. + # renovate: datasource=custom.pio depName=platformio/tool-scons packageName=platformio/tool/tool-scons + platformio/tool-scons@4.40801.0 extra_scripts = ${env.extra_scripts} From b46aec31f474deee2db43d76ebd5de32f7f27161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 7 Sep 2026 10:45:17 +0200 Subject: [PATCH 085/143] fix(rp2xx0): ignore iLabs_Hearth so Pico targets build again (#11757) arduino-pico gained a bundled iLabs_Hearth library that supplies its own Preferences.h. NodeDB.cpp includes inside an ARCH_ESP32 guard, and the library dependency finder runs in chain mode, which matches include directives without evaluating the preprocessor. It therefore pulls the library into every Pico build, and Hearth.cpp refuses to compile on a board that does not define ESP_SERIAL_PORT: iLabs_Hearth/src/Hearth.cpp:121:2: error: #error "iLabs Hearth requires a board variant that defines ESP_SERIAL_PORT ..." The library only reaches the build because the platform resolves framework-arduinopico from an arduino-pico master commit; the platform_packages entry here pins the name arduino-pico, which is a different package and does not override it. Ignoring the library is enough, since no Pico target uses Matter. The sibling iLabs_ESP-NOW ships ESP32_NOW.h and ATLink.h, which nothing includes, so it needs no entry. --- variants/rp2040/rp2040.ini | 3 +++ variants/rp2350/rp2350.ini | 3 +++ 2 files changed, 6 insertions(+) diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index 7be150e922..aec2a13b34 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -25,6 +25,9 @@ build_src_filter = lib_ignore = BluetoothOTA lvgl + ; Ships its own Preferences.h, which the LDF matches against the ARCH_ESP32-only + ; include in NodeDB.cpp; compiling it then trips its own "not a Challenger" #error. + iLabs_Hearth lib_deps = ${arduino_base.lib_deps} diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index 0705ed8eca..d4965fb376 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -22,6 +22,9 @@ build_src_filter = lib_ignore = BluetoothOTA lvgl + ; Ships its own Preferences.h, which the LDF matches against the ARCH_ESP32-only + ; include in NodeDB.cpp; compiling it then trips its own "not a Challenger" #error. + iLabs_Hearth lib_deps = ${arduino_base.lib_deps} From f631428309ffe701a90450dbcac38adcb3054c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 7 Sep 2026 12:24:12 +0200 Subject: [PATCH 086/143] fix(ci): build with pioarduino core instead of upstream platformio (#11759) * fix(ci): build with pioarduino core instead of upstream platformio The espressif32 platform is the pioarduino fork, but setup-base installed upstream platformio and then ran pio upgrade, so every firmware build ran a core the platform is not built against. Upstream 6.2.0, released 2026-09-05, moved its tool-scons core dependency to ~4.41101.0 (SCons 4.11.1). The lazy "import SCons.Tool.FortranCommon" that smart_link() uses to choose a linker fails there, so every ESP target died at link-action resolution before compiling a file. Core resolves tool-scons as a core dependency, so a platform_packages pin cannot help: core installs its own version and removes the pinned one. pioarduino core pins SCons 4.8.1 by URL rather than by range, so upstream releases cannot reach it. Dropping pio upgrade as well, since it re-pulled the latest upstream core regardless of what pip installed. Only setup-base changes. The matrix-generation jobs install platformio to parse the ini files and never build firmware, and the native test suites pass on upstream core because that platform does not reach smart_link. * Revert "fix(ci): pin tool-scons to 4.8.1 for ESP targets (#11756)" This reverts commit a8912b1eb. The pin never took effect. PlatformIO Core resolves tool-scons as a core dependency, so it installs its own version and removes the pinned one: Installing platformio/tool-scons @ 4.40801.0 Installing platformio/tool-scons @ ~4.41101.0 Removing tool-scons @ 4.40801.0 Switching to pioarduino core in the preceding commit fixes this properly, and leaving a platform_packages entry that fights a core dependency would only be misleading. --- .github/actions/setup-base/action.yml | 9 +++------ variants/esp32/esp32-common.ini | 4 ---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml index 4c1a2ff792..405e843ded 100644 --- a/.github/actions/setup-base/action.yml +++ b/.github/actions/setup-base/action.yml @@ -29,11 +29,8 @@ runs: run: | python -m pip install --upgrade pip pip install -U --no-build-isolation --no-cache-dir "setuptools<72" - pip install -U platformio adafruit-nrfutil --no-build-isolation + # pioarduino, not upstream platformio: the espressif32 platform is the + # pioarduino fork, and its core pins a working SCons by URL. + pip install -U pioarduino adafruit-nrfutil --no-build-isolation pip install -U poetry --no-build-isolation pip install -U meshtastic --pre --no-build-isolation - - - name: Upgrade platformio - shell: bash - run: | - pio upgrade diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 211abeb734..78977756e1 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -12,10 +12,6 @@ platform = platform_packages = # renovate: datasource=custom.pio depName=platformio/tool-mklittlefs packageName=platformio/tool/tool-mklittlefs platformio/tool-mklittlefs@1.203.210628 - ; Hold at 4.8.1: in 4.11.1 the lazy FortranCommon import in smart_link() kills every - ; ESP build before it compiles. Drop once PlatformIO ships a tool-scons that imports. - # renovate: datasource=custom.pio depName=platformio/tool-scons packageName=platformio/tool/tool-scons - platformio/tool-scons@4.40801.0 extra_scripts = ${env.extra_scripts} From 7b8a02bb4ceb7a880d41482824f8bf24b3e1c777 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 7 Sep 2026 09:23:15 -0400 Subject: [PATCH 087/143] =?UTF-8?q?Revert=20"fix(ci):=20build=20with=20pio?= =?UTF-8?q?arduino=20core=20instead=20of=20upstream=20platformio=20(#?= =?UTF-8?q?=E2=80=A6"=20(#11760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f631428309ffe701a90450dbcac38adcb3054c9f. --- .github/actions/setup-base/action.yml | 9 ++++++--- variants/esp32/esp32-common.ini | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml index 405e843ded..4c1a2ff792 100644 --- a/.github/actions/setup-base/action.yml +++ b/.github/actions/setup-base/action.yml @@ -29,8 +29,11 @@ runs: run: | python -m pip install --upgrade pip pip install -U --no-build-isolation --no-cache-dir "setuptools<72" - # pioarduino, not upstream platformio: the espressif32 platform is the - # pioarduino fork, and its core pins a working SCons by URL. - pip install -U pioarduino adafruit-nrfutil --no-build-isolation + pip install -U platformio adafruit-nrfutil --no-build-isolation pip install -U poetry --no-build-isolation pip install -U meshtastic --pre --no-build-isolation + + - name: Upgrade platformio + shell: bash + run: | + pio upgrade diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 78977756e1..211abeb734 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -12,6 +12,10 @@ platform = platform_packages = # renovate: datasource=custom.pio depName=platformio/tool-mklittlefs packageName=platformio/tool/tool-mklittlefs platformio/tool-mklittlefs@1.203.210628 + ; Hold at 4.8.1: in 4.11.1 the lazy FortranCommon import in smart_link() kills every + ; ESP build before it compiles. Drop once PlatformIO ships a tool-scons that imports. + # renovate: datasource=custom.pio depName=platformio/tool-scons packageName=platformio/tool/tool-scons + platformio/tool-scons@4.40801.0 extra_scripts = ${env.extra_scripts} From f36d7f11ecc24686641b367ec02215ebe33272ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:51:44 +0200 Subject: [PATCH 088/143] Update protobufs (#11762) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/module_config.pb.h | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/protobufs b/protobufs index 3808a392e3..f008c459d7 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 3808a392e3317a7cbf1e6ded00bb3c570e3cfc7e +Subproject commit f008c459d78de46779408d5bdb7bc0634550bb49 diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 5d6585f036..e689ace5c5 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -48,8 +48,14 @@ typedef enum _meshtastic_ModuleConfig_AudioConfig_Audio_Baud { meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_1400 = 4, meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_1300 = 5, meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_1200 = 6, + /* Removed from libcodec2 upstream. A device configured to one of these + falls back to CODEC2_700C. */ meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700 = 7, - meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700B = 8 + meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700B = 8, + /* Replaces CODEC2_700. Default for new configurations. */ + meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700C = 9, + /* Lowest rate, and the only one usable on slower modem presets. */ + meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_450 = 10 } meshtastic_ModuleConfig_AudioConfig_Audio_Baud; /* TODO: REPLACE */ @@ -225,7 +231,7 @@ typedef struct _meshtastic_ModuleConfig_AudioConfig { bool codec2_enabled; /* PTT Pin */ uint8_t ptt_pin; - /* The audio sample rate to use for codec2 */ + /* The codec2 bitrate to encode at. Sample rate is always 8 kHz. */ meshtastic_ModuleConfig_AudioConfig_Audio_Baud bitrate; /* I2S Word Select */ uint8_t i2s_ws; @@ -588,8 +594,8 @@ extern "C" { #define _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_ARRAYSIZE ((meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType)(meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_EITHER_EDGE_ACTIVE_HIGH+1)) #define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MIN meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_DEFAULT -#define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MAX meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700B -#define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_ARRAYSIZE ((meshtastic_ModuleConfig_AudioConfig_Audio_Baud)(meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_700B+1)) +#define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MAX meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_450 +#define _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_ARRAYSIZE ((meshtastic_ModuleConfig_AudioConfig_Audio_Baud)(meshtastic_ModuleConfig_AudioConfig_Audio_Baud_CODEC2_450+1)) #define _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MIN meshtastic_ModuleConfig_SerialConfig_Serial_Baud_BAUD_DEFAULT #define _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MAX meshtastic_ModuleConfig_SerialConfig_Serial_Baud_BAUD_921600 From fd623f64fdd2ab3ed0ac00310f66c9a9d2f987fd Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 7 Sep 2026 10:02:04 -0400 Subject: [PATCH 089/143] Revert "fix(ci): pin tool-scons to 4.8.1 for ESP targets (#11756)" (#11763) This reverts commit a8912b1eb59bc0814680a9a847a5416dd3d204d4. --- variants/esp32/esp32-common.ini | 4 ---- 1 file changed, 4 deletions(-) diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 211abeb734..78977756e1 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -12,10 +12,6 @@ platform = platform_packages = # renovate: datasource=custom.pio depName=platformio/tool-mklittlefs packageName=platformio/tool/tool-mklittlefs platformio/tool-mklittlefs@1.203.210628 - ; Hold at 4.8.1: in 4.11.1 the lazy FortranCommon import in smart_link() kills every - ; ESP build before it compiles. Drop once PlatformIO ships a tool-scons that imports. - # renovate: datasource=custom.pio depName=platformio/tool-scons packageName=platformio/tool/tool-scons - platformio/tool-scons@4.40801.0 extra_scripts = ${env.extra_scripts} From cccefa09a4ddc0c590819519fde03f0a9e905d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 7 Sep 2026 16:02:30 +0200 Subject: [PATCH 090/143] fix(rp2xx0): match the real library name when ignoring iLabs_Hearth (#11761) lib_ignore matches the name from the library manifest, and iLabs_Hearth declares "iLabs Hearth" with a space. The directory name used in #11757 therefore never matched, and the Pico builds still compile the library and still fail on its ESP_SERIAL_PORT #error. --- variants/rp2040/rp2040.ini | 2 +- variants/rp2350/rp2350.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index aec2a13b34..f84b6ee09f 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -27,7 +27,7 @@ lib_ignore = lvgl ; Ships its own Preferences.h, which the LDF matches against the ARCH_ESP32-only ; include in NodeDB.cpp; compiling it then trips its own "not a Challenger" #error. - iLabs_Hearth + iLabs Hearth lib_deps = ${arduino_base.lib_deps} diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index d4965fb376..bde4fcce34 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -24,7 +24,7 @@ lib_ignore = lvgl ; Ships its own Preferences.h, which the LDF matches against the ARCH_ESP32-only ; include in NodeDB.cpp; compiling it then trips its own "not a Challenger" #error. - iLabs_Hearth + iLabs Hearth lib_deps = ${arduino_base.lib_deps} From 0becda3017e4a1fba6201ebbd7aeccf9201aa7c5 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 7 Sep 2026 21:39:53 -0400 Subject: [PATCH 091/143] Actions: Only publish nightly releases to R2 (#11719) Depends on: https://github.com/meshtastic/web-flasher/pull/427 Do not publish high-churn nightly releases to meshtastic.github.io. Now R2-only. Co-authored-by: Ben Meadors --- .github/workflows/main_matrix.yml | 70 ++++++++----------------------- 1 file changed, 17 insertions(+), 53 deletions(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 6ae725e20e..eec6f5ef82 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -35,8 +35,8 @@ on: #- "**.yml" schedule: - # Nightly develop build, published to meshtastic.github.io firmware-nightly/ and to the - # meshtastic-firmware-nightly R2 bucket (no GitHub release). + # Nightly develop build, published to the meshtastic-firmware-nightly R2 + # bucket (no GitHub release). # Scheduled runs execute on the default branch (develop). 07:00 UTC avoids the 00:00 tests # and 02:00 daily_packaging crons. - cron: 0 7 * * * # Nightly develop build/publish (default branch is develop) @@ -45,7 +45,7 @@ on: inputs: # trunk-ignore(checkov/CKV_GHA_7): intentional manual-test switch for the nightly publish path nightly: - description: "Nightly mode: build + publish develop to github.io firmware-nightly/ and R2 (skips creating a GitHub release)" + description: "Nightly mode: build + publish develop to the nightly R2 bucket (skips creating a GitHub release)" type: boolean default: false @@ -728,10 +728,9 @@ jobs: --metadata "commit=${{ github.sha }},run=${{ github.run_id }},version=${VERSION}" \ --cache-control 'public, max-age=86400, s-maxage=2592000' - # Nightly publish: refresh the single, stable firmware-nightly/ folder on - # meshtastic.github.io, and the root of the meshtastic-firmware-nightly R2 - # bucket, with the current develop build. Runs on the cron schedule (or a manual - # nightly=true dispatch) and never creates a GitHub release. The folder's + # Nightly publish: refresh the root of the meshtastic-firmware-nightly R2 + # bucket with the current develop build. Runs on the cron schedule (or a manual + # nightly=true dispatch) and never creates a GitHub release. The bucket's # release_notes.md is maintained by hand and deliberately left untouched. publish-nightly: runs-on: ubuntu-24.04 @@ -764,35 +763,15 @@ jobs: '{version: $ver, id: ("v" + $ver), title: ("Meshtastic Firmware " + $ver + " Nightly"), commit: $sha}' \ > ./stage/index.json - - name: Preserve manually-maintained release notes - # firmware-nightly/release_notes.md is edited by hand. Carry the current - # copy into ./stage so the keep_files:false publish (which refreshes the - # folder and clears stale nightly binaries) does not drop it. Seed a - # placeholder on the first run (404); fail closed on any other error so a - # transient fetch failure never clobbers the notes. - run: | - set -euo pipefail - url=https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-nightly/release_notes.md - code=$(curl -sSL -o ./stage/release_notes.md -w '%{http_code}' --retry 5 --retry-all-errors "$url" || echo 000) - if [ "$code" = "200" ]; then - echo "Preserved existing release_notes.md" - elif [ "$code" = "404" ]; then - echo "No existing release_notes.md; seeding placeholder" - printf '# Nightly (develop)\n\nAutomated nightly build from the `develop` branch. Edit these notes by hand.\n' > ./stage/release_notes.md - else - echo "Unexpected HTTP $code fetching release_notes.md; refusing to publish to avoid clobbering manual notes" - exit 1 - fi - # For diagnostics - name: Display structure of files to publish run: ls -lR ./stage - name: Verify the staged nightly is not empty - # Both publishes below refresh their destination in place (keep_files:false - # for github.io, --delete for the R2 sync), so an empty ./stage would clear - # the live nightly rather than replace it. A failed or pattern-mismatched - # artifact download is the way that happens, so fail closed here instead. + # The publish below refreshes the bucket in place (the R2 sync runs with + # --delete), so an empty ./stage would clear the live nightly rather than + # replace it. A failed or pattern-mismatched artifact download is the way + # that happens, so fail closed here instead. run: | set -euo pipefail images=$(find ./stage -type f \ @@ -803,28 +782,11 @@ jobs: exit 1 fi - - name: Publish nightly to meshtastic.github.io - uses: peaceiris/actions-gh-pages@v4 - with: - deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }} - external_repository: meshtastic/meshtastic.github.io - publish_branch: master - publish_dir: ./stage - # keep_files:false is scoped to destination_dir, so this refreshes only - # firmware-nightly/ (clearing stale nightly binaries) while sibling - # release folders stay untouched; release_notes.md is carried in above. - destination_dir: firmware-nightly - keep_files: false - user_name: github-actions[bot] - user_email: github-actions[bot]@users.noreply.github.com - commit_message: Nightly ${{ needs.version.outputs.long }} - enable_jekyll: true - - # Mirror the same staged directory to Cloudflare R2. --delete at the bucket - # root is the counterpart of keep_files:false above - it clears stale nightly - # binaries - and is in scope for the whole bucket because this bucket holds - # nothing but the nightly build. release_notes.md is carried into ./stage by - # the step above, so the sync preserves it here too. + # Mirror the staged directory to Cloudflare R2. --delete at the bucket root + # clears stale nightly binaries, and is in scope for the whole bucket because + # this bucket holds nothing but the nightly build. release_notes.md lives only + # in the bucket and is never staged, so it is excluded from the sync to keep + # --delete from removing it. - name: Publish nightly to Cloudflare R2 env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} @@ -841,10 +803,12 @@ jobs: set -euo pipefail aws --version # Cache for 1 hour in browser, 1 day on CDN. + # Preserve the release_notes.md (manually maintained) aws s3 sync ./stage "s3://${r2_bucket}/" \ --endpoint-url "$R2_ENDPOINT" \ --no-progress \ --delete \ + --exclude 'release_notes.md' \ --exclude 'index.json' \ --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ --cache-control 'public, max-age=3600, s-maxage=86400' From cfeb8a70ea8022268f84ab1e105ddbc1b871974a Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 8 Sep 2026 16:29:34 -0400 Subject: [PATCH 092/143] fix(checks): define PROGMEM to avoid cppcheck reporting unknownMacro (#11776) --- bin/check-all.sh | 3 ++- platformio.ini | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/check-all.sh b/bin/check-all.sh index 3186899aa5..0b22069efa 100755 --- a/bin/check-all.sh +++ b/bin/check-all.sh @@ -50,7 +50,8 @@ trap 'rm -f "$LOG"' EXIT # Keep streaming to the console so the CI log reads exactly as it did before; tee a copy for the # post-mortem classification below. -pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG" +# define PROGMEM to avoid cppcheck reporting unknownMacro (--skip-packages excludes Arduino.h) +pio check --flags "-DAPP_VERSION=${APP_VERSION} -DPROGMEM= --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG" STATUS=${PIPESTATUS[0]} if [[ $STATUS -eq 0 ]]; then diff --git a/platformio.ini b/platformio.ini index 6d7242c9c5..a9ffece8c4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -99,6 +99,8 @@ check_tool = cppcheck check_skip_packages = yes check_flags = -DAPP_VERSION=1.0.0 + ; define PROGMEM to avoid cppcheck reporting unknownMacro (--skip-packages excludes Arduino.h) + -DPROGMEM= --suppressions-list=suppressions.txt --inline-suppr From 1251d31df6221f76081f3b30032304040ef369f8 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 8 Sep 2026 18:25:04 -0400 Subject: [PATCH 093/143] fix(observer): suppress cppcheck warning for removeObserver method (#11779) Accepting cppcheck's suggestion results in a no-compile, we cannot use a const here. Suppress the warning instead. --- src/Observer.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Observer.h b/src/Observer.h index 6e1ec44c81..9c445e2b5b 100644 --- a/src/Observer.h +++ b/src/Observer.h @@ -81,6 +81,7 @@ template class Observable // Not called directly, instead call observer.observe void addObserver(Observer *o) { observers.push_back(o); } + // cppcheck-suppress constParameterPointer ; std::list::remove() needs a non-const pointer void removeObserver(Observer *o) { observers.remove(o); } }; From 9ac0c2c75d99ba9c281d57740fd9479e05091c01 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 8 Sep 2026 18:25:57 -0400 Subject: [PATCH 094/143] fix(checks): silence cppcheck functionStatic on the no-screen Screen stub (#11778) pioarduino's cppcheck 2.20 reports functionStatic for all 20 methods of the no-op graphics::Screen defined under !HAS_SCREEN: none of them touch a member, so it offers to make them static. The advice is wrong here - the stub exists only to mirror the real Screen's instance API so call sites like screen->setFrames(...) compile on screenless boards, so the methods have to stay non-static member functions. The header is included by 85 translation units, so this fired 1700 times on the two screenless esp32 boards in the check matrix (heltec-ht62-esp32c3-sx1262 and tlora-c6) - the entire src/graphics low-severity count for those boards. bin/check-all.sh passes --fail-on-defect=low, so it was failing those jobs. Wrap the stub in an inline cppcheck-suppress-begin/end block, matching the inline-suppression style already used elsewhere in the tree. Co-authored-by: Claude Opus 5 --- src/graphics/Screen.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 584238170c..7a99c4820b 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -67,6 +67,8 @@ class Screen }; explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY); + // These are empty stubs, but they mirror the real Screen's instance API, so they can't become static. + // cppcheck-suppress-begin functionStatic void onPress() {} void setup() {} void setOn(bool) {} @@ -87,6 +89,7 @@ class Screen bool getIsI2cScreen() const { return false; } uint32_t getI2cFrequency() const { return 0; } ScanI2C::I2CPort getI2CPort() const { return ScanI2C::I2CPort::NO_I2C; } + // cppcheck-suppress-end functionStatic }; } // namespace graphics #else From 784014e8a7e05be26076dfa97609c1adecbca4c3 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 8 Sep 2026 23:52:54 +0000 Subject: [PATCH 095/143] fix(ci): unbreak the ESP32 static analysis gate after the cppcheck 2.20 jump (#11777) Every PR targeting develop has been red since 2026-09-07 on the seven ESP32 check jobs, while the nRF52, RP2040 and STM32 jobs pass on identical source. gh-action-firmware#61 moved the ESP32 container images onto the pioarduino core. Its esp32 platform ships its own tool-cppcheck 2.20.1 and reinstalls it over anything the repo pins, so ESP32 now analyses with cppcheck 2.20 while every other platform still resolves platformio/tool-cppcheck 1.21100.230717, i.e. 2.11. 2.20 parses far more of this tree than 2.11 ever managed, so checks that were always enabled fired for the first time: 388 defects, ESP32 only. #11776 cleared the two unknownMacro errors. Of the 386 left, two are worth acting on and are fixed rather than suppressed: * SerialModule dereferenced a null Position in NMEA/CALTOPO mode. `decoded` stays NULL when pb_decode_from_bytes() fails, but printWPL() was called with *decoded regardless, so a malformed position payload on our portnum crashed the node. Emit the waypoint only on a successful decode. * InkHUD's 12-hour clock passed a signed 12 to a %u conversion. The rest are style and performance suggestions - functionStatic and the const-correctness family account for 351 of them. Suppress those check ids so the gate means the same thing on every platform again, scoping the one-off ones to their file so a new occurrence elsewhere still fails. Burning them down is worth doing deliberately, not under a CI outage. Verified in the CI container images: all seven previously failing ESP32 environments pass, and rak4631, tracker-t1000-e and t-echo-plus still pass under cppcheck 2.11. --- src/graphics/niche/InkHUD/Applet.cpp | 2 +- src/modules/SerialModule.cpp | 11 +++---- suppressions.txt | 44 ++++++++++++++++++++++++---- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/graphics/niche/InkHUD/Applet.cpp b/src/graphics/niche/InkHUD/Applet.cpp index 50efff8ffe..03fdd0ea40 100644 --- a/src/graphics/niche/InkHUD/Applet.cpp +++ b/src/graphics/niche/InkHUD/Applet.cpp @@ -635,7 +635,7 @@ std::string InkHUD::Applet::getTimeString(uint32_t epochSeconds) // Format the clock string, either 12 hour or 24 hour char clockStr[11]; if (config.display.use_12h_clock) - sprintf(clockStr, "%u:%02u %s", (hour % 12 == 0 ? 12 : hour % 12), min, hour > 11 ? "PM" : "AM"); + sprintf(clockStr, "%u:%02u %s", (hour % 12 == 0 ? 12u : hour % 12), min, hour > 11 ? "PM" : "AM"); else sprintf(clockStr, "%02u:%02u", hour, min); diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index ef26bc360e..c1b2d9d6b7 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -408,17 +408,14 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp HAS_GPS) { // Decode the Payload some more meshtastic_Position scratch; - meshtastic_Position *decoded = NULL; if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.decoded.portnum == ourPortNum) { memset(&scratch, 0, sizeof(scratch)); + // A payload that fails to decode leaves nothing to report, so say nothing. if (pb_decode_from_bytes(p.payload.bytes, p.payload.size, &meshtastic_Position_msg, &scratch)) { - decoded = &scratch; - } - // send position packet as WPL to the serial port - { - meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp)); + // send position packet as WPL to the serial port + const meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp)); const char *senderName = senderNode ? senderNode->long_name : ""; - printWPL(outbuf, sizeof(outbuf), *decoded, senderName, + printWPL(outbuf, sizeof(outbuf), scratch, senderName, moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO); serialPrint->printf("%s", outbuf); } diff --git a/suppressions.txt b/suppressions.txt index ca94c269d9..63c9b9208e 100644 --- a/suppressions.txt +++ b/suppressions.txt @@ -52,13 +52,8 @@ postfixOperator:*/mqtt/* missingOverride virtualCallInConstructor -passedByValue:*/RedirectablePrint.h - internalAstError:*/CrossPlatformCryptoEngine.cpp uninitMemberVar:*/AudioThread.h -// False positive -constVariableReference:*/Channels.cpp -constParameterPointer:*/unishox2.c // False positive: make_zeroizing_array() returns unique_ptr, so // .get() is uint8_t*, not void*. cppcheck can't resolve the custom-deleter alias @@ -67,4 +62,41 @@ arithOperationsOnVoidPointer:*/EncryptedStorage.cpp useStlAlgorithm -variableScope \ No newline at end of file +variableScope + +// cppcheck 2.20 (ESP32 only) +// +// The ESP32 CI images build on the pioarduino core, whose esp32 platform installs its own +// tool-cppcheck 2.20.1 and reinstalls it over any pinned version. Every other platform still +// resolves platformio/tool-cppcheck 1.21100.230717, i.e. cppcheck 2.11. 2.20 parses far more of +// this tree than 2.11 managed to, so checks that have always been enabled started firing for the +// first time - 388 defects on ESP32, none anywhere else, for identical source. +// +// Silence the checks that appeared with that jump so the gate means the same thing on every +// platform again. These are style and performance suggestions, not defects; burning them down is +// worth doing deliberately, not under a CI outage. +functionStatic +staticFunction +constParameterPointer +iterateByValue +returnByReference +passedByValue +uselessOverride + +constVariable +constVariablePointer +constVariableReference +constParameterReference + +// Single deliberate sites, scoped so a new one elsewhere still fails the gate. Router folds the +// bitfield's want_response bit into the decoded bool; Power interpolates the OCV curve in float. +bitwiseOnBoolean:*/Router.cpp +suspiciousFloatingPointCast:*/Power.cpp + +// The 2.20 successor to cstyleCast, which is already suppressed above for the same reason. +dangerousTypeCast + +// Sensor drivers hold a driver object they new in begin() and are constructed once, at global +// scope. cppcheck wants the rule of three on them; nothing ever copies one. +noCopyConstructor:*/Telemetry/Sensor/* +noOperatorEq:*/Telemetry/Sensor/* From 95f96439c6ad0491341a9dbc58fe6d5cf55ab786 Mon Sep 17 00:00:00 2001 From: Jason P Date: Wed, 9 Sep 2026 01:53:17 +0000 Subject: [PATCH 096/143] Hide Navigation Bar when shutting down EInk (#11775) * Hide Navigation Bar on EInk Shutdown * Revert "Hide Navigation Bar on EInk Shutdown" This reverts commit 744812555b826496f9b8ae7a44d0d5aac1bde1a9. * Hide Navigation Bar on EInk Shutdown * It's Hide, not Drop --- src/graphics/Screen.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index b9309f5308..8b9bb61bd8 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1324,6 +1324,10 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) if (einkScreensaver != NULL) { screensaverFrame = einkScreensaver; ui->setFrames(&screensaverFrame, 1); + + // Hide the nav bar before the sleep / shutdown screen is rendered + static OverlayCallback screensaverOverlays[] = {NotificationRenderer::drawBannercallback}; + ui->setOverlays(screensaverOverlays, 1); } // Else, display the usual "overlay" screensaver From e09b961f391acb5899571ffdc801cae0f373ec62 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:56:22 +0000 Subject: [PATCH 097/143] docs(agents): exempt test headers from the two-line comment limit (#11742) * docs(agents): exempt test headers from the two-line comment limit The one-or-two-line comment rule rests on "the diff and commit message carry the rationale". For a test that premise is false. A test outlives the PR that added it, and the next person to read it is reading it because it failed - months later, in someone else's change, with the original discussion out of reach. That reader has one decision to make: real regression, or an expectation that has gone stale? The assertions alone cannot answer it, so the justification has to live in the file. The new "Test comments" section requires three things of a test header - what is under test by symbol and file, why that behavior is required, and the specific regression that returns if the assertions are deleted or relaxed - and grants whatever length they need. Authoritative statement lives in that section of .github/copilot-instructions.md, anchored at #test-comments. AGENTS.md and CLAUDE.md link to it rather than restate it. The test/** entry in .coderabbit.yaml carries the one permitted copy, since a YAML instruction cannot follow a link. This documents existing practice rather than changing it: 57 of the 72 suites with a test_main.cpp already open with three or more comment lines, test/test_gps_fix_hold/test_main.cpp with eleven. The rule as written forbade all of them, and automated reviewers acted on it. Scoped to the single comment bullet, not to the whole "General Style" section. Unlike "Naming Conventions", that section also holds the logging tiers and the Throttle rule, which bind test code as hard as src/; a blanket preamble would have quietly exempted tests from millis() discipline as well. The exception is bounded. The CodeRabbit entry still flags narrative that carries no contract - debugging journey, changelog prose, restating what the assertions plainly do - and per-case comments that merely repeat the test name. The documentation-does-not-live-here rule is untouched. * docs(agents): cut the test-comment rule to its essential statements A section about comment length had no business running to 373 words. Canonical section down to 194: dropped the elaboration of why the commit message is unavailable, the aside about which bullet reviewers most often cut, the "not licence for narrative" preamble, and the closing flourish. What remains is what an agent has to act on - the premise that fails for tests, the three things a header states, the instruction to reject a shorten-this review comment, and the worked example. Pointer files carry scope, not argument. The AGENTS.md bullet is one sentence and a link, matching the Test naming pointer directly above it. The .coderabbit.yaml copy keeps its imperatives, since a YAML instruction cannot follow the link to find them, but loses the causal explanation it did not need in order to act. --- .coderabbit.yaml | 11 +++++++++++ .github/copilot-instructions.md | 18 +++++++++++++++++- AGENTS.md | 1 + CLAUDE.md | 17 +++++++++-------- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 0b5651bab2..c99af3e1d6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -49,6 +49,17 @@ reviews: Helpers and fixtures inside a suite do follow the normal src/ conventions. Authoritative rule, which this entry mirrors: the "Test naming" section of .github/copilot-instructions.md. + + Comment length is a second deliberate exception. The one-or-two-line comment limit + does NOT apply to a test's header comment. The header states what is under test by + symbol and file, why that behavior is required, and the regression that returns if + the assertions are deleted or relaxed; that routinely runs past two lines and is + correct at whatever length it needs. Do NOT ask for a test header to be shortened, + condensed, or moved to the commit message, and do NOT flag it as a multi-paragraph + block comment. Still DO flag narrative that carries no contract - debugging journey, + changelog prose, restating what the assertions do - and per-case comments that + merely repeat the test name. Authoritative rule, which this entry mirrors: the + "Test comments" section of .github/copilot-instructions.md. - path: "**/*.md" instructions: > Documentation does not live in this repo; it lives in diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c5b894aafc..90286936f0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -337,7 +337,7 @@ firmware/ - **Do not zero-pad one-byte values to 8.** `next_hop`, `relay_node`, and the next-hop hint are `uint8_t` last-byte route hints, and `channel` is a one-byte hash/index - log these as `0x%x` (or `%d`). Padding a byte to `0x000000ab` falsely implies a full node number. The same goes for I2C addresses, register values, flags/bitmasks, and error/reason codes: they are not IDs, so leave them `0x%x`. - Use `assert()` for invariants that should never fail - C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.) -- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. +- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. Code under `test/` is a deliberate exception - see [Test comments](#test-comments) below. - **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. - **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. @@ -352,6 +352,22 @@ firmware/ **And decide which way the sentinel should fall.** "Inactive" does not always mean "suppress". At the GPS fix-hold site `fixHoldEnds == 0` means _no hold is in force_, which is exactly when a new hold must be armed - the naive comparison it replaced was `(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, always true when nothing was armed. Guarding it with `fixHoldEnds != 0 &&` looks like this rule and inverts the site: nothing re-arms, nothing publishes, and the receiver stays powered until the search timeout. Read the surrounding logic before adding the guard. `fixHoldInForce()` in `src/gps/GPS.cpp` is the worked example - state the predicate positively, so the sentinel has an honest answer, and derive both decisions from it - with `test/test_gps_fix_hold/` pinning both directions. + + +#### Test comments - a test must justify what it pins and name the regression it guards + +**This section is the single authoritative statement of the rule. `AGENTS.md` and `CLAUDE.md` link here and must not restate it. The one permitted copy is the `test/**` entry in `.coderabbit.yaml`, because a YAML instruction cannot follow a link; keep it in sync with this section.** + +The limit above rests on "the diff and commit message carry the rationale". For a test that premise is false: it is read when it fails, long after that message is out of reach, by someone deciding whether the failure is a real regression or a stale expectation. **A review comment asking a test header to be cut to one or two lines is wrong, and should be rejected rather than acted on.** + +The header of `test_main.cpp` states three things, at whatever length they take: + +- **What is under test**, by symbol and file - `fixHoldInForce()` in `src/gps/GPS.cpp`, not "the GPS logic". +- **Why that behavior is required** - the contract being pinned. +- **The regression guarded** - the wrong behavior that returns if these assertions are deleted or relaxed. + +Per-case comments stay short; add one only where an assertion turns on something non-obvious. The allowance is for the argument, not for narrative: no debugging journey, no changelog prose, no restating what the assertions do. Worked example: `test/test_gps_fix_hold/test_main.cpp`. + ### Naming Conventions These apply to firmware source under `src/`. Code under `test/` is a deliberate exception - see [Test naming](#test-naming) below. diff --git a/AGENTS.md b/AGENTS.md index 97d555c42b..ec315c9dae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor - **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. - **The `src/` naming rule does not apply under `test/`.** Suite directories and `test_*` functions follow their own rule and must not be renamed to match `src/`. What that rule is, and why: [**Test naming** in `.github/copilot-instructions.md`](.github/copilot-instructions.md#test-naming) - authoritative there, not restated here. - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. +- **That limit does not bind a test's header comment.** A test header states what it pins and the regression it guards, at whatever length that takes, and must not be cut to two lines. What it must contain, and why: [**Test comments** in `.github/copilot-instructions.md`](.github/copilot-instructions.md#test-comments) - authoritative there, not restated here. - **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. - **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. diff --git a/CLAUDE.md b/CLAUDE.md index 054b497932..8869be9803 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,14 +11,15 @@ > > **Need this? It's here.** > -> | | | -> | --------------------------------------------------------- | ---------------------------------------------------------------------------------- | -> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | -> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | -> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` | -> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | -> | Observer / event wiring | `src/Observer.h` | -> | Test naming (the `src/` rule does **not** apply) | [copilot-instructions.md#test-naming](.github/copilot-instructions.md#test-naming) | +> | | | +> | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | +> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | +> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | +> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` | +> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | +> | Observer / event wiring | `src/Observer.h` | +> | Test naming (the `src/` rule does **not** apply) | [copilot-instructions.md#test-naming](.github/copilot-instructions.md#test-naming) | +> | Test comments (the 1-2 line limit does **not** apply) | [copilot-instructions.md#test-comments](.github/copilot-instructions.md#test-comments) | **Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions, the build system, CI/CD, the native C++ test suite, and the MCP Server & Hardware Test Harness. Read it top-to-bottom before starting any non-trivial change. From 125c4514b0521af04ae07ee2b70421446394be99 Mon Sep 17 00:00:00 2001 From: Jason P Date: Wed, 9 Sep 2026 01:58:10 +0000 Subject: [PATCH 098/143] Allow Spacebar to advance frames (#11771) --- src/graphics/Screen.cpp | 3 ++- src/modules/CannedMessageModule.cpp | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8b9bb61bd8..651deacbf9 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -2311,7 +2311,8 @@ int Screen::handleInputEvent(const InputEvent *event) #endif if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS) { showFrame(FrameDirection::PREVIOUS); - } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) { + } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS || + (event->inputEvent == INPUT_BROKER_ANYKEY && event->kbchar == ' ')) { showFrame(FrameDirection::NEXT); } else if (event->inputEvent == INPUT_BROKER_FN_F1) { this->ui->switchToFrame(0); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 33d0816004..ea6f5ea96e 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -444,6 +444,9 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) LaunchWithDestination(NODENUM_BROADCAST); return 1; } + // Space is reserved for advancing frames (handled by Screen), so it must not open the composer + if (event->kbchar == ' ') + return 0; // Printable char (ASCII) opens free text compose if (event->kbchar >= 32 && event->kbchar <= 126) { updateState(CANNED_MESSAGE_RUN_STATE_FREETEXT, true); From 73c41105282e3f6245714efbaad2e061f7bfa821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 9 Sep 2026 06:35:14 +0000 Subject: [PATCH 099/143] fix(phoneapi): resend my_info when the node num moves mid-session (#11732) * fix(phoneapi): resend my_info when the node num moves mid-session The first region set mints the PKI key and moves my_node_num to crc32(public_key) live. my_info only went out during the want_config_id handshake, so an already-connected client kept addressing the old number and its admin packets NAKed PKI_SEND_FAIL_PUBLIC_KEY until it reconnected. PhoneAPI tracks the number it last reported and re-sends my_info from STATE_SEND_PACKETS when it no longer matches. createNewIdentity() nudges fromNum so clients poll. Fixes #11718 * fix(phoneapi): key the MyInfo re-announce off a one-shot state Review follow-up. The per-connection reportedNodeNum field is gone: adding per-instance members to PhoneAPI is documented as breaking USB-CDC enumeration on the nRF52 Adafruit framework, and the baseline was never set for SPECIAL_NONCE_ONLY_NODES, which skips STATE_SEND_MY_INFO and so emitted an unexpected my_info after config_complete_id. MeshService::identityMoved is set with the nudge and cleared once the notify pass has reached every observer, so PhoneAPI::onNotify arms STATE_RESEND_MY_INFO on each connected client in that single pass and stores nothing per connection. The test now drives NodeDB::createNewIdentity() and MeshService::loop() instead of writing my_node_num directly, and asserts the transport wake-up. Nodes-only sync asserts no trailing my_info. drainToIdle() honours its read cap. * fix(phoneapi): restart the dump when the node num moves mid-sync Review follow-up. A client still in its config dump has already been sent the old my_info and has no steady state for the one-shot to fall back from, so the notify pass cleared identityMoved without covering it and the client finished syncing on the obsolete number. PhoneAPI::onNotify now restarts such a client's dump, which is the existing re-handshake path. Skipped for a client that has not reached my_info yet and for SPECIAL_NONCE_ONLY_NODES, which never sends one. test_node_num_change_mid_dump_restarts_sync renumbers mid-dump and asserts the restart, the new number, and that no part of the config is lost. Verified to fail without the fix. * fix(phoneapi): make the identity-move signal survive a concurrent notify pass Review follow-up. The identity move can run off the loop task: a local admin set_config reaches AdminModule through Router::sendLocal() on whichever task delivered it. A bool cleared by MeshService::loop() could therefore be set and cleared without any client being armed, losing the re-announce. A generation counter replaces the bool. loop() snapshots it with fromNum before notifying and only advances the seen counter afterwards, so anything bumped during the pass is still pending. The same snapshot fixes a notify for a fromNum bump that arrived mid-pass being marked delivered. test_node_num_change_mid_dump_restarts_sync now asserts the whole restarted dump: header order, channels, both config sections, our node record, nonce. Also trims the MyInfo redaction comment to the two-line cap. * fix(nodedb): keep self at index 0 after a live renumber, restart nodes-only syncs Review follow-up. createNewIdentity() removed our old row and appended the new one, leaving index 0 pointing at some other node. PhoneAPI's own-nodeinfo read and the demote/evict scans that skip index 0 to protect us both rely on that slot being self, so a renumbered node handed every client a stranger's record as its own. Pinned the way nodeDBSelfCare() does it. onNotify no longer exempts SPECIAL_NONCE_ONLY_NODES from the mid-sync restart. That dump carries no my_info, but it does carry the self record, which the move invalidates the same way. Such a client also gets the re-announce once its sync lands in STATE_SEND_PACKETS, which it previously never did. The generation counters are atomic. Every interleaving was already safe, since observers read the live counter and the seen counter only advances to a pre-pass snapshot, but the concurrent plain accesses were a data race on paper. * fix(meshservice): make fromNum atomic Review follow-up. The counter is bumped from whichever task queued the packet and read by loop(). It is private to MeshService, so the type change covers every access. --- src/mesh/MeshService.cpp | 12 +- src/mesh/MeshService.h | 14 +- src/mesh/NodeDB.cpp | 14 +- src/mesh/PhoneAPI.cpp | 64 +++++---- src/mesh/PhoneAPI.h | 6 +- test/test_phone_api_config_dump/test_main.cpp | 131 ++++++++++++++++-- 6 files changed, 201 insertions(+), 40 deletions(-) diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 73fb7f6004..6a44aba25c 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -138,9 +138,15 @@ void MeshService::loop() (void)sendQueueStatusToPhone(qs, 0, 0); } if (oldFromNum != fromNum) { // We don't want to generate extra notifies for multiple new packets - int result = fromNumChanged.notifyObservers(fromNum); - if (result == 0) // If any observer returns non-zero, we will try again - oldFromNum = fromNum; + // Snapshot both first: the identity move can run on another task, and anything it bumps during + // the pass must still be pending afterwards rather than being marked delivered. + const uint32_t num = fromNum; + const uint32_t generation = identityGeneration; + int result = fromNumChanged.notifyObservers(num); + if (result == 0) { // If any observer returns non-zero, we will try again + oldFromNum = num; + identityGenerationSeen = generation; + } } } diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 85d09058cc..8c48964fc3 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "GPSStatus.h" @@ -72,8 +73,9 @@ class MeshService // This holds the last QueueStatus send meshtastic_QueueStatus lastQueueStatus; - /// The current nonce for the newest packet which has been queued for the phone - uint32_t fromNum = 0; + /// The current nonce for the newest packet which has been queued for the phone. Bumped from + /// whichever task queued it, read by loop(), hence atomic. + std::atomic fromNum{0}; /// Updated in loop() to detect when fromNum changes uint32_t oldFromNum = 0; @@ -160,6 +162,14 @@ class MeshService /// senders. void nudgeFromNum() { fromNum++; } + /// Bumped with a nudgeFromNum() when our node num changes; the seen counter only advances once a + /// notify pass has reached every client, so a move landing during a pass stays pending after it. + std::atomic identityGeneration{0}; + std::atomic identityGenerationSeen{0}; + + /// True while a node num change still owes connected clients a fresh MyInfo. + bool identityMovePending() const { return identityGeneration != identityGenerationSeen; } + /** * Given a ToRadio buffer parse it and properly handle it (setup radio, owner or send packet into the mesh) * Called by PhoneAPI.handleToRadio. Note: p is a scratch buffer, this function is allowed to write to it but it can not keep diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 9867a848c6..35c29dc99f 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -4555,11 +4555,21 @@ bool NodeDB::createNewIdentity() // The number has moved, so the caller must persist it whatever happens next. Returning false here // would leave the new key saved against the old number, which is the break this exists to prevent. meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum()); - if (info) + if (info) { TypeConversions::CopyUserToNodeInfoLite(info, owner); - else + // Our row was appended, but index 0 is self by invariant: the phone's own-nodeinfo read and the + // demote/evict scans that skip index 0 to protect us both depend on it. + if (info != &meshNodes->at(0)) + std::swap(meshNodes->at(0), *info); + } else LOG_ERROR("No room for our own node 0x%08x, identity moved without a self record", newNodeNum); + // Clients cache my_node_num from the handshake; the region set that mints the key never reboots. + if (service) { + service->identityGeneration++; + service->nudgeFromNum(); + } + return true; } diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index f6757206c5..f2e9db8aa9 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -549,6 +549,27 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) STATE_SEND_PACKETS // send packets or debug strings */ +void PhoneAPI::fillMyInfo() +{ + fromRadioScratch.which_payload_variant = meshtastic_FromRadio_my_info_tag; + strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env)); + // strncpy does not terminate when the source fills the buffer; a 40+ char + // APP_ENV would make nanopb reject the MyInfo encode ("unterminated string"). + myNodeInfo.pio_env[sizeof(myNodeInfo.pio_env) - 1] = '\0'; + myNodeInfo.nodedb_count = static_cast(nodeDB->getNumMeshNodes()); + fromRadioScratch.my_info = myNodeInfo; +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL + if (!getAdminAuthorized()) { + // device_id fingerprints the hardware and pio_env/min_app_version name the exact build to pick a + // known CVE for. my_node_num is broadcast on the mesh anyway, and nodedb_count is not secret. + fromRadioScratch.my_info.device_id.size = 0; + memset(fromRadioScratch.my_info.device_id.bytes, 0, sizeof(fromRadioScratch.my_info.device_id.bytes)); + memset(fromRadioScratch.my_info.pio_env, 0, sizeof(fromRadioScratch.my_info.pio_env)); + fromRadioScratch.my_info.min_app_version = 0; + } +#endif +} + size_t PhoneAPI::getFromRadio(uint8_t *buf) { // Respond to heartbeat by sending queue status @@ -575,30 +596,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) break; case STATE_SEND_MY_INFO: LOG_DEBUG("FromRadio=STATE_SEND_MY_INFO"); - // If the user has specified they don't want our node to share its location, make sure to tell the phone - // app not to send locations on our behalf. - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_my_info_tag; - strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env)); - // strncpy does not terminate when the source fills the buffer; a 40+ char - // APP_ENV would make nanopb reject the MyInfo encode ("unterminated string"). - myNodeInfo.pio_env[sizeof(myNodeInfo.pio_env) - 1] = '\0'; - myNodeInfo.nodedb_count = static_cast(nodeDB->getNumMeshNodes()); - fromRadioScratch.my_info = myNodeInfo; -#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL - if (!getAdminAuthorized()) { - // device_id is a stable hardware identifier - useful for an attacker - // to fingerprint / correlate the device across observations. Strip it - // for unauthenticated clients. my_node_num is kept (it's broadcast - // on the mesh anyway). pio_env / min_app_version reveal the exact - // build flavour, useful only for picking which known-CVE to try. - // nodedb_count stays - clients need it to decide whether to pull - // the node DB after unlocking. - fromRadioScratch.my_info.device_id.size = 0; - memset(fromRadioScratch.my_info.device_id.bytes, 0, sizeof(fromRadioScratch.my_info.device_id.bytes)); - memset(fromRadioScratch.my_info.pio_env, 0, sizeof(fromRadioScratch.my_info.pio_env)); - fromRadioScratch.my_info.min_app_version = 0; - } -#endif + fillMyInfo(); state = STATE_SEND_UIDATA; service->refreshLocalMeshNode(); // Update my NodeInfo because the client will be asking for it soon. @@ -1097,6 +1095,14 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) } break; + case STATE_RESEND_MY_INFO: + // Our node num moved after this client's handshake, so it is addressing a number we no + // longer answer to. Re-announce, then carry on with live traffic. + LOG_INFO("FromRadio=STATE_RESEND_MY_INFO, node num now 0x%08x", nodeDB->getNodeNum()); + fillMyInfo(); + state = STATE_SEND_PACKETS; + break; + default: LOG_ERROR("getFromRadio unexpected state %d", state); } @@ -1668,6 +1674,7 @@ bool PhoneAPI::available() case STATE_SEND_OWN_NODEINFO: case STATE_SEND_FILEMANIFEST: case STATE_SEND_COMPLETE_ID: + case STATE_RESEND_MY_INFO: return true; case STATE_SEND_OTHER_NODEINFOS: { @@ -1904,8 +1911,17 @@ int PhoneAPI::onNotify(uint32_t newValue) // doesn't call this from idle) if (state == STATE_SEND_PACKETS) { + // Consumed by every connected client in this one notify pass, so no per-connection bookkeeping. + if (service->identityMovePending()) + state = STATE_RESEND_MY_INFO; LOG_INFO("Tell client new packets %u", newValue); onNowHasData(newValue); + } else if (service->identityMovePending() && state != STATE_SEND_NOTHING && state != STATE_SEND_MY_INFO) { + // Mid-sync, so this dump is already carrying the old number in its my_info, its self record or + // both, and has no steady state to fall back from. Restart it on the new one. + LOG_INFO("Node num moved mid-sync, restart client config"); + handleStartConfig(); + onNowHasData(newValue); } else { LOG_DEBUG("Client not yet interested in packets (state=%d)", state); } diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index ab04c178b0..245b4b94da 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -53,7 +53,8 @@ class PhoneAPI STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to to the client STATE_SEND_FILEMANIFEST, // Send file manifest STATE_SEND_COMPLETE_ID, - STATE_SEND_PACKETS // live mesh packets + any cached satellite-DB replay that trails sync completion + STATE_SEND_PACKETS, // live mesh packets + any cached satellite-DB replay that trails sync completion + STATE_RESEND_MY_INFO // one-shot: our node num moved after the handshake, re-announce and fall back }; // Satellite-DB replay (positions / telemetry / environment / status) used to live @@ -133,6 +134,9 @@ class PhoneAPI void resetReadIndex() { readIndex = 0; } + /// Load fromRadioScratch with a MyInfo for this connection and record the number it carried. + void fillMyInfo(); + public: PhoneAPI(); diff --git a/test/test_phone_api_config_dump/test_main.cpp b/test/test_phone_api_config_dump/test_main.cpp index 2dff75d6ec..af6f83ecf6 100644 --- a/test/test_phone_api_config_dump/test_main.cpp +++ b/test/test_phone_api_config_dump/test_main.cpp @@ -27,6 +27,7 @@ constexpr uint32_t SECOND_NONCE = 0x0DDBA11; constexpr NodeNum SEEDED_NODE_A = 0x00000A01; constexpr NodeNum SEEDED_NODE_B = 0x00000A02; +constexpr unsigned MAX_IDLE_DRAIN_READS = 8; // replay phases left to drain after config_complete_id constexpr unsigned NUM_SINGLETON_PREFIX = 5; // my_info, deviceuiConfig, own node_info, metadata, region_presets constexpr unsigned NUM_CONFIG_MESSAGES = _meshtastic_AdminMessage_ConfigType_MAX + 1; constexpr unsigned NUM_MODULE_CONFIG_MESSAGES = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1; @@ -73,8 +74,12 @@ static_assert(sizeof(kExpectedModuleConfigVariants) / sizeof(kExpectedModuleConf /// PhoneAPI over a permanently-connected fake transport. class PhoneAPITestShim : public PhoneAPI { + public: + unsigned dataNotifications = 0; // transport wake-ups, i.e. "come and read" + protected: bool checkIsConnected() override { return true; } + void onNowHasData(uint32_t fromRadioNum) override { dataNotifications++; } }; /// Concrete Router with no radio interface: getQueueStatus() reports an all-zero queue. @@ -223,6 +228,26 @@ bool drainUntilComplete(DumpTranscript &t, unsigned maxMessages = 600) return false; } +/// What the first region set does to a live node: a minted key moves my_node_num with it. +void mintIdentity() +{ + config.security.public_key.size = 32; + memset(config.security.public_key.bytes, 0x5A, sizeof(config.security.public_key.bytes)); + TEST_ASSERT_TRUE_MESSAGE(nodeDB->createNewIdentity(), "identity did not move"); +} + +/// Read until available() reports idle; false if it never does within the cap. +bool drainToIdle() +{ + uint8_t buf[meshtastic_FromRadio_size]; + for (unsigned i = 0; i < MAX_IDLE_DRAIN_READS; i++) { + if (!api->available()) + return true; + api->getFromRadio(buf); + } + return false; +} + unsigned countVariant(const DumpTranscript &t, pb_size_t tag) { unsigned n = 0; @@ -369,6 +394,14 @@ void test_only_nodes_nonce_sends_nodes_then_complete() TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_config_tag)); TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_moduleConfig_tag)); TEST_ASSERT_EQUAL_UINT(0, t.fileInfoCount); + + // This nonce skips my_info entirely, so the trailing replay must not produce one either. + for (unsigned i = 0; i < MAX_IDLE_DRAIN_READS && api->available(); i++) { + meshtastic_FromRadio trailing; + if (readOneFromRadio(trailing)) + TEST_ASSERT_NOT_EQUAL_MESSAGE(meshtastic_FromRadio_my_info_tag, trailing.which_payload_variant, + "nodes-only sync must not emit my_info"); + } } // SPECIAL_NONCE_ONLY_CONFIG delivers the full config but skips the non-self node DB, and must @@ -490,18 +523,97 @@ void test_dump_reaches_idle_after_complete() DumpTranscript t; TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_TRUE_MESSAGE(drainToIdle(), "post-complete drain never went idle: available() stuck true"); uint8_t buf[meshtastic_FromRadio_size]; - bool idle = false; - for (unsigned i = 0; i < 8 && !idle; i++) { - if (!api->available()) - idle = true; - else - api->getFromRadio(buf); // replay drain: empty phases must advance toward idle - } - TEST_ASSERT_TRUE_MESSAGE(idle, "post-complete drain never went idle: available() stuck true"); TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf)); } +// The first region set moves my_node_num live (NodeDB::createNewIdentity()) with no reboot to force a +// re-handshake, so the stream must re-announce my_info - exactly once - on its own. +void test_node_num_change_resends_my_info() +{ + startHandshake(FULL_DUMP_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_TRUE_MESSAGE(drainToIdle(), "post-complete drain never went idle"); + + const uint32_t handshakeNodeNum = nodeDB->getNodeNum(); + const unsigned notificationsBefore = api->dataNotifications; + + mintIdentity(); + TEST_ASSERT_NOT_EQUAL_MESSAGE(handshakeNodeNum, nodeDB->getNodeNum(), "node num did not actually change"); + + service->loop(); // delivers the fromNum notify that arms the re-announce + TEST_ASSERT_GREATER_THAN_UINT_MESSAGE(notificationsBefore, api->dataNotifications, + "transport was never woken to come and read"); + + meshtastic_FromRadio msg; + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant, + "a renumber must be announced as my_info"); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), msg.my_info.my_node_num); + + // One-shot: the stream falls back to live traffic and nothing repeats. + TEST_ASSERT_FALSE_MESSAGE(api->available(), "my_info resend repeated after the client was told"); +} + +// The same move landing mid-sync: my_info is already out with the old number and there is no +// steady state to fall back from, so the dump restarts and carries the new one. +void test_node_num_change_mid_dump_restarts_sync() +{ + startHandshake(FULL_DUMP_NONCE); + + meshtastic_FromRadio msg; + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS; i++) + TEST_ASSERT_TRUE(readOneFromRadio(msg)); // through the channels, my_info long since sent + + mintIdentity(); + service->loop(); + + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant, + "a mid-sync renumber must restart the dump"); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), msg.my_info.my_node_num); + + // Everything after the my_info already read must arrive again, in order and complete. + DumpTranscript t; + TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "restarted dump never completed"); + const pb_size_t expectedPrefix[] = {meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag, + meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag}; + TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX - 1, sizeof(expectedPrefix) / sizeof(expectedPrefix[0])); + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX - 1; i++) + TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "restarted dump changed the header sequence"); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(), "restarted dump lost part of the config"); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size()); // our own record; this test seeds no remotes + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, t.completeId); +} + +// Same move during a nodes-only sync: the self record it already sent is gone from the DB, so that +// dump restarts too, even though this nonce never carries a my_info to be stale. +void test_node_num_change_mid_nodes_only_restarts_sync() +{ + seedRemoteNode(SEEDED_NODE_A); + startHandshake(SPECIAL_NONCE_ONLY_NODES); + + meshtastic_FromRadio msg; + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant); + const uint32_t staleSelf = msg.node_info.num; + + mintIdentity(); + service->loop(); + + DumpTranscript t; + TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "restarted nodes-only dump never completed"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(nodeDB->getNumMeshNodes(), t.nodeNums.size(), "restart must resend every node"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(staleSelf, t.nodeNums[0], "restart must carry the new self record"); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId); +} + } // namespace void setUp(void) @@ -567,6 +679,9 @@ void setup() RUN_TEST(test_close_mid_dump_then_reconnect_restarts_clean); RUN_TEST(test_rehandshake_mid_dump_restarts_from_my_info); RUN_TEST(test_dump_reaches_idle_after_complete); + RUN_TEST(test_node_num_change_resends_my_info); + RUN_TEST(test_node_num_change_mid_dump_restarts_sync); + RUN_TEST(test_node_num_change_mid_nodes_only_restarts_sync); exit(UNITY_END()); } From 382980637b1a91cb9f493c8e62b682f5981aa166 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 9 Sep 2026 11:30:03 +0000 Subject: [PATCH 100/143] perf(nodedb): bind encode-loop entries by const reference (#11780) cppcheck (iterateByValue) flagged five range-for loops in the NodeDatabase pb_callbacks that copy a whole protobuf entry off the vector only to pass its address to pb_encode_submessage(), which takes a const void *. Bind by const reference instead. Drops one meshtastic_NodePositionEntry, NodeTelemetryEntry, NodeStatusEntry, NodeEnvironmentEntry and NodeInfoLite_Legacy copy per node per encode pass; these run on every nodes.proto save. The nodes_tag loop in NodeDB.cpp is intentionally left as a by-value copy: it mutates item.snr_q4/item.snr to the on-disk quantized form before encoding, so it is a working copy rather than a redundant one. cppcheck does not flag it. Co-authored-by: Claude Opus 5 Co-authored-by: Ben Meadors --- src/mesh/NodeDB.cpp | 8 ++++---- src/mesh/NodeDBLegacyMigration.cpp | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 35c29dc99f..ff7bcca348 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -294,7 +294,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre case meshtastic_NodeDatabase_positions_tag: { if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodePositionEntry_fields, &item)) @@ -320,7 +320,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre case meshtastic_NodeDatabase_telemetry_tag: { if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeTelemetryEntry_fields, &item)) @@ -346,7 +346,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre case meshtastic_NodeDatabase_status_tag: { if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeStatusEntry_fields, &item)) @@ -372,7 +372,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre case meshtastic_NodeDatabase_environment_tag: { if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeEnvironmentEntry_fields, &item)) diff --git a/src/mesh/NodeDBLegacyMigration.cpp b/src/mesh/NodeDBLegacyMigration.cpp index 408df62e28..ef7f3108df 100644 --- a/src/mesh/NodeDBLegacyMigration.cpp +++ b/src/mesh/NodeDBLegacyMigration.cpp @@ -26,7 +26,7 @@ bool meshtastic_NodeDatabase_Legacy_callback(pb_istream_t *istream, pb_ostream_t const auto *iter = reinterpret_cast(field); if (ostream) { const auto *vec = static_cast *>(iter->pData); - for (auto item : *vec) { + for (const auto &item : *vec) { if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_Legacy_fields, &item)) From 42d32fcea60f2804f2fb143276e383f0fce522c8 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 9 Sep 2026 11:37:22 +0000 Subject: [PATCH 101/143] fix(radio): make limitPower() idempotent so chip re-inits don't compound PA gain subtraction (#11782) limitPower() converts the member `power` in place (regulatory clamp, then the TX_GAIN_LORA/FEM subtraction) and relied on applyModemConfig() having just re-seeded it. Since #10025 every driver calls it from both reinitChip() and programModemParams(), and the recovery paths added in #11676/#11678 run the two back-to-back, so each recovery re-converts an already-converted value. On a RAK13302 (22-entry gain table) one recovery walks a 30 dBm request 30 -> 22 -> 13 dBm and a second one down toward the -9 dBm floor, while config.lora.tx_power still reads 30. Seed `power` from config.lora.tx_power at the top of limitPower(); applyModemConfig() always writes the resolved value back there, so a single call is unchanged. Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> --- src/mesh/RadioInterface.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 7da9325d46..ee76fa19bb 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1432,11 +1432,13 @@ uint32_t RadioInterface::computeSlotTimeMsec() /** * Some regulatory regions limit xmit power. - * This function should be called by subclasses after setting their desired power. It might lower it + * This function should be called by subclasses after setting their desired power. It might lower it. + * Re-derives `power` from config each call so a re-init that runs it twice cannot subtract PA gain twice. */ void RadioInterface::limitPower(int8_t loraMaxPower) { - uint8_t maxPower = 255; // No limit + power = config.lora.tx_power; // applyModemConfig() writes the resolved value back here + uint8_t maxPower = 255; // No limit if (myRegion->powerLimit) maxPower = myRegion->powerLimit; From 29a65aa13d492af561debf9ae786f8469a93fc67 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:27:20 +0000 Subject: [PATCH 102/143] fix(mesh): use valid default packet history size (#11786) --- src/mesh/PacketHistory.cpp | 12 ++++++----- src/mesh/PacketHistory.h | 2 +- test/test_packet_history/test_main.cpp | 29 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index da745a25e7..3efeee2a75 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -17,14 +17,16 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initialize members { if (size < 4 || size > PACKETHISTORY_MAX) { // Copilot suggested - makes sense - LOG_WARN("Packet History - Invalid size %d, using default %d", size, PACKETHISTORY_MAX); + LOG_WARN("Packet History - Invalid size %u, using default %u", static_cast(size), + static_cast(PACKETHISTORY_MAX)); 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); + LOG_WARN("Packet History - Clamping size %u to %u (hash index limit)", static_cast(size), + static_cast(HASH_EMPTY - 1)); size = HASH_EMPTY - 1; } #endif @@ -33,7 +35,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initia recentPacketsCapacity = size; recentPackets.reset(new PacketRecord[recentPacketsCapacity]); if (!recentPackets) { // No logging here, console/log probably uninitialized yet. - LOG_ERROR("Packet History - Memory allocation failed for size=%d entries / %d Bytes", size, + LOG_ERROR("Packet History - Memory allocation failed for size=%u entries / %zu Bytes", static_cast(size), sizeof(PacketRecord) * recentPacketsCapacity); recentPacketsCapacity = 0; // mark allocation fail return; // return early @@ -49,7 +51,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initia hashMask = hashCapacity - 1; hashIndex.reset(new uint16_t[hashCapacity]); if (!hashIndex) { - LOG_ERROR("Packet History - Hash index allocation failed for %d entries", hashCapacity); + LOG_ERROR("Packet History - Hash index allocation failed for %u entries", static_cast(hashCapacity)); hashCapacity = 0; hashMask = 0; return; @@ -601,4 +603,4 @@ inline uint8_t PacketHistory::getOurTxHopLimit(const PacketRecord &r) inline void PacketHistory::setOurTxHopLimit(PacketRecord &r, uint8_t hopLimit) { r.hop_limit = (r.hop_limit & ~HOP_LIMIT_OUR_TX_MASK) | ((hopLimit << HOP_LIMIT_OUR_TX_SHIFT) & HOP_LIMIT_OUR_TX_MASK); -} \ No newline at end of file +} diff --git a/src/mesh/PacketHistory.h b/src/mesh/PacketHistory.h index 84af59874e..42359e5808 100644 --- a/src/mesh/PacketHistory.h +++ b/src/mesh/PacketHistory.h @@ -68,7 +68,7 @@ class PacketHistory void setOurTxHopLimit(PacketRecord &r, uint8_t hopLimit); public: - explicit PacketHistory(uint32_t size = -1); // Constructor with size parameter, default is PACKETHISTORY_MAX + explicit PacketHistory(uint32_t size = PACKETHISTORY_MAX); /** * Update recentBroadcasts and return true if we have already seen this packet diff --git a/test/test_packet_history/test_main.cpp b/test/test_packet_history/test_main.cpp index 913417f4d2..bfce175df2 100644 --- a/test/test_packet_history/test_main.cpp +++ b/test/test_packet_history/test_main.cpp @@ -10,8 +10,11 @@ #include "PacketHistory.h" +#include "SerialConsole.h" #include "TestUtil.h" +#include #include +#include // --------------------------------------------------------------------------- // Constants @@ -25,6 +28,18 @@ static constexpr uint32_t SMALL_CAPACITY = 8; // --------------------------------------------------------------------------- static PacketHistory *ph = nullptr; +class RecordingPrint : public Print +{ + public: + size_t write(uint8_t value) override + { + output.push_back(value); + return 1; + } + + std::vector output; +}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -65,6 +80,19 @@ void test_init_valid_size(void) TEST_ASSERT_TRUE(h.initOk()); } +void test_init_default_size_does_not_warn(void) +{ + RecordingPrint sink; + console->setDestination(&sink); + + PacketHistory h; + + console->setDestination(&Serial); + const std::string output(sink.output.begin(), sink.output.end()); + TEST_ASSERT_TRUE(h.initOk()); + TEST_ASSERT_NULL(strstr(output.c_str(), "Packet History - Invalid size")); +} + void test_init_minimum_size(void) { PacketHistory h(4); @@ -741,6 +769,7 @@ void setup() // Group 1 - Initialization RUN_TEST(test_init_valid_size); + RUN_TEST(test_init_default_size_does_not_warn); RUN_TEST(test_init_minimum_size); RUN_TEST(test_init_too_small_falls_back); From 66d1a37871da0161a2ca5c354a6ddad6bf547d2d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:27:34 +0000 Subject: [PATCH 103/143] chore(deps): update meshtastic/device-ui digest to 7bdde1f (#11767) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index a9ffece8c4..1489a1908b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -140,7 +140,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/69d7000134f28cd70f404608e52364239804afc7.zip + https://github.com/meshtastic/device-ui/archive/7bdde1fb941caba2c8be0b06947f7c75e0458c31.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 9f51963b4223a0c139d69b0b42a60b1095fae416 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 9 Sep 2026 13:28:52 +0000 Subject: [PATCH 104/143] fix(xmodem): return the phone-facing packet by const reference (#11781) cppcheck reports returnByReference on XModemAdapter::getForPhone(): `meshtastic_XModem` carries a 128-byte payload buffer plus header fields, so returning it by value copied the whole struct on every call. Return `const meshtastic_XModem &` instead, and mark the method const - it is a pure read of xmodemStore, with resetForPhone() being what drains it. Every caller either copies into a value or reads a single field, so no call site changes. Co-authored-by: Claude Opus 5 Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> --- src/xmodem.cpp | 2 +- src/xmodem.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/xmodem.cpp b/src/xmodem.cpp index 3c318f8021..3183de6385 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -124,7 +124,7 @@ void XModemAdapter::sendControl(meshtastic_XModem_Control c) packetReady.notifyObservers(packetno); } -meshtastic_XModem XModemAdapter::getForPhone() +const meshtastic_XModem &XModemAdapter::getForPhone() const { return xmodemStore; } diff --git a/src/xmodem.h b/src/xmodem.h index 6119a7b509..996b5cf109 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -49,7 +49,7 @@ class XModemAdapter XModemAdapter(); void handlePacket(meshtastic_XModem xmodemPacket); - meshtastic_XModem getForPhone(); + const meshtastic_XModem &getForPhone() const; void resetForPhone(); // True while a file transfer is in flight; lets callers avoid racing our `file` handle. From fa81b47ecbcf04ef88d7658280a88a9dd130c478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 9 Sep 2026 16:25:49 +0000 Subject: [PATCH 105/143] refactor(sensorlib): unify on 0.4.1 and move the PCF RTCs to PCF8xRTC (#11754) * chore(deps): unify SensorLib on 0.4.1 and port the 0.4.x API changes The 19 SensorLib declarations were split across 0.3.1, 0.3.4 and 0.4.1. Pin all of them to 0.4.1 and fix the renovate datasource on ThinkNode-M9 (custom -> custom.pio). API changes in 0.4.x: - BMA423Sensor: the configAccelerometer/enableFeature/readIrqStatus surface is gone. SensorBMA423 now derives from SensorBMA4XX and dispatches tilt and tap through callbacks driven by update(). - BHI260APSensor: SensorRemap is a scoped enum in sensor/SensorDefs.hpp, and BoschSensorInfo members are protected, so read them through the accessors. - ExtensionIOXL9555 is renamed to IoExpanderXL9555 and the touch drivers moved under touch/. Point the includes at the current paths instead of the compatibility shims, which emit warnings on every build. Drop four lewisxhe/PCF8563_Library declarations. Nothing in the tree includes pcf8563.h; all RTC code goes through SensorLib. Drop the BMA423_INT block. No variant defines BMA423_INT (t-watch-s3 defines BMA4XX_INT), so it has never been compiled. Interrupt-driven wake on BMA423 is unimplemented rather than regressed by this change. Drop the T_WATCH_S3 branch in BHI260APSensor. That file requires HAS_BHI260AP, which T_WATCH_S3 does not define. SensorQMC6309.hpp does not exist in 0.3.4, so src/motion/QMC6309Sensor.cpp compiles for the first time on 0.4.1. * fix(motion): fail BMA423 init when the sensor rejects its configuration configAccelerometer, enableTiltDetector and enableTapDetector return false only on an I2C or driver-level failure, so treat them the way QMC6309Sensor treats configMagnetometer rather than initializing a sensor that never took its settings. Trim the t-watch-ultra placement comment to the two lines the coding guidelines allow. * refactor(rtc): drive the PCF clocks from PCF8xRTC instead of SensorLib SensorLib reaches its PCF8563 and PCF85063 drivers through a comm layer spanning Arduino, ESP-IDF, SPI and custom callbacks, which is a lot of code to link for four calls on an I2C RTC. Measured against develop, the boards that pull SensorLib grew about 10 KB moving from 0.3.4 to 0.4.1, while the nRF52 boards that do not pull it moved by 100-300 bytes. meshtastic/PCF8xRTC covers both parts in one class over Adafruit BusIO, which every board with a PCF part already links. Ten boards used SensorLib for nothing but the RTC and now drop it entirely; the remaining seven keep it for a BMA423, BHI260AP, QMI8658, XL9555 or touch controller and take the new driver for their RTC. Behaviour changes with it. Both parts latch an oscillator-stop flag on power loss, which the old path ignored: readFromRTC() now refuses a calendar the chip has marked invalid rather than feeding a plausible wrong date to BUILD_EPOCH, the result of begin() is checked, and a failed set is logged. The isBitSet workaround moves from configuration.h to MMC5983MASensor.h. It worked only because configuration.h pulled SensorLib.h in first, so the later include was a no-op and the macro stayed undefined; with the global include gone it has to sit where SensorLib and the SparkFun header actually meet. * fix(rtc): report a missing PCF chip separately from a stopped oscillator lostPower() reads a register, so it also returns true when the chip cannot be reached at all. Folding it into one warning meant a failed begin() reported "oscillator stopped", which is a different fault. * fix(t5s3): read GT911 touches through getTouchPoints 0.4.x dropped the default argument from getPoint(x, y, count) and marked it deprecated, so the two-argument call no longer resolves: variant.cpp:618:27: error: no matching function for call to 'TouchDrvGT911::getPoint(int16_t*, int16_t*)' Use getTouchPoints(), which is what the deprecation points at, rather than passing the count to a call that is on its way out. --- src/configuration.h | 19 ++++-- src/gps/RTC.cpp | 53 ++++++++------- src/motion/BHI260APSensor.cpp | 24 +++---- src/motion/BMA423Sensor.cpp | 64 ++++++++----------- src/motion/BMA423Sensor.h | 2 +- src/motion/MMC5983MASensor.h | 6 ++ .../extra_variants/t-watch-ultra/variant.cpp | 14 ++-- .../extra_variants/t5s3_epaper/variant.cpp | 10 +-- .../tbeam_displayshield/variant.cpp | 2 +- variants/esp32/m5stack_coreink/platformio.ini | 4 +- variants/esp32/tbeam/platformio.ini | 2 +- .../ELECROW-ThinkNode-M5/platformio.ini | 4 +- .../ELECROW-ThinkNode-M9/platformio.ini | 4 +- .../esp32s3/meshnology-w10/platformio.ini | 5 +- .../esp32s3/mini-epaper-s3/platformio.ini | 8 +-- variants/esp32s3/t-connect-pro/platformio.ini | 2 +- variants/esp32s3/t-watch-s3/platformio.ini | 4 +- variants/esp32s3/t-watch-ultra/platformio.ini | 4 +- variants/esp32s3/t-watch-ultra/variant.h | 4 +- variants/esp32s3/t5s3_epaper/platformio.ini | 4 +- variants/esp32s3/tbeam-s3-core/platformio.ini | 4 +- variants/esp32s3/tlora-pager/platformio.ini | 6 +- variants/esp32s3/tlora-pager/variant.cpp | 4 +- variants/esp32s3/tlora-pager/variant.h | 4 +- .../ELECROW-ThinkNode-M3/platformio.ini | 4 +- .../ELECROW-ThinkNode-M4/platformio.ini | 2 - .../ELECROW-ThinkNode-M6/platformio.ini | 4 +- .../ELECROW-ThinkNode-M8/platformio.ini | 4 +- .../heltec_mesh_node_t1/platformio.ini | 1 - .../nrf52840/nano-g2-ultra/platformio.ini | 4 +- variants/nrf52840/t-echo-plus/platformio.ini | 2 - variants/nrf52840/t-echo/platformio.ini | 8 +-- 32 files changed, 139 insertions(+), 147 deletions(-) diff --git a/src/configuration.h b/src/configuration.h index 85a085053b..2018fe678c 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -29,18 +29,23 @@ along with this program. If not, see . #if __has_include("Melopero_RV3028.h") #include "Melopero_RV3028.h" #endif -#if __has_include("SensorRtcHelper.hpp") -#include "SensorRtcHelper.hpp" -// SensorLib defines isBitSet as a macro; undefine it here to avoid conflicts -// with the SparkFun MMC5983MA library, which has a class method of the same name. -#ifdef isBitSet -#undef isBitSet -#endif +#if __has_include() +#include #endif /* Offer chance for variant-specific defines */ #include "variant.h" +// Both PCF parts answer at the same address and differ only in register layout, so a variant +// picks one by defining PCF8563_RTC or PCF85063_RTC to it. +#if defined(PCF8563_RTC) +#define PCF_RTC_ADDRESS PCF8563_RTC +#define PCF_RTC_CHIP PCF8xRTC::PCF8563 +#elif defined(PCF85063_RTC) +#define PCF_RTC_ADDRESS PCF85063_RTC +#define PCF_RTC_CHIP PCF8xRTC::PCF85063 +#endif + // ----------------------------------------------------------------------------- // Display feature overrides // ----------------------------------------------------------------------------- diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 6423c1b00f..58c00e12ee 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -156,24 +156,25 @@ RTCSetResult readFromRTC() LOG_WARN("RTC read: not found (addr 0x%02X)", rtc_found.address); } #elif defined(PCF8563_RTC) || defined(PCF85063_RTC) -#if defined(PCF8563_RTC) - if (rtc_found.address == PCF8563_RTC) { - SensorPCF8563 rtc; -#elif defined(PCF85063_RTC) - if (rtc_found.address == PCF85063_RTC) { - SensorPCF85063 rtc; - -#endif + if (rtc_found.address == PCF_RTC_ADDRESS) { + PCF8xRTC rtc; const uint64_t now = Time::getMillisMonotonic(); #if WIRE_INTERFACES_COUNT == 2 - rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found)); + TwoWire &rtcBus = *ScanI2CTwoWire::fetchI2CBus(rtc_found); #else - rtc.begin(Wire); + TwoWire &rtcBus = Wire; #endif - - RTC_DateTime datetime = rtc.getDateTime(); - tm t = datetime.toUnixTime(); + tm t; + if (!rtc.begin(rtcBus, PCF_RTC_ADDRESS, PCF_RTC_CHIP)) { + LOG_WARN("%s not responding at 0x%02X", rtc.chipName(), PCF_RTC_ADDRESS); + return RTCSetResultInvalidTime; + } + if (!rtc.getTime(t)) { + // Only the chip itself can tell us the oscillator stopped, so ask after begin() worked. + LOG_WARN("%s read failed%s", rtc.chipName(), rtc.lostPower() ? " (oscillator stopped)" : ""); + return RTCSetResultInvalidTime; + } tv.tv_sec = gm_mktime(&t); tv.tv_usec = 0; uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms @@ -188,7 +189,7 @@ RTCSetResult readFromRTC() } #endif - LOG_DEBUG_GPS("RTC time from %s getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t.tm_year + 1900, + LOG_DEBUG_GPS("RTC time from %s getTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.chipName(), t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; @@ -357,27 +358,23 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address); } #elif defined(PCF8563_RTC) || defined(PCF85063_RTC) -#if defined(PCF8563_RTC) - if (rtc_found.address == PCF8563_RTC) { - SensorPCF8563 rtc; -#elif defined(PCF85063_RTC) - if (rtc_found.address == PCF85063_RTC) { - SensorPCF85063 rtc; - -#endif - + if (rtc_found.address == PCF_RTC_ADDRESS) { + PCF8xRTC rtc; #if WIRE_INTERFACES_COUNT == 2 - rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found)); + TwoWire &rtcBus = *ScanI2CTwoWire::fetchI2CBus(rtc_found); #else - rtc.begin(Wire); + TwoWire &rtcBus = Wire; #endif // tv_sec is a long, which is not time_t everywhere: on Windows // time_t is 64-bit while long is 32-bit. Copy before taking &. time_t setSecs = tv->tv_sec; const tm *t = gmtime(&setSecs); - rtc.setDateTime(*t); - LOG_DEBUG_GPS("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, - t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + if (rtc.begin(rtcBus, PCF_RTC_ADDRESS, PCF_RTC_CHIP) && rtc.setTime(*t)) { + LOG_DEBUG_GPS("%s setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.chipName(), t->tm_year + 1900, t->tm_mon + 1, + t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + } else { + LOG_WARN("%s set time failed", rtc.chipName()); + } } else { LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address); } diff --git a/src/motion/BHI260APSensor.cpp b/src/motion/BHI260APSensor.cpp index 90c6e1f82a..c9efda96c6 100644 --- a/src/motion/BHI260APSensor.cpp +++ b/src/motion/BHI260APSensor.cpp @@ -2,7 +2,6 @@ #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && defined(HAS_BHI260AP) && __has_include() #define BOSCH_BHI260_KLIO -#define USING_DATA_HELPER #include BHI260APSensor::BHI260APSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} @@ -14,16 +13,16 @@ bool BHI260APSensor::init() sensor.setFirmware(bosch_firmware_image, bosch_firmware_size, bosch_firmware_type); sensor.setBootFromFlash(bosch_firmware_type); if (sensor.begin(Wire, deviceAddress())) { - sensor.setRemapAxes(SensorBHI260AP::TOP_LAYER_BOTTOM_RIGHT_CORNER); + sensor.setRemapAxes(SensorRemap::TOP_LAYER_BOTTOM_RIGHT_CORNER); BoschSensorInfo info = sensor.getSensorInfo(); - LOG_INFO("Product ID : %02x\n", info.product_id); - LOG_INFO("Kernel version : %04u\n", info.kernel_version); - LOG_INFO("User version : %04u\n", info.user_version); - LOG_INFO("ROM version : %04u\n", info.rom_version); - LOG_INFO("Power state : %s\n", (info.host_status & BHY2_HST_POWER_STATE) ? "sleeping" : "active"); - LOG_INFO("Host interface : %s\n", (info.host_status & BHY2_HST_HOST_PROTOCOL) ? "SPI" : "I2C"); - LOG_INFO("Feature status : 0x%02x\n", info.feat_status); + LOG_INFO("Product ID : %02x\n", info.getProductId()); + LOG_INFO("Kernel version : %04u\n", info.getKernelVersion()); + LOG_INFO("User version : %04u\n", info.getUserVersion()); + LOG_INFO("ROM version : %04u\n", info.getRomVersion()); + LOG_INFO("Power state : %s\n", (info.getHostStatus() & BHY2_HST_POWER_STATE) ? "sleeping" : "active"); + LOG_INFO("Host interface : %s\n", (info.getHostStatus() & BHY2_HST_HOST_PROTOCOL) ? "SPI" : "I2C"); + LOG_INFO("Feature status : 0x%02x\n", info.getFeatStatus()); stepCounter = new SensorStepCounter(sensor); // stepDetector = new SensorStepDetector(sensor); @@ -42,13 +41,6 @@ bool BHI260APSensor::init() RISING); // Select the interrupt mode according to the actual circuit #endif -#ifdef T_WATCH_S3 - // Need to raise the wrist function, need to set the correct axis - sensor.setRemapAxes(sensor.REMAP_TOP_LAYER_RIGHT_CORNER); -#else - // sensor.setRemapAxes(sensor.REMAP_BOTTOM_LAYER_BOTTOM_LEFT_CORNER); -#endif - // stepDetector->enable(1.0, 0); stepCounter->enable(1.0, 0); LOG_DEBUG("BHI260AP init ok"); diff --git a/src/motion/BMA423Sensor.cpp b/src/motion/BMA423Sensor.cpp index 5111dae325..98491311b9 100755 --- a/src/motion/BMA423Sensor.cpp +++ b/src/motion/BMA423Sensor.cpp @@ -6,55 +6,45 @@ BMA423Sensor::BMA423Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::Mot bool BMA423Sensor::init() { - if (sensor.begin(Wire, deviceAddress())) { - sensor.configAccelerometer(sensor.RANGE_2G, sensor.ODR_100HZ, sensor.BW_NORMAL_AVG4, sensor.PERF_CONTINUOUS_MODE); - sensor.enableAccelerometer(); - sensor.configInterrupt(); + if (!sensor.begin(Wire, deviceAddress())) { + LOG_DEBUG("BMA423 init failed"); + return false; + } -#ifdef BMA423_INT - pinMode(BMA4XX_INT, INPUT); - attachInterrupt( - BMA4XX_INT, - [] { - // Set interrupt to set irq value to true - BMA_IRQ = true; - }, - RISING); // Select the interrupt mode according to the actual circuit -#endif + if (!sensor.configAccelerometer(OperationMode::NORMAL, AccelFullScaleRange::FS_2G, 100.0f, AccelBandwidth::NORMAL_AVG4, + AccelPerfMode::CONTINUOUS_MODE)) { + LOG_DEBUG("BMA423 accelerometer config failed"); + return false; + } #ifdef T_WATCH_S3 - // Need to raise the wrist function, need to set the correct axis - sensor.setRemapAxes(sensor.REMAP_TOP_LAYER_RIGHT_CORNER); + // Need to raise the wrist function, need to set the correct axis + sensor.setRemapAxes(SensorRemap::TOP_LAYER_RIGHT_CORNER); #else - sensor.setRemapAxes(sensor.REMAP_BOTTOM_LAYER_BOTTOM_LEFT_CORNER); + sensor.setRemapAxes(SensorRemap::BOTTOM_LAYER_BOTTOM_LEFT_CORNER); #endif - // sensor.enableFeature(sensor.FEATURE_STEP_CNTR, true); - sensor.enableFeature(sensor.FEATURE_TILT, true); - sensor.enableFeature(sensor.FEATURE_WAKEUP, true); - // sensor.resetPedometer(); - // Turn on feature interrupt - sensor.enablePedometerIRQ(); - sensor.enableTiltIRQ(); - - // It corresponds to isDoubleClick interrupt - sensor.enableWakeupIRQ(); - LOG_DEBUG("BMA423 init ok"); - return true; + // The tap detector defaults to double tap; tilt and double tap both wake the screen. + sensor.setOnTiltDetectedCallback([this] { wakeRequested = true; }); + sensor.setOnTapCallback([this](TapType) { wakeRequested = true; }); + if (!sensor.enableTiltDetector(true, true) || !sensor.enableTapDetector(true, true)) { + LOG_DEBUG("BMA423 wake detector setup failed"); + return false; } - LOG_DEBUG("BMA423 init failed"); - return false; + + LOG_DEBUG("BMA423 init ok"); + return true; } int32_t BMA423Sensor::runOnce() { - if (sensor.readIrqStatus()) { - if (sensor.isTilt() || sensor.isDoubleTap()) { - wakeScreen(); - return 500; - } + wakeRequested = false; + sensor.update(); + if (wakeRequested) { + wakeScreen(); + return 500; } return MOTION_SENSOR_CHECK_INTERVAL_MS; } -#endif \ No newline at end of file +#endif diff --git a/src/motion/BMA423Sensor.h b/src/motion/BMA423Sensor.h index b9d7b4aa0d..7ce5525c66 100755 --- a/src/motion/BMA423Sensor.h +++ b/src/motion/BMA423Sensor.h @@ -13,7 +13,7 @@ class BMA423Sensor : public MotionSensor { private: SensorBMA423 sensor; - volatile bool BMA_IRQ = false; + bool wakeRequested = false; public: explicit BMA423Sensor(ScanI2C::FoundDevice foundDevice); diff --git a/src/motion/MMC5983MASensor.h b/src/motion/MMC5983MASensor.h index 9d349b53a4..f3a6696f13 100644 --- a/src/motion/MMC5983MASensor.h +++ b/src/motion/MMC5983MASensor.h @@ -6,6 +6,12 @@ #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() +// SensorLib defines isBitSet as a macro, which collides with the class method of the same name +// below. Drop it here, where the two actually meet, rather than relying on include order. +#ifdef isBitSet +#undef isBitSet +#endif + #include class MMC5983MASensor : public MotionSensor diff --git a/src/platform/extra_variants/t-watch-ultra/variant.cpp b/src/platform/extra_variants/t-watch-ultra/variant.cpp index f77c1e97b5..252a9fa143 100644 --- a/src/platform/extra_variants/t-watch-ultra/variant.cpp +++ b/src/platform/extra_variants/t-watch-ultra/variant.cpp @@ -2,19 +2,15 @@ #ifdef T_WATCH_ULTRA -// Board-specific init lives here (rather than in variants/esp32s3/t-watch-ultra/variant.cpp) -// so that PlatformIO's library dependency finder can resolve headers such as -// input/TouchScreenImpl1.h (which transitively pulls in the ArduinoThread "Thread.h"), -// ExtensionIOXL9555.hpp and TouchDrvCSTXXX.hpp. Files compiled from outside src/ only get -// include paths for libraries they reference directly, so the transitive Thread.h include -// is not found there. See src/platform/extra_variants/README.md. +// Lives here, not under variants/, so PlatformIO's LDF resolves the Thread.h that +// input/TouchScreenImpl1.h pulls in transitively. See extra_variants/README.md. -#include "TouchDrvCSTXXX.hpp" #include "input/TouchScreenImpl1.h" -#include +#include "touch/TouchDrvCST92xx.h" +#include #include -static ExtensionIOXL9555 io; +static IoExpanderXL9555 io; static TouchDrvCST92xx touchDrv; void earlyInitVariant() diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp index a83b04b0fb..548fec421d 100644 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -3,7 +3,6 @@ #ifdef T5_S3_EPAPER_PRO #include "Observer.h" -#include "TouchDrvGT911.hpp" #include "Wire.h" #include "buzz.h" #include "concurrency/OSThread.h" @@ -12,6 +11,7 @@ #include "main.h" #include "mesh/Throttle.h" #include "sleep.h" +#include "touch/TouchDrvGT911.hpp" #include #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS @@ -613,9 +613,11 @@ bool readTouch(int16_t *x, int16_t *y) #endif if (!digitalRead(GT911_PIN_INT)) { - int16_t raw_x; - int16_t raw_y; - if (touch.getPoint(&raw_x, &raw_y)) { + // 0.4.x deprecates getPoint() in favour of getTouchPoints(); only the first touch is used here. + const TouchPoints &points = touch.getTouchPoints(); + if (points.getPointCount()) { + const int16_t raw_x = static_cast(points.getPoint(0).x); + const int16_t raw_y = static_cast(points.getPoint(0).y); #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // Transform raw GT911 axes to visual-frame coordinates for the current display rotation. // rotation=3 is the physical identity (device's default orientation). diff --git a/src/platform/extra_variants/tbeam_displayshield/variant.cpp b/src/platform/extra_variants/tbeam_displayshield/variant.cpp index a6bb2f86ab..af3fb13b91 100644 --- a/src/platform/extra_variants/tbeam_displayshield/variant.cpp +++ b/src/platform/extra_variants/tbeam_displayshield/variant.cpp @@ -2,8 +2,8 @@ #ifdef HAS_CST226SE -#include "TouchDrvCSTXXX.hpp" #include "input/TouchScreenImpl1.h" +#include "touch/TouchDrvCST226.h" #include #ifndef TOUCH_RST diff --git a/variants/esp32/m5stack_coreink/platformio.ini b/variants/esp32/m5stack_coreink/platformio.ini index 9d31c50ed5..f727c6371d 100644 --- a/variants/esp32/m5stack_coreink/platformio.ini +++ b/variants/esp32/m5stack_coreink/platformio.ini @@ -20,8 +20,8 @@ lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2 zinggjm/GxEPD2@1.6.9 - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip lib_ignore = m5stack-coreink monitor_filters = esp32_exception_decoder diff --git a/variants/esp32/tbeam/platformio.ini b/variants/esp32/tbeam/platformio.ini index 79277f840b..7669afc57f 100644 --- a/variants/esp32/tbeam/platformio.ini +++ b/variants/esp32/tbeam/platformio.ini @@ -31,4 +31,4 @@ lib_deps = # renovate: datasource=github-tags depName=meshtastic-st7796 packageName=meshtastic/st7796 https://github.com/meshtastic/st7796/archive/1.0.5.zip # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 diff --git a/variants/esp32s3/ELECROW-ThinkNode-M5/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M5/platformio.ini index 17e2b68823..48fccb750b 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M5/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M5/platformio.ini @@ -35,5 +35,5 @@ lib_deps = ${esp32s3_base.lib_deps} https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip # renovate: datasource=custom.pio depName=PCA9557-arduino packageName=maxpromer/library/PCA9557-arduino maxpromer/PCA9557-arduino@1.0.0 - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 \ No newline at end of file + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini index b0c0111ba5..dd7dc4a09d 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini @@ -36,8 +36,8 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.26 - # renovate: datasource=custom depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip [env:thinknode_m9] extends = thinknode_m9_base diff --git a/variants/esp32s3/meshnology-w10/platformio.ini b/variants/esp32s3/meshnology-w10/platformio.ini index 73168ac569..5b0736b6a6 100644 --- a/variants/esp32s3/meshnology-w10/platformio.ini +++ b/variants/esp32s3/meshnology-w10/platformio.ini @@ -32,8 +32,11 @@ lib_deps = ; renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.21 ; PCF85063 RTC driver (PCF85063_RTC) + ; renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip + ; QMI8658 IMU driver ; renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 ; ES8311 audio codec + I2S notification tones (HAS_I2S) ; renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip diff --git a/variants/esp32s3/mini-epaper-s3/platformio.ini b/variants/esp32s3/mini-epaper-s3/platformio.ini index e7daf63e09..3bd2032b26 100644 --- a/variants/esp32s3/mini-epaper-s3/platformio.ini +++ b/variants/esp32s3/mini-epaper-s3/platformio.ini @@ -32,8 +32,8 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-GxEPD2 packageName=https://github.com/meshtastic/GxEPD2 gitBranch=master https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip [env:mini-epaper-s3-inkhud] extends = esp32s3_base, inkhud @@ -51,5 +51,5 @@ build_flags = lib_deps = ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX ${esp32s3_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/esp32s3/t-connect-pro/platformio.ini b/variants/esp32s3/t-connect-pro/platformio.ini index 3a8692c235..378e12f941 100644 --- a/variants/esp32s3/t-connect-pro/platformio.ini +++ b/variants/esp32s3/t-connect-pro/platformio.ini @@ -29,7 +29,7 @@ lib_deps = # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.28 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 custom_sdkconfig = ${esp32s3_base.custom_sdkconfig} diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index 46c41ac5fd..2246dd2df6 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -24,7 +24,9 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.28 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library adafruit/Adafruit DRV2605 Library@1.2.4 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix diff --git a/variants/esp32s3/t-watch-ultra/platformio.ini b/variants/esp32s3/t-watch-ultra/platformio.ini index 8564ec079f..9e43f64887 100644 --- a/variants/esp32s3/t-watch-ultra/platformio.ini +++ b/variants/esp32s3/t-watch-ultra/platformio.ini @@ -54,7 +54,9 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM earlephilhower/ESP8266SAM@1.1.0 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.1 + lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip [env:t-watch-ultra-tft] board_level = extra diff --git a/variants/esp32s3/t-watch-ultra/variant.h b/variants/esp32s3/t-watch-ultra/variant.h index ed9431b6c8..62af5168c7 100644 --- a/variants/esp32s3/t-watch-ultra/variant.h +++ b/variants/esp32s3/t-watch-ultra/variant.h @@ -43,8 +43,8 @@ // External expansion chip XL9555 #define USE_PCA95X5 -#define PCA95X5_CLS ExtensionIOXL9555 -#define PCA95X5_INC "ExtensionIOXL9555.hpp" +#define PCA95X5_CLS IoExpanderXL9555 +#define PCA95X5_INC "IoExpanderXL9555.hpp" // PCF85063 RTC Module #define PCF85063_RTC 0x51 diff --git a/variants/esp32s3/t5s3_epaper/platformio.ini b/variants/esp32s3/t5s3_epaper/platformio.ini index d4ba001586..d44bfae86a 100644 --- a/variants/esp32s3/t5s3_epaper/platformio.ini +++ b/variants/esp32s3/t5s3_epaper/platformio.ini @@ -23,7 +23,9 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip diff --git a/variants/esp32s3/tbeam-s3-core/platformio.ini b/variants/esp32s3/tbeam-s3-core/platformio.ini index b0421855cd..f58d718603 100644 --- a/variants/esp32s3/tbeam-s3-core/platformio.ini +++ b/variants/esp32s3/tbeam-s3-core/platformio.ini @@ -18,8 +18,8 @@ board_build.partitions = default_8MB.csv lib_deps = ${esp32s3_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip build_flags = ${esp32s3_base.build_flags} diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index 732ab40402..1c494d8a4c 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -40,10 +40,10 @@ lib_deps = ${esp32s3_base.lib_deps} earlephilhower/ESP8266SAM@1.1.0 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library adafruit/Adafruit DRV2605 Library@1.2.4 - # renovate: datasource=custom.pio depName=PCF8563 packageName=lewisxhe/library/PCF8563_Library - lewisxhe/PCF8563_Library@1.0.1 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + lewisxhe/SensorLib@0.4.1 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.1.zip # TODO renovate diff --git a/variants/esp32s3/tlora-pager/variant.cpp b/variants/esp32s3/tlora-pager/variant.cpp index 7b0cbdfece..ddceee5555 100644 --- a/variants/esp32s3/tlora-pager/variant.cpp +++ b/variants/esp32s3/tlora-pager/variant.cpp @@ -1,6 +1,6 @@ #include "variant.h" -#include "ExtensionIOXL9555.hpp" -extern ExtensionIOXL9555 io; +#include "IoExpanderXL9555.hpp" +extern IoExpanderXL9555 io; void earlyInitVariant() { diff --git a/variants/esp32s3/tlora-pager/variant.h b/variants/esp32s3/tlora-pager/variant.h index a152fdddf5..53869947a3 100644 --- a/variants/esp32s3/tlora-pager/variant.h +++ b/variants/esp32s3/tlora-pager/variant.h @@ -89,8 +89,8 @@ // External expansion chip XL9555 #define USE_PCA95X5 -#define PCA95X5_CLS ExtensionIOXL9555 -#define PCA95X5_INC "ExtensionIOXL9555.hpp" +#define PCA95X5_CLS IoExpanderXL9555 +#define PCA95X5_INC "IoExpanderXL9555.hpp" #define EXPANDS_DRV_EN (0) #define EXPANDS_AMP_EN (1) #define EXPANDS_KB_RST (2) diff --git a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini index 2b6b9aabfc..c41d415d9c 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini @@ -23,5 +23,5 @@ lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=custom.pio depName=nRF52_PWM packageName=khoih-prog/library/nRF52_PWM khoih-prog/nRF52_PWM@1.0.1 - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/nrf52840/ELECROW-ThinkNode-M4/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M4/platformio.ini index 4c6de0e4e7..654055aefc 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M4/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M4/platformio.ini @@ -12,5 +12,3 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ELECROW-ThinkNode-M4> lib_deps = ${nrf52840_base.lib_deps} - # renovate: datasource=custom.pio depName=PCF8563 packageName=lewisxhe/library/PCF8563_Library - lewisxhe/PCF8563_Library@1.0.1 diff --git a/variants/nrf52840/ELECROW-ThinkNode-M6/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M6/platformio.ini index 3166494ce5..60deb337b8 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M6/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M6/platformio.ini @@ -21,5 +21,5 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ELECROW-ThinkNode-M6> lib_deps = ${nrf52840_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini index fa1f3cf111..3ad343a4e3 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini @@ -35,7 +35,7 @@ lib_deps = https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip # renovate: datasource=custom.pio depName=nRF52_PWM packageName=khoih-prog/library/nRF52_PWM khoih-prog/nRF52_PWM@1.0.1 - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip diff --git a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini index 3ec2997797..65c5a6e23e 100644 --- a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini @@ -32,5 +32,4 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_mesh_node_t1> lib_deps = ${nrf52840_base.lib_deps} - lewisxhe/PCF8563_Library@^1.0.1 bodmer/TFT_eSPI@2.5.43 diff --git a/variants/nrf52840/nano-g2-ultra/platformio.ini b/variants/nrf52840/nano-g2-ultra/platformio.ini index ae7f7599a9..71f18eacc7 100644 --- a/variants/nrf52840/nano-g2-ultra/platformio.ini +++ b/variants/nrf52840/nano-g2-ultra/platformio.ini @@ -20,6 +20,6 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/nano-g2-ultra> lib_deps = ${nrf52840_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip ;upload_protocol = fs diff --git a/variants/nrf52840/t-echo-plus/platformio.ini b/variants/nrf52840/t-echo-plus/platformio.ini index 8a567061f4..765c53736a 100644 --- a/variants/nrf52840/t-echo-plus/platformio.ini +++ b/variants/nrf52840/t-echo-plus/platformio.ini @@ -32,7 +32,5 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/t-echo- lib_deps = ${nrf52840_base.lib_deps} https://github.com/meshtastic/GxEPD2/archive/55f618961db45a23eff0233546430f1e5a80f63a.zip - # renovate: datasource=custom.pio depName=PCF8563 packageName=lewisxhe/library/PCF8563_Library - lewisxhe/PCF8563_Library@1.0.1 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library adafruit/Adafruit DRV2605 Library@1.2.4 diff --git a/variants/nrf52840/t-echo/platformio.ini b/variants/nrf52840/t-echo/platformio.ini index fe166ccf7f..f2e1b107d6 100644 --- a/variants/nrf52840/t-echo/platformio.ini +++ b/variants/nrf52840/t-echo/platformio.ini @@ -31,8 +31,8 @@ lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-GxEPD2 packageName=https://github.com/meshtastic/GxEPD2 gitBranch=master https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip ;upload_protocol = fs [env:t-echo-inkhud] @@ -51,5 +51,5 @@ build_src_filter = lib_deps = ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX ${nrf52840_base.lib_deps} - # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib - lewisxhe/SensorLib@0.3.4 + # renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main + https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip From 7aaf647064bd6661d92d284bf64d5ec542a06cd0 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 9 Sep 2026 17:11:25 +0000 Subject: [PATCH 106/143] Actions: Fix uploading zips to GitHub Releases (#11793) The last two GitHub releases were *empty*. master -> develop bit us here, update the workflows to track the default branch instead. --- .github/workflows/main_matrix.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index eec6f5ef82..95f965cf15 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -568,8 +568,8 @@ jobs: path: firmware-${{ needs.version.outputs.long }}.json - name: Add sources to GitHub Release - # Only run when targeting master branch with workflow_dispatch - if: github.ref_name == 'master' + # Only run when targeting the default branch with workflow_dispatch + if: github.ref_name == github.event.repository.default_branch run: | gh release upload v${{ needs.version.outputs.long }} ./firmware-${{ needs.version.outputs.long }}.json gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip @@ -635,8 +635,8 @@ jobs: run: ls -lR - name: Add bins and debug elfs to GitHub Release - # Only run when targeting master branch with workflow_dispatch - if: github.ref_name == 'master' + # Only run when targeting the default branch with workflow_dispatch + if: github.ref_name == github.event.repository.default_branch run: | gh release upload v${{ needs.version.outputs.long }} ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip gh release upload v${{ needs.version.outputs.long }} ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip From 0c4bee7a7be5aa79c8fba1c7e1cf19e6bab42437 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 9 Sep 2026 20:15:57 +0000 Subject: [PATCH 107/143] fix(sx126x): let CalibrateImage settle before re-applying RX registers in resetAGC() (#11774) * fix(sx126x): let CalibrateImage settle before re-applying RX registers in resetAGC() CalibrateImage returns as soon as the command is accepted and BUSY does not stay asserted for the rest of the calibration. resetAGC() then re-applies the RX boosted-gain and 0x8B5 registers immediately, and a register write landing in that window fails write-verify (RADIOLIB_ERR_SPI_WRITE_FAILED), leaving the chip needing a full re-init. On RAK3401 + RAK13302 (nRF52840, busy mesh) this hit ~69% of resets with no delay, ~3% at 10-20 ms, and 0 at 50 ms. * fix(sx126x): number the CalibrateImage settle as step 6 and re-wrap the comment Review feedback: the settle is the wait for step 5's image calibration, just as step 4 waits for step 3, so number it and wrap to the width of the other steps. Resume receiving becomes step 7. No functional change. --------- Co-authored-by: Ben Meadors --- src/mesh/SX126xInterface.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index aa55335b44..ab1f5eb38b 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -569,6 +569,10 @@ template void SX126xInterface::resetAGC() // 5. Re-calibrate image rejection for actual operating frequency // Calibrate(0x7F) defaults to 902-928 MHz which is wrong for other regions. lora.calibrateImage(getFreq()); + // 6. CalibrateImage keeps working internally after it returns, and BUSY does not + // stay asserted for it; a register write in that window fails write-verify + // (RADIOLIB_ERR_SPI_WRITE_FAILED) and stalls the chip. + module.hal->delay(50); // Re-apply settings that calibration may have reset @@ -592,7 +596,7 @@ template void SX126xInterface::resetAGC() LOG_WARN("SX126x resetAGC: 0x8B5 RX patch re-apply failed"); } - // 6. Resume receiving + // 7. Resume receiving startReceive(); } From 2c595e935daa46e6396df88d023a497b72b6964c Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:50:46 +0000 Subject: [PATCH 108/143] feat(native): add macOS MUI simulator target (#11739) Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> --- src/main.cpp | 2 +- variants/native/portduino/platformio.ini | 29 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index db087d863c..bce0d6894c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1554,7 +1554,7 @@ void loop() rebootAtMsec = millis() + 25; } } -#if HAS_TFT +#if HAS_TFT && HAS_SCREEN if (screen && portduino_config.displayPanel == x11 && config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { auto dispdev = screen->getDisplayDevice(); diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 0a9db5d99a..412a971b5e 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -293,6 +293,35 @@ build_flags = ${env:native-macos.build_flags} build_src_filter = ${env:native-macos.build_src_filter} lib_ignore = ${env:native-macos.lib_ignore} +; Native macOS MUI window. This is the normal meshtasticd application using +; the X11 driver via XQuartz; it intentionally has no simulator fixtures. +[env:native-macos-tft] +extends = env:native-macos +build_type = release +build_unflags = ${env:native-macos.build_unflags} +build_flags = ${env:native-macos.build_flags} + -I/opt/X11/include + -L/opt/X11/lib + !pkg-config --cflags --libs x11 --silence-errors || : + !pkg-config --cflags --libs libcurl --silence-errors || : + -DRAM_SIZE=16384 + -DUSE_X11=1 + -DHAS_TFT=1 + -DLV_CACHE_DEF_SIZE=6291456 + -DLV_BUILD_TEST=0 + -DLV_USE_LIBINPUT=0 + -DLV_LIBINPUT_XKB=0 + -DLV_LVGL_H_INCLUDE_SIMPLE + -DLV_CONF_INCLUDE_SIMPLE + -DUSE_LOG_DEBUG + -DLOG_DEBUG_INC=\"DebugConfiguration.h\" + -DUSE_PACKET_API + -DVIEW_320x240 +lib_deps = + ${native_base.lib_deps} + ${device-ui_base.lib_deps} +lib_ignore = ${portduino_base.lib_ignore} + ; --------------------------------------------------------------------------- ; Native build for Windows (x86_64) via the MSYS2 UCRT64 MinGW-w64 toolchain. ; Headless meshtasticd.exe running in SimRadio mode (`-s`). No BlueZ, libgpiod or From 81b3ce8fc2a63e5d92c3b9188090339c44dc67aa Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 9 Sep 2026 20:57:40 +0000 Subject: [PATCH 109/143] Alternate button handling for Muzi Base without screen (#11800) --- src/input/InputBroker.cpp | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 8ea9af2bb3..30c1bd60e1 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -47,7 +47,7 @@ static bool touchBacklightActive = false; #endif #endif -#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) +#if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(MUZI_BASE) ButtonThread *UserButtonThread = nullptr; #endif @@ -504,11 +504,35 @@ void InputBroker::Init() } #endif #if !MESHTASTIC_EXCLUDE_INPUTBROKER && HAS_TRACKBALL - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + if (screen && config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { trackballInterruptImpl1 = new TrackballInterruptImpl1(); trackballInterruptImpl1->init(TB_DOWN, TB_UP, TB_LEFT, TB_RIGHT, TB_PRESS); } #endif +#if MUZI_BASE + if (!screen) { + UserButtonThread = new ButtonThread("UserButton"); + ButtonConfig userConfigNoScreen; + userConfigNoScreen.pinNumber = (uint8_t)TB_PRESS; + userConfigNoScreen.activeLow = true; + userConfigNoScreen.activePullup = true; + userConfigNoScreen.pullupSense = pullup_sense; + userConfigNoScreen.intRoutine = []() { + UserButtonThread->userButton.tick(); + UserButtonThread->setIntervalFromNow(0); + runASAP = true; + BaseType_t higherWake = 0; + concurrency::mainDelay.interruptFromISR(&higherWake); + }; + userConfigNoScreen.singlePress = INPUT_BROKER_USER_PRESS; + userConfigNoScreen.longPress = INPUT_BROKER_NONE; + userConfigNoScreen.longPressTime = 500; + userConfigNoScreen.longLongPress = INPUT_BROKER_SHUTDOWN; + userConfigNoScreen.doublePress = INPUT_BROKER_SEND_PING; + userConfigNoScreen.triplePress = INPUT_BROKER_GPS_TOGGLE; + UserButtonThread->initButton(userConfigNoScreen); + } +#endif #ifdef INPUTBROKER_EXPRESSLRSFIVEWAY_TYPE expressLRSFiveWayInput = new ExpressLRSFiveWay(); #endif From 973925b2199c4048ef961f222ae1267b8082c18d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:10:58 +0000 Subject: [PATCH 110/143] chore(deps): update meshtastic/device-ui digest to c6d003e (#11803) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 1489a1908b..ff108f2b2f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -140,7 +140,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/7bdde1fb941caba2c8be0b06947f7c75e0458c31.zip + https://github.com/meshtastic/device-ui/archive/c6d003eb6d6b65f74de6738be924a49a973608b9.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From a25ff005f093421aabe8a6fccb17b95d0f6488d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:35:20 +0200 Subject: [PATCH 111/143] Update protobufs (#11804) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/admin.pb.h | 6 +-- src/mesh/generated/meshtastic/apponly.pb.h | 2 +- src/mesh/generated/meshtastic/atak.pb.h | 46 +++++++++---------- src/mesh/generated/meshtastic/channel.pb.h | 18 ++++++-- src/mesh/generated/meshtastic/device_ui.pb.h | 4 ++ src/mesh/generated/meshtastic/deviceonly.pb.h | 4 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 18 ++++---- .../generated/meshtastic/mesh_beacon.pb.h | 4 +- .../generated/meshtastic/module_config.pb.h | 12 +++-- src/mesh/generated/meshtastic/telemetry.pb.h | 2 +- 12 files changed, 68 insertions(+), 52 deletions(-) diff --git a/protobufs b/protobufs index f008c459d7..251c52e897 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit f008c459d78de46779408d5bdb7bc0634550bb49 +Subproject commit 251c52e897bf40d99b141cf530755ac3a9069880 diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index ccf6f54c8b..036505da34 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -186,7 +186,7 @@ typedef struct _meshtastic_LockdownAuth { token at unlock time: the client-supplied boots_remaining when non-zero, otherwise the firmware default (TOKEN_DEFAULT_BOOTS). Note that boots_remaining == 0 in this message means "use firmware - default", NOT "zero boots" — a client computing the ceiling for + default", NOT "zero boots" - a client computing the ceiling for display should mirror that resolution rather than multiplying the raw request value. @@ -196,7 +196,7 @@ typedef struct _meshtastic_LockdownAuth { Uses millis() (CPU uptime), not wall-clock time, so the cap is immune to GPS spoofing, RTC backup-battery removal, and Faraday - cage isolation — none of those move the uptime counter. The only + cage isolation - none of those move the uptime counter. The only way to reset the session clock is a reboot, which costs a boot from the on-flash, HMAC-bound counter. */ uint32_t max_session_seconds; @@ -213,7 +213,7 @@ typedef struct _meshtastic_LockdownAuth { NOT reversed by this operation: APPROTECT. Once the debug port lockout has been burned (on silicon where it is effective) it is - permanent — disabling lockdown decrypts your data and removes the + permanent - disabling lockdown decrypts your data and removes the access gates, but the SWD/JTAG port stays locked for the life of the device (recoverable only via a full chip erase over a debug probe, which destroys all data). Clients should make this diff --git a/src/mesh/generated/meshtastic/apponly.pb.h b/src/mesh/generated/meshtastic/apponly.pb.h index 88cbcb5e67..592dc68157 100644 --- a/src/mesh/generated/meshtastic/apponly.pb.h +++ b/src/mesh/generated/meshtastic/apponly.pb.h @@ -55,7 +55,7 @@ extern const pb_msgdesc_t meshtastic_ChannelSet_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_APPONLY_PB_H_MAX_SIZE meshtastic_ChannelSet_size -#define meshtastic_ChannelSet_size 685 +#define meshtastic_ChannelSet_size 701 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/atak.pb.h b/src/mesh/generated/meshtastic/atak.pb.h index 6ea298f9ed..e0dab86590 100644 --- a/src/mesh/generated/meshtastic/atak.pb.h +++ b/src/mesh/generated/meshtastic/atak.pb.h @@ -332,7 +332,7 @@ typedef enum _meshtastic_CotType { /* y-: TAKTALK room/membership broadcast. Payload carried via the TakTalkRoomData typed variant (sender_callsign, room_id, room_name, participants). The CoT type literally has a trailing dash and no - second atom — not a typo. */ + second atom - not a typo. */ meshtastic_CotType_CotType_y = 126 } meshtastic_CotType; @@ -380,7 +380,7 @@ typedef enum _meshtastic_DrawnShape_Kind { /* u-r-b-bullseye: Bullseye ring with range rings and bearing reference */ meshtastic_DrawnShape_Kind_Kind_Bullseye = 7, /* u-d-c-e: Ellipse with distinct major/minor axes (same storage as - Kind_Circle — uses major_cm/minor_cm/angle_deg — but receivers + Kind_Circle - uses major_cm/minor_cm/angle_deg - but receivers render it as a non-circular ellipse rather than a round circle). */ meshtastic_DrawnShape_Kind_Kind_Ellipse = 8, /* u-d-v: 2D vehicle outline drawn on the map. Vertices carry the @@ -400,7 +400,7 @@ typedef enum _meshtastic_DrawnShape_Kind { end of parse; builder uses it to decide which of / to emit in the reconstructed XML. */ typedef enum _meshtastic_DrawnShape_StyleMode { - /* Unspecified — receiver infers from which color fields are non-zero. */ + /* Unspecified - receiver infers from which color fields are non-zero. */ meshtastic_DrawnShape_StyleMode_StyleMode_Unspecified = 0, /* Stroke only. No in the source XML. Used for polylines, ranging lines, bullseye rings. */ @@ -417,7 +417,7 @@ typedef enum _meshtastic_DrawnShape_StyleMode { alone is ambiguous (e.g. a-u-G could be a 2525 symbol or a custom icon depending on the iconset path). */ typedef enum _meshtastic_Marker_Kind { - /* Unspecified — fall back to TAKPacketV2.cot_type_id */ + /* Unspecified - fall back to TAKPacketV2.cot_type_id */ meshtastic_Marker_Kind_Kind_Unspecified = 0, /* b-m-p-s-m: Spot map marker */ meshtastic_Marker_Kind_Kind_Spot = 1, @@ -680,10 +680,10 @@ typedef struct _meshtastic_AircraftTrack { hundred meters of the anchor has per-vertex deltas in the ±10^4 range. Under sint32+zigzag those encode as 2 bytes each (tag+varint), versus the 4 bytes that sfixed32 would always require. At 32 vertices that is ~128 - bytes of savings — the difference between fitting under the LoRa MTU or + bytes of savings - the difference between fitting under the LoRa MTU or not. Absolute coordinates (values ~10^9) would cost sint32 varint 5 bytes per field, which is why TAKPacketV2's top-level latitude_i / longitude_i - stay sfixed32 — only small values win with sint32. */ + stay sfixed32 - only small values win with sint32. */ typedef struct _meshtastic_CotGeoPoint { /* Latitude delta from TAKPacketV2.latitude_i, in 1e-7 degree units. Add to the enclosing event's latitude_i to recover the absolute latitude. */ @@ -791,7 +791,7 @@ typedef struct _meshtastic_Marker { Covers CoT type u-rb-a. The anchor position is on TAKPacketV2.latitude_i/longitude_i; the target endpoint is carried as a - CotGeoPoint — same delta-from-anchor encoding used by DrawnShape.vertices + CotGeoPoint - same delta-from-anchor encoding used by DrawnShape.vertices so a self-anchored RAB (common case) encodes in zero bytes. */ typedef struct _meshtastic_RangeAndBearing { /* Target/anchor endpoint (delta-encoded from TAKPacketV2.latitude_i/longitude_i). */ @@ -899,12 +899,12 @@ typedef struct _meshtastic_CasevacReport { same as the envelope callsign but ATAK sometimes carries a distinct ops-number here. */ pb_callback_t title; - /* Primary medline free-text — the single most clinically important line + /* Primary medline free-text - the single most clinically important line on a MEDLINE form (e.g. "2 urgent litter patients, smoke on approach"). MUST be preserved under MTU pressure as long as any casevac is sent. */ pb_callback_t medline_remarks; /* Line 3 (newer ATAK format): patient counts by precedence level. - Coexists with the enum-style `precedence` field (tag 1) — older ATAK + Coexists with the enum-style `precedence` field (tag 1) - older ATAK emits a single enum, newer ATAK emits these counts, and both can be set simultaneously. Senders populate whichever style(s) the source XML had; receivers prefer counts when non-zero. */ @@ -946,19 +946,19 @@ typedef struct _meshtastic_CasevacReport { (e.g. "Primary HLZ is soccer field"). */ pb_callback_t hlz_remarks; /* Per-patient clinical records. Each entry is one patient's ZMIST card - (Zap number / Mechanism / Injuries / Signs / Treatment). Repeatable — + (Zap number / Mechanism / Injuries / Signs / Treatment). Repeatable - a mass-casualty event can carry 1-6 entries in practice, limited by the 237 B LoRa MTU. */ pb_callback_t zmist; } meshtastic_CasevacReport; -/* Per-patient clinical summary record — one entry per patient in a CASEVAC. +/* Per-patient clinical summary record - one entry per patient in a CASEVAC. Maps directly to ATAK's child element inside . All fields are optional free-text; senders populate what they have. */ typedef struct _meshtastic_ZMistEntry { /* Patient identifier / sequence label (e.g. "ZMIST-1", "ZMIST-2"). */ pb_callback_t title; - /* Zap number — unique patient tracking ID (often a terse code like + /* Zap number - unique patient tracking ID (often a terse code like "Gunshot" or a serial). */ pb_callback_t z; /* Mechanism of injury (e.g. "Penetrating trauma", "Blast injury"). */ @@ -997,7 +997,7 @@ typedef struct _meshtastic_EmergencyAlert { creation time; the fields below carry structured metadata the raw-detail fallback currently loses. - Fields are deliberately lean — this variant is closer to the MTU ceiling + Fields are deliberately lean - this variant is closer to the MTU ceiling than the others, so every string is capped in options. */ typedef struct _meshtastic_TaskRequest { /* Short tag for the task category (e.g. "engage", "observe", "recon", @@ -1017,7 +1017,7 @@ typedef struct _meshtastic_TaskRequest { /* Weather annotation from CoT detail element. - Attaches to any TAKPacketV2 regardless of payload_variant — an Aircraft, + Attaches to any TAKPacketV2 regardless of payload_variant - an Aircraft, PLI, or Marker can all carry observed conditions at the emitting station. ATAK-CIV ships an XSD for but no dedicated handler, so the element round-trips through the generic detail pipeline; this message @@ -1026,7 +1026,7 @@ typedef struct _meshtastic_TaskRequest { Target wire cost: ~6-8 bytes compressed with a fully populated instance. Named `TAKEnvironment` (not just `Environment`) because the bare name - collides with `SwiftUI.Environment` — every SwiftUI view in a consuming + collides with `SwiftUI.Environment` - every SwiftUI view in a consuming iOS app uses the `@Environment` property wrapper, and importing the generated proto module would make `Environment` ambiguous in every one of those files. The `TAK` prefix matches the convention used by the @@ -1055,7 +1055,7 @@ typedef struct _meshtastic_TAKEnvironment { The receiving ATAK client restores those from its own defaults, same as every other CoT carried over Meshtastic today. - Attaches to any TAKPacketV2 — a PLI with a sensor on the operator's head, + Attaches to any TAKPacketV2 - a PLI with a sensor on the operator's head, an Aircraft with a FLIR turret, a Marker dropped on a UAV. Target wire cost: ~7-14 bytes compressed (dominated by model string). */ typedef struct _meshtastic_SensorFov { @@ -1065,30 +1065,30 @@ typedef struct _meshtastic_SensorFov { SensorDetailHandler default (270°) and save varint bytes over centi-deg. */ uint32_t azimuth_deg; /* Maximum range of the cone in meters. - Optional — if unset, receivers should use the ATAK-CIV default of 100m. */ + Optional - if unset, receivers should use the ATAK-CIV default of 100m. */ bool has_range_m; uint32_t range_m; /* Horizontal field of view in whole degrees (cone's angular width). ATAK-CIV default is 45°. */ uint32_t fov_horizontal_deg; /* Vertical field of view in whole degrees. ATAK-CIV default is 45°. - Optional — a value of 0 means "not set / use horizontal FOV". */ + Optional - a value of 0 means "not set / use horizontal FOV". */ uint32_t fov_vertical_deg; /* Elevation angle in whole degrees. Positive = up, negative = down. Range -90 to +90. sint32 for varint efficiency on small negatives. */ int32_t elevation_deg; /* Roll (camera tilt) in whole degrees, -180 to +180. - Optional — use 0 if the sensor doesn't track roll. */ + Optional - use 0 if the sensor doesn't track roll. */ int32_t roll_deg; /* Free-form device model identifier, e.g. "FLIR-Boson-640", "SEEK". - Optional — empty string means "unknown model" (ATAK-CIV default). */ + Optional - empty string means "unknown model" (ATAK-CIV default). */ pb_callback_t model; } meshtastic_SensorFov; /* TAKTALK chat message payload (CoT type m-t-t). TAKTALK is an ATAK plugin for voice + text team messaging. The voice - audio stream goes over UDP/RTP and is NOT carried by the mesh — only + audio stream goes over UDP/RTP and is NOT carried by the mesh - only the text envelope (this message) is. `from_voice` marks messages sent via push-to-talk speech-to-text so receivers can render a mic icon next to the text. @@ -1122,7 +1122,7 @@ typedef struct _meshtastic_TakTalkMessage { Announces a TAKTALK chatroom's friendly name and roster so peers can resolve room UUIDs (used in TakTalkMessage.chatroom_id and GeoChat.room_id) to a display name and participant list. Not a chat - message itself — these events are emitted by TAKTALK when rooms are + message itself - these events are emitted by TAKTALK when rooms are created or memberships change. */ typedef struct _meshtastic_TakTalkRoomData { /* Callsign of the device broadcasting the room state (typically the @@ -1161,7 +1161,7 @@ typedef struct _meshtastic_Marti { primary-vs-cc distinction the same way ATAK does. If dest_callsign is [TAKPacketV2.callsign] (self-addressed, unusual but - legal — e.g. ATAK echoing back to its own room), the builder still emits + legal - e.g. ATAK echoing back to its own room), the builder still emits the element so loopback shapes round-trip cleanly. */ pb_callback_t dest_callsign; } meshtastic_Marti; diff --git a/src/mesh/generated/meshtastic/channel.pb.h b/src/mesh/generated/meshtastic/channel.pb.h index 9dc757ab4a..9e66edbb8e 100644 --- a/src/mesh/generated/meshtastic/channel.pb.h +++ b/src/mesh/generated/meshtastic/channel.pb.h @@ -97,6 +97,12 @@ typedef struct _meshtastic_ChannelSettings { /* Per-channel module settings. */ bool has_module_settings; meshtastic_ModuleSettings module_settings; + /* Enable authenticated encryption (AES-CCM) for this channel. + When true, messages include a 12-byte authentication tag that prevents + forgery and bit-flipping attacks. All nodes on the channel must have + this enabled - unauthenticated (AES-CTR) packets are rejected. + Experimental. Default: false (standard AES-CTR encryption). */ + bool use_aead; } meshtastic_ChannelSettings; /* A pair of a channel number, mode and the (sharable) settings for that channel */ @@ -128,10 +134,10 @@ extern "C" { /* Initializer values for message structs */ -#define meshtastic_ChannelSettings_init_default {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_default} +#define meshtastic_ChannelSettings_init_default {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_default, 0} #define meshtastic_ModuleSettings_init_default {0, 0} #define meshtastic_Channel_init_default {0, false, meshtastic_ChannelSettings_init_default, _meshtastic_Channel_Role_MIN} -#define meshtastic_ChannelSettings_init_zero {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_zero} +#define meshtastic_ChannelSettings_init_zero {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_zero, 0} #define meshtastic_ModuleSettings_init_zero {0, 0} #define meshtastic_Channel_init_zero {0, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Channel_Role_MIN} @@ -145,6 +151,7 @@ extern "C" { #define meshtastic_ChannelSettings_uplink_enabled_tag 5 #define meshtastic_ChannelSettings_downlink_enabled_tag 6 #define meshtastic_ChannelSettings_module_settings_tag 7 +#define meshtastic_ChannelSettings_use_aead_tag 8 #define meshtastic_Channel_index_tag 1 #define meshtastic_Channel_settings_tag 2 #define meshtastic_Channel_role_tag 3 @@ -157,7 +164,8 @@ X(a, STATIC, SINGULAR, STRING, name, 3) \ X(a, STATIC, SINGULAR, FIXED32, id, 4) \ X(a, STATIC, SINGULAR, BOOL, uplink_enabled, 5) \ X(a, STATIC, SINGULAR, BOOL, downlink_enabled, 6) \ -X(a, STATIC, OPTIONAL, MESSAGE, module_settings, 7) +X(a, STATIC, OPTIONAL, MESSAGE, module_settings, 7) \ +X(a, STATIC, SINGULAR, BOOL, use_aead, 8) #define meshtastic_ChannelSettings_CALLBACK NULL #define meshtastic_ChannelSettings_DEFAULT NULL #define meshtastic_ChannelSettings_module_settings_MSGTYPE meshtastic_ModuleSettings @@ -187,8 +195,8 @@ extern const pb_msgdesc_t meshtastic_Channel_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_CHANNEL_PB_H_MAX_SIZE meshtastic_Channel_size -#define meshtastic_ChannelSettings_size 72 -#define meshtastic_Channel_size 87 +#define meshtastic_ChannelSettings_size 74 +#define meshtastic_Channel_size 89 #define meshtastic_ModuleSettings_size 8 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/device_ui.pb.h b/src/mesh/generated/meshtastic/device_ui.pb.h index b99fb10b93..461477bdd0 100644 --- a/src/mesh/generated/meshtastic/device_ui.pb.h +++ b/src/mesh/generated/meshtastic/device_ui.pb.h @@ -70,6 +70,10 @@ typedef enum _meshtastic_Language { meshtastic_Language_CZECH = 18, /* Danish */ meshtastic_Language_DANISH = 19, + /* Hungarian */ + meshtastic_Language_HUNGARIAN = 20, + /* Azerbaijani */ + meshtastic_Language_AZERBAIJANI = 21, /* Simplified Chinese (experimental) */ meshtastic_Language_SIMPLIFIED_CHINESE = 30, /* Traditional Chinese (experimental) */ diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index a1b8df59b2..cd3a9b89e3 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -457,8 +457,8 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2656 -#define meshtastic_ChannelFile_size 718 +#define meshtastic_BackupPreferences_size 2674 +#define meshtastic_ChannelFile_size 734 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 321 #define meshtastic_NodeInfoLite_size 112 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 35a0d43d55..ebf9bdc654 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -212,7 +212,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size #define meshtastic_LocalConfig_size 759 -#define meshtastic_LocalModuleConfig_size 1042 +#define meshtastic_LocalModuleConfig_size 1044 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 9d379158ae..a490d4e2be 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1288,15 +1288,15 @@ typedef struct _meshtastic_LockdownStatus { /* Current lockdown state being reported. */ meshtastic_LockdownStatus_State state; /* For LOCKED: machine-readable reason. Known values: - "needs_auth" — storage already unlocked, client must auth - "token_missing" — no boot token on flash - "token_expired" — boot token wall-clock TTL elapsed - "token_boots_zero" — boot token boot-count TTL exhausted - "token_hmac_fail" — token tampered or wrong device - "token_dek_fail" — token DEK decrypt failed - "token_wrong_size" — token file corrupted - "token_bad_magic" — token file corrupted - "not_provisioned" — should generally use NEEDS_PROVISION state instead + "needs_auth" - storage already unlocked, client must auth + "token_missing" - no boot token on flash + "token_expired" - boot token wall-clock TTL elapsed + "token_boots_zero" - boot token boot-count TTL exhausted + "token_hmac_fail" - token tampered or wrong device + "token_dek_fail" - token DEK decrypt failed + "token_wrong_size" - token file corrupted + "token_bad_magic" - token file corrupted + "not_provisioned" - should generally use NEEDS_PROVISION state instead Other values may be added; clients should treat unknown values as "locked, ask for passphrase". */ char lock_reason[32]; diff --git a/src/mesh/generated/meshtastic/mesh_beacon.pb.h b/src/mesh/generated/meshtastic/mesh_beacon.pb.h index 028d8269f5..4d23e17340 100644 --- a/src/mesh/generated/meshtastic/mesh_beacon.pb.h +++ b/src/mesh/generated/meshtastic/mesh_beacon.pb.h @@ -15,7 +15,7 @@ /* Payload for MESH_BEACON_APP packets. Periodically broadcast by nodes in beacon mode. Listeners deliver the text message to the local inbox and cache any offered - channel/preset for the client app to act on — the firmware never auto-applies them. */ + channel/preset for the client app to act on - the firmware never auto-applies them. */ typedef struct _meshtastic_MeshBeacon { /* Human-readable beacon message. Max 100 bytes enforced by firmware on send. */ char message[101]; @@ -63,7 +63,7 @@ extern const pb_msgdesc_t meshtastic_MeshBeacon_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_MESH_BEACON_PB_H_MAX_SIZE meshtastic_MeshBeacon_size -#define meshtastic_MeshBeacon_size 180 +#define meshtastic_MeshBeacon_size 182 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index e689ace5c5..dd82f424bb 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -255,10 +255,14 @@ typedef struct _meshtastic_ModuleConfig_PaxcounterConfig { } meshtastic_ModuleConfig_PaxcounterConfig; /* Config for the Traffic Management module. - Provides packet inspection and traffic shaping to help reduce channel utilization */ + Provides packet inspection and traffic shaping to help reduce channel utilization. + Every field uses the proto3 zero value to mean "disabled"; there is no + "use the firmware default" sentinel. Firmware installs its own defaults when it + first creates this config, and a client that writes 0 turns that feature off. */ typedef struct _meshtastic_ModuleConfig_TrafficManagementConfig { /* Minimum interval in seconds between position updates from the same node. - A non-zero value implicitly enables the suppression window; 0 disables it. */ + A non-zero value implicitly enables the suppression window; 0 disables it. + Firmware default: 21600 (6 hours), installed when this config is first created. */ uint32_t position_min_interval_secs; /* Maximum hop distance from the requestor at which direct NodeInfo responses are served from the local cache. A non-zero value implicitly enables direct @@ -1138,7 +1142,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_MQTTConfig_size 224 #define meshtastic_ModuleConfig_MapReportSettings_size 14 #define meshtastic_ModuleConfig_MeshBeaconConfig_BroadcastTarget_size 10 -#define meshtastic_ModuleConfig_MeshBeaconConfig_size 240 +#define meshtastic_ModuleConfig_MeshBeaconConfig_size 242 #define meshtastic_ModuleConfig_NeighborInfoConfig_size 10 #define meshtastic_ModuleConfig_PaxcounterConfig_size 30 #define meshtastic_ModuleConfig_RangeTestConfig_size 12 @@ -1149,7 +1153,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_TAKConfig_size 4 #define meshtastic_ModuleConfig_TelemetryConfig_size 50 #define meshtastic_ModuleConfig_TrafficManagementConfig_size 30 -#define meshtastic_ModuleConfig_size 244 +#define meshtastic_ModuleConfig_size 246 #define meshtastic_RemoteHardwarePin_size 21 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index 8cae56f921..f1c1bf2a91 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -118,7 +118,7 @@ typedef enum _meshtastic_TelemetrySensorType { meshtastic_TelemetrySensorType_DS248X = 51, /* MMC5983MA 3-Axis Digital Magnetic Sensor */ meshtastic_TelemetrySensorType_MMC5983MA = 52, - /* ICM-42607-P 6‑Axis IMU */ + /* ICM-42607-P 6-Axis IMU */ meshtastic_TelemetrySensorType_ICM42607P = 53, /* SPA06 pressure and temperature */ meshtastic_TelemetrySensorType_SPA06 = 54, From a495ef007c95fc2a08eadb81d3ad642a00b6421c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:16:07 +0200 Subject: [PATCH 112/143] Update protobufs (#11806) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- .../generated/meshtastic/telemetry.pb.cpp | 3 + src/mesh/generated/meshtastic/telemetry.pb.h | 97 ++++++++++++------- 4 files changed, 65 insertions(+), 39 deletions(-) diff --git a/protobufs b/protobufs index 251c52e897..fa26b5bfef 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 251c52e897bf40d99b141cf530755ac3a9069880 +Subproject commit fa26b5bfefd00f7dcdedbdcfd6738b4faf6c67d2 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index cd3a9b89e3..893f980593 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -460,7 +460,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_BackupPreferences_size 2674 #define meshtastic_ChannelFile_size 734 #define meshtastic_DeviceState_size 1944 -#define meshtastic_NodeEnvironmentEntry_size 321 +#define meshtastic_NodeEnvironmentEntry_size 231 #define meshtastic_NodeInfoLite_size 112 #define meshtastic_NodePositionEntry_size 42 #define meshtastic_NodeStatusEntry_size 89 diff --git a/src/mesh/generated/meshtastic/telemetry.pb.cpp b/src/mesh/generated/meshtastic/telemetry.pb.cpp index aa095b1a2a..fe5db7bc3e 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.cpp +++ b/src/mesh/generated/meshtastic/telemetry.pb.cpp @@ -12,6 +12,9 @@ PB_BIND(meshtastic_DeviceMetrics, meshtastic_DeviceMetrics, AUTO) PB_BIND(meshtastic_EnvironmentMetrics, meshtastic_EnvironmentMetrics, 2) +PB_BIND(meshtastic_SoilWaterMetrics, meshtastic_SoilWaterMetrics, AUTO) + + PB_BIND(meshtastic_PowerMetrics, meshtastic_PowerMetrics, AUTO) diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index f1c1bf2a91..b97a04b303 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -274,6 +274,14 @@ typedef struct _meshtastic_EnvironmentMetrics { /* Estimated distance to the leading edge of the storm, in km */ bool has_lightning_distance_km; float lightning_distance_km; +} meshtastic_EnvironmentMetrics; + +/* Soil and water probe metrics. + + Chemistry reported by soil probes (RS-485/SDI-12 NPK probes) and by + water-quality sondes. Split out of EnvironmentMetrics so that message stays + within the mesh payload budget. */ +typedef struct _meshtastic_SoilWaterMetrics { /* Soil pH, 0-14 */ bool has_soil_ph; float soil_ph; @@ -319,7 +327,7 @@ typedef struct _meshtastic_EnvironmentMetrics { /* Solar irradiance in W/m^2 (distinct from the radiation field's uR/h) */ bool has_solar_irradiance; float solar_irradiance; -} meshtastic_EnvironmentMetrics; +} meshtastic_SoilWaterMetrics; /* Power Metrics (voltage / current / etc) */ typedef struct _meshtastic_PowerMetrics { @@ -573,6 +581,8 @@ typedef struct _meshtastic_Telemetry { meshtastic_HostMetrics host_metrics; /* Traffic management statistics */ meshtastic_TrafficManagementStats traffic_management_stats; + /* Soil and water probe metrics */ + meshtastic_SoilWaterMetrics soil_water_metrics; } variant; } meshtastic_Telemetry; @@ -653,9 +663,11 @@ extern "C" { + /* Initializer values for message structs */ #define meshtastic_DeviceMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_SoilWaterMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -668,7 +680,8 @@ extern "C" { #define meshtastic_SEN5XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN6XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_DeviceMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_SoilWaterMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -727,21 +740,21 @@ extern "C" { #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch7_tag 39 #define meshtastic_EnvironmentMetrics_lightning_strike_count_1h_tag 40 #define meshtastic_EnvironmentMetrics_lightning_distance_km_tag 41 -#define meshtastic_EnvironmentMetrics_soil_ph_tag 42 -#define meshtastic_EnvironmentMetrics_ph_tag 43 -#define meshtastic_EnvironmentMetrics_electrical_conductivity_tag 44 -#define meshtastic_EnvironmentMetrics_salinity_tag 45 -#define meshtastic_EnvironmentMetrics_nitrogen_tag 46 -#define meshtastic_EnvironmentMetrics_phosphorus_tag 47 -#define meshtastic_EnvironmentMetrics_potassium_tag 48 -#define meshtastic_EnvironmentMetrics_dissolved_oxygen_tag 49 -#define meshtastic_EnvironmentMetrics_orp_tag 50 -#define meshtastic_EnvironmentMetrics_chemical_oxygen_demand_tag 51 -#define meshtastic_EnvironmentMetrics_turbidity_tag 52 -#define meshtastic_EnvironmentMetrics_nitrate_tag 53 -#define meshtastic_EnvironmentMetrics_ammonium_tag 54 -#define meshtastic_EnvironmentMetrics_biochemical_oxygen_demand_tag 55 -#define meshtastic_EnvironmentMetrics_solar_irradiance_tag 56 +#define meshtastic_SoilWaterMetrics_soil_ph_tag 1 +#define meshtastic_SoilWaterMetrics_ph_tag 2 +#define meshtastic_SoilWaterMetrics_electrical_conductivity_tag 3 +#define meshtastic_SoilWaterMetrics_salinity_tag 4 +#define meshtastic_SoilWaterMetrics_nitrogen_tag 5 +#define meshtastic_SoilWaterMetrics_phosphorus_tag 6 +#define meshtastic_SoilWaterMetrics_potassium_tag 7 +#define meshtastic_SoilWaterMetrics_dissolved_oxygen_tag 8 +#define meshtastic_SoilWaterMetrics_orp_tag 9 +#define meshtastic_SoilWaterMetrics_chemical_oxygen_demand_tag 10 +#define meshtastic_SoilWaterMetrics_turbidity_tag 11 +#define meshtastic_SoilWaterMetrics_nitrate_tag 12 +#define meshtastic_SoilWaterMetrics_ammonium_tag 13 +#define meshtastic_SoilWaterMetrics_biochemical_oxygen_demand_tag 14 +#define meshtastic_SoilWaterMetrics_solar_irradiance_tag 15 #define meshtastic_PowerMetrics_ch1_voltage_tag 1 #define meshtastic_PowerMetrics_ch1_current_tag 2 #define meshtastic_PowerMetrics_ch2_voltage_tag 3 @@ -827,6 +840,7 @@ extern "C" { #define meshtastic_Telemetry_health_metrics_tag 7 #define meshtastic_Telemetry_host_metrics_tag 8 #define meshtastic_Telemetry_traffic_management_stats_tag 9 +#define meshtastic_Telemetry_soil_water_metrics_tag 11 #define meshtastic_Nau7802Config_zeroOffset_tag 1 #define meshtastic_Nau7802Config_calibrationFactor_tag 2 #define meshtastic_AS3935Config_tuning_cap_pf_tag 1 @@ -893,25 +907,29 @@ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch5, 37) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch6, 38) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch7, 39) \ X(a, STATIC, OPTIONAL, UINT32, lightning_strike_count_1h, 40) \ -X(a, STATIC, OPTIONAL, FLOAT, lightning_distance_km, 41) \ -X(a, STATIC, OPTIONAL, FLOAT, soil_ph, 42) \ -X(a, STATIC, OPTIONAL, FLOAT, ph, 43) \ -X(a, STATIC, OPTIONAL, FLOAT, electrical_conductivity, 44) \ -X(a, STATIC, OPTIONAL, FLOAT, salinity, 45) \ -X(a, STATIC, OPTIONAL, FLOAT, nitrogen, 46) \ -X(a, STATIC, OPTIONAL, FLOAT, phosphorus, 47) \ -X(a, STATIC, OPTIONAL, FLOAT, potassium, 48) \ -X(a, STATIC, OPTIONAL, FLOAT, dissolved_oxygen, 49) \ -X(a, STATIC, OPTIONAL, FLOAT, orp, 50) \ -X(a, STATIC, OPTIONAL, FLOAT, chemical_oxygen_demand, 51) \ -X(a, STATIC, OPTIONAL, FLOAT, turbidity, 52) \ -X(a, STATIC, OPTIONAL, FLOAT, nitrate, 53) \ -X(a, STATIC, OPTIONAL, FLOAT, ammonium, 54) \ -X(a, STATIC, OPTIONAL, FLOAT, biochemical_oxygen_demand, 55) \ -X(a, STATIC, OPTIONAL, FLOAT, solar_irradiance, 56) +X(a, STATIC, OPTIONAL, FLOAT, lightning_distance_km, 41) #define meshtastic_EnvironmentMetrics_CALLBACK NULL #define meshtastic_EnvironmentMetrics_DEFAULT NULL +#define meshtastic_SoilWaterMetrics_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, FLOAT, soil_ph, 1) \ +X(a, STATIC, OPTIONAL, FLOAT, ph, 2) \ +X(a, STATIC, OPTIONAL, FLOAT, electrical_conductivity, 3) \ +X(a, STATIC, OPTIONAL, FLOAT, salinity, 4) \ +X(a, STATIC, OPTIONAL, FLOAT, nitrogen, 5) \ +X(a, STATIC, OPTIONAL, FLOAT, phosphorus, 6) \ +X(a, STATIC, OPTIONAL, FLOAT, potassium, 7) \ +X(a, STATIC, OPTIONAL, FLOAT, dissolved_oxygen, 8) \ +X(a, STATIC, OPTIONAL, FLOAT, orp, 9) \ +X(a, STATIC, OPTIONAL, FLOAT, chemical_oxygen_demand, 10) \ +X(a, STATIC, OPTIONAL, FLOAT, turbidity, 11) \ +X(a, STATIC, OPTIONAL, FLOAT, nitrate, 12) \ +X(a, STATIC, OPTIONAL, FLOAT, ammonium, 13) \ +X(a, STATIC, OPTIONAL, FLOAT, biochemical_oxygen_demand, 14) \ +X(a, STATIC, OPTIONAL, FLOAT, solar_irradiance, 15) +#define meshtastic_SoilWaterMetrics_CALLBACK NULL +#define meshtastic_SoilWaterMetrics_DEFAULT NULL + #define meshtastic_PowerMetrics_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, FLOAT, ch1_voltage, 1) \ X(a, STATIC, OPTIONAL, FLOAT, ch1_current, 2) \ @@ -1021,7 +1039,8 @@ X(a, STATIC, ONEOF, MESSAGE, (variant,power_metrics,variant.power_metrics) X(a, STATIC, ONEOF, MESSAGE, (variant,local_stats,variant.local_stats), 6) \ X(a, STATIC, ONEOF, MESSAGE, (variant,health_metrics,variant.health_metrics), 7) \ X(a, STATIC, ONEOF, MESSAGE, (variant,host_metrics,variant.host_metrics), 8) \ -X(a, STATIC, ONEOF, MESSAGE, (variant,traffic_management_stats,variant.traffic_management_stats), 9) +X(a, STATIC, ONEOF, MESSAGE, (variant,traffic_management_stats,variant.traffic_management_stats), 9) \ +X(a, STATIC, ONEOF, MESSAGE, (variant,soil_water_metrics,variant.soil_water_metrics), 11) #define meshtastic_Telemetry_CALLBACK NULL #define meshtastic_Telemetry_DEFAULT NULL #define meshtastic_Telemetry_variant_device_metrics_MSGTYPE meshtastic_DeviceMetrics @@ -1032,6 +1051,7 @@ X(a, STATIC, ONEOF, MESSAGE, (variant,traffic_management_stats,variant.tra #define meshtastic_Telemetry_variant_health_metrics_MSGTYPE meshtastic_HealthMetrics #define meshtastic_Telemetry_variant_host_metrics_MSGTYPE meshtastic_HostMetrics #define meshtastic_Telemetry_variant_traffic_management_stats_MSGTYPE meshtastic_TrafficManagementStats +#define meshtastic_Telemetry_variant_soil_water_metrics_MSGTYPE meshtastic_SoilWaterMetrics #define meshtastic_Nau7802Config_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, INT32, zeroOffset, 1) \ @@ -1066,6 +1086,7 @@ X(a, STATIC, OPTIONAL, FIXED64, voc_state_array, 6) extern const pb_msgdesc_t meshtastic_DeviceMetrics_msg; extern const pb_msgdesc_t meshtastic_EnvironmentMetrics_msg; +extern const pb_msgdesc_t meshtastic_SoilWaterMetrics_msg; extern const pb_msgdesc_t meshtastic_PowerMetrics_msg; extern const pb_msgdesc_t meshtastic_AirQualityMetrics_msg; extern const pb_msgdesc_t meshtastic_LocalStats_msg; @@ -1081,6 +1102,7 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_DeviceMetrics_fields &meshtastic_DeviceMetrics_msg #define meshtastic_EnvironmentMetrics_fields &meshtastic_EnvironmentMetrics_msg +#define meshtastic_SoilWaterMetrics_fields &meshtastic_SoilWaterMetrics_msg #define meshtastic_PowerMetrics_fields &meshtastic_PowerMetrics_msg #define meshtastic_AirQualityMetrics_fields &meshtastic_AirQualityMetrics_msg #define meshtastic_LocalStats_fields &meshtastic_LocalStats_msg @@ -1098,7 +1120,7 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; #define meshtastic_AS3935Config_size 6 #define meshtastic_AirQualityMetrics_size 157 #define meshtastic_DeviceMetrics_size 27 -#define meshtastic_EnvironmentMetrics_size 312 +#define meshtastic_EnvironmentMetrics_size 222 #define meshtastic_HealthMetrics_size 11 #define meshtastic_HostMetrics_size 264 #define meshtastic_LocalStats_size 87 @@ -1106,7 +1128,8 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; #define meshtastic_PowerMetrics_size 81 #define meshtastic_SEN5XState_size 27 #define meshtastic_SEN6XState_size 27 -#define meshtastic_Telemetry_size 320 +#define meshtastic_SoilWaterMetrics_size 75 +#define meshtastic_Telemetry_size 272 #define meshtastic_TrafficManagementStats_size 42 #ifdef __cplusplus From 777c79f6d8749a324dfcb84bb81f8690c0c431d0 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:35:33 +0000 Subject: [PATCH 113/143] revert a conflict regression and utilise the full power of the lr2021 lna (#10633) * revert a conflict regression and introduce the DCDC workaround from semtech example code. * fix: Adjust DCDC workaround placement for * clod fixes stuff * clod fixes some more things --- src/mesh/LR20x0Interface.cpp | 127 +++++++++++++++++- src/mesh/LR20x0Interface.h | 9 ++ .../nrf52_promicro_diy_tcxo/platformio.ini | 1 + 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index f6936afe73..e0d797ccfd 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -6,6 +6,12 @@ #include "error.h" #include "mesh/NodeDB.h" +#if defined(LR2021_DCDC_WORKAROUND) && RADIOLIB_GODMODE +// The DCDC sensitivity workaround pokes RadioLib-internal DCDC registers that are NOT exposed via the +// public LR2021.h, so pull in the internal register map explicitly. Opt-in only (see LR2021_DCDC_WORKAROUND). +#include +#endif + // Keep LR20x0 naming while RadioLib exposes LR2021 symbols. #ifndef LR20x0 #define LR20x0 LR2021 @@ -46,6 +52,12 @@ static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { // Last programmed carrier; LF/HF hops use full begin() (live setOutputPower returns -706). static float lr20x0LastFreqMHz = 0; +// Unlike SX126x/LR11x0 (on/off bool), the LR2021 RX gain boost is a 0-7 level (0 = disabled, 7 = max boost). +// Map the historical on/off sx126x_rx_boosted_gain flag to max boost when enabled. +#ifndef LR2021_RX_GAIN_BOOST_LEVEL +#define LR2021_RX_GAIN_BOOST_LEVEL 7 +#endif + template LR20x0Interface::LR20x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, RADIOLIB_PIN_TYPE busy) @@ -140,6 +152,24 @@ template bool LR20x0Interface::init() if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED) return false; + // Some basic info about the module's explicit firmware version - no other info available + // Currently requires radiolib godmode + +#if RADIOLIB_GODMODE + if (res == RADIOLIB_ERR_NONE) { + uint8_t fwMajor = 0; + uint8_t fwMinor = 0; + int versionRes = lora.getVersion(&fwMajor, &fwMinor); + if (versionRes == RADIOLIB_ERR_NONE) + LOG_DEBUG("LR20x0 FW %d.%d", fwMajor, fwMinor); + } +#endif + + // Semtech DCDC sensitivity workaround for sub-GHz operation - applied here after lora.begin() has set the + // packet type and modulation params. reconfigure() reapplies it after its own modulation changes. + if (res == RADIOLIB_ERR_NONE) + applyDcdcWorkaround(); + LOG_INFO("Frequency set to %f", getFreq()); LOG_INFO("Bandwidth set to %f", bw); LOG_INFO("Power output set to %d", power); @@ -147,6 +177,18 @@ template bool LR20x0Interface::init() if (res == RADIOLIB_ERR_NONE) res = lora.setCRC(2); + // Standard DCDC ramp timing from RadioLib workarounds (register 0x00F20024) + // Currently requires radiolib godmode +#if RADIOLIB_GODMODE + if (res == RADIOLIB_ERR_NONE) { + uint8_t rampTimes[4] = {15, 15, 15, 15}; // Standard case for all conditions + // godmode-only DCDC ramp tuning: log failures but don't fail init (radio is already up) + int16_t rmRes = lora.setRegMode(RADIOLIB_LR2021_REG_MODE_SIMO_NORMAL, rampTimes); + if (rmRes != RADIOLIB_ERR_NONE) + LOG_WARN("LR2021 setRegMode failed: %d", rmRes); + } +#endif + #ifdef LR2021_DIO_AS_RF_SWITCH bool dioAsRfSwitch = true; #elif defined(ARCH_PORTDUINO) @@ -162,11 +204,11 @@ template bool LR20x0Interface::init() if (res == RADIOLIB_ERR_NONE) { if (config.lora.sx126x_rx_boosted_gain) { // the name is unfortunate but historically accurate - res = lora.setRxBoostedGainMode(true); - LOG_INFO("Set RX gain to boosted mode; result: %d", res); + res = lora.setRxBoostedGainMode(LR2021_RX_GAIN_BOOST_LEVEL); + LOG_INFO("Set RX gain to boosted mode (level %d); result: %d", LR2021_RX_GAIN_BOOST_LEVEL, res); } else { - res = lora.setRxBoostedGainMode(false); - LOG_INFO("Set RX gain to power saving mode; result: %d", res); + res = lora.setRxBoostedGainMode(0); + LOG_INFO("Set RX gain to power saving mode (boosted mode off); result: %d", res); } } @@ -262,7 +304,7 @@ template bool LR20x0Interface::reconfigure() // Warn-only, as in LR11x0: a rejected gain mode is cosmetic and not a lost-state signature, so // it must not drag reconfigure() into a full chip reset. - err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain ? LR2021_RX_GAIN_BOOST_LEVEL : 0); if (err != RADIOLIB_ERR_NONE) LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err); } @@ -280,6 +322,11 @@ template bool LR20x0Interface::reconfigure() LOG_INFO("LR20x0 recovered after re-init"); } + // setSpreadingFactor/setBandwidth/setCodingRate each re-run setLoRaModulationParams(), which resets + // the DCDC configure state, so reapply the workaround before we resume receiving. + if (standbySuccess) + applyDcdcWorkaround(); + startReceive(); lr20x0LastFreqMHz = freq; return reconfigureSuccess; @@ -348,17 +395,83 @@ template bool LR20x0Interface::fullBegin(float freq) lora.setRfSwitchTable(lr20x0_rfswitch_dio_pins, lr20x0_rfswitch_table); #endif - res = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + res = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain ? LR2021_RX_GAIN_BOOST_LEVEL : 0); if (res != RADIOLIB_ERR_NONE) { LOG_ERROR("LR20x0 band-hop setRxBoostedGainMode %s%d", radioLibErr, res); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); return false; } + // begin() above reprogrammed the modulation params, so the DCDC configure state is reset here too. + applyDcdcWorkaround(); + return true; } } +// Semtech DCDC sensitivity workaround for sub-GHz operation on engineering sample date code 2513. +// Worthy of note is that we tested this on non-engineering samples and it didn't make any difference, but +// we went to the trouble of writing this, so it can stay in, albeit gated behind a compile-time option. +// lr20xx_workarounds_dcdc_reset must follow setPacketType; lr20xx_workarounds_dcdc_configure must follow +// setModulationParams. In init() both hold once lora.begin() returns; in reconfigure() the caller invokes this +// after setSpreadingFactor/setBandwidth/setCodingRate, whose RadioLib implementations re-run +// setLoRaModulationParams() and reset the DCDC configure state. Only applies to sub-GHz; 2.4 GHz (LORA_24) is +// excluded. Opt-in only: requires -DLR2021_DCDC_WORKAROUND (and RADIOLIB_GODMODE for the internal register access). +template void LR20x0Interface::applyDcdcWorkaround() +{ +#if defined(LR2021_DCDC_WORKAROUND) && RADIOLIB_GODMODE + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) + return; + + // Helper: set DCDC LF frequency register and re-apply the current RF frequency. + auto dcdcSetFreq = [&](uint32_t freqHz) -> int16_t { + const uint32_t freqLf = (uint32_t)((float)freqHz * 1.048576f); + int16_t s = lora.writeRegMem32(RADIOLIB_LR2021_REG_DCDC_FREQ_LF, &freqLf, 1); + if (s != RADIOLIB_ERR_NONE) + return s; + uint32_t rawRfFreq = 0; + s = lora.readRegMem32(RADIOLIB_LR2021_REG_RTTOF_RF_FREQ, &rawRfFreq, 1); + if (s != RADIOLIB_ERR_NONE) + return s; + // Convert PLL steps to Hz: (steps * 15625 + 16383) / 16384 + uint32_t rfHz = (uint32_t)(((uint64_t)rawRfFreq * 15625ULL + 16383ULL) / 16384ULL); + return lora.setRfFrequency(rfHz); + }; + + // dcdc_reset: reset RISE/FALL ramp fields to conservative 15/15 at 2.8 MHz + int16_t dcdcRes = lora.writeRegMemMask32(RADIOLIB_LR2021_REG_DCDC_SWITCHER, 0xFu << 20, 15u << 20); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = lora.writeRegMemMask32(RADIOLIB_LR2021_REG_DCDC_SWITCHER, 0xFu << 16, 15u << 16); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = dcdcSetFreq(2800000); + + // dcdc_configure: tune RISE/FALL and DC freq based on ADC decimation and RX path. + // Matches lr20xx_workarounds_dcdc_configure() exactly. + if (dcdcRes == RADIOLIB_ERR_NONE) { + uint32_t adcCtrl = 0, rxPath = 0; + dcdcRes = lora.readRegMem32(RADIOLIB_LR2021_REG_DCDC_ADC_CTRL, &adcCtrl, 1); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = lora.readRegMem32(RADIOLIB_LR2021_REG_DCDC_RX_PATH, &rxPath, 1); + if (dcdcRes == RADIOLIB_ERR_NONE) { + const uint32_t anaDec = (adcCtrl >> 8) & 0x7; + const bool isRxHf = (rxPath & 0x3) == 1; + // Narrowband sub-GHz path (ana_dec 1 or 2): use tighter RISE=11/FALL=13 timing + const uint32_t rise = (!isRxHf && (anaDec == 1 || anaDec == 2)) ? 11u : 15u; + const uint32_t fall = (!isRxHf && (anaDec == 1 || anaDec == 2)) ? 13u : 15u; + dcdcRes = lora.writeRegMemMask32(RADIOLIB_LR2021_REG_DCDC_SWITCHER, 0xFu << 20, rise << 20); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = lora.writeRegMemMask32(RADIOLIB_LR2021_REG_DCDC_SWITCHER, 0xFu << 16, fall << 16); + if (dcdcRes == RADIOLIB_ERR_NONE) + dcdcRes = dcdcSetFreq(anaDec == 1 ? 4300000 : 2800000); + } + } + if (dcdcRes != RADIOLIB_ERR_NONE) + LOG_WARN("LR20x0 DCDC workaround failed: %d", dcdcRes); + else + LOG_DEBUG("LR20x0 DCDC workaround applied"); +#endif +} + template void LR20x0Interface::clearRadioIsr() { lora.clearIrqAction(); @@ -509,7 +622,7 @@ template void LR20x0Interface::resetAGC() lora.calibrateImageRejection(getFreq() - 4.0f, getFreq() + 4.0f); // 5. Re-apply RX boosted gain mode - lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain ? LR2021_RX_GAIN_BOOST_LEVEL : 0); // 6. Resume receiving startReceive(); diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index 5150e9ab8c..4ebda649af 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -72,6 +72,15 @@ template class LR20x0Interface : public RadioLibInterface virtual void setStandby() override; + /** + * Apply the Semtech DCDC sensitivity workaround (opt-in, godmode-only). Must be called after the LoRa + * modulation parameters have been set - i.e. after lora.begin() in init(), or after the + * setSpreadingFactor/setBandwidth/setCodingRate calls in reconfigure(), all of which re-run + * setLoRaModulationParams() and thereby reset the DCDC configure state. No-op unless built with + * -DLR2021_DCDC_WORKAROUND (and RADIOLIB_GODMODE). Logs success/failure; never fatal. + */ + void applyDcdcWorkaround(); + uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } private: diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index a72b8c61e6..6ac17146a4 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -17,6 +17,7 @@ build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo -D NRF52_PROMICRO_DIY -D EXCLUDE_EMOJI ; this variant builds 4 radio driver families and is the largest nrf52 image; emote bitmaps don't fit under the 0xEA000 warm-store cap + ; -D RADIOLIB_GODMODE=1 ; needed for some LR2021 items, but not enabled by default. build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> debug_tool = jlink From 103463e26da497125621ef59455aa5da0bc422f8 Mon Sep 17 00:00:00 2001 From: agentkekbot Date: Thu, 10 Sep 2026 16:42:39 +0300 Subject: [PATCH 114/143] fix(esp32): identify LilyGo T5 S3 ePaper Pro targets (#11368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(esp32): identify LilyGo T5 S3 ePaper Pro targets * fix(esp32): mark T5 S3 ePaper Pro targets actively supported --------- Co-authored-by: George <509474+giannoug@users.noreply.github.com> Co-authored-by: Thomas Göttgens Co-authored-by: rcarteraz --- src/platform/esp32/architecture.h | 2 ++ variants/esp32s3/t5s3_epaper/platformio.ini | 22 +++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 648c8dfe99..2e695a8aa4 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -215,6 +215,8 @@ #define HW_VENDOR meshtastic_HardwareModel_M5STACK_CARDPUTER_ADV #elif defined(MESHNOLOGY_W10) #define HW_VENDOR meshtastic_HardwareModel_MESHNOLOGY_W10 +#elif defined(T5_S3_EPAPER_PRO) +#define HW_VENDOR meshtastic_HardwareModel_T5_S3_EPAPER_PRO #elif defined(ELECROW_ThinkNode_M9) #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M9 #elif defined(HELTEC_V4_R8) diff --git a/variants/esp32s3/t5s3_epaper/platformio.ini b/variants/esp32s3/t5s3_epaper/platformio.ini index d44bfae86a..19361a4dd7 100644 --- a/variants/esp32s3/t5s3_epaper/platformio.ini +++ b/variants/esp32s3/t5s3_epaper/platformio.ini @@ -3,13 +3,21 @@ extends = esp32s3_base board = t5-epaper-s3 board_build.partitions = default_16MB.csv upload_protocol = esptool +custom_meshtastic_hw_model = 123 +custom_meshtastic_hw_model_slug = T5_S3_EPAPER_PRO +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_images = t5s3_epaper.svg +custom_meshtastic_tags = LilyGo +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB build_flags = -fno-strict-aliasing ${esp32s3_base.build_flags} -I variants/esp32s3/t5s3_epaper -D T5_S3_EPAPER_PRO -D USE_EINK -D USE_EINK_PARALLELDISPLAY - -D PRIVATE_HW -D TOUCH_THRESHOLD_X=40 -D TOUCH_THRESHOLD_Y=40 -D TIME_LONG_PRESS=500 @@ -39,7 +47,11 @@ custom_sdkconfig = [env:t5s3_epaper_inkhud] extends = t5s3_epaper_base, inkhud -board_level = extra +board_level = release +custom_meshtastic_display_name = LilyGo T5 E-paper S3 Pro V2 InkHUD +custom_meshtastic_key = t5s3_epaper_inkhud +custom_meshtastic_variant = H752-01 (V2) InkHUD +custom_meshtastic_has_ink_hud = true build_flags = ${t5s3_epaper_base.build_flags} ${inkhud.build_flags} @@ -56,6 +68,9 @@ lib_deps = [env:t5s3-epaper-v1] ; H752 extends = t5s3_epaper_base board_level = release +custom_meshtastic_display_name = LilyGo T5 E-paper S3 Pro V1 +custom_meshtastic_key = t5s3-epaper-v1 +custom_meshtastic_variant = H752 (V1) build_flags = ${t5s3_epaper_base.build_flags} -D T5_S3_EPAPER_PRO_V1 @@ -64,6 +79,9 @@ build_flags = [env:t5s3-epaper-v2] ; H752-01 extends = t5s3_epaper_base board_level = release +custom_meshtastic_display_name = LilyGo T5 E-paper S3 Pro V2 +custom_meshtastic_key = t5s3-epaper-v2 +custom_meshtastic_variant = H752-01 (V2) build_flags = ${t5s3_epaper_base.build_flags} -D T5_S3_EPAPER_PRO_V2 From 2dbc33e4d16da39649f9030d43d89d6206f9fb39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:05:13 +0200 Subject: [PATCH 115/143] Update protobufs (#11809) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/telemetry.pb.cpp | 2 +- src/mesh/generated/meshtastic/telemetry.pb.h | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/protobufs b/protobufs index fa26b5bfef..3b3df2a5e5 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit fa26b5bfefd00f7dcdedbdcfd6738b4faf6c67d2 +Subproject commit 3b3df2a5e54a6f4599ab37ac819da597607a4a27 diff --git a/src/mesh/generated/meshtastic/telemetry.pb.cpp b/src/mesh/generated/meshtastic/telemetry.pb.cpp index fe5db7bc3e..dba25ace20 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.cpp +++ b/src/mesh/generated/meshtastic/telemetry.pb.cpp @@ -30,7 +30,7 @@ PB_BIND(meshtastic_TrafficManagementStats, meshtastic_TrafficManagementStats, AU PB_BIND(meshtastic_HealthMetrics, meshtastic_HealthMetrics, AUTO) -PB_BIND(meshtastic_HostMetrics, meshtastic_HostMetrics, 2) +PB_BIND(meshtastic_HostMetrics, meshtastic_HostMetrics, AUTO) PB_BIND(meshtastic_Telemetry, meshtastic_Telemetry, 2) diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index b97a04b303..e32f0ec6c2 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -556,7 +556,7 @@ typedef struct _meshtastic_HostMetrics { /* Optional User-provided string for arbitrary host system information that doesn't make sense as a dedicated entry. */ bool has_user_string; - char user_string[200]; + char user_string[161]; } meshtastic_HostMetrics; /* Types of Measurements the telemetry module is equipped to handle */ @@ -1122,14 +1122,14 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; #define meshtastic_DeviceMetrics_size 27 #define meshtastic_EnvironmentMetrics_size 222 #define meshtastic_HealthMetrics_size 11 -#define meshtastic_HostMetrics_size 264 +#define meshtastic_HostMetrics_size 225 #define meshtastic_LocalStats_size 87 #define meshtastic_Nau7802Config_size 16 #define meshtastic_PowerMetrics_size 81 #define meshtastic_SEN5XState_size 27 #define meshtastic_SEN6XState_size 27 #define meshtastic_SoilWaterMetrics_size 75 -#define meshtastic_Telemetry_size 272 +#define meshtastic_Telemetry_size 233 #define meshtastic_TrafficManagementStats_size 42 #ifdef __cplusplus From 27afe1159ba35e99068e26c5ca2737f85edbc615 Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:02:58 +0000 Subject: [PATCH 116/143] fix vbus detection (#11801) --- src/Power.cpp | 20 +++++++++++++++++-- .../seeed_wio_tracker_L2/platformio.ini | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index b792bf1813..cd9bfa7ea4 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -783,6 +783,12 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel int16_t raw = _ads.readADC_SingleEnded(0); sum += _ads.computeVolts(raw); } + // Piggyback a toggle-engine watchdog on this same throttle interval. + // Only re-arm when VBUS is absent — calling this while attached + // would restart CC toggling and could glitch an active sink attach. + if (_aw35615.isReady() && !_aw35615.isVbusPresent()) { + _aw35615.rearmToggle(); + } } // Voltage divider scales by 2.0; convert volts to millivolts @@ -803,7 +809,14 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel { if (_aw35615.isReady()) { concurrency::LockGuard guard(spiLock); - return _aw35615.isVbusPresent() && cached_mv >= 4200; + + bool vbus = _aw35615.isVbusPresent(); + if (!vbus) { + // VBUS just went away (or has been away) — make sure the CC + // toggle engine is re-armed so the next attach gets detected. + _aw35615.rearmToggle(); + } + return vbus; } // Fallback to base GPIO/board checks (or false) if CC chip is absent return false; @@ -816,7 +829,10 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel if (_aw35615.isReady()) { concurrency::LockGuard guard(spiLock); - return _aw35615.isSinkAttached() && cached_mv >= 4200; + // Charging == VBUS present AND we're attached as a sink. + // (isSinkAttached() is a latched result — safe to trust here since + // isVbusIn() above keeps re-arming toggle on every detach.) + return _aw35615.isVbusPresent() && _aw35615.isSinkAttached(); } return isVbusIn(); } diff --git a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini index be437c34a2..c46602d71b 100644 --- a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini +++ b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini @@ -47,7 +47,7 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit_ADS1X15 packageName=adafruit/Adafruit_ADS1X15 https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip # renovate: datasource=github-tags depName=AW35615 packageName=meshtastic/AW35615 - https://github.com/mverch67/AW35615/archive/refs/tags/1.0.0.zip + https://github.com/mverch67/AW35615/archive/refs/tags/1.0.1.zip [env:seeed_wio_tracker_L2-tft] extends = env:seeed_wio_tracker_L2 From 06f21117849c68726a5a08aec1d2a54da383dda4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:04:28 +0000 Subject: [PATCH 117/143] chore(deps): update rak13800-w5100s to v1.0.4 (#11783) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/nrf52840/monteops_hw1/platformio.ini | 2 +- variants/nrf52840/r1-neo/platformio.ini | 2 +- variants/nrf52840/rak4631_eth_gw/platformio.ini | 2 +- variants/nrf52840/rak_wismeshtap/platformio.ini | 2 +- variants/rp2040/rak11310/platformio.ini | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/variants/nrf52840/monteops_hw1/platformio.ini b/variants/nrf52840/monteops_hw1/platformio.ini index b14db28d11..685582589e 100644 --- a/variants/nrf52840/monteops_hw1/platformio.ini +++ b/variants/nrf52840/monteops_hw1/platformio.ini @@ -11,7 +11,7 @@ lib_deps = ${nrf52840_base.lib_deps} ${networking_base.lib_deps} # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip debug_tool = jlink ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) ;upload_protocol = jlink diff --git a/variants/nrf52840/r1-neo/platformio.ini b/variants/nrf52840/r1-neo/platformio.ini index 8ffd186305..805188a605 100644 --- a/variants/nrf52840/r1-neo/platformio.ini +++ b/variants/nrf52840/r1-neo/platformio.ini @@ -24,7 +24,7 @@ lib_deps = ${nrf52840_base.lib_deps} ${networking_base.lib_deps} # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=custom.pio depName=ArtronShop_RX8130CE packageName=artronshop/library/ArtronShop_RX8130CE diff --git a/variants/nrf52840/rak4631_eth_gw/platformio.ini b/variants/nrf52840/rak4631_eth_gw/platformio.ini index 8c4ece2c44..4e422e3fcd 100644 --- a/variants/nrf52840/rak4631_eth_gw/platformio.ini +++ b/variants/nrf52840/rak4631_eth_gw/platformio.ini @@ -37,7 +37,7 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main diff --git a/variants/nrf52840/rak_wismeshtap/platformio.ini b/variants/nrf52840/rak_wismeshtap/platformio.ini index 46d6b5161d..bbf700c1fc 100644 --- a/variants/nrf52840/rak_wismeshtap/platformio.ini +++ b/variants/nrf52840/rak_wismeshtap/platformio.ini @@ -32,7 +32,7 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=custom.pio depName=TFT_eSPI packageName=bodmer/library/TFT_eSPI diff --git a/variants/rp2040/rak11310/platformio.ini b/variants/rp2040/rak11310/platformio.ini index f3496023f3..5f5bc19956 100644 --- a/variants/rp2040/rak11310/platformio.ini +++ b/variants/rp2040/rak11310/platformio.ini @@ -28,6 +28,6 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.4.zip debug_build_flags = ${rp2040_base.build_flags}, -g debug_tool = cmsis-dap ; for e.g. Picotool From 546b678d50a385dbab736dcfaa171a7531b59c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 10 Sep 2026 11:50:37 +0000 Subject: [PATCH 118/143] fix(motion): drive screen wake from the accelerometer interrupt (#11758) * fix(motion): drive screen wake from the accelerometer interrupt The BHI260AP ISR body was empty and BHI_IRQ was never set or read, so the attach only consumed a GPIO slot. ICM20948 could not reach its interrupt path at all: the ICM_20948_INT_PIN fallback in the header is guarded on ICM_20948_WOM_THRESHOLD, which the block above it always defines, so the pin was never defined and the config, attach and interrupt-driven runOnce() were dropped by the preprocessor on every board. BHI260AP now configures the FIFO interrupt, attaches an ISR that sets a flag, and enables the wrist tilt gesture so runOnce() can call wakeScreen(). BMA423 arms INT1 push-pull active-high, which the BMA4 reset default leaves disabled, and drains on the interrupt instead of every 50 ms. Both keep a slow keepalive drain so a pin that never asserts degrades to polling rather than losing tilt and tap wake. MOTION_WAKE_INT_PIN resolves whichever motion interrupt a variant declares. doLightSleep() arms it as a GPIO wake source and lsIdle() attributes the resulting wake to motion, which it previously charged to BUTTON_PIN and dropped. Both are gated on config.display.wake_on_tap_or_motion, matching MotionSensor::wakeScreen(). Closes #11755 * fix(motion): use the ICM20948 interrupt without dropping the compass The ICM_20948_INT_PIN build of runOnce() was a full replacement for the polled one and kept only wake-on-motion, so defining the pin would have dropped the magnetometer fusion that feeds screen->setHeading(), the calibration flow and the IMU sleep handling. providesHeading() returns true for this part, so that is the compass. Merge the two: the pin now selects the wake-on-motion mechanism only. The status register poll stays compiled in behind a keepalive, since no shipped firmware has exercised this line, so a pin that never asserts costs latency rather than wake-on-motion. Declare the pin on t-echo-card. Sensor_INT is P1.13, open drain with a 10K pullup to VDD3V3, matching the driver's active-low config and FALLING attach. The schematic's SCL P1.02 / SDA P1.04 match PIN_WIRE_SCL and PIN_WIRE_SDA. * Revert the t-echo-card ICM20948 interrupt pin Sensor_INT is not the IMU. In both T-Echo-Lite_V1.0 and T-Echo-Lite-Card_V1.0 it appears only on the unannotated 5-pin expansion header (P?, 5PIN_PA1.0) carrying SDA_P1.04, SCL_P1.02, VDD3V3, GND and Sensor_INT with its 10K pullup, and it leaves the sheet as an off-sheet port. Neither schematic contains an ICM20948 symbol at all, and the vendor pin map declares only ICM20948_SDA, ICM20948_SCL and ICM20948_ADDRESS for the part. The interrupt belongs to whatever plugs into that header, so the onboard IMU has no reason to drive it. The driver keeps polling. * Poll until an ICM20948 interrupt pin proves itself A variant that declares ICM_20948_INT_PIN is asserting routing no vendor firmware has ever exercised, so treat the line as unproven: keep polling the wake-on-motion status register at full rate, and only back off to the keepalive once the pin has actually fired. A wrong pin then behaves exactly as before rather than trading wake latency for the guess. * feat(t-impulse-plus): drive ICM20948 wake-on-motion from its INT pin The LilyGO pinmap documents the IMU's INT on P0.07, and variant.cpp already maps and names it as D27, but the pin was never handed to the driver, so wake-on-motion polled the status register every 50 ms. Use the D number: pinMode() and attachInterrupt() index g_ADigitalPinMap, where a raw 7 selects P1.13, the LoRa RF_VC1 TXEN line. The driver polls until the pin proves itself, so an ICM20948 that turns out not to drive it keeps working as before. * Derive MOTION_WAKE_INT_PIN after the build exclusions MESHTASTIC_MINIMIZE_BUILD defines MESHTASTIC_EXCLUDE_I2C further down the file, so the guard read as unset and a minimized build defined the pin anyway. doLightSleep() would then arm a GPIO no motion driver configures, since every driver is compiled out with I2C. Latent rather than live: nothing sets MESHTASTIC_MINIMIZE_BUILD today, and the variants that pass -DMESHTASTIC_EXCLUDE_I2C were already correct because a build flag is defined before this file is parsed. * fix(motion): keep the BMA423 INT1 config failure non-fatal Restores the resolution made when feature/sensorlib-0.4.1 was merged into this branch. That merge is gone after the rebase, and neither parent carried this: the interrupt path is an optimisation over the existing poll, so a pin-config failure should log and fall back rather than be ignored outright. --- src/PowerFSM.cpp | 7 +++ src/configuration.h | 27 +++++++++++ src/motion/BHI260APSensor.cpp | 56 ++++++++++++++++------ src/motion/BHI260APSensor.h | 8 +++- src/motion/BMA423Sensor.cpp | 27 +++++++++++ src/motion/BMA423Sensor.h | 3 ++ src/motion/ICM20948Sensor.cpp | 34 +++++++------ src/motion/ICM20948Sensor.h | 10 ++-- src/motion/MotionSensor.h | 2 + src/sleep.cpp | 10 ++++ variants/nrf52840/t-impulse-plus/variant.h | 2 + 11 files changed, 149 insertions(+), 37 deletions(-) diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 400aabc676..7f35bf41e3 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -167,6 +167,13 @@ static void lsIdle() if (pressed) { powerFSM.trigger(EVENT_PRESS); } +#ifdef MOTION_WAKE_INT_PIN + // Not the button: the accelerometer can have raised the line instead. + else if (config.display.wake_on_tap_or_motion && + digitalRead(MOTION_WAKE_INT_PIN) == (MOTION_WAKE_INT_ACTIVE_HIGH ? HIGH : LOW)) { + powerFSM.trigger(EVENT_INPUT); + } +#endif break; } default: diff --git a/src/configuration.h b/src/configuration.h index 2018fe678c..45e18bf6f0 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -642,6 +642,33 @@ along with this program. If not, see . #define HAS_SCREEN 0 #endif +// ----------------------------------------------------------------------------- +// Motion sensor wake +// ----------------------------------------------------------------------------- + +/* The motion driver that owns this pin attaches the ISR. sleep.cpp reuses it as a + light-sleep wake source and PowerFSM attributes the resulting GPIO wake to motion. + Must stay below the exclusion cascade: MESHTASTIC_MINIMIZE_BUILD derives + MESHTASTIC_EXCLUDE_I2C above, and no motion driver is built when it is set. */ +#if !MESHTASTIC_EXCLUDE_I2C +#if defined(BMA4XX_INT) && defined(HAS_BMA423) +#define MOTION_WAKE_INT_PIN BMA4XX_INT +#define MOTION_WAKE_INT_ACTIVE_HIGH 1 +#elif defined(BHI260AP_INT) && defined(HAS_BHI260AP) +#define MOTION_WAKE_INT_PIN BHI260AP_INT +#define MOTION_WAKE_INT_ACTIVE_HIGH 1 +#elif defined(STK8XXX_INT) && defined(HAS_STK8XXX) +#define MOTION_WAKE_INT_PIN STK8XXX_INT +#define MOTION_WAKE_INT_ACTIVE_HIGH 1 +#elif defined(ICM_20948_INT_PIN) && defined(HAS_ICM20948) +#define MOTION_WAKE_INT_PIN ICM_20948_INT_PIN +#define MOTION_WAKE_INT_ACTIVE_HIGH 0 +#elif defined(QMA_6100P_INT_PIN) && defined(HAS_QMA6100P) +#define MOTION_WAKE_INT_PIN QMA_6100P_INT_PIN +#define MOTION_WAKE_INT_ACTIVE_HIGH 0 +#endif +#endif + #ifndef USE_ETHERNET_DEFAULT #define USE_ETHERNET_DEFAULT 0 #endif diff --git a/src/motion/BHI260APSensor.cpp b/src/motion/BHI260APSensor.cpp index c9efda96c6..bf5290dce3 100644 --- a/src/motion/BHI260APSensor.cpp +++ b/src/motion/BHI260APSensor.cpp @@ -3,8 +3,19 @@ #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && defined(HAS_BHI260AP) && __has_include() #define BOSCH_BHI260_KLIO +#include "mesh/Throttle.h" #include + +#ifdef BHI260AP_INT +static volatile bool BHI_IRQ = false; +#endif + BHI260APSensor::BHI260APSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} + +void BHI260APSensor::onWristTilt(uint8_t, const uint8_t *, uint32_t, uint64_t *, void *user_data) +{ + static_cast(user_data)->wakeRequested = true; +} // https://github.com/lewisxhe/SensorLib/blob/master/examples/Sensors/IMU/BHI260AP_InterruptSettings/BHI260AP_InterruptSettings.ino bool BHI260APSensor::init() @@ -29,18 +40,25 @@ bool BHI260APSensor::init() // sensor.configAccelerometer(sensor.RANGE_2G, sensor.ODR_100HZ, sensor.BW_NORMAL_AVG4, sensor.PERF_CONTINUOUS_MODE); // sensor.enableAccelerometer(); - // sensor.configInterrupt(); #ifdef BHI260AP_INT + // Defaults: active-high, level-triggered, push-pull, FIFO sources unmasked. + InterruptConfig intConfig; + sensor.configureInterrupt(intConfig); pinMode(BHI260AP_INT, INPUT); attachInterrupt( - BHI260AP_INT, - [] { - // Set interrupt to set irq value to true - }, - RISING); // Select the interrupt mode according to the actual circuit + BHI260AP_INT, [] { BHI_IRQ = true; }, RISING); #endif + // Wrist tilt wakes the screen. Not every firmware image ships it and SensorAnyMotion + // is BHI360-only, so fall back to step counting alone. + constexpr uint8_t wristTilt = static_cast(BoschSensorID::WRIST_TILT_GESTURE); + if (sensor.onResultEvent(wristTilt, onWristTilt, this) && sensor.configure(wristTilt, 1.0f, 0)) { + LOG_DEBUG("BHI260AP wrist tilt wake enabled"); + } else { + LOG_WARN("BHI260AP firmware has no wrist tilt gesture, motion wake unavailable"); + } + // stepDetector->enable(1.0, 0); stepCounter->enable(1.0, 0); LOG_DEBUG("BHI260AP init ok"); @@ -52,6 +70,14 @@ bool BHI260APSensor::init() int32_t BHI260APSensor::runOnce() { +#ifdef BHI260AP_INT + // The INT line is the fast path; the keepalive keeps the step counter alive without it. + if (!BHI_IRQ && !Throttle::hasElapsed(lastPollMs, MOTION_SENSOR_IRQ_KEEPALIVE_MS)) + return MOTION_SENSOR_CHECK_INTERVAL_MS; + BHI_IRQ = false; + lastPollMs = millis(); +#endif + sensor.update(); if (stepCounter->hasUpdated()) { steps = stepCounter->getStepCount(); @@ -59,14 +85,16 @@ int32_t BHI260APSensor::runOnce() if (screen) screen->steps = steps; } - // LOG_WARN("Step count: %u", stepCounter->getStepCount()); - // if (sensor.readIrqStatus()) { - // if (sensor.isTilt() || sensor.isDoubleTap()) { - // wakeScreen(); - // return 500; - // } - //} + if (wakeRequested) { + wakeRequested = false; + wakeScreen(); + } +#ifdef BHI260AP_INT + // Tick fast for tilt latency; without the INT line every tick would be an I2C drain. + return MOTION_SENSOR_CHECK_INTERVAL_MS; +#else return 1000; +#endif } -#endif \ No newline at end of file +#endif diff --git a/src/motion/BHI260APSensor.h b/src/motion/BHI260APSensor.h index b0a4064872..ec8163c45e 100644 --- a/src/motion/BHI260APSensor.h +++ b/src/motion/BHI260APSensor.h @@ -15,10 +15,16 @@ class BHI260APSensor : public MotionSensor { private: SensorBHI260AP sensor; - volatile bool BHI_IRQ = false; SensorStepCounter *stepCounter; SensorStepDetector *stepDetector; uint32_t steps = 0; + bool wakeRequested = false; +#ifdef BHI260AP_INT + uint32_t lastPollMs = 0; +#endif + + // Fires from sensor.update() when the fusion hub reports a wrist tilt. + static void onWristTilt(uint8_t sensor_id, const uint8_t *data, uint32_t size, uint64_t *timestamp, void *user_data); public: explicit BHI260APSensor(ScanI2C::FoundDevice foundDevice); diff --git a/src/motion/BMA423Sensor.cpp b/src/motion/BMA423Sensor.cpp index 98491311b9..6271d70caa 100755 --- a/src/motion/BMA423Sensor.cpp +++ b/src/motion/BMA423Sensor.cpp @@ -2,6 +2,12 @@ #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && defined(HAS_BMA423) && __has_include() +#include "mesh/Throttle.h" + +#ifdef BMA4XX_INT +static volatile bool BMA_IRQ = false; +#endif + BMA423Sensor::BMA423Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} bool BMA423Sensor::init() @@ -24,6 +30,13 @@ bool BMA423Sensor::init() sensor.setRemapAxes(SensorRemap::BOTTOM_LAYER_BOTTOM_LEFT_CORNER); #endif +#ifdef BMA4XX_INT + // enableTiltDetector()/enableTapDetector() only map the feature onto INT1, and the BMA4 + // reset default leaves that pin's output driver off. Arm it push-pull active-high. + if (!sensor.setInterruptPinConfig(InterruptPinMap::PIN1, false, false, true, false)) + LOG_DEBUG("BMA423 INT1 pin config failed, keeping the polled path"); // not fatal +#endif + // The tap detector defaults to double tap; tilt and double tap both wake the screen. sensor.setOnTiltDetectedCallback([this] { wakeRequested = true; }); sensor.setOnTapCallback([this](TapType) { wakeRequested = true; }); @@ -32,12 +45,26 @@ bool BMA423Sensor::init() return false; } +#ifdef BMA4XX_INT + pinMode(BMA4XX_INT, INPUT); + attachInterrupt( + BMA4XX_INT, [] { BMA_IRQ = true; }, RISING); +#endif + LOG_DEBUG("BMA423 init ok"); return true; } int32_t BMA423Sensor::runOnce() { +#ifdef BMA4XX_INT + // INT1 is the fast path; update() reads and clears the status register and fires the callbacks. + if (!BMA_IRQ && !Throttle::hasElapsed(lastPollMs, MOTION_SENSOR_IRQ_KEEPALIVE_MS)) + return MOTION_SENSOR_CHECK_INTERVAL_MS; + BMA_IRQ = false; + lastPollMs = millis(); +#endif + wakeRequested = false; sensor.update(); if (wakeRequested) { diff --git a/src/motion/BMA423Sensor.h b/src/motion/BMA423Sensor.h index 7ce5525c66..512457daf9 100755 --- a/src/motion/BMA423Sensor.h +++ b/src/motion/BMA423Sensor.h @@ -14,6 +14,9 @@ class BMA423Sensor : public MotionSensor private: SensorBMA423 sensor; bool wakeRequested = false; +#ifdef BMA4XX_INT + uint32_t lastPollMs = 0; +#endif public: explicit BMA423Sensor(ScanI2C::FoundDevice foundDevice); diff --git a/src/motion/ICM20948Sensor.cpp b/src/motion/ICM20948Sensor.cpp index 8238b3dbc9..a05f9aeca7 100644 --- a/src/motion/ICM20948Sensor.cpp +++ b/src/motion/ICM20948Sensor.cpp @@ -2,6 +2,7 @@ #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() #include "detect/ScanI2CTwoWire.h" +#include "mesh/Throttle.h" #if !defined(MESHTASTIC_EXCLUDE_SCREEN) // screen is defined in main.cpp @@ -34,21 +35,6 @@ bool ICM20948Sensor::init() return wakeOnMotionOk; } -#ifdef ICM_20948_INT_PIN - -int32_t ICM20948Sensor::runOnce() -{ - // Wake on motion using hardware interrupts - this is the most efficient way to check for motion - if (ICM20948_IRQ) { - ICM20948_IRQ = false; - sensor->clearInterrupts(); - wakeScreen(); - } - return MOTION_SENSOR_CHECK_INTERVAL_MS; -} - -#else - int32_t ICM20948Sensor::runOnce() { #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN @@ -105,7 +91,21 @@ int32_t ICM20948Sensor::runOnce() screen->setHeading(heading); #endif - // Wake on motion using polling - this is not as efficient as using hardware interrupt pin (see above) +#ifdef ICM_20948_INT_PIN + if (ICM20948_IRQ) { + ICM20948_IRQ = false; + intPinProven = true; + sensor->clearInterrupts(); + wakeScreen(); + return MOTION_SENSOR_CHECK_INTERVAL_MS; + } + // Back off to the keepalive only once the pin has actually fired. No vendor firmware + // uses this line, so an unproven one keeps full-rate polling instead of costing latency. + if (intPinProven && !Throttle::hasElapsed(lastWomPollMs, MOTION_SENSOR_IRQ_KEEPALIVE_MS)) + return MOTION_SENSOR_CHECK_INTERVAL_MS; + lastWomPollMs = millis(); +#endif + auto status = sensor->setBank(0); if (sensor->status != ICM_20948_Stat_Ok) { LOG_DEBUG("ICM20948 isWakeOnMotion failed to set bank - %s", sensor->statusString()); @@ -126,8 +126,6 @@ int32_t ICM20948Sensor::runOnce() return MOTION_SENSOR_CHECK_INTERVAL_MS; } -#endif - void ICM20948Sensor::calibrate(uint16_t forSeconds) { #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN diff --git a/src/motion/ICM20948Sensor.h b/src/motion/ICM20948Sensor.h index e84c7ea1bd..152ea9c95c 100755 --- a/src/motion/ICM20948Sensor.h +++ b/src/motion/ICM20948Sensor.h @@ -24,10 +24,8 @@ #define ICM_20948_WOM_THRESHOLD 16U #endif -// Define a pin in variant.h to use interrupts to read the ICM-20948 -#ifndef ICM_20948_WOM_THRESHOLD -#define ICM_20948_INT_PIN 255 -#endif +// Define ICM_20948_INT_PIN in variant.h to drive wake-on-motion from the INT pin +// instead of polling. The driver configures it active-low. // Uncomment this line to enable helpful debug messages on Serial // #define ICM_20948_DEBUG 1 @@ -83,6 +81,10 @@ class ICM20948Sensor : public MotionSensor ICM20948Singleton *sensor = nullptr; bool showingScreen = false; bool isAsleep = false; +#ifdef ICM_20948_INT_PIN + uint32_t lastWomPollMs = 0; + bool intPinProven = false; +#endif static constexpr const char *compassCalibrationFileName = "/prefs/compass_icm20948.dat"; #ifdef MUZI_BASE float highestX = 449.000000, lowestX = -140.000000, highestY = 422.000000, lowestY = -232.000000, highestZ = 749.000000, diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h index ef84e1b19b..5a7111c52b 100755 --- a/src/motion/MotionSensor.h +++ b/src/motion/MotionSensor.h @@ -3,6 +3,8 @@ #define _MOTION_SENSOR_H_ #define MOTION_SENSOR_CHECK_INTERVAL_MS 50 +// Safety-net drain for the interrupt-driven drivers: a dead INT pin degrades to polling. +#define MOTION_SENSOR_IRQ_KEEPALIVE_MS 1000 #define MOTION_SENSOR_CLICK_THRESHOLD 40 #include "../configuration.h" diff --git a/src/sleep.cpp b/src/sleep.cpp index 31c91e80d0..2b6f58843c 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -477,6 +477,12 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #endif #if defined(WAKE_ON_TOUCH) gpio_wakeup_enable((gpio_num_t)SCREEN_TOUCH_INT, GPIO_INTR_LOW_LEVEL); +#endif +#ifdef MOTION_WAKE_INT_PIN + // Only arm motion wake when the user asked for it, otherwise every tilt costs a wakeup. + if (config.display.wake_on_tap_or_motion) + gpio_wakeup_enable((gpio_num_t)MOTION_WAKE_INT_PIN, + MOTION_WAKE_INT_ACTIVE_HIGH ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL); #endif enableLoraInterrupt(); #ifdef PMU_IRQ @@ -524,6 +530,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #if defined(WAKE_ON_TOUCH) gpio_wakeup_disable((gpio_num_t)SCREEN_TOUCH_INT); #endif +#ifdef MOTION_WAKE_INT_PIN + // Unconditional: the config can have changed while we were asleep. + gpio_wakeup_disable((gpio_num_t)MOTION_WAKE_INT_PIN); +#endif #if !defined(SOC_PM_SUPPORT_EXT_WAKEUP) && defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) if (radioType != RF95_RADIO) { gpio_wakeup_disable((gpio_num_t)LORA_DIO1); diff --git a/variants/nrf52840/t-impulse-plus/variant.h b/variants/nrf52840/t-impulse-plus/variant.h index e3a6c7ee63..33cfaff247 100644 --- a/variants/nrf52840/t-impulse-plus/variant.h +++ b/variants/nrf52840/t-impulse-plus/variant.h @@ -157,6 +157,8 @@ static const uint8_t SCL = PIN_WIRE_SCL; // IMU (ICM20948 on Wire1) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #define HAS_ICM20948 +// D27, not (0 + 7): pinMode/attachInterrupt index g_ADigitalPinMap, where 7 is P1.13 (RF_VC1). +#define ICM_20948_INT_PIN D27 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Charger (SGM41562 on Wire1 @ 0x03) From 6554194e663ecc53e2316e648a4cce5c85acecc2 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 10 Sep 2026 14:37:09 +0000 Subject: [PATCH 119/143] Generate (very basic) HTML index for Meshtastic Nightlies (#11807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So https://nightly.meshtastic.org doesn't just 404. We ❤️ our nightly testers, if they don't want to use the Flasher that is fine! --- .github/workflows/main_matrix.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 95f965cf15..d54eae77e3 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -782,6 +782,13 @@ jobs: exit 1 fi + - name: Generate nightly html index + env: + VERSION: ${{ needs.version.outputs.long }} + working-directory: ./stage + run: | + tree -H "." -T "Meshtastic Nightly $VERSION" --noreport -I "index.html" --charset utf-8 > index.html + # Mirror the staged directory to Cloudflare R2. --delete at the bucket root # clears stale nightly binaries, and is in scope for the whole bucket because # this bucket holds nothing but the nightly build. release_notes.md lives only @@ -810,11 +817,16 @@ jobs: --delete \ --exclude 'release_notes.md' \ --exclude 'index.json' \ + --exclude 'index.html' \ --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ --cache-control 'public, max-age=3600, s-maxage=86400' - # index.json is the pointer to the current nightly, don't cache it so hard (5 minutes). - aws s3 cp ./stage/index.json "s3://${r2_bucket}/index.json" \ - --endpoint-url "$R2_ENDPOINT" \ - --no-progress \ - --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ - --cache-control 'public, max-age=300' + # The indices point at whatever the current nightly is - index.json for + # clients, index.html for browsers - so they are excluded from the sync + # above and uploaded here with a 5 minute cache instead. + for index in index.json index.html; do + aws s3 cp "./stage/$index" "s3://${r2_bucket}/$index" \ + --endpoint-url "$R2_ENDPOINT" \ + --no-progress \ + --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ + --cache-control 'public, max-age=300' + done From 5c113c730bfaa2cb1ad7976af9f4ede2850d7d84 Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:08:33 +0000 Subject: [PATCH 120/143] feat: add exFat support for SDIO SD cards (#11805) * exFat for SDIO * update device-ui commt --- extra_scripts/esp32_fatfs_exfat.py | 124 ++++++++++++++++++ platformio.ini | 5 +- .../seeed_wio_tracker_L2/platformio.ini | 7 + 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 extra_scripts/esp32_fatfs_exfat.py diff --git a/extra_scripts/esp32_fatfs_exfat.py b/extra_scripts/esp32_fatfs_exfat.py new file mode 100644 index 0000000000..1fc5636a99 --- /dev/null +++ b/extra_scripts/esp32_fatfs_exfat.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# trunk-ignore-all(ruff/F821) +# trunk-ignore-all(flake8/F821): For SConstruct imports +import re +from os.path import exists, join + +Import("env") + +# --------------------------------------------------------------------------- +# exFAT for the IDF FatFs component. +# +# ESP-IDF exposes no Kconfig symbol for exFAT (checked 5.5.x and master): +# components/fatfs/src/ffconf.h hardcodes "#define FF_FS_EXFAT 0", so +# CONFIG_FATFS_FS_EXFAT=y in custom_sdkconfig is silently dropped by kconfgen +# and an exFAT card fails to mount with ESP_FAIL from esp_vfs_fat_sdmmc_mount. +# This script turns that inert symbol into a real ffconf.h patch. +# +# Two headers must agree, because FF_FS_EXFAT changes the FATFS/FIL/DIR layout +# in ff.h: +# 1. framework-espidf - compiled into libfatfs.a by the HybridCompile +# IDF-libs rebuild (arduino.py -> espidf.py). Patched in the pre: pass. +# 2. framework-arduinoespressif32-libs//include - used when the +# application and the Arduino SD_MMC library are compiled. espidf.py's +# idf_lib_copy() copies back archives and sdkconfig.h but no headers, and +# a custom_sdkconfig hash change wipes the whole package and reinstalls +# it, so this copy is patched again in the post: pass. +# +# framework-espidf is shared by every ESP32 env while this script is registered +# only on the SDIO variants, so the patch there is undone once the build is +# done: an env that never asked for exFAT must not rebuild its IDF libs against +# a header the rest of its build does not see. The per-chip -libs copy stays +# patched, matching the libfatfs.a it was built with. +# +# The libs rebuild is keyed on the md5 of custom_sdkconfig, which the patch +# state does not otherwise reach, so the state is appended as a comment line +# (inert in kconfig). Keep the marker lowercase: arduino.py greps +# custom_sdkconfig for "PSRAM" and "CONFIG_SPIRAM=y". +# --------------------------------------------------------------------------- + +SWITCH = "CONFIG_FATFS_FS_EXFAT=y" +MARKER_KEY = "meshtastic_fatfs_exfat" +PATTERN = re.compile(r"^(#define\s+FF_FS_EXFAT\s+)([01])", re.MULTILINE) + + +def wants_exfat(env): + config = env.GetProjectConfig() + section = "env:" + env["PIOENV"] + if not config.has_option(section, "custom_sdkconfig"): + return False + return any( + line.strip() == SWITCH + for line in env.GetProjectOption("custom_sdkconfig").splitlines() + ) + + +def ffconf_paths(env, phase): + platform = env.PioPlatform() + board = env.BoardConfig() + chip = board.get("build.chip_variant", "").lower() or board.get( + "build.mcu", "esp32" + ) + + paths = [] + if phase == "pre": + idf = idf_ffconf(env) + if idf: + paths.append(idf) + libs_dir = platform.get_package_dir("framework-arduinoespressif32-libs") + if libs_dir: + paths.append(join(libs_dir, chip, "include", "fatfs", "src", "ffconf.h")) + return [p for p in paths if exists(p)] + + +def idf_ffconf(env): + idf_dir = env.PioPlatform().get_package_dir("framework-espidf") + return join(idf_dir, "components", "fatfs", "src", "ffconf.h") if idf_dir else None + + +def set_ff_fs_exfat(path, enable): + with open(path) as src: + content = src.read() + patched = PATTERN.sub(r"\g<1>%d" % (1 if enable else 0), content, count=1) + if patched == content: + return False + with open(path, "w") as dst: + dst.write(patched) + print("*** FF_FS_EXFAT=%d: %s ***" % (1 if enable else 0, path)) + return True + + +def tag_sdkconfig_state(env, enable): + config = env.GetProjectConfig() + section = "env:" + env["PIOENV"] + if not config.has_option(section, "custom_sdkconfig"): + return + current = env.GetProjectOption("custom_sdkconfig") + if MARKER_KEY in current: + return + marker = "# %s: %d" % (MARKER_KEY, 1 if enable else 0) + config.set(section, "custom_sdkconfig", current.rstrip("\n") + "\n" + marker) + + +phase = "post" if env.get("MESHTASTIC_EXFAT_PATCHED") else "pre" +enable = wants_exfat(env) + +for path in ffconf_paths(env, phase): + set_ff_fs_exfat(path, enable) + +if phase == "pre": + # revert as well as enable, so an env without the switch never links a + # FatFs built with a different struct layout than the headers it compiles against + tag_sdkconfig_state(env, enable) + env["MESHTASTIC_EXFAT_PATCHED"] = True + + if enable: + + def restore_idf_ffconf(target, source, env): + idf = idf_ffconf(env) + if idf and exists(idf): + set_ff_fs_exfat(idf, False) + + # the IDF libs are compiled during the build phase, so this is the first + # point at which the shared package can be handed back unpatched + env.AddPostAction("checkprogsize", restore_idf_ffconf) diff --git a/platformio.ini b/platformio.ini index ff108f2b2f..a65f0c5a82 100644 --- a/platformio.ini +++ b/platformio.ini @@ -140,10 +140,13 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/c6d003eb6d6b65f74de6738be924a49a973608b9.zip + https://github.com/meshtastic/device-ui/archive/33738beab3ef0257f6b88486e4e7552d9bd5de4e.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y + ; not an IDF symbol; read by extra_scripts/esp32_fatfs_exfat.py to patch ffconf.h + CONFIG_FATFS_FS_EXFAT=y + CONFIG_FATFS_LFN_STACK=y ; Common libs for environmental measurements in telemetry module [environmental_base] diff --git a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini index c46602d71b..3f337f0c91 100644 --- a/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini +++ b/variants/esp32s3/seeed_wio_tracker_L2/platformio.ini @@ -81,3 +81,10 @@ lib_deps = custom_sdkconfig = ${esp32s3_base.custom_sdkconfig} ${device-ui_base.custom_sdkconfig} + +; twice on purpose: the pre pass patches framework-espidf before the IDF-libs +; rebuild, the post pass re-patches the -libs headers a reinstall just wiped +extra_scripts = + ${esp32s3_base.extra_scripts} + pre:extra_scripts/esp32_fatfs_exfat.py + post:extra_scripts/esp32_fatfs_exfat.py From 34190aac07b862b505a5f8e162315de26c39d35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 10 Sep 2026 15:50:48 +0000 Subject: [PATCH 121/143] fix(nodedb): drop satellite entries that no hot node owns (#11808) * fix(nodedb): drop satellite entries that no hot node owns * test(nodedb): assert every persisted satellite key is owned --- src/mesh/NodeDB.cpp | 39 +++++++-- src/mesh/PhoneAPI.cpp | 80 +++++++++-------- src/mesh/PhoneAPI.h | 10 ++- test/test_nodedb_v25_roundtrip/test_main.cpp | 90 ++++++++++++++++++-- 4 files changed, 162 insertions(+), 57 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index ff7bcca348..b0b3df57b7 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -253,6 +253,12 @@ std::map *s_decodeEnvironmentTarget = nu #if !MESHTASTIC_EXCLUDE_STATUSDB std::map *s_decodeStatusTarget = nullptr; #endif + +// Keys that can never name a real node. +[[maybe_unused]] inline bool isUsableSatelliteKey(NodeNum n) +{ + return n != 0 && !isBroadcast(n); +} } // namespace bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field) @@ -306,7 +312,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (pb_decode(istream, meshtastic_NodePositionEntry_fields, &entry)) { #if !MESHTASTIC_EXCLUDE_POSITIONDB if (s_decodePositionsTarget) { - if (entry.has_position) + if (entry.has_position && isUsableSatelliteKey(entry.num)) (*s_decodePositionsTarget)[entry.num] = entry.position; return true; } @@ -332,7 +338,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (pb_decode(istream, meshtastic_NodeTelemetryEntry_fields, &entry)) { #if !MESHTASTIC_EXCLUDE_TELEMETRYDB if (s_decodeTelemetryTarget) { - if (entry.has_device_metrics) + if (entry.has_device_metrics && isUsableSatelliteKey(entry.num)) (*s_decodeTelemetryTarget)[entry.num] = entry.device_metrics; return true; } @@ -358,7 +364,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (pb_decode(istream, meshtastic_NodeStatusEntry_fields, &entry)) { #if !MESHTASTIC_EXCLUDE_STATUSDB if (s_decodeStatusTarget) { - if (entry.has_status) + if (entry.has_status && isUsableSatelliteKey(entry.num)) (*s_decodeStatusTarget)[entry.num] = entry.status; return true; } @@ -384,7 +390,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (pb_decode(istream, meshtastic_NodeEnvironmentEntry_fields, &entry)) { #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB if (s_decodeEnvironmentTarget) { - if (entry.has_environment_metrics) + if (entry.has_environment_metrics && isUsableSatelliteKey(entry.num)) (*s_decodeEnvironmentTarget)[entry.num] = entry.environment_metrics; return true; } @@ -1936,16 +1942,35 @@ bool NodeDB::enforceSatelliteCaps() { concurrency::LockGuard guard(&satelliteMutex); bool trimmedAny = false; - auto trim = [this, &trimmedAny](auto &map, const char *name) { + const NodeNum self = getNodeNum(); + // One sorted snapshot of the hot keys serves all four maps; the orphan test is a binary search. + std::vector hotNums; + hotNums.reserve(numMeshNodes); + for (int i = 0; i < numMeshNodes; i++) + hotNums.push_back(meshNodes->at(i).num); + std::sort(hotNums.begin(), hotNums.end()); + + auto trim = [this, &trimmedAny, &hotNums, self](auto &map, const char *name) { const size_t before = map.size(); + // Orphans (key with no hot-table owner) only ever arrive from disk, and the + // cap paths never reclaim them because they fire above the cap, not at it. + size_t orphans = 0; + for (auto it = map.begin(); it != map.end();) { + if (it->first != self && !std::binary_search(hotNums.begin(), hotNums.end(), it->first)) { + it = map.erase(it); + orphans++; + } else { + ++it; + } + } while (map.size() > MAX_SATELLITE_NODES) { if (!evictStalestSatellite(*this, map)) break; } if (map.size() != before) { trimmedAny = true; - LOG_MIGRATION("Trimmed %s satellites %u -> %u (cap %d)", name, (unsigned)before, (unsigned)map.size(), - MAX_SATELLITE_NODES); + LOG_MIGRATION("Trimmed %s satellites %u -> %u (cap %d, %u orphaned)", name, (unsigned)before, (unsigned)map.size(), + MAX_SATELLITE_NODES, (unsigned)orphans); } }; #if !MESHTASTIC_EXCLUDE_POSITIONDB diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index f2e9db8aa9..50daa635d4 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1281,26 +1281,26 @@ bool lastHeardIsWallClock(const meshtastic_NodeInfoLite *header) // Previously these packets carried a bare 0, which a client renders as a real reading. // Note the asymmetry with rx_snr below: that field is still proto3 singular, so "unknown" and // "0 dB" remain indistinguishable there. -meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos) +meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_PositionLite &pos) { // Shape this exactly like a fresh live broadcast Position from the peer so the // phone runs it through its normal "live position broadcast" handler path. // to=ourNum would read as a DM-from-peer and never lands in node detail UI. meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; - const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); - pkt.from = num; + pkt.from = header->num; pkt.to = NODENUM_BROADCAST; // rx_time means "when *we* received this" - use last_heard, not the position's own GPS // fix time (which is often 0 and, when present, already round-trips inside the payload // via ConvertToPosition). - pkt.rx_time = header ? header->last_heard : 0; + pkt.rx_time = header->last_heard; // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). pkt.has_rx_time = lastHeardIsWallClock(header); // Stable per-node/per-fix id: replaying the same unchanged history on every // reconnect must not look like a brand new packet to the phone's history/dedup. - pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_POSITION_APP); - pkt.channel = header ? header->channel : 0; - pkt.rx_snr = header ? header->snr : 0; + pkt.id = makeReplayPacketId(header->num, pkt.rx_time, meshtastic_PortNum_POSITION_APP); + pkt.channel = header->channel; + pkt.rx_snr = header->snr; pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -1315,19 +1315,19 @@ meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const mesh return pkt; } -meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const meshtastic_DeviceMetrics &metrics) +meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_DeviceMetrics &metrics) { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; - pkt.from = num; + pkt.from = header->num; pkt.to = NODENUM_BROADCAST; // No native timestamp on telemetry packets here; use last_heard. - const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); - pkt.rx_time = header ? header->last_heard : 0; + pkt.rx_time = header->last_heard; // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). pkt.has_rx_time = lastHeardIsWallClock(header); - pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag); - pkt.channel = header ? header->channel : 0; - pkt.rx_snr = header ? header->snr : 0; + pkt.id = makeReplayPacketId(header->num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag); + pkt.channel = header->channel; + pkt.rx_snr = header->snr; pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -1376,10 +1376,13 @@ void PhoneAPI::prefetchReplayPositions() wasEmpty = replayQueue.empty(); while (replayQueue.size() < kReplayPrefetchDepth && replayPositionIndex < replayPositionOrder.size()) { NodeNum num = replayPositionOrder[replayPositionIndex++]; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); meshtastic_PositionLite pos; - if (!nodeDB->copyNodePosition(num, pos)) - continue; // entry was evicted between snapshot and now - replayQueue.push_back(makeReplayPositionPacket(num, pos)); + // No header means an orphan the phone cannot attribute; a failed copy means + // the entry was evicted between snapshot and now. + if (!header || !nodeDB->copyNodePosition(num, pos)) + continue; + replayQueue.push_back(makeReplayPositionPacket(header, pos)); added = true; } } @@ -1412,10 +1415,11 @@ void PhoneAPI::prefetchReplayTelemetry() wasEmpty = replayQueue.empty(); while (replayQueue.size() < kReplayPrefetchDepth && replayTelemetryIndex < replayTelemetryOrder.size()) { NodeNum num = replayTelemetryOrder[replayTelemetryIndex++]; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); meshtastic_DeviceMetrics dm; - if (!nodeDB->copyNodeTelemetry(num, dm)) + if (!header || !nodeDB->copyNodeTelemetry(num, dm)) continue; - replayQueue.push_back(makeReplayTelemetryPacket(num, dm)); + replayQueue.push_back(makeReplayTelemetryPacket(header, dm)); added = true; } } @@ -1424,18 +1428,18 @@ void PhoneAPI::prefetchReplayTelemetry() #endif } -meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env) +meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_EnvironmentMetrics &env) { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; - pkt.from = num; + pkt.from = header->num; pkt.to = NODENUM_BROADCAST; - const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); - pkt.rx_time = header ? header->last_heard : 0; + pkt.rx_time = header->last_heard; // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). pkt.has_rx_time = lastHeardIsWallClock(header); - pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag); - pkt.channel = header ? header->channel : 0; - pkt.rx_snr = header ? header->snr : 0; + pkt.id = makeReplayPacketId(header->num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag); + pkt.channel = header->channel; + pkt.rx_snr = header->snr; pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -1478,10 +1482,11 @@ void PhoneAPI::prefetchReplayEnvironment() wasEmpty = replayQueue.empty(); while (replayQueue.size() < kReplayPrefetchDepth && replayEnvironmentIndex < replayEnvironmentOrder.size()) { NodeNum num = replayEnvironmentOrder[replayEnvironmentIndex++]; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); meshtastic_EnvironmentMetrics env; - if (!nodeDB->copyNodeEnvironment(num, env)) + if (!header || !nodeDB->copyNodeEnvironment(num, env)) continue; - replayQueue.push_back(makeReplayEnvironmentPacket(num, env)); + replayQueue.push_back(makeReplayEnvironmentPacket(header, env)); added = true; } } @@ -1490,19 +1495,19 @@ void PhoneAPI::prefetchReplayEnvironment() #endif } -meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status) +meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_StatusMessage &status) { meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; - pkt.from = num; + pkt.from = header->num; pkt.to = NODENUM_BROADCAST; // StatusMessage has no native timestamp; use last_heard. - const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); - pkt.rx_time = header ? header->last_heard : 0; + pkt.rx_time = header->last_heard; // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). pkt.has_rx_time = lastHeardIsWallClock(header); - pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP); - pkt.channel = header ? header->channel : 0; - pkt.rx_snr = header ? header->snr : 0; + pkt.id = makeReplayPacketId(header->num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP); + pkt.channel = header->channel; + pkt.rx_snr = header->snr; pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -1540,10 +1545,11 @@ void PhoneAPI::prefetchReplayStatus() wasEmpty = replayQueue.empty(); while (replayQueue.size() < kReplayPrefetchDepth && replayStatusIndex < replayStatusOrder.size()) { NodeNum num = replayStatusOrder[replayStatusIndex++]; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); meshtastic_StatusMessage status; - if (!nodeDB->copyNodeStatus(num, status) || status.status[0] == '\0') + if (!header || !nodeDB->copyNodeStatus(num, status) || status.status[0] == '\0') continue; - replayQueue.push_back(makeReplayStatusPacket(num, status)); + replayQueue.push_back(makeReplayStatusPacket(header, status)); added = true; } } diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index 245b4b94da..1762a37948 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -288,10 +288,12 @@ class PhoneAPI void prefetchReplayEnvironment(); void beginReplayStatus(); void prefetchReplayStatus(); - meshtastic_MeshPacket makeReplayPositionPacket(uint32_t num, const meshtastic_PositionLite &pos); - meshtastic_MeshPacket makeReplayTelemetryPacket(uint32_t num, const meshtastic_DeviceMetrics &metrics); - meshtastic_MeshPacket makeReplayEnvironmentPacket(uint32_t num, const meshtastic_EnvironmentMetrics &env); - meshtastic_MeshPacket makeReplayStatusPacket(uint32_t num, const meshtastic_StatusMessage &status); + meshtastic_MeshPacket makeReplayPositionPacket(const meshtastic_NodeInfoLite *header, const meshtastic_PositionLite &pos); + meshtastic_MeshPacket makeReplayTelemetryPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_DeviceMetrics &metrics); + meshtastic_MeshPacket makeReplayEnvironmentPacket(const meshtastic_NodeInfoLite *header, + const meshtastic_EnvironmentMetrics &env); + meshtastic_MeshPacket makeReplayStatusPacket(const meshtastic_NodeInfoLite *header, const meshtastic_StatusMessage &status); // Post-sync replay drain: pop one cached packet from the active phase, advancing // through positions -> telemetry -> environment -> status until everything is drained. diff --git a/test/test_nodedb_v25_roundtrip/test_main.cpp b/test/test_nodedb_v25_roundtrip/test_main.cpp index 93b15a9551..66db1da693 100644 --- a/test/test_nodedb_v25_roundtrip/test_main.cpp +++ b/test/test_nodedb_v25_roundtrip/test_main.cpp @@ -115,6 +115,27 @@ meshtastic_StatusMessage makeStatus(const char *text) return st; } +/// A header row as a saved nodes.proto carries it. HAS_USER matters: cleanupMeshDB +/// purges a userless row on load and erases its satellites with it. +meshtastic_NodeInfoLite craftedOwner(NodeNum num, uint32_t lastHeard) +{ + meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; + n.num = num; + n.last_heard = lastHeard; + n.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; + return n; +} + +meshtastic_NodePositionEntry craftedPosition(NodeNum num, int32_t lat) +{ + meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero; + e.num = num; + e.has_position = true; + e.position.latitude_i = lat; + e.position.time = 1000 + (uint32_t)lat; + return e; +} + bool readFileBytes(const char *path, std::vector &out) { auto f = FSCom.open(path, FILE_O_READ); @@ -522,16 +543,14 @@ static void test_bootTrim_overCapSatellitesHealedOnDisk(void) const NodeNum base = 0x70000000u; // Craft a v25 nodes.proto whose position store exceeds this build's cap, as a - // larger-cap build (or a peer backup) would leave behind. + // larger-cap build (or a peer backup) would leave behind. Every entry has a hot + // owner, so the boot orphan sweep keeps all of them and only the cap trims. meshtastic_NodeDatabase crafted{}; crafted.version = DEVICESTATE_CUR_VER; for (size_t i = 0; i < (size_t)MAX_SATELLITE_NODES + overBy; i++) { - meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero; - e.num = base + (uint32_t)i; - e.has_position = true; - e.position.latitude_i = (int32_t)(1000 + i); - e.position.time = 1000 + (uint32_t)i; - crafted.positions.push_back(e); + const NodeNum num = base + (uint32_t)i; + crafted.nodes.push_back(craftedOwner(num, 1000 + (uint32_t)i)); + crafted.positions.push_back(craftedPosition(num, (int32_t)(1000 + i))); } size_t craftedSize = 0; TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); @@ -539,8 +558,7 @@ static void test_bootTrim_overCapSatellitesHealedOnDisk(void) coldBoot(); - // Trimmed in RAM to exactly the cap; all entries were orphans, so the - // lowest-recency victims (here: the lowest-numbered) went first. + // Trimmed in RAM to exactly the cap, stalest owner first. TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotPositionNodeNums(0).size()); TEST_ASSERT_TRUE(db->hasNodePosition(base + (uint32_t)MAX_SATELLITE_NODES + (uint32_t)overBy - 1)); TEST_ASSERT_FALSE(db->hasNodePosition(base)); @@ -557,6 +575,59 @@ static void test_bootTrim_overCapSatellitesHealedOnDisk(void) } #endif // !MESHTASTIC_EXCLUDE_POSITIONDB +// --- Boot-time heal of a nodes.proto carrying unowned satellite entries --- + +#if !MESHTASTIC_EXCLUDE_POSITIONDB +// Guards #11798: satellite entries whose key names no hot node are dropped on boot and the +// healed store is rewritten once; keys that cannot name a node (0, NODENUM_BROADCAST) are +// refused at decode. +static void test_bootHeal_unownedSatellitesDropped(void) +{ + const NodeNum ownedBase = 0x72000000u; + const NodeNum orphanBase = 0x73000000u; + const size_t owned = 5; + const size_t orphans = 6; + + meshtastic_NodeDatabase crafted{}; + crafted.version = DEVICESTATE_CUR_VER; + for (size_t i = 0; i < owned; i++) { + const NodeNum num = ownedBase + (uint32_t)i; + crafted.nodes.push_back(craftedOwner(num, 1000 + (uint32_t)i)); + crafted.positions.push_back(craftedPosition(num, (int32_t)(100 + i))); + } + for (size_t i = 0; i < orphans; i++) + crafted.positions.push_back(craftedPosition(orphanBase + (uint32_t)i, (int32_t)(200 + i))); + // Keys no NodeNum derivation can produce; these must never reach the map. + crafted.positions.push_back(craftedPosition(0, 300)); + crafted.positions.push_back(craftedPosition(NODENUM_BROADCAST, 301)); + + size_t craftedSize = 0; + TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); + TEST_ASSERT_TRUE(db->saveProto(nodeDatabaseFileName, craftedSize, &meshtastic_NodeDatabase_msg, &crafted, false)); + + coldBoot(); + + // In RAM: every owned entry kept, every unowned one gone. + for (size_t i = 0; i < owned; i++) + TEST_ASSERT_TRUE_MESSAGE(db->hasNodePosition(ownedBase + (uint32_t)i), "hot-owned entry must survive the sweep"); + for (size_t i = 0; i < orphans; i++) + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(orphanBase + (uint32_t)i), "orphan must be swept on boot"); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(0), "key 0 must be refused at decode"); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(NODENUM_BROADCAST), "broadcast key must be refused at decode"); + // And healed on disk: nodeDBSelfCare rewrote the store once during the boot. + meshtastic_NodeDatabase reloaded{}; + decodeNodesFile(reloaded); + size_t persisted = 0; + for (const auto &e : reloaded.positions) { + if (!e.has_position) + continue; + TEST_ASSERT_TRUE_MESSAGE(e.num >= ownedBase && e.num < ownedBase + owned, "healed store must contain only owned entries"); + persisted++; + } + TEST_ASSERT_EQUAL_UINT_MESSAGE((unsigned)owned, (unsigned)persisted, "boot must rewrite the store without the orphans"); +} +#endif // !MESHTASTIC_EXCLUDE_POSITIONDB + // --- resetNodes(keepFavorites): no ghost rows above numMeshNodes --- static void test_resetNodesKeepFavorites_compactsWithoutGhostRows(void) @@ -666,6 +737,7 @@ NDBR_TEST_ENTRY void setup() #endif #if !MESHTASTIC_EXCLUDE_POSITIONDB RUN_TEST(test_bootTrim_overCapSatellitesHealedOnDisk); + RUN_TEST(test_bootHeal_unownedSatellitesDropped); #endif printf("\n=== resetNodes ghost rows ===\n"); From d4482a28a1297ea9545c3da97d6e7db307a36cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 10 Sep 2026 17:46:44 +0000 Subject: [PATCH 122/143] Fail the build when Telemetry no longer fits the packet payload (#11810) * Fail the build when Telemetry no longer fits the packet payload * Trim the comments to the house limit --- src/mesh/mesh-pb-constants.h | 5 +++ test/test_telemetry_payload_fit/test_main.cpp | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 test/test_telemetry_payload_fit/test_main.cpp diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index aa41c16952..c45b0008a3 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -239,6 +239,11 @@ static_assert((uint32_t)TRAFFIC_MANAGEMENT_CACHE_SIZE * 10u + (uint32_t)WARM_NOD "MESHTASTIC_BOOT_CACHE_BUDGET in memory/MemClass.h"); #endif +// An oversized encode is silent: pb_encode_to_bytes() returns 0 and allocDataProtobuf() ships a +// well-formed packet carrying nothing (#11797). +static_assert(meshtastic_Telemetry_size <= meshtastic_Constants_DATA_PAYLOAD_LEN, + "Telemetry no longer fits DATA_PAYLOAD_LEN - shrink a variant in the protobufs repo"); + /// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic /// returns the encoded packet size size_t pb_encode_to_bytes(uint8_t *destbuf, size_t destbufsize, const pb_msgdesc_t *fields, const void *src_struct); diff --git a/test/test_telemetry_payload_fit/test_main.cpp b/test/test_telemetry_payload_fit/test_main.cpp new file mode 100644 index 0000000000..5e8dd59826 --- /dev/null +++ b/test/test_telemetry_payload_fit/test_main.cpp @@ -0,0 +1,44 @@ +// Pins the static_assert in src/mesh/mesh-pb-constants.h that meshtastic_Telemetry_size fits +// meshtastic_Constants_DATA_PAYLOAD_LEN. That assert compares two generated constants and cannot +// tell whether Telemetry_size is a size a real encode reaches, so this fills the largest variant +// to its worst case and measures what nanopb emits. +// +// Regression guarded (#11797): an oversized Telemetry fails silently - pb_encode_to_bytes() +// returns 0 and a well-formed packet carrying nothing goes out. Relax this and the next protobuf +// size increase ships as empty packets again. + +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// host_metrics is the largest variant, so it sets Telemetry_size; 0xFF is its worst case, every +// optional present and every varint at full width. +void test_largest_telemetry_matches_generated_size_and_fits_payload() +{ + meshtastic_Telemetry t = meshtastic_Telemetry_init_zero; + t.time = 0xFFFFFFFF; // proto3 omits a zero scalar; force the 5 B time field to be emitted + t.which_variant = meshtastic_Telemetry_host_metrics_tag; + memset(&t.variant.host_metrics, 0xFF, sizeof(t.variant.host_metrics)); + memset(t.variant.host_metrics.user_string, 'x', sizeof(t.variant.host_metrics.user_string) - 1); + t.variant.host_metrics.user_string[sizeof(t.variant.host_metrics.user_string) - 1] = '\0'; + + size_t size = 0; + TEST_ASSERT_TRUE(pb_get_encoded_size(&size, &meshtastic_Telemetry_msg, &t)); + TEST_ASSERT_EQUAL_size_t(meshtastic_Telemetry_size, size); + TEST_ASSERT_LESS_OR_EQUAL_size_t(meshtastic_Constants_DATA_PAYLOAD_LEN, size); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_largest_telemetry_matches_generated_size_and_fits_payload); + exit(UNITY_END()); +} + +void loop() {} From c3df4914b45aaa85de0bfe69808784aab5c6c8bc Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 11 Sep 2026 01:53:15 +0000 Subject: [PATCH 123/143] Make Nightly index beautiful, commit nightly release_notes (#11816) Link back to the Action, mention the commit, build date, make it pretty with CSS. Also moves release notes into .github/nightly so they can be more easily edited. (Contents unchanged). --- .github/nightly/hintro.html | 153 ++++++++++++++++++++++++++++ .github/nightly/houtro.html | 11 ++ .github/nightly/release_notes.md | 161 ++++++++++++++++++++++++++++++ .github/workflows/main_matrix.yml | 38 +++++-- .trunk/trunk.yaml | 6 ++ 5 files changed, 362 insertions(+), 7 deletions(-) create mode 100644 .github/nightly/hintro.html create mode 100644 .github/nightly/houtro.html create mode 100644 .github/nightly/release_notes.md diff --git a/.github/nightly/hintro.html b/.github/nightly/hintro.html new file mode 100644 index 0000000000..c8737fd414 --- /dev/null +++ b/.github/nightly/hintro.html @@ -0,0 +1,153 @@ + + + + + + +Meshtastic Nightly v%%VERSION%% + + + + + +
+
+ + + + + + +

MeshtasticNightly Firmware

+
+ +

+ Firmware built straight from the tip of develop, published every night. + Grab a .bin, .uf2 or .hex for your board below, or point the + Web Flasher at the nightly channel and let it pick for you. +

+ +
+
Version
%%VERSION%%
+
Commit
%%COMMIT_SHORT%%
+
Built
%%BUILD_DATE%%
+
Build log
run %%RUN_ID%%
+
+ +

+ Nightlies are untested pre-release builds and can be unstable. Only flash hardware you can afford to + fully erase, and back up your config first. Start with the + release notes — they cover what changed and how to report what you find. +

+ +

Files

+
diff --git a/.github/nightly/houtro.html b/.github/nightly/houtro.html new file mode 100644 index 0000000000..02529e1be7 --- /dev/null +++ b/.github/nightly/houtro.html @@ -0,0 +1,11 @@ +
+ + +
+ + diff --git a/.github/nightly/release_notes.md b/.github/nightly/release_notes.md new file mode 100644 index 0000000000..972431e2c9 --- /dev/null +++ b/.github/nightly/release_notes.md @@ -0,0 +1,161 @@ +# Help Test 2.8 - Flash a Nightly, Send Feedback + +We're preparing the **2.8** release, and we need your help shaking it out on real hardware. The web flasher now has a **built-in feedback form**. Flash a nightly, use your node like you normally would, and tell us what you find. Every report on a real board moves the release forward. + +⚠️ 2.8 nightlies are **experimental, pre-release builds** and can be unstable. Please read the warnings and only flash hardware you can afford to fully erase. Back up your config first. + +## Big changes + +There are a number of fundamental changes to 2.8 that require a heads-up. Please review some of these higher profile changes so that you are aware of them before installing: + +- Node numbers (ID) are now derived from the public-key identity of the node +- XEdDSA based packet signing +- Reduction of long-name to 25 bytes +- Precise position no longer allowed on known-keys (public mesh. Please use private channels for this) +- Telemetry and position are off-by-default and must be opted into +- Ground-up redesigned NodeDB storage and traffic management +- Completely new allocation of LoRa Regions and presets, including ham specific carve-outs + +--- + +## How to help in 3 steps + +1. **Flash a 2.8 nightly** from the web flasher onto supported hardware. +2. Make sure your apps / clients are up to date with the latest release in order to support the 2.8 firmware features like packet signing. +3. **Use it normally** for a while - send messages, move around, let it mesh, sleep, and wake. Try the features you actually rely on. +4. **Open the feedback form in the web flasher and tell us what happened** - good, bad, or broken. "Works great on my board" is genuinely useful data too. + +--- + +## Where we most need coverage + +The more different boards and setups we hear from, the better. Especially valuable: + +- **A variety of supported boards** +- **Both radios and roles** - routers, clients, repeaters, and low-power/sleep configurations. +- **Different regions** and channel/modem-preset combinations. +- **Bluetooth pairing and reconnection** from Android and iOS. +- **Upgrade paths** - flashing 2.8 over an existing 2.x install and confirming your config/keys survive. +- **Peripherals** - GPS, screens (OLED/E-Ink/TFT), sensors, and buttons. + +--- + +## What makes a feedback report actionable + +The feedback form captures a lot automatically, but the more of the following you include, the faster we can act: + +| Include | Why it helps | +| ------------------------- | --------------------------------------------------------------------------------------------- | +| **Exact board / variant** | Behavior is often board-specific (e.g. `rak4631`, `heltec-v3`, `t1000-e`). | +| **Build identifier** | The nightly version / commit / date you flashed, so we can reproduce against the right build. | +| **What you did** | Concrete steps leading up to the problem - the shorter the repro, the better. | +| **Expected vs. actual** | What you thought would happen, and what actually happened. | +| **How often** | Every time? Once? Only after a reboot or after X hours? | +| **Logs / screenshots** | Serial or app logs, and photos of the screen or error, when you have them. | +| **Environment** | Region, phone OS + app version, and anything unusual about your setup. | + +### Especially flag these + +- **Boot loops, hangs, watchdog resets, or unexpected reboots** +- **Config, channel, or key loss** after flashing or upgrading +- **Regressions** - something that worked on your previous (stable) firmware but is now broken +- **Meshing / DM problems** between a 2.8 node and nodes on other firmware +- **Power regressions** - noticeably worse battery life + +--- + +## Good feedback vs. vague feedback + +**Vague:** "It's broken, keeps rebooting." + +**Actionable:** "On a `rak4631` flashed with the 2.8 nightly from , the node reboots every time I open the Android app (v2.7.15) and tap Position. Happens every time. Serial log attached, region US." + +--- + +> [!NOTE] +> Thank you for testing. Reports from real hardware - including the boring "everything works" ones - are exactly what let us promote 2.8 from nightly preview to a stable release with confidence. + +# Changes + +### Radio & mesh protocol + +- **Packet Signing via XEdDSA** (#10478) plus a **BaseUI signing status UI** (#10841), unsigned-packet policy hardening with test coverage (#10858), and runtime-toggleable `MESHTASTIC_LOCKDOWN` hardening for nRF52 (#10349, opt-in via #10712). +- **Traffic Management Module** for packet forwarding - dedup, rate limiting, role-aware policing (#9358 base, #10706 dedup/rate-limit expansion, #10745 next-hop cache overflow store, #9921 congestion-aware position interval/hop-exhaustion tuning). +- **Automatic variable hop limits** based on live mesh activity and message-size estimation (#10176). +- **Mesh beacon** feature and admin controls (#10618 base implementation, #10839 second pass adding beacon admin/config). +- **Noise floor** tracking with a sliding-window estimate (#9347). +- **Hash table index for O(1) packet history lookups** - improves routing/dedup performance on busy meshes (#9499). +- New amateur-radio regions: 70cm (#10627), 1.25m/125cm (#10638), 2m/~144MHz (#10623); EU regions merge plus Narrow/Lite region enablement for EU (#10675, #10120); LoRa region preset map for cleaner per-region defaults (#10736). +- **TinyFast and TinySlow modem presets** added to config and menu (#10597). +- LoRa config changes now apply live without a reboot (#9962); LoRa settings expansion and validation improvements (#9878). +- Position privacy: direct-send position packets are clamped to channel precision (#10383), and public/known-key position precision is clamped similarly (#10665) and honors explicit channel settings to prevent location leaks (#10513). +- Licensed operators are now prevented from rebroadcasting packets to/from unlicensed users, for regulatory compliance (#9958). +- Packets with missing/invalid `hop_start` (pre-hop firmware) are now deprecated/blocked (#9476). +- Spoof detection added for UDP multicast packets (#9905). +- Low-bandwidth conversion support added to MeshRadio (#10595). +- ATAK Plugin V2 implemented (drops legacy unishox2 compression) (#10105), including TAKTALK voice/text chat message and room data structures. +- Ethernet HTTP/HTTPS API server ported to RP2350 + W5500 boards (#10573), plus Ethernet OTA support for RP2350/W5500 (#10136). +- Optional `LED_LORA` indicator to show LoRa TX activity (#10465), and an LED indicator for LoRa RX (#10674). +- GPS time sync: device now sets its clock from GPS every 30 minutes (#10737); GPS is skipped at startup if the LoRa region is unset, to save battery (#10386); GPS model/baudrate now cached across reboots to skip a full sweep (#10544). +- Config: **position & telemetry broadcast are now opt-in** rather than always-on (#10929). +- **Extra-repeat tolerance** - device tolerates a configurable number of heard repeats before cancelling its own rebroadcast, with tolerance suppressed when the mesh is busy or dense (recent local branch work). + +### UI / display - BaseUI + +_BaseUI is the current default graphical UI (`src/graphics/Screen.cpp`, `draw/UIRenderer`, `draw/MenuHandler`, `draw/CompassRenderer`, `draw/NotificationRenderer`, `draw/NodeListRenderer`)._ + +- **"Ham Mode"** - first implementation (#10663). +- **Color support for TFT-equipped nodes** (#10233). +- Status-message display added to Favorite/NodeList screens (#9504, #10197). +- **Hex picker** UI added for entering hex values (#10650). +- Save/restore of frame visibility state across sessions (#10576). +- BLE pairing PIN now shown via a proper on-screen banner (#8902). +- Compass rendering/behavior improvements (#10166). +- Emote handling refactor (#9896). + +### UI / display - InkHUD + +_InkHUD is the e-ink-optimized UI (`src/graphics/niche/InkHUD`)._ + +- **Offline map tiles with zoom controls** (#10785) and general GPS UX improvements (#10846). +- **Full touch support** for the T5 E-Paper S3 (#10286) and a full InkHUD port for the LilyGo T5 E-Paper S3 Pro (#10211). +- **"Wipe all messages"** option (#10721). +- T-mini E-Ink S3 support added (#9856). + +### UI / display - MUI + +_MUI is the older/legacy graphical UI menu system, still selectable alongside BaseUI (`draw/MenuHandler`'s `MuiPicker`/`switchToMUIMenu`)._ + +- WiFi map-tile download adapted for Heltec V4 (#10011). + +### UI / display - shared / cross-cutting + +_Touches the display driver layer or more than one UI system at once._ + +- InkHUD and BaseUI **message store unified** into a shared implementation (#10596). +- T-mini E-Ink S3 support landed for both InkHUD and BaseUI (#9856). +- Board-specific TFT driver support and simplified TFT ifdef chains for easier porting (#10827, #10803); faster TFT color conversion (#10814); touchscreen variant flags (`VARIANT_TOUCHSCREEN`/`ENABLE_TOUCH_INT`) added for new touch boards (#10815). + +## Backend / developer-facing features + +### Memory & stability infrastructure + +- **MemClass.h** - a central memory-class ladder providing fail-safe-small defaults across constrained platforms (#10901). +- **MemAudit** - per-subsystem heap accounting reported in the boot log (#10900). +- nRF52 heap tiers and SoftDevice RAM reservation right-sized, freeing an extra ~8KB heap arena (#10898, #10903). +- Native Portduino malloc shim added for more realistic memory behavior in simulation (#10677). +- Bluetooth memory freed automatically when Wi-Fi is enabled or Bluetooth is disabled (#10398); Bluetooth wait is skipped entirely when disabled (#10571). +- NodeDB "warm store" - new persistent warm-tier node storage layer (#10705 base, #10746/#10759 right-sizing for constrained platforms, #10809 fixing associated spiLock deadlocks). +- 2.8 NodeDB shrink/decoupling/restructuring to reduce per-node memory footprint (#10413). +- Flash hardening, filesystem platform unification, and a write-behind LFS cache for STM32WL/nRF52 - a storage-format break (#10171). + +### New APIs / module-author infrastructure + +- **MCP server** added for interacting with Meshtastic devices and driving a testing framework / TUI, later extracted to its own repo (#10194, #10861). +- Native "sensors" simulation support for Portduino (#10748); `PortduinoSetOptions` to override the `realhardware` flag for tests (#10157). +- Hardfault handler added for STM32, making crashes visibly obvious in logs (#10071). +- `BinarySemaphorePosix` implemented with proper pthread synchronization for native builds (#9895). +- NimBLE parameter overhaul, including a fix attempt for incompatible BLE bond cleanup (#10741). +- STM32 ADC support added to `AnalogBatteryLevel` (#9369). +- JSON library dependency removed from firmware builds while retaining full JSON support in meshtasticd (#10152) - reduces firmware footprint for module authors relying on the JSON path. +- Improved manual build flow / developer build ergonomics (#8839).- External Notifications module logic fully reworked for more flexible notification rules (#10006). diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index d54eae77e3..10281b6bd0 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -731,7 +731,7 @@ jobs: # Nightly publish: refresh the root of the meshtastic-firmware-nightly R2 # bucket with the current develop build. Runs on the cron schedule (or a manual # nightly=true dispatch) and never creates a GitHub release. The bucket's - # release_notes.md is maintained by hand and deliberately left untouched. + # release_notes.md is published from .github/nightly/ in this repo. publish-nightly: runs-on: ubuntu-24.04 if: github.repository_owner == 'meshtastic' && (github.event_name == 'schedule' || github.event.inputs.nightly == 'true') @@ -741,6 +741,11 @@ jobs: esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 r2_bucket: meshtastic-firmware-nightly steps: + # Only the index templates and release notes are needed here. + - uses: actions/checkout@v7 + with: + sparse-checkout: .github/nightly + - name: Get firmware artifacts uses: actions/download-artifact@v8 with: @@ -763,6 +768,10 @@ jobs: '{version: $ver, id: ("v" + $ver), title: ("Meshtastic Firmware " + $ver + " Nightly"), commit: $sha}' \ > ./stage/index.json + # The notes are maintained in-tree and published to the bucket root. + - name: Stage the nightly release notes + run: cp .github/nightly/release_notes.md ./stage/release_notes.md + # For diagnostics - name: Display structure of files to publish run: ls -lR ./stage @@ -782,18 +791,35 @@ jobs: exit 1 fi + # tree renders the file listing; .github/nightly wraps it in the page + # chrome. tree silently skips an --hintro/--houtro file it cannot open, so + # the rendered page is checked for both markers below. - name: Generate nightly html index env: VERSION: ${{ needs.version.outputs.long }} + COMMIT: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} working-directory: ./stage run: | - tree -H "." -T "Meshtastic Nightly $VERSION" --noreport -I "index.html" --charset utf-8 > index.html + set -euo pipefail + # Substitute placeholders in the nightly index header template. + sed -e "s|%%VERSION%%|$VERSION|g" \ + -e "s|%%COMMIT%%|$COMMIT|g" \ + -e "s|%%COMMIT_SHORT%%|${COMMIT:0:7}|g" \ + -e "s|%%BUILD_DATE%%|$(date -u +'%Y-%m-%d %H:%M UTC')|g" \ + -e "s|%%RUN_ID%%|$RUN_ID|g" \ + -e "s|%%RUN_URL%%|$RUN_URL|g" \ + "$GITHUB_WORKSPACE/.github/nightly/hintro.html" > "$RUNNER_TEMP/hintro.html" + # Render the html index using tree, with custom header and footer templates. + tree -H "." -h --noreport -I "index.html" --charset utf-8 \ + --hintro "$RUNNER_TEMP/hintro.html" \ + --houtro "$GITHUB_WORKSPACE/.github/nightly/houtro.html" \ + > index.html # Mirror the staged directory to Cloudflare R2. --delete at the bucket root # clears stale nightly binaries, and is in scope for the whole bucket because - # this bucket holds nothing but the nightly build. release_notes.md lives only - # in the bucket and is never staged, so it is excluded from the sync to keep - # --delete from removing it. + # this bucket holds nothing but the nightly build. - name: Publish nightly to Cloudflare R2 env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} @@ -810,12 +836,10 @@ jobs: set -euo pipefail aws --version # Cache for 1 hour in browser, 1 day on CDN. - # Preserve the release_notes.md (manually maintained) aws s3 sync ./stage "s3://${r2_bucket}/" \ --endpoint-url "$R2_ENDPOINT" \ --no-progress \ --delete \ - --exclude 'release_notes.md' \ --exclude 'index.json' \ --exclude 'index.html' \ --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \ diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index aec3fc6f87..9c7d770523 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -161,6 +161,12 @@ lint: - test/test_airtime/test_main.cpp - test/test_throttle/test_main.cpp - test/test_uptime_clock/test_main.cpp + # The nightly index templates are halves of one page, not whole documents: + # hintro.html stops mid-
and houtro.html starts with closing tags, so + # that `tree --hintro/--houtro` can splice the file listing between them. + - linters: [ALL] + paths: + - .github/nightly/*.html runtimes: enabled: - python@3.14.4 From 8a9e10d120f21133f55dc3642d50bc87fbcc183b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 11 Sep 2026 16:43:28 +0000 Subject: [PATCH 124/143] fix(power): stop a battery-less board deep-sleeping itself forever (#11821) * fix(power): stop a battery-less board deep-sleeping itself forever The low-battery counter only reset inside its `hasBattery && !hasUSB` guard, so a board with no battery - whose floating divider drifts in and out of the battery-present window - ratcheted the count up across the gaps until it tripped `sds_secs`, which defaults to a ~24.8-day deep sleep. The button could not rescue it either, because `doDeepSleep()` force-holds `BUTTON_PIN` and a held pad ignores `ext1_wakeup_prepare()`'s re-route to RTC; `rtc_gpio_isolate()`'s pin list has the same effect on boards whose button is GPIO 2 or 34. Separately the cutoff now scales by `NUM_CELLS`, without which no multi-cell pack can ever read low enough to shut down at all. Co-Authored-By: Claude Opus 5 (1M context) * fix(power): satisfy trunk check Apply the `ascii-dash` autoformat that `trunk fmt` wants on the comments this PR's file already carries, and rename the no-battery test so its `test_` prefix plus exactly 35 characters stops matching trufflehog's Lob API-key shape. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- src/Power.cpp | 40 +++-- src/Power.h | 8 +- src/platform/esp32/main-esp32.cpp | 20 ++- src/sleep.cpp | 4 +- test/test_low_battery_shutdown/test_main.cpp | 146 +++++++++++++++++++ 5 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 test/test_low_battery_shutdown/test_main.cpp diff --git a/src/Power.cpp b/src/Power.cpp index cd9bfa7ea4..f230318f67 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -784,7 +784,7 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel sum += _ads.computeVolts(raw); } // Piggyback a toggle-engine watchdog on this same throttle interval. - // Only re-arm when VBUS is absent — calling this while attached + // Only re-arm when VBUS is absent - calling this while attached // would restart CC toggling and could glitch an active sink attach. if (_aw35615.isReady() && !_aw35615.isVbusPresent()) { _aw35615.rearmToggle(); @@ -812,7 +812,7 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel bool vbus = _aw35615.isVbusPresent(); if (!vbus) { - // VBUS just went away (or has been away) — make sure the CC + // VBUS just went away (or has been away) - make sure the CC // toggle engine is re-armed so the next attach gets detected. _aw35615.rearmToggle(); } @@ -830,7 +830,7 @@ class ADS1115BatteryLevel : public AnalogBatteryLevel if (_aw35615.isReady()) { concurrency::LockGuard guard(spiLock); // Charging == VBUS present AND we're attached as a sink. - // (isSinkAttached() is a latched result — safe to trust here since + // (isSinkAttached() is a latched result - safe to trust here since // isVbusIn() above keeps re-arming toggle on every detach.) return _aw35615.isVbusPresent() && _aw35615.isSinkAttached(); } @@ -1097,6 +1097,20 @@ void Power::shutdown() #endif } +// Consecutive readings only: a battery-less board's floating divider drifts in and out of the +// "battery present" window, and a count that survived the gaps would deep-sleep a USB-powered node. +bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv) +{ + if (!hasBattery || hasUsb || battMv >= cutoffMv) { + counter = 0; + return false; + } + + if (counter < UINT8_MAX) + counter++; + return counter > LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN; +} + /// Reads power status to powerStatus singleton. // // TODO(girts): move this and other axp stuff to power.h/power.cpp. @@ -1229,16 +1243,16 @@ void Power::readPowerStatus() // is 2.0 to 2.5V, current OCV min is set to 3100 that is large enough. // - if (batteryLevel && powerStatus2.getHasBattery() && !powerStatus2.getHasUSB()) { - if (batteryLevel->getBattVoltage() < OCV[NUM_OCV_POINTS - 1]) { - low_voltage_counter++; - LOG_DEBUG("Low voltage counter: %d/10", low_voltage_counter); - if (low_voltage_counter > 10) { - LOG_INFO("Low voltage detected, trigger deep sleep"); - powerFSM.trigger(EVENT_LOW_BATTERY); - } - } else { - low_voltage_counter = 0; + if (batteryLevel) { + // getBattVoltage() reports pack voltage; the OCV table is per cell. + const bool shutdownNow = + updateLowVoltageCounter(low_voltage_counter, powerStatus2.getHasBattery(), powerStatus2.getHasUSB(), + batteryLevel->getBattVoltage(), OCV[NUM_OCV_POINTS - 1] * NUM_CELLS); + if (low_voltage_counter) + LOG_DEBUG("Low voltage counter: %d/%d", low_voltage_counter, LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN); + if (shutdownNow) { + LOG_INFO("Low voltage detected, trigger deep sleep"); + powerFSM.trigger(EVENT_LOW_BATTERY); } } } diff --git a/src/Power.h b/src/Power.h index f21511ec08..6a7bd21643 100644 --- a/src/Power.h +++ b/src/Power.h @@ -30,6 +30,12 @@ #define NUM_CELLS 1 #endif +/// Consecutive below-cutoff readings needed before the low-battery deep sleep fires. +static constexpr uint8_t LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN = 10; + +/// Advance the low-battery shutdown counter by one reading; true once the device should deep sleep. +bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv); + #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #include "modules/Telemetry/Sensor/nullSensor.h" #if __has_include() @@ -98,7 +104,7 @@ class Power : public 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; }; + bool isLowBattery() { return low_voltage_counter >= LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN; }; #ifdef ARCH_ESP32 int beforeLightSleep(void *unused); diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index f7879a333f..74b8e0865f 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -345,16 +345,24 @@ void cpuDeepSleep(uint32_t msecToWake) #endif 34, 35, 37}; - for (int i = 0; i < sizeof(rtcGpios); i++) - rtc_gpio_isolate((gpio_num_t)rtcGpios[i]); +#ifdef BUTTON_PIN + const int wakeButton = config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN; +#else + const int wakeButton = -1; +#endif + // Isolating a pad holds it, and ext1 cannot re-arm a held pad - skip the pin we wake on. + for (int i = 0; i < sizeof(rtcGpios); i++) { + if (rtcGpios[i] != wakeButton) + rtc_gpio_isolate((gpio_num_t)rtcGpios[i]); + } #endif - // FIXME, disable internal rtc pullups/pulldowns on the non isolated pins. for inputs that we aren't using - // to detect wake and in normal operation the external part drives them hard. + // FIXME, disable internal rtc pullups/pulldowns on the non isolated pins. for inputs that we aren't using + // to detect wake and in normal operation the external part drives them hard. #ifdef BUTTON_PIN - // Only GPIOs which are have RTC functionality can be used in this bit map: 0,2,4,12-15,25-27,32-39. + // Only GPIOs which are have RTC functionality can be used in this bit map: 0,2,4,12-15,25-27,32-39. #if SOC_RTCIO_HOLD_SUPPORTED && SOC_PM_SUPPORT_EXT_WAKEUP - uint64_t gpioMask = (1ULL << (config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN)); + uint64_t gpioMask = (1ULL << wakeButton); #endif #ifdef ALT_BUTTON_WAKE gpioMask |= (1ULL << BUTTON_PIN_ALT); diff --git a/src/sleep.cpp b/src/sleep.cpp index 2b6f58843c..5b224bae9e 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -317,7 +317,9 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN #else pinMode(BUTTON_PIN, INPUT); #endif - gpio_hold_en((gpio_num_t)BUTTON_PIN); + // A held pad ignores ext1_wakeup_prepare()'s re-route to RTC, so never hold the pin we wake on. + if (config.device.button_gpio && config.device.button_gpio != BUTTON_PIN) + gpio_hold_en((gpio_num_t)BUTTON_PIN); } #endif #ifdef SENSECAP_INDICATOR diff --git a/test/test_low_battery_shutdown/test_main.cpp b/test/test_low_battery_shutdown/test_main.cpp new file mode 100644 index 0000000000..24ff195ffe --- /dev/null +++ b/test/test_low_battery_shutdown/test_main.cpp @@ -0,0 +1,146 @@ +// Unit tests for updateLowVoltageCounter() in src/Power.cpp - the gate on the low-battery deep sleep. +// +// Power::readPowerStatus() calls this once per Power thread cycle (20s) with the freshly probed +// battery state. When it returns true the device takes EVENT_LOW_BATTERY -> stateLowBattSDS -> +// doDeepSleep(config.power.sds_secs), and sds_secs defaults to UINT32_MAX, so a false positive parks +// a node for the ~24.8-day clamp with only RST or a power cycle to recover it. That asymmetry is why +// the counter has to be conservative: a missed shutdown costs a flat battery, a spurious one costs +// the whole node. +// +// The contract is that only *consecutive* confirmed-low readings count. The regression guarded is +// the original shape, where the reset lived inside the "battery present and not on USB" guard rather +// than beside it. A board with no battery reads a floating divider that drifts across the 2600mV +// battery-present threshold, so each excursion into the window bumped the counter and nothing outside +// the window ever cleared it; eleven such flickers, spread over any span at all, deep-slept a healthy +// USB-powered node. Reported for Heltec V4 on USB with no battery in meshtastic/firmware#11796. +// +// Also pins the cutoff as a pack voltage. readPowerStatus() passes OCV[NUM_OCV_POINTS-1] * NUM_CELLS; +// it previously passed the bare single-cell OCV point, which no multi-cell pack can fall below, so +// those boards would have discharged to destruction instead of shutting down. +#include "Arduino.h" +#include "TestUtil.h" +#include +#include + +// Declared here rather than via Power.h, which pulls in the ADC and telemetry sensor headers. +// A signature change breaks the link rather than silently diverging from the definition. +bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv); + +// LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN, spelled out so a change to it has to be a deliberate edit here. +static constexpr uint8_t kReadingsBeforeShutdown = 10; + +// The default LiIon curve's lowest OCV point, and a single-cell pack comfortably below it. +static constexpr uint16_t kCutoffMv = 3100; +static constexpr uint16_t kLowMv = 2900; +static constexpr uint16_t kHealthyMv = 3900; + +// One reading of a battery-backed node running off its battery - the only case that may ever count. +static bool lowReading(uint8_t &counter, uint16_t battMv = kLowMv, uint16_t cutoffMv = kCutoffMv) +{ + return updateLowVoltageCounter(counter, true, false, battMv, cutoffMv); +} + +void setUp(void) {} +void tearDown(void) {} + +void test_an_unbroken_run_of_low_readings_shuts_down(void) +{ + uint8_t counter = 0; + + for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++) + TEST_ASSERT_FALSE_MESSAGE(lowReading(counter), "must not fire before the full run is seen"); + + TEST_ASSERT_TRUE(lowReading(counter)); +} + +void test_a_healthy_reading_clears_the_run(void) +{ + uint8_t counter = 0; + for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++) + lowReading(counter); + + TEST_ASSERT_FALSE(lowReading(counter, kHealthyMv)); + TEST_ASSERT_EQUAL_UINT8(0, counter); + TEST_ASSERT_FALSE_MESSAGE(lowReading(counter), "the run restarts from zero, it does not resume"); +} + +// #11796: the battery-less board. Its floating divider reads "no battery" as often as it reads a +// phantom one, and it is never on a detectable USB rail, so the gaps are the only thing that can +// save it. Interleaving them must hold the counter at zero however long this runs. +void test_no_battery_reading_clears_the_run(void) +{ + uint8_t counter = 0; + + for (int cycle = 0; cycle < 50; cycle++) { + TEST_ASSERT_FALSE(lowReading(counter)); + TEST_ASSERT_FALSE(updateLowVoltageCounter(counter, false, false, kLowMv, kCutoffMv)); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, counter, "a reading with no battery resets, it does not skip"); + } +} + +void test_usb_power_clears_the_run(void) +{ + uint8_t counter = 0; + for (uint8_t i = 0; i < kReadingsBeforeShutdown; i++) + lowReading(counter); + + TEST_ASSERT_FALSE(updateLowVoltageCounter(counter, true, true, kLowMv, kCutoffMv)); + TEST_ASSERT_EQUAL_UINT8(0, counter); +} + +// A pack sitting exactly on the cutoff is not below it; the OCV table's last point is a valid voltage. +void test_the_cutoff_is_exclusive(void) +{ + uint8_t counter = 0; + TEST_ASSERT_FALSE(lowReading(counter, kCutoffMv)); + TEST_ASSERT_EQUAL_UINT8(0, counter); + + TEST_ASSERT_FALSE(lowReading(counter, kCutoffMv - 1)); + TEST_ASSERT_EQUAL_UINT8(1, counter); +} + +// The caller scales by NUM_CELLS. Against the bare single-cell point a 2S pack never reads low at all. +void test_the_cutoff_is_a_pack_voltage(void) +{ + constexpr uint16_t twoCellCutoffMv = kCutoffMv * 2; + constexpr uint16_t flatTwoCellPackMv = 6000; + + uint8_t counter = 0; + for (uint8_t i = 0; i <= kReadingsBeforeShutdown; i++) + TEST_ASSERT_EQUAL(i == kReadingsBeforeShutdown, lowReading(counter, flatTwoCellPackMv, twoCellCutoffMv)); + + counter = 0; + for (uint8_t i = 0; i <= kReadingsBeforeShutdown; i++) + TEST_ASSERT_FALSE_MESSAGE(lowReading(counter, flatTwoCellPackMv, kCutoffMv), + "unscaled cutoff: the regression that never shuts a 2S pack down"); +} + +// The counter is a uint8_t and the caller keeps calling after it fires, so it must saturate. Were it +// to wrap, the node would come back up, count to 255 again and re-sleep in an unattended loop. +void test_the_counter_saturates_rather_than_wrapping(void) +{ + uint8_t counter = 0; + + for (int i = 0; i < 400; i++) { + const bool shutdown = lowReading(counter); + if (i >= kReadingsBeforeShutdown) + TEST_ASSERT_TRUE_MESSAGE(shutdown, "once tripped it stays tripped until a reading clears it"); + } + TEST_ASSERT_EQUAL_UINT8(UINT8_MAX, counter); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_an_unbroken_run_of_low_readings_shuts_down); + RUN_TEST(test_a_healthy_reading_clears_the_run); + RUN_TEST(test_no_battery_reading_clears_the_run); + RUN_TEST(test_usb_power_clears_the_run); + RUN_TEST(test_the_cutoff_is_exclusive); + RUN_TEST(test_the_cutoff_is_a_pack_voltage); + RUN_TEST(test_the_counter_saturates_rather_than_wrapping); + exit(UNITY_END()); +} + +void loop() {} From 2a01676227dc2da412120569d0f7c4ae33216f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 11 Sep 2026 18:51:10 +0000 Subject: [PATCH 125/143] fix(nodedb): track whether each node was heard on the current LoRa config (#11811) * fix(nodedb): track whether each node was heard on the current LoRa config Set NODEINFO_BITFIELD_HEARD_ON_CURRENT_LORA on a genuine RF hear and clear it for every node when the LoRa slot config moves, so clients can tell which nodes went unreachable after a preset, region, slot or primary-channel-name change. Hooked into MeshService::reloadConfig(), the single funnel for the device menu, admin/CLI and scanned-URL paths, plus NodeDB::restorePreferences(), which reboots without passing through it. Fixes #11745 * fix(nodedb): store the slot each node was heard on instead of sweeping a bit A client scanning for traffic rolls through presets with live set_config writes, so every hop reached reloadConfig and the sweep cleared the marks on the way out and again on the way home. Each node now carries a 12-bit fingerprint of the slot it was heard on in spare bitfield bits, and heard_on_current_lora is derived by comparing that against the slot the radio is committed to. Config changes no longer touch the node database at all. * fix(nodedb): keep comments inside the two-line limit, rename a test Trunk read test_fingerprint_channelNumIsASlotChange as a Lob API key, since it is test_ followed by exactly 35 alphanumerics, so the tail is now shorter. The comments added under src/ are back within the one-or-two-line limit in AGENTS.md. * fix(nodedb): drop legacy bitfield bits above 10 during v24 migration v24 assigned bits 0..10, so a legacy record carrying anything higher would arrive claiming an RF hear with a stray slot fingerprint, and a never-heard node would read as reachable whenever that stray value matched ours. The migration now masks those bits off, and a new case in test_nodedb_legacy_migration pins it. --- src/mesh/MeshService.cpp | 4 + src/mesh/NodeDB.cpp | 71 ++++ src/mesh/NodeDB.h | 78 +++- src/mesh/NodeDBLegacyMigration.cpp | 4 +- src/mesh/TypeConversions.cpp | 1 + src/modules/MeshBeaconModule.cpp | 8 + test/state-manifest.tsv | 1 + .../test_main.cpp | 20 ++ test/test_nodedb_lora_slot/test_main.cpp | 338 ++++++++++++++++++ 9 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 test/test_nodedb_lora_slot/test_main.cpp diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 6a44aba25c..e3803c43ae 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -163,6 +163,10 @@ void MeshService::reloadConfig(int saveWhat) nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc + + // Nothing is swept and nothing extra persisted: each node carries the slot it was heard on, so + // a client rolling through presets just moves this and moves it back. + nodeDB->refreshCommittedLoraSlot(); } nodeDB->saveToDisk(saveWhat); } diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b0b3df57b7..d910660215 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -717,6 +717,8 @@ NodeDB::NodeDB() } #endif sortMeshDB(); + // resetRadioConfig() above loaded config and channels, so this records the slot we booted on. + refreshCommittedLoraSlot(); saveToDisk(saveWhat); bootInitializationInProgress = false; } @@ -825,6 +827,63 @@ void NodeDB::resetRadioConfig(bool is_fresh_install) initRegion(); } +LoraSlotSnapshot loraSlotSnapshotFrom(const meshtastic_Config_LoRaConfig &lora, const char *primaryChannelName) +{ + LoraSlotSnapshot snap; + snap.region = lora.region; + snap.use_preset = lora.use_preset; + // Record only the modem fields the radio is actually using. The unused half of the pair keeps + // whatever the client last wrote into it, and editing a dormant field moves nothing on air. + if (lora.use_preset) { + snap.modem_preset = lora.modem_preset; + } else { + snap.bandwidth = lora.bandwidth; + snap.spread_factor = lora.spread_factor; + snap.coding_rate = lora.coding_rate; + } + snap.override_frequency = lora.override_frequency; + snap.channel_num = lora.channel_num; + strncpy(snap.primary_channel_name, primaryChannelName, sizeof(snap.primary_channel_name) - 1); + return snap; +} + +uint16_t LoraSlotSnapshot::fingerprint() const +{ + // FNV-1a over the populated fields. Only ever compared against another fingerprint, so the hash + // needs to be stable and well-spread, not cryptographic. + uint32_t h = 2166136261u; + auto mix = [&h](const void *data, size_t len) { + const uint8_t *p = static_cast(data); + for (size_t i = 0; i < len; i++) { + h ^= p[i]; + h *= 16777619u; + } + }; + const uint8_t scalars[] = {(uint8_t)region, (uint8_t)use_preset, (uint8_t)modem_preset, + (uint8_t)coding_rate, (uint8_t)bandwidth, (uint8_t)(bandwidth >> 8), + (uint8_t)spread_factor, (uint8_t)channel_num, (uint8_t)(channel_num >> 8)}; + mix(scalars, sizeof(scalars)); + mix(&override_frequency, sizeof(override_frequency)); + mix(primary_channel_name, strnlen(primary_channel_name, sizeof(primary_channel_name))); + // Fold the full width down rather than truncating, so every input bit reaches the stored value. + const uint16_t folded = (uint16_t)((h ^ (h >> 16)) & ((1u << NODEINFO_BITFIELD_HEARD_SLOT_BITS) - 1)); + return folded; +} + +LoraSlotSnapshot NodeDB::currentLoraSlot() const +{ + return loraSlotSnapshotFrom(config.lora, channels.getName(channels.getPrimaryIndex())); +} + +void NodeDB::refreshCommittedLoraSlot() +{ + // A beacon TX parks the radio on someone else's preset and puts it back; config.lora is not the + // committed config for that window, and adopting it would read every node as unheard meanwhile. + if (loraSlotTransient) + return; + committedSlot = currentLoraSlot().fingerprint(); +} + bool NodeDB::factoryReset(bool eraseBleBonds) { LOG_INFO("Factory reset"); @@ -2962,6 +3021,9 @@ bool NodeDB::reloadFromDisk() channels.onConfigChanged(); rIface->reconfigure(); } + // The unlock replaced the locked-default config with the operator's, so the boot snapshot + // describes a slot we were never on. + refreshCommittedLoraSlot(); return true; } @@ -3810,6 +3872,11 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_HAS_SNR_MASK, true); } + // RF-origin only (a via_mqtt rebroadcast proves the gateway is in earshot, not the node); not + // has_rx_rssi-gated, as SimRadio omits it. Live slot, so a beacon-preset hear fails to match home. + if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt) + nodeInfoLiteSetHeardSlot(info, currentLoraSlot().fingerprint()); + nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK, mp.via_mqtt); // Store if we received this packet via MQTT @@ -4692,6 +4759,10 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, if (restoreWhat & SEGMENT_CHANNELS) channels.onConfigChanged(); + // Restore reboots without going through MeshService::reloadConfig(), which is where the + // committed slot is otherwise re-read. + refreshCommittedLoraSlot(); + success = saveToDisk(restoreWhat); if (success) { LOG_INFO("Restored prefs from backup"); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 4ab9655c31..d36fd7ca03 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -273,6 +274,29 @@ struct NodeHeardAt { uint32_t heardAtUptimeSecs = 0; ///< Time::getUptimeSecs() when last heard }; +/// What decides which LoRa slot this radio listens on. Only ever consumed as a fingerprint(), which +/// is what each node stores and what the committed slot is compared against. +struct LoraSlotSnapshot { + meshtastic_Config_LoRaConfig_RegionCode region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + bool use_preset = false; + /// Only the modem fields actually in force are populated - see loraSlotSnapshotFrom(). + meshtastic_Config_LoRaConfig_ModemPreset modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + uint16_t bandwidth = 0; + uint32_t spread_factor = 0; + uint8_t coding_rate = 0; + float override_frequency = 0; + uint16_t channel_num = 0; + /// Channels::getName(); 16 covers name[12] and the preset name it substitutes for an empty one. + char primary_channel_name[16] = {0}; + + /// Fold into the NODEINFO_BITFIELD_HEARD_SLOT_BITS-wide value stored per node. A collision only + /// costs a node heard on one slot reading as heard on another, which 12 bits makes remote. + uint16_t fingerprint() const; +}; + +/// Normalises to the modem fields actually in force, so editing a dormant one is not a slot change. +LoraSlotSnapshot loraSlotSnapshotFrom(const meshtastic_Config_LoRaConfig &lora, const char *primaryChannelName); + class NodeDB { // NodeNum provisionalNodeNum; // if we are trying to find a node num this is our current attempt @@ -331,6 +355,18 @@ class NodeDB /// we updateGUI and updateGUIforNode if we think our this change is big enough for a redraw void updateFrom(const meshtastic_MeshPacket &p); + /// Re-read which slot this radio is committed to. Cheap, touches no node and writes nothing, so + /// it is safe on every config write - a client hopping presets just moves it and moves it back. + void refreshCommittedLoraSlot(); + + /// Fingerprint of the slot the radio is committed to, which a node's stored slot is compared + /// against to derive NodeInfo.heard_on_current_lora. + uint16_t committedLoraSlot() const { return committedSlot; } + + /// Declare that config.lora holds a temporary radio switch - a beacon keying up on another preset. + /// While set the committed slot is pinned, so neither the switch nor its restore reads as a move. + void setLoraSlotTransient(bool transient) { loraSlotTransient = transient; } + void addFromContact(const meshtastic_SharedContact); /// On the clock-becoming-trusted transition (see RTC.cpp): convert every RAM arrival stamp into @@ -700,6 +736,11 @@ class NodeDB EvictionRecency evictionRecency(const meshtastic_NodeInfoLite *n) const; static bool evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent); + /// The slot this radio is committed to; see refreshCommittedLoraSlot(). + uint16_t committedSlot = 0; + bool loraSlotTransient = false; + LoraSlotSnapshot currentLoraSlot() const; + /* * Internal boolean to track sorting paused */ @@ -821,7 +862,16 @@ extern uint32_t error_address; // Use this instead of `if (snr_q4)`. Legacy records (bit clear) are unambiguously "unknown". #define NODEINFO_BITFIELD_HAS_SNR_SHIFT 10 #define NODEINFO_BITFIELD_HAS_SNR_MASK (1u << NODEINFO_BITFIELD_HAS_SNR_SHIFT) -// Bits 11..31 reserved for future single-bit flags. +// Set on a genuine RF hear, and with it the slot fingerprint below. Clear means never heard over our +// own radio, so the fingerprint is meaningless - legacy records read that way and are correct. +#define NODEINFO_BITFIELD_HAS_RF_HEAR_SHIFT 11 +#define NODEINFO_BITFIELD_HAS_RF_HEAR_MASK (1u << NODEINFO_BITFIELD_HAS_RF_HEAR_SHIFT) +// Bits 12..23: fingerprint of the LoRa slot this node was last heard on. NodeInfo.heard_on_current_lora +// is derived from it matching the slot the radio is committed to, which is what makes scanning harmless. +#define NODEINFO_BITFIELD_HEARD_SLOT_SHIFT 12 +#define NODEINFO_BITFIELD_HEARD_SLOT_BITS 12 +#define NODEINFO_BITFIELD_HEARD_SLOT_MASK (((1u << NODEINFO_BITFIELD_HEARD_SLOT_BITS) - 1) << NODEINFO_BITFIELD_HEARD_SLOT_SHIFT) +// Bits 24..31 reserved for future single-bit flags. // Convenience accessors so call sites read like the old struct fields. inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n) @@ -870,6 +920,32 @@ inline bool nodeInfoLiteHasSnr(const meshtastic_NodeInfoLite *n) { return n && (n->bitfield & NODEINFO_BITFIELD_HAS_SNR_MASK); } + +inline bool nodeInfoLiteHasRfHear(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_HAS_RF_HEAR_MASK); +} + +inline uint16_t nodeInfoLiteHeardSlot(const meshtastic_NodeInfoLite *n) +{ + return n ? (n->bitfield & NODEINFO_BITFIELD_HEARD_SLOT_MASK) >> NODEINFO_BITFIELD_HEARD_SLOT_SHIFT : 0; +} + +/// Record that this node was just heard over RF on `slot`. +inline void nodeInfoLiteSetHeardSlot(meshtastic_NodeInfoLite *n, uint16_t slot) +{ + if (!n) + return; + n->bitfield = (n->bitfield & ~NODEINFO_BITFIELD_HEARD_SLOT_MASK) | + (((uint32_t)slot << NODEINFO_BITFIELD_HEARD_SLOT_SHIFT) & NODEINFO_BITFIELD_HEARD_SLOT_MASK) | + NODEINFO_BITFIELD_HAS_RF_HEAR_MASK; +} + +/// True iff this node was last heard over RF on the slot the radio is committed to right now. +inline bool nodeInfoLiteHeardOnSlot(const meshtastic_NodeInfoLite *n, uint16_t committedSlot) +{ + return nodeInfoLiteHasRfHear(n) && nodeInfoLiteHeardSlot(n) == committedSlot; +} /// A node that the eviction/migration paths must not drop: a favourite, an /// ignored (blocked) node, or a manually-verified key. inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n) diff --git a/src/mesh/NodeDBLegacyMigration.cpp b/src/mesh/NodeDBLegacyMigration.cpp index ef7f3108df..8186f06261 100644 --- a/src/mesh/NodeDBLegacyMigration.cpp +++ b/src/mesh/NodeDBLegacyMigration.cpp @@ -78,7 +78,9 @@ bool NodeDB::migrateLegacyNodeDatabase() slim.has_hops_away = legacy.has_hops_away; slim.hops_away = legacy.hops_away; slim.next_hop = legacy.next_hop; - slim.bitfield = legacy.bitfield; + // v24 assigned bits 0..10 only; anything above is noise and must not arrive as RF-hear + // state or a slot fingerprint (see NODEINFO_BITFIELD_HEARD_SLOT_SHIFT). + slim.bitfield = legacy.bitfield & (NODEINFO_BITFIELD_HAS_RF_HEAR_MASK - 1); if (legacy.via_mqtt) slim.bitfield |= NODEINFO_BITFIELD_VIA_MQTT_MASK; if (legacy.is_favorite) diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 9fc80fd863..3f137e107c 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -22,6 +22,7 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite); info.is_muted = nodeInfoLiteIsMuted(lite); info.has_xeddsa_signed = nodeInfoLiteHasXeddsaSigned(lite); + info.heard_on_current_lora = nodeDB && nodeInfoLiteHeardOnSlot(lite, nodeDB->committedLoraSlot()); if (lite->has_hops_away) { info.has_hops_away = true; diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 9842de2549..34758de0ed 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -257,6 +257,10 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ if (switchDepth > 1) LOG_WARN("Beacon: switching again with no restore between; home preset=%d slot=%u region=%d still held", originalModemPreset, originalLoraChannel, originalRegion); + // Before config.lora stops describing the config we are committed to, so the committed slot + // stays pinned to ours while we key up on someone else's preset. + if (nodeDB) + nodeDB->setLoraSlotTransient(true); config.lora.modem_preset = targetPreset; config.lora.channel_num = targetSlot; if (targetRegion != config.lora.region) @@ -288,6 +292,10 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ primaryCh->name[sizeof(primaryCh->name) - 1] = '\0'; channels.fixupChannel(channels.getPrimaryIndex()); + if (nodeDB) { // config.lora describes the committed config again + nodeDB->setLoraSlotTransient(false); + nodeDB->refreshCommittedLoraSlot(); + } radioSwitched = false; // cleared before reconfigure(), so the flag never lags the radio it describes switchDepth = 0; switchedForId = 0; diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index f2ef9ff4c1..faaa5a0854 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -64,6 +64,7 @@ test_nodedb_blocked state=per-suite writes=config.proto,module.proto,device.prot test_nodedb_boot_recovery state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto deliberate boot-recovery ladder: corrupts/deletes/restores the pref files and reboots a NodeDB per test to pin the DECODE_FAILED identity freeze, so each test observes the previous test's on-disk state test_nodedb_identity_hygiene writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs constructs a NodeDB; addFromContact persists the node DB after every merge, the reboot test proves the key-erasure guard survives a reload, and the should_ignore path rewrites the message store test_nodedb_legacy_migration writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat each test hand-writes a v24-format nodes.proto fixture and cold-boots a NodeDB, whose constructor persists the migrated v25 database (warm.dat via the over-cap eviction absorb) +test_nodedb_lora_slot writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty; the tests themselves only mutate config.lora in RAM and restore it in tearDown test_nodedb_v25_roundtrip writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat v25 persistence round-trips: every test saves nodes.proto and cold-boots a NodeDB whose constructor persists the default segments; warm.dat on the node-DB save cadence test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=300 needs a NodeDB holding both peers' keys for the PKI encode/decode paths test_phone_api_config_dump writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto per-test NodeDB fixture backing full PhoneAPI want_config dumps; the constructor persists a default config/channel/node set in a fresh sandbox diff --git a/test/test_nodedb_legacy_migration/test_main.cpp b/test/test_nodedb_legacy_migration/test_main.cpp index 3f6a645f36..6abea3b98d 100644 --- a/test/test_nodedb_legacy_migration/test_main.cpp +++ b/test/test_nodedb_legacy_migration/test_main.cpp @@ -337,6 +337,25 @@ static void test_v24RoundTrip_migratesFieldsBitfieldAndSatellites(void) // has_position=false / has_device_metrics=false entries must not seed // zero-position ghosts in the satellite maps. +// v24 assigned bits 0..10 of the bitfield; this build reads bit 11 as "heard over RF" and bits 12..23 +// as the slot it was heard on. A legacy record carrying anything up there must not arrive claiming to +// have been heard, or a never-heard node reads as reachable whenever the stray slot matches ours. +static void test_v24StrayHighBits_doNotBecomeRfHearState(void) +{ + auto n = makeLegacyNode(0xD4000001, 1000); + giveLegacyUser(n, "Stray", "ST"); + n.is_favorite = true; // a real bit 3, which must survive + n.bitfield = 0xFFFFFFFFu; // every reserved bit above 10 set + writeLegacyNodesFile(24, {n}); + coldBoot(); + + const meshtastic_NodeInfoLite *m = db->getMeshNode(0xD4000001); + TEST_ASSERT_NOT_NULL(m); + TEST_ASSERT_FALSE(nodeInfoLiteHasRfHear(m)); + TEST_ASSERT_EQUAL_UINT16(0, nodeInfoLiteHeardSlot(m)); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(m)); // the legacy bits it did own are untouched +} + static void test_absentSubmessages_noSatelliteGhostRows(void) { auto a = makeLegacyNode(0xC3000001, 1000); @@ -540,6 +559,7 @@ NDBM_TEST_ENTRY void setup() printf("\n=== Migration fidelity ===\n"); RUN_TEST(test_v24RoundTrip_migratesFieldsBitfieldAndSatellites); + RUN_TEST(test_v24StrayHighBits_doNotBecomeRfHearState); RUN_TEST(test_absentSubmessages_noSatelliteGhostRows); printf("\n=== sanitizeUtf8 firewall ===\n"); diff --git a/test/test_nodedb_lora_slot/test_main.cpp b/test/test_nodedb_lora_slot/test_main.cpp new file mode 100644 index 0000000000..adf53ad826 --- /dev/null +++ b/test/test_nodedb_lora_slot/test_main.cpp @@ -0,0 +1,338 @@ +// The "heard on the current LoRa config" mark - src/mesh/NodeDB.cpp and src/mesh/TypeConversions.cpp. +// Each node stores the slot it was last heard on; NodeInfo.heard_on_current_lora is that matching the +// slot the radio is committed to. The regression guarded is a client rolling through presets to scan +// for traffic: nothing may be swept on the way out, and returning to a slot must mark its nodes again. +#include "MeshTypes.h" // BEFORE TestUtil.h - provides WARM_NODE_COUNT / MAX_NUM_NODES via mesh-pb-constants.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NDB_TEST_ENTRY extern "C" +#else +#define NDB_TEST_ENTRY +#endif + +#include "mesh/NodeDB.h" +#include "mesh/TypeConversions.h" +#include + +// Name and global scope both fixed by the `friend class NodeDBTestShim` declaration in NodeDB.h. +class NodeDBTestShim : public NodeDB +{ + public: + void clearHot() + { + meshNodes->clear(); + numMeshNodes = 0; + } + + // A node admitted without ever being heard over RF - an all-zero bitfield, as a pre-feature + // record loaded from disk has. + void push(NodeNum num) + { + meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; + n.num = num; + n.last_heard = 1000; + meshNodes->push_back(n); + numMeshNodes = meshNodes->size(); + } +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; +meshtastic_Config_LoRaConfig savedLora; + +// Every field the snapshot reads is non-default, so changing one is a real change, not a zero swap. +meshtastic_Config_LoRaConfig baselineLora() +{ + meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_zero; + lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + lora.use_preset = true; + lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + lora.bandwidth = 250; + lora.spread_factor = 11; + lora.coding_rate = 5; + lora.override_frequency = 869.525f; + lora.channel_num = 7; + return lora; +} + +uint16_t fp(const meshtastic_Config_LoRaConfig &lora, const char *name) +{ + return loraSlotSnapshotFrom(lora, name).fingerprint(); +} + +// What a client actually sees: derived at conversion time from the slot the radio is committed to. +bool heard(NodeNum num) +{ + return TypeConversions::ConvertToNodeInfo(db->getMeshNode(num), nullptr, nullptr).heard_on_current_lora; +} + +// A decoded packet as updateFrom() sees it coming off the RX pipeline. +meshtastic_MeshPacket rxPacket(NodeNum from) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = from; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.has_rx_time = true; + mp.rx_time = 1000; + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + return mp; +} + +// Move the radio the way a client's set_config(lora) does, committing to the new slot. +void commitPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + config.lora.modem_preset = preset; + db->refreshCommittedLoraSlot(); +} + +void commitHome() +{ + config.lora = savedLora; + db->refreshCommittedLoraSlot(); +} + +} // namespace + +void setUp(void) +{ + db->clearHot(); + config.lora = savedLora; + db->setLoraSlotTransient(false); + db->refreshCommittedLoraSlot(); +} + +void tearDown(void) {} + +// ---------- the fingerprint: what counts as a different slot --------------------------------- + +static void test_fingerprint_identicalConfigMatches(void) +{ + const meshtastic_Config_LoRaConfig lora = baselineLora(); + TEST_ASSERT_EQUAL_UINT16(fp(lora, "LongFast"), fp(lora, "LongFast")); +} + +static void test_fingerprint_regionIsASlotChange(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.region = meshtastic_Config_LoRaConfig_RegionCode_US; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_presetIsASlotChange(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_channelNumChangesSlot(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.channel_num = 8; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_overrideFrequencyIsASlotChange(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.override_frequency = 869.4f; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +// Slot is the hash of the primary channel name, so a rename or a scanned QR moves the radio. +static void test_fingerprint_primaryChannelRenameIsASlotChange(void) +{ + const meshtastic_Config_LoRaConfig lora = baselineLora(); + TEST_ASSERT_NOT_EQUAL_UINT16(fp(lora, "LongFast"), fp(lora, "MyMesh")); +} + +static void test_fingerprint_usePresetToggleIsASlotChange(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); + other.use_preset = false; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +// The dormant half of the preset/custom pair moves nothing on air; editing it must not read as a move. +static void test_fingerprint_dormantModemFieldsIgnoredWhenUsingPreset(void) +{ + meshtastic_Config_LoRaConfig other = baselineLora(); // use_preset = true + other.bandwidth = 125; + other.spread_factor = 7; + other.coding_rate = 8; + TEST_ASSERT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_dormantPresetIgnoredWhenNotUsingPreset(void) +{ + meshtastic_Config_LoRaConfig base = baselineLora(); + base.use_preset = false; + meshtastic_Config_LoRaConfig other = base; + other.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + TEST_ASSERT_EQUAL_UINT16(fp(base, "LongFast"), fp(other, "LongFast")); +} + +static void test_fingerprint_customModemFieldsCountWhenNotUsingPreset(void) +{ + meshtastic_Config_LoRaConfig base = baselineLora(); + base.use_preset = false; + meshtastic_Config_LoRaConfig bw = base, sf = base, cr = base; + bw.bandwidth = 125; + sf.spread_factor = 7; + cr.coding_rate = 8; + TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(bw, "LongFast")); + TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(sf, "LongFast")); + TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(cr, "LongFast")); +} + +// ---------- storing the slot on a hear ------------------------------------------------------- + +static void test_hear_rfHearMarksNodeOnCurrentSlot(void) +{ + db->updateFrom(rxPacket(0x4444)); + TEST_ASSERT_TRUE(heard(0x4444)); +} + +// A gateway rebroadcast is TRANSPORT_LORA + via_mqtt: we heard the gateway, not the node. +static void test_hear_mqttRelayDoesNotMark(void) +{ + meshtastic_MeshPacket mp = rxPacket(0x5555); + mp.via_mqtt = true; + db->updateFrom(mp); + TEST_ASSERT_NOT_NULL(db->getMeshNode(0x5555)); // admitted... + TEST_ASSERT_FALSE(heard(0x5555)); // ...but not as an RF hear on this slot +} + +static void test_hear_mqttTransportDoesNotMark(void) +{ + meshtastic_MeshPacket mp = rxPacket(0x6666); + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + db->updateFrom(mp); + TEST_ASSERT_NOT_NULL(db->getMeshNode(0x6666)); + TEST_ASSERT_FALSE(heard(0x6666)); +} + +// The mark is about the radio, not the clock: an RF hear counts before the clock is trusted. +static void test_hear_countsWithUntrustedClock(void) +{ + meshtastic_MeshPacket mp = rxPacket(0x7777); + mp.has_rx_time = false; + db->updateFrom(mp); + TEST_ASSERT_TRUE(heard(0x7777)); +} + +// A pre-feature record has an all-zero bitfield. Without the has-RF-hear bit gating it, stored slot 0 +// would collide with whatever the radio happens to be on and mark every legacy node heard. +static void test_hear_legacyRecordReadsUnheard(void) +{ + db->push(0xAAAA); + TEST_ASSERT_FALSE(heard(0xAAAA)); +} + +// ---------- the scan: rolling through presets and back --------------------------------------- + +// The regression this design exists for. A client scanning A->B->C->A must leave A's marks intact: +// the hops sweep nothing, and coming home makes the stored slots match again on their own. +static void test_scan_roundTripRestoresTheMark(void) +{ + db->updateFrom(rxPacket(0x1111)); // heard on A + TEST_ASSERT_TRUE(heard(0x1111)); + + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); // hop to B + TEST_ASSERT_FALSE(heard(0x1111)); // unreachable while parked on B + + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); // hop to C + TEST_ASSERT_FALSE(heard(0x1111)); + + commitHome(); + TEST_ASSERT_TRUE(heard(0x1111)); +} + +// The other direction: a node heard only while parked on B must not read as reachable back on A. +static void test_scan_nodeHeardOnOtherSlotStaysUnheardAtHome(void) +{ + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + db->updateFrom(rxPacket(0x2222)); // a foreign node, heard on B + TEST_ASSERT_TRUE(heard(0x2222)); + + commitHome(); + TEST_ASSERT_FALSE(heard(0x2222)); +} + +// Re-reading the committed slot is not a sweep: it must never touch a node's stored bitfield, which +// is what keeps a scan off the flash and makes the round trip above possible at all. +static void test_scan_refreshWritesNoNode(void) +{ + db->updateFrom(rxPacket(0x3333)); + const uint32_t before = db->getMeshNode(0x3333)->bitfield; + + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + + TEST_ASSERT_EQUAL_UINT32(before, db->getMeshNode(0x3333)->bitfield); +} + +// ---------- transient switch (a beacon keyed up on another preset) --------------------------- + +// MeshBeaconModule rewrites config.lora for a beacon TX and restores it. The committed slot is pinned +// across that window, so the whole node list does not blink to unheard while we key up elsewhere. +static void test_transient_committedSlotIsPinned(void) +{ + db->updateFrom(rxPacket(0x1111)); + const uint16_t home = db->committedLoraSlot(); + + db->setLoraSlotTransient(true); + commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + + TEST_ASSERT_EQUAL_UINT16(home, db->committedLoraSlot()); + TEST_ASSERT_TRUE(heard(0x1111)); +} + +// A hear while parked on the beacon's preset belongs to that preset, so it stops matching at home. +static void test_transient_hearIsStampedWithTheLiveSlot(void) +{ + db->setLoraSlotTransient(true); + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + db->updateFrom(rxPacket(0x8888)); + + db->setLoraSlotTransient(false); + commitHome(); + + TEST_ASSERT_NOT_NULL(db->getMeshNode(0x8888)); // still admitted + TEST_ASSERT_FALSE(heard(0x8888)); +} + +NDB_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + db = new NodeDBTestShim(); + nodeDB = db; + savedLora = config.lora; + + UNITY_BEGIN(); + RUN_TEST(test_fingerprint_identicalConfigMatches); + RUN_TEST(test_fingerprint_regionIsASlotChange); + RUN_TEST(test_fingerprint_presetIsASlotChange); + RUN_TEST(test_fingerprint_channelNumChangesSlot); + RUN_TEST(test_fingerprint_overrideFrequencyIsASlotChange); + RUN_TEST(test_fingerprint_primaryChannelRenameIsASlotChange); + RUN_TEST(test_fingerprint_usePresetToggleIsASlotChange); + RUN_TEST(test_fingerprint_dormantModemFieldsIgnoredWhenUsingPreset); + RUN_TEST(test_fingerprint_dormantPresetIgnoredWhenNotUsingPreset); + RUN_TEST(test_fingerprint_customModemFieldsCountWhenNotUsingPreset); + RUN_TEST(test_hear_rfHearMarksNodeOnCurrentSlot); + RUN_TEST(test_hear_mqttRelayDoesNotMark); + RUN_TEST(test_hear_mqttTransportDoesNotMark); + RUN_TEST(test_hear_countsWithUntrustedClock); + RUN_TEST(test_hear_legacyRecordReadsUnheard); + RUN_TEST(test_scan_roundTripRestoresTheMark); + RUN_TEST(test_scan_nodeHeardOnOtherSlotStaysUnheardAtHome); + RUN_TEST(test_scan_refreshWritesNoNode); + RUN_TEST(test_transient_committedSlotIsPinned); + RUN_TEST(test_transient_hearIsStampedWithTheLiveSlot); + exit(UNITY_END()); +} +NDB_TEST_ENTRY void loop() {} From df47f95ff3badd4cf42a91dac14e75e9fa6a1f99 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 11 Sep 2026 23:41:23 +0000 Subject: [PATCH 126/143] fix(nodedb): build the fixed-GPS userprefs path again (#11825) * fix(nodedb): build the fixed-GPS userprefs path again The USERPREFS_FIXED_GPS block in the NodeDB constructor carries two defects that only surface on vendor builds setting USERPREFS_FIXED_GPS_LAT and USERPREFS_FIXED_GPS_LON. userPrefs.jsonc ships those keys commented out and no CI target defines them, so the block is never compiled here and neither defect was caught. info has not existed in this scope since 94bb21ecc7 removed the constructor's local NodeInfoLite pointer, leaving a hard compile error behind. That local was initialised from getOrCreateMeshNode(getNodeNum()), so getNodeNum() resolves to the same key it always did, and it matches how clearLocalPosition() and every other own-node satellite write address the local node. setLocalPosition() was reached through the global nodeDB, which main.cpp only assigns once the constructor has returned. Inside the constructor it is still nullptr, so the call stored localPosition through a null this. This one dates to #5341 rather than the later restructuring. Calling directly matches the sibling setLocalPosition() earlier in the same constructor. Verified by forcing the two userprefs keys on: the native target fails to compile before this change and builds clean after it. Fixes #11812 * fix(nodedb): persist the fixed position the userprefs block writes saveWhat is finalised by the CRC compares near the top of the constructor, which run before the USERPREFS_FIXED_GPS block. nodePositions is a member map, so crc32Buffer(&nodeDatabase, ...) cannot observe the position write at all, and the config writes land after their own compare. saveToDisk(saveWhat) is the only save left in the constructor, so both updates survived a reboot only by chance. The bad case is asymmetric. On a build that also pins a region, key generation dirties config, so fixed_position = true persists while the coordinates do not. The next boot then finds no stored position, GPS wake stays suppressed because the fixed flag is set, and the block cannot re-run because it is gated on reboot_count == 1. Flag each segment next to the write that dirties it. SEGMENT_CONFIG keeps the degraded-boot guard the compare above uses, so an unreadable config is still never overwritten with UNSET defaults. --- src/mesh/NodeDB.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index d910660215..3f3c5cfef6 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -708,11 +708,18 @@ NodeDB::NodeDB() #if !MESHTASTIC_EXCLUDE_POSITIONDB { concurrency::LockGuard guard(&satelliteMutex); - nodePositions[info->num] = TypeConversions::ConvertToPositionLite(fixedGPS); + nodePositions[getNodeNum()] = TypeConversions::ConvertToPositionLite(fixedGPS); } + // nodePositions is a member map, so the nodeDatabase CRC compare above cannot see this write - + // and it has already run. Flag the segment or the fixed position is only persisted by chance. + saveWhat |= SEGMENT_NODEDATABASE; #endif - nodeDB->setLocalPosition(fixedGPS); + setLocalPosition(fixedGPS); config.position.fixed_position = true; + // Same for config, whose CRC compare also ran before this block. Keep that compare's + // degraded-boot guard so an unreadable config is never overwritten with UNSET defaults. + if (!configDecodeFailed) + saveWhat |= SEGMENT_CONFIG; #endif } #endif From dcdfa32250282cb01745f774cd8aa410e6e44f8e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:10:54 +0000 Subject: [PATCH 127/143] chore(deps): update meshtastic/device-ui digest to 776ab04 (#11815) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index a65f0c5a82..f061da4e91 100644 --- a/platformio.ini +++ b/platformio.ini @@ -140,7 +140,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/33738beab3ef0257f6b88486e4e7552d9bd5de4e.zip + https://github.com/meshtastic/device-ui/archive/776ab044345fcd41c4d9eda0eb86c9ad2206b2e1.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 80cfa52665436f87417a333fda24da59a77d639f Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:54:50 +0000 Subject: [PATCH 128/143] Add zero guards on time calculations where they were missing (#11692) * time: add skipZero/safeMillis/timerEndsAtMillis helpers skipZero() steps a millis value past 0, since stored stamps and deadlines conventionally use 0 for "unset" and the one tick per ~49.7-day wrap that lands on 0 would otherwise read as never-set. safeMillis() covers a bare stamp; timerEndsAtMillis(delayMs) covers a deadline, where the sum is what has to dodge 0 - a non-zero read plus a delay lands there once per wrap - so it is not safeMillis() + delayMs. * time: replace hand-rolled zero-dodging with the UptimeClock helpers PacketHistory rxTimeMsec, EncryptedStorage s_lastFailMillis (stamps), and SGM41562 lastRefreshMs_ / NextHopRouter learnedAtMsec (ternary stamps) each hand-rolled skipZero() in place; swap in safeMillis()/skipZero() directly. HapticFeedback pulseOffAt/delayedPulseAt and GPS fixHoldEnds hand-rolled the deadline form - millis() + delay, then remap a 0 result to 1 - swap in timerEndsAtMillis(delay). No behavior change; each site keeps the value it already computed. * time: guard the remaining 0-means-unset deadline/stamp writes rebootAtMsec, shutdownAtMsec, and NotificationRenderer::alertBannerUntil are all read back with a bare == 0 / != 0 check for 'not scheduled', but every write site computed millis() + delay (or a bare millis() stamp) with no guard against landing exactly on 0 - the same wrap hazard skipZero() exists for, just never applied here. Route every rebootAtMsec/shutdownAtMsec/alertBannerUntil write through timerEndsAtMillis()/safeMillis(); RadioLibInterface's reboot-on-stuck-tx sums an already-captured stamp rather than "now", so it goes through skipZero() directly instead. No behavior change outside the ~1-in-2^32 wrap window each site was already exposed to. * time: guard three more 0-means-unset deadline writes ntp_renew (ethClient.cpp), suppressTouchTapUntilMs (Events.cpp), and tx_after (RadioLibInterface.cpp) all read back 0 as a real state - forced NTP renewal, no suppress window active, no TX delay armed, respectively - but each arm site wrote a bare millis()/getMillis() + delay with no guard against the sum landing exactly on 0. Route each through Time::timerEndsAtMillis(). No behavior change outside the wrap window each site was already exposed to. Refresh the Throttle.h TODO list to note ntp_renew is converted too. * motion: guard the calibration deadline and use Throttle::deadlinePassed endCalibrationAt's arm site wrote millis() + calibrateFor with no guard against landing on 0, the same value finishCalibrationIfExpired()/ drawFrameCalibration() treat as "not calibrating". Route it through Time::timerEndsAtMillis(). Also swap finishCalibrationIfExpired()'s hand-rolled (int32_t)(now - deadline) < 0 for Throttle::deadlinePassed(): same wrap-safe comparison the codebase already provides, without the signed-cast pattern Throttle.h documents as implementation-defined past INT32_MAX, and it drops the file's last direct millis() call in favor of the Time:: wrapper the rest of it already uses. * time: fix Throttle::execute()'s own zero-dodging Both places execute() writes *lastExecutionMs - the first-ever-run branch and the regular update - used bare Time::getMillis() with no guard against landing on 0, which is the exact sentinel this function reads back as "never run" one line above. A hit there makes the next call re-fire immediately instead of respecting minumumIntervalMs. Capture now via Time::safeMillis() once; every use downstream (the elapsed comparison, the stored value) is then safe by construction instead of needing the guard reapplied at each write. * revert some safeMillis cases where overflow is a bad thing * test(uptime): pin skipZero/safeMillis/timerEndsAtMillis at the wrap boundary Covers the zero case, an ordinary nonzero value, and a sum that lands exactly on 0 from a nonzero start - the case timerEndsAtMillis() exists for, and the one the prior suite had no direct coverage of. * time: restore the route-health write normalization and put it on one clock noteRouteLearned()/noteRouteSuccess() lost their `now ? now : 1` normalization, leaving learnedAtMsec able to store 0 - which getOrAllocRouteHealth() reads as an ever-growing age, making the slot the first eviction candidate and permanently stale. Normalize at the write, where the block comment already says it happens, so every caller is covered rather than just today's two. Both callers, the two isRouteStale() sites and doRetransmissions() now read Time::getMillis(), so the stamp and every comparison against it share a clock. doRetransmissions() goes back to getMillis(): its `now` feeds only comparisons, never a 0-sentinel field, so skipping zero there only cost accuracy. * time: read the haptic, InkHUD and calibration deadlines on the write's clock These three deadlines were converted to Time::timerEndsAtMillis() on the write side while their reads stayed on millis(), so each spanned two clocks and would fire immediately or never under an injected test clock. Convert the reads to match: HapticFeedback::scheduleNext()/runOnce(), the InkHUD tap-suppression window, and the calibration countdown's read-back of screen->getEndCalibration(). MotionSensor's sampledAtMs is left alone - its write and read are both millis() and consistent already. * time: correct the sentinel notes to match what the code actually does The Throttle.h enumeration claimed the remaining timerEndsAtMillis() callers "already dodge the sentinel", which reads as a completeness claim the same branch contradicts: RadioLibInterface's tx_after and activeReceiveStart are both 0=unarmed and both still arm from bare millis(). Name them instead, so the deadline-type conversion has the real list. The ntp_renew entry now separates a deliberate 0 ("due now", forced at link-up) from a computed one, which is what changed there. The three TODO(elapsed-stamp) blocks ran four and five lines against the repo's one-or-two rule, and two of them argued their case wrongly. Throttle.cpp implied safeMillis() simply doesn't help; in fact neither store is safe on the wrap tick - the 1 underflows a same-instant read, the 0 re-takes the never-run branch - which is the symmetry worth recording. PacketHistory.cpp called its dodge "reflecting the previous pattern" when it is load-bearing: rxTimeMsec 0 means "empty slot" (PacketHistory.h:21) and insert() drops a record stamped 0 outright, so without it a packet arriving on the wrap tick is never stored and loses its dedup. Also picks up trunk fmt's trailing-whitespace fix in Throttle.cpp and the comment realignment in SGM41562.cpp that this branch's added comment knocked out. * test(nexthop): pin the route-health stamp against the 0 sentinel The uptime suite covers skipZero/safeMillis/timerEndsAtMillis themselves, but nothing covered a call site, so the branch deleted noteRouteLearned()'s normalization and stayed green. None of the existing route-health tests pass 0 as `now` - they use 1000, learnAt, or millis() - (TTL + 5000) - which is exactly the gap the regression went through. Both new tests fail with "Expected 0 to be not equal to 0" when the skipZero() is backed out of NextHopRouter, and pass with it. noteRouteSuccess() only refreshes an existing record, so its twin learns a route first to reach the write. Also drops a self-referential assertion in the uptime suite: comparing getMillis() against safeMillis() passes even if safeMillis() does no dodge at all, so it now asserts the literal. * discard safemillis for skipzero (better semantics and therefore maintainability) and make consistent use of getmillis where it is called (to permit testing) * more wrapzero safety * STM gets some too * time: stop the next 0-means-unset deadline being armed from raw millis() The fields this branch armed through Time::timerEndsAtMillis() / Time::skipZero() are the kind that get added by copy-paste: `rebootAtMsec = millis() + N` appears at twenty-odd sites across six files, and the next module to defer a reboot will be written from one of them. Nothing catches the mistake afterwards - the sum lands on 0 for one tick per ~49.7-day wrap, so a test run, a soak and a bench session all pass while a pending reboot, shutdown, DFU jump or banner expiry is silently dropped. Two guards, at the two places it can go wrong. The helpers themselves: skipZero() is constexpr, so its contract is now pinned by static_assert in the header rather than only by test_uptime_clock. The asserts are chosen against the two plausible rewrites - `ms | 1` perturbs every even value and `ms + 1` turns the last tick of the wrap into the 0 the function exists to avoid. Both compile, and both pass a test that only checks skipZero(0); each trips a distinct assert here, naming the failure mode. The call sites: bin/lint-unset-sentinel-millis.sh flags a sentinel field in src/ assigned from a raw millis()/getMillis() read, and names the helper to use. It is name-driven because the 0 contract is declared in src/main.h and enforced in six other files, so no single-file scan can infer it; every one of the thirteen fields was checked to actually test against 0 before being listed. nagCycleCutoff and LinuxJoystick's nextRepeatX/nextRepeatY are deliberately absent - their unset state is a separate bool - and the nine remaining `millis() + x` sites in src/ are locals that never store 0 for anything to misread. Blocking, unlike its note-level neighbours: there is no run-time enforcer to pair with, and the tree has zero violations today, so gating costs nothing. Scoped to src/ so test_uptime_clock can keep building raw wrap values on purpose. bin/test-lint-unset-sentinel-millis.sh pins the scanner against 23 fixtures - reads, disarms, shadowing locals, comments, string literals and the already-fixed forms all have to stay quiet. Co-Authored-By: Claude Opus 5 * time: guard the four 0-means-unset stamps this branch had missed Sweeping src/ for the `if (stamp && )` idiom - the shape that makes 0 mean "unset" - turned up four stamps still armed from a raw clock read, so the new lint rule would have had to either ignore them or go red on checkout. Each is the same one-tick-per-wrap hole the rest of the branch closes: * TrackballInterruptBase lastInterruptTime, armed in all four ISR handlers and explicitly disarmed to 0 at the threshold reset. getMillis() is the ISR-safe read by construction - it compiles to millis() outside PIO_UNIT_TESTING - and skipZero() is pure, so neither adds anything to interrupt context. * NeighborInfoModule lastSentReply, read as `if (lastSentReply && ...)` before the 3-minute reply throttle. Needed the UptimeClock.h include. * PositionModule lastSentReply, same throttle; already on the injectable clock but still missing the guard. * NodeDB lastSort, whose own read spells the sentinel out as `lastSort == 0 ||`. On the wrap tick each would read as never-stamped: a trackball debounce window lost, a neighbour or position reply sent inside the throttle it was meant to respect, one extra NodeDB sort. Cheap individually, which is why they were missed. All four are now listed in bin/lint-unset-sentinel-millis.sh, so the rule covers every field in the tree that actually tests against 0 rather than a subset, and the header records the eight stamps left off for the opposite reason - their unset state is a separate flag (isNagging, busyTx, heldX/heldY, formatted_this_boot, heartbeat, gotwind, haveSample, lastIaqValid), so 0 is a value they may legally hold. The rule is silent across src/ on this tree. Co-Authored-By: Claude Opus 5 * lint: let a site opt out of the sentinel rule, with its reason on the record The rule is blocking, so it needs an escape hatch for the site where 0 genuinely is a legal timestamp - and the hatch should cost something, or it becomes the first thing anyone reaches for. `unset-sentinel-ok: ` in a comment on the write, or on a comment line above it, suppresses that one statement: // unset-sentinel-ok: busyTx carries the armed state, so 0 is a legal stamp here lastTxStart = Time::getMillis(); The reason is mandatory. A bare `unset-sentinel-ok`, or a colon with nothing after it, is reported instead of honoured - with a message saying so - so the only way to silence a site is to write down why it is safe. trunk-ignore still works, but this states the justification at the write and also applies when the script runs outside trunk. The marker is read from comment text collected during the same character-level pass that strips comments and literals, not by re-scanning the raw line. That is what keeps it out of reach of data: LOG_DEBUG("unset-sentinel-ok: ...") mutes nothing, because a string literal is not a comment. It is also consumed by the statement it was written for, so it cannot leak onto the next write - while still carrying across any number of intervening comment lines to the statement below, which is where a real justification wants to be written. Twelve fixtures added for the new behaviour: both comment styles, block and multi-line block comments, the bare form, the marker-in-a-string cases, and three leak cases. 35 total, all green, under bash 3.2 as well. Co-Authored-By: Claude Opus 5 * lint: watch the separate-flag stamps too, with their exemption stated at the write The nine stamps whose armed state lives in a companion boolean were previously just absent from the rule's list, which meant the reasoning for leaving them out existed only as prose in a shell script. They are now listed and individually opted out at the write, naming the flag that actually carries the armed state: // unset-sentinel-ok: haveSample carries the armed state, so 0 is a legal stamp lastSampleMs = Time::getMillis(); The point is what happens later. If someone rewrites `if (haveSample && ...)` as `if (lastSampleMs && ...)`, the field has silently acquired the 0 contract; with the opt-out sitting at the write, the claim to re-examine is in front of whoever makes that edit instead of buried in bin/. Every exemption was checked against its real read sites before being written, and three candidates did not survive that check. They stay off the list, because listing one would mean stamping an opt-out over a claim that does not hold: * nagCycleCutoff. handleInputEvent reads `if (nagCycleCutoff != UINT32_MAX)` without consulting isNagging, so at that read the field is its own armed flag with UINT32_MAX as the sentinel - and the arm at ExternalNotificationModule .cpp:521 can land exactly there. skipZero() cannot help: it lifts 0 to 1 and leaves UINT32_MAX alone, which UptimeClock.h's own static_assert pins. There is also a live boot-state bug behind this - the in-class initializer is 1 while isNagging starts false - and fixing the read is a behaviour change that belongs in its own PR. * TouchScreenBase::_start. Overloaded as an event stamp AND a `+ 30000` suppression deadline compared by signed subtraction, so a near-zero value reads as "long ago" rather than "armed 30s out" and LONG_PRESS re-fires. skipZero() does not fix this one either: 1 reads as long-ago exactly as 0 does. It needs the stamp and the deadline held separately. * StoreForwardModule::retry_delay. No reads at all today, so nothing misbehaves yet; exempting it now would pre-approve the raw arm for whoever implements the retry its own comment promises. The rule is silent across src/ on this tree, and the header records all three rejections so the next person does not have to re-derive them. The self-test's negative fixture no longer uses nagCycleCutoff as its example of a safely unlisted field - that would have encoded the opposite of what the header says. Co-Authored-By: Claude Opus 5 * fix(time,lint): guard the recomputed tx_after, and judge one write at a time Two review findings, both real. setTransmitDelay() recomputes p->tx_after from a clamp of three candidates, and that recomputation was still raw. Two lines above it, `if (p->tx_after)` is the read that takes 0 as "no delay wanted", so a clamp landing on 0 drops the CSMA backoff and the packet goes out immediately instead of after its computed delay. The first arm site in this function was already guarded; this one was missed because the value is not a plain `now + delay` and so does not fit timerEndsAtMillis() - it takes skipZero() instead. The narrowing order matters here and is spelled out at the site: add_delay is unsigned long, 64-bit on the portduino host, so the clamp can exceed UINT32_MAX there. skipZero() on the wide value would pass 0x100000000 through as non-zero and the store to this uint32_t field would then truncate it back to the 0 being avoided, so the cast comes first. The lint rule judged each write by the wrong text. rhs was taken from the write to the end of the accumulated statement, so a neighbour on the same line decided the verdict - and it was wrong in both directions: rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10); the later helper call suppressed a genuine raw arm rebootAtMsec = otherDeadline; shutdownAtMsec = millis(); the later millis() reported a safe copy rhs is now cut at its own semicolon. Six fixtures cover it, including both cases above, two raw writes on one line, two helper writes on one line, and a statement split across lines, which must still see its whole right-hand side. 41 fixtures total, green under bash 3.2. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Tom <116762865+Nestpebble@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Claude Opus 5 --- .trunk/trunk.yaml | 19 ++ bin/lint-unset-sentinel-millis.sh | 248 ++++++++++++++++ bin/test-lint-unset-sentinel-millis.sh | 270 ++++++++++++++++++ src/PowerFSM.cpp | 3 +- src/UptimeClock.h | 24 ++ src/concurrency/OSThread.cpp | 1 + src/gps/GPS.cpp | 3 +- src/graphics/Screen.cpp | 11 +- src/graphics/draw/MenuHandler.cpp | 23 +- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 23 +- src/graphics/niche/InkHUD/Events.cpp | 5 +- src/input/HapticFeedback.cpp | 14 +- src/input/LinuxJoystick.cpp | 2 + src/input/TrackballInterruptBase.cpp | 8 +- src/main.cpp | 6 +- src/mesh/NextHopRouter.cpp | 10 +- src/mesh/NodeDB.cpp | 2 +- src/mesh/PacketHistory.cpp | 7 +- src/mesh/PhoneAPI.cpp | 3 +- src/mesh/RadioInterface.cpp | 3 +- src/mesh/RadioLibInterface.cpp | 35 ++- src/mesh/ReliableRouter.cpp | 3 +- src/mesh/Throttle.cpp | 2 + src/mesh/Throttle.h | 6 +- src/mesh/eth/ethClient.cpp | 5 +- src/modules/AdminModule.cpp | 13 +- src/modules/NeighborInfoModule.cpp | 3 +- src/modules/PositionModule.cpp | 2 +- src/modules/SerialModule.cpp | 1 + src/modules/StoreForwardModule.cpp | 2 + src/modules/SystemCommandsModule.cpp | 13 +- src/modules/Telemetry/Sensor/BME680Sensor.cpp | 1 + src/motion/MotionSensor.cpp | 9 +- src/platform/nrf52/main-nrf52.cpp | 1 + .../portduino/windows/WindowsService.cpp | 3 +- src/power/SGM41562.cpp | 7 +- src/security/EncryptedStorage.cpp | 7 +- test/test_nexthop_routing/test_main.cpp | 21 ++ test/test_uptime_clock/test_main.cpp | 47 ++- 39 files changed, 760 insertions(+), 106 deletions(-) create mode 100755 bin/lint-unset-sentinel-millis.sh create mode 100755 bin/test-lint-unset-sentinel-millis.sh diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 9c7d770523..765e2889c6 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -92,11 +92,30 @@ lint: run: ${workspace}/bin/lint-unity-exit.sh ${target} success_codes: [0] read_output_from: stdout + # Flags a 0-means-unset deadline in src/ armed from a raw millis()/getMillis() read. The sum + # lands on 0 once per ~49.7-day wrap and every reader then decides the timer was never set, so + # a pending reboot, shutdown, DFU jump or banner expiry is silently dropped. The fix is + # Time::timerEndsAtMillis() / Time::skipZero() from src/UptimeClock.h. Unlike its neighbours + # this one is blocking: nothing catches it at run time, because the window is one tick in seven + # weeks. A site where 0 really is legal opts out with an `unset-sentinel-ok: ` comment on + # the write or the line above it; the reason is mandatory, so nothing can be muted silently. The + # field list lives in bin/lint-unset-sentinel-millis.sh; read the header there before adding to + # it, and re-run bin/test-lint-unset-sentinel-millis.sh after any change to the rule. + - name: unset-sentinel-millis + files: [cpp-sources] + commands: + - name: lint + output: regex + parse_regex: (?P.+):(?P\d+):(?P\d+):(?P\w+):(?P.+):(?P[a-z-]+) + run: ${workspace}/bin/lint-unset-sentinel-millis.sh ${target} + success_codes: [0] + read_output_from: stdout enabled: - ascii-dash@SYSTEM - too-many-defined@SYSTEM - node-id-format@SYSTEM - unity-exit@SYSTEM + - unset-sentinel-millis@SYSTEM - checkov@3.3.8 - renovate@44.2.3 - prettier@3.9.6 diff --git a/bin/lint-unset-sentinel-millis.sh b/bin/lint-unset-sentinel-millis.sh new file mode 100755 index 0000000000..1ff34da200 --- /dev/null +++ b/bin/lint-unset-sentinel-millis.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# lint-unset-sentinel-millis.sh - flag a 0-means-unset deadline armed from a raw clock read. +# +# A family of fields in this tree uses 0 to mean "unarmed", and is then read as `if (field)` or +# `field != 0` before the deadline is compared. src/main.h says so out loud: +# +# extern uint32_t enterDfuAtMsec; // 0 = unset; else millis() deadline for the deferred DFU jump +# +# Arming one of those with `field = millis() + delay` is correct for ~49.7 days and then wrong for +# one tick: the sum lands on 0 exactly once per wrap, and at that instant every reader decides the +# timer was never set. A pending reboot, shutdown, DFU jump or banner expiry is silently dropped. +# `field = millis()` has the same hole for a stamp. The fix is Time::timerEndsAtMillis(delay) for a +# countdown, or Time::skipZero(Time::getMillis()) for a stamp; both are in src/UptimeClock.h, whose +# static_asserts pin the behaviour this rule steers people toward. +# +# Why a name list and not a pattern over every `millis() + x`: most sums are fine. A local +# `const uint32_t deadline = millis() + timeoutMs;` that is compared a few lines later never stores +# 0 for anything to misread, and src/ has nine such sites that are all correct. The 0 contract is +# also declared in one file and enforced in six others - rebootAtMsec is written in AdminModule.cpp +# and tested in Power.cpp, PowerFSM.cpp, main.cpp, Screen.cpp and portduino/USBHal.h - so no +# single-file scan can infer it. The list below is therefore explicit. +# +# Two kinds of field are on it: +# +# * Fields whose 0 IS the unset state. These must be armed through the helpers. +# * Fields whose unset state is a separate flag, so 0 is a value they may legally hold. These are +# listed so that the rule notices them, and each arm site carries an opt-out comment naming the +# flag that actually carries the armed state. Listing-plus-opt-out beats silent omission: if +# someone later rewrites `if (haveSample && ...)` into `if (lastSampleMs && ...)`, the field has +# quietly acquired the contract, and the opt-out comment is sitting right there at the write to +# be reconsidered. +# +# OPTING OUT. Put `unset-sentinel-ok: ` in a comment, either on the line of the write or on +# a comment line above it: +# +# // unset-sentinel-ok: busyTx carries the armed state, so 0 is a legal timestamp here +# lastTxStart = Time::getMillis(); +# +# The reason is mandatory - a bare `unset-sentinel-ok` with nothing after the colon is reported +# rather than honoured, so a site cannot be muted without saying why. trunk-ignore works too, but +# prefer this: it states the justification at the write, and it also applies when the script is run +# directly rather than through trunk. +# +# Adding a field: append it to SENTINELS. If its 0 is the unset state, fix the arm sites; if a +# separate flag carries the armed state, add an opt-out comment at each write. Verify which of the +# two it is by reading every site that READS the field - one missed read is what makes this wrong. +# +# Three fields are absent on purpose, and NOT because 0 is safe there. Each was examined and came +# back unresolved rather than exempt, so listing one would mean stamping an opt-out over a claim +# that does not hold: +# +# * nagCycleCutoff. ExternalNotificationModule::handleInputEvent reads +# `if (nagCycleCutoff != UINT32_MAX)` without consulting isNagging, so at that read the field is +# its own armed flag with UINT32_MAX - not 0 - as the sentinel, and the arm site can land there. +# skipZero() would not help: it lifts 0 to 1 and leaves UINT32_MAX alone, by design. The fix is +# to gate that read on isNagging, a behaviour change that belongs in its own PR. +# * TouchScreenBase::_start. Overloaded as both an event stamp and a `+ 30000` suppression +# deadline compared by signed subtraction, so a near-zero value reads as "long ago" rather than +# "armed 30s out", and LONG_PRESS re-fires. skipZero() does not fix that either - 1 reads as +# long-ago exactly as 0 does. It needs the stamp and the deadline held separately. +# * StoreForwardModule::retry_delay. Has no reads at all today, so nothing misbehaves yet; +# exempting it now would pre-approve the raw arm for whoever implements the retry its own +# comment promises. +# +# Also absent, for the ordinary reason that 0 carries no meaning there: locals such as +# NodeInfoModule's lastNodeInfo, derived per call from TransmitHistory rather than stored. +# +# Emitted at "error" rather than the "note" its neighbours use, because nothing catches this at run +# time: the window is one tick in seven weeks, so a test run, a soak and a bench session all pass. +# The tree has zero unexplained violations, so blocking costs nothing and is the only thing that +# actually prevents the next one. +# +# Not handled: a write through an alias (`uint32_t &d = rebootAtMsec; d = millis() + 5;`) or through +# a pointer, and a sentinel armed by a helper that takes it by reference. None occur, and tracking +# aliases is untested code guarding a case that does not exist. Raw string literals (R"(...)") are +# not tokenised either, for the same reason as in bin/lint-unity-exit.sh. +# +# bin/test-lint-unset-sentinel-millis.sh is this rule's self-test; every false positive and false +# negative found in review belongs there as a fixture. +# +# Emits one line per finding in the format +# ::::: +# which trunk parses via parse_regex. Always exits 0; findings go to stdout. + +set -uo pipefail + +# Millisecond fields this rule watches. See the notes above before editing. +SENTINELS='rebootAtMsec|shutdownAtMsec|enterDfuAtMsec|alertBannerUntil|pulseOffAt|delayedPulseAt|ntp_renew|tx_after|suppressTouchTapUntilMs|fixHoldEnds|lastChipRecoveryMs|activeReceiveStart|rxTimeMsec|lastInterruptTime|lastSentReply|lastSort|lastTxStart|lastHeartbeat|lastAveraged|lastSampleMs|lastIaqMs|last_format_ms|nextRepeatX|nextRepeatY|_cached_next_run' + +for target in "$@"; do + [[ -f $target ]] || continue + + # Path is reported relative to the workspace so findings are clickable from the repo root. + rel="${target#"$PWD"/}" + + # Firmware sources only. Tests construct raw wrap values on purpose - pinning what happens at + # 0xFFFFFFFF is the point of test_uptime_clock - and UptimeClock.h itself defines the helpers. + [[ $rel == src/* ]] || continue + [[ $rel == src/UptimeClock.h ]] && continue + + awk -v path="$rel" -v sentinels="$SENTINELS" ' + # Return the line with comments and string/char literals removed, carrying /* ... */ state + # across lines, and fill colmap[] mapping each position in the result back to its raw column. + # Character-level rather than layered regexes, for the reasons spelled out at length in + # bin/lint-unity-exit.sh: a /* inside a string literal flips comment state and hides real code, + # and an assignment quoted inside a log string reads as one. Literals collapse to a space so two + # tokens cannot be glued together. + # + # Also sets CMT to this line s comment text, which is where an opt-out has to live. Collecting + # it here rather than re-scanning the raw line is what stops `LOG_DEBUG("unset-sentinel-ok: x")` + # from muting anything: a string literal is not a comment. + function strip_noncode(s, out, i, n, c, two, q) { + n = length(s); i = 1; out = "" + delete colmap + CMT = "" + while (i <= n) { + if (in_block) { + if (substr(s, i, 2) == "*/") { in_block = 0; i += 2 } + else { CMT = CMT substr(s, i, 1); i++ } + continue + } + two = substr(s, i, 2) + if (two == "//") { CMT = CMT " " substr(s, i + 2); return out } + if (two == "/*") { in_block = 1; i += 2; continue } + c = substr(s, i, 1) + if (c == "\"" || c == "'"'"'") { # skip a whole literal, honouring backslash escapes + q = c + out = out " "; colmap[length(out)] = i + i++ + while (i <= n) { + c = substr(s, i, 1) + if (c == "\\") { i += 2; continue } + i++ + if (c == q) break + } + continue + } + out = out c; colmap[length(out)] = i; i++ + } + return out + } + + # An opt-out only counts with something after the colon. A bare marker is reported instead of + # honoured, so "shut this up" is not available without writing down why. + function has_reasoned_ok(t) { return (t ~ /unset-sentinel-ok:[ \t]*[^ \t]/) } + function has_bare_ok(t) { return (t ~ /unset-sentinel-ok/) && !has_reasoned_ok(t) } + + # Is the character before position `at` part of an identifier? Used to require a token boundary, + # so myRebootAtMsec is not mistaken for rebootAtMsec. A `.`, `->` or `::` qualifier is a + # boundary on purpose: txp->tx_after and NotificationRenderer::alertBannerUntil are the real + # call sites and must still be seen. + function ident_before(s, at, c) { + if (at <= 1) return 0 + c = substr(s, at - 1, 1) + return (c ~ /[A-Za-z0-9_]/) + } + + # A local declaration that happens to reuse a sentinel name shadows the field and carries none + # of its contract, so it is not this rule business. Detected by a type-ish token immediately + # before the name - `uint32_t tx_after = millis() + d;` declares a local, `tx_after = ...` does + # not. Kept narrow: only the spellings this tree actually uses for a millis value. + function is_declaration(s, at, head) { + head = substr(s, 1, at - 1) + sub(/[ \t]*(\*|&)?[ \t]*$/, "", head) + return (head ~ /(^|[^A-Za-z0-9_])(uint32_t|uint64_t|int32_t|unsigned[ \t]+long|unsigned[ \t]+int|unsigned|long|int|auto|size_t|TickType_t)$/) + } + + BEGIN { LINE_CAP = 12 } # give up accumulating a statement after this many lines + + { + code = strip_noncode($0) + + # An opt-out is sticky until the next statement that actually contains code is judged. That + # is what lets it sit on its own line above the write, however many comment lines intervene, + # without leaking past the statement it was written for. + if (has_reasoned_ok(CMT)) pending_ok = 1 + if (has_bare_ok(CMT)) pending_bare = 1 + + if (stmt == "") { start = NR; nhits = 0 } + base = length(stmt) + 1 # the leading space added below shifts everything by one + stmt = stmt " " code + + # Record every ` =` on this line, with its position in the accumulated statement + # so the RHS can be judged once the statement is whole. + rest = code; off = 0 + while (match(rest, "(" sentinels ")[ \t]*=")) { + p = RSTART; len = RLENGTH + off += p + # A plain assignment only: ==, !=, <=, >=, +=, -= and friends are reads or updates, + # not an arming write, and `=` must not be the head of `==`. + eq = off + len - 1 + prev = (eq - 1 >= 1) ? substr(code, eq - 1, 1) : " " + nxt = (eq + 1 <= length(code)) ? substr(code, eq + 1, 1) : " " + if (prev !~ /[-+*\/%&|^!<>=]/ && nxt != "=" && !ident_before(code, off) && + !is_declaration(code, off)) { + nhits++ + hit_at[nhits] = base + eq # index of the `=` within stmt + hit_line[nhits] = NR + hit_col[nhits] = colmap[off] + hit_name[nhits] = substr(code, off, len - 1) + sub(/[ \t]*$/, "", hit_name[nhits]) + } + rest = substr(rest, p + len - 1) + off += len - 2 + } + + # End of statement: judge each recorded write against its own right-hand side. + if (code ~ /;/ || NR - start >= LINE_CAP) { + for (k = 1; k <= nhits; k++) { + # The right-hand side of THIS write only, cut at its own semicolon. Without the + # cut, rhs ran on to the end of the accumulated statement and judged a neighbour + # as if it belonged to this write - wrongly in both directions. On + # rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10); + # the later helper call suppressed a genuine raw arm, and on + # rebootAtMsec = otherDeadline; shutdownAtMsec = millis(); + # the later millis() reported a safe copy. Both are fixtures now. + rhs = substr(stmt, hit_at[k] + 1) + semi = index(rhs, ";") + if (semi > 0) rhs = substr(rhs, 1, semi - 1) + + # Only a raw clock read is a finding. `= 0` disarms, a copy from another + # variable inherits whatever that one did, and anything already routed through + # the helpers is the fix rather than the defect. Matching `millis` loosely + # covers millis(), Time::getMillis() and any wrapper ending in millis. + if (rhs !~ /[Mm]illis[ \t]*\(/ || rhs ~ /skipZero/ || rhs ~ /timerEndsAtMillis/) + continue + if (pending_ok) + continue # opted out, with a reason, at the write + if (pending_bare) + printf "%s:%d:%d:%s:%s:%s\n", path, hit_line[k], hit_col[k], "error", + "unset-sentinel-ok needs a reason after the colon saying why 0 is legal for " hit_name[k] " (see bin/lint-unset-sentinel-millis.sh)", + "unset-sentinel-millis" + else + printf "%s:%d:%d:%s:%s:%s\n", path, hit_line[k], hit_col[k], "error", + hit_name[k] " is 0-means-unset - arm it with Time::timerEndsAtMillis(delay), or Time::skipZero(Time::getMillis()) for a stamp (see src/UptimeClock.h)", + "unset-sentinel-millis" + } + # Comment-only lines carry an opt-out toward the write below them, so they must not + # clear it; a statement with real code in it consumes it. + if (stmt ~ /[^ \t]/) { pending_ok = 0; pending_bare = 0 } + stmt = "" + nhits = 0 + } + } + ' "$target" +done + +exit 0 diff --git a/bin/test-lint-unset-sentinel-millis.sh b/bin/test-lint-unset-sentinel-millis.sh new file mode 100755 index 0000000000..94c509f415 --- /dev/null +++ b/bin/test-lint-unset-sentinel-millis.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# test-lint-unset-sentinel-millis.sh - self-test for bin/lint-unset-sentinel-millis.sh. +# +# The scanner has to tell an arming write apart from a read, a disarm, a shadowing local, a quoted +# string and an already-fixed site. Each case below is a fixture: a snippet, the lines that must be +# reported, and nothing else. Run it after any change to the rule. +# +# Exit 0 = all cases pass, 1 = at least one case failed. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LINT="$ROOT_DIR/bin/lint-unset-sentinel-millis.sh" + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +FAILURES=0 + +# run_case +run_case() { + local name="$1" expect="$2" body="$3" + # The rule only looks at src/, and reports paths relative to $PWD, so the fixture has to live + # under a src/ directory that is also the working directory's child. + local dir="$WORK/case" + rm -rf "$dir" + mkdir -p "$dir/src" + printf '%s\n' "$body" >"$dir/src/fixture.cpp" + + local got + got=$(cd "$dir" && "$LINT" src/fixture.cpp | awk -F: '{print $2}' | paste -sd, -) + local want + want=$(printf '%s' "$expect" | paste -sd, -) + + if [[ $got == "$want" ]]; then + echo "PASS $name" + else + echo "FAIL $name: expected lines [$want], got [$got]" + FAILURES=$((FAILURES + 1)) + fi +} + +# --- must be reported --------------------------------------------------------- + +run_case "bare millis() sum" "2" 'void f() { + rebootAtMsec = millis() + 5000; +}' + +run_case "bare millis() stamp" "2" 'void f() { + shutdownAtMsec = millis(); +}' + +run_case "Time::getMillis() sum" "2" 'void f() { + enterDfuAtMsec = Time::getMillis() + 25; +}' + +run_case "qualified and arrow targets" "2 +3" 'void f(MeshPacket *txp) { + NotificationRenderer::alertBannerUntil = millis() + durationMs; + txp->tx_after = millis() + delay; +}' + +run_case "statement split across lines" "2" 'void f() { + ntp_renew = + millis() + 43200 * 1000; +}' + +run_case "parenthesised sum" "2" 'void f() { + rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); +}' + +run_case "two writes on one line" "2 +2" 'void f() { + rebootAtMsec = millis() + 5; shutdownAtMsec = millis(); +}' + +run_case "code after a block comment on the same line" "2" 'void f() { + /* arm it */ pulseOffAt = millis() + durationMs; +}' + +# --- must NOT be reported ---------------------------------------------------- + +run_case "already fixed - timerEndsAtMillis" "" 'void f() { + rebootAtMsec = Time::timerEndsAtMillis(5000); +}' + +run_case "already fixed - skipZero stamp" "" 'void f() { + shutdownAtMsec = Time::skipZero(Time::getMillis()); +}' + +run_case "already fixed - ternary keeping the 0 arm" "" 'void f() { + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); +}' + +run_case "disarm" "" 'void f() { + rebootAtMsec = 0; + shutdownAtMsec = 0; +}' + +run_case "reads and comparisons" "" 'void f() { + if (rebootAtMsec && Throttle::deadlinePassed(rebootAtMsec)) {} + if (shutdownAtMsec == 0 && millis() > 5) {} + if (tx_after != 0) {} +}' + +run_case "shadowing local declaration" "" 'void f() { + uint32_t tx_after = millis() + 100; + unsigned long rxTimeMsec = millis(); +}' + +run_case "longer identifier containing a sentinel name" "" 'void f() { + myRebootAtMsec = millis() + 5000; + lastRxTimeMsec = millis(); +}' + +run_case "inside a line comment" "" 'void f() { + // rebootAtMsec = millis() + 5000; +}' + +run_case "inside a block comment" "" 'void f() { +/* + rebootAtMsec = millis() + 5000; +*/ +}' + +run_case "inside a string literal" "" 'void f() { + LOG_DEBUG("rebootAtMsec = millis() + 5000"); +}' + +run_case "copy from another variable" "" 'void f() { + rebootAtMsec = otherDeadline; +}' + +run_case "compound assignment" "" 'void f() { + rebootAtMsec += millis(); +}' + +# A field that is simply not on the list, and a local that never persists. nagCycleCutoff is NOT +# used as the example here: it is off the list because its exemption was rejected, not because 0 is +# safe for it, so pinning it as a negative fixture would encode the opposite of what the header says. +run_case "unlisted field and a local deadline" "" 'void f() { + someUnrelatedDeadline = millis() + durationMs; + const uint32_t deadline = millis() + BODY_TIMEOUT_MS; +}' + +# --- opt-out comments -------------------------------------------------------- + +run_case "opt-out on the same line" "" 'void f() { + lastSort = millis(); // unset-sentinel-ok: sortingIsPaused gates it, 0 is legal +}' + +run_case "opt-out on the line above" "" 'void f() { + // unset-sentinel-ok: busyTx carries the armed state + tx_after = millis() + d; +}' + +run_case "opt-out above, separated by more comment lines" "" 'void f() { + // unset-sentinel-ok: a separate flag carries the armed state + // and here is some more explanation spilling onto another line + // and another + tx_after = millis() + d; +}' + +run_case "opt-out in a block comment" "" 'void f() { + /* unset-sentinel-ok: a separate flag carries the armed state */ + tx_after = millis() + d; +}' + +run_case "opt-out in a multi-line block comment" "" 'void f() { + /* + * unset-sentinel-ok: a separate flag carries the armed state + */ + tx_after = millis() + d; +}' + +# A bare marker is reported rather than honoured, so nothing can be muted silently. +run_case "bare opt-out with no reason" "2" 'void f() { + rebootAtMsec = millis() + 5000; // unset-sentinel-ok +}' + +run_case "bare opt-out with a colon but nothing after it" "2" 'void f() { + rebootAtMsec = millis() + 5000; // unset-sentinel-ok: +}' + +# Must not be mutable from data. A marker inside a string literal is not a comment. +run_case "marker inside a string literal does not mute" "3" 'void f() { + LOG_DEBUG("unset-sentinel-ok: pretend this counts"); + rebootAtMsec = millis() + 5000; +}' + +run_case "marker in a trailing string on the same line does not mute" "2" 'void f() { + rebootAtMsec = millis() + 5000; LOG_DEBUG("unset-sentinel-ok: nope"); +}' + +# The opt-out is consumed by the statement it was written for and must not leak onward. +run_case "opt-out does not leak to the next write" "3" 'void f() { + lastSort = millis(); // unset-sentinel-ok: legal here + rebootAtMsec = millis() + 5000; +}' + +run_case "opt-out attached to an unrelated statement does not leak" "3" 'void f() { + int x = 1; // unset-sentinel-ok: nothing to do with the line below + rebootAtMsec = millis() + 5000; +}' + +run_case "opt-out covers both writes on its own line only" "3" 'void f() { + tx_after = millis() + 1; lastSort = millis(); // unset-sentinel-ok: both legal + rebootAtMsec = millis() + 5000; +}' + +# --- one write must not be judged by its neighbour on the same line ---------- +# +# rhs used to run to the end of the accumulated statement, so a neighbour decided this write. + +run_case "raw write is not excused by a helper call later on the line" "2" 'void f() { + rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10); +}' + +run_case "safe copy is not blamed for a raw write later on the line" "2" 'void f() { + rebootAtMsec = otherDeadline; shutdownAtMsec = millis(); +}' + +run_case "helper call is not blamed for a raw write later on the line" "2" 'void f() { + rebootAtMsec = Time::skipZero(Time::getMillis()); shutdownAtMsec = millis(); +}' + +run_case "two raw writes on one line are both reported" "2 +2" 'void f() { + rebootAtMsec = millis() + 5; shutdownAtMsec = millis(); +}' + +run_case "two helper writes on one line are both quiet" "" 'void f() { + rebootAtMsec = Time::timerEndsAtMillis(5); shutdownAtMsec = Time::skipZero(Time::getMillis()); +}' + +run_case "multi-line statement still sees its whole right-hand side" "2" 'void f() { + ntp_renew = + millis() + 43200 * 1000; +}' + +# --- scope ------------------------------------------------------------------- + +# test/ builds raw wrap values on purpose, so the rule must not reach into it. +mkdir -p "$WORK/scope/test" +printf 'void f() { rebootAtMsec = millis() + 5000; }\n' >"$WORK/scope/test/test_main.cpp" +if [[ -z $(cd "$WORK/scope" && "$LINT" test/test_main.cpp) ]]; then + echo "PASS test/ is out of scope" +else + echo "FAIL test/ is out of scope: expected no findings" + FAILURES=$((FAILURES + 1)) +fi + +# The helpers' own header must not report itself. +mkdir -p "$WORK/self/src" +printf 'void f() { rebootAtMsec = millis() + 5000; }\n' >"$WORK/self/src/UptimeClock.h" +if [[ -z $(cd "$WORK/self" && "$LINT" src/UptimeClock.h) ]]; then + echo "PASS src/UptimeClock.h is exempt" +else + echo "FAIL src/UptimeClock.h is exempt: expected no findings" + FAILURES=$((FAILURES + 1)) +fi + +echo +if [[ $FAILURES -eq 0 ]]; then + echo "RESULT: PASS" + exit 0 +fi +echo "RESULT: FAIL ($FAILURES case(s))" +exit 1 diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 7f35bf41e3..1a5d3f34db 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -12,6 +12,7 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerMon.h" +#include "UptimeClock.h" #include "configuration.h" #include "graphics/Screen.h" #include "main.h" @@ -104,7 +105,7 @@ extern Power *power; static void shutdownEnter() { LOG_POWERFSM("State: SHUTDOWN"); - shutdownAtMsec = millis(); + shutdownAtMsec = Time::skipZero(Time::getMillis()); } #include "error.h" diff --git a/src/UptimeClock.h b/src/UptimeClock.h index efc04eb899..18895fd550 100644 --- a/src/UptimeClock.h +++ b/src/UptimeClock.h @@ -44,6 +44,30 @@ void setMonotonicPublishHookForTests(MonotonicPublishHook hook); /// which is wrap-correct with no carry state at all. uint32_t getMillis(); +/// Step a millis value past 0. Stored stamps and deadlines conventionally use 0 for "unset", so the +/// one tick per ~49.7-day wrap that lands on 0 would read as never-set; 1 is a 1 ms error instead. +constexpr uint32_t skipZero(uint32_t ms) +{ + return ms ? ms : 1; +} + +/// Start a countdown to delayMs from now, never 0. The sum is what has to dodge 0 - a non-zero +/// read plus a delay lands there once per wrap - so this is not skipZero(getMillis()) + delayMs. +inline uint32_t timerEndsAtMillis(uint32_t delayMs) +{ + return skipZero(getMillis() + delayMs); +} + +// skipZero() is the whole 0-means-unset contract in one expression, and it is constexpr, so pin it +// here rather than only in test_uptime_clock: a build that breaks it stops at this header instead of +// shipping a deadline that reads as never-set. The two obvious "simplifications" are what these +// catch - `ms | 1` perturbs every even value, and `ms + 1` turns the last tick of the wrap into the +// 0 the function exists to avoid. Both compile and both pass a test that only checks skipZero(0). +static_assert(skipZero(0) == 1, "skipZero must lift the one 0 tick to 1"); +static_assert(skipZero(1) == 1, "skipZero must leave 1 alone"); +static_assert(skipZero(2) == 2, "skipZero must pass even values through untouched (ms | 1 would not)"); +static_assert(skipZero(UINT32_MAX) == UINT32_MAX, "skipZero must not wrap the last tick to 0 (ms + 1 would)"); + /// Milliseconds since boot as a monotonic 64-bit count. /// /// A pure read: it derives its answer from a complete snapshot published by serviceMonotonic() diff --git a/src/concurrency/OSThread.cpp b/src/concurrency/OSThread.cpp index ce9a256b73..defc040b9c 100644 --- a/src/concurrency/OSThread.cpp +++ b/src/concurrency/OSThread.cpp @@ -54,6 +54,7 @@ void OSThread::setIntervalFromNow(unsigned long _interval) interval = _interval; // Cache the next run based on the last_run + // unset-sentinel-ok: enabled is the armed flag, and tillRun() reads this as a wrap-safe delta _cached_next_run = millis() + interval; } diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 58dadcf530..c10e3a3e67 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1561,8 +1561,7 @@ int32_t GPS::runOnce() if (holdTime > GPS_FIX_HOLD_MAX_MS) holdTime = GPS_FIX_HOLD_MAX_MS; // Same clock the Throttle evaluation reads, and never the "no hold" sentinel. - const uint32_t holdEnds = Time::getMillis() + holdTime; - fixHoldEnds = holdEnds == 0 ? 1 : holdEnds; + fixHoldEnds = Time::timerEndsAtMillis(holdTime); LOG_DEBUG_GPS("Holding for %ums after lock", holdTime); } } diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 651deacbf9..6b2ed5b481 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -24,6 +24,7 @@ along with this program. If not, see . #include "NodeDB.h" #include "PowerMon.h" #include "Throttle.h" +#include "UptimeClock.h" #include "configuration.h" #include "meshUtils.h" #if HAS_SCREEN @@ -327,7 +328,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) NotificationRenderer::parseBannerMessageWithFonts(NotificationRenderer::alertBannerMessage); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination NotificationRenderer::alertBannerUntil = - (banner_overlay_options.durationMs == 0) ? 0 : millis() + banner_overlay_options.durationMs; + (banner_overlay_options.durationMs == 0) ? 0 : Time::timerEndsAtMillis(banner_overlay_options.durationMs); NotificationRenderer::optionsArrayPtr = banner_overlay_options.optionsArrayPtr; NotificationRenderer::optionsEnumPtr = banner_overlay_options.optionsEnumPtr; NotificationRenderer::alertBannerOptions = banner_overlay_options.optionsCount; @@ -351,7 +352,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct // Store the message and set the expiration timestamp strncpy(NotificationRenderer::alertBannerMessage, message, 255); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); NotificationRenderer::alertBannerCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; @@ -373,7 +374,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t // Store the message and set the expiration timestamp strncpy(NotificationRenderer::alertBannerMessage, message, 255); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); NotificationRenderer::alertBannerCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; @@ -402,7 +403,7 @@ void Screen::showAlphanumericPicker(const char *message, const char *initialText strncpy(NotificationRenderer::alertBannerMessage, message, 255); NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); NotificationRenderer::textInputCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; @@ -439,7 +440,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t // Store the message and set the expiration timestamp (use same pattern as other notifications) strncpy(NotificationRenderer::alertBannerMessage, header ? header : "Text Input", 255); NotificationRenderer::alertBannerMessage[255] = '\0'; - NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs); NotificationRenderer::pauseBanner = false; NotificationRenderer::current_notification_type = notificationTypeEnum::text_input; diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 8627dd2f89..b631fa8416 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -9,6 +9,7 @@ #include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "buzz.h" #include "graphics/Backlight.h" #include "graphics/Screen.h" @@ -478,7 +479,7 @@ void menuHandler::deviceRolePicker() config.device.role = meshtastic_Config_DeviceConfig_Role_TRACKER; } service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); }; screen->showOverlayBanner(bannerOptions); } @@ -1872,12 +1873,12 @@ void menuHandler::resetNodeDBMenu() LOG_INFO("Initiate node-db reset"); nodeDB->resetNodes(); disableBluetooth(); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == 2) { LOG_INFO("Initiate node-db reset, keep favorites"); nodeDB->resetNodes(1); disableBluetooth(); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == 0) { menuQueue = NodeBaseMenu; screen->runNow(); @@ -2072,12 +2073,12 @@ void menuHandler::GPSSmartPositionMenu() config.position.position_broadcast_smart_enabled = true; saveUIConfig(); service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == 2) { config.position.position_broadcast_smart_enabled = false; saveUIConfig(); service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; bannerOptions.InitialSelected = config.position.position_broadcast_smart_enabled ? 1 : 2; @@ -2132,7 +2133,7 @@ void menuHandler::GPSUpdateIntervalMenu() if (selected != 0) { saveUIConfig(); service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; @@ -2222,7 +2223,7 @@ void menuHandler::GPSPositionBroadcastMenu() if (selected != 0) { saveUIConfig(); service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; @@ -2363,7 +2364,7 @@ void menuHandler::switchToMUIMenu() config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR; config.bluetooth.enabled = false; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; screen->showOverlayBanner(bannerOptions); @@ -2384,7 +2385,7 @@ void menuHandler::rebootMenu() IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0)); nodeDB->saveToDisk(); messageStore.saveToFlash(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else { menuQueue = PowerMenu; screen->runNow(); @@ -2687,12 +2688,12 @@ void menuHandler::wifiToggleMenu() config.network.wifi_enabled = false; config.bluetooth.enabled = true; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else if (selected == Wifi_enable) { config.network.wifi_enabled = true; config.bluetooth.enabled = false; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } }; screen->showOverlayBanner(bannerOptions); diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 6f003d8b24..6de9c48a5c 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -9,6 +9,7 @@ #include "MessageStore.h" #include "Power.h" #include "Router.h" +#include "UptimeClock.h" #include "airtime.h" #include "gps/RTC.h" #include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" @@ -349,7 +350,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); service->reloadConfig(changes); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role) @@ -366,7 +367,7 @@ static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role) // Notify UI that changes are being applied InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) @@ -383,7 +384,7 @@ static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) // Notify UI that changes are being applied InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = false) @@ -393,7 +394,7 @@ static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = f if (reboot) { InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } } @@ -572,7 +573,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) case SHUTDOWN: LOG_INFO("Shutting down from menu"); - shutdownAtMsec = millis(); + shutdownAtMsec = Time::skipZero(Time::getMillis()); // Menu is then sent to background via onShutdown break; @@ -675,7 +676,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) config.bluetooth.enabled = true; nodeDB->saveToDisk(SEGMENT_CONFIG); InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + 2000; + rebootAtMsec = Time::timerEndsAtMillis(2000); break; // Power / Network (ESP32-only) @@ -684,7 +685,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) config.power.is_power_saving = !config.power.is_power_saving; nodeDB->saveToDisk(SEGMENT_CONFIG); InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; case TOGGLE_WIFI: @@ -697,7 +698,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) nodeDB->saveToDisk(SEGMENT_CONFIG); InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; #endif // ADC Calibration @@ -775,7 +776,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) nodeDB->saveToDisk(SEGMENT_CONFIG); InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; case TOGGLE_BLUETOOTH_PAIR_MODE: @@ -1123,13 +1124,13 @@ void InkHUD::MenuApplet::execute(MenuItem item) case RESET_NODEDB_ALL: InkHUD::getInstance()->notifyApplyingChanges(); nodeDB->resetNodes(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; case RESET_NODEDB_KEEP_FAVORITES: InkHUD::getInstance()->notifyApplyingChanges(); nodeDB->resetNodes(1); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); break; case WIPE_MESSAGES_ALL: diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index 505cb37384..54d061fa90 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -4,6 +4,7 @@ #include "MessageStore.h" #include "PowerFSM.h" +#include "UptimeClock.h" #include "WaypointStore.h" #include "buzz.h" #include "gps/RTC.h" @@ -365,7 +366,7 @@ void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress) // 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) { + if ((int32_t)(Time::getMillis() - suppressTouchTapUntilMs) < 0) { noteInkHUDUserInteraction(); return; } @@ -401,7 +402,7 @@ void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress) // 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; + suppressTouchTapUntilMs = Time::timerEndsAtMillis(TOUCH_MENU_OPEN_TAP_SUPPRESS_MS); } } else onButtonShort(); diff --git a/src/input/HapticFeedback.cpp b/src/input/HapticFeedback.cpp index fcd215be06..d345c9cd4d 100644 --- a/src/input/HapticFeedback.cpp +++ b/src/input/HapticFeedback.cpp @@ -4,6 +4,8 @@ #include +#include "UptimeClock.h" + #ifdef HAPTIC_FEEDBACK_ACTIVE_LOW #define HAPTIC_FEEDBACK_ON_STATE LOW #define HAPTIC_FEEDBACK_OFF_STATE HIGH @@ -34,17 +36,13 @@ void HapticFeedback::motorWrite(bool on) void HapticFeedback::pulse(uint16_t durationMs) { motorWrite(true); - pulseOffAt = millis() + durationMs; - if (pulseOffAt == 0) // 0 is the "no pulse" sentinel - pulseOffAt = 1; + pulseOffAt = Time::timerEndsAtMillis(durationMs); scheduleNext(); } void HapticFeedback::armDelayedPulse(uint16_t delayMs, uint16_t durationMs) { - delayedPulseAt = millis() + delayMs; - if (delayedPulseAt == 0) - delayedPulseAt = 1; + delayedPulseAt = Time::timerEndsAtMillis(delayMs); delayedPulseDuration = durationMs; scheduleNext(); } @@ -56,7 +54,7 @@ void HapticFeedback::cancelDelayedPulse() void HapticFeedback::scheduleNext() { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); uint32_t next = 0; if (pulseOffAt != 0) next = pulseOffAt; @@ -70,7 +68,7 @@ void HapticFeedback::scheduleNext() int32_t HapticFeedback::runOnce() { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); if (pulseOffAt != 0 && (int32_t)(now - pulseOffAt) >= 0) { motorWrite(false); diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp index f6848a5908..8951a00b1c 100644 --- a/src/input/LinuxJoystick.cpp +++ b/src/input/LinuxJoystick.cpp @@ -138,6 +138,7 @@ int32_t LinuxJoystick::runOnce() heldX = zone; if (zone != 0) { emitEvent((zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT); + // unset-sentinel-ok: heldX carries the armed state, so 0 is a legal deadline nextRepeatX = millis() + JOY_REPEAT_DELAY_MS; } } @@ -147,6 +148,7 @@ int32_t LinuxJoystick::runOnce() heldY = zone; if (zone != 0) { emitEvent((zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN); + // unset-sentinel-ok: heldY carries the armed state, so 0 is a legal deadline nextRepeatY = millis() + JOY_REPEAT_DELAY_MS; } } diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index 77fa2ff88b..2be2495e12 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -260,7 +260,7 @@ void TrackballInterruptBase::intDownHandler() { if (TB_THRESHOLD || !Throttle::isWithinTimespanMs(lastInterruptTime, 10)) this->action = TB_ACTION_DOWN; - lastInterruptTime = millis(); + lastInterruptTime = Time::skipZero(Time::getMillis()); #if TB_THRESHOLD down_counter++; @@ -271,7 +271,7 @@ void TrackballInterruptBase::intUpHandler() { if (TB_THRESHOLD || !Throttle::isWithinTimespanMs(lastInterruptTime, 10)) this->action = TB_ACTION_UP; - lastInterruptTime = millis(); + lastInterruptTime = Time::skipZero(Time::getMillis()); #if TB_THRESHOLD up_counter++; @@ -282,7 +282,7 @@ void TrackballInterruptBase::intLeftHandler() { if (TB_THRESHOLD || !Throttle::isWithinTimespanMs(lastInterruptTime, 10)) this->action = TB_ACTION_LEFT; - lastInterruptTime = millis(); + lastInterruptTime = Time::skipZero(Time::getMillis()); #if TB_THRESHOLD left_counter++; #endif @@ -292,7 +292,7 @@ void TrackballInterruptBase::intRightHandler() { if (TB_THRESHOLD || !Throttle::isWithinTimespanMs(lastInterruptTime, 10)) this->action = TB_ACTION_RIGHT; - lastInterruptTime = millis(); + lastInterruptTime = Time::skipZero(Time::getMillis()); #if TB_THRESHOLD right_counter++; #endif diff --git a/src/main.cpp b/src/main.cpp index bce0d6894c..7e9bd39d40 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1405,7 +1405,7 @@ void loop() if (nodeDB->disableLockdownToPlaintext()) { LOG_INFO("Lockdown: disabled, reboot to normal mode"); PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_DISABLED, "", 0, 0, 0); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else { // Revert failed mid-way (a file couldn't be decrypted/rewritten). // The DEK file is still present (it's deleted last), so the device @@ -1459,7 +1459,7 @@ void loop() EncryptedStorage::lockNow(); PhoneAPI::revokeAllAuth(); PhoneAPI::broadcastLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "session_budget_exhausted", 0, 0, 0); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } else { uint8_t newBoots = EncryptedStorage::consumeSessionBoot(); LOG_WARN("Lockdown: session expired, next budget slot (boots=%u left)", newBoots); @@ -1551,7 +1551,7 @@ void loop() if (screen) { screen->showSimpleBanner("Rebooting..."); } - rebootAtMsec = millis() + 25; + rebootAtMsec = Time::timerEndsAtMillis(25); } } #if HAS_TFT && HAS_SCREEN diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index c86f35ec76..f6c8857f46 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -201,7 +201,7 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast p->relay_node, wasAlreadyRelayer, weWereSoleRelayer); origTx->next_hop = p->relay_node; } - noteRouteLearned(p->from, p->relay_node, millis()); // M3: anchor freshness (hot or overflow route) + noteRouteLearned(p->from, p->relay_node, Time::getMillis()); // M3: anchor freshness (hot or overflow route) #if HAS_TRAFFIC_MANAGEMENT // Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it // survives even when the source isn't (or is no longer) in the hot NodeDB. @@ -313,7 +313,7 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) // a health record that still matches the stored byte; a next_hop set by another path (e.g. // TraceRouteModule) with no matching record is left authoritative. const RouteHealth *h = findRouteHealth(to); - if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) { + if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, Time::getMillis())) { LOG_INFO("Next hop 0x%x for 0x%08x stale (age/fails); flood and clear", node->next_hop, to); node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route clearRouteHealth(to); // clear RAM health @@ -345,7 +345,7 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) uint8_t hint = trafficManagementModule->getNextHopHint(to); if (hint && hint != relay_node) { const RouteHealth *h = findRouteHealth(to); - if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) { + if (h && h->lastNextHop == hint && isRouteStale(*h, Time::getMillis())) { LOG_INFO("TMM next hop 0x%x for 0x%08x stale (age/fails); flood and clear", hint, to); trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0) clearRouteHealth(to); // clear RAM health @@ -617,7 +617,7 @@ void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now h->lastNextHop = nextHop; h->consecutiveFailures = 0; } - h->learnedAtMsec = now ? now : 1; + h->learnedAtMsec = Time::skipZero(now); } void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now) @@ -626,7 +626,7 @@ void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now) if (!h) return; // only routes we actually learned have health to refresh h->consecutiveFailures = 0; - h->learnedAtMsec = now ? now : 1; + h->learnedAtMsec = Time::skipZero(now); } void NextHopRouter::noteRouteFailure(NodeNum dest) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 3f3c5cfef6..ba7eefd4c8 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -4027,7 +4027,7 @@ void NodeDB::pause_sort(bool paused) void NodeDB::sortMeshDB() { if (!sortingIsPaused && (lastSort == 0 || !Throttle::isWithinTimespanMs(lastSort, 1000 * 5))) { - lastSort = millis(); + lastSort = Time::skipZero(Time::getMillis()); bool changed = true; while (changed) { // dumb reverse bubble sort, but probably not bad for what we're doing changed = false; diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index 3efeee2a75..8e22f5bb8f 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -8,6 +8,7 @@ #include "platform/portduino/PortduinoGlue.h" #endif #include "Throttle.h" +#include "UptimeClock.h" #define RECENT_WARN_AGE (10 * 60 * 1000L) // Warn if the packet that gets removed was more recent than 10 min @@ -93,9 +94,9 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd r.relayed_by[0] = p->relay_node; } - r.rxTimeMsec = millis(); // - if (r.rxTimeMsec == 0) // =0 every 49.7 days? 0 is special - r.rxTimeMsec = 1; + // TODO(elapsed-stamp): 0 means "empty slot" here and insert() drops a record stamped 0, so the + // dodge is important; a same-instant `now - rxTimeMsec` read still underflows to a huge age. + r.rxTimeMsec = Time::skipZero(Time::getMillis()); #if VERBOSE_PACKET_HISTORY LOG_DEBUG( diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 50daa635d4..17f063a64f 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -22,6 +22,7 @@ #include "Router.h" #include "SPILock.h" #include "TypeConversions.h" +#include "UptimeClock.h" #include "concurrency/LockGuard.h" #include "main.h" #include "modules/NodeInfoModule.h" @@ -2102,7 +2103,7 @@ bool PhoneAPI::handleLockdownAuthInline(const meshtastic_LockdownAuth &la) revokeAllAuth(); queueLockdownStatus(meshtastic_LockdownStatus_State_LOCKED, "", 0, 0, 0); zeroPassphrase(); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); return true; } diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index ee76fa19bb..aafc848e36 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -14,6 +14,7 @@ #include "SX1262Interface.h" #include "SX1268Interface.h" #include "SX1280Interface.h" +#include "UptimeClock.h" #include "configuration.h" #include "detect/LoRaRadioType.h" #include "main.h" @@ -643,7 +644,7 @@ std::unique_ptr initLoRa() if (screen) { screen->showSimpleBanner("Rebooting..."); } - rebootAtMsec = millis() + 5000; + rebootAtMsec = Time::timerEndsAtMillis(5000); } } return rIf; diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 4a5fb86a25..ca534993b5 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -109,7 +109,7 @@ bool RadioLibInterface::canSendImmediately() LOG_ERROR("Hardware Failure! busyTx >60s"); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_TRANSMIT_FAILED); // reboot in 5 seconds when this condition occurs. - rebootAtMsec = lastTxStart + 65000; + rebootAtMsec = Time::skipZero(lastTxStart + 65000); } if (busyRx) { LOG_WARN("Can not send yet, busyRx"); @@ -125,7 +125,7 @@ bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHead // Handle false detections if (detected) { if (!activeReceiveStart) { - activeReceiveStart = millis(); + activeReceiveStart = Time::skipZero(Time::getMillis()); } else if (!Throttle::isWithinTimespanMs(activeReceiveStart, 2 * preambleTimeMsec)) { if (!(irq & syncWordHeaderValidFlag)) { // The HEADER_VALID flag should be set by now if it was really a packet, so ignore PREAMBLE_DETECTED flag @@ -269,11 +269,10 @@ void RadioLibInterface::updateNoiseFloor() return; } - uint32_t now = millis(); - if (now - lastNoiseFloorUpdate < NOISE_FLOOR_UPDATE_INTERVAL_MS) { + if (Throttle::isWithinTimespanMs(lastNoiseFloorUpdate, NOISE_FLOOR_UPDATE_INTERVAL_MS)) { return; } - lastNoiseFloorUpdate = now; + lastNoiseFloorUpdate = Time::getMillis(); int16_t rssi = getCurrentRSSI(); if (rssi == NOISE_FLOOR_INVALID || rssi >= 0 || rssi < NOISE_FLOOR_VALID_MIN) { @@ -434,7 +433,7 @@ void RadioLibInterface::onNotify(uint32_t notification) meshtastic_MeshPacket *txp = txQueue.getFront(); assert(txp); const uint32_t now = Time::getMillis(); - // Not `long remaining = tx_after - millis()`: that uint32_t subtraction widens to + // Not `long remaining = tx_after - Time::getMillis()`: that uint32_t subtraction widens to // ~4.29e9 where long is 64-bit (portduino), rescheduling a due packet ~49.7 days out. if (txp->tx_after && !Throttle::deadlinePassedAt(now, txp->tx_after)) { // There's still some delay pending on this packet, so resume waiting for it to elapse @@ -488,8 +487,17 @@ void RadioLibInterface::setTransmitDelay() if (p->tx_after) { unsigned long add_delay = p->rx_rssi ? getTxDelayMsecWeighted(p) : getTxDelayMsec(); - unsigned long now = millis(); - p->tx_after = min(max(p->tx_after + add_delay, now + add_delay), now + 2 * getTxDelayMsecWeightedWorst(p->rx_snr)); + unsigned long now = Time::getMillis(); + // skipZero, not timerEndsAtMillis: this is a clamp of three candidates rather than a plain + // now + delay, and `if (p->tx_after)` above is the read that takes 0 as "no delay wanted" - + // so a recomputation landing on 0 drops the CSMA backoff and the packet goes out at once. + // + // Narrow to uint32_t BEFORE skipZero, not after: add_delay is unsigned long, 64-bit on the + // portduino host, so the clamp can exceed UINT32_MAX there. skipZero on the wide value would + // pass 0x100000000 through as non-zero and the store to this uint32_t field would truncate it + // back to the 0 being avoided. + p->tx_after = Time::skipZero( + (uint32_t)min(max(p->tx_after + add_delay, now + add_delay), now + 2 * getTxDelayMsecWeightedWorst(p->rx_snr))); notifyLater(p->tx_after - now, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); } else if (p->rx_snr == 0 && p->rx_rssi == 0) { /* We assume if rx_snr = 0 and rx_rssi = 0, the packet was generated locally. @@ -530,7 +538,7 @@ void RadioLibInterface::clampToLateRebroadcastWindow(NodeNum from, PacketId id) // Look for non-late packets only, so we don't do this twice! meshtastic_MeshPacket *p = txQueue.remove(from, id, true, false); if (p) { - p->tx_after = millis() + getTxDelayMsecWeightedWorst(p->rx_snr); + p->tx_after = Time::timerEndsAtMillis(getTxDelayMsecWeightedWorst(p->rx_snr)); bool dropped = false; if (txQueue.enqueue(p, &dropped)) { LOG_TRACE("Move queued packet to late rebroadcast window %ums from now", (uint32_t)(p->tx_after - millis())); @@ -750,7 +758,7 @@ bool RadioLibInterface::maybeRecoverChipStateLoss() // One attempt per window: the transient resets this recovers from need a single re-init, and a // chip that stays dead must not stall the TX/RX paths with a begin() attempt on every call if (lastChipRecoveryMs && Throttle::isWithinTimespanMs(lastChipRecoveryMs, 30 * 1000UL)) { - LOG_DEBUG("Radio recovery suppressed, %us since the last attempt", (millis() - lastChipRecoveryMs) / 1000); + LOG_DEBUG("Radio recovery suppressed, %us since the last attempt", (Time::getMillis() - lastChipRecoveryMs) / 1000); return false; } @@ -761,11 +769,11 @@ bool RadioLibInterface::maybeRecoverChipStateLoss() // Attempts are a throttle window apart, so this is minutes of a provably deaf chip. begin() alone // clearly isn't reviving it; reboot to re-run init(), which redoes the power-on sequence it skips. LOG_ERROR("Radio still deaf after %u re-inits, rebooting", chipRecoveryFailures); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } chipRecoveryFailures++; - lastChipRecoveryMs = millis(); + lastChipRecoveryMs = Time::skipZero(Time::getMillis()); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); LOG_ERROR("Radio chip state lost mid-operation, re-init"); bool recovered = recoverChipStateLoss(); @@ -830,7 +838,8 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) // Must be done AFTER, starting transmit, because startTransmit clears (possibly stale) interrupt pending register // bits enableInterrupt(isrTxLevel0); - lastTxStart = millis(); + // unset-sentinel-ok: busyTx/sendingPacket is the armed flag, so 0 is a legal stamp + lastTxStart = Time::getMillis(); printPacket("Started Tx", txp); #ifdef LED_LORA digitalWrite(LED_LORA, LED_STATE_ON); diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 8be48f105e..a3c86cd0ef 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -2,6 +2,7 @@ #include "Default.h" #include "MeshTypes.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "configuration.h" #include "memGet.h" #include "mesh-pb-constants.h" @@ -180,7 +181,7 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas // M3: an end-to-end ACK proves the directed route to the ACK's sender currently works, // so clear its failure count and refresh freshness (keeps a good route pinned). if (!isBroadcast(getFrom(p))) - noteRouteSuccess(getFrom(p), millis()); + noteRouteSuccess(getFrom(p), Time::getMillis()); } else { stopRetransmission(p->to, nakId); } diff --git a/src/mesh/Throttle.cpp b/src/mesh/Throttle.cpp index 606ba737e9..0c2e316da6 100644 --- a/src/mesh/Throttle.cpp +++ b/src/mesh/Throttle.cpp @@ -10,6 +10,8 @@ /// @return true if the function was executed, false if it was deferred bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*throttleFunc)(void), void (*onDefer)(void)) { + // TODO(elapsed-stamp): 0 doubles as "never run" here, so neither store is safe on the wrap tick: + // skipZero()'s 1 underflows a same-instant `now - *lastExecutionMs`, and 0 re-takes this branch. if (*lastExecutionMs == 0) { *lastExecutionMs = Time::getMillis(); throttleFunc(); diff --git a/src/mesh/Throttle.h b/src/mesh/Throttle.h index 7df932dfab..bd16ab1ccf 100644 --- a/src/mesh/Throttle.h +++ b/src/mesh/Throttle.h @@ -35,10 +35,12 @@ class Throttle /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the four /// meanings they give the sentinel today: /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec, GPS.cpp fixHoldEnds, AdminModule.cpp - /// enterDfuAtMsec - the last two remap a 0 result to 1 at the arm site by hand. + /// enterDfuAtMsec and the other timerEndsAtMillis()/skipZero() arm sites dodge + /// it; RadioLibInterface::setTransmitDelay()'s tx_after recompute still cannot. /// 0 = forever - NotificationRenderer.cpp alertBannerUntil. Every read spells its own `> 0` /// guard, so this third state wants naming rather than repeating. - /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. + /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. A computed renewal now dodges 0, + /// so only a deliberate write still means "due now". /// UINT32_MAX - ExternalNotificationModule.cpp nagCycleCutoff, whose armed() also lives in a /// second variable (isNagging) and whose arm site can land on the sentinel. static bool deadlinePassed(uint32_t deadlineMs); diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index bf85eef925..ce26923d6f 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -1,5 +1,6 @@ #include "mesh/eth/ethClient.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "concurrency/Periodic.h" #include "configuration.h" #include "gps/RTC.h" @@ -211,10 +212,10 @@ static int32_t reconnectETH() perhapsSetRTC(RTCQualityNTP, &tv); - ntp_renew = millis() + 43200 * 1000; // success, refresh every 12 hours + ntp_renew = Time::timerEndsAtMillis(43200 * 1000); // success, refresh every 12 hours } else { LOG_ERROR("NTP Update failed"); - ntp_renew = millis() + 300 * 1000; // failure, retry every 5 minutes + ntp_renew = Time::timerEndsAtMillis(300 * 1000); // failure, retry every 5 minutes } timeClient.end(); // W5100S: release UDP socket for other services } diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 4f22ef6738..6dfed40c3d 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -8,6 +8,7 @@ #include "PositionPrecision.h" #include "PowerFSM.h" #include "SPILock.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include "input/InputBroker.h" #include "meshUtils.h" @@ -427,13 +428,13 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #endif int s = 1; // Reboot in 1 second, hard coded LOG_INFO("Reboot in %d seconds", s); - rebootAtMsec = (s < 0) ? 0 : (millis() + s * 1000); + rebootAtMsec = (s < 0) ? 0 : Time::timerEndsAtMillis(s * 1000); break; } case meshtastic_AdminMessage_shutdown_seconds_tag: { int32_t s = r->shutdown_seconds; LOG_INFO("Shutdown in %d seconds", s); - shutdownAtMsec = (s < 0) ? 0 : (millis() + s * 1000); + shutdownAtMsec = (s < 0) ? 0 : Time::timerEndsAtMillis(s * 1000); break; } case meshtastic_AdminMessage_get_device_metadata_request_tag: { @@ -628,10 +629,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // Delay the jump so this ACK reaches the client and it releases the port before the // STM32WL ROM bootloader takes the UART and autobauds off the next byte it sees. LOG_INFO("Entering DFU in %us - disconnect now", (STM32_DFU_DETACH_DELAY_MS + 999) / 1000); - enterDfuAtMsec = millis() + STM32_DFU_DETACH_DELAY_MS; - // Guard against enterDfuAtMsec rolling over to 0, the sentinel powerCommandsCheck() reads as unarmed. - if (enterDfuAtMsec == 0) - enterDfuAtMsec = 1; + // timerEndsAtMillis() dodges 0, the sentinel powerCommandsCheck() reads as unarmed. + enterDfuAtMsec = Time::timerEndsAtMillis(STM32_DFU_DETACH_DELAY_MS); #elif defined(ARCH_NRF52) || defined(ARCH_RP2040) enterDfuMode(); #endif @@ -1883,7 +1882,7 @@ void AdminModule::reboot(int32_t seconds) LOG_INFO("Reboot in %d seconds", seconds); if (screen) screen->showSimpleBanner("Rebooting...", 0); // stays on screen - rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000); + rebootAtMsec = (seconds < 0) ? 0 : Time::timerEndsAtMillis(seconds * 1000); } // Without this, a commit that never arrives leaves the transaction open forever and every later diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp index a05b09b0fa..c73293ff46 100644 --- a/src/modules/NeighborInfoModule.cpp +++ b/src/modules/NeighborInfoModule.cpp @@ -2,6 +2,7 @@ #include "Default.h" #include "MeshService.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include @@ -151,7 +152,7 @@ meshtastic_MeshPacket *NeighborInfoModule::allocReply() meshtastic_MeshPacket *reply = allocDataProtobuf(neighborInfo); if (reply) { - lastSentReply = millis(); // Track when we sent this reply + lastSentReply = Time::skipZero(Time::getMillis()); // Track when we sent this reply } return reply; } diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 55167d472c..213237341a 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -292,7 +292,7 @@ meshtastic_MeshPacket *PositionModule::allocReply() meshtastic_MeshPacket *reply = allocPositionPacket(precision); if (reply) { - lastSentReply = Time::getMillis(); // Track when we sent this reply + lastSentReply = Time::skipZero(Time::getMillis()); // Track when we sent this reply } return reply; } diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index c1b2d9d6b7..3ca69169b0 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -663,6 +663,7 @@ void SerialModule::processWXSerial() if (dirAvg < 0) { dirAvg += 360.0; } + // unset-sentinel-ok: gotwind carries the armed state; no read tests this for 0 lastAveraged = millis(); // make a telemetry packet with the data diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index ec0011f4cf..f8e14f87ec 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -45,6 +45,7 @@ int32_t StoreForwardModule::runOnce() } } else if (this->heartbeat && (!Throttle::isWithinTimespanMs(lastHeartbeat, heartbeatInterval * 1000)) && airTime->isTxAllowedChannelUtil(true)) { + // unset-sentinel-ok: the heartbeat bool gates it and the only read is elapsed math lastHeartbeat = millis(); LOG_INFO("Send heartbeat"); meshtastic_StoreAndForward sf = meshtastic_StoreAndForward_init_zero; @@ -535,6 +536,7 @@ bool StoreForwardModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, if (p->which_variant == meshtastic_StoreAndForward_heartbeat_tag) { heartbeatInterval = p->variant.heartbeat.period; } + // unset-sentinel-ok: the heartbeat bool gates it and the only read is elapsed math lastHeartbeat = millis(); LOG_INFO("StoreAndForward Heartbeat received"); } diff --git a/src/modules/SystemCommandsModule.cpp b/src/modules/SystemCommandsModule.cpp index 1b31942903..eca8e9e500 100644 --- a/src/modules/SystemCommandsModule.cpp +++ b/src/modules/SystemCommandsModule.cpp @@ -12,6 +12,7 @@ #include "MeshService.h" #include "Module.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "main.h" #include "modules/AdminModule.h" #include "modules/ExternalNotificationModule.h" @@ -59,10 +60,10 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) if (!config.bluetooth.enabled) { disableBluetooth(); IF_SCREEN(screen->showSimpleBanner("Bluetooth OFF\nRebooting", 3000)); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 2000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 2000); } else { IF_SCREEN(screen->showSimpleBanner("Bluetooth ON\nRebooting", 3000)); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } #else if (!config.bluetooth.enabled) { @@ -70,7 +71,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) IF_SCREEN(screen->showSimpleBanner("Bluetooth OFF", 3000)); } else { IF_SCREEN(screen->showSimpleBanner("Bluetooth ON\nRebooting", 3000)); - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); } #endif return 0; @@ -80,7 +81,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) #if HAS_SCREEN messageStore.saveToFlash(); #endif - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); // runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; return true; } @@ -125,7 +126,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) return true; // Power control case INPUT_BROKER_SHUTDOWN: - shutdownAtMsec = millis(); + shutdownAtMsec = Time::skipZero(Time::getMillis()); return true; // factory reset case INPUT_BROKER_FACTORY_RST: @@ -136,7 +137,7 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) LOG_INFO("Reboot in %d seconds", DEFAULT_REBOOT_SECONDS); if (screen) screen->showSimpleBanner("Rebooting...", 0); // stays on screen - rebootAtMsec = (DEFAULT_REBOOT_SECONDS < 0) ? 0 : (millis() + DEFAULT_REBOOT_SECONDS * 1000); + rebootAtMsec = (DEFAULT_REBOOT_SECONDS < 0) ? 0 : Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000); return true; default: diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.cpp b/src/modules/Telemetry/Sensor/BME680Sensor.cpp index 9162a93212..556feced99 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.cpp +++ b/src/modules/Telemetry/Sensor/BME680Sensor.cpp @@ -86,6 +86,7 @@ void BME680Sensor::captureSample() lastPressureHPa = bme680->pressure / 100.0F; lastGasOhms = (float)bme680->gas_resistance; haveSample = true; + // unset-sentinel-ok: haveSample carries the armed state, so 0 is a legal stamp lastSampleMs = Time::getMillis(); uint16_t iaq; diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index e6331ea857..575fff2bcb 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -2,6 +2,8 @@ #include "FSCommon.h" #include "SPILock.h" #include "SafeFile.h" +#include "Throttle.h" +#include "UptimeClock.h" #include "concurrency/LockGuard.h" #include "graphics/draw/CompassRenderer.h" @@ -149,8 +151,7 @@ void MotionSensor::beginCalibrationDisplay(bool &showingScreen) void MotionSensor::finishCalibrationIfExpired(bool &showingScreen, const char *filePath, float highestX, float lowestX, float highestY, float lowestY, float highestZ, float lowestZ) { - const uint32_t now = millis(); - if ((int32_t)(now - endCalibrationAt) < 0) + if (!Throttle::deadlinePassed(endCalibrationAt)) return; doCalibration = false; @@ -170,7 +171,7 @@ void MotionSensor::startCalibrationWindow(uint16_t forSeconds) { doCalibration = true; const uint32_t calibrateFor = static_cast(forSeconds) * 1000U; - endCalibrationAt = millis() + calibrateFor; + endCalibrationAt = Time::timerEndsAtMillis(calibrateFor); #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN if (screen) screen->setEndCalibration(endCalibrationAt); @@ -288,7 +289,7 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState const bool compactLayout = (height <= 80); const int16_t margin = 4; - const uint32_t now = millis(); + const uint32_t now = Time::getMillis(); const uint32_t endCalibrationAt = screen->getEndCalibration(); uint32_t timeRemaining = 0; // Signed delta, as in finishCalibrationIfExpired(): this needs the remaining magnitude, not diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index 865e1c3633..784d29e283 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -297,6 +297,7 @@ void preFSBegin() if (!(NRF_POWER->RESETREAS == 0 && NRF_POWER->GPREGRET == NRF52_MAGIC_LFS_IS_CORRUPT)) return; NRF_POWER->GPREGRET = 0; + // unset-sentinel-ok: formatted_this_boot carries the armed state, so 0 is a legal stamp last_format_ms = Time::getMillis(); formatted_this_boot = true; InternalFS.format(); diff --git a/src/platform/portduino/windows/WindowsService.cpp b/src/platform/portduino/windows/WindowsService.cpp index 4532ca7176..c378ca75f3 100644 --- a/src/platform/portduino/windows/WindowsService.cpp +++ b/src/platform/portduino/windows/WindowsService.cpp @@ -2,6 +2,7 @@ #if defined(ARCH_PORTDUINO) && defined(_WIN32) +#include "UptimeClock.h" #include "configuration.h" #include "main.h" @@ -65,7 +66,7 @@ static DWORD WINAPI controlHandler(DWORD control, DWORD, LPVOID, LPVOID) reportStatus(SERVICE_STOP_PENDING, PENDING_WAIT_HINT_MS); // Teardown belongs on the main thread: powerCommandsCheck() saves and exits, and the // atexit hook reports SERVICE_STOPPED on the way out. - shutdownAtMsec = millis(); + shutdownAtMsec = Time::skipZero(Time::getMillis()); return NO_ERROR; case SERVICE_CONTROL_INTERROGATE: return NO_ERROR; diff --git a/src/power/SGM41562.cpp b/src/power/SGM41562.cpp index 1fce21dbb0..7835d19914 100644 --- a/src/power/SGM41562.cpp +++ b/src/power/SGM41562.cpp @@ -5,6 +5,7 @@ #include #include "Throttle.h" +#include "UptimeClock.h" SGM41562 *sgm41562 = nullptr; @@ -165,10 +166,10 @@ bool SGM41562::hasExtendedRegisterMap() const bool SGM41562::refresh() { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); if (lastRefreshMs_ != 0 && Throttle::isWithinTimespanMs(lastRefreshMs_, 250)) - return true; // cached - lastRefreshMs_ = now == 0 ? 1 : now; + return true; // cached + lastRefreshMs_ = Time::skipZero(now); // dodge the 0-sentinel case uint8_t status, fault; if (!readReg(REG_SYSTEM_STATUS, status)) diff --git a/src/security/EncryptedStorage.cpp b/src/security/EncryptedStorage.cpp index 9572f7ea07..9d697ec5c1 100644 --- a/src/security/EncryptedStorage.cpp +++ b/src/security/EncryptedStorage.cpp @@ -8,6 +8,7 @@ #include "SPILock.h" #include "SafeFile.h" #include "SecureZero.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include @@ -1273,9 +1274,9 @@ bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8 writeBackoff(reservedAttempts, 0, now); } auto onFailure = [reservedAttempts]() { - s_lastFailMillis = millis(); - if (s_lastFailMillis == 0) - s_lastFailMillis = 1; // sentinel: never 0 after a real fail + // TODO(elapsed-stamp): 0 doubles as "no failure yet" and the backoff read above is still on + // millis(); wants the sentinel's necessity and the clock split settled together, not a dodge. + s_lastFailMillis = Time::skipZero(Time::getMillis()); s_backoffSecondsRemaining = backoffDelay(reservedAttempts); LOG_WARN("EncryptedStorage: Wrong passphrase (attempt %u, next in ~%us)", (unsigned)reservedAttempts, s_backoffSecondsRemaining); diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index c5915bfc99..ecfe8ac5a8 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -685,6 +685,25 @@ void test_health_lru_eviction_bounds_table(void) TEST_ASSERT_NOT_NULL(shim->findRouteHealth(0x2000)); // newest present } +// A stamp of 0 is the empty-slot marker, so an arm landing on the wrap tick must be normalized: +// getOrAllocRouteHealth() would otherwise read the slot as ever-older and evict it first. +void test_health_learn_never_stores_zero_sentinel(void) +{ + shim->noteRouteLearned(DEST, 0xAB, 0); // learned exactly on the wrap tick + RouteHealth *h = shim->findRouteHealth(DEST); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_NOT_EQUAL_UINT32(0u, h->learnedAtMsec); +} + +void test_health_success_never_stores_zero_sentinel(void) +{ + shim->noteRouteLearned(DEST, 0xAB, 1000); + shim->noteRouteSuccess(DEST, 0); // refreshed exactly on the wrap tick + RouteHealth *h = shim->findRouteHealth(DEST); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_NOT_EQUAL_UINT32(0u, h->learnedAtMsec); +} + // =========================================================================== // Group 4 - shouldDecrementHopLimit favorite-router resolution (M2, site 4) // =========================================================================== @@ -1106,6 +1125,8 @@ void setup() RUN_TEST(test_health_failure_without_record_is_noop); RUN_TEST(test_health_clear); RUN_TEST(test_health_lru_eviction_bounds_table); + RUN_TEST(test_health_learn_never_stores_zero_sentinel); + RUN_TEST(test_health_success_never_stores_zero_sentinel); printf("\n=== shouldDecrementHopLimit (M2 site 4) ===\n"); RUN_TEST(test_hoplimit_preserve_unique_favorite_router); diff --git a/test/test_uptime_clock/test_main.cpp b/test/test_uptime_clock/test_main.cpp index f950102c24..daf4cbed31 100644 --- a/test/test_uptime_clock/test_main.cpp +++ b/test/test_uptime_clock/test_main.cpp @@ -1,8 +1,9 @@ // Unit tests for src/UptimeClock.{h,cpp} - the monotonic uptime seam. -// Covers: test-clock injection, stepping the injected clock, the real-clock fallback, and the -// single-writer wrap carry (readers derive, serviceMonotonic() publishes). getMillis() itself is a -// plain 32-bit read with no wrap handling of its own - its consumers' wrap arithmetic is tested in -// test_throttle/. +// Covers: test-clock injection, stepping the injected clock, the real-clock fallback, the +// single-writer wrap carry (readers derive, serviceMonotonic() publishes), and the 0-sentinel dodge +// helpers (skipZero/timerEndsAtMillis). getMillis() itself is a plain 32-bit read with no wrap +// handling of its own beyond those helpers - deadline/throttle wrap arithmetic built on top of it +// is tested in test_throttle/. #include "Arduino.h" #include "TestUtil.h" #include "UptimeClock.h" @@ -70,6 +71,39 @@ void test_advanceTestMillis_wraps_like_millis() TEST_ASSERT_EQUAL_UINT32(0x00000100u, Time::getMillis()); } +// --- skipZero() / timerEndsAtMillis(): dodging the wrap tick that lands on 0 --- + +void test_skipZero_maps_zero_to_one() +{ + TEST_ASSERT_EQUAL_UINT32(1u, Time::skipZero(0)); +} + +void test_skipZero_leaves_nonzero_values_alone() +{ + TEST_ASSERT_EQUAL_UINT32(5u, Time::skipZero(5)); + TEST_ASSERT_EQUAL_UINT32(0xFFFFFFFFu, Time::skipZero(0xFFFFFFFFu)); +} + +void test_timerEndsAtMillis_is_an_ordinary_sum_away_from_the_wrap() +{ + Time::setTestMillis(1000); + TEST_ASSERT_EQUAL_UINT32(1500u, Time::timerEndsAtMillis(500)); +} + +// The sum is what has to dodge 0, not the read: a non-zero getMillis() plus a delay can still land +// exactly on the wrap, which is why this is not skipZero(getMillis()) + delayMs. +void test_timerEndsAtMillis_dodges_a_sum_that_wraps_to_zero() +{ + Time::setTestMillis(0xFFFFFF00u); + TEST_ASSERT_EQUAL_UINT32(1u, Time::timerEndsAtMillis(0x100u)); // 0xFFFFFF00 + 0x100 wraps to 0 +} + +void test_timerEndsAtMillis_dodges_the_wrap_tick_itself() +{ + Time::setTestMillis(0); + TEST_ASSERT_EQUAL_UINT32(1u, Time::timerEndsAtMillis(0)); // getMillis()==0, delayMs==0: sum is 0 +} + // --- getMillisMonotonic(): the published wrap carry --- void test_monotonic_matches_millis_before_any_wrap() @@ -337,6 +371,11 @@ void setup() RUN_TEST(test_getMillis_returns_injected_value); RUN_TEST(test_advanceTestMillis_steps_clock); RUN_TEST(test_advanceTestMillis_wraps_like_millis); + RUN_TEST(test_skipZero_maps_zero_to_one); + RUN_TEST(test_skipZero_leaves_nonzero_values_alone); + RUN_TEST(test_timerEndsAtMillis_is_an_ordinary_sum_away_from_the_wrap); + RUN_TEST(test_timerEndsAtMillis_dodges_a_sum_that_wraps_to_zero); + RUN_TEST(test_timerEndsAtMillis_dodges_the_wrap_tick_itself); RUN_TEST(test_monotonic_matches_millis_before_any_wrap); RUN_TEST(test_monotonic_counts_a_wrap); RUN_TEST(test_monotonic_reader_crosses_the_wrap_without_a_publish); From d05fbec64cdd7c80dcc34f045b0617fbf4c0d803 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Mon, 14 Sep 2026 06:32:35 +0000 Subject: [PATCH 129/143] Add AEAD (AES-CCM) authenticated encryption for PSK channels (#9749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add AEAD (AES-CCM) authenticated encryption for PSK channels Extend PSK channel encryption with optional AES-CCM authenticated encryption (use_aead flag in ChannelSettings). When enabled, messages include a 12-byte authentication tag that prevents forgery, bit-flipping, and injection attacks by anyone with the channel PSK. Changes: - Add encryptPacketCCM/decryptPacketCCM to CryptoEngine with key promotion (16-byte keys zero-padded to 32 for AESSmall256 compat) - Move AES-CCM primitives (aes-ccm.h/cpp, aesSetKey, aesEncrypt) outside PKI guard so they're available unconditionally - Add isAEADEnabled() to Channels with hash differentiation (XOR 0xAE) - Add AEAD encrypt/decrypt branches in Router perhapsEncode/perhapsDecode with no CTR fallback on AEAD channels - Add use_aead field to channel.pb.h (bool, tag 8) - Add MESHTASTIC_AEAD_OVERHEAD constant to RadioInterface.h - Add comprehensive test suite: round-trip (AES-128/256), tamper detection (ciphertext, tag, sweep), wrong PSK, wrong sender, packet-too-small, deterministic output verification Addresses firmware#4030. * Apply clang-format to match project style * Guard AEAD path against empty PSK and check encrypt return value - Add early return in encryptPacketCCM/decryptPacketCCM when psk.length == 0, preventing null dereference in aesSetKey - Check encryptPacketCCM return value in Router::perhapsEncode (both PKI and non-PKI paths), returning BAD_REQUEST on failure instead of silently transmitting corrupt packets - Add unit test for empty PSK (encrypt and decrypt must return false without crashing) * Use true AES-128 for 16-byte PSKs instead of promoting to AES-256 aesSetKey now dispatches based on key length: 16 bytes creates AESSmall128, 32 bytes creates AESSmall256. The aes member type changes from AESSmall256 to BlockCipher (polymorphic base class). This removes the unnecessary key promotion that added two extra AES rounds (14 vs 12) with no security benefit since the entropy stays at 128 bits for 16-byte keys. encryptPacketCCM/decryptPacketCCM now pass psk.length directly to aes_ccm_ae/aes_ccm_ad instead of promoting to 32. New tests: ECB AES-128 with NIST vectors, AEAD test verifying AES-128 and AES-256 produce different ciphertexts with same key material and cross-key decryption fails. * Reject the invalid-key sentinel in the AEAD paths CryptoKey documents length == -1 as "invalid key - do not use", but the AEAD guards only tested for 0. Since length is int8_t and the aes_ccm_* key length parameter is size_t, a -1 would widen into a huge unsigned length and be handed to the cipher instead of being rejected. Both callers in Router.cpp are gated on a non-negative channel hash, and generateHash() already returns -1 exactly when getKey() yields an invalid key, so the sentinel cannot reach these functions today. Guard against it anyway rather than relying on callers to keep that invariant. * Tie MESHTASTIC_AEAD_OVERHEAD to CryptoEngine::AEAD_TAG_SIZE The packet-size boundary checks in perhapsEncode/perhapsDecode budget for MESHTASTIC_AEAD_OVERHEAD, but the tag actually written is AEAD_TAG_SIZE. Nothing tied the two together, so changing one would have silently produced oversized packets or truncated payloads. Assert they match instead of coupling RadioInterface.h to CryptoEngine. Also trims the sentinel comment to the two-line limit in AGENTS.md. * Add RFC 3610 known-answer vectors and widen the tamper sweep Packet Vectors #1, #2 and #7 pin aes_ccm_ae()/aes_ccm_ad() to published data rather than to their own output, covering M=8 and M=10, a trailing partial block in every case, and rejection of a modified AAD. Test 1 in test_AES_CCM_AEAD is relabelled as the smoke test it actually is. The per-byte tamper loop now walks the whole buffer including the tag, instead of only the first four ciphertext bytes. * Cover the second nonce input and tighten the AEAD test buffers Test 10 only ever varied fromNode, leaving packetId — the other half of the nonce — unexercised. It now checks each one wrong on its own, both wrong, and both right, so the negative assertions cannot pass vacuously. The undersized-packet test wrote into a one-byte buffer and only survived because decryptPacketCCM() returns before touching it; size it for the whole input so a regressed length guard fails an assertion instead of the stack. Also assert makePsk() cannot overrun CryptoKey::bytes. * Rewrite Unicode dashes to ASCII in AEAD comments The ascii-dash formatter that landed in develop rewrites U+2014/U+2013 to an ASCII hyphen. Three files on this branch still carried em dashes in comments, so Trunk Check went red once develop was merged in. Comments only, no code change. * Authenticate sender and destination IDs as AEAD associated data The nonce binds the sender and the packet id, but nothing bound the destination, so `to` could be rewritten in flight and the tag would still validate. Pass `from || to` as associated data to aes_ccm_ae/aes_ccm_ad so a redirected packet fails authentication. The hop fields stay out of the AAD on purpose: relays legitimately rewrite hop_limit, hop_start, relay_node and next_hop. Adds a sub-test covering redirection to another node and promotion of a unicast to a broadcast; both must be rejected, and the unmodified destination must still round-trip. This changes the on-the-wire format for AEAD packets. Nothing ships with use_aead yet, so there is no deployed traffic to stay compatible with. * fix(crypto): repair EXCLUDE_PKI builds and guard AEAD channel config aes-ccm.cpp is compiled in every build now and calls CryptoEngine::aesSetKey and CryptoEngine::aesEncrypt, whose definitions were still inside the !(MESHTASTIC_EXCLUDE_PKI) block in CryptoEngine.cpp, so MESHTASTIC_EXCLUDE_PKI=1 failed at the link step. Move both definitions outside the guard, and move the pending-public-key declarations back inside it next to the fields they read. fixupChannel() clears use_aead on a channel that resolves to no key material. That combination kept a valid-looking channel hash while every encode returned BAD_REQUEST and every decode dropped, with nothing in the config to show why. encryptPacketCCM/decryptPacketCCM are virtual, so a platform engine can back them with hardware CCM the way it already overrides encryptAESCtr. perhapsEncode() carries one copy of the AEAD/CTR branch instead of an identical copy in each arm of the MESHTASTIC_EXCLUDE_PKI ifdef. Tests: three use_aead cases in test_channel_keys covering the hash split, the no-key clear, and a secondary that borrows the primary's key. * fix(crypto): move CryptoEngine::hash out of the PKI guard hash() is plain SHA256, and PortduinoGlue calls it unguarded to derive a MAC address from the CH341 serial, so MESHTASTIC_EXCLUDE_PKI=1 failed to compile. With this and the previous commit that build links clean. * fix(channels): resolve primaryIndex before hashing in onConfigChanged A keyless secondary resolves its key through primaryIndex, so fixing up channels in the same pass that finds the primary hashed the early slots against the previous one and cleared their use_aead against a key they do in fact inherit. Split the pass, and re-run the fixups in the no-primary restore path, which moves the primary after the fact. Also splits the thirteen AES-CCM AEAD scenarios into separate test functions so a Unity failure names the one that broke. * chore(crypto): trim the AEAD maintainer commits Shortens three comments that outgrew the one-to-two line house rule, drops a truncated sentence and the braces around a single return in perhapsEncode(), and removes a channel test that the moved-primary regression test already covers. No behaviour change. --------- Co-authored-by: Thomas Göttgens --- src/mesh/Channels.cpp | 34 +- src/mesh/Channels.h | 15 +- src/mesh/CryptoEngine.cpp | 61 +++- src/mesh/CryptoEngine.h | 18 +- src/mesh/RadioInterface.h | 1 + src/mesh/Router.cpp | 70 ++-- src/mesh/aes-ccm.cpp | 2 - src/mesh/aes-ccm.h | 2 - test/test_channel_keys/test_main.cpp | 60 ++++ test/test_crypto/test_main.cpp | 461 +++++++++++++++++++++++++++ 10 files changed, 679 insertions(+), 45 deletions(-) diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index ec1a418eae..d79dada511 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -47,6 +47,12 @@ int16_t Channels::generateHash(ChannelIndex channelNum) h ^= xorHash(k.bytes, k.length); + // Differentiate AEAD channels in routing so AEAD and non-AEAD + // channels with the same PSK have different hashes + auto &ch = getByIndex(channelNum); + if (ch.has_settings && ch.settings.use_aead) + h ^= 0xAE; + return h; } } @@ -71,6 +77,13 @@ meshtastic_Channel &Channels::fixupChannel(ChannelIndex chIndex) // Convert the old string "Default" to our new short representation if (strcmp(meshtastic_channelSettings.name, "Default") == 0) *meshtastic_channelSettings.name = '\0'; + + // AEAD needs key material. Left set on a channel that resolves to no PSK it would make every + // send fail with BAD_REQUEST and every receive drop, with nothing in the config to show why. + if (meshtastic_channelSettings.use_aead && getKey(chIndex).length <= 0) { + LOG_WARN("Channel %d has AEAD enabled but no PSK; clearing use_aead", chIndex); + meshtastic_channelSettings.use_aead = false; + } } hashes[chIndex] = generateHash(chIndex); @@ -317,16 +330,21 @@ void Channels::initDefaults() void Channels::onConfigChanged() { - // Make sure the phone hasn't mucked anything up + // Make sure the phone hasn't mucked anything up. Settle the primary first: fixupChannel() + // hashes through getKey(), which follows a keyless secondary to primaryIndex. bool hasPrimary = false; for (int i = 0; i < channelFile.channels_count; i++) { - const meshtastic_Channel &ch = fixupChannel(i); + const meshtastic_Channel &ch = getByIndex(i); - if (ch.role == meshtastic_Channel_Role_PRIMARY) { + if (ch.has_settings && ch.role == meshtastic_Channel_Role_PRIMARY) { primaryIndex = i; hasPrimary = true; } } + + for (int i = 0; i < channelFile.channels_count; i++) + fixupChannel(i); + // Enforce the invariant that primaryIndex references a PRIMARY channel: a malformed config can // demote every slot, which would leave all getPrimaryIndex() readers on a stale non-primary slot if (!hasPrimary) { @@ -339,7 +357,9 @@ void Channels::onConfigChanged() initDefaultChannel(0); } LOG_WARN("Config has no PRIMARY channel, restored one at slot %u", primaryIndex); - fixupChannel(primaryIndex); + // The key every keyless secondary resolves through just changed, so re-run the lot + for (int i = 0; i < channelFile.channels_count; i++) + fixupChannel(i); } #if !MESHTASTIC_EXCLUDE_MQTT if (channels.anyMqttEnabled() && mqtt && !mqtt->isEnabled()) { @@ -574,6 +594,12 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash) return false; } +bool Channels::isAEADEnabled(ChannelIndex chIndex) +{ + auto &ch = getByIndex(chIndex); + return ch.has_settings && ch.settings.use_aead; +} + /** Given a channel index setup crypto for encoding that channel (or the primary channel if that channel is unsecured) * * This method is called before encoding outbound packets diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index a7bbd2277a..6fd9f807a4 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -119,6 +119,15 @@ class Channels int16_t getHash(ChannelIndex i) { return hashes[i]; } + /** Return true if the channel has AEAD (authenticated encryption) enabled */ + bool isAEADEnabled(ChannelIndex chIndex); + + /** + * Return the key used for encrypting this channel (if channel is secondary and no key provided, use the primary channel's + * PSK) + */ + CryptoKey getKey(ChannelIndex chIndex); + private: /** Given a channel index, change to use the crypto key specified by that index * @@ -145,12 +154,6 @@ class Channels * Write default channels defined in UserPrefs */ void initDefaultChannel(ChannelIndex chIndex); - - /** - * Return the key used for encrypting this channel (if channel is secondary and no key provided, use the primary channel's - * PSK) - */ - CryptoKey getKey(ChannelIndex chIndex); }; /// Singleton channel table diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index bd199e8fd9..ac35773894 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -1,17 +1,17 @@ #include "CryptoEngine.h" // #include "NodeDB.h" +#include "aes-ccm.h" #include "architecture.h" +#include #include #if !(MESHTASTIC_EXCLUDE_PKI) #include "HardwareRNG.h" #include "NodeDB.h" -#include "aes-ccm.h" #include "meshUtils.h" #include #include #include -#include #if !(MESHTASTIC_EXCLUDE_XEDDSA) #include "XEdDSA.h" @@ -292,6 +292,8 @@ void CryptoEngine::setDHPrivateKey(uint8_t *_private_key) memcpy(private_key, _private_key, 32); } +#endif // !(MESHTASTIC_EXCLUDE_PKI) + /** * Hash arbitrary data using SHA256. * @@ -314,11 +316,16 @@ void CryptoEngine::hash(uint8_t *bytes, size_t numBytes) hash.finalize(bytes, 32); } +// aes-ccm.cpp drives the block cipher through these two, and it is compiled in every build, +// so they must stay outside the PKI guard or MESHTASTIC_EXCLUDE_PKI=1 fails to link. void CryptoEngine::aesSetKey(const uint8_t *key_bytes, size_t key_len) { aes = nullptr; - if (key_len != 0) { - aes = std::unique_ptr(new AESSmall256()); + if (key_len == 16) { + aes = std::unique_ptr(new AESSmall128()); + aes->setKey(key_bytes, 16); + } else if (key_len != 0) { + aes = std::unique_ptr(new AESSmall256()); aes->setKey(key_bytes, key_len); } } @@ -328,6 +335,8 @@ void CryptoEngine::aesEncrypt(uint8_t *in, uint8_t *out) aes->encryptBlock(out, in); } +#if !(MESHTASTIC_EXCLUDE_PKI) + bool CryptoEngine::setDHPublicKey(uint8_t *pubKey) { uint8_t local_priv[32]; @@ -369,6 +378,50 @@ bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_pu } #endif + +// AAD layout: [fromNode (4)] [toNode (4)], in the same native byte order initNonce uses. +static void initAad(uint32_t fromNode, uint32_t toNode, uint8_t *aad) +{ + // memcpy to avoid breaking strict-aliasing, as initNonce does + memcpy(aad, &fromNode, sizeof(uint32_t)); + memcpy(aad + sizeof(uint32_t), &toNode, sizeof(uint32_t)); +} + +bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint32_t toNode, uint64_t packetId, size_t numBytes, + const uint8_t *plaintext, uint8_t *ciphertextWithTag) +{ + // length is int8_t and the aes_ccm_* key length is size_t, so the -1 "invalid key" + // sentinel would widen into a huge unsigned length rather than being rejected. + if (psk.length <= 0) { + LOG_ERROR("AEAD encryption requires a valid, non-empty PSK"); + return false; + } + initNonce(fromNode, packetId); + uint8_t aad[AEAD_AAD_SIZE]; + initAad(fromNode, toNode, aad); + // Output layout: [ciphertext (numBytes)] [auth_tag (AEAD_TAG_SIZE bytes)] + return aes_ccm_ae(psk.bytes, psk.length, nonce, AEAD_TAG_SIZE, plaintext, numBytes, aad, sizeof(aad), ciphertextWithTag, + ciphertextWithTag + numBytes) == 0; +} + +bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint32_t toNode, uint64_t packetId, + size_t totalBytes, const uint8_t *ciphertextWithTag, uint8_t *plaintext) +{ + if (psk.length <= 0) { + LOG_ERROR("AEAD decryption requires a valid, non-empty PSK"); + return false; + } + if (totalBytes <= AEAD_TAG_SIZE) + return false; + initNonce(fromNode, packetId); + uint8_t aad[AEAD_AAD_SIZE]; + initAad(fromNode, toNode, aad); + size_t crypt_len = totalBytes - AEAD_TAG_SIZE; + const uint8_t *auth = ciphertextWithTag + crypt_len; + return aes_ccm_ad(psk.bytes, psk.length, nonce, AEAD_TAG_SIZE, ciphertextWithTag, crypt_len, aad, sizeof(aad), auth, + plaintext); +} + concurrency::Lock *cryptLock; void CryptoEngine::setKey(const CryptoKey &k) diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 95c7eb8ece..b23df129fc 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -57,7 +57,6 @@ class CryptoEngine virtual bool decryptCurve25519(uint32_t fromNode, meshtastic_NodeInfoLite_public_key_t remotePublic, uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut); virtual bool setDHPublicKey(uint8_t *publicKey); - virtual void hash(uint8_t *bytes, size_t numBytes); // Temporary holder for a peer's not-yet-verified public key, learned in-band during an // in-progress key-verification handshake before it is committed to NodeDB. Lets the Router @@ -69,13 +68,26 @@ class CryptoEngine void clearPendingPublicKey(); // Fills `out` (size set to 32) and returns true iff a pending key is held for `node`. bool getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_public_key_t &out); +#endif + + // Plain SHA256; outside the guard because PortduinoGlue uses it on EXCLUDE_PKI builds. + virtual void hash(uint8_t *bytes, size_t numBytes); virtual void aesSetKey(const uint8_t *key, size_t key_len); virtual void aesEncrypt(uint8_t *in, uint8_t *out); - std::unique_ptr aes = nullptr; + std::unique_ptr aes = nullptr; -#endif + static constexpr size_t AEAD_TAG_SIZE = 12; + // Sender and destination IDs are authenticated as associated data: the nonce already binds + // `from` and the packet id, and the hop fields are left out because relays rewrite them. + static constexpr size_t AEAD_AAD_SIZE = 2 * sizeof(uint32_t); + + virtual bool encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint32_t toNode, uint64_t packetId, size_t numBytes, + const uint8_t *plaintext, uint8_t *ciphertextWithTag); + + virtual bool decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint32_t toNode, uint64_t packetId, size_t totalBytes, + const uint8_t *ciphertextWithTag, uint8_t *plaintext); /** * Set the key used for encrypt, decrypt. diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index e76b977bf4..67870d0ad4 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -20,6 +20,7 @@ typedef struct _meshtastic_Config_LoRaConfig meshtastic_Config_LoRaConfig; #define MAX_LORA_PAYLOAD_LEN 255 // max length of 255 per Semtech's datasheets on SX12xx #define MESHTASTIC_HEADER_LENGTH 16 #define MESHTASTIC_PKC_OVERHEAD 12 +#define MESHTASTIC_AEAD_OVERHEAD 12 #define PACKET_FLAGS_HOP_LIMIT_MASK 0x07 #define PACKET_FLAGS_WANT_ACK_MASK 0x08 diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 0d40ae715f..c4d670f830 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -34,6 +34,11 @@ #include "serialization/MeshPacketSerializer.h" #endif +// The size checks below budget for the tag that encryptPacketCCM actually appends, so the +// two constants must not drift apart. +static_assert(MESHTASTIC_AEAD_OVERHEAD == CryptoEngine::AEAD_TAG_SIZE, + "MESHTASTIC_AEAD_OVERHEAD must match CryptoEngine::AEAD_TAG_SIZE"); + #define MAX_RX_FROMRADIO \ 4 // max number of packets destined to our queue, we dispatch packets quickly so it doesn't need to be big @@ -1041,15 +1046,32 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) // we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a // fresh copy for each decrypt attempt. memcpy(bytes, p->encrypted.bytes, rawSize); - // Try to decrypt the packet if we can - crypto->decrypt(p->from, p->id, rawSize, bytes); + + size_t decryptedSize = rawSize; + + if (channels.isAEADEnabled(chIndex)) { + // AEAD decryption - no CTR fallback + if (rawSize <= MESHTASTIC_AEAD_OVERHEAD) { + LOG_ERROR("Packet too small for AEAD (size=%d)", rawSize); + continue; + } + CryptoKey k = channels.getKey(chIndex); + if (!crypto->decryptPacketCCM(k, p->from, p->to, p->id, rawSize, p->encrypted.bytes, bytes)) { + LOG_WARN("AEAD authentication failed for ch %d", chIndex); + continue; // reject - no fallback to CTR + } + decryptedSize = rawSize - MESHTASTIC_AEAD_OVERHEAD; + } else { + // Standard AES-CTR decryption + crypto->decrypt(p->from, p->id, rawSize, bytes); + } // printBytes("plaintext", bytes, p->encrypted.size); // Take those raw bytes and convert them back into a well structured protobuf we can understand meshtastic_Data decodedtmp; memset(&decodedtmp, 0, sizeof(decodedtmp)); - if (!pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp)) { + if (!pb_decode_from_bytes(bytes, decryptedSize, &meshtastic_Data_msg, &decodedtmp)) { LOG_DEBUG("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)", p->id); } else if (decodedtmp.portnum == meshtastic_PortNum_UNKNOWN_APP) { LOG_DEBUG("Invalid portnum (bad psk?)"); @@ -1303,38 +1325,38 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) numbytes += MESHTASTIC_PKC_OVERHEAD; p->channel = 0; p->pki_encrypted = true; - } else { + } else +#endif + { if (p->pki_encrypted == true) { // Client specifically requested PKI encryption return meshtastic_Routing_Error_PKI_FAILED; } + const bool useAead = channels.isAEADEnabled(chIndex); + if (useAead && numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_AEAD_OVERHEAD > MAX_LORA_PAYLOAD_LEN) + return meshtastic_Routing_Error_TOO_LARGE; + hash = channels.setActiveByIndex(chIndex); // Now that we are encrypting the packet channel should be the hash (no longer the index) p->channel = hash; - if (hash < 0) { - // No suitable channel could be found for + if (hash < 0) return meshtastic_Routing_Error_NO_CHANNEL; - } - crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); - memcpy(p->encrypted.bytes, bytes, numbytes); - } -#else - if (p->pki_encrypted == true) { - // Client specifically requested PKI encryption - return meshtastic_Routing_Error_PKI_FAILED; - } - hash = channels.setActiveByIndex(chIndex); - // Now that we are encrypting the packet channel should be the hash (no longer the index) - p->channel = hash; - if (hash < 0) { - // No suitable channel could be found for - return meshtastic_Routing_Error_NO_CHANNEL; + if (useAead) { + // AEAD (AES-CCM) authenticated encryption path + CryptoKey k = channels.getKey(chIndex); + if (!crypto->encryptPacketCCM(k, getFrom(p), p->to, p->id, numbytes, bytes, p->encrypted.bytes)) { + LOG_ERROR("AEAD encryption failed for ch %d", chIndex); + return meshtastic_Routing_Error_BAD_REQUEST; + } + numbytes += MESHTASTIC_AEAD_OVERHEAD; + } else { + // Standard AES-CTR encryption path + crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); + memcpy(p->encrypted.bytes, bytes, numbytes); + } } - crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); - memcpy(p->encrypted.bytes, bytes, numbytes); -#endif // Copy back into the packet and set the variant type p->encrypted.size = numbytes; diff --git a/src/mesh/aes-ccm.cpp b/src/mesh/aes-ccm.cpp index 29e96cd4be..d9702c0c1b 100644 --- a/src/mesh/aes-ccm.cpp +++ b/src/mesh/aes-ccm.cpp @@ -8,7 +8,6 @@ */ #define AES_BLOCK_SIZE 16 #include "aes-ccm.h" -#if !MESHTASTIC_EXCLUDE_PKI /** * Constant-time comparison of two byte arrays @@ -178,4 +177,3 @@ bool aes_ccm_ad(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t } return true; } -#endif \ No newline at end of file diff --git a/src/mesh/aes-ccm.h b/src/mesh/aes-ccm.h index 6b8edcde49..b3642ceb6d 100644 --- a/src/mesh/aes-ccm.h +++ b/src/mesh/aes-ccm.h @@ -1,10 +1,8 @@ #pragma once #include "CryptoEngine.h" -#if !MESHTASTIC_EXCLUDE_PKI int aes_ccm_ae(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t M, const uint8_t *plain, size_t plain_len, const uint8_t *aad, size_t aad_len, uint8_t *crypt, uint8_t *auth); bool aes_ccm_ad(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t M, const uint8_t *crypt, size_t crypt_len, const uint8_t *aad, size_t aad_len, const uint8_t *auth, uint8_t *plain); -#endif \ No newline at end of file diff --git a/test/test_channel_keys/test_main.cpp b/test/test_channel_keys/test_main.cpp index 34c42b3328..98fad049fd 100644 --- a/test/test_channel_keys/test_main.cpp +++ b/test/test_channel_keys/test_main.cpp @@ -286,6 +286,61 @@ void test_recursion_guard_primary_slot_marked_secondary() TEST_ASSERT_EQUAL_INT8(0, crypto->key.length); } +// ===================================================================================== +// Group 3b: use_aead consistency +// ===================================================================================== + +void test_aead_flag_changes_the_hash() +{ + // Same name and PSK on both slots, AEAD on one of them. The hashes must differ, or a + // receiver with AEAD off would match the hash and then CTR-decrypt an AEAD frame. + static const uint8_t psk[16] = {0x42}; + setSlot(1, meshtastic_Channel_Role_SECONDARY, "alpha", psk, sizeof(psk)); + const int16_t plainHash = channels.getHash(1); + TEST_ASSERT_FALSE(channels.isAEADEnabled(1)); + + setSlot(2, meshtastic_Channel_Role_SECONDARY, "alpha", psk, sizeof(psk)).settings.use_aead = true; + channels.fixupChannel(2); + TEST_ASSERT_TRUE(channels.isAEADEnabled(2)); + TEST_ASSERT_NOT_EQUAL(plainHash, channels.getHash(2)); + TEST_ASSERT_EQUAL_INT16((uint8_t)(plainHash ^ 0xAE), channels.getHash(2)); +} + +void test_aead_without_key_material_is_cleared() +{ + // PSK index 0 means encryption off. Leaving use_aead set there would still produce a + // valid-looking hash while every encode returns BAD_REQUEST and every decode drops. + static const uint8_t pskOff[1] = {0x00}; + meshtastic_Channel &ch = setSlot(0, meshtastic_Channel_Role_PRIMARY, "plain", pskOff, sizeof(pskOff)); + const int16_t plainHash = channels.getHash(0); + + ch.settings.use_aead = true; + channels.fixupChannel(0); + TEST_ASSERT_FALSE(ch.settings.use_aead); + TEST_ASSERT_FALSE(channels.isAEADEnabled(0)); + TEST_ASSERT_EQUAL_INT16(plainHash, channels.getHash(0)); // and no stray 0xAE in the hash +} + +void test_onconfigchanged_resolves_primary_before_hashing() +{ + // The primary moves to slot 2 while slot 0 becomes a keyless secondary. onConfigChanged() + // has to settle primaryIndex before it fixes anything up: hashing slot 0 against the old + // primary (itself) trips getKey()'s recursion guard, which yields a name-only hash and + // clears use_aead against key material the channel does in fact inherit. + static const uint8_t movedPsk[16] = {0x5A}; + setSlot(0, meshtastic_Channel_Role_SECONDARY, "second", nullptr, 0); + channels.getByIndex(0).settings.use_aead = true; + setSlot(2, meshtastic_Channel_Role_PRIMARY, "moved", movedPsk, sizeof(movedPsk)); + + channels.onConfigChanged(); + + TEST_ASSERT_EQUAL_UINT8(2, channels.getPrimaryIndex()); + TEST_ASSERT_TRUE(channels.isAEADEnabled(0)); // the key is inherited, not absent + TEST_ASSERT_EQUAL_INT16((uint8_t)(refHash("second", movedPsk, sizeof(movedPsk)) ^ 0xAE), channels.getHash(0)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(movedPsk, sizeof(movedPsk)); +} + // ===================================================================================== // Group 4: onConfigChanged() no-primary restore and setChannel() demotion // ===================================================================================== @@ -562,6 +617,11 @@ CK_TEST_ENTRY void setup() RUN_TEST(test_secondary_empty_psk_inherits_primary_key); RUN_TEST(test_recursion_guard_primary_slot_marked_secondary); + printf("\n=== use_aead consistency ===\n"); + RUN_TEST(test_aead_flag_changes_the_hash); + RUN_TEST(test_aead_without_key_material_is_cleared); + RUN_TEST(test_onconfigchanged_resolves_primary_before_hashing); + printf("\n=== onConfigChanged restore and setChannel ===\n"); RUN_TEST(test_onconfigchanged_promotes_demoted_primary_slot_keeping_key); RUN_TEST(test_onconfigchanged_restores_default_when_all_disabled); diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index 448942ffe6..949cbde668 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -4,6 +4,7 @@ #include "TestUtil.h" #include "aes-ccm.h" #include +#include #include void HexToBytes(uint8_t *result, const std::string hex, size_t len = 0) @@ -87,6 +88,29 @@ void test_ECB_AES256(void) crypto->aesEncrypt(plain, result); // Does 16 bytes at a time TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); } +void test_ECB_AES128(void) +{ + // https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Standards-and-Guidelines/documents/examples/AES_ECB.pdf + uint8_t key[16] = {0}; + uint8_t plain[16] = {0}; + uint8_t result[16] = {0}; + uint8_t expected[16] = {0}; + + HexToBytes(key, "2B7E151628AED2A6ABF7158809CF4F3C"); + + HexToBytes(plain, "6BC1BEE22E409F96E93D7E117393172A"); + HexToBytes(expected, "3AD77BB40D7A3660A89ECAF32466EF97"); + crypto->aesSetKey(key, 16); + crypto->aesEncrypt(plain, result); + TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); + + HexToBytes(plain, "AE2D8A571E03AC9C9EB76FAC45AF8E51"); + HexToBytes(expected, "F5D3D58503B9699DE785895A96FDBAAF"); + crypto->aesSetKey(key, 16); + crypto->aesEncrypt(plain, result); + TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); +} + void test_DH25519(void) { // test vectors from wycheproof x25519 @@ -358,6 +382,429 @@ void test_AES_CCM_partial_block_bounds(void) } } +void test_AES_CCM_rfc3610(void) +{ + // Known-answer vectors from RFC 3610 section 8. They all use L=2, which is what + // aes_ccm_ae()/aes_ccm_ad() hardcode, and each ends in a partial block. + struct CcmVector { + const char *key; + const char *nonce; + const char *aad; + const char *plain; + const char *crypt; + const char *tag; + }; + const CcmVector vectors[] = { + // Packet Vector #1, M=8 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000003020100A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E", "588C979A61C663D2F066D0C2C0F989806D5F6B61DAC384", "17E8D12CFDF926E0"}, + // Packet Vector #2, M=8 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000004030201A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", "72C91A36E135F8CF291CA894085C87E3CC15C439C9E43A3B", + "A091D56E10400916"}, + // Packet Vector #7, M=10 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000009080706A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E", "0135D1B2C95F41D5D1D4FEC185D166B8094E999DFED96C", + "048C56602C97ACBB7490"}, + }; + + for (size_t v = 0; v < sizeof(vectors) / sizeof(vectors[0]); v++) { + const CcmVector &vec = vectors[v]; + const size_t plainLen = strlen(vec.plain) / 2; + const size_t aadLen = strlen(vec.aad) / 2; + const size_t tagLen = strlen(vec.tag) / 2; + + uint8_t key[16], nonce[13], aad[8]; + uint8_t plain[32], expectedCrypt[32], expectedTag[16]; + uint8_t crypt[32], tag[16], decrypted[32]; + + HexToBytes(key, vec.key); + HexToBytes(nonce, vec.nonce); + HexToBytes(aad, vec.aad); + HexToBytes(plain, vec.plain); + HexToBytes(expectedCrypt, vec.crypt); + HexToBytes(expectedTag, vec.tag); + + TEST_ASSERT_EQUAL(0, aes_ccm_ae(key, sizeof(key), nonce, tagLen, plain, plainLen, aad, aadLen, crypt, tag)); + TEST_ASSERT_EQUAL_MEMORY(expectedCrypt, crypt, plainLen); + TEST_ASSERT_EQUAL_MEMORY(expectedTag, tag, tagLen); + + TEST_ASSERT_TRUE(aes_ccm_ad(key, sizeof(key), nonce, tagLen, crypt, plainLen, aad, aadLen, tag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plain, decrypted, plainLen); + + // The AAD is authenticated but not encrypted: corrupting it must fail the tag check + aad[0] ^= 0x01; + TEST_ASSERT_FALSE(aes_ccm_ad(key, sizeof(key), nonce, tagLen, crypt, plainLen, aad, aadLen, tag, decrypted)); + } +} + +// Helper to create a zero-initialized CryptoKey (matching Channels::getKey() behavior) +static CryptoKey makePsk(const std::string &hex) +{ + CryptoKey k; + assert(hex.length() / 2 <= sizeof(k.bytes)); + memset(k.bytes, 0, sizeof(k.bytes)); + k.length = hex.length() / 2; + HexToBytes(k.bytes, hex); + return k; +} + +void test_AES_CCM_AEAD_smoke(void) +{ + // Smoke test - encryption changes the payload and produces a tag + // (the known-answer coverage lives in test_AES_CCM_rfc3610) + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0x12345678; + uint32_t toNode = 0x0000AAAA; + uint64_t packetId = 0xAABBCCDD; + + uint8_t plaintext[10]; + HexToBytes(plaintext, "08011204746573744800"); + + uint8_t ciphertextWithTag[10 + CryptoEngine::AEAD_TAG_SIZE]; + memset(ciphertextWithTag, 0, sizeof(ciphertextWithTag)); + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 10, plaintext, ciphertextWithTag)); + + // Ciphertext should differ from plaintext + TEST_ASSERT_FALSE(memcmp(plaintext, ciphertextWithTag, 10) == 0); + + // Tag bytes (last 12) should not all be zero + bool tagAllZero = true; + for (size_t i = 0; i < CryptoEngine::AEAD_TAG_SIZE; i++) { + if (ciphertextWithTag[10 + i] != 0) { + tagAllZero = false; + break; + } + } + TEST_ASSERT_FALSE(tagAllZero); +} + +void test_AES_CCM_AEAD_roundtrip_aes256(void) +{ + // Round-trip encrypt → decrypt → compare (AES-256) + CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4"); + + uint32_t fromNode = 0xDEADBEEF; + uint32_t toNode = 0xFFFFFFFF; + uint64_t packetId = 0x0102030405060708; + + const char *msg = "Hello Meshtastic AEAD!"; + size_t msgLen = strlen(msg); + + uint8_t ciphertextWithTag[64]; + memset(ciphertextWithTag, 0, sizeof(ciphertextWithTag)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, msgLen, (const uint8_t *)msg, ciphertextWithTag)); + + uint8_t decrypted[64]; + memset(decrypted, 0, sizeof(decrypted)); + size_t totalBytes = msgLen + CryptoEngine::AEAD_TAG_SIZE; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, totalBytes, ciphertextWithTag, decrypted)); + + TEST_ASSERT_EQUAL_MEMORY(msg, decrypted, msgLen); +} + +void test_AES_CCM_AEAD_rejects_tampering(void) +{ + // Tampered ciphertext - flip a bit, verify rejection + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xABCD1234; + uint32_t toNode = 0x00000001; + uint64_t packetId = 0x11223344; + + uint8_t plaintext[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + uint8_t ciphertextWithTag[8 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 8, plaintext, ciphertextWithTag)); + + // Flip a bit in the ciphertext portion + ciphertextWithTag[3] ^= 0x01; + + uint8_t decrypted[8]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } + + // Tampered auth tag - modify tag, verify rejection + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xABCD1234; + uint32_t toNode = 0x87654321; + uint64_t packetId = 0x55667788; + + uint8_t plaintext[16] = {0}; + for (int i = 0; i < 16; i++) + plaintext[i] = (uint8_t)i; + + uint8_t ciphertextWithTag[16 + CryptoEngine::AEAD_TAG_SIZE]; + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 16, plaintext, ciphertextWithTag)); + + // Corrupt the auth tag (last byte) + ciphertextWithTag[16 + CryptoEngine::AEAD_TAG_SIZE - 1] ^= 0xFF; + + uint8_t decrypted[16]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 16 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } +} + +void test_AES_CCM_AEAD_rejects_undersized(void) +{ + // Packet too small for AEAD - totalBytes <= AEAD_TAG_SIZE + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint8_t dummy[CryptoEngine::AEAD_TAG_SIZE] = {0}; + // Sized for the whole input so a regressed length guard fails the assertion below + // instead of corrupting the stack on its way out. + uint8_t out[CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x4321, 0x5678, CryptoEngine::AEAD_TAG_SIZE, dummy, out)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x4321, 0x5678, 0, dummy, out)); +} + +void test_AES_CCM_AEAD_rejects_wrong_psk(void) +{ + // Wrong PSK - decrypt with different key, verify rejection + CryptoKey pskA = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + CryptoKey pskB = makePsk("00112233445566778899aabbccddeeff"); + + uint32_t fromNode = 0x99887766; + uint32_t toNode = 0x13579BDF; + uint64_t packetId = 0xDEADFACE; + + uint8_t plaintext[12] = "Hello World"; + uint8_t ciphertextWithTag[12 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(pskA, fromNode, toNode, packetId, 12, plaintext, ciphertextWithTag)); + + // Attempt decryption with wrong key + uint8_t decrypted[12]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(pskB, fromNode, toNode, packetId, 12 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); +} + +void test_AES_CCM_AEAD_roundtrip_aes128(void) +{ + // Round-trip with AES-128 PSK (16-byte key, true AES-128-CCM) + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0x42424242; + uint32_t toNode = 0x2468ACE0; + uint64_t packetId = 0xBEEF1234; + + uint8_t plaintext[20] = "AES128 round trip!"; + uint8_t ciphertextWithTag[20 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 20, plaintext, ciphertextWithTag)); + + uint8_t decrypted[20]; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 20 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 20); +} + +void test_AES_CCM_AEAD_tamper_sweep(void) +{ + // AES-256-CCM round-trip + per-byte tamper detection + CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4"); + + uint32_t fromNode = 0x01020304; + uint32_t toNode = 0x0BADCAFE; + uint64_t packetId = 0x0A0B0C0D0E0F1011; + + uint8_t plaintext[32]; + for (int i = 0; i < 32; i++) + plaintext[i] = (uint8_t)(i * 7 + 3); + + uint8_t ciphertextWithTag[32 + CryptoEngine::AEAD_TAG_SIZE]; + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 32, plaintext, ciphertextWithTag)); + + // Valid decrypt + uint8_t decrypted[32]; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 32); + + // Flip a bit in every byte in turn, tag included, and verify each one is rejected + for (size_t i = 0; i < 32 + CryptoEngine::AEAD_TAG_SIZE; i++) { + uint8_t tampered[32 + CryptoEngine::AEAD_TAG_SIZE]; + memcpy(tampered, ciphertextWithTag, sizeof(tampered)); + tampered[i] ^= 0x80; + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, tampered, decrypted)); + } +} + +void test_AES_CCM_AEAD_is_deterministic(void) +{ + // Deterministic - same inputs produce same output + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xCAFEBABE; + uint32_t toNode = 0x5A5A5A5A; + uint64_t packetId = 0xFEEDFACE; + + uint8_t plaintext[5] = {0xDE, 0xAD, 0xBE, 0xEF, 0x42}; + uint8_t ct1[5 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t ct2[5 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 5, plaintext, ct1)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 5, plaintext, ct2)); + + TEST_ASSERT_EQUAL_MEMORY(ct1, ct2, 5 + CryptoEngine::AEAD_TAG_SIZE); +} + +void test_AES_CCM_AEAD_binds_nonce_inputs(void) +{ + // Wrong nonce input - the nonce derives from both fromNode and packetId, + // so each one on its own must be enough to make the tag check fail + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNodeA = 0x11111111; + uint32_t fromNodeB = 0x22222222; + uint32_t toNode = 0x77777777; + uint64_t packetIdA = 0xAAAABBBB; + uint64_t packetIdB = 0xCCCCDDDD; + + uint8_t plaintext[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + uint8_t ciphertextWithTag[6 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNodeA, toNode, packetIdA, 6, plaintext, ciphertextWithTag)); + + uint8_t decrypted[6]; + // Wrong fromNode, right packetId + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeB, toNode, packetIdA, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + // Right fromNode, wrong packetId + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeA, toNode, packetIdB, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + // Both wrong + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeB, toNode, packetIdB, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + // Both right still succeeds, so the assertions above are not passing for free + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNodeA, toNode, packetIdA, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 6); +} + +void test_AES_CCM_AEAD_rejects_invalid_psk(void) +{ + // Empty PSK - must return false, not crash + CryptoKey emptyPsk; + memset(&emptyPsk, 0, sizeof(emptyPsk)); + emptyPsk.length = 0; + + uint32_t fromNode = 0xDEADBEEF; + uint32_t toNode = 0x0000BEEF; + uint64_t packetId = 0x12345678; + + uint8_t plaintext[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + uint8_t ciphertextWithTag[8 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t decrypted[8]; + + // Encrypt with empty PSK must fail gracefully + TEST_ASSERT_FALSE(crypto->encryptPacketCCM(emptyPsk, fromNode, toNode, packetId, 8, plaintext, ciphertextWithTag)); + + // Decrypt with empty PSK must fail gracefully + // (use dummy ciphertext since encrypt failed) + memset(ciphertextWithTag, 0xAA, sizeof(ciphertextWithTag)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(emptyPsk, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + + // CryptoKey uses -1 as its "invalid key - do not use" sentinel, and it would widen + // into a huge unsigned length rather than be rejected. Both directions must refuse it. + CryptoKey invalidPsk; + memset(&invalidPsk, 0, sizeof(invalidPsk)); + invalidPsk.length = -1; + + TEST_ASSERT_FALSE(crypto->encryptPacketCCM(invalidPsk, fromNode, toNode, packetId, 8, plaintext, ciphertextWithTag)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(invalidPsk, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); +} + +void test_AES_CCM_AEAD_key_size_distinction(void) +{ + // AES-128 vs AES-256 produce different ciphertexts + // Verifies that 16-byte keys use true AES-128, not AES-256 with padding. + // Same 16 bytes of key material, but one is AES-128 (16 bytes) + // and the other is AES-256 (32 bytes, zero-padded). + CryptoKey psk128 = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + CryptoKey psk256; + memset(psk256.bytes, 0, sizeof(psk256.bytes)); + HexToBytes(psk256.bytes, "d4f1bb3a20290759f0bcffabcf4e6901"); + psk256.length = 32; // same first 16 bytes, but treated as AES-256 + + uint32_t fromNode = 0x55AA55AA; + uint32_t toNode = 0x33333333; + uint64_t packetId = 0x1234ABCD; + + uint8_t plaintext[8] = {0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80}; + uint8_t ct128[8 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t ct256[8 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk128, fromNode, toNode, packetId, 8, plaintext, ct128)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk256, fromNode, toNode, packetId, 8, plaintext, ct256)); + + // AES-128 and AES-256 with the same key material must produce different output + TEST_ASSERT_FALSE(memcmp(ct128, ct256, 8 + CryptoEngine::AEAD_TAG_SIZE) == 0); + + // Both must still round-trip correctly + uint8_t dec128[8], dec256[8]; + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk128, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct128, dec128)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, dec128, 8); + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk256, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct256, dec256)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, dec256, 8); + + // Cross-key decryption must fail + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk256, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct128, dec128)); + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk128, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct256, dec256)); +} + +void test_AES_CCM_AEAD_binds_destination(void) +{ + // Rewritten destination - `to` is authenticated as associated data, so changing + // it in flight must fail the tag check even though the nonce is unaffected + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0x0A0B0C0D; + uint32_t toNode = 0x00000042; + uint32_t otherNode = 0x00000043; + uint32_t broadcast = 0xFFFFFFFF; + uint64_t packetId = 0x99887766; + + uint8_t plaintext[9] = {'t', 'o', '-', 'i', 's', '-', 'a', 'a', 'd'}; + uint8_t ciphertextWithTag[9 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t decrypted[9]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 9, plaintext, ciphertextWithTag)); + + // Redirecting the packet to another node must be rejected + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, otherNode, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + + // Promoting a unicast to a broadcast must be rejected too + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, broadcast, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + + // The unmodified destination still round-trips, so the rejections above are not vacuous + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 9); + + // A different destination must also change the tag, not just be rejected on decrypt + uint8_t otherCiphertextWithTag[9 + CryptoEngine::AEAD_TAG_SIZE]; + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, otherNode, packetId, 9, plaintext, otherCiphertextWithTag)); + TEST_ASSERT_FALSE(memcmp(ciphertextWithTag + 9, otherCiphertextWithTag + 9, CryptoEngine::AEAD_TAG_SIZE) == 0); +} + void setup() { // NOTE!!! Wait for >2 secs @@ -369,10 +816,12 @@ void setup() UNITY_BEGIN(); // IMPORTANT LINE! RUN_TEST(test_SHA256); RUN_TEST(test_SHA256_large_input); + RUN_TEST(test_ECB_AES128); RUN_TEST(test_ECB_AES256); RUN_TEST(test_DH25519); RUN_TEST(test_AES_CTR); RUN_TEST(test_AES_CCM_partial_block_bounds); + RUN_TEST(test_AES_CCM_rfc3610); RUN_TEST(test_PKC); RUN_TEST(test_XEdDSA); RUN_TEST(test_XEdDSA_cross_key_reject); @@ -380,6 +829,18 @@ void setup() RUN_TEST(test_XEdDSA_curve_to_ed_cache); RUN_TEST(test_XEdDSA_max_payload); RUN_TEST(test_XEdDSA_repeated_sign_is_randomized); + RUN_TEST(test_AES_CCM_AEAD_smoke); + RUN_TEST(test_AES_CCM_AEAD_roundtrip_aes256); + RUN_TEST(test_AES_CCM_AEAD_rejects_tampering); + RUN_TEST(test_AES_CCM_AEAD_rejects_undersized); + RUN_TEST(test_AES_CCM_AEAD_rejects_wrong_psk); + RUN_TEST(test_AES_CCM_AEAD_roundtrip_aes128); + RUN_TEST(test_AES_CCM_AEAD_tamper_sweep); + RUN_TEST(test_AES_CCM_AEAD_is_deterministic); + RUN_TEST(test_AES_CCM_AEAD_binds_nonce_inputs); + RUN_TEST(test_AES_CCM_AEAD_rejects_invalid_psk); + RUN_TEST(test_AES_CCM_AEAD_key_size_distinction); + RUN_TEST(test_AES_CCM_AEAD_binds_destination); exit(UNITY_END()); // stop unit testing } From 644a43ca9bdc4975c7112751bebb0a4a1d602aff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:27:39 +0000 Subject: [PATCH 130/143] Update protobufs (#11841) Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/storeforward.pb.h | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/protobufs b/protobufs index 3b3df2a5e5..723a31e420 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 3b3df2a5e54a6f4599ab37ac819da597607a4a27 +Subproject commit 723a31e42013f155b529929e675db8caef20c534 diff --git a/src/mesh/generated/meshtastic/storeforward.pb.h b/src/mesh/generated/meshtastic/storeforward.pb.h index 75cff52058..f481a8c49f 100644 --- a/src/mesh/generated/meshtastic/storeforward.pb.h +++ b/src/mesh/generated/meshtastic/storeforward.pb.h @@ -107,6 +107,8 @@ typedef struct _meshtastic_StoreAndForward { /* Text from history message. */ meshtastic_StoreAndForward_text_t text; } variant; + /* Contains the original ID of the contained message. */ + uint32_t original_id; } meshtastic_StoreAndForward; @@ -126,11 +128,11 @@ extern "C" { /* Initializer values for message structs */ -#define meshtastic_StoreAndForward_init_default {_meshtastic_StoreAndForward_RequestResponse_MIN, 0, {meshtastic_StoreAndForward_Statistics_init_default}} +#define meshtastic_StoreAndForward_init_default {_meshtastic_StoreAndForward_RequestResponse_MIN, 0, {meshtastic_StoreAndForward_Statistics_init_default}, 0} #define meshtastic_StoreAndForward_Statistics_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_StoreAndForward_History_init_default {0, 0, 0} #define meshtastic_StoreAndForward_Heartbeat_init_default {0, 0} -#define meshtastic_StoreAndForward_init_zero {_meshtastic_StoreAndForward_RequestResponse_MIN, 0, {meshtastic_StoreAndForward_Statistics_init_zero}} +#define meshtastic_StoreAndForward_init_zero {_meshtastic_StoreAndForward_RequestResponse_MIN, 0, {meshtastic_StoreAndForward_Statistics_init_zero}, 0} #define meshtastic_StoreAndForward_Statistics_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_StoreAndForward_History_init_zero {0, 0, 0} #define meshtastic_StoreAndForward_Heartbeat_init_zero {0, 0} @@ -155,6 +157,7 @@ extern "C" { #define meshtastic_StoreAndForward_history_tag 3 #define meshtastic_StoreAndForward_heartbeat_tag 4 #define meshtastic_StoreAndForward_text_tag 5 +#define meshtastic_StoreAndForward_original_id_tag 6 /* Struct field encoding specification for nanopb */ #define meshtastic_StoreAndForward_FIELDLIST(X, a) \ @@ -162,7 +165,8 @@ X(a, STATIC, SINGULAR, UENUM, rr, 1) \ X(a, STATIC, ONEOF, MESSAGE, (variant,stats,variant.stats), 2) \ X(a, STATIC, ONEOF, MESSAGE, (variant,history,variant.history), 3) \ X(a, STATIC, ONEOF, MESSAGE, (variant,heartbeat,variant.heartbeat), 4) \ -X(a, STATIC, ONEOF, BYTES, (variant,text,variant.text), 5) +X(a, STATIC, ONEOF, BYTES, (variant,text,variant.text), 5) \ +X(a, STATIC, SINGULAR, UINT32, original_id, 6) #define meshtastic_StoreAndForward_CALLBACK NULL #define meshtastic_StoreAndForward_DEFAULT NULL #define meshtastic_StoreAndForward_variant_stats_MSGTYPE meshtastic_StoreAndForward_Statistics @@ -211,7 +215,7 @@ extern const pb_msgdesc_t meshtastic_StoreAndForward_Heartbeat_msg; #define meshtastic_StoreAndForward_Heartbeat_size 12 #define meshtastic_StoreAndForward_History_size 18 #define meshtastic_StoreAndForward_Statistics_size 50 -#define meshtastic_StoreAndForward_size 238 +#define meshtastic_StoreAndForward_size 244 #ifdef __cplusplus } /* extern "C" */ From bef289ef42205d80b9cd12cac03e032ebe63584a Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 14 Sep 2026 09:33:53 +0000 Subject: [PATCH 131/143] fix(extnotif): make isNagging the only armed flag for the nag cycle (#11828) * fix(extnotif): make isNagging the only armed flag for the nag cycle ExternalNotificationModule kept the nag cycle's armed state in two places that could disagree: the isNagging bool, and nagCycleCutoff reserving UINT32_MAX for "not armed". handleInputEvent() read only the second one: if (nagCycleCutoff != UINT32_MAX) { stopNow(); return 1; } The field is declared `= 1`, while isNagging starts false, so at boot that test said "armed" when nothing was nagging. The first input event of every boot was therefore answered with stopNow() and a non-zero return - and a non-zero return ends the observer chain (Observable::notifyObservers in src/Observer.h returns on the first one), so that event was swallowed from every later observer. The handler is registered whenever external_notification.enabled, and InputBroker only short-circuits while nagging() is true, so the event does reach it. The same read had a second failure mode once per ~49.7-day wrap: armNagCycle() computes `millis() + durationMs`, which can land exactly on UINT32_MAX. When it does, a real nag is running with isNagging true, but this read says "not armed" and the module's own handler never stops it. Time::skipZero() cannot help here - it lifts 0 to 1 and leaves UINT32_MAX alone, which src/UptimeClock.h static_asserts. So the fix is not a zero guard, it is removing the second opinion. isNagging is the armed flag - which is what the comment above the expiry check already claimed, and what the other four reads already use - and nagCycleCutoff is now only ever a deadline, read after isNagging has been checked. Nothing reserves a value, which matters because an arm site spelled `millis() + interval` can produce any value there is, so no value is safe to reserve. That is the shape the TODO(deadline-type) note in src/mesh/Throttle.h is aiming at, and that note is updated to match rather than keep describing the sentinel this removes. Worth knowing for review, though not changed here: InputBroker::handleInputEvent already calls stopNow() itself when nagging() is true, and returns without notifying observers. Every path that starts a notification calls armNagCycle() first, so isNagging is true for the whole life of any real nag. That makes this handler reachable only when there is nothing to stop - its stopNow() was never doing useful work. Gated rather than deleted, because removing a public handler and its observer registration is a bigger call than fixing the defect. Co-Authored-By: Claude Opus 5 * style(extnotif): trim comments to the house limit --------- Co-authored-by: Claude Opus 5 Co-authored-by: nomdetom --- src/mesh/Throttle.h | 6 +++--- src/modules/ExternalNotificationModule.cpp | 11 ++++++----- src/modules/ExternalNotificationModule.h | 4 +++- test/test_throttle/test_main.cpp | 8 ++++++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/mesh/Throttle.h b/src/mesh/Throttle.h index bd16ab1ccf..86740f5777 100644 --- a/src/mesh/Throttle.h +++ b/src/mesh/Throttle.h @@ -32,7 +32,7 @@ class Throttle /// Deadline::in(ms) / .armed() / .passed() / .disarm(). A hand-built `now + interval` could then /// no longer land on the sentinel by accident, and "armed" would stay a question separate from /// "passed" - the split that has to survive, because which way "inactive" falls is the caller's - /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the four + /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the three /// meanings they give the sentinel today: /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec, GPS.cpp fixHoldEnds, AdminModule.cpp /// enterDfuAtMsec and the other timerEndsAtMillis()/skipZero() arm sites dodge @@ -41,8 +41,8 @@ class Throttle /// guard, so this third state wants naming rather than repeating. /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. A computed renewal now dodges 0, /// so only a deliberate write still means "due now". - /// UINT32_MAX - ExternalNotificationModule.cpp nagCycleCutoff, whose armed() also lives in a - /// second variable (isNagging) and whose arm site can land on the sentinel. + /// ExternalNotificationModule.cpp nagCycleCutoff reserves nothing: isNagging is the only armed + /// flag and the deadline is read only while it is set. static bool deadlinePassed(uint32_t deadlineMs); /// deadlinePassed() against a caller-supplied "now", for a loop that snapshots the time once and diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index ef0627f06b..28eaf6ccd8 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -88,12 +88,11 @@ int32_t ExternalNotificationModule::runOnce() #if defined(HAS_I2S_SPEAKER_NRF52) isRtttlPlaying = isRtttlPlaying || nrf52RtttlPlayer.isPlaying(); #endif - // isNagging is the armed flag; nagCycleCutoff holds a real deadline only while it is set - // (UINT32_MAX once stopped, 1 at boot), so short-circuit before the comparison. + // isNagging is the armed flag; nagCycleCutoff is only a deadline while it is set, so + // short-circuit before the comparison. `millis() + durationMs` can land on any value. const bool nagWindowExpired = !isNagging || Throttle::deadlinePassed(nagCycleCutoff); if (nagWindowExpired && !isRtttlPlaying) { // Turn off external notification immediately when timeout is reached, regardless of song state - nagCycleCutoff = UINT32_MAX; ExternalNotificationModule::stopNow(); isNagging = false; return INT32_MAX; // save cycles till we're needed again @@ -309,9 +308,9 @@ void ExternalNotificationModule::stopNow() #endif // Prevent the state machine from immediately re-triggering outputs after a manual stop. + // Clearing isNagging disarms the cycle; nagCycleCutoff is never read without it. isNagging = false; buzzerShouldAlert = false; - nagCycleCutoff = UINT32_MAX; #ifdef HAS_I2S // GPIO0 is used as mclk for I2S audio and set to OUTPUT by the sound library @@ -624,7 +623,9 @@ void ExternalNotificationModule::handleSetRingtone(const char *from_msg) #if !MESHTASTIC_EXCLUDE_INPUTBROKER int ExternalNotificationModule::handleInputEvent(const InputEvent *event) { - if (nagCycleCutoff != UINT32_MAX) { + // Testing the deadline instead of isNagging was true at boot, and the non-zero return + // swallowed the first input event from every later observer. + if (isNagging) { stopNow(); return 1; } diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index 969638583c..95ff5b4d66 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -64,7 +64,9 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: int handleInputEvent(const InputEvent *arg); #endif - uint32_t nagCycleCutoff = 1; + /// When the current nag cycle ends. Meaningful only while isNagging is set; never test it for a + /// magic value. + uint32_t nagCycleCutoff = 0; void setExternalState(uint8_t index = 0, bool on = false); bool getExternal(uint8_t index = 0); diff --git a/test/test_throttle/test_main.cpp b/test/test_throttle/test_main.cpp index e2630ba3dd..b0fe23a04c 100644 --- a/test/test_throttle/test_main.cpp +++ b/test/test_throttle/test_main.cpp @@ -148,8 +148,12 @@ void test_deadlinePassed_reads_disarmed_sentinels_as_passed() { Time::setTestMillis(6247); - TEST_ASSERT_TRUE(Throttle::deadlinePassed(0)); // "inactive" for rebootAtMsec et al - TEST_ASSERT_TRUE(Throttle::deadlinePassed(UINT32_MAX)); // "inactive" for nagCycleCutoff + TEST_ASSERT_TRUE(Throttle::deadlinePassed(0)); // "inactive" for rebootAtMsec et al + + // UINT32_MAX is not a usable "far future" either - at a low uptime it is a hair BEHIND now, so + // it reads as passed like any other past value. ExternalNotificationModule used to reserve it + // for "unarmed" and now keeps that state in its isNagging flag instead. + TEST_ASSERT_TRUE(Throttle::deadlinePassed(UINT32_MAX)); // The guarded form every caller must use. const uint32_t disarmed = 0; From 31f05ab0572408c605b36e6108c373eab20cdca6 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 14 Sep 2026 09:34:31 +0000 Subject: [PATCH 132/143] fix(touch): stop LONG_PRESS repeating when the suppression deadline wraps (#11829) * fix(touch): stop LONG_PRESS repeating when the suppression deadline wraps TouchScreenBase::_start was one field doing two incompatible jobs. It held the press-down timestamp, and then the LONG_PRESS handler overwrote it with `millis() + 30000` to stop the event repeating for the rest of the hold. Every read was a hand-rolled signed subtraction on time_t, and suppression worked only because `time_t(millis()) - _start` came out around -30000. Where time_t is 64 bits - the portduino host - that uint32_t sum wraps to a small number while millis() is still just under 0xFFFFFFFF. The subtraction then goes hugely positive instead of negative, the threshold test passes on every 20ms poll, and each pass re-arms to another wrapped value. It keeps firing until millis() itself wraps, up to ~30 s later: about 1500 TOUCH_ACTION_LONG_PRESS events injected into InputBroker for one finger that never moved. Modelling the old expression across press-start offsets puts the worst case at exactly 1500 for a 60 s hold, where three is correct. On a 32-bit time_t build the signed wrap happens to keep suppressing, so this is host-and-variant dependent rather than universal. The zero-dodging helpers in src/UptimeClock.h are no use here: they map 0 to 1, and 1 reads as "long ago" exactly as 0 does. The defect is the overload, not the zero, so the field is split by what it is actually asked: _pressStartMs a past event time - how long has the finger been down _longPressSuppressed is repeat suppression armed _longPressSuppressUntilMs when it expires, read only while the bool is set Two fields for the suppression rather than one, for the reason Throttle.h's TODO(deadline-type) gives: armed has to stay a separate question from passed. No single value can stand in for "unarmed" here either, since deadlinePassed() reads 0 as long past below ~24.8 days of uptime and as far future above it. Nothing new uses 0 as a sentinel, so bin/lint-unset-sentinel-millis.sh needs no entry. All three comparisons now go through Throttle - hasElapsed() for the two elapsed-since-press questions, which also buys the full ~49.7 day range that a stored event time gets, and deadlinePassed() for the suppression window. Behaviour is preserved deliberately, including the part that is easy to miss: the old `+ 30000` made a held finger re-report LONG_PRESS once every 30 s, not once per touch. A bool latch would have been simpler and quietly narrowed that, so the window is kept as LONG_PRESS_REPEAT_SUPPRESS_MS. Old and new were compared across five wrap scenarios and agree everywhere except the wrap window the old code got wrong. The tap-on-release suppression the old write also provided is not needed: a hold long enough to reach here has duration >= TIME_LONG_PRESS, so the tap branch already takes its else and clears _tapped. One guard added while here. The RAK14014 deferred-tap window is TIME_LONG_PRESS - 50 and that subtraction is unsigned now, so a variant lowering TIME_LONG_PRESS below 50 would underflow it into a ~49.7 day wait and the deferred TAP would never fire. The only override in the tree is t5s3_epaper at 500; a static_assert fails the build instead of the touch panel. Co-Authored-By: Claude Opus 5 * style(touch): trim comments to the house limit --------- Co-authored-by: Claude Opus 5 Co-authored-by: nomdetom --- src/input/TouchScreenBase.cpp | 27 ++++++++++++++++++++------- src/input/TouchScreenBase.h | 13 +++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/input/TouchScreenBase.cpp b/src/input/TouchScreenBase.cpp index 8512300f71..0770e7aec8 100644 --- a/src/input/TouchScreenBase.cpp +++ b/src/input/TouchScreenBase.cpp @@ -1,5 +1,6 @@ #include "TouchScreenBase.h" #include "main.h" +#include "mesh/Throttle.h" #if defined(RAK14014) && !defined(MESHTASTIC_EXCLUDE_CANNEDMESSAGES) #include "modules/CannedMessageModule.h" @@ -9,6 +10,12 @@ #define TIME_LONG_PRESS 400 #endif +// The deferred-tap window is `TIME_LONG_PRESS - 50`, unsigned: below 50 it underflows to ~49.7 days. +static_assert(TIME_LONG_PRESS >= 50, "TIME_LONG_PRESS must be at least 50ms: see the deferred-tap window below"); + +// How long a held finger stays suppressed after a LONG_PRESS is reported. +#define LONG_PRESS_REPEAT_SUPPRESS_MS 30000 + // Touch sampling cadence (milliseconds). // Can be overridden by board variants for faster touch panels. #ifndef TOUCH_POLL_INTERVAL_IDLE @@ -49,7 +56,8 @@ 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), _lastTouchSeenMs(0), _tapped(false), _originName(name) + _last_y(0), _pressStartMs(0), _longPressSuppressed(false), _longPressSuppressUntilMs(0), _lastTouchSeenMs(0), + _tapped(false), _originName(name) { } @@ -95,12 +103,13 @@ int32_t TouchScreenBase::runOnce() if (touched) { hapticFeedback(); _state = TOUCH_EVENT_OCCURRED; - _start = millis(); + _pressStartMs = nowMs; + _longPressSuppressed = false; _first_x = x; _first_y = y; } else { _state = TOUCH_EVENT_CLEARED; - time_t duration = millis() - _start; + uint32_t duration = nowMs - _pressStartMs; x = _last_x; y = _last_y; this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_RELEASE_FAST : TOUCH_POLL_INTERVAL_RELEASE); @@ -157,7 +166,7 @@ int32_t TouchScreenBase::runOnce() LOG_DEBUG("action TAP(%d/%d)", _last_x, _last_y); } } else { - if (_tapped && (time_t(millis()) - _start) > TIME_LONG_PRESS - 50) { + if (_tapped && Throttle::hasElapsed(_pressStartMs, TIME_LONG_PRESS - 50)) { _tapped = false; e.touchEvent = static_cast(TOUCH_ACTION_TAP); LOG_DEBUG("action TAP(%d/%d)", _last_x, _last_y); @@ -173,9 +182,13 @@ int32_t TouchScreenBase::runOnce() #endif // fire LONG_PRESS event without the need for release - if (allowLongPress && touched && (time_t(millis()) - _start) > TIME_LONG_PRESS) { - // tricky: prevent reoccurring events and another touch event when releasing - _start = millis() + 30000; + // Armed and expired are asked separately; folding the deadline into the press stamp repeated + // LONG_PRESS every poll across the wrap on 64-bit time_t hosts. + const bool longPressSuppressed = _longPressSuppressed && !Throttle::deadlinePassed(_longPressSuppressUntilMs); + if (allowLongPress && touched && !longPressSuppressed && Throttle::hasElapsed(_pressStartMs, TIME_LONG_PRESS)) { + // A finger held past the window re-reports LONG_PRESS once per window, as before. + _longPressSuppressed = true; + _longPressSuppressUntilMs = nowMs + LONG_PRESS_REPEAT_SUPPRESS_MS; e.touchEvent = static_cast(TOUCH_ACTION_LONG_PRESS); LOG_DEBUG("action LONG PRESS(%d/%d)", _last_x, _last_y); } diff --git a/src/input/TouchScreenBase.h b/src/input/TouchScreenBase.h index 91ec165ec7..67667232a2 100644 --- a/src/input/TouchScreenBase.h +++ b/src/input/TouchScreenBase.h @@ -50,10 +50,15 @@ class TouchScreenBase : public Observable, public concurrenc bool _touchedOld = false; // previous touch state 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 - uint32_t _lastRun = 0; // helps suppress too fast consecutive runOnce() executions + uint32_t _pressStartMs; // when the current touch began; read via Throttle::hasElapsed() + + // LONG_PRESS repeat suppression while one touch is held: the bool is the armed flag, the + // deadline is read only while it is set. No value of the deadline can mean "unarmed". + bool _longPressSuppressed; + uint32_t _longPressSuppressUntilMs; // meaningful only while _longPressSuppressed + uint32_t _lastTouchSeenMs; // helps suppress brief touch-controller dropouts + bool _tapped; // for DOUBLE_TAP + uint32_t _lastRun = 0; // helps suppress too fast consecutive runOnce() executions const char *_originName; }; From be449b525fa61106f9658bad0592bc071819f236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 14 Sep 2026 10:38:19 +0000 Subject: [PATCH 133/143] fix(esp32): rebuild IDF libs when the HybridCompile cache is stale (#11834) The platform decides whether framework-arduinoespressif32-libs matches the current env from a hash written into sdkconfig.defaults at the start of the IDF-libs pass, before those libs are compiled, so an interrupted or metadata-only pass leaves a hash describing libs that were never built. Later builds match that hash, skip the recompile and link the previously compiled board's IDF configuration; on tlora-t3s3-v1 this linked a t-connect-pro build's CONFIG_SPIRAM_MODE_OCT libs into a quad-PSRAM ESP32-S3FH4R2, which aborts in esp_psram_init() before the console exists and boot loops with no serial output. Drop sdkconfig.defaults when the package's own /sdkconfig, rewritten as the last step of a completed compile, predates it. --- extra_scripts/esp32_pre.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/extra_scripts/esp32_pre.py b/extra_scripts/esp32_pre.py index b2c4171e7e..d9884862ae 100755 --- a/extra_scripts/esp32_pre.py +++ b/extra_scripts/esp32_pre.py @@ -3,7 +3,8 @@ # trunk-ignore-all(flake8/F821): For SConstruct imports import json import sys -from os.path import isfile +from os import remove +from os.path import getmtime, isfile, join Import("env") @@ -167,3 +168,22 @@ def tag_sdkconfig_cache_key(env): tag_sdkconfig_cache_key(env) + + +# The platform writes its cache-key hash into sdkconfig.defaults before compiling +# the IDF libs, so an aborted pass leaves it describing libs that were never built. +def drop_stale_sdkconfig_defaults(env): + defaults = join(env.subst("$PROJECT_DIR"), "sdkconfig.defaults") + mcu = env.BoardConfig().get("build.mcu", "esp32") + try: + libs = env.PioPlatform().get_package_dir("framework-arduinoespressif32-libs") + # Rewritten last by a completed compile, so it marks "libs built". + if getmtime(join(libs, mcu, "sdkconfig")) >= getmtime(defaults): + return + except (OSError, TypeError): + return + print("*** Stale %s IDF libs; forcing a HybridCompile rebuild ***" % mcu) + remove(defaults) + + +drop_stale_sdkconfig_defaults(env) From ee15508494b23225fef78ea9a10d5310f4e87d62 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 14 Sep 2026 12:19:02 +0000 Subject: [PATCH 134/143] time: arm the remaining 0-means-unset stamps through the helpers (#11830) * time: add skipZero/safeMillis/timerEndsAtMillis helpers skipZero() steps a millis value past 0, since stored stamps and deadlines conventionally use 0 for "unset" and the one tick per ~49.7-day wrap that lands on 0 would otherwise read as never-set. safeMillis() covers a bare stamp; timerEndsAtMillis(delayMs) covers a deadline, where the sum is what has to dodge 0 - a non-zero read plus a delay lands there once per wrap - so it is not safeMillis() + delayMs. * time: replace hand-rolled zero-dodging with the UptimeClock helpers PacketHistory rxTimeMsec, EncryptedStorage s_lastFailMillis (stamps), and SGM41562 lastRefreshMs_ / NextHopRouter learnedAtMsec (ternary stamps) each hand-rolled skipZero() in place; swap in safeMillis()/skipZero() directly. HapticFeedback pulseOffAt/delayedPulseAt and GPS fixHoldEnds hand-rolled the deadline form - millis() + delay, then remap a 0 result to 1 - swap in timerEndsAtMillis(delay). No behavior change; each site keeps the value it already computed. * time: guard the remaining 0-means-unset deadline/stamp writes rebootAtMsec, shutdownAtMsec, and NotificationRenderer::alertBannerUntil are all read back with a bare == 0 / != 0 check for 'not scheduled', but every write site computed millis() + delay (or a bare millis() stamp) with no guard against landing exactly on 0 - the same wrap hazard skipZero() exists for, just never applied here. Route every rebootAtMsec/shutdownAtMsec/alertBannerUntil write through timerEndsAtMillis()/safeMillis(); RadioLibInterface's reboot-on-stuck-tx sums an already-captured stamp rather than "now", so it goes through skipZero() directly instead. No behavior change outside the ~1-in-2^32 wrap window each site was already exposed to. * time: guard three more 0-means-unset deadline writes ntp_renew (ethClient.cpp), suppressTouchTapUntilMs (Events.cpp), and tx_after (RadioLibInterface.cpp) all read back 0 as a real state - forced NTP renewal, no suppress window active, no TX delay armed, respectively - but each arm site wrote a bare millis()/getMillis() + delay with no guard against the sum landing exactly on 0. Route each through Time::timerEndsAtMillis(). No behavior change outside the wrap window each site was already exposed to. Refresh the Throttle.h TODO list to note ntp_renew is converted too. * motion: guard the calibration deadline and use Throttle::deadlinePassed endCalibrationAt's arm site wrote millis() + calibrateFor with no guard against landing on 0, the same value finishCalibrationIfExpired()/ drawFrameCalibration() treat as "not calibrating". Route it through Time::timerEndsAtMillis(). Also swap finishCalibrationIfExpired()'s hand-rolled (int32_t)(now - deadline) < 0 for Throttle::deadlinePassed(): same wrap-safe comparison the codebase already provides, without the signed-cast pattern Throttle.h documents as implementation-defined past INT32_MAX, and it drops the file's last direct millis() call in favor of the Time:: wrapper the rest of it already uses. * time: fix Throttle::execute()'s own zero-dodging Both places execute() writes *lastExecutionMs - the first-ever-run branch and the regular update - used bare Time::getMillis() with no guard against landing on 0, which is the exact sentinel this function reads back as "never run" one line above. A hit there makes the next call re-fire immediately instead of respecting minumumIntervalMs. Capture now via Time::safeMillis() once; every use downstream (the elapsed comparison, the stored value) is then safe by construction instead of needing the guard reapplied at each write. * revert some safeMillis cases where overflow is a bad thing * test(uptime): pin skipZero/safeMillis/timerEndsAtMillis at the wrap boundary Covers the zero case, an ordinary nonzero value, and a sum that lands exactly on 0 from a nonzero start - the case timerEndsAtMillis() exists for, and the one the prior suite had no direct coverage of. * time: restore the route-health write normalization and put it on one clock noteRouteLearned()/noteRouteSuccess() lost their `now ? now : 1` normalization, leaving learnedAtMsec able to store 0 - which getOrAllocRouteHealth() reads as an ever-growing age, making the slot the first eviction candidate and permanently stale. Normalize at the write, where the block comment already says it happens, so every caller is covered rather than just today's two. Both callers, the two isRouteStale() sites and doRetransmissions() now read Time::getMillis(), so the stamp and every comparison against it share a clock. doRetransmissions() goes back to getMillis(): its `now` feeds only comparisons, never a 0-sentinel field, so skipping zero there only cost accuracy. * time: read the haptic, InkHUD and calibration deadlines on the write's clock These three deadlines were converted to Time::timerEndsAtMillis() on the write side while their reads stayed on millis(), so each spanned two clocks and would fire immediately or never under an injected test clock. Convert the reads to match: HapticFeedback::scheduleNext()/runOnce(), the InkHUD tap-suppression window, and the calibration countdown's read-back of screen->getEndCalibration(). MotionSensor's sampledAtMs is left alone - its write and read are both millis() and consistent already. * time: correct the sentinel notes to match what the code actually does The Throttle.h enumeration claimed the remaining timerEndsAtMillis() callers "already dodge the sentinel", which reads as a completeness claim the same branch contradicts: RadioLibInterface's tx_after and activeReceiveStart are both 0=unarmed and both still arm from bare millis(). Name them instead, so the deadline-type conversion has the real list. The ntp_renew entry now separates a deliberate 0 ("due now", forced at link-up) from a computed one, which is what changed there. The three TODO(elapsed-stamp) blocks ran four and five lines against the repo's one-or-two rule, and two of them argued their case wrongly. Throttle.cpp implied safeMillis() simply doesn't help; in fact neither store is safe on the wrap tick - the 1 underflows a same-instant read, the 0 re-takes the never-run branch - which is the symmetry worth recording. PacketHistory.cpp called its dodge "reflecting the previous pattern" when it is load-bearing: rxTimeMsec 0 means "empty slot" (PacketHistory.h:21) and insert() drops a record stamped 0 outright, so without it a packet arriving on the wrap tick is never stored and loses its dedup. Also picks up trunk fmt's trailing-whitespace fix in Throttle.cpp and the comment realignment in SGM41562.cpp that this branch's added comment knocked out. * test(nexthop): pin the route-health stamp against the 0 sentinel The uptime suite covers skipZero/safeMillis/timerEndsAtMillis themselves, but nothing covered a call site, so the branch deleted noteRouteLearned()'s normalization and stayed green. None of the existing route-health tests pass 0 as `now` - they use 1000, learnAt, or millis() - (TTL + 5000) - which is exactly the gap the regression went through. Both new tests fail with "Expected 0 to be not equal to 0" when the skipZero() is backed out of NextHopRouter, and pass with it. noteRouteSuccess() only refreshes an existing record, so its twin learns a route first to reach the write. Also drops a self-referential assertion in the uptime suite: comparing getMillis() against safeMillis() passes even if safeMillis() does no dodge at all, so it now asserts the literal. * discard safemillis for skipzero (better semantics and therefore maintainability) and make consistent use of getmillis where it is called (to permit testing) * more wrapzero safety * STM gets some too * time: stop the next 0-means-unset deadline being armed from raw millis() The fields this branch armed through Time::timerEndsAtMillis() / Time::skipZero() are the kind that get added by copy-paste: `rebootAtMsec = millis() + N` appears at twenty-odd sites across six files, and the next module to defer a reboot will be written from one of them. Nothing catches the mistake afterwards - the sum lands on 0 for one tick per ~49.7-day wrap, so a test run, a soak and a bench session all pass while a pending reboot, shutdown, DFU jump or banner expiry is silently dropped. Two guards, at the two places it can go wrong. The helpers themselves: skipZero() is constexpr, so its contract is now pinned by static_assert in the header rather than only by test_uptime_clock. The asserts are chosen against the two plausible rewrites - `ms | 1` perturbs every even value and `ms + 1` turns the last tick of the wrap into the 0 the function exists to avoid. Both compile, and both pass a test that only checks skipZero(0); each trips a distinct assert here, naming the failure mode. The call sites: bin/lint-unset-sentinel-millis.sh flags a sentinel field in src/ assigned from a raw millis()/getMillis() read, and names the helper to use. It is name-driven because the 0 contract is declared in src/main.h and enforced in six other files, so no single-file scan can infer it; every one of the thirteen fields was checked to actually test against 0 before being listed. nagCycleCutoff and LinuxJoystick's nextRepeatX/nextRepeatY are deliberately absent - their unset state is a separate bool - and the nine remaining `millis() + x` sites in src/ are locals that never store 0 for anything to misread. Blocking, unlike its note-level neighbours: there is no run-time enforcer to pair with, and the tree has zero violations today, so gating costs nothing. Scoped to src/ so test_uptime_clock can keep building raw wrap values on purpose. bin/test-lint-unset-sentinel-millis.sh pins the scanner against 23 fixtures - reads, disarms, shadowing locals, comments, string literals and the already-fixed forms all have to stay quiet. Co-Authored-By: Claude Opus 5 * time: guard the four 0-means-unset stamps this branch had missed Sweeping src/ for the `if (stamp && )` idiom - the shape that makes 0 mean "unset" - turned up four stamps still armed from a raw clock read, so the new lint rule would have had to either ignore them or go red on checkout. Each is the same one-tick-per-wrap hole the rest of the branch closes: * TrackballInterruptBase lastInterruptTime, armed in all four ISR handlers and explicitly disarmed to 0 at the threshold reset. getMillis() is the ISR-safe read by construction - it compiles to millis() outside PIO_UNIT_TESTING - and skipZero() is pure, so neither adds anything to interrupt context. * NeighborInfoModule lastSentReply, read as `if (lastSentReply && ...)` before the 3-minute reply throttle. Needed the UptimeClock.h include. * PositionModule lastSentReply, same throttle; already on the injectable clock but still missing the guard. * NodeDB lastSort, whose own read spells the sentinel out as `lastSort == 0 ||`. On the wrap tick each would read as never-stamped: a trackball debounce window lost, a neighbour or position reply sent inside the throttle it was meant to respect, one extra NodeDB sort. Cheap individually, which is why they were missed. All four are now listed in bin/lint-unset-sentinel-millis.sh, so the rule covers every field in the tree that actually tests against 0 rather than a subset, and the header records the eight stamps left off for the opposite reason - their unset state is a separate flag (isNagging, busyTx, heldX/heldY, formatted_this_boot, heartbeat, gotwind, haveSample, lastIaqValid), so 0 is a value they may legally hold. The rule is silent across src/ on this tree. Co-Authored-By: Claude Opus 5 * lint: let a site opt out of the sentinel rule, with its reason on the record The rule is blocking, so it needs an escape hatch for the site where 0 genuinely is a legal timestamp - and the hatch should cost something, or it becomes the first thing anyone reaches for. `unset-sentinel-ok: ` in a comment on the write, or on a comment line above it, suppresses that one statement: // unset-sentinel-ok: busyTx carries the armed state, so 0 is a legal stamp here lastTxStart = Time::getMillis(); The reason is mandatory. A bare `unset-sentinel-ok`, or a colon with nothing after it, is reported instead of honoured - with a message saying so - so the only way to silence a site is to write down why it is safe. trunk-ignore still works, but this states the justification at the write and also applies when the script runs outside trunk. The marker is read from comment text collected during the same character-level pass that strips comments and literals, not by re-scanning the raw line. That is what keeps it out of reach of data: LOG_DEBUG("unset-sentinel-ok: ...") mutes nothing, because a string literal is not a comment. It is also consumed by the statement it was written for, so it cannot leak onto the next write - while still carrying across any number of intervening comment lines to the statement below, which is where a real justification wants to be written. Twelve fixtures added for the new behaviour: both comment styles, block and multi-line block comments, the bare form, the marker-in-a-string cases, and three leak cases. 35 total, all green, under bash 3.2 as well. Co-Authored-By: Claude Opus 5 * lint: watch the separate-flag stamps too, with their exemption stated at the write The nine stamps whose armed state lives in a companion boolean were previously just absent from the rule's list, which meant the reasoning for leaving them out existed only as prose in a shell script. They are now listed and individually opted out at the write, naming the flag that actually carries the armed state: // unset-sentinel-ok: haveSample carries the armed state, so 0 is a legal stamp lastSampleMs = Time::getMillis(); The point is what happens later. If someone rewrites `if (haveSample && ...)` as `if (lastSampleMs && ...)`, the field has silently acquired the 0 contract; with the opt-out sitting at the write, the claim to re-examine is in front of whoever makes that edit instead of buried in bin/. Every exemption was checked against its real read sites before being written, and three candidates did not survive that check. They stay off the list, because listing one would mean stamping an opt-out over a claim that does not hold: * nagCycleCutoff. handleInputEvent reads `if (nagCycleCutoff != UINT32_MAX)` without consulting isNagging, so at that read the field is its own armed flag with UINT32_MAX as the sentinel - and the arm at ExternalNotificationModule .cpp:521 can land exactly there. skipZero() cannot help: it lifts 0 to 1 and leaves UINT32_MAX alone, which UptimeClock.h's own static_assert pins. There is also a live boot-state bug behind this - the in-class initializer is 1 while isNagging starts false - and fixing the read is a behaviour change that belongs in its own PR. * TouchScreenBase::_start. Overloaded as an event stamp AND a `+ 30000` suppression deadline compared by signed subtraction, so a near-zero value reads as "long ago" rather than "armed 30s out" and LONG_PRESS re-fires. skipZero() does not fix this one either: 1 reads as long-ago exactly as 0 does. It needs the stamp and the deadline held separately. * StoreForwardModule::retry_delay. No reads at all today, so nothing misbehaves yet; exempting it now would pre-approve the raw arm for whoever implements the retry its own comment promises. The rule is silent across src/ on this tree, and the header records all three rejections so the next person does not have to re-derive them. The self-test's negative fixture no longer uses nagCycleCutoff as its example of a safely unlisted field - that would have encoded the opposite of what the header says. Co-Authored-By: Claude Opus 5 * fix(time,lint): guard the recomputed tx_after, and judge one write at a time Two review findings, both real. setTransmitDelay() recomputes p->tx_after from a clamp of three candidates, and that recomputation was still raw. Two lines above it, `if (p->tx_after)` is the read that takes 0 as "no delay wanted", so a clamp landing on 0 drops the CSMA backoff and the packet goes out immediately instead of after its computed delay. The first arm site in this function was already guarded; this one was missed because the value is not a plain `now + delay` and so does not fit timerEndsAtMillis() - it takes skipZero() instead. The narrowing order matters here and is spelled out at the site: add_delay is unsigned long, 64-bit on the portduino host, so the clamp can exceed UINT32_MAX there. skipZero() on the wide value would pass 0x100000000 through as non-zero and the store to this uint32_t field would then truncate it back to the 0 being avoided, so the cast comes first. The lint rule judged each write by the wrong text. rhs was taken from the write to the end of the accumulated statement, so a neighbour on the same line decided the verdict - and it was wrong in both directions: rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10); the later helper call suppressed a genuine raw arm rebootAtMsec = otherDeadline; shutdownAtMsec = millis(); the later millis() reported a safe copy rhs is now cut at its own semicolon. Six fixtures cover it, including both cases above, two raw writes on one line, two helper writes on one line, and a statement split across lines, which must still see its whole right-hand side. 41 fixtures total, green under bash 3.2. Co-Authored-By: Claude Opus 5 * time: arm the remaining 0-means-unset stamps through the helpers The follow-up sweep to the sixteen fields the previous commits covered. A field here uses 0 to mean "unset" - some read spells `if (f)`, `f != 0`, `f == 0 ||` or `f > 0`, or a site disarms it with `f = 0` - but it was armed from a raw clock read, so once per ~49.7-day wrap it stores the value its own readers treat as never-set. 34 fields, 58 arm sites. The rule could not have found most of them first. It treated `field = ` as inheriting whatever that variable did, which made the commonest shape in the tree invisible: one `now = millis()` at the top of a runOnce(), then several `xStartTime = now` below it. Listing those names would have bought no protection at all, so the scanner now tracks a local assigned from a clock and treats a write from it as the raw arm it is. One hop, one function, name-based, and it forgets a local reassigned from anything else; taint is dropped at each function boundary. Twelve fixtures pin it, including the negative cases - no leak across functions, `now` does not match `nowMs`, and neither `==` nor `+=` records anything. That pass immediately found a site the previous commits missed: setTransmitDelay() recomputes p->tx_after from a tainted `now`, two lines under the `if (p->tx_after)` read that takes 0 as "no delay wanted". Three of the fields are worth naming because the consequence is not cosmetic: * UpDownInterruptBase press/up/downStartTime - xDetected is only cleared INSIDE the block guarded by `xDetected && xStartTime > 0`, so a stored 0 makes both the entry and the exit condition unreachable and that button is dead for the rest of the boot, not for one tick. * PhoneAPI lastContactMsec - ServerAPI reads `lastContactMsec > 0` before the TCP idle close, and the field stays 0 until the next inbound packet, so a client that never speaks again leaks the socket for the life of the connection. * EInkDisplay lastDrawMsec - `if (lastDrawMsec)` gates every plain display() call on a keyframe having been shown, so a stored 0 stops the screen updating until something calls Screen::forceDisplay() again. TransmitHistory needed more than its arm sites. getLastSentToMeshMillis() returns 0 to mean "module has never sent", and besides the two stores, both reconstruction helpers end in `millis() - msAgo`, which can produce a 0 of their own. All three computed returns are guarded; the deliberate `return 0;` sentinels are untouched. Judged and deliberately not changed: * nRF54L15 connect_time_ms is armed from k_uptime_get_32(), not millis(). It is guarded with skipZero() but keeps its own clock - swapping in Time::getMillis() would have it compared against a k_uptime now at the watchdog read. The rule now recognises that clock too, so listing the field is not an empty gesture. * RotaryEncoderInterruptBase pressStartTime shares a name with the UpDown field and has a different contract: no read here tests the stamp against 0, pressDetected is the only armed flag. Opted out at the write. Its lastPressLongEventTime sibling IS a `== 0` latch and is fixed. * PositionModule line 38 copies a value the enclosing `if (restored != 0)` has already proven non-zero. Opted out. * pmMeasureStarted, adminKeyFallbackRefillMs, the two autosave stamps and scrollStartDelay are lazy initialisations whose wrap behaviour costs at most one interval and drops nothing. Left alone, and not listed. 47 lint fixtures green, the rule silent across src/, full native suite 1421/1421. Co-Authored-By: Claude Opus 5 * time: dodge the wrap at the clock read, not only at the store Review on #11830 made a point that was right and that this branch had wrong. Applying skipZero() at the STORE while a reader measures elapsed time against a raw clock splits the two sides apart for one tick per ~49.7-day wrap: the stamp becomes 1 while `now` is still 0, so `now - stamp` is UINT32_MAX and a brand new stamp reads as about 49.7 days old. Every elapsed-since guard then fires when it must not. Concretely, UpDownInterruptBase computed `now - pressStartTime` and emitted a long press for a fresh press, and TraceRouteModule read `now - lastTraceRouteTime < cooldownMs` as false and bypassed its cooldown. So the dodge moves to the read. Time::stampMillis() is getMillis() with the one 0 tick called 1; a site that both stores a stamp and measures against stamps reads the clock once through it and stores that value directly. Nine files, and the 1 ms skew is the same one skipZero() already documents. Where the clock arrives as a PARAMETER the store keeps its own skipZero() as well, because the function cannot assume the caller dodged anything. Removing that was a real regression and test_nexthop_routing caught it: noteRouteLearned() and noteRouteSuccess() are called with a literal 0 by test_health_learn_never_stores_zero_sentinel and test_health_success_never_stores_zero_sentinel, which assert the store normalises it - 0 is the empty-slot marker getOrAllocRouteHealth() evicts on. The two guards compose without shifting twice, since skipZero() of a non-zero value is itself. trySmartBroadcast() and directResponseAllowed() have the same parameter shape and keep their store-side guard for the same reason. Only stores fed by a stampMillis() local in the same function are bare. EInkParallelDisplay was missed the first time: the third class in the family, still storing skipZero(getMillis()) while rate-limiting against a raw millis() local. Normalised like its siblings. The lint rule gained three false positives with the class-scope tracking, all of them shapes that are not class bodies at all: template void f(T x) { uint32_t lastSort = millis(); } class Foo { void tick() { uint32_t lastSort = millis(); } }; void g(struct Bar *b) { uint32_t lastSort = millis(); } Two causes. pending_class matched class/struct anywhere on the line, so a template parameter list and an elaborated type in a parameter list both marked the following FUNCTION body as class scope; it is anchored to the start of the line now. And update_scope() runs at the end of a line, so a body opened earlier on the same line had not been counted when the statement was judged; is_declaration() now also counts unmatched braces earlier in the statement. The rule is blocking and `template ` is ordinary C++, so these would have reddened files nobody touched. note_taint() also never received the per-write `;` cut the judging path was given earlier in review, so on a line holding two statements it learned taint from the neighbour. Same cut applied. Five tests in test_uptime_clock pin the contract, including one that asserts the old store-only shape really does produce UINT32_MAX, and one that pins the 1 ms skew at 399 rather than 400 so nobody "corrects" it back into a raw read. Lint fixtures 53 -> 65. Full native suite 1426/1426, rule silent across src/. Known residual, deliberately not changed: a store that dodges zero while its reader measures through a Throttle:: helper still splits for that one tick, because those helpers read the clock internally and raw. About eight sites tree-wide, including PositionModule trySmartBroadcast and the lastContactMsec TCP idle check. Closing it means making Throttle read through the dodge, which was proposed on #11692 and declined there pending a caller audit, so fixing one site here would only make the tree inconsistent. The direction is also the same one the un-dodged code already took: a fresh stamp reads as old, and the guards involved were already passing on a 0 stamp. Co-Authored-By: Claude Opus 5 * lint: a dodged value is safe to copy, not to do arithmetic on Two more review findings on the rule, both real, both false negatives. Arithmetic on an already-dodged value was excused. stampMillis() guarantees only its own result, so `now + 5000` can carry a non-zero stamp straight back onto the sentinel - 0xFFFFEC78 + 5000 is exactly 0. That sum is precisely what Time::timerEndsAtMillis() exists to dodge, and the rule was waving it through because a helper name appeared somewhere in the expression. Worse, a fixture asserted that behaviour was correct, so the self-test was pinning the hole open. A local holding a dodged value is now tracked separately from a tainted one: it may be stored or copied straight through, but + or - applied at the OUTERMOST level is reported and the message points at timerEndsAtMillis(). Depth-aware, so the operator inside Time::skipZero(getMillis() - msAgo) is still fine, and so is the `(d == 0) ? 0 : timerEndsAtMillis(d)` arming form, which has no top-level operator at all. The wrong fixture is replaced by four: store-through, copy one more hop, arithmetic on a dodged local, and arithmetic on a direct helper call. A class body that opens and closes on one line was never recognised. The header check rejected it because the line ends in a semicolon, which a one-liner body always does, and even once armed the class brace counted as a function body and excused the member. Both halves fixed: the header arms on the brace rather than on the absence of a semicolon, and when the body opened on the statement being judged, one unmatched brace is class scope while two is a method body inside it. Getting that wrong first broke every multi-line class, because setting the per-statement flag without also arming pending_class meant update_scope() never registered the body - the three existing class fixtures caught it. 72 fixtures, green under bash 3.2, shellcheck clean, rule silent across src/. No src/ or test/ file changes, so the native suite is untouched by this commit. Co-Authored-By: Claude Opus 5 * style(time): trim comments to the house limit --------- Co-authored-by: Tom <116762865+Nestpebble@users.noreply.github.com> Co-authored-by: nomdetom Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> Co-authored-by: Claude Opus 5 --- bin/lint-unset-sentinel-millis.sh | 201 ++++++++++++++++- bin/test-lint-unset-sentinel-millis.sh | 211 ++++++++++++++++++ src/Power.cpp | 3 +- src/UptimeClock.h | 7 + src/gps/RTC.cpp | 2 +- src/graphics/BaseUIEInkDisplay.cpp | 3 +- src/graphics/EInkDisplay2.cpp | 3 +- src/graphics/EInkParallelDisplay.cpp | 9 +- src/graphics/Screen.cpp | 2 +- src/graphics/draw/UIRenderer.cpp | 7 +- src/input/ExpressLRSFiveWay.cpp | 2 +- src/input/LinuxJoystick.cpp | 2 + src/input/RotaryEncoderInterruptBase.cpp | 4 +- src/input/TrackballInterruptBase.cpp | 8 +- src/input/UpDownInterruptBase.cpp | 3 +- src/mesh/IndicatorSerial.cpp | 3 +- src/mesh/NextHopRouter.cpp | 11 +- src/mesh/PhoneAPI.cpp | 4 +- src/mesh/ReliableRouter.cpp | 2 +- src/mesh/Router.cpp | 2 +- src/mesh/TransmitHistory.cpp | 13 +- src/mesh/api/PacketAPI.cpp | 3 +- src/mesh/eth/ethOTA.cpp | 5 +- src/mesh/http/WebServer.cpp | 2 +- src/mesh/wifi/WiFiAPClient.cpp | 3 +- src/modules/DropzoneModule.cpp | 5 +- src/modules/KeyVerificationModule.cpp | 3 +- src/modules/PositionModule.cpp | 7 +- src/modules/Telemetry/AirQualityTelemetry.cpp | 3 +- src/modules/Telemetry/DeviceTelemetry.cpp | 2 +- .../Telemetry/EnvironmentTelemetry.cpp | 3 +- src/modules/Telemetry/HealthTelemetry.cpp | 3 +- src/modules/Telemetry/PowerTelemetry.cpp | 3 +- src/modules/TraceRouteModule.cpp | 7 +- src/modules/TrafficManagementModule.cpp | 5 +- src/mqtt/MQTT.cpp | 3 +- .../extra_variants/t5s3_epaper/variant.cpp | 5 +- src/platform/nrf54l15/NRF54L15Bluetooth.cpp | 3 +- src/platform/stm32wl/main-stm32wl.cpp | 3 +- test/test_uptime_clock/test_main.cpp | 60 +++++ 40 files changed, 566 insertions(+), 64 deletions(-) diff --git a/bin/lint-unset-sentinel-millis.sh b/bin/lint-unset-sentinel-millis.sh index 1ff34da200..d3348cb8f4 100755 --- a/bin/lint-unset-sentinel-millis.sh +++ b/bin/lint-unset-sentinel-millis.sh @@ -85,7 +85,7 @@ set -uo pipefail # Millisecond fields this rule watches. See the notes above before editing. -SENTINELS='rebootAtMsec|shutdownAtMsec|enterDfuAtMsec|alertBannerUntil|pulseOffAt|delayedPulseAt|ntp_renew|tx_after|suppressTouchTapUntilMs|fixHoldEnds|lastChipRecoveryMs|activeReceiveStart|rxTimeMsec|lastInterruptTime|lastSentReply|lastSort|lastTxStart|lastHeartbeat|lastAveraged|lastSampleMs|lastIaqMs|last_format_ms|nextRepeatX|nextRepeatY|_cached_next_run' +SENTINELS='rebootAtMsec|shutdownAtMsec|enterDfuAtMsec|alertBannerUntil|pulseOffAt|delayedPulseAt|ntp_renew|tx_after|suppressTouchTapUntilMs|fixHoldEnds|lastChipRecoveryMs|activeReceiveStart|rxTimeMsec|lastInterruptTime|lastSentReply|lastSort|lastTxStart|lastHeartbeat|lastAveraged|lastSampleMs|lastIaqMs|last_format_ms|nextRepeatX|nextRepeatY|_cached_next_run|connect_time_ms|directionStartTime|downStartTime|fileage|keyDownStart|lastAuthFailure|lastContactMsec|lastDirectResponseMs|lastDiskSave|lastDownLongEventTime|lastDrawMsec|lastGpsSend|lastHeadingAtMs|lastHeapLogTime|lastHeapWarning|lastLfsFormatMs|lastMillis|lastPressLongEventTime|lastRemoteSessionMs|lastSentStatsToPhone|lastSentToPhone|lastSetFromPhoneNtpOrGps|lastTraceRouteTime|lastUpLongEventTime|lastUpdateMs|last_probe|last_report_to_map|lastrun_ntp|navBarLastShown|pressStartTime|startSendConditions|suppressFromMs|touchResumeAtMs|upStartTime' for target in "$@"; do [[ -f $target ]] || continue @@ -155,21 +155,200 @@ for target in "$@"; do return (c ~ /[A-Za-z0-9_]/) } + # Brace depth, and which depths are a class/struct BODY rather than a function body. Needed + # because a typed declaration means opposite things in the two places: inside a function it is a + # throwaway local that shadows the field, but at class scope it IS the field, with an initializer + # that can read the clock - src/modules/SerialModule.h does exactly that. Treating the second as a + # local silently excused a real arm site. + function update_scope(s, i, c) { + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "{") { + depth++ + if (pending_class) { class_body[depth] = 1; pending_class = 0 } + } else if (c == "}") { + delete class_body[depth] + if (depth > 0) depth-- + } + } + } + + # Is the statement being judged sitting directly in a class body? `opened` is the net brace + # count seen earlier in this same statement, which update_scope() has not applied yet: a body + # opened on this very line (a one-line inline method) puts the statement inside a function, not + # in the class body. + function at_class_scope(opened) { + # The class body opened on this very statement, so its own brace is the class brace: exactly + # one unmatched brace means class scope, two or more means a method body inside it. + if (stmt_class_brace) return (opened == 1) + return ((depth in class_body) && opened <= 0) + } + + # Net unmatched `{` in the first `at` characters of the statement being judged. + function braces_before(s, at, i, c, n) { + n = 0 + for (i = 1; i < at && i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "{") n++ + else if (c == "}") n-- + } + return n + } + # A local declaration that happens to reuse a sentinel name shadows the field and carries none # of its contract, so it is not this rule business. Detected by a type-ish token immediately # before the name - `uint32_t tx_after = millis() + d;` declares a local, `tx_after = ...` does # not. Kept narrow: only the spellings this tree actually uses for a millis value. + # + # Only honoured inside a function body; see update_scope() for why class scope is different. function is_declaration(s, at, head) { + if (at_class_scope(braces_before(s, at))) return 0 head = substr(s, 1, at - 1) sub(/[ \t]*(\*|&)?[ \t]*$/, "", head) return (head ~ /(^|[^A-Za-z0-9_])(uint32_t|uint64_t|int32_t|unsigned[ \t]+long|unsigned[ \t]+int|unsigned|long|int|auto|size_t|TickType_t)$/) } + # Does this statement read a clock directly? Matches millis(), Time::getMillis() and any wrapper + # whose name ends in millis, which is how almost every clock read in this tree spells itself, plus + # Zephyr k_uptime_get_32() - the nRF54L15 BLE code has no millis() at all and wraps at 32 bits just + # the same, so a sentinel armed from it needs the same guard. + function reads_clock(s) { return (s ~ /[Mm]illis[ \t]*\(/ || s ~ /k_uptime_get_32[ \t]*\(/) } + + # Already routed through a helper that dodges 0, so it is the fix rather than the defect. Note + # stampMillis() belongs here even though its name ends in millis: a local read through it holds a + # value that is already non-zero, so a write from that local is safe and must not be flagged. + function is_safe_arm(s) { return (s ~ /skipZero/ || s ~ /timerEndsAtMillis/ || s ~ /stampMillis/) } + + # Does the expression apply + or - to an already-dodged value at the OUTERMOST level? A dodged + # value is safe to store or copy, but not to do arithmetic on: stampMillis() guarantees only its + # own result, and `now + 5000` can carry a non-zero stamp straight back onto 0 - 0xFFFFEC78 + 5000 + # is exactly 0. That sum is what Time::timerEndsAtMillis() exists to dodge, so it has to be + # reported rather than excused. + # + # Depth-aware on purpose: the operator inside Time::skipZero(getMillis() - msAgo) is at depth 1 + # and is fine, because the helper wraps the result. So is the `? :` in the ternary arming form, + # which has no top-level + or - at all. + function toplevel_arith(s, i, n, c, d, prev) { + n = length(s); d = 0; prev = "" + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "(") d++ + else if (c == ")") d-- + else if (d == 0 && (c == "+" || c == "-")) { + # not a unary sign, and not part of -> or ++/-- + if (prev != "" && prev != "(" && prev != "," && prev != "=" && prev != "+" && + prev != "-" && prev != "*" && prev != "/" && prev != "?" && prev != ":" && + substr(s, i + 1, 1) != ">") + return 1 + } + if (c != " " && c != "\t") prev = c + } + return 0 + } + + # Remember a local that was just assigned from a clock, so `field = now` a few lines later is + # recognised as the raw arm it really is. Without this the rule is blind to the commonest shape + # in the tree - `unsigned long now = millis();` at the top of a runOnce(), then half a dozen + # `xStartTime = now;` writes below it - and listing those fields would buy no protection at all. + # + # Deliberately shallow: one hop, within one function, name-based. It records ` = ` + # and ` = `, and it FORGETS the name when the same local is + # reassigned from anything else, so a variable reused for something unrelated stops matching. + # Taint is dropped at every function boundary (see the reset below), because a name that means a + # clock in one function usually means nothing in the next. + function note_taint(s, lhs, rhs, eqp, semi) { + eqp = index(s, "=") + if (eqp == 0) return + if (substr(s, eqp + 1, 1) == "=") return # `==` is a comparison + if (substr(s, eqp - 1, 1) ~ /[-+*\/%&|^!<>=]/) return # `+=`, `!=`, ... are not plain + lhs = substr(s, 1, eqp - 1) + rhs = substr(s, eqp + 1) + # This assignment only. Without the cut, a second statement on the same line teaches taint + # for the first - the same defect the judging path was fixed for. + semi = index(rhs, ";") + if (semi > 0) rhs = substr(rhs, 1, semi - 1) + # Take the last identifier on the left, which skips any type and `*`/`&` decoration. + if (!match(lhs, /[A-Za-z_][A-Za-z0-9_]*[ \t]*$/)) return + lhs = substr(lhs, RSTART, RLENGTH) + sub(/[ \t]+$/, "", lhs) + if (lhs == "") return + if (is_safe_arm(rhs) && !toplevel_arith(rhs)) { + delete tainted[lhs] + normalized[lhs] = 1 # holds a value that has already dodged 0 + } else if (reads_clock(rhs) || rhs_is_tainted(rhs)) { + tainted[lhs] = 1 + delete normalized[lhs] + } else { + delete tainted[lhs] # reused for something else - stop trusting the name + delete normalized[lhs] + } + } + + # Is any tainted local read in this expression, as a whole token? Token-bounded so a tainted + # `now` does not match `nowMs` or `snowfall`. Local names are plain identifiers, so using one + # as a match() pattern carries no regex metacharacters. + function rhs_is_normalized(s, name, t, p, before, after) { + for (name in normalized) { + t = s + while (match(t, name)) { + p = RSTART + before = (p == 1) ? " " : substr(t, p - 1, 1) + after = substr(t, p + length(name), 1) + if (before !~ /[A-Za-z0-9_]/ && after !~ /[A-Za-z0-9_]/) return 1 + t = substr(t, p + length(name)) + if (t == "") break + } + } + return 0 + } + + function rhs_is_tainted(s, name, t, p, before, after) { + for (name in tainted) { + t = s + while (match(t, name)) { + p = RSTART + before = (p == 1) ? " " : substr(t, p - 1, 1) + after = substr(t, p + length(name), 1) + if (before !~ /[A-Za-z0-9_]/ && after !~ /[A-Za-z0-9_]/) return 1 + t = substr(t, p + length(name)) + if (t == "") break + } + } + return 0 + } + BEGIN { LINE_CAP = 12 } # give up accumulating a statement after this many lines { code = strip_noncode($0) + # A class or struct header whose body opens on this line or the next. Anchored at the start + # of the line, because the keyword appears mid-line in shapes that are not class bodies at + # all: `template ` on a function, and an elaborated type in a parameter list such as + # `void g(struct Bar *b)`. Both used to mark the following FUNCTION body as class scope, which + # then reported every typed local in it. Not a forward declaration either, which ends in a + # semicolon with no brace. + stmt_class_brace = 0 + if (code ~ /^[ \t]*(class|struct)[ \t]+[A-Za-z_][A-Za-z0-9_]*/) { + # The body may open on this line or the next. `class Foo;` is a forward declaration and + # opens nothing; `class Foo { uint32_t t = millis(); };` opens AND closes here, so the + # trailing semicolon cannot be used to rule it out. + if (code ~ /\{/) { + # Body opens on this line. stmt_class_brace judges THIS statement (a one-liner + # whose member sits after the brace); pending_class is still needed so + # update_scope() registers the body for the lines that follow. + stmt_class_brace = 1 + pending_class = 1 + } else if (code !~ /;[ \t]*$/) { + pending_class = 1 # body opens on a later line + } + } + + # A closing brace in column 1 is the end of a function as this tree formats code, and a + # local called `now` there has nothing to do with the one in the next function. clang-format + # is enforced repo-wide, so this is reliable enough for a one-hop heuristic. + if ($0 ~ /^\}/) delete tainted + # An opt-out is sticky until the next statement that actually contains code is judged. That # is what lets it sit on its own line above the write, however many comment lines intervene, # without leaking past the statement it was written for. @@ -222,8 +401,15 @@ for target in "$@"; do # variable inherits whatever that one did, and anything already routed through # the helpers is the fix rather than the defect. Matching `millis` loosely # covers millis(), Time::getMillis() and any wrapper ending in millis. - if (rhs !~ /[Mm]illis[ \t]*\(/ || rhs ~ /skipZero/ || rhs ~ /timerEndsAtMillis/) - continue + # Arithmetic applied on top of an already-dodged value can wrap it back onto 0, + # so it is reported even though a helper appears in the expression. + if ((is_safe_arm(rhs) || rhs_is_normalized(rhs)) && !toplevel_arith(rhs)) { + continue # stored or copied straight through - safe + } + if (is_safe_arm(rhs) && !toplevel_arith(rhs)) + continue # already routed through the helpers + if (!reads_clock(rhs) && !rhs_is_tainted(rhs) && !rhs_is_normalized(rhs)) + continue # not a clock read, directly or via a local holding one if (pending_ok) continue # opted out, with a reason, at the write if (pending_bare) @@ -235,12 +421,21 @@ for target in "$@"; do hit_name[k] " is 0-means-unset - arm it with Time::timerEndsAtMillis(delay), or Time::skipZero(Time::getMillis()) for a stamp (see src/UptimeClock.h)", "unset-sentinel-millis" } + # Learn from this statement before dropping it: `now = millis()` here is what makes + # `field = now` below recognisable. Done after judging so a sentinel write cannot + # taint its own name. + if (nhits == 0) note_taint(stmt) + # Comment-only lines carry an opt-out toward the write below them, so they must not # clear it; a statement with real code in it consumes it. if (stmt ~ /[^ \t]/) { pending_ok = 0; pending_bare = 0 } stmt = "" nhits = 0 } + + # Last, so every brace on this line counts toward the scope of the NEXT line: a declaration + # sits at the depth its own line opened with. + update_scope(code) } ' "$target" done diff --git a/bin/test-lint-unset-sentinel-millis.sh b/bin/test-lint-unset-sentinel-millis.sh index 94c509f415..ab0923182f 100755 --- a/bin/test-lint-unset-sentinel-millis.sh +++ b/bin/test-lint-unset-sentinel-millis.sh @@ -41,6 +41,27 @@ run_case() { fi } +# run_case_h - same, but the fixture is a HEADER, so class-scope +# cases can be pinned. A typed declaration means opposite things in a class body and a function body. +run_case_h() { + local name="$1" expect="$2" body="$3" + local dir="$WORK/case_h" + rm -rf "$dir" + mkdir -p "$dir/src" + printf '%s\n' "$body" >"$dir/src/fixture.h" + + local got want + got=$(cd "$dir" && "$LINT" src/fixture.h | awk -F: '{print $2}' | paste -sd, -) + want=$(printf '%s' "$expect" | paste -sd, -) + + if [[ $got == "$want" ]]; then + echo "PASS $name" + else + echo "FAIL $name: expected lines [$want], got [$got]" + FAILURES=$((FAILURES + 1)) + fi +} + # --- must be reported --------------------------------------------------------- run_case "bare millis() sum" "2" 'void f() { @@ -209,6 +230,82 @@ run_case "opt-out covers both writes on its own line only" "3" 'void f() { rebootAtMsec = millis() + 5000; }' +# --- clock held in a local --------------------------------------------------- +# +# The commonest shape in the tree: one `now = millis()` at the top of a runOnce(), then several +# writes from it. Without these the rule is blind to every such field and listing one buys nothing. + +run_case "stamp copied from a tainted local" "3" 'void f() { + unsigned long now = millis(); + rebootAtMsec = now; +}' + +run_case "deadline built from a tainted local" "3" 'void f() { + uint32_t now = Time::getMillis(); + tx_after = now + delay; +}' + +run_case "several writes from one tainted local" "3 +4 +5" 'void f() { + unsigned long now = millis(); + pulseOffAt = now; + rebootAtMsec = now + 5000; + lastSort = now; +}' + +run_case "taint carried one hop through another local" "4" 'void f() { + uint32_t now = millis(); + uint32_t alsoNow = now; + lastSort = alsoNow; +}' + +run_case "tainted local still fixable via the helpers" "" 'void f() { + unsigned long now = millis(); + rebootAtMsec = Time::skipZero(now); +}' + +run_case "tainted local with an opt-out" "" 'void f() { + unsigned long now = millis(); + // unset-sentinel-ok: heldX carries the armed state + nextRepeatX = now + JOY_REPEAT_INTERVAL_MS; +}' + +# --- the taint must NOT spread further than one function, one name ----------- + +run_case "untainted local is not flagged" "" 'void f() { + uint32_t now = packet->rx_time; + rebootAtMsec = now; +}' + +run_case "similarly named local is not tainted" "" 'void f() { + uint32_t now = millis(); + rebootAtMsec = nowMs; +}' + +run_case "taint dropped when the local is reassigned from something else" "" 'void f() { + uint32_t now = millis(); + now = packet->rx_time; + rebootAtMsec = now; +}' + +run_case "taint does not cross a function boundary" "" 'void f() { + uint32_t now = millis(); +} +void g() { + rebootAtMsec = now; +}' + +run_case "taint from a comparison is not recorded" "" 'void f() { + if (now == millis()) {} + rebootAtMsec = now; +}' + +run_case "compound assignment does not taint" "" 'void f() { + now += millis(); + rebootAtMsec = now; +}' + # --- one write must not be judged by its neighbour on the same line ---------- # # rhs used to run to the end of the accumulated statement, so a neighbour decided this write. @@ -239,6 +336,120 @@ run_case "multi-line statement still sees its whole right-hand side" "2" 'void f millis() + 43200 * 1000; }' +# --- stampMillis() is the read-side dodge, not a raw clock ------------------- +# +# Its name ends in millis, so the clock-read test matches it. It must still count as safe, or every +# site that normalises at the read and then stores the local gets flagged. + +run_case "storing a dodged local straight through is safe" "" 'void f() { + uint32_t now = Time::stampMillis(); + lastSort = now; +}' + +# A dodged value is safe to store or copy, NOT to do arithmetic on: stampMillis() guarantees only its +# own result, and 0xFFFFEC78 + 5000 is exactly 0. That sum is what timerEndsAtMillis() is for. +run_case "arithmetic on a dodged local can wrap back onto 0" "3" 'void f() { + uint32_t now = Time::stampMillis(); + rebootAtMsec = now + 5000; +}' + +run_case "arithmetic on a direct helper call is reported too" "2" 'void f() { + rebootAtMsec = Time::stampMillis() + 5000; +}' + +run_case "an operator INSIDE the helper call is fine" "" 'void f() { + lastSort = Time::skipZero(Time::getMillis() - msAgo); +}' + +run_case "copying a dodged local one more hop stays safe" "" 'void f() { + uint32_t now = Time::stampMillis(); + uint32_t alsoNow = now; + lastSort = alsoNow; +}' + +run_case "stampMillis directly in the write is safe" "" 'void f() { + lastSort = Time::stampMillis(); +}' + +run_case "a raw read after a safe one re-taints the local" "5" 'void f() { + uint32_t now = Time::stampMillis(); + lastSort = now; + now = millis(); + rebootAtMsec = now; +}' + +# --- class scope versus function scope --------------------------------------- +# +# A typed declaration is a shadowing local inside a function, but AT CLASS SCOPE it is the field +# itself, with an initializer that can read the clock - src/modules/SerialModule.h does that today. +# Excusing the second as a local silently skipped a real arm site. + +run_case_h "class member initialised from the clock is reported" "2" 'class Foo { + uint32_t lastSort = millis(); +};' + +run_case_h "local inside an inline method is still excused" "6" 'class Foo { + void tick() + { + uint32_t lastSort = millis(); + } + uint32_t lastDrawMsec = millis(); +};' + +run_case_h "member already routed through the helpers is quiet" "" 'class Foo { + uint32_t lastSort = Time::stampMillis(); +};' + +run_case_h "forward declaration does not open a class body" "3" 'class Foo; +void f() { + lastSort = millis(); +}' + +# --- class scope must not swallow ordinary function bodies -------------------- +# +# The keyword appears mid-line in shapes that are not class bodies, and a body opened on the same +# line puts the statement inside a function. All three reported every typed local in the body. + +run_case "template on a function is not a class body" "" 'template void f(T x) { + uint32_t lastSort = millis(); +}' + +run_case "struct in a parameter list is not a class body" "" 'void g(struct Bar *b) { + uint32_t lastSort = millis(); +}' + +run_case_h "one-line inline method is a function body" "" 'class Foo { + void tick() { uint32_t lastSort = millis(); } +};' + +run_case_h "class with a multi-line method: member yes, local no" "6" 'class Foo { + void tick() + { + uint32_t lastSort = millis(); + } + uint32_t lastDrawMsec = millis(); +};' + +# note_taint gets the same per-write cut the judging path has. +run_case "taint is not learned from a neighbour on the same line" "" 'void f() { + uint32_t a = 0; uint32_t now = packet->rx_time; + rebootAtMsec = now; +}' + +# --- a class body that opens and closes on one line --------------------------- +# +# The trailing semicolon cannot be used to rule out a class header, because the whole body fits on +# the line; and that line\'s own brace is the CLASS brace, not a function body. + +run_case_h "one-line class body reports its member initialiser" "1" 'class Foo { uint32_t lastSort = millis(); };' + +run_case_h "one-line class with a one-line method excuses the local" "" 'class Foo { void tick() { uint32_t lastSort = millis(); } };' + +run_case_h "forward declaration opens nothing" "3" 'class Foo; +void f() { + lastSort = millis(); +}' + # --- scope ------------------------------------------------------------------- # test/ builds raw wrap values on purpose, so the rule must not reach into it. diff --git a/src/Power.cpp b/src/Power.cpp index f230318f67..ee14b3c639 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -19,6 +19,7 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "Throttle.h" +#include "UptimeClock.h" #include "WaypointStore.h" #include "buzz/buzz.h" #include "configuration.h" @@ -1296,7 +1297,7 @@ void Power::logHeapUsage() memaudit::logBreakdown("periodic"); lastHeapLogFree = heapFree; - lastHeapLogTime = millis(); + lastHeapLogTime = Time::skipZero(Time::getMillis()); #endif } diff --git a/src/UptimeClock.h b/src/UptimeClock.h index 18895fd550..853fe7cbcf 100644 --- a/src/UptimeClock.h +++ b/src/UptimeClock.h @@ -58,6 +58,13 @@ inline uint32_t timerEndsAtMillis(uint32_t delayMs) return skipZero(getMillis() + delayMs); } +/// getMillis() for 0-means-unset stamps, with the 0 tick called 1. Use at the read when the value +/// is both stored and compared against stamps: skipZero() only at the store makes `now - stamp` wrap. +inline uint32_t stampMillis() +{ + return skipZero(getMillis()); +} + // skipZero() is the whole 0-means-unset contract in one expression, and it is constexpr, so pin it // here rather than only in test_uptime_clock: a build that breaks it stops at this header instead of // shipping a deadline that reads as never-set. The two obvious "simplifications" are what these diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 58c00e12ee..ca5ebe146a 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -332,7 +332,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd currentQuality = q; lastSetMsec = now; if (currentQuality >= RTCQualityNTP) { - lastSetFromPhoneNtpOrGps = now; + lastSetFromPhoneNtpOrGps = Time::skipZero(now); } // This delta value works on all platforms diff --git a/src/graphics/BaseUIEInkDisplay.cpp b/src/graphics/BaseUIEInkDisplay.cpp index 0506ba68dd..15d00f1ee7 100644 --- a/src/graphics/BaseUIEInkDisplay.cpp +++ b/src/graphics/BaseUIEInkDisplay.cpp @@ -1,6 +1,7 @@ #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS #include "./BaseUIEInkDisplay.h" +#include "UptimeClock.h" #include "configuration.h" #include "main.h" @@ -73,7 +74,7 @@ void BaseUIEInkDisplay::display() // Keyframe path. Returns true if a frame was pushed (sets lastDrawMsec). bool BaseUIEInkDisplay::forceDisplay(uint32_t msecLimit) { - const uint32_t now = millis(); + const uint32_t now = Time::stampMillis(); if (lastDrawMsec != 0 && (now - lastDrawMsec) < msecLimit) return false; diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index de058bc1f9..aec2c2caf2 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #include "graphics/Backlight.h" @@ -59,7 +60,7 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit) // No need to grab this lock because we are on our own SPI bus // concurrency::LockGuard g(spiLock); - uint32_t now = millis(); + uint32_t now = Time::stampMillis(); uint32_t sinceLast = now - lastDrawMsec; if (adafruitDisplay && (sinceLast > msecLimit || lastDrawMsec == 0)) diff --git a/src/graphics/EInkParallelDisplay.cpp b/src/graphics/EInkParallelDisplay.cpp index a61b1bae97..d8d0f9475c 100644 --- a/src/graphics/EInkParallelDisplay.cpp +++ b/src/graphics/EInkParallelDisplay.cpp @@ -1,4 +1,5 @@ #include "EInkParallelDisplay.h" +#include "UptimeClock.h" #ifdef USE_EINK_PARALLELDISPLAY @@ -208,7 +209,7 @@ void EInkParallelDisplay::display(void) const uint16_t h = this->displayHeight; // Simple rate limiting: avoid very-frequent responsive updates - uint32_t nowMs = millis(); + uint32_t nowMs = Time::stampMillis(); if (lastUpdateMs != 0 && (nowMs - lastUpdateMs) < EPD_RESPONSIVE_MIN_MS) { LOG_DEBUG("rate-limited, skipping update"); return; @@ -367,11 +368,11 @@ void EInkParallelDisplay::display(void) startAsyncFullUpdate(forceFull ? CLEAR_SLOW : CLEAR_FAST); } - lastUpdateMs = millis(); + lastUpdateMs = Time::stampMillis(); previousImageHash = imageHash; // Keep same behavior as before - lastDrawMsec = millis(); + lastDrawMsec = Time::stampMillis(); } #ifdef EINK_LIMIT_GHOSTING_PX @@ -420,7 +421,7 @@ bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit) if (!displayReady) return false; - uint32_t now = millis(); + uint32_t now = Time::stampMillis(); if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) { display(); return true; diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 6b2ed5b481..8d9c35ae42 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -493,7 +493,7 @@ float Screen::estimatedHeading(double lat, double lon) static double oldLat, oldLon; static float b = -1.0f; static uint32_t lastHeadingAtMs = 0; - const uint32_t now = millis(); + const uint32_t now = Time::stampMillis(); const uint32_t gpsUpdateIntervalSecs = Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval); uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs; diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 9a7638280c..58d759679b 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if HAS_SCREEN #include "CompassRenderer.h" @@ -2212,12 +2213,12 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta if (navBarVisible && !navBarPrevVisible) { EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when showing nav bar cosmeticRefreshDone = false; - navBarLastShown = millis(); + navBarLastShown = Time::skipZero(Time::getMillis()); } if (!navBarVisible && navBarPrevVisible) { - EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when hiding nav bar - navBarLastShown = millis(); // Mark when it disappeared + EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when hiding nav bar + navBarLastShown = Time::skipZero(Time::getMillis()); // Mark when it disappeared } if (!navBarVisible && navBarLastShown != 0 && !cosmeticRefreshDone) { diff --git a/src/input/ExpressLRSFiveWay.cpp b/src/input/ExpressLRSFiveWay.cpp index e9efeda52e..7d1e4de639 100644 --- a/src/input/ExpressLRSFiveWay.cpp +++ b/src/input/ExpressLRSFiveWay.cpp @@ -80,7 +80,7 @@ void ExpressLRSFiveWay::update(int *keyValue, bool *keyLongPressed) if (keyInProcess == NO_PRESS) { // New key down if (newKey != NO_PRESS) { - keyDownStart = Time::getMillis(); + keyDownStart = Time::skipZero(Time::getMillis()); // DBGLN("down=%u", newKey); } } else { diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp index 8951a00b1c..d8f99ccec0 100644 --- a/src/input/LinuxJoystick.cpp +++ b/src/input/LinuxJoystick.cpp @@ -167,10 +167,12 @@ int32_t LinuxJoystick::runOnce() uint32_t now = millis(); if (heldX != 0 && (int32_t)(now - nextRepeatX) >= 0) { emitEvent((heldX < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT); + // unset-sentinel-ok: heldX carries the armed state, so 0 is a legal deadline nextRepeatX = now + JOY_REPEAT_INTERVAL_MS; } if (heldY != 0 && (int32_t)(now - nextRepeatY) >= 0) { emitEvent((heldY < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN); + // unset-sentinel-ok: heldY carries the armed state, so 0 is a legal deadline nextRepeatY = now + JOY_REPEAT_INTERVAL_MS; } diff --git a/src/input/RotaryEncoderInterruptBase.cpp b/src/input/RotaryEncoderInterruptBase.cpp index c177403bf0..20e40d58e7 100644 --- a/src/input/RotaryEncoderInterruptBase.cpp +++ b/src/input/RotaryEncoderInterruptBase.cpp @@ -1,4 +1,5 @@ #include "RotaryEncoderInterruptBase.h" +#include "UptimeClock.h" #include "configuration.h" RotaryEncoderInterruptBase::RotaryEncoderInterruptBase(const char *name) : concurrency::OSThread(name) @@ -48,13 +49,14 @@ int32_t RotaryEncoderInterruptBase::runOnce() InputEvent e = {}; e.inputEvent = INPUT_BROKER_NONE; e.source = this->_originName; - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); // Handle press long/short detection if (this->action == ROTARY_ACTION_PRESSED) { bool buttonPressed = !digitalRead(_pinPress); if (!pressDetected && buttonPressed) { pressDetected = true; + // unset-sentinel-ok: pressDetected is the armed flag; no read tests the stamp against 0 pressStartTime = now; pressAndTurnFired = false; } diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index 2be2495e12..e30cddd90e 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -203,20 +203,20 @@ int32_t TrackballInterruptBase::runOnce() if (e.inputEvent == INPUT_BROKER_NONE) { if (this->action == TB_ACTION_UP && !digitalRead(_pinUp) && !directionDetected) { directionDetected = true; - directionStartTime = millis(); + directionStartTime = Time::skipZero(Time::getMillis()); e.inputEvent = this->_eventUp; // send event first,will automatically trigger every 50ms * 3 after 500ms } else if (this->action == TB_ACTION_DOWN && !digitalRead(_pinDown) && !directionDetected) { directionDetected = true; - directionStartTime = millis(); + directionStartTime = Time::skipZero(Time::getMillis()); e.inputEvent = this->_eventDown; } else if (this->action == TB_ACTION_LEFT && !digitalRead(_pinLeft) && !directionDetected) { directionDetected = true; - directionStartTime = millis(); + directionStartTime = Time::skipZero(Time::getMillis()); e.inputEvent = this->_eventLeft; } else if (this->action == TB_ACTION_RIGHT && !digitalRead(_pinRight) && !directionDetected) { directionDetected = true; - directionStartTime = millis(); + directionStartTime = Time::skipZero(Time::getMillis()); e.inputEvent = this->_eventRight; } } diff --git a/src/input/UpDownInterruptBase.cpp b/src/input/UpDownInterruptBase.cpp index d597c8d8f4..5ad0d64ff0 100644 --- a/src/input/UpDownInterruptBase.cpp +++ b/src/input/UpDownInterruptBase.cpp @@ -1,4 +1,5 @@ #include "UpDownInterruptBase.h" +#include "UptimeClock.h" #include "configuration.h" UpDownInterruptBase::UpDownInterruptBase(const char *name) : concurrency::OSThread(name) @@ -50,7 +51,7 @@ int32_t UpDownInterruptBase::runOnce() { InputEvent e = {}; e.inputEvent = INPUT_BROKER_NONE; - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); // Read all button states once at the beginning bool pressButtonPressed = !digitalRead(_pinPress); diff --git a/src/mesh/IndicatorSerial.cpp b/src/mesh/IndicatorSerial.cpp index 74608b5fa9..e146178954 100644 --- a/src/mesh/IndicatorSerial.cpp +++ b/src/mesh/IndicatorSerial.cpp @@ -1,6 +1,7 @@ #ifdef SENSECAP_INDICATOR #include "IndicatorSerial.h" +#include "UptimeClock.h" #include "concurrency/LockGuard.h" #include "mesh/comms/UARTProxy.h" #include @@ -61,7 +62,7 @@ void SensecapIndicator::probe_link() msg.data.ping = meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT; stamp_request(msg); send_uplink_unlocked(msg); - last_probe = millis(); + last_probe = Time::skipZero(Time::getMillis()); } // Read whatever is available on the link and process complete packets diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index f6c8857f46..00ccc814ef 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -201,7 +201,7 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast p->relay_node, wasAlreadyRelayer, weWereSoleRelayer); origTx->next_hop = p->relay_node; } - noteRouteLearned(p->from, p->relay_node, Time::getMillis()); // M3: anchor freshness (hot or overflow route) + noteRouteLearned(p->from, p->relay_node, Time::stampMillis()); // M3: anchor freshness (hot or overflow route) #if HAS_TRAFFIC_MANAGEMENT // Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it // survives even when the source isn't (or is no longer) in the hot NodeDB. @@ -313,7 +313,7 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) // a health record that still matches the stored byte; a next_hop set by another path (e.g. // TraceRouteModule) with no matching record is left authoritative. const RouteHealth *h = findRouteHealth(to); - if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, Time::getMillis())) { + if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, Time::stampMillis())) { LOG_INFO("Next hop 0x%x for 0x%08x stale (age/fails); flood and clear", node->next_hop, to); node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route clearRouteHealth(to); // clear RAM health @@ -345,7 +345,7 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) uint8_t hint = trafficManagementModule->getNextHopHint(to); if (hint && hint != relay_node) { const RouteHealth *h = findRouteHealth(to); - if (h && h->lastNextHop == hint && isRouteStale(*h, Time::getMillis())) { + if (h && h->lastNextHop == hint && isRouteStale(*h, Time::stampMillis())) { LOG_INFO("TMM next hop 0x%x for 0x%08x stale (age/fails); flood and clear", hint, to); trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0) clearRouteHealth(to); // clear RAM health @@ -444,7 +444,7 @@ int32_t NextHopRouter::doRetransmissions() { // Same clock Throttle reads, so setNextTx() deadlines and this test can't diverge under an // injected test clock. - uint32_t now = Time::getMillis(); + uint32_t now = Time::stampMillis(); int32_t d = INT32_MAX; // FIXME, we should use a better datastructure rather than walking through this map. @@ -617,6 +617,7 @@ void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now h->lastNextHop = nextHop; h->consecutiveFailures = 0; } + // `now` is a parameter, so guard at the store too: 0 is the empty-slot marker. h->learnedAtMsec = Time::skipZero(now); } @@ -626,7 +627,7 @@ void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now) if (!h) return; // only routes we actually learned have health to refresh h->consecutiveFailures = 0; - h->learnedAtMsec = Time::skipZero(now); + h->learnedAtMsec = Time::skipZero(now); // a parameter, so guard at the store too } void NextHopRouter::noteRouteFailure(NodeNum dest) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 17f063a64f..aa5ff2d48e 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -245,7 +245,7 @@ static void clearAuthSlot_LH(const PhoneAPI *p) PhoneAPI::PhoneAPI() { - lastContactMsec = millis(); + lastContactMsec = Time::skipZero(Time::getMillis()); std::fill(std::begin(recentToRadioPacketIds), std::end(recentToRadioPacketIds), 0); } @@ -437,7 +437,7 @@ bool PhoneAPI::checkConnectionTimeout() bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) { powerFSM.trigger(EVENT_CONTACT_FROM_PHONE); // As long as the phone keeps talking to us, don't let the radio go to sleep - lastContactMsec = millis(); + lastContactMsec = Time::skipZero(Time::getMillis()); memset(&toRadioScratch, 0, sizeof(toRadioScratch)); if (pb_decode_from_bytes(buf, bufLength, &meshtastic_ToRadio_msg, &toRadioScratch)) { diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index a3c86cd0ef..7be23ee810 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -181,7 +181,7 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas // M3: an end-to-end ACK proves the directed route to the ACK's sender currently works, // so clear its failure count and refresh freshness (keeps a good route pinned). if (!isBroadcast(getFrom(p))) - noteRouteSuccess(getFrom(p), Time::getMillis()); + noteRouteSuccess(getFrom(p), Time::stampMillis()); } else { stopRetransmission(p->to, nakId); } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index c4d670f830..55a10ebaa4 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1151,7 +1151,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) JSONFile.close(); } JSONFile.open(portduino_config.JSONFilename + "_" + datetime, std::ios::out | std::ios::app); - fileage = millis(); + fileage = Time::skipZero(Time::getMillis()); } } if (portduino_config.JSONFilter == (_meshtastic_PortNum)0 || portduino_config.JSONFilter == p->decoded.portnum) { diff --git a/src/mesh/TransmitHistory.cpp b/src/mesh/TransmitHistory.cpp index 35144ec0d7..4b2ebc8a37 100644 --- a/src/mesh/TransmitHistory.cpp +++ b/src/mesh/TransmitHistory.cpp @@ -1,6 +1,7 @@ #include "TransmitHistory.h" #include "FSCommon.h" #include "SPILock.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include @@ -81,7 +82,7 @@ void TransmitHistory::loadFromDisk() void TransmitHistory::setLastSentToMesh(uint16_t key) { - lastMillis[key] = millis(); + lastMillis[key] = Time::skipZero(Time::getMillis()); uint32_t now = getTime(); if (now >= 2) { const uint8_t flags = (getRTCQuality() == RTCQualityNone) ? ENTRY_FLAG_BOOT_RELATIVE : ENTRY_FLAG_NONE; @@ -94,7 +95,7 @@ void TransmitHistory::setLastSentToMesh(uint16_t key) // after boot so a crash-reboot loop can't avoid persisting. if (lastDiskSave == 0 || !Throttle::isWithinTimespanMs(lastDiskSave, SAVE_INTERVAL_MS)) { if (saveToDisk()) { - lastDiskSave = millis(); + lastDiskSave = Time::skipZero(Time::getMillis()); } } } @@ -151,7 +152,7 @@ uint32_t TransmitHistory::getLastSentAbsoluteMillis(uint32_t storedEpoch) const return 0; } - return millis() - msAgo; + return Time::skipZero(Time::getMillis() - msAgo); } uint32_t TransmitHistory::getLastSentBootRelativeMillis(uint32_t storedSeconds) const @@ -167,7 +168,7 @@ uint32_t TransmitHistory::getLastSentBootRelativeMillis(uint32_t storedSeconds) if (secondsAgo > BOOT_RELATIVE_RECOVERY_WINDOW_SEC) { return 0; } - return millis() - (secondsAgo * 1000); + return Time::skipZero(Time::getMillis() - (secondsAgo * 1000)); } uint32_t secondsAhead = storedSeconds - now; @@ -175,7 +176,7 @@ uint32_t TransmitHistory::getLastSentBootRelativeMillis(uint32_t storedSeconds) return 0; } - return millis(); + return Time::skipZero(Time::getMillis()); } uint32_t TransmitHistory::getLastSentToMeshMillis(uint16_t key) const @@ -286,7 +287,7 @@ void TransmitHistory::loadFromDisk() {} void TransmitHistory::setLastSentToMesh(uint16_t key) { - lastMillis[key] = millis(); + lastMillis[key] = Time::skipZero(Time::getMillis()); } uint32_t TransmitHistory::getLastSentToMeshEpoch(uint16_t key) const diff --git a/src/mesh/api/PacketAPI.cpp b/src/mesh/api/PacketAPI.cpp index c8adda5204..3590cfdb76 100644 --- a/src/mesh/api/PacketAPI.cpp +++ b/src/mesh/api/PacketAPI.cpp @@ -2,6 +2,7 @@ // First, in its own block so the include sorter keeps it there: configuration.h supplies the // variant defines mesh-pb-constants.h needs (portduino resolves MAX_NUM_NODES at runtime). +#include "UptimeClock.h" #include "configuration.h" #include "MeshService.h" @@ -59,7 +60,7 @@ bool PacketAPI::receivePacket(void) data_received = true; powerFSM.trigger(EVENT_INPUT); - lastContactMsec = millis(); + lastContactMsec = Time::skipZero(Time::getMillis()); meshtastic_ToRadio *mr; auto p = server->receivePacket()->move(); diff --git a/src/mesh/eth/ethOTA.cpp b/src/mesh/eth/ethOTA.cpp index b99ff73046..458b9cf5fc 100644 --- a/src/mesh/eth/ethOTA.cpp +++ b/src/mesh/eth/ethOTA.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if HAS_ETHERNET && defined(HAS_ETHERNET_OTA) @@ -119,7 +120,7 @@ static bool authenticateClient(EthernetClient &client) uint8_t clientHash[OTA_HASH_SIZE]; if (!readExact(client, clientHash, OTA_HASH_SIZE)) { LOG_WARN("ETH OTA: Timeout reading auth response"); - lastAuthFailure = millis(); + lastAuthFailure = Time::skipZero(Time::getMillis()); return false; } @@ -136,7 +137,7 @@ static bool authenticateClient(EthernetClient &client) if (diff != 0) { LOG_WARN("ETH OTA: Authentication failed"); client.write(OTA_ERR_AUTH); - lastAuthFailure = millis(); + lastAuthFailure = Time::skipZero(Time::getMillis()); return false; } diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index 8a44895241..befc5d8776 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -111,7 +111,7 @@ static void handleWebResponse() static uint32_t lastHeapWarning = 0; if (lastHeapWarning == 0 || !Throttle::isWithinTimespanMs(lastHeapWarning, 30000)) { LOG_WARN("Low heap (%u bytes), not accepting HTTPS connections", freeHeap); - lastHeapWarning = millis(); + lastHeapWarning = Time::skipZero(Time::getMillis()); } } } diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 35cb5653eb..8f53187279 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if HAS_WIFI #include "NodeDB.h" @@ -313,7 +314,7 @@ static int32_t reconnectWiFi() tv.tv_usec = 0; perhapsSetRTC(RTCQualityNTP, &tv); - lastrun_ntp = millis(); + lastrun_ntp = Time::skipZero(Time::getMillis()); } else { LOG_DEBUG("NTP Update failed"); } diff --git a/src/modules/DropzoneModule.cpp b/src/modules/DropzoneModule.cpp index 100b87662c..4adcf8c4a7 100644 --- a/src/modules/DropzoneModule.cpp +++ b/src/modules/DropzoneModule.cpp @@ -2,6 +2,7 @@ #include "DropzoneModule.h" #include "Meshservice->h" +#include "UptimeClock.h" #include "configuration.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" @@ -39,13 +40,13 @@ ProcessMessage DropzoneModule::handleReceived(const meshtastic_MeshPacket &mp) snprintf(matchCompare, sizeof(matchCompare), "%s conditions", owner.short_name); if (received >= strlen(matchCompare) && strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) { LOG_DEBUG("Received dropzone conditions request"); - startSendConditions = millis(); + startSendConditions = Time::skipZero(Time::getMillis()); } snprintf(matchCompare, sizeof(matchCompare), "%s conditions", owner.long_name); if (received >= strlen(matchCompare) && strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) { LOG_DEBUG("Received dropzone conditions request"); - startSendConditions = millis(); + startSendConditions = Time::skipZero(Time::getMillis()); } return ProcessMessage::CONTINUE; } diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index eb6c496413..29594f64ab 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -3,6 +3,7 @@ #include "CryptoEngine.h" #include "HardwareRNG.h" #include "MeshService.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include "graphics/draw/MenuHandler.h" #include "main.h" @@ -400,7 +401,7 @@ void KeyVerificationModule::resetToIdle() memset(hash1, 0, 32); memset(hash2, 0, 32); if (sessionFromRemote) - lastRemoteSessionMs = millis(); // start the cooldown when the session ends, not when it opened + lastRemoteSessionMs = Time::skipZero(Time::getMillis()); // start the cooldown when the session ends, not when it opened sessionFromRemote = false; currentNonce = 0; currentNonceTimestamp = 0; diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 213237341a..68a7ccdf66 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -35,6 +35,7 @@ PositionModule::PositionModule() if (transmitHistory) { uint32_t restored = transmitHistory->getLastSentToMeshMillis(meshtastic_PortNum_POSITION_APP); if (restored != 0) { + // unset-sentinel-ok: the enclosing restored != 0 already rules out the unset value lastGpsSend = restored; LOG_INFO("Position: restored lastGpsSend from transmit history"); } @@ -550,7 +551,7 @@ int32_t PositionModule::runOnce() if (node == nullptr) return RUNONCE_INTERVAL; - uint32_t now = Time::getMillis(); + uint32_t now = Time::stampMillis(); // Local-only delivery, so it runs regardless of mesh opt-in state or channel utilization. // Only send while the queue is empty (phone assumed connected), like telemetry. The cadence @@ -709,7 +710,7 @@ void PositionModule::trySmartBroadcast(const meshtastic_PositionLite &selfPos, u if (!sendOurPosition()) return; - lastGpsSend = nowMs; + lastGpsSend = Time::skipZero(nowMs); // nowMs is a parameter, so guard at the store as well if (transmitHistory) transmitHistory->setLastSentToMesh(meshtastic_PortNum_POSITION_APP); LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " @@ -729,7 +730,7 @@ void PositionModule::handleNewPosition() meshtastic_PositionLite selfPos; if (!nodeDB->copyNodePosition(node->num, selfPos)) return; - trySmartBroadcast(selfPos, Time::getMillis()); + trySmartBroadcast(selfPos, Time::stampMillis()); } } diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index 2eb596bd96..bfb0ab73bb 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -1,4 +1,5 @@ #include "DebugConfiguration.h" +#include "UptimeClock.h" #include "configuration.h" #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR @@ -244,7 +245,7 @@ int32_t AirQualityTelemetryModule::runOnce() } else if (phoneDue && phoneAllowed) { // Mesh transmission isn't due yet, but we can still update the phone. if (sendTelemetry(NODENUM_BROADCAST, true)) { - lastSentToPhone = millis(); + lastSentToPhone = Time::skipZero(Time::getMillis()); // Correct the awake time, trimming to 0 const unsigned long elapsed = millis() - startAirQualityTelemetryCycle; awakeAheadOfTimeMs = elapsed >= awakeAheadOfTimeMs ? 0 : awakeAheadOfTimeMs - elapsed; diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index 8d3834137e..1b6b074e00 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -41,7 +41,7 @@ int32_t DeviceTelemetryModule::runOnce() sendTelemetry(NODENUM_BROADCAST, true); if (lastSentStatsToPhone == 0 || Throttle::hasElapsed(lastSentStatsToPhone, sendStatsToPhoneIntervalMs)) { sendLocalStatsToPhone(); - lastSentStatsToPhone = Time::getMillis(); + lastSentStatsToPhone = Time::skipZero(Time::getMillis()); } } return sendToPhoneIntervalMs; diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index a4143de299..e638a5db53 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR @@ -466,7 +467,7 @@ int32_t EnvironmentTelemetryModule::runOnce() // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + lastSentToPhone = Time::skipZero(Time::getMillis()); } } if (sleepOnNextExecution) { diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index 6ec316e701..10b8d68153 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY && !defined(ARCH_PORTDUINO) @@ -90,7 +91,7 @@ int32_t HealthTelemetryModule::runOnce() // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + lastSentToPhone = Time::skipZero(Time::getMillis()); } } if (sleepOnNextExecution) { diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 684ce20133..f73fbf8120 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR @@ -105,7 +106,7 @@ int32_t PowerTelemetryModule::runOnce() // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + lastSentToPhone = Time::skipZero(Time::getMillis()); } } if (sleepOnNextExecution) { diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index 310cf4bc1b..4549f19df9 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -1,6 +1,7 @@ #include "TraceRouteModule.h" #include "MeshService.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "graphics/Screen.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" @@ -534,7 +535,7 @@ const char *TraceRouteModule::getNodeName(NodeNum node) bool TraceRouteModule::startTraceRoute(NodeNum node) { LOG_INFO("TraceRoute startTraceRoute: node=0x%08x", node); - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); if (node == 0 || node == NODENUM_BROADCAST) { LOG_ERROR("Invalid trace route node: 0x%08x", node); @@ -700,7 +701,7 @@ void TraceRouteModule::launch(NodeNum node) LOG_INFO("TraceRoute first init"); } - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); if (initialized && lastTraceRouteTime > 0 && now - lastTraceRouteTime < cooldownMs) { unsigned long wait = (cooldownMs - (now - lastTraceRouteTime)) / 1000; bannerText = String("Wait for ") + String(wait) + String("s"); @@ -842,7 +843,7 @@ void TraceRouteModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state #endif // HAS_SCREEN int32_t TraceRouteModule::runOnce() { - unsigned long now = millis(); + unsigned long now = Time::stampMillis(); if (runState == TRACEROUTE_STATE_IDLE) { return INT32_MAX; diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index f12fbfbcdc..f0892e4590 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -1,4 +1,5 @@ #include "TrafficManagementModule.h" +#include "UptimeClock.h" #if HAS_TRAFFIC_MANAGEMENT @@ -1523,7 +1524,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // request declined above never spends the budget). false forwards the request instead of consuming // it. Rationale in https://meshtastic.org/docs/development/reference/traffic-management-internals "Throttling direct // responses". - if (!directResponseAllowed(getFrom(p), p->to, clockMs())) { + if (!directResponseAllowed(getFrom(p), p->to, Time::skipZero(clockMs()))) { TM_LOG_DEBUG("NodeInfo direct response throttled for 0x%08x; forwarding request", getFrom(p)); return false; } @@ -1627,7 +1628,7 @@ bool TrafficManagementModule::directResponseAllowed(NodeNum requester, NodeNum t reqSlot->lastReplyMs = nowMs; tgtSlot->key = target; tgtSlot->lastReplyMs = nowMs; - lastDirectResponseMs = nowMs; + lastDirectResponseMs = Time::skipZero(nowMs); // a parameter, so guard at the store as well return true; } diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 1c6cd57a0c..8108ba050c 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -3,6 +3,7 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "ServiceEnvelope.h" +#include "UptimeClock.h" #include "configuration.h" #include "main.h" #include "mesh/Channels.h" @@ -870,5 +871,5 @@ void MQTT::perhapsReportToMap() packetPool.release(mp); // Update the last report time - last_report_to_map = millis(); + last_report_to_map = Time::skipZero(Time::getMillis()); } diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp index 548fec421d..39ebcdc231 100644 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #ifdef T5_S3_EPAPER_PRO @@ -555,7 +556,7 @@ struct TouchLightSleepEndObserver { } touchStateEpoch++; - touchResumeAtMs = millis(); + touchResumeAtMs = Time::skipZero(Time::getMillis()); touchIndicatorRefreshPending = !isTouchInputEnabled(); #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // Clear sleep-time touch overlay after wake. @@ -602,7 +603,7 @@ bool readTouch(int16_t *x, int16_t *y) LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); touch.wakeup(); touchNeedsWake = false; - suppressFromMs = millis(); + suppressFromMs = Time::skipZero(Time::getMillis()); return false; } diff --git a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp b/src/platform/nrf54l15/NRF54L15Bluetooth.cpp index 9ce0326316..8cef2fb59c 100644 --- a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp +++ b/src/platform/nrf54l15/NRF54L15Bluetooth.cpp @@ -21,6 +21,7 @@ #include "BluetoothCommon.h" #include "BluetoothStatus.h" #include "PowerFSM.h" +#include "UptimeClock.h" #include "concurrency/OSThread.h" #include "configuration.h" #include "main.h" @@ -387,7 +388,7 @@ static void connected_cb(struct bt_conn *conn, uint8_t err) k_mutex_unlock(&ble_mutex); memset(lastToRadio, 0, sizeof(lastToRadio)); - connect_time_ms = k_uptime_get_32(); + connect_time_ms = Time::skipZero(k_uptime_get_32()); last_att_time_ms = connect_time_ms; char addr[BT_ADDR_LE_STR_LEN]; diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 1f363edfa9..efeadedfeb 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,4 +1,5 @@ #include "FSCommon.h" +#include "UptimeClock.h" #include "configuration.h" #include "error.h" #include "gps/GPS.h" @@ -228,7 +229,7 @@ void preFSBegin() if (g_lfsCorruptMagic != LFS_CORRUPT_MAGIC) return; g_lfsCorruptMagic = 0; - lastLfsFormatMs = millis(); + lastLfsFormatMs = Time::skipZero(Time::getMillis()); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE); fsFormat(); LOG_INFO("LittleFS format complete; restoring default settings"); diff --git a/test/test_uptime_clock/test_main.cpp b/test/test_uptime_clock/test_main.cpp index daf4cbed31..db0adacdb0 100644 --- a/test/test_uptime_clock/test_main.cpp +++ b/test/test_uptime_clock/test_main.cpp @@ -104,6 +104,61 @@ void test_timerEndsAtMillis_dodges_the_wrap_tick_itself() TEST_ASSERT_EQUAL_UINT32(1u, Time::timerEndsAtMillis(0)); // getMillis()==0, delayMs==0: sum is 0 } +// --- stampMillis(): one value for storing a stamp AND measuring against it --- + +void test_stampMillis_matches_getMillis_away_from_the_wrap() +{ + Time::setTestMillis(123456u); + TEST_ASSERT_EQUAL_UINT32(123456u, Time::stampMillis()); +} + +void test_stampMillis_calls_the_zero_tick_one() +{ + Time::setTestMillis(0); + TEST_ASSERT_EQUAL_UINT32(1u, Time::stampMillis()); +} + +// The regression this helper exists for. Applying skipZero() at the STORE while a reader measures +// elapsed time against a raw clock splits the two sides apart on the wrap tick: the stamp is 1 while +// now is still 0, so `now - stamp` is UINT32_MAX and a brand new stamp reads as ~49.7 days old. Any +// elapsed-since guard - a cooldown, a debounce, a long-press threshold - then fires when it must not. +void test_store_side_dodge_alone_makes_a_fresh_stamp_read_as_ancient() +{ + Time::setTestMillis(0); + const uint32_t rawNow = Time::getMillis(); // what an un-normalised reader holds: 0 + const uint32_t stampedAtStore = Time::skipZero(rawNow); // the old shape: dodge only at the store + TEST_ASSERT_EQUAL_UINT32(0u, rawNow); + TEST_ASSERT_EQUAL_UINT32(1u, stampedAtStore); + TEST_ASSERT_EQUAL_UINT32(UINT32_MAX, (uint32_t)(rawNow - stampedAtStore)); // the whole problem +} + +void test_reading_through_stampMillis_keeps_elapsed_at_zero_on_the_wrap_tick() +{ + Time::setTestMillis(0); + const uint32_t now = Time::stampMillis(); // read once, used for the store AND the comparison + const uint32_t stamp = now; // store it as-is; no second dodge needed + TEST_ASSERT_EQUAL_UINT32(1u, now); + TEST_ASSERT_EQUAL_UINT32(0u, (uint32_t)(now - stamp)); // fresh reads as fresh + + // Once the clock moves on, the measured age is short by exactly the 1 ms the dodge introduced: + // the stamp was taken at tick 0 and recorded as 1, so 400 ticks later it reads as 399 old. That + // is the whole cost of the scheme, and it is the same 1 ms skew skipZero() already documents - + // pinned here so nobody "corrects" it to 400 and reintroduces a raw read on one side. + Time::advanceTestMillis(400u); + TEST_ASSERT_EQUAL_UINT32(399u, (uint32_t)(Time::stampMillis() - stamp)); +} + +// A stamp stored one tick BEFORE the wrap, read one tick after, must still measure 1 ms - the dodge +// must not disturb ordinary wrap-crossing arithmetic. +void test_stampMillis_measures_across_the_wrap_boundary() +{ + Time::setTestMillis(0xFFFFFFFFu); + const uint32_t stamp = Time::stampMillis(); + TEST_ASSERT_EQUAL_UINT32(0xFFFFFFFFu, stamp); + Time::advanceTestMillis(1u); // wraps to 0, which stampMillis reports as 1 + TEST_ASSERT_EQUAL_UINT32(2u, (uint32_t)(Time::stampMillis() - stamp)); +} + // --- getMillisMonotonic(): the published wrap carry --- void test_monotonic_matches_millis_before_any_wrap() @@ -373,6 +428,11 @@ void setup() RUN_TEST(test_advanceTestMillis_wraps_like_millis); RUN_TEST(test_skipZero_maps_zero_to_one); RUN_TEST(test_skipZero_leaves_nonzero_values_alone); + RUN_TEST(test_stampMillis_matches_getMillis_away_from_the_wrap); + RUN_TEST(test_stampMillis_calls_the_zero_tick_one); + RUN_TEST(test_store_side_dodge_alone_makes_a_fresh_stamp_read_as_ancient); + RUN_TEST(test_reading_through_stampMillis_keeps_elapsed_at_zero_on_the_wrap_tick); + RUN_TEST(test_stampMillis_measures_across_the_wrap_boundary); RUN_TEST(test_timerEndsAtMillis_is_an_ordinary_sum_away_from_the_wrap); RUN_TEST(test_timerEndsAtMillis_dodges_a_sum_that_wraps_to_zero); RUN_TEST(test_timerEndsAtMillis_dodges_the_wrap_tick_itself); From 32eb1a1237bef6fa92b5484ac5c0fea14fe14de5 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Mon, 14 Sep 2026 12:39:25 +0000 Subject: [PATCH 135/143] Honor an explicit -c config path when -s is given (#11348) The simradio flag (-s) is the first branch of an if/else-if chain that also handles config loading, so it short-circuits every later branch -- including the one for an explicit -c . Skipping config discovery under -s is intended, but a config path the user passed by hand is not discovery, and it is silently ignored today. Move the -s check after the -c branch so an explicit path is always parsed, and skip only the implicit discovery (./config.yaml, /etc/meshtasticd/config.yaml) when -s is given without -c. The radio override then runs after every config source, since -c and its ConfigDirectory entries can both set Lora.Module and -s has to win over them. Doing it there also fixes --check and --output-yaml, which reported the configured module rather than the simulated one because the old override sat behind an early return. Behaviour with a bare -s is unchanged: no YAML is loaded and the radio is the simulator. Co-authored-by: Jonathan Bennett --- src/platform/portduino/PortduinoGlue.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 7ac778e3c4..f7aa2726ae 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -331,9 +331,9 @@ void portduinoSetup() return; #endif - if (portduino_config.force_simradio == true) { - portduino_config.lora_module = use_simradio; - } else if (configPath != nullptr) { + // An explicit -c is honored even under -s: it also carries non-radio settings + // (EnableUDP, display, GPIO) that have to survive simulated mode. + if (configPath != nullptr) { if (loadConfig(configPath)) { if (!yamlOnly && !configCheck) std::cout << "Using " << configPath << " as config file" << std::endl; @@ -343,6 +343,8 @@ void portduinoSetup() std::cout << "Unable to use " << configPath << " as config file" << std::endl; exit(EXIT_FAILURE); } + } else if (portduino_config.force_simradio) { + // -s with no -c: the simulator brings its own defaults, so skip config discovery. } else if (access("config.yaml", R_OK) == 0) { if (loadConfig("config.yaml")) { if (!yamlOnly && !configCheck) @@ -390,6 +392,12 @@ void portduinoSetup() } } + // Applied after every config source: ConfigDirectory entries can set Lora.Module + // too, and -s must win over all of them, including in --check / --output-yaml. + if (portduino_config.force_simradio) { + portduino_config.lora_module = use_simradio; + } + #ifndef ARCH_PORTDUINO_WASM // --check wins over --output-yaml: asking for validation and getting a config dump // with no report at all would be the more surprising of the two outcomes. From f5158f50be68a49e61593d48cfc106b30b904c77 Mon Sep 17 00:00:00 2001 From: Tadayoshi MIURA <11958457+t-miura@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:02:57 +0000 Subject: [PATCH 136/143] feat(rp2040/rp2350): Update earlephilhower/arduino-pico to 6.1.0, bump maxgerhardt/platform-raspberrypi to latest (#11814) * Update earlephilhower/arduino-pico to 6.1.0 * feat(rp2040/rp2350) bump platform-raspberrypi * fix(rp2040/rp2350) recover printf and scanf --- variants/rp2040/rp2040.ini | 6 ++++-- variants/rp2350/rp2350.ini | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index f84b6ee09f..1a061c29c1 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -2,12 +2,12 @@ [rp2040_base] platform = # TODO renovate - https://github.com/maxgerhardt/platform-raspberrypi#aa70b802be8851668053d4f09734e4089fe41932 + https://github.com/maxgerhardt/platform-raspberrypi#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1 ; For arduino-pico >= 5.6.1 extends = arduino_base platform_packages = # TODO renovate - arduino-pico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip + framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip board_build.core = earlephilhower board_build.filesystem_size = 0.5m @@ -18,6 +18,8 @@ build_flags = -Isrc/platform/rp2xx0/pico_sleep/include -D__PLAT_RP2040__ -D__FREERTOS=1 + -Wl,-u,_printf_float + -Wl,-u,_scanf_float # -D _POSIX_THREADS build_src_filter = ${arduino_base.build_src_filter} + - - - - - - diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index bde4fcce34..f81b99f15f 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -2,12 +2,12 @@ [rp2350_base] platform = # TODO renovate - https://github.com/maxgerhardt/platform-raspberrypi#aa70b802be8851668053d4f09734e4089fe41932 + https://github.com/maxgerhardt/platform-raspberrypi#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1 ; For arduino-pico >= 5.6.1 extends = arduino_base platform_packages = # TODO renovate - arduino-pico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip + framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip board_build.core = earlephilhower board_build.filesystem_size = 0.5m @@ -16,6 +16,8 @@ build_flags = -Isrc/platform/rp2xx0 -D__PLAT_RP2350__ -D__FREERTOS=1 + -Wl,-u,_printf_float + -Wl,-u,_scanf_float build_src_filter = ${arduino_base.build_src_filter} + - - - - - - - - From 2826c6712bf4d9f385670960b412fdfc79b6d9c6 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 14 Sep 2026 12:19:56 -0400 Subject: [PATCH 137/143] Pin nrf54 platform, update toolchain-gccarmnoneeabi for arm64 build hosts (#11848) --- variants/nrf54l15/nrf54l15.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 45e997271e..86fa03f119 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -1,5 +1,6 @@ [nrf54l15_base] -platform = https://github.com/Seeed-Studio/platform-seeedboards.git +# renovate: datasource=git-refs depName=Seeed-Studio/platform-seeedboards packageName=https://github.com/Seeed-Studio/platform-seeedboards gitBranch=main +platform = https://github.com/Seeed-Studio/platform-seeedboards.git#1ec1287f8e4bc4067a6fd593991e36875aef989f ; Pin the Zephyr package explicitly. Seeed's platform script only maps their ; own "seeed-xiao-*" board ids to a framework-zephyr package; any other board ; -- nrf54l15dk included -- falls back to whatever platform.json declares as @@ -13,6 +14,8 @@ platform = https://github.com/Seeed-Studio/platform-seeedboards.git ; tree having changed. platform_packages = platformio/framework-zephyr-nrf54lm20@https://dl.registry.platformio.org/download/platformio/tool/framework-zephyr/3.40201.251021/framework-zephyr-3.40201.251021.tar.gz + ; Don't renovate toolchain-gccarmnoneeabi + platformio/toolchain-gccarmnoneeabi@1.90201.191206 framework = zephyr extends = arduino_base From 9d27b276aa87d956c64a4c76da5f01321a65f337 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 14 Sep 2026 14:00:55 -0400 Subject: [PATCH 138/143] NRF54: Update to toolchain-gccarmnoneeabi@1.90301.200702, align NRF52840 (#11850) Version 1.90201.191206 supports arm64 MacOS, but not arm64 Linux (1.90301.200702 supports both) Also change the fuzzy match for gccarmnoneeabi to an exact match for nRF52840 (for reproducible builds), this is effectively a no-op change, today. --- variants/nrf52840/nrf52.ini | 2 +- variants/nrf54l15/nrf54l15.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index c9bf15c51c..9192fc84a1 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -9,7 +9,7 @@ platform_packages = # renovate: datasource=git-refs depName=meshtastic/Adafruit_nRF52_Arduino packageName=https://github.com/meshtastic/Adafruit_nRF52_Arduino gitBranch=master platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#0fd295f13203e93df19d578073646ec32f2bf45a ; Don't renovate toolchain-gccarmnoneeabi - platformio/toolchain-gccarmnoneeabi@~1.90301.0 + platformio/toolchain-gccarmnoneeabi@1.90301.200702 extra_scripts = ${env.extra_scripts} diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 86fa03f119..a40f1baa5b 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -15,7 +15,7 @@ platform = https://github.com/Seeed-Studio/platform-seeedboards.git#1ec1287f8e4b platform_packages = platformio/framework-zephyr-nrf54lm20@https://dl.registry.platformio.org/download/platformio/tool/framework-zephyr/3.40201.251021/framework-zephyr-3.40201.251021.tar.gz ; Don't renovate toolchain-gccarmnoneeabi - platformio/toolchain-gccarmnoneeabi@1.90201.191206 + platformio/toolchain-gccarmnoneeabi@1.90301.200702 framework = zephyr extends = arduino_base From ea7d4aa4101f2b90568c2a5d30449e8900a83abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 15 Sep 2026 07:16:33 +0000 Subject: [PATCH 139/143] Port nRF54L15 to the s145 SoftDevice Arduino core (#11842) * Remove the Zephyr based nRF54L15 port * Add nRF54L15 port on the s145 Arduino core: nrf54l15dk and xiao_nrf54l15 variants * nRF54L: errno-style nrfx results, flush console before assert reset * nRF54L: log the SoftDevice status on Bluefruit failure, ignore the seed request event * Support the Wio-LR2021 LoRa Plus expansion board with OLED and K1 on the XIAO nRF54L15 variant * Consume the nRF54L15 platform, core and bootloader from their repositories * Pin the nRF54L15 platform to v0.2.0 * nRF52: forward SoftDevice flash events taken by the main loop to the flash driver, log the pairing failure status * Pin the nRF54L15 platform to v0.2.1 * Pin the nRF54L15 platform to v0.3.0 * Split the XIAO nRF54L15 variant into SX1262 and LoRa Plus environments, seed the SoftDevice on request, add the nrf54l15 CI build script * Pin the nRF54L15 platform to meshtastic/platform-nordicnrf54 v0.3.1 * NRF54: Fix mtjson generation --------- Co-authored-by: vidplace7 --- bin/build-nrf54l15.sh | 36 + bin/platformio-custom.py | 3 + boards/nrf54l15dk.json | 26 - extra_scripts/nrf54l15_linker.py | 140 --- platformio.ini | 1 - src/FSCommon.cpp | 4 + src/FSCommon.h | 8 - src/RedirectablePrint.cpp | 4 - src/freertosinc.h | 2 +- src/main.cpp | 12 - src/main.h | 4 - src/mesh/HardwareRNG.cpp | 11 +- src/modules/AdminModule.cpp | 7 - src/platform/nrf52/BLEDfuSecure.cpp | 5 + src/platform/nrf52/NRF52Bluetooth.cpp | 14 +- src/platform/nrf52/architecture.h | 4 +- src/platform/nrf52/hardfault.cpp | 2 +- src/platform/nrf52/main-nrf52.cpp | 75 +- src/platform/nrf54l15/Arduino.h | 835 ------------------ src/platform/nrf54l15/IPAddress.h | 34 - src/platform/nrf54l15/InternalFileSystem.cpp | 274 ------ src/platform/nrf54l15/InternalFileSystem.h | 212 ----- src/platform/nrf54l15/NRF52Bluetooth.h | 18 - src/platform/nrf54l15/NRF54L15Bluetooth.cpp | 806 ----------------- src/platform/nrf54l15/NRF54L15Bluetooth.h | 29 - src/platform/nrf54l15/Nrf52SaadcLock.h | 17 - src/platform/nrf54l15/Print.h | 4 - src/platform/nrf54l15/SPI.h | 62 -- src/platform/nrf54l15/Stream.h | 5 - src/platform/nrf54l15/Tone.h | 4 - src/platform/nrf54l15/WProgram.h | 5 - src/platform/nrf54l15/Wire.cpp | 219 ----- src/platform/nrf54l15/Wire.h | 83 -- src/platform/nrf54l15/architecture.h | 79 -- src/platform/nrf54l15/bluefruit.h | 19 - src/platform/nrf54l15/main-nrf54l15.cpp | 222 ----- src/platform/nrf54l15/nrf54l15_arduino.cpp | 557 ------------ src/platform/nrf54l15/nrf54l15_main.cpp | 121 --- src/platform/nrf54l15/utility/bonding.h | 11 - variants/nrf54l15/cpp_overrides/lfs_assert.h | 18 + variants/nrf54l15/nrf54l15.ini | 99 +-- variants/nrf54l15/nrf54l15dk/README.md | 109 --- variants/nrf54l15/nrf54l15dk/platformio.ini | 8 +- variants/nrf54l15/nrf54l15dk/variant.cpp | 18 +- variants/nrf54l15/nrf54l15dk/variant.h | 144 +-- .../nrf54l15/xiao_nrf54l15/platformio.ini | 34 + variants/nrf54l15/xiao_nrf54l15/variant.cpp | 22 + variants/nrf54l15/xiao_nrf54l15/variant.h | 140 +++ .../boards/nrf54l15dk_nrf54l15_cpuapp.overlay | 117 --- zephyr/prj.conf | 299 ------- 50 files changed, 475 insertions(+), 4507 deletions(-) create mode 100755 bin/build-nrf54l15.sh delete mode 100644 boards/nrf54l15dk.json delete mode 100644 extra_scripts/nrf54l15_linker.py delete mode 100644 src/platform/nrf54l15/Arduino.h delete mode 100644 src/platform/nrf54l15/IPAddress.h delete mode 100644 src/platform/nrf54l15/InternalFileSystem.cpp delete mode 100644 src/platform/nrf54l15/InternalFileSystem.h delete mode 100644 src/platform/nrf54l15/NRF52Bluetooth.h delete mode 100644 src/platform/nrf54l15/NRF54L15Bluetooth.cpp delete mode 100644 src/platform/nrf54l15/NRF54L15Bluetooth.h delete mode 100644 src/platform/nrf54l15/Nrf52SaadcLock.h delete mode 100644 src/platform/nrf54l15/Print.h delete mode 100644 src/platform/nrf54l15/SPI.h delete mode 100644 src/platform/nrf54l15/Stream.h delete mode 100644 src/platform/nrf54l15/Tone.h delete mode 100644 src/platform/nrf54l15/WProgram.h delete mode 100644 src/platform/nrf54l15/Wire.cpp delete mode 100644 src/platform/nrf54l15/Wire.h delete mode 100644 src/platform/nrf54l15/architecture.h delete mode 100644 src/platform/nrf54l15/bluefruit.h delete mode 100644 src/platform/nrf54l15/main-nrf54l15.cpp delete mode 100644 src/platform/nrf54l15/nrf54l15_arduino.cpp delete mode 100644 src/platform/nrf54l15/nrf54l15_main.cpp delete mode 100644 src/platform/nrf54l15/utility/bonding.h create mode 100644 variants/nrf54l15/cpp_overrides/lfs_assert.h delete mode 100644 variants/nrf54l15/nrf54l15dk/README.md create mode 100644 variants/nrf54l15/xiao_nrf54l15/platformio.ini create mode 100644 variants/nrf54l15/xiao_nrf54l15/variant.cpp create mode 100644 variants/nrf54l15/xiao_nrf54l15/variant.h delete mode 100644 zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay delete mode 100644 zephyr/prj.conf diff --git a/bin/build-nrf54l15.sh b/bin/build-nrf54l15.sh new file mode 100755 index 0000000000..9ff90384f7 --- /dev/null +++ b/bin/build-nrf54l15.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -e + +VERSION=$(bin/buildinfo.py long) + +BUILDDIR=.pio/build/$1 +OUTDIR=release + +rm -f $OUTDIR/firmware* +rm -r $OUTDIR/* || true + +# Important to pull latest version of libs into all device flavors, otherwise some devices might be stale +platformio pkg install -e $1 + +echo "Building for $1 with $PLATFORMIO_BUILD_FLAGS" +rm -f $BUILDDIR/firmware* + +# The shell vars the build tool expects to find +export APP_VERSION=$VERSION + +basename=firmware-$1-$VERSION +ota_basename=${basename}-ota + +pio run --environment $1 -t dfu -t mtjson # -v + +cp $BUILDDIR/$basename.elf $OUTDIR/$basename.elf + +echo "Copying merged hex (bootloader + SoftDevice + application)" +cp $BUILDDIR/$basename.hex $OUTDIR/$basename.hex + +echo "Copying nRF54L dfu (OTA) file" +cp $BUILDDIR/$basename.zip $OUTDIR/$ota_basename.zip + +echo "Copying manifest" +cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json || true diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py index bd2d3fc82e..7ded02b121 100644 --- a/bin/platformio-custom.py +++ b/bin/platformio-custom.py @@ -41,8 +41,11 @@ def infer_architecture(board_cfg): return "rp2350" if "nrf52" in mcu_l or "nrf52840" in mcu_l: return "nrf52840" + if "nrf54l15" in mcu_l: + return "nrf54l15" if "stm32" in mcu_l: return "stm32" + print(f"mtjson: could not infer architecture from MCU '{mcu_l}'") return None def run_size_tool(env, flag, purpose): diff --git a/boards/nrf54l15dk.json b/boards/nrf54l15dk.json deleted file mode 100644 index 863ad290b4..0000000000 --- a/boards/nrf54l15dk.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "build": { - "cpu": "cortex-m33", - "f_cpu": "128000000L", - "mcu": "nrf54l15", - "zephyr": { - "variant": "nrf54l15dk/nrf54l15/cpuapp" - } - }, - "connectivity": ["bluetooth"], - "debug": { - "default_tools": ["jlink"], - "jlink_device": "nRF54L15_M33", - "svd_path": "nrf54l15.svd" - }, - "frameworks": ["zephyr"], - "name": "Nordic nRF54L15-DK (PCA10156)", - "upload": { - "maximum_ram_size": 262144, - "maximum_size": 1572864, - "protocol": "jlink", - "protocols": ["jlink"] - }, - "url": "https://www.nordicsemi.com/Products/nRF54L15", - "vendor": "Nordic Semiconductor" -} diff --git a/extra_scripts/nrf54l15_linker.py b/extra_scripts/nrf54l15_linker.py deleted file mode 100644 index 824aae7ce3..0000000000 --- a/extra_scripts/nrf54l15_linker.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# trunk-ignore-all(ruff/F821) -# trunk-ignore-all(flake8/F821): For SConstruct imports -# -# post:extra_scripts/nrf54l15_linker.py -# -# Fix for Zephyr two-pass link on nRF54L15: -# platformio-build.py registers env.Depends("$PROG_PATH", final_ld_script) but -# the SCons dependency chain is broken (final_ld_script Command never runs). -# This script adds a PreAction on the final firmware binary that runs the gcc -# preprocessing command directly (extracted from build.ninja) to generate -# zephyr/linker.cmd before the link step. -# -# PlatformIO bundles an old Ninja that can't handle multi-output depslog rules, -# so we parse the COMMAND line from build.ninja and run just the gcc -E part, -# skipping the cmake_transform_depfile step (only needed for Ninja deps tracking). - -import os -import re -import subprocess - -Import("env") - -if env.get("PIOENV") != "nrf54l15dk": - pass # Only for the nrf54l15dk environment -else: - - def _extract_gcc_command(ninja_build): - """Parse build.ninja to find the gcc -E command that generates linker.cmd. - - The rule format depends on the host: - Windows (CMake's RunCMake wraps every command): - COMMAND = cmd.exe /C "cd /D DIR && arm-none-eabi-gcc.exe ... -o linker.cmd && cmake.exe -E cmake_transform_depfile ..." - POSIX (Linux/macOS - no wrapper): - COMMAND = cd DIR && arm-none-eabi-gcc ... -o linker.cmd && cmake -E cmake_transform_depfile ... - - Returns (gcc_cmd_string, cwd_path) or raises RuntimeError. - """ - in_rule = False - with open(ninja_build, "r", encoding="utf-8", errors="replace") as f: - for line in f: - # Detect start of the linker.cmd custom command rule - if not in_rule: - if "build zephyr/linker.cmd" in line and "CUSTOM_COMMAND" in line: - in_rule = True - continue - - stripped = line.strip() - if not stripped.startswith("COMMAND = "): - continue - - command_val = stripped[len("COMMAND = ") :] - - # On Windows the value is wrapped in `cmd.exe /C "..."` - strip - # the wrapper. On POSIX hosts the inner sequence is the value - # itself (no quoting layer). - m = re.search(r'/C\s+"(.*)"\s*$', command_val) - inner = m.group(1) if m else command_val - parts = inner.split(" && ") - - cwd = None - gcc_cmd = None - for part in parts: - part = part.strip() - if part.startswith("cd /D "): # Windows form - cwd = part[len("cd /D ") :] - elif part.startswith("cd "): # POSIX form - cwd = part[len("cd ") :] - elif "arm-none-eabi-gcc" in part: - gcc_cmd = part - - if not gcc_cmd: - raise RuntimeError( - "nRF54L15 linker fix: arm-none-eabi-gcc command not found in:\n%s" - % inner[:400] - ) - - return gcc_cmd, cwd - - raise RuntimeError( - "nRF54L15 linker fix: 'build zephyr/linker.cmd' rule not found in build.ninja" - ) - - def _generate_linker_cmd(target, source, env): - """Generate zephyr/linker.cmd via direct gcc invocation before the final link.""" - build_dir = env.subst("$BUILD_DIR") - zephyr_dir = os.path.join(build_dir, "zephyr") - linker_cmd = os.path.join(zephyr_dir, "linker.cmd") - - if os.path.exists(linker_cmd): - return # Already present - nothing to do - - ninja_build = os.path.join(build_dir, "build.ninja") - if not os.path.exists(ninja_build): - raise RuntimeError( - "nRF54L15 linker fix: build.ninja not found at %s\n" - "Run a full build first so CMake generates the Ninja files." - % ninja_build - ) - - gcc_cmd, cwd = _extract_gcc_command(ninja_build) - run_cwd = cwd if cwd else zephyr_dir - - print( - "==> nRF54L15: Generating zephyr/linker.cmd (LINKER_ZEPHYR_FINAL) via GCC" - ) - # gcc_cmd comes verbatim from our own build.ninja (never user input) and - # contains Windows-style paths with spaces that cannot be safely argv-split - # with shlex, so we run it via the platform shell. nosec/nosemgrep below - # acknowledge this deliberate, scoped use of shell=True. - result = subprocess.run( # nosec B602 - gcc_cmd, - shell=True, # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true - cwd=run_cwd, - capture_output=True, - text=True, - ) - if result.returncode != 0: - print("GCC stdout:", result.stdout[:2000]) - print("GCC stderr:", result.stderr[:2000]) - raise RuntimeError( - "nRF54L15 linker fix: GCC failed to generate linker.cmd (rc=%d)" - % result.returncode - ) - if not os.path.exists(linker_cmd): - raise RuntimeError( - "nRF54L15 linker fix: GCC returned 0 but linker.cmd was not created at %s" - % linker_cmd - ) - print("==> linker.cmd generated successfully") - - # Use PIOMAINPROG (set by ZephyrBuildProgram) to get the exact SCons node - prog = env.get("PIOMAINPROG") - if prog: - env.AddPreAction(prog, _generate_linker_cmd) - else: - print( - "[nrf54l15_linker] WARNING: PIOMAINPROG not set, falling back to $PROG_PATH" - ) - env.AddPreAction(env.subst("$PROG_PATH"), _generate_linker_cmd) diff --git a/platformio.ini b/platformio.ini index f061da4e91..aa3e8f9ed6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -27,7 +27,6 @@ extra_scripts = pre:bin/platformio-pre.py pre:bin/optional-modules.py bin/platformio-custom.py - post:extra_scripts/nrf54l15_linker.py ; note: we add src to our include search path so that lmic_project_config can override ; note: TINYGPS_OPTION_NO_CUSTOM_FIELDS is VERY important. We don't use custom fields and somewhere in that pile ; of code is a heap corruption bug! diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index 0bba740116..b6269141b6 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -40,7 +40,11 @@ size_t fsUsedBytes() return 0; size_t blocks = 0; FSCom._lockFS(); +#if LFS_VERSION_MAJOR >= 2 + int err = lfs_fs_traverse(fs, fsCountBlockCb, &blocks); +#else int err = lfs_traverse(fs, fsCountBlockCb, &blocks); +#endif FSCom._unlockFS(); if (err < 0) return fsTotalBytes(); // report "full" so capacity checks fail safe diff --git a/src/FSCommon.h b/src/FSCommon.h index 7daa57ad90..de4322579e 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -48,14 +48,6 @@ using namespace STM32_LittleFS_Namespace; using namespace Adafruit_LittleFS_Namespace; #endif -#if defined(ARCH_NRF54L15) -// nRF54L15 - Zephyr LittleFS on 36 KB storage_partition (internal RRAM) -#include "InternalFileSystem.h" -#define FSCom InternalFS -#define FSBegin() FSCom.begin() -using namespace Adafruit_LittleFS_Namespace; -#endif - // Filesystem capacity, in bytes. Only ESP32's LittleFS and the nRF54L15 wrapper expose totalBytes()/usedBytes() // directly; the other backends need per-platform work (littlefs v1 traversal, FSInfo, statvfs), so callers must // use these helpers rather than reaching into FSCom. diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 66a266d960..e4a56bb4af 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -235,8 +235,6 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ isBleConnected = nimbleBluetooth && nimbleBluetooth->isActive() && nimbleBluetooth->isConnected(); #elif defined(ARCH_NRF52) isBleConnected = nrf52Bluetooth != nullptr && nrf52Bluetooth->isConnected(); -#elif defined(ARCH_NRF54L15) - isBleConnected = nrf54l15Bluetooth != nullptr && nrf54l15Bluetooth->isConnected(); #endif if (isBleConnected) { auto thread = concurrency::OSThread::currentThread; @@ -253,8 +251,6 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_ nimbleBluetooth->sendLog(buffer.get(), size); #elif defined(ARCH_NRF52) nrf52Bluetooth->sendLog(buffer.get(), size); -#elif defined(ARCH_NRF54L15) - nrf54l15Bluetooth->sendLog(buffer.get(), size); #endif } } diff --git a/src/freertosinc.h b/src/freertosinc.h index e9e6cd53a0..db49fbab11 100644 --- a/src/freertosinc.h +++ b/src/freertosinc.h @@ -12,7 +12,7 @@ #include #endif -#if defined(ARDUINO_NRF52_ADAFRUIT) || defined(ARDUINO_ARCH_RP2040) +#if defined(ARDUINO_NRF52_ADAFRUIT) || defined(ARDUINO_ARCH_NRF54) || defined(ARDUINO_ARCH_RP2040) #define HAS_FREE_RTOS #include diff --git a/src/main.cpp b/src/main.cpp index 7e9bd39d40..3f7baa2fdb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -76,12 +76,6 @@ NimbleBluetooth *nimbleBluetooth = nullptr; NRF52Bluetooth *nrf52Bluetooth = nullptr; #endif -#ifdef ARCH_NRF54L15 -void nrf54l15Setup(); -void nrf54l15Loop(); -NRF54L15Bluetooth *nrf54l15Bluetooth = nullptr; -#endif - #ifdef MESHTASTIC_ENABLE_APPROTECT #include "security/APProtect.h" #endif @@ -852,9 +846,6 @@ void setup() #ifdef ARCH_NRF52 nrf52Setup(); #endif -#ifdef ARCH_NRF54L15 - nrf54l15Setup(); -#endif #ifdef ARCH_RP2040 rp2040Setup(); @@ -1483,9 +1474,6 @@ void loop() #ifdef ARCH_NRF52 nrf52Loop(); #endif -#ifdef ARCH_NRF54L15 - nrf54l15Loop(); -#endif #ifdef ARCH_RP2040 rp2040Loop(); #endif diff --git a/src/main.h b/src/main.h index 49a63365bc..29a76aa2f6 100644 --- a/src/main.h +++ b/src/main.h @@ -20,10 +20,6 @@ extern NimbleBluetooth *nimbleBluetooth; #include "NRF52Bluetooth.h" extern NRF52Bluetooth *nrf52Bluetooth; #endif -#ifdef ARCH_NRF54L15 -#include "NRF54L15Bluetooth.h" -extern NRF54L15Bluetooth *nrf54l15Bluetooth; -#endif #if !MESHTASTIC_EXCLUDE_I2C #include "detect/ScanI2CTwoWire.h" #endif diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index 43a3e03854..451b8d8b74 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -10,7 +10,9 @@ #include "RadioLibInterface.h" #endif -#if defined(ARCH_NRF52) +#if defined(ARCH_NRF54L) +#include +#elif defined(ARCH_NRF52) #include extern Adafruit_nRFCrypto nRFCrypto; #elif defined(ARCH_ESP32) @@ -107,7 +109,12 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) bool filled = false; -#if defined(ARCH_NRF52) +#if defined(ARCH_NRF54L) + // CRACEN TRNG + nRF54Crypto.begin(); + filled = nRF54Crypto.random(buffer, length); + nRF54Crypto.end(); +#elif defined(ARCH_NRF52) // The Nordic SDK RNG provides cryptographic-quality randomness backed by hardware. nRFCrypto.begin(); auto result = nRFCrypto.Random.generate(buffer, length); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 6dfed40c3d..9be681b42f 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1819,10 +1819,6 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r if (config.bluetooth.enabled && nrf52Bluetooth) { conn.bluetooth.is_connected = nrf52Bluetooth->isConnected(); } -#elif defined(ARCH_NRF54L15) - if (config.bluetooth.enabled && nrf54l15Bluetooth) { - conn.bluetooth.is_connected = nrf54l15Bluetooth->isConnected(); - } #endif #endif conn.has_serial = true; // No serial-less devices @@ -2512,9 +2508,6 @@ void disableBluetooth() #elif defined(ARCH_NRF52) if (nrf52Bluetooth) nrf52Bluetooth->shutdown(); -#elif defined(ARCH_NRF54L15) - if (nrf54l15Bluetooth) - nrf54l15Bluetooth->shutdown(); #endif #endif } diff --git a/src/platform/nrf52/BLEDfuSecure.cpp b/src/platform/nrf52/BLEDfuSecure.cpp index 040df8bdff..c198e86388 100644 --- a/src/platform/nrf52/BLEDfuSecure.cpp +++ b/src/platform/nrf52/BLEDfuSecure.cpp @@ -121,7 +121,12 @@ static void bledfu_control_wr_authorize_cb(uint16_t conn_hdl, BLECharacteristic Bluefruit.Advertising.restartOnDisconnect(false); conn->disconnect(); +#ifdef ARCH_NRF54L + sd_power_gpregret_clr(0, 0xFF); + sd_power_gpregret_set(0, 0xB1); +#else NRF_POWER->GPREGRET = 0xB1; +#endif NVIC_SystemReset(); } } diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 608fb1b7bb..c0c5a2bb2f 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -11,6 +11,11 @@ #include "mesh/mesh-pb-constants.h" #include #include + +#ifdef ARCH_NRF54L +extern uint32_t sd_app_ram_start_required; // Bluefruit54Lib +extern "C" uint32_t verify_last_err, verify_last_line; // core verify.h +#endif static BLEService meshBleService = BLEService(BLEUuid(MESH_SERVICE_UUID_16)); static BLECharacteristic fromNum = BLECharacteristic(BLEUuid(FROMNUM_UUID_16)); static BLECharacteristic fromRadio = BLECharacteristic(BLEUuid(FROMRADIO_UUID_16)); @@ -23,7 +28,7 @@ static int lastBatteryLevel = -1; // last value written to BAS, to skip redundan #ifndef BLE_DFU_SECURE static BLEDfu bledfu; // DFU software update helper service #else -static BLEDfuSecure bledfusecure; // DFU software update helper service +static BLEDfuSecure bledfusecure; // DFU software update helper service #endif // This scratch buffer is used for various bluetooth reads/writes - but it is safe because only one bt operation can be in @@ -287,7 +292,12 @@ void NRF52Bluetooth::setup() // current Bluefruit config. Without this check the node would silently run without BLE. // Rebuild with -DCFG_DEBUG=1 to get "SoftDevice's RAM requires: 0x..." in the log, then // raise the ORIGIN accordingly. +#ifdef ARCH_NRF54L + LOG_ERROR("Bluefruit.begin failed: status 0x%lx at line %lu, app RAM base wanted 0x%08lx", verify_last_err, + verify_last_line, sd_app_ram_start_required); +#else LOG_ERROR("Bluefruit.begin failed: SoftDevice RAM too small"); +#endif RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_UNSPECIFIED); return; } @@ -498,7 +508,7 @@ void NRF52Bluetooth::onPairingCompleted(uint16_t conn_handle, uint8_t auth_statu meshtastic::BluetoothStatus newConnectedStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); bluetoothStatus->updateStatus(&newConnectedStatus); } else { - LOG_INFO("BLE pair failed"); + LOG_INFO("BLE pair failed, status 0x%02x", auth_status); // Notify UI (or any other interested firmware components) meshtastic::BluetoothStatus newDisconnectedStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); bluetoothStatus->updateStatus(&newDisconnectedStatus); diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index 279ad75757..c3b62320da 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -51,7 +51,7 @@ #ifndef HAS_CPU_SHUTDOWN #define HAS_CPU_SHUTDOWN 1 #endif -#ifndef HAS_CUSTOM_CRYPTO_ENGINE +#if !defined(HAS_CUSTOM_CRYPTO_ENGINE) && !defined(ARCH_NRF54L) #define HAS_CUSTOM_CRYPTO_ENGINE 1 #endif @@ -198,7 +198,7 @@ // If we are not on a NRF52840 (which has built in USB-ACM serial support) and we don't have serial pins hooked up, then we MUST // use SEGGER for debug output -#if !defined(PIN_SERIAL_RX) && !defined(NRF52840_XXAA) +#if !defined(PIN_SERIAL_RX) && !defined(NRF52840_XXAA) && !defined(ARCH_NRF54L) // No serial ports on this board - ONLY use segger in memory console #define USE_SEGGER #endif diff --git a/src/platform/nrf52/hardfault.cpp b/src/platform/nrf52/hardfault.cpp index 260a0a6a6f..1f54b4486d 100644 --- a/src/platform/nrf52/hardfault.cpp +++ b/src/platform/nrf52/hardfault.cpp @@ -1,5 +1,5 @@ #include "configuration.h" -#include +#include #ifdef MESHTASTIC_ENCRYPTED_STORAGE #include "security/EncryptedStorage.h" diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index 784d29e283..6114975513 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -1,22 +1,37 @@ #include "UptimeClock.h" #include "configuration.h" #include "mesh/Throttle.h" +#ifndef ARCH_NRF54L #include #include +#endif #include #include #include #define APP_WATCHDOG_SECS 90 +#ifdef ARCH_NRF54L +// The nRF54L core compiles the nrfx drivers itself (nrfx 3: errno-style returns, 0 is success); +// POWER/RESET registers are split differently. +#include +#include +#define NRFX_OK 0 +#define GPREGRET_REG NRF_POWER->GPREGRET[0] +#define RESETREAS_REG NRF_RESET->RESETREAS +#else #define NRFX_WDT_ENABLED 1 #define NRFX_WDT0_ENABLED 1 #define NRFX_WDT_CONFIG_NO_IRQ 1 #include "nrfx_power.h" +#include +#include +#define GPREGRET_REG NRF_POWER->GPREGRET +#define RESETREAS_REG NRF_POWER->RESETREAS +#define NRFX_OK NRFX_SUCCESS +#endif #include #include #include -#include -#include #include // #include #include "HardwareRNG.h" @@ -71,7 +86,11 @@ __attribute__((noinline)) bool variant_enableBatteryLpcompWake() return true; } +#ifdef ARCH_NRF54L +static nrfx_wdt_t nrfx_wdt = NRFX_WDT_INSTANCE(NRF_WDT31); +#else static nrfx_wdt_t nrfx_wdt = NRFX_WDT_INSTANCE(0); +#endif static nrfx_wdt_channel_id nrfx_wdt_channel_id_nrf52_main; // This is a public global so that the debugger can set it to false automatically from our gdbinit @@ -89,7 +108,11 @@ static inline void debugger_break(void) // PowerHAL NRF52 specific function implementations bool powerHAL_isVBUSConnected() { +#ifdef ARCH_NRF54L + return false; // no USB peripheral +#else return NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk; +#endif } bool powerHAL_isPowerLevelSafe() @@ -138,8 +161,10 @@ void powerHAL_platformInit() // I did experiments with bench power supply and no matter what is set to POFCON, it always triggers right below // 2.8V. I compared raw registry values with datasheet. +#ifndef ARCH_NRF54L NRF_POWER->POFCON = ((POWER_POFCON_THRESHOLD_V22 << POWER_POFCON_THRESHOLD_Pos) | (POWER_POFCON_POF_Enabled << POWER_POFCON_POF_Pos)); +#endif // remember to always match VBAT_AR_INTERNAL with AREF_VALUE in variant definition file #ifdef VBAT_AR_INTERNAL @@ -183,7 +208,8 @@ bool loopCanSleep() void __attribute__((noreturn)) __assert_func(const char *file, int line, const char *func, const char *failedexpr) { LOG_ERROR("assert failed %s: %d, %s, test=%s", file, line, func, failedexpr); - // debugger_break(); FIXME doesn't work, possibly not for segger + Serial.flush(); // the reset below would cut the message short + // debugger_break(); FIXME doesn't work, possibly for segger // Reboot cpu NVIC_SystemReset(); } @@ -203,7 +229,11 @@ bool getDeviceId(uint8_t *deviceId) { // Nordic burns a FIPS-compliant random id into each chip at the factory. We concatenate // the device address to that random id to form the 16-byte hardware identifier. +#ifdef ARCH_NRF54L + uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; +#else uint64_t device_id_start = ((uint64_t)NRF_FICR->DEVICEID[1] << 32) | NRF_FICR->DEVICEID[0]; +#endif uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; memcpy(deviceId, &device_id_start, sizeof(device_id_start)); memcpy(deviceId + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); @@ -294,9 +324,9 @@ void preFSBegin() { // The GPREGRET register keeps its value across warm boots. Check that this is a warm boot and, if GPREGRET // is set to NRF52_MAGIC_LFS_IS_CORRUPT, format LittleFS. - if (!(NRF_POWER->RESETREAS == 0 && NRF_POWER->GPREGRET == NRF52_MAGIC_LFS_IS_CORRUPT)) + if (!(RESETREAS_REG == 0 && GPREGRET_REG == NRF52_MAGIC_LFS_IS_CORRUPT)) return; - NRF_POWER->GPREGRET = 0; + GPREGRET_REG = 0; // unset-sentinel-ok: formatted_this_boot carries the armed state, so 0 is a legal stamp last_format_ms = Time::getMillis(); formatted_this_boot = true; @@ -334,7 +364,7 @@ extern "C" void lfs_assert(const char *reason) if (!NRF_POWER->EVENTS_POFWARN) { if (!(sd_power_gpregret_clr(0, 0xFF) == NRF_SUCCESS && sd_power_gpregret_set(0, NRF52_MAGIC_LFS_IS_CORRUPT) == NRF_SUCCESS)) { - NRF_POWER->GPREGRET = NRF52_MAGIC_LFS_IS_CORRUPT; + GPREGRET_REG = NRF52_MAGIC_LFS_IS_CORRUPT; } } @@ -344,6 +374,9 @@ extern "C" void lfs_assert(const char *reason) NVIC_SystemReset(); } +// Defined by the core's InternalFileSystem, completes a pending sd_flash_write() +extern "C" void flash_nrf5x_event_cb(uint32_t event); + void checkSDEvents() { if (useSoftDevice) { @@ -353,6 +386,21 @@ void checkSDEvents() case NRF_EVT_POWER_FAILURE_WARNING: RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_BROWNOUT); break; + // Bluefruit's SoC task polls the same queue; an event taken here must still reach the flash driver + case NRF_EVT_FLASH_OPERATION_SUCCESS: + case NRF_EVT_FLASH_OPERATION_ERROR: + flash_nrf5x_event_cb(evt); + break; +#ifdef ARCH_NRF54L + case NRF_EVT_RAND_SEED_REQUEST: { + uint8_t seed[SD_RAND_SEED_SIZE]; + nRF54Crypto.begin(); + if (nRF54Crypto.random(seed, sizeof(seed))) + sd_rand_seed_set(seed); + nRF54Crypto.end(); + break; + } +#endif default: LOG_DEBUG("Unexpected SDevt %d", evt); @@ -452,6 +500,11 @@ void nrf52Setup() // Set up nrfx watchdog. Do not enable the watchdog yet (we do that // the first time through the main loop), so that other threads can // allocate their own wdt channel to protect themselves from hangs. +#ifdef ARCH_NRF54L + // nrfx 3: behaviour is a RUN_* mask (0 = pause in sleep and halt), init takes a context argument + nrfx_wdt_config_t wdt0_config = {.behaviour = 0, .reload_value = APP_WATCHDOG_SECS * 1000}; + int r = nrfx_wdt_init(&nrfx_wdt, &wdt0_config, nullptr, nullptr); +#else nrfx_wdt_config_t wdt0_config = { .behaviour = NRF_WDT_BEHAVIOUR_PAUSE_SLEEP_HALT, .reload_value = APP_WATCHDOG_SECS * 1000, // Note: Not using wdt interrupts. @@ -460,10 +513,11 @@ void nrf52Setup() nrfx_err_t r = nrfx_wdt_init(&nrfx_wdt, &wdt0_config, nullptr // Watchdog event handler, not used, we just reset. ); - assert(r == NRFX_SUCCESS); +#endif + assert(r == NRFX_OK); r = nrfx_wdt_channel_alloc(&nrfx_wdt, &nrfx_wdt_channel_id_nrf52_main); - assert(r == NRFX_SUCCESS); + assert(r == NRFX_OK); } void cpuDeepSleep(uint32_t msecToWake) @@ -541,11 +595,16 @@ void cpuDeepSleep(uint32_t msecToWake) } #endif +#ifdef ARCH_NRF54L + // s145 has no sd_power_system_off(); REGULATORS is not SoftDevice-restricted + NRF_REGULATORS->SYSTEMOFF = 1; +#else auto ok = sd_power_system_off(); if (ok != NRF_SUCCESS) { LOG_ERROR("FIXME: Ignoring soft device (EasyDMA pending?) and forcing system-off"); NRF_POWER->SYSTEMOFF = 1; } +#endif } // The following code should not be run, because we are off diff --git a/src/platform/nrf54l15/Arduino.h b/src/platform/nrf54l15/Arduino.h deleted file mode 100644 index 0d7449e891..0000000000 --- a/src/platform/nrf54l15/Arduino.h +++ /dev/null @@ -1,835 +0,0 @@ -/** - * Arduino.h - Zephyr compatibility shim for nRF54L15 - * - * Provides the Arduino API surface expected by Meshtastic, backed by - * Zephyr primitives. Only the subset actually used by Meshtastic is - * implemented; the rest compiles as no-ops / stubs for now. - * - * Phase 2: compile only. Real GPIO / SPI / Wire implementations follow - * in Phase 3 once the build is clean. - */ - -#pragma once -#ifndef Arduino_h -#define Arduino_h - -// ── C standard headers ─────────────────────────────────────────────────────── -#include -#include -#include -#include -#include -#include -#include -#include /* strcasecmp, strncasecmp */ - -// ── Zephyr kernel ──────────────────────────────────────────────────────────── -#include -#include - -// ── Basic Arduino types ────────────────────────────────────────────────────── -typedef bool boolean; -typedef uint8_t byte; -typedef uint16_t word; - -// ── Pin / digital constants ────────────────────────────────────────────────── -#define INPUT 0u -#define OUTPUT 1u -#define INPUT_PULLUP 2u -#define INPUT_PULLDOWN 3u -#define OUTPUT_OPENDRAIN 4u - -#define HIGH 1u -#define LOW 0u - -#define CHANGE 1 -#define FALLING 2 -#define RISING 3 - -#ifndef LED_BUILTIN -#define LED_BUILTIN -1 -#endif - -// ── Math / trig constants ──────────────────────────────────────────────────── -#ifndef PI -#define PI 3.14159265358979323846 -#endif -#define HALF_PI 1.57079632679489661923 -#define TWO_PI 6.28318530717958647693 -#define DEG_TO_RAD 0.01745329251994329576 -#define RAD_TO_DEG 57.2957795130823208767 -#define EULER 2.71828182845904523536 - -// ── Bit utilities ──────────────────────────────────────────────────────────── -#define bitRead(v, b) (((v) >> (b)) & 1) -#define bitSet(v, b) ((v) |= (1UL << (b))) -#define bitClear(v, b) ((v) &= ~(1UL << (b))) -#define bitToggle(v, b) ((v) ^= (1UL << (b))) -#define bitWrite(v, b, x) ((x) ? bitSet(v, b) : bitClear(v, b)) -#define bit(b) (1UL << (b)) -#define lowByte(w) ((uint8_t)((w)&0xff)) -#define highByte(w) ((uint8_t)((w) >> 8)) -// word(h,l) - only define if not already defined (conflicts with typedef above) -#undef word -#define word(h, l) ((uint16_t)(((h) << 8) | (l))) - -// ── UART config constants ───────────────────────────────────────────────────── -#define SERIAL_8N1 0x800001cu -#define SERIAL_8N2 0x8000001eu -#define SERIAL_8E1 0x8000001eu -#define SERIAL_7E1 0x8000001cu - -// ── Integer order ──────────────────────────────────────────────────────────── -// Adafruit BusIO's SPIDevice.h has `typedef BitOrder BusIOBitOrder;` which -// requires BitOrder to be a *type*, not a macro. Mirror the ArduinoCore-API -// enum definition rather than #defines. -enum BitOrder : uint8_t { - LSBFIRST = 0, - MSBFIRST = 1, -}; - -// ── pgmspace compatibility (no-ops on Cortex-M) ────────────────────────────── -#define PROGMEM -#define PSTR(s) (s) -#define F(s) (s) -#define pgm_read_byte(addr) (*((const uint8_t *)(addr))) -#define pgm_read_word(addr) (*((const uint16_t *)(addr))) -#define pgm_read_dword(addr) (*((const uint32_t *)(addr))) -#define pgm_read_float(addr) (*((const float *)(addr))) -#define pgm_read_ptr(addr) (*((const void **)(addr))) -#define strlen_P(s) strlen(s) -#define strcpy_P(d, s) strcpy(d, s) -#define strncpy_P(d, s, n) strncpy(d, s, n) -#define strcmp_P(a, b) strcmp(a, b) -#define memcpy_P(d, s, n) memcpy(d, s, n) -#define sprintf_P sprintf -typedef const char *PGM_P; -typedef const char *PGM_VOID_P; - -// ── Arduino numeric base constants (used by Print, RadioLib, etc.) ─────────── -#define DEC 10 -#define HEX 16 -#define OCT 8 -#define BIN 2 - -// ── ulong / uint typedef (used by RadioLibInterface, etc.) ─────────────────── -typedef unsigned long ulong; -typedef unsigned int uint; - -// ── Interrupt stubs ────────────────────────────────────────────────────────── -static inline void interrupts() {} -static inline void noInterrupts() {} -#define digitalPinToInterrupt(p) (p) - -// ── portMAX_DELAY - freertosinc.h also defines this; let it win ────────────── -// We intentionally do NOT define portMAX_DELAY here. freertosinc.h defines -// it for the FreeRTOS / Meshtastic threading layer and must not be overridden. - -// ── Timing & system functions - declared with C linkage ────────────────────── -// buzz.cpp and others forward-declare delay() as extern "C"; keep linkage -// consistent by wrapping in extern "C" here. -#ifdef __cplusplus -extern "C" { -#endif -void NVIC_SystemReset(void); -uint32_t millis(void); -uint32_t micros(void); -void delay(uint32_t ms); -void delayMicroseconds(uint32_t us); -void yield(void); -#ifdef __cplusplus -} -#endif - -#ifdef __cplusplus - -#include -#include - -// ── C++ STL - include BEFORE defining any min/max helpers ─────────────────── -// Include algorithm first so its min/max templates are in scope. -// We must NOT define min/max as function-like macros: the C++ STL uses -// 3-argument versions (min(a,b,comp)) that the preprocessor would treat as -// calling a 2-arg macro with 3 args. -#include -// Bring 2-arg std::min / std::max into the global namespace as unqualified -// names so that Arduino code calling min(a,b) continues to compile. -// (Arduino convention; kept minimal to avoid surprises.) -#undef min -#undef max -using std::max; -using std::min; - -// ── Arduino math helpers (macros safe for mixed-type / C calls) ────────────── -#ifndef abs -#define abs(x) ((x) >= 0 ? (x) : -(x)) -#endif -#define constrain(x, l, h) ((x) < (l) ? (l) : ((x) > (h) ? (h) : (x))) -#define round(x) ((x) >= 0 ? (long)((x) + 0.5) : (long)((x)-0.5)) -#define radians(d) ((d)*DEG_TO_RAD) -#define degrees(r) ((r)*RAD_TO_DEG) -#define sq(x) ((x) * (x)) - -// ── Random ─────────────────────────────────────────────────────────────────── -static inline void randomSeed(unsigned long seed) -{ - srand((unsigned int)seed); -} -static inline long random(void) -{ - return (long)rand(); -} -static inline long random(long bound) -{ - return bound > 0 ? (rand() % bound) : 0; -} -static inline long random(long lo, long hi) -{ - return hi > lo ? lo + rand() % (hi - lo) : lo; -} - -// ── GPIO - real Zephyr implementation (Phase 3) ────────────────────────────── -// Implemented in nrf54l15_arduino.cpp using Zephyr GPIO/SPI APIs. -// Pin numbering: P0.n = n, P1.n = 16+n, P2.n = 32+n -void pinMode(uint32_t pin, uint32_t mode); -void digitalWrite(uint32_t pin, uint32_t value); -int digitalRead(uint32_t pin); -static inline void digitalToggle(uint32_t pin) -{ - digitalWrite(pin, !digitalRead(pin)); -} -static inline uint32_t analogRead(uint32_t) -{ - return 0; -} -static inline void analogWrite(uint32_t, uint32_t) {} -static inline void analogReadResolution(int) {} -static inline void analogWriteResolution(int) {} - -// ── __WFI - provided by CMSIS core_cm33.h; do NOT redefine here ───────────── - -// ── __FlashStringHelper - Arduino PROGMEM string class (no-op on Cortex-M) ── -class __FlashStringHelper; - -// ── attachInterrupt / detachInterrupt - real Zephyr GPIO interrupt impl ────── -typedef void (*voidFuncPtr)(void); -void attachInterrupt(uint32_t pin, voidFuncPtr cb, int mode); -void detachInterrupt(uint32_t pin); - -// ── Forward declaration of String (needed by Print / Stream) ───────────────── -class String; - -// ── Print base class ───────────────────────────────────────────────────────── -class Print -{ - public: - virtual size_t write(uint8_t c) = 0; - virtual size_t write(const uint8_t *buf, size_t n) - { - size_t written = 0; - while (n--) - written += write(*buf++); - return written; - } - size_t write(const char *s) { return s ? write((const uint8_t *)s, strlen(s)) : 0; } - size_t write(const char *s, size_t n) { return write((const uint8_t *)s, n); } - - size_t print(const char *s) { return s ? write((const uint8_t *)s, strlen(s)) : 0; } - int printf(const char *fmt, ...) __attribute__((format(printf, 2, 3))); - - size_t print(char c) { return write((uint8_t)c); } - size_t print(const String &s); - size_t print(unsigned char n, int base = 10); - size_t print(int n, int base = 10); - size_t print(long n, int base = 10); - size_t print(unsigned int n, int base = 10); - size_t print(unsigned long n, int base = 10); - size_t print(float n, int digits = 2); - size_t print(double n, int digits = 2); - size_t print(bool b) { return print(b ? "true" : "false"); } - - size_t println() { return write((uint8_t)'\n'); } - size_t println(const char *s) - { - size_t r = print(s); - return r + println(); - } - size_t println(char c) - { - size_t r = print(c); - return r + println(); - } - size_t println(const String &s); - size_t println(int n, int base = 10) - { - size_t r = print(n, base); - return r + println(); - } - size_t println(long n, int base = 10) - { - size_t r = print(n, base); - return r + println(); - } - size_t println(unsigned long n, int base = 10) - { - size_t r = print(n, base); - return r + println(); - } - size_t println(unsigned int n, int base = 10) - { - size_t r = print(n, base); - return r + println(); - } - size_t println(float n, int d = 2) - { - size_t r = print(n, d); - return r + println(); - } - size_t println(double n, int d = 2) - { - size_t r = print(n, d); - return r + println(); - } - size_t println(bool b) - { - size_t r = print(b); - return r + println(); - } - - virtual void flush() {} - virtual int availableForWrite() { return 0; } -}; - -// ── Stream base class ──────────────────────────────────────────────────────── -class Stream : public Print -{ - public: - virtual int available() = 0; - virtual int read() = 0; - virtual int peek() = 0; - virtual void setTimeout(unsigned long) {} - virtual bool find(const char *) { return false; } - String readString(); - String readStringUntil(char terminator); -}; - -// ── Minimal Arduino String class (backed by a char buffer) ─────────────────── -class String -{ - public: - String() : _buf(nullptr), _len(0), _cap(0) {} - // Implicit conversion is part of the Arduino String contract, used pervasively across the codebase. - // cppcheck-suppress noExplicitConstructor - String(const char *cstr) : _buf(nullptr), _len(0), _cap(0) - { - if (cstr) - assign(cstr, strlen(cstr)); - } - // cppcheck-suppress noExplicitConstructor - String(const String &s) : _buf(nullptr), _len(0), _cap(0) { assign(s._buf ? s._buf : "", s._len); } - // cppcheck-suppress noExplicitConstructor - String(char c) : _buf(nullptr), _len(0), _cap(0) - { - const char tmp[2] = {c, 0}; - assign(tmp, 1); - } - // cppcheck-suppress noExplicitConstructor - String(int n) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[16]; - snprintf(tmp, 16, "%d", n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(unsigned int n) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[16]; - snprintf(tmp, 16, "%u", n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(long n) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[24]; - snprintf(tmp, 24, "%ld", n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(unsigned long n) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[24]; - snprintf(tmp, 24, "%lu", n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(float n, int d = 2) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[32]; - snprintf(tmp, 32, "%.*f", d, n); - assign(tmp, strlen(tmp)); - } - // cppcheck-suppress noExplicitConstructor - String(double n, int d = 2) : _buf(nullptr), _len(0), _cap(0) - { - char tmp[32]; - snprintf(tmp, 32, "%.*f", d, (double)n); - assign(tmp, strlen(tmp)); - } - ~String() { free(_buf); } - - String &operator=(const String &s) - { - assign(s._buf ? s._buf : "", s._len); - return *this; - } - String &operator=(const char *s) - { - assign(s ? s : "", s ? strlen(s) : 0); - return *this; - } - String &operator=(char c) - { - const char tmp[2] = {c, 0}; - assign(tmp, 1); - return *this; - } - - String &operator+=(const String &s) - { - concat(s._buf ? s._buf : "", s._len); - return *this; - } - String &operator+=(const char *s) - { - if (s) - concat(s, strlen(s)); - return *this; - } - String &operator+=(char c) - { - concat(&c, 1); - return *this; - } - String &operator+=(int n) { return *this += String(n); } - String &operator+=(unsigned long n) { return *this += String(n); } - - String operator+(const String &rhs) const - { - String r(*this); - r += rhs; - return r; - } - String operator+(const char *rhs) const - { - String r(*this); - r += rhs; - return r; - } - String operator+(char rhs) const - { - String r(*this); - r += rhs; - return r; - } - - bool operator==(const String &s) const { return _len == s._len && (_len == 0 || strcmp(_buf, s._buf) == 0); } - bool operator==(const char *s) const { return s && strcmp(c_str(), s) == 0; } - bool operator!=(const String &s) const { return !(*this == s); } - bool operator!=(const char *s) const { return !(*this == s); } - bool operator<(const String &s) const { return strcmp(c_str(), s.c_str()) < 0; } - bool operator>(const String &s) const { return strcmp(c_str(), s.c_str()) > 0; } - - char operator[](unsigned int i) const { return (_buf && i < _len) ? _buf[i] : 0; } - char &operator[](unsigned int i) - { - static char dummy = 0; - return (_buf && i < _len) ? _buf[i] : dummy; - } - - const char *c_str() const { return _buf ? _buf : ""; } - unsigned int length() const { return _len; } - bool isEmpty() const { return _len == 0; } - bool equals(const String &s) const { return *this == s; } - bool equals(const char *s) const { return *this == s; } - bool equalsIgnoreCase(const String &s) const - { - if (_len != s._len) - return false; - for (unsigned i = 0; i < _len; i++) - if (std::tolower(_buf[i]) != std::tolower(s._buf[i])) - return false; - return true; - } - bool startsWith(const String &pfx) const - { - if (pfx._len > _len) - return false; - return strncmp(c_str(), pfx.c_str(), pfx._len) == 0; - } - bool startsWith(const char *pfx) const - { - if (!pfx) - return false; - size_t pl = strlen(pfx); - return pl <= _len && strncmp(c_str(), pfx, pl) == 0; - } - bool endsWith(const String &sfx) const - { - if (sfx._len > _len) - return false; - return strcmp(c_str() + _len - sfx._len, sfx.c_str()) == 0; - } - int indexOf(char c, unsigned from = 0) const - { - if (!_buf) - return -1; - const char *p = strchr(_buf + from, c); - return p ? (int)(p - _buf) : -1; - } - int indexOf(const String &s, unsigned from = 0) const - { - if (!_buf) - return -1; - const char *p = strstr(_buf + from, s.c_str()); - return p ? (int)(p - _buf) : -1; - } - int lastIndexOf(char c) const - { - if (!_buf) - return -1; - const char *p = strrchr(_buf, c); - return p ? (int)(p - _buf) : -1; - } - String substring(unsigned beginIndex) const - { - if (!_buf || beginIndex >= _len) - return String(); - return String(_buf + beginIndex); - } - String substring(unsigned beginIndex, unsigned endIndex) const - { - if (!_buf || beginIndex >= _len) - return String(); - if (endIndex > _len) - endIndex = _len; - if (endIndex <= beginIndex) - return String(); - String r; - r.assign(_buf + beginIndex, endIndex - beginIndex); - return r; - } - void toUpperCase() - { - if (_buf) - for (unsigned i = 0; i < _len; i++) - _buf[i] = (char)std::toupper(_buf[i]); - } - void toLowerCase() - { - if (_buf) - for (unsigned i = 0; i < _len; i++) - _buf[i] = (char)std::tolower(_buf[i]); - } - void trim() - { - if (!_buf || _len == 0) - return; - unsigned s = 0; - while (s < _len && std::isspace(_buf[s])) - s++; - unsigned e = _len; - while (e > s && std::isspace(_buf[e - 1])) - e--; - if (s > 0 || e < _len) { - memmove(_buf, _buf + s, e - s); - _len = e - s; - _buf[_len] = 0; - } - } - void replace(char from, char to) - { - if (_buf) - for (unsigned i = 0; i < _len; i++) - if (_buf[i] == from) - _buf[i] = to; - } - void replace(const String &from, const String &to); - bool remove(unsigned index, unsigned count = 1) - { - if (!_buf || index >= _len) - return false; - if (index + count > _len) - count = _len - index; - memmove(_buf + index, _buf + index + count, _len - index - count + 1); - _len -= count; - return true; - } - void clear() - { - _len = 0; - if (_buf) - _buf[0] = 0; - } - char charAt(unsigned i) const { return (*this)[i]; } - void setCharAt(unsigned i, char c) - { - if (_buf && i < _len) - _buf[i] = c; - } - void toCharArray(char *buf, unsigned int bufsize, unsigned int index = 0) const - { - if (!buf || bufsize == 0) - return; - unsigned int avail = (_buf && _len > index) ? (_len - index) : 0; - unsigned int copy = avail < bufsize - 1 ? avail : bufsize - 1; - if (copy > 0) - memcpy(buf, _buf + index, copy); - buf[copy] = '\0'; - } - void concat(const String &s) { *this += s; } - void concat(const char *s) { *this += s; } - long toInt() const { return _buf ? atol(_buf) : 0; } - float toFloat() const { return _buf ? (float)atof(_buf) : 0.0f; } - double toDouble() const { return _buf ? atof(_buf) : 0.0; } - - private: - char *_buf; - unsigned int _len; - unsigned int _cap; - - void assign(const char *s, unsigned int n) - { - // reserve() keeps the old (smaller) buffer on OOM, so a failed grow must abort the - // write: memcpy'ing n >= _cap bytes would overflow into adjacent heap. - if (n + 1 == 0) - return; // n + 1 would wrap - if (n >= _cap && !reserve(n + 1)) - return; - if (_buf) { - memcpy(_buf, s, n); - _buf[n] = 0; - _len = n; - } - } - void concat(const char *s, unsigned int n) - { - if (!s || n == 0) - return; - unsigned newlen = _len + n; - if (newlen < _len || newlen + 1 == 0) - return; // length arithmetic wrapped - if (newlen >= _cap && !reserve(newlen + 1)) - return; // OOM: keep the existing content intact instead of writing past the buffer - if (_buf) { - memcpy(_buf + _len, s, n); - _len = newlen; - _buf[_len] = 0; - } - } - bool reserve(unsigned int n) - { - if (n == 0) - return false; - char *b = (char *)realloc(_buf, n); - if (b) { - _buf = b; - _cap = n; - return true; - } - return false; - } -}; - -inline String operator+(const char *lhs, const String &rhs) -{ - return String(lhs) + rhs; -} -inline String operator+(char lhs, const String &rhs) -{ - return String(lhs) + rhs; -} - -// ── Print inline definitions that need String ──────────────────────────────── -inline size_t Print::print(const String &s) -{ - return write((const uint8_t *)s.c_str(), s.length()); -} -inline size_t Print::println(const String &s) -{ - size_t r = print(s); - return r + println(); -} - -// ── Stream inline definitions that need String ─────────────────────────────── -inline String Stream::readString() -{ - return String(); -} -inline String Stream::readStringUntil(char) -{ - return String(); -} - -// ── HardwareSerial ─────────────────────────────────────────────────────────── -class HardwareSerial : public Stream -{ - public: - void begin(unsigned long) {} - void begin(unsigned long, uint16_t) {} - void end() {} - void setPins(int rx, int tx) {} - void setPinout(int tx, int rx) {} - void setFIFOSize(size_t) {} - void setRxBufferSize(size_t) {} - void begin(unsigned long baud, uint32_t config, int8_t rx = -1, int8_t tx = -1, bool invert = false) {} - int available() override { return 0; } - int read() override { return -1; } - int peek() override { return -1; } - size_t write(uint8_t c) override; - size_t write(const uint8_t *buf, size_t n) override; - using Print::write; // un-hide base class write(const char*) - size_t readBytes(uint8_t *buf, size_t len) { return 0; } - size_t readBytes(char *buf, size_t len) { return 0; } - operator bool() const { return true; } - void flush() override {} - String readString() { return String(); } - String readStringUntil(char) { return String(); } -}; - -// Uart - nRF52 BSP alias for HardwareSerial (used by GPS.h when ARCH_NRF52) -typedef HardwareSerial Uart; - -extern HardwareSerial Serial; -extern HardwareSerial Serial1; -extern HardwareSerial Serial2; - -// ── map() utility ──────────────────────────────────────────────────────────── -static inline long map(long x, long in_min, long in_max, long out_min, long out_max) -{ - return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; -} - -// ── shiftIn / shiftOut stubs ───────────────────────────────────────────────── -static inline uint8_t shiftIn(uint8_t, uint8_t, uint8_t) -{ - return 0; -} -static inline void shiftOut(uint8_t, uint8_t, uint8_t, uint8_t) {} - -// ── tone / noTone stubs ────────────────────────────────────────────────────── -static inline void tone(uint8_t, unsigned int, unsigned long = 0) {} -static inline void noTone(uint8_t) {} - -// ── pulseIn stub ───────────────────────────────────────────────────────────── -static inline unsigned long pulseIn(uint8_t, uint8_t, unsigned long = 1000000UL) -{ - return 0; -} - -// ── strdup / stpcpy - POSIX extensions not in Zephyr newlib ───────────────── -#ifndef strdup -static inline char *strdup(const char *s) -{ - size_t n = strlen(s) + 1; - char *d = (char *)malloc(n); - if (d) - memcpy(d, s, n); - return d; -} -#endif -#ifndef stpcpy -static inline char *stpcpy(char *dst, const char *src) -{ - while ((*dst++ = *src++) != '\0') { - } - return dst - 1; -} -#endif - -// ── strnstr - BSD extension not in Zephyr libc; defined in meshUtils.cpp ───── -// Declare here so callers (GPS.cpp etc.) don't need ARCH_PORTDUINO. -#ifndef STRNSTR -#define STRNSTR -char *strnstr(const char *s, const char *find, size_t slen); -#endif - -// ── strlcpy - BSD extension; implementation in nrf54l15_arduino.cpp ────────── -#ifndef HAVE_STRLCPY -#define HAVE_STRLCPY -#ifdef __cplusplus -extern "C" { -#endif -size_t strlcpy(char *dst, const char *src, size_t size); -#ifdef __cplusplus -} -#endif -#endif - -// ── setenv / getenv / tzset - Zephyr stubs for timezone support ────────────── -#include -static inline int setenv(const char *, const char *, int) -{ - return 0; -} -static inline void tzset(void) {} - -// ── dbgHeapFree / dbgHeapTotal - nRF52 BSP heap diagnostics ───────────────── -// Used by memGet.cpp when ARCH_NRF52 is defined. Return 0 for Phase 2. -static inline uint32_t dbgHeapFree(void) -{ - return 0; -} -static inline uint32_t dbgHeapTotal(void) -{ - return 0; -} - -// ── WCharacter helpers ─────────────────────────────────────────────────────── -static inline bool isAlpha(char c) -{ - return std::isalpha((unsigned char)c) != 0; -} -static inline bool isAlphaNumeric(char c) -{ - return std::isalnum((unsigned char)c) != 0; -} -static inline bool isDigit(char c) -{ - return std::isdigit((unsigned char)c) != 0; -} -static inline bool isSpace(char c) -{ - return std::isspace((unsigned char)c) != 0; -} -static inline bool isUpperCase(char c) -{ - return std::isupper((unsigned char)c) != 0; -} -static inline bool isLowerCase(char c) -{ - return std::islower((unsigned char)c) != 0; -} -static inline char toUpperCase(char c) -{ - return (char)std::toupper((unsigned char)c); -} -static inline char toLowerCase(char c) -{ - return (char)std::tolower((unsigned char)c); -} - -#else /* C only */ -#ifndef min -#define min(a, b) ((a) < (b) ? (a) : (b)) -#endif -#ifndef max -#define max(a, b) ((a) > (b) ? (a) : (b)) -#endif -#ifndef abs -#define abs(x) ((x) >= 0 ? (x) : -(x)) -#endif -#define constrain(x, l, h) ((x) < (l) ? (l) : ((x) > (h) ? (h) : (x))) -#define round(x) ((x) >= 0 ? (long)((x) + 0.5) : (long)((x)-0.5)) -#endif /* __cplusplus */ - -#endif /* Arduino_h */ diff --git a/src/platform/nrf54l15/IPAddress.h b/src/platform/nrf54l15/IPAddress.h deleted file mode 100644 index 549d11d157..0000000000 --- a/src/platform/nrf54l15/IPAddress.h +++ /dev/null @@ -1,34 +0,0 @@ -// IPAddress.h - stub for nRF54L15/Zephyr -// MQTT.cpp includes this for IPv4 address representation. -// Phase 2: compile-only stub. -#pragma once -#include - -class IPAddress -{ - public: - IPAddress() : _addr(0) {} - explicit IPAddress(uint32_t addr) : _addr(addr) {} - IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) - : _addr(((uint32_t)a) | ((uint32_t)b << 8) | ((uint32_t)c << 16) | ((uint32_t)d << 24)) - { - } - - uint8_t operator[](int i) const { return reinterpret_cast(&_addr)[i]; } - operator uint32_t() const { return _addr; } - bool operator==(const IPAddress &o) const { return _addr == o._addr; } - bool operator!=(const IPAddress &o) const { return _addr != o._addr; } - - bool fromString(const char *addr) - { - unsigned a, b, c, d; - if (sscanf(addr, "%u.%u.%u.%u", &a, &b, &c, &d) == 4 && a <= 255 && b <= 255 && c <= 255 && d <= 255) { - _addr = a | (b << 8) | (c << 16) | (d << 24); - return true; - } - return false; - } - - private: - uint32_t _addr; -}; diff --git a/src/platform/nrf54l15/InternalFileSystem.cpp b/src/platform/nrf54l15/InternalFileSystem.cpp deleted file mode 100644 index 15fdec6df6..0000000000 --- a/src/platform/nrf54l15/InternalFileSystem.cpp +++ /dev/null @@ -1,274 +0,0 @@ -// InternalFileSystem.cpp - Zephyr LittleFS backend for nRF54L15 -// -// Implements Adafruit_LittleFS_Namespace used by FSCommon.h/cpp. -// Storage: 36 KB storage_partition in nRF54L15 internal RRAM (defined in -// zephyr/dts/nordic/nrf54l15_partition.dtsi, included by the board DTS). - -#include "InternalFileSystem.h" -#include "configuration.h" - -#include -#include -#include - -using namespace Adafruit_LittleFS_Namespace; - -// ── LittleFS mount ──────────────────────────────────────────────────────── - -FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(nrf54l15_lfs_data); - -static struct fs_mount_t _lfs_mnt = { - .type = FS_LITTLEFS, - .mnt_point = NRF54L15_FS_MOUNT, - .fs_data = &nrf54l15_lfs_data, - .storage_dev = (void *)(uintptr_t)FIXED_PARTITION_ID(storage_partition), - .flags = 0, -}; - -// ── Global singleton ────────────────────────────────────────────────────── - -Adafruit_LittleFS_Namespace::InternalFileSystem InternalFS; - -// ── Path helpers ────────────────────────────────────────────────────────── - -void InternalFileSystem::toabs(const char *rel, char *abs, size_t abssz) -{ - // Root "/" maps to the mount point itself (no trailing slash) - if (rel[0] == '/' && rel[1] == '\0') { - strncpy(abs, NRF54L15_FS_MOUNT, abssz - 1); - abs[abssz - 1] = '\0'; - } else if (rel[0] == '/') { - snprintf(abs, abssz, "%s%s", NRF54L15_FS_MOUNT, rel); - } else { - snprintf(abs, abssz, "%s/%s", NRF54L15_FS_MOUNT, rel); - } -} - -// Strip mount-point prefix to get the FS-root-relative path ("/prefs/..."). -static void torel(const char *abs, char *rel, size_t relsz) -{ - const char *mp = NRF54L15_FS_MOUNT; - size_t mplen = strlen(mp); - if (strncmp(abs, mp, mplen) == 0) { - const char *suffix = abs + mplen; - if (suffix[0] == '\0') { - strncpy(rel, "/", relsz); - } else { - strncpy(rel, suffix, relsz - 1); - rel[relsz - 1] = '\0'; - } - } else { - strncpy(rel, abs, relsz - 1); - rel[relsz - 1] = '\0'; - } -} - -// ── InternalFileSystem methods ──────────────────────────────────────────── - -bool InternalFileSystem::begin() -{ - if (_mounted) - return true; - - int rc = fs_mount(&_lfs_mnt); - if (rc == 0) { - _mounted = true; - return true; - } - - // Mount failed: attempt to format (creates a fresh LittleFS) - LOG_WARN("LittleFS mount failed (%d), formatting storage partition", rc); - int fmt_rc = fs_mkfs(FS_LITTLEFS, (uintptr_t)FIXED_PARTITION_ID(storage_partition), NULL, 0); - if (fmt_rc != 0) { - LOG_ERROR("LittleFS format failed (%d)", fmt_rc); - return false; - } - - rc = fs_mount(&_lfs_mnt); - if (rc == 0) { - _mounted = true; - return true; - } - - LOG_ERROR("LittleFS mount failed after format (%d)", rc); - return false; -} - -File InternalFileSystem::open(const char *path, const char *mode) -{ - if (!_mounted) - return File(); - - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - - auto s = std::make_shared(); - if (!s) - return File(); - - strncpy(s->fullpath, abs, sizeof(s->fullpath) - 1); - torel(abs, s->relpath, sizeof(s->relpath)); - - // Check whether the path is a directory - struct fs_dirent entry; - int stat_rc = fs_stat(abs, &entry); - if (stat_rc == 0 && entry.type == FS_DIR_ENTRY_DIR) { - s->is_dir = true; - if (fs_opendir(&s->dir, abs) == 0) { - s->valid = true; - return File(s); - } - return File(); - } - - // Open as a regular file - fs_mode_t flags; - if (strcmp(mode, FILE_O_WRITE) == 0) { - // Truncate on write - unlink first to ensure a clean start - fs_unlink(abs); - flags = FS_O_WRITE | FS_O_CREATE; - } else { - flags = FS_O_READ; - } - - if (fs_open(&s->file, abs, flags) == 0) { - s->is_dir = false; - s->valid = true; - return File(s); - } - - return File(); -} - -bool InternalFileSystem::exists(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - struct fs_dirent entry; - return fs_stat(abs, &entry) == 0; -} - -bool InternalFileSystem::remove(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - return fs_unlink(abs) == 0; -} - -bool InternalFileSystem::rename(const char *from, const char *to) -{ - if (!_mounted) - return false; - char absfrom[NRF54L15_FS_PATHLEN], absto[NRF54L15_FS_PATHLEN]; - toabs(from, absfrom, sizeof(absfrom)); - toabs(to, absto, sizeof(absto)); - return fs_rename(absfrom, absto) == 0; -} - -bool InternalFileSystem::mkdir(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - int rc = fs_mkdir(abs); - return rc == 0 || rc == -EEXIST; -} - -bool InternalFileSystem::rmdir(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - return fs_unlink(abs) == 0; -} - -bool InternalFileSystem::rmdir_r(const char *path) -{ - if (!_mounted) - return false; - char abs[NRF54L15_FS_PATHLEN]; - toabs(path, abs, sizeof(abs)); - - struct fs_dir_t dir; - fs_dir_t_init(&dir); - if (fs_opendir(&dir, abs) != 0) { - // Not a directory - try to delete as file - return fs_unlink(abs) == 0; - } - - struct fs_dirent entry; - char child[NRF54L15_FS_PATHLEN]; - while (fs_readdir(&dir, &entry) == 0 && entry.name[0] != '\0') { - snprintf(child, sizeof(child), "%s/%s", abs, entry.name); - if (entry.type == FS_DIR_ENTRY_DIR) { - // Recurse: pass the absolute path stripped of mount prefix - char childrel[NRF54L15_FS_PATHLEN]; - torel(child, childrel, sizeof(childrel)); - rmdir_r(childrel); - } else { - fs_unlink(child); - } - } - fs_closedir(&dir); - return fs_unlink(abs) == 0; -} - -bool InternalFileSystem::format() -{ - if (_mounted) { - fs_unmount(&_lfs_mnt); - _mounted = false; - } - int rc = fs_mkfs(FS_LITTLEFS, (uintptr_t)FIXED_PARTITION_ID(storage_partition), NULL, 0); - if (rc != 0) { - LOG_ERROR("LittleFS format failed (%d)", rc); - return false; - } - return begin(); -} - -// ── File::openNextFile ──────────────────────────────────────────────────── -// Defined here because it accesses Zephyr fs_readdir/fs_open APIs. - -File File::openNextFile() -{ - if (!_s || !_s->valid || !_s->is_dir) - return File(); - - struct fs_dirent entry; - if (fs_readdir(&_s->dir, &entry) != 0) - return File(); - if (entry.name[0] == '\0') - return File(); // end of directory - - char childabs[NRF54L15_FS_PATHLEN]; - snprintf(childabs, sizeof(childabs), "%s/%s", _s->fullpath, entry.name); - - auto s = std::make_shared(); - if (!s) - return File(); - - strncpy(s->fullpath, childabs, sizeof(s->fullpath) - 1); - torel(childabs, s->relpath, sizeof(s->relpath)); - - if (entry.type == FS_DIR_ENTRY_DIR) { - s->is_dir = true; - if (fs_opendir(&s->dir, childabs) == 0) { - s->valid = true; - return File(s); - } - } else { - s->is_dir = false; - if (fs_open(&s->file, childabs, FS_O_READ) == 0) { - s->valid = true; - return File(s); - } - } - return File(); -} diff --git a/src/platform/nrf54l15/InternalFileSystem.h b/src/platform/nrf54l15/InternalFileSystem.h deleted file mode 100644 index 8eea7c1cb3..0000000000 --- a/src/platform/nrf54l15/InternalFileSystem.h +++ /dev/null @@ -1,212 +0,0 @@ -// InternalFileSystem.h - Zephyr LittleFS backend for nRF54L15 -// -// Implements the Adafruit InternalFileSystem API subset used by Meshtastic, -// backed by Zephyr's fs/littlefs on the storage_partition of the nRF54L15's -// internal RRAM. Partition size is taken from the DTS at compile time via -// FIXED_PARTITION_SIZE(storage_partition) - the DK overlay currently maps -// ~700 KB into slot1 (see zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay). -// -// Mount point: /lfs -// All paths passed to open/exists/mkdir etc. are relative to the FS root -// (e.g. "/prefs/config.proto") and are prepended with "/lfs" internally. -// -// File objects are copyable via std::shared_ptr. -// The underlying Zephyr handle is closed when the last copy is destroyed. - -#pragma once - -#include -#include -#include -#include - -#include -#include - -#ifndef FILE_O_READ -#define FILE_O_READ "r" -#define FILE_O_WRITE "w" -#endif - -#define NRF54L15_FS_MOUNT "/lfs" -#define NRF54L15_FS_PATHLEN 256 - -namespace Adafruit_LittleFS_Namespace -{ - -class InternalFileSystem; // forward - -// ── Internal file/dir state ─────────────────────────────────────────────── - -struct NRF54L15FileState { - bool valid = false; - bool is_dir = false; - - // Absolute Zephyr path, e.g. "/lfs/prefs/config.proto" - char fullpath[NRF54L15_FS_PATHLEN] = {0}; - // Path from FS root, e.g. "/prefs/config.proto" (returned by name()) - char relpath[NRF54L15_FS_PATHLEN] = {0}; - - struct fs_file_t file; - struct fs_dir_t dir; - - NRF54L15FileState() - { - fs_file_t_init(&file); - fs_dir_t_init(&dir); - } - - ~NRF54L15FileState() - { - if (valid) { - if (is_dir) - fs_closedir(&dir); - else - fs_close(&file); - valid = false; - } - } -}; - -// ── File ───────────────────────────────────────────────────────────────── - -class File -{ - public: - File() = default; - explicit File(InternalFileSystem &) {} // nRF52 compat constructor - - explicit operator bool() const { return _s && _s->valid; } - - int read(void *buf, uint16_t nbyte) - { - if (!_s || !_s->valid || _s->is_dir) - return -1; - ssize_t n = fs_read(&_s->file, buf, nbyte); - return n < 0 ? -1 : (int)n; - } - - int read() - { - uint8_t b; - return read(&b, 1) == 1 ? (int)b : -1; - } - - size_t write(const uint8_t *buf, size_t len) - { - if (!_s || !_s->valid || _s->is_dir) - return 0; - ssize_t n = fs_write(&_s->file, buf, len); - return n < 0 ? 0 : (size_t)n; - } - - size_t write(uint8_t b) { return write(&b, 1); } - - void flush() - { - if (_s && _s->valid && !_s->is_dir) - fs_sync(&_s->file); - } - - void close() { _s.reset(); } - - size_t size() - { - if (!_s || !_s->valid || _s->is_dir) - return 0; - struct fs_dirent entry; - if (fs_stat(_s->fullpath, &entry) == 0) - return (size_t)entry.size; - return 0; - } - - bool isDirectory() { return _s && _s->valid && _s->is_dir; } - - // Returns path from FS root, e.g. "/prefs/config.proto" - const char *name() { return _s ? _s->relpath : ""; } - - // Returns the next entry in a directory. Modifies the dir stream in _s. - File openNextFile(); - - void rewindDirectory() - { - if (!_s || !_s->valid || !_s->is_dir) - return; - // Zephyr has no rewinddir(); close + reopen the same handle. Skipping - // the close would leak the LittleFS dir state and the next openNextFile - // could return stale entries on some Zephyr versions. - fs_closedir(&_s->dir); - fs_dir_t_init(&_s->dir); - if (fs_opendir(&_s->dir, _s->fullpath) != 0) { - _s->valid = false; - } - } - - bool seek(uint32_t pos) - { - if (!_s || !_s->valid || _s->is_dir) - return false; - return fs_seek(&_s->file, (off_t)pos, FS_SEEK_SET) == 0; - } - - int available() - { - if (!_s || !_s->valid || _s->is_dir) - return 0; - off_t pos = fs_tell(&_s->file); - if (pos < 0) - return 0; - struct fs_dirent entry; - if (fs_stat(_s->fullpath, &entry) != 0) - return 0; - long rem = (long)entry.size - (long)pos; - return rem > 0 ? (int)rem : 0; - } - - int peek() { return -1; } - - // Internal: constructed by InternalFileSystem and openNextFile() - explicit File(std::shared_ptr s) : _s(std::move(s)) {} - - private: - std::shared_ptr _s; -}; - -// ── InternalFileSystem ──────────────────────────────────────────────────── - -class InternalFileSystem -{ - public: - bool begin(); - File open(const char *path, const char *mode); - bool exists(const char *path); - bool remove(const char *path); - bool rename(const char *from, const char *to); - bool mkdir(const char *path); - bool rmdir(const char *path); - bool rmdir_r(const char *path); // recursive delete (used by FSCommon rmDir) - uint32_t usedBytes() - { - struct fs_statvfs st = {}; - if (fs_statvfs(NRF54L15_FS_MOUNT, &st) != 0) - return 0; - // Zephyr returns block counts; convert to bytes. f_frsize is the - // fundamental fragment size (LittleFS reports it equal to the block - // size). used = (total - free) * frag_size. - if (st.f_blocks <= st.f_bfree) - return 0; - return (uint32_t)((st.f_blocks - st.f_bfree) * st.f_frsize); - } - uint32_t totalBytes() { return (uint32_t)FIXED_PARTITION_SIZE(storage_partition); } - bool format(); - - // Convert a FS-root-relative path to an absolute Zephyr path. - static void toabs(const char *rel, char *abs, size_t abssz); - - private: - bool _mounted = false; -}; - -} // namespace Adafruit_LittleFS_Namespace - -extern Adafruit_LittleFS_Namespace::InternalFileSystem InternalFS; diff --git a/src/platform/nrf54l15/NRF52Bluetooth.h b/src/platform/nrf54l15/NRF52Bluetooth.h deleted file mode 100644 index 55686d379e..0000000000 --- a/src/platform/nrf54l15/NRF52Bluetooth.h +++ /dev/null @@ -1,18 +0,0 @@ -// NRF52Bluetooth.h - stub for nRF54L15/Zephyr -// main.h includes this when ARCH_NRF52 is defined. -// Bluetooth is excluded (MESHTASTIC_EXCLUDE_BLUETOOTH=1); this satisfies the -// include chain without pulling in the nRF52 Bluefruit SDK. -#pragma once - -class NRF52Bluetooth -{ - public: - void setup() {} - void shutdown() {} - void startDisabled() {} - void resumeAdvertising() {} - void clearBonds() {} - bool isConnected() { return false; } - int getRssi() { return 0; } - void sendLog(const uint8_t *, size_t) {} -}; diff --git a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp b/src/platform/nrf54l15/NRF54L15Bluetooth.cpp deleted file mode 100644 index 8cef2fb59c..0000000000 --- a/src/platform/nrf54l15/NRF54L15Bluetooth.cpp +++ /dev/null @@ -1,806 +0,0 @@ -// NRF54L15Bluetooth.cpp - Zephyr BLE GATT peripheral for Meshtastic nRF54L15 -// -// GATT profile (identical UUIDs to the nRF52 / NimBLE implementations): -// Service: 6ba1b218-15a8-461f-9fa8-5dcae273eafd -// fromNum: ed9da18c-a800-4f66-a670-aa7547e34453 READ | NOTIFY -// fromRadio: 2c55e69e-4993-11ed-b878-0242ac120002 READ -// toRadio: f75c76d2-129e-4dad-a1dd-7866124401e7 WRITE -// logRadio: 5a3d6e49-06e6-4423-9944-e9de8cdf9547 READ | NOTIFY | INDICATE -// -// Threading model: -// - BT RX thread: connected_cb / disconnected_cb / GATT read_/write_ -// callbacks -// - Meshtastic OSThread scheduler (cooperative, main thread): -// BleDeferredThread -// polls pendingToRadio and runs the zombie-connection watchdog every 100 ms -// - PhoneAPI::onNowHasData: sends fromNum notify synchronously from whichever -// thread pushed the packet (bt_gatt_notify is thread-safe in Zephyr) -// - active_conn protected by ble_mutex where needed - -#include "NRF54L15Bluetooth.h" -#include "BluetoothCommon.h" -#include "BluetoothStatus.h" -#include "PowerFSM.h" -#include "UptimeClock.h" -#include "concurrency/OSThread.h" -#include "configuration.h" -#include "main.h" -#include "mesh/PhoneAPI.h" -#include "mesh/mesh-pb-constants.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -// ── UUID definitions (little-endian per Bluetooth spec) -// ─────────────────────── Syntax: replace hyphens with commas, prefix 0x - -// matches BT_UUID_128_ENCODE doc. - -#define MESH_SVC_UUID_VAL BT_UUID_128_ENCODE(0x6ba1b218, 0x15a8, 0x461f, 0x9fa8, 0x5dcae273eafd) -#define FROMNUM_UUID_VAL BT_UUID_128_ENCODE(0xed9da18c, 0xa800, 0x4f66, 0xa670, 0xaa7547e34453) -#define FROMRADIO_UUID_VAL BT_UUID_128_ENCODE(0x2c55e69e, 0x4993, 0x11ed, 0xb878, 0x0242ac120002) -#define TORADIO_UUID_VAL BT_UUID_128_ENCODE(0xf75c76d2, 0x129e, 0x4dad, 0xa1dd, 0x7866124401e7) -#define LOGRADIO_UUID_VAL BT_UUID_128_ENCODE(0x5a3d6e49, 0x06e6, 0x4423, 0x9944, 0xe9de8cdf9547) - -static const struct bt_uuid_128 mesh_svc_uuid = BT_UUID_INIT_128(MESH_SVC_UUID_VAL); -static const struct bt_uuid_128 fromnum_uuid = BT_UUID_INIT_128(FROMNUM_UUID_VAL); -static const struct bt_uuid_128 fromradio_uuid = BT_UUID_INIT_128(FROMRADIO_UUID_VAL); -static const struct bt_uuid_128 toradio_uuid = BT_UUID_INIT_128(TORADIO_UUID_VAL); -static const struct bt_uuid_128 logradio_uuid = BT_UUID_INIT_128(LOGRADIO_UUID_VAL); - -// ── Module state ───────────────────────────────────────────────────────────── - -static struct bt_conn *active_conn = nullptr; -static K_MUTEX_DEFINE(ble_mutex); - -// Take a reference to active_conn under ble_mutex. Returns nullptr if there is -// no active connection. Caller MUST bt_conn_unref() when done. -// -// Reading `active_conn` outside this lock races with disconnected_cb which can -// unref + null it on the BT RX thread - touching the freed pointer (even just -// to bt_conn_ref it) is a use-after-free. -static struct bt_conn *acquire_active_conn() -{ - struct bt_conn *conn = nullptr; - k_mutex_lock(&ble_mutex, K_FOREVER); - if (active_conn) { - conn = bt_conn_ref(active_conn); - } - k_mutex_unlock(&ble_mutex); - return conn; -} - -static bool bt_initialized = false; // bt_enable() called at most once -static bool ble_enabled = false; // set by setup(), cleared by shutdown() - -// Forward declarations - BT_GATT_SERVICE_DEFINE(mesh_svc, ...) is below, but -// read_fromradio() (defined earlier) needs to reference the service to notify -// on fromNum after each non-empty read. -#define FROMNUM_ATTR_IDX 2 -#define LOGRADIO_ATTR_IDX 9 -extern const struct bt_gatt_service_static mesh_svc; - -static void start_advertising(); // forward declaration (defined in advertising - // section below) - -// Work item for advertising restart after disconnect. -// -// disconnected_cb runs on the BT RX thread (the same thread that processes -// HCI Command Complete events). Calling bt_le_adv_start() → -// bt_hci_cmd_send_sync() directly from that thread deadlocks: the thread blocks -// on k_sem_take waiting for Command Complete, but it is the very thread that -// would process it. After 10 s the host panics with "Controller unresponsive, -// opcode 0x2006 timeout". -// -// Fix: submit a k_work item. The system workqueue runs bt_adv_restart_work_fn -// on its own thread → no deadlock. -static struct k_work adv_restart_work; - -static void adv_restart_work_fn(struct k_work *work) -{ - if (ble_enabled) { - start_advertising(); - } -} - -// CCC state: 0=off, BT_GATT_CCC_NOTIFY=notify, BT_GATT_CCC_INDICATE=indicate -static uint16_t fromnum_ccc_val = 0; -static uint16_t logradio_ccc_val = 0; - -// Scratch buffers - only one BLE operation at a time -static uint8_t fromRadioBytes[meshtastic_FromRadio_size]; -static size_t fromRadioLen = 0; -static uint8_t toRadioBytes[meshtastic_ToRadio_size]; -static uint8_t lastToRadio[MAX_TO_FROM_RADIO_SIZE]; -static uint32_t fromNumValue = 0; - -// Deferred ToRadio processing -// -// write_toradio() runs on the BT RX workqueue thread (6 KB stack). Calling -// phoneAPI->handleToRadio() directly triggers handleStartConfig → -// getFiles("/", 10) → nanopb encode, which overflows the stack on the exact -// "Client wants config" write. Instead we copy the payload into a pending -// buffer under a mutex and let BleDeferredThread (running on the Meshtastic -// OSThread scheduler, 24 KB stack) do the actual call outside the lock. -// -// The mutex makes the producer/consumer handoff race-free - producer may -// overwrite a pending buffer the consumer hasn't read yet (dropped packet), -// but partial reads / torn writes are impossible. -K_MUTEX_DEFINE(pendingToRadioMutex); -static uint8_t pendingToRadioBuf[MAX_TO_FROM_RADIO_SIZE]; -static size_t pendingToRadioLen = 0; -static bool pendingToRadio = false; - -// Zombie-connection watchdog state. -// -// The nRF54L15 Zephyr 4.2.1 SW-LL occasionally fails to forward an -// LE Disconnection Complete event to the host: when iOS tears down the link -// (either explicitly by the user or via supervision timeout), the LL layer -// drops the connection but disconnected_cb never fires, active_conn stays -// non-null and advertising never restarts - the device vanishes from scans -// until power cycle. Track the connected timestamp and the last time we -// observed ATT traffic; a long ATT idle on an "active" connection means we -// are zombied. A cold reboot is the only path that reliably recovers (any -// bt_hci_cmd_send_sync after this state, e.g. bt_le_adv_start or -// bt_conn_disconnect, hangs in k_sem_take and later panics with "Controller -// unresponsive, opcode 0x2006 timeout"). -static uint32_t connect_time_ms = 0; -static uint32_t last_att_time_ms = 0; - -// ── BluetoothPhoneAPI -// ───────────────────────────────────────────────────────── - -class BluetoothPhoneAPI : public PhoneAPI -{ - virtual void onNowHasData(uint32_t fromRadioNum) override; - virtual bool checkIsConnected() override; - - public: - BluetoothPhoneAPI() { api_type = TYPE_BLE; } -}; - -static BluetoothPhoneAPI *phoneAPI = nullptr; - -// ── CCC change callbacks -// ────────────────────────────────────────────────────── - -static void fromnum_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) -{ - fromnum_ccc_val = value; - LOG_INFO("BLE fromNum CCC: %u", value); -} - -static void logradio_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) -{ - logradio_ccc_val = value; - LOG_INFO("BLE logRadio CCC: %u", value); -} - -// ── GATT attribute callbacks -// ────────────────────────────────────────────────── - -static ssize_t read_fromnum(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) -{ - LOG_INFO("GATT read_fromnum: fromNum=%u offset=%u", fromNumValue, offset); - return bt_gatt_attr_read(conn, attr, buf, len, offset, &fromNumValue, sizeof(fromNumValue)); -} - -static ssize_t read_fromradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) -{ - if (offset == 0) { - // First chunk: pull the next packet from the queue. - // Subsequent chunks (offset > 0) are ATT_READ_BLOB continuations of the - // same value and must reuse fromRadioBytes untouched. - fromRadioLen = phoneAPI ? phoneAPI->getFromRadio(fromRadioBytes) : 0; - LOG_DEBUG("GATT read_fromradio len=%u", (unsigned)fromRadioLen); - } - last_att_time_ms = k_uptime_get_32(); - return bt_gatt_attr_read(conn, attr, buf, len, offset, fromRadioBytes, fromRadioLen); -} - -static ssize_t read_logradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) -{ - // logRadio is write-only from the device side (notify/indicate). - // Return an empty read so GATT discovery doesn't fail with NOT_PERMITTED. - return bt_gatt_attr_read(conn, attr, buf, len, offset, NULL, 0); -} - -static ssize_t write_toradio(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len, - uint16_t offset, uint8_t flags) -{ - // Writes >MTU-3 arrive here with offset=0 and - // flags=BT_GATT_WRITE_FLAG_EXECUTE after Zephyr reassembles the ATT Prepare - // Write fragments (CONFIG_BT_ATT_PREPARE_COUNT>0). Single writes arrive with - // flags=0. - LOG_DEBUG("GATT write_toradio len=%u flags=0x%x", len, flags); - if (offset != 0) { - return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET); - } - // Reject any write that won't fit in the dedup buffer (lastToRadio) or the - // pending handoff buffer (pendingToRadioBuf), both sized - // MAX_TO_FROM_RADIO_SIZE. Returning success while silently dropping a - // payload would let the phone believe a config write was applied. - if (len > sizeof(toRadioBytes) || len > MAX_TO_FROM_RADIO_SIZE) { - return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); - } - - // Deduplicate - drop packet if identical to the last one we processed - if (memcmp(lastToRadio, buf, len) != 0) { - memcpy(lastToRadio, buf, len); - if (len < MAX_TO_FROM_RADIO_SIZE) { - memset(lastToRadio + len, 0, MAX_TO_FROM_RADIO_SIZE - len); - } - // Defer handleToRadio() to BleDeferredThread (24 KB stack). - // Running it here on bt_workq (6 KB) overflows during handleStartConfig. - // Always overwrite pending - we already dedup'd above via lastToRadio, - // so any new write here is genuinely new data that must be delivered. - k_mutex_lock(&pendingToRadioMutex, K_FOREVER); - memcpy(pendingToRadioBuf, buf, len); - pendingToRadioLen = len; - pendingToRadio = true; - k_mutex_unlock(&pendingToRadioMutex); - } - last_att_time_ms = k_uptime_get_32(); - return (ssize_t)len; -} - -// ── GATT service definition (static, linked at compile time) -// ────────────────── -// -// Attribute indices (0-based): -// [0] Primary Service declaration -// [1] fromNum characteristic declaration -// [2] fromNum value ← notify target (FROMNUM_ATTR_IDX) -// [3] fromNum CCC descriptor -// [4] fromRadio characteristic declaration -// [5] fromRadio value -// [6] toRadio characteristic declaration -// [7] toRadio value -// [8] logRadio characteristic declaration -// [9] logRadio value ← notify target (LOGRADIO_ATTR_IDX) -// [10] logRadio CCC descriptor - -// All user characteristics require authenticated encryption (MITM passkey) -// before the client can read/write. This mirrors the nrf52 -// SECMODE_ENC_WITH_MITM service permission. The stack returns "Insufficient -// Authentication" on the first access attempt, prompting the client to pair -// with the configured PIN. -#define MESH_PERM_READ (BT_GATT_PERM_READ | BT_GATT_PERM_READ_AUTHEN) -#define MESH_PERM_WRITE (BT_GATT_PERM_WRITE | BT_GATT_PERM_WRITE_AUTHEN) - -BT_GATT_SERVICE_DEFINE(mesh_svc, BT_GATT_PRIMARY_SERVICE(&mesh_svc_uuid.uuid), - - // fromNum: READ | NOTIFY - packet-counter triggers phone to read fromRadio - BT_GATT_CHARACTERISTIC(&fromnum_uuid.uuid, BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY, MESH_PERM_READ, - read_fromnum, NULL, &fromNumValue), - BT_GATT_CCC(fromnum_ccc_changed, MESH_PERM_READ | MESH_PERM_WRITE), - - // fromRadio: READ - phone polls this after receiving a fromNum notification - BT_GATT_CHARACTERISTIC(&fromradio_uuid.uuid, BT_GATT_CHRC_READ, MESH_PERM_READ, read_fromradio, NULL, - NULL), - - // toRadio: WRITE - phone sends protobuf packets to the device - BT_GATT_CHARACTERISTIC(&toradio_uuid.uuid, BT_GATT_CHRC_WRITE, MESH_PERM_WRITE, NULL, write_toradio, NULL), - - // logRadio: READ | NOTIFY | INDICATE - log stream to phone when connected - BT_GATT_CHARACTERISTIC(&logradio_uuid.uuid, - BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY | BT_GATT_CHRC_INDICATE, MESH_PERM_READ, - read_logradio, NULL, NULL), - BT_GATT_CCC(logradio_ccc_changed, MESH_PERM_READ | MESH_PERM_WRITE), ); - -// ── Advertising -// ─────────────────────────────────────────────────────────────── -// -// Use legacy advertising (bt_le_adv_start / HCI 0x2006 path). -// -// History: we previously used bt_le_ext_adv_create (true extended advertising) -// because bt_le_adv_start() with CONFIG_BT_EXT_ADV=y was translated internally -// to the extended HCI path with LEGACY-bit (0x2036), which produced -// non-connectable PDUs on the nRF54L15 SW-LL. The true extended path -// (0x203x, AUX_ADV_IND) was connectable but caused two problems: -// 1. iOS CoreBluetooth does not reliably complete GATT after connecting via -// extended advertising (zero ATT PDUs observed in all test sessions). -// 2. After each connection the controller auto-stops the advertising set, and -// the subsequent bt_le_ext_adv_delete() sends LE Remove Advertising Set -// (0x203c) which times out → kernel oops at hci_core.c:506. -// -// With CONFIG_BT_EXT_ADV=n the host uses pure legacy HCI commands - the same -// path Nordic NCS uses in all nRF54L15 examples (peripheral_uart, -// peripheral_lbs) and which is universally iOS-compatible. The legacy data -// payload is 31 bytes: -// FLAGS (3B) + UUID128 (18B) = 21B in adv; NAME in scan-response (17B). - -static void start_advertising() -{ - // IMPORTANT: BT_DATA_BYTES() uses C99 compound literals that GCC C++ treats - // as temporaries; with -Os the compiler may elide writes, leaving stack - // uninitialized. Use static const arrays for stable data (flags, UUID) - // and a runtime pointer for the dynamic device name. - static const uint8_t adv_flags_val[] = {BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR}; - static const uint8_t adv_uuid128_val[] = {MESH_SVC_UUID_VAL}; - - const char *name = bt_get_name(); - size_t full_name_len = strlen(name); - - // Legacy scan-response payload is 31 bytes total. Each AD entry costs 2 - // bytes (length + type), leaving 29 bytes for the name. With - // CONFIG_BT_DEVICE_NAME_MAX=32 the name can exceed that - truncate and - // mark as SHORTENED so bt_le_adv_start() doesn't reject the payload. - constexpr size_t LEGACY_SCAN_RSP_NAME_MAX = 31 - 2; - bool name_shortened = full_name_len > LEGACY_SCAN_RSP_NAME_MAX; - uint8_t name_len = (uint8_t)(name_shortened ? LEGACY_SCAN_RSP_NAME_MAX : full_name_len); - - // Primary advertising data: FLAGS + Meshtastic service UUID128 (21 bytes - // total) - struct bt_data ad[] = { - {BT_DATA_FLAGS, sizeof(adv_flags_val), adv_flags_val}, - {BT_DATA_UUID128_ALL, sizeof(adv_uuid128_val), adv_uuid128_val}, - }; - // Scan response: device name (discovered after scan request) - struct bt_data sd[] = { - {(uint8_t)(name_shortened ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE), name_len, (const uint8_t *)name}, - }; - - // BT_LE_ADV_OPT_CONN = connectable legacy ADV_IND + stops after first - // connection (replaces deprecated - // CONNECTABLE|ONE_TIME in Zephyr 4.2.1; - // BT_LE_ADV_OPT_CONN = BIT(0)|BIT(1)) - // BT_LE_ADV_OPT_USE_IDENTITY = use static random identity address (stable - // across reboots) Advertising restart after disconnect is via - // adv_restart_work (system workqueue) so calling bt_le_adv_start() from the - // BT RX thread context is avoided. - int err = bt_le_adv_start(BT_LE_ADV_PARAM(BT_LE_ADV_OPT_CONN | BT_LE_ADV_OPT_USE_IDENTITY, BT_GAP_ADV_FAST_INT_MIN_2, - BT_GAP_ADV_FAST_INT_MAX_2, NULL), - ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); - - if (err == -EALREADY) { - return; - } - if (err) { - LOG_WARN("BLE adv start failed: %d", err); - } else { - LOG_INFO("BLE advertising as '%s'", bt_get_name()); - } -} - -static void stop_advertising() -{ - bt_le_adv_stop(); -} - -// ── Connection callbacks -// ────────────────────────────────────────────────────── - -static void connected_cb(struct bt_conn *conn, uint8_t err) -{ - if (err) { - LOG_WARN("BLE connection failed, err=0x%02x", err); - return; - } - - k_mutex_lock(&ble_mutex, K_FOREVER); - active_conn = bt_conn_ref(conn); - k_mutex_unlock(&ble_mutex); - - memset(lastToRadio, 0, sizeof(lastToRadio)); - connect_time_ms = Time::skipZero(k_uptime_get_32()); - last_att_time_ms = connect_time_ms; - - char addr[BT_ADDR_LE_STR_LEN]; - bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); - LOG_INFO("BLE connected: %s", addr); - - meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); - bluetoothStatus->updateStatus(&newStatus); - - // nRF54L15-DK has no screen - cannot display a PIN to the user. - // Requesting BT_SECURITY_L2 causes the OS to show a pairing dialog that - // the user dismisses, triggering disconnect + advertising restart failure. - // Skip security negotiation; the Meshtastic app works over plain GATT. - // (Security can be re-enabled once a display or NFC OOB path is available.) -} - -static void disconnected_cb(struct bt_conn *conn, uint8_t reason) -{ - LOG_INFO("BLE disconnected, reason=0x%02x", reason); - - k_mutex_lock(&ble_mutex, K_FOREVER); - if (active_conn) { - bt_conn_unref(active_conn); - active_conn = nullptr; - } - k_mutex_unlock(&ble_mutex); - - fromnum_ccc_val = 0; - logradio_ccc_val = 0; - connect_time_ms = 0; - last_att_time_ms = 0; - - if (phoneAPI) { - phoneAPI->close(); - } - memset(lastToRadio, 0, sizeof(lastToRadio)); - - meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); - bluetoothStatus->updateStatus(&newStatus); - - // Schedule advertising restart via work queue - NOT from this callback - // directly. disconnected_cb runs on the BT RX thread; calling - // bt_le_adv_start() here would deadlock (see adv_restart_work comment above). - if (ble_enabled) { - k_work_submit(&adv_restart_work); - } -} - -#if defined(CONFIG_BT_SMP) -static void security_changed_cb(struct bt_conn *conn, bt_security_t level, enum bt_security_err err) -{ - if (err == BT_SECURITY_ERR_PIN_OR_KEY_MISSING) { - // Phone has a stale bond (device was wiped/reflashed). Unpair the stale - // entry so the phone re-pairs cleanly on the next connection attempt. - LOG_WARN("BLE stale bond (key missing) - unpairing"); - bt_unpair(BT_ID_DEFAULT, bt_conn_get_dst(conn)); - bt_conn_disconnect(conn, BT_HCI_ERR_AUTH_FAIL); - } else if (err) { - LOG_WARN("BLE security change failed: level=%d err=%d", (int)level, (int)err); - } else { - LOG_INFO("BLE security level %d established", (int)level); - } -} -#endif /* CONFIG_BT_SMP */ - -BT_CONN_CB_DEFINE(conn_callbacks) = { - .connected = connected_cb, - .disconnected = disconnected_cb, -#if defined(CONFIG_BT_SMP) - .security_changed = security_changed_cb, -#endif -}; - -// ── Pairing / auth callbacks -// ────────────────────────────────────────────────── - -#if defined(CONFIG_BT_SMP) -static uint32_t configuredPasskey; - -static void auth_passkey_display(struct bt_conn *conn, unsigned int passkey) -{ - char passkey_str[7]; - snprintf(passkey_str, sizeof(passkey_str), "%06u", passkey); - configuredPasskey = passkey; - LOG_INFO("BLE pairing PIN: %s", passkey_str); - powerFSM.trigger(EVENT_BLUETOOTH_PAIR); - - std::string textkey(passkey_str); - meshtastic::BluetoothStatus pairingStatus(textkey); - bluetoothStatus->updateStatus(&pairingStatus); -} - -static void auth_cancel(struct bt_conn *conn) -{ - LOG_WARN("BLE pairing cancelled"); -} - -static struct bt_conn_auth_cb auth_cb = { - .passkey_display = auth_passkey_display, - .passkey_entry = NULL, - .cancel = auth_cancel, -}; - -static void pairing_complete_cb(struct bt_conn *conn, bool bonded) -{ - LOG_INFO("BLE pairing complete, bonded=%d", (int)bonded); - meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); - bluetoothStatus->updateStatus(&newStatus); -} - -static void pairing_failed_cb(struct bt_conn *conn, enum bt_security_err reason) -{ - LOG_WARN("BLE pairing failed, reason=%d", (int)reason); - meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED); - bluetoothStatus->updateStatus(&newStatus); -} - -static struct bt_conn_auth_info_cb auth_info_cb = { - .pairing_complete = pairing_complete_cb, - .pairing_failed = pairing_failed_cb, -}; -#endif /* CONFIG_BT_SMP */ - -// ── BluetoothPhoneAPI methods -// ───────────────────────────────────────────────── - -void BluetoothPhoneAPI::onNowHasData(uint32_t fromRadioNum) -{ - PhoneAPI::onNowHasData(fromRadioNum); - fromNumValue = fromRadioNum; - - if (!(fromnum_ccc_val & BT_GATT_CCC_NOTIFY)) - return; - - // active_conn may be torn down on another thread while we're dispatching - // this notify - acquire under ble_mutex so disconnected_cb can't free the - // conn between the null check and bt_conn_ref. - struct bt_conn *conn = acquire_active_conn(); - if (!conn) - return; - bt_gatt_notify(conn, &mesh_svc.attrs[FROMNUM_ATTR_IDX], &fromNumValue, sizeof(fromNumValue)); - bt_conn_unref(conn); -} - -bool BluetoothPhoneAPI::checkIsConnected() -{ - return active_conn != nullptr; -} - -// ── Deferred ToRadio processor + zombie-connection watchdog ────────────────── -// -// write_toradio() runs on the BT RX workqueue thread (CONFIG_BT_RX_STACK_SIZE) -// and cannot execute phoneAPI->handleToRadio() directly: handleStartConfig -// recurses through nanopb encode + state machine init and overflows the RX -// stack. This thread runs on the Meshtastic OSThread scheduler (24 KB stack), -// picks up the pending ToRadio buffer flagged by write_toradio(), and calls -// handleToRadio() with plenty of headroom. -// -// Real-time fromNum notifications are sent synchronously from -// BluetoothPhoneAPI::onNowHasData() (called by PhoneAPI when new data is -// queued). -// -// Zombie-connection detection has two tiers: -// -// (1) Liveness probe. After IDLE_BEFORE_PROBE_MS without ATT traffic, send -// a bt_gatt_notify to fromNum every PROBE_INTERVAL_MS. If the -// controller replies -ENOTCONN the LL link is definitely dead but the -// host didn't forward LE Disconnection Complete → reboot. We avoid -// probing during normal activity so iOS isn't woken up unnecessarily -// (each probe wakes iOS → triggers a zero-byte FromRadio drain). -// -// (2) Hard watchdog. Absolute HARD_WATCHDOG_MS ceiling on ATT idle as a -// fallback if probes somehow don't detect the zombie. -class BleDeferredThread : public concurrency::OSThread -{ - static constexpr uint32_t IDLE_BEFORE_PROBE_MS = 30000; // 30 s: start probing - static constexpr uint32_t PROBE_INTERVAL_MS = 5000; // 5 s: between probes - static constexpr uint32_t HARD_WATCHDOG_MS = 60000; // 1 min: last resort - - uint32_t last_probe_ms = 0; - - public: - BleDeferredThread() : concurrency::OSThread("BleDeferred") {} - - protected: - int32_t runOnce() override - { - // Snapshot the pending ToRadio buffer under the mutex, then release - // the lock before calling into handleToRadio (which can be slow and - // must not block the BT RX thread producer). - uint8_t buf[MAX_TO_FROM_RADIO_SIZE]; - size_t n = 0; - bool have_pending = false; - k_mutex_lock(&pendingToRadioMutex, K_FOREVER); - if (pendingToRadio) { - memcpy(buf, pendingToRadioBuf, pendingToRadioLen); - n = pendingToRadioLen; - pendingToRadio = false; - have_pending = true; - } - k_mutex_unlock(&pendingToRadioMutex); - if (have_pending && phoneAPI) { - phoneAPI->handleToRadio(buf, n); - } - - // Take a reference to active_conn so it can't be freed underneath us - // if disconnected_cb fires on another thread while we're dispatching. - struct bt_conn *conn = acquire_active_conn(); - if (!conn || connect_time_ms == 0) { - if (conn) - bt_conn_unref(conn); - last_probe_ms = 0; - return 100; - } - - uint32_t now = k_uptime_get_32(); - uint32_t att_idle = now - last_att_time_ms; - - // Liveness probe - only when ATT has been quiet for a while. - if (att_idle > IDLE_BEFORE_PROBE_MS && (now - last_probe_ms) >= PROBE_INTERVAL_MS && - (fromnum_ccc_val & BT_GATT_CCC_NOTIFY)) { - last_probe_ms = now; - int err = bt_gatt_notify(conn, &mesh_svc.attrs[FROMNUM_ATTR_IDX], &fromNumValue, sizeof(fromNumValue)); - if (err == -ENOTCONN) { - LOG_WARN("BLE zombie (probe ENOTCONN); rebooting"); - bt_conn_unref(conn); - k_sleep(K_MSEC(50)); // flush log - sys_reboot(SYS_REBOOT_COLD); - } - } - bt_conn_unref(conn); - - // Hard ceiling - last-resort reboot if probes miss the zombie. - if (att_idle > HARD_WATCHDOG_MS && (now - connect_time_ms) > HARD_WATCHDOG_MS) { - LOG_WARN("BLE zombie (hard watchdog %us); rebooting", HARD_WATCHDOG_MS / 1000); - k_sleep(K_MSEC(50)); - sys_reboot(SYS_REBOOT_COLD); - } - return 100; - } -}; - -static BleDeferredThread *bleDeferredThread = nullptr; - -// ── BT stack pre-initializer (call from main thread before OSThreads start) ── -// -// bt_enable() requires substantially more stack than a Meshtastic OSThread -// (PowerFSMThread) provides - calling it there causes a stack overflow. -// Call this from nrf54l15Setup() (main Zephyr thread, CONFIG_MAIN_STACK_SIZE) -// so that by the time NRF54L15Bluetooth::setup() runs from PowerFSMThread, -// bt_initialized is already true and bt_enable() is skipped. - -void nrf54l15_bt_preinit() -{ - if (!bt_initialized) { - int err = bt_enable(NULL); - if (err) { - LOG_ERROR("BLE pre-init failed: %d", err); - return; - } - bt_initialized = true; - LOG_INFO("BLE stack pre-initialized on main thread"); - - // Phase 7: load bonding keys from LittleFS (/lfs/bt_settings). - // LittleFS is already mounted by fsInit() before nrf54l15Setup() runs. - // On first boot the file doesn't exist - settings_load() returns 0 (OK). - // On subsequent boots, previously bonded peers are restored so the - // phone can reconnect without re-pairing. - err = settings_load(); - if (err) { - LOG_WARN("settings_load failed: %d (OK on first boot)", err); - } else { - LOG_INFO("BT settings loaded from /lfs/bt_settings"); - } - } -} - -// ── NRF54L15Bluetooth public methods ───────────────────────────────────────── - -// Shared init: idempotent setup of work item, OSThread, auth callbacks, -// bt_enable, and device name. Leaves advertising control to the caller. -static bool nrf54l15_bt_init_common() -{ - k_work_init(&adv_restart_work, adv_restart_work_fn); - - if (!bleDeferredThread) { - bleDeferredThread = new BleDeferredThread(); - } - - if (!phoneAPI) { - phoneAPI = new BluetoothPhoneAPI(); - } - -#if defined(CONFIG_BT_SMP) - // NO_PIN is unsupported on this platform: the mesh GATT permissions are - // declared with BT_GATT_PERM_*_AUTHEN, prj.conf sets - // CONFIG_BT_SMP_ENFORCE_MITM=y, and the build pulls in the SMP/passkey path. - // If a user requested NO_PIN we'd register no auth callbacks → no display - // path for the passkey → every GATT access returns BT_ATT_ERR_AUTHENTICATION - // and the link is unusable. Fall back to RANDOM_PIN behavior with a warning - // instead of leaving BLE silently broken. - if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { - LOG_WARN("BLE: NO_PIN not supported on nRF54L15-DK (MITM-only build); " - "treat as RANDOM_PIN"); - } - - bt_conn_auth_cb_register(&auth_cb); - bt_conn_auth_info_cb_register(&auth_info_cb); - - // FIXED_PIN - register the configured passkey so the mobile app prompts - // the user for that specific number instead of a random display-only PIN. - // RANDOM_PIN (and clamped NO_PIN) keeps the default behavior: Zephyr - // generates a fresh passkey on each pairing attempt and fires - // auth_passkey_display with it. - if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN) { - configuredPasskey = config.bluetooth.fixed_pin; - int rc = bt_passkey_set(configuredPasskey); - if (rc) { - LOG_WARN("bt_passkey_set(%u) failed: %d", configuredPasskey, rc); - } else { - LOG_INFO("BLE fixed PIN: %06u", configuredPasskey); - } - } else { - bt_passkey_set(BT_PASSKEY_INVALID); // random per-pair - } -#endif /* CONFIG_BT_SMP */ - - if (!bt_initialized) { - int err = bt_enable(NULL); - if (err) { - LOG_ERROR("BLE enable failed: %d", err); - return false; - } - bt_initialized = true; - LOG_INFO("BLE stack enabled"); - } - - bt_set_name(getDeviceName()); - return true; -} - -void NRF54L15Bluetooth::setup() -{ - LOG_INFO("NRF54L15Bluetooth::setup()"); - if (!nrf54l15_bt_init_common()) { - return; - } - ble_enabled = true; - start_advertising(); -} - -void NRF54L15Bluetooth::shutdown() -{ - LOG_INFO("NRF54L15Bluetooth::shutdown()"); - ble_enabled = false; - stop_advertising(); - - struct bt_conn *conn = acquire_active_conn(); - if (conn) { - bt_conn_disconnect(conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); - bt_conn_unref(conn); - } -} - -void NRF54L15Bluetooth::startDisabled() -{ - // Initialize BT stack but leave advertising off until resumeAdvertising(). - if (!nrf54l15_bt_init_common()) { - return; - } - ble_enabled = false; - LOG_INFO("BLE initialized, adv stopped (startDisabled)"); -} - -void NRF54L15Bluetooth::resumeAdvertising() -{ - ble_enabled = true; - start_advertising(); -} - -void NRF54L15Bluetooth::clearBonds() -{ - LOG_INFO("BLE clear bonds"); - bt_unpair(BT_ID_DEFAULT, BT_ADDR_LE_ANY); -} - -bool NRF54L15Bluetooth::isConnected() -{ - return active_conn != nullptr; -} - -int NRF54L15Bluetooth::getRssi() -{ - return 0; // TODO: Zephyr has no direct bt_conn_get_rssi; use HCI RSSI read - // command -} - -void NRF54L15Bluetooth::sendLog(const uint8_t *logMessage, size_t length) -{ - if (length > 512 || logradio_ccc_val == 0) { - return; - } - // Acquire a reference under ble_mutex so disconnected_cb can't free the - // connection between the null check and bt_gatt_notify. - struct bt_conn *conn = acquire_active_conn(); - if (!conn) { - return; - } - // Send as notify regardless of whether client subscribed to NOTIFY or - // INDICATE - bt_gatt_indicate() requires a params struct with a callback; - // notify is simpler and the app accepts both. Change to indicate if - // compatibility issues arise. - bt_gatt_notify(conn, &mesh_svc.attrs[LOGRADIO_ATTR_IDX], logMessage, (uint16_t)length); - bt_conn_unref(conn); -} diff --git a/src/platform/nrf54l15/NRF54L15Bluetooth.h b/src/platform/nrf54l15/NRF54L15Bluetooth.h deleted file mode 100644 index 36d434d37c..0000000000 --- a/src/platform/nrf54l15/NRF54L15Bluetooth.h +++ /dev/null @@ -1,29 +0,0 @@ -// NRF54L15Bluetooth.h - Zephyr BLE backend for nRF54L15 -// -// Implements the same interface as NRF52Bluetooth (same method names and -// signatures) so main.cpp and AdminModule can use nrf52Bluetooth pointer -// without knowing the underlying implementation. -// -// GATT profile is identical to the nRF52 implementation: -// Service: MESH_SERVICE_UUID -// toRadio: TORADIO_UUID (WRITE) -// fromRadio: FROMRADIO_UUID (READ) -// fromNum: FROMNUM_UUID (READ | NOTIFY) -// logRadio: LOGRADIO_UUID (READ | NOTIFY | INDICATE) - -#pragma once - -#include "BluetoothCommon.h" - -class NRF54L15Bluetooth : public BluetoothApi -{ - public: - void setup(); - void shutdown(); - void startDisabled(); - void resumeAdvertising(); - void clearBonds(); - bool isConnected(); - int getRssi(); - void sendLog(const uint8_t *logMessage, size_t length); -}; diff --git a/src/platform/nrf54l15/Nrf52SaadcLock.h b/src/platform/nrf54l15/Nrf52SaadcLock.h deleted file mode 100644 index 1e0a04bc21..0000000000 --- a/src/platform/nrf54l15/Nrf52SaadcLock.h +++ /dev/null @@ -1,17 +0,0 @@ -// Nrf52SaadcLock.h - stub for nRF54L15/Zephyr -// Power.cpp includes this when ARCH_NRF52 is defined. -// Phase 2: compile-only stub. -#pragma once - -#ifdef ARCH_NRF52 - -#include "concurrency/Lock.h" - -namespace concurrency -{ -/** Shared mutex for SAADC configuration and reads (VDD + battery analog path). - * On nRF54L15 ADC is handled differently; this is a compile-only stub. */ -extern Lock *nrf52SaadcLock; -} // namespace concurrency - -#endif diff --git a/src/platform/nrf54l15/Print.h b/src/platform/nrf54l15/Print.h deleted file mode 100644 index 5db9a7406b..0000000000 --- a/src/platform/nrf54l15/Print.h +++ /dev/null @@ -1,4 +0,0 @@ -// Print.h - shim for nRF54L15/Zephyr -// Meshtastic includes separately; redirect to our Arduino.h shim. -#pragma once -#include "Arduino.h" diff --git a/src/platform/nrf54l15/SPI.h b/src/platform/nrf54l15/SPI.h deleted file mode 100644 index b35af0dc96..0000000000 --- a/src/platform/nrf54l15/SPI.h +++ /dev/null @@ -1,62 +0,0 @@ -/** - * SPI.h - Arduino SPI shim for Zephyr/nRF54L15 - * - * Provides the Arduino SPIClass interface backed by Zephyr's SPI API. The - * backing controller is SPIM00 (HP domain, 3.0 V); the implementation in - * nrf54l15_arduino.cpp binds to DEVICE_DT_GET(DT_NODELABEL(spi00)) and the - * bus is configured in zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay. - * RadioLib uses ArduinoHal which calls transfer() byte-by-byte. - * - * CS pin is handled by RadioLib via digitalWrite() - hardware CS is not used. - */ - -#pragma once - -#include "Arduino.h" -#include - -#define SPI_MODE0 0 -#define SPI_MODE1 1 -#define SPI_MODE2 2 -#define SPI_MODE3 3 - -struct SPISettings { - uint32_t clock; - uint8_t bitOrder; - uint8_t dataMode; - - // Arduino API allows `SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0))` - implicit form is intentional. - // cppcheck-suppress noExplicitConstructor - SPISettings(uint32_t clock = 4000000, uint8_t bitOrder = MSBFIRST, uint8_t dataMode = SPI_MODE0) - : clock(clock), bitOrder(bitOrder), dataMode(dataMode) - { - } -}; - -class SPIClass -{ - public: - void begin() {} - void begin(uint8_t sck, uint8_t miso, uint8_t mosi, uint8_t ss = 0xFF) {} - void end() {} - void beginTransaction(SPISettings) {} - void endTransaction() {} - void setBitOrder(uint8_t order) {} - void setDataMode(uint8_t mode) {} - void setClockDivider(uint8_t div) {} - void setFrequency(uint32_t freq) {} - - // Real Zephyr SPI implementation - defined in nrf54l15_arduino.cpp - uint8_t transfer(uint8_t data); - uint16_t transfer16(uint16_t data); - void transfer(void *buf, size_t count); - void transferBytes(const uint8_t *tx, uint8_t *rx, uint32_t count); - uint8_t transfer(uint8_t tx, uint8_t *rx, uint32_t count) - { - transferBytes(&tx, rx, count); - return rx ? rx[0] : 0; - } -}; - -extern SPIClass SPI; -extern SPIClass SPI1; diff --git a/src/platform/nrf54l15/Stream.h b/src/platform/nrf54l15/Stream.h deleted file mode 100644 index 450423cbf2..0000000000 --- a/src/platform/nrf54l15/Stream.h +++ /dev/null @@ -1,5 +0,0 @@ -// Stream.h - shim for nRF54L15/Zephyr -// StreamAPI.h and other Meshtastic headers include . -// Redirect to our Arduino.h shim which defines the Stream base class. -#pragma once -#include "Arduino.h" diff --git a/src/platform/nrf54l15/Tone.h b/src/platform/nrf54l15/Tone.h deleted file mode 100644 index 4a12310b7b..0000000000 --- a/src/platform/nrf54l15/Tone.h +++ /dev/null @@ -1,4 +0,0 @@ -// Tone.h - shim for nRF54L15/Zephyr -// Tone functions are stubbed in Arduino.h; this header satisfies direct includes. -#pragma once -#include "Arduino.h" diff --git a/src/platform/nrf54l15/WProgram.h b/src/platform/nrf54l15/WProgram.h deleted file mode 100644 index a8e3dc0d54..0000000000 --- a/src/platform/nrf54l15/WProgram.h +++ /dev/null @@ -1,5 +0,0 @@ -// WProgram.h - shim for nRF54L15/Zephyr -// ArduinoThread (and other legacy Arduino libs) include . -// Redirect to our Arduino.h shim. -#pragma once -#include "Arduino.h" diff --git a/src/platform/nrf54l15/Wire.cpp b/src/platform/nrf54l15/Wire.cpp deleted file mode 100644 index 96f952df33..0000000000 --- a/src/platform/nrf54l15/Wire.cpp +++ /dev/null @@ -1,219 +0,0 @@ -// Wire.cpp - Arduino TwoWire backed by Zephyr i2c30 (TWIM30 hardware). -// -// The pinctrl + clock-frequency are configured in -// zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay. Runtime begin()/setClock() -// are best-effort: setClock() goes through i2c_configure() to actually change -// the bus speed; begin() just verifies the device is ready. - -#include "Wire.h" -#include "configuration.h" - -#include -#include -#include -#include - -// Resolve the i2c30 node at compile time. If the overlay has not enabled -// i2c30, this evaluates to a NULL device pointer and every call short-circuits -// to a NACK return code - matching the prior compile-only stub behavior. -#define I2C_NODE DT_NODELABEL(i2c30) - -static const struct device *getI2CDevice() -{ -#if DT_NODE_HAS_STATUS(I2C_NODE, okay) - static const struct device *const dev = DEVICE_DT_GET(I2C_NODE); - return dev; -#else - return nullptr; -#endif -} - -// Wire/Wire1 instances are defined in nrf54l15_arduino.cpp alongside the -// other Arduino singletons (Serial, SPI, …). - -TwoWire::TwoWire() : txAddr(0), txLen(0), txBuf{}, rxLen(0), rxPos(0), rxBuf{} {} - -void TwoWire::begin() -{ - const struct device *dev = getI2CDevice(); - if (dev == nullptr) { - LOG_WARN("Wire.begin(): i2c30 not enabled in DT overlay"); - return; - } - if (!device_is_ready(dev)) { - LOG_WARN("Wire.begin(): i2c30 device not ready"); - return; - } - LOG_INFO("Wire.begin(): i2c30 ready"); -} - -void TwoWire::begin(uint8_t /*sda*/, uint8_t /*scl*/) -{ - // SDA/SCL fixed by overlay pinctrl - pin args ignored. - begin(); -} - -void TwoWire::begin(int /*sda*/, int /*scl*/, uint32_t freq) -{ - begin(); - if (freq) { - setClock(freq); - } -} - -void TwoWire::end() -{ - // No-op: Zephyr i2c devices stay initialized for the lifetime of the - // application. Runtime PM (zephyr,pm-device-runtime-auto in the DT) - // handles low-power transitions when idle. -} - -void TwoWire::setClock(uint32_t freq) -{ - const struct device *dev = getI2CDevice(); - if (dev == nullptr) { - return; - } - uint32_t speed; - if (freq >= 1000000U) { - speed = I2C_SPEED_FAST_PLUS; // 1 MHz - } else if (freq >= 400000U) { - speed = I2C_SPEED_FAST; // 400 kHz - } else { - speed = I2C_SPEED_STANDARD; // 100 kHz - } - uint32_t cfg = I2C_MODE_CONTROLLER | I2C_SPEED_SET(speed); - int rc = i2c_configure(dev, cfg); - if (rc) { - LOG_WARN("Wire.setClock(%u) failed: %d", (unsigned)freq, rc); - } -} - -void TwoWire::beginTransmission(uint8_t addr) -{ - txAddr = addr; - txLen = 0; -} - -size_t TwoWire::write(uint8_t data) -{ - if (txLen >= WIRE_BUFFER_LENGTH) { - return 0; // overflow - endTransmission() will return 1 - } - txBuf[txLen++] = data; - return 1; -} - -size_t TwoWire::write(const uint8_t *data, size_t n) -{ - size_t written = 0; - for (size_t i = 0; i < n; i++) { - if (write(data[i]) == 0) { - break; - } - written++; - } - return written; -} - -uint8_t TwoWire::endTransmission(bool /*stop*/) -{ - // Arduino return codes: - // 0 = success - // 1 = data-too-long (overflow caught in write()) - // 2 = NACK on address - // 3 = NACK on data - // 4 = other error - // 5 = timeout - if (txLen > WIRE_BUFFER_LENGTH) { - return 1; - } - const struct device *dev = getI2CDevice(); - if (dev == nullptr || !device_is_ready(dev)) { - return 4; - } - int rc = i2c_write(dev, txBuf, txLen, txAddr); - txLen = 0; - if (rc == 0) { - return 0; - } - if (rc == -EIO) { - return 2; // address NACK is the most common -EIO source on nrf-twim - } - if (rc == -ETIMEDOUT) { - return 5; - } - return 4; -} - -uint8_t TwoWire::requestFrom(uint8_t addr, uint8_t quantity, bool /*stop*/) -{ - rxLen = 0; - rxPos = 0; - if (quantity == 0) { - return 0; - } - if (quantity > WIRE_BUFFER_LENGTH) { - quantity = WIRE_BUFFER_LENGTH; - } - const struct device *dev = getI2CDevice(); - if (dev == nullptr || !device_is_ready(dev)) { - return 0; - } - - // If there is a pending TX (driver wrote register address via write() - // without an explicit endTransmission()), use i2c_write_read so the - // repeated-start path matches the typical "set register pointer then - // read N bytes" sensor protocol. - int rc; - if (txLen > 0) { - rc = i2c_write_read(dev, addr, txBuf, txLen, rxBuf, quantity); - txLen = 0; - } else { - rc = i2c_read(dev, rxBuf, quantity, addr); - } - if (rc) { - return 0; - } - rxLen = quantity; - return quantity; -} - -int TwoWire::available() -{ - return rxLen - rxPos; -} - -int TwoWire::read() -{ - if (rxPos >= rxLen) { - return -1; - } - return rxBuf[rxPos++]; -} - -int TwoWire::peek() -{ - if (rxPos >= rxLen) { - return -1; - } - return rxBuf[rxPos]; -} - -size_t TwoWire::readBytes(uint8_t *buf, size_t len) -{ - size_t n = 0; - while (n < len) { - int b = read(); - if (b < 0) { - break; - } - buf[n++] = (uint8_t)b; - } - return n; -} - -TwoWire::operator bool() const -{ - return getI2CDevice() != nullptr; -} diff --git a/src/platform/nrf54l15/Wire.h b/src/platform/nrf54l15/Wire.h deleted file mode 100644 index febf24527e..0000000000 --- a/src/platform/nrf54l15/Wire.h +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Wire.h - Arduino TwoWire (I2C) shim for Zephyr / nRF54L15. - * - * Bus binding: the Zephyr device tree alias `i2c30` (TWIM30 hardware - * peripheral, HP domain, 3.0 V) is resolved at compile time via - * DEVICE_DT_GET in Wire.cpp. SDA/SCL pins are configured in the board - * overlay via pinctrl - `begin(sda, scl)` overloads are accepted for - * Arduino API compatibility but the pin arguments are ignored. - * - * Buffer sizes are sized for the worst-case I2C consumer we plan to use - * (NXP SE050 secure element, ~256-byte T=1 frames). BMP280 / INA228 / - * SHT40 / INA3221 read in single-digit bytes and fit trivially. - */ - -#pragma once - -#include "Arduino.h" -#include -#include - -#ifndef WIRE_BUFFER_LENGTH -#define WIRE_BUFFER_LENGTH 256 -#endif - -class TwoWire -{ - public: - TwoWire(); - - // ── Bus lifecycle ───────────────────────────────────────────────── - // begin() variants - pin arguments are accepted for API compatibility - // but ignored: SDA/SCL are fixed by the Zephyr overlay pinctrl. freq - // is also fixed by overlay clock-frequency (use setClock() at runtime). - void begin(); - void begin(uint8_t sda, uint8_t scl); - void begin(int sda, int scl, uint32_t freq); - void end(); - - void setClock(uint32_t freq); - void setClockStretchLimit(uint32_t) {} // no-op on TWIM hardware - - // ── Master write ───────────────────────────────────────────────── - void beginTransmission(uint8_t addr); - void beginTransmission(int addr) { beginTransmission((uint8_t)addr); } - // Return codes (Arduino convention): - // 0 = success, 1 = data-too-long, 2 = NACK on addr, 3 = NACK on data, - // 4 = other error, 5 = timeout. - uint8_t endTransmission(bool stop = true); - uint8_t endTransmission(uint8_t stop) { return endTransmission(stop != 0); } - - size_t write(uint8_t data); - size_t write(const uint8_t *data, size_t n); - - // ── Master read ────────────────────────────────────────────────── - uint8_t requestFrom(uint8_t addr, uint8_t quantity, bool stop = true); - uint8_t requestFrom(uint8_t addr, uint8_t quantity, uint8_t stop) { return requestFrom(addr, quantity, stop != 0); } - uint8_t requestFrom(int addr, int quantity, int stop = 1) { return requestFrom((uint8_t)addr, (uint8_t)quantity, stop != 0); } - - int available(); - int read(); - int peek(); - size_t readBytes(uint8_t *buf, size_t len); - size_t readBytes(char *buf, size_t len) { return readBytes((uint8_t *)buf, len); } - void flush() {} - - // Slave callbacks unsupported - peripheral-only stub. - void onReceive(void (*)(int)) {} - void onRequest(void (*)(void)) {} - - operator bool() const; - - private: - uint8_t txAddr; - uint16_t txLen; - uint8_t txBuf[WIRE_BUFFER_LENGTH]; - - uint16_t rxLen; - uint16_t rxPos; - uint8_t rxBuf[WIRE_BUFFER_LENGTH]; -}; - -extern TwoWire Wire; -extern TwoWire Wire1; // alias to Wire - only one I2C bus on this board diff --git a/src/platform/nrf54l15/architecture.h b/src/platform/nrf54l15/architecture.h deleted file mode 100644 index 24b3c980dd..0000000000 --- a/src/platform/nrf54l15/architecture.h +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once - -#define ARCH_NRF54L15 - -// -// Feature flags for nRF54L15. -// -// The HAS_* macros below are Meshtastic's compile-time feature gate: every -// optional subsystem (BLE, screen, I2C, GPS, buttons, telemetry, sensors, -// radio, CPU shutdown, ...) is wrapped in `#if HAS_FOO` so a given board -// only pays for the features it actually ships. On memory-tight MCUs this -// is not cosmetic - it's the difference between a binary that fits in -// flash and one that doesn't, and between a build that links and one that -// drags in drivers for hardware the board doesn't have. Defaulting to 0 -// here (rather than inheriting nRF52 defaults) is deliberate: the -// nRF54L15-DK is a bare dev kit with no screen, no I2C sensors, no GPS, -// no user buttons - so every HAS_* flag starts off and gets flipped on -// explicitly by variants that add that hardware. -// -// Feature flags are also the cleanest way to absorb platform divergence -// without sprinkling `#ifdef ARCH_NRF54L15` across shared code. Anywhere -// a subsystem can be conditionally compiled via HAS_*, prefer that over -// per-arch guards: it keeps the core code arch-agnostic, makes it trivial -// to bring up the next board (flip the flags, don't patch call sites), -// and keeps the "does this platform support X?" question answerable by -// reading one file instead of grepping the tree. BLE in particular is -// deferred to Phase 2 on this port - the nRF54L15 uses MPSL/Zephyr BLE -// APIs rather than the Adafruit SoftDevice stack used by nRF52840 - so -// while HAS_BLUETOOTH defaults to 1, the actual implementation lives in -// NRF54L15Bluetooth.cpp behind its own Zephyr Kconfig gates. -// - -#ifndef HAS_BLUETOOTH -#define HAS_BLUETOOTH 1 -#endif -#ifndef HAS_SCREEN -#define HAS_SCREEN 0 -#endif -#ifndef HAS_WIRE -#define HAS_WIRE 0 -#endif -#ifndef HAS_GPS -#define HAS_GPS 0 -#endif -#ifndef HAS_BUTTON -#define HAS_BUTTON 0 -#endif -#ifndef HAS_TELEMETRY -#define HAS_TELEMETRY 0 -#endif -#ifndef HAS_SENSOR -#define HAS_SENSOR 0 -#endif -#ifndef HAS_RADIO -#define HAS_RADIO 1 -#endif -#ifndef HAS_CPU_SHUTDOWN -#define HAS_CPU_SHUTDOWN 0 -#endif - -// ADC reference - nRF54L15 SAADC uses VDD/4 internal ref by default -#ifndef AREF_VOLTAGE -#define AREF_VOLTAGE 3.6 -#endif -#ifndef BATTERY_SENSE_RESOLUTION_BITS -#define BATTERY_SENSE_RESOLUTION_BITS 12 -#endif - -// -// HW_VENDOR - maps build-time define to HardwareModel enum. -// PRIVATE_HW (255): the protobuf HardwareModel enum reserves DK / DIY boards -// without an SKU under this value; the nRF54L15-DK doesn't get a dedicated -// enum number. Variant manifest matches via custom_meshtastic_hw_model = 255. -// -#ifdef NRF54L15_DK -#define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW -#else -#define HW_VENDOR meshtastic_HardwareModel_UNSET -#endif diff --git a/src/platform/nrf54l15/bluefruit.h b/src/platform/nrf54l15/bluefruit.h deleted file mode 100644 index 306e810b4a..0000000000 --- a/src/platform/nrf54l15/bluefruit.h +++ /dev/null @@ -1,19 +0,0 @@ -// bluefruit.h - stub for nRF54L15/Zephyr -// NodeDB.cpp includes this when ARCH_NRF52 is defined. -// Bluetooth is excluded (MESHTASTIC_EXCLUDE_BLUETOOTH=1); this satisfies -// the include chain without pulling in the Adafruit Bluefruit SDK. -#pragma once - -struct BLEPeripheral { - void clearBonds() {} -}; -struct BLECentral { - void clearBonds() {} -}; - -struct BlueFruitClass { - BLEPeripheral Periph; - BLECentral Central; -}; - -extern BlueFruitClass Bluefruit; diff --git a/src/platform/nrf54l15/main-nrf54l15.cpp b/src/platform/nrf54l15/main-nrf54l15.cpp deleted file mode 100644 index b91fe67ee4..0000000000 --- a/src/platform/nrf54l15/main-nrf54l15.cpp +++ /dev/null @@ -1,222 +0,0 @@ -/* - * main-nrf54l15.cpp - Platform entry points for Nordic nRF54L15 - * - * Adapted from src/platform/nrf52/main-nrf52.cpp. - * SoftDevice, Adafruit BLE, and nRFCrypto are NOT available on nRF54L15. - * Phase 2 will add proper BLE via Zephyr MPSL APIs. - * - * TODO items are marked with "TODO(nrf54l15):" - */ - -#include "configuration.h" -#include -#include -#include -#include -#include - -#include "NodeDB.h" -#include "Power.h" -#include "PowerMon.h" -#include "Router.h" -#include "error.h" -#include "main.h" -#include "mesh/MeshService.h" -#include "meshUtils.h" -#include - -// ── Watchdog ────────────────────────────────────────────────────────────── -// TODO(nrf54l15): nRF54L15 has a WDT peripheral but nrfx_wdt driver support -// may differ depending on the Zephyr SDK version. Enable once confirmed. -#define APP_WATCHDOG_SECS 90 -static bool watchdog_running = false; - -static inline void watchdog_feed() {} // TODO(nrf54l15): replace with real WDT feed - -// ── Weak variant hooks ──────────────────────────────────────────────────── -void variant_shutdown() __attribute__((weak)); -void variant_shutdown() {} - -void variant_nrf54l15LoopHook(void) __attribute__((weak)); -void variant_nrf54l15LoopHook(void) {} - -// ── PowerHAL ───────────────────────────────────────────────────────────── -bool powerHAL_isVBUSConnected() -{ - // TODO(nrf54l15): nRF54L15 has a USB POWER peripheral - read USBREGSTATUS - return false; -} - -bool powerHAL_isPowerLevelSafe() -{ - // TODO(nrf54l15): implement SAADC VDD measurement similar to nRF52 - return true; -} - -void powerHAL_platformInit() -{ - // TODO(nrf54l15): configure POF comparator and analog reference if needed -} - -// ── Utilities ───────────────────────────────────────────────────────────── -bool loopCanSleep() -{ - return !Serial; -} - -void updateBatteryLevel(uint8_t level) -{ - (void)level; -} - -void __attribute__((noreturn)) __assert_func(const char *file, int line, const char *func, const char *failedexpr) -{ - LOG_ERROR("assert failed %s: %d, %s, test=%s", file, line, func, failedexpr); - NVIC_SystemReset(); -} - -void getMacAddr(uint8_t *dmac) -{ - // TODO(nrf54l15): verify FICR register layout for nRF54L15. - // nRF52840 uses NRF_FICR->DEVICEADDR[0/1]; nRF54L15 Zephyr HAL may differ. -#if defined(NRF_FICR) - const uint8_t *src = (const uint8_t *)NRF_FICR->DEVICEADDR; - dmac[5] = src[0]; - dmac[4] = src[1]; - dmac[3] = src[2]; - dmac[2] = src[3]; - dmac[1] = src[4]; - dmac[0] = src[5] | 0xc0; -#else - // Fallback: fixed placeholder until Zephyr FICR path is confirmed - dmac[0] = 0xC2; - dmac[1] = 0xA7; - dmac[2] = 0x54; - dmac[3] = 0x15; - dmac[4] = 0x00; - dmac[5] = 0x01; -#endif -} - -bool getDeviceId(uint8_t *deviceId) -{ - // nRF54L15: DEVICEID under the FICR->INFO sub-struct. Read unconditionally so a future build - // lacking NRF_FICR fails loudly rather than silently sharing getMacAddr()'s placeholder MAC. - uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; - uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; - memcpy(deviceId, &device_id_start, sizeof(device_id_start)); - memcpy(deviceId + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); - return true; -} - -// ── Bluetooth ───────────────────────────────────────────────────────────────── - -void setBluetoothEnable(bool enable) -{ - if (enable) { - static bool initialized = false; - if (!initialized) { - nrf54l15Bluetooth = new NRF54L15Bluetooth(); - nrf54l15Bluetooth->startDisabled(); - initialized = true; - } - if (nrf54l15Bluetooth) { - nrf54l15Bluetooth->resumeAdvertising(); - } - } else { - if (nrf54l15Bluetooth) { - nrf54l15Bluetooth->shutdown(); - } - } -} - -void clearBonds() -{ - if (!nrf54l15Bluetooth) { - nrf54l15Bluetooth = new NRF54L15Bluetooth(); - nrf54l15Bluetooth->setup(); - } - nrf54l15Bluetooth->clearBonds(); -} - -void enterDfuMode() -{ - // TODO(nrf54l15): nRF54L15 uses nRF Connect DFU (MCUboot/SUIT). - // Trigger via Zephyr boot_request_upgrade() or similar. - NVIC_SystemReset(); -} - -// ── printf via RTT ──────────────────────────────────────────────────────── -// TODO(nrf54l15): SEGGER_RTT may not be available with Zephyr; use printk() -// or a USB CDC console instead. Remove this override if it conflicts. -#ifdef SEGGER_RTT_PRINTF -int printf(const char *fmt, ...) -{ - va_list args; - va_start(args, fmt); - auto res = SEGGER_RTT_vprintf(0, fmt, &args); - va_end(args); - return res; -} -#endif - -// ── Deep sleep ──────────────────────────────────────────────────────────── -void cpuDeepSleep(uint32_t msecToWake) -{ -#if HAS_WIRE - Wire.end(); -#endif - SPI.end(); - if (Serial) - Serial.end(); - - variant_shutdown(); - - // TODO(nrf54l15): use Zephyr pm_system_suspend() or WFI for proper low-power - if (msecToWake != portMAX_DELAY) { - delay(msecToWake); - NVIC_SystemReset(); - } else { - // System off equivalent - halt - while (1) { - __WFI(); - } - } -} - -// ── Setup / Loop ────────────────────────────────────────────────────────── -// Forward declaration - defined in NRF54L15Bluetooth.cpp -void nrf54l15_bt_preinit(); - -void nrf54l15Setup() -{ - // nRF54L15 power peripheral layout differs from nRF52; RESETREAS not present here. - // TODO(Phase 3): use zephyr/drivers/hwinfo.h hwinfo_get_reset_cause() - LOG_DEBUG("Reset reason: (nRF54L15 power peripheral differs from nRF52, skipped)"); - - // TODO(nrf54l15): init SAADC, watchdog, and random seed via nrfx or Zephyr - // For now seed with a fixed value; replace with hardware entropy source. -#if defined(NRF_FICR) - randomSeed(analogRead(0) ^ (uint32_t)NRF_FICR->DEVICEADDR[0]); -#else - randomSeed(analogRead(0)); -#endif - - // Pre-initialize BT stack here on the main thread (CONFIG_MAIN_STACK_SIZE=8192). - // bt_enable() overflows the smaller PowerFSMThread stack when called later. - // NRF54L15Bluetooth::setup() checks bt_initialized and skips bt_enable() if true. - nrf54l15_bt_preinit(); -} - -void nrf54l15Loop() -{ - // First-call gate for the future WDT init - body will hold real init code, not just the bookkeeping flag. - // cppcheck-suppress duplicateConditionalAssign - if (!watchdog_running) { - // TODO(nrf54l15): enable WDT here - watchdog_running = true; - } - watchdog_feed(); - - variant_nrf54l15LoopHook(); -} diff --git a/src/platform/nrf54l15/nrf54l15_arduino.cpp b/src/platform/nrf54l15/nrf54l15_arduino.cpp deleted file mode 100644 index 8d8c30b323..0000000000 --- a/src/platform/nrf54l15/nrf54l15_arduino.cpp +++ /dev/null @@ -1,557 +0,0 @@ -/** - * nrf54l15_arduino.cpp - Arduino shim implementations for Zephyr/nRF54L15 - * - * Provides concrete implementations for Print, HardwareSerial, GPIO, SPI, - * and String methods declared in Arduino.h / SPI.h. - * - * Phase 3: real GPIO via Zephyr GPIO API and real SPI via Zephyr SPI API. - * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. - */ - -#include "Arduino.h" -#include "SPI.h" -#include "Wire.h" -#include -#include -#include -#include -#include -#include -#include -// ── Bluefruit singleton stub (satisfies NodeDB.cpp ARCH_NRF52 path) ────────── -#include "bluefruit.h" -BlueFruitClass Bluefruit; - -// ── _fini stub - ARM newlib's __libc_fini_array references _fini, but ──────── -// Zephyr startup doesn't provide it. Provide a weak no-op so the linker -// is satisfied when C++ global dtors or atexit() pull in __libc_fini_array. -extern "C" void __attribute__((weak)) _fini(void) {} - -// ── SPI / Wire singletons ───────────────────────────────────────────────────── -SPIClass SPI; -SPIClass SPI1; -TwoWire Wire; -TwoWire Wire1; - -// ── HardwareSerial singletons ──────────────────────────────────────────────── -HardwareSerial Serial; -HardwareSerial Serial1; -HardwareSerial Serial2; - -// ── Timing functions - C linkage to match extern "C" declarations ──────────── -extern "C" uint32_t millis(void) -{ - return (uint32_t)k_uptime_get_32(); -} -extern "C" uint32_t micros(void) -{ - return (uint32_t)(k_uptime_get() * 1000ULL); -} -extern "C" void delay(uint32_t ms) -{ - k_sleep(K_MSEC(ms)); -} -extern "C" void delayMicroseconds(uint32_t us) -{ - k_sleep(K_USEC(us)); -} -extern "C" void yield(void) -{ - k_yield(); -} - -// ── NVIC_SystemReset - wraps __NVIC_SystemReset from CMSIS core_cm33.h ─────── -// core_cm33.h has #define NVIC_SystemReset __NVIC_SystemReset, so undef it -// before defining our own implementation to prevent macro expansion collision. -#pragma push_macro("NVIC_SystemReset") -#undef NVIC_SystemReset -extern "C" void NVIC_SystemReset(void) -{ - sys_reboot(SYS_REBOOT_COLD); -} -#pragma pop_macro("NVIC_SystemReset") - -// ── HardwareSerial::write ───────────────────────────────────────────────────── -size_t HardwareSerial::write(uint8_t c) -{ - // TODO(nrf54l15 Phase 3): route through Zephyr UART / USB-CDC console - // For now use printk so we at least get something over RTT/UART0 - printk("%c", (char)c); - return 1; -} - -size_t HardwareSerial::write(const uint8_t *buf, size_t n) -{ - for (size_t i = 0; i < n; i++) - printk("%c", (char)buf[i]); - return n; -} - -// ── Print::printf ───────────────────────────────────────────────────────────── -int Print::printf(const char *fmt, ...) -{ - char buf[256]; - va_list args; - va_start(args, fmt); - int n = vsnprintf(buf, sizeof(buf), fmt, args); - va_end(args); - if (n > 0) - write((const uint8_t *)buf, (size_t)(n < (int)sizeof(buf) ? n : (int)sizeof(buf) - 1)); - return n; -} - -// ── strlcpy - BSD extension not in Zephyr newlib ──────────────────────────── -extern "C" size_t strlcpy(char *dst, const char *src, size_t size) -{ - size_t len = strlen(src); - if (size > 0) { - size_t copy = len < size - 1 ? len : size - 1; - memcpy(dst, src, copy); - dst[copy] = '\0'; - } - return len; -} - -// ── Print numeric helpers ───────────────────────────────────────────────────── -static size_t printNumber(Print &p, unsigned long n, uint8_t base) -{ - if (base == 0) - return p.write((uint8_t)n); - - char buf[8 * sizeof(long) + 1]; - char *end = buf + sizeof(buf) - 1; - *end = '\0'; - if (n == 0) { - *--end = '0'; - } else { - while (n > 0) { - unsigned long remainder = n % base; - *--end = (char)(remainder < 10 ? '0' + remainder : 'A' + remainder - 10); - n /= base; - } - } - return p.write((const uint8_t *)end, strlen(end)); -} - -static size_t printFloat(Print &p, double number, uint8_t digits) -{ - if (isnan(number)) - return p.print("nan"); - if (isinf(number)) - return p.print("inf"); - if (number > 4294967040.0 || number < -4294967040.0) - return p.print("ovf"); - - size_t n = 0; - if (number < 0.0) { - n += p.write('-'); - number = -number; - } - - // Round - double rounding = 0.5; - for (uint8_t i = 0; i < digits; i++) - rounding /= 10.0; - number += rounding; - - unsigned long int_part = (unsigned long)number; - double remainder = number - (double)int_part; - n += printNumber(p, int_part, 10); - if (digits > 0) { - n += p.write('.'); - for (uint8_t i = 0; i < digits; i++) { - remainder *= 10.0; - unsigned int d = (unsigned int)remainder; - n += p.write('0' + d); - remainder -= d; - } - } - return n; -} - -size_t Print::print(unsigned char n, int base) -{ - return printNumber(*this, n, base); -} -size_t Print::print(int n, int base) -{ - if (base == 10 && n < 0) { - size_t r = write('-'); - return r + printNumber(*this, (unsigned long)(-n), base); - } - return printNumber(*this, (unsigned long)n, base); -} -size_t Print::print(long n, int base) -{ - if (base == 10 && n < 0) { - size_t r = write('-'); - return r + printNumber(*this, (unsigned long)(-n), base); - } - return printNumber(*this, (unsigned long)n, base); -} -size_t Print::print(unsigned int n, int base) -{ - return printNumber(*this, n, base); -} -size_t Print::print(unsigned long n, int base) -{ - return printNumber(*this, n, base); -} -size_t Print::print(float n, int d) -{ - return printFloat(*this, n, d); -} -size_t Print::print(double n, int d) -{ - return printFloat(*this, n, d); -} - -// ── String::replace(String, String) ───────────────────────────────────────── -void String::replace(const String &from, const String &to) -{ - if (from.isEmpty() || !_buf) - return; - // Simple O(n²) replace - fine for typical Meshtastic string lengths - String result; - const char *p = _buf; - while (*p) { - if (strncmp(p, from.c_str(), from.length()) == 0) { - result += to; - p += from.length(); - } else { - result += *p++; - } - } - *this = result; -} - -// ═════════════════════════════════════════════════════════════════════════════ -// GPIO - Real Zephyr implementation (Phase 3) -// Pin mapping: P0.n = n (0-15), P1.n = 16+n (16-31), P2.n = 32+n (32-47) -// ═════════════════════════════════════════════════════════════════════════════ - -static const struct device *_gpio_dev_for_pin(uint32_t pin, gpio_pin_t *zpin) -{ - if (pin < 16) { - *zpin = (gpio_pin_t)pin; - return DEVICE_DT_GET(DT_NODELABEL(gpio0)); - } else if (pin < 32) { - *zpin = (gpio_pin_t)(pin - 16); - return DEVICE_DT_GET(DT_NODELABEL(gpio1)); - } else { - *zpin = (gpio_pin_t)(pin - 32); - return DEVICE_DT_GET(DT_NODELABEL(gpio2)); - } -} - -void pinMode(uint32_t pin, uint32_t mode) -{ - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - if (!device_is_ready(dev)) - return; - - gpio_flags_t flags; - switch (mode) { - case OUTPUT: - flags = GPIO_OUTPUT_INACTIVE; - break; - case INPUT_PULLUP: - flags = GPIO_INPUT | GPIO_PULL_UP; - break; - case INPUT_PULLDOWN: - flags = GPIO_INPUT | GPIO_PULL_DOWN; - break; - default: - flags = GPIO_INPUT; - break; - } - gpio_pin_configure(dev, zpin, flags); -} - -// Bring-up diagnostics for the SX1262 wiring path. Off by default - enable by -// adding `-DNRF54L15_GPIO_DEBUG` to platformio.ini build_flags. Useful when -// validating CS/NRESET toggles after a wiring change, diagnosing a "stuck HIGH" -// BUSY before the first NRESET pulse, or tracing BUSY transitions during early -// boot. In normal operation these traces are noise (they bypass LOG level -// controls and print on every GPIO touch), so they are gated at compile time. -#ifdef NRF54L15_GPIO_DEBUG -#define GPIO_LOG_MAX 20 -static uint32_t _gpio_log_count = 0; -#endif - -void digitalWrite(uint32_t pin, uint32_t value) -{ -#ifdef NRF54L15_GPIO_DEBUG - // Before the very first NRESET pulse, snapshot BUSY state. - // If BUSY is already HIGH here, the chip never completed power-on calibration. - if (pin == 32 && value == 0) { - static bool _first_nreset = true; - if (_first_nreset) { - _first_nreset = false; - const struct device *bdev = DEVICE_DT_GET(DT_NODELABEL(gpio2)); - if (device_is_ready(bdev)) { - gpio_pin_configure(bdev, 3, GPIO_INPUT); // P2.03 = BUSY - int busy_before = gpio_pin_get(bdev, 3); - printk("[nrf54l15] BUSY before first NRESET = %d%s\n", busy_before, - busy_before ? " ← STUCK HIGH (chip damaged?)" : " ← LOW (chip OK)"); - } - } - } -#endif - - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - if (!device_is_ready(dev)) { - // Genuine hardware/DTS misconfiguration - keep this regardless of the - // GPIO_DEBUG gate so it surfaces in production builds too. - printk("[GPIO] pin%u dev NOT READY\n", (unsigned)pin); - return; - } - gpio_pin_set(dev, zpin, (int)value); -#ifdef NRF54L15_GPIO_DEBUG - if ((pin == 37 || pin == 32) && _gpio_log_count < GPIO_LOG_MAX) { - // Read back the pin state to confirm it actually changed - int actual = gpio_pin_get(dev, zpin); - printk("[GPIO] pin%u → %u (read-back=%d)\n", (unsigned)pin, (unsigned)value, actual); - _gpio_log_count++; - } -#endif -} - -int digitalRead(uint32_t pin) -{ - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - if (!device_is_ready(dev)) - return 0; - int v = gpio_pin_get(dev, zpin); -#ifdef NRF54L15_GPIO_DEBUG - // Log BUSY pin (35=P2.03) state changes + periodic updates for 10 seconds - if (pin == 35) { - static uint32_t busy_log_count = 0; - static int last_busy = -1; - static uint32_t first_read_ms = 0; - if (first_read_ms == 0) - first_read_ms = k_uptime_get_32(); - uint32_t elapsed_ms = k_uptime_get_32() - first_read_ms; - - // Always log state changes - if (v != last_busy) { - printk("[BUSY] %ums: state changed %d → %d\n", (unsigned)elapsed_ms, last_busy, v); - last_busy = v; - } - // Also log every 500ms for first 10 seconds so we can see timeline - if (elapsed_ms < 10000 && busy_log_count < 20 && (elapsed_ms / 500) > (busy_log_count)) { - printk("[BUSY] %ums: pin=%d (periodic)\n", (unsigned)elapsed_ms, v); - busy_log_count = (elapsed_ms / 500) + 1; - } - } -#endif - return v; -} - -// ─── attachInterrupt - supports up to NRF54L15_MAX_IRQS pins ──────────────── -#define NRF54L15_MAX_IRQS 8 - -struct _PinIrq { - struct gpio_callback cb; - voidFuncPtr user_cb; - const struct device *dev; - gpio_pin_t zpin; - bool used; -}; - -static _PinIrq _irq_table[NRF54L15_MAX_IRQS]; - -static void _gpio_irq_dispatch(const struct device *dev, struct gpio_callback *cb, uint32_t pins) -{ - _PinIrq *irq = CONTAINER_OF(cb, _PinIrq, cb); - if (irq->user_cb) - irq->user_cb(); -} - -void attachInterrupt(uint32_t pin, voidFuncPtr cb, int mode) -{ - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - if (!device_is_ready(dev)) - return; - - // Find a free slot (or reuse existing registration for same pin) - _PinIrq *slot = nullptr; - for (int i = 0; i < NRF54L15_MAX_IRQS; i++) { - if (_irq_table[i].used && _irq_table[i].dev == dev && _irq_table[i].zpin == zpin) { - // Re-register: remove old callback first - gpio_remove_callback(dev, &_irq_table[i].cb); - slot = &_irq_table[i]; - break; - } - if (!slot && !_irq_table[i].used) - slot = &_irq_table[i]; - } - if (!slot) - return; // table full - - gpio_flags_t irq_flags; - switch (mode) { - case RISING: - irq_flags = GPIO_INT_EDGE_RISING; - break; - case FALLING: - irq_flags = GPIO_INT_EDGE_FALLING; - break; - default: - irq_flags = GPIO_INT_EDGE_BOTH; - break; - } - - slot->user_cb = cb; - slot->dev = dev; - slot->zpin = zpin; - slot->used = true; - - gpio_pin_configure(dev, zpin, GPIO_INPUT); - gpio_init_callback(&slot->cb, _gpio_irq_dispatch, BIT(zpin)); - gpio_add_callback(dev, &slot->cb); - gpio_pin_interrupt_configure(dev, zpin, irq_flags); -} - -void detachInterrupt(uint32_t pin) -{ - gpio_pin_t zpin; - const struct device *dev = _gpio_dev_for_pin(pin, &zpin); - for (int i = 0; i < NRF54L15_MAX_IRQS; i++) { - if (_irq_table[i].used && _irq_table[i].dev == dev && _irq_table[i].zpin == zpin) { - gpio_pin_interrupt_configure(dev, zpin, GPIO_INT_DISABLE); - gpio_remove_callback(dev, &_irq_table[i].cb); - _irq_table[i].used = false; - break; - } - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// SPI - Real Zephyr implementation using SPIM00 (HP domain, 3.0V) -// CS is handled by RadioLib via digitalWrite() - hardware CS not used. -// Mode 0 (CPOL=0, CPHA=0), MSB first. -// ═════════════════════════════════════════════════════════════════════════════ - -// Use SPIM00 (HP domain, 3.0V) - SPIM20 is 1.8V LP domain, incompatible with SX1262. -// Lazy-init: DEVICE_DT_GET in global scope fails when the extern symbol is -// not visible in this translation unit. Use a function-local static instead. -static const struct device *_spi00(void) -{ - static const struct device *dev = nullptr; - if (!dev) { - dev = DEVICE_DT_GET(DT_NODELABEL(spi00)); - if (!device_is_ready(dev)) { - printk("[nrf54l15] spi00 NOT READY\n"); - dev = nullptr; - } else { - printk("[nrf54l15] spi00 ready\n"); - } - } - return dev; -} - -// SPI config: Mode 0, MSB first, no hardware CS (RadioLib does it manually) -// -// SPIM00 base clock = 128 MHz (nRF54L15 default when NRF_CONFIG_CPU_FREQ_MHZ -// is not set; SystemInit() applies 128 MHz). Hardware prescaler must be EVEN -// and in [4, 126] (SPIM00_PRESCALER_DIVISOR_RANGE_MIN/MAX from MDK). -// 1 MHz → prescaler = 128 > 126 → NRFX_ERROR_INVALID_PARAM → -EIO on every -// transfer. Minimum valid frequency is 2 MHz (prescaler = 64). -static const struct spi_config _spi00_cfg = { - .frequency = 2000000U, // 2 MHz - minimum valid for SPIM00 at 128 MHz base - .operation = SPI_OP_MODE_MASTER | SPI_WORD_SET(8) | SPI_TRANSFER_MSB, - .slave = 0, - .cs = {}, // CS = NULL → RadioLib handles CS via GPIO -}; - -// Static DMA buffers - stack-allocated bufs on nRF54L15 may not be reachable -// by SPIM20 EasyDMA. Static placement in .bss/.data is always in Global SRAM. -// rx_byte is pre-filled with 0xAA before every transfer so we can distinguish: -// 0xAA → DMA never wrote (EasyDMA can't reach the buffer) -// 0x00 → MISO actively driven LOW (chip in reset / bus fight) -// 0xFF → MISO floating HIGH -// other → real chip response -static uint8_t _spi_tx_byte __attribute__((aligned(4))); -static uint8_t _spi_rx_byte __attribute__((aligned(4))); - -// Dump the first SPI_DUMP_N byte exchanges so we can see what MISO returns. -#define SPI_DUMP_N 30 -static uint32_t _spi_dump_count = 0; - -uint8_t SPIClass::transfer(uint8_t data) -{ - const struct device *dev = _spi00(); - if (!dev) - return 0xFF; - - _spi_tx_byte = data; - _spi_rx_byte = 0xAA; // sentinel: if DMA doesn't write, we return 0xAA - - struct spi_buf tx_buf = {.buf = &_spi_tx_byte, .len = 1}; - struct spi_buf rx_buf = {.buf = &_spi_rx_byte, .len = 1}; - struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; - struct spi_buf_set rx_set = {.buffers = &rx_buf, .count = 1}; - - static uint32_t spi_err_count = 0; - int ret = spi_transceive(dev, &_spi00_cfg, &tx_set, &rx_set); - if (ret != 0 && spi_err_count++ < 3) - printk("[SPI] err=%d tx=0x%02x\n", ret, data); - - if (_spi_dump_count < SPI_DUMP_N) { - printk("[SPI] #%u tx=0x%02x rx=0x%02x\n", (unsigned)_spi_dump_count, data, _spi_rx_byte); - _spi_dump_count++; - } - - return _spi_rx_byte; -} - -// Static DMA-safe buffers for transfer16 - same EasyDMA reachability concern -// applies as for the byte path: stack buffers from a caller thread may sit in -// per-thread RAM regions that EasyDMA cannot reach. -static uint8_t _spi_tx16[2] __attribute__((aligned(4))); -static uint8_t _spi_rx16[2] __attribute__((aligned(4))); - -uint16_t SPIClass::transfer16(uint16_t data) -{ - const struct device *dev = _spi00(); - if (!dev) - return 0xFFFF; - - _spi_tx16[0] = (uint8_t)(data >> 8); - _spi_tx16[1] = (uint8_t)(data & 0xFF); - _spi_rx16[0] = 0xAA; - _spi_rx16[1] = 0xAA; - struct spi_buf tx_buf = {.buf = _spi_tx16, .len = 2}; - struct spi_buf rx_buf = {.buf = _spi_rx16, .len = 2}; - struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; - struct spi_buf_set rx_set = {.buffers = &rx_buf, .count = 1}; - spi_transceive(dev, &_spi00_cfg, &tx_set, &rx_set); - return ((uint16_t)_spi_rx16[0] << 8) | _spi_rx16[1]; -} - -void SPIClass::transferBytes(const uint8_t *tx, uint8_t *rx, uint32_t count) -{ - if (!count) - return; - const struct device *dev = _spi00(); - if (!dev) - return; - // Zephyr requires non-const buf pointer; cast is safe for tx-only direction - struct spi_buf tx_buf = {.buf = const_cast(tx), .len = count}; - struct spi_buf rx_buf = {.buf = rx, .len = count}; - struct spi_buf_set tx_set = {.buffers = &tx_buf, .count = 1}; - struct spi_buf_set rx_set = {.buffers = rx_buf.buf ? &rx_buf : nullptr, .count = rx_buf.buf ? 1U : 0U}; - spi_transceive(dev, &_spi00_cfg, &tx_set, rx ? &rx_set : nullptr); -} - -void SPIClass::transfer(void *buf, size_t count) -{ - if (!count || !buf) - return; - transferBytes(reinterpret_cast(buf), reinterpret_cast(buf), (uint32_t)count); -} diff --git a/src/platform/nrf54l15/nrf54l15_main.cpp b/src/platform/nrf54l15/nrf54l15_main.cpp deleted file mode 100644 index 8dc5644e2d..0000000000 --- a/src/platform/nrf54l15/nrf54l15_main.cpp +++ /dev/null @@ -1,121 +0,0 @@ -/* - * nrf54l15_main.cpp - Zephyr entry point for Meshtastic nRF54L15 port - * - * Zephyr calls main() instead of Arduino's setup()/loop(). - * This file provides the main() that bootstraps the Arduino-style - * Meshtastic application loop. - */ - -#include -#include -#include -#include - -// Forward declarations from src/main.cpp -void setup(); -void loop(); - -// ── Crash info saved to noinit RAM (survives soft reset) ───────────────────── -// Zephyr's arch_esf does not expose the faulting SP directly; we capture PSP -// at entry to the fatal handler (the exception-basic frame lives there) and -// store xPSR alongside PC/LR for context. -struct crash_info { - uint32_t magic; - uint32_t reason; - uint32_t pc; - uint32_t psp; // stack pointer captured at fault entry - uint32_t xpsr; // saved program status (flags + exception number) - uint32_t lr; - uint32_t cfsr; // Configurable Fault Status Register -}; -static struct crash_info saved_crash __attribute__((section(".noinit"))); -#define CRASH_MAGIC 0xDEADBEEF - -// Override Zephyr's weak fatal handler to save crash info, then cold-reboot so -// main() can report the saved record on the next boot. We don't rely on -// CONFIG_RESET_ON_FATAL_ERROR (default off → k_fatal_halt would spin forever) -// - we issue sys_reboot() ourselves after flushing logs. -extern "C" void k_sys_fatal_error_handler(unsigned int reason, const struct arch_esf *esf) -{ - saved_crash.magic = CRASH_MAGIC; - saved_crash.reason = reason; - // Capture the faulting thread's stack pointer before we start using the - // handler's own stack for logging. - uint32_t psp_at_entry; - __asm__ volatile("mrs %0, psp" : "=r"(psp_at_entry)); - saved_crash.psp = psp_at_entry; - if (esf) { - saved_crash.pc = esf->basic.pc; - saved_crash.xpsr = esf->basic.xpsr; - saved_crash.lr = esf->basic.lr; - } - // Read Cortex-M33 SCB CFSR - saved_crash.cfsr = *((volatile uint32_t *)0xE000ED28U); - printk("[nrf54l15] FATAL reason=%u pc=0x%08x lr=0x%08x cfsr=0x%08x\n", reason, saved_crash.pc, saved_crash.lr, - saved_crash.cfsr); - - // Walk the failing thread's stack and print any word that looks like a - // Thumb code address (0x1000 - flash end, with the Thumb-mode low bit set). - // The Cortex-M exception frame at PSP holds r0,r1,r2,r3,r12,lr,pc,xpsr - // (8 words); deeper words are the caller's saved frame, which gives a - // crude but useful poor-man's backtrace when CONFIG_DEBUG_COREDUMP is off. - // Found the BLE-init bad_alloc → abort() chain (heap exhaustion under - // CONFIG_BT_BUF_ACL_RX_SIZE=251) when the fault dump alone showed only - // abort itself. Cheap (~150 B of code) and silent until a fault. - uint32_t psp; - __asm__ volatile("mrs %0, psp" : "=r"(psp)); - printk("[nrf54l15] PSP=0x%08x - stack walk:\n", psp); - // Validate PSP before dereferencing. Real faults frequently leave PSP - // pointing at corrupted/unmapped memory, and walking it blindly triggers a - // second fault inside this handler. Restrict to nRF54L15 SRAM (256 KB at - // 0x20000000) with 4-byte alignment, and clamp the walk so we never read - // past the end of RAM. - const uintptr_t SRAM_START = 0x20000000UL; - const uintptr_t SRAM_END = 0x20040000UL; - if (psp < SRAM_START || psp >= SRAM_END || (psp & 0x3U) != 0) { - printk("[nrf54l15] PSP out of SRAM range or unaligned, skipping walk\n"); - } else { - const uint32_t *sp = (const uint32_t *)psp; - int max_words = (int)((SRAM_END - psp) / sizeof(uint32_t)); - if (max_words > 96) - max_words = 96; - for (int i = 0; i < max_words; i++) { - uint32_t v = sp[i]; - if (v >= 0x00001000 && v < 0x00080000 && (v & 1)) { - printk("[nrf54l15] sp[%d]=0x%08x (code)\n", i, v); - } - } - } - - // Give the RTT/printk backend a chance to drain before we reset, otherwise - // the crash log line above is lost and the next boot's "Prev crash" line is - // the only forensic evidence we get. - k_busy_wait(50000); // 50 ms - sys_reboot(SYS_REBOOT_COLD); - // Unreachable; k_fatal_halt as a defensive backstop in case sys_reboot - // returns (it shouldn't). - k_fatal_halt(reason); -} - -int main(void) -{ - uint32_t reset_cause = 0; - hwinfo_get_reset_cause(&reset_cause); - hwinfo_clear_reset_cause(); - printk("[nrf54l15] Reset cause: 0x%08x\n", reset_cause); - - if (saved_crash.magic == CRASH_MAGIC) { - printk("[nrf54l15] Prev crash: reason=%u pc=0x%08x lr=0x%08x psp=0x%08x xpsr=0x%08x cfsr=0x%08x\n", saved_crash.reason, - saved_crash.pc, saved_crash.lr, saved_crash.psp, saved_crash.xpsr, saved_crash.cfsr); - saved_crash.magic = 0; - } - - printk("[nrf54l15] A: main() entry\n"); - printk("[nrf54l15] B: calling setup()\n"); - setup(); - printk("[nrf54l15] C: setup() returned\n"); - while (true) { - loop(); - } - return 0; -} diff --git a/src/platform/nrf54l15/utility/bonding.h b/src/platform/nrf54l15/utility/bonding.h deleted file mode 100644 index 951ee9e699..0000000000 --- a/src/platform/nrf54l15/utility/bonding.h +++ /dev/null @@ -1,11 +0,0 @@ -// utility/bonding.h - stub for nRF54L15/Zephyr -// NodeDB.cpp includes this when ARCH_NRF52 is defined. -// Bluetooth is excluded; this stub satisfies the include chain. -#pragma once - -// BLE role constants (from Bluefruit SDK) -#define BLE_GAP_ROLE_PERIPH 0x01 -#define BLE_GAP_ROLE_CENTRAL 0x02 - -// Stub for bond_print_list() -static inline void bond_print_list(uint8_t) {} diff --git a/variants/nrf54l15/cpp_overrides/lfs_assert.h b/variants/nrf54l15/cpp_overrides/lfs_assert.h new file mode 100644 index 0000000000..8c53ba570a --- /dev/null +++ b/variants/nrf54l15/cpp_overrides/lfs_assert.h @@ -0,0 +1,18 @@ +#pragma once +// Force-included so littlefs v2 (nRF54L core) routes assertions and logs through Meshtastic +// (lfs_assert() in main-nrf52.cpp formats the filesystem on corruption). +#ifdef __cplusplus +extern "C" { +#endif +void lfs_assert(const char *reason); +void logLegacy(const char *level, const char *fmt, ...); +#ifdef __cplusplus +} +#endif +#define LFS_ASSERT(test) \ + if (!(test)) \ + lfs_assert(#test) +#define LFS_LOG_(level, fmt, ...) logLegacy(level, "lfs:%d: " fmt "%s\n", __LINE__, __VA_ARGS__) +#define LFS_DEBUG(...) LFS_LOG_("DEBUG", __VA_ARGS__, "") +#define LFS_WARN(...) LFS_LOG_("WARN", __VA_ARGS__, "") +#define LFS_ERROR(...) LFS_LOG_("ERROR", __VA_ARGS__, "") diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index a40f1baa5b..0c1fea75fb 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -1,90 +1,49 @@ [nrf54l15_base] -# renovate: datasource=git-refs depName=Seeed-Studio/platform-seeedboards packageName=https://github.com/Seeed-Studio/platform-seeedboards gitBranch=main -platform = https://github.com/Seeed-Studio/platform-seeedboards.git#1ec1287f8e4bc4067a6fd593991e36875aef989f -; Pin the Zephyr package explicitly. Seeed's platform script only maps their -; own "seeed-xiao-*" board ids to a framework-zephyr package; any other board -; -- nrf54l15dk included -- falls back to whatever platform.json declares as -; the default, which is now framework-zephyr-nrf54lm20 (Zephyr 4.4.0). Its -; west manifest pulls a CMSIS_6 whose cmsis_gcc.h calls the ACLE builtins -; __sxtb16/__sxtab16, and none of the GCC ARM toolchains PlatformIO ships -; (8.2.1/9.2.1/9.3.1) declare them in arm_acle.h. In C that is only an -; implicit-declaration warning, so the pure-C Zephyr core never notices; in -; C++ it is a hard error, and any .cpp pulling in zephyr/kernel.h hits it. -; Without the pin a fresh package cache breaks this build with nothing in the -; tree having changed. -platform_packages = - platformio/framework-zephyr-nrf54lm20@https://dl.registry.platformio.org/download/platformio/tool/framework-zephyr/3.40201.251021/framework-zephyr-3.40201.251021.tar.gz - ; Don't renovate toolchain-gccarmnoneeabi - platformio/toolchain-gccarmnoneeabi@1.90301.200702 -framework = zephyr +; Out-of-tree platform (meshtastic/platform-nordicnrf54) with the s145 SoftDevice Arduino core; +; the platform pulls the Arduino core and the DFU bootloader from their own repositories. +platform = https://github.com/meshtastic/platform-nordicnrf54.git#v0.3.1 extends = arduino_base build_type = release build_flags = + -include variants/nrf54l15/cpp_overrides/lfs_assert.h ${arduino_base.build_flags} - -Isrc/platform/nrf54l15 + -Wno-unused-variable + -Isrc/platform/nrf52 + ; The nRF54L port rides on the nRF52 platform layer; ARCH_NRF54L marks the few divergent spots. + -DARCH_NRF54L + -DPRIVATE_HW + -DLOOP_STACK_SZ=2048 + -DCFG_BLE_TASK_STACKSIZE=2048 + -DNRF_USE_SERIAL_DFU -DMESHTASTIC_EXCLUDE_AUDIO=1 - -DMESHTASTIC_EXCLUDE_GPS=1 - -DMESHTASTIC_EXCLUDE_MQTT=1 - -DHAS_WIRE=1 - -DHAS_SENSOR=1 - -DHAS_BUTTON=0 - -DHAS_TELEMETRY=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 - -DARDUINO=100 - -DMESHTASTIC_EXCLUDE_ACCELEROMETER=1 - -DMAX_NUM_NODES=40 - -fpermissive - # Libraries that Zephyr LDF misses; add include paths explicitly - -I.pio/libdeps/${PIOENV}/Crypto - -I.pio/libdeps/${PIOENV}/ArduinoThread - -I".pio/libdeps/${PIOENV}/ESP8266 and ESP32 OLED driver for SSD1306 displays/src" - -I.pio/libdeps/${PIOENV}/OneButton/src - -I.pio/libdeps/${PIOENV}/arduino-fsm - -I.pio/libdeps/${PIOENV}/TinyGPSPlus/src - -I.pio/libdeps/${PIOENV}/ErriezCRC32/src - -I.pio/libdeps/${PIOENV}/NonBlockingRTTTL/src - -I.pio/libdeps/${PIOENV}/RadioLib/src + -DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1 + -DOLEDDISPLAY_REDUCE_MEMORY + -Os + -std=gnu++17 +build_unflags = + -Ofast + -Og + -ggdb3 + -ggdb2 + -g3 + -g2 + -g + -g1 + -g0 + -std=c++11 + -std=gnu++11 build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - - - - - - - - - - - - - + - -lib_compat_mode = off + ${arduino_base.build_src_filter} + - - - - - - - - - - lib_deps = ${arduino_base.lib_deps} ${radiolib_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip - ; Cherry-picked sensor libs from environmental_base. The full - ; environmental_base pulls Adafruit_SSD1306 / GFX which need Arduino - ; pin macros (digitalPinToPort / portOutputRegister) that the Zephyr - ; Arduino shim does not implement. - https://github.com/adafruit/Adafruit_BusIO/archive/refs/tags/1.17.4.zip - https://github.com/adafruit/Adafruit_Sensor/archive/refs/tags/1.1.15.zip - https://github.com/adafruit/Adafruit_BMP280_Library/archive/refs/tags/3.0.0.zip - https://github.com/adafruit/Adafruit_BME280_Library/archive/refs/tags/2.3.0.zip - https://github.com/adafruit/Adafruit_INA260/archive/refs/tags/1.5.3.zip - https://github.com/adafruit/Adafruit_INA219/archive/refs/tags/1.2.3.zip - https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip - https://github.com/RobTillaart/INA226/archive/refs/tags/0.6.6.zip - ; SHTXXSensor gates on __has_include(), a header shipped by - ; Sensirion/arduino-sht. Adafruit_SHT4X ships Adafruit_SHT4X.h instead and - ; has no consumer in src/, so it left the SHT40 driver out of the build. - https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip lib_ignore = BluetoothOTA lvgl - Adafruit_nRFCrypto diff --git a/variants/nrf54l15/nrf54l15dk/README.md b/variants/nrf54l15/nrf54l15dk/README.md deleted file mode 100644 index 053fddff45..0000000000 --- a/variants/nrf54l15/nrf54l15dk/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# nRF54L15-DK - EBYTE E22-900M30S Wiring Guide - -Board: **Nordic nRF54L15-DK (PCA10156)** -Radio: **EBYTE E22-900M30S** (SX1262, 30 dBm, 868/915 MHz) - ---- - -## Why P2 (HP domain) and not P1 - -The nRF54L15 splits its GPIOs across three supply domains: - -- **P0** - Main domain, **3.0 V** - usable -- **P1** - LP domain, **1.8 V** - **not compatible** with the SX1262 -- **P2** - HP domain, **3.0 V** - usable - -The SX1262 requires VIH ≥ 0.7 × VDD (≈ 2.31 V at VDD = 3.3 V). P1's 1.8 V output -leaves the chip stuck in reset with `BUSY` never going LOW. All E22 signals -therefore live on **P2** and are driven by **SPIM00**. - -> `P2.09` is normally wired to LED0 on the DK; we ignore the LED and use -> SPIM00's default MISO pin. The on-board MX25R64 NOR flash also sat on SPIM00 -> -> - it is deleted in the device-tree overlay to free the bus. - ---- - -## Connections - J2 header, P2 bank - -| E22-900M30S | GPIO | DK pin | Function | -| ----------- | ----- | ------ | ---------------------------------------------- | -| MISO | P2.04 | 36 | SPIM00 data in | -| NSS / CS | P2.05 | 37 | SPI chip-select (driven by RadioLib as a GPIO) | -| DIO1 | P2.06 | 38 | IRQ - modem interrupt (routed via gpiote30) | -| BUSY | P2.03 | 35 | Module busy (GPIO input) | -| NRESET | P2.00 | 32 | Module reset (GPIO output, active LOW) | -| RXEN | P2.07 | 39 | LNA enable - held HIGH via `SX126X_ANT_SW` | -| MOSI | P2.02 | 34 | SPIM00 data out | -| SCK | P2.01 | 33 | SPIM00 clock | -| GND | - | GND | Common ground | -| VCC | - | VDD | 3.3 V | - -> **Numbering convention**: `P0.n = n`, `P1.n = 16+n`, `P2.n = 32+n`. -> Example: `P2.04` → 32 + 4 = **36**. - ---- - -## DIO2 → TXEN bridge (required) - -The E22-900M30S does **not** connect DIO2 to TXEN internally. A physical bridge on the module is required: - -1. Locate the `DIO2` and `TXEN` pads on the underside of the E22 module. -2. Solder a wire bridge or a 0 Ω resistor between the two pads. -3. With this bridge, the SX1262 drives the PA automatically via `SX126X_DIO2_AS_RF_SWITCH`. - -Without this bridge the module **will not transmit** (PA is never enabled). - ---- - -## RXEN - LNA always on - -`RXEN` (P2.07) is held HIGH permanently via `SX126X_ANT_SW 39` in `variant.h`. -**Do not use** `SX126X_RXEN` - RadioLib would drive it LOW in IDLE state and -the LNA would stay disabled (radio deaf in RX). - ---- - -## Reserved DK pins - do not reuse - -| Pins | Reserved function | -| ----------- | -------------------------------------------------------- | -| P0.00-P0.03 | IMCU debug UART (uart30, J-Link VCOM - used by RTT host) | -| P0.04 | BTN3 | -| P1.00-P1.01 | 32 kHz crystal | -| P1.02-P1.03 | NFC antenna | -| P1.10 | LED1 (status LED - kept) | -| P1.13 | BTN0 (only remaining user button) | -| P1.14 | LED3 | -| P2.01-P2.05 | SPIM00 / E22 (see connection table above) | -| P2.08-P2.10 | Trace ETM / LED2 (avoid) | - ---- - -## Build and flash - -```bash -# Build -pio run -e nrf54l15dk - -# Flash (requires a J-Link connected via the DK's on-board IMCU) -pio run -e nrf54l15dk -t upload - -# Monitor RTT (channel 1 = Meshtastic logs) -JLinkRTTLogger -device nRF54L15_xxAA -if SWD -speed 4000 -RTTChannel 1 boot.log -``` - -Expected boot log: - -```text -*** Booting Zephyr OS build zephyr-v40201 *** -[nrf54l15] Reset cause: ... -[nrf54l15] B: calling setup() -INFO | ... SX1262 -INFO | ... lora.begin() = 0 ← RADIOLIB_ERR_NONE -[nrf54l15] C: setup() returned -``` - -If you see `Record critical error 3` (`NO_RADIO`), check: DIO2→TXEN bridge, -supply voltages (the E22 must see 3.0-3.3 V on P2, not 1.8 V), and SPI wiring -continuity. diff --git a/variants/nrf54l15/nrf54l15dk/platformio.ini b/variants/nrf54l15/nrf54l15dk/platformio.ini index 8f570f7448..52e25525be 100644 --- a/variants/nrf54l15/nrf54l15dk/platformio.ini +++ b/variants/nrf54l15/nrf54l15dk/platformio.ini @@ -11,14 +11,16 @@ custom_meshtastic_display_name = Nordic nRF54L15-DK extends = nrf54l15_base board = nrf54l15dk board_level = extra -debug_tool = jlink upload_protocol = jlink -board_runner_args_jlink = --device nRF54L15_xxAA --speed 4000 +debug_tool = jlink build_flags = ${nrf54l15_base.build_flags} -Ivariants/nrf54l15/nrf54l15dk -DNRF54L15_DK - -DMESHTASTIC_EXCLUDE_FILES_MANIFEST=1 + -DMESHTASTIC_EXCLUDE_GPS=1 + -DHAS_GPS=0 + -DHAS_SCREEN=0 + -DMESHTASTIC_EXCLUDE_SCREEN=1 build_src_filter = ${nrf54l15_base.build_src_filter} +<../variants/nrf54l15/nrf54l15dk> diff --git a/variants/nrf54l15/nrf54l15dk/variant.cpp b/variants/nrf54l15/nrf54l15dk/variant.cpp index b2982e7c55..2e82825ee0 100644 --- a/variants/nrf54l15/nrf54l15dk/variant.cpp +++ b/variants/nrf54l15/nrf54l15dk/variant.cpp @@ -1,9 +1,19 @@ #include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +// Arduino pin index == physical GPIO number: P0.n = n, P1.n = 32 + n, P2.n = 64 + n +const uint32_t g_ADigitalPinMap[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, + 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95}; void initVariant() { - // Minimal board init for nRF54L15-DK. - // GPIO/SPI peripheral setup is handled by the Zephyr device tree overlay - // (zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay). - // Add any board-level power sequencing here if needed. + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); + pinMode(PIN_LED2, OUTPUT); + ledOff(PIN_LED2); } diff --git a/variants/nrf54l15/nrf54l15dk/variant.h b/variants/nrf54l15/nrf54l15dk/variant.h index b8417b68b7..7d263cfd46 100644 --- a/variants/nrf54l15/nrf54l15dk/variant.h +++ b/variants/nrf54l15/nrf54l15dk/variant.h @@ -1,86 +1,88 @@ #pragma once /* - * Nordic nRF54L15-DK (PCA10156) - Meshtastic variant + * Nordic nRF54L15-DK (PCA10156) with an EBYTE E22-900M30S (SX1262) on the J2 header. * - * ── GPIO voltage domains ───────────────────────────────────────────────────── - * P0 (gpio0 @ 0x10A000) Main domain 3.0 V ← usable - * P1 (gpio1 @ 0xd8200 ) LP domain 1.8 V ← NOT compatible with E22 - * P2 (gpio2 @ 0x50400 ) HP domain 3.0 V ← usable + * This header shadows the framework's variants/nrf54l15dk/variant.h, so it carries the core + * pin table definitions as well. Arduino pin = physical GPIO: P0.n = n, P1.n = 32+n, P2.n = 64+n. * - * The SX1262 needs VIH ≥ 0.7 × VDD = 2.31 V (VDD = 3.3 V). - * P1 outputs only 1.8 V → chip stays in reset, BUSY never goes LOW. - * All E22 signals are therefore on P2 (3.0 V), driven by SPIM00. + * GPIO supply domains: P0 3.0 V, P1 1.8 V (too low for the SX1262), P2 3.0 V. + * Serial peripherals are port bound: SERIAL00 (UARTE00/SPIM00) -> P2, SERIAL2x -> P1, SERIAL30 -> P0. * - * EBYTE E22-900M30S (SX1262) wiring - J2 header, all P2: - * - * E22 pin GPIO pin# Notes - * ───────────────────────────────────────────────────────────────────── - * MISO → P2.04 36 SPIM00 data in - * NSS/CS → P2.05 37 SPI chip-select (RadioLib GPIO) - * DIO1 → P2.06 38 IRQ - interrupt via gpiote30 - * BUSY → P2.03 35 GPIO input - * NRESET → P2.00 32 GPIO output - * RXEN → P2.07 39 Held HIGH via ANT_SW (LNA always active) - * MOSI → P2.02 34 SPIM00 data out - * SCK → P2.01 33 SPIM00 clock - * - * DIO2 → TXEN bridge required on E22 module (solder bridge / wire). - * DIO3 drives TCXO reference (1.8 V). - * - * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. - * - * Reserved / do-not-use DK pins: - * P0.00-P0.02 IMCU VCOM TX/RX/RTS pads (uart30 disabled; pads idle) - * P0.03 I2C SDA (TWIM30) - sensor bus - * P0.04 I2C SCL (TWIM30) - sensor bus; SW3 button on this pad, - * DO NOT press SW3 while I2C is active - * P1.00-P1.01 32 kHz crystal - * P1.02-P1.03 NFC antenna - * P1.10 LED1 (status LED - keep) - * P1.13 BTN0 - main user button - * P1.14 LED3 - * P2.01-P2.05 SPIM00 / E22 (see above) - * P2.08-P2.10 Trace pins (avoid) + * E22 wiring (all P2, SPIM00): + * SCK P2.01, MOSI P2.02, BUSY P2.03, MISO P2.04, NSS P2.05, DIO1 P2.06, RXEN P2.07, NRESET P2.00 + * DIO2 -> TXEN bridge on the module, DIO3 drives the TCXO (1.8 V). */ -#ifndef NRF54L15_DK -#define NRF54L15_DK +#define VARIANT_MCK (128000000ul) +#define USE_LFXO + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { #endif -// ── SX1262 / E22-900M30S - all P2, HP domain (3.0 V) ──────────────────────── -#define USE_SX1262 -#define SX126X_CS 37 // P2.05 - chip-select -#define SX126X_DIO1 38 // P2.06 - IRQ (gpiote30 capable) -#define SX126X_BUSY 35 // P2.03 - BUSY -#define SX126X_RESET 32 // P2.00 - NRESET +#define PINS_COUNT (96) +#define NUM_DIGITAL_PINS (96) +#define NUM_ANALOG_INPUTS (8) +#define NUM_ANALOG_OUTPUTS (0) +#define ADC_RESOLUTION 14 -// RXEN (P2.07) held HIGH permanently - LNA always active. -// RadioLib must NOT toggle it; ANT_SW drives it HIGH before lora.begin(). -#define SX126X_ANT_SW 39 // P2.07 - RXEN driven HIGH at init +// LEDs (active low): LED1 P1.10 status, LED0 P2.09 +#define PIN_LED1 42 +#define PIN_LED2 73 +#define LED_BUILTIN PIN_LED1 +#define LED_STATE_ON 0 -// DIO2 controls TXEN via bridge on E22 module. -// DIO3 provides 1.8 V TCXO reference. -#define SX126X_DIO2_AS_RF_SWITCH -#define SX126X_DIO3_TCXO_VOLTAGE 1.8f - -// ── LEDs (active HIGH) ─────────────────────────────────────────────────────── -#define PIN_LED1 26 // P1.10 - LED1 (status LED, LP domain - output only, OK) -#define PIN_LED2 41 // P2.09 - LED0 on DK (remapped; P2.07 now used for RXEN) -#define LED_STATE_ON 1 - -// ── Buttons (active LOW, internal pull-up) ─────────────────────────────────── -// BTN1 (P1.09), BTN2 (P1.08) and BTN3 (P0.04) deleted from DTS - only BTN0 -// remains. BTN3's pad (P0.04) is now I2C SCL. -#define PIN_BUTTON1 29 // P1.13 - BTN0 +// BTN0 P1.13 (active low) +#define PIN_BUTTON1 45 #define BUTTON_NEED_PULLUP -// ── I2C bus (TWIM30, HP domain, 3.0 V) ────────────────────────────────────── -// SDA=P0.03, SCL=P0.04. Pinctrl + clock-frequency live in the board overlay. -// External 4.7 kΩ pull-ups required on both lines. Meshtastic's Arduino -// TwoWire layer (src/platform/nrf54l15/Wire.cpp) resolves the device at -// compile time via DT_NODELABEL(i2c30); these PIN_WIRE_* defines are kept -// for parity with the Arduino convention used by other variants. -#define PIN_WIRE_SDA 3 // P0.03 -#define PIN_WIRE_SCL 4 // P0.04 +// Serial1: VCOM0 of the on-board J-Link (UARTE20): TX P1.04, RX P1.05 +#define PIN_SERIAL1_RX 37 +#define PIN_SERIAL1_TX 36 +#define SERIAL1_UARTE NRF_UARTE20 +#define SERIAL1_IRQN SERIAL20_IRQn +#define SERIAL1_IRQ_HANDLER SERIAL20_IRQHandler + +// Serial2 (UARTE21, serial module): RX P1.15, TX P1.16; only P1 pins can be assigned to it +#define PIN_SERIAL2_RX 47 +#define PIN_SERIAL2_TX 48 +#define SERIAL2_UARTE NRF_UARTE21 +#define SERIAL2_IRQN SERIAL21_IRQn +#define SERIAL2_IRQ_HANDLER SERIAL21_IRQHandler + +// SPI (SPIM00) for the E22 +#define SPI_INTERFACES_COUNT 1 +#define PIN_SPI_MISO 68 +#define PIN_SPI_MOSI 66 +#define PIN_SPI_SCK 65 +static const uint8_t SS = 69; +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +// I2C (TWIM30): SDA P0.03, SCL P0.04, external 4.7k pull-ups required #define WIRE_INTERFACES_COUNT 1 +#define PIN_WIRE_SDA 3 +#define PIN_WIRE_SCL 4 +#define WIRE_TWIM NRF_TWIM30 +#define WIRE_TWIS NRF_TWIS30 +#define WIRE_IRQN SERIAL30_IRQn +#define WIRE_IRQ_HANDLER SERIAL30_IRQHandler + +#ifdef __cplusplus +} +#endif + +// SX1262 / E22-900M30S +#define USE_SX1262 +#define SX126X_CS 69 +#define SX126X_DIO1 70 +#define SX126X_BUSY 67 +#define SX126X_RESET 64 +// RXEN is held high permanently (LNA always on); TXEN follows DIO2. +#define SX126X_ANT_SW 71 +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8f diff --git a/variants/nrf54l15/xiao_nrf54l15/platformio.ini b/variants/nrf54l15/xiao_nrf54l15/platformio.ini new file mode 100644 index 0000000000..4397d23e5c --- /dev/null +++ b/variants/nrf54l15/xiao_nrf54l15/platformio.ini @@ -0,0 +1,34 @@ +[env:xiao_nrf54l15] +# PRIVATE_HW (255) until the protobuf HardwareModel enum gets an entry for this board. +custom_meshtastic_hw_model = 255 +custom_meshtastic_hw_model_slug = XIAO_NRF54L15 +custom_meshtastic_architecture = nrf54l15 +custom_meshtastic_actively_supported = false +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = Seeed XIAO nRF54L15 + +extends = nrf54l15_base +board = xiao_nrf54l15 +board_level = extra + +build_flags = ${nrf54l15_base.build_flags} + -Ivariants/nrf54l15/xiao_nrf54l15 + -DXIAO_NRF54L15 + -DMESHTASTIC_EXCLUDE_GPS=1 + -DHAS_GPS=0 + +build_src_filter = ${nrf54l15_base.build_src_filter} + +<../variants/nrf54l15/xiao_nrf54l15> + +[env:xiao_nrf54l15_lr2021] +# Seeed LoRa Plus expansion board: Wio-LR2021, SSD1306 OLED and Grove I2C on D4/D5, K1 on D14. +custom_meshtastic_hw_model = 255 +custom_meshtastic_hw_model_slug = XIAO_NRF54L15_LR2021 +custom_meshtastic_architecture = nrf54l15 +custom_meshtastic_actively_supported = false +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = Seeed XIAO nRF54L15 LoRa Plus + +extends = env:xiao_nrf54l15 +build_flags = ${env:xiao_nrf54l15.build_flags} + -DXIAO_NRF54L15_LR2021 diff --git a/variants/nrf54l15/xiao_nrf54l15/variant.cpp b/variants/nrf54l15/xiao_nrf54l15/variant.cpp new file mode 100644 index 0000000000..a1387cd7e9 --- /dev/null +++ b/variants/nrf54l15/xiao_nrf54l15/variant.cpp @@ -0,0 +1,22 @@ +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + // D0..D5 (P1 header pins) + 36, 37, 38, 39, 42, 43, + // D6..D10 (P2 header pins): TX, RX, SCK, MISO, MOSI + 72, 71, 65, 68, 66, + // 11..15: P0.03, P0.04, P2.10, P2.09, P2.06 + 3, 4, 74, 73, 70, + // 16 LED P2.00, 17 button P0.00, 18 SAMD11 RX (nRF TX) P1.09, 19 SAMD11 TX (nRF RX) P1.08 + 64, 0, 41, 40, + // 20 IMU/mic power P0.01, 21 RF switch power P2.03, 22 RF path select P2.05, 23 VBAT divider enable P1.15 + 1, 67, 69, 47}; + +void initVariant() +{ + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); +} diff --git a/variants/nrf54l15/xiao_nrf54l15/variant.h b/variants/nrf54l15/xiao_nrf54l15/variant.h new file mode 100644 index 0000000000..dae47b2dbd --- /dev/null +++ b/variants/nrf54l15/xiao_nrf54l15/variant.h @@ -0,0 +1,140 @@ +#pragma once + +/* + * Seeed XIAO nRF54L15 with a Wio-SX1262 for XIAO (SKU 113010003), or with XIAO_NRF54L15_LR2021 the + * Wio-LR2021 on the LoRa Plus expansion board (SKU 100039980, SSD1306 OLED and Grove I2C on D4/D5). + * The two modules share D1..D3 with different roles, so they are separate build environments. + * + * This header shadows the framework's variants/xiao_nrf54l15/variant.h, so it carries the core + * pin table definitions as well. Arduino pins 0..10 are the XIAO header D0..D10, 11..23 are + * internal signals (see variant.cpp for the physical GPIO of each index). + */ + +#define VARIANT_MCK (128000000ul) +#define USE_LFXO + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PINS_COUNT (24) +#define NUM_DIGITAL_PINS (24) +#define NUM_ANALOG_INPUTS (8) +#define NUM_ANALOG_OUTPUTS (0) +#define ADC_RESOLUTION 14 + +#define D0 (0ul) +#define D1 (1ul) +#define D2 (2ul) +#define D3 (3ul) +#define D4 (4ul) +#define D5 (5ul) +#define D6 (6ul) +#define D7 (7ul) +#define D8 (8ul) +#define D9 (9ul) +#define D10 (10ul) + +#define PIN_A0 D0 +#define PIN_A1 D1 +#define PIN_A2 D2 +#define PIN_A3 D3 +#define PIN_A4 D4 +#define PIN_A5 D5 +static const uint8_t A0 = PIN_A0; +static const uint8_t A1 = PIN_A1; +static const uint8_t A2 = PIN_A2; +static const uint8_t A3 = PIN_A3; +static const uint8_t A4 = PIN_A4; +static const uint8_t A5 = PIN_A5; + +// User LED P2.00 (active low) +#define PIN_LED1 16 +#define LED_BUILTIN PIN_LED1 +#define LED_STATE_ON 0 + +// User button P0.00 (active low) +#define PIN_BUTTON1 17 +#define BUTTON_NEED_PULLUP +#ifdef XIAO_NRF54L15_LR2021 +// K1 on the LoRa Plus expansion board, D14 (P2.09) +#define PIN_BUTTON2 14 +#endif + +// Serial1: the SAMD11 USB-CDC bridge (UARTE20): nRF TX P1.09, nRF RX P1.08 +#define PIN_SERIAL1_TX 18 +#define PIN_SERIAL1_RX 19 +#define SERIAL1_UARTE NRF_UARTE20 +#define SERIAL1_IRQN SERIAL20_IRQn +#define SERIAL1_IRQ_HANDLER SERIAL20_IRQHandler + +// Serial2: header D6 (TX) / D7 (RX) on UARTE21 +#define PIN_SERIAL2_TX D6 +#define PIN_SERIAL2_RX D7 +#define SERIAL2_UARTE NRF_UARTE21 +#define SERIAL2_IRQN SERIAL21_IRQn +#define SERIAL2_IRQ_HANDLER SERIAL21_IRQHandler + +// SPI (SPIM00): D8 SCK, D9 MISO, D10 MOSI +#define SPI_INTERFACES_COUNT 1 +#define PIN_SPI_MISO D9 +#define PIN_SPI_MOSI D10 +#define PIN_SPI_SCK D8 +#ifdef XIAO_NRF54L15_LR2021 +static const uint8_t SS = D3; +#else +static const uint8_t SS = D4; +#endif +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +#ifdef XIAO_NRF54L15_LR2021 +// Wire (TWIM22): header I2C on D4 (SDA, P1.10) / D5 (SCL, P1.11) +#define PIN_WIRE_SDA D4 +#define PIN_WIRE_SCL D5 +#define WIRE_TWIM NRF_TWIM22 +#define WIRE_TWIS NRF_TWIS22 +#define WIRE_IRQN SERIAL22_IRQn +#define WIRE_IRQ_HANDLER SERIAL22_IRQHandler +#else +// Wire (TWIM30): the Sense variant's internal sensor bus, SDA P0.04, SCL P0.03; D4/D5 belong to the radio +#define PIN_WIRE_SDA 12 +#define PIN_WIRE_SCL 11 +#define WIRE_TWIM NRF_TWIM30 +#define WIRE_TWIS NRF_TWIS30 +#define WIRE_IRQN SERIAL30_IRQn +#define WIRE_IRQ_HANDLER SERIAL30_IRQHandler +#endif +#define WIRE_INTERFACES_COUNT 1 + +#ifdef __cplusplus +} +#endif + +#ifdef XIAO_NRF54L15_LR2021 +// Wio-LR2021: NSS D3, IRQ on DIO8 D0, NRESET D2, BUSY D1, switchless RF, 32 MHz crystal (no TCXO), +// DIO7/DIO11 reach D6/D7 through 470R and stay unused +#define USE_LR2021 +#define LR2021_SPI_NSS_PIN D3 +#define LR2021_IRQ_PIN D0 +#define LR2021_NRESET_PIN D2 +#define LR2021_BUSY_PIN D1 +#define LR2021_SPI_SCK_PIN PIN_SPI_SCK +#define LR2021_SPI_MOSI_PIN PIN_SPI_MOSI +#define LR2021_SPI_MISO_PIN PIN_SPI_MISO +#define IRQ_DIO_NUM 8 +#else +// Wio-SX1262 for XIAO +#define USE_SX1262 +#define SX126X_CS D4 +#define SX126X_DIO1 D1 +#define SX126X_BUSY D3 +#define SX126X_RESET D2 +#define SX126X_RXEN D5 +#define SX126X_TXEN RADIOLIB_NC +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +#endif diff --git a/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay b/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay deleted file mode 100644 index 4c861312c1..0000000000 --- a/zephyr/boards/nrf54l15dk_nrf54l15_cpuapp.overlay +++ /dev/null @@ -1,117 +0,0 @@ -#include - -/* - * Zephyr device tree overlay — Nordic nRF54L15-DK + EBYTE E22-900M30S (SX1262) - * - * P1 GPIO bank runs at 1.8V VDDIO (LP domain) — NOT compatible with SX1262 - * which needs VIH ≥ 2.31V (0.7 × 3.3V). All E22 signals therefore use P2, - * which is in the HP domain (3.0V VDDIO), via SPIM00. - * - * SPIM00 (HP domain, 3.0V) replaces SPIM20 (LP domain, 1.8V). - * Default pinctrl from cpuapp_common.dtsi already maps SPIM00 to P2 pins: - * MISO = P2.04, MOSI = P2.02, SCK = P2.01 - * - * The on-board MX25R64 NOR flash was attached to SPIM00; it is not used by - * Meshtastic (LittleFS lives in internal RRAM), so we delete its DTS node to - * free the bus and P2.05 (its CS pin) for our use. - * - * Physical wiring (all P2, HP domain): - * E22 MISO → P2.04 (SPIM00 MISO) - * E22 NSS → P2.05 (freed from MX25R64 CS, RadioLib GPIO) - * E22 DIO1 → P2.06 (IRQ — interrupt capable via gpiote30) - * E22 BUSY → P2.03 (GPIO input) - * E22 RST → P2.00 (GPIO output) - * E22 RXEN → P2.07 (held HIGH via ANT_SW; replaces LED2 on DK) - * E22 MOSI → P2.02 (SPIM00 MOSI) - * E22 SCK → P2.01 (SPIM00 SCK) - * - * Pin numbering convention: P0.n = n, P1.n = 16+n, P2.n = 32+n. - */ - -/* Disable uart20 — no longer needed for LoRa (P1 domain abandoned). */ -&uart20 { - status = "disabled"; -}; - -/* Disable uart30 to free P0.00–P0.03 from the uart30_default pinctrl (which - * also reserves P0.03 as UART30 CTS). The peripheral instance 30 is shared - * with i2c30/spi30 — only one of {UARTE30, TWIM30, SPIM30} can be enabled at - * a time. We pick TWIM30 (i2c30) for sensors (SHT40 / INA3221 / SE050 on the - * custom PCB; BMP280 / INA228 on the DK for bring-up). Console stays on RTT - * via CONFIG_RTT_CONSOLE=y. */ -&uart30 { - status = "disabled"; -}; - -/* I2C bus via TWIM30 (HP domain, 3.0 V), SDA=P0.03 / SCL=P0.04. - * P0.03 was the UART30 CTS; freed by disabling uart30 above. - * P0.04 was button SW3 on the DK; deleted below. The pad still routes to - * the SW3 button on the board — DO NOT press SW3 during I2C use, it will - * short SCL to GND mid-transaction. - * 400 kHz is the highest standard rate supported by all three target sensors - * (BMP280, INA228, SE050). External 4.7 kΩ pull-ups required on both lines. */ -&pinctrl { - i2c30_default: i2c30_default { - group1 { - psels = , - ; - bias-pull-up; - }; - }; - - i2c30_sleep: i2c30_sleep { - group1 { - psels = , - ; - low-power-enable; - }; - }; -}; - -&i2c30 { - status = "okay"; - clock-frequency = ; /* 400 kHz */ - pinctrl-0 = <&i2c30_default>; - pinctrl-1 = <&i2c30_sleep>; - pinctrl-names = "default", "sleep"; -}; - -/ { - buttons { - /* button1 (P1.09) and button2 (P1.08) no longer repurposed — - * E22 now uses P2. button3 (P0.04) is now I2C SCL — its node - * must be removed before the i2c30 pinctrl can claim the pad. */ - /delete-node/ button_1; - /delete-node/ button_2; - /delete-node/ button_3; - }; - - aliases { - /delete-property/ sw1; - /delete-property/ sw2; - /delete-property/ sw3; - }; -}; - -/* - * Override SPIM00 to remove the MX25R64 NOR flash child. - * The SPIM00 peripheral itself stays enabled (status = "okay" from - * cpuapp_common.dtsi) with its existing pinctrl (P2.01/P2.02/P2.04). - * RadioLib drives CS (P2.05) as a plain GPIO — no hardware CS needed. - */ -&spi00 { - /delete-node/ mx25r6435f@0; -}; - -/* - * Grow storage_partition from 36 KB to 700 KB by reclaiming slot1_partition. - * slot1 (image-1) is the MCUboot secondary slot for dual-bank OTA, which we - * don't use (flashing is direct via J-Link). With only 9 blocks (36 KB / 4 KB) - * LittleFS ran out of space during COW writes of config.proto. - * New layout: storage_partition spans 0xb6000..0x165000 (700 KB, ~175 blocks). - */ -/delete-node/ &slot1_partition; - -&storage_partition { - reg = <0xb6000 0xaf000>; -}; diff --git a/zephyr/prj.conf b/zephyr/prj.conf deleted file mode 100644 index 0723d0cd67..0000000000 --- a/zephyr/prj.conf +++ /dev/null @@ -1,299 +0,0 @@ -# Zephyr project configuration for nRF54L15 Meshtastic port -# -# NOTE: this prj.conf is shared by ALL Zephyr PlatformIO environments -# in this project. Keep it compatible with any future Zephyr targets. - -# ── C++ support (required by Meshtastic) ────────────────────────────────────── -CONFIG_CPP=y -CONFIG_STD_CPP17=y -# Full libstdc++ — provides , , , , etc. -# Works with either newlib or picolibc (Zephyr auto-selects based on board). -CONFIG_REQUIRES_FULL_LIBCPP=y -# Disable C++ exceptions — not needed by Meshtastic and saves RAM/ROM -CONFIG_CPP_EXCEPTIONS=n - -# ── Peripheral subsystems ───────────────────────────────────────────────────── -CONFIG_SPI=y -CONFIG_I2C=y -CONFIG_GPIO=y - -# sys_reboot() used by BLE zombie-connection watchdog (BleDeferredThread). -# nRF54L15 SW-LL occasionally drops the BLE link without forwarding -# LE Disconnection Complete to the host; cold reboot is the only reliable -# recovery path. -CONFIG_REBOOT=y - -# ── ATT Prepare/Execute Write (LONG WRITE) ─────────────────────────────────── -# iOS CoreBluetooth automatically fragments writes >MTU-3 via ATT Prepare Write -# (opcode 0x16). Default CONFIG_BT_ATT_PREPARE_COUNT=0 makes Zephyr reject with -# "Request Not Supported" (0x06), which iOS surfaces as a write error → -# disconnect. With MTU=65 any ToRadio write >62 bytes triggers this path. -# Enabling this allocates N prep_pool buffers (each BT_ATT_BUF_SIZE = 65 bytes) -# and reassembles fragments into a single write_toradio() call on execute. -# 4 × 65 = 260 B max assembled value — enough for typical iOS NodeInfo/admin -# writes after config stream completes. -CONFIG_BT_ATT_PREPARE_COUNT=4 - -# ── Filesystem — LittleFS on storage_partition (RRAM) ─────────────────────── -# Size is set by the board overlay (the nRF54L15-DK overlay reclaims slot1 to -# expand storage_partition to ~700 KB). Capacity is reported at runtime via -# FIXED_PARTITION_SIZE(storage_partition) in InternalFileSystem::totalBytes(). -CONFIG_FLASH=y -CONFIG_FLASH_MAP=y -CONFIG_FLASH_PAGE_LAYOUT=y -CONFIG_FILE_SYSTEM=y -CONFIG_FILE_SYSTEM_LITTLEFS=y -CONFIG_FILE_SYSTEM_MKFS=y -# Disable SPI NOR flash driver — MX25R64 node deleted from DTS, SPIM00 used -# exclusively by RadioLib (SX1262). Without this, the spi_nor driver claims -# SPIM00 at boot and tries to read MX25R64 ID (gets garbage since the chip is -# not wired), producing "Device id a8 a8 a8 does not match config c2 28 17". -CONFIG_SPI_NOR=n -# Disable runtime PM — keeps SPI initialization path simple; avoids any -# interaction between PM auto-suspend/resume cycles and the SPIM00 clock -# request mechanism (CONFIG_CLOCK_CONTROL_NRF_HSFLL_GLOBAL). -CONFIG_PM_DEVICE_RUNTIME=n -# Suppress Zephyr FS subsystem's internal error/warning logs (ENOENT on -# missing files and EEXIST on duplicate mkdir are expected and handled). -CONFIG_FS_LOG_LEVEL_OFF=y - -# ── Console / logging ───────────────────────────────────────────────────────── -# Use SEGGER RTT for console — does not require COM3 (CDC UART), reads via SWD -CONFIG_UART_CONSOLE=n -CONFIG_USE_SEGGER_RTT=y -CONFIG_RTT_CONSOLE=y -CONFIG_LOG=y -CONFIG_LOG_BACKEND_RTT=y -CONFIG_LOG_DEFAULT_LEVEL=2 -# Immediate mode: log writes go directly to RTT backend without a separate thread. -# Deferred mode requires the log thread to run (lowest priority — never gets CPU -# in heavy setup()/loop() workloads), leaving the RTT buffer empty indefinitely. -CONFIG_LOG_MODE_IMMEDIATE=y -# Force RTT control block re-init on every boot — prevents stale/corrupted CB after crash -CONFIG_SEGGER_RTT_INIT_MODE_ALWAYS=y -# Use RTT channel 1 for the LOG backend, channel 0 (Terminal) for direct printk. -# Sharing channel 0 forces LOG_PRINTK=y (deferred) to avoid corruption. -CONFIG_LOG_BACKEND_RTT_BUFFER=1 -# Buffer sizes shrunk from 24576 → 4096 to free ~40 KB of BSS for newlib heap. -# At 24576 the BSS pushed _end up so far that newlib heap was only ~25 KB, -# and BUF_ACL_RX_SIZE=152 + BLE/PhoneAPI lazy init ran out of malloc space. -# 4 KB still gives several seconds of log retention before host attaches. -CONFIG_LOG_BACKEND_RTT_BUFFER_SIZE=4096 -# Overwrite oldest data if buffer fills — never stalls -CONFIG_LOG_BACKEND_RTT_MODE_BLOCK=n -CONFIG_LOG_BACKEND_RTT_MODE_OVERWRITE=y -CONFIG_LOG_BACKEND_RTT_OUTPUT_BUFFER_SIZE=256 - -# ── LFXO clock source — use RC oscillator to avoid ~2s crystal stabilization -# disrupting the GRTC timer and hanging k_sleep -CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y - -# ── Stack sizes — Meshtastic setup() is heavy (RadioLib, NodeDB, printf) ────── -# bt_enable() called from nrf54l15Setup() needs >8KB. -# Phase 7: CONFIG_BT_SETTINGS=y causes bt_set_name() → settings_save_one() → -# settings_file_save() → LittleFS I/O. The I/O chain needs ~3 KB of stack -# headroom beyond what the BT init alone requires. Increase main stack to 24KB -# and system workqueue to 8KB to cover both the cooperative-OSThread call path -# (which runs on the main thread) and any async flash work items. -CONFIG_MAIN_STACK_SIZE=24576 -CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=8192 -# Log processing thread stack — default 768 overflows when processing RTT fault dump -CONFIG_LOG_PROCESS_THREAD_STACK_SIZE=2048 - -# ── Fault/exception diagnostics — identify ~2000ms crash ───────────────────── -CONFIG_FAULT_DUMP=2 -CONFIG_EXCEPTION_DEBUG=y -CONFIG_STACK_SENTINEL=y -# Thread names + extra exception info — fault dumps then identify the failing -# thread (otherwise "Current thread: 0x... (unknown)") and include r4-r11 + psp -# so the custom k_sys_fatal_error_handler can walk the stack to show the caller -# chain. Cheap (~32 B/thread for names, no perf hit) and very useful when a -# crash recurs in the field. -CONFIG_THREAD_NAME=y -CONFIG_EXTRA_EXCEPTION_INFO=y -# SEGGER RTT buffer — keep modest (4 KB) to leave RAM for newlib heap. -CONFIG_SEGGER_RTT_BUFFER_SIZE_UP=4096 -# Report reset reason from previous crash -CONFIG_HWINFO=y - -# ── Bluetooth ───────────────────────────────────────────────────────────────── -# Zephyr BT host + LL SW controller (MPSL) — peripheral role only -CONFIG_BT=y -CONFIG_BT_PERIPHERAL=y -# SMP / LE Encryption enabled so the Meshtastic app pairs with a PIN before -# accessing the GATT service. -CONFIG_BT_SMP=y -# Enforce MITM so clients must complete passkey exchange — without this Just -# Works pairings complete silently without prompting the user for a PIN. -CONFIG_BT_SMP_ENFORCE_MITM=y -# Fixed-passkey path so the device (no display) can advertise a known PIN via -# bt_passkey_set() when config.bluetooth.mode == FIXED_PIN. -CONFIG_BT_FIXED_PASSKEY=y -# Allow legacy pairing as fallback. SC_PAIR_ONLY=y has been observed to cause -# some clients to abort pairing with reason 0x01 within 150 ms of the pairing -# request, before any PIN dialog appears. Accepting legacy lets the same -# clients fall through to Passkey Entry successfully. -CONFIG_BT_SMP_SC_PAIR_ONLY=n -# BT_LL_SW_SPLIT is auto-selected from DT (zephyr,bt-hci-ll-sw-split node in nRF54L15 DTS) -# Do NOT set CONFIG_BT_CTLR=y (deprecated — radio silently non-functional) -# Dynamic device name so bt_set_name() can embed the node short ID at runtime -CONFIG_BT_DEVICE_NAME_DYNAMIC=y -CONFIG_BT_DEVICE_NAME_MAX=32 -# Only need one simultaneous central connection -CONFIG_BT_MAX_CONN=1 -# BT subsystem logging — INF for connection/service diagnostics -CONFIG_BT_LOG_LEVEL_INF=y -# BT thread stacks — defaults are too small for nRF54L15 SW-LL init. -# prio_recv_thread overflows at 2048; bump all BT stacks to safe sizes. -# BT RX thread runs ALL our GATT write callbacks (rx_work_handler → hci_acl → -# bt_conn_recv → bt_l2cap_recv → bt_att_recv → write_toradio_cb → -# PhoneAPI::handleStartConfig → getFiles("/", 10) recursion + nanopb encode). -# 4096 overflows on "Client wants config" → abort() / kernel panic (reason 4). -CONFIG_BT_RX_STACK_SIZE=4096 -CONFIG_BT_HCI_TX_STACK_SIZE=1024 -# bt_long_wq runs bt_pub_key_gen (ECC P256 keygen) on this thread. -# Defaults (prio=10, stack=1400) leave it starved by Meshtastic app threads -# at boot: pub_key gen never completes, so smp_public_key() defers -# indefinitely waiting for sc_public_key, and every SC pairing attempt -# stalls right after exchanging public keys (no PIN prompt on iOS, every -# AUTHEN-gated char rejects with ATT error 0x05). -# Prio 0 = highest preemptible, ties with main; stack 4096 clears P256M -# driver frames with margin. -CONFIG_BT_LONG_WQ_PRIO=0 -CONFIG_BT_LONG_WQ_STACK_SIZE=4096 -# Use legacy advertising (bt_le_adv_start / HCI 0x2006 path). -# With CONFIG_BT_EXT_ADV=y, bt_le_adv_start() is internally translated to the -# extended HCI path with LEGACY-bit (0x2036), which produces non-connectable PDUs -# on the nRF54L15 SW-LL. With CONFIG_BT_EXT_ADV=n the host uses pure legacy HCI -# commands (0x2006/0x2008/0x200a) — the same path Nordic uses in all nRF54L15 -# NCS examples (peripheral_uart, peripheral_lbs), which is iOS-compatible and -# avoids the LE Remove Advertising Set (0x203c) controller timeout crash. -CONFIG_BT_EXT_ADV=n - -# ── Phase 7: BT bond persistence ────────────────────────────────────────────── -# CONFIG_BT_SETTINGS enables the BT host settings integration: the stack -# automatically calls settings_save_subtree("bt/keys") after pairing, and -# settings_load() on boot restores previously bonded peers. -# -# Backend: SETTINGS_FILE stores all key-value pairs in a single flat file in -# LittleFS. No new partition needed — the existing storage_partition (mounted -# at /lfs, size set by the board overlay) is used. File path: /lfs/bt_settings. -# -# Ordering guarantee: LittleFS is mounted by fsInit() BEFORE nrf54l15Setup() -# calls nrf54l15_bt_preinit(), so the file backend is always available when -# settings_load() is called after bt_enable(). -CONFIG_BT_SETTINGS=y -CONFIG_SETTINGS=y -CONFIG_SETTINGS_FILE=y -CONFIG_SETTINGS_FILE_PATH="/lfs/bt_settings" -# BT_MAX_PAIRED default is 1 — first bond (e.g. iOS) blocks every subsequent -# peer's SMP pairing request with "Unable to get keys" because there is no free -# bt_keys slot to allocate. Raise to 4 so the device can simultaneously hold -# iOS, Windows, Linux, and one spare bond. Add OVERWRITE_OLDEST so that when -# the table fills, the LRU peer is evicted instead of rejecting the new pair. -CONFIG_BT_MAX_PAIRED=4 -CONFIG_BT_KEYS_OVERWRITE_OLDEST=y -# Disable GATT database caching and Service Changed characteristic. -# CONFIG_BT_GATT_CACHING (default y with BT_SETTINGS) marks every new client as -# "not change-aware" and returns ATT_ERR_DB_OUT_OF_SYNC (0x12) on every GATT -# request until the client reads the DB-hash characteristic. The Meshtastic app -# does not implement GATT caching and silently aborts service discovery on 0x12, -# causing the connection to stall with zero GATT activity. -# CONFIG_BT_GATT_SERVICE_CHANGED (default y) adds the Generic Attribute Profile -# service; disabling it is required before BT_GATT_CACHING can be disabled. -CONFIG_BT_GATT_SERVICE_CHANGED=n -CONFIG_BT_GATT_CACHING=n -# Disable automatic PHY update (1M→2M) after connection. -# The nRF54L15 SW-LL fails the LL_PHY_REQ/RSP exchange and disconnects -# exactly 1.786s after connection — before any ATT/GATT operations. -CONFIG_BT_AUTO_PHY_UPDATE=n -# ATT/GATT/L2CAP debug logging — see exactly what happens after connection -CONFIG_BT_ATT_LOG_LEVEL_DBG=y -CONFIG_BT_GATT_LOG_LEVEL_DBG=y -CONFIG_BT_SMP_LOG_LEVEL_DBG=y -# L2CAP DBG: shows recv on fixed ATT channel — confirms whether iOS sends any data -CONFIG_BT_L2CAP_LOG_LEVEL_DBG=y -# Keep bt_conn at INF — DBG floods RTT buffer every ~150µs (tx_processor loop), -# overwriting all ATT/GATT messages before they can be read. -# Connection events (connected/disconnected) are logged at INF level. -CONFIG_BT_CONN_LOG_LEVEL_INF=y -# Keep HCI logs at INF to save RAM (log thread processing buffers, etc.). -# (Earlier DBG was used to diagnose the hci_acl → L2CAP stall — fix applied.) -CONFIG_BT_HCI_CORE_LOG_LEVEL_INF=y -CONFIG_BT_HCI_DRIVER_LOG_LEVEL_INF=y -# Fix: ACL packets reach hci_acl() but never reach bt_l2cap_recv(). -# Root cause: bt_conn_recv() calls bt_conn_tx_notify(conn, true) which submits -# tx_complete_work to k_sys_work_q and blocks on k_work_flush(). The BT rx -# workqueue (bt_workq) is stuck in k_work_flush waiting for the system -# workqueue, which is busy with LittleFS I/O / other work → dead stall until -# iOS supervision timeout fires (5s) and disconnects with reason 0x13. -# Solution: dedicate a separate workqueue for TX notify processing so it is -# independent from the system workqueue. -CONFIG_BT_CONN_TX_NOTIFY_WQ=y -# Dedicated workqueue only runs tx_notify_process() (iterates tx_complete list, -# calls short callbacks). Default 8192 is overkill and eats malloc heap needed -# by PowerFSM init → realloc() returns NULL → bus fault during FSM::add_transition. -CONFIG_BT_CONN_TX_NOTIFY_WQ_STACK_SIZE=2048 - -# ── ATT/L2CAP MTU — larger payloads for Meshtastic packets ─────────────────── -# TX side: controller sends up to L2CAP_TX_MTU bytes per ATT operation. -# RX side: server ATT MTU is min(BT_L2CAP_TX_MTU, BT_BUF_ACL_RX_SIZE - 4). -# Both set to 247 / 251 → ATT MTU = 247 in each direction, matching Zephyr's -# samples/bluetooth/mtu_update reference. This means typical iOS ToRadio -# writes (NodeInfo, channel settings, common admin packets) fit in a single -# ATT_WRITE_REQ and avoid the ATT Prepare/Execute Write path entirely. -# CONFIG_BT_ATT_PREPARE_COUNT=4 (above) still backstops oversized writes. -# -# Heap dependency: bumping BUF_ACL_RX_SIZE > default (~69) grows the BT host -# net_buf pools in BSS, which proportionally shrinks the newlib heap arena -# (MAX_HEAP_SIZE = SRAM_SIZE - (_end - SRAM_BASE), so any BSS growth steals -# from the heap directly). Empirically the lazy BLE init path -# (setBluetoothEnable → startDisabled → bt_set_name → settings_save → -# LittleFS) needs ~12 KB of newlib heap to run without bad_alloc. At -# BUF_ACL_RX_SIZE=251 with the previous 24 KB RTT buffers (LOG_BACKEND_RTT + -# SEGGER_RTT_BUFFER_SIZE_UP), the heap collapsed to ~4 KB free at -# transition time → `new char[]` in RedirectablePrint::log returned NULL → -# libstdc++ called abort() from main thread. Shrinking both RTT buffers to -# 4 KB (above) frees ~40 KB of BSS for the heap and resolves it. -# -# DLE stays off (BT_DATA_LEN_UPDATE=n below): the LLCP remote table at -# ull_llcp_remote.c:878 is guarded by #ifdef CONFIG_BT_CTLR_DATA_LENGTH, so -# the controller answers iOS's LL_LENGTH_REQ with LL_UNKNOWN_RSP and falls -# back to 27-byte LL PDUs. The host reassembles LL PDUs into L2CAP frames -# up to BT_BUF_ACL_RX_SIZE before dispatching to ATT. -CONFIG_BT_L2CAP_TX_MTU=247 -# Server ATT MTU = BUF_ACL_RX_SIZE - 4 = 247 (matches L2CAP_TX_MTU) -CONFIG_BT_BUF_ACL_RX_SIZE=251 - -# ── Fix: LL Feature Exchange collision (ROOT CAUSE of iOS GATT hang) ───────── -# On connection, Zephyr host calls bt_hci_le_read_remote_features() because -# BT_CTLR_PER_INIT_FEAT_XCHG=y makes can_initiate_feature_exchange() return -# true for peripheral role. This makes the controller send LL_PER_INIT_FEAT_XCHG -# to iOS right after connecting. -# iOS (as central) simultaneously sends LL_FEATURE_REQ to the peripheral. -# The nRF54L15 SW-LL mishandles this COLLISION: iOS waits for LL_FEATURE_RSP -# to its LL_FEATURE_REQ, never gets it, and stalls — sending zero L2CAP bytes. -# BT_CTLR_PER_INIT_FEAT_XCHG=n: host does NOT send HCI_LE_Read_Remote_Features -# as peripheral → no LL_PER_INIT_FEAT_XCHG sent → no collision → iOS feature -# exchange completes → iOS proceeds to L2CAP/ATT. -CONFIG_BT_CTLR_PER_INIT_FEAT_XCHG=n -# Fix: LL Connection Parameter Request handling. -# BT_CTLR_CONN_PARAM_REQ=n was ineffective: the LLCP remote decode table in -# ull_llcp_remote.c hardcodes PDU_DATA_LLCTRL_TYPE_CONN_PARAM_REQ → PROC_CONN_PARAM_REQ -# regardless of Kconfig. With =n the handler is compiled out → controller asserts / -# enters broken state when iOS sends LL_CONN_PARAM_REQ (which is optional from Central). -# Fix: =y so the procedure is actually handled. To avoid host/peripheral vs Central -# collision at 5 s (deferred_work → send_conn_le_param_update), disable auto-update -# below so the host never initiates HCI_LE_Connection_Update. -CONFIG_BT_CTLR_CONN_PARAM_REQ=y -# Prevent Zephyr host from initiating connection parameter update 5 s after connect. -# With CONN_PARAM_REQ=y, if iOS (Central) already issued LL_CONN_PARAM_REQ and the -# SW-LL is mid-procedure, a simultaneous host-initiated HCI_LE_Connection_Update -# creates an LL collision. Disabling the auto-update avoids the collision entirely. -CONFIG_BT_GAP_AUTO_UPDATE_CONN_PARAMS=n -# Disable optional LL procedures (belt-and-suspenders while debugging): -# BT_PHY_UPDATE=n + BT_CTLR_PHY_2M=n: no PHY update procedure → iOS doesn't attempt LL_PHY_REQ -# BT_DATA_LEN_UPDATE=n: no DLE → controller sends LL_UNKNOWN_RSP to LL_LENGTH_REQ -CONFIG_BT_CTLR_PHY_2M=n -CONFIG_BT_PHY_UPDATE=n -CONFIG_BT_DATA_LEN_UPDATE=n From 2fe711115ee584cb3bcb564c29e9e85b254fc762 Mon Sep 17 00:00:00 2001 From: "Ethac.chen" Date: Tue, 15 Sep 2026 08:15:33 +0000 Subject: [PATCH 140/143] feat(nrf52): add RAK3401 + LR2021 (RAK13700) variant (#11819) * feat(nrf52): add RAK3401 + LR2021 (RAK13700) variant WisBlock core with IO-slot LR2021: DIO RF switch, board LF PA table, and 1.6 V TCXO. Keep extra/unsupported until it has its own hw_model. * fix(lr2021): log custom PA setOutputPower in fullBegin Match init(): a calibration miss stays a warning so band-hop keeps the begin() PA config. * refactor(lr2021): share custom LF PA table helper init() and fullBegin() both re-install the board table after begin(); keep the warn-only setOutputPower miss. --- src/mesh/LR20x0Interface.cpp | 27 +++ src/mesh/LR20x0Interface.h | 3 + variants/nrf52840/rak3401_lr2021/pa_table.h | 43 ++++ .../nrf52840/rak3401_lr2021/platformio.ini | 37 ++++ variants/nrf52840/rak3401_lr2021/rfswitch.h | 22 ++ variants/nrf52840/rak3401_lr2021/variant.cpp | 39 ++++ variants/nrf52840/rak3401_lr2021/variant.h | 207 ++++++++++++++++++ 7 files changed, 378 insertions(+) create mode 100644 variants/nrf52840/rak3401_lr2021/pa_table.h create mode 100644 variants/nrf52840/rak3401_lr2021/platformio.ini create mode 100644 variants/nrf52840/rak3401_lr2021/rfswitch.h create mode 100644 variants/nrf52840/rak3401_lr2021/variant.cpp create mode 100644 variants/nrf52840/rak3401_lr2021/variant.h diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index e0d797ccfd..c3a4c12059 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -31,6 +31,10 @@ static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { }; #endif +#ifdef LR2021_CUSTOM_PA_TABLE +#include "pa_table.h" +#endif + // Particular boards might define a different max power based on what their hardware can do, default to max power output if not // specified (may be dangerous if using external PA and LR20x0 power config forgotten) #if ARCH_PORTDUINO @@ -170,6 +174,8 @@ template bool LR20x0Interface::init() if (res == RADIOLIB_ERR_NONE) applyDcdcWorkaround(); + applyCustomLfPaTable(getFreq()); + LOG_INFO("Frequency set to %f", getFreq()); LOG_INFO("Bandwidth set to %f", bw); LOG_INFO("Power output set to %d", power); @@ -379,6 +385,9 @@ template bool LR20x0Interface::fullBegin(float freq) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); return false; } + + applyCustomLfPaTable(freq); + lr20x0LastFreqMHz = freq; res = lora.setCRC(2); @@ -409,6 +418,24 @@ template bool LR20x0Interface::fullBegin(float freq) } } +// Board LF PA table after begin(); pointer is retained. HF keeps the RadioLib default. +// Warn-only: a calibration miss must not fail init/fullBegin, keep the begin() PA config. +template void LR20x0Interface::applyCustomLfPaTable(float freq) +{ +#ifdef LR2021_CUSTOM_PA_TABLE + if (isLr20x0HighBand(freq)) + return; + lora.setPaTable(lr2021_pa_table_lf, false); + int16_t paRes = lora.setOutputPower(power); + if (paRes != RADIOLIB_ERR_NONE) + LOG_WARN("LR2021 custom LF PA table setOutputPower failed (%s%d)", radioLibErr, paRes); + else + LOG_DEBUG("LR2021 custom LF PA table installed"); +#else + (void)freq; +#endif +} + // Semtech DCDC sensitivity workaround for sub-GHz operation on engineering sample date code 2513. // Worthy of note is that we tested this on non-engineering samples and it didn't make any difference, but // we went to the trouble of writing this, so it can stay in, albeit gated behind a compile-time option. diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index 4ebda649af..45399db368 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -87,6 +87,9 @@ template class LR20x0Interface : public RadioLibInterface /** Chip-side re-init shared by the band-hop and recovery paths: front-end GPIOs, begin(), CRC, RF switch, RX gain */ bool fullBegin(float freq); + /** Board LF PA table after begin(); HF keeps RadioLib default. Warn-only on setOutputPower miss. */ + void applyCustomLfPaTable(float freq); + /** setStandby()'s body, returning the standby error instead of asserting - for callers that can recover */ int16_t trySetStandby(); diff --git a/variants/nrf52840/rak3401_lr2021/pa_table.h b/variants/nrf52840/rak3401_lr2021/pa_table.h new file mode 100644 index 0000000000..fd345ec3bd --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/pa_table.h @@ -0,0 +1,43 @@ +#pragma once +#include "RadioLib.h" + +// RAK13700 LF PA - mid fine-tune: 8 high (+0.7), 10 low (-0.5); rest within ~0.6 dB. +// Index = power_dBm + 9. paVal in 0.5 dB. +// HF (2.4 GHz) uses RadioLib default PA table (LORA_24 region caps at 10 dBm). + +static LR2021PaTableEntry_t lr2021_pa_table_lf[RADIOLIB_LR2021_PA_TABLE_LEN] = { + // clang-format off + { .paDutyCycle = 1, .paSlices = 1, .paVal = 8 }, // -9 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 1 }, // -8 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 3 }, // -7 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 5 }, // -6 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 13 }, // -5 + { .paDutyCycle = 2, .paSlices = 1, .paVal = 13 }, // -4 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 11 }, // -3 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 13 }, // -2 + { .paDutyCycle = 3, .paSlices = 1, .paVal = 12 }, // -1 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 18 }, // 0 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 18 }, // 1 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 16 }, // 2 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 20 }, // 3 + { .paDutyCycle = 1, .paSlices = 1, .paVal = 22 }, // 4 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 22 }, // 5 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 24 }, // 6 + { .paDutyCycle = 1, .paSlices = 3, .paVal = 27 }, // 7 was 28 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 30 }, // 8 was 32; meas 8.7 want ~8 + { .paDutyCycle = 1, .paSlices = 2, .paVal = 32 }, // 9 was 33 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 33 }, // 10 was 35; meas 11 want ~10 + { .paDutyCycle = 2, .paSlices = 2, .paVal = 35 }, // 11 + { .paDutyCycle = 2, .paSlices = 3, .paVal = 35 }, // 12 + { .paDutyCycle = 2, .paSlices = 5, .paVal = 37 }, // 13 + { .paDutyCycle = 3, .paSlices = 2, .paVal = 38 }, // 14 + { .paDutyCycle = 3, .paSlices = 3, .paVal = 39 }, // 15 + { .paDutyCycle = 3, .paSlices = 6, .paVal = 38 }, // 16 + { .paDutyCycle = 4, .paSlices = 4, .paVal = 40 }, // 17 + { .paDutyCycle = 4, .paSlices = 5, .paVal = 41 }, // 18 + { .paDutyCycle = 4, .paSlices = 7, .paVal = 43 }, // 19 + { .paDutyCycle = 5, .paSlices = 4, .paVal = 44 }, // 20 + { .paDutyCycle = 5, .paSlices = 6, .paVal = 44 }, // 21 + { .paDutyCycle = 6, .paSlices = 7, .paVal = 44 }, // 22 + // clang-format on +}; diff --git a/variants/nrf52840/rak3401_lr2021/platformio.ini b/variants/nrf52840/rak3401_lr2021/platformio.ini new file mode 100644 index 0000000000..be70ebb9af --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/platformio.ini @@ -0,0 +1,37 @@ +; RAK3401 + RAK13700 (LR2021) - same WisBlock core as rak3401-1watt; LoRa is LR2021 +[env:rak3401-lr2021] +custom_meshtastic_hw_model = 117 +custom_meshtastic_hw_model_slug = RAK3401 +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = false +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = RAK3401 LR2021 +custom_meshtastic_images = rak3401.svg +custom_meshtastic_tags = RAK +custom_meshtastic_requires_dfu = true + +extends = nrf52840_base +board = wiscore_rak4631 +board_level = extra +board_check = true +build_flags = ${nrf52840_base.build_flags} + -Ivariants/nrf52840/rak3401_lr2021 + -D RAK_4631 + -D RAK3401 + -D PRIVATE_HW + -DRADIOLIB_EXCLUDE_SX126X=1 + -DRADIOLIB_EXCLUDE_SX128X=1 + -DRADIOLIB_EXCLUDE_SX127X=1 + -DRADIOLIB_EXCLUDE_LR11X0=1 +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak3401_lr2021> + +lib_deps = + ${nrf52840_base.lib_deps} + ${networking_base.lib_deps} + # 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 + rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 + # renovate: datasource=custom.pio depName=RAK12035_SoilMoisture packageName=beegee-tokyo/library/RAK12035_SoilMoisture + beegee-tokyo/RAK12035_SoilMoisture@1.0.4 + # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main + https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip diff --git a/variants/nrf52840/rak3401_lr2021/rfswitch.h b/variants/nrf52840/rak3401_lr2021/rfswitch.h new file mode 100644 index 0000000000..a8bf36bc6b --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/rfswitch.h @@ -0,0 +1,22 @@ +#pragma once +#include "RadioLib.h" + +#ifndef LR20x0 +#define LR20x0 LR2021 +#endif + +// RAK13700: DIO7 = LF TX/RX (HIGH=RX, LOW=TX); DIO9 = HF TX/RX (HIGH=TX, LOW=RX) +static const uint32_t lr20x0_rfswitch_dio_pins[] = {RADIOLIB_LR2021_DIO7, RADIOLIB_LR2021_DIO9, RADIOLIB_NC, RADIOLIB_NC, + RADIOLIB_NC}; + +static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { + // clang-format off + // mode DIO7 DIO9 + {LR20x0::MODE_STBY, {LOW, LOW}}, + {LR20x0::MODE_RX, {HIGH, LOW}}, // LF RX + {LR20x0::MODE_TX, {LOW, LOW}}, // LF TX + {LR20x0::MODE_RX_HF, {LOW, LOW}}, // HF RX + {LR20x0::MODE_TX_HF, {LOW, HIGH}}, // HF TX + END_OF_MODE_TABLE, + // clang-format on +}; diff --git a/variants/nrf52840/rak3401_lr2021/variant.cpp b/variants/nrf52840/rak3401_lr2021/variant.cpp new file mode 100644 index 0000000000..414f52faf9 --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/variant.cpp @@ -0,0 +1,39 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + // P0 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; + +void initVariant() +{ + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); + + // WisBlock 3V3_S - required for IO-slot RAK13700 + pinMode(PIN_3V3_EN, OUTPUT); + digitalWrite(PIN_3V3_EN, HIGH); +} diff --git a/variants/nrf52840/rak3401_lr2021/variant.h b/variants/nrf52840/rak3401_lr2021/variant.h new file mode 100644 index 0000000000..c0cacf3ae6 --- /dev/null +++ b/variants/nrf52840/rak3401_lr2021/variant.h @@ -0,0 +1,207 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/* + RAK3401 (nRF52840 WisBlock Core) + RAK13700 (LR2021). + Core / GPS / I2C / battery / eink pin map aligned with rak3401_1watt; + radio macros are LR2021 instead of SX1262 (RAK13302). +*/ + +#ifndef _VARIANT_RAK3401_LR2021_ +#define _VARIANT_RAK3401_LR2021_ + +#define RAK4630 + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (6) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED1 (35) +#define LED_BLUE (36) +#define LED_GREEN PIN_LED1 +#define LED_NOTIFICATION LED_BLUE +#define LED_STATE_ON 1 + +/* + * Analog pins + */ +#define PIN_A0 (5) +#define PIN_A1 (31) +#define PIN_A2 (28) +#define PIN_A3 (29) +#define PIN_A4 (30) +#define PIN_A5 (31) +#define PIN_A6 (0xff) +#define PIN_A7 (0xff) + +static const uint8_t A0 = PIN_A0; +static const uint8_t A1 = PIN_A1; +static const uint8_t A2 = PIN_A2; +static const uint8_t A3 = PIN_A3; +static const uint8_t A4 = PIN_A4; +static const uint8_t A5 = PIN_A5; +static const uint8_t A6 = PIN_A6; +static const uint8_t A7 = PIN_A7; +#define ADC_RESOLUTION 14 + +// Other pins (same as rak3401_1watt) +#define WB_I2C1_SDA (13) // SENSOR_SLOT IO_SLOT +#define WB_I2C1_SCL (14) // SENSOR_SLOT IO_SLOT + +#define PIN_AREF (2) +#define PIN_NFC1 (9) +#define WB_IO5 PIN_NFC1 +#define WB_IO4 (4) +#define PIN_NFC2 (10) + +static const uint8_t AREF = PIN_AREF; + +/* + * Serial interfaces + */ +#define PIN_SERIAL1_RX (15) +#define PIN_SERIAL1_TX (16) + +// Connected to Jlink CDC +#define PIN_SERIAL2_RX (8) +#define PIN_SERIAL2_TX (6) + +/* + * SPI Interfaces + */ +#define SPI_INTERFACES_COUNT 2 + +#define PIN_SPI_MISO (45) +#define PIN_SPI_MOSI (44) +#define PIN_SPI_SCK (43) + +#define PIN_SPI1_MISO (29) +#define PIN_SPI1_MOSI (30) +#define PIN_SPI1_SCK (3) + +static const uint8_t SS = 42; +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +/* + * eink display pins - same mapping as rak3401_1watt. + * Note: CS/BUSY/SCLK/MOSI overlap IO-slot LoRa SPI pins; only safe when an + * e-ink module is present and firmware uses CS gating (same as 1watt). + */ +#define PIN_EINK_CS (0 + 26) +#define PIN_EINK_BUSY (0 + 4) +#define PIN_EINK_DC (0 + 17) +#define PIN_EINK_RES (-1) +#define PIN_EINK_SCLK (0 + 3) +#define PIN_EINK_MOSI (0 + 30) + +/* + * Wire Interfaces + */ +#define WIRE_INTERFACES_COUNT 1 +#define PIN_WIRE_SDA (WB_I2C1_SDA) +#define PIN_WIRE_SCL (WB_I2C1_SCL) + +// QSPI Pins / on-board flash +#define PIN_QSPI_SCK 3 +#define PIN_QSPI_CS 26 +#define PIN_QSPI_IO0 30 +#define PIN_QSPI_IO1 29 +#define PIN_QSPI_IO2 28 +#define PIN_QSPI_IO3 2 +#define EXTERNAL_FLASH_DEVICES IS25LP080D +#define EXTERNAL_FLASH_USE_QSPI + +/* + * RAK13700 (LR2021) on WisBlock IO slot + * GPIO numbers match RAK13302 on the same slot (rak3401_1watt). + */ +#define HW_SPI1_DEVICE 1 + +#define LORA_SCK PIN_SPI1_SCK +#define LORA_MISO PIN_SPI1_MISO +#define LORA_MOSI PIN_SPI1_MOSI +#define LORA_CS 26 +#define LORA_RESET 4 +#define LORA_DIO1 10 // IRQ (module DIO8) +#define LORA_DIO2 9 // BUSY + +#define USE_LR2021 +#define LR2021_IRQ_PIN LORA_DIO1 +#define LR2021_NRESET_PIN LORA_RESET +#define LR2021_BUSY_PIN LORA_DIO2 +#define LR2021_SPI_NSS_PIN LORA_CS +#define LR2021_SPI_SCK_PIN LORA_SCK +#define LR2021_SPI_MOSI_PIN LORA_MOSI +#define LR2021_SPI_MISO_PIN LORA_MISO +#define LR2021_IRQ_DIO_NUM 8 + +// Stable default on current samples; see docs/lr2021_tcxo_calibrate_707.md. +#define LR2021_DIO3_TCXO_VOLTAGE 1.6f +#define LR2021_DIO_AS_RF_SWITCH +#define LR2021_CUSTOM_PA_TABLE // board LF PA table - see pa_table.h + +// UNCERTAIN: same as RAK13302 SX126X_POWER_EN (WB IO3) +#define LR2021_POWER_EN (21) +#define LR2021_MAX_POWER 22 +#define LR2021_MAX_POWER_HF 12 + +#define NRF_APM + +// enables 3.3V periphery like GPS or IO Module +#define PIN_3V3_EN (34) +#define WB_IO2 PIN_3V3_EN + +// RAK1910 GPS on Port A (UART1); power stays on 3V3_S (WB_IO2) - do not use as GPS reset +#define PIN_GPS_PPS (17) +#define GPS_RX_PIN PIN_SERIAL1_RX +#define GPS_TX_PIN PIN_SERIAL1_TX + +// RAK12002 RTC Module +#define RV3028_RTC (uint8_t)0b1010010 + +// Battery +#define BATTERY_PIN PIN_A0 +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER 1.73 + +#define RAK_4631 1 + +#ifdef __cplusplus +} +#endif + +#endif From e0cf782131bb716e7aedc4d227a61f2768eec9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 15 Sep 2026 13:00:34 +0200 Subject: [PATCH 141/143] Build xiao_nrf54l15_lr2021 on every PR as the nrf54l15 canary and mark it community supported (#11856) --- variants/nrf54l15/xiao_nrf54l15/platformio.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/variants/nrf54l15/xiao_nrf54l15/platformio.ini b/variants/nrf54l15/xiao_nrf54l15/platformio.ini index 4397d23e5c..c47b865f88 100644 --- a/variants/nrf54l15/xiao_nrf54l15/platformio.ini +++ b/variants/nrf54l15/xiao_nrf54l15/platformio.ini @@ -25,10 +25,11 @@ build_src_filter = ${nrf54l15_base.build_src_filter} custom_meshtastic_hw_model = 255 custom_meshtastic_hw_model_slug = XIAO_NRF54L15_LR2021 custom_meshtastic_architecture = nrf54l15 -custom_meshtastic_actively_supported = false -custom_meshtastic_support_level = 3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 2 custom_meshtastic_display_name = Seeed XIAO nRF54L15 LoRa Plus extends = env:xiao_nrf54l15 +board_level = pr build_flags = ${env:xiao_nrf54l15.build_flags} -DXIAO_NRF54L15_LR2021 From bc2528b005290c5293f4baabb84591d8e97a44e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 15 Sep 2026 11:16:11 +0000 Subject: [PATCH 142/143] Show the full Bluetooth pairing PIN on tiny OLED panels by drawing it full-screen with all lines spread evenly. (#11855) --- src/graphics/draw/NotificationRenderer.cpp | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 7abfd210da..28de1a4b74 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -802,7 +802,10 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp uint16_t screenHeight = display->height(); uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; - uint8_t visibleTotalLines = std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight); + // Pairing PIN: pass every line, drawNotificationBox fits them (tiny panels spread them over the full screen). + uint8_t visibleTotalLines = (current_notification_type == notificationTypeEnum::pairing_pin) + ? totalLines + : std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight); uint8_t linesShown = lineCount; const char *linePointers[visibleTotalLines + 1] = {0}; // this is sort of a dynamic allocation @@ -940,10 +943,16 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; uint8_t visibleTotalLines = 0; uint16_t contentHeight = 0; +#if defined(OLED_TINY) + // Tiny panels: the pairing PIN takes the whole screen, all lines shown and spread evenly over it. + const bool fullScreenPin = (current_notification_type == notificationTypeEnum::pairing_pin); +#else + const bool fullScreenPin = false; +#endif const uint16_t availableHeight = (screenHeight > (vPadding * 2)) ? (screenHeight - vPadding * 2) : 0; for (uint8_t i = 0; i < lineCount; i++) { uint8_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight; - if (contentHeight + thisLineHeight > availableHeight) { + if (!fullScreenPin && contentHeight + thisLineHeight > availableHeight) { break; } contentHeight += thisLineHeight; @@ -964,7 +973,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay } int16_t boxTop = (display->height() / 2) - (boxHeight / 2); boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1; - if (graphics::isCompactPanel(display)) { + if (fullScreenPin || graphics::isCompactPanel(display)) { boxLeft = 0; boxTop = 0; boxWidth = display->width(); @@ -1008,6 +1017,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay for (int i = 0; i < visibleTotalLines; i++) { display->setFont(fontForBannerLine(lineFonts[i])); int16_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight; + if (fullScreenPin) { + // Equal slots over the full height (10 rows each on a 32px panel, glyphs sit in rows 3..9). + thisLineHeight = boxHeight / visibleTotalLines; + lineY = i * thisLineHeight; + } int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2; if (needs_bell && i == 0) { int fontHeight = thisLineHeight + 3; From 3468af94aa0f79e93c9bf041244bf230161fc704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 15 Sep 2026 12:06:19 +0000 Subject: [PATCH 143/143] fix(hopscale): gate hop scaling on measured channel congestion (#11826) * fix(hopscale): gate hop scaling on measured channel congestion * fix(hopscale): rename test tripping trufflehog and release congestion at the threshold * test(hopscale): pin the unscaled precondition in the busy-channel gate test * fix(hopscale): floor infrastructure roles and engage below the polite gate * test(hopscale): name the symbol under test and reset the gate in clear() * fix(hopscale): drop the unneeded congestion reset in clear() * refactor(hopscale): drop the unused utilization accessor and duplicated log fields * refactor(hopscale): drive the politeness extension from measured congestion (#11831) * refactor(hopscale): drive the politeness extension from measured congestion * refactor(airtime): own the smoothed channel utilization (#11832) * refactor(airtime): own the smoothed channel utilization * refactor(airtime): cut comments to the two-line limit * fix(airtime): fold the smoothed utilization once per crossed bucket * fix(hopscale): compare the congestion thresholds at whole-percent resolution --- src/airtime.cpp | 66 +++++- src/airtime.h | 22 +- src/mesh/Default.h | 11 + src/modules/HopScalingModule.cpp | 112 +++++++--- src/modules/HopScalingModule.h | 44 +++- test/test_airtime/test_main.cpp | 114 ++++++++++ test/test_hop_scaling/test_main.cpp | 334 ++++++++++++++++++++++++++++ 7 files changed, 647 insertions(+), 56 deletions(-) diff --git a/src/airtime.cpp b/src/airtime.cpp index aaacefb092..154014cf8e 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -2,7 +2,9 @@ #include "NodeDB.h" #include "UptimeClock.h" #include "configuration.h" +#include #include +#include #include AirTime *airTime = NULL; @@ -60,7 +62,7 @@ uint8_t AirTime::Windows::getPeriodUtilHour(const Held &) return (secSinceBoot / 60) % MINUTES_IN_HOUR; } -void AirTime::Windows::syncNow(const Held &) +void AirTime::Windows::syncNow(const Held &held) { // Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move // airtime accounting. Pure read; the main loop publishes the wrap carry it derives from. @@ -112,13 +114,16 @@ void AirTime::Windows::syncNow(const Held &) // Channel utilization is a rolling 60-second view split into six 10-second buckets. // Clear every bucket crossed while asleep so old airtime decays by real elapsed time. uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10); - if (elapsedUtilPeriods >= CHANNEL_UTILIZATION_PERIODS) { - memset(this->channelUtilization, 0, sizeof(this->channelUtilization)); - } else { - for (uint32_t i = 1; i <= elapsedUtilPeriods; i++) { - this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; - } + // Fold one reading per crossed bucket, each before that bucket is cleared, so one delayed sync + // lands where the same number of 10 s syncs would have. Bounded: six clears empty the window. + const uint32_t steppedUtilPeriods = std::min(elapsedUtilPeriods, CHANNEL_UTILIZATION_PERIODS); + for (uint32_t i = 1; i <= steppedUtilPeriods; i++) { + foldChannelUtil(channelUtilizationPercentRaw(held), 1, held); + this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; } + // Anything past a full window is elapsed time against an already-empty ring, so it folds as + // idle in closed form rather than looping over a sleep that may have lasted days. + foldChannelUtil(0.0f, elapsedUtilPeriods - steppedUtilPeriods, held); // TX utilization is a rolling 60-minute view used by duty-cycle checks. uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60); @@ -154,11 +159,8 @@ bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size return true; } -float AirTime::Windows::channelUtilizationPercent(const Held &held) +float AirTime::Windows::channelUtilizationPercentRaw(const Held &) { - // Gate decisions should see buckets that have decayed across light-sleep time. - syncNow(held); - uint32_t sum = 0; for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) { sum += this->channelUtilization[i]; @@ -167,6 +169,42 @@ float AirTime::Windows::channelUtilizationPercent(const Held &held) return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100; } +float AirTime::Windows::channelUtilizationPercent(const Held &held) +{ + // Gate decisions should see buckets that have decayed across light-sleep time. + syncNow(held); + + return channelUtilizationPercentRaw(held); +} + +void AirTime::Windows::foldChannelUtil(float sample, uint32_t steps, const Held &) +{ + if (steps == 0) + return; + + if (!hasChannelUtilSample) { + // Seed from the first reading, or a node booting onto a busy channel reports it quiet + // for a whole time constant. + channelUtilAvg = sample; + hasChannelUtilSample = true; + steps--; + } + + if (steps > 0) { + const float retained = powf(1.0f - 1.0f / float(CHANNEL_UTILIZATION_EMA_DIVISOR), float(steps)); + channelUtilAvg = sample + (channelUtilAvg - sample) * retained; + } +} + +float AirTime::Windows::smoothedChannelUtilizationPercent(const Held &held) +{ + syncNow(held); + + // Nothing folded yet before the first bucket crossing, and 0 would read as an idle channel + // rather than as no data. + return hasChannelUtilSample ? channelUtilAvg : channelUtilizationPercentRaw(held); +} + float AirTime::Windows::utilizationTXPercent(const Held &held) { // Duty-cycle checks use this value, so keep it current even outside the periodic thread. @@ -242,6 +280,12 @@ float AirTime::channelUtilizationPercent() return w.channelUtilizationPercent(held); } +float AirTime::smoothedChannelUtilizationPercent() +{ + Held held(this); + return w.smoothedChannelUtilizationPercent(held); +} + float AirTime::utilizationTXPercent() { Held held(this); diff --git a/src/airtime.h b/src/airtime.h index b1e1172a76..e789aa62b5 100644 --- a/src/airtime.h +++ b/src/airtime.h @@ -34,6 +34,8 @@ OUTPUTS: channelUtilizationPercent() % of the last 60s busy, all three types + smoothedChannelUtilizationPercent() + the same, behind a ~21 min EMA folded per bucket utilizationTXPercent() % of the last hour we transmitted isTxAllowedChannelUtil() gate on the former, 40% or 25% "polite" isTxAllowedAirUtil() gate on the latter, at HALF the duty cycle @@ -77,6 +79,9 @@ */ #define CHANNEL_UTILIZATION_PERIODS 6 +// EMA weight per crossed 10 s bucket: 1/128 is a time constant of about 21 minutes, so one busy +// or quiet minute cannot move the smoothed figure far. +#define CHANNEL_UTILIZATION_EMA_DIVISOR 128 #define SECONDS_PER_PERIOD 3600 #define PERIODS_TO_LOG 8 #define MINUTES_IN_HOUR 60 @@ -130,6 +135,9 @@ class AirTime : private concurrency::OSThread void logAirtime(reportTypes reportType, uint32_t airtime_ms); float channelUtilizationPercent(); + /// channelUtilizationPercent() behind an EMA advanced by elapsed time, for a caller that must + /// judge load from a trend rather than from one 60-second window. + float smoothedChannelUtilizationPercent(); float utilizationTXPercent(); /// Compatibility shim: no caller in the tree, kept for out-of-tree ones. @@ -182,7 +190,12 @@ class AirTime : private concurrency::OSThread // Modular rings: index is absolute phase, (uptime secs / period) % N, never age. uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; // 6 x 10s - uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only + + // EMA over channelUtilization, folded in syncNow() once per crossed 10 s bucket so its + // time constant follows elapsed time rather than how often a caller happens to ask. + float channelUtilAvg = 0.0f; + bool hasChannelUtilSample = false; + uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only // Hour crossings rotated but not yet traced. The core cannot log its own rotations: it // only ever runs under the lock, and DEBUG_PORT.log() blocks on a UART write. runOnce() @@ -198,6 +211,13 @@ class AirTime : private concurrency::OSThread void logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &); float channelUtilizationPercent(const Held &); + /// The bucket sum alone. syncNow() folds the EMA and cannot reach it through + /// channelUtilizationPercent(), which would re-enter syncNow(). + float channelUtilizationPercentRaw(const Held &); + float smoothedChannelUtilizationPercent(const Held &); + /// Fold `steps` readings of `sample` into channelUtilAvg. Closed form, not a loop, so a + /// multi-day sleep decays by the time elapsed at the cost of one powf. + void foldChannelUtil(float sample, uint32_t steps, const Held &); float utilizationTXPercent(const Held &); bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &); uint8_t getSilentMinutes(float txPercent, float dutyCycle, const Held &); diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 4f6c000f84..b6d93eb218 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -58,6 +58,17 @@ enum class TrafficType { POSITION, TELEMETRY }; #define default_hop_scaling_min_target_nodes_floor 5 // minimum allowed min_target_nodes #define default_hop_scaling_max_target_nodes_ceiling 512 // maximum allowed max_target_nodes +// Congestion gate: hop scaling only applies while the channel is measurably busy. Engage sits below +// AirTime::polite_channel_util_percent (25) so hops ease back before the polite gate withholds traffic. +#define default_hop_scaling_congestion_engage_pct 20 // smoothed channel utilization that engages scaling +#define default_hop_scaling_congestion_release_pct 12 // smoothed channel utilization that releases it +#define default_hop_scaling_congestion_confirm_runs 3 // consecutive 5-min samples needed to flip either way +// Above this the hop walk's one-hop extension is cut to its strictest setting: the radio is +// already withholding metadata at the polite gate, so an extra relay is the wrong thing to spend. +#define default_hop_scaling_congestion_strict_pct 25 // = AirTime::polite_channel_util_percent +// Hop floor for the infrastructure roles Router.cpp already groups for zero-cost hops. +#define default_hop_scaling_infrastructure_hop_floor 3 + #ifdef USERPREFS_RINGTONE_NAG_SECS #define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS #else diff --git a/src/modules/HopScalingModule.cpp b/src/modules/HopScalingModule.cpp index 758715f971..18d143e447 100644 --- a/src/modules/HopScalingModule.cpp +++ b/src/modules/HopScalingModule.cpp @@ -7,6 +7,7 @@ #include "FSCommon.h" #include "NodeDB.h" #include "SPILock.h" +#include "airtime.h" #include "concurrency/LockGuard.h" #include "mesh-pb-constants.h" #include @@ -245,23 +246,15 @@ void HopScalingModule::rollHour() } lastPerHopCounts = counts; - // 1b. Compute politeness factor from the 0-2 h vs 1-3 h activity ratio. - { - const uint32_t recent = static_cast(hourlyRaw[0]) + hourlyRaw[1]; - const uint32_t older = static_cast(hourlyRaw[1]) + hourlyRaw[2]; - if (older > 1 && recent > 1) { - const uint32_t r = static_cast(recent) * ACTIVITY_WEIGHT_SCALE; - const uint32_t o = static_cast(older); - if (r < o * ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER) - lastPoliteNumer = POLITENESS_GENEROUS; - else if (r > o * ACTIVITY_WEIGHT_STRICT_MIN_NUMER) - lastPoliteNumer = POLITENESS_STRICT; - else - lastPoliteNumer = POLITENESS_DEFAULT; - } else { - lastPoliteNumer = POLITENESS_DEFAULT; - } - } + // 1b. Pick the politeness factor from measured channel utilization. How far the walk may + // stretch and whether it is applied at all now read the same signal, so a node cannot be + // told the mesh is filling up by node counts while the channel says it is idle. + if (smoothedUtilPct() >= CONGESTION_STRICT_PCT) + lastPoliteNumer = POLITENESS_STRICT; + else if (smoothedUtilPct() >= CONGESTION_ENGAGE_PCT) + lastPoliteNumer = POLITENESS_DEFAULT; + else + lastPoliteNumer = POLITENESS_GENEROUS; // 1c. Scale and cache trend stats (denominatorHistory already advanced above). { @@ -424,6 +417,34 @@ void HopScalingModule::trimIfNeeded() } } +float HopScalingModule::channelUtil() +{ +#ifdef PIO_UNIT_TESTING + return s_testChannelUtil; +#else + return airTime ? airTime->smoothedChannelUtilizationPercent() : 0.0f; +#endif +} + +void HopScalingModule::updateCongestion() +{ + // AirTime folds its own EMA once per 10 s bucket, so this reads a figure that already covers + // the whole interval between ticks rather than only the 60 s before each one. + utilizationAvg = channelUtil(); + + // Separate engage/release thresholds, each confirmed over several ticks, so a mesh sitting + // near a threshold does not flap the hop limit between rolls. + const uint8_t util = smoothedUtilPct(); + const bool wantsFlip = congested ? (util <= CONGESTION_RELEASE_PCT) : (util >= CONGESTION_ENGAGE_PCT); + congestionConfirmRuns = wantsFlip ? static_cast(congestionConfirmRuns + 1u) : 0u; + if (congestionConfirmRuns >= CONGESTION_CONFIRM_RUNS) { + congested = !congested; + congestionConfirmRuns = 0; + LOG_INFO("[HOPSCALE] Congestion %s at chanUtil=%u%%", congested ? "engaged" : "released", + static_cast(utilizationAvg)); + } +} + void HopScalingModule::logStatusReport(bool didHourlyUpdate) const { const bool histActive = (histogramRollCount > 0 && count > 0); @@ -431,10 +452,11 @@ void HopScalingModule::logStatusReport(bool didHourlyUpdate) const const uint8_t runsRemaining = didHourlyUpdate ? RUNS_PER_HOUR : (RUNS_PER_HOUR - runsSinceLastHourlyUpdate); const uint8_t minsUntilRollover = runsRemaining * (RUN_INTERVAL_MS / (60 * 1000UL)); - LOG_INFO("[HOPSCALE] hop=%u histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u lastCounted=%u polite=%u/4 " - "nextRoll=%umin", - lastRequiredHop, histActive ? 1u : 0u, getFillPercentage(), samplingDenominator, filteringDenominator, count, - histCounts.total, lastPoliteNumer, minsUntilRollover); + LOG_INFO("[HOPSCALE] hop=%u congested=%u chanUtil=%u%% histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u " + "lastCounted=%u polite=%u/4 nextRoll=%umin", + lastRequiredHop, congested ? 1u : 0u, static_cast(utilizationAvg), histActive ? 1u : 0u, + getFillPercentage(), samplingDenominator, filteringDenominator, count, histCounts.total, lastPoliteNumer, + minsUntilRollover); LOG_INFO("[HOPSCALE] nodes perHop: [%u %u %u %u %u %u %u %u]", histCounts.perHop[0], histCounts.perHop[1], histCounts.perHop[2], histCounts.perHop[3], histCounts.perHop[4], histCounts.perHop[5], histCounts.perHop[6], @@ -449,6 +471,9 @@ int32_t HopScalingModule::runOnce() const bool isFirstRun = !hasCompletedInitialRun; bool didHourlyUpdate = false; + // Sampled every tick, not only on a roll, so the gate reacts within minutes of a change. + updateCongestion(); + if (isFirstRun) { hasCompletedInitialRun = true; runsSinceLastHourlyUpdate = 0; @@ -466,23 +491,36 @@ int32_t HopScalingModule::runOnce() } if (didHourlyUpdate) { - uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX; - // Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops, - // SENSOR reaches at least 1, so these reporting roles remain reachable even - // on a dense mesh where the histogram recommends a lower hop count. - uint8_t roleFloor = 0; - switch (config.device.role) { - case meshtastic_Config_DeviceConfig_Role_TRACKER: - case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER: - roleFloor = 2; - break; - case meshtastic_Config_DeviceConfig_Role_SENSOR: - roleFloor = 1; - break; - default: - break; + if (!congested) { + // Density alone is not a reason to throttle. Hand a hop back per roll rather than + // jumping to HOP_MAX, so a mesh that just quietened does not un-throttle all at once. + if (lastRequiredHop < HOP_MAX) + lastRequiredHop++; + } else { + uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX; + // Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops, SENSOR reaches + // at least 1, and the infrastructure roles reach INFRASTRUCTURE_HOP_FLOOR, so these + // reporting roles remain reachable even on a dense mesh recommending fewer hops. + // The infrastructure set matches the one Router.cpp uses for zero-cost hops. + uint8_t roleFloor = 0; + switch (config.device.role) { + case meshtastic_Config_DeviceConfig_Role_ROUTER: + case meshtastic_Config_DeviceConfig_Role_ROUTER_LATE: + case meshtastic_Config_DeviceConfig_Role_CLIENT_BASE: + roleFloor = INFRASTRUCTURE_HOP_FLOOR; + break; + case meshtastic_Config_DeviceConfig_Role_TRACKER: + case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER: + roleFloor = 2; + break; + case meshtastic_Config_DeviceConfig_Role_SENSOR: + roleFloor = 1; + break; + default: + break; + } + lastRequiredHop = std::max(suggested, roleFloor); } - lastRequiredHop = std::max(suggested, roleFloor); } logStatusReport(didHourlyUpdate); diff --git a/src/modules/HopScalingModule.h b/src/modules/HopScalingModule.h index 70d87428a5..b8b6786d39 100644 --- a/src/modules/HopScalingModule.h +++ b/src/modules/HopScalingModule.h @@ -105,16 +105,21 @@ class HopScalingModule : private concurrency::OSThread static constexpr uint8_t POLITENESS_DEFAULT = 2u; // 2/4 = 0.50 static constexpr uint8_t POLITENESS_STRICT = 1u; // 1/4 = 0.25 - // Activity weight thresholds (ratio of 0-2 h window vs 1-3 h window). - // Cross-multiply form: recent * ACTIVITY_WEIGHT_SCALE vs older * threshold_numer. - // GENEROUS if recent*10 < older*9 (ratio < 0.9); STRICT if recent*10 > older*12 (ratio > 1.2) - static constexpr uint8_t ACTIVITY_WEIGHT_SCALE = 10u; - static constexpr uint8_t ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER = 9u; - static constexpr uint8_t ACTIVITY_WEIGHT_STRICT_MIN_NUMER = 12u; - // Scheduling: number of 5-minute runOnce() ticks that make up one hourly rollover static constexpr uint8_t RUNS_PER_HOUR = 12; + // Congestion gate. Scaling is applied only while the smoothed channel utilization says the + // channel is busy; below the release threshold the recommendation is not applied at all. + static constexpr uint8_t CONGESTION_ENGAGE_PCT = default_hop_scaling_congestion_engage_pct; + static constexpr uint8_t CONGESTION_RELEASE_PCT = default_hop_scaling_congestion_release_pct; + static constexpr uint8_t CONGESTION_CONFIRM_RUNS = default_hop_scaling_congestion_confirm_runs; + // Upper band for the politeness numerator, which reads the same smoothed utilization as the + // gate: STRICT at or above this, DEFAULT from CONGESTION_ENGAGE_PCT, GENEROUS below it. + static constexpr uint8_t CONGESTION_STRICT_PCT = default_hop_scaling_congestion_strict_pct; + + // Hop floor for the infrastructure roles, so a remote site's own telemetry still reaches operators. + static constexpr uint8_t INFRASTRUCTURE_HOP_FLOOR = default_hop_scaling_infrastructure_hop_floor; + // ----------------------------------------------------------------------- // Types // ----------------------------------------------------------------------- @@ -179,6 +184,7 @@ class HopScalingModule : private concurrency::OSThread const PerHopCounts &getLastPerHopCounts() const { return lastPerHopCounts; } uint8_t getLastSuggestedHop() const { return lastSuggestedHop; } const MeshTrendStats &getLastTrendStats() const { return lastTrendStats; } + bool isCongested() const { return congested; } // Compatibility accessors used by tests uint8_t getCompactHistogramEntryCount() const { return getEntryCount(); } @@ -200,6 +206,8 @@ class HopScalingModule : private concurrency::OSThread // Writable from tests as HopScalingModule::s_testNowMs; drives nowMs() in PIO_UNIT_TESTING builds. inline static uint32_t s_testNowMs = 0; /// Override the per-session hash seed. Use in tests that need a specific sampling distribution. + // Drives channelUtilizationPercent() in PIO_UNIT_TESTING builds, as s_testNowMs drives nowMs(). + inline static float s_testChannelUtil = 0.0f; void setHashSeed(uint16_t seed) { hashSeed = seed; } uint16_t getHashSeed() const { return hashSeed; } /// Expose hashNodeId for tests that need to compute which node IDs pass a given denominator. @@ -223,6 +231,19 @@ class HopScalingModule : private concurrency::OSThread /// filteringDenominator once toward samplingDenominator per rollHour() call. /// 6. Shifts all seen bitmaps left by one hour slot. void rollHour(); + + /// Cache the smoothed channel utilization and flip the congestion state once the engage or + /// release threshold has held for CONGESTION_CONFIRM_RUNS consecutive runOnce() ticks. + void updateCongestion(); + + /// Smoothed channel utilization percent, or 0 when AirTime is not up yet. + static float channelUtil(); + + /// utilizationAvg to the nearest whole percent. The thresholds are whole percents and the + /// underlying 60 s window is far coarser than a float ULP, so comparing at full float + /// precision only creates dead zones: an EMA converging on a threshold from above settles one + /// ULP off it (12.00006103515625 for a sustained 12%) and an inclusive test never fires. + uint8_t smoothedUtilPct() const { return static_cast(std::min(utilizationAvg + 0.5f, 255.0f)); } // ----------------------------------------------------------------------- // Persistence // ----------------------------------------------------------------------- @@ -309,6 +330,15 @@ class HopScalingModule : private concurrency::OSThread uint8_t lastRequiredHop = HOP_MAX; uint8_t histogramRollCount = 0; + // ----------------------------------------------------------------------- + // Congestion state + // ----------------------------------------------------------------------- + // Cached once per runOnce() from AirTime, so the hourly roll and the status log read one + // consistent value without re-taking the AirTime lock. + float utilizationAvg = 0.0f; + bool congested = false; + uint8_t congestionConfirmRuns = 0; + // ----------------------------------------------------------------------- // Scheduler state // ----------------------------------------------------------------------- diff --git a/test/test_airtime/test_main.cpp b/test/test_airtime/test_main.cpp index 6edab96fb3..330c9668c3 100644 --- a/test/test_airtime/test_main.cpp +++ b/test/test_airtime/test_main.cpp @@ -137,6 +137,115 @@ void test_channel_utilization_decays_once_the_60s_window_passes() TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.channelUtilizationPercent()); } +// channelUtilizationPercent() is a 60-second window, so one reading says almost nothing about +// load: a consumer that samples it every few minutes sees only the minute before each sample. +// The smoothed figure folds that window into an EMA once per crossed 10 s bucket, so it advances +// with real elapsed time rather than with how often somebody asks. + +void test_smoothed_channel_utilization_starts_from_the_raw_window() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, 30000); // 30 s of a 60 s window + + // Nothing has been folded yet. Reporting 0 here would read as an idle channel rather than as + // "no history", so the raw window stands in until the first bucket crossing. + TEST_ASSERT_FLOAT_WITHIN(0.01f, a.channelUtilizationPercent(), a.smoothedChannelUtilizationPercent()); +} + +void test_smoothed_channel_utilization_lags_a_sudden_spike() +{ + Time::setTestMillis(0); + AirTime a; + a.smoothedChannelUtilizationPercent(); // seed from an idle window + + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 30000); + + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + + const float raw = a.channelUtilizationPercent(); + const float smoothed = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_TRUE_MESSAGE(raw > 40.0f, "a 30 s burst should show up strongly in the 60 s window"); + TEST_ASSERT_TRUE_MESSAGE(smoothed < raw, "one busy bucket must not drag the smoothed figure with it"); +} + +void test_smoothed_channel_utilization_converges_on_a_sustained_level() +{ + Time::setTestMillis(0); + AirTime a; + a.smoothedChannelUtilizationPercent(); + + // Hold the channel at a steady load for well past the ~21 min time constant, refilling each + // 10 s bucket as it is cleared, and the smoothed figure should walk up to meet the window. + for (uint32_t tick = 0; tick < 400; tick++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 5000); // 5 s busy in every 10 s bucket -> 50% + a.smoothedChannelUtilizationPercent(); + } + + const float raw = a.channelUtilizationPercent(); + const float smoothed = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(5.0f, raw, smoothed, "sustained load should converge on the raw window"); +} + +// The raw window is already required to be independent of how often the scheduler runs (see +// test_channel_utilization_is_independent_of_scheduler_rate, and the rotation-on-access contract in +// src/airtime.h). The smoothed figure inherits that requirement: folding one reading for a whole +// delayed sync instead of one per crossed bucket would make the EMA a function of call frequency, +// so two identical nodes would disagree purely because one of them slept. +void test_smoothed_channel_utilization_is_independent_of_sync_rate() +{ + Time::setTestMillis(0); + AirTime stepped; + AirTime delayed; + + // Identical airtime history: one burst, then a full window of silence. Only the rate at which + // each instance is asked for the figure differs. + stepped.logAirtime(RX_LOG, 30000); + delayed.logAirtime(RX_LOG, 30000); + + for (uint32_t bucket = 0; bucket < CHANNEL_UTILIZATION_PERIODS; bucket++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + stepped.smoothedChannelUtilizationPercent(); // sampled every bucket + } + + const float steppedPct = stepped.smoothedChannelUtilizationPercent(); + const float delayedPct = delayed.smoothedChannelUtilizationPercent(); // asked once, at the end + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, steppedPct, delayedPct, + "the smoothed figure must not depend on how often it is sampled"); +} + +void test_smoothed_channel_utilization_decays_across_a_long_sleep() +{ + Time::setTestMillis(0); + AirTime a; + + for (uint32_t tick = 0; tick < 400; tick++) { + Time::advanceTestMillis(10u * 1000u); + Time::serviceMonotonic(); + a.logAirtime(RX_LOG, 5000); + a.smoothedChannelUtilizationPercent(); + } + const float busy = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_TRUE(busy > 10.0f); + + // A sleep longer than the whole window clears every bucket. The fold is capped at one window + // of steps, so the figure drops sharply without being reset outright. + Time::advanceTestMillis(10u * 60u * 1000u); + Time::serviceMonotonic(); + + const float afterSleep = a.smoothedChannelUtilizationPercent(); + TEST_ASSERT_EQUAL_FLOAT(0.0f, a.channelUtilizationPercent()); + TEST_ASSERT_TRUE_MESSAGE(afterSleep < busy, "a long idle gap must pull the smoothed figure down"); +} + void test_isTxAllowedChannelUtil_blocks_once_over_threshold() { Time::setTestMillis(0); @@ -1211,6 +1320,11 @@ void setup() RUN_TEST(test_period_history_clears_when_asleep_longer_than_the_whole_log); RUN_TEST(test_channel_utilization_reflects_recent_airtime); RUN_TEST(test_channel_utilization_decays_once_the_60s_window_passes); + RUN_TEST(test_smoothed_channel_utilization_starts_from_the_raw_window); + RUN_TEST(test_smoothed_channel_utilization_lags_a_sudden_spike); + RUN_TEST(test_smoothed_channel_utilization_converges_on_a_sustained_level); + RUN_TEST(test_smoothed_channel_utilization_is_independent_of_sync_rate); + RUN_TEST(test_smoothed_channel_utilization_decays_across_a_long_sleep); RUN_TEST(test_isTxAllowedChannelUtil_blocks_once_over_threshold); RUN_TEST(test_tx_utilization_decays_once_the_60_minute_window_passes); RUN_TEST(test_syncNow_survives_millis_wrap); diff --git a/test/test_hop_scaling/test_main.cpp b/test/test_hop_scaling/test_main.cpp index aaa4e9ad2e..88db48d10f 100644 --- a/test/test_hop_scaling/test_main.cpp +++ b/test/test_hop_scaling/test_main.cpp @@ -1,3 +1,23 @@ +// Unit tests for HopScalingModule in src/modules/HopScalingModule.{h,cpp} - the sampled hop +// histogram and the hop limit it recommends for this node's own routine broadcasts. +// +// What is pinned: +// - HopScalingModule::rollHour() walks the scaled per-hop buckets and recommends the smallest +// hop limit that still reaches default_hop_scaling_min_target_nodes, extended by at most one +// hop when the politeness envelope allows it. +// - HopScalingModule::runOnce() applies that recommendation only while the congestion gate is +// engaged, floors it by the sending node's role, and hands a hop back per hourly roll once +// congestion clears. Router.cpp reads the result through getLastRequiredHop() and only ever +// lowers a packet below the user's configured hop_limit. +// - The sampling/filtering denominator state machine, which keeps the 128-entry histogram +// bounded while leaving the population estimate invariant. +// +// The regression guarded: before the congestion gate, the recommendation was driven by node +// density alone, so a dense but idle mesh was throttled exactly as hard as a saturated one and +// remote routers on a near-idle MEDIUM_SLOW mesh went silent (meshtastic/firmware#11794). Delete +// or relax the gate assertions and that returns: scaling engages on node count, with no reference +// to whether the channel is actually busy. + #include "MeshTypes.h" #include "TestUtil.h" #include @@ -8,6 +28,7 @@ #include "gps/RTC.h" #include "mesh/NodeDB.h" #include "modules/HopScalingModule.h" +#include #include #include #include @@ -80,6 +101,20 @@ class HopScalingTestShim : public HopScalingModule } uint8_t getFilteringDenomHoldRollsRemaining() const { return filteringDenomHoldRollsRemaining; } + /// Put the congestion gate directly into a state, bypassing the confirm counter, and seed the + /// EMA to a value consistent with it so the next runOnce() does not immediately count toward + /// the opposite flip. + void forceCongestion(bool value) + { + congested = value; + congestionConfirmRuns = 0; + utilizationAvg = value ? static_cast(CONGESTION_ENGAGE_PCT) : 0.0f; + } + + /// Set the smoothed utilization directly, so a test can sit on a band boundary without + /// pumping the EMA there sample by sample. + void setSmoothedChannelUtilization(float pct) { utilizationAvg = pct; } + /// Insert an entry with an explicit hash, bypassing the sampling filter. /// Used to fill the histogram to a known state without depending on hashNodeId distribution. void forceInsertEntry(uint16_t hash, uint8_t hops) @@ -130,6 +165,11 @@ static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const { shim.setHistogramDenominator(HopScalingModule::DENOM_MIN); + // The scenario suites below all assert on an applied hop limit, which only happens while the + // congestion gate is engaged. Put the channel at a busy reading and engage it up front. + HopScalingModule::s_testChannelUtil = 45.0f; + shim.forceCongestion(true); + for (uint8_t roll = 0; roll < numRolls; ++roll) { mockTime += ONE_HOUR_MS; @@ -145,6 +185,15 @@ static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const } } +// Drive N runOnce() ticks with AirTime reporting a fixed smoothed utilization. +// The gate reads it once per tick, so this is how a test moves it through the confirm counter. +static void pumpRuns(HopScalingTestShim &shim, float channelUtilPct, int runs) +{ + HopScalingModule::s_testChannelUtil = channelUtilPct; + for (int i = 0; i < runs; i++) + shim.runOnce(); +} + static void assertCompactHistogramActive(HopScalingTestShim &shim) { TEST_ASSERT_GREATER_THAN_UINT8(0, shim.getCompactHistogramEntryCount()); @@ -494,6 +543,281 @@ void test_startup_blank_state() hopScalingModule = nullptr; } +// --------------------------------------------------------------------------- +// Tests - Congestion gate +// --------------------------------------------------------------------------- + +// Pins the fix for meshtastic/firmware#11794: hop scaling used to trigger on node density alone, +// so a dense but idle mesh was throttled exactly as hard as a saturated one. The reporter's +// MEDIUM_SLOW mesh sat at 10-15% channel utilization with spikes into the high teens and went +// silent. A node that is not congested must leave hop_limit alone no matter how dense it is. +void test_congestion_gate_idle_channel_does_not_scale() +{ + TEST_MESSAGE("=== Congestion gate: dense mesh, idle channel ==="); + TEST_MESSAGE("Topology: the dense 110-node mesh that scales to <= 3 hops when the channel is busy."); + TEST_MESSAGE("Expectation: with the channel reading 10%, nothing is applied and hop returns to HOP_MAX."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0x9E000000, distA); + + // The histogram recommendation itself is unchanged - it is the application that is gated. + shim->runOnce(); + TEST_ASSERT_TRUE(shim->isCongested()); + const uint8_t scaledWhileBusy = shim->getLastRequiredHop(); + TEST_MSG_FMT("While congested: hop=%u", scaledWhileBusy); + TEST_ASSERT_TRUE(scaledWhileBusy <= 3); + + // 10% is inside the band the reporter measured; it must release and stay released. + pumpRuns(*shim, 10.0f, HopScalingModule::RUNS_PER_HOUR * 8); + + TEST_MSG_FMT("After idle channel: congested=%u hop=%u", shim->isCongested() ? 1u : 0u, shim->getLastRequiredHop()); + TEST_ASSERT_FALSE(shim->isCongested()); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop()); + TEST_ASSERT_TRUE(shim->getLastSuggestedHop() <= 3); // recommendation still warm, just not applied + + hopScalingModule = nullptr; +} + +// The complement of the test above: the gate must still engage on a genuinely busy channel, driven +// through the real EMA and confirm counter rather than forced, or the scaler is dead code. +void test_congestion_gate_scales_on_busy_channel() +{ + TEST_MESSAGE("=== Congestion gate: dense mesh, busy channel ==="); + TEST_MESSAGE("Expectation: a sustained 45% channel reading engages the gate and applies the hop walk."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0x9F000000, distA); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + TEST_ASSERT_FALSE(shim->isCongested()); + // Pin the precondition: injectSampleTraffic() drives rollHour() directly and never runOnce(), + // so nothing has been applied yet. Without this the final assertion could pass vacuously. + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop()); + + pumpRuns(*shim, 45.0f, HopScalingModule::RUNS_PER_HOUR * 2); + + TEST_MSG_FMT("After busy channel: congested=%u hop=%u", shim->isCongested() ? 1u : 0u, shim->getLastRequiredHop()); + TEST_ASSERT_TRUE(shim->isCongested()); + TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3); + + hopScalingModule = nullptr; +} + +// HopScalingModule::updateCongestion() in src/modules/HopScalingModule.cpp. +// A mesh idling near a threshold would otherwise toggle the gate - and therefore hop_limit - on +// every roll. Smoothing lives in AirTime now, so what this pins is the confirm counter alone: +// readings that cross a threshold on alternate ticks never hold it for CONGESTION_CONFIRM_RUNS +// in a row, so the state must not flip in either direction. Delete the counter and it flaps. +void test_congestion_gate_does_not_flap_at_threshold() +{ + TEST_MESSAGE("=== Congestion gate: no flapping around the thresholds ==="); + TEST_MESSAGE("Phase 1: released gate, samples alternating either side of the engage threshold."); + TEST_MESSAGE("Phase 2: engaged gate, samples alternating either side of the release threshold."); + + // Straddle each threshold rather than hard-coding percentages, so the test follows Default.h. + constexpr float kStraddle = 4.0f; + constexpr float kEngage = static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT); + constexpr float kRelease = static_cast(HopScalingModule::CONGESTION_RELEASE_PCT); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA0000000, distA); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + for (int i = 0; i < 60; i++) { + HopScalingModule::s_testChannelUtil = (i % 2) ? kEngage - kStraddle : kEngage + kStraddle; + shim->runOnce(); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "gate engaged on samples whose average stays below the threshold"); + } + + shim->forceCongestion(true); + for (int i = 0; i < 60; i++) { + HopScalingModule::s_testChannelUtil = (i % 2) ? kRelease - kStraddle : kRelease + kStraddle; + shim->runOnce(); + TEST_ASSERT_TRUE_MESSAGE(shim->isCongested(), "gate released on a dip that never held for the confirm window"); + } + + hopScalingModule = nullptr; +} + +// HopScalingModule::updateCongestion() in src/modules/HopScalingModule.cpp. +// Both threshold tests are inclusive - at exactly CONGESTION_ENGAGE_PCT the gate engages, at +// exactly CONGESTION_RELEASE_PCT it releases - and nothing else pins that. It is worth pinning +// because the comparison is made on a float average: AirTime's EMA converging on a threshold from +// above settles one ULP off it (12.00006103515625 for a sustained 12%), so comparing at full float +// precision left an inclusive test that could never fire and a gate that never released. +// smoothedUtilPct() rounds to the whole percent the thresholds are declared in; drop that rounding +// and a node sitting exactly on the release threshold stays throttled forever. +void test_congestion_gate_thresholds_are_inclusive() +{ + TEST_MESSAGE("=== Congestion gate: engage and release thresholds are inclusive ==="); + TEST_MESSAGE("Expectation: exactly the engage percent engages; exactly the release percent releases."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA5000000, distA); + + shim->forceCongestion(false); + pumpRuns(*shim, static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT), HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_MSG_FMT("At exactly %u%%: congested=%u", HopScalingModule::CONGESTION_ENGAGE_PCT, shim->isCongested() ? 1u : 0u); + TEST_ASSERT_TRUE_MESSAGE(shim->isCongested(), "sitting exactly on the engage threshold must engage"); + + pumpRuns(*shim, static_cast(HopScalingModule::CONGESTION_RELEASE_PCT), HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_MSG_FMT("At exactly %u%%: congested=%u", HopScalingModule::CONGESTION_RELEASE_PCT, shim->isCongested() ? 1u : 0u); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "sitting exactly on the release threshold must release"); + + // The dead zone itself: one ULP above the threshold is where AirTime's EMA actually settles + // when it converges on it from above, and a raw float compare reads that as "still congested" + // forever. Rounding to the whole percent is what makes it releasable. + shim->forceCongestion(true); + const float justAbove = std::nextafterf(static_cast(HopScalingModule::CONGESTION_RELEASE_PCT), 100.0f); + pumpRuns(*shim, justAbove, HopScalingModule::CONGESTION_CONFIRM_RUNS); + TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "one ULP above the release threshold must still release"); + + hopScalingModule = nullptr; +} + +// Releasing the gate must not hand every node its full hop_limit back in the same roll - that turns +// a mesh that just quietened into a broadcast storm. Recovery is one hop per hourly roll. +void test_congestion_release_ramps_one_hop_per_roll() +{ + TEST_MESSAGE("=== Congestion gate: release ramps one hop per hourly roll ==="); + TEST_MESSAGE("Expectation: after release, hop rises by exactly 1 per rollover, not straight to HOP_MAX."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA1000000, distA); + + shim->runOnce(); + uint8_t previous = shim->getLastRequiredHop(); + TEST_MSG_FMT("Engaged at hop=%u", previous); + TEST_ASSERT_TRUE(previous < HOP_MAX); + + shim->forceCongestion(false); + HopScalingModule::s_testChannelUtil = 0.0f; + + for (uint8_t roll = 0; roll < 3; roll++) { + for (int run = 0; run < HopScalingModule::RUNS_PER_HOUR; run++) + shim->runOnce(); + const uint8_t now = shim->getLastRequiredHop(); + TEST_MSG_FMT("Roll %u: hop=%u", roll + 1, now); + TEST_ASSERT_EQUAL_UINT8(previous + 1, now); + previous = now; + } + + hopScalingModule = nullptr; +} + +// HopScalingModule::runOnce() in src/modules/HopScalingModule.cpp. +// The role floor is keyed on the sending node's own role, so this lets a remote site's telemetry +// travel without loosening anything for client nodes. Operators read that telemetry to know a +// mountain-top site is alive; issue #11794 is a report of exactly those routers going quiet. +// +// The floored set is the same one Router.cpp groups for zero-cost hops: ROUTER, ROUTER_LATE and +// CLIENT_BASE. REPEATER is deliberately absent - it is deprecated and AdminModule demotes it to +// CLIENT on config set, so a floor keyed on it could never fire. +void test_infrastructure_role_floor_applies_when_congested() +{ + TEST_MESSAGE("=== Role floor: infrastructure roles keep a minimum hop count ==="); + TEST_MESSAGE("Topology: 200 nodes at hop 0, so the hop walk recommends 0 for an unfloored role."); + TEST_MESSAGE("Expectation: CLIENT scales below the floor, ROUTER/ROUTER_LATE/CLIENT_BASE sit on it."); + + const uint16_t distLocal[HOP_MAX + 1] = {200, 60, 20, 5, 3, 2, 2, 1}; + const meshtastic_Config_DeviceConfig_Role savedRole = config.device.role; + + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + uint8_t clientHop = HOP_MAX; + { + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + injectSampleTraffic(*shim, 0xA2000000, distLocal); + shim->runOnce(); + clientHop = shim->getLastRequiredHop(); + hopScalingModule = nullptr; + } + TEST_MSG_FMT("CLIENT: hop=%u", clientHop); + TEST_ASSERT_TRUE(clientHop < HopScalingModule::INFRASTRUCTURE_HOP_FLOOR); + + const meshtastic_Config_DeviceConfig_Role infraRoles[] = {meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, + meshtastic_Config_DeviceConfig_Role_CLIENT_BASE}; + for (size_t i = 0; i < sizeof(infraRoles) / sizeof(infraRoles[0]); i++) { + config.device.role = infraRoles[i]; + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + injectSampleTraffic(*shim, 0xA3000000 + (static_cast(i) << 20), distLocal); + shim->runOnce(); + + TEST_MSG_FMT("Infrastructure role %u: hop=%u", static_cast(infraRoles[i]), shim->getLastRequiredHop()); + TEST_ASSERT_EQUAL_UINT8(HopScalingModule::INFRASTRUCTURE_HOP_FLOOR, shim->getLastRequiredHop()); + hopScalingModule = nullptr; + } + + config.device.role = savedRole; +} + +// The one-hop extension used to be graded by a density trend (0-2 h vs 1-3 h node counts) while the +// gate that decides whether the walk applies at all reads measured airtime. A node could therefore +// be told the mesh was filling up by node counts while the channel sat idle, which is the same +// mismatch issue #11794 reports one level up. Both now read the smoothed channel utilization. +// +// The three regimes PR #10176 defined are preserved, read from airtime instead of node counts: +// GENEROUS while the channel is quiet or clearing, DEFAULT once past the gate's engage point, and +// STRICT at the polite gate, where the radio is already withholding metadata traffic. +void test_politeness_tracks_channel_utilization() +{ + TEST_MESSAGE("=== Politeness: graded by measured utilization, not by node-count trend ==="); + TEST_MESSAGE("Expectation: 4/4 below the engage point, 2/4 from it, 1/4 from the strict point."); + + auto shim = std::unique_ptr(new HopScalingTestShim()); + hopScalingModule = shim.get(); + buildDenseLocalMesh(); + const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0}; + injectSampleTraffic(*shim, 0xA4000000, distA); + + struct Band { + float util; + uint8_t numer; + }; + // forceCongestion() seeds the EMA, so each band is reached without pumping it there sample by + // sample; rollHour() then reads utilizationAvg directly. + constexpr float kEngage = static_cast(HopScalingModule::CONGESTION_ENGAGE_PCT); + constexpr float kStrict = static_cast(HopScalingModule::CONGESTION_STRICT_PCT); + // Both band edges are inclusive, so each is probed exactly and one below. + const Band bands[] = { + {0.0f, HopScalingModule::POLITENESS_GENEROUS}, {kEngage - 1.0f, HopScalingModule::POLITENESS_GENEROUS}, + {kEngage, HopScalingModule::POLITENESS_DEFAULT}, {kStrict - 1.0f, HopScalingModule::POLITENESS_DEFAULT}, + {kStrict, HopScalingModule::POLITENESS_STRICT}, {100.0f, HopScalingModule::POLITENESS_STRICT}}; + + for (size_t i = 0; i < sizeof(bands) / sizeof(bands[0]); i++) { + shim->setSmoothedChannelUtilization(bands[i].util); + shim->rollHourTest(); + + const float expected = bands[i].numer / static_cast(HopScalingModule::POLITENESS_DENOM); + TEST_MSG_FMT("util=%u%% -> polite=%u/4", static_cast(bands[i].util), + static_cast(shim->getPoliteness() * HopScalingModule::POLITENESS_DENOM)); + TEST_ASSERT_EQUAL_FLOAT(expected, shim->getPoliteness()); + } + + hopScalingModule = nullptr; +} + // --------------------------------------------------------------------------- // Tests - Denominator state machine // --------------------------------------------------------------------------- @@ -761,6 +1085,16 @@ void setup() RUN_TEST(test_intermediate_status); RUN_TEST(test_startup_blank_state); + printf("\n=== Congestion gate ===\n"); + RUN_TEST(test_congestion_gate_idle_channel_does_not_scale); + RUN_TEST(test_congestion_gate_scales_on_busy_channel); + RUN_TEST(test_congestion_gate_does_not_flap_at_threshold); + RUN_TEST(test_congestion_gate_thresholds_are_inclusive); + RUN_TEST(test_congestion_release_ramps_one_hop_per_roll); + RUN_TEST(test_infrastructure_role_floor_applies_when_congested); + + RUN_TEST(test_politeness_tracks_channel_utilization); + printf("\n=== Denominator state machine ===\n"); RUN_TEST(test_denominator_rises_on_overflow); RUN_TEST(test_filtering_denom_hold_counts_down);