Commit Graph
12698 Commits
Author SHA1 Message Date
James Rich 51eadb77d4 fix(NodeDB): reset a persisted event firmware_edition on vanilla builds (#11504)
* fix(NodeDB): reset a persisted event firmware_edition on vanilla builds

myNodeInfo lives in devicestate, which survives a firmware reinstall, and
the boot-time edition stamp was compiled out entirely on builds without
USERPREFS_FIRMWARE_EDITION. A device flashed from an event build back to
vanilla therefore kept reporting the event edition forever, and clients
kept its branding until a factory reset. Stamp VANILLA in the else branch
so the running build is always the source of truth.

* Stamp the edition before the boot save decision, and assert the on-disk value

Review follow-up: the stamp sat after the devicestate CRC compare, so an
edition-only change stayed RAM-only and the persisted event edition
survived on disk. Move it next to the other running-build-wins fixups
(device_id, min_app_version), which run inside the CRC window, and extend
the test to read device.proto back so the persisted value is asserted
too.
2026-08-14 19:37:01 +00:00
Ben Meadors f57ee0bd71 fix(mesh): restore the implicit ACK for our own overheard PKI DMs (#11502)
* fix(mesh): restore the implicit ACK for our own overheard PKI DMs

A DM we originate is PKI-encrypted to the recipient, so when we overhear it
being rebroadcast we cannot decrypt it. perhapsHandleReceived() classifies it
DECODE_OPAQUE and returns before shouldFilterReceived() runs, which is where
the implicit ACK for our own transmission is generated. The client therefore
never receives the ROUTING_APP ack it renders as "Delivered to mesh" for a DM,
and the message sits in "sending" until it either succeeds outright or times
out as max retransmissions.

The ACK only needs the packet header (from/id), not the decoded payload, so
split it out of shouldFilterReceived() into
perhapsGenerateImplicitAckForOwnOverheard() and also call it from the opaque
short-circuit for packets that are from us. Behavior on the decodable path is
unchanged.

Broadcasts on a PSK channel decode normally and always reached the generator,
which is why channel messages were unaffected and only DMs showed the symptom.

* test: rename implicit-ack tests to avoid a trufflehog false positive

The camelCase identifiers tripped trunk's trufflehog/Lob secret detector.

* test: shorten one test name past trunk's Lob secret-detector pattern

trufflehog's Lob rule matches test_ followed by exactly 35 word characters,
which both new test names happened to hit. Unrelated to the fix.
2026-08-14 12:15:31 -05:00
Ben Meadors 905482ccce fix(serial): don't sleep forever with pending PhoneAPI output on UART consoles (#11500)
* fix(serial): don't sleep forever with pending PhoneAPI output on UART consoles

Since #11164 bounded the stream drain, a config dump can end a dispatch
with output still queued. On UART-console ESP32 boards runOnce() then
returns INT32_MAX with no RX pending, and neither rxInt() nor
onNowHasData() fires for the remaining output, so the download wedges
mid nodeinfo stream until the client happens to send a byte.

Add StreamAPI::hasPendingOutput() (transport-retained frame or queued
PhoneAPI data) and have SerialConsole::runOnce() short-poll (<=25ms)
while it holds instead of sleeping INT32_MAX. The #11164 write budget
is unchanged; idle sleep behavior with a drained queue is unchanged.
The retained-frame probe also covers the ESP32-S2 USB-CDC branch,
which takes the same INT32_MAX path.

* test(serial): restore scratch NodeDB via tearDown, trim comments to house style

A failed TEST_ASSERT longjmps out of a Unity test without running
destructors, so RAII cannot restore the swapped nodeDB pointer; install
the scratch NodeDB explicitly and restore/delete it in tearDown(),
which runs after every test outcome. Also shorten the new comments to
the two-line house limit.
2026-08-14 10:41:44 -05:00
Ben Meadors fb6a212b44 fix(graphics): make on-screen keyboard lifecycle safe and RAII-managed (#11460)
- VirtualKeyboard::handleLongPress VK_ESC invoked the onTextEntered
  member std::function directly, but that callback path reaches
  OnScreenKeyboardModule::stop(), which destroys the keyboard - and
  with it the std::function whose invocation is still on the stack.
  handlePress and submitText already deliberately copy-and-clear before
  invoking for exactly this reason (CannedMessageModule documents the
  same hazard); do the same here.

- OnScreenKeyboardModule's keyboard becomes unique_ptr, replacing the
  delete-in-destructor / delete-then-new-in-start / delete-in-stop
  bookkeeping that runs on every keyboard open/close. The
  NotificationRenderer legacy hook receives a non-owning raw pointer,
  as before.
2026-08-14 10:05:16 +00:00
Ben Meadors 0ff10318ad refactor(net): unique_ptr for connection-lifecycle objects (#11459)
- WiFiServerAPI/ethServerAPI apiPort and ethApiServer's listener are
  create/destroy cycles that repeat across WiFi teardown and W5500
  chip resets; the manual delete+null bookkeeping becomes reset().
  (ethTlsApiServer's listener is left for a follow-up: that file is
  already touched by the partial-init fix PR and converting it here
  would conflict.)

- ContentHandler::handleFormUpload held its body parser raw with
  delete on four separate exit paths of a per-request handler; any
  future early return was a silent leak. unique_ptr removes all four.

- The portduino ch341Hal global becomes unique_ptr. The LoRa-error
  recovery loop's delete/null/new sequence was correct only by
  hand-preserved ordering; it becomes reset()/make_unique. RadioLibHAL
  keeps a non-owning raw pointer, as before.

No behavior change.
2026-08-14 10:04:57 +00:00
Ben Meadors 5e54262fe1 refactor(io): unique_ptr ownership for motion sensors and I2C keyboard (#11458)
* refactor(io): unique_ptr ownership for motion sensors and I2C keyboard

- AccelerometerThread / MagnetometerThread: the owned MotionSensor
  becomes unique_ptr, removing the manual delete/null bookkeeping in
  clean(). Deletion behavior is unchanged (MotionSensor's destructor is
  virtual).

- KbI2cBase: the TCA keyboard was a reference member bound to an
  anonymous heap allocation - ownership was invisible and nothing could
  ever free it. It becomes unique_ptr with an out-of-line destructor
  (the base type is only forward-declared in the header).

- GeoCoord::pointAtDistance returned shared_ptr with no shared
  ownership anywhere (and no callers); return by value instead.

No behavior change.

* fix(io): make TCA8418KeyboardBase destructor public for unique_ptr ownership

* refactor(gps): delete dead pointAtDistance instead of converting it

Per review: zero callers in this repo or device-ui, and the math was
wrong at both ends (rangeMetersToRadians multiplies meters by 1852,
treating meters as nautical miles). Remove it, its now-unused helper,
and the <memory> include the old shared_ptr signature pulled in.
2026-08-14 09:33:39 +00:00
Thomas Göttgens a661fd8cd4 fixes #11466 (#11487)
* fixes #11466

* Keep locally-addressed routing feedback out of the phone echo filter

allocForSending stamps ACK/NAK packets with from == our nodenum and sendLocal
defaults to RX_SRC_RADIO, so the loopback gate never applies. Filtering on
isFromUs alone dropped implicit rebroadcast ACKs, duty-cycle and NO_INTERFACE
NAKs, and PhoneAPI rate-limit errors on their way to the client.

Add coverage through the real RoutingModule, which the mocked one used by the
rest of the suite cannot exercise, and correct the test seam comment.

* Clean up the temporary RoutingModule in tearDown()

A failed Unity assertion longjmps out of the test, so the in-test delete never
ran and the module stayed registered in MeshModule::modules for every later
test. Track it at file scope, as realNeighborInfoModule already is.
2026-08-14 09:12:31 +00:00
Thomas Göttgens 4d524320b5 Add agent guideline: documentation belongs in the docs repo (#11492)
Mirrored in AGENTS.md, .github/copilot-instructions.md and CLAUDE.md, with a
matching CodeRabbit path instruction for **/*.md.
2026-08-14 09:09:03 +00:00
renovate[bot] cd18f382f1 chore(deps): update adafruit sh110x to v2.1.15 (#11464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-14 09:08:55 +00:00
renovate[bot] 9bf80c2fc6 Update meshtastic/device-ui digest to e1de01e (#11497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-14 08:37:33 +00:00
Thomas Göttgens b42f59a940 fix(t-impulse-plus): enable 1200bps touch for nrfutil upload (#11499)
Board lacked use_1200bps_touch, so uploads targeted the running
application CDC instead of the bootloader. Adds wait_for_upload_port
and the 0x239A:0x00DA bootloader hwid.
2026-08-14 08:34:57 +00:00
Thomas Göttgens 34680833b8 fix(test): make the native-windows test suite build and run (#11482)
* fix(test): make the native-windows test suite build and run

pio test -e native-windows failed every suite at the build stage. Five
independent causes, all Windows-only:

- TestUtil.cpp called lstat(), which MinGW-w64 does not provide. The
  state-checkpoint walk added in #11322 is fenced with ARCH_PORTDUINO,
  which native-windows also satisfies, so all 53 suites failed to
  compile. Route it through a stat() shim on _WIN32.

- test_default, test_http_content_handler, test_meshpacket_serializer
  and test_serial define no setUp/tearDown and relied on the weak
  defaults PlatformIO emits in unity_config.c. GCC lowers a weak
  definition on PE-COFF to a weak external, leaving the symbol
  undefined, so it does not satisfy unity.c's reference and the link
  fails. Define them explicitly, as the other 49 suites already do.

- test_mqtt included <arpa/inet.h>, absent on MinGW, for htonl(). Use
  winsock2.h there.

- test_gps_update_scheduling uses TEST_ASSERT_DOUBLE_WITHIN. Unity
  omits double support unless UNITY_INCLUDE_DOUBLE is defined, so the
  assertion compiled to an unconditional failure. Define it for the
  env.

- test_getfiles_rejects_overlong_path is excluded on _WIN32. Overrunning
  the 228-byte file_name needs at least 229 bytes below the portduino
  root, and that root is already ~34 bytes, so every qualifying path
  passes the 260-byte MAX_PATH: the nested mkdir() fails, the file is
  never created, and getFiles() has nothing to drop. No component
  layout satisfies both limits.

Each of the seven suites that failed on Windows was verified
individually after the change. test_fscommon_getfiles still fails in a
full run, for a cause outside this change: rmDir() does not remove
directories on Windows, so empty dirs left by an earlier run survive
setUp() and make getFiles() report a depth truncation. That is a
pre-existing FSCommon bug, reported separately.

No Linux or macOS behaviour changes: every guard is _WIN32-only except
UNITY_INCLUDE_DOUBLE, which is scoped to env:native-windows.

* fix(test): define UNITY_INCLUDE_DOUBLE for every native env

The flag was scoped to env:native-windows, but the gap is not
Windows-specific. Verified on Debian with gcc against the Linux env's
own Unity 2.6.1 and PlatformIO's generated native unity_config:

  UNITY_INCLUDE_DOUBLE : NOT defined
  UNITY_EXCLUDE_DOUBLE : defined
  test_double_within:FAIL: Unity Double Precision Disabled

UNITY_INCLUDE_DOUBLE appears nowhere in the repo, the ini files, the
workflow, or PlatformIO's unity runner, which adds only
UNITY_INCLUDE_CONFIG_H. So TEST_ASSERT_DOUBLE_* is an always-failing
stub on Linux and macOS too, not only on Windows.

Moved to portduino_base.build_flags_common, which every native env
resolves: native, native-tft, native-fb, native-tft-debug, coverage,
coverage-event-policy, native-macos, native-windows and native-wasm.

This does change Linux and macOS: TEST_ASSERT_DOUBLE_* becomes a real
comparison instead of a stub. test_gps_update_scheduling is the only
suite using those macros and its arithmetic is integer-based and
bit-identical across platforms, so it should pass wherever it runs.
Note it currently reports PASSED on CI in 0.03s while emitting no Unity
output at all, so those assertions appear never to execute there; that
is tracked separately and is not addressed here.
2026-08-14 00:51:40 +00:00
Tom a00675e00c unset can have what it likes (#11496) 2026-08-13 17:36:06 -05:00
Austin 119cd261be Cache docker image layers in Registry (#11495) 2026-08-13 19:42:56 +00:00
Manuel 778041ec06 fix esp32 time sync (RTC / NTP) (#11494) 2026-08-13 13:57:07 -04:00
Benjamin FaershteinandBen Meadors a400143090 fix: improve acknowledged unicast retry reliability (#11320)
* Improve acknowledged unicast retry reliability

* Fix merged next-hop routing tests

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-13 13:55:53 -04:00
Jonathan BennettandClaude faa2c8fc52 fix(portduino): don't segfault writing the trace file (#11493)
The TraceFile path took the first variadic argument as a char* and
streamed it, which only held while the tree's sole LOG_TRACE sites were
LOG_TRACE("%s", json). Trace-level lines without a string argument (e.g.
"Filesystem files:" from fsInit) read a garbage pointer and crashed
meshtasticd at boot whenever Logging.TraceFile was configured.

Format the message instead. The buffer covers the worst-case packet JSON
(233-byte payload escaped 6x plus metadata, ~1.7 KB); the trace file is
written untruncated today, so it must not be sized below that.

Fixes #11490


Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-13 13:24:00 -04:00
Austin 30e6e0ec8c Do not build nucleo_wl55jc upon PR (#11489)
Honestly don't build it at all, it's a devkit.
2026-08-13 13:21:53 -04:00
Ben Meadors b565a07a83 Remove proprietary Bosch BSEC blob; open in-tree IAQ estimator for BME680 (#11381)
* Remove proprietary Bosch BSEC blob; open in-tree IAQ estimator for BME680

BSEC2 cost ~37-39 KB flash and ~4-5 KB static RAM on ~190 of ~240 build
targets, linked whether or not a BME680 was attached, and was a no-source
proprietary archive inside GPLv3 release binaries. The firmware consumed
exactly one BSEC-exclusive output: the IAQ value.

- New BME680IaqEstimator: clean-room log-domain baseline tracker
  (humidity-compensated gas resistance vs a rise-fast/decay-slow ceiling,
  0-500 scale matching the existing UI bands), pure math, unit-tested on
  native (test_bme680_iaq, 15 tests incl. a deep-sleep reboot simulation).
  Warm-up/burn-in progress persists to /prefs/bme680.dat via SafeFile so
  one-sample-per-wake SENSOR nodes converge across reboots; stale
  /prefs/bsec.dat is removed once.
- BME680Sensor: single-path rewrite on Adafruit_BME680 with async
  once-per-minute sampling (~20x lower heater duty than BSEC LP mode),
  a hard 2-minute publish-freshness bound (a dead sensor stops reporting
  instead of freezing its last reading on the wire), and suppression of
  bogus gas_resistance=0 points from heater-unstable cycles.
- platformio.ini: environmental_extra_common/_extra/_no_bsec collapsed
  into one section; Bosch BSEC2 + BME68x deps deleted; per-variant BSEC
  link-path hacks and the TEMPORARY promicro lib_ignore removed.
  nrf52_promicro_diy_tcxo regains BME680 support at 36 KB clear of the
  warm-store cap; rak4631 lands at 75 KB clear.
- EnvironmentTelemetry: iaq rendering gates on has_iaq (a genuine IAQ of
  0 now displays); stale BSEC comments rewritten.
- rak4631 size budgets tightened (113000->108000 RAM, 786000->746000
  flash) to lock in the reclaimed headroom.
- bin/bme680_iaq_replay.cpp: host-side replay harness for tuning the
  estimator against captured BSEC traces (mean abs error + band
  agreement), no reflashing needed.

Measured (develop -> this branch): rak4631 -38.8 KB flash / -4.9 KB RAM;
heltec-v3 -36.4 KB / -4.0 KB; tlora-v2-1-1_6 +1.3 KB (its IAQ
approximation had been dead code since #9663 due to an inverted isfinite
check and now actually runs).

Note: gas_resistance stays kOhm on the wire for fleet compatibility; the
proto comment claiming MOhm gets a separate meshtastic/protobufs docs PR.

* Address CodeRabbit review feedback

- Use Throttle::isWithinTimespanMs for all elapsed-time predicates in
  BME680Sensor per coding guidelines (deadline math for the async reading
  completion stays raw, as it targets an absolute timestamp)
- Make the state file name members static constexpr
- Replay tool: cast uint16_t before %u (default argument promotion), report
  malformed input lines instead of silently skipping, and fail non-zero on
  stream read errors

* Address CodeRabbit nitpicks

- Replace the local clampf helper with std::clamp (meshUtils.h's clamp drags
  in Arduino.h, which would break the estimator's standalone host build that
  the replay harness depends on)
- Trim the replay tool's file header to a two-line summary; the full build,
  capture, and tuning workflow moves to docs/bme680_iaq_replay.md
2026-08-13 13:21:16 -04:00
Austin 3e71c679c1 Actions: Add PIO caching to build-debian-src workflow (#11465)
Prevent a few more transient fails
2026-08-13 13:17:53 -04:00
Ben MeadorsandClaude Opus 5 230da77642 fix(time): convert the millis() rollover sites #11291's CI guard cannot see (#11483)
* MeshPacketQueue: fix millis() rollover in the late-packet drop test

replaceLowerPriorityPacket() read `backPacket->tx_after < now`, with `now`
taken from millis() on the line above. tx_after is an absolute deadline, so
that comparison inverts while the deadline sits on the far side of the 32-bit
wrap: a queued late packet reads as not-yet-due for the rest of the wrap
window, or every late packet reads as droppable at once. The same statement
ordered two deadlines against each other with `backPacket->tx_after >
p->tx_after`, which has the same problem.

#11291 swept every site where millis() sits next to the comparison operator,
and its CI guard matches that shape. Stashing the clock in a local first is
the same bug written so the guard cannot see it.

Both tests now subtract before comparing: the due test through
Throttle::deadlinePassedAt(), and the ordering through the elapsed-since-now
form already used in AdminModule's oldest-slot scan. The snapshot comes from
Time::getMillis() so the deadlines and the test read one clock, per the
convention deadlinePassedAt() documents.

The `dt` the log line reports is now derived from the same elapsed value
rather than recomputed. Behaviour is otherwise unchanged, save the boundary:
deadlinePassedAt() is inclusive, so a deadline landing exactly on `now` reads
as due rather than one millisecond early.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* RadioLibInterface: don't widen a uint32_t deadline delta into a 64-bit long

TRANSMIT_DELAY_COMPLETED tested whether the front packet was still waiting
with

    long delay_remaining = txp->tx_after ? txp->tx_after - millis() : 0;
    if (delay_remaining > 0) ...

The subtraction is uint32_t. Where long is 32-bit - every embedded target -
an already-due deadline lands negative and the packet transmits, which is why
this has never been visible on device. Where long is 64-bit (portduino, and
the native test build) the same value zero-extends to ~4.29e9, reads as
positive, and the packet is rescheduled 49.7 days out. It stays parked until
some later notifyLater() with overwrite happens to reset the timer.

That is not an edge case. notifyLater() schedules through
setIntervalFromNow(), so the thread wakes at or after the deadline; being a
millisecond past due is the ordinary path through this branch.

Ask Throttle instead. deadlinePassedAt() is the unsigned half-range test, so
there is no signed conversion to get wrong at any width, and the remaining
delay handed to notifyLater() is computed from the same snapshot. On 32-bit
the behaviour is identical, including at the boundary: a deadline equal to
now transmitted before and still does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ExpressLRSFiveWay: convert the two remaining raw window checks to Throttle

runOnce() dismissed the alert frame with `now > alertingSinceMs + 2000` and
chose its poll rate with `now < keyDownStart + 20000`, both against a millis()
snapshot in a local. Same rollover inversion as any other naive compare, and
invisible to the millis-deadline-check guard because millis() is not adjacent
to the operator. update() in the same file was already on Throttle.

hasElapsed()/isWithinTimespanMs() with the stored event give the full ~49.7
day range and need no snapshot. Sentinels are unchanged in meaning:
`alerting` is the armed flag for alertingSinceMs and is tested first, and
keyDownStart == 0 reads as "recent" for the first 20s of uptime exactly as
`now < 0 + 20000` did - a poll rate either way.

The arm sites move to Time::getMillis() so the writes land on the clock
Throttle reads, which also puts them within reach of Time::setTestMillis().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* GPSUpdateScheduling: record whether a search is running, don't infer it

elapsedSearchMs() answered "am I searching?" by ordering two raw millis()
stamps: searchStartedMs > searchEndedMs. Whichever stamp lands on the far
side of the 32-bit wrap reads as the larger one, so the answer inverts once
per wrap cycle, in both directions:

  - a search that started before the wrap and ended after it keeps reading as
    "searching". elapsedSearchMs() then grows without bound and
    searchedTooLong() aborts a search that is not running.
  - a search that started after the wrap, following one that ended before it,
    reads as "idle". elapsedSearchMs() returns 0, so an unproductive search is
    never aborted and the receiver stays powered until it locks.

Both self-heal at the next informSearching(), which bounds the damage to one
GPS cycle - but the ordering test cannot be made wrap-correct, because the
two stamps carry no information about which wrap they belong to.

It does not need to be. Whether a search is in progress is a fact the three
inform*() calls already have in hand; the ordering was only ever standing in
for it. Add the flag and set it there. elapsedSearchMs() keeps its unsigned
subtraction, which was always the correct part.

The file's clock reads move to Time::getMillis() so the suite can drive them
across the wrap. Behaviour-preserving in production - Time::getMillis() is
millis() unless a test injects a clock.

test_gps_update_scheduling/ gains seven cases: the idle/searching/ended
states, elapsed exactness across the wrap, both inversion directions above,
and reset(). The two wrap cases fail on the old predicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* MessageStore: date boot-relative messages in uptime seconds

A message received before the wall clock is trustworthy is stamped
boot-relative and healed by upgradeBootRelativeTimestamps() once the RTC
arrives. Both the stamp and the "same boot?" test were millis() / 1000, which
wraps every 49.7 days: a stamp taken before the wrap reads as newer than
`bootNow` afterwards, so `m.timestamp <= bootNow` declines to heal it and the
message shows "???" until it ages out. MessageRenderer's own copy of the test
falls the same way and prints invalidTime.

Neither produces a wrong time - the guard is what fails safe - but
Time::getUptimeSecs() landed in #11291 for exactly this, and does not wrap for
136 years. Both sites take it, which makes the comparison exact rather than
merely fail-safe.

While here, the autosave tick had its own hand-rolled deadline helper -
`reachedMs(now, target)` as `(int32_t)(now - target) >= 0`. Wrap-correct, but
a competing idiom for what Throttle::isWithinTimespanMs() already answers, and
the signed cast is the form #11291 replaced everywhere else. Deleted; the
stamps read Time::getMillis() so the whole path is on one clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* WebServer: drop the hand-rolled millis() wrap branch

getAdaptiveInterval() special-cased the wrap by hand:

    if (currentTime >= lastActivityTime)
        timeSinceActivity = currentTime - lastActivityTime;
    else
        timeSinceActivity = (UINT32_MAX - lastActivityTime) + currentTime + 1;

Those two expressions are the same number - unsigned subtraction already
computes the difference modulo 2^32 - so this is not a bug, just eight lines
reimplementing what Throttle does. It also reads like a site that has thought
about the wrap and settled it, which makes it a bad example to copy.

Two isWithinTimespanMs() calls against the stored activity stamp, matching
ethApiServer's shape for the same adaptive-interval decision. The stamps move
to Time::getMillis() so the writes and the reads share a clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* MeshPacketQueue: only order elapsed times once both deadlines have passed

The late-packet eviction I rewrote compared how long ago each deadline passed:

    backElapsed < (uint32_t)(now - p->tx_after)

That is only an ordering when both deadlines are in the past. An incoming
packet whose tx_after is still in the future subtracts to a near-2^32 elapsed,
which reads as the most overdue packet in the queue rather than the least - so
a full queue would drop the overdue packet it was about to transmit in favour
of one that is not ready yet. The comparison it replaced,
`backPacket->tx_after > p->tx_after`, got this right away from the wrap; I
lost it in the conversion.

Classify before ordering: p->tx_after must be unset, or passed, before its
elapsed time means anything. Two expired deadlines still order by which is
further overdue, which is what the branch is for.

Caught by CodeRabbit on #11483.

test/test_meshpacket_queue/ pins the branch: the future-dated arrival that
started this, both directions of the both-expired ordering, the undelayed
arrival, and all of it again with the deadlines and `now` on opposite sides of
the wrap. maxLen is 1 so the suite reaches the branch without dragging in
CompareMeshPacketFunc and a NodeDB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ExpressLRSFiveWay: treat "no key pressed yet" as no activity

keyDownStart is 0 until the first press of a boot, and the fast-poll window
read that as a press at time zero: 100ms polling for the first 20s of uptime
with no activity at all, re-triggering once per millis() wrap. The arithmetic
this replaced (`now < keyDownStart + 20000`) did the same, so it is not a
regression - but the sentinel is exactly what the conventions say to test
before the elapsed comparison, and "has there been recent key activity" has an
honest answer here.

250ms is the documented floor for not missing presses, so an idle node simply
starts there and moves to 100ms on the first press.

Also trims the wrap-cases comment in test_gps_update_scheduling to the
two-line house limit.

Both from CodeRabbit review on #11483.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:15:21 -04:00
Jason P c144fa5484 Fix the IFDEF guard causing OLED_TINY failures (#11484) 2026-08-13 13:14:42 -04:00
oscgonferandcoderabbitai[bot] 944fd26580 Add SEN6x sensors (#11390)
* Add SEN6X

* Adds new SENXX class for SEN5X and SEN6X
* Adds CO2 sensor calibration class to be shared among othre CO2 sensors

* Make existing CO2 sensor draw from CO2Sensor class

* Minor coment for CO2 sensor class

* Move away from getRTC in SENXX class to keep track of time changes.

* Change all sensors to millis for tracking time, instead of using getRTC

* Add comments regarding VOC state

* Avoid storing non-valid RTC

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Avoid CO2 sensor warm-up time to be below PM measured started

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix limits in CO2 sensor calibration

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add pragma once on headers

* Avoid non-working ASC commands

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix data poll

* Move pm measure started before warmup check

* Make cleaning non-blocking

* Restore previous state if cleaning fails. Fix data ready condition.

* Fix CO2 sensor checks for calibration

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-13 13:13:50 -04:00
Thomas Göttgens 3b608b8fc5 fix(mesh): keep ROUTING_APP responses when toPhoneQueue is full (#11480)
* fix(mesh): keep ROUTING_APP responses when toPhoneQueue is full

#2918 narrowed the overflow policy to evict the oldest entry only for
TEXT_MESSAGE_APP and RANGE_TEST_APP, dropping every other portnum. A
dropped ROUTING_APP response leaves the phone with no delivery
confirmation for a message it sent.

Add ROUTING_APP to the eviction list and pin the policy in
test/test_tophone_queue.

Fixes #11439

* fix(mesh): gate the queue-overflow portnum check on the decoded variant

decoded.portnum aliases encrypted.size in the payload union, so an
encrypted packet could be read as a privileged portnum by its ciphertext
length. Restore config.device.rebroadcast_mode in the test teardown.

* test: rename a test to avoid a trufflehog false positive

test_text_still_admitted_when_queue_full is "test_" followed by exactly
35 characters, which matches the Lob API key shape and fails trunk check.
2026-08-13 13:12:54 -04:00
Tom f5314148c2 Serialise AirTime behind a lock, and stop handing out its buckets (#11362)
* Copy airtime reports into a caller buffer instead of exposing the array

airtimeReport() returned a pointer into the rotating bucket arrays, so the
caller held a handle to state that logAirtime() and every accessor mutate
underneath it. Copy into a caller-supplied buffer instead, and report failure
for a null buffer, a count past the log depth, or an unknown report type.

ContentHandler owns its buffer and hoists getPeriodsToLog() out of the three
calls that repeated it.

* Cover the AirTime report API and log-dispatch contract

Half of AirTime's surface had no tests: which store each report type feeds,
what airtimeReport() does when misused, how the first sync seeds itself, and
whether calling several entry points in one interval compounds the rotation.

Eighteen tests, asserted through the public API rather than the public bucket
arrays - those arrays are meant to become private, and a test that reads them
would have to be rewritten rather than pinning a contract.

Two of them state a convention that was never written down: the report arrays
are shift-ordered with slot 0 newest, and slot 0 covers only the time since the
last rotation. channelUtilization and utilizationTX use the opposite convention
- a modular ring indexed by uptime phase - and reading one as if it were the
other is a defect that has already happened once.

* Characterise AirTime window decay, TX gates, and sleep behaviour

Thirty-three tests in three kinds. Invariants must hold forever; boundaries pin
off-by-ones a refactor would move; five characterisations encode today's wrong
numbers, each tagged with the phase that will flip it.

Readings are asserted against an event-log oracle - airtime physically on air
inside (now - window, now], computed from a list of completed packets - rather
than against hand-worked constants, so a test states "this matches the
definition" instead of "this looked right when I wrote it".

The characterisations, all measured rather than assumed:
  - the window covers (N-1)p + phase but divides by Np, so a steady 10% load
    reads 8.33% right after a bucket boundary                     -> phase 5
  - the same load sweeps across bucket phase instead of holding    -> phase 5
  - the hour window carries the same defect, 10x smaller           -> phase 5
  - a packet longer than its bucket is credited whole to the bucket
    it completed in, so a saturated LONG_SLOW channel reads >100%  -> phase 4b
  - getSilentMinutes() reads a modular ring as if the index were an
    age, so identical airtime gives different answers by phase     -> phase 6

Two tests needed correcting during the write, both my expectations rather than
the code: a six-bucket ring sheds whole buckets, so a 30s gap drops three of
five survivors and not "half"; and the oracle sees 59 completions in a 60s
window, not 60, because the one on the lower edge is outside it.

Not written: the planned RX_LOG/RX_ALL_LOG disjointness test. That is a
property of the two radio drivers, which choose one or the other per packet -
it is not observable from AirTime, which records what it is told. The
AirTime-side half is already covered by the routing tests.

* Drop write-only and undefined AirTime members

None of this was reachable:

  air_period_tx / air_period_rx   file-scope mirrors of airtimes.periodTX/RX,
                                  accumulated, rotated and memset in lockstep
                                  with them but never read out or serialised.
                                  Orphaned when #2552 re-pointed the writes at
                                  bare globals instead of deleting them.
  lastUtilPeriod, lastUtilPeriodTX  written on every sync, read nowhere
  airtimes.lastPeriodIndex        written on every rotation, read nowhere
  currentPeriodIndex()            computes (secs / 3600) % 8 - a modular-ring
                                  index for the one array that is shift-ordered
                                  rather than a ring. Its only two uses were the
                                  dead field above and a log line. It is the
                                  fossil of the same confusion that makes
                                  getSilentMinutes() wrong.
  UtilizationPercentTX()          declared, never defined
  free logAirtime()/airtimeReport()  declared, never defined; the latter still
                                  carried the array-returning signature the
                                  previous commit removed, so it actively misled

Also fixes the rotation log line, which read currentPeriodIndex() from inside
the loop although the index is advanced before it - on a multi-hour wake it
printed the same final value once per rotation. It now reports which of the
crossed hours is being rotated.

airtimeRotatePeriod() is kept: it has no caller in the tree either, but unlike
the above it is a defined public method, so out-of-tree callers are plausible.

Measured, not estimated: sizeof(AirTime) 464 -> 456 B, plus 64 B of globals, so
-72 B of static RAM. Padding accounts for the difference from the 66 B the plan
predicted by counting declared bytes.

The whole point of writing the tests first: the suite is green here with zero
test changes.

* Document what the AirTime figures measure and how they are stored

Comments only, but four of the things they replace were false.

The header's example analytics claimed RX_ALL_LOG was "all received lora
packets" and offered "RX_ALL_LOG - RX_LOG = other lora radios". Both radio
drivers pick exactly one of the two per packet, so they are disjoint: RX_ALL_LOG
is airtime we could not parse, the subtraction can go negative, and the total is
TX + RX + RX_ALL. Replaced with the actual contract - four inputs, eight
outputs, the window each spans, and the fact that the three thresholds are
hard-coded members rather than the settings they look like.

Names the two storage conventions on their declarations, because mixing them up
is what makes getSilentMinutes() wrong: channelUtilization and utilizationTX are
modular rings indexed by uptime phase, where the oldest bucket is (current + 1)
% N; airtimes.period* is shift-ordered with slot 0 newest, where the index IS an
age and slot 0 is a partial hour.

Defines the measurement as wall time rather than awake time, and says why: a
sleeping node still hears traffic, and per-node redefinition would make two
broadcast readings incomparable. Records that the 60s figure is published to the
mesh at >= 1h cadence, so what other nodes see is a snapshot - at LONG_FAST and
1% occupancy it reads exactly 0 in about 44% of reports - and that the contention
window it feeds moves in 20-percentage-point steps, so small errors never reach
the backoff.

Finally, states that rotation happens on access rather than on the scheduler
tick, names the test that enforces it, and leaves a TODO pointing at the plan
phases that fix the characterised accuracy defects.

* Serialise AirTime behind a lock proven by a private token

Two mechanisms solving different halves. A lock-free inner core (Windows) holds
all state and all logic; it has no lock and no way to reach one, so nesting is
impossible by construction. A private Held token takes the lock in its own
constructor and is the only thing that can be passed where a core method demands
one, so the lock cannot be forgotten either.

The rule is now uniform with no exceptions to remember: every public method
takes the lock once and delegates. In particular isTxAllowed*() lock like
everything else - before the split they could not, because they called the
public accessors and the lock is not recursive. That asymmetry was the foot-gun
the previous design documented in prose and hoped nobody would trip.
getPeriodsToLog()/getSecondsPerPeriod() still take no lock; they return
compile-time constants and touch no state.

channelUtilization[] and utilizationTX[] were public, so the lock was bypassable
at compile time. They move into the private core. Four test sites reached in;
all four now use logAirtime() plus the virtual clock, and no new test seam was
needed. Nothing in src/ was affected.

The re-entry assert is guarded on PIO_UNIT_TESTING, so it exists in test builds
only. The design sketched #ifdef DEBUG, but nothing in this tree defines DEBUG
or NDEBUG, so either spelling ships the assert to every board - and
nrf52_promicro_diy_tcxo has ~128 bytes of headroom under its 0xEA000 warm-store
cap, which the assert's strings and abort path overrun. It would have worked on
hardware, since the check runs in Held's owner initialiser and so precedes the
blocking take; the objection is that abort()ing a live mesh node is a poor trade
for a bug never seen in the field. Native tests are where it earns its keep
anyway: Portduino compiles Lock::lock() to an empty body, so a nested take there
succeeds silently and nothing else would notice.

Also comments out ScopedBusyAirTime in test_traffic_management. It is inert
twice over: the module holds no reference to airTime at all since hop exhaustion
was shelved, and the fixture never worked anyway - writing the buckets on a
fresh AirTime is undone by the first accessor call, which takes the firstTime
branch and memsets them. It reported 0%, not the 100% it claimed. Left in place,
commented, with both reasons recorded.

Cost on the tightest board in the tree, nrf52_promicro_diy_tcxo: the six phases
together add 96 bytes of flash, leaving it 32 bytes clear of the warm-store
guard. RAM is 72 bytes lower from the dead-state removal. Suite green at 47/47,
with test_airtime unedited apart from the added nesting test.

* Count rotations with the loop variable, not a separate tally

LOG_DEBUG compiles to nothing under DEBUG_MUTE, so the counter's only read
disappeared with it and the tally became write-only. It does not warn today -
this build has -Wunused-but-set-variable on, and it fires for other locals, but
not for one that is only initialised and never read - so it was latent rather
than broken: a stricter flag or -Werror would have failed muted builds only.

Using the loop variable removes the class of problem, since the loop condition
reads it, and drops the elapsedAirtimePeriods-- mutation as a side benefit.
Same iteration count, same output.

Found by compiling nrf52_promicro_diy_tcxo with -D DEBUG_MUTE, which is worth
recording for its own sake: muting logs takes that image from 802 784 to
673 416 bytes, 98.5% to 82.6% of flash. Logging is 16% of the largest nrf52
image, and its 32 bytes of warm-store headroom are a logging-verbosity question
rather than a code-size one.

* Tighten the comments added by this branch

Comment-only: with comments stripped, all five files are byte-identical to the
previous commit.

Removed the references to the planning notes. Those documents are working
material and will go stale; the code should not depend on them. The five
CHARACTERISATION tags now describe the defect they pin and stop there, and the
accuracy TODO names the four defects and points at the tests instead of a plan
file.

Also removed, as noise rather than information:
  - comparisons against pre-#11291 behaviour, which nobody reading this needs
  - a comment describing the lock restructure as future work, written before it
    landed
  - speculation ("plausible", "worth pinning so a future...")
  - an aside arguing with an arithmetic slip made while writing the test

Kept the mechanical facts that are slow to re-derive: the two storage orderings
and which array uses which, RX_LOG/RX_ALL_LOG disjointness, the locking rule and
the addSpanned() constraint that protects it, why the re-entry assert is
test-only, and the concrete numbers - (N-1)p + phase, 14 164 ms, the 20 pp
contention-window steps.

Net 16 comment lines out of src/, 33 out of test/.

* Gate the AirTime re-entry check on the host, not on testing

PIO_UNIT_TESTING is injected by PlatformIO purely on BUILD_TYPE, with no
platform check, so it is defined on an on-target `pio test` run too. The
check arms before the lock is taken - a nested take blocks forever, so a
later check would never run - which under preemption false-positives on
legitimate contention and races on its own write.

Derive AIRTIME_REENTRY_CHECK once from PIO_UNIT_TESTING && !HAS_FREE_RTOS
and use it at all three sites. Had the three conditions ever diverged, an
on-target test build would fail to compile on a member the header no
longer declares.

* Log AirTime outside the lock it serialises

DEBUG_PORT.log() blocks on a UART write, and `lock` is a plain binary
semaphore with no priority inheritance, so holding it across a log call
lets the main thread stall the radio thread in getTxDelayMsec().

Move logAirtime()'s LOG_DEBUG into the shell, after the Held scope
closes; the shell already has both arguments, so nothing has to be
passed back out of the core. isTxAllowed{ChannelUtil,AirUtil} read into
a local under the lock and warn after it. The log bodies are braced
because LOG_DEBUG compiles away under DEBUG_MUTE and a bare `if (x) ;`
trips -Wempty-body.

Fold the two doubled index calls into `+=` while touching the lines.

* Give each airtime report its own buffer

handleReport() reused one array across the three airtimeReport() calls
and ignored the bool. A failed report would have left the previous
type's data in place and emitted it under the next type's key. Build
each through a lambda whose buffer is zeroed per call, so a failure
emits zeros.

Unreachable today - the count is always PERIODS_TO_LOG and the type is
always valid - but the old shape only read as correct by accident.

* Drop a stray semicolon from the inert-guard comment

* Address external review: name the race, tighten the claims and the tests

The header sold the lock as mechanism without naming a second thread, which
invites the reasonable objection that this is a cooperative OSThread codebase.
There is a real race and it is nRF52-only: NRF52Bluetooth registers its ToRadio
write callback with defer == false, so a phone's packet runs handleToRadio ->
sendToMesh -> Router::send on the Bluefruit BLE task, reading
utilizationTXPercent() and getSilentMinutes() while loopTask may be inside
logAirtime(). ESP32 hands BLE work to the main task and does not have it.

Three claims in the header were wrong or overstated:

  - "nesting is impossible by construction" - Windows is a nested class with an
    enclosing class's access rights, and `extern AirTime *airTime` is in the
    same header, so airTime->anyPublicMethod() from inside it is well-formed
    and would hang. Nothing does it; the assert is the backstop. Say that
    instead, because the comment below instructs contributors to add helpers
    to Windows on the strength of the guarantee.
  - "every public method takes the lock exactly once" - two constant accessors
    take none and isTxAllowedAirUtil() takes it zero or one times. State the
    exceptions where the invariant is stated, not only at the definitions.
  - "both radio drivers pick exactly one per packet" - five drop paths log
    neither. At most one. Recorded against plan4 rather than fixed here: it
    changes a telemetry value.

getPeriodsToLog()/getSecondsPerPeriod() become static constexpr, which removes
them from the locking claim structurally and lets ContentHandler size its
buffer and its count from one constant.

Tests:

  - C14's saturated AirTime is installed by a helper and restored in tearDown.
    Unity's TEST_ABORT() is longjmp and does not run destructors of automatic
    objects, so the scoped guard it replaces would leave airTime dangling into
    an abandoned frame on any assertion failure - and the same commit that
    added it removed the tearDown reset that did cover that.
  - test_getSilentMinutes_counts_minutes_until_enough_ages_out asserted only
    `mins <= 60`, which neither return path can violate. The answer is 59.
  - test_backwards_uptime_degrades_safely stepped 600s -> 60s, which leaves
    elapsedAirtimePeriods at 0, so it never reached the hourly-report branch
    its own comment describes. Step by the wrap instead and assert the exact
    figures.
  - test_airtime leaked EU_868 out of the duty-cycle case into every later one,
    and the reentry test's isTxAllowedAirUtil() coverage depended on it.
    Restore the region in tearDown and set it explicitly where it is wanted.
  - Rename that test to what it can actually check: no single method takes the
    lock twice. The calls are sequential, so it cannot catch two methods
    nesting.

* trunk: suppress trufflehog/Lob false positives in test_airtime

* Address CodeRabbit review: the rotate trace, the cap warn, the backoff

Four findings from the CodeRabbit pass. Two were introduced by this branch,
one is a real inconsistency it inherited, one is a naming slip.

The rotate trace was the one that mattered. "Log AirTime outside the lock it
serialises" moved the per-packet lines and the two TX-gate warnings out to the
shell, but missed LOG_DEBUG("Rotate airtimes, crossed hour %u") because it does
not sit in the shell at all: it is inside Windows::syncNow(), the lock-free
core, which by construction only ever runs under Held. Nothing at that line
looks like a lock, which is why it survived.

The exposure is smaller than the review suggests - runOnce() syncs at 1 Hz, so
in steady state this is one line an hour, and the PERIODS_TO_LOG - 1 burst
needs an hour of light sleep with no intervening sync - but a UART write under
a plain binary semaphore with no priority inheritance is exactly what the
comment above logAirtime() says this code does not do. syncNow() now
accumulates crossings in rotationsPendingLog and runOnce() drains it inside the
Held scope, then logs after release. Any caller can cross an hour; only that
thread reports it, so a crossing raised elsewhere is traced at most one tick
late. The `if (rotations > 0)` guard keeps the drained value read under
DEBUG_MUTE, where LOG_DEBUG expands to nothing - the write-only tally that
"Count rotations with the loop variable" removed.

addFromContact()'s favorite fallback stamped silently when the protected cap
refused it. The stamp is new on this branch; the two sibling refusals (ignore,
verify) both emit PROTECTED_CAP_WARN_FMT, so the operator lost the only signal
that the cap was hit on the one path that has a fallback.

lfs_assert() mixed clocks: Throttle read Time::getMillis(), the remainder was
computed from a second, bare millis(). The review's stated failure mode - a
native test overriding the clock - cannot happen, since the hook is behind
PIO_UNIT_TESTING and this file is nRF52-only. The real defect is the second
read: a tick landing on the 20-minute boundary between the check and the
subtraction underflows the remainder into delay(~50 days), on a device that has
just found its flash corrupt. One read, clamped, and preFSBegin() stores from
the same clock.

The eviction test is renamed to
test_eviction_prefersCurrentBootStampOverPost2038Epoch. The finding is right
that it was snake_case, but the suggested testEvictionPrefers... does not match
this file either, which is test_<area>_<camelCase> throughout.

Not taken, both pre-existing and out of scope for a rollover branch:

  - t5s3_epaper's touchResumeAtMs/suppressFromMs read an active suppression as
    inactive if the wake lands in the 1 ms where millis() is 0. Consequence is
    one skipped 150 ms touch-settle window per 49.7-day wrap.
  - NRF52Bluetooth::onPairingPasskey() busy-waits 30 s in a BLE callback. Worth
    saying plainly that this branch makes it more visible: the old
    `millis() < start_time + 30000` overflowed at the wrap and cut the wait
    short, so the correct Throttle form is what lets it run the full 30 s.
    Reworking it into an OSThread is its own change.

Native suite GREEN, 48/48, 672 cases.
2026-08-13 13:12:14 -04:00
Tom 6745995442 docs: move the firmware design docs to the documentation site (#11488)
The five documents under docs/ were written in this repo while their features
were developed. Four of them describe shipped, upstream behaviour and belong on
meshtastic.org, where users and client authors will look for them:

  traffic_management_module.md    -> configuration/module/traffic-management
                                    + development/reference/traffic-management-internals
  node_info_stores.md            -> development/reference/node-info-stores
  mesh_beacon_module.md          -> configuration/module/mesh-beacon
                                    + development/reference/mesh-beacon-internals
                                    + development/device/mesh-beacon-client-interface
  lora_region_preset_compatibility_client_spec.md
                                 -> development/device/region-preset-compatibility

Each is split by audience: settings pages carry the config surface in user
terms, reference pages carry firmware mechanism, and the device pages carry the
protocol a client app speaks. The region-preset spec always said it should
graduate out of this repo once its protobuf landed upstream, which it has
(FromRadio.region_presets, field 19).

nexthop-routing-reliability.md is not documentation - it is a working document
with a mitigation plan, a "files to modify" list and commit sequencing. Its
mitigations shipped in #10745, so the plan is history and the analysis is
superseded; it is dropped rather than published.

Comments that cited the deleted files now point at the published pages, and the
NextHop test header cites #10745 instead of the deleted plan.
2026-08-13 16:25:03 +02:00
hackengineerandClaude Fable 5 c5d3321a41 Fix unterminated MyNodeInfo.pio_env when APP_ENV is 40+ chars (#11468)
strncpy does not null-terminate when the source fills the destination.
A PlatformIO environment name of 40 or more characters leaves pio_env
unterminated, nanopb aborts the whole MyInfo encode with 'unterminated
string', getFromRadio() returns 0 bytes forever, and the client app never
receives any config after want_config_id.

Clients that receive a redacted MyInfo (pio_env cleared before encode) are
unaffected, which makes the failure look client-specific when it is not.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:11:11 +00:00
Andrew Yong 210014e81f feat(stm32wl): add ST Nucleo-WL55JC variant (#11384)
STM32 Nucleo-64 development board with STM32WL55JC MCU, SMPS,
supports Arduino and ST morpho connectivity.

https://www.st.com/en/evaluation-tools/nucleo-wl55jc.html

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-08-13 11:06:37 +00:00
Ben MeadorsandThomas Göttgens fa031c95dc perf(crypto): stop heap-allocating a cipher object per packet (#11462)
* perf(crypto): stop heap-allocating a cipher object per packet

encryptAESCtr() constructed a fresh CTR<AES128/256> on the heap for
every call - once per encrypted transmit and once per channel decrypt
attempt on every received encrypted packet. On the platforms that use
this base implementation (STM32WL, RP2040, nRF54L15, portduino) that
is avoidable per-packet malloc/free churn on small heaps.

Reuse lazily-created singletons instead. Safe for the same reason the
function's static scratch buffer already is: every caller serializes
under cryptLock, and setKey/setIV reinitialize the cipher state each
call. Lazy heap pointers rather than static objects so ESP32/nRF52
(which override this method) never reserve the RAM.

* Improve comments in encryptAESCtr function

Refactor comments for clarity and conciseness in AES-CTR encryption implementation.

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-13 09:27:05 +02:00
oscgonferandThomas Göttgens a65d9aef39 Add ADS1X15 ADC (#9846)
* Add ADS1X15 class
* Initialize bus and address in ADS1X15Sensor
* Initialize member variables and pass bus to ads1x15 begin object
* More register values options for ADS1X15
* Mark constructor as explicit
* Move ADS1X15 from PowerTelemetry to EnvironmentTelemetry:
* Adds ADS to Environment Telemetry
* Adds possibility to use template function with multiple devices of the same type on the same bus with different addresses
* Moves moduleConfig dev overrides to i2cScan function
* Make logs in env telemetry only show what has been collected
* Fix channel naming and logging
* Remove scannerToSensorsMap for ADS1X15
* Fix ADS1X15 reclock
* Fix merge
* Trunk format issue
* Set port
* Remove status overrride
* Return status on boot
* Set data rate as per coderabbit request.
* Add define for ADS type and use it to set SPS
* Add object based on define

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-13 09:26:45 +02:00
Andrew YongandJonathan Bennett b4c2eb0b78 refactor(led): generalize LED_LORA init from ThinkNode-M7 (#11437)
Migrate LED_LORA init to match existing LED init patterns.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-08-13 08:31:39 +02:00
Ben Meadors 4296d5d584 fix(eth): free partially initialized TLS contexts on init failure (#11452)
* fix(eth): free partially initialized TLS contexts on init failure

initTlsContext() inits the four mbedtls contexts and populates them step
by step, but every failure return left the already-parsed material
(X.509 chain, EC key, ssl config) allocated. Since tlsReady is only set
after full success, deInitEthTlsApiServer()'s cleanup - guarded by
if (tlsReady) - could never reclaim that partial state, and runOnce()
hard-fails with the contexts stranded for the life of the process. Also
reachable via the cert-regeneration path when a DHCP lease change bumps
the cert generation and the rebuild fails.

Factor the four frees into freeTlsContexts() (safe on init-but-
unpopulated contexts) and call it from every initTlsContext failure
return; deInit now uses the same helper.

* docs(eth): shorten freeTlsContexts comment per review
2026-08-12 19:38:04 -05:00
Ben Meadors d4de61362b fix(mesh): plug pooled-object and driver leaks in core paths (#11450)
- MeshService::sendQueueStatusToPhone: release the pooled QueueStatus
  when the toPhone queue enqueue fails, matching what
  sendMqttMessageToClientProxy and sendClientNotification already do.
  The full-queue guard makes this failure rare, but the check/enqueue
  sequence is not atomic and this path is reachable concurrently from
  the main loop and the nRF52 BLE write callback; each failure
  permanently lost one of the four pool slots, and after four losses
  the phone never receives QueueStatus again until reboot.

- MessageStore::storeTextInPool: bail out when the boot-time pool
  allocation failed instead of memcpy'ing through a null pointer. The
  read side (getTextFromPool) already guards and maps offset 0 to an
  empty string.

- RF95Interface: hold the RadioLibRF95 driver in a unique_ptr. It is
  constructed in init(), and when init() subsequently fails (e.g. chip
  probe NOT_FOUND) initLoRa() destroys the interface, leaking the
  driver; every sibling interface holds its driver by value so nothing
  else needed a destructor here.
2026-08-12 19:14:18 -05:00
Ben Meadors 22079ca863 fix(platform): OOM null-write in stm32wl File and exception leak in portduino GPIO init (#11456)
- STM32_LittleFS File::_open_dir: the _dir_path allocation was the only
  unchecked malloc in the file, followed immediately by strcpy - an OOM
  became a NULL write, and the half-initialized state (open dir, null
  path) would later feed strlen(NULL) in openNextFile(). Check it and
  unwind the already-opened dir, matching the sibling failure path.

- PortduinoGlue initGPIOPin: if setSilent()/gpioBind() threw after the
  LinuxGPIOPin was constructed, the pointer was lost in the catch
  block. Hold it in a unique_ptr and release only after the gpio table
  takes ownership.
2026-08-12 19:13:07 -05:00
Ben Meadors 0739d2a68c fix(raspihttp): memory safety and error-path leaks in PiWebServer (#11451)
- handleAPIv1ToRadio: the toRadio handler memcpy'd a fixed 512 bytes
  out of the request body regardless of its actual size. ulfius
  allocates binary_body at exactly binary_body_length bytes and leaves
  it NULL for a body-less PUT, so short bodies caused a heap overread
  and empty ones dereferenced NULL. The unclamped length (ulfius
  accepts up to 1024) was also handed to handleToRadio, reading past
  the 512-byte stack buffer. Clamp both directions, matching the ESP32
  ContentHandler behavior.

- callback_static_file: the stream-free callback only runs when
  ulfius_set_stream_response succeeds, so the failure branch leaked the
  FILE (and its fd) on every failed request. Close it and return 500.

- CheckSSLandLoad: free cert_pem before the missing-key return; the
  constructor retries after regenerating certs and the reload
  overwrote (leaked) the first buffer.

- generate_rsa_key / CreateSSLCertificate: free the EVP_PKEY_CTX,
  EVP_PKEY, and X509 on their error returns; previously only the
  success paths released them.
2026-08-12 19:12:32 -05:00
Jason P 10217172f0 Fixed GPS icon alignment for all OLEDs (#11463) 2026-08-12 18:51:07 -05:00
Ben Meadors d27d0c4a7f fix(detectionsensor): release unsent packets and drop heap scratch buffer (#11449)
Both sendDetectionMessage() and sendCurrentStateMessage() allocate a
packet from packetPool and then, when the primary channel is the
public/default channel, log and return without sending or releasing it.
Each refused send permanently leaks one packet from the fixed-size pool;
with state_broadcast_secs configured, the heartbeat path repeats this on
a timer until the pool is exhausted and the device can no longer
allocate packets at all.

Release the packet in the refusal branch, matching the pattern used in
PositionModule. Also replace the per-message 'new char[40]' scratch
buffer with a stack buffer (and sprintf with snprintf), removing the
manual delete[] bookkeeping on every exit path.
2026-08-12 18:26:27 -05:00
Ben Meadors a41ddec1a7 fix(nrf54l15): don't write past String buffer when a grow fails (#11454)
* fix(nrf54l15): don't write past String buffer when a grow fails

reserve() correctly keeps the old buffer when realloc returns NULL, but
returned void, and assign()/concat() proceeded to memcpy with
n >= _cap anyway - a heap overflow of up to n+1-_cap bytes into
adjacent allocations. On this Zephyr target allocation failure is a
realistic condition, and the result was heap corruption instead of a
clean no-op.

reserve() now reports success and the callers leave the string
unchanged when the grow fails.

* fix(nrf54l15): guard String length arithmetic against wraparound

Per review: reject size requests whose n+1 / _len+n arithmetic would
wrap before they reach the capacity check, and make reserve(0) fail
without calling realloc (realloc(p, 0) would free the buffer and
return NULL, leaving _buf dangling).
2026-08-12 18:17:33 -05:00
Ben Meadors 5172e58526 fix(nimble): stop leaking BLE2904 descriptor on BLE re-setup (#11453)
setupService() re-runs on every Bluetooth re-enable cycle, and every
other callback object in it is deliberately function-local static for
exactly that reason - the battery level descriptor was the one heap
allocation that was missed. The framework never frees descriptors
(~BLECharacteristic has an empty body and BLEDevice::deinit only
deletes server/advertising/scan), so each cycle leaked one BLE2904.
Make it static like its neighbors.
2026-08-12 18:15:18 -05:00
Jason P f06f808391 Add Transmit Enabled menu to LoRa frame (#11442)
* Add Transmit Enabled menu to LoRa frame

* Fix outer variable shadows
2026-08-12 21:59:26 +00:00
fdb644e0b7 Fix millis() rollover in deadline, interval, and timestamp handling (#11291)
* Add native test coverage for the UptimeClock monotonic seam

src/UptimeClock.{h,cpp} shipped without a dedicated test suite. Port the six
tests from the monotonic-time branch (test/test_time), retargeted to the
renamed header.

The wrap test crosses 0xFFFFFFFF via advanceTestMillis() rather than a second
setTestMillis(): setTestMillis() sets clockSourceChanged, which makes
getMillis64() rebase its accumulator and swallow the wrap.

* NextHopRouter: fix 49.7-day millis() rollover in retransmission timing

Resolves the "FIXME, handle 51 day rolloever here!!!" in
NextHopRouter::doRetransmissions() by switching the retransmission-due
comparison from plain unsigned <= to a signed-difference cast.

The previous p.nextTxMsec <= now comparison silently breaks across the
~49.7 day millis() wraparound: pending retransmissions either stall
for the remainder of the wrap window, or all fire simultaneously at
the rollover boundary. Long-running router/infrastructure nodes do hit
this in practice.

The replacement (int32_t)(p.nextTxMsec - now) <= 0 is the standard
Arduino/embedded idiom for rollover-safe deadline checks and behaves
identically to the original for any non-wrap timing.

* Address Copilot review: use unsigned half-range for rollover-safe retransmit check

Review feedback from @Copilot on PR #10227: casting a uint32_t
subtraction to int32_t is implementation-defined in C++ when the
unsigned value exceeds INT32_MAX (even though it works on typical
two's-complement targets).

Switch to the fully well-defined unsigned half-range form:
  nextTxMsec is in the past-or-equal iff (now - nextTxMsec) has not
  wrapped past 2^31 ms. Future offsets < 2^31 ms wrap into the top
  half and read as 'not yet'.

Same semantics as the signed-cast version on every two's-complement
platform we care about, but portable to any conforming C++ impl.

* Use monotonic time for airtime windows

* Document monotonic airtime windows

* Fix test_packet_signing sentinel that #10227's rollover fix inverts

test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state parked a
pending packet at nextTxMsec = UINT32_MAX to mean "never retransmit", then
asserted that a rejected repeated packet leaves the retry state untouched.

NextHopRouter::doRetransmissions() now tests whether a retransmit is due with
an unsigned half-range compare, (uint32_t)(now - nextTxMsec) < 0x80000000u,
so that retransmission timing survives the ~49.7 day millis() wrap. Under it
now - 0xFFFFFFFF == now + 1, a small positive delta, so UINT32_MAX reads as
~1ms in the past: the retransmit fires and rewrites nextTxMsec, and the test
failed with "Expected 4294967295 Was 6247".

Use a representable future time instead. Production is unaffected either way -
nextTxMsec is only ever written as millis() + d, and UINT32_MAX came from the
test harness alone - so the sentinel is what needs to go, not the comparison.
Special-casing UINT32_MAX in the retransmit path would keep a value that reads
as "expired" under any wrap-correct compare.

The value is held in a local because millis() advances across
runPipelineIngress(), so recomputing it at the assertion would compare against
a different number.

Reported upstream on meshtastic/firmware#10227, whose branch predates this test.

* Make Throttle time-injectable and add hasElapsed()

Throttle backs ~94 call sites, which makes it the highest-leverage place in
the tree to put the clock seam: reading Time::getMillis() instead of millis()
in its three call sites turns all of them into time-injectable code at once,
without touching any of them. The 32-bit millis() wrap is not otherwise
reachable from a native test.

The read is behaviour-preserving - Time::getMillis() returns millis() unless a
test injects a clock - and the full native suite passes with it live.

Also add hasElapsed(), the complement of isWithinTimespanMs(), because 51 of
the 94 call sites are spelled !isWithinTimespanMs and read poorly. Its
boundary is inclusive (>=) since isWithinTimespanMs uses <; both are
documented. It deliberately does not treat lastExecutionMs == 0 as "never
run": call sites pair that test with the interval check themselves, and
absorbing a sentinel into the one helper every module depends on is exactly
the value-overloading hazard being removed elsewhere.

Migrating the existing !isWithinTimespanMs sites is cosmetic and deliberately
left out of this commit.

test/test_throttle/ covers window semantics, both boundaries, the complement
identity, execute()'s first-run and throttled paths, and - the point of the
exercise - a window opened before the wrap closing correctly after it,
including at the 24h interval that is the longest in the tree.

* Stop disarmed deadline sentinels reaching the comparison

Two deadline variables encoded "inactive" as a magic value that only reads as
"never" because the comparison against it is a naive millis() compare. Under
any rollover-correct comparison both invert to "expired ~49 days ago", so they
have to be untangled before those comparisons can be fixed.

Power::reboot() set rebootAtMsec = -1 on platforms with no reboot
implementation, intending "never fire". Every reader already treats 0 as the
disarm value - powerCommandsCheck() tests `if (rebootAtMsec && ...)`, and
AdminModule writes 0 to cancel - so -1 was both wrong and unnecessary. Use 0.
Left as UINT32_MAX it would reboot-loop the moment the comparison is corrected.

ExternalNotificationModule's nag window compared against nagCycleCutoff, which
holds UINT32_MAX once stopped and 1 at boot. isNagging is the real armed flag,
so test it first and short-circuit: a disarmed cutoff can no longer reach the
arithmetic, while an idle module still takes the same sleep path that the
boot-time value of 1 was relying on.

Note this fixes the sentinel only. The comparison itself is still a naive
`nagCycleCutoff < millis()` and remains on the list to convert.

* Fix millis() rollover in every deadline and interval comparison

Roughly 20 sites compared against millis() directly - `millis() > deadline`,
`deadline < millis()`, `last + interval < millis()`. All of them break for
about 24 days after the 32-bit millis() wrap: depending on which side of the
wrap each value sits, the action either stalls for weeks or fires immediately
and repeatedly. The longest affected interval is the 12 hour NTP renewal, a
~50x margin against the wrap, so none of these needed the range - only the
correct comparison.

Add Throttle::deadlinePassed(deadlineMs) for sites that store an absolute
deadline they cannot re-express as "interval since an event". It uses the same
unsigned half-range test as NextHopRouter::doRetransmissions() rather than
introducing a competing signed-cast idiom, and unlike the signed cast it is
defined for every input. Sites that do store an event use the existing
isWithinTimespanMs / hasElapsed. Nothing gained new state.

Because both helpers read Time::getMillis(), every converted site is now
reachable from a native test that drives the clock across the wrap; the
comparison itself is covered directly in test/test_throttle/.

Sentinel handling is the reason this could not be a mechanical rewrite. The
disarm convention is not uniform: 0 means "inactive" for rebootAtMsec,
shutdownAtMsec, alertBannerUntil, fixHoldEnds, suppressUntilMs and
touchResumeBlockUntilMs; 0 means "due now" for ntp_renew, which is forced to 0
at link-up; UINT32_MAX means "inactive" for nagCycleCutoff; and
alertBannerUntil == 0 in isOverlayBannerShowing() means "show indefinitely".
Every inactive marker is arithmetically far in the past, so a correct
comparison fires on it - each site tests its sentinel before the arithmetic,
and keeps the meaning it had.

Two sites carried a second bug found on the way:

BME680Sensor tested (stateUpdateCounter * STATE_SAVE_PERIOD) < millis(). With
a 6 hour period and a uint16_t counter that product overflows uint32_t after
about 198 saves, independently of the millis() wrap. It now measures the
interval since the last save.

EInkDynamicDisplay had `if (previousRunMs > millis()) return;` as a millis()
overflow guard, which skipped rate limiting entirely for the whole post-wrap
period - the bug it meant to prevent. Every check below it already goes
through Throttle, so the guard is removed rather than fixed.

MotionSensor's calibration countdown is converted to a signed delta rather
than deadlinePassed, because it needs the remaining magnitude and not a
boolean; that matches the already-correct check in the same file.

* Remove getMillis64() and use Throttle for the NodeInfo reply window

getMillis64() had exactly one caller and no callers in tests. It also carried
obligations that made it the wrong shape for this firmware: a wrap accumulator
in mutable statics, which is not ISR-safe, and which must be polled at least
once every ~49.7 days or it silently misses a wrap and returns a time ~49 days
short.

Its one caller only wanted to know whether a 12 hour suppression window had
elapsed - which Throttle answers correctly across the wrap without any
accumulator. NodeInfoModule now stores Time::getMillis() in lastNodeInfoSeen
and tests the window with Throttle::isWithinTimespanMs, so the map holds
milliseconds rather than seconds derived from a 64-bit read.

USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS is user-overridable and now feeds a
multiply by 1000, so a static_assert rejects any value too large to express in
milliseconds instead of letting it wrap.

clockSourceChanged goes too. It existed solely to rebase getMillis64()'s
accumulator when a test swapped clock sources, and it made the wrap untestable
through the injection API: setTestMillis() set the flag, so a wrap crossed by
two setTestMillis() calls was swallowed. With the accumulator gone the flag has
nothing to rebase, and the injection API is a plain settable clock.

The three getMillis64 tests are dropped as they no longer describe anything.
One test replaces them, pinning that advanceTestMillis() wraps past
0xFFFFFFFF rather than saturating, since the Throttle wrap tests rely on it.

Also fix eviction in pruneLastNodeInfoCache(): it picked the entry with the
smallest stored stamp, which is the wrong victim once some stamps sit on the
far side of the wrap. It now evicts the largest elapsed time.

* Add CI guard and docs rule against naive millis() comparisons

Fixing the existing sites does not stop the next one being added. The
millis-deadline-check job rejects millis() placed directly next to a comparison
operator, in either order, anywhere in src/. It lives in test_native.yml
alongside suite-count-check, which sets the precedent for a repo-hygiene guard
that CI enforces and bin/run-tests.sh does not.

The correct idioms all subtract before comparing, so none of them match the
pattern. Line comments are stripped first, so documentation is free to name the
broken form - as the guard's own comment and the coding conventions both do.

Writing the check before finishing the sweep turned out to be worth it: it
found roughly 14 sites that a by-hand audit of deadline variables had missed,
including two extra nagCycleCutoff compares, both boot-screen timeouts, and a
6 hour sensor save interval that was also overflowing a uint32_t multiply.

.github/millis-deadline-allowlist.txt covers the cases that are genuinely not
deadline tests. Both current entries are uptime thresholds - "has the device
been up N ms" - with no stored deadline and no event to measure from: a 30s
button holdoff against phantom shutdown from floating pins, and a 10s window
for the OEM boot logo. Each re-crosses its threshold once per wrap, which is
harmless for boot-holdoff logic and not worth new state to avoid. Entries are
keyed on file plus exact source text, without line numbers, so an edit above an
entry does not silently invalidate it.

Locally the guard reports 19 matches before the sweep and 2 after, both
allowlisted.

The Throttle bullet in the coding conventions is rewritten from "prefer
Throttle for rate limiting" to "never compare against millis() directly", lists
all four helpers with when to use which, names the CI guard, and documents the
sentinel hazard with the rebootAtMsec = -1 case that would have become a reboot
loop. Mirrored into AGENTS.md; CLAUDE.md gets a pointer row.

* Trim rollover comments to what the code needs

The comments added with the millis() rollover fixes carried too much of the
investigation that produced them: how many sites were found, which document
recorded them, what the old code used to do. That belongs in the commit history,
not in the source, and some of it was already stale - Power::reboot() still
described the check it disarms as "a naive millis() > deadline" when that
comparison had been fixed in the same series.

What stays is the non-obvious part at each site: which sentinel value the
variable overloads and what it means there, since that differs between call
sites and is what a correct comparison gets wrong. 0 means "not scheduled" for
rebootAtMsec, "renew now" for ntp_renew, and "show indefinitely" in
isOverlayBannerShowing().

Exposition is kept where it earns its place: the Throttle helpers, the uptime
clock's note on why there is no 64-bit variant, and the tests. The Throttle
docs lose only the site count and the "longest interval in the firmware"
statistic, both of which would age badly; the range trade-off between the two
forms is what a caller actually needs.

Comments only - no code changed, verified by diff.

* possible fixes

* Address review feedback on the rollover fixes

- BME680Sensor: checkpoint lastStateSaveMs after a successful write instead of
  at the interval test. The first save (IAQ accuracy >= 2) left it at 0, timing
  the next save from boot, and stamping before the write deferred the retry a
  full period when the write failed. Reads Time::getMillis(), the same clock
  Throttle compares against.

- Throttle: add deadlinePassedAt(now, deadline) for loops that snapshot the
  clock once and test many deadlines; deadlinePassed() now delegates to it.
  NextHopRouter::doRetransmissions() uses it, replacing the inline half-range
  compare adopted from #10227 (nightjoker7) - same arithmetic, credited at the
  call site - and takes its snapshot from Time::getMillis() so setNextTx()
  deadlines and the due test cannot diverge under an injected test clock.

- test_native.yml: set -euo pipefail in the millis-deadline guard, matching the
  sibling suite-count job. Without -e a partially failed scan could report "no
  violations" from truncated output.

- test_packet_signing: build the not-due deadline from Time::getMillis() rather
  than millis(), so the test and the router read one clock.

- test_throttle: cover deadlinePassedAt(), and correct a wrapped-value comment
  (0xFFFFFF00 + 400 is 0x00000090, not 0x00000094).

Two review comments were declined: the AirTime mutex (every airTime-> caller
runs in the single cooperative loop, WebServerThread included) and the
MotionSensor 0-sentinel countdown (the calibration frame is only installed
while a window is open).

clod helped out here

* Correct the described failure window of a naive millis() compare

The comments and agent docs said a bare `millis() > deadline` "breaks for ~24
days after the wrap". That figure belongs to the fix, not the bug: it is the
half-range limit of deadlinePassed(), which reads deadlines more than 2^31 ms
ahead as already passed, and the range over which a UINT32_MAX sentinel reads
as passed.

The naive compare's actual failure is an inversion lasting only while the
deadline sits on the far side of the wrap, so it is bounded by the interval:
the action fires immediately and loses its wait, or blocks for about the wait
it should have performed - days for the nRF52 flash-corruption backoff,
one skipped cycle for a seconds-long retransmit timer.

Comments and docs only; the ~24.8 day statements that correctly describe
deadlinePassed()'s own range are left as they were.

clod helped out here

* Restore a monotonic uptime clock and consolidate the wrap counters

Time::getMillisMonotonic() is the getMillis64() shape - a 32-bit wrap
counter carried across reads - promoted to the shared timebase, with
Time::getUptimeSecs() as the derived whole-seconds view. This deliberately
reverses the earlier removal of getMillis64(), and the distinction matters:
removal was right for a lazily-read accumulator with one rare caller, where
a 49.7-day gap between reads silently swallowed a wrap. Here every read is
the poll and AirTime::runOnce() guarantees one per second; the missed-wrap
contract is pinned by a test rather than left as a footnote.

Three private wrap counters collapse into it:

- AirTime::syncNow() takes its seconds from Time::getUptimeSecs() and drops
  its lastSyncMsec checkpoint; window rotation is unchanged.
- DeviceTelemetryModule loses refreshUptime()/uptimeWrapCount/uptimeLastMs;
  uptime_seconds comes from Time::getUptimeSecs(), which also removes the
  0.296s-per-wrap truncation of (0xFFFFFFFF / 1000) * wraps. Its two
  interval checks move to Throttle::hasElapsed().
- HostMetricsModule's copies of those members were never read (its uptime
  comes from /proc/uptime) - deleted.

Not ISR-safe (unguarded mutable carry): ISRs keep using getMillis(), which
stays a pure read. Audited: no interrupt-context file reads getTime(),
getValidTime(), or the new accessors.

test/native-suite-count 44 -> 45: the bump for test_uptime_clock was lost
in a branch history rewrite, leaving every later value off by one -
run-tests.sh reports AMBER and CI's suite-count-check fails on the current
push until this correction.

* Anchor the wall clock in monotonic milliseconds

getTime() computed elapsed-since-time-set as a 32-bit millis() delta, so a
node that took time once and stayed up past 49.7 days reported a wall clock
one full cycle in the past - and last_heard, rx_time, message and position
stamps all inherited it. The anchor is now the 64-bit monotonic count
(timeStartMsec -> timeStartMs64) and the elapsed term is computed in 64-bit,
so the wall clock is exact at any uptime.

All six anchor writers follow: the five hardware-RTC read branches and
perhapsSetRTC(), which keeps a truncated 32-bit copy of the same instant for
its Throttle-checked rate-limit stamps. The test seams anchor the same way.

Two native regression tests drive getTime() across the wrap through the
Time seam - one anchored before the wrap and read after it, one anchored
after a counted wrap - with the test epoch derived from BUILD_EPOCH so the
plausibility window cannot rot as the build date advances.

* Stamp the rx_time placeholder in monotonic uptime seconds

computeRxTimeStamp() stamped Time::getMillis() when the clock was untrusted,
and reconcilePendingRxTimes() back-calculated with a 32-bit millis() delta -
correct within one wrap, but a placeholder older than 49.7 days aliased to a
small elapsed value and reconciled to a plausible-but-wrong recent epoch:
the exact failure has_rx_time exists to prevent, reachable by an ordinary
unattended router whose phone connects two months in.

The placeholder is now Time::getUptimeSecs(). Both stamps come off the
monotonic counter, so the elapsed term is exact at any age and the aliasing
window is gone outright rather than widened. If elapsed somehow exceeds the
epoch itself, the packet stays un-dated (absent, never wrong) instead of
clamping to a pre-1970 value. Defence in depth: a placeholder that leaks
needs ~50 years of uptime to cross MIN_PLAUSIBLE_EPOCH, where milliseconds
took 18.3 days.

The stream-API reconciliation tests keep their scenarios with the placeholder
unit switched, and ScopedTimeFixture resets the monotonic carry so uptime
seconds are deterministic per case.

* Date nodes heard before the clock arrives, without polluting last_heard

A node first heard while the wall clock was untrusted got no last_heard at
all, and nothing backfilled it once time arrived - the phone showed "Last
heard: unknown" for a node it had just announced. The arrival instant now
waits in a RAM-only sidecar (NodeNum -> uptime seconds, 32 slots,
reuse-oldest - the RouteHealth shape) and is converted to a real epoch on
the clock-becoming-trusted transition, beside the existing rx_time
reconciliation. last_heard itself never holds anything but a real epoch or
0: it persists to flash and the warm tier, where an uptime-relative value
would be meaningless after reboot.

The sidecar's write sites are updateFrom()'s no-trusted-clock path (the
rx_time placeholder already carries the arrival instant, so this is a store,
not a second clock read) and addFromContact's anti-eviction stamps, which
previously wrote a bare getTime() - boot-relative seconds on a clockless
node, the exact value lastHeardIsWallClock() exists to catch. Eviction
ranking honours the stamps: heard-this-boot outranks every stored epoch,
ordered among themselves, so a stamped contact is not the first victim.

PhoneAPI re-reads last_heard at nodeinfo send time: a record prefetched
before the clock became trusted can carry 0 while the store has since been
backfilled, and re-reading at the pop makes handshake ordering (time-set vs
node-list download) irrelevant. Backfill never moves last_heard backwards
and skips the pathological elapsed-exceeds-epoch case. A node evicted to
the warm tier before time arrives is still absorbed with last_heard 0 -
same as before, bounded to the untrusted window.

* Update the agent docs for the monotonic timebase

The conventions bullet asserted there is deliberately no 64-bit millis; the
monotonic uptime clock restored for timestamps changes that contract. State
the split explicitly: Throttle for deadlines and intervals (no carry state),
Time::getMillisMonotonic()/getUptimeSecs() for timestamps, polled by
construction and not ISR-safe.

* Publish the monotonic wrap carry from a single writer

getMillisMonotonic() was a read-modify-write on two unguarded statics, and it
is reached off the main loop: the nRF52 Bluefruit task via
onFromRadioAuthorize() -> PhoneAPI::getFromRadio -> getValidTime(), and the
portduino civetweb workers via the same path. Two readers interleaving inside
the wrap window could each increment the carry, putting every uptime and
wall-clock reading 2^32 ms ahead for the rest of the boot - a permanent ~49.7
day jump in rx_time, last_heard and ClientNotification.time.

Readers no longer write. serviceMonotonic() publishes a snapshot behind a
seqlock and is the only writer; a reader adds its own unsigned elapsed time to
that snapshot, which is exact across the wrap, so it never inspects the
boundary and cannot miscount it. The main loop publishes every iteration, so
the once-per-49.7-days obligation now has the whole window of margin instead of
resting on an instruction-wide race.

AirTime was the guaranteed poller and is now a pure reader, so the two airtime
wrap tests step the clock the way loop() does. The test clock itself is atomic
so a suite can drive it from one thread while others read.

* Re-arm the GPS ephemeris hold when none is in force

The rollover sweep guarded the hold re-arm with `fixHoldEnds != 0 &&`, which
reads like the sentinel rule but inverts this site. The comparison it replaced,
`(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, was always true when nothing
was armed - that was the point, since 0 means "not holding" and so is a reason
to arm. With the guard, a publish that cleared the hold without sleeping (the
`shouldPublish && !tooLong && !holdExpired` path, which does not call down())
left hasValidLocation set and prev_fixQual non-zero, so no disjunct held:
nothing re-armed, nothing published, and the receiver stayed powered at the
200ms poll until searchedTooLong() fired.

State the question positively instead. fixHoldInForce() is the only place the
sentinel is interpreted, and both of runOnce()'s decisions derive from it - the
asymmetry is now visible rather than implied, since arming does not require a
prior hold but expiring does. Its `!= 0` test is not redundant with the
arithmetic: deadlinePassed() is an unsigned half-range test, so past 2^31 ms of
uptime the sentinel reads as a deadline ~24.9 days in the future.

Kept beside its caller rather than in a header; the native test build compiles
GPS.cpp, so the suite declares the prototypes.

Also converts the getACK() wait to isWithinTimespanMs(start, interval): it has
both the start instant and the interval in hand, which gives the full 49.7-day
range instead of 24.8 days ahead, and takes its anchor from Time::getMillis()
so the wait is injectable.

* Date the NodeInfo reply window in uptime seconds

The 12h reply-suppression stamp regressed from wrap-immune 64-bit seconds to
raw 32-bit milliseconds, and pruneLastNodeInfoCache() evicts only by node count
and DB membership - never by age. A stable mesh under the node cap therefore
keeps every stamp indefinitely, and once uptime passes 49.7 days an old one
aliases back into the window: `now - stamp` computes as ~0 and a legitimate
NodeInfo request goes unanswered for up to 12h. It self-heals and repeats once
per wrap cycle.

Store Time::getUptimeSecs() instead, which does not wrap for 136 years, and
drop the millisecond conversion the previous shape needed. Entries past the
window are now evicted too: they can only ever decide "don't suppress".

N8-N11 cover the window from both sides, and N10 pins the regression - it needs
a full 2^32 ms of uptime to elapse, not merely a crossing of the boundary,
because that is when a millisecond stamp reads as "answered this instant".

tearDown() now restores the injected clock and C14's region and TX bucket. A
failing assertion aborts the test body, so restoring at the end of it leaked
that state into every later case.

* Update the agent docs for the single-writer clock and sentinel direction

Two rules the preceding three commits changed.

The monotonic clock is no longer maintained by whoever happens to read it:
serviceMonotonic() is the only writer, readers are pure, and calling it from
anywhere but the main loop reintroduces the double-count.

The sentinel guidance gained the half it was missing. It named UINT32_MAX as a
sentinel while prescribing an idiom that only covers 0, and it assumed the
sentinel always means "suppress" - at the GPS fix-hold site it meant "fire",
which is how that regression passed review looking like the rule.

* Name the fix-hold expiry predicate and arm it from the injected clock

holdJustExpired() gives the second reading of the fixHoldEnds sentinel a
name beside the first, so both are pinned by test/test_gps_fix_hold/ and
neither can be respelled at the call site. The old inline form could not
be tested: written as a literal, its guard folds at compile time and the
assertion asserts nothing.

The arm site used bare millis() while the evaluation reads the Throttle
clock; same value in production, but it kept that write out of reach of
Time::setTestMillis(). Remap a deadline that lands on 0, which would
otherwise read as no hold at all.

* Share the extend formula between the clock's reader and writer

getMillisMonotonic() and serviceMonotonic() carried byte-identical wrap
arithmetic. A one-sided edit to either would drift the published carry
from what readers report, so keep one copy.

* Trim the NodeInfo dedup comment to the house limit

* todo note for potential future imrpovments

* fix some simple deadlines

* Trim the hold-expiry test comment to the house limit

* Fix non-blocking uptime publication and pre-clock recency edges (#29)

* fix(time): avoid blocking monotonic readers

* test(time): make paused-publisher check deterministic

* fix(time): address review portability gaps

* Init the eviction sentinel to the newest possible recency

EvictionRecency{} is {0, false}, which evictionRecencyOlder() ranks as older than
every candidate: without the oldestIndex/oldestBoringIndex guards nothing would
ever be selected and a full node DB would stop evicting entirely.

Init to the genuine maximum instead, so the sentinel is correct on its own. The
index guards stay: two independent reasons the scan is right beats one.

* Keep the deadline-guard check name branch protection matches

The guard was widened to cover Time::getMillis() and unqualified getMillis(),
and renamed to suit. Upstream branch protection matches required checks by name,
so a rename means the old name never reports and merges block on a check that
will never arrive.

Widen the guard, keep the name; the descriptive text carries the broader scope.

* Correct native-suite-count to 47 after the develop merge

Upstream #11293 added test_nmea_wpl and took develop's count to 43; this branch
had independently reached 46. Merging develop resolved the counter textually,
keeping 46, while the directory set became the union of both sides at 47.

The suite-count CI gate fails on the mismatch, and it gates the native test jobs,
so the tests themselves were being skipped.

* test(uptime): make the wrap fall where the comment says it does

The concurrent-reader case started at 0xFFFFF000, leaving 0x1000 to the wrap, so
the 0x800 advance annotated "cross the wrap" fell short and the wrap actually
happened during the following 60s advance.

Start at 0xFFFFF800 instead, so the first advance lands exactly on the wrap while
the readers are running and the second is the ordinary time after it - the shape
both comments already described. Total elapsed is unchanged, so the closing
assertion still holds.

* Respond to human comments

* Did I ever tell you about the time I went to Shelbyville? I wore an onion on my belt, which was the style at the time.

* Convert the I2S nag deadline develop dragged in

The HAS_I2S_SPEAKER_NRF52 RTTTL block arrived from develop with a raw
nagCycleCutoff >= millis(), which the deadline guard rejects. Use the same
Throttle::deadlinePassed() form as the two sibling paths in this function.

* Arm the LittleFS format guard with a flag, not a zero timestamp

preFSBegin() runs in the first millisecond of boot, so millis() can legitimately
return 0 there. Both readers of last_format_ms treated 0 as "nothing formatted
this boot", which would skip the repeat-corruption escalation and let a dead
flash reformat-loop instead of reporting FLASH_CORRUPTION_UNRECOVERABLE.

* Note the single-thread contract on AirTime

* Note the AirTime locking TODO, and tighten the thread note

The two constant getters are not constrained, and getSilentMinutes() reads the
buckets without rotating them, so "the accessors mutate" was not accurate.

* trunk: ignore trufflehog false positives on millis-wrap test constants

test_throttle and test_uptime_clock pin dense clusters of hex boundary
constants (0xFFFFFF00u and neighbors) to exercise 32-bit millis()
rollover. trufflehog's Lob detector stitches nearby hex literals into
one candidate string, and the result happens to match a Lob API key
shape - not a secret, just test fixtures.

Same pattern already used for the gitleaks/nodedb-fixture false
positive in this file.

---------

Co-authored-by: nightjoker7 <mattdeering7@gmail.com>
Co-authored-by: Clive Blackledge <clive@ansible.org>
Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-12 16:49:17 -05:00
Clive BlackledgeandClaude Opus 5 db3eb91015 fix(security): never log the X25519 identity private key (#11435)
installDefaultConfig() restores a preserved identity key when the config
is reset with preserveKey=true. On that path it called:

    printBytes("Restored key", config.security.private_key.bytes,
               config.security.private_key.size);

printBytes() hex-dumps the buffer to LOG_DEBUG, so this emitted all 32
bytes of the raw X25519 identity private key to the serial/BLE debug log.

Debug logs are not a private channel. They are routinely captured over
serial or BLE and pasted verbatim into GitHub issues, Discord threads and
support requests. Anyone who reads such a log recovers the node's identity
private key, and can then impersonate the node and decrypt every PKI direct
message addressed to it -- past messages included, since the key is
long-lived and the DH shared secret is static per node pair. There is no
revocation story short of generating a new identity.

Replaced with a LOG_DEBUG that records that a key was restored and contains
no key-derived bytes. The restore/no-restore signal is the genuinely useful
diagnostic here ("did my key survive the reset?"), and it costs nothing to
keep; the bytes were never what made the line useful. Log level is unchanged
-- printBytes() already logged at LOG_DEBUG.

Deliberately NOT a truncated prefix or a hash. A prefix is still key
material: it hands an attacker free bytes and shrinks the search space.
A hash is a confirmation oracle -- it lets anyone holding a candidate key
verify it against the log, which is exactly the check an attacker needs.
Neither is a compromise; both leak. If a log line survives at all it must
carry zero key-derived bytes.

Sites changed:
  - src/mesh/NodeDB.cpp:988 -- the only full private-key dump in src/.

Audited and deliberately left alone:
  - NodeDB.cpp:3552,3604 ("Incoming Pubkey", "Saved Pubkey") -- public keys,
    published to the mesh by design; not secret.
  - CryptoEngine.cpp:245,285 -- nonces, not key material.
  - CryptoEngine.cpp:246,286 -- first 8 bytes of the derived shared_key, and
    AdminModule.cpp:2006,2013,2014 -- the 8-byte admin session passkey.
    Both are secrets rather than public values, but neither is the identity
    private key and both are out of scope for this fix; noted for follow-up.

No unused-variable fallout: private_key_temp is still read by the memcpy
above, and printBytes() is still used by the two pubkey sites, so the
meshUtils.h include is still required.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:05:20 +00:00
54d6ce833e gps: avoid pow() in GPS_HARDSLEEP threshold heuristic (#11179)
* gps: avoid pow() in GPS_HARDSLEEP threshold heuristic

GPS::down() used pow(seconds, 1.22) to pick between GPS_SOFTSLEEP and
GPS_HARDSLEEP - a curve fit the surrounding comment already describes
as "not particularly accurate". On flash-constrained builds where this
was the only pow() call site (e.g. wio-e5), it single-handedly pulled
in the full double-precision libm pow/rem_pio2 chain for a heuristic
threshold decision.

Replaces it with gpsHardsleepThresholdMs(), a piecewise-linear lookup
over the same curve, sampled at 16 points and verified to track the
original formula within ~0.5% for inputs >=10s and ~1.6% for 5-10s
(worse only in relative terms below 5s, where the absolute difference
is at most a couple of seconds - negligible against update intervals
measured in tens of seconds to hours).

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>

* gps: trim comments to repo convention (1-2 lines)

Addresses a CodeRabbit nitpick: the explanatory comments in
GPSUpdateScheduling.cpp and test_gps_update_scheduling/test_main.cpp had
grown into multi-line blocks with provenance detail that belongs in the
commit message, not inline. Trims each to 1-2 lines, keeping only the
essential rationale/bounds.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>

* Refactor main function to setup and loop for tests

Signed-off-by: Thomas Göttgens <tgoettgens@gmail.com>

* gps: extend the hardsleep threshold table below 5s and tighten its tests

The 0s-to-5s chord read 42% high at 1s, 22% at 2s and 12% at 3s, against the
~1.6% the comment claimed. Adding 1s, 2s and 3s sample points brings the worst
error below 10s to 1.60% at 7s. Above 10s it is 0.55% at 728s, unchanged.

Tests: sample off-breakpoint values only, including both worst-error inputs
(7s and 728s). Replace the 3000ms absolute floor, which made the 1s assertion
unfalsifiable given a true value of 2750ms, with 2% and 0.75% bounds. Add
breakpoint-exactness and clamp-boundary coverage.

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Signed-off-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Austin <vidplace7@gmail.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-12 12:42:19 +00:00
Clive BlackledgeandClaude Opus 5 579d26e1b2 fix(NodeDB): don't let an empty contact key erase a stored public key (#11432)
Clients send `add_contact` before every text-message DM, because a phone
often holds a larger contact database (with public keys) than the radio
can keep. That makes `addFromContact()` the highest-volume key-write path
on the device - and it had no protection against key erasure.

Its only key guard covered the manually-verified case: if the local entry
was marked manually verified and the incoming contact was not, a key
mismatch aborted the update. Every ordinary entry fell straight through to
`CopyUserToNodeInfoLite()`, which assigns `public_key` unconditionally. So
a SharedContact with `has_user` set and an empty `public_key` overwrote a
peer's stored, XEdDSA-proven key with zeros - and `addFromContact()` calls
`saveNodeDatabaseToDisk()`, so the erasure survived a reboot. Subsequent
DMs to that peer then failed with PKI_SEND_FAIL_PUBLIC_KEY, with no way to
recover until the peer's NodeInfo was re-exchanged.

`public_key` is a singular (non-optional) bytes field, so "absent" and
"empty" both decode to size 0; a client that simply has no key for a
contact is indistinguishable on the wire from one asking to clear it.

The fix is deliberately narrow: keep the stored key when the entry already
holds a full 32-byte key and the incoming contact does not. A well-formed
32-byte contact key still updates the entry exactly as before.

Deliberately NOT changed here:
- `updateUser()`'s first-key-wins pin is not applied to this path. Clients
  legitimately use add_contact to supply keys the radio never had and to
  update them (QR-code contact sharing); a blanket pin would break that
  documented flow. Only erasure is refused.
- `CopyUserToNodeInfoLite()` itself is untouched - it has many other
  callers (self-record refresh, updateUser, warm-tier rehydration), so the
  guard lives at this call site.
- The manually-verified branch is unchanged.
- Node-number validation (reserved/broadcast/self) on this path remains
  open and is tracked separately.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:22:53 +00:00
Andrew Yong 2f6906974e gps: replace GeoCoord::latLongToMeter's spherical trig with equirectangular approximation (#11184) 2026-08-12 12:27:36 +02:00
Tadayoshi MIURA 9199e6b663 fix(stm32wl): smaller MAX_RX_TOPHONE and PACKETHISTORY_MAX on stm32wl (#11400)
* fixes for stm32: memory optimization and constraints tuning

* fold stm32wl elif to existing define

* revert changes for packet pool
2026-08-12 07:02:56 +00:00
d5d5bad97c SEN5X: fix version parsing, VOC index reporting, and read-buffer handling (#11114)
* SEN5X: validate read lengths and initialize read buffers

readBuffer() returns the number of data bytes written (0 on error).
Check the return value against the number of bytes each caller
consumes before parsing, and zero-initialize the destination buffers:
findModel (5), getMeasurements (2), readValues (16), readPNValues
(20), and vocStateFromSensor (SEN5X_VOC_STATE_BUFFER_SIZE). This also
resolves maybe-uninitialized compiler warnings.

Small simplifications in the same area:

- Assign the converted measurement values directly; the isnan()
  checks on integer intermediates always took the conversion branch,
  so this preserves behavior.
- Fold a redundant state comparison in wakeUp() that immediately
  followed the assignment of the same value.
- Add an explicit 'return false' to the non-FSCom branches of
  loadState() and saveState().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* SEN5X: correct version parsing, VOC index gating, and cleaning wait

- getVersion() requested 3 raw I2C bytes (2 data bytes) but parsed
  versionBuffer[0..6], so the hardware and protocol versions came from
  the buffer's initialized-but-unwritten tail. Request the full
  12-byte reply (8 data bytes, the layout used by Sensirion's
  embedded-i2c-sen5x driver) and validate the received length before
  parsing. Also make the error message specific to the version read.
- Use floating-point division when deriving major.minor version
  numbers so minor versions below 10 are preserved (integer division
  reported e.g. firmware 2.2 as 2.00).
- Gate pm_voc_idx on vocIndex rather than noxIndex, so SEN54 devices
  (VOC but no NOx) report their VOC index.
- Widen the millis() snapshot in startCleaning() to uint32_t so the
  10-second fan-cleaning wait always measures elapsed time correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* SEN5X: add size checks to I2C helpers, stage VOC state, handle unavailable readings

- readBuffer(): accept only request sizes that are a multiple of 3
  (2 data bytes + 1 CRC per group), keeping the read loop's size
  arithmetic in bounds for any future caller. Current callers all
  comply.
- sendCommand(): likewise accept only even payload sizes on the write
  side.
- vocStateFromSensor(): read into a staging buffer and copy to
  vocState only after the full transfer verifies, so the stored state
  stays consistent if a read fails partway through.
- readValues()/readPNValues(): the sensor reports unavailable values
  as 0xFFFF (unsigned) / 0x7FFF (signed); map these to the
  UINT16_MAX / UINT32_MAX / FLT_MAX sentinels that getMetrics()
  checks, so unavailable channels are omitted from telemetry rather
  than scaled into numeric readings. Guard the cumulative-to-binned
  PN subtraction so the sentinels are preserved.
- readPNValues(): convert #/cm3 to #/0.1l as raw * 10, retaining the
  0.1-resolution digit that dividing before multiplying discarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* SEN5X: size read buffers in data bytes and document I2C helper conventions

readBuffer()'s size parameter is the raw I2C transfer size including
CRC bytes, while only the verified data bytes (2/3 of the request) are
written to the destination. Two call sites sized their buffers in raw
units (findModel: 48 for 32 data bytes; getMeasurements: 3 for 2);
both were safe over-allocations. Size them in data bytes so every call
site reflects the same convention, and document the raw-vs-data
contracts on the readBuffer() and sendCommand() declarations.

No functional change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* SEN5X: use named defines for I2C reply buffer sizes

Follow the SEN5X_VOC_STATE_BUFFER_SIZE pattern for all reply reads,
per review feedback: define each reply's payload size in data bytes,
size the destination buffer with it, request <size> + <size> / 2 raw
bytes, and compare the received count against the same define.

The version and product-name guards now compare against the full
reply size rather than the bytes parsed (previously 7 and 5);
readBuffer() returns either 0 or the full data count, so the
conditions accept and reject the same transfers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* SEN5X: document I2C helper size requirements instead of checking at runtime

Per review feedback: drop the runtime even-size and multiple-of-3
checks from sendCommand()/readBuffer() and state the requirements in
@brief/@param documentation on the declarations. All callers pass
sizes derived from the SEN5X_*_BUFFER_SIZE defines, which satisfy
both requirements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* SEN5X: name the sensor's invalid-value constants

Per review feedback, define SEN5X_UINT_INVALID (0xFFFF) and
SEN5X_INT_INVALID (0x7FFF) for the values the sensor reports when a
reading is unavailable, and use them in the readValues()/readPNValues()
conversions in place of the numeric literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: oscgonfer <oscgonfer@users.noreply.github.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-12 06:56:46 +00:00
renovate[bot] b68de08c6b chore(deps): update esp32-ch390 to v1.1.1 (#11398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-12 06:34:44 +00:00
Andrew Yong f960c84f5b fix(stm32wl): reset instead of hanging on faults (#11420)
* fix(stm32wl): reset instead of hanging on faults

Reset instead of hanging forever on three unrecoverable faults,
each of which previously required a manual power cycle to recover:

- HardFault_Handler_C: blinked SOS forever with no debugger
  attached; now resets once the fault registers are printed.
- __wrap___assert_func: silently hung on an assert failure; now
  prints file/line/func/expr via debug_printf, then resets.
- earlyBootCheck: silently hung if the jump into the bootloader
  ROM failed to take; now calls the bare NVIC_SystemReset(), not
  the HAL wrapper, since it runs pre-HAL_Init() and MSP/VTOR are
  already repointed at the bootloader by this point, so a return
  would unwind through a corrupted stack frame.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* refactor(stm32wl): group fault-handling code

Group the fault-handling code together and drop incidental cruft:

- Move __wrap___assert_func next to HardFault_Handler_C and the
  other fault-reporting helpers.
- Add banner comments separating linker-hack wrappers from
  fault-handling/recovery code, matching the existing Bootloader
  redirect banner.
- Drop the forward declaration for debug_printf, no longer needed
  now that __wrap___assert_func sits below its definition.
- Trim the Bootloader redirect banner comment to 1-3 lines.

No behavior change.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-08-12 06:29:52 +00:00
renovate[bot] ea1e6b88e0 Update meshtastic/device-ui digest to 7bfabe5 (#11421)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-12 01:59:08 +00:00