* CI: track RAM (.data+.bss) in size reports and gate on per-env budgets
The 2.8.0 nRF52840 heap regression (99% heap in field reports) shipped
invisibly because CI only tracked flash. On nRF52840 the heap arena is the
linker gap after .bss, so every byte of static .data+.bss growth shrinks the
usable heap 1:1 - RAM needs the same guardrails flash already has.
What's added:
- bin/platformio-custom.py emits ram_bytes (.data + .bss from the ELF, via
the toolchain size tool) into each .mt.json manifest. Heap/stack
placeholder sections are deliberately excluded.
- bin/collect_sizes.py records {flash_bytes, ram_bytes} per env;
bin/size_report.py grows RAM and RAM-delta columns in the PR size report.
Older artifacts without ram_bytes (and legacy int-schema baselines)
degrade to "n/a" instead of crashing.
- bin/ram_budgets.json: per-env RAM/flash budgets, enforced only for envs
listed there. Seeded with rak4631: ram 113,000 (current 110,948 + ~2 KB
slack), flash 786,000 (current 765,192 + ~20 KB; the app region is
0x27000..0xEA000 = 798,720 and the image must stay clear of the
warm-store ring guard in extra_scripts/nrf52_warm_region.py).
- New size-budget-gate CI job runs size_report.py --enforce-budgets and
fails the build on violation; the informational firmware-size-report job
now also renders budget usage into the PR comment.
- src/main.cpp: opt-in boot heap watermark (-DMESHTASTIC_HEAP_WATERMARK_CHECK)
logs LOG_ERROR when less than 20% of the heap is free at the end of
setup(). Off by default; skipped on platforms without heap accounting.
How budgets are raised: deliberately, never automatically. If a change needs
more headroom, bump the env's limit in bin/ram_budgets.json in the same PR
and justify the increase in the PR description.
Verified: python3 bin/test_size_scripts.py (23/23 pass, including ram_bytes
parsing, n/a fallback, and over/under/missing-env budget-gate cases).
* Address review: fail the budget gate closed, fix RAM section matching
- size-budget-gate workflow: drop continue-on-error on the manifest
download and the empty-dir fallback, so the job fails when the data it
gates on cannot be fetched.
- size_report.py --enforce-budgets now fails closed on every
missing-data path instead of trivially passing: empty collected sizes,
a budgeted env that was not built, or a manifest without the budgeted
metric. Report-only mode keeps rendering those as n/a.
- load_budgets() rejects zero/negative/non-integer budgets with a clear
error (a typo'd budget could previously crash budget_markdown with
ZeroDivisionError or silently skip the check); the percentage render
keeps a defensive guard for direct callers.
- compute_ram_bytes(): count RISC-V small-data sections (.sdata/.sbss)
and exclude ESP-IDF .rtc.* sections, which live outside the
heap-competing SRAM.
- Trim the heap-watermark comment in main.cpp to two lines.
bin/test_size_scripts.py: 27/27 - the two fail-open assertions are
flipped to fail-closed, with new report-only counterparts plus cases for
empty sizes under enforcement and malformed budgets.
The Python MCP server + hardware test harness that lived under mcp-server/
now has its own home at https://github.com/meshtastic/meshtastic-mcp
(published, versioned independently). Remove the in-tree copy and wire the
firmware repo to the standalone server externally.
- Delete mcp-server/ (96 files) and the 8 harness-coupled AI workflow files
under .claude/commands/ and .github/prompts/ that drove ./mcp-server/
run-tests.sh — those workflows now ship with meshtastic-mcp as skills.
- .mcp.json: register the server via
`uvx --from git+https://github.com/meshtastic/meshtastic-mcp meshtastic-mcp`
instead of a local ./mcp-server/.venv, keeping MESHTASTIC_FIRMWARE_ROOT="."
so the MCP tools still work from this checkout with no local build.
- Repoint the remaining references (AGENTS.md, CLAUDE.md,
.github/copilot-instructions.md, bin/regen-*.sh, docs, Screen.h,
userPrefs.jsonc, test/fixtures/nodedb/README.md, .trunk/configs/.bandit)
at the standalone repo. The MCP tool surface is unchanged — only the
pytest harness moves out; run it from a meshtastic-mcp checkout with
MESHTASTIC_FIRMWARE_ROOT pointed here.
No build/CI/platformio coupling existed, so nothing in the firmware build
changes.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Add gpsd support to portduino
* copilot fixes
* emit gps config in emit_yaml
* Fix formating to standard
* Address coderabitai issues
---------
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
* Add ARCH_PORTDUINO_WASM build: meshtasticd in WebAssembly over WebUSB
Compile the full portduino firmware to WebAssembly (Emscripten) so a real node runs in a browser tab or headless Node, driving a LoRa radio over WebUSB through a CH341 — the desktop Ch341Hal path with its libusb backend swapped for a WebUSB one. The native/desktop portduino build is unchanged.
New build env under src/platform/portduino/wasm/ (excluded from the native build_src_filter): WebUSB libpinedio backend, config/FS/region/MAC/PhoneAPI glue, wasm setup/loop, JS WebUSB runtime, and build stubs. bin/build-portduino-wasm.sh runs a standalone cached emcc build to build/wasm/meshnode.{mjs,wasm}.
Six firmware sources gain #ifdef ARCH_PORTDUINO_WASM guards (single-threaded cooperative emscripten_sleep loop, continuous RX, US region default, std RNG, no-popen exec); none affect non-wasm builds.
* PortDuino WASM: CI build job, cross-platform libdeps, configurable adapter
bin/build-portduino-wasm.sh: auto-detect native-macos (macOS) vs native (Linux/CI) libdeps with a NATIVE_ENV override, and use the SAME env's Crypto with an XEdDSA-present guard. Drops the heltec-v3/Crypto borrow — the meshtastic/Crypto pin already ships XEdDSA; the native libdeps cache was just stale. No env pin change.
Add .github/workflows/build_portduino_wasm.yml: build the ARCH_PORTDUINO_WASM target in CI (ubuntu + emsdk, native libdeps) so it can't silently bit-rot; asserts build/wasm/meshnode.{mjs,wasm}.
src/platform/portduino/wasm: add wasm_set_lora_* setters so the JS host can configure any CH341 LoRa adapter (module, USB ids, DIO/TCXO, SPI speed, pins); wasm_config_apply falls back to the MeshToad defaults when unset.
* WASM: build as a first-class [env:wasm] via platform-wasm (retire emcc script)
Replace the standalone emcc build (bin/build-portduino-wasm.sh) with a normal
PlatformIO env, `pio run -e wasm`, using the new meshtastic/platform-wasm
platform (emcc/em++ + Asyncify, the WASM sibling of platform-native). The
portduino WebAssembly node is now built the same way as every other target.
- variants/native/portduino/platformio.ini: add [env:wasm] (platform pinned to
platform-wasm, board wasm). Translates the script's source set into a curated
build_src_filter, the ~30 EXCLUDE_* defines, and the lib set; defines
ARCH_PORTDUINO_WASM in-repo so correctness doesn't hinge on the platform's
board.json.
- extra_scripts/wasm_link_flags.py: the firmware-specific emcc *link* settings
(EXPORT_NAME, EXPORTED_RUNTIME_METHODS, EXPORTED_FUNCTIONS, ASYNCIFY_IMPORTS).
PlatformIO feeds build_flags to compile only, so these must ride LINKFLAGS;
without it the WebUSB Asyncify seam and the JS host's runtime methods are
dropped (the _wasm_* exports survive only via EMSCRIPTEN_KEEPALIVE).
- PortduinoGlue.{h,cpp}: guard the yaml-cpp dependency out of the WASM build
(#ifndef ARCH_PORTDUINO_WASM around the include, emit_yaml/loadConfig/
readGPIOFromYaml). The browser node configures via the wasm_set_lora_* setters
and dead-strips the YAML path; this drops the host yaml-cpp build dependency
entirely. Native is unchanged (guards are inert there).
- portduino_glue_wasm.cpp / portduino_main_wasm.cpp: repair EM_ASM JS that a
formatter had mangled (!== -> != =, regex split) in the prior landing; the
emcc link succeeds regardless, so CI now runs `node --check meshnode.mjs`.
- .github/workflows/build_portduino_wasm.yml: build via `pio run -e wasm`
(artifacts under .pio/build/wasm/), trigger on the shared inputs the env
inherits (root platformio.ini, bin/platformio-*.py).
- NodeDB.cpp: drop the dead ARCH_PORTDUINO_WASM region-default branch (region
now defaults the same as native).
- Crypto renovate pins: add the missing gitBranch so they track upstream.
Output: .pio/build/wasm/meshnode.{mjs,wasm} (ES module, factory createMeshNode).
Verified: pio run -e wasm (against the published platform archive), node --check,
module instantiates in Node with all exports; native-macos + Docker native unit
tests (450/450) still pass.
* Fix name on the Piggystick
* wasm: pin platform-wasm at the GPL-3.0-relicensed commit
platform-wasm's LICENSE was always GPLv3 (matching this firmware), but its
platform.json/README still declared Apache-2.0 (mis-copied from platform-native).
That's fixed upstream in b83fa5b; bump the [env:wasm] pin to it. Build output is
unchanged (license metadata only). Verified: pio run -e wasm against b83fa5b.
* wasm: make reboot() actually restart the node (was a no-op)
In wasm the reboot path is live (main.cpp -> Power::powerCommandsCheck ->
Power::reboot), but Power::reboot's ARCH_PORTDUINO arm tore down SPI/Wire/Serial
and then called the no-op ::reboot() stub — leaving the node running with a dead
radio until the tab was manually reloaded. Triggers include an admin/phone
reboot, factory reset, the "reconfigure failed" path, and the 60 s stuck-TX
hardware watchdog (RadioLibInterface).
- Power::reboot(): add an ARCH_PORTDUINO_WASM arm (before ARCH_PORTDUINO, since
the wasm build defines both) that skips the host teardown and just calls
::reboot(). notifyReboot already let modules persist.
- ::reboot() (glue): hand off to the JS host — browser reloads the tab (NodeDB
state survives via IDBFS, same identity returns); headless calls Module.onReboot
if provided, else logs. Loose !=/== so clang-format doesn't mangle the EM_ASM JS.
- README: document the reboot handoff + the Module.onReboot hook.
Verified: pio run -e wasm + node --check (EM_ASM intact); native-macos unaffected.
* wasm: rename env to native-wasm and run it in the main CI matrix
Rename [env:wasm] -> [env:native-wasm] for consistency with the portduino
native family (native, native-macos, native-tft). The build dir follows to
.pio/build/native-wasm/ (artifact is still meshnode.{mjs,wasm}); the PIOENV
guard in extra_scripts/wasm_link_flags.py, the README, and the companion wrapper
move with it. The board stays `wasm`.
Also wire the build into normal CI: build_portduino_wasm.yml becomes a reusable
workflow (workflow_call) invoked as the `build-wasm` job of main_matrix.yml, so
the WebAssembly node is built like every other platform instead of on a separate
path trigger.
* native-wasm: auto-locate the Emscripten SDK (pre-build script)
`pio run -e native-wasm` failed with "emcc not found" whenever it was invoked
from a shell that hadn't sourced emsdk_env.sh — a VS Code task, an IDE build
button, a bare terminal. Add a pre: extra script that probes the usual emsdk
locations ($EMSDK_ENV, $EMSDK, ~/emsdk, ./.emsdk, the sibling companion
checkout), sources emsdk_env.sh, and imports the resulting environment so the
platform builder and emcc see PATH/EMSDK/EM_CONFIG. No-op when emcc is already
reachable (CI), silent when no SDK is found (the platform emits its own error).
* wasm: address PR review feedback
- js/bridge.js: import CH341 from "./ch341.js" (sibling in this layout), not
"../src/ch341.js" which doesn't resolve here.
- js/ch341.js: a zero-length transferIn while MISO bytes are still outstanding
now throws instead of breaking out with a partially-filled buffer — silent SPI
corruption becomes a loud error, matching the comment above it.
- libpinedio_webusb.c: webusb_set_auto_cs honors the AUTO_CS option (? 1 : 0)
instead of the always-on ? 1 : 1. Runtime behavior is unchanged — Ch341Hal sets
AUTO_CS=0 right after pinedio_init (RadioLib drives the active-low NSS); the
option just isn't set yet at init, so this now correctly defaults off.
- SX126xInterface.cpp: the RX-start error log now names the method actually
called (startReceive vs startReceiveDutyCycleAuto) instead of hardcoding the
duty-cycle name in the WASM branch.
* native-wasm: drop the emsdk bootstrap shim (now in platform-wasm)
The Emscripten SDK auto-location moved into the platform-wasm builder, so the
firmware no longer needs its own pre: extra script. Remove
extra_scripts/wasm_emsdk_env.py and bump the platform pin to the build that
carries the bootstrap. The wasm_link_flags.py post script stays — those exported
fns / runtime methods / Asyncify import seam are firmware-app-specific.
* wasm: use the canonical companion name (meshtasticd-wasm-node)
The companion repo was renamed meshtastic-web-node -> meshtasticd-wasm-node; fix
the stale name in the wasm README and bump the platform pin to the build that
promotes the canonical name in its emsdk auto-location.
* wasm: re-entrancy guard for the API/region entry points + flaky-open retry
Two robustness fixes for the browser node:
- Re-entrancy guard. The node is single-threaded + Asyncify: while setup()/loop()
is suspended inside a WebUSB transfer, the JS event loop is free, so a stray
DOM/timer callback that re-enters a wasm_* entry point starts a second Asyncify
unwind ("async operation already in flight" abort) or clobbers shared PhoneAPI
state (observed as a "PhoneAPI::available unexpected state" flood). Add a
g_wasm_in_firmware flag set around setup()/loop() (portduino_main_wasm.cpp); the
wasm_set_region / wasm_api_to_radio / wasm_api_from_radio / wasm_api_available
entry points now reject a mid-tick call (return busy) instead of corrupting or
aborting. The host must still call them between ticks — this is the safety net
the design lacked, not a substitute for the JS queue.
- CH341 open retry (js/bridge.js). First-connect WebUSB is flaky — the interface
is briefly unclaimable right after the grant, or held by a prior session,
giving a transient "Could not open SPI: -1". Retry the open with a short
backoff, closing the device between attempts so claimInterface starts clean.
* wasm: exclude emscripten-only sources from cppcheck
The `check` board matrix runs `pio check` (cppcheck) over all of src/,
including src/platform/portduino/wasm/. cppcheck can't parse the EM_ASYNC_JS/
EM_JS macros (Syntax Error: AST broken at libpinedio_webusb.c:39,
internalAstError) and these sources are not part of any checked board build
([env:native-wasm] is board_level=extra, compiled by the build-wasm CI job).
Suppress the wasm dir in suppressions.txt, the same way generated/ and .pio/
are already excluded.
* wasm: coalesce FS.syncfs so two never run at once
IDBFS syncfs is async; the explicit wasm_fs_sync (5s timer + post-save +
beforeunload) could overlap a prior in-flight sync, warning "2 FS.syncfs
operations in flight at once". Serialize: if a sync is running, mark a pending
re-sync and let the in-flight one chain it on completion — at most one in flight,
trailing writes still flushed. (Companion drops IDBFS autoPersist so this is the
single persistence path.)
* wasm: silence false-positive SAST on the emscripten glue
- extra_scripts/wasm_link_flags.py: restore the trunk-ignore-all(ruff/F821,
flake8/F821) header every other SCons extra_script carries; Import/env are
SConscript-injected globals, so ruff/flake8 flag them as undefined.
- .semgrepignore: exclude src/platform/portduino/wasm/js/ (browser WebUSB glue,
not part of the firmware binary). The unsafe-formatstring rule false-positives
on its benign retry/diagnostic console logs.
* Update .github/workflows/build_portduino_wasm.yml
Co-authored-by: Austin <vidplace7@gmail.com>
---------
Co-authored-by: Austin <vidplace7@gmail.com>
configs for raspberry pi and luckfox lyra zero
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
(cherry picked from commit bfc206a47b64738575b8039733e663218870398b)
* feat: add Raspberry Pi Pico 2 + W5500 + E22-900M30S variant
Adds community variant for Raspberry Pi Pico 2 (RP2350, 4 MB flash)
with external WIZnet W5500 Ethernet module and EBYTE E22-900M30S LoRa
module (SX1262, 30 dBm PA, 868/915 MHz).
Key details:
- LoRa on SPI1: GP10/11/12/13 (SCK/MOSI/MISO/CS), RST=GP15,
DIO1=GP14, BUSY=GP2, RXEN=GP3 (held HIGH via SX126X_ANT_SW)
- W5500 on SPI0: GP16/17/18/19/20 (MISO/CS/SCK/MOSI/RST)
- SX126X_DIO2_AS_RF_SWITCH: DIO2→TXEN bridge on module handles PA
- SX126X_DIO3_TCXO_VOLTAGE 1.8: TCXO support via EBYTE_E22 flags
- DHCP timeout reduced to 10 s to avoid blocking LoRa startup
- GPS on UART1/Serial2: GP8 TX, GP9 RX
- Reuses WIZNET_5500_EVB_PICO2 code paths for Ethernet init
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* pico2_w5500_e22: rename define and address review feedback
Rename WIZNET_5500_EVB_PICO2 to PICO2_W5500_E22 so the variant-specific
define matches the variant directory name and isn't confused with an
on-board EVB SKU.
Review fixes from PR #10135:
- Gate the 10 s Ethernet DHCP timeout behind PICO2_W5500_E22 so other
Ethernet builds keep the default 60 s behavior; apply the same timeout
to reconnectETH() for consistency.
- Drop the unused -D EBYTE_E22 flag; EBYTE_E22_900M30S already selects
TX_GAIN_LORA / SX126X_MAX_POWER in src/configuration.h.
- Rewrite "on-board W5500" comments to describe the external module.
- Correct README TX_GAIN_LORA value (7, not 10) and drop the EBYTE_E22
row.
* fix(pico2_w5500_e22): drop DEBUG_RP2040_PORT=Serial
The arduino-pico framework hooks _write() when DEBUG_RP2040_PORT=Serial
is set and dumps raw debug bytes onto USB CDC, corrupting any binary
protobuf stream sent through StreamAPI (e.g. `meshtastic --port COMx`).
The variant excludes BT and WiFi, so the primary client transport is
Ethernet TCP via ethServerAPI — unaffected — but users who configure
the node over USB serial would see protobuf decode failures from
debug-byte interleaving. Removing the flag restores clean USB CDC.
Debug output can still be enabled per-build by adding -D DEBUG_RP2040_PORT=Serial1
to redirect to UART0 instead of USB CDC.
* feat: add Ethernet OTA support for RP2350/W5500 boards
Adds over-the-air firmware update capability for RP2350-based boards
with a WIZnet W5500 Ethernet module (e.g. pico2_w5500_e22).
Protocol (MOTA):
- SHA256 challenge-response authentication with a configurable PSK
(override via USERPREFS_OTA_PSK; default key ships in source)
- 12-byte header: magic "MOTA" + firmware size + CRC32
- Firmware received in 1 KB chunks, verified with CRC32, written via
Updater (picoOTA), then device reboots to apply
- Constant-time hash comparison prevents timing attacks on auth
- 30s inactivity timeout + 5s cooldown after failed auth
- Response codes 0x00-0x08 map 1:1 to OTAResponse enum
Firmware side:
- ethOTA.cpp / ethOTA.h: OTA TCP server on port 4243
- ethClient.cpp: wire initEthOTA/ethOTALoop into reconnect loop
- main-rp2xx0.cpp: hardware watchdog (8s, paused during debug)
- pico2_w5500_e22/platformio.ini: HAS_ETHERNET_OTA flag,
filesystem_size bumped to 0.75m for OTA staging
Host side:
- bin/eth-ota-upload.py: Python uploader with progress and full
result-code mapping (matches OTAResponse 0x00-0x08)
* style(eth): clang-format ethOTA.cpp per repo .clang-format
Reformat to the repo trunk clang-format config (IndentWidth 4,
ColumnLimit 130). Resolves the Trunk Check 'Incorrect formatting'
failure on PR #10136.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* ci(test report): drop no-status testsuites from the PlatformIO report
PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_*
dir x every hardware variant it can't run on native (~4900 rows). They carry
no pass/fail/skip status and bury the suites that actually ran in the dorny
Test Report. Strip them (in generate-reports, before the reporter) so the
report shows only real results. The uploaded artifact keeps the full XML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add bin/run-tests.sh: standardised local test verdict
Self-contained RED/AMBER/GREEN runner: matches all pass/fail spellings and
cross-checks the suite count against test/test_*/ so a missing suite shows AMBER.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* run-tests.sh: detect sanitizer faults + live build/test progress
Two usability improvements to the local verdict script:
1. Sanitizer-fault detection. A sanitizer-instrumented (coverage) build aborts
non-zero at EXIT on an ASan/LSan/UBSan/TSan fault — most often a LeakSanitizer
leak — AFTER every test has printed [PASSED], so pio reports it as
[ERRORED]/SIGHUP with no :FAIL: anywhere and it masquerades as a phantom
failure. verdict_red now recognises the documented fault signatures (ERROR:/
WARNING: <San>:, SUMMARY: <San>:, Direct/Indirect leak of, heap-use-after-free,
runtime error:, etc. — not the benign "failed to intercept" startup noise) and
reports e.g. "RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: ...",
plus the "run the binary bare (gdb hides it via ptrace)" recipe. Also flags the
"all tests passed but aborted at exit" shape when the report was swallowed.
2. Progress trail. Long rebuilds were silent for minutes. A background heartbeat
now appends a status line every few seconds to .pio/build/<env>/.runtests-progress
(always — tail -f it to check a backgrounded/piped run) and live-updates the tty
for interactive --quiet runs: build = objects recompiled / cached total + ETA;
test = suites done / expected. The object-count baseline is cached per env in the
gitignored build dir. Writes never touch the parsed verdict log; tty writes are
guarded so a no-tty run emits no redirect-open error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13302 and 13300 had missing module config for slot 2, also the rf switch and voltage info was missing.
Without that the auto setting in the config.yaml will try to use the default lora-hat-rak-6421-pi-hat.yaml and things do not work correctly.
* Added NodeDB fixtures and refactored to use std maps for better efficiency
* Defer NodeDB save during xmodem transfer to prevent mid-transfer fsFormat
Add a meshtasticd config for the Luckfox Pico Max with the Waveshare Pico LoRa SX1262 TCXO HAT.
Tested on hardware with successful SX1262 init, broadcast, and direct messaging.
Add a meshtasticd config for the Luckfox Pico Max with the Waveshare Pico LoRa SX1262 TCXO HAT.
Tested on hardware with successful SX1262 init, broadcast, and direct messaging.
* Add Portduino Enable pins
* Add hat plus custom fields
* Punt on the GPIO device detection for now
* Simplify TX_GAIN_LORA for RAK13302
---------
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
* Add transmit history for throttling that persists between reboots
* Fix RAK long press detection to prevent phantom shutdowns from floating pins
* Update test/test_transmit_history/test_main.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Test fixes and placeholder for content handler tests
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Add GPIO_DETECT_PA portduino config, and support 13302 detection with it.
* Tweak PA detect gpio to use pinMapping
* minor yaml output fixes
---------
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>