* 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>
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>
* 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>
* 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>
- 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.
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.
* 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>
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.
* 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>
* 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.
* 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
* 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>
* 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.
* 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>
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.
* fix(motion): drive screen wake from the accelerometer interrupt
The BHI260AP ISR body was empty and BHI_IRQ was never set or read, so the
attach only consumed a GPIO slot. ICM20948 could not reach its interrupt
path at all: the ICM_20948_INT_PIN fallback in the header is guarded on
ICM_20948_WOM_THRESHOLD, which the block above it always defines, so the
pin was never defined and the config, attach and interrupt-driven
runOnce() were dropped by the preprocessor on every board.
BHI260AP now configures the FIFO interrupt, attaches an ISR that sets a
flag, and enables the wrist tilt gesture so runOnce() can call
wakeScreen(). BMA423 arms INT1 push-pull active-high, which the BMA4
reset default leaves disabled, and drains on the interrupt instead of
every 50 ms. Both keep a slow keepalive drain so a pin that never
asserts degrades to polling rather than losing tilt and tap wake.
MOTION_WAKE_INT_PIN resolves whichever motion interrupt a variant
declares. doLightSleep() arms it as a GPIO wake source and lsIdle()
attributes the resulting wake to motion, which it previously charged to
BUTTON_PIN and dropped. Both are gated on
config.display.wake_on_tap_or_motion, matching MotionSensor::wakeScreen().
Closes#11755
* fix(motion): use the ICM20948 interrupt without dropping the compass
The ICM_20948_INT_PIN build of runOnce() was a full replacement for the
polled one and kept only wake-on-motion, so defining the pin would have
dropped the magnetometer fusion that feeds screen->setHeading(), the
calibration flow and the IMU sleep handling. providesHeading() returns
true for this part, so that is the compass.
Merge the two: the pin now selects the wake-on-motion mechanism only.
The status register poll stays compiled in behind a keepalive, since no
shipped firmware has exercised this line, so a pin that never asserts
costs latency rather than wake-on-motion.
Declare the pin on t-echo-card. Sensor_INT is P1.13, open drain with a
10K pullup to VDD3V3, matching the driver's active-low config and
FALLING attach. The schematic's SCL P1.02 / SDA P1.04 match PIN_WIRE_SCL
and PIN_WIRE_SDA.
* Revert the t-echo-card ICM20948 interrupt pin
Sensor_INT is not the IMU. In both T-Echo-Lite_V1.0 and
T-Echo-Lite-Card_V1.0 it appears only on the unannotated 5-pin expansion
header (P?, 5PIN_PA1.0) carrying SDA_P1.04, SCL_P1.02, VDD3V3, GND and
Sensor_INT with its 10K pullup, and it leaves the sheet as an off-sheet
port. Neither schematic contains an ICM20948 symbol at all, and the
vendor pin map declares only ICM20948_SDA, ICM20948_SCL and
ICM20948_ADDRESS for the part.
The interrupt belongs to whatever plugs into that header, so the onboard
IMU has no reason to drive it. The driver keeps polling.
* Poll until an ICM20948 interrupt pin proves itself
A variant that declares ICM_20948_INT_PIN is asserting routing no vendor
firmware has ever exercised, so treat the line as unproven: keep polling
the wake-on-motion status register at full rate, and only back off to the
keepalive once the pin has actually fired. A wrong pin then behaves
exactly as before rather than trading wake latency for the guess.
* feat(t-impulse-plus): drive ICM20948 wake-on-motion from its INT pin
The LilyGO pinmap documents the IMU's INT on P0.07, and variant.cpp
already maps and names it as D27, but the pin was never handed to the
driver, so wake-on-motion polled the status register every 50 ms.
Use the D number: pinMode() and attachInterrupt() index
g_ADigitalPinMap, where a raw 7 selects P1.13, the LoRa RF_VC1 TXEN
line. The driver polls until the pin proves itself, so an ICM20948 that
turns out not to drive it keeps working as before.
* Derive MOTION_WAKE_INT_PIN after the build exclusions
MESHTASTIC_MINIMIZE_BUILD defines MESHTASTIC_EXCLUDE_I2C further down the
file, so the guard read as unset and a minimized build defined the pin
anyway. doLightSleep() would then arm a GPIO no motion driver configures,
since every driver is compiled out with I2C.
Latent rather than live: nothing sets MESHTASTIC_MINIMIZE_BUILD today,
and the variants that pass -DMESHTASTIC_EXCLUDE_I2C were already correct
because a build flag is defined before this file is parsed.
* fix(motion): keep the BMA423 INT1 config failure non-fatal
Restores the resolution made when feature/sensorlib-0.4.1 was merged into
this branch. That merge is gone after the rebase, and neither parent
carried this: the interrupt path is an optimisation over the existing
poll, so a pin-config failure should log and fall back rather than be
ignored outright.
* revert a conflict regression and introduce the DCDC workaround from semtech example code.
* fix: Adjust DCDC workaround placement for
* clod fixes stuff
* clod fixes some more things
* chore(deps): unify SensorLib on 0.4.1 and port the 0.4.x API changes
The 19 SensorLib declarations were split across 0.3.1, 0.3.4 and 0.4.1.
Pin all of them to 0.4.1 and fix the renovate datasource on ThinkNode-M9
(custom -> custom.pio).
API changes in 0.4.x:
- BMA423Sensor: the configAccelerometer/enableFeature/readIrqStatus
surface is gone. SensorBMA423 now derives from SensorBMA4XX and
dispatches tilt and tap through callbacks driven by update().
- BHI260APSensor: SensorRemap is a scoped enum in sensor/SensorDefs.hpp,
and BoschSensorInfo members are protected, so read them through the
accessors.
- ExtensionIOXL9555 is renamed to IoExpanderXL9555 and the touch drivers
moved under touch/. Point the includes at the current paths instead of
the compatibility shims, which emit warnings on every build.
Drop four lewisxhe/PCF8563_Library declarations. Nothing in the tree
includes pcf8563.h; all RTC code goes through SensorLib.
Drop the BMA423_INT block. No variant defines BMA423_INT (t-watch-s3
defines BMA4XX_INT), so it has never been compiled. Interrupt-driven
wake on BMA423 is unimplemented rather than regressed by this change.
Drop the T_WATCH_S3 branch in BHI260APSensor. That file requires
HAS_BHI260AP, which T_WATCH_S3 does not define.
SensorQMC6309.hpp does not exist in 0.3.4, so src/motion/QMC6309Sensor.cpp
compiles for the first time on 0.4.1.
* fix(motion): fail BMA423 init when the sensor rejects its configuration
configAccelerometer, enableTiltDetector and enableTapDetector return false
only on an I2C or driver-level failure, so treat them the way QMC6309Sensor
treats configMagnetometer rather than initializing a sensor that never took
its settings.
Trim the t-watch-ultra placement comment to the two lines the coding
guidelines allow.
* refactor(rtc): drive the PCF clocks from PCF8xRTC instead of SensorLib
SensorLib reaches its PCF8563 and PCF85063 drivers through a comm layer
spanning Arduino, ESP-IDF, SPI and custom callbacks, which is a lot of code
to link for four calls on an I2C RTC. Measured against develop, the boards
that pull SensorLib grew about 10 KB moving from 0.3.4 to 0.4.1, while the
nRF52 boards that do not pull it moved by 100-300 bytes.
meshtastic/PCF8xRTC covers both parts in one class over Adafruit BusIO,
which every board with a PCF part already links. Ten boards used SensorLib
for nothing but the RTC and now drop it entirely; the remaining seven keep
it for a BMA423, BHI260AP, QMI8658, XL9555 or touch controller and take the
new driver for their RTC.
Behaviour changes with it. Both parts latch an oscillator-stop flag on power
loss, which the old path ignored: readFromRTC() now refuses a calendar the
chip has marked invalid rather than feeding a plausible wrong date to
BUILD_EPOCH, the result of begin() is checked, and a failed set is logged.
The isBitSet workaround moves from configuration.h to MMC5983MASensor.h.
It worked only because configuration.h pulled SensorLib.h in first, so the
later include was a no-op and the macro stayed undefined; with the global
include gone it has to sit where SensorLib and the SparkFun header actually
meet.
* fix(rtc): report a missing PCF chip separately from a stopped oscillator
lostPower() reads a register, so it also returns true when the chip cannot be
reached at all. Folding it into one warning meant a failed begin() reported
"oscillator stopped", which is a different fault.
* fix(t5s3): read GT911 touches through getTouchPoints
0.4.x dropped the default argument from getPoint(x, y, count) and marked
it deprecated, so the two-argument call no longer resolves:
variant.cpp:618:27: error: no matching function for call to
'TouchDrvGT911::getPoint(int16_t*, int16_t*)'
Use getTouchPoints(), which is what the deprecation points at, rather than
passing the count to a call that is on its way out.
lib_ignore matches the name from the library manifest, and iLabs_Hearth
declares "iLabs Hearth" with a space. The directory name used in #11757
therefore never matched, and the Pico builds still compile the library and
still fail on its ESP_SERIAL_PORT #error.
* fix(ci): build with pioarduino core instead of upstream platformio
The espressif32 platform is the pioarduino fork, but setup-base installed
upstream platformio and then ran pio upgrade, so every firmware build ran
a core the platform is not built against.
Upstream 6.2.0, released 2026-09-05, moved its tool-scons core dependency
to ~4.41101.0 (SCons 4.11.1). The lazy "import SCons.Tool.FortranCommon"
that smart_link() uses to choose a linker fails there, so every ESP target
died at link-action resolution before compiling a file. Core resolves
tool-scons as a core dependency, so a platform_packages pin cannot help:
core installs its own version and removes the pinned one.
pioarduino core pins SCons 4.8.1 by URL rather than by range, so upstream
releases cannot reach it. Dropping pio upgrade as well, since it re-pulled
the latest upstream core regardless of what pip installed.
Only setup-base changes. The matrix-generation jobs install platformio to
parse the ini files and never build firmware, and the native test suites
pass on upstream core because that platform does not reach smart_link.
* Revert "fix(ci): pin tool-scons to 4.8.1 for ESP targets (#11756)"
This reverts commit a8912b1eb.
The pin never took effect. PlatformIO Core resolves tool-scons as a core
dependency, so it installs its own version and removes the pinned one:
Installing platformio/tool-scons @ 4.40801.0
Installing platformio/tool-scons @ ~4.41101.0
Removing tool-scons @ 4.40801.0
Switching to pioarduino core in the preceding commit fixes this properly,
and leaving a platform_packages entry that fights a core dependency would
only be misleading.
arduino-pico gained a bundled iLabs_Hearth library that supplies its own
Preferences.h. NodeDB.cpp includes <Preferences.h> inside an ARCH_ESP32
guard, and the library dependency finder runs in chain mode, which matches
include directives without evaluating the preprocessor. It therefore pulls
the library into every Pico build, and Hearth.cpp refuses to compile on a
board that does not define ESP_SERIAL_PORT:
iLabs_Hearth/src/Hearth.cpp:121:2: error: #error "iLabs Hearth requires a
board variant that defines ESP_SERIAL_PORT ..."
The library only reaches the build because the platform resolves
framework-arduinopico from an arduino-pico master commit; the
platform_packages entry here pins the name arduino-pico, which is a
different package and does not override it.
Ignoring the library is enough, since no Pico target uses Matter. The
sibling iLabs_ESP-NOW ships ESP32_NOW.h and ATLink.h, which nothing
includes, so it needs no entry.
PlatformIO Core now pulls tool-scons ~4.41101.0 (SCons 4.11.1), which
overrides the 4.8.1 the pioarduino espressif32 platform asks for. In
4.11.1 the lazy "import SCons.Tool.FortranCommon" that smart_link() uses
to pick a linker raises ModuleNotFoundError, so every ESP environment
fails at link-action resolution, before a single file is compiled:
*** [.pio/build/<env>/firmware-<env>.elf] ModuleNotFoundError :
No module named 'SCons.Tool.FortranCommon'
The package is not at fault; FortranCommon.py is present in
tool-scons-4.41101.0 and imports cleanly outside SCons. The failure comes
from the module state SCons's own tool loader leaves behind.
Scoped to esp32_common, which all six ESP architectures extend. nRF52,
STM32 and rp2040 are unaffected and keep the toolchain they have.
* feat(t-connect-pro): add LilyGo T-Connect-Pro variant
ESP32-S3R8, 16MB flash, 8MB octal PSRAM. SX1262 LoRa, 480x222 ST7796 LCD
with CST226SE touch, W5500 ethernet and a 10A relay on EXT_NOTIFY_OUT.
LoRa, display and ethernet share one SPI bus (SCK 12 / MISO 13 / MOSI 11),
so every peripheral stays on SPI2_HOST.
board_level is extra and HW_VENDOR falls through to PRIVATE_HW until a
HardwareModel enum value is allocated.
* fix(w5500): serialize shared-bus SPI access with spiLock
Arduino's ETHClass reaches SPI through SPIClass, whose mutex is invisible to
LovyanGFX. On a board where both share a bus the MAC reads glitched frame
headers, and the resulting ESP_LOGE flood blocks the W5500 RX task on the
console UART until the task watchdog reboots the device.
SharedBusEthernet installs esp_eth directly so its custom_spi_driver
callbacks can take spiLock, the mutex the radio, display, SD and sensors
already share. It derives from NetworkInterface, so localIP(), connected(),
config() and the GOT_IP events are unchanged.
Selected by ETH_SHARED_SPI; boards without it keep the stock ETHClass path.
Measured on T-Connect-Pro under a 150 x 1472 byte flood with the display
active: 34948 truncated frames, 3 reboots and 20% packet loss before,
none after.
* fix(cst226se): honour reset pin, screen rotation and skip wrong-model probes
Drive TOUCH_RST when the variant defines one, and stop passing I2C pins to
begin() so SensorLib does not re-init a bus the scan already owns.
Derive touch geometry from SCREEN_ROTATE the way TFTDisplay does, so a
rotated panel maps to the landscape UI rather than the raw panel size.
Use TouchDrvCST226 rather than the TouchDrvCSTXXX wrapper. Pinning the model
does not stop the wrapper walking CST816 and CST92xx, whose retries cost
about 3.5s of boot. T-Beam behaviour is unchanged.
* style: trim comments to the two-line limit
Follows the comment rule in .github/copilot-instructions.md, which the
original commits missed.
* feat(t-connect-pro): use the T_CONNECT_PRO hardware model
Depends on meshtastic/protobufs#1062. Does not build until that merges and
the generated headers are synced, since meshtastic_HardwareModel_T_CONNECT_PRO
does not exist yet.
Drops -D PRIVATE_HW, which becomes a no-op once HW_VENDOR resolves, and
promotes board_level to release.
* chore(t-connect-pro): mark as community supported
Support level 3, matching the other unlicensed LilyGo boards.
* fix(w5500): roll back partial init when begin() fails
begin() returns early when ethHandle is set, so a failure after
esp_eth_driver_install() left the handle populated and every later call
returned true with no working driver.
teardown() releases the event handler, netif glue, netif, driver, PHY and MAC
in reverse creation order, and every failure path now uses it.
* refactor(w5500): drop config the custom SPI driver never reads
spi_devcfg and spi_host_id are only read by w5500_spi_init, which esp_eth
skips when custom_spi_driver is set, so the device config fields were dead.
Also drops the handle() accessor, its only caller is the class's own event
handler, the eventRegistered flag, since unregistering an unregistered
handler is safe, the redundant _esp_netif guard around destroyNetif(), and
the TFT_CS indirection, which this panel path does not read.
* fix(w5500): fail begin() when the event handler cannot register
The return value was ignored, so a failed registration still started Ethernet
and returned true while onEthEvent never fired. WiFiAPClient would then miss
ETH_CONNECTED, GOT_IP and DISCONNECTED, leaving the link up with the firmware
believing it was down.
The T-Deck Pro V1.1 boots into Critical Fault #3 (NoRadio) because
LORA_EN (GPIO 46) is never driven high, so the SX1262 never receives
power and is never detected.
Before #9438 this pin was driven from src/main.cpp under
`#elif defined(T_DECK_PRO)`, which covered both the t-deck-pro and the
t-deck-pro-v1_1 environments, since both build with -D T_DECK_PRO.
#9438 moved that block into variants/esp32s3/t-deck-pro/variant.cpp and
linked it with a build_src_filter added only to the t-deck-pro
environment. t-deck-pro-v1_1 had been added five days earlier and has
neither a variant.cpp nor a build_src_filter, so it silently lost the
pin setup: earlyInitVariant() is a weak symbol with an empty default in
main.cpp, so a missing strong override produces no compile or link
error.
Give t-deck-pro-v1_1 the same variant.cpp as t-deck-pro, plus the
build_src_filter needed to actually link it. This also restores the
LORA_CS, SDCARD_CS and PIN_EINK_CS pre-init lost at the same time; all
three share the SPI bus and need their chip selects deasserted before
the bus is used.
Fixes#11708
Co-authored-by: Claude <noreply@anthropic.com>
* Fix architecture name for Seeed Wio Tracker L2
* Normalize custom_meshtastic_architecture against the board MCU
The declared value reached the manifest unchecked, which is how esp32s3 shipped
here and in the -tft env that extends it. infer_architecture() already derives
the canonical spelling from the board MCU, so prefer it when the two disagree
and print the override.
Scanned all 113 envs declaring an architecture; this variant was the only
mismatch.
* Simplify the architecture override
---------
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>