Commit Graph
1598 Commits
Author SHA1 Message Date
Jonathan BennettandClaude Opus 5.5 8ebb6988b5 fix(gps): match Airoha idle_int sleep to the documented sequence (#12167)
* fix(gps): match Airoha idle_int sleep to the documented sequence

For boards that wake the AG3352 through idle_int (ThinkNode M8 and M9):

- Drop $PAIR382,1 before the sleep command. It locks the CPU out of
  sleep, which works against both $PAIR003 and $PAIR650.
- Hold idle_int low 50 ms before sending, and wait 50 ms after the
  command takes before EN cuts the core, as Elecrow's reference does.
- A successful $PAIR650 need not reply, so also accept NMEA going quiet
  for a full cycle as success; resend once otherwise.

Boards with RTC_INT are unchanged.

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

* ThinkNode M8: reset the GPS out of backup mode at boot

If the MCU resets while the AG3352 is in backup mode, the GPS stays
there and the cached-probe check gets no answer, forcing a full re-probe.
Pulse RESETB in earlyInitVariant(), as the M5 does with its wake pulse
in #12113.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:01:36 +00:00
9c119da2e3 AG3352 GPS update for ThinkNode M5 (#12113)
* initial commit for L76K/AG3352 dual GNSS detection

* GPIO fixes according firmware demo

* resolve rabbits

* define PIN_GPS_SWITCH

* fix wake button and power pin

* clear response to avoid logging junk data

* increase IO07 samples to more reliably detect version

* ThinkNode M5: read the v1.0 GPS switch on GPIO10

On a v1.0 (L76K) board the GPS switch is sensed on GPIO10, not GPIO18.
Watching GPIO18 there meant flipping the switch did nothing. Pick the pin
by revision, as the GPS UART pins already are; m5HasAg3352 is set in
earlyInitVariant() before createGps() configures and starts watching it.

Tested on a v1.0 board: switch off and on now turns the GPS off and on.

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

* ThinkNode M5: trunk fmt variant.h

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

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:34:59 +00:00
Ethac.chenandBen Meadors b33c337a89 feat(variants): add RAK WisMesh Dongle (USB Type-C) (#12009)
* feat(variants): add RAK WisMesh Dongle. USB-powered headless adapter on the RAK4631 module. Hardware model stays RAK4631 until protobufs add WISMESH_DONGLE. The boot VDD gate is disabled because phone VBUS makes the SAADC sample look unsafe.

* add board_level

* fix(rak_wismesh_dongle): set VDD gate to 1 mV so cppcheck passes

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-10-17 02:55:59 +00:00
Jonathan BennettandClaude Opus 5.5 c4b08e82a8 CI: build no firmware in the merge queue; trim repeated CI work (#12162)
* CI: run pio check on PRs only, not in the merge queue

The merge queue re-ran cppcheck on the same --level pr boards the PR run
had already checked. Restrict the check job to pull_request; ci-gate
already treats a skipped check as passing, so merge_group runs gate on
the builds alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: cancel superseded push runs on the same branch

Push runs fell back to github.run_id for their concurrency group, so
every merge to develop ran the full 156-board matrix to completion even
when a newer merge had already landed. Group pushes by github.ref so the
newer push cancels the older run. PRs keep grouping by head_ref, and
merge_group, schedule and workflow_dispatch runs keep a group of their
own, so the queue, the nightly and releases are never cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: move axiometa-genesis-mini out of the PR board set

Set its board_level to release. PRs and the merge queue now build 17
boards instead of 18; the board is still built in the full matrix on
push, nightly and release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: reuse the merge queue's PR-level builds in the post-merge run

A post-merge push to master/develop lands the exact commit its
merge_group run already built, so the PR-level boards were compiled
twice. When the push's merge_group run is found and its ci-gate passed,
setup marks those boards 'reuse' and build_firmware.yml copies their
firmware and manifest artifacts from that run instead of building. The
artifacts keep their names, so the size report baseline, the RAM/flash
budget gate and MemBrowse are unchanged.

If no such run is found (event/* and feature/* pushes, a push that
bypassed the queue, an API failure), every board builds as before.

ci-gate is read through the check-runs API: the run's jobs endpoint
returns 502 for a run this size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: skip macOS, Windows and debian-src on reused post-merge pushes

When setup finds the merge_group run that already built this commit
(reuse_run_id), these jobs ran on that exact commit there and nothing
on push consumes their output: the debian-src artifact is only read by
release-artifacts on workflow_dispatch. test-native, WASM and Docker
still run, because only pushes to develop save their caches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: only skip OS builds on push when the merge queue run passed them

ci-gate needs only build and check, so a merge_group run whose macOS,
Windows or debian-src job failed still merges, and skipping those jobs
on the push hid the failure. setup now sets skip_os_builds only when the
latest attempt of every MacOS, Windows and build-debian-src check run in
that merge_group run concluded success and all three are present; the
jobs skip on that flag instead of reuse_run_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: harden the post-merge reuse lookup (review fixes)

- Find the merge_group run at any status. The queue merges once ci-gate
  passes, often while jobs outside it still run, so requiring a
  completed run usually found nothing and rebuilt every board.
- Mark a board 'reuse' only if its firmware and manifest artifacts are
  still present and unexpired in that run; any other board compiles.
  setup now needs version to build the exact artifact names.
- Never skip MacOS, Windows or build-debian-src on the default branch:
  those jobs save their caches only on its pushes. The skip stays for
  master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: drop the macOS/Windows/debian-src skip on post-merge pushes

Those jobs save their caches only on default-branch pushes, so the skip
was already limited to master, where it saved too little to justify the
check-run lookup. They now run on every push exactly as before this PR.
Reuse of the PR-level firmware builds is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: look up the merge_group run by workflow file, not display name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

* CI: build no firmware in the merge queue; drop post-merge artifact reuse

Per review: the merge queue now only batches PRs into develop/master.
setup is skipped on merge_group, which skips the build and check
matrices that depend on it, and ci-gate accepts a skipped build on
merge_group only; a PR must still build. Post-merge pushes build the
full matrix as before.

With no firmware built in the queue there is nothing to reuse, so the
post-merge artifact copy (reuse-build, the merge_group run lookup and
setup's dependency on version) is removed and build_firmware.yml is
back to develop's version. The merge_group branches of setup's matrix
generation are removed with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-16 20:28:21 -05:00
James Rich 200aa4270e Stop treating BATTERY_PIN -1 as a battery ADC (#12091)
* Stop treating BATTERY_PIN -1 as a battery ADC

Six variants defined BATTERY_PIN -1 to mean "no battery sense", but every guard
is `#ifdef BATTERY_PIN`, so the analog battery read compiled in anyway. On the
Seeed Xiao S3 it samples an unwired GPIO1 and reports a battery that is not
there: 0% at 3112 mV on one board, charging at 6310 mV on another. On nRF52,
analogRead(-1) indexes past the pin map.

Leave BATTERY_PIN undefined on those boards, which takes the existing
external-power path, and fail the build when a variant defines a negative pin.

* Keep the INA battery path on boards without a battery ADC pin

AnalogBatteryLevel is the only reader of a battery INA configured through
power.device_battery_ina_address, and analogInit() only installed it when
BATTERY_PIN was defined. Dropping the -1 placeholder from six variants
therefore also dropped their INA path. Install AnalogBatteryLevel on any
board without a pin when the environmental sensors are compiled in, and
let the PowerFSM treat such a board as externally powered only while no
battery source has been found.
2026-10-16 21:48:28 +00:00
Austin 38ae9aab86 Renovate: Fix lookup errors / tag formatting (#12176)
Also unpins actions/checkout... no need for this on official Actions (needless renovate PR churn).
2026-10-16 15:48:31 +00:00
Carlos Valdes a2a27f4f99 Track meshtastic/Crypto with git-refs on rp2040/rp2350 and bump to 9ceb213 (#12157)
The other platforms moved their Crypto comment to datasource=git-refs in
#11065 and Renovate has kept them current since (#11879, 1c817c2). rp2040
and rp2350 still say datasource=github-tags, and meshtastic/Crypto has no
tags, so Renovate never updates them: both are still on 591ff9a.

Switch the comment to the same git-refs form and bump the pin to the
current main (9ceb213), which brings in meshtastic/Crypto #4, #5, #7 and #8.
2026-10-15 14:38:41 -05:00
James CroneyandHarukiToreda 7579bfe4d3 InkHUD: Feature/sdcard eink maps (#12118)
* InkHUD: load offline map tiles from SD cards

InkHUD map backgrounds previously came only from MapTile.h compiled into
the firmware image. This commit expands that to read map tiles from an SD Card.

* Firmware-compiled tiles remain the first choice, with SD Card secondary.
* SD Card MapTiles' header position table and payload descriptors loaded into PSRAM.
* The index is cached in PSRAM when allocation succeeds; otherwise streamed index access.
* Validates the file magic, layout, counts and index/data bounds before use.
* Added an MTB1 reader for /Map/MapTile.bin
* Supports sparse and grid indexes together with white, black and raw LZ4 block entries.
* Moved the tile format constants and LZ4 decoder out of MapApplet.
* Reuses the applet's existing decoded-tile buffer.

Compressed tile imagery remains on the card. Rendering resolves
coordinates from the cached index, takes spiLock for a bounded
open/seek/read/close operation, releases the bus, then decompresses
the tile. Memory use therefore stays bounded by the index and one
compressed and decoded tile rather than the size of the map file.

The T5S3 E-Paper V2 connects its SD card and LoRa radio to the same SPI
pins. Drop SDCARD_USE_SPI1 only from the InkHUD environment so both use
the default SPI instance and the existing shared lock. Keep SD map
enablement and preload setup in the T5S3 variant; the non-InkHUD V2
environment retains its existing SPI1 configuration.

Update MapApplet to walk only the visible tile-coordinate range, wrap
tile X coordinates across the antimeridian and check embedded tiles
before requesting an SD tile. A missing, invalid or temporarily busy SD
source simply leaves that background tile undrawn without changing the
existing map applets or their compiled-in tile behavior.

* PowerFSM: service non-input GPIO wakeups after light sleep

Before c308d0aca, a light-sleep wake that was not caused by the button
triggered EVENT_WAKE_TIMER. Despite its name, that event is also the
PowerFSM path for leaving LS after a peripheral interrupt so the normal
loop can service the pending hardware IRQ.

c308d0aca (feat: Support Elecrow ThinkNode M9, August 17, 2026) split
ESP_SLEEP_WAKEUP_GPIO out of the default switch branch to recognize
KB_INT as a key press. The new GPIO branch retained the EVENT_PRESS path
but omitted the previous fallback for GPIO wakes that were not buttons.
546b678d5 later added explicit motion-interrupt handling, but other GPIO
wake sources still produced no PowerFSM event.

LoRa DIO and other ESP32 peripheral wake sources are reported as
ESP_SLEEP_WAKEUP_GPIO. When neither the button nor motion line is
asserted, lsIdle() therefore remains in LS and can enter light sleep
again before the cooperative loop services the pending interrupt. A
latched IRQ can repeatedly wake the CPU into the same path, producing a
wake/sleep loop while delaying radio and other peripheral handling.

Restore the old fallback after the button and motion checks. An
unattributed GPIO wake now triggers EVENT_WAKE_TIMER, moving the FSM
from LS to NB or DARK so the regular loop can handle the interrupt
before light sleep is attempted again. Button wakes still use
EVENT_PRESS, motion wakes still use EVENT_INPUT, and non-input GPIO
wakes do not turn on the screen.

* InkHUD: centralize map tile zoom lookup

Select the embedded zoom metadata once and share the lookup between the zoom count and index helpers. Preserve the SD-card fallback while avoiding duplicate ternary expressions when the default tile metadata is empty.

* InkHUD: bound or stream SD map indexes

Limit index caching to a configurable percentage of available PSRAM
and fall back to block-based SD streaming when the index is too large or
allocation fails. Release the SPI lock and yield between blocks to keep
other tasks responsive.

Retry temporary SD failures and publish map header state only after all
required index reads complete successfully.

* added requested build flag for sdcard maps to t3s3 variant

---------

Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
2026-10-15 14:47:48 +00:00
Ethac.chenandThomas Göttgens 4964a0c6a5 feat(raksensorhub): SensorHub uplink for WisMesh Hub (RAK2560) (#11817)
* feat(raksensorhub): add SensorHub uplink driver for WisMesh Hub
Add HAS_RAKHUB env rak_wismesh_sensorhub with 1-Wire bus + IPSO cache.
Ship standard environment metrics only; soil/water chemistry stays gated until SoilWaterMetrics lands in firmware.

* fix(raksensorhub): harden RX length, MODBUS log, and power TTL
Reject oversized frames without uint16 wrap, validate MODBUS before head[] access, TTL hub power like other scalars, and set board_level for CI matrix.

* fix(raksensorhub): track HubPower freshness per field
Use separate timestamps for voltage, current, and capacity so one IPSO cannot keep stale siblings fresh, and percent no longer depends on volMv.

* fix(raksensorhub): stamp 0-unset cache times with Time::skipZero
A wrap or boot 0 tick must not look like never-updated; freshness reads use Time::stampMillis() too.

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-10-15 13:16:43 +00:00
Jonathan BennettandClaude Opus 5.5 ed072eb4b5 ThinkNode M8: switch the GPS to the AG3352Q (#12151)
* ThinkNode M8: switch the GPS to the AG3352Q

Later M8 units replace the ATGM336H with an Airoha AG3352Q. Drop the
ATGM336H standby pin and wire up idle_int (P1.07) so the existing
GPS_SLEEP_INT path stops the engine with $PAIR003 before GPS_EN cuts
the 0.8 V core, as on the ThinkNode M9. Default baud is now 115200.

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

* ThinkNode M8: put the AG3352 into backup mode for sleep

$PAIR003 only stops the GNSS engine, so with the 1.8 V rail always on
the receiver never reached low power (1.5 mA measured by Elecrow).

Add GPS_AIROHA_RESET_WAKE for boards without RTC_INT that have RESETB:
sleep sends $PAIR650,0 (with idle_int low) before EN goes low, and wake
pulses RESETB once EN is back up. Enable it on the M8.

At shutdown, hold idle_int high and RESETB released instead of letting
the variant's pin sweep float them through System OFF.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:49:21 +00:00
Jonathan BennettandClaude Opus 5.5 a2984a5d28 ThinkNode M8: make button E the cancel button (#12150)
Button E was PIN_BUTTON1 with a hand-rolled FunctionButton map in
InputBroker (single press: ping, double: privacy toggle, 5 s hold:
shutdown). The rotary encoder already drives the UI, so define the pin as
CANCEL_BUTTON_PIN instead and drop the M8 special case: a press now
cancels and a 4 s hold shuts down, through the generic cancel button.
variant_shutdown() follows the rename so the wake-on-press pin is still
parked and sensed.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:02:07 +00:00
Thomas Göttgens afdaccc25f Makerfabs nomad terminal (#12112)
* Add Makerfabs Nomad Terminal variant

* Configure external power detect pins before probing battery gauges

* TFTDisplay: add ILI9488 reset pin and colour inversion options

* Makerfabs Nomad Terminal: add BaseUI env, early power latch, soft-start LCD/GPS rail, GPS wake

* I2C scan: tell FocalTech touch from AHT10 at 0x38 and BMM150 from VEML7700 at 0x10

* Run a BMM150 next to an IMU on the magnetometer thread

* Makerfabs Nomad Terminal: BaseUI touch

* BMM150: add hard/soft-iron calibration, read at 10 Hz

* Makerfabs Nomad Terminal: fix MUI build, restart Wire after MUI touch init

* BMM150: seed calibration from the first valid sample

* Makerfabs Nomad Terminal: combined BaseUI+MUI -tft firmware, SD defines in build flags

* Makerfabs Nomad Terminal: reset touch controller at init, wake touch thread on INT

* BMM150: poll every 200 ms

* Makerfabs Nomad Terminal: scale CW2015 SOC to the on-USB full reading

* BMM150: end an expired calibration when the sample is rejected
2026-10-14 15:18:24 +00:00
tornadus f7f66b4731 Initial Elecrow ThinkNode M5 InkHUD port (#12097)
Adds an InkHUD driver (nicheGraphics.h) and build target
(platformio.ini) for InkHUD on the M5
2026-10-14 06:09:48 +00:00
Manuel 04cb888511 Wio Tracker L2 fixes (#12133)
* fix WakeKey not responding when pressed during boot

* fix I2C error address 0x48 during scan

* enable GT911 gesture recognition in MUI

* shutdown_variant()

* disable io expander INT during light sleep

* fix BaseUI wakeup

* prevent bootloop in case DIO1 fires

* fix ADS1X15 scan type

* fix power down sequence

* external keyboard support

* fix I2C port 1 define; skip false positive
2026-10-13 19:50:53 +00:00
Austin b48e1e0a2f Update RAKwireless RAK12034 library git ref (#12147)
These three variants were referencing the older (broken) library, where other RAKs had already updated to this fixed version. This PR aligns everything on the fixed version.
2026-10-13 01:11:41 +00:00
Austin 2e69aecf19 nRF52: Update gcc to v12.3.1 (same as nRF54) (#12027)
Align the two nRF platforms on v12.3.1
Switch to forked RAK12035_SoilMoisture library for now
2026-10-12 21:44:55 +00:00
1e3a38fc8a Add native Portduino mDNS discovery with Avahi and Bonjour (#12032)
* Add native Portduino mDNS discovery via Avahi and Bonjour

* Guard native discovery in Portduino WASM builds

* Add linux dependencies in packaging, support "buildroot"

Adds libavahi dependencies for Debian, RPM, Docker and CI.
Moves the libavahi definition to the shared `build_flags` (which is only consumed on Linux targets)

* Fix platformio formatting

* fix(portduino): disable Avahi when daemon unavailable

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: vidplace7 <vidplace7@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:56:28 +00:00
Austin 9015b2213f Update portduino to gnu++20 (#12069)
Upstream dependencies in the newest distros are beginning to require c++20.

This PR bumps all portduino-based targets to c++20, removes the shared c++17 build_flag, and adds it to each variant family where it is needed.
2026-10-11 19:02:14 +00:00
Thomas GöttgensandJason P bd951500b7 Select InkHUD font code page with the OLED_* language flags (#11869)
OLED_GR selects Win1253, OLED_RU/OLED_UA Win1251, OLED_PL/OLED_CS
Win1250, no flag Win1252. heltec_mesh_pocket returns to the Win1252
default. t5s3_epaper stays on Win1253 (18pt/24pt exist only there).

Co-authored-by: Jason P <applewiz@mac.com>
2026-10-11 03:48:37 +00:00
AustinandManuel e09cec6ada Update pioarduino to v3.3.12-1 (#12052)
* Update pioarduino to v3.3.12-1

Updates pioarduino platform and arduino-esp32 to v3.3.12, a minor bump from current v3.3.11
https://github.com/espressif/arduino-esp32/releases/tag/3.3.12

Meshtastic's fixes have been integrated upstream, so move off of our fork and back to official releases.

* update indicator to 3.3.12

---------

Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
2026-10-09 11:22:31 +00:00
bce6f4fb4d nRF52840: run X25519 and Ed25519 verify on the CC310 (#12098)
CryptoEngine gains two virtual hooks, x25519 (Curve25519::eval semantics)
and ed25519Verify, which setDHPublicKey, regeneratePublicKey and
xeddsa_verify now call. setDHPublicKey keeps dh2's weak-point checks around
the hook. Other platforms are unchanged.

NRF52CryptoEngine overrides both with the CryptoCell CC310 through
nRFCrypto_X25519 / nRFCrypto_Ed25519. Only clamped scalars go to the
hardware, since the CC310 clamps and eval does not; non-canonical peer keys
return false as eval does. A verify result is final only when the CC310
accepts or rejects; any other CC310 error falls back to software.

nRFCrypto is bumped to Kongduino/Adafruit_nRFCrypto@6d4d369, whose wrappers
survive the firmware's per-call nRFCrypto.begin()/end() and follow RFC 7748.

On a RAK4631: X25519 11.7 ms vs 62.3 ms, Ed25519 verify 17.9 ms vs 173.6 ms.

Co-authored-by: Kongduino <Kongduino@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:42:11 +00:00
TomandTom 07008058c0 nrf52 promicro: LR2021 LF PA table from the LR20xx datasheet (#11980)
* nrf52 promicro: LR2021 LF PA table from the LR20xx datasheet

RadioLib's default LR2021 LF PA table never programs tx_power above
20.5 dBm, so output stops rising above 14 dBm. This board table takes
10-22 dBm from LR20xx DS rev 2.2 Table 7-19 and keeps RadioLib's rows
below 10 dBm, where that table stops.

* LR2021: install the board LF PA table only at 500-1000 MHz

The datasheet table is the 915 MHz reference design, but applyCustomLfPaTable()
installed it for every sub-GHz frequency, so 433 and 490 MHz regions used it too.
Outside 500-1000 MHz the LF table now goes back to RadioLib's default.

RadioLib keeps the table pointer, so the same-band reconfigure path, which
retunes without a full begin(), also reselects the table before
setOutputPower(): a 915 -> 433 MHz region change would otherwise have kept the
915 MHz table. isLr20x0CustomLfPaBand() carries the range and is covered in
test_radio.

* fix(lr2021): restore receive metadata after develop merge

Restore the virtual method lost in the merge; CI native and LR2021 links otherwise fail.

---------

Co-authored-by: Tom <116762865+Nestpebble@users.noreply.github.com>
2026-10-07 00:07:30 +00:00
TomandJonathan Bennett 5ced5ee4f7 Nrf promicro hf (#11966)
* define txen pin to allow sx128x testing

* nrf52 promicro: gate the P1.06 TXEN pin, and add an optional SX1280 build

P1.06 was wired to SX126X_TXEN unconditionally earlier in this series, to drive
an external PA on a bench unit. That is not safe to ship: three envs share this
variant.h, and P1.06 is PIN_WIRE_SCL on the EasyProMicro pinout and
PIN_EINK_BUSY on the InkHUD one. The default builds go back to RADIOLIB_NC.

Put it behind -D PROMICRO_SX126X_TXEN, refused wherever the pin is already
claimed. The EasyProMicro arrangement has its own SX126X_TXEN on P1.00 in the
#else branch and never reaches this, so only the e-ink exclusion is needed.

The pin was wanted for SX1280 in the first place - every other SX128X line is
already assigned on this board and TXEN was the one missing - so add that too,
behind -D PROMICRO_SX1280 and a new nrf52_promicro_diy-sx1280 env. Both opt-ins
claim P1.06, so asking for both is an #error rather than a silent pick.

Costs about 10.7 KB: the image ends at 0xE5140, 19 KB clear of the warm region,
against 0xE2710 and 30 KB for the base env.

* nrf52 promicro: keep P1.06 off TXEN on a MESHTASTIC_USE_EINK_UI build

configuration.h includes variant.h before it derives USE_EINK from
MESHTASTIC_USE_EINK_UI, so a build enabling the e-ink UI that way passed
the guard and could give the panel's BUSY line to TXEN. Test both.

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-10-06 23:47:46 +00:00
0fc168d875 Improved LBT behaviour - CAD-to-RX, correct symbol times, better thresholds (#11137)
* LoRa LBT: fix SX128x/SX126x CAD encoding + slot model, ESP32 sleep race

Three fixes from the "are we really listening?" LBT audit:

- SX128x CAD: pass RADIOLIB_SX128X_CAD_ON_4_SYMB instead of the raw count 4.
  The raw value set bits 7:5 = 000, i.e. a 1-symbol scan, leaving LBT nearly
  blind on 2.4 GHz. Now runs the intended 4-symbol CAD.

- SX126x CAD / slot time: name the symNum value explicitly as CAD_ON_4_SYMB
  (identical 0x02 raw value, so no on-air change) and add a virtual
  getCadSymbolCount() so computeSlotTimeMsec() models the real ~4.5-symbol
  scan for the SX126x/LLCC68/STM32WL family only, leaving LR11x0/SX127x at
  their correct 2.5 symbols. Fixes the too-short contention-window slots.

- ESP32 light sleep: if the DIO1/RF95_IRQ line is already asserted just before
  esp_light_sleep_start(), skip the sleep so the main loop services the pending
  packet instead of stranding it for up to a full sleep chunk (the
  detachInterrupt-clears-the-GPIO-wake disarm race).

clod helped too

* LoRa LBT: keep software-poll preamble flags latched while idle (anti-stomp)

Finding #10 from the "are we really listening?" audit, LORA_DIO1_SOFTWARE_POLL
boards (meshnology-w10).

The 1 ms poll cleared latched PREAMBLE_DETECTED|HEADER_VALID whenever they were
the only bits set, so for the rest of a reception isActivelyReceiving() read idle.
A TX queued during the payload phase then passed the send gate and startTransmit
overwrote the shared chip buffer, losing the inbound packet.

That clear only exists to stop a noise preamble from permanently blocking our own
TX (starvation). Starvation can only happen when we actually have something to
send, so gate the clear on a non-empty txQueue. While the queue is empty the bits
stay latched, isActivelyReceiving() reports the in-progress packet, and a later TX
defers instead of stomping it. A queued TX still gets the escape hatch. The poll
cadence is a fixed 1 ms reschedule either way, so latched bits don't spin the loop;
readData() clears everything once a real RX_DONE arrives.

clod helped

* LoRa LBT: optional RSSI energy second-gate before TX (default off)

Findings #4/#5 of the "are we really listening?" audit.

CAD only detects a preamble. A neighbour's packet whose preamble already aired -
during our own TX, a CAD scan, or a standby gap - is mid-payload and invisible to
CAD, so we key up on top of it. Add an RSSI energy check as a second listen-before-
talk gate in the TX-decision path: channelBusyByRSSI() compares the instantaneous
in-RX RSSI against the rolling noise floor (getNoiseFloor() + margin) and defers if
the channel is hot. It reads RSSI before isChannelActive()'s setStandby() via the
|| short-circuit, so the sample is taken while still in RX where GET_RSSI_INST is
valid - no mode juggling.

Orthogonal to CAD: CAD has processing gain and catches weak preambles below the
noise floor; RSSI catches strong energy at any packet phase, including the mid-
payload case CAD is blind to. Neither subsumes the other.

DEFAULT OFF. A mis-set threshold makes a node read "busy" on noise and stop
transmitting entirely, which is worse than the bug, so it must be tuned on hardware
before it ships live. Compiled out unless LORA_RSSI_LBT_MARGIN_DB is defined at
build time; when off it is a compile-time `return false` (zero behaviour change).
Fails open: returns not-busy unless in RX with a full noise-floor window and a sane
dBm reading, so it can never persistently silence a node.

Native build (gate off) links clean.

clod helped

* LoRa LBT: RSSI margin profiler to tune RSSI_LBT_MARGIN_DB (default off)

Instrumentation to pick the #4/#5 RSSI second-gate margin from measured data
instead of guessing. Build with -DLORA_RSSI_LBT_PROFILE; compiled out otherwise.

It accumulates (RSSI - rolling noise floor) in two channel states:
- idle   (reuses updateNoiseFloor()'s existing RX sample, no extra SPI): the noise-
         jitter distribution. Its high tail is the floor the margin must clear.
- signal (decoded-packet RSSI, hooked in handleReceiveInterrupt): the real-traffic
         rise. Its low tail is the ceiling the margin must stay under.
A throttled log dump prints both distributions (min/mean/max + percentiles) and the
derived usable window (idle p99, signal p05).

Placement: the stats/histogram engine is a dependency-free, radio-agnostic header
(RssiProfiler.h) so it stays out of the core file and is testable on its own; the
two hooks and the RSSI/log I/O live on RadioLibInterface where the state is.

Independent of the gate flag (LORA_RSSI_LBT_MARGIN_DB) on purpose: profile first to
choose the margin, then build the gate on. Native build (profiler on) compiles clean.

clod helped

* docs: describe the RSSI LBT margin profiler

Feature doc for the RSSI listen-before-talk margin profiler
(-DLORA_RSSI_LBT_PROFILE): what it measures (idle noise-jitter vs decoded-signal
distributions of RSSI-minus-noise-floor), how it's wired (RssiProfiler.h engine +
RadioLibInterface hooks, idle sample reused from updateNoiseFloor), how to build and
run it, and how to read the log output to choose RSSI_LBT_MARGIN_DB.

clod helped

* LoRa LBT: draft #3/#6 fixes behind MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE (inactive)

Firmware side of the CAD->RX handoff (#3) and ISR_RX re-arm (#6) from the LBT
audit. Both need to re-arm RX WITHOUT the standby() that startReceive() forces
(which aborts an in-flight reception) - unavailable in stock RadioLib, provided by
resumeReceive() in NomDeTom/RadioLib@rxResume.

Guarded by MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE, left UNDEFINED: against stock
RadioLib these paths compile out and behaviour is unchanged. Define it (via
build_flags) only when linking a RadioLib that has resumeReceive().

- resumeReceiveInPlace(bool startRx): base-class helper - iface->resumeReceive()
  (remap RX IRQ, optional SetRx, no standby) + re-attach the MCU ISR + base RX
  bookkeeping. One place, chip-agnostic via iface.
- #3: SX126x/LR11x0 isChannelActive() CAD exitMode -> GOTO_RX; the TX-decision path
  splits the busy check into rssi/cad flags and, on a CAD detect, hands the packet
  to RX via resumeReceiveInPlace(false) instead of startReceive(). Macro-off, the
  split is exactly the old `channelBusyByRSSI() || isChannelActive()` short-circuit.
- #6: ISR_RX re-arms via resumeReceiveInPlace(true).

Open (flagged in-code, for review + HW validation): whether re-issuing SetRx (#6)
disturbs an on-chip in-flight reception, and the #3 handoff's interaction with a
beacon-target packet on the home config.

clod helped

* LoRa LBT #4: synthesize GPIO wake cause when light sleep is skipped

doLightSleep() skips esp_light_sleep_start() when a LoRa IRQ is already
asserted, but still read esp_sleep_get_wakeup_cause() unconditionally - so
the skip path returned the previous real sleep's cause (e.g. TIMER) and
missed the ESP_SLEEP_WAKEUP_GPIO branch that sets runASAP. Capture whether we
skipped and return a synthetic GPIO cause in that case so PowerFSM takes the
packet-service path. Reads the ESP-IDF cause only after an actual sleep.

clod helped too

* LoRa LBT #10: keep HEADER_VALID latched in the software IRQ poll

The LORA_DIO1_SOFTWARE_POLL anti-starvation escape hatch cleared both
PREAMBLE_DETECTED and HEADER_VALID whenever a TX was queued. HEADER_VALID is
evidence of a real inbound frame, so clearing it made isActivelyReceiving()
read idle mid-payload and let the queued TX stomp the frame. Gate the hatch on
a bare-preamble IRQ (PREAMBLE set, HEADER_VALID clear) and clear only
PREAMBLE_DETECTED. A stuck header still can't block TX forever - receiveDetected()
ages it out after maxPacketTimeMsec - and readData() clears everything on RX_DONE.

clod helped too

* LoRa LBT #3/#6: CAD->RX handoff + continuous-RX re-arm on stock RadioLib

Reimplements the two audit fixes that previously needed the NomDeTom/RadioLib
@rxResume fork (MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE), using only stock
RadioLib, behind a new default-off flag MESHTASTIC_LBT_CAD_TO_RX. Per the
RadioLib maintainer's feedback, no new RX API is needed.

(GOTO_RX) with the RX IRQs pre-mapped in the scan config, so the chip's own
CAD->RX transition delivers RX_DONE with no library call in between. irqFlags
(status-enable) carries the CAD verdict + full RX set incl. PREAMBLE/HEADER so
isActivelyReceiving() can read them; irqMask (DIO trigger) carries the CAD
verdict + terminal RX events only - NOT PREAMBLE/HEADER, which would fire the
ISR mid-frame and run readData() on an incomplete packet. On detection it hands
off in place: clear the latched CAD verdict, re-attach the MCU ISR, mark
receiving - no standby (which would abort the packet just detected).

standby), so re-attach the MCU ISR instead of re-issuing SetRx (which could
disturb a second packet already arriving on-chip).

Removes resumeReceiveInPlace() and the fork macro. SX126x-only: LR11x0's
startChannelScan conflates irqFlags/irqMask (drives the DIO from irqFlags), so
it keeps the safe GOTO_STDBY path, re-arming on detect to stay coherent with
onNotify when the flag is on.

Default-off: needs on-hardware validation and assumes continuous RX (the
default preamble=16/minSymbols=8 degenerates startReceiveDutyCycleAuto to
continuous). Native builds pass both flag-off and flag-on.

clod helped too

* LoRa LBT #1: re-arm RX unconditionally when deferring a busy channel

The post-CAD/RSSI defer path wrapped its startReceive() re-arm in a
!hasTargetRadioSettings(txp) guard, so a beacon-target packet that hit a busy
channel took setTransmitDelay() with the radio left in standby. When the
beacon's target config coincides with the home config the radio is on the home
channel, so the node went deaf on home traffic until the next TX cycle - and
kept skipping the re-arm every cycle the channel stayed busy. Drop the guard:
re-arm unconditionally and keep listening while we wait to key up. A beacon's
job is to get a packet out, but it should still mirror the main RX path.

(No effect under MESHTASTIC_LBT_CAD_TO_RX, where the chip is already left in RX.)

clod helped too

* docs: fix RSSI profiler fences (MD040) + RAM figure, note negative-bin limit

- Tag the formula and sample-output fenced blocks as `text` so markdownlint
  MD040 passes.
- Correct the RAM figure: RssiDeltaStats is 148 bytes (12 scalar + 34*4
  histogram), so two instances are ~296 bytes, not ~280.
- Add a limitations note that all negative deltas collapse into bin[0], so a
  percentile landing there returns the minimum sample. This does not affect the
  margin (keyed off the always-positive idle p99) but can understate signal p05
  when a node decodes below-floor packets; never biases toward a smaller margin.

clod helped too

* docs: RSSI profiler - record histogram bucketing design choices

Add a "Histogram bucketing" section laying out why a fixed histogram is used
over exact percentiles, what the axis has to resolve (the margin window sits at
roughly -a few..+20 dB), and the arguments for five bucketing schemes: the
current positive-only layout (A), a cost-neutral re-centred layout with
negative-range resolution (B, recommended if signal p05 must be trusted),
wider full-range bins (C), more bins (D), and exact/off-box (E). Explains the
current choice and when to switch.

clod helped too

* LoRa LBT: move RSSI profiler + energy second-gate to listening-now-rssi

Split the RSSI work off this branch so the active CAD/sleep fixes and the #3/#6
CAD->RX rework can land without the tuning-dependent RSSI pieces. Removed here
(preserved intact on branch listening-now-rssi):
  - RSSI margin profiler: RssiProfiler.h, the LORA_RSSI_LBT_PROFILE hooks, and
    docs/rssi_lbt_margin_profiler.md.
  - RSSI energy second-gate: channelBusyByRSSI() and the LORA_RSSI_LBT_MARGIN_DB
    gate.

The pre-existing noise-floor sampler (updateNoiseFloor/getNoiseFloor, already on
develop and consumed by telemetry) is untouched. The TX-decision block reverts
to a plain CAD scan; #3/#6 (behind MESHTASTIC_LBT_CAD_TO_RX) and #1 unchanged.
Native build green.

clod helped too

* reduce to bare essentials

* LoRa LBT: calibrate CAD symbol count and detPeak against vendor data

NUM_SYM_CAD carried two incompatible meanings: computeSlotTimeMsec() read it
as a literal symbol count, while the drivers passed it into scanChannel(),
where symNum is a register encoding on SX126x/SX128x. On SX126x the raw 2
encodes CAD_ON_4_SYMB, so the chip has scanned 4 symbols since #5749/#5772
while the slot time was priced at 2.5 - a 1.8x mis-scaling of the CSMA
backoff unit, live since January 2025.

Give the constant one meaning (a literal count of 4) and have each driver
translate at its own call site. getCadSymbolCount() reports the real scan
length so slotTimeMsec matches it; RF95 overrides it because SX127x CAD is
fixed in hardware and its legacy scanChannel() takes no symNum.

AN1200.48 Table 1 recommends 4 symbols for SF9-SF12, which is where most
shipped presets sit, so 4 is the right target. The comment claiming 2 was
reading that note's SF7/SF8 row.

detPeak is calibrated per (SF, symbol count), and on LR11x0 per bandwidth
as well. Both LR drivers ran thresholds meant for a 2-symbol scan, leaving
them less sensitive than measured-best and biased toward missed detections.
Now sourced explicitly:

  LR20x0  LR20xx datasheet rev 2.2 Table 6-19, indexed by symbols x SF
  LR11x0  Semtech SWSD003 lr11xx/apps/cad/main_cad.c optimized_parameters,
          indexed by symbols x bandwidth x SF
  SX126x  RadioLib's SF+13, which sits at or below every AN1200.48 value and
          so errs toward deferring rather than transmitting

Combinations the vendor data does not characterise fall back to the RadioLib
default rather than guessing. LR2021 also pins fastCad, which scanChannel()
reads off the radio object rather than the config struct.

Also fix the 2.4 GHz slot term: (2 * sf + 3) / 32 is integer division that
truncates to 0 for every legal SF, so the AN1200.22 fractional term never
contributed anything.

Not hardware-verified.

* LoRa LBT: drop audit-doc pointers and cross-chip asides from comments

The LBT comments referenced an external audit document by item number (#1,
#3, #3/#6, #10, and a see-.notes/lbt pointer), which means nothing to anyone
reading the tree on its own. They also described what sibling drivers do,
which goes stale silently the moment one of them changes and belongs in that
driver's own file anyway.

Keep the reasoning, drop the pointer and the comparison. Where a comment
explained a rule that genuinely spans drivers - NUM_SYM_CAD's register-
encoding caveat - state it as a rule rather than a roll-call of chips.

No behaviour change; comments only.

* LoRa LBT: don't index the LR11x0 CAD table with a 2.4 GHz bandwidth

The detPeak lookup picked its bandwidth row by range, so wideLora's 406.25 kHz
fell into the sub-GHz 500 kHz row - SWSD003's table is the sub-GHz LoRa modem
(BW enums 62.5-500), and 2.4 GHz is a separate modem with its own calibration.
Match the four bandwidths exactly instead, so anything unmatched falls back to
the RadioLib default rather than borrowing a row it has no claim to.

Those four are the whole sub-GHz set today - LR11x0::setBandwidth() rejects
anything else - so note that a newly unlocked bandwidth, or a symbol count
outside 2/4/8/16, needs a row adding here and fails silently if it doesn't get
one. Same note on LR20x0 for symbol counts above 4.

Also correct two citations. The 2.4 GHz slot term is the SX1280 datasheet's own
CAD duration formula, and the CAD symbol-count guidance is AN1200.77, not
AN1200.22 - which is "LoRa Modulation Basics" from 2015 and predates the part.
Record that SX1280 ignores detPeak/detMin/exitMode entirely: SetCadParams
carries only cadSymbolNum, and the PNR threshold lives in register 0x942.

* LoRa LBT: set the SX1280 CAD PNR threshold and scan 8 symbols

SetCadParams on this part carries only cadSymbolNum - detPeak/detMin in the
scan config are inert - so CAD sensitivity is governed entirely by register
0x942, which nothing was writing. The scan therefore ran at the chip's reset
value of 0x32, against the 19-36 that AN1200.77 Table 1 calibrates per SF and
bandwidth: at LONG_FAST wideLora the recommendation is 27 and we were using 50.
A threshold that high needs a much stronger correlation peak, so 2.4 GHz LBT
was under-detecting on top of the symbol-count bug already fixed.

Write the table value before each scan, indexed by bandwidth x SF. Unmatched
bandwidths write nothing and leave the chip's default, as on the LR parts.

This goes through our own Module rather than lora.writeRegister(), which
RadioLib keeps protected unless RADIOLIB_LOW_LEVEL unlocks raw register access
across every driver. SX128x::writeRegister() is a straight SPIwriteRegisterBurst
on the same Module we already own, and begin() has pointed spiConfig at the
0x18 WriteRegister opcode long before any scan runs, so the transaction is
identical - no subclass needed.

Also raise the CAD window from 4 to 8 symbols, matching RadioLib's own SX128x
default. AN1200.77 measures a longer window as moving P(Detection) further from
P(False Detection); its thresholds are stated to hold for any window duration,
so the table above is unaffected. NUM_SYM_CAD_24GHZ moves in step so the slot
time still reflects the real scan.

Not hardware-verified.

* LoRa LBT: hand CAD straight to RX on the LR parts

CAD exited to standby, and the busy-channel path then issued a full
startReceive() - which standbys the radio and re-enters RX from scratch,
throwing away the reception CAD had just detected. Both LR families expose a
CAD-to-RX exit mode that avoids the round trip, and it reaches the chip through
the public startChannelScan(cfg), so nothing here needs a library patch.

A note in an earlier commit claimed this was impossible because RadioLib
collapses irqFlags and irqMask into one set. That reasoning is SX126x's, where
irqMask is a status gate and an event omitted from it never latches at all.
The LR parts have no such gate: SetDioIrqParams takes a mask per interrupt pin
and SetDioIrqConfig maps IRQs to one DIO, while getIrqStatus() reads the status
bytes returned on every SPI transaction and consults no pin mapping. So the CAD
IRQ set now carries the verdict plus terminal RX events only - preamble and
header stay off the pin, where they would fire the ISR mid-frame and run
readData() on a partial packet - and isActivelyReceiving() still sees them.

rearmReceive() cannot be unconditionally standby-free the way SX126x's is.
cad_timeout 0 means "RX until one packet arrives, then standby", so the two
re-arm paths leave the chip in different states: already listening after the
handoff, back in standby after that packet's RX_DONE. It branches on a flag set
at detection and cleared on use, so the first re-arm skips the standby and the
next restores continuous RX. Forcing continuous RX through the timeout instead
is not reliable - RadioLib converts it to RTC ticks through a float divide, so
the 0xFFFFFF "no timeout" encoding is not addressable.

On a CAD false positive the chip now waits in single-shot RX rather than
returning to standby. Still listening, but a state the old code never entered.

Not hardware-verified.

* LoRa LBT: size the 2.4 GHz CAD slot per chip, not per band

computeSlotTimeMsec() branches on myRegion->wideLora, which is a region rather
than a part. LR1120 and LR2021 both run there, and both scan NUM_SYM_CAD
symbols - but that branch read the flat NUM_SYM_CAD_24GHZ, so raising it to 8
for SX128x moved the slot for two families whose scan never changed. At
LONG_FAST wideLora that priced ~22 ms of CAD against a ~10 ms scan.

getCadSymbolCount() exists precisely so the slot tracks the real scan, but only
the sub-GHz branch consulted it. Use it in both, and give SX128x an override
returning NUM_SYM_CAD_24GHZ. That constant stops being a fleet-wide 2.4 GHz
value and becomes what one driver reports; SX128x is unchanged at 8.78 symbols,
while the LR parts on 2.4 GHz drop from a wrong 8.78 to 4.78.

The error was conservative - slots too long, so throughput lost rather than
collisions caused - but it is the same scan/slot mismatch the rest of this work
removes.

* LoRa LBT: declare CAD symbols per chip and per band, 8 on 2.4 GHz

A 2.4 GHz region is shared: an LR1120 and an SX1280 can sit on the same mesh.
Leaving the LR parts at 4 symbols there while SX1280 scanned 8 gave them
different slot times, and so a different CSMA backoff scale between neighbours
- the cross-family asymmetry this branch removed for sub-GHz, reintroduced one
band up. Both LR families now scan 8 on 2.4 GHz, matching SX1280.

Rather than switch on the band inside a shared constant, each driver states its
own count for each band it works in, and derives the symNum it passes from the
same accessor - so the scan and the slot cannot drift apart:

  SX126x   4 sub-GHz
  SX128x   8 on 2.4 GHz
  LR11x0   4 sub-GHz, 8 on 2.4 GHz
  LR20x0   4 sub-GHz, 8 on 2.4 GHz
  RF95     2 sub-GHz, its CAD being fixed in hardware and not settable

NUM_SYM_CAD and NUM_SYM_CAD_24GHZ become base-class defaults nothing falls
through to; they now record the fleet convention rather than any part's
behaviour.

LR20xx Table 6-19 stops at 4 symbols, so an 8-symbol scan reuses that row -
the last measured point, erring high and so slightly less sensitive than a
true 8-symbol value. Noted at the table.

Also a comment pass over the branch: drop retrospective phrasing that describes
what the code used to do, cut trailing justification clauses, and fix comments
the per-band change left stale - one still claimed the LR parts scan fewer
symbols than SX1280 on 2.4 GHz, which is now the opposite of the truth.

* LoRa LBT: sweep up a CAD->RX handoff that delivers nothing

A handoff leaves the chip listening by its own exit mode, so nothing in the
driver arms the radio and no return value says whether it worked. If the
detection was false, or the exit mode was not honoured, the node waits in a
state we never verified - and on the LR parts that is single-shot RX, which
does not return to continuous listening on its own.

Stamp the handoff and let the existing ~1s poll re-arm if a whole max-length
airtime passes with no packet, the same bound receiveDetected() uses. Clearing
it on a real RX_DONE is obvious; clearing it in startSend() is not, but a TX
can intervene before any packet arrives and its completion re-arms RX anyway,
so leaving the marker set would have the poll re-arm a radio already listening.

Reads the clock through Time::getMillis(), the same source Throttle compares
against, so the site stays testable across the millis() wrap. 0 is the
"none outstanding" sentinel and is tested before the elapsed compare.

Also document on rearmReceive() that an override must never call the chip's own
startReceive() - its standby+SetRx aborts the packet CAD just detected, which
is the entire reason the override exists.

* define txen pin to allow sx128x testing

* LoRa LBT: bound the CAD->RX handoff in hardware, and re-arm after it delivers

cadTimeout only applies to the CAD->RX exit mode. Every driver on develop
exited CAD to standby, so the field was ignored and its value - 0, which no
datasheet defines - was dead data. Switching the exit mode to RX made it
load-bearing while the line itself never changed, so the diff showed nothing.

All three handoff parts now bound that RX at one max-length airtime, taken
from getPacketTime() so it tracks the preset. A detection that delivers
nothing expires to standby and raises TIMEOUT, already mapped to the pin, so
the existing ISR re-arms and the queued TX retries on its own backoff.
handleReceiveInterrupt() returns early on TIMEOUT without RX_DONE; otherwise
each dud detection would reach readData() and be logged as a bad packet.

That RX is single-shot, so the arm after it delivers must be a full one.
SX126x re-armed unconditionally without a standby, which left the chip in
standby while the interface believed it was listening - and nothing swept it,
since RX_DONE has already cleared the handoff stamp. A node that received a
handoff packet then went deaf until its next transmit. It now carries the
same cadHandedToRx flag the LR drivers already had.

Sourcing, since the parts disagree and RadioLib's uniform ChannelScanConfig_t
hides it: SX126x DS_SX1261-2 p98 (15.625us steps), LR11x0 UM Table 8-8
(31.25us), LR2021 lr20xx_driver cad_timeout_in_pll_step (31.25us, contra the
datasheet's "32 MHz periods"). RadioLib scales both LR parts as 30.52us, so
the programmed value lands ~2% long - harmless, and left for upstream.

Also marks the three SWSD003 cells at 2 symbols/BW500/SF10-12 as that SDK's
fallback rather than measurements.

* LoRa LBT: own the CAD->RX handoff in one place, and void it on standby

The handoff state was split across two classes: cadHandoffRxStart in
RadioLibInterface, cadHandedToRx in each of SX126x/LR11x0/LR20x0, whose
rearmReceive() overrides were byte-identical. Neither field was cleared by
setStandby(), so a standby taken while a handoff was outstanding could strand
cadHandedToRx set - and the next rearmReceive() would then re-attach the ISR to
a chip sitting in standby, marking the interface as receiving with no RX_DONE
ever to come and no recovery path. A stale cadHandoffRxStart was milder but
still re-armed the radio over whatever was arriving at the time.

Hold both in the base, arm them together in noteCadHandoffToRx(), clear both in
setStandby(), and collapse the three overrides into one non-virtual base
rearmReceive(). SX128x and RF95 never arm the flag, so they keep taking the
plain startReceive() branch. Document on isChannelActive() that a true return
must be followed by rearmReceive() - the invariant the whole scheme rests on.

Two fixes fall out of the shape:

- The handoff re-arm attaches a rising-edge ISR to a line that is not known to
  be low, unlike startReceive() which clears the IRQ status first. Follow it
  with checkRxDoneIrqFlag(), as startReceive() already does.

- checkCadHandoffTimeout() used one max-length airtime, the same figure the
  chip's own cadTimeout now uses, so the two expired together. Losing that race
  left a queued ISR_RX to run against flags the software path had just cleared,
  falling past the TIMEOUT guard into readData() on an empty buffer. Double it
  so the hardware always expires first; the backstop then only covers an RX
  whose timer stopped on a header that never completed.

* LoRa LBT: re-arm RX when a queued packet is dropped for tx_enabled=false

Pre-existing on develop, not introduced by this branch, but it is the same
failure this series exists to remove. TRANSMIT_DELAY_COMPLETED reaches
startSend() only through isChannelActive(), which leaves the chip in standby
for the transmit about to follow. When that transmit is then dropped because
LoRa TX is disabled, the early return frees the packet and returns without
arming anything: isReceiving stays false, so pollMissedIrqs() skips the radio
too, and the node is deaf until something else re-arms it.

Narrow - tx_enabled has to change between enqueue and dequeue, since send()
checks it as well - but the fix is one call on a path that already exists.

* LoRa LBT: say what the CAD slot time actually does per chip

Two comments in RadioInterface.h described intentions the code does not carry
out.

NUM_SYM_CAD/NUM_SYM_CAD_24GHZ claimed the per-band values keep the CW backoff
scale equal across a mesh. They do not: the slot has to match the scan each part
really runs, and RF95's CAD is fixed in hardware at 2.25 symbols while
SX126x/LR scan 4. A mixed sub-GHz mesh therefore runs two slot lengths, which
is correct per part and worth stating rather than implying otherwise.

The slotTimeMsec initializer runs during RadioInterface's own construction, so
the virtual getCadSymbolCount() it calls always resolves to the fallbacks here,
never a driver override. That is harmless only because applyModemConfig()
recomputes it before anything reads it - left as-is deliberately, since
initializing to 0 would make random(0, 2^CW) * slotTimeMsec fire an immediate
transmit if anything ever did read it early.

* LoRa LBT: set the SX128x CAD threshold at config time, not per scan

isChannelActive() looked up the AN1200.77 PNR row and wrote register 0x0942 on
every scan, but sf and bw only change in applyModemConfig(). That is an SPI
round trip between the channel scan and the transmit that follows it, on the
one path this file's own comments ask to keep short.

Move it to applyCadDetPeak(), called from reconfigure() after the sf/bw setters
it depends on, and from init() as well - init() does not route through
reconfigure(), so it needs its own call.

* ESP32: stop skipping light sleep forever on a stuck LoRa IRQ

The skip added earlier in this series returns instead of sleeping whenever
DIO1 already reads high, so the main loop can service the packet rather than
strand it for the sleep chunk. It had no bound: a line that latches high with
nothing clearing it makes every subsequent call skip too, and the node quietly
stops sleeping at all, announced only by a LOG_INFO each time.

Count consecutive skips - warn at 3, sleep through at 5, reset on any real
sleep. If the IRQ has survived five attempts the main loop is not going to
clear it, and never sleeping costs more than one stranded packet.

Also drop the LORA_DIO1_EXTENDED_IO branch in loraWakeIrqAsserted(): those
boards return from doLightSleep() before it is ever called.

* nrf52 promicro: gate the P1.06 TXEN pin, and add an optional SX1280 build

P1.06 was wired to SX126X_TXEN unconditionally earlier in this series, to drive
an external PA on a bench unit. That is not safe to ship: three envs share this
variant.h, and P1.06 is PIN_WIRE_SCL on the EasyProMicro pinout and
PIN_EINK_BUSY on the InkHUD one. The default builds go back to RADIOLIB_NC.

Put it behind -D PROMICRO_SX126X_TXEN, refused wherever the pin is already
claimed. The EasyProMicro arrangement has its own SX126X_TXEN on P1.00 in the
#else branch and never reaches this, so only the e-ink exclusion is needed.

The pin was wanted for SX1280 in the first place - every other SX128X line is
already assigned on this board and TXEN was the one missing - so add that too,
behind -D PROMICRO_SX1280 and a new nrf52_promicro_diy-sx1280 env. Both opt-ins
claim P1.06, so asking for both is an #error rather than a silent pick.

Costs about 10.7 KB: the image ends at 0xE5140, 19 KB clear of the warm region,
against 0xE2710 and 30 KB for the base env.

* LoRa LBT: fix two defects in the CAD->RX handoff found in review

Both reported by Copilot against 1fcd219af, both confirmed against the code as
it stands after the handoff was hoisted into RadioLibInterface.

rearmReceive() armed the MCU ISR before marking the interface as receiving. An
ISR firing in that window queues ISR_RX while isReceiving is still false, so
handleReceiveInterrupt() bails with "called while not in rx mode" and the packet
is dropped - the exact packet the CAD handoff existed to catch. Reversed, which
also makes this path match the order the drivers' own startReceive() has always
used: base state, then arm, then checkRxDoneIrqFlag().

noteCadHandoffToRx() stored Time::getMillis() raw, but 0 is the "no handoff
outstanding" sentinel that checkCadHandoffTimeout() tests before its elapsed
comparison. A handoff armed on the one millisecond per wrap where getMillis()
returns 0 reads as "none outstanding" and the backstop never fires. Step past
it to 1, the same hand-remap Throttle.h's own TODO records GPS.cpp using for
fixHoldEnds. One millisecond of error, once per 49.7 days.

* ESP32: wake on SX128X_DIO1, which five SX1280 boards define instead of LORA_DIO1

enableLoraInterrupt() only ever looked at LORA_DIO1 and RF95_IRQ. RadioInterface
builds an SX1280Interface from SX128X_DIO1, and several variants name the IRQ
pin only that way, or put it on a different pin from LORA_DIO1:

  tlora_v2_1_18          LORA_DIO1 undefined      SX128X_DIO1 26
  betafpv_2400_tx_micro  LORA_DIO1 undefined      SX128X_DIO1 4
  t-eth-elite            LORA_DIO1 16             SX128X_DIO1 8
  tlora_t3s3_v1          LORA_DIO1 33 // TCXO_EN? SX128X_DIO1 9
  tlora_t3s3_epaper      LORA_DIO1 33 // TCXO_EN? SX128X_DIO1 9

Every branch of the function is gated on defined(LORA_DIO1), so on the first two
it preprocessed to an empty function: esp_sleep_enable_gpio_wakeup() still
enabled GPIO as a wake class, but no pin was ever armed for the radio. On the
other three it armed the wrong pin - GPIO33 there being the line the variant's
own comment questions as TCXO_EN. Those boards could not be woken from light
sleep by an incoming packet; they waited out the 30s chunk and pollMissedIrqs()
recovered the latched RX_DONE about a second after waking. Delayed, not lost,
but deaf for the duration.

loraWakeIrqAsserted(), added earlier in this series, inherited the same blind
spot and was dead code on the two boards with no LORA_DIO1 at all - so the
skip-the-sleep fix never once fired on an SX1280.

Resolve the pin per radio in loraWakeDio1Pin() and use it for arming, polling
and teardown alike, so the three cannot drift apart again, and widen their
guards to accept either macro. Behaviour is unchanged wherever it already
worked: for any non-SX1280 radio the helper returns LORA_DIO1, including on
RF95 boards where the SOC_PM_SUPPORT_EXT_WAKEUP branch arms it regardless of
radio type. LORA_DIO1_EXTENDED_IO still short-circuits first.

Note this is a power-profile change for those five boards, not only a fix: they
gain a LoRa wake source they have never had, so on a busy channel their light
sleep will now be cut short by traffic like every other board's.

* LoRa LBT: trim three-line comments to the repo's two-line limit

copilot-instructions.md:340 says one or two lines; the P1.06 availability note
and the LR2021 DIO routing note were both three. CodeRabbit flagged both.

* LoRa LBT: trace the CAD scan, its exits and the handoff outcome

Eight short lines, all in the base class so they cost one set of strings
rather than one per chip driver. The scan logs arm/busy/free; the in-chip
GOTO_RX handoff logs started, then exactly one of pkt, empty, timeout or
void - so a dropped or double-counted handoff is visible by inspection.

void and timeout are LOG_WARN because both mean an invariant broke: a
standby discarded a live handoff, or the chip's own timer failed to fire
and the software backstop had to re-arm. The rest are LOG_DEBUG, so a node
at normal log level stays quiet unless something is actually wrong.

The two pre-existing handoff strings were 44 and 41 bytes of flash; cut to
"CAD>RX timeout" and "CAD>RX empty", they pay for most of the new set. The
shared "CAD" prefix does not dedup - only string suffixes merge - and
splitting it out behind a %s measured 8 bytes worse on Cortex-M once the
extra pointer argument per call site is counted.

Also fixes the delivered-packet trace, which sat above the TIMEOUT check
and so claimed a packet on a window that expired empty.

* LoRa LBT: restore the SX1280 CAD threshold after a chip-state recovery

The calibrated AN1200.77 PNR threshold moved from a per-scan register write to
a config-time one, so it now has to be re-applied wherever the chip's state is
rebuilt. recoverChipStateLoss() hardware-resets the chip and reprograms only
the modem params, which left a recovered SX1280 doing CAD against the vendor
default threshold until the next reconfigure() - and on a node whose config
never changes, that is the rest of its uptime.

* LoRa LBT: retire RX flags that plain RX never completes

Plain RX runs with no chip timeout (startReceiveDutyCycleAuto(16, 8)
falls back to RX_TIMEOUT_INF) and the DIO mask carries RX_DONE only, so
a PREAMBLE_DETECTED, HEADER_VALID or HEADER_ERR that never completes
stays latched until something re-arms RX. On a node with nothing to
send, nothing does. The CAD->RX handoff already has this recovery
(cadTimeout plus checkCadHandoffTimeout()); plain RX had none.

checkStaleRxFlags() is its twin on the ~1 s pollMissedIrqs() tick. It
notes when flags are first seen, and if no RX_DONE has consumed them one
max-length packet later:

- a header (valid or errored) re-arms RX. RX_DONE clears every flag in
  readData(), so a header still latched has no frame behind it. SX1280
  DS rev 3.3 section 16.2: a header error leaves RX wedged with no
  RX_DONE or timeout, and only a re-arm recovers it.
- a bare preamble is only cleared. ClearIrqStatus/ClearIrq changes the
  IRQ register alone on SX126x, SX1280, LR11x0 and LR20x0, so it never
  aborts a reception; startReceive() goes through standby and would.

HEADER_VALID is never cleared on its own: RadioLib's readData() reads
it at RX_DONE, and on LR2021 its absence reports the packet as header
damaged.

Skipped while a CAD handoff is outstanding (its own timeout owns that
RX) and while RX_DONE is pending. RF95 opts out: its carrier sense reads
modem status rather than latched flags, and SX127x maps neither
PREAMBLE_DETECTED nor HEADER_ERR.

The decision is staleRxFlagAction() in RadioInterface.h, pinned in
test_radio at both sides of the window and for each action.

* LoRa LBT: hold TX on a seen preamble instead of judging it by a deadline

receiveDetected() declared a bare PREAMBLE_DETECTED false once
2 * preambleTimeMsec passed without HEADER_VALID - about 8 ms at
SF7/BW500. Nothing reads the flags on that cadence: they are latched
status bits, never routed to DIO, and the only reader is the TX path,
once per CSMA backoff (0-56 ms at SF7/BW500). The verdict was usually
reached by a read older than its own deadline, and the queued TX went
out over the frame. Reported in #11933 by @jp-bennett, whose two-node
bench measured own TX a median 43 ms after the verdict, inside a ~102 ms
frame. This branch's longer CAD slot widens it to LONG_FAST: 0-308 ms of
backoff at CWmin against a 262 ms deadline.

A read that sees a bare preamble now clears PREAMBLE_DETECTED and holds
TX for one max-length airtime. The clear touches only the IRQ register
(ClearIrqStatus/ClearIrq on SX126x, SX1280, LR11x0, LR20x0), so it never
aborts a reception. Reads during the hold report busy, so the normal
random backoff still spreads retries, and any RX interrupt ends the hold
early. A preamble that fires again during the hold stays latched, and
the first read after it holds again: a source that keeps triggering keeps
deferring TX, one airtime at a time, while a single stale flag cannot
block it. HEADER_VALID is never cleared here - RadioLib's readData()
reads it at RX_DONE, and on LR2021 its absence reports the packet as
header-damaged - and keeps its one-airtime ageing.

The SX126x software IRQ poll routes its bare-preamble case through the
same hold instead of clearing on every tick while a TX is queued, which
would have released TX before the next read could see a refire.

That poll read the private txQueue directly, so meshnology_w10 - the
only env that defines LORA_DIO1_SOFTWARE_POLL - did not compile.
hasQueuedTx() exposes the one fact it needs.

#11937 addresses the same defect by deferring the verdict when the
previous read was stale; the two conflict in receiveDetected().

* LoRa: re-arm RX before logging the packet that just went out

After TX_DONE the chip sits in standby, deaf, until startReceive(). The ISR_TX
path used to log airtime ("Packet TX") and printPacket() the sent packet
first, which kept it deaf for ~2-5 ms more depending on where the log goes.
Measured on an nRF52 SX1262 bench node: the TX_DONE-to-RX gap drops from
~8.8 ms to ~3.7 ms with USB serial logging (~11.3 to ~6.2 ms with the log
also going to a phone over BLE).

completeSending() is split into detachSentPacket(), which clears sendingPacket
and runs packetReleased() so RX re-arms on the home config, and
finishSentPacket(), which does the airtime, counters, log and pool release.
setStandby() callers keep the combined completeSending().

* SX126x: keep the TCXO powered in standby

RadioLib leaves standbyXOSC off, so standby and the post-TX/RX fallback both
use STDBY_RC, which powers a DIO3-controlled TCXO down. Every SetRx and SetTx
after that first waits out the TCXO start-up delay programmed into the chip
(RadioLib's setTCXO() default, 5000 us). On an nRF52 SX1262 bench node the RX
start after TX_DONE took ~6.0 ms at SF7, SF9 and SF11 alike; with
setStandbyXOSC(true) it takes ~0.95 ms, and the TX_DONE-to-RX gap falls to
~1.1 ms together with the previous commit. The result held across the
periodic AGC reset.

Only boards that drive a TCXO from DIO3 change. The cost is the TCXO and XOSC
current while the chip sits in standby, which the firmware keeps brief;
duty-cycled RX still sleeps, and powers the TCXO down, between windows.

* LoRa: skip the periodic AGC reset while a packet is queued

resetAGC() ends in startReceive(), and trySetStandby() first runs
checkNotification() to drain pending events. With a packet queued that can be
TRANSMIT_DELAY_COMPLETED: CAD passes, startSend() keys up, and trySetStandby()
then puts the chip in standby over the transmission it just started.
completeSending() still reports the packet as sent and counts its airtime.
A bench node logged this twice in 20 minutes; one was a packet's last retry,
so it never went on air.

Skip the reset for this tick when a TX is queued. The queue drains within
seconds, and the reset comes round again on the next interval.

* GPS: re-enable on a LoRa config save only when the region was unset

#10386 keeps the GPS off until a region is set and turns it on when a LoRa
config save sets one. The check never looked at the old region, so a GPS the
probe had disabled ("GPS not detected; not present this boot") was
re-enabled by every LoRa config save, preset changes included, and the probe
ran again. The probe blocks the cooperative main loop: on a bench node with
no GPS fitted, TX_DONE handling stalled by 0.34-0.89 s on 4 of 9 packets
after a preset change, leaving the radio deaf in standby for that long.

* LR20x0: CAD exit to RX is 0x01 on the LR2021

RadioLib (510e00cf) defines RADIOLIB_LR2021_CAD_EXIT_MODE_RX as 0x02. The
LR2021 takes SetLoraCadParams exit modes 0x00 CAD only, 0x01 RX on
detection and 0x10 TX when clear, as the LR11x0 does, and refuses 0x02
with a processing error (-706). With the CAD>RX handoff passing 0x02,
every scan failed before the CAD ran and isChannelActive() reported the
channel clear: on a Seeed Mesh-Tracker X1, 539 scans against live
traffic gave no busy verdict, so the node transmitted with no carrier
sense.

Pass 0x01, and warn when a scan fails rather than reporting it clear in
silence.

* LoRa LBT: take out the preamble hold on a seen preamble

Revert be3bf1f38. receiveDetected() judges a preamble by its two-preamble
deadline again, and the SX126x IRQ poll clears a bare preamble directly.
hasQueuedTx() stays: periodicRadioMaintenance() still uses it.

* SX126x: read the queued-TX state through hasQueuedTx() in the IRQ poll

957fbd3f5 restored the poll's direct txQueue.empty() read along with the
preamble clear. txQueue is private to RadioLibInterface, so meshnology_w10,
the only env that defines LORA_DIO1_SOFTWARE_POLL, no longer compiled.

* SX126x: retry a standby that reports a stale command timeout

After a CAD-to-RX handoff's bounded RX times out, the status byte that
comes back with the next SET_STANDBY still carries command status 0x3
(timeout). RadioLib reports that as RADIOLIB_ERR_SPI_CMD_TIMEOUT although
the chip took the command, so every "CAD>RX empty" was followed by a
failed standby, a failed RX start and a full chip re-init. Send
SET_STANDBY once more on that error; the second one sees a fresh status.

Measured on a CH341 host (SHORT_TURBO): chip re-inits went from 3 to 0,
and all 7 retries returned 0.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9
(cherry picked from commit 57d6a86b71)

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:14:40 +00:00
dee945cd6a CH341: fewer waits on the USB bus per radio command (#12013)
* CH341: fewer waits on the USB bus per radio command

Every RadioLib command to a radio behind a CH341 waits on the USB bus about
five times: the BUSY read before it, CS low, the command, CS high, and the
BUSY read after, with a 1 us settle between the last two that usleep() turns
into ~50 us of timer slack.

- A BUSY read that follows the read that found BUSY low after a command is
  answered from it, once, within 1 ms and only if nothing else has been sent
  to the adapter in between: RadioLib's wait before a command then costs
  nothing after the wait that ended the last one.
- Delays under 100 us spin on the steady clock instead of sleeping.
- With a libch341 that has pinedio_transceive_select(), and where Lora.CS is
  D0, the CS levels travel with the SPI transfer and RadioLib's separate CS
  writes are dropped. Against an older libch341 this part compiles out.

The HAL learns the radio's CS and BUSY pins from initLoRa(), since USBHal.h
is included by PortduinoGlue.h ahead of portduino_config.

Measured on an SX1262 over a CH341 (SHORT_TURBO, 6 v 6 runs of 300 s): the
three-command CAD setup fell 1.01 -> 0.62 ms, CAD arm to air 6.60 -> 5.23 ms,
and total packet loss 1.51 -> 1.13% (p=0.048), with collisions 4.83 -> 0.67
per run (p=0.002).

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Bump libch341-spi-userspace to 027cde5 for pinedio_transceive_select()

Picks up libch341#6 (1 ms interrupt poll, poll thread woken on detach) and
#7 (pinedio_transceive_select()), so the packed-CS path in USBHal.h builds
in rather than compiling out.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-10-01 23:57:06 +00:00
Georg von StackelbergandClaude Sonnet 5.5 e0c670a65d Meshnology W12: report MESHNOLOGY_W12 hardware model (#12037)
The MESHNOLOGY_W12 enum (145) was added in meshtastic/protobufs#1042, but
the W12 variant predates it and still reports PRIVATE_HW (255). Add the
HW_VENDOR mapping in architecture.h, set custom_meshtastic_hw_model to 145,
and drop the outdated "no hardware model yet" comment.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 22:46:39 +00:00
Jonathan BennettandClaude Opus 5 8c0abbd522 feat(portduino): BLE peripheral support via BlueZ for meshtasticd (Raspberry Pi) (#11396)
* feat(portduino): BLE peripheral support via BlueZ for meshtasticd on Linux

Adds the standard Meshtastic BLE service (toRadio/fromRadio/fromNum/logRadio)
to the Linux native target, so a Raspberry Pi running meshtasticd can be
paired and used over BLE like any other Meshtastic device.

Implementation: a new LinuxBluetooth backend registers a GATT application,
LE advertisement and pairing agent with bluetoothd over the org.bluez D-Bus
APIs, using sdbus-c++ (both the 1.x and 2.x major versions, via a small
compat shim - Debian bookworm/Ubuntu 24.04 ship 1.x, trixie/Fedora ship 2.x).
When the sdbus-c++ dev package is absent the whole backend compiles out via
__has_include, the same optional-dependency idiom as the ulfius webserver.

Threading follows the NimbleBluetooth model, simplified: the sdbus event
loop runs its own thread, and all PhoneAPI calls happen on the main thread.
Writes queue to the main loop; reads park the D-Bus reply and are completed
from the main thread after queued writes, so write-then-read clients see
their answer without any busy-waiting.

Enablement is a double opt-in: a new `Bluetooth:` config.yaml section
(Enabled, default false; AdapterId, default hci0) must turn BLE on for the
host, and the regular device config bluetooth.enabled must be on. The
config-check schema and fixtures cover the new section.

Pairing honors config.bluetooth.mode: NO_PIN maps to a NoInputNoOutput
just-works agent; RANDOM_PIN to DisplayOnly with the kernel-generated
passkey shown on screen/log via the existing BluetoothStatus plumbing.
FIXED_PIN falls back to random-passkey semantics with a warning - BlueZ
does not support forcing a passkey. PIN modes enforce
encrypt-authenticated-read/write on all mesh characteristics.

Packaging: install a D-Bus system policy so the meshtasticd user may talk
to org.bluez, add it to the bluetooth group, order the unit after
bluetooth.service, and add libsdbus-c++-dev to debian/rpm/docker/CI deps.

Verified in-container against a mock bluetoothd: registration flow, GATT
tree enumeration, advertisement properties, and a full config download
(ToRadio wantConfig -> 47 FromRadio packets) through the D-Bus bridge.
Real-hardware pairing/notify testing on a Pi still pending.

Known limitations (v1): meshtasticd must be restarted if bluetoothd
restarts; FIXED_PIN degrades to a random passkey; getRssi() returns 0
(same as nRF52).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): BLE fixes from first real-hardware pass (Pi CM5 + RAK6421)

Findings from testing PR #11396 on a Raspberry Pi CM5 (Pi OS trixie,
BlueZ/sdbus-c++ 2.1 - the v2 compat path) with a RAK6421 HAT and an
Android phone:

- getMacAddr() leaked its HCI socket on every call and never closed it,
  and on failure returned without touching the caller's buffer - which
  getDeviceName() passed in uninitialized. Close the socket on all paths
  and cache the MAC after the first successful read; it cannot change at
  runtime and this now runs on every bluetoothd property read.
- getDeviceName() zero-initializes its MAC buffer, and LinuxBluetooth
  snapshots the name once at setup() on the main thread: the
  advertisement's LocalName getter runs on the D-Bus event-loop thread
  and getDeviceName()'s static buffer is not thread-safe.
- Restore NimBLE-style config-phase packet prefetch (depth 3). The
  initial port answered every FromRadio read with a D-Bus -> main-loop
  round trip, which made the config download noticeably slow; ReadValue
  now answers straight from the prefetch queue on the event-loop thread,
  with NimBLE's safety rules (never in STATE_SEND_PACKETS, writes always
  observed before reads, queue cleared on disconnect).
- Set advertising MinInterval/MaxInterval to 20-100ms (BlueZ >= 5.71;
  older versions ignore the properties). btmon showed the kernel default
  of 1.28s otherwise, and Android's background-connect scan windows are
  sparse enough that tap-to-connect took 8-14s; 20ms is the same floor
  NimBLE uses on ESP32.

Verified on hardware: scan, passkey pairing, connect, config download,
reconnect after bond wipe. Also diagnosed (no code change): the node
identity MAC comes from the RAK HAT EEPROM by design, so the BLE name
suffix follows the HAT rather than the BT adapter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): address CodeRabbit review on BLE support

- setBluetoothEnable: handle disable before the config gate, so a running
  BLE stack is always stoppable even after the device config turns
  Bluetooth off underneath it
- getMacAddr: read the adapter configured as Bluetooth.AdapterId instead
  of hardcoding hci0, falling back to hci0 for unparseable names
- systemd unit: Wants=bluetooth.service so bluetoothd is pulled up when
  present (After= only orders, it does not start it)
- debian/rpm: Recommends: bluez as the runtime contract for BLE
- dbus policy: document why the org.bluez rule is destination-wide
  rather than a per-interface allowlist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): show the BLE pairing code on BaseUI screens

onDisplayPasskey published the passkey to bluetoothStatus and triggered
PowerFSM, but never called screen->startAlert(), so on BaseUI the code only
ever reached the log. BluetoothStatus has no BaseUI consumer -- only InkHUD's
PairingApplet and StatusLEDModule read it -- so a Pi driving a HUB75/OLED
panel showed nothing while BlueZ sat waiting for the user to type a code they
could not see. NimBLE and nRF52 draw it via startAlert(); this adds the
missing half for Linux.

The agent callbacks run on the sdbus event-loop thread while the screen is
owned by the main thread, so the passkey is handed over as a pending flag and
drawn from runOnce(), matching the existing disconnectCleanupPending pattern
rather than reaching into the screen from the event loop.

Dismissed on all four exits, so a stale code cannot stick on an always-on
panel: Paired -> true (newly watched in PropertiesChanged, which previously
only looked at Connected), agent Cancel, peer disconnect (moved out of the
lastGone branch so a peer leaving mid-pairing clears the code even when
another device is still connected), and doDeinit() -- applied inline there
because runOnce() may never be scheduled again after teardown.

Verified on a Pi 5 + BlueZ 5.66 in RANDOM_PIN mode: the code renders on a
HUB75 panel and clears once the phone completes pairing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: install libsdbus-c++-dev for the native test build

setup-native-test landed on develop while this branch was adding
libsdbus-c++-dev to setup-native, so the new action's "full setup-native
list" of C libraries is missing it. Without the package the test job
builds with HAS_BLUETOOTH 0 and never compiles LinuxBluetooth.cpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): warn when a factory reset cannot clear BLE bonds

factoryReset(eraseBleBonds) silently did nothing on Linux when the BLE
backend was not running, so the reset reported success while the host's
pairings stayed. Removing them needs a live connection to bluetoothd that
a disabled backend never opened, so say so rather than imply they went.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): gate the factory-reset bond clear on an enabled backend

setup() leaves linuxBluetooth allocated with its bus torn down when it
throws, so a pointer check alone let factoryReset log "Clear bluetooth
bonds" for a clear that clearBonds() then declined to perform. isEnabled()
is only true after setup() completes, which routes that case to the
warning that says the bonds were left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* style: reformat under clang-format 20

#11909 moved trunk from clang-format 16 to 20, which spaces C-style casts
differently and reindents the comment above the HAS_WIFI block. Both files
are ones this branch already touches, and trunk's fmt linter grades whole
files, so its check fails until they are reformatted. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): three BLE config and lifecycle fixes from review

Bluetooth config keys are now assigned individually rather than per section.
loadConfig() runs once for every file in config.d, so reading an absent key as
its default let a later file that named only one of them silently reset the
other: `AdapterId: hci1` alone turned Bluetooth off, and `Enabled: true` alone
dragged the adapter back to hci0. Only what a file actually states should
override what an earlier one set.

A backend that failed to come up is now retried. setup() can leave
linuxBluetooth non-null but disabled - bluetoothd not ready, adapter missing,
policy refusing - and every later enable then called resumeAdvertising(), which
returns immediately while disabled. A transient failure at boot kept BLE off
until the process restarted. doSetup() already opens with `if (enabled) return`
and tears the bus down on every failure path, so calling it again is safe.

Bluetooth.AdapterId is now checked for the hci<digits> form. LinuxBluetooth uses
the value verbatim as the BlueZ object path while the MAC fallback reads only
the leading hciN, so "hci1junk" looks plausible, yields a MAC, then finds no
adapter and BLE never comes up. Covered by a new fixture and suite case, which
is the kind of silent no-op that directory exists to catalogue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-01 21:20:09 +00:00
f5940ab5dd Meshtasticd notifications (#10216)
* First attempt at sending message notifications on Linux

* Add files via upload

* Notifications: Use libnotify

Use libnotify for meshtasticd desktop notifications.

Add "HAS_LIBNOTIFY" macro to guard for builds where libnotify is expected.
This is not included in buildroot/openwrt builds. (no libnotify except when extra repos are added).

Install desktop icon in the correct location on Debian and Fedora packages.
Update dependencies in packaging and dockerfiles.

* Add libnotify to setup-native (GitHub Actions)

* Address review feedback on the meshtasticd notification path

- platformio.ini: only define HAS_LIBNOTIFY when pkg-config actually finds
  libnotify. The probe previously ran unguarded and the macro was defined
  unconditionally, so a native build without libnotify-dev both hard-failed at
  config time and claimed the feature was available.
- Fix the sender lookup for develop's flattened NodeInfoLite: has_user/user.*
  are gone, replaced by nodeInfoLiteHasUser() and direct long_name/short_name.
  This is a silent semantic conflict - it merges cleanly but does not compile.
- Move the desktop notification off the packet path. notify_notification_show()
  is a synchronous DBus round trip and meshtasticd's packet handling is
  single-threaded, so a slow or wedged notification daemon could stall the
  radio. handleReceived() now resolves the strings and queues them (bounded at
  16); a dedicated worker owns every libnotify call.
- Stop retrying forever: a failed notify_init(), or three consecutive failed
  shows, latches desktop notifications off instead of re-logging per message.
- NodeDB: the ARCH_PORTDUINO default-enable block was nested inside
  #ifdef HAS_I2S, which Portduino never defines, so it never ran. Hoist it out.

* fix(portduino): close input-broker guard before the libnotify block

The libnotify implementation was inserted ahead of the #endif that closed
#if !MESHTASTIC_EXCLUDE_INPUTBROKER, so the file's trailing #endif closed
#if HAS_LIBNOTIFY instead and the input-broker conditional was never closed.
Every build target failed to preprocess:

  src/modules/ExternalNotificationModule.cpp:630: error: unterminated #if

Close the guard immediately after handleInputEvent(), as develop does, so the
two conditionals stay independent and HAS_LIBNOTIFY still resolves when the
input broker is excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EZstXBJtzRyBmUD1h2FLs

* fix(portduino): back off desktop notifications instead of latching them off

notifyDisabled was doing two jobs: "libnotify looks unusable" and "the
destructor wants the worker to exit". Because the worker's only exit path was
also its only failure path, three failed shows turned notifications off for the
process lifetime with no way back. The packaged daemon is exactly that case:
bin/meshtasticd.service runs as User=meshtasticd and the rpm spec creates that
account with /sbin/nologin, so there is no session bus and every show() fails.
A stock install logged three warnings and then went silent forever, while
NodeDB now enables the module by default on Portduino.

Split the flag. notifyShutdown is destructor-only and remains the worker's one
exit; a retry window replaces the latch. After maxNotifyFailures the worker
arms a backoff (30s, doubling to a 15min cap), drops the queue rather than
holding stale popups, and keeps looping. The producer refuses to queue while
the window is open, so the retry is driven by the next message after it expires
rather than by a timer - no idle wakeups, no probe notifications. Any success
resets the backoff. notify_init() moved inside the loop so a retry can pick up
a session bus that was absent at startup.

The worker also no longer calls the LOG_ macros. RedirectablePrint formats into
a shared static buffer that nothing guards and every other writer to it is on
the main thread, so the worker now records the state change under the mutex it
already holds and reportNotifyStatus() emits it from portduinoNotify() and
runOnce(). Per-message failure spam becomes one line per transition: the reason
and retry interval when it goes down, and a line when it comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EZstXBJtzRyBmUD1h2FLs

* fix(portduino): sanitize mesh text before it reaches libnotify

The notification body was the raw decoded payload and the summary was a node
name, both attacker-controlled, and neither was checked before being handed to
libnotify. g_variant_new_string() rejects invalid UTF-8: a GLib CRITICAL and an
"[Invalid UTF-8]" body by default, and a hard abort under G_DEBUG=fatal-criticals,
so an unauthenticated mesh packet could terminate meshtasticd on any install
running with that flag. An embedded NUL separately truncated the body at the
first one, hiding the rest of the message.

Route both strings through sanitizedMeshText(), which replaces embedded NULs and
then applies the existing sanitizeUtf8() helper. TypeConversions already
sanitizes names on the way into NodeDB, but an abort is too sharp an edge to
leave resting on an invariant owned by another file.

Escape the body for Pango markup as well. Servers advertising "body-markup"
parse a markup subset there, so a message can inject formatting to dress itself
up as trusted UI, and where the server also advertises body-images or
body-hyperlinks it can inject tags that make the notification daemon fetch a
remote URL. The escaping is unconditional rather than gated on
notify_get_server_caps(): a caps query that fails, or goes stale across a daemon
restart, fails open, while on a server without body-markup the only cost is
entities rendering literally, and mesh text carries no markup worth preserving.
The summary is not escaped - the spec gives it no markup, so escaping it would
only ever show entities.

Also call notify_uninit() in the destructor, after the join: the worker owns
every libnotify call, so tearing down while it is still running would be a
use-after-uninit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EZstXBJtzRyBmUD1h2FLs

* fix(portduino): gate the notification default-on behind HAS_LIBNOTIFY

The default-enable block was gated on ARCH_PORTDUINO, so a native build where
pkg-config could not find libnotify still shipped external_notification enabled.
portduinoNotify() is not compiled into that build, and native defines
EXT_NOTIFICATION_MODULE_OUTPUT as 0 with setup() guarding the pin writes behind
output > 0, so nothing was driven - it only exposed a config surface that can
do nothing. Gate it on the same macro that governs the code it exists to feed.

HAS_LIBNOTIFY is a build flag for the whole env and is simply absent when the
probe fails, so this reads as 0 on every other target, matching how
ExternalNotificationModule.cpp already tests it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EZstXBJtzRyBmUD1h2FLs

* fix(portduino): fail closed when markup escaping returns NULL

escapedNotificationBody() returned its input unescaped if g_markup_escape_text()
gave back NULL, which would hand libnotify the exact attacker-controlled string
the function exists to neutralize. The branch is unreachable for the input we
pass - already sanitized to valid UTF-8, and GLib documents no NULL return for
it - but an error path whose fallback is the unsafe action is the wrong shape
for a helper on this boundary. Drop the body instead.

Also note in the doc comment why running this on the caller's thread does not
weaken the rule that the worker owns every libnotify call: it is a pure GLib
string function that touches no libnotify or DBus state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EZstXBJtzRyBmUD1h2FLs

* style: format ExternalNotificationModule.h per the pinned clang-format

Trunk Check Runner failed on c0d5ebff with "1 unformatted file" against this
header. The deviation is a brace-spacing fix that clang-format wants on the
rtttl stub's begin(): the formatter emitted it while reformatting this file for
an earlier commit, and I reverted it then as unrelated churn. It was not -
trunk's fmt check reports a modified file as a whole, so touching this header at
all surfaces that line, and dropping the formatter's output is what turned the
check red.

Verified with the version trunk.yaml pins, clang-format 20.1.0, rather than the
older one available locally: it is the only remaining deviation across the three
files this branch touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EZstXBJtzRyBmUD1h2FLs

* fix(portduino): stop the libnotify probe swallowing native-tft's link flags

The docker arm64 builds failed to link native-tft:

  libmeshtastic-device-ui.a(CURLService.cpp.o): undefined reference to symbol
  'curl_easy_reset@@CURL_GNUTLS_3'
  /lib/aarch64-linux-gnu/libcurl-gnutls.so.4: DSO missing from command line

-lcurl was never on the link line. The libnotify probe was the last line of
[native_base].build_flags, and an env extending it writes

  build_flags = ${native_base.build_flags} -Os -lcurl -lX11 ...

so the interpolation appended those flags to the probe's own line. The result is
a single shell command ending in `... && echo -D HAS_LIBNOTIFY=1 || : -Os -lcurl
-lX11 -linput -lxkbcommon -ffunction-sections -fdata-sections -Wl,--gc-sections`,
where the trailing flags are arguments to echo when the probe succeeds and to `:`
when it fails. They never reach the compiler, and nothing reports it.

Three envs lost flags this way: native-tft and native-tft-debug (-lcurl -lX11
-linput -lxkbcommon), and native-fb (-lcurl, --gc-sections). env:native appends
nothing on that line, which is why the native test suite stayed green and this
stayed hidden until the module compiled and something actually tried to link.

Move the probe above `-I /usr/include` so a plain flag line ends the value, and
record the constraint so the next flag added here does not re-break it.

Verified with `pio project config --json-output`: before, the three envs carried
the flags inside the probe's command string; after, all three carry them as
build flags and no env still swallows any.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EZstXBJtzRyBmUD1h2FLs

---------

Co-authored-by: Austin Lane <vidplace7@gmail.com>
Co-authored-by: Tom Fifield <tom@tomfifield.net>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-01 17:38:14 +00:00
Manuel 17cc649a64 fix(gps): stop the AIROHA engine before power-down on boards with GPS_SLEEP_INT (ThinkNode M9) (#12004)
* fix AIROHA GPS and M9 sleep/wake

* fix nrf compile error

* make coderabbit happy as well
2026-09-29 16:11:52 +00:00
Thomas Göttgens 09e514602e feat(variants): add L1 Pro 1W battery curve and brightness levels (#11972)
- Add OCV_ARRAY for the L1 Pro 1W battery.
- Let variants override the brightness menu levels via
  SCREEN_BRIGHTNESS_LEVEL_{MEDIUM,HIGH,VERY_HIGH}; defaults unchanged.
- Set L1 Pro 1W brightness levels to 100/160/255.
- Drop USE_SSD1306 from the L1 Pro 1W variant; USE_SH1106 is set in
  configuration.h.
2026-09-25 20:10:14 +02:00
Tom f90b48ea6c chore: trunk fmt --all (#11938)
Whitespace and comment-alignment only: the output of `trunk fmt --all` on
develop @ d49cf21c3 with the pinned clang-format@20.1.0 and prettier. 48
files had drifted; trunk-action only checks the files a PR touches, so the
drift never fails CI and instead lands as noise in the next PR to edit any of
them. No code change.
2026-09-24 22:41:37 +00:00
Jonathan Bennett 608ff51c86 Update L2 TCXO value (#11960) 2026-09-24 12:45:11 +00:00
rcarteraz 12db3b6dc1 Declare custom_meshtastic_has_mui for Heltec V4 R8 (#11957)
Matches the heltec-v4 env. Fixes #11956.
2026-09-24 07:40:13 +00:00
b1470cd719 fix(heltec): sleep the T1 and T096 panels on screen-off to stop image retention (#11894)
* fix(heltec-t1): sleep the panel on screen-off to stop image retention

The T1 is excluded from the LovyanGFX sleep()/wakeup() calls because it uses
TFT_eSPI, so DISPLAYOFF only dropped the backlight and the ST7735 kept driving
the last frame unlit for the whole screen-off timeout. That constant static
image is what burns ghost pixels into the panel.

Add opt-in TFT_SLEEP_WHEN_OFF for the TFT_eSPI path: DISPOFF + SLPIN on
screen-off, SLPOUT (120 ms) + DISPON on wake, issued as raw MIPI DCS since
TFT_eSPI exposes no sleep API. Frame memory survives sleep-in, so the previous
frame reappears and the dirty-window diff continues unchanged. The sleep flag
keeps the double displayOn() in Screen::handleSetOn() from paying the delay
twice.

* fix(heltec-t096): sleep the panel on screen-off to stop image retention

The T096 sits behind the same TFT_eSPI exclusion as the T1, so DISPLAYOFF only
dropped its backlight while the ST7735S kept driving the last frame unlit for
the whole screen-off timeout. Same panel, same bus, same burn-in.

Opt in to TFT_SLEEP_WHEN_OFF; the TFTDisplay side of the fix is already generic.

* fix(tft): harden the TFT_SLEEP_WHEN_OFF wake/sleep sequence

Drive VTFT_CTRL LOW before SLPOUT, so the rail is up before the panel is
addressed. Wait out the remainder of the 120 ms the controller needs after
SLPIN before sending SLPOUT, so a wake landing as the screen timeout fires is
not dropped. Guard DISPLAYOFF on panelAsleep to match DISPLAYON.

Correct the T1 VTFT_CTRL comment: LOW enables the rail, not HIGH.

* fix(tft): wait out only what is left of the sleep-in window before SLPOUT

Throttle::isWithinTimespanMs() is true for the whole 120 ms after SLPIN, so the
wake path paid a fresh 120 ms on top of however much had already elapsed. A wake
119 ms after the SLPIN waited ~120 ms rather than ~1 ms, up to 119 ms of
avoidable latency on every quick off/on.

Add Throttle::remainingMs(), which returns what is left of the interval and
saturates at 0 instead of underflowing to a ~49 day wait. It reads the clock
once, so a caller that tests and then waits cannot be preempted between the two
and land on that underflow - which a separate isWithinTimespanMs() plus
subtraction at the call site could.

heltec-mesh-node-t1 and -t096 both build; neither is board_level = pr, so CI
does not compile this path. Docker native suite green, 1515/1515.

* trunk

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-09-23 09:10:38 +00:00
Manuel d971d507c4 Thinknode M9 V2: keyboard and GPS (#11905)
* thinknode v2 keyboard and GPS

* update device-ui commit reference
2026-09-20 15:29:51 +00:00
renovate[bot] fa87e7d29c chore(deps): update libch341-spi-userspace digest to d85aceb (#11907)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-19 08:44:00 +00:00
rcarteraz 60f82b4488 Fix Mesh Node T1 device image filename (#11893)
custom_meshtastic_images pointed at heltec-mesh-node-t1.svg, which does
not exist in web-flasher. The file there is heltec-meshnode-t1.svg. The
wrong path returns HTTP 200 with a 7 KB HTML error page rather than the
203 KB SVG, so it does not surface as a 404.
2026-09-18 14:58:11 +00:00
renovate[bot] d4166d0426 chore(deps): update libch341-spi-userspace digest to eaaef01 (#11895)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-18 06:45:22 +00:00
54334ff936 Initial firmware support for Axiometa Genesis Mini (#11852)
* Initial firmware support for Axiometa Genesis Mini

* Report the real AXIOMETA_GENESIS_MINI hardware model

The protobufs now carry AXIOMETA_GENESIS_MINI = 148, so the board no
longer has to masquerade as private hardware.

On ESP32 the -D PRIVATE_HW flag never selected the model on its own -
architecture.h has no arm for it, so the board fell through to the
PRIVATE_HW default at the end of the chain. Give it its own arm and drop
the flag.

* fix(input): sample the encoder button after light-sleep wake

The edge that wakes the device lands while beforeLightSleep() has the
interrupts detached, and a button that is still held produces no further edge
until it is released. The thread stayed parked at INT32_MAX, so the press was
never sampled - no event was emitted, PowerFSM's GPIO-wake branch reads
BUTTON_PIN rather than the encoder pin, and the node dropped straight back into
light sleep with the press swallowed entirely. The second press worked, the
first did not.

Sample once on wake, and only when the button is asserted, so a timer or radio
wake leaves the thread alone. The press then follows the ordinary path and
InputBroker drops the event because the screen was off, so it wakes the screen
and does nothing more - the same behaviour every other input device has.

Rotation stays deliberately non-waking: only the button pin is armed in
doLightSleep(), and the abState re-seed discards a shaft moved during sleep
rather than replaying it as detents.

Reported by CodeRabbit on #11852.

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

---------

Co-authored-by: rcarteraz <robert.l.carter2@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:22:05 +00:00
rcarteraz 7bfa062f27 fix(esp32): raise T-Watch S3 to support level 1 (#11884) 2026-09-17 15:46:38 +00:00
renovate[bot] dbbd42687b chore(deps): update meshtastic/crypto digest to 1c817c2 (#11879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-17 12:03:28 +00:00
Thomas Göttgens f36a1ea821 nRF52: reclaim flash to bring rak4631 back under its size budget (#11873)
* build(nrf52): drop unused TinyUSB classes and assert function-name strings

Only the CDC class is used on nRF52. Disable the MSC, HID, MIDI, vendor and
video class drivers in the Adafruit TinyUSB config, and pass an empty
__ASSERT_FUNC so assert() no longer embeds __PRETTY_FUNCTION__ strings.
File and line are still reported.

rak4631 estimate: ~7.4 KB flash, ~2.3 KB RAM.

* fix(nrf52): link only the secp256r1 cc310 curve domain

CRYS_ECPKI_GetEcDomain indexes ecDomainsFuncP, which references the
parameter tables of all eleven cc310 curves. Bluefruit LESC pairing only
requests secp256r1, so override the lookup to return that domain alone.

rak4631 estimate: ~7.4 KB flash.

* fix(airtime): replace powf in the channel-utilization EMA fold

foldChannelUtil was the only powf caller on nRF52. The exponent is an
integer step count, so raise the EMA factor by squaring instead; a
multi-day sleep still folds in at most 32 multiplications.

rak4631 estimate: ~1.9 KB flash.

* fix(graphics): use double sin/cos in the compass renderers

The compass renderers were the only sinf/cosf callers on nRF52 screen
builds, pulling in the float trig kernels next to the double ones GeoCoord
already links. Call the double variants instead.

rak4631 estimate: ~3.2 KB flash.

* fix(motion): use double atan2 for magnetometer heading fallbacks

MMC5983MA, QMC6309 and the InkHUD map centre were the remaining
application atan2f callers. The double atan2 is already linked, so the
float variant only added atan2f, __ieee754_atan2f and atanf. The saving
lands once meshtastic/Fusion#1 removes the library's atan2f as well.

rak4631 estimate: ~0.8 KB flash with Fusion#1.

* fix(hopscale): trim diagnostic logging to state changes and anomalies

Drop the save/restore confirmations, the hourly histogram and trend dumps,
the denominator step logs and the per-packet hop_limit log (printPacket
already reports HopLim). Keep the save-failure and histogram-full warnings,
the congestion on/off transition and a single periodic status line, and
remove lastScaledPerHop, which only fed the logs.

* fix(hopscale): silence cppcheck uselessAssignmentArg on restored count

* perf(crypto): use full-schedule AES128/AES256 for AES-CCM

aesSetKey used AESSmall128/AESSmall256, which re-derive round keys for
every block. AES128/AES256 precompute the schedule, encrypt faster and are
already linked by encryptAESCtr, so the AESSmall*/AESTiny* code drops out.
No change on ESP32, where AESSmall* already aliases AES128/AES256.

rak4631 estimate: ~3.9 KB flash; cipher object up to 184 bytes larger.

* perf(nrf52): use the shared software CTR for AES-256 and remove tiny-aes

CryptoCell only accelerates AES-128, which stays on hardware. AES-256 CTR
now calls CryptoEngine::encryptAESCtr (rweather CTR<AES256>, already
linked) instead of the in-tree tiny-aes copy, whose sources were removed
in the previous commit. Output is identical.

rak4631 estimate: ~0.8 KB flash.

* perf(mesh): use std::map for pending retransmissions and API port timestamps

NextHopRouter::pending and PhoneAPI::lastPortNumToRadio were the only
unordered_map instances linked on nRF52. Switching them to std::map, which
is already linked, drops the libstdc++ hashtable, rehash policy and prime
table. GlobalPacketId gains operator<; the unused hash functor is removed.

rak4631 estimate: ~2.1 KB flash.

* perf: parse sensor decimals without strtod

The WS85 serial parser (strtof) and DFRobotLarkSensor (String::toFloat)
were the only callers of newlib's strtod. Add parseDecimalFloat to
meshUtils for plain [+-]digits[.digits] fields and use it at both sites.
Covered by test_type_conversions against strtof.

rak4631 estimate: ~4.5 KB flash.

* perf(gps): compute tan from sin/cos in UTM and OSGR conversion

latLongToUTM and latLongToOSGR were the only tan callers. sin and cos are
already linked, so deriving tan from them drops tan and __kernel_tan.

rak4631 estimate: ~1.1 KB flash.

* fix(graphics): only dispatch the theme menu when TFT coloring is enabled

The Theme option is only offered with GRAPHICS_TFT_COLORING_ENABLED, but
handleMenuSwitch dispatched ThemeMenu unconditionally, linking kThemes and
the theme accessors into monochrome builds where the menu is unreachable.

rak4631 estimate: ~1 KB flash.

* fix(senxx): trim diagnostic logging to errors and user-visible actions

Keep all errors and warnings and a single version line; shorten the admin
action messages; drop progress chatter, state save/restore confirmations and
the per-reading and VOC-state debug dumps. The nested VOC restore branch
collapses to one condition with the same behaviour.

rak4631 estimate: ~2 KB flash.

* build(nrf52): define CRYPTO_AES_NO_DECRYPT

CTR and CCM only encrypt, so the AES inverse tables and round helpers are
dead code on nRF52. Takes effect once the Crypto dependency includes
meshtastic/Crypto#5.

rak4631 estimate: ~1.0 KB flash.
2026-09-17 09:36:41 +00:00
Thomas Göttgens ce7e6e448d Separate the nRF54 platform code from src/platform/nrf52 (#11867)
* Move the nRF54L platform code into src/platform/nrf54l15 and drop the ARCH_NRF54L branches from src/platform/nrf52

* Name the platform directory nrf54 so future nRF54 variants can share it

* Rename the nRF54 platform base to nrf54_base in variants/nrf54l15/nrf54.ini

* Leave the SoftDevice random seed to the Bluefruit core, which seeds in begin() and answers NRF_EVT_RAND_SEED_REQUEST

* Seed the SoftDevice from checkSDEvents() when it pops NRF_EVT_RAND_SEED_REQUEST
2026-09-16 21:21:41 +00:00
Ben MeadorsandThomas Göttgens ff9a2b1b86 fix(http): give a TLS session the contiguous heap it actually needs (#6960) (#11838)
* fix(http): give a TLS session the contiguous heap it actually needs (#6960)

An HTTPS connection to a no-PSRAM ESP32-S3 fails its handshake with
MBEDTLS_ERR_SSL_ALLOC_FAILED and the client gets a TCP reset. Four things
were in the way, and the first two are the bug.

mbedtls_ssl_setup() makes two independent calloc() calls, one inbound
record buffer and one outbound, each needing its own contiguous block of
16717 bytes. The admission gate compared ESP.getFreeHeap(), which is a sum
over every free block and says nothing about the largest one, so on a
fragmented heap it waved the connection through and let the handshake be
the thing that discovered there was no room.

Size the two record buffers separately. Inbound stays at the RFC maximum
because browsers and our own MQTT and OTA clients receive full-size
records; outbound only ever carries what this device sends, 4 kB at a time
at most. That is 12288 bytes of contiguous heap back per session, which is
the difference between one usable HTTPS connection and two. These are the
ESP-IDF defaults - Espressif's arduino-lib-builder is what turns the
asymmetric split off. It replaces CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN,
which no longer applies: that symbol is `depends on
!MBEDTLS_ASYMMETRIC_CONTENT_LEN`, and mbedTLS dropped it in 3.0.

Then ask the allocator the question mbedTLS is about to ask it, rather
than a different one. The probe holds both record buffers at once, as
setup() does, through the same heap that CONFIG_MBEDTLS_*_MEM_ALLOC pins
mbedTLS to - a plain malloc would be served from PSRAM that mbedTLS never
touches. Not heap_caps_get_largest_free_block(), which walks every block
under the allocator lock and tripped the interrupt watchdog in #11666.
Closed connections are reaped before the measurement, so the library
cannot free a slot behind it and accept into it unmeasured, and so the
probe sees the memory a finished session just returned. The answer is
advisory: the heap can still turn between the probe and setup(), which
makes the failure rare rather than routine.

A smaller outgoing record makes an existing bug much easier to hit.
mbedtls_ssl_write() accepts at most one record per call and returns a
short count for the rest, and neither the server library nor Print::print()
loops on that, so any response body larger than one record was quietly cut
off - /json/nodes has been truncating at 16 kB already. Loop at the bulk
write sites. Looping on the returned count also covers a client that
negotiates a smaller maximum fragment length, which a fixed-size chunker
would not.

/json/nodes also built its whole body in one std::string. A full node DB
is 200 entries of ~240 bytes, and std::string grows by doubling, so it
asked for a 64 kB contiguous block while still holding a 32 kB one, on a
board with ~50 kB of heap, through operator new, which aborts rather than
throws here. Stream it a node at a time instead.

Finally, stop every node in the fleet paying for geofencing. The crossing
table reserved all 256 slots at construction, 4 kB, though a node with no
geofenced waypoints never tracks a crossing at all. Allocate on the first
one instead, stepping the capacity explicitly so there is a size to probe
for, and hand the block back once the last waypoint is gone. The probe
matters because this allocation has moved off the pristine boot heap onto
a live one, where a reserve() that cannot find the block would abort;
a refusal now degrades into the bounded-drop path that already exists.

heltec-v3 builds clean from scratch with the IDF recompiled, and comes out
20,656 bytes smaller in flash and 172 bytes smaller in static RAM. Full
native suite is green at 1414 cases.

* style(http,geofence): cut the added comments to the repo's one-or-two-line rule

The rationale belongs in the commit message and the PR, not in block comments
above every new symbol. No behaviour change; the binary is byte-identical.

* fix(geofence): index the capacity probe write to clear cppcheck uninitdata

* fix(http): stop the /json/nodes body at the first failed write

* fix(geofence): grow crossing state with realloc so a refused allocation drops instead of aborting

* refactor(http): probe through esp_mbedtls_mem_calloc and fold the free-slot scan into reaping

* refactor(geofence): allocate the crossing table once on first use and free it on store changes

* fix(http): probe through mbedtls_calloc so the calls keep C linkage

* fix(http): name the check that refused a TLS session, and sum the right heap

Both failure modes logged the same line, so a refusal on the free-heap
threshold read as fragmentation. The probe returns a verdict now and the
warning names the check that failed.

That threshold also read ESP.getFreeHeap(), the internal heap, while the probe
below it goes through mbedtls_calloc() - PSRAM wherever EXTERNAL_MEM_ALLOC
sends mbedTLS, which [device-ui_base] sets. There is no free-size query on the
allocator, and the handshake's remaining small blocks want a sum rather than a
block to probe for, so the sum asks heap_caps for the same heap.

* fix(http): probe the TLS heap only when a client is waiting for a slot

The probe holds ~21 kB across two callocs and ran whenever a slot was free,
which on a board with nobody connecting is every 50 ms while the web server is
active and every second when it is idle - on exactly the low-heap boards this
branch is for.

It now runs only when a zero-timeout select() on the listen socket says a
client is already in the backlog, which is the question loop() asks a few
microseconds later. With nothing knocking the server drives its open
connections and accepts nothing, so a connection is never accepted past an
unmeasured gate; one that lands between the two select()s waits a tick.

* perf(http): flush /json/nodes a couple of kB at a time

One writeAll() per node is one mbedtls_ssl_write() per node, about 200 of them
for a full node DB. Filling a 2 kB buffer first cuts that by roughly 9x and
still bounds the allocation, which is why the whole-body string went away in
the first place.

* fix(geofence): keep the table-full warning for a full table

A refused malloc shared the one-shot table-full warning, so a momentary heap
dip spent the message that means the 256-entry cap was reached, and the real
thing then never printed. They are separate now, and the allocation failure
throttles rather than latching, because the next position may well find the
4 kB the table needs.

* fix(http): close a waiting client the TLS heap check refuses

* fix(http): hold spiLock only for filesystem calls in the browse and delete handlers

* refactor(http): leave the browse and delete spiLock scoping to #11870

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-09-16 16:52:00 +00:00
Thomas Göttgens e0cf782131 Build xiao_nrf54l15_lr2021 on every PR as the nrf54l15 canary and mark it community supported (#11856) 2026-09-15 13:00:34 +02:00
Ethac.chen 2fe711115e feat(nrf52): add RAK3401 + LR2021 (RAK13700) variant (#11819)
* feat(nrf52): add RAK3401 + LR2021 (RAK13700) variant
WisBlock core with IO-slot LR2021: DIO RF switch, board LF PA table, and 1.6 V TCXO. Keep extra/unsupported until it has its own hw_model.

* fix(lr2021): log custom PA setOutputPower in fullBegin
Match init(): a calibration miss stays a warning so band-hop keeps the begin() PA config.

* refactor(lr2021): share custom LF PA table helper

init() and fullBegin() both re-install the board table after begin(); keep the warn-only setOutputPower miss.
2026-09-15 08:15:33 +00:00
Thomas Göttgensandvidplace7 ea7d4aa410 Port nRF54L15 to the s145 SoftDevice Arduino core (#11842)
* Remove the Zephyr based nRF54L15 port

* Add nRF54L15 port on the s145 Arduino core: nrf54l15dk and xiao_nrf54l15 variants

* nRF54L: errno-style nrfx results, flush console before assert reset

* nRF54L: log the SoftDevice status on Bluefruit failure, ignore the seed request event

* Support the Wio-LR2021 LoRa Plus expansion board with OLED and K1 on the XIAO nRF54L15 variant

* Consume the nRF54L15 platform, core and bootloader from their repositories

* Pin the nRF54L15 platform to v0.2.0

* nRF52: forward SoftDevice flash events taken by the main loop to the flash driver, log the pairing failure status

* Pin the nRF54L15 platform to v0.2.1

* Pin the nRF54L15 platform to v0.3.0

* Split the XIAO nRF54L15 variant into SX1262 and LoRa Plus environments, seed the SoftDevice on request, add the nrf54l15 CI build script

* Pin the nRF54L15 platform to meshtastic/platform-nordicnrf54 v0.3.1

* NRF54: Fix mtjson generation

---------

Co-authored-by: vidplace7 <vidplace7@gmail.com>
2026-09-15 07:16:33 +00:00
Austin 9d27b276aa NRF54: Update to toolchain-gccarmnoneeabi@1.90301.200702, align NRF52840 (#11850)
Version 1.90201.191206 supports arm64 MacOS, but not arm64 Linux (1.90301.200702 supports both)

Also change the fuzzy match for gccarmnoneeabi to an exact match for nRF52840 (for reproducible builds), this is effectively a no-op change, today.
2026-09-14 14:00:55 -04:00
Austin 2826c6712b Pin nrf54 platform, update toolchain-gccarmnoneeabi for arm64 build hosts (#11848) 2026-09-14 12:19:56 -04:00
Tadayoshi MIURA f5158f50be feat(rp2040/rp2350): Update earlephilhower/arduino-pico to 6.1.0, bump maxgerhardt/platform-raspberrypi to latest (#11814)
* Update earlephilhower/arduino-pico to 6.1.0

* feat(rp2040/rp2350) bump platform-raspberrypi

* fix(rp2040/rp2350) recover printf and scanf
2026-09-14 13:02:57 +00:00