From d9c2558ef94e01b403e4014cf2fdb6589fe2804f Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 11 Sep 2026 04:43:20 +0000 Subject: [PATCH] deploy: 01cc7b8ae4dbacc58e59ad45a145f427499942a0 --- cli_command_availability/index.html | 4 ++ cli_commands/index.html | 68 +++++++++++++++++++++++++++++ search/search_index.json | 2 +- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cli_command_availability/index.html b/cli_command_availability/index.html index 1b83f22c..3932485e 100644 --- a/cli_command_availability/index.html +++ b/cli_command_availability/index.html @@ -2335,6 +2335,10 @@ over the normal binary USB, BLE, or TCP connection:

Touchscreen Companions using the shared UI; text terminal and framed CLI; off after reboot +get/set display.inbox +Shared Companion UI; history (default), pending, or unread; saved across reboot + + uf2reset Every nRF52 Companion text terminal and local command 0x42; local only diff --git a/cli_commands/index.html b/cli_commands/index.html index cc621059..2a110cb7 100644 --- a/cli_commands/index.html +++ b/cli_commands/index.html @@ -773,6 +773,17 @@ + + +
  • + + + + Set Companion inbox behavior + + + +
  • @@ -4230,6 +4241,17 @@ +
  • + +
  • + + + + Set Companion inbox behavior + + + +
  • @@ -6781,6 +6803,52 @@ cannot distinguish USB from solar charging. Other boards without a power detector may require a USB host to recognize USB power. Wi-Fi/MQTT connection or BLE pairing is not evidence of USB power. E-paper panels can retain their last image when powered off.

    +

    Set Companion inbox behavior

    +

    The shared Companion UI (including T114 and R8) defaults to History: the +last 32 received text previews remain available on the radio after an app +downloads them. Pending separately counts frames waiting for the app, +including non-text frames. Downloading a frame clears it from Pending; it does +not mean someone read it. The phone's delivery queue is independent of the +32-entry preview history and is never drained by reading on the radio.

    +
    get display.inbox
    +set display.inbox history
    +set display.inbox pending
    +set display.inbox unread
    +
    + + + + + + + + + + + + + + + + + + + + + +
    ModeDisplay behavior
    history (default)Shows HISTORY with the retained preview count, plus Pending. Downloaded previews remain browseable.
    pendingShows INBOX with the app queue count and limits the reader to previews still waiting for the app. Non-text frames count toward Pending but have no text preview.
    unreadShows UNREAD with the number of retained previews not yet displayed on the radio, plus Pending. Opening a preview on a lit display marks it read; app downloads do not. Read previews remain browseable.
    +

    All modes retain up to 32 previews internally; switching from pending back to +history makes retained downloaded previews visible again. Evicted or rejected +queue entries are not shown as pending. Previews and local read state are in RAM +and reset on reboot. The mode applies immediately and survives reboot in +/display_prefs; existing version-1 display settings load with history as the +default. Legacy and tiny Companion UIs report Error: inbox modes unsupported.

    +

    Incoming messages automatically open their preview only when no client is +connected. While connected, new messages neither select another page nor move +the preview being read; history stays accessible by button or touch. The home +screen identifies connected USB and Bluetooth clients (or both), and TCP, +Ethernet, or serial clients where supported. Actual BLE pairing prompts retain +priority over connection status.

    Set MQTT observer display flip

    Supported observer displays, including the R8 OLED and ST7789 panels, can also be turned 180 degrees relative to their compiled orientation:

    diff --git a/search/search_index.json b/search/search_index.json index fcc834eb..a38d08c2 100644 --- a/search/search_index.json +++ b/search/search_index.json @@ -1 +1 @@ -{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"Introduction","text":"

    Welcome to the MeshCore documentation.

    Below are a few quick start guides.

    If you find a mistake in any of our documentation, or find something is missing, please feel free to open a pull request for us to review.

    "},{"location":"WiFi/","title":"WiFi and MQTT by Firmware Type","text":"

    MeshCore itself does not require WiFi or the internet. LoRa packet exchange, repeating, room servers, companions, and sensors can all operate without either. WiFi is added by particular ESP32 firmware targets for one or more of these purposes:

    The firmware role and the build profile are separate choices. USB logging does not itself provide MQTT. MQTT-capable Full Companions include that feature without mqtt in their filename; check the artifact capability manifest. See feature switches by role for the different Companion and infrastructure controls and the USB web console.

    "},{"location":"WiFi/#quick-reference","title":"Quick reference","text":"Firmware target or role Infrastructure WiFi MQTT What WiFi does *_repeater Build-dependent on ESP32 No FULL builds provide WebConfig, browser OTA, and the TCP 5001 LoRa-OTA seeder while WiFi is active *_repeater_observer_mqtt Yes Yes Uplinks heard and selected transmitted LoRa packets; repeating remains enabled by default *_room_server Build-dependent on ESP32 No FULL builds provide WebConfig, browser OTA, and the TCP 5001 LoRa-OTA seeder while WiFi is active *_room_server_observer_mqtt Yes Yes Runs the room server and uplinks radio traffic *_companion_radio_wifi Yes No Exposes the MeshCore companion protocol on TCP port 5000 *_companion_radio_wifi_mqtt Yes Yes Runs both the TCP companion interface and the MQTT uplink *_companion_radio_full* ESP32, subject to board/transport selection Where compiled on ESP32 Includes supported USB/wireless transports; configure MQTT through WebConfig Dedicated USB, BLE, or serial companion (not Full) No No Uses the transport named by the target instead *_repeater_bridge_espnow Build-dependent on ESP32 No Uses ESP-NOW for its bridge; bridge.format wrapped|raw selects legacy bridge framing or primary-ESP-NOW compatibility; a FULL build also exposes the TCP 5001 LoRa-OTA seeder whenever WiFi is usable RS232 bridge No No Bridges through a serial interface Ethernet repeater/room server No WiFi No Uses wired Ethernet for its role-specific network interface *_sensor Build-dependent on ESP32 No A FULL ESP32 sensor can expose the TCP 5001 LoRa-OTA seeder through its browser-OTA setup AP Terminal-chat or KISS modem No in current targets No Uses LoRa and its role-specific local interface *_lora_ota_no_external_sensors On demand on ESP32 No Lean LoRa-OTA repeater image; ESP32 builds retain the compact start ota browser uploader

    Direct on-device WiFi and MQTT are currently ESP32 features. nRF52, STM32, and the currently enabled RP2040 targets do not run this MQTT bridge. An nRF52 connected to a Raspberry Pi can still be logged or uplinked by software on the Pi, but that is a separate host-side bridge rather than MQTT running in the radio firmware.

    "},{"location":"WiFi/#how-the-mqtt-bridge-works","title":"How the MQTT bridge works","text":"

    The MQTT bridge is an outbound observer. It does not subscribe to MQTT topics and does not inject broker messages into LoRa.

    For a received packet, the flow is:

    LoRa radio -> successful packet parse -> MQTT capture queue -> broker slots\n                                  \\----> normal MeshCore filtering/handling\n

    Packets transmitted by the node can also be queued according to mqtt.tx. Receive capture happens before the packet is passed to the normal MeshCore flood filters and forwarding decision. A packet may therefore be observed on MQTT even when a later scope, path, region, duplicate, or repeat rule prevents the node from forwarding it over LoRa.

    On infrastructure observer builds, MQTT publication and LoRa repetition are separate:

    Fresh MQTT observer settings are:

    Up to six broker configurations can be saved. The number that can be active at once depends on the target and available memory. A non-PSRAM ESP32 should normally use no more than two TLS/WSS brokers; some classic ESP32 targets are reliable with only one. Excess configured slots remain saved but show as inactive in get mqtt.status.

    The bridge has a bounded packet queue for broker or WiFi outages and reconnects automatically. A non-PSRAM build holds 6 packets and a PSRAM build holds 50. When the queue is full, the oldest item is replaced; after five minutes with no broker connected, stale queued packets are flushed. MQTT packet delivery is best effort rather than durable storage. Each enabled slot publishes independently, so one failed broker does not intentionally stop the other slots.

    See MQTT_IMPLEMENTATION.md for the complete preset list, custom broker configuration, topic formats, authentication, diagnostics, and memory limits.

    "},{"location":"WiFi/#mqtt-observer-setup","title":"MQTT observer setup","text":"

    Most full-size ESP32 MQTT observer builds have the shared WebConfig portal. On a fresh device with no saved SSID:

    1. Join the open MeshCore-Setup-XXXX access point.
    2. If the captive page does not open, browse to http://192.168.4.1/.
    3. Enter WiFi, radio, identity, and MQTT settings.
    4. Select Save & Reboot.
    5. After reboot, check get wifi.status and get mqtt.status.

    The setup AP is unauthenticated unless the firmware was built with WEBCONFIG_AP_PASSWORD. It uses plain HTTP, so provision it in a trusted location. When WebConfig is running on the normal LAN, repeater and room-server builds require the node's admin password.

    On an expanded FULL profile with no saved SSID, this automatic setup AP has an absolute 30-minute window. Browser activity or a phone left associated with the AP does not extend it. If no SSID has been saved when the window expires, WebConfig closes and the ESP32 WiFi radio remains off automatically for the remainder of that boot. A reboot or power cycle starts a new 30-minute setup window; the timeout is deliberately not written to preferences. An administrator can still override the automatic cutoff with an explicit start webconfig command.

    Once an SSID is saved, the provisioning cutoff no longer applies. When WiFi is selected by logging.output wifi|both, the station stays enabled and keeps trying the saved network indefinitely: ESP automatic reconnect remains on and the explicit fallback advances through 15, 30, 60, 120, then 300-second retry intervals, remaining at five minutes until it reconnects. MQTT broker retries use their own backoff; a repeatedly failing broker eventually receives one probe every 30 minutes. Selecting logging.output off|usb keeps the MQTT bridge off, so saved credentials alone do not force WiFi on unless WebConfig is also enabled explicitly.

    The equivalent MQTT observer CLI setup is:

    set wifi.ssid Your WiFi Name\nset wifi.pwd Your WiFi Password\nset mqtt.iata SEA\nset name MyObserver\nreboot\n

    The SSID and password values are the rest of the command line. Spaces are allowed and quotes must not be added. SSIDs may contain at most 31 characters and passwords at most 63. Leave the password value empty for an open network.

    Useful checks are:

    get wifi.ssid\nget wifi.status\nget wifi.powersave\nget wifi.cli\nget mqtt.enabled\nget mqtt.rx\nget mqtt.tx\nget mqtt.status\nget mqtt1.diag\nget mqtt2.diag\n

    To change a configured observer through WebConfig without leaving the portal enabled after every reboot:

    start webconfig\n

    This uses the LAN when WiFi is connected. To force the setup AP, the MQTT bridge must release WiFi first:

    set mqtt.enabled off\nstart webconfig ap\n

    After provisioning, use Save & Reboot, or stop the temporary portal and restart the bridge:

    stop webconfig\nset mqtt.enabled on\n

    Current MQTT observer artifacts use the expanded FULL partition profile so WebConfig and the complete role CLI are retained. Install the matching merged image over USB once when moving a device from the legacy partition layout.

    "},{"location":"WiFi/#wifi-companion-setup","title":"WiFi companion setup","text":"

    A *_companion_radio_wifi build replaces the BLE or USB companion link with the MeshCore companion protocol over TCP port 5000. The phone or computer must be able to reach the device on the same LAN.

    Most ESP32 *_companion_radio_full targets keep all three Companion links at once: USB, BLE, and TCP port 5000. The two SenseCAP Indicator Full targets are the exception: USB remains available, while a saved next-boot selector starts exactly one secondary wireless Companion transport, either BLE or infrastructure WiFi/TCP. On the ESP-NOW Indicator, this selector never stops the primary ESP-NOW mesh radio. Full ESP32 images also provide a source-only LoRa mOTA service on ports 5001 and 5002 whenever their WiFi transport is active. See the full Companion guide for its build, terminal mode, and complete update-source workflow.

    The LilyGo T-Beam 1W Full Companion maps one press of the physical BOOT button (GPIO0) to a persistent WiFi on/off toggle. Turning WiFi off closes WebConfig, Companion TCP, mOTA, and MQTT network services while USB, BLE, the display, GPS, and LoRa remain available. Another BOOT press turns WiFi back on even after reboot. WiFi/WebConfig starts first; BLE starts two seconds later so the ESP32-S3 can reserve the setup server's internal heap before both radios run together.

    The companion loads runtime credentials saved in NVS. A non-placeholder compile-time WIFI_SSID/WIFI_PWD can be used as a first-boot fallback, but saved credentials take priority. With no credentials, its WebConfig portal starts in setup-AP mode. With credentials, the WebUI is enabled by default on the station IP.

    Full Companion can also be provisioned from its USB terminal or TCP port 5002:

    get wifi.ssid\nget wifi.status\nset wifi.ssid SlowFi\nset wifi.pwd your-password\n

    Credential writes are persisted immediately. After the reply has drained, the Companion restarts its WiFi station with the saved pair. A port-5002 client is expected to disconnect and can reconnect at the new LAN IP. USB masks the password as it is entered; get wifi.pwd is intentionally unavailable. The standalone credential store accepts an empty password, ordinary passphrases up to 63 characters, or an exact 64-character hexadecimal WPA/WPA2 PSK. A non-hexadecimal 64-character value and every longer value are rejected.

    After the saved station first connects following boot, every ESP32 WiFi Companion requests a fresh UTC time from NTP. A verified reply updates both the MeshCore clock and any detected hardware RTC; the next explicit refresh is due 24 hours after that success. A failed request times out without blocking the radio loop and retries after five minutes. While WiFi is disconnected or only the setup AP is available, the request waits for a station connection. Builds with a configured MQTT bridge use its boot-and-daily NTP owner instead, so two firmware services never reconfigure the ESP32 SNTP client at the same time. A later WiFi reconnect does not add another successful-sync request; it preserves the existing 24-hour deadline. Until a fresh internet reply succeeds, LoRa clock consensus remains available as the fallback.

    On Full Companion targets whose primary mesh radio is ESP-NOW, ESP-NOW, the setup AP, and the infrastructure-WiFi station must share one 2.4 GHz channel. The persisted channel defaults to 1 and can be inspected or changed from the text terminal:

    get espnow.channel\nset espnow.channel 6\nreboot\n

    Valid values are 1 through 13; use only a channel permitted in your region and supported by the router. set espnow.channel saves the new value but does not move the running radio; reboot the node to apply it. Every primary ESP-NOW node that should communicate with it must use the same channel, and the configured router's 2.4 GHz radio must be fixed to that channel as well. A router left on automatic channel selection can move the infrastructure connection away from the ESP-NOW mesh.

    espnow.channel configures the node's primary ESP-NOW mesh radio. It is distinct from bridge.channel, which applies only to the separate ESP-NOW bridge feature. WiFi power saving does not let ESP-NOW and infrastructure WiFi operate on different channels. On an ESP32 Full build whose primary radio is ESP-NOW, wifi.powersave max is unavailable because a station using maximum modem sleep can miss ESP-NOW broadcasts; use min for coexistence. The primary mesh transport holds the driver's RF wake reference continuously, so min does not suspend its ESP-NOW receiver.

    ESP-NOW compatibility also depends on the bytes and PHY used above the common Espressif transport. The historical *_repeater_bridge_espnow firmware wraps each MeshCore packet with a magic value, checksum, and secret-based XOR, while Generic_ESPNOW and SenseCapIndicator-ESPNow send the serialized MeshCore packet directly using the ESP-NOW LR PHY. Updated bridge firmware can select either behavior without changing the board target:

    get bridge.format\nset bridge.channel 1\nset bridge.format raw\n

    On a Heltec V4, the ordinary companion_radio_full image remains a LoRa-primary Companion and does not instantiate this bridge. Select and flash the existing heltec_v4_repeater_bridge_espnow role before using these commands. Treat that Companion-to-repeater change as a role/partition migration: back up the node and use the exact V4 merged install artifact.

    wrapped remains the default and interoperates with existing bridge nodes that use the same channel and bridge.secret. raw interoperates with primary-ESP-NOW nodes and other raw bridges; it requires channel 1-13, enables LR reception, forces LR transmission, and ignores bridge.secret. Receive parsing is strict to the selected format, so migrate peers deliberately rather than expecting one bridge to accept both. Match bridge.channel on the LoRa gateway to the primary nodes' espnow.channel; a primary-node channel change takes effect after its reboot.

    Raw mode retains MeshCore's normal packet parsing, authentication/encryption, and the bridge's duplicate suppression, but it does not provide the wrapper's secret-based network isolation. That XOR wrapper is itself only lightweight isolation, not cryptographic security. Multiple LoRa/ESP-NOW gateways covering the same nodes can temporarily amplify duplicates, even though seen-packet tracking prevents an individual bridge from immediately echoing a packet back.

    If the configured network remains unavailable for two minutes, the companion opens its setup AP so the credentials can be repaired. It continues retrying the saved network. WiFi modem sleep has its own persisted wifi.powersave setting and is independent of the Companion device power-saving setting. The WiFi card in Companion WebConfig exposes none, min, and max. Fresh Cascade-profile builds select min; target-default builds select none. A saved setting takes precedence after an upgrade.

    WiFi-only Companions can use all three modes. A Full Companion that runs WiFi and BLE simultaneously requires at least minimum modem sleep. It reports min when an old or target-default none value is found and rejects a new none selection. When ESP-NOW is also the primary mesh radio, max is unavailable: the access point does not buffer ESP-NOW broadcasts for a sleeping station, so maximum modem sleep can lose them. Firmware caps a previously saved max value to and reports it as min, and rejects a new max selection.

    The SenseCAP Indicator Full targets select BLE or infrastructure WiFi instead of running both. On a WiFi-selected boot, the LoRa layout accepts none|min|max, while the ESP-NOW layout accepts none|min and still rejects max. On a BLE-selected boot infrastructure WiFi is off; the LoRa layout accepts min|max for the saved WiFi setting, while ESP-NOW + BLE requires min. Device power saving remains independent of this modem policy.

    WiFi companions do not have the repeater/room-server admin CLI password model, so their LAN WebConfig page is intentionally unauthenticated. Use them only on a trusted LAN.

    The WebConfig Advanced card exposes device power saving on WiFi Companion, repeater, and room-server builds. It also exposes RX power saving on radio chips that support receive duty cycling. RXPS can select continuous receive, levels 1-10, automatic or explicit 16/32-symbol timing assumptions, or manual receive/sleep windows. The timing assumption does not change the radio's actual wire preamble. The WebConfig WiFi card exposes WiFi modem power saving. All three settings are persisted across reboots.

    The three settings are independent. A WiFi Companion keeps its transports available while device power saving reduces CPU and GPS idle power. An infrastructure node can sleep when device power saving is enabled, so its WiFi services may be temporarily unavailable. RXPS only duty-cycles the LoRa receiver. Fresh Cascade-profile builds default to device power saving on, RXPS on at level 8 with a 16-symbol preamble, and WiFi modem power saving at min on every build that includes ESP32 WiFi. The RXPS preamble value is a saved timing assumption, not the wire length: starting with v1.17.1.5, SF5-SF8 packets normally use a 32-symbol physical preamble. Firmware selects 64 for the whole SF/BW tuple only when 32 cannot enable RXPS at any level and 64 can (SF5/BW250 and SF6/BW500), then 128 only when neither shorter length works (SF5/BW500).

    When ENABLE_OTA is included, a WiFi companion also listens on:

    These ports do not replace the companion protocol on TCP 5000. On a companion_radio_full build, port 5002 is the same role-specific text terminal available over USB, including chat, remote administration, radio and power settings, WiFi/WebConfig management, tempradio, and the source-only ota commands. Other OTA-enabled Companion builds expose the same local maintenance commands as binary command 0x42, plus ota ... commands. LoRa staging and installation on the Full Companion itself remain disabled.

    Port 5002 is plaintext and has no independent login gate. Use it only on a trusted LAN or temporary setup network, especially when entering a remote-node admin password with the terminal's login command.

    FULL ESP32 builds share the port 5001 folder seeder. It starts whenever that role has a usable WiFi station or setup access point and stops when WiFi stops. For example, a FULL repeater can run start webconfig to join its saved network, then accept:

    motatool serve --dir ./motas --tcp <repeater-ip>:5001 -v\n

    The TCP connection supplies .mota files for the node to advertise and relay over LoRa; it is not a raw .bin uploader. start ota continues to provide the direct browser uploader on HTTP port 80. A FULL role without WebConfig but with browser OTA support can use the MeshCore-OTA access point raised by start ota; its seeder address is 192.168.4.1:5001.

    Only one external folder link can be active. A TCP client is rejected while ota folder on is using USB serial, and disconnecting motatool automatically removes the TCP folder. Port 5001 has no login layer, so expose it only on a trusted LAN or temporary setup network. Firmware target and hash checks still apply at the receiving node, along with its configured signature/trust policy.

    When the shared seeder is running, get wifi.status appends its live state:

    OTA TCP 5001: listening\nOTA TCP 5001: client connected\n

    The first state means WiFi is usable and the node is waiting for motatool serve --tcp. The second means a motatool folder is currently attached and available for LoRa OTA service.

    "},{"location":"WiFi/#wifi-companion-with-mqtt","title":"WiFi companion with MQTT","text":"

    A *_companion_radio_wifi_mqtt build combines both systems:

    The companion owns WiFi connection and recovery in this build. The MQTT bridge waits for that connection rather than creating a second one. Stopping MQTT does not disable the TCP companion service.

    MQTT-capable Full Companions also combine these services. The WebConfig page contains MQTT settings, and the USB/TCP and browser terminals accept the same mqtt.* settings as infrastructure for shared features. Use set mqtt.enabled on|off to control MQTT without discarding broker settings. Use set logging.output off|usb|wifi|both to select both outputs together.

    "},{"location":"WiFi/#webconfig-without-mqtt","title":"WebConfig without MQTT","text":"

    Full-size ESP32 repeater and room-server builds can include WebConfig even when MQTT is absent. The portal then shows node, radio, and WiFi-related controls but removes the MQTT wizard step and MQTT tab.

    In this case WebConfig owns WiFi only while it is needed. Stopping the portal disconnects WiFi and turns the WiFi radio off. There is no persistent MQTT connection keeping WiFi active.

    Unified FULL USB + WiFi and FULL logging-fallback repeater/room-server builds both provide these CLI controls and status checks. The unified profile includes the MQTT bridge; the fallback is used only where no matching MQTT environment exists:

    get wifi.ssid\nget wifi.status\nget wifi.powersave\nget wifi.cli\nget webui\nset wifi.ssid SlowFi\nset wifi.pwd your-password\nset wifi.powersave none\nset wifi.cli on\n

    get wifi.status distinguishes an unconfigured node, an inactive WiFi radio, a station connection attempt, the setup AP, a connection failure, and a working LAN connection. For a LAN connection it reports the SSID, IP address, and RSSI. If the shared OTA seeder is active, the same reply also reports whether TCP port 5001 is listening or has a motatool client attached. get wifi.powersave reports the saved standalone WebConfig setting as none, min, or max. An inactive status is normal when webui is off: run start webconfig to connect temporarily. Standalone credentials can be changed through WebConfig or with the listed CLI commands. Changing the SSID or password stops an active WebConfig session; start it again to connect with the new values. Use set wifi.pwd with no value for an open network. get wifi.pwd returns the saved password on a local connection; LoRa callers cannot read it. Standalone WiFi accepts ordinary passphrases up to 63 characters and exact 64-character hexadecimal WPA/WPA2 PSKs; other 64-character values and all longer values are rejected. MQTT observer WiFi passwords remain limited to 63 characters by their fixed persisted layout.

    get webui starts with the saved boot setting, then reports the current session. For example, > off, http://192.168.1.130/ means automatic WebConfig startup is saved as off, but a temporary session started by start webconfig is currently active at that URL.

    "},{"location":"WiFi/#webconfig-cli-terminal","title":"WebConfig CLI terminal","text":"

    The terminal defaults to on. Enable or disable it from an existing admin CLI:

    set wifi.cli on\nset wifi.cli off\nget wifi.cli\n

    The saved on setting becomes active only when the WiFi station client is connected and WebConfig is running in LAN mode. It is never exposed on the open setup access point. When active, the WebConfig page has a CLI tab. Repeater and room-server commands use their local CLI after admin authentication. The browser can run get password, get prv.key, erase, set freq, stats-core, stats-radio, stats-radio-diag, stats-packets, and explicit WiFi/MQTT credential getters. It also streams get acl and raw log dumps back to the browser. Log reads pause for slow clients and resume as output is read, using a fixed 160-byte file chunk. A dump snapshots the file length, so live logging cannot extend it indefinitely. The browser keeps the complete requested dump, including files larger than 64 KiB.

    Companion uses the same complete terminal as USB and Full Companion TCP port 5002, including card, import meshcore://..., list, to, messages, remote login, and delayed replies. It uses the Companion's trusted LAN. See Companion browser terminal for contact import, terminal ownership, and disconnect behavior.

    Type a command at the prompt, or paste multiple lines and confirm the sequence. Blank lines and comments are ignored. The device reports the supported batch limit. The browser sends each line after the previous command finishes and continues polling for incoming text after the sequence. help lists the device's actual terminal commands; Tab completes command names and settings.

    The up/down arrow keys recall commands entered during the current browser session. Commands available in the terminal still depend on the firmware role and build profile. Commands such as stop webconfig, set wifi.cli off, WiFi credential changes, and reboot operations stop the remaining block and can disconnect the page before it receives their final reply.

    "},{"location":"WiFi/#build-profiles","title":"Build profiles","text":"

    build.sh produces several profiles. A profile changes the features compiled into a selected target; it does not change that target into another firmware role.

    This fork defaults every build.sh target to the USA Cascadia radio preset and the Cascade firmware profile. The script resolves the live USA/Canada entry by name, so its changing number in the downloaded menu does not affect builds. If the preset service is unavailable, it falls back to 910.525 MHz / BW62.5 / SF7 / CR5. Use --radio-preset target --profile default only when a build intentionally needs the target's original radio and profile defaults. --radio-preset usa-cascadia is the stable explicit name; legacy numbered choices remain accepted but their meaning can change when the service reorders or adds presets.

    Build profile WiFi/MQTT behavior Standard Uses the selected target's role. Where USB is a safe plaintext console, the same artifact embeds debug/packet logging behind persistent get/set usb.logging. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. Legacy logging No separate artifact is emitted; USB logging is part of the ordinary image. Logging output itself is not a direct MQTT uplink. MQTT Builds explicit MQTT observer or WiFi-companion-MQTT targets with USB packet logging off. Non-companion ESP32 MQTT observers always use FULL expanded partitions. FULL ESP32 USB + WiFi Uses the board's MQTT target with USB packet logging and direct WiFi MQTT together, expanded dual-OTA partitions, up to 254 neighbors, LoRa OTA, and full-size ESP32 features such as WebConfig where supported. get/set logging.output off\\|usb\\|wifi\\|both persists the active paths. Classic T-Beam MQTT observers retain their 50-entry table because their persistent discovery state exhausts internal DRAM at 254. FULL ESP32 logging fallback Uses the board's non-MQTT target only when no matching WiFi MQTT environment exists. It keeps debug and packet logging, expanded dual-OTA partitions, up to 254 neighbors, and LoRa OTA. Persistent usb.logging off also provides normal output-off operation, so ESP-NOW FULL roles need no second non-logging image. LoRa-OTA no-external-sensors A lean repeater image with no MQTT; ESP32 builds retain the compact on-demand browser WiFi uploader and up to 254 neighbors, subject to target RAM limits.

    Repeater profiles use up to 254 neighbors. Selected Generic E22, Heltec V2, Meshadventurer, T-Beam OTA/MQTT, and TLora MQTT profiles use 50 to preserve internal-DRAM reserves. Consult the artifact capability manifest and ESP32 memory budget for the exact target.

    The interactive Option 1 FULL everything choice and the standalone FULL command select the unified USB + WiFi image when a matching MQTT target exists; otherwise they select the logging fallback. The build matrix no longer emits any separate standard logging image, or a non-MQTT FULL twin for a role covered by the unified image. Expanded Full infrastructure profiles include LoRa OTA, WebConfig where supported, up to 254 neighbors, and expanded dual-OTA partitions. Target-specific internal-DRAM limits still apply.

    FULL images change the ESP32 partition layout. Flash the matching *-merged.bin once when installing that layout. A partition-layout change can invalidate NVS, including saved WiFi, MQTT, name, and admin settings. Later updates using the same layout normally preserve them.

    "},{"location":"WiFi/#wifi-power-behavior","title":"WiFi power behavior","text":"

    MQTT observers and FULL standalone ESP32 repeater/room-server builds support:

    set wifi.powersave none\nset wifi.powersave min\nset wifi.powersave max\n

    Fresh Cascade-profile builds default to min; target-default builds use none, which gives the most predictable MQTT and radio performance at the highest power use. min and max reduce power but may add latency or reduce reliability on busy nodes. A saved operator setting takes precedence on an upgrade. ESP32 WiFi Companions expose the same values in their WebConfig WiFi card and USB text terminal. Full Companion also accepts the text commands from TCP port 5002. The normal binary Companion protocol can read or write the setting over USB, BLE, or TCP port 5000 without entering terminal mode. A Full Companion that runs BLE and infrastructure WiFi simultaneously rejects none because coexistence requires WiFi modem sleep. An ESP32 Full Companion whose primary mesh radio is ESP-NOW also rejects max; maximum modem sleep can cause the station to miss ESP-NOW broadcasts because the access point does not buffer them. A previously saved max value is capped to and reported as min. The SenseCAP Indicator exception uses the active-mode rules above rather than assuming BLE and infrastructure WiFi are active together. ESP-NOW and infrastructure WiFi still must use the same fixed channel.

    MQTT observer targets normally limit ESP32 WiFi transmit power to 11 dBm unless the board configuration overrides MQTT_WIFI_TX_POWER. This setting affects WiFi only, not LoRa transmit power.

    "},{"location":"WiFi/#recognizing-the-wrong-firmware","title":"Recognizing the wrong firmware","text":"

    get wifi.status, get wifi.ssid, get wifi.powersave, and get wifi.cli are available on MQTT observers and on FULL non-MQTT repeater/room-server builds with WebConfig. ESP32 WiFi Companions also expose all four commands, credential setters, and start webconfig [ap] through their USB text terminal. Their browser CLI defaults to on in station/LAN mode. Full Companion also exposes its complete role-specific text terminal on TCP port 5002, including chat and streaming commands. MQTT commands such as get mqtt.status and set mqtt1.preset ... require MQTT code in the image, on Companion or infrastructure. Unknown settings return Error: unknown setting: <name>. Older firmware that used the discontinued compact CLI can instead report Unsupported in this firmware when a command was cut for space.

    Check the complete firmware filename and role. In particular:

    Rolling firmware back does not restore settings erased by a full flash or a partition-table change. If the correct MQTT target still has no configuration, provision WiFi and MQTT again.

    "},{"location":"WiFi/#troubleshooting","title":"Troubleshooting","text":"

    For an MQTT observer:

    get wifi.status\nget mqtt.enabled\nget mqtt.status\nget mqtt1.diag\nget mqtt2.diag\n

    Common causes are:

    For a WiFi companion, find its station IP in the router, connect the client to TCP port 5000, and use the open MeshCore-Setup-XXXX AP at http://192.168.4.1/ if it cannot join the saved network. Current firmware normalizes both ESP32 WiFi interfaces to standard b/g/n before advertising the setup AP. Full Companion targets whose primary mesh radio is ESP-NOW use b/g/n+LR instead and keep the setup AP, infrastructure station, and mesh on the persisted espnow.channel (channel 1 by default). Their configured 2.4 GHz router and every other primary ESP-NOW node must use that same channel. MQTT-capable Companions also accept get mqtt.enabled, get mqtt.running, and get mqtt.status in their USB/TCP and browser terminals. Extended slot and NTP diagnostics remain infrastructure features.

    "},{"location":"cli_build_matrix/","title":"CLI Availability by Firmware Build","text":"

    MeshCore command availability is determined in three layers:

    1. Role - repeater, room server, sensor, companion, bridge, or KISS modem.
    2. Build profile - standard, logging, OTA, or FULL.
    3. Compiled hardware features - WiFi, MQTT, GPS, external sensors, PSRAM, Ethernet, and similar optional support.

    The complete command descriptions are in CLI Commands. This page describes the commands intentionally omitted or limited by build profile. The CLI Command Availability Matrix expands this summary into separate nRF52 and ESP32 command tables.

    "},{"location":"cli_build_matrix/#selecting-a-build-profile","title":"Selecting a build profile","text":"

    The runtime settings profile and the feature profile are separate switches:

    bash build.sh build-firmware RAK_3401_repeater \\\n  --profile cascade \\\n  --build-profile auto\n

    --profile default|cascade selects saved-setting defaults. It does not select which code is linked. --build-profile auto|standard|full controls features and partition policy:

    Selection Behavior auto For one explicit target, pass 1 builds the complete supported LoRa-OTA-capable recipe. ESP32 boards with a qualified expanded profile use it; other repeaters attempt the complete recipe in their current application region. A non-repeater keeps every capability declared by its resolved PlatformIO recipe, including LoRa OTA, and fails instead of silently removing one that does not fit. A measured repeater flash/partition overflow starts the standard no_external_sensors LoRa OTA pass. Internal-flash nRF52 repeaters publish that reduced pass even when the complete image fits, because the smaller running image leaves more room to stage a delta; matched QSPI/SD repeaters do not need the redundant artifact. Compiler errors and missing-capability checks never trigger or conceal a reduced build. Canonical bulk commands keep their established standard partition contract. standard Immediately uses the deployed/portable partition contract and its documented reductions. This is useful when the operator already knows the expanded or complete image is unsuitable. full Requires a qualified ESP32 expanded-partition target (or an explicitly named Full Companion). Install a matching merged image when this changes the partition table.

    Successful builds also emit <firmware>.capabilities.json. The sidecar records the effective profile, logical OTA target, actual PlatformIO base, artifact name target, promised capabilities, and every reduction selected by the script. The build fails if a promised linked marker is absent. Current invariants include retry.preset for repeater/room-server roles, WebConfig for the Indicator and ESP32 Full Companion, TempRadio/OTA controls for install-capable Companions, host-mOTA controls for Full Companion, and the advanced flood-rule engine for Full room servers. Resume mode will not accept an old or failed artifact without a verified sidecar.

    For a single ESP32 repeater or room-server target, auto selects its matching combined Full MQTT/USB/WiFi image when that image covers the plain Full recipe. The direct observer and Full-matrix paths use the same full-usb-wifi-ota artifact and runtime output controls. Plain Full T-Beam SX1262/SX1276 profiles and the TLora V2 repeater remain alternatives because their MQTT siblings have lower routing-table capacities.

    Standard and reduced OTA images, including images that fit the 1.25 MiB portable application slot, remain available and remain in the standard matrix pass. Select --build-profile standard to retain the original target and partition contract. Moving from a plain target to its MQTT sibling changes the LoRa OTA target identity; existing nodes need an appropriate wired migration. The default cascade settings profile enables device and LoRa RX power saving; saved preferences take precedence, and active USB/network services can prevent device sleep. --profile default instead uses the upstream power-saving defaults.

    "},{"location":"cli_build_matrix/#role-comes-first","title":"Role comes first","text":"Role Text administration CLI Repeater Full repeater administration surface, subject to the profile differences below Room server Room-server administration surface, subject to the profile differences below Sensor Sensor command surface; it does not acquire the repeater administration tree Full, serial, Ethernet, USB, BLE, or WiFi companion Uses the companion protocol; Full combines every qualified transport for that exact board KISS modem Uses the KISS/TNC frame interface, not the repeater text CLI Bridge Uses its base role plus commands for the bridge transport compiled into that target

    A command belonging to a different role is not considered a profile cut. For example, adding FULL features to a sensor does not turn it into a repeater administrator.

    Repeater profiles use up to 254 neighbor entries. Measured internal-DRAM limits reduce selected Generic E22, Heltec V2, Meshadventurer, T-Beam OTA/MQTT, and TLora MQTT profiles to 50. Check the artifact capability manifest and ESP32 memory budget for the exact target.

    See local maintenance commands for USB, BLE/binary Companion, TCP/Ethernet, and browser CLI privileges. A direct connection can access local diagnostics and explicit credential getters; relaying a command over LoRa keeps its remote restrictions.

    "},{"location":"cli_build_matrix/#profile-matrix","title":"Profile matrix","text":"Build/profile Command availability Standard non-MQTT repeater or room server Keeps the normal role CLI and, where USB is a safe plaintext console, embeds debug/packet logging behind persistent get/set usb.logging. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. Legacy standard logging No longer emitted separately. Its behavior is compiled into the ordinary artifact. Size-constrained STM32 targets embed packet logging without verbose MESH_DEBUG. LoRa-OTA (-ota-) LoRa OTA adds the ota ... commands; it does not otherwise reduce the role CLI. ESP32 no_external_sensors artifacts retain the compact browser WiFi uploader, the complete CLI, and up to 254 neighbors, subject to recorded internal-DRAM reductions. Internal-flash nRF52 repeater auto pair full-ota retains the board's external-sensor drivers; reduced-ota omits the declared optional sensors to leave additional internal-flash staging room. RAK3401 and RAK4631 reduced builds retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors at a measured cost below 5 KiB. Both artifacts carry the same stable OTA target identity and are checked for ota ... and retry.preset; RAK artifacts also verify the retained monitor drivers. ESP32 MQTT observer or ESP-NOW bridge Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. FULL ESP32 USB + WiFi Uses the matching MQTT target with packet logging on, verbose debug off, and the complete command surface supported by that role and hardware. get/set logging.output off\\|usb\\|wifi\\|both selects and persists the active output paths. FULL ESP32 logging fallback Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. nRF52 dual-CDC Full Companion Fresh installs expose only interface 00; it starts as an ASCII terminal and automatically hands a complete < frame to framed Companion. The same interface also carries exclusive serial mOTA traffic. Enabling logging and rebooting adds interface 02 for plaintext logs. BLE and source-only LoRa OTA remain available. get/set usb.logging persistently controls whether the logging interface is present. ESP32 single-TTY Full Companion Every ESP32 Full image starts with the ASCII terminal on its one USB TTY and automatically hands a complete < frame to framed Companion. On 1.17.1.5, run set powersaving off first. set usb.logging on switches that TTY to an input-capable plaintext logging terminal and makes framed Companion unavailable on USB; set usb.logging off stops logging but leaves the TTY in normal ASCII mode. The terminal stop token or a valid incoming framed probe then performs the ordinary switch to Binary Companion. A saved logging-on setting starts directly in that logging terminal and disables automatic frame detection. BLE, WiFi, and source-only LoRa OTA remain available. ESP32 Full uses Arduino-ESP32 2.x where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but still expose only one TTY. no_external_sensors Trims selected optional environmental/ranging drivers and their settings; it does not remove generic I2C, core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because RAK12501/L76K GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility.

    The four retained INA drivers are entries in the optional environmental-sensor table, not the complete set of RAK I2C consumers. Compatible reduced profiles also retain the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 I2C GPS as separate board peripherals. RAK12501/L76K GPS uses UART Serial1. The explicit RAK4631 Serial1 bridge omits the combined GPS provider, including the otherwise non-UART RAK12500 path.

    The firmware-configured INA3221 and RAK12500 addresses are both 0x42. To use both devices on one bus, leave RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and build with -DTELEM_INA3221_ADDRESS=0x43.

    logging, OTA, and FULL describe independent build features in historical filenames. Current standard artifacts use no -logging- infix because their USB logging is runtime controlled.

    "},{"location":"cli_build_matrix/#canonical-bulk-build-policy","title":"Canonical bulk-build policy","text":"

    Bulk and release-matrix commands omit legacy names whose behavior is already available from a canonical image:

    The old aliases still work with build-firmware and build-matching-firmwares. Dedicated LoRa OTA repeater images are not collapsed; they retain their exact storage, bootloader, role, and target identity contracts. Companion boards keep transport-specific canonical images only when no exact Full recipe has passed the combined flash/RAM qualification. ESP32 deliberately uses one TTY: Binary Companion and plaintext USB logging are mutually exclusive there. nRF52 retains its optional second CDC port.

    "},{"location":"cli_build_matrix/#complete-cli-policy","title":"Complete CLI policy","text":"

    The compact ESP32 CLI has been removed. retry.preset is a checked invariant for every repeater and room-server artifact, not a FULL-only command. MQTT observers and ESP-NOW bridges are automatically promoted to FULL builds with expanded partitions rather than dropping administration commands. This keeps tempradio, LoRa OTA, power saving, RXPS, logging, statistics, sensor, ACL, routing, and advanced radio commands whenever the role and compiled hardware support them.

    Some commands still have hardware or feature limits. MQTT neighbor-table publishing and discover.scopes require the compiled WITH_MQTT_NEIGHBORS feature. PSRAM boards enable it automatically, and selected non-PSRAM variants opt in with MQTT_NEIGHBORS_WITHOUT_PSRAM. discover.neighbors does not require MQTT or PSRAM.

    "},{"location":"cli_build_matrix/#discovery-invariant","title":"Discovery invariant","text":"

    discover.neighbors sends the zero-hop node-discovery request used to refresh the repeater neighbor table. It is available in every repeater build profile, including standard, logging, OTA, unified FULL, and FULL logging-fallback builds.

    The exact command is:

    discover.neighbors\n

    It accepts no options and returns:

    OK - Discover sent\n

    Some MQTT room-server targets also expose the command as part of their compiled neighbor-table feature. It is not a cross-role guarantee: companion, KISS, sensor, and ordinary room-server firmware use different interfaces or do not maintain the repeater administrator neighbor table.

    "},{"location":"cli_build_matrix/#feature-dependent-commands","title":"Feature-dependent commands","text":"

    Even in a FULL build, a command can be unavailable when its underlying feature does not exist on that target:

    When diagnosing an unavailable command, check the role first, then the filename profile, then the target's compiled hardware features and its adjacent .capabilities.json file. A current artifact that promises the command but does not contain its linked marker is rejected during the build.

    "},{"location":"cli_command_availability/","title":"CLI Command Availability Matrix","text":"

    This page is the command-by-command companion to CLI Availability by Firmware Build. Each command name links to its detailed documentation.

    The tables cover the text administration CLI used by repeater, room-server, sensor, and bridge firmware. Full Companion also has its own ASCII USB/TCP terminal; see feature switches by role. Companion apps use the companion protocol, KISS firmware uses framed KISS/TNC commands, and terminal-chat firmware has its own interface, so those build roles are not represented in the main matrices. The Companion section below lists its framed maintenance commands. Infrastructure get password is available locally in every profile; LoRa requests cannot read it. get acl and stored packet log are infrastructure features, not Companion contact or live USB-logging commands.

    Build columns mean:

    The firmware-configured INA3221 and RAK12500 addresses are both 0x42; they cannot coexist on one bus at those addresses. Leave RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use a build with -DTELEM_INA3221_ADDRESS=0x43 when both are installed.

    Cell values mean:

    Runtime state can still make an included command fail. Examples include no GPS fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without .mota apply support.

    "},{"location":"cli_command_availability/#companion-framed-cli","title":"Companion framed CLI","text":"

    Companion builds are not represented by the role/profile matrices below. Their local terminal and command 0x42 (CMD_RUN_CLI_COMMAND) expose these settings over the normal binary USB, BLE, or TCP connection:

    Command Availability stats-core, stats-radio, stats-radio-diag, stats-packets Local terminal and binary command 0x42 erase Local terminal and binary command 0x42; reboot after erasing stored settings set freq <MHz> Local terminal, binary command 0x42, and authorized LoRa CLI; reboot to apply get prv.key Local terminal and binary command 0x42; requires private key export enabled get password Reports that Companion has no admin password; infrastructure returns its password locally get wifi.pwd, get mqttN.password, get mqttN.token Local terminal and binary command 0x42; corresponding WiFi/MQTT feature required board Every Companion text terminal and command 0x42 version Every Companion text terminal and protocol-v14 command 0x42; returns the untruncated build identity get storage.layout Every Companion text terminal and command 0x42; reports the running platform's storage layout memory ESP32 Full Companion text terminal and command 0x42 get/set radio.rxgain Companion with a supported radio get/set radio.fem.rxgain Companion on a board with controllable LoRa FEM LNA get/set wifi.powersave ESP32 WiFi Companion; active transport constraints still apply get/set bluetooth.name Companion firmware get/set bluetooth.mac Every Companion build with Bluetooth get/set display.touch Touchscreen Companions using the shared UI; text terminal and framed CLI; off after reboot uf2reset Every nRF52 Companion text terminal and local command 0x42; local only

    Deprecated binary aliases remain receive-only for older clients; new clients should use command 0x42. See Companion radio binary protocol.

    "},{"location":"cli_command_availability/#nrf52","title":"nRF52","text":"Area Command Scope Standard Logging LoRa OTA Operational reboot All text CLI roles Yes Yes Yes Operational poweroff; shutdown Board power-off support Yes Yes Yes Operational uf2reset Local connection; supported UF2 boards Local Local Local Operational clkreboot All text CLI roles Yes Yes Yes Operational clock sync All text CLI roles Yes Yes Yes Operational clock All text CLI roles Yes Yes Yes Operational time <epoch_seconds> Clock only moves forward Yes Yes Yes Operational advert Advert-capable role Yes Yes Yes Operational advert.zerohop Advert-capable role Yes Yes Yes Operational erase Local connection Local Local Local Information ver All text CLI roles Yes Yes Yes Information board All text CLI roles Yes Yes Yes Diagnostics get storage.layout Internal layout and configured QSPI or SD storage Yes Yes Yes Diagnostics memory ESP32 only No No No Diagnostics sensor Hardware wiring summary Yes Yes Yes Diagnostics powerlog Reset, shutdown, and boot-voltage summary Yes Yes Yes Neighbors neighbors Role with a neighbor table Yes Yes Yes Neighbors neighbor.remove [pubkey_prefix] Role with a mutable neighbor table; omit prefix to clear all Yes Yes Yes Neighbors discover.neighbors Repeater; some MQTT room servers Yes Yes Yes Neighbors discover.scopes MQTT observer with compiled neighbor support No No No Statistics clear stats All full-parser text CLI roles Yes Yes Yes Statistics stats-core Local connection Local Local Local Statistics stats-radio Local connection Local Local Local Statistics stats-radio-diag Local connection Local Local Local Statistics stats-packets Local connection Local Local Local Statistics get telemetry.temp/volt/volt.i2c; optional GPS history Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator Yes Yes Yes Statistics set telemetry.gps; get/set/send telemetry.tx Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator Yes Yes Yes Logging log start; log stop; log erase Storage-backed roles retain data; other roles can return empty data Yes Yes Yes Logging log Local connection Local Local Local Logging get/set usb.logging; unified FULL get/set logging.output Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; nRF52 Full Companion can add a reboot-controlled second CDC Yes Yes No Radio get radio; set radio ... All text CLI roles Yes Yes Yes Radio get tx; set tx <dbm> Board TX-power limits apply Yes Yes Yes Radio tempradio ...; normalradio Full parser Yes Yes Yes Radio get/set/del radioat; get/set/del tempradioat Full parser Yes Yes Yes Radio get freq; set freq <mhz> Local or authorized LoRa CLI Yes Yes Yes Radio get/set radio.rxgain Supported radio Feature Feature Feature Radio get/set radio.fem.rxgain Controllable LoRa FEM Feature Feature Feature Radio get/set radio.fem.txgain Controllable LoRa FEM Feature Feature Feature Radio get/set radio.rxps; get rxps.wd RX power-saving support Feature Feature Feature System get/set name All text CLI roles Yes Yes Yes System get/set lat All text CLI roles Yes Yes Yes System get/set lon All text CLI roles Yes Yes Yes System get/set prv.key get requires a local connection Yes Yes Yes System get password; password <new_password> get requires a local connection; setter requires administrator Yes Yes Yes System get/set guest.password Role with guest administration Yes Yes Yes System get/set owner.info All text CLI roles Yes Yes Yes System get/set adc.multiplier Board ADC override support Feature Feature Feature System send text.flood <message> Repeater Yes Yes Yes System host <text>; get host Repeater with host bridge; host requires a remote administrator and a USB/MQTT service Yes Yes Yes System get/set battery.alert; get battery.alert.region Repeater Yes Yes Yes System get/set battery.alert.low; get/set battery.alert.critical Repeater Yes Yes Yes System get/set rx.watchdog Repeater Yes Yes Yes System get/set system.watchdog nRF52 Yes Yes Yes System get public.key All text CLI roles Yes Yes Yes System get role All text CLI roles Yes Yes Yes System get powersaving; set powersaving on/off Supported repeater board Feature Feature Feature System get/set reboot.interval Full parser Yes Yes Yes Clock sync get/set clock.sync.*; clock.sync.mesh now Repeater, sensor, and room server; clock.sync.internet needs MQTT repeater Yes Yes Yes Routing get/set repeat Forwarding-capable role Yes Yes Yes Routing get/set path.hash.mode Role that supports path-hash selection Yes Yes Yes Routing get/set loop.detect Repeater Yes Yes Yes Routing get/set txdelay All text CLI roles, including bridges Yes Yes Yes Routing get/set direct.txdelay Full parser Yes Yes Yes Routing get/set rxdelay All text CLI roles, including bridges Yes Yes Yes Routing get/set dutycycle Full parser Yes Yes Yes Routing get/set af Full parser Yes Yes Yes Routing get/set int.thresh All text CLI roles Yes Yes Yes Routing get/set cad Radio CAD support Feature Feature Feature Routing get/set agc.reset.interval All text CLI roles Yes Yes Yes Routing get/set radio.watchdog MQTT observer No No No Routing get/set multi.acks Full parser Yes Yes Yes Routing get/set flood.advert.interval Repeater Yes Yes Yes Routing get/set advert.interval Advert-capable role Yes Yes Yes Routing get/set flood.max Repeater Yes Yes Yes Routing get/set flood.max.unscoped Repeater Yes Yes Yes Routing get/set flood.max.advert Repeater Yes Yes Yes Routing get/set flood.channel.data; get/set flood.channel.data.hops Repeater Yes Yes Yes Routing get/set/del flood.channel.scope* Repeater Yes Yes Yes Routing get/set/del flood.channel.scope.require* Repeater Yes Yes Yes Routing get/set/del flood.rule*; get/set/del flood.filter*; get/set/del flood.filter.blacklist* Repeater; flood.rule/flood.filter also on FULL ESP32 room server (no blacklist) Yes Yes Yes Routing get/set/del flood.moderation* Repeater Yes Yes Yes Routing get/set outpath Repeater remote-client context Yes Yes Yes Routing get/set altpath Repeater remote-client context Yes Yes Yes ACL setperm <pubkey> <permissions> Repeater, room server, or sensor Yes Yes Yes ACL get acl [page] Repeater, room server, or sensor; admin over LoRa Yes Yes Yes ACL get/set allow.read.only Room server Yes Yes No Regions region load; region save Role with region storage Yes Yes Yes Regions region allowf; region denyf Role with region storage Yes Yes Yes Regions region get; region list; region Role with region storage Yes Yes Yes Regions region home; region default Role with region storage Yes Yes Yes Regions region put; region def; region remove Role with region storage Yes Yes Yes Retry get/set direct.retry; get/set direct.retry.heard Role with basic retry support Yes Yes Yes Retry get/set retry.preset Role with retry support Yes Yes Yes Retry get/set flood.retry.count; get/set flood.retry.path; get/set flood.retry.group.path Repeater Yes Yes Yes Retry get/set flood.retry.advert; get/set flood.retry.prefixes; get/set flood.retry.ignore Repeater Yes Yes Yes Retry get/set flood.retry.bridge; get/set flood.retry.bucket Repeater bridge retry support Feature Feature Feature Retry get/set direct.retry.count; get/set direct.retry.base; get/set direct.retry.step Role with retry support Yes Yes Yes Retry get/set direct.retry.margin; get/set direct.retry.cr Role with retry support Yes Yes Yes Retry get/set/clear recent.repeater; get recent.repeaters Repeater Yes Yes Yes GPS get gps; set gps on/off Compiled GPS Feature Feature Feature GPS gps sync Compiled GPS Feature Feature Feature GPS gps setloc Compiled GPS Feature Feature Feature GPS gps advert [none/share/prefs] Compiled GPS Feature Feature Feature Sensors get/set telemetry.access Sensor-capable role Feature Feature Limited Sensors sensor list [start] Compiled sensor manager; reduced OTA trims target-selected optional environmental/ranging drivers Feature Feature Limited Sensors sensor get; sensor set Compiled sensor setting; reduced OTA trims target-selected optional environmental/ranging drivers Feature Feature Limited Sensors io [r/s/t]<hex> Simple sensor role with board GPIO support Feature Feature No Bridge get bridge.type Compiled bridge Feature Feature Feature Bridge get/set bridge.enabled; get bridge.running Compiled bridge Feature Feature Feature Bridge get/set bridge.delay Compiled bridge Feature Feature Feature Bridge get/set bridge.source Compiled bridge Feature Feature Feature Bridge get/set bridge.baud RS-232 bridge Feature Feature Feature Bridge get/set bridge.uart RS-232 bridge Feature Feature Feature Bridge get/set bridge.channel ESP-NOW is ESP32 only No No No Bridge get/set bridge.format ESP-NOW bridge No No No Bridge get/set bridge.secret ESP-NOW is ESP32 only No No No Board get bootloader.ver nRF52 bootloader metadata Yes Yes Yes Board get pwrmgt.support; get pwrmgt.source; get pwrmgt.bootreason; get pwrmgt.bootmv Board power-management implementation Feature Feature Feature Ethernet eth.status Ethernet target Feature Feature No Browser OTA start ota [ap]; stop ota ESP32 browser uploader No No No WebConfig start webconfig [ap]; stop webconfig; get/set webui ESP32 WebConfig No No No WiFi get/set wifi.ssid; set wifi.pwd; get wifi.status; get/set wifi.powersave ESP32 WiFi No No No WiFi get/set wifi.cli ESP32 WebConfig No No No LoRa OTA ota help; ota ?; ota h LoRa OTA build No No Yes LoRa OTA ota; ota status; ota st LoRa OTA build No No Yes LoRa OTA ota stats LoRa OTA build No No Yes LoRa OTA ota ls; ota neighbors; ota nbrs; ota updates; ota n LoRa OTA build No No Yes LoRa OTA ota get; ota pull; ota download LoRa OTA build; internal-store nRF52 installs in-place deltas, while matched SD/QSPI nRF52 also accepts full images No No Yes LoRa OTA ota install; ota apply; ota applydelta Compatible bootloader and completed update No No Yes LoRa OTA ota rescue install <base_hash16> Legacy internal-flash nRF52 LoRa OTA build with failed app-side EndF validation; shared bootloader-update builds require valid EndF No No Feature LoRa OTA ota bootloader; ota bootloader install <MID8> <HASH16> Allowlisted internal-flash, XIAO-QSPI, or exact MeshTower V2 microSD nRF52 repeater with ABI-3 exact-board OTAFIX No No Feature LoRa OTA ota cancel; ota drop; ota stop LoRa OTA build No No Yes LoRa OTA ota announce; ota adv LoRa OTA build No No Yes LoRa OTA ota self; ota id Firmware with EndF trailer No No Yes LoRa OTA ota qspi; ota storage nRF52 build with raw-QSPI OTA staging Feature Feature Feature LoRa OTA ota folder; ota fold on needs compiled folder transport No No Feature LoRa OTA ota config; ota cfg; ota set LoRa OTA build No No Yes LoRa OTA ota key; ota keys LoRa OTA build No No Yes LoRa OTA ota dev ... Developer diagnostics No No Yes MQTT get/set mqttN.preset MQTT observer No No No MQTT get/set mqttN.server; get/set mqttN.port; get/set mqttN.username; get/set mqttN.password MQTT observer No No No MQTT get/set mqttN.token; get/set mqttN.topic; get/set mqttN.audience MQTT observer No No No MQTT get mqttN.diag MQTT observer No No No MQTT get/set mqtt.origin; get/set mqtt.iata; get mqtt.presets MQTT observer No No No MQTT get mqtt.stats MQTT observer No No No MQTT get/set mqtt.status; get/set mqtt.packets; get/set mqtt.raw; get/set mqtt.interval MQTT observer No No No MQTT get/set mqtt.rx; get/set mqtt.tx MQTT observer No No No MQTT get/set mqtt.neighbors; get/set mqtt.neighbors.interval MQTT observer with compiled neighbor support No No No MQTT get/set mqtt.ntp MQTT observer No No No MQTT get mqtt.ntp.diag Full MQTT observer No No No MQTT get/set timezone; get/set timezone.offset MQTT observer No No No MQTT get/set mqtt.analyzer.us; get/set mqtt.analyzer.eu Legacy MQTT aliases No No No MQTT get/set mqtt.owner; get/set mqtt.email MQTT observer; get requires a local connection No No No MQTT get mqtt.config.valid MQTT observer No No No SNMP get/set snmp; get/set snmp.community MQTT target compiled with SNMP No No No Alerts get/set alert; get/set alert.psk; get/set alert.hashtag; get/set alert.region; get/set alert.wifi; get/set alert.mqtt; get/set alert.interval MQTT observer No No No Alerts alert test [message] MQTT observer with configured alert channel No No No TLS tls.bundletest <host> MQTT target with embedded certificate bundle No No No Manifest OTA ota check MQTT target with OTA_MANIFEST_BASE No No No Manifest OTA ota update MQTT target with OTA_MANIFEST_BASE No No No"},{"location":"cli_command_availability/#esp32","title":"ESP32","text":"Area Command Scope Standard Logging LoRa OTA FULL unified FULL fallback Operational reboot All text CLI roles Yes Yes Yes Yes Yes Operational poweroff; shutdown Board power-off support Yes Yes Yes Yes Yes Operational uf2reset nRF52 only No No No No No Operational clkreboot Full parser Yes Yes Yes Yes Yes Operational clock sync All text CLI roles Yes Yes Yes Yes Yes Operational clock All text CLI roles Yes Yes Yes Yes Yes Operational time <epoch_seconds> Clock only moves forward Yes Yes Yes Yes Yes Operational advert Advert-capable role Yes Yes Yes Yes Yes Operational advert.zerohop Advert-capable role Yes Yes Yes Yes Yes Operational erase Local connection Local Local Local Local Local Display get/set display.mode; display.timeout; display.usb.mode; display.usb.timeout Display-equipped roles; pairing modes require BLE Companion Feature Feature Feature Feature Feature Display get/set display.flip MQTT observer with supported display driver No No No Feature No Information ver All text CLI roles Yes Yes Yes Yes Yes Information board All text CLI roles Yes Yes Yes Yes Yes Diagnostics get storage.layout Detected flash size and live partition table Yes Yes Yes Yes Yes Diagnostics memory ESP32 heap and PSRAM summary Yes Yes Yes Yes Yes Diagnostics sensor Hardware wiring summary Yes Yes Yes Yes Yes Diagnostics powerlog Reset-reason summary Yes Yes Yes Yes Yes Neighbors neighbors Role with a neighbor table Yes Yes Yes Yes Yes Neighbors neighbor.remove [pubkey_prefix] Role with a mutable neighbor table; omit prefix to clear all Yes Yes Yes Yes Yes Neighbors discover.neighbors Repeater; some MQTT room servers Yes Yes Yes Yes Yes Neighbors discover.scopes MQTT observer with compiled neighbor support No No No Feature No Statistics clear stats Full parser Yes Yes Yes Yes Yes Statistics stats-core Local connection Local Local Local Local Local Statistics stats-radio Local connection Local Local Local Local Local Statistics stats-radio-diag Local connection Local Local Local Local Local Statistics stats-packets Local connection Local Local Local Local Local Statistics get telemetry.temp/volt/volt.i2c; optional GPS history Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator Yes Yes Yes Yes Yes Statistics set telemetry.gps; get/set/send telemetry.tx Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator Yes Yes Yes Yes Yes Logging log start; log stop; log erase Storage-backed roles retain data Yes Yes Yes Yes Yes Logging log Local connection Local Local Local Local Local Logging get/set usb.logging; unified FULL get/set logging.output Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; every ESP32 Full Companion uses an input-capable single-TTY logging terminal with framed USB Companion disabled while logging Yes Yes No No Yes Radio get radio; set radio ... All text CLI roles Yes Yes Yes Yes Yes Radio get tx; set tx <dbm> Board TX-power limits apply Yes Yes Yes Yes Yes Radio tempradio ...; normalradio Full parser Yes Yes Yes Yes Yes Radio get/set/del radioat; get/set/del tempradioat Full parser Yes Yes Yes Yes Yes Radio get freq; set freq <mhz> Local or authorized LoRa CLI Yes Yes Yes Yes Yes Radio get/set radio.rxgain Supported radio Feature Feature Feature Feature Feature Radio get/set espnow.channel Primary ESP-NOW mesh radio Feature Feature Feature Feature Feature Radio get/set radio.fem.rxgain Controllable LoRa FEM Feature Feature Feature Feature Feature Radio get/set radio.fem.txgain Controllable LoRa FEM Feature Feature Feature Feature Feature Radio get/set radio.rxps; get rxps.wd Full parser and RX power-saving support Feature Feature Feature Feature Feature System get/set name All full-parser text CLI roles Yes Yes Yes Yes Yes System get/set lat All full-parser text CLI roles Yes Yes Yes Yes Yes System get/set lon All full-parser text CLI roles Yes Yes Yes Yes Yes System get/set prv.key get requires a local connection Yes Yes Yes Yes Yes System get password; password <new_password> get requires a local connection; setter requires administrator Yes Yes Yes Yes Yes System get/set guest.password Role with guest administration Yes Yes Yes Yes Yes System get/set owner.info Full parser Yes Yes Yes Yes Yes System get/set adc.multiplier Board ADC override support Feature Feature Feature Feature Feature System send text.flood <message> Repeater Yes Yes Yes Yes Yes System host <text>; get host Repeater with host bridge; host requires a remote administrator and a USB/MQTT service Yes Yes Yes Yes Yes System get/set battery.alert; get battery.alert.region Repeater Yes Yes Yes Yes Yes System get/set battery.alert.low; get/set battery.alert.critical Repeater Yes Yes Yes Yes Yes System get/set rx.watchdog Repeater Yes Yes Yes Yes Yes System get/set system.watchdog nRF52 only No No No No No System get public.key Full parser Yes Yes Yes Yes Yes System get role All text CLI roles Yes Yes Yes Yes Yes System get powersaving; set powersaving on/off Supported repeater board Feature Feature Feature Feature Feature System get/set reboot.interval Full parser Yes Yes Yes Yes Yes Clock sync get/set clock.sync.*; clock.sync.mesh now Repeater, sensor, and room server; clock.sync.internet needs MQTT repeater Yes Yes Yes Yes Yes Routing get/set repeat Forwarding-capable role Yes Yes Yes Yes Yes Routing get/set path.hash.mode Full parser Yes Yes Yes Yes Yes Routing get/set loop.detect Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set txdelay All text CLI roles, including bridges Yes Yes Yes Yes Yes Routing get/set direct.txdelay Full parser Yes Yes Yes Yes Yes Routing get/set rxdelay All text CLI roles, including bridges Yes Yes Yes Yes Yes Routing get/set dutycycle Full parser Yes Yes Yes Yes Yes Routing get/set af Full parser Yes Yes Yes Yes Yes Routing get/set int.thresh All text CLI roles Yes Yes Yes Yes Yes Routing get/set cad Radio CAD support Feature Feature Feature Feature Feature Routing get/set agc.reset.interval All text CLI roles Yes Yes Yes Yes Yes Routing get/set radio.watchdog MQTT observer No No No Yes No Routing get/set multi.acks Full parser Yes Yes Yes Yes Yes Routing get/set flood.advert.interval Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set advert.interval Full common parser Yes Yes Yes Yes Yes Routing get/set flood.max Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set flood.max.unscoped Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set flood.max.advert Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set flood.channel.data; get/set flood.channel.data.hops Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set/del flood.channel.scope* Repeater role handler Yes Yes Yes Yes Yes Routing get/set/del flood.channel.scope.require* Repeater role handler Yes Yes Yes Yes Yes Routing get/set/del flood.rule*; get/set/del flood.filter*; get/set/del flood.filter.blacklist* Repeater role handler; flood.rule/flood.filter also on FULL ESP32 room server (no blacklist) Yes Yes Yes Yes Yes Routing get/set/del flood.moderation* Repeater role handler Yes Yes Yes Yes Yes Routing get/set outpath Repeater remote-client context Yes Yes Yes Yes Yes Routing get/set altpath Repeater remote-client context Yes Yes Yes Yes Yes ACL setperm <pubkey> <permissions> Repeater, room server, or sensor Yes Yes Yes Yes Yes ACL get acl [page] Repeater, room server, or sensor; admin over LoRa Yes Yes Yes Yes Yes ACL get/set allow.read.only Room server Yes Yes No Yes Yes Regions region load; region save Role with region storage Yes Yes Yes Yes Yes Regions region allowf; region denyf Role with region storage Yes Yes Yes Yes Yes Regions region get; region list; region Role with region storage Yes Yes Yes Yes Yes Regions region home; region default Role with region storage Yes Yes Yes Yes Yes Regions region put; region def; region remove Role with region storage Yes Yes Yes Yes Yes Retry get/set direct.retry; get/set direct.retry.heard Role with basic retry support Yes Yes Yes Yes Yes Retry get/set retry.preset Role with retry support Yes Yes Yes Yes Yes Retry get/set flood.retry.count; get/set flood.retry.path; get/set flood.retry.group.path Repeater Yes Yes Yes Yes Yes Retry get/set flood.retry.advert; get/set flood.retry.prefixes; get/set flood.retry.ignore Repeater Yes Yes Yes Yes Yes Retry get/set flood.retry.bridge; get/set flood.retry.bucket Repeater bridge retry support Feature Feature Feature Feature Feature Retry get/set direct.retry.count; get/set direct.retry.base; get/set direct.retry.step Role with retry support Yes Yes Yes Yes Yes Retry get/set direct.retry.margin; get/set direct.retry.cr Role with retry support Yes Yes Yes Yes Yes Retry get/set/clear recent.repeater; get recent.repeaters Repeater Yes Yes Yes Yes Yes GPS get gps; set gps on/off Compiled onboard GPS Feature Feature Feature Feature Feature GPS gps sync Compiled onboard GPS Feature Feature Feature Feature Feature GPS gps setloc Compiled onboard GPS Feature Feature Feature Feature Feature GPS gps advert [none/share/prefs] Compiled onboard GPS Feature Feature Feature Feature Feature Sensors get/set telemetry.access Sensor-capable full parser Feature Feature Limited Feature Feature Sensors sensor list [start] Compiled sensor manager; no_external_sensors trims target-selected environmental/ranging drivers, not generic I2C Feature Feature Limited Feature Feature Sensors sensor get; sensor set Compiled sensor setting; no_external_sensors trims target-selected environmental/ranging drivers, not generic I2C Feature Feature Limited Feature Feature Sensors io [r/s/t]<hex> Simple sensor role with board GPIO support Feature Feature No No Feature Bridge get bridge.type Compiled bridge Feature Feature Feature Yes Feature Bridge get/set bridge.enabled; get bridge.running Compiled bridge Feature Feature Feature Yes Feature Bridge get/set bridge.delay Compiled bridge Feature Feature Feature Yes Feature Bridge get/set bridge.source Compiled bridge Feature Feature Feature Yes Feature Bridge get/set bridge.baud RS-232 bridge Feature Feature Feature No Feature Bridge get/set bridge.uart RS-232 bridge Feature Feature Feature No Feature Bridge get/set bridge.channel ESP-NOW bridge No No No Feature Feature Bridge get/set bridge.format ESP-NOW bridge No No No Feature Feature Bridge get/set bridge.secret ESP-NOW bridge No No No Feature Feature Board get bootloader.ver nRF52 only No No No No No Board get pwrmgt.support; get pwrmgt.source; get pwrmgt.bootreason; get pwrmgt.bootmv nRF52 only No No No No No Ethernet eth.status Ethernet target Feature Feature No No Feature Browser OTA start ota [ap]; stop ota Compiled browser uploader No No Yes Feature Feature WebConfig start webconfig [ap]; stop webconfig; get/set webui Compiled WebConfig No No No Feature Feature WiFi get/set wifi.ssid; set wifi.pwd; get wifi.status; get/set wifi.powersave MQTT WiFi, or standalone FULL WebConfig; standalone has no get wifi.pwd No No No Yes Feature WiFi get/set wifi.cli Compiled WebConfig No No No Feature Feature LoRa OTA ota help; ota ?; ota h LoRa OTA build No No Yes Yes Yes LoRa OTA ota; ota status; ota st LoRa OTA build No No Yes Yes Yes LoRa OTA ota stats LoRa OTA build No No Yes Yes Yes LoRa OTA ota ls; ota neighbors; ota nbrs; ota updates; ota n LoRa OTA build No No Yes Yes Yes LoRa OTA ota get; ota pull; ota download LoRa OTA build No No Yes Yes Yes LoRa OTA ota install; ota apply; ota applydelta Compatible completed update No No Yes Yes Yes LoRa OTA ota rescue install <base_hash16> Legacy internal-flash nRF52 LoRa OTA build with failed app-side EndF validation; shared bootloader-update builds require valid EndF No No Feature No No LoRa OTA ota bootloader; ota bootloader install <MID8> <HASH16> Allowlisted nRF52 internal/QSPI repeater feature No No No No No LoRa OTA ota cancel; ota drop; ota stop LoRa OTA build No No Yes Yes Yes LoRa OTA ota announce; ota adv LoRa OTA build No No Yes Yes Yes LoRa OTA ota self; ota id Firmware with EndF trailer No No Yes Yes Yes LoRa OTA ota qspi; ota storage nRF52 build with raw-QSPI OTA staging No No No No No LoRa OTA ota folder; ota fold on needs compiled serial or TCP folder transport No No Feature Feature Feature LoRa OTA ota config; ota cfg; ota set LoRa OTA build No No Yes Yes Yes LoRa OTA ota key; ota keys LoRa OTA build No No Yes Yes Yes LoRa OTA ota dev ... Developer diagnostics No No Yes Yes Yes MQTT get/set mqttN.preset MQTT observer No No No Yes No MQTT get/set mqttN.server; get/set mqttN.port; get/set mqttN.username; get/set mqttN.password MQTT observer No No No Yes No MQTT get/set mqttN.token; get/set mqttN.topic; get/set mqttN.audience MQTT observer No No No Yes No MQTT get mqttN.diag MQTT observer No No No Yes No MQTT get/set mqtt.origin; get/set mqtt.iata; get mqtt.presets MQTT observer No No No Yes No MQTT get mqtt.stats MQTT observer No No No Yes No MQTT get/set mqtt.status; get/set mqtt.packets; get/set mqtt.raw; get/set mqtt.interval MQTT observer No No No Yes No MQTT get/set mqtt.rx; get/set mqtt.tx MQTT observer No No No Yes No MQTT get/set mqtt.neighbors; get/set mqtt.neighbors.interval MQTT observer with compiled neighbor support No No No Feature No MQTT get/set mqtt.ntp MQTT observer No No No Yes No MQTT get mqtt.ntp.diag MQTT observer No No No Yes No MQTT get/set timezone; get/set timezone.offset MQTT observer No No No Yes No MQTT get/set mqtt.analyzer.us; get/set mqtt.analyzer.eu Legacy MQTT aliases No No No Yes No MQTT get/set mqtt.owner; get/set mqtt.email MQTT observer; get requires a local connection No No No Yes No MQTT get mqtt.config.valid MQTT observer No No No Yes No SNMP get/set snmp; get/set snmp.community MQTT target compiled with SNMP No No No Feature No Alerts get/set alert; get/set alert.psk; get/set alert.hashtag; get/set alert.region; get/set alert.wifi; get/set alert.mqtt; get/set alert.interval MQTT observer No No No Yes No Alerts alert test [message] MQTT observer with configured alert channel No No No Yes No TLS tls.bundletest <host> MQTT target with embedded certificate bundle No No No Feature No Manifest OTA ota check MQTT target with OTA_MANIFEST_BASE No No No Manifest No Manifest OTA ota update MQTT target with OTA_MANIFEST_BASE No No No Manifest No"},{"location":"cli_command_availability/#supplemental-command-notes","title":"Supplemental command notes","text":"

    These short entries cover implemented commands that do not yet have their own section in the primary command reference.

    "},{"location":"cli_command_availability/#memory","title":"memory","text":"

    ESP32 only. Prints free heap, minimum heap, largest allocation, queue depth, internal heap, and PSRAM totals.

    "},{"location":"cli_command_availability/#sensor-hardware-summary","title":"sensor hardware summary","text":"

    Prints the configured I2C pins and GPS serial pins. This is a wiring/build diagnostic, distinct from sensor list, sensor get, and sensor set.

    "},{"location":"cli_command_availability/#powerlog","title":"powerlog","text":"

    Prints the last reset reason. nRF52 also prints the captured shutdown reason and boot voltage.

    "},{"location":"cli_command_availability/#stats-radio-diag","title":"stats-radio-diag","text":"

    Local-serial diagnostic that prints the extended radio diagnostic counters.

    "},{"location":"cli_command_availability/#radiorxps-and-rxpswd","title":"radio.rxps and rxps.wd","text":"

    get radio.rxps reports RX power-saving state and timing. set radio.rxps ... changes that configuration. get rxps.wd reports the soft and hard RX power-saving watchdog counters.

    "},{"location":"cli_command_availability/#rebootinterval","title":"reboot.interval","text":"

    get reboot.interval shows the scheduled reboot interval. Use set reboot.interval <hours> for 1-255 hours, or set reboot.interval 0 to disable it.

    "},{"location":"cli_command_availability/#sensor-io","title":"Sensor io","text":"

    The simple-sensor role exposes its board GPIO word as hexadecimal. io reads it; io <hex> replaces it; and io r<hex>, io s<hex>, or io t<hex> clear, set, or toggle selected bits.

    "},{"location":"cli_command_availability/#get-mqttndiag","title":"get mqttN.diag","text":"

    Prints the runtime diagnostic summary for MQTT slot N, including its connection state and failure history.

    "},{"location":"cli_command_availability/#get-mqttstats","title":"get mqtt.stats","text":"

    Prints the MQTT bridge runtime statistics summary.

    "},{"location":"cli_command_availability/#get-mqttconfigvalid","title":"get mqtt.config.valid","text":"

    Reports whether the saved MQTT configuration has the minimum values needed to run.

    "},{"location":"cli_command_availability/#tlsbundletest","title":"tls.bundletest","text":"

    tls.bundletest <host[:port]|url> tests the embedded TLS certificate bundle against a remote host without changing the MQTT configuration.

    "},{"location":"cli_command_availability/#manifest-ota-check-and-ota-update","title":"Manifest ota check and ota update","text":"

    On MQTT targets with OTA_MANIFEST_BASE, ota check fetches and checks the target manifest. ota update downloads and applies the compatible update selected by that manifest.

    "},{"location":"cli_commands/","title":"CLI Commands","text":"

    For copy/paste on/off recipes and the differences from Full Companion, see feature switches by role. The USB web console opens the default ASCII terminal at 115200 baud; it does not require on-device WebConfig or WiFi.

    This document provides an overview of CLI commands that can be sent to MeshCore Repeaters, Room Servers and Sensors.

    See CLI Availability by Firmware Build for the role and profile matrix. Commands depend on compiled features; some portable builds omit WebConfig while retaining the complete role CLI and compact WiFi updater.

    See CLI Command Availability Matrix for the command-by-command nRF52 and ESP32 build tables.

    The first word of a command is case-insensitive, so set, Set, and SET are equivalent, as are get, Get, and the other command verbs. The case of arguments such as node names, passwords, and keys is left unchanged.

    Use the site search or your browser's Find command with everyday wording such as tx retries, retry attempts, serial logging, or tx power. Search terms below are alternative wording to help find a command, not additional CLI aliases. Enter the syntax shown under Usage; supported command aliases are listed there explicitly.

    "},{"location":"cli_commands/#navigation","title":"Navigation","text":""},{"location":"cli_commands/#operational","title":"Operational","text":""},{"location":"cli_commands/#reboot-the-node","title":"Reboot the node","text":"

    Search terms: restart, restart node, reboot device.

    Usage: - reboot

    Note: No reply is sent.

    "},{"location":"cli_commands/#power-off-the-node","title":"Power-off the node","text":"

    Search terms: turn off device, shut down, power off.

    Usage: - poweroff, or - shutdown

    Note: No reply is sent.

    "},{"location":"cli_commands/#enter-the-uf2-bootloader-nrf52-only","title":"Enter the UF2 bootloader (nRF52 only)","text":"

    Search terms: bootloader mode, USB firmware update, UF2 mode.

    Usage: - uf2reset

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    Note: Reboots directly into the UF2 bootloader on supported nRF52 boards. This includes the Repeater, Room Server, Sensor, Companion, and Terminal Chat local serial command surfaces. It is never accepted as a remote mesh command.

    "},{"location":"cli_commands/#reset-the-clock-and-reboot","title":"Reset the clock and reboot","text":"

    Usage: - clkreboot

    Note: No reply is sent.

    "},{"location":"cli_commands/#sync-the-clock-with-the-remote-device","title":"Sync the clock with the remote device","text":"

    Usage: - clock sync

    "},{"location":"cli_commands/#display-current-time-in-utc","title":"Display current time in UTC","text":"

    Usage: - clock

    "},{"location":"cli_commands/#set-the-time-to-a-specific-timestamp","title":"Set the time to a specific timestamp","text":"

    Usage: - time <epoch_seconds>

    Parameters: - epoch_seconds: Unix epoch time

    "},{"location":"cli_commands/#send-a-flood-advert","title":"Send a flood advert","text":"

    Usage: - advert

    "},{"location":"cli_commands/#send-a-zero-hop-advert","title":"Send a zero-hop advert","text":"

    Usage: - advert.zerohop

    "},{"location":"cli_commands/#ask-a-usb-connected-host-service-over-lora-repeater-only","title":"Ask a USB-connected host service over LoRa (Repeater Only)","text":"

    Usage:

    host hands one remote LoRa CLI request to a service on the repeater's USB host and returns that service's reply over LoRa. It does not execute the text inside the firmware. The service has 4 seconds to prove the exact request is still pending, then 6 seconds to execute and reply. A second remote command receives busy while either phase is pending.

    Request text is limited to 155 UTF-8 bytes, or 152 bytes when a companion uses its legacy three-byte correlation prefix. The complete LoRa reply is limited to 162 bytes. USB records use Base64URL text, a random 64-bit nonce, and the repeater identity's Ed25519 signature. Before executing anything, the service must return a random one-time challenge and receive the repeater's signed live proof. This avoids any dependency on repeater/Pi clock agreement. Every host.reply USB command must carry the matching request ID and nonce; it is not accepted from LoRa or the Ethernet CLI.

    The included Raspberry Pi endpoint supports exact commands for help, cpu-temp, hostname, uptime, load, memory, disk-free, and clock status. It also provides strictly validated clock sync and clock set <unix_epoch> recovery actions, opt-in network restart and reboot actions, action status <operation_id>, and run <alias> [arguments] for locally allowlisted executables with typed arguments. Clock changes and host recovery actions are disabled by default, arbitrary executables are never accepted, and no action uses a shell. Clock set accepts only canonical unsigned decimal epochs from 2020 through 2099; a root-owned Unix-socket service revalidates the request and authenticates the local service account with SO_PEERCRED before changing system time. Network restart and reboot use a separate root-owned Unix-socket broker with fixed systemd units; installing clock control does not grant those actions. The endpoint never uses sudo for privileged host actions. See LoRa CLI host service for the complete meshcoretomqtt setup and security model.

    "},{"location":"cli_commands/#start-or-stop-an-over-the-air-ota-firmware-update","title":"Start or stop an Over-The-Air (OTA) firmware update","text":"

    Search terms: WiFi OTA, wireless firmware update, OTA uploader, update firmware.

    Usage: - start ota - start ota ap - stop ota

    On nRF52, start ota invokes Bluetooth DFU with the matching bootloader and application DFU ZIP. The WiFi/AP instructions below apply to ESP32.

    On ESP32, start ota serves the web upload page on the station IP when connected to WiFi; otherwise it raises the MeshCore-OTA access point. start ota ap always raises the access point, which is useful when the normal network uses client isolation.

    On an ESP32 build with WebConfig, the manual OTA uploader and WebConfig both use HTTP port 80 and cannot run together. Stop WebConfig before start ota, or stop OTA before start webconfig.

    FULL ESP32 builds also expose the .mota folder seeder on TCP port 5001 whenever WiFi is usable. This listener is independent of port 80, so a host can run motatool serve --dir ./motas --tcp <node-ip>:5001 while WebConfig is active. The TCP connection auto-attaches and detaches the folder; do not run ota folder on at the same time because that command selects the USB-serial folder transport.

    "},{"location":"cli_commands/#browser-configuration-portal-esp32-repeater-and-room-server","title":"Browser configuration portal (ESP32 repeater and room server)","text":"

    Search terms: web UI, web interface, WiFi settings page, configuration website, WebConfig.

    Usage:

    set webui on is the persistent master switch. It starts the portal now and again after future reboots; set webui off closes it and disables that boot start. get webui reports the saved on/off state plus whether the portal is inactive, joining WiFi, serving a setup AP, or serving a LAN URL. The default is off on repeater and room-server builds. On display-equipped repeaters, an otherwise-unused triple click toggles the same saved setting. Consequently, > off, http://192.168.1.130/ means the saved boot setting is off while a temporary WebConfig session is currently active at that URL.

    start webconfig is a temporary start that does not change the saved switch. It serves the shared WiFi, radio, flood, loop, and status page and reports its URL. MQTT builds also show the MQTT tab and wizard step; non-MQTT builds remove them entirely. Sign in with the node's admin password. If the node has no saved WiFi SSID, the command starts the open MeshCore-Setup-XXXX captive AP at http://192.168.4.1/ instead.

    start webconfig ap forces captive-AP mode. It will not interrupt an active MQTT bridge, so run set mqtt.enabled off first. Use stop webconfig to close either mode for the current boot. (stop webconfig does not change a saved webui on.) LAN mode otherwise remains active until reboot. On expanded FULL builds, an unconfigured automatic setup AP receives one absolute 30-minute window per boot, then powers WiFi off even if a client remains attached; rebooting starts a new automatic window. An administrator can explicitly run start webconfig again without rebooting. Once an SSID is saved, the cutoff no longer applies and the selected WiFi/MQTT mode keeps reconnecting. Other setup sessions retain their profile's idle timeout.

    Every ESP32 build with WebConfig supports the browser command terminal, including WiFi Companion and Full Companion. Companions use their complete USB/TCP terminal on the trusted LAN, including contact import, chat, recipient selection, and delayed replies. Repeater and Room Server use their local CLI after admin authentication.

    To add a Companion contact, run import meshcore://<full-contact-card-data>, then list to confirm it appears. Get the complete card with card on the other Companion. A public key alone is insufficient. See the terminal command guide for to, send, login, and cmd examples and session ownership.

    The saved wifi.cli setting defaults to on. Use set wifi.cli off to disable the CLI tab. get wifi.cli reports off, on, waiting for WiFi client, or on, active. The saved setting becomes active only in station/LAN mode while the WiFi client is connected. It is deliberately unavailable on the open setup access point. The tab displays each reply in the browser, including local diagnostics, get password, private-key and WiFi/MQTT credential reads, and erase. Infrastructure get acl and raw log stream to the requesting browser, with backpressure for slow clients. These maintenance commands retain their LoRa restrictions. set freq works both here and through authorized LoRa CLI. The bounded /api/cli endpoint accepts local maintenance commands; use /api/terminal for complete ACL and log listings. Select Command block to paste up to 100 commands with one command per line. Blank lines are ignored, and every nonblank line must fit the normal 159-byte CLI command limit. The browser sends the lines sequentially and waits for each reply before sending the next line. The queue exists only in that browser page; closing it stops any commands that have not yet been sent. A lost connection also stops the remaining block.

    Up/down arrow keys recall commands from the current browser session in single-command mode. A command that stops WebConfig, changes its WiFi connection, disables wifi.cli, or reboots the node stops the remaining block and can close the page before its reply is collected.

    On unified FULL USB + WiFi and FULL logging-fallback ESP32 repeater/room-server builds, get wifi.ssid reports the saved standalone WebConfig network and get wifi.status reports whether WiFi is unconfigured, off, connecting, running the setup AP, failed, or connected. A connected result includes the SSID, LAN IP, and RSSI. When the shared OTA seeder is running, the reply appends OTA TCP 5001: listening or OTA TCP 5001: client connected. WiFi being off is normal while WebConfig is inactive; run start webconfig when a temporary connection is wanted. get wifi.powersave reports the saved standalone setting as none, min, or max. Fresh Cascade-profile builds default to min; target-default builds use none. A saved setting takes precedence after an upgrade.

    The WiFi set commands work on MQTT observers and on FULL standalone ESP32 repeater/room-server builds. On a standalone build, changing the SSID or password stops an active WebConfig session; run start webconfig again to use the new credentials. set wifi.pwd with no value selects an open network. Standalone WiFi also accepts an exact 64-character hexadecimal WPA/WPA2 PSK; ordinary passphrases remain limited to 63 characters. Other 64-character values and all longer values are rejected. MQTT observer WiFi passwords retain their fixed 63-character limit. Power-save changes are applied immediately when WiFi is running and otherwise take effect on the next connection. get wifi.pwd returns the saved password on a local connection. It remains unavailable or masked over LoRa, and the configuration form continues to mask stored secrets.

    ESP32 WiFi Companion WebConfig exposes the same wifi.powersave values in its WiFi card. Every ESP32 WiFi Companion with WebConfig exposes the standalone wifi.ssid, wifi.status, wifi.powersave, wifi.cli, and WebConfig command families through its USB text terminal. Full Companion exposes the same role-specific terminal on TCP port 5002. Credential writes reply before restarting the WiFi station, so a TCP client should expect to reconnect at the new address; USB password input is masked. Binary Companion clients can use USB, BLE, or TCP port 5000 without the terminal-start token: send command 0x42 (CMD_RUN_CLI_COMMAND) followed by the same CLI text, such as get wifi.powersave, get wifi.pwd, stats-core, or set wifi.powersave min. See local maintenance commands for the complete access rules. WiFi-only Companions accept all three modes. A Full Companion that runs BLE and infrastructure WiFi simultaneously rejects none because coexistence requires modem sleep. Companion device power saving and LoRa radio.rxps remain independent. On an ESP32 Full Companion whose primary mesh radio is ESP-NOW, max is also unavailable: a station using maximum modem sleep can miss ESP-NOW broadcasts, which the access point does not buffer for it. A previously saved conflicting value is capped to and reported as min, and a new conflicting selection is rejected. The primary mesh radio also holds the ESP-IDF WiFi wake reference continuously so unsolicited ESP-NOW frames remain receivable; selecting min does not put that primary receiver to sleep.

    SenseCAP Indicator Full is the exclusive-secondary exception. On a WiFi-selected boot, LoRa accepts none|min|max and ESP-NOW accepts none|min. On a BLE-selected boot infrastructure WiFi is not started; LoRa accepts min|max for the saved WiFi setting, while ESP-NOW + BLE requires min. USB and the primary LoRa or ESP-NOW radio remain available in every mode.

    "},{"location":"cli_commands/#view-or-change-the-primary-esp-nowwifi-channel","title":"View or change the primary ESP-NOW/WiFi channel","text":"

    Usage:

    Parameters:

    Default: 1

    This command is available when ESP-NOW is the node's primary mesh radio. The setting is persisted. A set reply reports that reboot is required; until reboot, the running ESP-NOW radio remains on its previous channel. On builds that also provide ordinary WiFi, its station and setup AP share that channel.

    After reboot, ESP-NOW, the setup AP, and the infrastructure-WiFi station use the selected channel. Every primary ESP-NOW node that must communicate with this node, plus the configured router's 2.4 GHz radio, must use the same fixed channel. WiFi power saving does not allow the transports to use different channels. On an ESP32 Full build with primary ESP-NOW, wifi.powersave max is unavailable because maximum modem sleep can miss ESP-NOW broadcasts; use min for coexistence. The firmware keeps the primary ESP-NOW receiver awake while still using the min WiFi/Bluetooth coexistence policy.

    espnow.channel is the channel of the primary ESP-NOW mesh transport. It is separate from bridge.channel, which configures only an ESP-NOW bridge on a firmware role whose primary mesh radio is LoRa.

    The browser portal is not compiled into the two 4 MB LilyGo_TLora_V2_1_1_6_*_observer_mqtt targets because it does not fit while retaining the two app slots required for LoRa OTA. Their normal CLI settings remain available.

    "},{"location":"cli_commands/#erasefactory-reset","title":"Erase/Factory Reset","text":"

    Search terms: factory defaults, reset settings, erase configuration.

    Usage: - erase

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    Warning: This is destructive!

    "},{"location":"cli_commands/#neighbors-repeater-only","title":"Neighbors (Repeater Only)","text":""},{"location":"cli_commands/#list-nearby-neighbors","title":"List nearby neighbors","text":"

    Search terms: nearby nodes, neighbor list, neighbour list.

    Usage: - neighbors

    Note: The output of this command is limited to the 8 most recent adverts.

    Note: Each line is encoded as {pubkey-prefix}:{timestamp}:{snr*4}

    "},{"location":"cli_commands/#remove-a-neighbor","title":"Remove a neighbor","text":"

    Usage: - neighbor.remove <pubkey_prefix> - neighbor.remove \u2014 remove all neighbors

    Parameters: - pubkey_prefix: An even-length hexadecimal prefix (up to the full 64-character public key). All neighbors matching it are removed. Invalid keys are rejected.

    Omitting the prefix clears the table. An empty or whitespace-only prefix is also accepted for compatibility with the mobile app's Remove All Neighbors action. Newly heard zero-hop adverts can populate the table again.

    "},{"location":"cli_commands/#discover-zero-hop-neighbors","title":"Discover zero hop neighbors","text":"

    Usage: - discover.neighbors

    This command is available in every repeater build profile, including portable MQTT, standard, logging, OTA, unified FULL, and FULL logging-fallback artifacts. It does not require MQTT or PSRAM.

    "},{"location":"cli_commands/#discover-neighbor-scopes-mqtt-observer-neighbors-feature","title":"Discover neighbor scopes (MQTT observer, neighbors feature)","text":"

    Refreshes the zero-hop neighbor table, then queries each neighbor for its region scopes and publishes the assembled table to the MQTT neighbors topic once.

    Usage: - discover.scopes

    Note: Requires an MQTT observer build with the neighbors feature compiled in (all PSRAM boards, plus non-PSRAM boards built with MQTT_NEIGHBORS_WITHOUT_PSRAM). Elsewhere it replies Err - neighbors not enabled in this build. If a discover.neighbors refresh is already in flight, the scope pass is queued behind it.

    "},{"location":"cli_commands/#statistics","title":"Statistics","text":""},{"location":"cli_commands/#list-rate-limited-flood-adverts","title":"List Rate-Limited Flood Adverts","text":"

    Usage: get flood.advert [page] or get flood.advert key <index>

    Lists tracked advert origins currently subject to a forwarding limit. Page 1 is the default, with three entries per page so replies fit both USB and admin LoRa CLI. Each row shows its index, the first 12 public-key hex characters, the limit reason, and the minimum wait before another new advert can pass the automatic limiter. Ordinary forwarding filters still apply.

    get flood.advert\nget flood.advert 2\nget flood.advert key 1\n

    Reasons are quota (the prefix's three-hour forwarding allowance is spent), history (distinct receive history is full), and bad (the full key is under the stricter one-advert-per-12-hours rule). A bad key remains listed when wait=0s: one forward is eligible, but its seven-day recovery is not complete. If several limits apply, bad takes precedence over history, then quota; the displayed wait accounts for all applicable limits.

    The key form returns the complete 64-hex public key, reason, wait, shared prefix forwarding count/quota, shortest received hop count, and remaining bad-list recovery time. Different full keys with the same 12-hex prefix remain separate entries. Indices refer to the current live list and can change as history expires; read the full key before using a targeted clear.

    Queries do not clear history, spend quota, or refresh abuse/recovery timers. Elapsed history expires normally. Origins rejected only because every table slot protects abuse history are not retained and cannot be enumerated. Available on repeaters, room servers, and forwarding sensors through their ordinary local/admin CLI. Companions do not have this table.

    "},{"location":"cli_commands/#clear-automatic-flood-advert-history","title":"Clear Automatic Flood Advert History","text":"

    Usage: clear flood.advert all or clear flood.advert <64-hex-full-public-key>

    Clears automatic advert forwarding quotas, first strikes, and seven-day bad-list history for all keys or one exact full key. Available on repeaters, room servers, and forwarding sensors through their ordinary local/admin CLI (including admin LoRa CLI). A missing selector, a key prefix, or a malformed key is rejected. It does not change saved settings or manual flood rules. Reboot also clears this RAM-only history. See automatic advert limits.

    "},{"location":"cli_commands/#clear-stats","title":"Clear Stats","text":"

    Usage: clear stats

    "},{"location":"cli_commands/#system-stats-battery-uptime-queue-length-and-debug-flags","title":"System Stats - Battery, Uptime, Queue Length and Debug Flags","text":"

    Usage: - stats-core

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    "},{"location":"cli_commands/#radio-stats-noise-floor-last-rssisnr-airtime-receive-errors","title":"Radio Stats - Noise floor, Last RSSI/SNR, Airtime, Receive errors","text":"

    Search terms: signal strength, signal quality, RSSI, SNR, radio noise.

    Usage: stats-radio

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    "},{"location":"cli_commands/#packet-stats-packet-counters-received-sent","title":"Packet stats - Packet counters: Received, Sent","text":"

    Usage: stats-packets

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    "},{"location":"cli_commands/#read-repeater-and-room-server-telemetry-history","title":"Read repeater and room-server telemetry history","text":"

    Repeater and room-server firmware record one UTC-aligned sample every 30 minutes. Temperature and battery voltage retain 336 samples (seven rolling days). Each detected INA219, INA226, INA260, or INA3221 voltage channel retains 192 samples (four rolling days). GPS-capable builds retain three GPS days by default; repeaters request a seven-day default at startup. Builds without a GPS provider omit the GPS history commands to conserve flash. All history and any runtime retention change are held in RAM and reset after a reboot.

    The feature is omitted from flash-constrained STM32 repeater and room images.

    Usage:

    Parameters:

    Local serial and remote administrator CLI sessions can read the history on both roles. Collection uses the MCU temperature, battery voltage, external I2C voltage monitors, and an already-valid onboard GPS fix. It does not wake GPS, so an off, sleeping, or unfixed GPS produces a missing location sample without changing its power-saving schedule.

    External voltage history allocates 360 bytes for each detected monitor channel (1,080 bytes for all three INA3221 inputs). The allocation reserves all detected inputs so a sensor connected later can start recording, but all-zero inputs are omitted from command replies and LoRa transmission.

    Replies contain > followed by standard padded Base64. After decoding, all multi-byte integers are little-endian. Packed fields are written most significant bit first, oldest sample first.

    Use the browser-based Telemetry history decoder to turn a reply into a timestamped table or downloadable CSV without uploading the data.

    telemetry.tx is disabled by default. Its schedule and direct path are stored across reboots. Configuring direct or a routed path enables the default 2d schedule; set telemetry.tx schedule changes it from one through 30 days or turns it off. An automatic run waits until 165 half-hour positions are available, then sends one maximum-size temperature packet, one maximum-size battery-voltage packet, and up to three IVB1 packets for every populated I2C voltage channel. GPS is never included in this raw transmission. The history remains boot-local, so the first scheduled run after a reboot needs about 82.5 hours to fill. On the default two-day interval, each 82.5-hour temperature or battery packet overlaps its predecessor by 34.5 hours. Packets are paced two seconds apart. A queue failure is retried after 30 minutes only for the packet that did not queue; successfully queued packets are not duplicated.

    send telemetry.tx now is an administrator-only test action. It queues temperature and battery-voltage snapshots plus the available I2C voltage chunks over the configured path. It uses all currently available base positions up to 165 and all available I2C positions up to 192 per populated channel. It works while the schedule is off and does not move the next scheduled send time. RAW_CUSTOM direct packets do not enter the normal encrypted direct-message retry mechanism.

    Full snapshots are 184-byte RAW_CUSTOM payloads. Manual tests can be shorter while history fills. Both formats are binary, not Base64 and not encrypted:

    Bytes Meaning 0-3 ASCII magic TTB1 for temperature or TVB1 for voltage 4-11 First eight bytes of the repeater public key 12-15 First sample UTC epoch, unsigned 32-bit little-endian 16-17 Sample interval in minutes, unsigned 16-bit little-endian (30) 18 Sample count (1-165) 19 onward Oldest-to-newest encoded samples for the selected series

    Temperature codes reserve 0 for no reading, 1 for below -50 C, and 2 for above +77 C. Codes 3 through 130 represent exact whole degrees from -50 C through +77 C; decode them as code - 53. Voltage codes use the same encoding as the paged voltage payload documented below.

    External voltage snapshots use a separate packed layout. A full four-day channel takes three 140-byte IVB1 payloads of 64 points each:

    Bytes Meaning 0-3 ASCII magic IVB1 4-11 First eight bytes of the source public key 12-15 First sample UTC epoch, unsigned 32-bit little-endian 16-17 Sample interval in minutes, unsigned 16-bit little-endian (30) 18 Cayenne LPP voltage channel 19 Sample count (1-64) 20 onward Oldest-to-newest packed 15-bit voltage codes

    Code 0 means missing or disconnected. Codes 1-32767 represent 0.02 V through 655.34 V in 0.02 V steps; decode millivolts as code * 20.

    Match bytes 4-11 to the first 16 hex characters of the repeater public key shown by its advert or get public.key. This compact identifier is useful for association but is not authenticated and can be spoofed. An MQTT observer can upload the received raw packet to LetsMesh Analyzer /packets; its packet hex contains the direct-route header and path followed by this payload.

    Temperature payload (0x11, 61 bytes):

    Bytes Meaning 0 Format/type 0x11 1-4 First sample UTC epoch, unsigned 32-bit 5 Sample interval in minutes (30) 6 Sample count (48) 7-18 48 packed 2-bit temperature statuses 19-60 48 packed 7-bit temperatures

    Temperature status codes are 0 none, 1 value, 2 below range, and 3 above range. For status 1, the 7-bit temperature is an exact whole-degree integer from -50 C through +77 C; decode it as code - 50. Low values use code 0, and high values use code 127. The separate status map is required because 7 bits contain exactly 128 codes, leaving no spare code for none, low, or high when the complete range is represented at 1 C resolution. No fractional temperature is stored or transmitted.

    Voltage payload (0x12, 55 bytes):

    Bytes Meaning 0 Format/type 0x12 1-4 First sample UTC epoch, unsigned 32-bit 5 Sample interval in minutes (30) 6 Sample count (48) 7-54 48 8-bit voltage codes

    Voltage codes reserve 0 for no reading, 1 for below 1.88 V, and 255 for above 4.40 V. Codes 2-254 represent 1.88 V through 4.40 V in 0.01 V steps; decode millivolts as 1880 + (code - 2) * 10.

    External I2C voltage payload (0x14, 98 bytes):

    Bytes Meaning 0 Format/type 0x14 1-4 First sample UTC epoch, unsigned 32-bit 5 Sample interval in minutes (30) 6 Sample count (48) 7 Cayenne LPP voltage channel 8-97 48 packed 15-bit voltage codes

    The codes have the same 0.02 V through 655.34 V meaning as IVB1.

    GPS payload (0x13, 101 bytes):

    Bytes Meaning 0 Format/type 0x13 1-4 First sample UTC epoch, unsigned 32-bit 5 Sample interval in minutes (30) 6 Sample count (24) 7-10 Page origin latitude in signed degrees times 10^7 11-14 Page origin longitude in signed degrees times 10^7 15 Origin sample index, or 255 when the page has no GPS fix 16 Flags; bit 0 means at least one differential was clipped 17-100 24 records: signed 14-bit north then signed 14-bit east

    GPS differentials use signed 14-bit two's-complement values at 10-meter resolution and are applied to the preceding decoded valid point. The origin sample begins at the header coordinates. A no-fix slot encodes 0,0 and does not advance the reference; a stationary valid fix also quantizes to 0,0. When a page has no fixes, its origin is 0,0, origin index is 255, and all differentials are 0,0. Values outside -8192 through 8191 are clipped and set flag bit 0.

    "},{"location":"cli_commands/#set-companion-display-rotation","title":"Set Companion display rotation","text":"

    SSD1306 Full Companion builds support a persisted runtime orientation:

    get display.rotation\nset display.rotation 0\nset display.rotation 90\nset display.rotation 180\nset display.rotation 270\n

    The values are clockwise degrees. 0 clears the override and restores the board's compiled default. Unsupported display drivers return an error.

    "},{"location":"cli_commands/#show-touchscreen-touch-areas","title":"Show touchscreen touch areas","text":"

    All touchscreen Companion builds using the shared touch UI support temporary diagnostic outlines, including both SenseCAP Indicator rendering sizes:

    get display.touch\nset display.touch on\nset display.touch off\n

    The default is off at every boot. Enabling wakes the display without selecting anything. Dotted yellow borders mark the actual scaled tap targets; the area under a held finger turns green and returns to yellow on release. Disabling removes the borders on the next redraw. Pairing screens suppress the overlay while they consume navigation. This option does not change the font, touch calibration, navigation actions, or saved settings, and is not persisted.

    Use the text terminal or Companion framed CLI (0x42) through an available USB, BLE, or TCP connection. Non-touchscreen, missing-display, and legacy UI builds report Error: touchscreen diagnostics unsupported. No Indicator-only build flag is needed.

    "},{"location":"cli_commands/#set-display-modes-and-timeouts","title":"Set display modes and timeouts","text":"

    Display-equipped Companions, repeaters, room servers, and sensors have four saved settings. The WebConfig Display card exposes the same controls:

    Setting CLI Default Battery mode get/set display.mode button-pairing on BLE Companions; button elsewhere Battery timeout, seconds get/set display.timeout 15 USB mode get/set display.usb.mode Same as battery mode USB timeout, seconds get/set display.usb.timeout 15

    Modes are off, on, button, pairing, button-pairing, and automatic. Pairing modes are available only on builds supporting BLE pairing.

    Timeouts are whole seconds from 1 to 3600. Use mode on for a permanent display rather than timeout 0. The timeout is retained but unused in off, on, and pairing modes. Settings apply immediately, including while blanked; changing power source applies the other profile without carrying over a wake from the previous profile. Settings survive reboot and filesystem-preserving updates in /display_prefs; erasing the filesystem restores the defaults. An existing observer timeout that differs from its old 60-second default is migrated once to both power profiles; an old zero timeout becomes mode on.

    set display.mode button-pairing\nset display.timeout 15\nset display.usb.mode on\nset display.usb.timeout 60\n

    USB profile selection uses the board's external-power detector, with USB-host detection as a fallback. VBUS-capable boards detect chargers without a data connection. Heltec V4 R8 uses either a detected USB host or a calibrated battery voltage above 4.21 V. The voltage estimate is checked every five seconds and clears at 4.20 V or below; host detection takes effect immediately. This estimate can miss a charger while the battery is low, depends on ADC calibration, and cannot distinguish USB from solar charging. Other boards without a power detector may require a USB host to recognize USB power. Wi-Fi/MQTT connection or BLE pairing is not evidence of USB power. E-paper panels can retain their last image when powered off.

    "},{"location":"cli_commands/#set-mqtt-observer-display-flip","title":"Set MQTT observer display flip","text":"

    Supported observer displays, including the R8 OLED and ST7789 panels, can also be turned 180 degrees relative to their compiled orientation:

    get display.flip\nset display.flip off\nset display.flip on\n

    0 and 1 are accepted aliases for off and on. This is intentionally different from Full Companion display.rotation: observer display.flip is a relative 180-degree mounting choice and cannot switch between portrait and landscape. The value is harmless on an observer display driver that does not support flipping.

    Flip survives reboot and firmware updates that preserve the filesystem; erasing flash restores the off default. The boot log reports the saved flip state on display-enabled observer builds.

    "},{"location":"cli_commands/#logging","title":"Logging","text":"

    Builds compiled with MESH_PACKET_LOGGING emit one RAW: line for every received radio frame. Serial output uses backpressure: if a connected host temporarily stops reading, packet processing waits for USB transmit space instead of silently omitting the record. A disconnected host cannot retain an unbounded capture, so logging deployments should keep the reader attached and draining the serial port.

    Every valid received frame also emits the decoded RX summary, including signal, timing, hash, type, route, and payload information. Frames that cannot be decoded still emit their RAW: line. Transmitted packets emit the decoded TX summary.

    Ordinary non-OTA artifacts compile packet logging into the canonical image and control its live USB output at runtime; no separate -logging- artifact is emitted. Use the separately named -ota- artifact when LoRa OTA is required. A -full-usb-wifi-ota- artifact combines USB packet logging, direct WiFi MQTT, LoRa OTA, and the expanded FULL feature set. A -full-logging-ota- artifact is emitted only when that hardware/role has no matching WiFi MQTT environment.

    "},{"location":"cli_commands/#control-live-usb-logging","title":"Control live USB logging","text":"

    Search terms: serial logging, USB debug log, enable logging, disable logging, debug output.

    Usage:

    get usb.logging\nset usb.logging on\nset usb.logging off\nset usb.logging on reboot\nset usb.logging off reboot\n

    ESP32 1.17.1.5 USB logging procedure: disable device sleep before enabling the log stream, using separate text commands:

    set powersaving off\nset usb.logging on\n

    Verify with get powersaving (expect off) and get usb.logging (expect on). Both settings are saved; disabling logging later does not restore power saving. This works around the released USB sleep bug. nRF52 does not need this ESP32 workaround.

    These commands are compiled into ordinary USB-loggable artifacts and every Full Companion. They control live USB debug and packet output. CommonCLI roles save the setting in /com_prefs, so it survives reboot; their first boot defaults to on. Full Companion and ordinary USB Companion start off on a fresh installation so diagnostics cannot corrupt framed traffic.

    On Full Companion these lines belong to its text terminal, not meshcli's Binary get/set parameter namespace. Open interface 00, send +++MESHCORE-TERM-START, and then issue the command. Running meshcli ... get usb.logging directly can instead return Unknown var usb.logging because that is a different protocol operation.

    nRF52 Full Companion defaults logging to off and enumerates only USB interface 00, which carries Companion, terminal, and serial mOTA traffic. Enabling logging adds interface 02, its dedicated plaintext logging port, on the next boot. Disabling it removes interface 02 on the next boot. A command without the optional reboot argument saves the choice and reports that a reboot is required when the USB interface count must change. The exact set usb.logging on reboot and set usb.logging off reboot forms save the choice, send their reply, and reboot one second later only when needed.

    On every ESP32 Full Companion, enter the USB text terminal and use set usb.logging on (preceded by set powersaving off on 1.17.1.5) to turn that TTY into a logging-repeater-style plaintext stream. Framed Binary Companion is unavailable on USB while logging is on. The TTY remains an input-capable CLI, so set usb.logging off works on the same TTY. After its reply, logging stops and the TTY remains in the normal ASCII terminal, just as it does after a fresh Full installation. Send +++MESHCORE-TERM-STOP, or let a Companion app send a valid framed probe, to switch it to Binary Companion. No reboot is needed because the USB interface count does not change.

    Turning USB logging off does not disable CLI replies. nRF52 keeps Companion frames active on interface 00; ESP32 resumes the ordinary ASCII/Binary switcher after the logging terminal turns logging off. This setting does not change the node-storage capture controlled by log start and log stop.

    Companion, Repeater, Room Server, and Sensor builds with both MQTT and USB logging provide the same saved selector for both output paths:

    get logging.output\nset logging.output off\nset logging.output usb\nset logging.output wifi\nset logging.output both\n

    usb emits RAW: packets for a USB-connected service such as meshcoretomqtt. wifi enables the direct MQTT bridge configured by the wifi.* and mqtt.* commands. both intentionally duplicates the radio stream to both consumers; do not point both consumers at the same broker unless the downstream setup deduplicates messages. Fresh unified Full infrastructure installs start in both mode; Full Companion starts with USB logging off. To change only MQTT, use set mqtt.enabled on|off; get mqtt.enabled checks the saved switch and get mqtt.running checks whether the MQTT service is running. Turning MQTT off preserves all broker slots and credentials.

    On 1.17.1.5 ESP32, run set powersaving off before selecting set logging.output usb or set logging.output both, since those modes enable USB logging. WiFi/MQTT-only logging does not need this workaround while the Repeater/Room Server MQTT bridge is running; the released code already blocks device sleep in that state. Check get mqtt.running, not only get mqtt.enabled. WiFi modem power saving is a separate setting.

    "},{"location":"cli_commands/#begin-capture-of-rx-log-to-node-storage","title":"Begin capture of rx log to node storage","text":"

    Search terms: save logs, record received packets, RX logging, stored packet log.

    Usage: log start

    "},{"location":"cli_commands/#end-capture-of-rx-log-to-node-storage","title":"End capture of rx log to node storage","text":"

    Usage: log stop

    "},{"location":"cli_commands/#erase-captured-log","title":"Erase captured log","text":"

    Usage: log erase

    "},{"location":"cli_commands/#print-the-captured-log-to-the-requesting-terminal","title":"Print the captured log to the requesting terminal","text":"

    Usage: log

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    "},{"location":"cli_commands/#info","title":"Info","text":""},{"location":"cli_commands/#get-the-version","title":"Get the Version","text":"

    Usage: ver

    On Full Companion, the Binary device-info frame retains its legacy fixed-width version field. Use version in the text terminal, or send version through Binary protocol command 0x42 (CMD_RUN_CLI_COMMAND), to read the complete untruncated firmware string together with protocol and build date.

    "},{"location":"cli_commands/#show-the-hardware-name","title":"Show the hardware name","text":"

    Usage: board

    "},{"location":"cli_commands/#show-the-storage-layout","title":"Show the storage layout","text":"

    Usage: get storage.layout

    Reports a compact, read-only summary of the storage compiled into the running firmware:

    The command does not write, format, or resize storage. A raw-QSPI or SD build may briefly probe, wake, or mount its configured external storage to read its capacity. Remote use follows the existing administrator-command permissions.

    "},{"location":"cli_commands/#configuration","title":"Configuration","text":""},{"location":"cli_commands/#radio","title":"Radio","text":""},{"location":"cli_commands/#view-or-change-this-nodes-radio-parameters","title":"View or change this node's radio parameters","text":"

    Usage: - get radio - set radio <freq>,<bw>,<sf>,<cr>

    Parameters: - freq: Frequency in MHz - bw: Bandwidth in kHz. Most targets allow 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500. LR1110 targets allow 62.5, 125, 250, 500. - sf: Spreading factor (5-12) - cr: Coding rate (5-8)

    Set by build flag: LORA_FREQ, LORA_BW, LORA_SF, LORA_CR

    Default: 869.525,250,11,5

    Note: Requires reboot to apply. If RXPS is enabled and the saved minimum level/preamble cannot safely cover the new radio timing, the command reply reports the effective level and preamble, or RXPS continuous-fast when no level through 10 is safe. Slower settings recalculate from the saved RXPS minimum and return to it exactly when it is safe.

    "},{"location":"cli_commands/#view-or-change-this-nodes-transmit-power","title":"View or change this node's transmit power","text":"

    Search terms: tx power, transmit strength, radio output power, dBm.

    Usage: - get tx - set tx <dbm>

    Parameters: - dbm: Requested radio-chip power in dBm. The valid range depends on the radio family, selected PA path, and any board-specific external-PA limit.

    Set by build flag: LORA_TX_POWER

    Default: Varies by board

    Notes: This setting only controls the power level of the LoRa chip. Some nodes have an additional power amplifier stage which increases the total output. Refer to the node's manual for the correct setting to use. Setting a value too high may violate the laws in your country.

    The command strictly rejects malformed values and saves the new preference only after the active radio driver accepts it. On LR2021, the chip range is -9 to 22 dBm below 1500 MHz and -19 to 12 dBm above 1500 MHz. A lower board-specific external-PA limit still takes precedence. A saved profile is clamped to the applicable limit when its frequency changes; temporary radio settings use a safe effective power without replacing the saved preference.

    "},{"location":"cli_commands/#change-the-radio-parameters-for-a-set-duration","title":"Change the radio parameters for a set duration","text":"

    Usage: - tempradio <freq>,<bw>,<sf>,<cr>,<timeout_mins> - normalradio

    Parameters: - freq: Frequency in MHz (150-2500) - bw: Bandwidth in kHz (same allowed values as set radio) - sf: Spreading factor (5-12) - cr: Coding rate (5-8) - timeout_mins: Duration in minutes (must be > 0)

    Notes: - tempradio is not saved to preferences and clears on reboot. - normalradio cancels pending and active temporary-radio windows, then restores the saved radio tuple after its CLI reply has drained on the current channel. Permanent radioat entries are not removed.

    "},{"location":"cli_commands/#schedule-radio-parameter-changes","title":"Schedule radio parameter changes","text":"

    Usage: - set radioat <freq>,<bw>,<sf>,<cr>,<start_time> - get radioat [n|all] - del radioat [n|all] - set tempradioat <freq>,<bw>,<sf>,<cr>,<start_time>,<end_time> - get tempradioat [n|all] - del tempradioat [n|all]

    Parameters: - freq: Frequency in MHz (150-2500) - bw: Bandwidth in kHz (same allowed values as set radio) - sf: Spreading factor (5-12) - cr: Coding rate (5-8) - start_time: Unix epoch time when the setting starts - end_time: Unix epoch time when a temporary setting reverts - n: Scheduled entry number from get radioat or get tempradioat

    Notes: - get radioat and get tempradioat list all entries when n is omitted. - del radioat and del tempradioat delete all entries when n is omitted. - Each queue supports 3 entries. Scheduled entries are not saved across reboot. - radioat saves the new radio preferences when it fires. tempradioat applies temporarily, then reverts to the saved radio preferences. - A successful scheduling reply notes any RXPS effective-level/preamble change required by the scheduled tuple. RXPS continuous-fast means the tuple is accepted but will use continuous receive because no safe duty-cycle level is available. Returning to slower settings recalculates from the saved RXPS minimum.

    With the measured RXPS model and the default 1600 us TCXO startup delay (2600 us total transition), a requested minimum level 1 gives:

    SF BW (kHz) Wire preamble Effective timing preamble Effective RXPS level RX / sleep 7 500 32 32 2 5594 / 3022 us 6 250 32 32 2 5844 / 3022 us 5 125 32 32 2 5844 / 3022 us 5 250 64 64 1 3829 / 5100 us 6 500 64 64 1 3829 / 5100 us 5 500 128 128 1 2563 / 6647 us 5 62.5 32 16 6 11922 / 2975 us

    These values include the actual transmitted preamble, explicit header, and a one-symbol timer margin. SF5/SF6 need two more sync symbols than SF7, so their receive windows differ even when symbol duration and sleep time match.

    The wire preamble remains compatible with the existing Cascade convention: 32 symbols at SF5-SF8, extended to 64 for SF5/BW250 and SF6/BW500, or 128 for SF5/BW500; SF9-SF12 use 16. Local RXPS settings and oscillator delays never change the sender's preamble choice. The receiver may use a shorter catch assumption, but its header timeout covers the full transmitted preamble.

    Automatic adjustment stops at guarded level 8. If no guarded timing fits, the radio receives continuously until the tuple changes. For example, a board configured with the previous 5000 us TCXO delay uses continuous RX at SF7/BW500; it cannot fit the new guard within that tuple's 32-symbol wire preamble. Experimental levels 9 and 10 require an explicit selection.

    Earlier firmware passed 16/16 packets in each direction between Heltec V4 and WisMesh Tag at SF5/BW250/64 and SF5/BW500/128, CR5, 909.950 MHz. That result established the existing wire-preamble convention; it does not validate the new timing model. All senders to an RXPS receiver must use a compatible wire preamble. LoRa OTA treats v1.17.1.5 as that capability boundary.

    "},{"location":"cli_commands/#view-or-change-this-nodes-frequency","title":"View or change this node's frequency","text":"

    Search terms: radio frequency, LoRa frequency, MHz.

    Usage: - get freq - set freq <frequency>

    Parameters: - frequency: Frequency in MHz

    Default: 869.525

    Note: Requires reboot to apply

    Connections: USB, browser/TCP/Ethernet CLI, Companion binary command 0x42 (USB/BLE/TCP), and authorized LoRa CLI. set freq saves the frequency; bandwidth, spreading factor, and coding rate stay as configured. Infrastructure roles also clamp transmit power to the board's limit for that frequency. Like set radio, it requires permission to configure the destination node.

    "},{"location":"cli_commands/#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141","title":"View or change this node's rx boosted gain mode (SX12xx and LR1110, v1.14.1+)","text":"

    Search terms: RX boost, receive gain, receiver sensitivity, boosted reception.

    Usage: - get radio.rxgain - set radio.rxgain <state>

    Parameters: - state: on|off

    Default: Target-specific. Most SX1262 and LR1110 targets default to on; Station G2/G3 targets default to off.

    Notes: - The saved setting is applied immediately and persists across reboots. - Periodic AGC resets restore the saved runtime setting; they do not replace it with the target's compile-time default. - Existing installations retain their previously saved value after an upgrade.

    "},{"location":"cli_commands/#view-or-change-rx-duty-cycle-power-saving","title":"View or change RX duty-cycle power saving","text":"

    Search terms: RX power saving, receiver sleep, radio power saving, RXPS.

    Usage: - get radio.rxps - get radio.rxps.config - get radio.rxps.rfrx_disabled - get rxps.wd - set radio.rxps.rfrx_disabled <state> - set radio.rxps off - set radio.rxps on - set radio.rxps conservative - set radio.rxps balanced - set radio.rxps max - set radio.rxps max preamble <16|32> - set radio.rxps overdrive - set radio.rxps overdrive preamble <16|32> - set radio.rxps riskyWorkingMax - set radio.rxps riskyWorkingMax preamble <16|32> - set radio.rxps <1-10> - set radio.rxps level <1-10> - set radio.rxps level <1-10> preamble <16|32> - set radio.rxps <rx_us> <sleep_us>

    Parameters: - rx_us, sleep_us: Receive and sleep durations in microseconds (1000-30000000). - level: Guarded levels 1-8, followed by experimental levels 9 and 10. Higher guarded levels allow more sleep. - preamble: RXPS timing assumption in symbols; 16 or 32. This does not change the radio's actual transmitted preamble. - state: on or off.

    Notes: - get rxps.wd reports the radio watchdogs' soft and hard recovery counts. See receive calibration and recovery for noise sampling, CAD handling, and the continuous-RX mode check. - get radio.rxps.config adds the persisted level and preamble assumption to the on/off and timing values. Deployment tools use it to restore a level-based preference without converting it to fixed manual timings. - radio.rxps.rfrx_disabled is a runtime-only diagnostic setting and resets to off after reboot. - Its default off state keeps the host-controlled SX1262 receive path enabled during RX duty-cycle mode. Setting it to on reproduces the old missing-RF_RX behavior and can significantly reduce receive sensitivity, making remote commands harder to receive. - radio.rxps.rfrx_disabled is supported only on SX1262 targets with a host-controlled RX enable pin. - on and conservative select level 3, balanced selects level 6, and max selects level 8; each named profile assumes a 16-symbol preamble. - The guarded catch ladders for levels 1-8 are 15,14,13,12,11,10,9,8 symbols for preamble 16 and 24,20,16,14,12,10,9,8 for preamble 32 or longer. Generated sleep leaves another 0.2 symbols of margin. The receive timer covers the actual wire preamble, sync, explicit header, and one extra symbol. - overdrive selects level 9; riskyWorkingMax selects level 10. Both operate outside the guarded timer condition. Upstream bench measurements found packet loss at level 10; neither profile is a general guarantee of reliable delivery. They are never selected by automatic level adjustment. - Named experimental profiles accept preamble 32; numeric levels keep the existing automatic-preamble selection. Manual timings bypass the level model. - MC_TCXO_DELAY_US defaults to 1600 us. Initialization applies it and checks TCXO/calibration errors. A board can override it for its oscillator. - Fresh Cascade-profile builds start with RXPS on at level 8 and a 16-symbol preamble. Saved operator settings still take precedence after an upgrade. - Level-based settings automatically recalculate their timings when the spreading factor or bandwidth changes. Custom <rx_us> <sleep_us> timings remain fixed. - get radio.rxps keeps the legacy on/off, RX, and sleep reply. The new get radio.rxps.config reply adds the saved level and preamble assumption. Radio-change replies and the Full Companion terminal status report any effective level/preamble adjustment. Effective preamble 64 or 128 appears only when the active tuple's physical wire preamble has that length. - The selected mode is applied immediately, persisted, and restored after reboot.

    "},{"location":"cli_commands/#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards","title":"View or change the LoRa FEM receive-path gain state on supported boards","text":"

    Usage: - get radio.fem.rxgain - set radio.fem.rxgain <state>

    Parameters: - state: on|off

    Notes: - This controls the external LoRa FEM receive-path LNA where the board supports it. - This is separate from radio.rxgain, which controls the radio chip receive gain mode.

    "},{"location":"cli_commands/#view-or-change-the-lora-fem-transmit-path-gain-state-on-supported-boards","title":"View or change the LoRa FEM transmit-path gain state on supported boards","text":"

    Usage: - get radio.fem.txgain - set radio.fem.txgain <state>

    Parameters: - state: on|off

    Notes: - This controls a software-selectable external LoRa FEM transmit gain where the board supports it. - On Station G3, remove the PA PL1 jumper to allow software control. on selects PA PL1 high/short and off selects PA PL1 low/open. The PA PL2 hardware jumper determines whether this switches between power levels 1/3 or 2/4. - Select an operating level and SX1262 transmit power that comply with local RF limits and the Station G3 power-supply requirements. - The setting is saved immediately, but on Station G3 the level is applied to the hardware at the start of the next transmit, so that the PA supply rail is never re-targeted while the PA is being driven. get reports the configured state, which may lead the hardware until the node next transmits.

    "},{"location":"cli_commands/#system","title":"System","text":""},{"location":"cli_commands/#view-or-change-this-nodes-name","title":"View or change this node's name","text":"

    Usage: - get name - set name <name>

    Parameters: - name: Node name

    Set by build flag: ADVERT_NAME

    Default: Varies by board

    Note: Advertised names can use up to 23 bytes when location is included and 31 bytes otherwise. Emoji and Unicode characters may take more than one byte. Names that exceed the available advert space are truncated at a valid UTF-8 code point boundary.

    "},{"location":"cli_commands/#view-or-change-the-independent-bluetooth-name-companion","title":"View or change the independent Bluetooth name (Companion)","text":"

    Usage:

    get ble.name and set ble.name ... are accepted aliases. This command is specific to Companion firmware. The default is MeshCore-<advert name>; default (or clear) removes a custom override. Names are limited to 31 valid UTF-8 bytes without control characters and take effect after reboot.

    A binary Companion client should carry the same text in command 0x42 (CMD_RUN_CLI_COMMAND), which works over USB, BLE, or TCP without entering USB terminal mode. See Companion radio binary protocol for the frame and reply format.

    "},{"location":"cli_commands/#view-or-change-the-bluetooth-address-ble-companion","title":"View or change the Bluetooth address (BLE Companion)","text":"

    Usage:

    get ble.mac and set ble.mac ... are accepted aliases. The setting is available on every Companion build that includes Bluetooth.

    Value Result after reboot C2:11:22:33:44:55 Save and use that custom address. random Generate one random address now, save it, and reuse it on later boots. random-every-boot Generate a new address once at each boot. random everyboot is also accepted. random-after-connect Keep one random address through unused boots. After an authenticated connection, arm a new address for the next boot. default or clear Remove the override and use the chipset's factory Bluetooth address.

    A custom value must be a valid BLE random-static address in XX:XX:XX:XX:XX:XX form. Its first byte must be from C0 through FF, and you are responsible for keeping addresses unique. Hyphens are also accepted on input; status output uses uppercase colons.

    Every change requires a reboot and invalidates the phone's previous view of the device. Forget the old device entry and pair again. random-every-boot also clears the Companion's saved peer bonds during each startup, so pairing and PIN entry are expected again after every reboot.

    random-after-connect keeps the same address across any number of reboots when nobody successfully authenticated. As soon as an authenticated BLE connection succeeds, the next boot rotates the address and clears stale bonds. The marker is saved while connected, so a normal reboot, power switch, or battery pull after that connection all produce the same next-boot rotation.

    The address setting does not enable or disable stealth. Use the separate bluetooth.stealth flag below. default changes only the address policy.

    "},{"location":"cli_commands/#view-or-change-bluetooth-stealth-ble-companion","title":"View or change Bluetooth stealth (BLE Companion)","text":"
    get bluetooth.stealth\nset bluetooth.stealth on\nset bluetooth.stealth off\n

    get ble.stealth and set ble.stealth on|off are short aliases. This flag defaults to off and is independent of the Bluetooth address policy. Enabling or disabling it keeps the configured custom/random/factory address policy unchanged and requires a reboot. Repeating on keeps an existing stealth pairing; repeating off does not alter address settings.

    For a custom address with stealth:

    set bluetooth.mac C2:11:22:33:44:55\nset bluetooth.stealth on\nreboot\n

    Use set bluetooth.mac random instead of the first line for one saved random address. Both combinations keep their address and bond across later boots. Rotating policies still rotate: random-every-boot reopens pairing at every boot, while random-after-connect reopens it on the first boot after an authenticated connection. Stealth stays enabled; each new address begins with a fresh pairing window so it cannot get hidden behind an unusable bond.

    With stealth on, the node is normally discoverable until the first authenticated pairing. After that, nRF52 uses directed advertising to the saved peer. ESP32 uses a minimal unnamed advertisement and a controller allowlist because its common Arduino BLE API does not expose the directed-advertising peer field. Both reject connection and scan requests from other peers. BLE cannot be completely radio silent and still accept a connection, so a passive radio sniffer may still observe packets. To deliberately clear the saved stealth peer, send set bluetooth.stealth off, then set bluetooth.stealth on, then reboot over USB or an existing authenticated connection. To leave stealth, set it off and reboot. If the saved node-side bond is lost, recovery reopens pairing. The former set bluetooth.mac stealth command is no longer accepted.

    This changes only the running MeshCore Companion application. On nRF52, the OTAFIX bootloader still uses its own address, which is visible only while the board is in BLE DFU/recovery mode. USB and LoRa updates are unaffected. Bluetooth update tools must follow that separate bootloader address after the handoff; they must not assume that a custom application address remains the DFU address. See the hardware observations for the tested XIAO handoff and host-cache caveats.

    "},{"location":"cli_commands/#view-or-change-this-nodes-latitude","title":"View or change this node's latitude","text":"

    Usage: - get lat - set lat <degrees>

    Set by build flag: ADVERT_LAT

    Default: 0

    Parameters: - degrees: Latitude in degrees

    "},{"location":"cli_commands/#view-or-change-this-nodes-longitude","title":"View or change this node's longitude","text":"

    Usage: - get lon - set lon <degrees>

    Set by build flag: ADVERT_LON

    Default: 0

    Parameters: - degrees: Longitude in degrees

    "},{"location":"cli_commands/#view-or-change-this-nodes-identity-private-key","title":"View or change this node's identity (Private Key)","text":"

    Usage: - get prv.key - set prv.key <private_key>

    Parameters: - private_key: Private key in hex format (64 hex characters)

    Local connection only: get prv.key. Companion also requires ENABLE_PRIVATE_KEY_EXPORT=1; use binary command 0x42 or the text terminal. Infrastructure set prv.key retains its administrator access rules.

    Note: Requires reboot to take effect after setting

    "},{"location":"cli_commands/#view-or-change-this-nodes-admin-password","title":"View or change this node's admin password","text":"

    Usage: - get password - password <new_password>

    Parameters: - new_password: New admin password

    Set by build flag: ADMIN_PASSWORD

    Default: password

    get password returns the current admin password only on a local connection: USB, Ethernet, or the authenticated LAN browser CLI. It is rejected over LoRa. Companion has no local admin password and reports that explicitly, including through binary command 0x42; this command does not report the Bluetooth PIN.

    Note: The password setter replies with confirmation.

    Note: Any node using this password will be added to the admin ACL list.

    "},{"location":"cli_commands/#view-or-change-this-nodes-guest-password","title":"View or change this node's guest password","text":"

    Usage: - get guest.password - set guest.password <password>

    Parameters: - password: Guest password

    Set by build flag: ROOM_PASSWORD (Room Server only)

    Default: <blank>

    "},{"location":"cli_commands/#view-or-change-this-nodes-owner-info","title":"View or change this node's owner info","text":"

    Usage: - get owner.info - set owner.info <text>

    Parameters: - text: Owner information text

    Default: <blank>

    Note: | characters are translated to newlines

    Note: Requires firmware 1.12+

    "},{"location":"cli_commands/#fine-tune-the-battery-reading","title":"Fine-tune the battery reading","text":"

    Usage: - get adc.multiplier - set adc.multiplier <value>

    Parameters: - value: ADC multiplier (0.0-10.0)

    Default: 0.0 (value defined by board)

    Note: Returns \"Error: unsupported by this board\" if hardware doesn't support it

    "},{"location":"cli_commands/#send-a-repeater-flood-text","title":"Send a repeater flood text","text":"

    Usage: - send text.flood <message>

    Parameters: - message: Text to send to the shared #repeaters flood channel, prefixed with this node's name. Any : in the node name is sent as ; so the prefix delimiter stays unambiguous.

    Example:

    send text.flood checking ridge link\n
    "},{"location":"cli_commands/#view-or-change-battery-alert-state","title":"View or change battery alert state","text":"

    Usage: - get battery.alert - get battery.alert.region - set battery.alert on [region] - set battery.alert off

    Parameters: - region: Optional named region scope. When omitted, the repeater selects the single deepest (most narrow) named region in the configured hierarchy. If multiple regions tie for deepest, specify one explicitly.

    Defaults: - battery.alert: off - battery.alert.region: <unset>

    Notes: - Enabling fails until at least one usable named region is defined. Alerts are never sent as unscoped floods. If the selected region is later removed, alerts stop until battery alerts are enabled again with a valid region. - Region hierarchy edits are not persistent until region save is run. After region def west pnw wa w-wa sea, run region save before enabling the alert if the hierarchy must survive a reboot. - A region must have a usable transport key. Public named regions derive one automatically; a private region without an available key is rejected. - The first alert is suppressed until the repeater has been up for at least 30 minutes. After that, the repeater checks every 30 minutes and sends low-battery warnings to the #repeaters channel in the selected region. - Once an alert finishes transmitting, another battery alert is suppressed for at least 12 hours. A queue rejection, stale-queue drop, or radio send failure does not start the cooldown. Battery recovery or toggling alerts off and back on does not bypass a completed alert's cooldown during the same boot. - With region def west pnw wa w-wa sea, set battery.alert on selects sea; set battery.alert on w-wa overrides that default. - get battery.alert.region returns the selected scope, for example > sea. - The battery check never requests a wake earlier than its 30-minute deadline. If the normal loop is already awake when that deadline has elapsed, the check is effectively free of an additional wake. Time in light/event sleep counts toward the startup delay, and a pending alert keeps the repeater awake until the packet is handled.

    "},{"location":"cli_commands/#view-or-change-battery-alert-thresholds","title":"View or change battery alert thresholds","text":"

    Usage: - get battery.alert.low - set battery.alert.low <1-100> - get battery.alert.critical - set battery.alert.critical <0-99>

    Defaults: - battery.alert.low: 20 - battery.alert.critical: 10

    Note: The low threshold must be greater than the critical threshold. Alerts at or below the critical threshold use CRITICAL BATTERY in the message; both severities use the same 12-hour resend cooldown.

    "},{"location":"cli_commands/#enable-or-disable-the-rx-inactivity-watchdog-repeater-only","title":"Enable or disable the RX inactivity watchdog (Repeater Only)","text":"

    Usage: - get rx.watchdog - set rx.watchdog on - set rx.watchdog off

    Default: off

    Notes: - When enabled, the first check is due after a full 12-hour observation window. The repeater then checks roughly every 12 hours and reboots if it has not successfully received a radio packet during the preceding 12 hours. - Enabling the watchdog starts a new 12-hour observation window. Rebooting also starts a new window, so a quiet mesh can reboot no more often than once every 12 hours. - The check reuses the radio driver's existing last-receive timestamp. It does not poll, sample, or wake the radio or CPU. A due check waits for the next normal loop/wake, so its actual cadence can drift around the 12-hour target. With RX power saving enabled, packets received during normal listening windows count as activity; the watchdog does not alter the RX/sleep timing.

    "},{"location":"cli_commands/#enable-or-disable-the-nrf52-system-watchdog","title":"Enable or disable the nRF52 system watchdog","text":"

    Usage: - get system.watchdog - set system.watchdog on - set system.watchdog off

    Default: on

    Notes: - This nRF52-only hardware watchdog resets the device if the application loop stops for 60 seconds, including an indefinite SoftDevice flash-write wait. - Enabling takes effect without a reboot. - The nRF52 hardware cannot stop a watchdog after it has started. Disabling is persisted immediately, then the current firmware stops feeding it so the board performs one watchdog restart within 60 seconds. It remains off after that restart. - This setting is nRF52-only. The SoftDevice flash deadlock does not apply to ESP32, whose existing watchdog behavior is unchanged.

    "},{"location":"cli_commands/#estimate-and-correct-infrastructure-node-time-after-startup","title":"Estimate and correct infrastructure-node time after startup","text":"

    Usage: - get clock.sync - get clock.sync.status - get clock.sync.status.table - get clock.sync.status.<1-16> - get clock.sync.mesh - set clock.sync.mesh <on|off> - get clock.sync.mesh.edge - set clock.sync.mesh.edge <on|off> - clock.sync.mesh now - get clock.sync.internet - set clock.sync.internet <on|off> - get clock.sync.drift - set clock.sync.drift <30-86400> - get clock.sync.samples - set clock.sync.samples <3-16>

    Defaults: - clock.sync.mesh: on for all repeater, sensor, and room-server builds - clock.sync.mesh.edge: on - clock.sync.internet: off - clock.sync.drift: 600 seconds (10 minutes) - clock.sync.samples: 9

    When either source is enabled, the node makes its first clock-bootstrap attempt after 30 minutes of uptime, or immediately when the configured number of fresh evidence sources has been collected, whichever comes first. A successful estimate changes the RTC only when the absolute difference is greater than clock.sync.drift; correction can move the clock forward or backward. A valid estimate within the threshold counts as a successful sync without changing the clock. Seven days after each successful estimate, the node evaluates time again; the seven-day deadline therefore starts from the last successful estimate rather than from boot. This is a lazy uptime deadline: the check runs on the first normal loop/wake after it becomes due and does not wake the device by itself. If no source or consensus is available, the node retries every 30 minutes, and newly collected evidence triggers another immediate evaluation once the configured source count is present. Every reboot starts with the initial bootstrap attempt. An existing saved setting always overrides the platform default.

    clock.sync.mesh now bypasses the startup/seven-day deadline and queues a LoRa-only consensus evaluation on the next normal loop, even when the internet source is also enabled. It uses any currently fresh samples without clearing the 16-slot table. If there is not yet enough evidence, mesh collection remains open and the next attempt follows the normal 30-minute retry. The command requires clock.sync.mesh to be on and does not bypass CLI, GPS, or NTP suppression; it also retains the normal quorum, timestamp-validity, and drift checks. The separate clock command only displays the current RTC and does not request a synchronization attempt.

    clock.sync.mesh collects signature-verified advert timestamps and MAC-valid, decrypted Public-channel plain-text timestamps. In normal path mode, collection occurs only after the packet passes every forwarding filter. Sources are deduplicated by advert public key or case-insensitive Public-channel display name. Every fresh sample must also have a different full received path; all direct, zero-hop receptions count as the same empty path. This prevents repeated packets or multiple names arriving over one route from increasing the vote count.

    For a node at the edge of the network where every packet arrives through one relay path, set clock.sync.mesh.edge on changes the evidence requirement from distinct receive paths to distinct sources. Signature-verified adverts are deduplicated by public key, and Public-channel timestamps are deduplicated by case-insensitive display name. Repeated packets from one source still count once. Edge mode observes this verified evidence on the receive path, independently of the forwarding decision, so repeat off and forwarding filters do not prevent clock collection. Other packet types are observed normally but cannot be clock evidence because they do not provide a suitable authenticated Unix timestamp. Changing edge mode clears the in-memory sample table so evidence collected under the other policy is not reused. The setting is persistent and defaults on.

    At least the configured number of distinct fresh evidence sources (nine by default) and a strict majority of all fresh samples must fall within ten minutes of the median. In normal mode each source must use a distinct receive path. In edge mode signed adverts are distinct by public key and Public-channel messages are distinct by display name, but all may use the same receive path. The effective quorum is therefore the larger of clock.sync.samples and half the fresh sample count plus one. For example, a 9-vs-7 split can succeed but an 8-vs-8 split cannot. The median is used. clock.sync.samples accepts 3 through 16; samples older than two hours are ignored. Status reports mode=paths or mode=edge and labels the collected evidence as paths or sources. It reports reason=need-more-paths or reason=need-more-sources when fewer than the configured number exist, and reason=no-consensus when enough evidence exists but the effective quorum does not agree. Mesh collection begins immediately after boot. Following a successful estimate, it resumes two hours before the next seven-day deadline so only evidence that can still be fresh at evaluation time is processed.

    get clock.sync.status reports whether the clock was set and a reason when it was not. Common reasons include waiting-deadline, need-more-sources, need-more-paths, no-consensus, within-drift, mesh-off, and suppression by CLI, GPS, or internet time. It also reports whether collection is active, the fresh evidence count, the number of occupied table slots, and the next evaluation deadline.

    get clock.sync.status.table shows the active sample table in compact form. Each item is slot:type:id-prefix:age, where type A is a signed advert and type P is a Public-channel message. A trailing ! marks a stale sample. If the compact reply is truncated, query any slot with get clock.sync.status.<1-16>. The detail view reports the full source and path hashes, age-adjusted epoch, difference from the local clock, and freshness.

    A timestamp is eligible for a clock-sync sample only when it falls between the UTC build epoch embedded by build.sh and that time plus ten calendar years. Direct developer builds that bypass build.sh fall back to the compiler timestamp. Validation happens before a slot is selected or written, so an advert or Public-channel timestamp outside that window is not recorded as a clock sample.

    Before voting, the node advances each packet timestamp by an estimated transit time. The estimate sums the radio airtime at the original packet length and at each progressively longer relay length, plus the expected midpoint of the random flood-forward delay at every prior hop. The normal elapsed time since the radio recorded local receipt is then added when consensus is evaluated, so local signature/decryption/filter processing time is included too. This is better than using hop count alone because LoRa airtime changes with packet length and radio settings. Transit compensation is capped at the ten-minute consensus window. It cannot know sender queueing, channel contention, or a remote relay's non-matching txdelay, so the consensus window and median still absorb residual error.

    If a clock sync or time <epoch> CLI command successfully sets the clock, or a GPS provider writes a valid GPS time, LoRa-derived clock collection and correction are suppressed for the rest of that boot. Turning clock.sync.mesh off and back on does not clear this safety latch; only a reboot does. get clock.sync.mesh and get clock.sync.status report whether CLI or GPS time caused the suppression. On a WiFi MQTT build, a successful NTP sync is authoritative and also suppresses LoRa correction for the rest of that boot. Source selection starts fresh after a reboot, so LoRa remains the fallback when NTP cannot obtain internet time during that boot.

    Public-channel display names are not authenticated and can be spoofed. Received path hashes are also truncated, unauthenticated routing hints; requiring unique paths prevents ordinary duplicate-route inflation but is not a cryptographic identity check. Signed adverts authenticate the advert contents but do not prove that the advertising node's own clock is correct. Mesh time is therefore a consensus estimate, not an authoritative time service. Edge mode intentionally gives up receive-path diversity. Public-channel display names can be spoofed, so one sender can claim multiple names and inflate the edge-mode vote count.

    clock.sync.internet is available on WiFi MQTT repeater-observer builds. Its initial and seven-day queries run on the MQTT/WiFi task and are read-only until the repeater applies the configured drift test. Failed queries retry after 30 minutes. On other infrastructure-node builds, the preference can be stored but status reports that internet time is unavailable. MQTT builds retain their existing startup NTP behavior required for MQTT/TLS/JWT operation; this setting controls the additional delayed drift checks. Startup NTP is always preferred when it succeeds, regardless of this setting.

    Sensor and room-server builds support mesh clock consensus and report clock.sync.internet as unavailable. Changing any clock.sync.* setting starts a new attempt for the current boot. Settings are persistent in /clock_sync; samples and schedule state are not.

    A backward correction is intentionally allowed, but peers that already recorded a later timestamp from this node may temporarily reject its lower timestamps as replays until corrected time passes the previously observed value.

    Example:

    set clock.sync.drift 600\nset clock.sync.samples 3\nset clock.sync.mesh on\nset clock.sync.mesh.edge on\nclock.sync.mesh now\nget clock.sync.status\n
    "},{"location":"cli_commands/#view-this-nodes-public-key","title":"View this node's public key","text":"

    Usage: get public.key

    "},{"location":"cli_commands/#view-this-nodes-firmware-version","title":"View this node's firmware version","text":"

    Usage: ver

    "},{"location":"cli_commands/#view-this-nodes-configured-role","title":"View this node's configured role","text":"

    Usage: get role

    "},{"location":"cli_commands/#view-or-change-this-nodes-power-saving-flag","title":"View or change this node's power saving flag","text":"

    Search terms: battery saver, device power saving, low power mode.

    Usage: - get powersaving - set powersaving on - set powersaving off

    Parameters: - on: enable power saving - off: disable power saving

    Default: on for fresh Cascade-profile builds and Companion firmware; off for other infrastructure profiles

    Note: get powersaving, set powersaving on, and set powersaving off are shared by Companion and infrastructure. The saved preference controls whether power saving is allowed; active USB, logging and network services may keep the hardware awake. Actual sleep depends on the board.

    For the 1.17.1.5 G3 USB-disconnect report, use set powersaving off as the workaround. The released ESP32 sleep code can lose native USB after two minutes when the terminal is closed, even with a computer attached. The source fix blocks ESP32 light sleep while a native USB host is attached or live USB logging is enabled; ordinary CPU idle/yield remains available. The G3 button also wakes the device for at least two minutes. See the G3 release note for verification steps and the distinction between the fix and the published binaries.

    Companion firmware defaults this setting to on. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. USB and each active wireless transport remain available; SenseCAP Indicator Full keeps only its selected BLE or infrastructure-WiFi secondary transport active. set powersaving off restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (radio.rxps) and WiFi modem power save (wifi.powersave). Infrastructure uses the same commands; its hardware and active-service sleep guards determine when the node can sleep.

    "},{"location":"cli_commands/#view-or-set-the-reboot-interval-repeater-and-room-server","title":"View or set the reboot interval (Repeater and room server)","text":"

    Usage: - get reboot.interval - set reboot.interval <hours>

    Parameters: - hours: 0-255; 0 disables scheduled reboots.

    Default: 0 (disabled)

    "},{"location":"cli_commands/#control-an-exposed-gpio","title":"Control an exposed GPIO","text":"

    Availability: ESP32 Repeater, Room Server, Bridge, and Sensor firmware. Companion firmware does not expose these commands. On nRF52, the commands are enabled only for Sensor builds on the Heltec T096, ProMicro, RAK3401, and RAK4631. GPIO expanders are not supported.

    Usage:

    Examples:

    An integer duration has seconds as its default unit, so 5 means 5 seconds. Add ms for milliseconds (5ms); an explicit s suffix is also accepted (5s). The maximum duration is 24 hours (86,400 seconds or 86,400,000 milliseconds). on or off without a duration remains in that state until another command, reset, or reboot.

    reset changes the pin to an input with no pull resistor (high impedance). It does not reboot the node. It also cancels any pending timer for that pin. A new timed command for the same pin cancels and replaces the previous timer. GPIOs begin in reset; states and timers are not saved and are lost on reboot.

    The immediate reply confirms the applied state and any pending transition. When a timed transition finishes, a command issued over the authenticated remote CLI receives a second report such as > GPIO 16 timer complete: off. Retries of the same authenticated timed command are recognized and do not restart its countdown.

    The pin number is the Arduino pin number used by that target (the normal GPIO number on ESP32 and the board's D/pin index on nRF52). The available-pin list is build-specific. Radio, flash/PSRAM, USB, serial console, display, GPS, I2C, buttons, LEDs, battery measurement, power control, bridge, Ethernet, watchdog, and other pins claimed by the firmware are rejected. A pin must also be physically broken out on your board; get gpio cannot detect wiring or an attached peripheral that is not represented by the firmware configuration.

    Electrical warning: GPIOs use 3.3 V logic and have limited drive current. Do not power a relay, motor, solenoid, or other load directly from a GPIO. Use a suitable transistor, MOSFET, optocoupler, or driver with the required protection components.

    "},{"location":"cli_commands/#routing","title":"Routing","text":""},{"location":"cli_commands/#view-or-set-the-direct-path-override-for-the-current-remote-client","title":"View or set the direct path override for the current remote client","text":"

    Usage: - get outpath - get outpath path - set outpath <hop1_hex,hop2_hex,...> - set outpath path - set outpath direct - set outpath clear - set outpath flood

    Parameters: - hopN_hex: Hop hash with 2, 4, or 6 hexadecimal characters. Every hop must use the same width.

    Notes: - These commands require remote client context and update the caller's ACL entry. - get outpath path reports the reciprocal PAYLOAD_TYPE_PATH received after the caller's latest flood login without changing the selected output route. Because that packet is asynchronous, an immediate query can report > path pending; retry shortly. The observation window expires after one minute, and the captured login path is not automatically selected. - set outpath path copies that observed route to outpath and saves it. It returns an error without changing outpath if no route was received. - direct selects a zero-hop route for a directly reachable caller. - clear forgets the override, replies > outpath cleared, and allows normal path discovery to repopulate it. - flood forces replies to use flood packets until the client logs in again.

    "},{"location":"cli_commands/#view-or-change-this-nodes-repeat-flag","title":"View or change this node's repeat flag","text":"

    Search terms: enable repeating, disable repeating, packet forwarding, stop relaying.

    Usage: - get repeat - set repeat <state>

    Parameters: - state: on|off

    Default: flood.channel.data on; flood.channel.data.hops h=all

    "},{"location":"cli_commands/#view-or-change-this-nodes-advert-path-hash-size","title":"View or change this node's advert path hash size","text":"

    Usage: - get path.hash.mode - set path.hash.mode <value>

    Parameters: - value: Path hash size (0-2) - 0: 1 Byte hash size (256 unique ids)[64 max flood] - 1: 2 Byte hash size (65,536 unique ids)[32 max flood] - 2: 3 Byte hash size (16,777,216 unique ids)[21 max flood] - 3: DO NOT USE (Reserved)

    Default: 0

    Note: the 'path.hash.mode' sets the low-level ID/hash encoding size used when the repeater adverts. This setting has no impact on what packet ID/hash size this repeater forwards, all sizes should be forwarded on firmware >= 1.14. This feature was added in firmware 1.14

    Temporary Note: adverts with ID/hash sizes of 2 or 3 bytes may have limited flood propagation in your network while this feature is new as v1.13.0 firmware and older will drop packets with multibyte path ID/hashes as only 1-byte hashes are supported. Consider your install base of firmware >=1.14 has reached a criticality for effective network flooding before implementing higher ID/hash sizes.

    "},{"location":"cli_commands/#view-or-change-this-nodes-loop-detection","title":"View or change this node's loop detection","text":"

    Usage: - get loop.detect - set loop.detect <state>

    Parameters: - state: - off: no loop detection is performed - minimal: packets are dropped if repeater's ID/hash appears 4 or more times (1-byte), 2 or more (2-byte), 1 or more (3-byte) - moderate: packets are dropped if repeater's ID/hash appears 2 or more times (1-byte), 1 or more (2-byte), 1 or more (3-byte) - strict: packets are dropped if repeater's ID/hash appears 1 or more times (1-byte), 1 or more (2-byte), 1 or more (3-byte)

    Default: off

    Note: When it is enabled, repeaters will now reject flood packets which look like they are in a loop. This has been happening recently in some meshes when there is just a single 'bad' repeater firmware out there (probably some forked or custom firmware). If the payload is messed with, then forwarded, the same packet ends up causing a packet storm, repeated up to the max 64 hops. This feature was added in firmware 1.14

    Example: If preference is loop.detect minimal, and a 1-byte path size packet is received, the repeater will see if its own ID/hash is already in the path. If it's already encoded 4 times, it will reject the packet. If the packet uses 2-byte path size, and repeater's own ID/hash is already encoded 2 times, it rejects. If the packet uses 3-byte path size, and the repeater's own ID/hash is already encoded 1 time, it rejects.

    "},{"location":"cli_commands/#view-or-change-the-retransmit-delay-factor-for-flood-traffic","title":"View or change the retransmit delay factor for flood traffic","text":"

    Search terms: flood forwarding delay, flood retransmit delay, flood TX delay.

    Usage: - get txdelay - set txdelay <value>

    Parameters: - value: Transmit delay factor (0-2)

    Default: 0.5

    Note: When multiple nearby repeaters all hear the same flood packet, each waits a random amount of time before retransmitting to avoid simultaneous collisions. This factor scales the size of that random window. Higher values reduce collision risk at the cost of added latency. 0 disables the window entirely.

    "},{"location":"cli_commands/#view-or-change-the-retransmit-delay-factor-for-direct-traffic","title":"View or change the retransmit delay factor for direct traffic","text":"

    Search terms: direct forwarding delay, direct retransmit delay, direct TX delay.

    Usage: - get direct.txdelay - set direct.txdelay <value>

    Parameters: - value: Direct transmit delay factor (0-2)

    Default: 0.2

    Note: Same collision-avoidance random window as txdelay, but applied to direct (non-flood, routed) traffic. The default is lower because direct packets are addressed to a specific next hop, so far fewer nodes compete to retransmit them.

    "},{"location":"cli_commands/#experimental-view-or-change-the-processing-delay-for-received-traffic","title":"[Experimental] View or change the processing delay for received traffic","text":"

    Usage: - get rxdelay - set rxdelay <value>

    Parameters: - value: Receive delay base (0-20)

    Default: 0.0

    Note: When enabled, repeaters that received a flood packet with a weak signal are held in a delay queue before processing, while those that received it with a strong signal process it immediately. This gives strong-signal paths forwarding priority. By the time weak-signal nodes process their copy, the packet may have already propagated and will be suppressed as a duplicate, reducing redundant retransmissions.

    "},{"location":"cli_commands/#view-or-change-the-duty-cycle-limit","title":"View or change the duty cycle limit","text":"

    Usage: - get dutycycle - set dutycycle <value>

    Parameters: - value: Duty cycle percentage (1-100)

    Default: 50% (equivalent to airtime factor 1.0)

    Examples: - set dutycycle 100 - no duty cycle limit - set dutycycle 50 - 50% duty cycle (default) - set dutycycle 10 - 10% duty cycle - set dutycycle 1 - 1% duty cycle (strictest EU requirement)

    Note: Added in firmware v1.15.0

    "},{"location":"cli_commands/#view-or-change-the-airtime-factor-duty-cycle-limit","title":"View or change the airtime factor (duty cycle limit)","text":"

    Deprecated as of firmware v1.15.0. Use get/set dutycycle instead.

    Usage: - get af - set af <value>

    Parameters: - value: Airtime factor (0-9). After each transmission, the repeater enforces a silent period of approximately the on-air transmission time multiplied by the value. This results in a long-term duty cycle of roughly 1 divided by (1 plus the value). For example: - af = 1 -> ~50% duty - af = 2 -> ~33% duty - af = 3 -> ~25% duty - af = 9 -> ~10% duty You are responsible for choosing a value that is appropriate for your jurisdiction and channel plan (for example EU 868 Mhz 10% duty cycle regulation).

    Default: 1.0

    "},{"location":"cli_commands/#view-or-change-the-local-interference-threshold","title":"View or change the local interference threshold","text":"

    Usage: - get int.thresh - set int.thresh <value>

    Parameters: - value: Interference threshold value

    Default: 0.0

    "},{"location":"cli_commands/#enable-or-disable-hardware-channel-activity-detection-cad","title":"Enable or disable hardware Channel Activity Detection (CAD)","text":"

    Usage: - get cad - set cad <on|off>

    Description: When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of int.thresh - either, both, or none may be active.

    Fresh Cascade-profile builds default CAD to off across all roles, including Companion. Saved CAD settings take precedence after an upgrade. Use set cad off to disable it on an existing node or set cad on to enable it explicitly. The repeater applies the saved toggle to the radio during its periodic noise-floor service. With one runnable packet, a busy result uses the normal CAD retry delay and allows roughly four seconds of continuous busy results. As the runnable transmit queue grows, both delays are divided by its depth: retry spacing will not fall below 50 ms and the busy ceiling will not fall below 500 ms. Reaching the busy ceiling records a CAD-timeout error and attempts the next queued transmission rather than waiting indefinitely. Future-scheduled packets do not accelerate CAD. get cad includes the hardware busy-result count.

    Parameters: - on|off: Enable or disable hardware CAD

    Default: off

    "},{"location":"cli_commands/#view-or-change-the-agc-reset-interval","title":"View or change the AGC Reset Interval","text":"

    Usage: - get agc.reset.interval - set agc.reset.interval <value>

    Parameters: - value: Interval in seconds rounded down to a multiple of 4 (17 becomes 16). 0 to disable.

    Default: 0.0

    "},{"location":"cli_commands/#view-or-change-the-radio-watchdog-interval-mqtt-observer-only","title":"View or change the radio watchdog interval (MQTT observer only)","text":"

    Usage: - get radio.watchdog - set radio.watchdog <minutes>

    Parameters: - minutes: 0 to disable, or 1-120 minutes

    Default: 5

    Note: This watchdog belongs to the MQTT observer runtime and is not available on a standalone FULL repeater. On quiet meshes, increasing it can reduce false recoveries when no traffic is expected.

    "},{"location":"cli_commands/#enable-or-disable-multi-acks-support","title":"Enable or disable Multi-Acks support","text":"

    Usage: - get multi.acks - set multi.acks <state>

    Parameters: - state: 0 (disable) or 1 (enable)

    Default: 0

    "},{"location":"cli_commands/#view-or-change-the-flood-advert-interval","title":"View or change the flood advert interval","text":"

    Usage: - get flood.advert.interval - set flood.advert.interval <hours>

    Parameters: - hours: Interval in hours (3-168)

    Default: 12 (Repeater) - 0 (Sensor)

    "},{"location":"cli_commands/#view-or-change-the-zero-hop-advert-interval","title":"View or change the zero-hop advert interval","text":"

    Usage: - get advert.interval - set advert.interval <minutes>

    Parameters: - minutes: Interval in minutes rounded down to the nearest multiple of 2 (61 becomes 60) (60-240)

    Default: 0

    "},{"location":"cli_commands/#limit-the-number-of-hops-for-a-flood-message","title":"Limit the number of hops for a flood message","text":"

    Search terms: hop limit, maximum hops, max hops, flood distance.

    Usage: - get flood.max - set flood.max <value>

    Parameters: - value: Maximum flood hop count (0-64)

    Default: 64

    "},{"location":"cli_commands/#limit-the-number-of-hops-for-an-unscoped-flood-message","title":"Limit the number of hops for an unscoped flood message","text":"

    Usage: - get flood.max.unscoped - set flood.max.unscoped <value>

    Parameters: - value: Maximum flood hop count (0-64) for a packet without a scope (no region set)

    Default: 0xFF - indicates it hasn't been set, will track flood.max until it is.

    Note: An alternative to region denyf *, setting flood.max.unscoped to a lower value such as 3 would allow for local unscoped messages to propagate, while preventing noisy neighbors from flooding a local region.

    "},{"location":"cli_commands/#limit-the-number-of-hops-for-an-advert-flood-message","title":"Limit the number of hops for an advert flood message","text":"

    Usage: - get flood.max.advert - set flood.max.advert <value>

    Parameters: - value: Maximum flood hop count (0-64) for an advert packet

    Default: 8

    "},{"location":"cli_commands/#forward-flood-group-data-packets-on-repeaters","title":"Forward flood group data packets on repeaters","text":"

    Usage: - get flood.channel.data - get flood.channel.data.hops - set flood.channel.data <on|off> - set flood.channel.data.hops <all|1-7>

    Parameters: - on: Retransmit received flood GRP_DATA channel packets. - off: Do not retransmit received flood GRP_DATA channel packets. - all: When flood.channel.data is off, block GRP_DATA at any received flood hop count. - 1-7: When flood.channel.data is off, repeat GRP_DATA at this hop count or lower and block longer paths.

    Default: flood.channel.data on; flood.channel.data.hops h=all

    Forwarding behavior: Repeater firmware only. The repeater still receives and logs the packet when logging is enabled; this only blocks retransmission. On generalized repeaters these commands manage an ordinary visible FPF7 type=grp_data drop row. off with all maps to hops=all; off with N maps to hops=N+1+. The 240 KB compact FPF6 profiles retain the legacy hard gate. Because it is an ordinary FPF7 row, a matching higher-priority stop rule can exempt traffic from it. The compact get flood.filter list marks the managed row with ~data. Flood group text (GRP_TXT) is unaffected by this setting.

    get flood.channel.data includes the active hop gate as h=all or h>N.

    "},{"location":"cli_commands/#block-selected-flood-channels-with-fpf7","title":"Block selected flood channels with FPF7","text":"

    The separate flood.channel.block command and 15-row table have been retired. Generalized repeaters use the 63-row FPF7 forward phase for authenticated channel blocks:

    set flood.rule type=any channel=#test hops=all drop\nset flood.rule.2 type=any channel=#wardriving hops=5+ drop\nset flood.rule type=any channel=9cd8fcf22a47333b591d96a2b848b73f hops=4+ drop\nget flood.rule\ndel flood.rule.2\n

    type=any with a channel condition can authenticate only GRP_TXT and GRP_DATA, so it does not match other payload types. Use hops=all to block at every received hop count. To preserve the old h=N meaning of repeating through N hops and blocking longer paths, use hops=N+1+; old h=4 is therefore hops=5+.

    New generalized repeater tables seed slot 2 with the second example. Existing FCB2 rows are imported once into free FPF7 slots and the retired file is then removed. The fixed-size STM32WL FPF6 build cannot match authenticated channels.

    "},{"location":"cli_commands/#force-a-transport-scope-onto-floods","title":"Force a transport scope onto floods","text":"

    Usage: - get flood.channel.scope - get flood.channel.scope.<n> - set flood.channel.scope <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow] - set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow] - del flood.channel.scope.<n> - del flood.channel.scope all

    Parameters: - n: Slot number within the table compiled for the target. Roomy ESP32 builds provide 1-255; classic ESP32 repeaters and nRF52/other normal constrained builds provide 1-31; very-tight STM32WL repeaters provide 1-15; the no-PSRAM LilyGo T-LoRa V2.1 repeater/observer provides 1-4. - channel: public, a public #channel, or a 128/256-bit channel key in hex. - txt:*: Unauthenticated fallback for otherwise-unmatched GRP_TXT and GRP_DATA. Plain * is an alias for txt:*. - login:*: Type-based wildcard for the remote-login/admin family: REQ, RESPONSE, TXT_MSG, ANON_REQ, and PATH (0x00, 0x01, 0x02, 0x07, and 0x08). It classifies the outer type; a transit repeater cannot authenticate whether a packet is actually part of a login session. - other:*: Type-based wildcard for every remaining flood payload type, including flood-form TRACE, ACK, advert, multipart, control, OTA, reserved types, and raw custom. - region: Existing named region with a usable transport key. A unique region name prefix is accepted; wildcard region * is not a scope target. - scope=<name>: Regionless alternative to region. The public name is normalized with a leading #, and its 128-bit transport key is derived directly from that hashtag exactly as for flood.filter scope=<name>. It does not need to exist in the region list. Public names up to 30 characters are accepted; private $ scopes are not. - path=blacklist: Optional. Require the received path to match the passive flood.filter.blacklist ID table. No flood.filter drop row needs to be enabled. One exact listed ID qualifies a 3-byte path. A 2-byte path requires two matching received path entries, while a 1-byte path never qualifies. - path=bucket:<1-6>: Optional alternative to path=blacklist. Match IDs in the selected persistent flood.retry.bucket. Each bucket holds up to 17 three-byte IDs and remains usable when flood.retry.bridge is off. It uses the same 3-byte, 2-byte, and 1-byte thresholds as path=blacklist. recent.repeater freshness and flood.retry.ignore do not affect this passive match. - tx=slow: Optional. Use an effective inbound rxdelay base of max(2, configured rxdelay * 2), keep normal outbound queue priority, and schedule retransmission with the maximum supported txdelay factor of 2.0 after changing the scope. The default is fast; tx=fast may be supplied explicitly when replacing a slow row.

    Default: No forced scopes.

    Remote ACL permission 4 (region/scope manager) can use all get, set, and del flood.channel.scope forms. Filter managers and other non-admin roles cannot change this table.

    Without .n, set updates the row for the same exact channel key or wildcard class with the same path selector, otherwise it uses the first empty slot. This permits an ordinary fallback and separate blacklist or bridge-bucket rows for the same channel. With .n, it replaces that slot. The three wildcard classes are independent and consume one slot each. get flood.channel.scope reports active/total slot counts; use the numbered form for row detail. Keyed rows are displayed by the first four bytes of their derived channel hash because channel secrets are never returned. Regionless targets are displayed with their normalized leading #.

    This acts on received ROUTE_TYPE_FLOOD and ROUTE_TYPE_TRANSPORT_FLOOD packets. An unscoped packet gains the configured scope; an already-scoped packet has its existing transport codes replaced. For GRP_TXT and GRP_DATA, all exact channel-key rows are tried first and must validate the packet MAC/decryption. Matching path-qualified exact rows are tried before ordinary exact fallback rows. A region-backed row whose target is missing or unusable is skipped; later exact rows and then txt:* are tried. Exact keyed rows with a usable target therefore beat txt:* regardless of slot number. Within each wildcard class, path-qualified rows similarly precede ordinary fallback rows. login:* and other:* select their non-overlapping outer-type families without decrypting the payload. The lowest usable slot wins within each priority tier.

    Standard traceroute is direct-routed and is therefore outside this flood-only table. A custom flood-form TRACE is treated like every other flood: an applicable wildcard may rewrite it and region/unknown-code gates still apply.

    On a match, the repeater sets the route to ROUTE_TYPE_TRANSPORT_FLOOD, computes transport code 0 from the selected region or direct hashtag key and packet payload, and sets transport code 1 to zero. This occurs before region enforcement, forwarding filters, and the seen-packet lookup. For an already-scoped packet, the selected code replaces both incoming transport-code fields. Direct routes are never rewritten. A packet converted from unscoped is no longer subject to flood.max.unscoped; all rewritten packets remain subject to normal payload handling, flood.max, flood.filter, loop detection, and moderation. Assigning a scope does not make a packet type forwardable if the core would otherwise reject it. By default, if the selected scope differs and the rewritten packet is accepted for forwarding, its initial retransmission uses zero txdelay and the highest outbound queue priority so the newly scoped copy can win at the next hop. Adding tx=slow uses an effective inbound rxdelay base of max(2, configured rxdelay * 2), keeps the ordinary queue priority, and uses the maximum txdelay factor of 2.0 for the retransmission. As with ordinary txdelay, the actual transmit delay is randomized from zero through the resulting window; factor 2.0 gives a maximum of ten packet airtimes. Neither mode preempts an active radio transmission or bypasses CAD and airtime-budget limits. Selecting the scope already carried by the packet is a no-op and does not grant special transmit treatment.

    A region-backed target must be locally flood-allowed and remains subject to the normal region gate. A scope=<name> target is trusted for this matched receive pass even though it has no region-list entry, matching the behavior of flood.filter scope=<name>. It does not create a region, consume a region slot, or change the allow/deny state for unrelated packets carrying the same transport code.

    If a region-backed row's target has been removed or has no usable key, the repeater tries the next applicable row. For group packets this means later authenticated exact rows followed by txt:*; wildcard duplicates likewise fall through to the next usable slot. When no usable mapping exists, the packet retains its original unscoped or scoped route.

    LoRa OTA remains functional when other:* is configured. OTA packets are given that target's transport code, replacing an existing code when necessary, but the OTA handler still accepts and re-floods them during the temporary-radio window. A region target must allow flooding; a direct target follows the regionless trust behavior above. The OTA core itself is dormant outside that window; no default flood filter row is needed for that behavior. Forced scope does not make OTA operate outside the window.

    Capacity cost: Each rule slot retains its 36-byte runtime and persistent record. A separate 32-byte-name table holds up to the smaller of the rule count or 32 distinct regionless targets; very-tight STM32WL builds hold one reusable direct target. Region-backed targets do not consume this table. The four-slot minimum uses 272 bytes RAM and a 278-byte file; it has room for the three wildcard classes plus one exact channel mapping. Very-tight 15-slot builds use 572 bytes RAM and a 578-byte file. The 31-slot table uses 2,108 bytes RAM and a 2,114-byte file. Roomy ESP32 builds use 255 rule slots and 32 direct-target slots: 10,204 bytes RAM and a 10,210-byte file. Classic ESP32 LoRa-OTA builds that cannot afford the 255-rule table use 31 slots instead. Both configured regions and regionless targets can be reused by any number of rules.

    Duplicate behavior: Mesh dedup hashes payload type and payload bytes; it does not hash route type, transport codes, or the ordinary flood path. Adding a transport scope therefore does not create a new duplicate identity. If the same payload later arrives scoped, unscoped, or through a different region, it is still the same seen packet. TRACE is the exception only in that its encoded path_len byte is also hashed.

    While equivalent non-TRACE flood copies are waiting in rxdelay, the normal receive-quality timing still selects the packet to process, but that winner receives a scope from the queued scoped copies with the same dedupe identity. If the copies carry different locally allowed scopes, the scope from the shortest received path wins. Unknown and denied transport codes are not candidates and therefore cannot overwrite an unscoped winner. With equal path lengths, the deeper child region wins because it is narrower. A remaining tie keeps queue order. The winner's own path, SNR reading, and scheduled time are not changed, and an already-scoped winner may have its code replaced by the better queued scope.

    The comparison is deferred until dequeue so each copy retains its original scope and path for arbitration. It can only use copies still present in rxdelay; it cannot replace a packet that already won the dedupe race. Flood-form TRACE participates in this arbitration; direct traceroute does not enter the flood queue.

    A packet that matches a fast flood.channel.scope or flood.filter scope= action and needs its scope changed bypasses the inbound rxdelay queue. A tx=slow row remains in that queue with twice the configured base, floored at 2.0, and participates in normal queued-copy scope arbitration.

    Examples:

    region put west\nregion save\nset flood.channel.scope #local west\nset flood.channel.scope.2 txt:* west tx=slow\nset flood.channel.scope.3 login:* west\nset flood.channel.scope.4 other:* west\nget flood.channel.scope\nget flood.channel.scope.1\ndel flood.channel.scope.2\n

    A regionless exact mapping needs no region command:

    set flood.channel.scope #rgdata scope=BlackHole86\nget flood.channel.scope\nget flood.channel.scope.1\n

    For example, if an authenticated #rgdata packet arrives carrying scope #usa, that rule replaces #usa with #BlackHole86 before forwarding. The rule also assigns #BlackHole86 when the packet is unscoped or carries any other scope; it is a channel-to-target mapping, not an incoming-scope filter.

    To use bridge bucket 1 to assign east to public packets whose received 3-byte path contains 7576FB, while assigning west to every other authenticated public packet:

    set flood.retry.bucket 1 7576FB\nset flood.channel.scope public west\nset flood.channel.scope public east path=bucket:1\n

    Additional 3-byte IDs may be added to bucket 1 later; any one exact hit qualifies the east row. This use is passive and does not require flood.retry.bridge to be enabled. The separate blacklist selector remains available for tables shared with flood.filter path=blacklist rules.

    "},{"location":"cli_commands/#require-valid-incoming-scopes-only-on-selected-channels","title":"Require valid incoming scopes only on selected channels","text":"

    Usage: - get flood.channel.scope.require - get flood.channel.scope.require.<n> - set flood.channel.scope.require <public|#channel|128/256-bit-key> - set flood.channel.scope.require.<n> <public|#channel|128/256-bit-key> - del flood.channel.scope.require.<n> - del flood.channel.scope.require all

    Default: Empty; normal global region enforcement remains active.

    Once this table contains a row, received flood GRP_TXT and GRP_DATA packets use selective region enforcement. A packet authenticating against a listed channel key must already carry a transport scope matching a locally flood-allowed region. Listed channels arriving unscoped, with an unknown code, or with a denied region are not retransmitted. This tests the original incoming scope before any flood.channel.scope or flood.filter scope= rewrite. Those rewrite actions are skipped for a rejected listed channel, so they cannot rescue it or grant special receive/transmit timing.

    Other group channels bypass the region/unknown-code gate while the table is active. They remain subject to every other forwarding control, including repeat, flood.max*, packet filters, loop detection, payload validation, and moderation. Non-channel flood payload types retain normal global region enforcement.

    Channel matching validates the packet MAC/decryption with the configured key; the visible one-byte channel hash is only a prefilter. Public hashtag channels use their derived public key. Without .n, setting an existing key updates it and a new key uses the first empty slot. Numbered set replaces that slot. get ...<n> reports a four-byte derived prefix and key size without exposing the key. The table uses the same build-dependent slot count as flood.channel.scope.

    Remote ACL permission 4 can manage this table. Deleting its final row restores normal global region enforcement for group channels.

    Example:

    set flood.channel.scope.require #bot\nget flood.channel.scope.require\nget flood.channel.scope.require.1\n
    "},{"location":"cli_commands/#change-persistent-flood-rules-in-the-field","title":"Change persistent flood rules in the field","text":"

    For setup guidance, interactions with the existing forwarding controls, and worked moderation examples, see Flood Filtering and Moderation.

    Usage: - get flood.rule - get flood.rule.<n> - set flood.rule[.<n>] type=<type> [hops=<range>] [channel=<channel>] [prefix=<path-prefix>] [in=<input-scope>] <drop|scope=<name>|region=<name>|rate=<N>/min|retry|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio] - del flood.rule.<n> - del flood.rule all - get flood.filter - get flood.filter.<n> - get flood.filter.blacklist - get flood.filter.blacklist.<n> - set flood.filter.blacklist <ID[,ID...]> - set flood.filter.blacklist.<n> <ID[,ID...]> - del flood.filter.blacklist - del flood.filter.blacklist.<n> - set flood.filter <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio] - set flood.filter.<n> <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio] - del flood.filter.<n> - del flood.filter all

    The extended table is available on repeaters with the rule engine enabled and on FULL-profile ESP32 room servers. A FULL room server exposes both flood.rule and flood.filter, has 31 slots, and requires an administrator for remote changes. It does not have the repeater's passive path blacklist, so flood.filter.blacklist* and path=blacklist are repeater-only; use the ordered prefix= match on a room server. Standard room-server profiles do not compile this table.

    Parameters: - n: Forward-rule slot in the build's compiled table (1-63 on generalized repeaters and 1-31 on FULL room servers; compact profiles may use fewer). - type: Payload type name, full PAYLOAD_TYPE_* name, decimal value 0-15, hexadecimal value 0x00-0x0F, or any. - hops: Optional; omitted means all. - N: Match only at received hop count N. - N+: Match at received hop count N and higher. - N-M: Match the inclusive received-hop range. - all: Match every received hop count (0-63). - 0+, all, and an omitted hop expression are equivalent. The CLI displays the saved range as all. - channel=*|public|#name|hash:XX|128-bit-key|256-bit-key: Optional channel match. channel=* means no channel condition at all, so the row matches everything selected by type= (including all flood payload types with type=any). It does not authenticate a packet. public, #name, and raw keys authenticate one channel and therefore narrow the row to GRP_TXT/GRP_DATA. hash:XX matches only the visible one-byte group-channel hash; short:XX and a bare two-digit byte are accepted aliases and are displayed as hash:XX. This form is deliberately unauthenticated. It can collide with another channel once in 256 hash values, and a sender can choose the byte, so use an exact channel name or key whenever it is available. In particular, channel=hash:11 does not mean Public: it matches Public plus every collision or deliberately selected 0x11 value. channel=public performs the deeper MAC/decrypt check with the Public channel key. That distinguishes an ordinary 0x11 collision, but it is channel authentication rather than sender authentication: the group MAC is two bytes and the Public key is shared. - prefix=<ID[,ID...]>: Optional ordered source-path prefix of one to three pbyte IDs. IDs must all be 2, 4, or 6 hex characters, matching a packet's 1-, 2-, or 3-byte pbyte width. path=<prefix> is an alias. - in=any|none|scoped|allowed|unknown|scope:<name>|region:<name>: Optional condition on the original incoming scope, before any rule rewrites it. none is an unscoped flood. scope:name is the exact public hashtag-derived scope. region:name is an exact allowed region match. - drop: Explicit drop action. The flood.rule form requires an explicit action. For compatibility, a legacy flood.filter row with no rewrite, rate, or stop action is treated as drop. - scope=<name>: Direct public-name scope rewrite. It derives a transport key from the name and does not require a configured region. For example, scope=BlackHole86 is a regionless sink scope; region=BlackHole86 would instead require a real configured, flood-allowed region with that name. - region=<name>: Rewrite using an existing locally allowed region and one of that region's transport keys. - rate=N/min: Per-node, per-row fixed one-minute forwarding limit. It can be the only action or accompany scope=/region=. Counters are charged only for packets that pass all forwarding gates. - retry, retry=on, retry=allow, or action=retry: Allow a matching received flood packet to enter the configured flood-retry sequence. With no active retry rows, retry eligibility remains backward compatible and is controlled by the global retry settings. Once any active retry row exists, the matching rows become a retry allow-list: a received flood must match at least one surviving retry row. flood.retry.bridge still selects ordinary or bridge-bucket completion for the allowed packet. This action can accompany rewrite, rate, or stop, but not drop; compact syntax uses f=r. - priority=0-255: Optional primary processing order. Higher values run first. At the same numeric priority, authenticated channel matches run before raw hash:XX matches, which run before channel=*; lower slot number breaks the remaining tie. The default is 0; pri= is an alias. An explicitly higher numeric priority still overrides this automatic specificity ordering. - stop or action=stop: Apply this matching row, then stop lower-order FPF7 rows from processing. It can stand alone or accompany drop, rewrite, or rate. A stop-only row acts as an exception to lower-priority FPF7 rules. If the same row uses region= and that configured region is missing, denied, wildcard, or has no usable transport key, both the rewrite and its stop are inert so lower-order safety rows still run. A direct scope= target does not depend on region configuration. - suspend=tempradio: Optional. Skip this row only while the temporary radio is actually active. - require=region: Legacy alias for in=allowed. Apply the row only if the original incoming packet already passes this repeater's region gate. An incoming transport scope must resolve to a locally allowed region; an unscoped flood must be allowed by the wildcard region. The check occurs before any scope rewrite during this receive pass. - tx=slow: Optional and valid with scope= or region=. Use an effective inbound rxdelay base of max(2, configured rxdelay * 2), keep normal outbound queue priority, and retransmit with the maximum supported txdelay factor of 2.0. Scope rows default to fast; tx=fast explicitly restores that default when replacing a slow row. - path=blacklist: Optional unordered path condition. The persistent blacklist is repeater-only. It contains up to 255 unique 3-byte repeater IDs on ESP32 builds and 18 on other builds, each written as six hexadecimal digits. A packet with 3-byte path hashes matches after one exact ID hit. A packet with 2-byte path hashes matches after two path entries match the first two bytes of listed IDs. Packets with 1-byte path hashes never match this condition. Each received path entry is counted at most once.

    The payload names follow the MeshCore packet-format allocation:

    Value Short name Full name 0x00 req PAYLOAD_TYPE_REQ 0x01 response PAYLOAD_TYPE_RESPONSE 0x02 txt_msg PAYLOAD_TYPE_TXT_MSG 0x03 ack PAYLOAD_TYPE_ACK 0x04 advert PAYLOAD_TYPE_ADVERT 0x05 grp_txt PAYLOAD_TYPE_GRP_TXT 0x06 grp_data PAYLOAD_TYPE_GRP_DATA 0x07 anon_req PAYLOAD_TYPE_ANON_REQ 0x08 path PAYLOAD_TYPE_PATH 0x09 trace PAYLOAD_TYPE_TRACE 0x0A multipart PAYLOAD_TYPE_MULTIPART 0x0B control PAYLOAD_TYPE_CONTROL 0x0C ota PAYLOAD_TYPE_OTA (this fork's LoRa OTA extension; reserved upstream) 0x0D 13 reserved 0x0E 14 reserved 0x0F raw_custom PAYLOAD_TYPE_RAW_CUSTOM

    Route scope: Rules are evaluated only for the two flood route values: ROUTE_TYPE_TRANSPORT_FLOOD (0x00, flood plus transport codes) and ROUTE_TYPE_FLOOD (0x01, unscoped flood). Direct routes 0x02 and 0x03 are never affected.

    Behavior: Match fields within one row are ANDed. Every FPF7 row is matched against the same immutable receive-time packet, before any rule changes its scope. Matching rows are processed in descending priority. At equal numeric priority, authenticated channel matches precede raw hashes, which precede an unrestricted channel matcher; lower slot wins after that. The first matching stop row is included and all lower-order FPF7 matches are discarded. A stop cannot undo an earlier drop or bypass hard forwarding gates or the other policy phases. A row with path=blacklist must meet the path condition as well as its other conditions; blacklist IDs can occur anywhere in the received path and their configured order is irrelevant. In contrast, prefix= begins at the first received path entry and preserves order. A matching drop row prevents retransmission. The highest-order remaining matching scope/region row wins; matching drop and rate rows remain independent and can still block the rewritten packet. Scope rewriting happens before region enforcement and is trusted even when its name is absent from the local region list. It does not bypass repeat, flood.max, other drop rows, loop detection, or moderation.

    Retry selection uses that same ordered, stop-truncated match set. With at least one active retry row, a received flood starts a retry sequence only when one of those matching rows includes retry. This selector cannot override flood.retry.count, path/type attempt caps, flood.retry.advert, disabled forwarding, a drop decision, or any other hard forwarding gate. Locally originated floods retain the normal global retry behavior.

    With require=region, a failed check makes that scope row ineligible. It leaves the packet unchanged and does not set the filter-scope trust bypass, so an unknown or denied incoming region is rejected normally unless another independent scope rule rewrites it. Later eligible filter scope rows may still match.

    By default, when a scope row will change the packet's transport codes, the packet bypasses inbound rxdelay; its retransmission then uses zero txdelay and the highest outbound queue priority. With tx=slow, the rewrite instead uses an effective inbound rxdelay base of max(2, configured rxdelay * 2), normal queue priority, and the maximum txdelay factor of 2.0. The actual randomized transmit wait ranges from zero to ten packet airtimes. Selecting the scope already carried is a no-op and does not grant special treatment. An active radio transmission is not preempted, and CAD and airtime-budget limits still apply.

    The packet is still received and can still be logged. Rules are persistent data and can be changed over serial or authenticated remote CLI without an OTA or reboot. flood.rule and flood.filter address the same table on extended builds; FPF6 files are migrated in memory and the next save writes FPF7. FPF1-FPF5 files are rejected and filtering fails open. FPF7 stores canonical region names rather than transient numeric region IDs. Removing, reordering, or reusing a region ID therefore cannot silently retarget a rule. If a saved input or target region name is absent, that input match or rewrite is inert; restoring the same region name reactivates it. While the temporary radio is active, only rows explicitly marked suspend=tempradio are skipped. tempradio is a radio state, not an OTA mode; normal payload types can also use the temporary channel. Other rows remain in force. A malformed persisted table fails open (no general rules are applied).

    Within one receive evaluation, rows that use the same channel key share one authentication result. The cache is discarded after that packet and stores neither plaintext nor passwords; different keys are authenticated separately. Raw hash:XX rows skip this authentication and compare only the one visible byte.

    Per-channel retry examples:

    # Use bridge-bucket retry only for authenticated Public and #hamradio traffic.\nset flood.retry.bridge on\nset flood.rule.2 type=any channel=public retry\nset flood.rule.3 type=any channel=#hamradio retry\n\n# If the channel key/name is unavailable, select visible channel hash A7.\n# This is an unauthenticated 1-byte hint, not a channel identity.\nset flood.rule.4 type=any channel=hash:A7 retry\n

    Because Public's visible hash is 0x11, a bare hash:11 drop row also matches Public. To exempt authenticated Public while dropping other packets that carry the same visible byte, put the exact Public rule first and stop lower-priority rules after it:

    # Retry and preserve authenticated Public; drop other channel-hash 11 packets.\nset flood.rule.2 type=any channel=public retry stop\nset flood.rule.3 type=any channel=hash:11 drop\n

    The Public row matches only after its MAC/decrypt check succeeds. A colliding channel therefore misses that stop and reaches the raw-hash drop row. Both rows use the default numeric priority, but authenticated-channel specificity automatically orders Public first even if its slot number is higher. Without stop, both rows match Public and the sticky drop action wins. Omit retry from the Public row when only the forwarding exemption is wanted. An operator can deliberately reverse this order by assigning the hash row a higher numeric priority.

    Deleting or replacing the last active retry row restores the legacy global retry eligibility. Firmware that predates the retry/hash:XX FPF7 extension cannot preserve tables containing those rows; remove them before downgrading.

    Default row: Repeater firmware and FULL ESP32 room-server firmware seed a new flood-filter table with ota all suspend=tempradio in slot 1. This blocks repeated LoRa OTA (0x0C) floods at every received hop unless temporary radio is actually active. The OTA core independently refuses OTA receive, relay, and transmit outside temporary radio. The row is editable and deletable; once the table is saved, deletion is persistent. Restore the exact seeded row with:

    set flood.filter.1 0x0C all suspend=tempradio\n

    Omitting all is equivalent. Omit .1 as well to reuse an identical rule or the first empty slot instead of replacing slot 1.

    Remote-admin lockout warning: There are no hidden payload-type or short-hop exceptions. FPF7 drop and rate rows may block req, response, txt_msg, anon_req, path, ACK, and multipart traffic beginning at hop 0 when their match fields say so. Transit repeaters cannot decrypt these outer types to distinguish an admin exchange from ordinary peer traffic. Keep a serial or other recovery path and stage broad deny/rate rules carefully.

    Without .n, set reuses an identical rule or uses the first empty slot. With .n, it replaces that slot, which is the intended way to change a row's match or action. get flood.filter or get flood.rule gives a compact list. Use the numbered form for full details, including channel, prefix, original-scope condition, action, timing, rate, and temporary-radio suspension.

    If all of those fields plus long names would exceed one CLI reply, the numbered form automatically switches to a non-truncating compact spelling. The compact aliases are also accepted by set: c= means channel=, p= means prefix=, i=*|n|s|a|u|s:<scope>|r:<region> means the corresponding in= condition, q=N means rate=N/min, pri=N means priority, and f=str combines slow timing (s), temporary-radio suspension (t), and retry allowance (r). Packet type is shown numerically in that fallback. Normal-sized rows keep the descriptive spelling above.

    On generalized repeaters, filter rows, scope-rewrite rows, the shared blacklist, and flood.channel.data compatibility state are committed in one atomic FPF7 image. Compact FPF6 profiles retain separate files. Replacing or deleting the blacklist does not delete rows containing path=blacklist; such rows remain dormant while the list is empty. Path hashes are truncated routing identifiers, not authenticated identities, so this is a forwarding signal rather than proof that a particular repeater handled a packet.

    A common use is containment of bulk internet-to-mesh dumping: list the path IDs associated with the offending gateways, then add a broad type=any hops=all path=blacklist drop row. This prevents this repeater from retransmitting matching floods; it does not delete them from local logs or prove who originated them.

    The unnumbered blacklist set replaces the whole list and accepts up to 18 IDs so it fits every CLI transport. Numbered set writes up to 18 consecutive entries beginning at an existing slot or exactly the next slot, allowing an ESP32 list to grow to 255 entries in batches. Numbered deletion compacts subsequent slots. The unnumbered get reports the total and as many leading IDs as fit in one reply; use numbered get to inspect entries beyond that reply.

    Standard traceroute is direct-routed and therefore outside flood.filter entirely. A custom flood-form trace participates normally: type=any, explicit trace, rewrite, rate, drop, and stop rows can all apply.

    Examples:

    set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86\nset flood.rule.3 type=grp_data channel=#rgdata in=scope:usa scope=BlackHole86\nset flood.rule.4 type=any prefix=860C rate=10/min\nset flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop\nget flood.rule.2\nset flood.filter grp_data 4+\nset flood.filter.2 PAYLOAD_TYPE_ADVERT 6+\nset flood.filter ota 2-4\nset flood.filter.1 0x0C all suspend=tempradio\nset flood.filter grp_data all suspend=tempradio\nset flood.filter grp_txt all scope=local\nset flood.filter grp_data all scope=local require=region\nset flood.filter grp_data all path=blacklist scope=local tx=slow\nset flood.filter.blacklist A1B2C3,D4E5F6,112233\nset flood.filter.blacklist.4 445566\nset flood.filter.blacklist.19 778899,AABBCC,DDEEFF\nset flood.filter any all path=blacklist\nget flood.filter.blacklist\nget flood.filter.blacklist.4\nset flood.filter any 12+\nget flood.filter\nget flood.filter.2\ndel flood.filter.2\n

    The first rule authenticates #rgdata, requires more than three received hops, and adds #BlackHole86 only when no scope was present. The second rewrites the exact incoming #usa scope. The third demonstrates a two-byte pbyte source prefix and a global per-row rate cap. The fourth authenticates #rgdata at zero through two hops, applies no FPF7 action of its own, and stops lower-order FPF7 forward rows; hard gates and the rewrite/moderation phases still apply.

    The fixed 240 KB STM32WL profiles leave MESH_ENABLE_FLOOD_RULE_ENGINE=0 and retain the compact, persistent FPF6 flood.filter and blacklist commands. They still filter floods, but do not expose the flood.rule alias or its extended channel, prefix, input-scope, region-action, or rate fields. No partition size changes are required.

    "},{"location":"cli_commands/#moderate-flood-group-text-by-channel-sender-and-source-path","title":"Moderate flood group text by channel, sender, and source path","text":"

    Usage: - get flood.moderation - get flood.moderation.<n> - set flood.moderation <channel> <sender> <action> [action...] - set flood.moderation.<n> <channel> <sender> <action> [action...] - del flood.moderation.<n> - del flood.moderation all

    Parameters: - n: Moderation slot from 1 to 16. - channel: - public: Built-in Public channel. - #channel: Derive the well-known hashtag-channel key. - A 128-bit or 256-bit channel key in hex, for any other/private channel. - sender: Exact group-text display name. Matching is ASCII case-insensitive. Quote names containing spaces, for example \"Field User\". - drop: Do not forward matching messages. Equivalent to rate=0/min. - rate=X/min: Forward at most X matching messages per 60-second local window. This option requires an exact sender rather than *. - hops=N: Do not forward a matching message whose received flood path count is N or higher. hops=all removes this constraint. - path=H1[,H2,H3]: Match the start of the flood path. One to three hashes are accepted; every hash must have the same 1-, 2-, or 3-byte width. - path=*: Match any source path (the default).

    At least one of drop, rate=X/min, or hops=N is required. Rate and hop limits can be combined. Rate counters are local to this repeater and rule, use a 60-second window beginning with the first matching message, and reset on reboot.

    Decode and identity behavior: Moderation applies only to flood PAYLOAD_TYPE_GRP_TXT. The repeater first checks the packet's channel-hash byte, then validates and decrypts with the configured key. It extracts the text before the first : from the standard <sender>: <message> plaintext. The channel key is stored locally but is never printed by get.

    The group-text sender is an unverified display name, not a public key. It can be spoofed. Combining it with the first one to three path hashes makes a more useful moderation signal, but path hashes are truncated and are not proof of the originating user. A path-qualified rule begins matching only after the packet contains all configured starting hops; it cannot identify a first hop on a zero-hop packet.

    As with general filtering, matching messages are still received/logged; only retransmission is denied. There are no moderation rules by default.

    Examples:

    set flood.moderation public \"Noisy User\" rate=5/min\nset flood.moderation #local bot drop path=A1B2C3,D4E5F6\nset flood.moderation.3 00112233445566778899AABBCCDDEEFF alice rate=10/min hops=4 path=71CE82\nget flood.moderation\nget flood.moderation.3\ndel flood.moderation.3\n
    "},{"location":"cli_commands/#acl","title":"ACL","text":""},{"location":"cli_commands/#add-update-or-remove-permissions-for-a-companion","title":"Add, update or remove permissions for a companion","text":"

    Usage: - setperm <pubkey> <permissions>

    Parameters: - pubkey: Companion public key - permissions: - 0: Guest - 1: Read-only - 2: Read-write - 3: Admin - 4: Region/scope manager (repeater delegated region and forced-scope management) - 5: Filter manager (repeater delegated forwarding-filter management)

    Filter manager scope: Permission 5 can use an explicit allowlist of non-secret operational/filter status commands and can change the forwarding controls repeat, loop.detect, flood.max*, flood.channel.data*, flood.filter*, flood.rule*, and flood.moderation*. It cannot read guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions, ACL entries, radio settings, or other admin configuration. Permission 4 is limited to region commands, flood.channel.scope*, and the same non-secret status allowlist. Both delegated manager roles are protected from least-recently-active ACL eviction like administrators.

    Note: Removes the entry when permissions is omitted

    "},{"location":"cli_commands/#view-the-current-acl","title":"View the current ACL","text":"

    Usage:

    Available on repeaters, room servers, and sensors. Over LoRa, an authenticated admin session is required; guests, read-only/read-write clients, and delegated region/filter managers cannot read the ACL.

    Each page starts with ACL <page>/<pages> and contains up to two entries. Each entry is a two-digit hexadecimal permission byte followed by the full 64-character public key. For example, use get acl 2 for the second page. An empty list returns ACL: empty. Deleted/guest entries with permission byte 00 are omitted, matching the local listing. Pages reflect the current live ACL, so entries can move between pages if permissions or clients change.

    Bare get acl keeps the full streamed listing on USB and direct network CLI, or via Companion binary command 0x42 where the role supports the command. The listing contains public keys and permissions, not passwords or shared secrets, and does not change the ACL.

    "},{"location":"cli_commands/#recover-a-repeaters-future-dated-replay-timestamp","title":"Recover a repeater's future-dated replay timestamp","text":"

    This is an explicit recovery operation after correcting a bad clock, not a contact deletion. It lowers selected replay timestamps to the repeater's current UTC epoch only when they are later than that epoch. Earlier values, public keys, permissions, stored paths, and historical identity records remain. The replay file is committed before the live table changes. No extra 60-second login reservation is added by this command.

    First set/synchronize and verify the repeater's clock (clock); a build-default clock without a manual or observed synchronization is not accepted. Also fix the companion's clock before its next login.

    USB console:

    replay reset <full-64-hex-public-key>\nreplay reset all CONFIRM\n

    Authenticated LoRa admin, including a resumed admin session:

    replay reset <full-64-hex-public-key>\n

    The reply shows now=<epoch>, ttl=<remaining-seconds>s, and a confirmation command containing the same full key and a one-use 32-hex-character token. Verify the displayed time and send that command before the original 300-second deadline. During the first 120 seconds, repeated requests for the same key by the same admin return the same token without restarting either timer. From 120 through 300 seconds, the token is retained for confirmation only: requests do not resend or replace it. This leaves at least 180 seconds to deliver a confirmation after the last permitted token response is generated (radio transit time still counts toward expiry). At 300 seconds it expires and a new request can receive a new token. Confirmation can succeed immediately; there is no requirement to wait for the resend window to close. The displayed TTL decreases on retries and is measured when the reply is generated, not when it reaches the companion.

    It can target the caller's own key or another exact key; prefixes, self, wildcards, and all are not allowed over LoRa. Tokens are bound to both the requesting admin and target, expire on reboot, and are invalid after use or a clock correction outside the five-second confirmation tolerance. A failed write requires a new confirmation. Challenge requests consult live token state instead of replaying cached challenge text; completed confirmation results remain cacheable without executing the reset again. Normal packet freshness checks still apply to retries.

    Only the physical serial console grants all access. Ethernet, browser and internal command callbacks do not count as USB. Guest, read-only, region-manager and filter-manager roles cannot reset replay state. This command is implemented in repeater firmware; room-server and sensor CLI are unchanged.

    Normal login and command admission checks remain in force: a fully locked-out caller that cannot send an accepted admin command needs another working admin or USB access. A corrupt/unreadable replay file fails closed and is not erased or formatted by this operation. Unknown keys do not create records.

    Security trade-off: lowering a replay boundary can admit previously captured future-dated login/command packets above the new boundary, including packets that could raise it again. The one-use token prevents the recovery command itself from being repeatedly executed; it does not replace the protocol's timestamp-based replay protection. Use recovery only after verifying clocks. Setting a clock alone never automatically resets this table.

    "},{"location":"cli_commands/#view-or-change-this-room-servers-read-only-flag","title":"View or change this room server's 'read-only' flag","text":"

    Usage: - get allow.read.only - set allow.read.only <state>

    Parameters: - state: on (enable) or off (disable)

    Default: off

    "},{"location":"cli_commands/#region-management-v110","title":"Region Management (v1.10.+)","text":""},{"location":"cli_commands/#bulk-load-region-lists","title":"Bulk-load region lists","text":"

    Usage: - region load - region load <name> [flood_flag]

    Parameters: - name: A name of a region. * represents the wildcard region

    Note: flood_flag: Optional F to allow flooding

    Note: Indentation creates parent-child relationships (max 8 levels)

    Note: region load with an empty name will not work remotely (it's interactive)

    "},{"location":"cli_commands/#save-any-changes-to-regions-made-since-reboot","title":"Save any changes to regions made since reboot","text":"

    Usage: - region save

    "},{"location":"cli_commands/#allow-a-region","title":"Allow a region","text":"

    Usage: - region allowf <name>

    Parameters: - name: Region name (or * for wildcard)

    Note: Setting on wildcard * allows packets without region transport codes

    "},{"location":"cli_commands/#block-a-region","title":"Block a region","text":"

    Usage: - region denyf <name>

    Parameters: - name: Region name (or * for wildcard)

    Note: Setting on wildcard * drops packets without region transport codes

    "},{"location":"cli_commands/#show-information-for-a-region","title":"Show information for a region","text":"

    Usage: - region get <name>

    Parameters: - name: Region name (or * for wildcard)

    "},{"location":"cli_commands/#view-or-change-the-home-region-for-this-node","title":"View or change the home region for this node","text":"

    Usage: - region home - region home <name>

    Parameters: - name: Region name

    "},{"location":"cli_commands/#view-or-change-the-default-scope-region-for-this-node","title":"View or change the default scope region for this node","text":"

    Usage: - region default - region default {name|<null>}

    Parameters: - name: Region name, or to reset/clear"},{"location":"cli_commands/#create-a-new-region","title":"Create a new region","text":"

    Usage: - region put <name> [parent_name]

    Parameters: - name: Region name - parent_name: Parent region name (optional, defaults to wildcard)

    Note: In firmware v1.15.0 and later, region put enables flooding for that region by default (you do not need a separate region allowf <name> after each put). On v1.14.0 and earlier, new regions may still require region allowf for flooding-see region allowf.

    "},{"location":"cli_commands/#define-region-hierarchy-single-line","title":"Define region hierarchy (single line)","text":"

    Usage: - region def <token> [<token> ...]

    Parameters (tokens): Space-separated. A logical cursor starts at the wildcard *.

    Behavior: Each created region defaults to flood-allowed (same as region put). The reply is the resulting region tree (same format as bare region); review it before running region save to persist. The command is transactional: invalid names, unknown or ambiguous jumps, table overflow, and hierarchy cycles return Err - ... without changing the existing tree.

    Existing regions: region def does not clear the existing tree - if a name already exists, its parent is updated to the current cursor; otherwise a new region is created. To start from scratch, region remove the unwanted regions first.

    Limits: Repeater serial accepts one line up to 160 characters. For larger trees, split across multiple region def commands; the cursor resets to * between commands, so lead the next command with child|ancestor to reposition. Each token splits at most once on | - region def a|b|c|d is not a flat-list shorthand; see the flat-list example below.

    Example - linear chain (each token becomes a child of the previous):

    region def a b c d e\nregion save\n

    Example - branched tree (equivalent to region put a, region put b a, region put c b, region put d c, region put e b, region put f e):

    region def a b c d|b e f\nregion save\n

    Example - transactional error:

    region def a b c|nope d\n

    The reply is Err - unknown or ambiguous jump: nope. The existing tree is unchanged; re-run with a corrected jump.

    Example - flat list (each region a child of *). Use |* after each token to pop the cursor back to the root before the next token:

    region def a|* b|* c|* d|* e|* f\nregion save\n
    "},{"location":"cli_commands/#remove-a-region","title":"Remove a region","text":"

    Usage: - region remove <name>

    Parameters: - name: Region name

    Note: Must remove all child regions before the region can be removed

    "},{"location":"cli_commands/#view-all-regions","title":"View all regions","text":"

    Usage: - region list <filter>

    Serial Only: Yes

    Parameters: - filter: allowed|denied

    Note: Requires firmware 1.12+

    "},{"location":"cli_commands/#dump-all-defined-regions-and-flood-permissions","title":"Dump all defined regions and flood permissions","text":"

    Usage: - region

    Serial Only: For firmware older than 1.12.0

    "},{"location":"cli_commands/#region-examples","title":"Region Examples","text":"

    Example 1: Using F Flag with Named Public Region

    region load\n#Europe F\n<blank line to end region load>\nregion save\n

    Explanation: - Creates a region named #Europe with flooding enabled - Packets from this region will be flooded to other nodes

    Example 2: Using Wildcard with F Flag

    region load \n* F\n<blank line to end region load>\nregion save\n

    Explanation: - Creates a wildcard region * with flooding enabled - Enables flooding for all regions automatically - Applies only to packets without transport codes

    Example 3: Using Wildcard Without F Flag

    region load \n*\n<blank line to end region load>\nregion save\n

    Explanation: - Creates a wildcard region * without flooding - This region exists but doesn't affect packet distribution - Used as a default/empty region

    Example 4: Nested Public Region with F Flag

    region load \n#Europe F\n  #UK\n    #London\n    #Manchester\n  #France\n    #Paris\n    #Lyon\n<blank line to end region load>\nregion save\n

    Explanation: - Creates #Europe region with flooding enabled - Adds nested child regions (#UK, #France) - All nested regions inherit the flooding flag from parent

    Example 5: Wildcard with Nested Public Regions

    region load \n* F\n  #NorthAmerica\n    #USA\n      #NewYork\n      #California\n    #Canada\n      #Ontario\n      #Quebec\n<blank line to end region load>\nregion save\n

    Explanation: - Creates wildcard region * with flooding enabled - Adds nested #NorthAmerica hierarchy - Enables flooding for all child regions automatically - Useful for global networks with specific regional rules

    "},{"location":"cli_commands/#direct-retry","title":"Direct Retry","text":"

    Direct retry resends direct-routed packets when the downstream echo is not heard. It applies to direct messages, ACK packets, multipart packets carrying ACK payloads, and TRACE packets.

    The shared state, count, base, and step controls work on repeater, room-server, and sensor firmware. Recent-repeater/SNR controls are repeater-only because the other roles do not keep the repeater reachability table they require.

    "},{"location":"cli_commands/#view-or-change-direct-retry-state","title":"View or change direct retry state","text":"

    Search terms: enable tx retries, disable tx retries, stop direct retries, turn off retransmissions.

    Usage: - get direct.retry - set direct.retry <state>

    Parameters: - state: on|off

    Default: on

    Notes: - New installs and older preference files without direct retry settings default to on with the rooftop preset.

    Examples:

    get direct.retry\nset direct.retry on\nset direct.retry off\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-heard-table-gate","title":"View or change direct retry heard-table gate","text":"

    Usage: - get direct.retry.heard - set direct.retry.heard <state>

    Parameters: - state: on|off

    Default: on

    Note: This command is repeater-only. When enabled, the recent repeater table is the direct retry eligibility gate. Prefixes missing from the table are assumed reachable; prefixes in the table below the active SNR gate are blocked.

    Examples:

    get direct.retry.heard\nset direct.retry.heard on\nset direct.retry.heard off\n
    "},{"location":"cli_commands/#view-or-apply-a-retry-preset","title":"View or apply a retry preset","text":"

    Search terms: retry profile, retry defaults, rooftop retries, mobile retries, infrastructure retries.

    Usage: - get retry.preset - set retry.preset <preset>

    Parameters: - preset: infra|rooftop|mobile

    Notes: - Applies shared direct retry and flood retry defaults. - infra: fewer, slower retries for stable fixed infrastructure. - rooftop: default long retry window for weak rooftop links. - mobile: long retry count with shorter spacing for moving or changing links; flood retry count is 15. - Changing direct.retry.count, direct.retry.base, direct.retry.step, direct.retry.margin, flood.retry.count, flood.retry.path, or flood.retry.group.path makes the preset report as custom.

    Examples:

    get retry.preset\nset retry.preset infra\nset retry.preset rooftop\nset retry.preset mobile\n
    "},{"location":"cli_commands/#flood-retry","title":"Flood Retry","text":"

    Flood retry resends flood-routed packets when the same packet is not heard from another qualifying repeater.

    The count, path, group-data path, and advert controls work on repeater, room-server, and sensor firmware. Flood forwarding must also be enabled for retries to run. Prefix, ignore, bridge, and bucket controls are repeater-only.

    "},{"location":"cli_commands/#hop-zero-flood-retry-coding-rate","title":"Hop-zero flood retry coding rate","text":"

    Flood packets with no path hops recorded yet use the same CR escalation ladder as direct retries. The starting point is the active radio CR, since a flood has no single next-hop repeater whose SNR can select a starting CR. Each retry uses its attempt number and the active radio setting; it does not restart the ladder from the previous retry's override. Forwarded packets with one or more recorded hops use the active radio CR on every retry, including bridge retries whose effective path length discounts ignored hops.

    The initial transmission uses the active radio CR. This table lists retries after that initial transmission; any later retries remain at CR8:

    Starting CR Retry 1 Retry 2 Retry 3 Retry 4 Retry 5+ CR4 CR4 CR5 CR7 CR7 CR8 CR5 CR5 CR7 CR7 CR8 CR8 CR6 CR6 CR7 CR7 CR8 CR8 CR7 CR7 CR7 CR8 CR8 CR8 CR8 CR8 CR8 CR8 CR8 CR8

    The ladder is shared across presets. Presets change the retry budget, not the CR steps. At CR5, the maximum hop-zero schedules are:

    Preset Configured flood count Hop-zero retries CR schedule after the initial send infra 1 2 CR5, CR7 rooftop 3 6 CR5, CR7, CR7, CR8, CR8, CR8 mobile 15 15 CR5, CR7, CR7, then CR8 for retries 4-15

    These budgets apply to group text and the origin login/message family (RESPONSE, TXT_MSG, ANON_REQ, PATH). Existing payload caps, echo cancellation, and retry eligibility still apply: REQ and OTA do not use generic flood retries; other payloads allow at most one retry. An eligible advert at CR5 therefore gets only its first CR5 retry.

    set flood.retry.count 0 continues to disable all automatic flood retries, including hop zero. It leaves the initial transmission and all direct-retry settings and CR schedules unchanged. Direct retry's direct.retry.cr switch and SNR thresholds continue to control direct retries only. Per-packet CR overrides are restored after transmission and do not change the saved radio setting or physical preamble.

    "},{"location":"cli_commands/#view-or-change-flood-retry-count","title":"View or change flood retry count","text":"

    Search terms: flood tx retries, flood retry attempts, flood retransmissions, broadcast retries.

    Usage: - get flood.retry.count - set flood.retry.count <count>

    Parameters: - count: Base retry attempts after the original send, from 0 to 15. 0 disables flood retry.

    Note: The role first calculates its retry count: path count 0 uses count * 2, path count 1 uses count * 1.5 rounded up, and path count 2 and higher uses the configured base count, with a hard cap of 15. A shared payload policy then applies to every build: REQ never retries; GRP_TXT keeps the role-calculated count; remote-login-critical RESPONSE, TXT_MSG, ANON_REQ, and PATH packets keep up to 15 at the originating node (path count 0) and cap at 2 after entering the path; all other flood payload types cap at 1. These caps never raise a lower role-calculated count. Setting count to 0 immediately removes queued and future flood retries; a packet already transmitting is allowed to finish.

    Forwarded neighbor adverts have an additional loop guard independent of the advert retry setting. After this node completes an advert transmission and hears a downstream copy with a longer path, it does not forward that same advert again while the advert's signed timestamp is less than six hours old. Self-originated adverts, adverts without a heard echo, and adverts six hours old or older are unaffected.

    An enabled self-originated advert retry waits at least one additional minute beyond the normal airtime-aware retry delay. Once a newer self advert has successfully entered the outbound queue, its retry sequence replaces queued or future retries for older self adverts; an older advert already transmitting is allowed to finish. Companion firmware permits this single slow retry for its own adverts while continuing to block retry attempts for neighbor adverts it forwards.

    Defaults: - infra: 1 - rooftop: 3 - mobile: 15

    Examples:

    get flood.retry.count\nset flood.retry.count 0\nset flood.retry.count 15\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-path-gate","title":"View or change flood retry path gate","text":"

    Usage: - get flood.retry.path - set flood.retry.path <count|off>

    Parameters: - count: Maximum flood path hash count eligible for retry, from 0 to 63. - off: Disable the path-length gate.

    Defaults: - infra: 1 - rooftop: 2 - mobile: 1

    Examples:

    get flood.retry.path\nset flood.retry.path 1\nset flood.retry.path off\n
    "},{"location":"cli_commands/#view-or-change-the-group-data-flood-retry-path-gate","title":"View or change the group-data flood retry path gate","text":"

    Usage: - get flood.retry.group.path - set flood.retry.group.path <count|off>

    Parameters: - count: Maximum flood path hash count eligible for retry for group data packets (PAYLOAD_TYPE_GRP_DATA/type 6), from 0 to 63. - off: Disable only the group-data-specific gate. The general flood.retry.path gate still applies.

    Default: 1 for infra, rooftop, and mobile presets.

    Note: The stricter of flood.retry.path and flood.retry.group.path is used. A value of 1 allows retry sequences at path counts 0 and 1; group data at path count 2 or higher is still forwarded normally but does not start a flood retry sequence. A value of 0 allows retries only at the originating sender.

    Setting flood.retry.path to 0 also sets flood.retry.group.path to off because the general zero-hop gate is already stricter. While the general gate remains 0, attempts to set the group-data gate keep it off. Applying a named retry preset restores the group-data default of 1.

    Examples:

    get flood.retry.group.path\nset flood.retry.group.path 1\nset flood.retry.group.path off\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-advert-handling","title":"View or change flood retry advert handling","text":"

    Usage: - get flood.retry.advert - set flood.retry.advert <on|off>

    Parameters: - on: Retry node advert floods. - off: Do not retry node advert floods.

    Default: off

    Examples:

    get flood.retry.advert\nset flood.retry.advert off\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-target-prefixes","title":"View or change flood retry target prefixes","text":"

    Usage: - get flood.retry.prefixes - set flood.retry.prefixes <prefixes|none|off>

    Parameters: - prefixes: Comma-separated 3-byte path hash prefixes, up to 8 entries. - none or off: Clear the list.

    Note: When set, non-bridge flood retry only accepts same-packet echoes whose last hop matches one of these prefixes. When unset, any non-ignored last hop can cancel the retry.

    Examples:

    get flood.retry.prefixes\nset flood.retry.prefixes A58296,860CCA,425E5C\nset flood.retry.prefixes none\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-ignored-prefixes","title":"View or change flood retry ignored prefixes","text":"

    Usage: - get flood.retry.ignore - set flood.retry.ignore <prefixes|none|off>

    Parameters: - prefixes: Comma-separated 3-byte path hash prefixes, up to 8 entries. - none or off: Clear the list.

    Note: Non-bridge flood retry does not cancel on same-packet echoes whose last hop matches this list. Bridge mode also excludes these prefixes from bucket and other hits.

    Examples:

    get flood.retry.ignore\nset flood.retry.ignore 71CE82,C7618C\nset flood.retry.ignore none\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-bridge-mode","title":"View or change flood retry bridge mode","text":"

    Usage: - get flood.retry.bridge - set flood.retry.bridge <on|off>

    Note: Bridge mode retries until each configured fresh bucket, plus the non-source other bucket, has been heard or the retry count is exhausted. If prefixes in different buckets share their first byte, configuration commands return a warning because a 1-byte path cannot distinguish those buckets. The configuration remains valid: an ambiguous source is treated as belonging to every matching source bucket, and an ambiguous echo credits every matching target bucket so it cannot force retry exhaustion.

    Flood retry timing retains its fixed maximum-frame plus 20 packet-airtime wait, then adds a random 0-200% of one additional packet airtime on every attempt. This de-synchronizes repeaters that may have missed the same echo while capping the added wait at two frames.

    Only one active retry sequence is kept for a given logical flood packet. An identical flood can still transmit normally, but it does not create a second sequence of extra attempts. Retry state is released if a queued packet is evicted, and the final echo window retains metadata without reserving a packet-pool entry.

    Bridge reachability learned from earlier hops in a successful echo is cached separately from recent.repeater. Only the final RF hop updates recent.repeater and its SNR, so indirect path entries cannot affect direct-retry SNR gating or coding-rate selection.

    Examples:

    get flood.retry.bridge\nset flood.retry.bridge on\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-bridge-buckets","title":"View or change flood retry bridge buckets","text":"

    Usage: - get flood.retry.bucket.<n> - set flood.retry.bucket <n> <prefixes|none|off>

    Parameters: - n: Bucket number from 1 to 6. - prefixes: Comma-separated 3-byte path hash prefixes, up to 17 entries per bucket. - none or off: Clear the bucket.

    Examples:

    get flood.retry.bucket.1\nset flood.retry.bucket 1 71CE82,C7618C\nset flood.retry.bucket 2 none\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-count","title":"View or change direct retry count","text":"

    Search terms: tx retries, transmit retries, direct retries, retry count, retry attempts, retransmission count, message retries, DM retries.

    Usage: - get direct.retry.count - set direct.retry.count <count>

    Parameters: - count: Maximum retry attempts after the original send, from 1 to 15.

    Default: 15 with the rooftop preset

    Note: This setting limits retries for eligible direct-routed packets such as traces, requests, responses, and ACKs. Direct-routed text messages (type 2) are an exception: they allow up to 21 retries after the original send, regardless of direct.retry.count or the repeater short-path cap. For example, set direct.retry.count 1 limits eligible non-text packets to one retry, but does not reduce the text-message limit.

    These are maximums, not a fixed number of transmissions. Retries stop early when the node hears the next hop forward the packet. The special final-hop retry sends only one duplicate because the destination does not forward the packet. Repeater non-text retries are also capped at 8 for a retry path of up to 3 hops, 12 for 4 hops, and 15 for longer paths; these caps never increase a lower configured count.

    Use set direct.retry off to disable this node's direct retries, including text-message retries, and set direct.retry on to enable them again. A count of 0 is not supported. Sending apps and other nodes may have their own retry behavior; this setting does not change it.

    Examples:

    get direct.retry.count\nset direct.retry.count 1\nset direct.retry.count 4\nset direct.retry.count 15\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-base-delay","title":"View or change direct retry base delay","text":"

    Search terms: retry delay, retry timeout, retransmission timeout, wait between retries.

    Usage: - get direct.retry.base - set direct.retry.base <ms>

    Parameters: - ms: First retry wait in milliseconds, from 10 to 5000.

    Default: 175 with the rooftop preset

    Explanation: - The first retry waits for base + packet-length add-on + random forwarding jitter after the preceding transmission completes. - TRACE and ANON_REQ/type 7 packets use a 3x line-time add-on. TXT_MSG/type 2 packets use 7x. Other direct retry packets use 6x. - Room-server and sensor firmware use this configured base with the same packet-type add-ons. - Larger values reduce channel pressure and give slow repeaters more time. - Smaller values recover faster but create tighter retry bursts.

    Examples:

    get direct.retry.base\nset direct.retry.base 175\nset direct.retry.base 275\nset direct.retry.base 500\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-step-delay","title":"View or change direct retry step delay","text":"

    Search terms: retry backoff, increasing retry delay, retry interval step.

    Usage: - get direct.retry.step - set direct.retry.step <ms>

    Parameters: - ms: Extra milliseconds added for each subsequent retry, from 0 to 5000.

    Default: 100 with the rooftop preset

    Explanation: - Retry delay is base + packet-length add-on + random forwarding jitter + attempt_index * step. - TRACE and ANON_REQ/type 7 packets use a 3x packet-length add-on. TXT_MSG/type 2 packets use 7x. Other direct retry packets use 6x. - Room-server and sensor firmware use this configured step with the same packet-type add-ons. - With base=175 and step=100, the fixed portion is 175, 275, 375, 475 ms, and so on, before the packet-length add-on and random jitter. - step=0 keeps every retry at the same delay. - Larger steps spread retries over time and are safer on busy channels.

    Examples:

    get direct.retry.step\nset direct.retry.step 0\nset direct.retry.step 50\nset direct.retry.step 100\nset direct.retry.step 250\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-snr-margin","title":"View or change direct retry SNR margin","text":"

    Search terms: retry signal threshold, retry SNR threshold, retry signal margin.

    Usage: - get direct.retry.margin - set direct.retry.margin <snr_db>

    Parameters: - snr_db: Extra SNR margin above the SF receive floor, from 0 to 40.

    Default: 5.00 with the rooftop preset

    Notes: - This command is repeater-only. - Unknown repeaters are still retried. - Known repeaters below the receive floor plus this margin are skipped. - Failed attempts lower the recent repeater SNR estimate by 0.25 dB.

    Examples:

    get direct.retry.margin\nset direct.retry.margin 0\nset direct.retry.margin 2.5\nset direct.retry.margin 5\nset direct.retry.margin 10\n
    "},{"location":"cli_commands/#view-or-change-adaptive-direct-retry-coding-rate","title":"View or change adaptive direct retry coding rate","text":"

    Usage: - get direct.retry.cr - set direct.retry.cr off - set direct.retry.cr on (room-server and sensor) - set direct.retry.cr <cr4_min>,<cr5_min>,<cr7_min>,<cr8_max>

    Parameters: - cr4_min: Minimum SNR in dB to retry at CR4. - cr5_min: Minimum SNR in dB to retry at CR5. - cr7_min: Minimum SNR in dB to retry at CR7. - cr8_max: Maximum SNR in dB that forces CR8.

    Default: 10.00,7.50,2.50,2.50

    Explanation: - Higher SNR uses faster coding rates. - Lower SNR uses more robust coding rates. - Repeater retry attempts escalate from the adaptive starting CR. CR4 starts as CR4, CR5, CR7, CR7, then CR8. CR5 starts as CR5, CR7, CR7, then CR8. CR7 gets two attempts, then CR8. - Repeater adaptive CR selection intentionally skips CR6. - Non-repeater retry packets start at the current radio CR and follow the same escalation pattern, clamped at CR8. With the normal CR5 radio setting this is CR5, CR7, CR7, then CR8. - Room-server and sensor firmware accept on or off; numeric SNR thresholds are repeater-only because those roles do not keep recent-repeater SNR data. - off disables per-packet direct-retry CR overrides and uses the current radio CR. - Retry packets may use a different coding rate, but they keep the radio's normal physical preamble. - Unknown repeaters start at +3.00 dB for adaptive CR selection. - A failed unknown repeater is seeded at +2.75 dB. - Each later failure lowers the SNR estimate by 0.25 dB.

    Examples:

    get direct.retry.cr\nset direct.retry.cr off\nset direct.retry.cr on\nset direct.retry.cr 10.0,7.5,2.5,2.5\nset direct.retry.cr 12.0,8.0,4.0,1.0\nset direct.retry.cr 8.0,5.0,1.5,0\nset direct.retry.cr 6.0,3.0,0,-2.0\nset direct.retry.cr 20.0,12.0,6.0,2.0\nset direct.retry.cr 4.0,2.0,0,-4.0\n

    Example profiles: - Conservative weak-link profile:

    set direct.retry.cr 12.0,8.0,4.0,1.0\n
    set direct.retry.cr 10.0,7.5,2.5,2.5\n
    set direct.retry.cr 6.0,3.0,0,-2.0\n
    set direct.retry.cr 20.0,12.0,6.0,2.0\n
    "},{"location":"cli_commands/#view-seed-or-clear-the-recent-repeater-table","title":"View, seed, or clear the recent repeater table","text":"

    Usage: - get recent.repeater - get recent.repeater <page> - get recent.repeaters <page> - get recent.repeaters search <prefix> [page] - set recent.repeater <prefix> [snr_db] - clear recent.repeater

    Parameters: - prefix: Repeater path-hash prefix as 2, 4, or 6 hex characters. - snr_db: Optional SNR in dB. If omitted or invalid, defaults to 3.0. - page: 1-based result page.

    Note: These commands are repeater-only.

    The default capacity is 256 entries on classic ESP32, 2,048 on other ESP32 chips, 512 on nRF52, and 64 on other platforms. Builds can override it with MAX_RECENT_REPEATERS. Classic ESP32's history uses 3,072 bytes of startup heap instead of 24,576 bytes, leaving more memory for the packet pool and Wi-Fi.

    Output order: - get recent.repeater lists 3-byte prefixes first, then 2-byte prefixes, then 1-byte prefixes. - Within each prefix length, entries are sorted from highest SNR to lowest SNR. - search returns every overlapping path-hash entry. For example, searching 860C can return 86, 860C, and 860CCA; it does not return a different branch such as 86D0. - Search rows include the monotonic age of the entry's most recent recording, compacted to a whole s, m, or h field. Search pages contain up to six rows so the result remains within the remote CLI reply limit.

    SNR details: - Recent repeater SNR is stored internally in quarter-dB units. - Heard repeater samples update an existing table entry with a weighted blend: 75% existing SNR and 25% new heard SNR, rounded up. - Direct retry success also feeds the heard echo SNR back into the same weighted table. - Direct retry failure is not weighted: each final echo-timeout failure lowers that repeater's SNR by 0.25 dB. - Unknown repeaters start at +3.00 dB for adaptive CR selection. - If an unknown repeater fails, it is seeded into the table at +2.75 dB. - set recent.repeater <prefix> [snr_db] seeds a missing prefix or adds another weighted sample for an existing prefix. - Successful set recent.repeater replies include the stored prefix and SNR, for example OK - set A1B2C3 at 3.0 SNR. - Entries strictly older than 24 hours are removed during a sweep every three hours, so an entry can remain for at most approximately 27 hours.

    Examples:

    get recent.repeater\nget recent.repeater 2\nget recent.repeaters search 86\nget recent.repeaters search 860C page 2\nset recent.repeater A1B2C3 8.5\nset recent.repeater 71CE82 -3.25\nset recent.repeater A1B2C3\nclear recent.repeater\n
    "},{"location":"cli_commands/#gps-when-gps-support-is-compiled-in","title":"GPS (When GPS support is compiled in)","text":""},{"location":"cli_commands/#view-or-change-gps-state","title":"View or change GPS state","text":"

    Search terms: enable GPS, disable GPS, turn GPS on, turn GPS off.

    Usage: - get gps - set gps <state>

    Parameters: - state: on|off

    Default: off

    Note: These command names are shared with GPS-capable Companion builds. Companion reports on or off; infrastructure includes receiver details: - off when the GPS hardware is disabled - on, {active|deactivated}, {fix|no fix}, {sat count} sats when the GPS hardware is enabled

    "},{"location":"cli_commands/#sync-this-nodes-clock-with-gps-time","title":"Sync this node's clock with GPS time","text":"

    Usage: - gps sync

    The GPS must be enabled. When GPS power saving has put an enabled receiver to sleep, this command schedules a sync and wakes it; after set gps off, it reports gps is off without scheduling work.

    "},{"location":"cli_commands/#set-this-nodes-location-based-on-the-gps-coordinates","title":"Set this node's location based on the GPS coordinates","text":"

    Usage: - gps setloc

    "},{"location":"cli_commands/#view-or-change-the-gps-advert-policy","title":"View or change the GPS advert policy","text":"

    Usage: - gps advert - gps advert <policy>

    Parameters: - policy: none|share|prefs - none: don't include location in adverts - share: share gps location (from SensorManager) - prefs: location stored in node's lat and lon settings

    Default: prefs on every repeater, room-server, and sensor build that uses the common advert policy. A previously saved none, share, or prefs choice still overrides the first-boot default after an update.

    "},{"location":"cli_commands/#sensors-when-sensor-support-is-compiled-in","title":"Sensors (When sensor support is compiled in)","text":""},{"location":"cli_commands/#view-or-change-telemetry-access-mode","title":"View or change telemetry access mode","text":"

    Usage: - get telemetry.access - set telemetry.access <mode>

    Parameters: - mode: all|acl - all: allow telemetry requests using the requester-provided telemetry mask - acl: require ACL read-only or higher for telemetry, including GPS

    Default: all

    Note: all matches the previous sensor telemetry behavior.

    "},{"location":"cli_commands/#view-the-list-of-sensors-on-this-node","title":"View the list of sensors on this node","text":"

    Usage: sensor list [start]

    Parameters: - start: Optional starting index (defaults to 0)

    Note: Output format: <var_name>=<value>\\n

    "},{"location":"cli_commands/#view-or-change-the-value-of-a-sensor","title":"View or change the value of a sensor","text":"

    Usage: - sensor get <key> - sensor set <key> <value>

    Parameters: - key: Sensor setting name - value: The value to set the sensor to

    "},{"location":"cli_commands/#bridge-when-bridge-support-is-compiled-in","title":"Bridge (When bridge support is compiled in)","text":""},{"location":"cli_commands/#view-the-compiled-bridge-type","title":"View the compiled bridge type","text":"

    Usage: get bridge.type

    "},{"location":"cli_commands/#view-or-change-the-bridge-enabled-flag","title":"View or change the bridge enabled flag","text":"

    Search terms: enable bridge, disable bridge, serial bridge, RS232 bridge.

    Usage: - get bridge.enabled - get bridge.running - set bridge.enabled <state>

    Parameters: - state: on|off

    bridge.enabled is the saved intent. bridge.running reports whether the bridge actually started in this boot; they can differ after a hardware conflict, missing credentials, or a transient initialization failure. Normal merged repeater images default to off; dedicated bridge images may default to on.

    "},{"location":"cli_commands/#add-a-delay-to-packets-routed-through-this-bridge","title":"Add a delay to packets routed through this bridge","text":"

    Usage: - get bridge.delay - set bridge.delay <ms>

    Parameters: - ms: Delay in milliseconds (0-10000)

    Default: 500

    "},{"location":"cli_commands/#view-or-change-the-source-of-packets-bridged-to-the-external-interface","title":"View or change the source of packets bridged to the external interface","text":"

    Usage: - get bridge.source - set bridge.source <source>

    Parameters: - source: - rx: bridges received packets - tx: bridges transmitted packets

    Default: tx

    Note: For MQTT, use mqtt.rx and mqtt.tx to control each direction independently. Both can be enabled together. Use mqtt.enabled as the MQTT master switch; bridge.* configures RS232 and ESP-NOW bridges.

    "},{"location":"cli_commands/#view-or-change-the-mqtt-master-switch","title":"View or change the MQTT master switch","text":"

    Search terms: enable MQTT, disable MQTT, MQTT on, MQTT off, WiFi logging.

    Usage:

    These commands are the same on MQTT-capable Companion, Repeater, Room Server, and Sensor builds. The master switch is saved; turning it off disconnects MQTT while keeping broker presets and credentials. Turning it on allows those brokers to reconnect. get mqtt.running checks the service's runtime state; get mqtt.status reports individual broker connections. Enabling MQTT does not imply that a broker is connected.

    USB logging remains independent. On images with both outputs, set logging.output off|usb|wifi|both selects USB and MQTT together.

    "},{"location":"cli_commands/#view-or-change-mqtt-rx-packet-uplinking","title":"View or change MQTT RX packet uplinking","text":"

    Search terms: MQTT receive logging, MQTT RX capture, publish received packets.

    Usage: - get mqtt.rx - set mqtt.rx <on|off>

    Parameters: - on: uplink received (RX) packets to MQTT brokers - off: disable RX packet uplinking

    Default: on

    "},{"location":"cli_commands/#view-or-change-mqtt-tx-packet-uplinking","title":"View or change MQTT TX packet uplinking","text":"

    Search terms: MQTT transmit logging, MQTT TX capture, publish sent packets.

    Usage: - get mqtt.tx - set mqtt.tx <on|off|advert>

    Parameters: - on: uplink all transmitted (TX) packets to MQTT brokers - advert: uplink only this node's own advert packets (self-originated advertisements only - forwarded adverts from other nodes are filtered out) - off: disable TX packet uplinking

    Default: advert

    Note: mqtt.rx and mqtt.tx take effect immediately - no restart required. Both can be enabled simultaneously.

    "},{"location":"cli_commands/#view-or-change-periodic-neighbors-publishing-mqtt-observer-neighbors-feature","title":"View or change periodic neighbors publishing (MQTT observer, neighbors feature)","text":"

    Usage: - get mqtt.neighbors - set mqtt.neighbors <on|off>

    Parameters: - on: periodically discover neighbor scopes and publish the neighbor table to the neighbors topic - off: disable periodic neighbors publishing

    Default: off

    Note: Requires a build with the neighbors feature compiled in (all PSRAM boards, plus non-PSRAM boards built with MQTT_NEIGHBORS_WITHOUT_PSRAM); elsewhere this replies Err - neighbors not enabled in this build. Non-PSRAM builds publish at most 20 neighbours per pass to bound internal-DRAM use, and set truncated with the true total_neighbors when the table is larger. The setting is read live by the mesh loop - no restart required; enabling it triggers a discovery on the next pass. While enabled, get mqtt.status gains a trailing nbr: <next>/<last> field (time to next publish, and how the last publish went).

    "},{"location":"cli_commands/#view-or-change-the-neighbors-publish-interval-mqtt-observer-neighbors-feature","title":"View or change the neighbors publish interval (MQTT observer, neighbors feature)","text":"

    Usage: - get mqtt.neighbors.interval - set mqtt.neighbors.interval <hours>

    Parameters: - hours: how often to publish the neighbor table (12-336, default 24)

    Default: 24 (hours)

    Note: Out-of-range values are rejected (not clamped). Requires a build with WITH_MQTT_NEIGHBORS; PSRAM boards enable it automatically and selected non-PSRAM variants opt in with MQTT_NEIGHBORS_WITHOUT_PSRAM.

    "},{"location":"cli_commands/#view-or-change-the-ntp-server-mqtt-observer-only","title":"View or change the NTP server (MQTT observer only)","text":"

    Usage: - get mqtt.ntp - set mqtt.ntp <hostname> - set mqtt.ntp none

    Description: Sets the primary NTP server used for clock sync (required for JWT MQTT auth). On set, the device attempts an immediate sync of the just-configured server (primary only, so a typo fails fast) when WiFi is connected and the MQTT bridge is running.

    Fallbacks: If the primary fails, the firmware tries pool.ntp.org, time.google.com, time.cloudflare.com, time.aws.com, and time.nist.gov in order (skipping duplicates).

    Default: pool.ntp.org (when unset or none)

    "},{"location":"cli_commands/#diagnose-ntp-server-connectivity-mqtt-observer-only","title":"Diagnose NTP server connectivity (MQTT observer only)","text":"

    Usage: - get mqtt.ntp.diag

    Description: Probes every configured NTP server (the custom primary, if set, plus the built-in fallbacks) and reports whether each responds. This is a pure connectivity diagnostic - it does not change the system clock.

    Requires WiFi connected and the MQTT bridge running.

    "},{"location":"cli_commands/#view-or-change-the-speed-of-the-bridge-rs-232-only","title":"View or change the speed of the bridge (RS-232 only)","text":"

    Usage: - get bridge.baud - set bridge.baud <rate>

    Parameters: - rate: Integer baud rate from 9600 through the board's compiled BRIDGE_MAX_BAUD (commonly 500000); for example 115200. Stop the bridge with set bridge.enabled off before changing it, then enable it again.

    Default: 115200

    "},{"location":"cli_commands/#view-or-change-the-uart-used-by-the-bridge-rs-232-only","title":"View or change the UART used by the bridge (RS-232 only)","text":"

    Usage: - get bridge.uart - set bridge.uart <port>

    Parameters: - port: Hardware UART number compiled for the board. Most boards expose one fixed UART. RAK4631 accepts 1 or 2; UART 2 is the default so UART 1 can remain available to the RAK12501/L76K GPS. RAK12500 GPS uses I2C rather than this UART, but the explicit legacy Serial1 bridge omits the combined GPS provider and therefore does not expose either GPS path.

    The setting is persistent and restarts an enabled bridge immediately. Normal repeater artifacts start with bridge.enabled off; configure the UART and baud rate before running set bridge.enabled on. On the canonical RAK4631 runtime image, UART 1 is reserved even if the bounded boot probe hears no RAK12501. Silence cannot prove that a cold L76K is physically absent, and that module remains powered by the shared WB_IO2/3V3_S rail. Use UART 2. UART 1 requires an explicit no-GPS/dedicated Serial1 bridge image. This fail-closed reservation also applies when the detected GPS is an I2C RAK12500; that receiver does not electrically use UART 1, but its presence cannot rule out another silent UART module.

    "},{"location":"cli_commands/#view-or-change-the-channel-used-for-bridging-espnow-only","title":"View or change the channel used for bridging (ESPNow only)","text":"

    Usage: - get bridge.channel - set bridge.channel <channel>

    Parameters: - channel: Channel number from 1 through 13 in either format. This matches the primary-ESP-NOW policy and the default regulatory range used by the supported ESP32 targets.

    This controls the optional ESP-NOW bridge transport; it does not change a node's primary ESP-NOW mesh channel. Primary-ESP-NOW firmware uses get espnow.channel and set espnow.channel <1-13> instead.

    "},{"location":"cli_commands/#view-or-change-the-esp-now-bridge-wire-format","title":"View or change the ESP-NOW bridge wire format","text":"

    Usage: - get bridge.format - set bridge.format <format>

    Parameters: - format: - wrapped: the original ESP-NOW bridge framing (magic, checksum, and XOR using bridge.secret) - raw: the exact serialized MeshCore packet used by primary ESPNOWRadio firmware

    Default: wrapped

    The setting is persistent and restarts an enabled bridge immediately. Receive parsing is strict: wrapped accepts only wrapped frames and raw accepts only raw MeshCore frames. Coordinate the format and channel on every ESP-NOW peer; there is no automatic dual-format receive mode because it would permit ambiguous, asymmetric bridge deployments.

    Use raw to connect a LoRa-primary *_repeater_bridge_espnow node to Generic_ESPNOW, SenseCapIndicator-ESPNow, or another primary-ESP-NOW node. Raw mode enables the ESP-NOW LR PHY and sends at the same LR rate used by those targets. All nodes must use the same 1-13 channel. On a primary-ESP-NOW node, change that side with set espnow.channel <1-13> and reboot; on the bridge, use set bridge.channel <1-13>.

    Raw mode ignores bridge.secret and removes the bridge wrapper's lightweight network isolation. MeshCore's own packet authentication/encryption still applies where the packet type provides it, but public frames and routing metadata remain visible. The wrapped format's XOR is isolation, not strong cryptography. Bridge duplicate tracking remains active in both formats, but deploying multiple gateways between the same LoRa and ESP-NOW coverage areas can still increase duplicate traffic while their seen-packet tables converge.

    One ESP-NOW frame carries at most 250 payload bytes. In raw mode, serialized MeshCore packets up to that size remain byte-for-byte compatible with existing raw endpoints. Updated raw endpoints split 251-255-byte transport units into two versioned fragments and reassemble them by source MAC, length, and CRC; both endpoints must include this support for those sizes. Current valid MeshCore packet geometry reaches 254 bytes, while 255 is retained as transport headroom. Wrapped mode retains its legacy 246-byte maximum after the four-byte magic and checksum overhead, and drops larger packets instead of truncating them.

    "},{"location":"cli_commands/#set-the-esp-now-secret","title":"Set the ESP-Now secret","text":"

    Usage: - get bridge.secret - set bridge.secret <secret>

    Parameters: - secret: ESP-NOW bridge secret, 1-15 characters

    Default: Varies by board

    This setting is used only by bridge.format wrapped; raw mode ignores it.

    "},{"location":"cli_commands/#view-the-bootloader-version-nrf52-only","title":"View the bootloader version (nRF52 only)","text":"

    Usage: get bootloader.ver

    Applies to every nRF52 board/role that exposes this getter, including Companion, repeater, and room-server firmware. It reads the installed bootloader region selected by the MBR/UICR boot address and bounded by the chip's flash geometry; it does not search application firmware or staged update files.

    The reader preserves a complete Adafruit/UF2 version string, including OTAFIX, preview, and vendor/build suffixes. If that string is absent or ambiguous, it uses the existing whole-image CRC and unique BLMF/BLM2 validation to read the embedded OTAFIX version (for example, > OTAFIX2.4.6). Some current release images, including MeshTower V2 SD 2.4.6, omit the UF2 text entirely. As a last fallback, the Adafruit core's startup-captured version is explicitly labelled (base); it cannot identify an OTAFIX/fork revision.

    unknown remains possible for a bootloader exposing none of these version sources, invalid geometry/vectors, or unresolved ambiguity. There is no universal version field shared by every third-party bootloader. This is diagnostic only: displaying a version neither grants OTA capability nor bypasses install checks. The fix is in MeshCore application firmware; installing another bootloader alone does not update an older application's version reader.

    "},{"location":"cli_commands/#view-power-management-support","title":"View power management support","text":"

    Usage: get pwrmgt.support

    "},{"location":"cli_commands/#view-the-current-power-source","title":"View the current power source","text":"

    Usage: get pwrmgt.source

    Note: Returns an error on boards without power management support.

    "},{"location":"cli_commands/#view-the-boot-reset-and-shutdown-reasons","title":"View the boot reset and shutdown reasons","text":"

    Usage: get pwrmgt.bootreason

    Note: Returns an error on boards without power management support.

    "},{"location":"cli_commands/#view-the-boot-voltage","title":"View the boot voltage","text":"

    Usage: get pwrmgt.bootmv

    Note: Returns an error on boards without power management support.

    "},{"location":"cli_commands/#ethernet-when-ethernet-support-is-compiled-in","title":"Ethernet (when Ethernet support is compiled in)","text":"

    Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Ethernet module. Use the _ethernet firmware variants (e.g. RAK_4631_repeater_ethernet) to enable this feature.

    "},{"location":"cli_commands/#view-ethernet-connection-status","title":"View Ethernet connection status","text":"

    Usage: - eth.status

    Output: - ETH: <ip>:<port> when connected (e.g. ETH: 192.168.1.50:23) - ETH: not connected when Ethernet is not active

    Notes: - Available on repeater and room server firmware only. Companion radio ethernet firmware does not expose a CLI. - The Ethernet interface obtains an IP address via DHCP automatically on boot. - A TCP server listens on port 23 (default) for CLI connections. - Connect with any TCP client (e.g. nc, PuTTY) to access the same CLI available over serial.

    "},{"location":"companion_contact_cache/","title":"Full Companion contact caches","text":"

    Full Companions on ESP32 and nRF52 without PSRAM now keep 16 outgoing paths and 16 shared secrets in RAM. Every contact remains in the contact table; selecting a different contact loads its saved path as needed. This recovers about 25.4 KiB of internal RAM in the 350-contact ESP32 qualification builds.

    See the qualification results for the six-board RAM comparison, physical tests, and shared-secret timings.

    PSRAM boards retain their existing complete contact table in external RAM. Legacy USB/BLE/WiFi Companion profiles also retain their inline paths and secrets. The build flag MESH_CONTACT_CACHE=0 or 1 overrides this policy for qualification; it is not a runtime feature switch.

    "},{"location":"companion_contact_cache/#paths-and-persistence","title":"Paths and persistence","text":"

    The 16 resident paths use least-recently-used replacement. Contact records keep small handles to their saved paths, including 1-, 2-, and 3-byte hop hashes. The existing 152-byte contact record and Companion app frame formats are unchanged. ESP32 uses /contacts3; nRF52 also supports its paged contact store and migration from /contacts3. An erase is not required.

    A hot path needs no flash read. A cold path is read from its existing contact record. ESP32 reuses one open contact-file reader to avoid repeated SPIFFS metadata scans during synchronization and saving. It closes that reader before replacing the file or recovering a saved transaction.

    Changing a copied contact preserves the old path for a pending app response or rollback. Dirty entries are saved before eviction. If storage cannot be read, written, or represented completely, the operation fails instead of silently losing a contact or sending along a different route. Reboot retries an incomplete load from the preserved files. The path checksum also rejects a cold path changed underneath a live handle; it does not add a new checksum to the legacy ESP32 on-disk format.

    "},{"location":"companion_contact_cache/#shared-secrets-and-power","title":"Shared secrets and power","text":"

    Both platforms reuse the 16 most recent secrets from RAM. Cache misses use a platform-specific policy:

    Platform Miss behavior ESP32 Recalculate the secret without reading or writing a secret file. nRF52 Try a saved LittleFS entry, then recalculate if it is unavailable.

    On the V4, a measured SPIFFS secret lookup took 132,427 microseconds while key exchange took 21,932 microseconds. ESP32 therefore uses recalculation on a RAM-cache miss. It avoids secret-file writes and reduces time awake for this measured workload. These are elapsed times, not measurements of energy in joules; other ESP32 hardware has not been physically benchmarked.

    On a RAK3401, a LittleFS lookup took 7,812 microseconds versus 31,250 microseconds for key exchange. The nRF52 backend therefore uses saved flash entries to avoid repeat key exchange when a usable entry exists, including after reboot. Saved entries match the full peer public key and a fingerprint of the local key pair, and have a record checksum. Importing a different private or public key invalidates old results. A corrupt or missing saved entry is recalculated. The native tests use the firmware's actual Ed25519 key-exchange library and verify agreement with the peer's independently calculated secret.

    Derived secrets are expendable. Their storage keeps a reserve for contacts, preferences and filesystem metadata; a full filesystem leaves the calculated secret usable in RAM. nRF52 packs 56 entries into a 3,844-byte page to use its 4 KiB flash blocks efficiently. Small filesystems may not have space to persist secrets for all 350 contacts. A miss without a usable saved entry still performs key exchange.

    MESH_CONTACT_SECRET_FLASH_CACHE=0 or 1 overrides the miss policy for qualification builds. The optional ESP32 flash backend groups eight entries per file. The normal ESP32 policy does not create or use those files.

    "},{"location":"companion_contact_cache/#inspect-the-cache","title":"Inspect the cache","text":"

    From the Full Companion's ASCII USB terminal or authenticated local CLI:

    get contact.cache\n

    Cached builds report paths=16 secrets=16, miss=calculate or miss=flash, plus these boot-session counters:

    Field Meaning ram_hits Shared secret reused from RAM. flash_hits Shared secret loaded from flash without recalculation. calculations Shared-secret key exchanges performed. save_skips Calculated secrets that could not be persisted, including storage reserve/backoff.

    Uncached builds report paths=inline secrets=inline. The normal memory command on supported ESP32 Full Companions shows live heap and queue capacity.

    get contact.cache.timing reports the last successful flash lookup and last key calculation in microseconds. Zero means no such operation has completed since boot. These timings exclude secret writes and are useful for comparing the work avoided on a valid flash hit.

    "},{"location":"companion_contact_cache/#offline-messages-during-mota","title":"Offline messages during mOTA","text":"

    Contact caches are independent of the offline message queue. Builds using the shared mOTA queue retain 256 frames normally and 128 while mOTA owns its workspace, then restore 256. No unread frames are discarded to start mOTA: synchronize the queue and retry if it exceeds the retained capacity. PSRAM-backed queues keep their existing capacity and allocation policy.

    Each queue frame occupies 177 bytes. Retaining 32 instead of 128 would make another 16,992 bytes (16.6 KiB) available inside the shared storage. It would not increase free heap by itself: the normal 256-frame storage is statically reserved, and the current mOTA context already fits in the loaned half. More mOTA scratch data would have to share that space to obtain an additional RAM saving. These changes keep the existing 128-frame mOTA limit.

    "},{"location":"companion_contact_cache/#build-the-six-capacity-trials","title":"Build the six capacity trials","text":"

    The optional environments in platformio.nimble.ini combine NimBLE, these contact caches, and the shared mOTA queue at 350 contacts, 40 channels and 256 normal offline frames. They remain outside the ordinary release matrix. The ordinary six constrained release recipes still use their established 150-contact limits pending wider hardware qualification.

    Enable platformio.nimble.ini as described in the NimBLE trial guide, then build one environment at a time. For example:

    OUTPUT_DIR=.releases/contact-cache-v3 bash build.sh build-firmware \\\n  Heltec_v3_ram_trial_companion_radio_full_nimble \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-cache-trial \\\n  --radio-preset usa-cascadia --profile cascade --standard --require-ota\n
    Hardware Trial environment Heltec V3 Heltec_v3_ram_trial_companion_radio_full_nimble Wireless Tracker Heltec_Wireless_Tracker_ram_trial_companion_radio_full_nimble Tracker V2, FEM on heltec_tracker_v2_ram_trial_companion_radio_full_femon_nimble Heltec CT62 Heltec_ct62_ram_trial_companion_radio_full_nimble XIAO ESP32-C3 Xiao_C3_ram_trial_companion_radio_full_nimble Generic ESP-NOW Generic_ESPNOW_ram_trial_companion_radio_full_nimble

    The firmware RAM guard remains enabled. A successful linked budget is distinct from live free heap and physical Bluetooth/LoRa qualification on each board.

    The smaller NimBLE applications let the CT62, XIAO C3 and Generic ESP-NOW trials use two 1.5 MiB WiFi OTA slots on 4 MiB flash. Their 896 KiB SPIFFS and coredump stay at the same addresses as the ordinary huge_app.csv Full layout. Install the trial's merged image over USB first to install its partition table. Subsequent WiFi updates must fit the new slots. An application-only update from the ordinary single-slot Full layout cannot change that layout or enable WiFi OTA.

    "},{"location":"companion_contact_cache/#regression-checks","title":"Regression checks","text":"
    python3 -B test/test_contact_cache.py\npython3 -B test/test_companion_contact_persistence_contract.py\npython3 -B test/test_companion_contact_stream_contract.py\npython3 -B test/test_nrf52_extrafs_contract.py\npython3 -B test/test_shared_mota_queue.py\npython3 -B test/test_t096_full_memory.py\npython3 -B test/test_firmware_ram.py\n

    The cache tests compile the production path/secret cache code and extracted production persistence, routing and request functions with filesystem/radio adapters. They exercise 350 routes, eviction, snapshot rollback, page migration, identity changes, read/write/rename faults, handle exhaustion, packet release on a failed path read, and recovery after a simulated reset between file renames. They do not simulate a physical flash power cut.

    tools/hil/contact_cache_serial_stress.py checks all 350 paths over USB, replaces them with a different generation, and optionally exercises secret eviction. Use an otherwise empty test node and exclusive access to its Companion data port. --populate writes test contacts; --secrets transmits 23 directed LoRa datagrams to synthetic peers. For an ESP32 cache trial:

    python3 tools/hil/contact_cache_serial_stress.py --port /dev/ttyACM0 \\\n  --populate --generation 1 --secrets --expect-calculate --reboot\npython3 tools/hil/contact_cache_serial_stress.py --port /dev/ttyACM0 \\\n  --generation 1 --secrets --expect-calculate\n

    Use the actual device path. Omit --expect-calculate for the nRF52 flash policy. The second invocation verifies the persisted paths after reboot.

    "},{"location":"companion_contact_cache_results/","title":"Contact-cache and NimBLE RAM qualification","text":"

    Test date: 2026-09-08. Firmware source: b68aa1c6.

    All six constrained ESP32 Full Companion trials pass the linked RAM budget at 350 contacts, 40 channels and 256 normal offline frames. Their final capability manifests confirm compiled Bluetooth/mOTA support and valid WiFi OTA layouts. These are the optional environments in the contact-cache guide. Ordinary release recipes retain their established contact limits pending qualification on those boards.

    "},{"location":"companion_contact_cache_results/#linked-internal-ram","title":"Linked internal RAM","text":"

    Every column below uses 350 contacts, 40 channels and 256 normal offline frames. Values are bytes available for runtime allocations after linking. The final margin subtracts the existing conservative runtime reservation; it is not a measurement of live free heap.

    Hardware Bluedroid NimBLE NimBLE + mOTA queue loan Plus contact caches Final margin Heltec V3 143,776 158,896 170,152 196,168 23,112 Wireless Tracker 148,432 163,536 174,792 200,824 30,838 Tracker V2, FEM on 133,064 148,168 159,416 185,456 15,470 Heltec CT62 99,040 114,096 125,312 151,344 15,152 XIAO ESP32-C3 98,816 113,888 125,120 151,152 14,960 Generic ESP-NOW 99,776 114,800 126,032 152,064 15,872

    NimBLE recovers about 14.7 KiB. Contact caches add about 25.4 KiB on top of NimBLE and the shared mOTA workspace. NimBLE alone fails the unchanged budget at this capacity on all six; adding the queue loan passes only on the original Wireless Tracker. Adding the contact caches passes all six, with 14.6-30.1 KiB of margin. The same Bluetooth runtime allowance is used in every comparison.

    The shared mOTA queue stays at 256 frames normally and retains 128 while mOTA borrows its workspace, then returns to 256. A queue above 128 must be synchronized before that workspace can be borrowed; unread frames are not discarded to start mOTA. PSRAM-backed queues retain their existing policy.

    The baseline builds use b041fc7f; the final builds use b68aa1c6, with the same ESP32 Arduino 2.0.17 toolchain and NimBLE-Arduino 2.5.1 where applicable.

    The CT62, XIAO C3 and Generic ESP-NOW applications fit two 1.5 MiB OTA slots on 4 MiB flash. Their explicit trial layouts retain 896 KiB SPIFFS and the existing SPIFFS/coredump addresses. Install the merged image over USB when changing from the ordinary single-slot Full layout. The other three trials also retain verified dual WiFi OTA slots. All six remain mOTA senders.

    Additional builds check the platform and default-policy branches:

    Build Available bytes Reserved runtime bytes Margin bytes Policy T096 Full, FEM on 100,076 73,728 26,348 nRF52 path/secret caches RAK3401 Full 99,900 50,976 48,924 nRF52 path/secret caches Wireless Paper Full 187,360 152,576 34,784 ESP32 caches, existing Bluedroid V4 Full NimBLE 263,944 173,056 90,888 Complete contacts in PSRAM XIAO nRF52 legacy BLE Companion 84,368 38,688 45,680 Existing inline contact storage

    The V4 test-only forced-cache build also passes its RAM budget. The V4 normally keeps complete contacts in PSRAM; forcing caches adds internal RAM overhead. The six-board savings above apply to boards without PSRAM.

    "},{"location":"companion_contact_cache_results/#physical-checks","title":"Physical checks","text":"

    The VM V4 used the test-only cache override. USB tests verified all 350 contacts and all 64 saved path bytes per contact, cold individual lookups, replacement of the complete path set, and persistence after reboot. A secret workload contacted 21 distinct synthetic peers, then the first peer twice: 22 calculations, one RAM hit, and no secret-file access under the ESP32 policy.

    A sustained send test exposed a false receive watchdog recovery. RX could occur inside the main loop between two successful transmissions, while the watchdog observed TX at both loop boundaries. It counted from the start of the whole burst and could reset a working radio during a later CAD pause. 468cc3cf restarts the receive recovery allowance after each successful TX. The native regression fails before that change and passes afterward; it also checks that a stuck receiver still gets recovered.

    The corrected V4 passed the same 23-datagram workload with zero radio error flags. It also passed a complete 350-path replacement, another send workload and a post-reboot path check with zero radio error flags. The replacement took about 127 seconds: dirty-cache eviction still performs synchronous SPIFFS contact-file saves, so bulk path updates remain a latency workload to qualify on the other ESP32 boards. After that run it reported 133,828 bytes free internal heap, a minimum of 130,976 bytes, and a largest free block of 122,868 bytes. These readings belong to the forced-cache V4 workload, not the six other boards.

    After qualification, the VM V4 was erased to remove synthetic contacts and installed with the normal PSRAM-backed V4 NimBLE trial from b68aa1c6. It is named NimBLE-V4-VM, uses the PIN shown on its display, and has powersaving off for USB use. That clean setup reported 143,612 bytes of free internal heap and passed 201 local USB requests with zero radio error flags. The Mercer V4 and XIAO S3 were left on their previously qualified NimBLE builds.

    The Mercer RAK3401 Full Companion verified 350 paths, eviction and persistence. Its first secret workload produced 21 calculations, one flash hit and one RAM hit with no skipped saves. After reboot, the same workload produced 22 flash hits, one RAM hit and zero calculations. Both completed with zero radio error flags. The final b68aa1c6 RAK image repeated the 350-path and saved-secret checks with zero radio errors and zero calculations. The RAK was returned to the published 26303793 repeater application with its original repeater name and USA Cascade settings; the Pi's MQTT logger was restarted after the exclusive USB tests.

    "},{"location":"companion_contact_cache_results/#shared-secret-timing","title":"Shared-secret timing","text":"Hardware/filesystem Successful saved-secret lookup Key exchange Selected miss policy V4 / SPIFFS 132,427 us 21,932 us Recalculate RAK3401 / LittleFS 7,812-8,789 us 31,250 us Read saved entry, calculate on miss

    The SPIFFS timing comes from the earlier forced flash-cache experiment. The final ESP32 policy does no secret-file I/O. Both platforms reuse the 16 most recent secrets from RAM. These are elapsed-time measurements, not energy measurements; timing on other ESP32/nRF52 boards remains to be measured.

    "},{"location":"companion_contact_cache_results/#regression-checks-and-evidence","title":"Regression checks and evidence","text":"

    The contact-cache suite exercises the production cache and persistence code under address/undefined-behavior sanitizers, with the real Ed25519 key-exchange implementation. Its cases include 350 paths, identity changes, snapshots, legacy migration, storage faults, handle exhaustion and packet release when a cold path cannot be read. The related contract suites passed 62 checks. The native queue/store suites passed 27 cases, the radio-liveness suite passed eight, and the partition-selection suite passed 11.

    Hardware reproduction commands are in the contact-cache guide. The tracked harness is tools/hil/contact_cache_serial_stress.py. It requires exclusive access to the specified Companion data port.

    Local evidence in the qualification workspace:

    Each final RAM report is checked against its ELF hash. All 12 final builds pass their RAM and OTA capability checks. Physical testing here covers the V4 and RAK3401; the six constrained ESP32 boards still need pairing, WiFi/BLE/LoRa coexistence and sustained-load testing on their own hardware. No GitHub release assets or firmware-picker entries were replaced by these experimental builds.

    "},{"location":"companion_offline_queue/","title":"Companion Offline Message Queue","text":"

    Companion firmware keeps received channel data, channel messages, and direct messages in one pending queue until a Companion client requests them with the sync-next-message command. This is volatile RAM, not message history in flash. A reboot clears it.

    "},{"location":"companion_offline_queue/#default-capacities","title":"Default capacities","text":"Platform or memory profile Pending frames ESP32 with configured PSRAM 512 ESP32 without PSRAM 256 nRF52840 256 nRF52 Full Companion with the memory correction 256 normally; 128 while mOTA owns shared storage RP2040 256 STM32 16 Known constrained classic ESP32 target override 128 Meshadventurer Full Companion 16 Constrained Full ESP32 fallback 16

    An explicit target OFFLINE_QUEUE_SIZE overrides the platform default. The Heltec V2 and TLora V2 Full Companion profiles, for example, use 16 frames so their combined WiFi, BLE, and LoRa mOTA image retains enough internal DRAM. Meshadventurer SX1262 and SX1268 Full Companion use 16 frames together with 100 contacts and 30 group channels; their ordinary transport-specific images keep 128 frames and 40 channels.

    The nRF52 Full memory correction applies queue sharing to every nRF52 Full Companion. It keeps 256 frames normally, retaining each board's contacts, channels, and all Full transports. The upper 128 slots temporarily hold the mOTA context when a source or TempRadio discovery session starts. Stopping or disconnecting the USB/Bluetooth source returns all 256 slots; a discovery-only session returns them when TempRadio ends. This shares a fixed memory region and avoids heap fragmentation from resizing.

    Existing unread messages retain their order. If more than 128 frames are pending, mOTA refuses the loan and asks you to sync messages with a Companion app first. While the loan is active, the overflow policy below applies at 128 frames. The original 26303793 1.17.1.5 builds reserve the queue and mOTA state separately. Sharing recovers about 19 KiB on nRF52 Full. The ST7735 color-display Full profiles require at least 72 KiB of heap space at link time; OLED and headless profiles use their own memory budgets.

    Standard, logging, MQTT, and Cascade build overlays retain the selected target capacity; they do not silently shrink the queue.

    Each queue slot currently costs 177 bytes. A 256-frame queue reserves 45,312 bytes, while a 512-frame queue reserves 90,624 bytes. There is no 256-frame protocol limit: the queue length and indexes can represent 512 or more. The practical limit is available RAM and the heap and stack headroom required by the transports and display.

    On ESP32 boards marked with BOARD_HAS_PSRAM, the queue is allocated from PSRAM before WiFi and BLE start. A failed 512-frame allocation retries at 256, then 128, and finally uses a 16-frame internal fallback. Full Companion prints the capacity actually allocated in its startup memory line as offline_queue=<frames>.

    "},{"location":"companion_offline_queue/#full-queue-behavior","title":"Full queue behavior","text":"

    The capacity is shared across Public, other channels, channel data, and direct messages. It is not a per-channel count. When the queue is full, firmware replaces the oldest queued channel frame so newer traffic can still arrive. If the full queue contains only direct messages, a new frame is dropped rather than deleting a direct message.

    Queue order is preserved. Removal uses a ring index, so delivering one pending message no longer copies every remaining frame; only the less-common removal of an old channel frame from a full queue may shift entries.

    Capacity is selected when firmware is compiled. There is no CLI or Companion protocol setting to resize it at runtime.

    "},{"location":"companion_protocol/","title":"Companion Protocol","text":"

    The command and response catalogs track examples/companion_radio/MyMesh.cpp. Applications should negotiate the protocol and validate lengths because older firmware exposes a subset.

    This document is a practical guide to MeshCore's binary companion protocol. The same protocol frames can be carried by the enabled BLE, USB serial, Wi-Fi, or Ethernet companion interface; connection details differ by build.

    On builds exposing more than one transport, delivery-required replies follow the interface which supplied the command. The multi-frame contact-list response holds that route until END_OF_CONTACTS; best-effort asynchronous observations may still be broadcast to enabled clients. Treat the device as one Companion session rather than as independent per-transport sessions.

    The examples focus on BLE, but the packet formats are transport-independent.

    "},{"location":"companion_protocol/#official-libraries","title":"Official Libraries","text":"

    Please see the following repos for existing MeshCore Companion Protocol libraries.

    "},{"location":"companion_protocol/#important-security-note","title":"Important Security Note","text":"

    All secrets, hashes, and cryptographic values shown in this guide are example values only.

    "},{"location":"companion_protocol/#table-of-contents","title":"Table of Contents","text":"
    1. BLE Connection
    2. Packet Structure
    3. Commands
    4. Channel Management
    5. Message Handling
    6. Response Parsing
    7. Example Implementation Flow
    8. Best Practices
    9. Troubleshooting
    "},{"location":"companion_protocol/#ble-connection","title":"BLE Connection","text":""},{"location":"companion_protocol/#service-and-characteristics","title":"Service and Characteristics","text":"

    MeshCore Companion devices expose a BLE service with the following UUIDs:

    An nRF52 Full Companion also exposes a separate LoRa mOTA source service. It does not replace or multiplex the normal Companion UART service:

    All three mOTA attributes require an encrypted, MITM-authenticated connection using the Companion's six-digit PIN. The source remains inactive until the client subscribes to Device Request and explicitly starts it with command 0x4B. See Bluetooth LoRa mOTA source.

    ESP32 and nRF52 Companion UART characteristics require the same PIN-protected, MITM-authenticated link. ESP32 advertises DisplayOnly capability so a central must enter the PIN shown by the Companion; a Just Works bond is insufficient.

    "},{"location":"companion_protocol/#connection-steps","title":"Connection Steps","text":"
    1. Scan for Devices

      • Scan for BLE devices advertising the MeshCore Service UUID
      • Optionally filter by device name (typically contains \"MeshCore\" prefix)
      • Note the device MAC address for reconnection
    2. Connect to GATT

      • Connect to the device using the discovered MAC address
      • Wait for connection to be established
    3. Discover Services and Characteristics

      • Discover the service with UUID 6E400001-B5A3-F393-E0A9-E50E24DCCA9E
      • Discover the RX characteristic 6E400002-B5A3-F393-E0A9-E50E24DCCA9E
        • Your app writes to this, the firmware reads from this
      • Discover the TX characteristic 6E400003-B5A3-F393-E0A9-E50E24DCCA9E
        • The firmware writes to this, your app reads from this
    4. Enable Notifications

      • Subscribe to notifications on the TX characteristic to receive data from the firmware
    5. Send Initial Commands

      • Send CMD_APP_START to identify your app to firmware and get radio settings
      • Send CMD_DEVICE_QUERY to fetch device info and negotiate supported protocol versions
      • Send CMD_SET_DEVICE_TIME to set the firmware clock
      • Send CMD_GET_CONTACTS to fetch all contacts
      • Send CMD_GET_CHANNEL multiple times to fetch all channel slots
      • Send CMD_SYNC_NEXT_MESSAGE to fetch the next message stored in firmware
      • Setup listeners for push codes, such as PUSH_CODE_MSG_WAITING or PUSH_CODE_ADVERT
      • See Commands section for information on other commands

    Note: MeshCore devices may disconnect after periods of inactivity. Implement auto-reconnect logic with exponential backoff.

    "},{"location":"companion_protocol/#ble-write-type","title":"BLE Write Type","text":"

    When writing commands to the RX characteristic, specify the write type:

    Platform-specific:

    Recommendation: Use write with response for reliability.

    "},{"location":"companion_protocol/#mtu-maximum-transmission-unit","title":"MTU (Maximum Transmission Unit)","text":"

    The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like SET_CHANNEL (50 bytes), you may need to:

    1. Request Larger MTU: Request MTU of 512 bytes if supported
      • Android: gatt.requestMtu(512)
      • iOS: peripheral.maximumWriteValueLength(for:)
      • Python (bleak): MTU is negotiated automatically
    "},{"location":"companion_protocol/#command-sequencing","title":"Command Sequencing","text":"

    Critical: Commands must be sent in the correct sequence:

    1. After Connection:

      • Wait for BLE connection to be established
      • Wait for services/characteristics to be discovered
      • Wait for notifications to be enabled
      • Now you can safely send commands to the firmware
    2. Command-Response Matching:

      • Send one command at a time
      • Wait for a response before sending another command
      • Use a timeout (typically 5 seconds)
      • Match response to command by type (e.g: CMD_GET_CHANNEL -> RESP_CODE_CHANNEL_INFO)
    "},{"location":"companion_protocol/#command-queue-management","title":"Command Queue Management","text":"

    For reliable operation, implement a command queue.

    Queue Structure:

    Error Handling:

    "},{"location":"companion_protocol/#packet-structure","title":"Packet Structure","text":"

    The MeshCore protocol uses a binary format with the following structure:

    Most packets follow this format:

    [Packet Type (1 byte)] [Data (variable length)]\n

    The first byte indicates the packet type (see Response Parsing).

    "},{"location":"companion_protocol/#commands","title":"Commands","text":"

    The first byte selects the command. This is the current protocol-v14 command catalog; bytes 0x2C-0x31 are parked and 0x35 is unused.

    Byte Firmware name Purpose 0x01 CMD_APP_START Start an app session and request self information. 0x02 CMD_SEND_TXT_MSG Send text to a contact. 0x03 CMD_SEND_CHANNEL_TXT_MSG Send channel text. 0x04 CMD_GET_CONTACTS Enumerate contacts, optionally modified since a timestamp. 0x05 / 0x06 CMD_GET_DEVICE_TIME / CMD_SET_DEVICE_TIME Read or set the device clock. 0x07 / 0x08 CMD_SEND_SELF_ADVERT / CMD_SET_ADVERT_NAME Advertise self or change the advertised name. 0x09 CMD_ADD_UPDATE_CONTACT Add or update a contact. 0x0A CMD_SYNC_NEXT_MESSAGE Dequeue the next pending message. 0x0B / 0x0C CMD_SET_RADIO_PARAMS / CMD_SET_RADIO_TX_POWER Set radio parameters or transmit power. 0x0D CMD_RESET_PATH Reset a contact's learned path. 0x0E CMD_SET_ADVERT_LATLON Set advertised coordinates. 0x0F CMD_REMOVE_CONTACT Remove a contact. 0x10 / 0x11 / 0x12 CMD_SHARE_CONTACT / CMD_EXPORT_CONTACT / CMD_IMPORT_CONTACT Share, export, or import contact data. 0x13 CMD_REBOOT Reboot after the required confirmation body. 0x14 CMD_GET_BATT_AND_STORAGE Read battery and storage usage. 0x15 CMD_SET_TUNING_PARAMS Set tuning parameters. 0x16 CMD_DEVICE_QUERY Negotiate protocol support and read device information. 0x17 / 0x18 CMD_EXPORT_PRIVATE_KEY / CMD_IMPORT_PRIVATE_KEY Export or import identity key material when enabled. 0x19 CMD_SEND_RAW_DATA Send an application raw-data packet. 0x1A-0x1D CMD_SEND_LOGIN through CMD_LOGOUT Manage a server connection. 0x1E CMD_GET_CONTACT_BY_KEY Look up a contact by public-key prefix. 0x1F / 0x20 CMD_GET_CHANNEL / CMD_SET_CHANNEL Read or write a channel slot. 0x21-0x23 CMD_SIGN_START through CMD_SIGN_FINISH Stream data for identity signing. 0x24 CMD_SEND_TRACE_PATH Trace a direct route. 0x25 CMD_SET_DEVICE_PIN Set or clear the device PIN. 0x26 CMD_SET_OTHER_PARAMS Set telemetry, location, ACK, and related preferences. 0x27 CMD_SEND_TELEMETRY_REQ Send the legacy telemetry request. 0x28 / 0x29 CMD_GET_CUSTOM_VARS / CMD_SET_CUSTOM_VAR Read or set custom variables. 0x2A CMD_GET_ADVERT_PATH Read a cached advertisement path. 0x2B CMD_GET_TUNING_PARAMS Read tuning parameters. 0x32 CMD_SEND_BINARY_REQ Send an application binary request. 0x33 CMD_FACTORY_RESET Factory-reset after the required confirmation body. 0x34 CMD_SEND_PATH_DISCOVERY_REQ Request path discovery. 0x36 CMD_SET_FLOOD_SCOPE_KEY Select scoped or unscoped flood behavior. 0x37 CMD_SEND_CONTROL_DATA Send zero-hop control data. 0x38 CMD_GET_STATS Read core, radio, or packet statistics. 0x39 CMD_SEND_ANON_REQ Send an anonymous request. 0x3A / 0x3B CMD_SET_AUTOADD_CONFIG / CMD_GET_AUTOADD_CONFIG Write or read automatic-contact policy. 0x3C CMD_GET_ALLOWED_REPEAT_FREQ Read allowed client-repeat frequency ranges. 0x3D CMD_SET_PATH_HASH_MODE Set path-hash width mode. 0x3E CMD_SEND_CHANNEL_DATA Send a channel binary datagram. 0x3F / 0x40 CMD_SET_DEFAULT_FLOOD_SCOPE / CMD_GET_DEFAULT_FLOOD_SCOPE Write or read the default flood scope. 0x41 CMD_SEND_RAW_PACKET Queue a fully encoded raw mesh packet. 0x42 CMD_RUN_CLI_COMMAND Run a local CLI command (protocol v14+). 0x4A CMD_EXEC_LOCAL_OTA_CONTROL Run one bounded local TempRadio or OTA command when the Companion includes the OTA CLI. 0x4B CMD_BLE_MOTA_SOURCE Query, start, or stop an nRF52 Full Companion's Bluetooth-backed LoRa mOTA source. 0x78-0x7F Deprecated hardware-setting aliases Receive-only compatibility for clients shipped before command 0x42 became the canonical settings path.

    The sections below detail the most common frames. Refer to the source named above for command bodies that are not expanded here.

    CMD_RUN_CLI_COMMAND is followed by the local CLI text without a terminating NUL. The device returns RESP_CODE_CLI_REPLY (0x1D) followed by the reply text. This is separate from sending a remote on-air CLI command with CMD_SEND_TXT_MSG and TXT_TYPE_CLI_COMMAND. The body must contain at least one byte and must not contain an embedded NUL. An unknown command is returned as the normal CLI reply text Unknown command, not as an error frame. Clients may prefix the CLI text with any two-character correlation tag and | (for example, A7|get radio.rxgain); the reply preserves that prefix.

    Direct USB, BLE, WiFi TCP, and Ethernet clients can also use this command for stats-core, stats-radio, stats-radio-diag, stats-packets, erase, and get prv.key (when private key export is enabled). WiFi/MQTT builds accept get wifi.pwd, get mqttN.password, and get mqttN.token. get password reports that Companion has no admin password; infrastructure roles return their own admin password on a local CLI connection. These privileges do not apply to CLI commands relayed over LoRa. set freq <MHz> works through command 0x42 and authorized LoRa CLI, like set radio; it saves the frequency and requires reboot to apply. See local maintenance commands for role differences and the text-terminal equivalents.

    Full Companion clients can send version through this command to receive the untruncated build identity, for example Companion 1.17.1.5-... (protocol 14, build 31-Aug-2026). This deliberately supplements rather than changes the 20-byte legacy version field in RESP_CODE_DEVICE_INFO, so existing clients keep the same frame layout.

    Firmware from this fork predating the upstream 0x42 allocation used 0x42-0x49 for these eight settings. This firmware accepts those values as deprecated inbound aliases so existing clients continue to work. A one-byte 0x42 frame is the legacy FEM-gain GET; 0x42 followed by text is the official CMD_RUN_CLI_COMMAND. New clients should use CMD_RUN_CLI_COMMAND for all of these settings, rather than allocating additional command bytes. For example, send 0x42 followed by get radio.rxgain or set radio.rxgain on. The reply is RESP_CODE_CLI_REPLY followed by the normal CLI reply text.

    Two deprecated binary alias blocks remain receive-only for compatibility:

    Setting Original alias Later fork alias GET body/reply SET body/reply FEM receive gain 0x42 / 0x43 0x78 / 0x79 No body; OK, state One byte 0/1; OK Radio receive gain 0x44 / 0x45 0x7A / 0x7B No body; OK, state One byte 0/1; OK WiFi power save 0x46 / 0x47 0x7C / 0x7D No body; OK, mode One mode byte 0-2; OK Bluetooth name 0x48 / 0x49 0x7E / 0x7F No body; OK, custom, name Zero to 31 UTF-8 bytes; OK

    Each pair lists GET then SET. Here OK is RESP_CODE_OK; the remaining reply bytes have the same meanings as the CLI settings below. A bare 0x42 is the old FEM GET, while 0x42 plus at least one text byte is CMD_RUN_CLI_COMMAND. New clients must use the framed CLI form; these aliases exist only so deployed clients do not break after a firmware update.

    The equivalent framed CLI commands are:

    Setting Commands Radio receive gain get radio.rxgain; set radio.rxgain on|off FEM receive gain get radio.fem.rxgain; set radio.fem.rxgain on|off WiFi power save get wifi.powersave; set wifi.powersave none|min|max Bluetooth name get bluetooth.name; set bluetooth.name <name|default> Bluetooth address get bluetooth.mac; set bluetooth.mac <address|random|random-every-boot|random-after-connect|default> Bluetooth stealth get bluetooth.stealth; set bluetooth.stealth on|off

    The framed form works over the normal binary USB, BLE, or TCP transport and does not need the USB terminal-start token. Unsupported settings return the same explanatory text as the local CLI.

    WiFi power-save modes are:

    Value Mode 0 min - minimum modem sleep 1 none - no modem sleep 2 max - maximum modem sleep

    A Full Companion that runs BLE and infrastructure WiFi simultaneously rejects WiFi mode none because coexistence requires modem sleep. A Full Companion using ESP-NOW as its primary mesh radio also rejects max, because maximum modem sleep can miss broadcasts that the access point cannot buffer. If an older image saved a conflicting value, the effective mode is capped to and reported as min. Device power saving remains independent.

    The SenseCAP Indicator Full profiles run exactly one secondary wireless Companion transport per boot. Their active-mode constraints are:

    Indicator mode Accepted wifi.powersave values LoRa + infrastructure WiFi none, min, max LoRa + BLE min, max; infrastructure WiFi is not started ESP-NOW + infrastructure WiFi none, min; max conflicts with primary ESP-NOW ESP-NOW + BLE min; infrastructure WiFi is not started and primary ESP-NOW remains active

    The Bluetooth name can be configured over USB, BLE, or TCP. Use set bluetooth.name default to restore MeshCore-<advert name>; an empty CLI value is rejected. (clear is also accepted as an alias for default.) A custom name is limited to 31 valid UTF-8 bytes and takes effect after reboot.

    Bluetooth Companion builds also accept bluetooth.mac (ble.mac is an alias). A literal address must be a BLE random-static address; random generates and saves one, random-every-boot rotates it at each startup, and random-after-connect retains it through unused boots but rotates it on the first boot after an authenticated connection. default or clear restores the factory address. The change takes effect after reboot.

    bluetooth.stealth on|off (ble.stealth is an alias) is a separate flag, defaulting to off. It preserves the address policy and can be combined with custom, saved random, rotating random, or factory addresses. With stealth on, the node advertises normally until the first authenticated pairing, then permits only that bonded peer to reconnect. Address changes clear the saved peer and reopen pairing without disabling stealth. Rotating policies retain their normal rotation triggers. Repeating on does not clear an existing bond. Both flag changes require reboot. The old MAC value stealth is not accepted.

    BLE still transmits directed or allowlisted advertisements; stealth is not radio silence. These are application settings; an nRF52 OTAFIX bootloader continues to advertise its separate DFU identity.

    "},{"location":"companion_protocol/#bluetooth-lora-mota-source","title":"Bluetooth LoRa mOTA source","text":"

    Protocol v14 lets a phone control an install-capable Companion's own LoRa OTA session. An nRF52 Full Companion can additionally act as the source for a remote repeater update without a USB computer. The normal Companion service still carries contacts, repeater login, CLI messages, and these two control commands. The separate mOTA service carries only host-folder request/response frames.

    CMD_EXEC_LOCAL_OTA_CONTROL (0x4A) is followed by 1-174 printable ASCII bytes. A Companion built with the OTA CLI accepts only these local command families:

    tempradio <freq_kHz>,<bw_kHz>,<sf>,<cr>,<minutes>\nnormalradio\nota ...\n

    ota folder ... is deliberately rejected because USB and Bluetooth source ownership must not be changed through the wrong transport. Embedded NUL, CR, LF, other control bytes, non-ASCII bytes, empty commands, and oversized frames return ERR_CODE_ILLEGAL_ARG. A recognized command replies with RESP_CODE_OK, one unsigned reply-length byte, and exactly that many printable result bytes. Shell metacharacters are rejected as well; the text is dispatched only to the in-firmware parser and is never passed to a host shell. Firmware without the OTA CLI feature returns ERR_CODE_UNSUPPORTED_CMD.

    CMD_BLE_MOTA_SOURCE (0x4B) has one action byte:

    Action Meaning 0 Read status without changing it. 1 Attach and enumerate the subscribed Bluetooth host's .mota catalog. 2 Detach the Bluetooth source.

    Current firmware returns eleven bytes (legacy protocol-v14 previews returned the seven-byte prefix only):

    00 action flags offered_le16 advertised_le16 source_packets_sent_le32\n

    Flag bit 0x01 means the encrypted GATT channel is connected and Device Request notifications are enabled. Bit 0x02 means the Bluetooth catalog is attached. Bit 0x04 means USB or another folder transport currently owns the source slot. Start without a ready subscription, or while another source link owns the slot, returns ERR_CODE_BAD_STATE. A non-nRF52 Full Companion returns ERR_CODE_UNSUPPORTED_CMD. source_packets_sent is a per-attachment count of OTA packets accepted by the Companion's LoRa transmit adapter, including catalog/manifest traffic, data, proofs, and retries. It wraps as an unsigned 32-bit value. Clients should accept the legacy seven-byte response and display the packet counter as unavailable.

    After a successful start, the device sends the same bounded seeder frames used by motatool serve on Device Request:

    device -> host: 'M' 'S' op args... xor(op || args)\nhost -> device: 'm' 's' op status payload... xor(all prior bytes)\n

    Device requests are at most 11 bytes. A source response is at most 197 bytes. The host may split one response across multiple write-with-response operations when the negotiated ATT payload is smaller; it must preserve byte order and must not interleave another response. Bad checksums, partial frames, overflow, unsubscribe, loss of encryption, or disconnect fail closed. The firmware then detaches the catalog and stops advertising its entries. USB and Bluetooth folder sources are mutually exclusive.

    A Linux reference controller and seeder is provided at tools/ble_mota/ble_mota_seeder.py. It verifies every input with motatool before offering it. A mobile implementation should apply the same complete container verification before serving files.

    "},{"location":"companion_protocol/#1-app-start","title":"1. App Start","text":"

    Purpose: Initialize communication with the device. Must be sent first after connection.

    Command Format:

    Byte 0: 0x01\nBytes 1-7: Reserved (currently ignored by firmware)\nBytes 8+: Application name (UTF-8, optional)\n

    Example (hex):

    01 00 00 00 00 00 00 00 6d 63 63 6c 69\n

    Response: PACKET_SELF_INFO (0x05)

    "},{"location":"companion_protocol/#2-device-query","title":"2. Device Query","text":"

    Purpose: Query device information.

    Command Format:

    Byte 0: 0x16\nByte 1: Highest companion protocol version understood by the app\n

    Example (hex):

    16 0E\n

    Response: PACKET_DEVICE_INFO (0x0D) with device information

    "},{"location":"companion_protocol/#3-get-channel-info","title":"3. Get Channel Info","text":"

    Purpose: Retrieve information about a specific channel.

    Command Format:

    Byte 0: 0x1F\nByte 1: Channel index (0 through max_channels - 1)\n

    Example (get channel 1):

    1F 01\n

    Response: PACKET_CHANNEL_INFO (0x12) with channel details

    "},{"location":"companion_protocol/#4-set-channel","title":"4. Set Channel","text":"

    Purpose: Create or update a channel on the device.

    Command Format:

    Byte 0: 0x20\nByte 1: Channel index (0 through max_channels - 1)\nBytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)\nBytes 34-49: Secret (16 bytes)\n

    Total Length: 50 bytes

    Channel index: - Slot count is build-specific. Read max_channels from byte 3 of PACKET_DEVICE_INFO; current profiles commonly expose 1, 8, or 40 slots. - No slot number has an intrinsic public/private meaning.

    Channel Name: - UTF-8 encoded - Maximum 32 bytes - Padded with null bytes (0x00) if shorter

    Secret Field (16 bytes): - Supply the exact 16-byte channel key. A private channel normally uses a cryptographically random key; known public and hashtag channels use their defined or derived key. - An all-zero key is not the public-channel key.

    Example (create channel \"SMS\" at index 1 with secret):

    20 01 53 4D 53 00 00 ... (name padded to 32 bytes)\n    [16 bytes of secret]\n

    Note: The 32-byte secret variant is unsupported and returns PACKET_ERROR.

    Response: PACKET_OK (0x00) on success, PACKET_ERROR (0x01) on failure

    "},{"location":"companion_protocol/#5-send-channel-message","title":"5. Send Channel Message","text":"

    Purpose: Send a text message to a channel.

    Command Format:

    Byte 0: 0x03\nByte 1: 0x00\nByte 2: Channel index (0 through max_channels - 1)\nBytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds)\nBytes 7+: Message Text (UTF-8, variable length)\n

    Timestamp: Unix timestamp in seconds (32-bit unsigned integer, little-endian)

    Example (send \"Hello\" to channel 1 at timestamp 1234567890):

    03 00 01 D2 02 96 49 48 65 6C 6C 6F\n

    Response: PACKET_MSG_SENT (0x06) on success

    "},{"location":"companion_protocol/#6-send-channel-data-datagram","title":"6. Send Channel Data Datagram","text":"

    Purpose: Send a binary datagram to a channel. Unlike channel text messages, datagrams carry no built-in sender identity and no timestamp - applications needing either must encode them inside the binary payload.

    Command Format:

    Byte 0:                         0x3E\nByte 1:                         Channel index (0 through max_channels - 1)\nByte 2:                         Encoded path descriptor (0xFF = flood)\nBytes 3+:                       Encoded path bytes (omitted for 0xFF)\nNext 2 bytes (little-endian):   Data Type (`data_type`, uint16)\nRemaining bytes:                Binary payload (variable length)\n

    For a direct send, the descriptor's low six bits are the hash count and its high two bits are the hash size minus one. Current mesh packets accept one-, two-, or three-byte hashes; the four-byte code is reserved. The following path therefore occupies hash_count * hash_size bytes; the descriptor itself is not a raw byte count.

    Example (flood, DATA_TYPE_DEV, payload A1 B2 C3, channel 1):

    3E 01 FF FF FF A1 B2 C3\n

    Data Type / Transport Mapping: - 0x0000 (DATA_TYPE_RESERVED) is invalid and rejected with PACKET_ERROR. - 0xFFFF (DATA_TYPE_DEV) is the developer namespace for experimenting and developing apps. - Registered application/community namespaces occupy 0x0100-0xFEFF; the remaining nonzero ranges are reserved for internal or development use. See the Registered data_type values table below.

    Limits: - Maximum payload length is MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 167 bytes. - Larger payloads are rejected with PACKET_ERROR (ERR_CODE_ILLEGAL_ARG).

    Response: PACKET_OK (0x00) on success, or PACKET_ERROR (0x01) with one of: - ERR_CODE_NOT_FOUND (2) - unknown channel_idx - ERR_CODE_ILLEGAL_ARG (6) - invalid path_len, reserved data_type (0x0000), or payload larger than MAX_CHANNEL_DATA_LENGTH - ERR_CODE_TABLE_FULL (3) - outbound send queue is full; retry later

    Inbound datagrams are delivered to the host via RESP_CODE_CHANNEL_DATA_RECV (0x1B); see Receive Channel Data Datagram.

    "},{"location":"companion_protocol/#registered-data_type-values","title":"Registered data_type values","text":"

    data_type is an application identifier, not a payload-format identifier. Each registered value identifies an application that owns its own internal payload schemas. The firmware does not inspect payload contents - data_type is transported opaquely.

    Value Constant Purpose 0x0000 DATA_TYPE_RESERVED Reserved; invalid on send 0x0001 - 0x00FF - Reserved for internal use 0x0100 - 0xFEFF - Registered application namespaces (see number_allocations.md) 0xFF00 - 0xFFFE - Testing/development; no registration required 0xFFFF DATA_TYPE_DEV Developer/experimental namespace

    To register a new application, submit a PR adding a row to the table in docs/number_allocations.md. Internal sub-formats within an allocated application ID are owned by that application and are not tracked in MeshCore firmware or this document.

    "},{"location":"companion_protocol/#receive-channel-data-datagram","title":"Receive Channel Data Datagram","text":"

    Inbound group datagrams (radio-level PAYLOAD_TYPE_GRP_DATA, 0x06) are forwarded to the host as RESP_CODE_CHANNEL_DATA_RECV notifications.

    Frame Format (RESP_CODE_CHANNEL_DATA_RECV, 0x1B):

    Byte 0:                 0x1B (packet type)\nByte 1:                 SNR (signed int8, scaled x4 - divide by 4.0 to recover dB)\nBytes 2-3:              Reserved (clients MUST ignore)\nByte 4:                 Channel index (0 through max_channels - 1)\nByte 5:                 Path Length (actual path length when flooded, otherwise 0xFF for direct)\nBytes 6-7:              Data Type (uint16 little-endian)\nByte 8:                 Data Length\nBytes 9 .. 8+data_len:  Payload\n

    Path bytes are not forwarded: Only path_len is reported in the receive frame - the path itself is not copied to the host. There are no path bytes between byte 5 and the data_type field at bytes 6-7, regardless of path_len.

    Path Length semantics differ between send and receive:

    Direction path_len = 0xFF path_len != 0xFF Send Flood the network Direct route; the encoded path follows (low 6 bits = hash count, top 2 bits + 1 = hash size; on-wire byte count = hash_count x hash_size) Receive Packet arrived via direct route Packet was flooded; this is the encoded pkt->path_len field as observed (no path bytes follow)

    In other words, the meaning of 0xFF is inverted between the two directions, and on receive the field carries metadata only - never a routable path. path_len is an encoded byte (see Packet::isValidPathLen / Packet::writePath in src/Packet.cpp), not a raw byte count.

    Note: The device may also emit PACKET_MESSAGES_WAITING (0x83) to notify the host that datagrams are queued; poll with CMD_SYNC_NEXT_MESSAGE (0x0A) to retrieve them.

    Parsing Pseudocode:

    def parse_channel_data_recv(data):\n    if len(data) < 9:\n        return None\n    snr_byte = data[1]\n    snr = (snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0\n    channel_idx = data[4]\n    path_len = data[5]\n    data_type = int.from_bytes(data[6:8], 'little')\n    data_len = data[8]\n    if 9 + data_len > len(data):\n        return None\n    payload = data[9:9 + data_len]\n    return {\n        'snr': snr,\n        'channel_idx': channel_idx,\n        'path_len': path_len,\n        'data_type': data_type,\n        'payload': bytes(payload),\n    }\n
    "},{"location":"companion_protocol/#7-get-message","title":"7. Get Message","text":"

    Purpose: Request the next queued message from the device.

    Command Format:

    Byte 0: 0x0A\n

    Example (hex):

    0A\n

    Response: - PACKET_CHANNEL_MSG_RECV (0x08) or PACKET_CHANNEL_MSG_RECV_V3 (0x11) for channel messages - PACKET_CONTACT_MSG_RECV (0x07) or PACKET_CONTACT_MSG_RECV_V3 (0x10) for contact messages - PACKET_CHANNEL_DATA_RECV (0x1B) for channel data datagrams - PACKET_NO_MORE_MSGS (0x0A) if no messages available

    Note: Poll this command periodically to retrieve queued messages. The device may also send PACKET_MESSAGES_WAITING (0x83) as a notification when messages are available.

    "},{"location":"companion_protocol/#8-get-battery-and-storage","title":"8. Get Battery and Storage","text":"

    Purpose: Query device battery voltage and storage usage.

    Command Format:

    Byte 0: 0x14\n

    Example (hex):

    14\n

    Response: PACKET_BATTERY (0x0C) with battery millivolts and storage information

    "},{"location":"companion_protocol/#channel-management","title":"Channel Management","text":""},{"location":"companion_protocol/#channel-types","title":"Channel Types","text":"
    1. Public Channel
      • Uses a publicly known 16-byte key: 8b3387e9c5cdea6ac9e5edbaa115cd72
      • Anyone can join this channel, messages should be considered public
      • Used as the default public group chat
    2. Hashtag Channels
      • Uses a secret key derived from the channel name
      • It is the first 16 bytes of sha256(\"#test\")
      • For example hashtag channel #test has the key: 9cd8fcf22a47333b591d96a2b848b73f
      • Traffic is encrypted on air, but anyone who knows or guesses the channel name can derive the key. Hashtag channels should not be treated as private.
      • Used as a topic based public group chat, separate from the default public channel
    3. Private Channels
      • Uses a randomly generated 16-byte secret key
      • Messages should be considered private between those that know the secret
      • Users should keep the key secret, and only share with those you want to communicate with
      • Used as a secure private group chat
    "},{"location":"companion_protocol/#channel-lifecycle","title":"Channel Lifecycle","text":"
    1. Set Channel:
      • Read max_channels from device info, fetch those slots, and choose an unused slot (normally an empty name and zeroed key)
      • Generate or provide a 16-byte secret
      • Send CMD_SET_CHANNEL with name and a 16-byte secret
    2. Get Channel:
      • Send CMD_GET_CHANNEL with channel index
      • Parse RESP_CODE_CHANNEL_INFO response
    3. Delete Channel:
      • Send CMD_SET_CHANNEL with empty name and all-zero secret
      • Or overwrite with a new channel
    "},{"location":"companion_protocol/#message-handling","title":"Message Handling","text":""},{"location":"companion_protocol/#receiving-messages","title":"Receiving Messages","text":"

    Messages are received via the TX characteristic (notifications). The device sends:

    1. Channel Messages:
    2. PACKET_CHANNEL_MSG_RECV (0x08) - Standard format
    3. PACKET_CHANNEL_MSG_RECV_V3 (0x11) - Version 3 with SNR

    4. Contact Messages:

    5. PACKET_CONTACT_MSG_RECV (0x07) - Standard format
    6. PACKET_CONTACT_MSG_RECV_V3 (0x10) - Version 3 with SNR

    7. Notifications:

    8. PACKET_MESSAGES_WAITING (0x83) - Indicates messages are queued
    "},{"location":"companion_protocol/#contact-message-format","title":"Contact Message Format","text":"

    Standard Format (PACKET_CONTACT_MSG_RECV, 0x07):

    Byte 0: 0x07 (packet type)\nBytes 1-6: Public Key Prefix (6 bytes, hex)\nByte 7: Path Length\nByte 8: Text Type\nBytes 9-12: Timestamp (32-bit little-endian)\nBytes 13-16: Signature (4 bytes, only if txt_type == 2)\nBytes 17+: Message Text (UTF-8)\n

    V3 Format (PACKET_CONTACT_MSG_RECV_V3, 0x10):

    Byte 0: 0x10 (packet type)\nByte 1: SNR (signed byte, multiplied by 4)\nBytes 2-3: Reserved\nBytes 4-9: Public Key Prefix (6 bytes, hex)\nByte 10: Path Length\nByte 11: Text Type\nBytes 12-15: Timestamp (32-bit little-endian)\nBytes 16-19: Signature (4 bytes, only if txt_type == 2)\nBytes 20+: Message Text (UTF-8)\n

    Parsing Pseudocode:

    def parse_contact_message(data):\n    packet_type = data[0]\n    offset = 1\n\n    # Check for V3 format\n    if packet_type == 0x10:  # V3\n        snr_byte = data[offset]\n        snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)\n        offset += 3  # Skip SNR + reserved\n\n    pubkey_prefix = data[offset:offset+6].hex()\n    offset += 6\n\n    path_len = data[offset]\n    txt_type = data[offset + 1]\n    offset += 2\n\n    timestamp = int.from_bytes(data[offset:offset+4], 'little')\n    offset += 4\n\n    # If txt_type == 2, skip 4-byte signature\n    if txt_type == 2:\n        offset += 4\n\n    message = data[offset:].decode('utf-8')\n\n    return {\n        'pubkey_prefix': pubkey_prefix,\n        'path_len': path_len,\n        'txt_type': txt_type,\n        'timestamp': timestamp,\n        'message': message,\n        'snr': snr if packet_type == 0x10 else None\n    }\n
    "},{"location":"companion_protocol/#channel-message-format","title":"Channel Message Format","text":"

    Standard Format (PACKET_CHANNEL_MSG_RECV, 0x08):

    Byte 0: 0x08 (packet type)\nByte 1: Channel index (0 through max_channels - 1)\nByte 2: Path Length\nByte 3: Text Type\nBytes 4-7: Timestamp (32-bit little-endian)\nBytes 8+: Message Text (UTF-8)\n

    V3 Format (PACKET_CHANNEL_MSG_RECV_V3, 0x11):

    Byte 0: 0x11 (packet type)\nByte 1: SNR (signed byte, multiplied by 4)\nBytes 2-3: Reserved\nByte 4: Channel index (0 through max_channels - 1)\nByte 5: Path Length\nByte 6: Text Type\nBytes 7-10: Timestamp (32-bit little-endian)\nBytes 11+: Message Text (UTF-8)\n

    Parsing Pseudocode:

    def parse_channel_message(data):\n    packet_type = data[0]\n    offset = 1\n\n    # Check for V3 format\n    if packet_type == 0x11:  # V3\n        snr_byte = data[offset]\n        snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)\n        offset += 3  # Skip SNR + reserved\n\n    channel_idx = data[offset]\n    path_len = data[offset + 1]\n    txt_type = data[offset + 2]\n    timestamp = int.from_bytes(data[offset+3:offset+7], 'little')\n    message = data[offset+7:].decode('utf-8')\n\n    return {\n        'channel_idx': channel_idx,\n        'timestamp': timestamp,\n        'message': message,\n        'snr': snr if packet_type == 0x11 else None\n    }\n
    "},{"location":"companion_protocol/#sending-messages","title":"Sending Messages","text":"

    Use the SEND_CHANNEL_MESSAGE command (see Commands).

    Important: - The shared text envelope permits up to 160 UTF-8 bytes. For channel text, firmware prepends <sender name>: inside that envelope, so the available message body is 160 - prefix_bytes and varies with the configured name. - Count encoded UTF-8 bytes, not Unicode characters. Split a longer message at valid UTF-8 boundaries. - Include a chunk indicator (e.g., \"[1/3] message text\")

    "},{"location":"companion_protocol/#response-parsing","title":"Response Parsing","text":""},{"location":"companion_protocol/#terminology","title":"Terminology","text":"

    This document uses a spec-level naming convention (PACKET_*) for bytes the firmware sends back to the host. In the firmware source these same values are split across two #define families by purpose:

    Byte values are authoritative; names are aliases. When reading firmware source, RESP_CODE_X / PUSH_CODE_X correspond to this doc's PACKET_X of the same numeric value.

    "},{"location":"companion_protocol/#response-types","title":"Response types","text":"Value Firmware name Description 0x00 RESP_CODE_OK Command succeeded. 0x01 RESP_CODE_ERR Command failed; byte 1 is the error code. 0x02 RESP_CODE_CONTACTS_START Contact enumeration started. 0x03 RESP_CODE_CONTACT One contact record. 0x04 RESP_CODE_END_OF_CONTACTS Contact enumeration ended. 0x05 RESP_CODE_SELF_INFO Device self-information. 0x06 RESP_CODE_SENT Send accepted, with route/tag/timeout data. 0x07 / 0x08 RESP_CODE_CONTACT_MSG_RECV / RESP_CODE_CHANNEL_MSG_RECV Queued legacy-format message. 0x09 RESP_CODE_CURR_TIME Current device time. 0x0A RESP_CODE_NO_MORE_MESSAGES Offline queue is empty. 0x0B RESP_CODE_EXPORT_CONTACT Exported contact bytes. 0x0C RESP_CODE_BATT_AND_STORAGE Battery and storage values. 0x0D RESP_CODE_DEVICE_INFO Protocol and build information. 0x0E RESP_CODE_PRIVATE_KEY Exported identity key, when enabled. 0x0F RESP_CODE_DISABLED Requested sensitive feature is disabled. 0x10 / 0x11 RESP_CODE_CONTACT_MSG_RECV_V3 / RESP_CODE_CHANNEL_MSG_RECV_V3 Queued message with SNR fields. 0x12 RESP_CODE_CHANNEL_INFO Channel slot information. 0x13 / 0x14 RESP_CODE_SIGN_START / RESP_CODE_SIGNATURE Signing capacity or completed signature. 0x15 RESP_CODE_CUSTOM_VARS Custom-variable data. 0x16 RESP_CODE_ADVERT_PATH Cached advertisement path. 0x17 RESP_CODE_TUNING_PARAMS Tuning parameters. 0x18 RESP_CODE_STATS Requested statistics subtype. 0x19 RESP_CODE_AUTOADD_CONFIG Automatic-contact policy. 0x1A RESP_ALLOWED_REPEAT_FREQ Allowed repeat-frequency ranges. 0x1B RESP_CODE_CHANNEL_DATA_RECV Queued channel datagram. 0x1C RESP_CODE_DEFAULT_FLOOD_SCOPE Default flood-scope data. 0x1D RESP_CODE_CLI_REPLY Text returned by CMD_RUN_CLI_COMMAND."},{"location":"companion_protocol/#asynchronous-push-types","title":"Asynchronous push types","text":"Value Firmware name Description 0x80 PUSH_CODE_ADVERT Advertisement received. 0x81 PUSH_CODE_PATH_UPDATED A contact path changed. 0x82 PUSH_CODE_SEND_CONFIRMED A sent message was acknowledged. 0x83 PUSH_CODE_MSG_WAITING One or more offline frames are waiting. 0x84 PUSH_CODE_RAW_DATA Raw application data received. 0x85 / 0x86 PUSH_CODE_LOGIN_SUCCESS / PUSH_CODE_LOGIN_FAIL Server login result. 0x87 PUSH_CODE_STATUS_RESPONSE Server status response. 0x88 PUSH_CODE_LOG_RX_DATA Radio receive log data. 0x89 PUSH_CODE_TRACE_DATA Completed trace data. 0x8A PUSH_CODE_NEW_ADVERT Newly stored contact advertisement. 0x8B PUSH_CODE_TELEMETRY_RESPONSE Telemetry response. 0x8C PUSH_CODE_BINARY_RESPONSE Binary request response. 0x8D PUSH_CODE_PATH_DISCOVERY_RESPONSE Path-discovery response. 0x8E PUSH_CODE_CONTROL_DATA Control/discovery data. 0x8F PUSH_CODE_CONTACT_DELETED Oldest contact was deleted while making room. 0x90 PUSH_CODE_CONTACTS_FULL Contact storage is full."},{"location":"companion_protocol/#parsing-responses","title":"Parsing Responses","text":"

    PACKET_OK (0x00):

    Byte 0: 0x00\nBytes 1-4: Optional value (32-bit little-endian integer)\n

    PACKET_ERROR (0x01):

    Byte 0: 0x01\nByte 1: Error code (optional)\n

    PACKET_CHANNEL_INFO (0x12):

    Byte 0: 0x12\nByte 1: Channel Index\nBytes 2-33: Channel Name (32 bytes, null-terminated)\nBytes 34-49: Secret (16 bytes)\n

    Note: The device returns the 16-byte channel secret in this response.

    PACKET_DEVICE_INFO (0x0D):

    Byte 0: 0x0D\nByte 1: Firmware Version (uint8)\nBytes 2+: Variable length based on firmware version\n\nFor firmware version >= 3:\nByte 2: Max Contacts Raw (uint8, actual = value * 2)\nByte 3: Max Channels (uint8)\nBytes 4-7: Active BLE PIN (32-bit little-endian; includes a generated session PIN)\nBytes 8-19: Firmware Build (12 bytes, UTF-8, null-padded)\nBytes 20-59: Model (40 bytes, UTF-8, null-padded)\nBytes 60-79: Version (20 bytes, UTF-8, null-padded)\nByte 80: Client repeat enabled/preferred (firmware v9+)\nByte 81: Path hash mode (firmware v10+)\n

    Parsing Pseudocode:

    def parse_device_info(data):\n    if len(data) < 2:\n        return None\n\n    fw_ver = data[1]\n    info = {'fw_ver': fw_ver}\n\n    if fw_ver >= 3 and len(data) >= 80:\n        info['max_contacts'] = data[2] * 2\n        info['max_channels'] = data[3]\n        info['ble_pin'] = int.from_bytes(data[4:8], 'little')\n        info['fw_build'] = data[8:20].decode('utf-8').rstrip('\\x00').strip()\n        info['model'] = data[20:60].decode('utf-8').rstrip('\\x00').strip()\n        info['ver'] = data[60:80].decode('utf-8').rstrip('\\x00').strip()\n\n    if fw_ver >= 9 and len(data) >= 81:\n        info['client_repeat'] = data[80] != 0\n    if fw_ver >= 10 and len(data) >= 82:\n        info['path_hash_mode'] = data[81]\n\n    return info\n

    PACKET_BATTERY (0x0C):

    Byte 0: 0x0C\nBytes 1-2: Battery Voltage (16-bit little-endian, millivolts)\nBytes 3-6: Used Storage (32-bit little-endian, KB)\nBytes 7-10: Total Storage (32-bit little-endian, KB)\n

    Parsing Pseudocode:

    def parse_battery(data):\n    if len(data) < 3:\n        return None\n\n    mv = int.from_bytes(data[1:3], 'little')\n    info = {'battery_mv': mv}\n\n    if len(data) >= 11:\n        info['used_kb'] = int.from_bytes(data[3:7], 'little')\n        info['total_kb'] = int.from_bytes(data[7:11], 'little')\n\n    return info\n

    PACKET_SELF_INFO (0x05):

    Byte 0: 0x05\nByte 1: Advertisement Type\nByte 2: TX Power\nByte 3: Max TX Power\nBytes 4-35: Public Key (32 bytes, hex)\nBytes 36-39: Advertisement Latitude (32-bit little-endian, divided by 1e6)\nBytes 40-43: Advertisement Longitude (32-bit little-endian, divided by 1e6)\nByte 44: Multi ACKs\nByte 45: Advertisement Location Policy\nByte 46: Telemetry Mode (bitfield)\nByte 47: Manual Add Contacts (bool)\nBytes 48-51: Radio Frequency (32-bit little-endian, divided by 1000.0)\nBytes 52-55: Radio Bandwidth (32-bit little-endian, divided by 1000.0)\nByte 56: Radio Spreading Factor\nByte 57: Radio Coding Rate\nBytes 58+: Device Name (UTF-8, variable length, no null terminator required)\n

    Parsing Pseudocode:

    def parse_self_info(data):\n    if len(data) < 36:\n        return None\n\n    offset = 1\n    info = {\n        'adv_type': data[offset],\n        'tx_power': data[offset + 1],\n        'max_tx_power': data[offset + 2],\n        'public_key': data[offset + 3:offset + 35].hex()\n    }\n    offset += 35\n\n    lat = int.from_bytes(data[offset:offset+4], 'little') / 1e6\n    lon = int.from_bytes(data[offset+4:offset+8], 'little') / 1e6\n    info['adv_lat'] = lat\n    info['adv_lon'] = lon\n    offset += 8\n\n    info['multi_acks'] = data[offset]\n    info['adv_loc_policy'] = data[offset + 1]\n    telemetry_mode = data[offset + 2]\n    info['telemetry_mode_env'] = (telemetry_mode >> 4) & 0b11\n    info['telemetry_mode_loc'] = (telemetry_mode >> 2) & 0b11\n    info['telemetry_mode_base'] = telemetry_mode & 0b11\n    info['manual_add_contacts'] = data[offset + 3] > 0\n    offset += 4\n\n    freq = int.from_bytes(data[offset:offset+4], 'little') / 1000.0\n    bw = int.from_bytes(data[offset+4:offset+8], 'little') / 1000.0\n    info['radio_freq'] = freq\n    info['radio_bw'] = bw\n    info['radio_sf'] = data[offset + 8]\n    info['radio_cr'] = data[offset + 9]\n    offset += 10\n\n    if offset < len(data):\n        name_bytes = data[offset:]\n        info['name'] = name_bytes.decode('utf-8').rstrip('\\x00').strip()\n\n    return info\n

    PACKET_MSG_SENT (0x06):

    Byte 0: 0x06\nByte 1: Route Flag (0 = direct, 1 = flood)\nBytes 2-5: Tag / Expected ACK (4 bytes, little-endian)\nBytes 6-9: Suggested Timeout (32-bit little-endian, milliseconds)\n

    PACKET_SEND_CONFIRMED (0x82):

    Byte 0: 0x82\nBytes 1-4: ACK code (32-bit little-endian)\nBytes 5-8: Round-trip time (32-bit little-endian, milliseconds)\n
    "},{"location":"companion_protocol/#error-codes","title":"Error Codes","text":"

    PACKET_ERROR (0x01) carries a single-byte error code in byte 1. Values match the ERR_CODE_* constants defined in examples/companion_radio/MyMesh.cpp:

    Code Constant (firmware) Description 1 ERR_CODE_UNSUPPORTED_CMD Unknown or unsupported command byte / sub-command 2 ERR_CODE_NOT_FOUND Target not found (channel, contact, message, etc.) 3 ERR_CODE_TABLE_FULL Internal queue or table is full - retry later 4 ERR_CODE_BAD_STATE Operation not valid in current device state (e.g. iterator already running) 5 ERR_CODE_FILE_IO_ERROR Filesystem or storage I/O failure 6 ERR_CODE_ILLEGAL_ARG Invalid argument (bad length, out-of-range value, reserved field, etc.)

    Note: Error codes may vary by firmware version. Always check byte 1 of PACKET_ERROR response, and treat unknown codes as generic errors.

    "},{"location":"companion_protocol/#frame-handling","title":"Frame Handling","text":"

    BLE implementations enqueue and deliver one protocol frame per BLE write/notification at the firmware layer.

    "},{"location":"companion_protocol/#response-handling","title":"Response Handling","text":"
    1. Command-Response Pattern:
    2. Send command via RX characteristic
    3. Wait for response via TX characteristic (notification)
    4. Match the response by the expected response type; frames do not carry a general command sequence number
    5. Handle timeout (typically 5 seconds)
    6. Use command queue to prevent concurrent commands

    7. Asynchronous Messages:

    8. Device may send messages at any time via TX characteristic
    9. Handle PACKET_MESSAGES_WAITING (0x83) by polling GET_MESSAGE command
    10. Parse incoming messages and route to appropriate handlers
    11. Validate frame length before decoding

    12. Response Matching:

    13. Match responses to commands by expected packet type:

      • APP_START -> PACKET_SELF_INFO
      • DEVICE_QUERY -> PACKET_DEVICE_INFO
      • GET_CHANNEL -> PACKET_CHANNEL_INFO
      • SET_CHANNEL -> PACKET_OK or PACKET_ERROR
      • SEND_CHANNEL_MESSAGE -> PACKET_MSG_SENT
      • GET_MESSAGE -> PACKET_CHANNEL_MSG_RECV, PACKET_CONTACT_MSG_RECV, PACKET_CHANNEL_DATA_RECV, or PACKET_NO_MORE_MSGS
      • SEND_CHANNEL_DATA -> PACKET_OK or PACKET_ERROR
      • GET_BATTERY -> PACKET_BATTERY
    14. Timeout Handling:

    15. Default timeout: 5 seconds per command
    16. On timeout: Log error, clear current command, proceed to next in queue
    17. Some commands may take longer (e.g., SET_CHANNEL may need 1-2 seconds)
    18. Consider longer timeout for channel operations

    19. Error Recovery:

    20. On PACKET_ERROR: Log error code, clear current command
    21. On connection loss: Clear command queue, attempt reconnection
    22. On invalid response: Log warning, clear current command, proceed
    "},{"location":"companion_protocol/#example-implementation-flow","title":"Example Implementation Flow","text":""},{"location":"companion_protocol/#initialization","title":"Initialization","text":"
    # 1. Scan for MeshCore device\ndevice = scan_for_device(\"MeshCore\")\n\n# 2. Connect to BLE GATT\ngatt = connect_to_device(device)\n\n# 3. Discover services and characteristics\nservice = discover_service(gatt, \"6E400001-B5A3-F393-E0A9-E50E24DCCA9E\")\nrx_char = discover_characteristic(service, \"6E400002-B5A3-F393-E0A9-E50E24DCCA9E\")\ntx_char = discover_characteristic(service, \"6E400003-B5A3-F393-E0A9-E50E24DCCA9E\")\n\n# 4. Enable notifications on TX characteristic\nenable_notifications(tx_char, on_notification_received)\n\n# 5. Send AppStart command\nsend_command(rx_char, build_app_start())\nwait_for_response(PACKET_SELF_INFO)\n
    "},{"location":"companion_protocol/#creating-a-private-channel","title":"Creating a Private Channel","text":"
    # 1. Generate 16-byte secret\nsecret_16_bytes = generate_secret(16)  # Use CSPRNG\nsecret_hex = secret_16_bytes.hex()\n\n# 2. Build SET_CHANNEL command\nchannel_name = \"YourChannelName\"\nchannel_index = choose_unused_slot(max_channels)\ncommand = build_set_channel(channel_index, channel_name, secret_16_bytes)\n\n# 3. Send command\nsend_command(rx_char, command)\nresponse = wait_for_response(PACKET_OK)\n\n# 4. Store secret locally\nstore_channel_secret(channel_index, secret_hex)\n
    "},{"location":"companion_protocol/#sending-a-message","title":"Sending a Message","text":"
    # 1. Build channel message command\nchannel_index = 1\nmessage = \"Hello, MeshCore!\"\ntimestamp = int(time.time())\ncommand = build_channel_message(channel_index, message, timestamp)\n\n# 2. Send command\nsend_command(rx_char, command)\nresponse = wait_for_response(PACKET_MSG_SENT)\n
    "},{"location":"companion_protocol/#receiving-messages_1","title":"Receiving Messages","text":"
    def on_notification_received(data):\n    packet_type = data[0]\n\n    if packet_type == PACKET_CHANNEL_MSG_RECV or packet_type == PACKET_CHANNEL_MSG_RECV_V3:\n        message = parse_channel_message(data)\n        handle_channel_message(message)\n    elif packet_type == PACKET_MESSAGES_WAITING:\n        # Poll for messages\n        send_command(rx_char, build_get_message())\n
    "},{"location":"companion_protocol/#best-practices","title":"Best Practices","text":"
    1. Connection Management:
    2. Implement auto-reconnect with exponential backoff
    3. Handle disconnections gracefully
    4. Store last connected device address for quick reconnection

    5. Secret Management:

    6. Always use cryptographically secure random number generators
    7. Store secrets securely (encrypted storage)
    8. Never log or transmit secrets in plain text

    9. Message Handling:

    10. Send CMD_SYNC_NEXT_MESSAGE when PUSH_CODE_MSG_WAITING is received
    11. Implement message deduplication to avoid displaying the same message twice

    12. Channel Management:

      • Fetch all channel slots even if you encounter an empty slot
      • Ideally save new channels into the first empty slot
    13. Error Handling:

    14. Implement timeouts for all commands (typically 5 seconds)
    15. Handle RESP_CODE_ERR responses appropriately
    "},{"location":"companion_protocol/#troubleshooting","title":"Troubleshooting","text":""},{"location":"companion_protocol/#connection-issues","title":"Connection Issues","text":""},{"location":"companion_protocol/#command-issues","title":"Command Issues","text":""},{"location":"companion_protocol/#message-issues","title":"Message Issues","text":""},{"location":"companion_radio_full/","title":"Full Companion","text":"

    For runtime on/off commands and board exceptions, see feature switches by role. Open the USB web console for the default ASCII terminal.

    For practical on/off commands and update directions, see Full Companion feature switches.

    companion_radio_full combines every qualified Companion transport for its platform into one artifact and acts as a host-backed LoRa mOTA source for updating other nodes. Most targets can run those transports simultaneously; the SenseCAP Indicator exception selects one secondary wireless transport per boot as described below.

    The full Companion is deliberately not a LoRa OTA destination: it has no firmware staging store, refuses ota install, and never advertises its own firmware as an mOTA image.

    Capability ESP32 full nRF52 full USB Binary Companion Yes Yes BLE Binary Companion Yes Yes USB ASCII terminal Yes Yes Dedicated USB plaintext logging No - shares the one USB TTY Yes Host-backed LoRa mOTA source WiFi TCP 5001 Exclusive USB mode or encrypted BLE WiFi Companion/WebConfig Yes No - nRF52840 has no WiFi Hardware serial Companion On targets with assigned serial pins On targets with assigned serial pins Ethernet Companion On targets with an Ethernet module On RAK4631 with RAK13800 LoRa self-update No No Optional self-update WiFi on existing dual-app layouts; USB on single-app layouts Bluetooth DFU with a compatible bootloader; USB always supported"},{"location":"companion_radio_full/#sensecap-indicator-transport-and-rendering-exception","title":"SenseCAP Indicator transport and rendering exception","text":"

    Both SenseCAP Indicator Full layouts keep USB available and start exactly one secondary wireless Companion transport per boot: BLE or infrastructure WiFi. The saved companion.transport selection takes effect after reboot. BLE mode does not start infrastructure WiFi, WebConfig, MQTT, TCP Companion, or network OTA services. WiFi mode never initializes BLE and releases its controller and host memory. LoRa remains the primary radio on the LoRa layout. ESP-NOW remains the primary mesh radio in both ESP-NOW modes, including BLE mode; only the infrastructure-WiFi services are omitted.

    The requested 4-bit internal render profiles are:

    Primary radio Secondary Companion transport Canvas LoRa Infrastructure WiFi Native 480x480 LoRa BLE Native 480x480 ESP-NOW Infrastructure WiFi Native 480x480 ESP-NOW BLE 320x320, scaled 1.5x to the 480x480 panel

    All four modes retain the same 160x160 logical UI. If a contiguous native DMA-capable block is unavailable, startup keeps the device usable with the 320x320 emergency fallback and reports the canvas actually retained. Such a fallback is not a successful native-480 validation result.

    "},{"location":"companion_radio_full/#build-and-install","title":"Build and install","text":"

    The target is synthesized by build.sh only for an exact board recipe that has passed the combined-transport size check:

    The measured ESP32 additions are M5Stack Unit C6L; XIAO C6/S3, Meshimi, WHY2025 Badge, LilyGo T-LoRa C6, T3S3 SX1262/SX1276, T-Deck, TETH Elite, classic T-Beam SX1262/SX1276, and T-Beam S3 Supreme; Heltec Wireless Tracker, Wireless Paper, E213, E290, T190, CT62, and V4 expansion-kit TFT; Generic ESP-NOW; SenseCAP Indicator ESP-NOW/LoRa; Ebyte EoRa-S3; and Meshadventurer SX1262/SX1268. The measured nRF52 additions are GAT562 Mesh Watch13, LilyGo T-Echo Lite, LilyGo T-Impulse Plus, and Wio Tracker L1 E-Ink. Their old transport-specific names remain available for explicit compatibility builds, but the Full image is the canonical release artifact.

    Every ESP32 Full image includes ordinary WiFi Companion, WebConfig, and the TCP mOTA services, even when its historical build base was USB- or BLE-only. On Generic_ESPNOW and SenseCapIndicator-ESPNow, ESP-NOW is also the primary mesh radio and shares the same 2.4 GHz hardware. Those two Full images retain B/G/N for ordinary clients alongside ESP-NOW LR. Their ESP-NOW mesh, setup AP, and infrastructure connection all use one persisted channel, which defaults to 1. Configure the router's 2.4 GHz radio and every other primary ESP-NOW node for that same fixed channel. Turning Companion WiFi off stops its TCP/AP services but deliberately leaves the ESP-NOW mesh radio running on the selected channel.

    Every ESP32 Full Companion with a station connection synchronizes its UTC clock from NTP after boot and explicitly refreshes it every 24 hours. The common WiFi path owns this for non-MQTT and runtime-unconfigured nodes; a configured MQTT bridge owns the same boot-and-daily schedule. Successful sync updates an attached hardware RTC as well as the ESP32 clock and suppresses LoRa clock fallback for that boot. Reconnecting WiFi preserves the existing daily deadline. A timeout leaves the existing clock and fallback intact and retries without blocking radio work.

    Inspect or change the shared channel from the Full Companion text terminal:

    get espnow.channel\nset espnow.channel 6\nreboot\n

    The accepted range is 1 through 13; use only a channel permitted in your region and supported by the router. The setter persists the selection, but the running radio stays on its current channel until reboot. Coordinate the change across every primary ESP-NOW node and the router before rebooting, or the node will lose one or both links. This primary-radio setting is not bridge.channel; that command belongs to the separate ESP-NOW bridge feature. To attach a LoRa-primary *_repeater_bridge_espnow gateway to these nodes, match its bridge.channel and select set bridge.format raw; the bridge's backward-compatible default is the distinct wrapped format. WiFi power saving cannot put infrastructure WiFi and ESP-NOW on different channels. On these two primary-ESP-NOW Full targets, max is unavailable because maximum modem sleep can make the station miss ESP-NOW broadcasts; use min for WiFi/BLE/ESP-NOW coexistence. The firmware also holds the ESP-IDF RF wake reference for the primary mesh radio, so the ESP-NOW receiver remains continuous even though the reported coexistence setting is min.

    List the available targets:

    bash build.sh list | grep companion_radio_full\n

    Build by using one exact listed name:

    bash build.sh build-firmware heltec_v4_r8_companion_radio_full \\\n  --firmware-version v1.17.0\n\nbash build.sh build-firmware RAK_4631_companion_radio_full \\\n  --firmware-version v1.17.0\n

    To build every canonical full Companion target, select the corresponding interactive menu item or run:

    bash build.sh build-full-companion-firmwares \\\n  --firmware-version v1.17.0\n

    Canonical Companion bulk builds also omit legacy _ps and _femoff aliases. Power saving and controllable FEM receive gain are persisted runtime settings; the old names remain available through an explicit build-firmware command for compatibility. Full Companion replaces separate USB, BLE, ordinary WiFi, hardware-serial, Ethernet Companion, Terminal Chat, and USB-only packet-logging release artifacts whenever the exact board supports those combined transports. Direct builds of the legacy targets remain available. RAK4631 repeater and room server Ethernet builds remain separate because they are different standalone roles, not Companion transports. nRF52 separates framed traffic and logs; ESP32 makes those modes mutually exclusive on its one USB TTY. In WebConfig, use the FEM RX boost switch. From the text terminal (USB, or TCP 5002 on ESP32), use:

    get radio.rxgain\nset radio.rxgain off\nset radio.rxgain on\nget radio.fem.rxgain\nset radio.fem.rxgain off\nset radio.fem.rxgain on\nget radio.fem.txgain\nset radio.fem.txgain off\nset radio.fem.txgain on\n

    radio.rxgain controls the radio chip's boosted receive-gain mode; the FEM commands control the external receive and transmit paths. The selected states are applied immediately and retained after reboot. FEM TX gain is reported as unsupported on boards without software-selectable PA gain.

    SSD1306 display builds also persist a runtime orientation. This replaces the separate rotated Full Companion release image:

    get display.rotation\nset display.rotation 90\nset display.rotation 180\nset display.rotation 270\nset display.rotation 0\n

    0 resets the screen to that board's compiled default orientation.

    Heltec E290 and T190 now use their Full Companion artifacts for simultaneous USB, BLE, and WiFi. Their older usb_ble, USB-only, and BLE-only names remain available only as explicit compatibility builds.

    Heltec V3 and base OLED V4 Full Companion also include the former direct WiFi MQTT Companion capability. Configure and enable MQTT at runtime through WebConfig; the canonical release therefore publishes the Full image instead of a second companion_radio_wifi_mqtt image. V4 TFT and expansion-kit layouts remain separate hardware images because their display and I2C wiring differs.

    Device power saving is separate from LoRa RXPS. It can be changed in WebConfig with the Device power saving switch or from the text terminal:

    get powersaving\nset powersaving on\nset powersaving off\n

    On ESP32, WiFi modem power saving is a third independent setting. Select it in the WebConfig WiFi card, or use the Full Companion text terminal:

    get wifi.ssid\nget wifi.status\nget wifi.powersave\nget webui\nset wifi.ssid MyNetwork\nset wifi.pwd my-password\nset wifi.powersave min\nset wifi.powersave max\nstart webconfig\nstop webconfig\n

    ESP32 WiFi Companions with WebConfig, including Full Companion, expose a CLI tab at the node's LAN address. It defaults to on; use get wifi.cli, set wifi.cli on, or set wifi.cli off. Older builds that report the browser terminal as unavailable need a firmware update. The open setup AP does not expose this tab.

    The browser accepts Companion configuration commands, including get powersaving, set usb.logging on|off, WiFi controls, and MQTT settings. set mqtt.enabled on|off controls MQTT without erasing broker settings; get mqtt.enabled, get mqtt.running, and get mqtt.status distinguish the saved switch from current connections. set logging.output off|usb|wifi|both selects both outputs together. The browser also uses the complete Companion terminal for card, import meshcore://..., list, to, send, login, cmd, and delayed replies, with the same syntax as USB and TCP port 5002. See the terminal command guide for contact import and session ownership. USB MOTA session commands require the USB connection; WiFi host folders use port 5001.

    On the two primary-ESP-NOW Full targets, the same terminal also provides get espnow.channel and set espnow.channel <1-13>. A channel change is persisted and requires a reboot, unlike a WiFi power-save change.

    SSID and password writes return their reply first, then restart the Companion WiFi station with the saved credentials. A TCP terminal therefore disconnects shortly after either write; reconnect to the IP reported by the new network. Password entry is masked over USB. get wifi.pwd reads the saved password from any local connection, including binary command 0x42; LoRa cannot read it. It may be empty for an open network, an ordinary passphrase of up to 63 characters, or an exact 64-character hexadecimal WPA/WPA2 PSK. Other 64-character values and all longer values are rejected.

    The normal binary Companion connection can also read or write this setting over USB, BLE, or TCP port 5000 without entering terminal mode. The mode values are 0 for min, 1 for none, and 2 for max; see the Companion protocol. A Full Companion that runs WiFi and BLE simultaneously rejects none because coexistence requires modem sleep. A primary-ESP-NOW Full target also rejects max; unlike infrastructure traffic, peer ESP-NOW broadcasts cannot be buffered by the access point while the station sleeps. If an older image saved a conflicting value, firmware applies and reports min instead. The SenseCAP Indicator uses the active-mode constraints in the exception above: LoRa + WiFi accepts all three values, ESP-NOW + WiFi accepts none|min, LoRa + BLE accepts min|max for the inactive WiFi setting, and ESP-NOW + BLE requires min. Fresh Cascade builds select min, and an existing valid saved selection takes precedence.

    On radios with RX duty-cycle support, WebConfig and the text terminal also expose the persisted RXPS setting:

    get radio.rxps\nset radio.rxps off\nset radio.rxps on\nset radio.rxps level 8 preamble 16\nset radio.rxps 65625 60000\n

    Fresh Cascade-profile Full Companion builds start with RXPS on at level 8 and a 16-symbol preamble. Changing it takes effect immediately and remains selected after reboot.

    Companion firmware defaults device power saving to on. Version 1.17.1.2 also turns it on once when upgrading an older Companion preference file, including one written by the short-lived default-off regression. After that one-time migration, an explicit set powersaving off selection remains persistent.

    On ESP32, enabling it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. Disabling it restores the normal CPU clock and keeps GPS awake. Active Full Companion transports remain available in both states; on the SenseCAP Indicator this means USB plus the selected BLE or infrastructure-WiFi secondary transport. WiFi modem sleep stays enabled when BLE and the ESP32 WiFi radio are active together because coexistence requires it. Changing device power saving does not overwrite the saved WiFi power-save mode. While a native-USB host is enumerated, the platform sleep attempt is held off so USB CDC remains responsive; detaching the host releases that guard. CPU, radio-modem, and GPS power-saving settings remain active, and USB power from a charger alone does not create a Companion session. The selected state is retained after reboot.

    On the LilyGo T-Beam 1W Full Companion, press the physical BOOT button once to turn the ESP32 WiFi radio and all WiFi services off or on. The screen confirms WiFi: OFF or WiFi: ON, and the selected state is retained after reboot. When WiFi is off, TCP ports 5000-5002, WebConfig, and MQTT are stopped; USB, BLE, the display, GPS, and LoRa continue to operate. Press BOOT again to restore WiFi, including the saved station or setup-AP mode. On boot, BLE starts two seconds after WiFi/WebConfig so their peak startup allocations do not overlap.

    Artifacts are written to out/ by default.

    Full Companion behavior is selected with independent capability macros for TempRadio, the OTA CLI, the TCP terminal, USB folder seeding, and memory diagnostics. The legacy COMPANION_RADIO_FULL flag remains an input for older target recipes, but application behavior no longer uses that umbrella as an unrelated compile guard. In particular, ESP32 WiFi/WebConfig terminal controls are compiled from their actual WiFi/WebConfig capability. On targets that run ESP32 WiFi and BLE simultaneously, BLE is started first to avoid heap fragmentation. The SenseCAP Indicator starts only its selected secondary transport. Compile-time prerequisite checks reject inconsistent feature flags. After linking, the capability sidecar verifies USB, BLE, the OTA CLI, TempRadio, and each platform's host-folder transport; it also verifies the TCP terminal, WebConfig, and WiFi seeder on ESP32, plus dedicated logging on nRF52.

    On 4 MB ESP32 boards, the full target uses a single 3 MB application partition so WiFi, BLE, WebConfig, and source-only mOTA fit in one artifact. The T-Beam 1W Full Companion uses that same LilyGo factory-compatible boot layout on its 16 MB flash because this source-only role does not install updates into a second app slot. Flash the generated -merged.bin when first installing this partition layout. Other boards with 8 MB or more retain dual application partitions. Heltec V2 and TLora V2 use 100 contacts, 8 group channels, and a 16-frame offline queue in this combined profile because of internal DRAM limits. Meshadventurer SX1262 and SX1268 use 100 contacts, 30 group channels, and a 16-frame queue. These capacities preserve the required 8 KiB static internal-DRAM reserve alongside MOTA and all Full transports; their ordinary transport-specific images retain 160 contacts, 40 channels, and 128 queued frames.

    The 1.17.1.5 memory replacements for Generic ESP-NOW, Heltec Wireless Paper, Wireless Tracker, CT62, V3, Tracker V2, and XIAO C3 Full Companion use 150 contacts and retain their 256-frame queue and Full transports. Export contacts before updating if you have more than 150; entries beyond the new limit may be unavailable and a later save may omit them.

    Full Companions normally retain 256 pending Companion message frames. ESP32 boards with configured PSRAM retain 512 and allocate that queue from PSRAM before WiFi and BLE start. If PSRAM is unavailable at runtime, allocation falls back through 256 and 128 frames, then to a 16-frame internal buffer. The Full Companion startup memory line reports the capacity actually allocated as offline_queue=<frames>. The queue is volatile and shared by all channels and direct messages; it is not flash-backed history. See Companion offline message queue for all platform defaults and full-queue behavior.

    Every corrected nRF52 Full profile keeps 256 offline frames normally and temporarily lends 128 slots to mOTA to leave room for Bluetooth tasks, displays and UI allocations. Queue sharing preserves the board's contacts, channels, and USB/Bluetooth mOTA sending. See the memory correction.

    The nRF52 target inherits the board's ordinary USB Companion installation format and adds BLE plus the serial mOTA source. It does not enable an SD cache or any other board-specific storage behavior; host files are streamed as they are requested. Its image is bounded by the board's normal application region, not the smaller OTAFIX in-place workspace reserved for firmware that can update itself.

    "},{"location":"companion_radio_full/#interfaces","title":"Interfaces","text":"Platform Interface Purpose Both USB, 115200 baud ASCII after boot; automatically switches on the first complete Binary Companion frame Both BLE Binary Companion; display builds show a random session PIN, while headless builds default to 123456 ESP32 TCP 5000 Binary Companion over WiFi ESP32 HTTP 80 Companion WebConfig and first-boot WiFi setup ESP32 TCP 5001 Host .mota folder from motatool serve --tcp ESP32 TCP 5002 Full Companion text terminal; same role commands as the USB terminal nRF52 USB mOTA mode Host .mota folder from motatool serve --serial nRF52 Encrypted BLE mOTA service Paired phone/tablet/Linux host .mota catalog

    Delivery-required replies are returned only to the interface which supplied the latest command. A contact-list stream keeps that route locked from CONTACTS_START through END_OF_CONTACTS; commands waiting on another interface are read after the stream finishes. Best-effort asynchronous observations such as adverts remain broadcast so passive clients can refresh their views. Companion session state is device-wide, so use one active Companion application at a time. On nRF52, BLE remains available while USB is in terminal or mOTA mode.

    USB Binary output is queued as complete length-prefixed frames. Temporary CDC or UART backpressure pauses the contact stream; a frame may drain through a smaller hardware FIFO in ordered chunks, but its remainder is retained and no later frame can interleave with it or cause it to be discarded.

    When a BLE client requests pairing, a display-equipped build wakes the screen, switches to the first home page, and keeps the active six-digit PIN visible until Bluetooth connects or the two-minute pairing window expires. USB, WiFi, Ethernet, and hardware-serial connections do not suppress this screen. With no saved BLE PIN, display builds generate a new PIN at boot; builds without a physical display use 123456. A PIN saved through the Companion protocol takes effect after reboot.

    The Bluetooth device name is independently configurable. In the text terminal, use get bluetooth.name and set bluetooth.name <name>; use set bluetooth.name default to restore MeshCore-<node name>. The WebConfig Node card exposes the same optional field on Bluetooth-capable ESP32 builds. Custom names replace the complete Bluetooth label rather than inheriting the prefix, accept up to 31 valid UTF-8 bytes, and take effect after reboot. This does not change the node's mesh advertisement name.

    Every Bluetooth Companion also supports get bluetooth.mac and set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>. A literal address is a BLE random-static identity, random creates one persistent identity, random-every-boot rotates it on startup, and random-after-connect rotates it on the first boot after an authenticated connection while leaving unused boots unchanged. default (or clear) restores the chipset address. None of these values changes the stealth flag. Reboot and re-pair after changing modes or after a rotation. Address rotation clears stale local bonds. On nRF52 this affects the MeshCore application only, not the OTAFIX bootloader's separate BLE DFU identity.

    get bluetooth.stealth and set bluetooth.stealth on|off control an independent flag, defaulting to off. Combine it with a custom address or random to retain the address and bond across boots. With stealth on, normal discovery ends after the first authenticated pairing and only the saved peer may reconnect. Rotating MAC policies still rotate and reopen first pairing when they do; stealth remains enabled. Repeating on keeps an existing bond. Toggle off, then on, then reboot to reset first pairing. All flag changes need reboot. The node still sends directed or allowlisted BLE packets; it is not radio silent.

    ESP32 ports 5000, 5001, 5002, and WebConfig have no independent login layer. Expose them only on a trusted LAN or temporary setup network. See WiFi setup for credential setup and reconnect behavior.

    "},{"location":"companion_radio_full/#usb-binary-and-text-terminal-modes","title":"USB Binary and text terminal modes","text":"

    Full Companion USB starts in the ASCII terminal after boot. MeshCore apps and meshcli send a <-prefixed framed command, which automatically hands the untouched frame to the Binary Companion parser:

    meshcli -s /dev/ttyACM0 -b 115200 ver\n

    The automatic probe runs only at an empty prompt. A complete frame confirms binary mode; an incomplete probe returns to ASCII after one second. Binary mode then remains selected until reboot or the explicit terminal start token. See Full Companion USB CLI and binary switcher for the byte-level state machine, logging and mOTA ownership, recovery paths, and known limitations.

    Immediately after boot, an ordinary terminal can issue ASCII commands without a start token. If the device is already in Binary Companion mode, open the port with the terminal start token sent automatically:

    picocom -b 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM0\n

    The input map prevents any Binary Companion control bytes received during the mode transition from changing the local terminal's character set or display state while leaving UTF-8 emoji intact. The banner confirms that terminal mode is active; do not enter the start token again after it appears.

    The terminal supports Companion chat commands, including channels, channel <name-or-slot> <message>, remote administration with login <admin-password> and cmd <remote-command>, and routed trace [recipient-name-or-prefix], plus local ota, tempradio, and normalradio controls. board reports the exact hardware target. ESP32 Full Companion also provides memory, which reports heap, internal allocation, PSRAM, and offline-queue usage; it is omitted from non-ESP32 help. ESP32 builds also provide local WiFi credential, status, WebConfig, and power-save controls. Every Full Companion provides persistent get/set usb.logging and starts with logging off on a fresh installation.

    "},{"location":"companion_radio_full/#esp32-single-usb-serial-port","title":"ESP32 single USB serial port","text":"

    Every ESP32 Full Companion exposes one USB TTY with two exclusive modes. It starts as the ASCII terminal unless a saved logging-on preference boots directly into the logging terminal. If it is already binary, enter its text terminal with +++MESHCORE-TERM-START. For an ESP32 1.17.1.5 USB logging session, run these separate commands:

    set powersaving off\nset usb.logging on\n

    The first command is the release's USB power-saving workaround. The same TTY then emits plaintext packet/debug logs and continues accepting CLI commands, including set usb.logging off. Framed Binary Companion is unavailable on USB while logging owns the TTY. Turning logging off sends the command reply, stops the logs, and leaves that TTY in the normal ASCII terminal, matching a fresh Full installation. Send +++MESHCORE-TERM-STOP, or let a Companion app send a valid framed probe, to switch it to Binary Companion afterward. A saved logging-on preference boots directly into this input-capable logging terminal. BLE and Wi-Fi Companion remain available while USB is logging on simultaneous targets. On the SenseCAP Indicator, only the selected BLE or Wi-Fi Companion transport remains available alongside the logging terminal.

    ESP32 Full Companion uses the repository's Arduino-ESP32 2.x platform base where the board supports it. RC32 and ESP32-C6 retain their board-required Arduino 3.x platform, but follow the same one-TTY policy. No ESP32 Full image creates an optional second CDC interface. The single-TTY behavior applies to native-USB ESP32-S3 boards and boards using a USB-UART bridge.

    "},{"location":"companion_radio_full/#nrf52-dual-usb-serial-ports","title":"nRF52 dual USB serial ports","text":"

    Current nRF52 Full Companion firmware can expose two CDC ACM serial interfaces on one physical USB cable:

    A fresh Full Companion starts with USB logging off and therefore enumerates only interface 00. Use set usb.logging on to save logging on; the reply says that a reboot is required. Use set usb.logging on reboot to save it and have the node reboot automatically after the reply. The second interface appears after that reboot. Likewise, set usb.logging off reboot removes interface 02. The optional reboot word is accepted only in these exact command forms and triggers a reboot only when the descriptor actually needs to change.

    These are Full Companion text-terminal commands. The superficially similar meshcli ... get usb.logging command uses the Binary Companion parameter registry and can report Unknown var usb.logging; it does not forward that line to the text terminal. Enter terminal mode on interface 00 with the +++MESHCORE-TERM-START token as described above, then issue the command.

    When logging is on, Linux normally shows two /dev/ttyACM* devices. Match the stable /dev/serial/by-id/*-if00 and *-if02 links, or use a udev rule matching ID_USB_INTERFACE_NUM, rather than assuming which tty number is assigned. On Windows they appear as two COM ports; identify them by USB interface instead of depending on a particular COM number. The nRF52 bootloader temporarily exposes its normal DFU serial interface during an update.

    Opening the nRF52 logging port prints MeshCore USB logging port followed by its portable identity, USB CDC 1; interface 02; Linux stable suffix: -if02. get usb.logging reports the same endpoint. Firmware cannot print the exact /dev/ttyACM* or COM* name because Linux, macOS, or Windows assigns that name after USB enumeration; use the *-if02 link on Linux to obtain the exact path. For example, readlink -f /dev/serial/by-id/*-if02 prints the host-assigned /dev/ttyACM* name.

    Every ESP32-S3 Full Companion image uses DIO flash mode, including the RAK3112 and RC32 profiles. The S3 ROM supports DIO while loading the software bootloader, and some flash configurations fail before the application starts when a merged image inherits QIO. DIO trades some maximum flash-read throughput for compatibility; it does not change a board's PSRAM type or any ordinary non-Full firmware profile.

    On nRF52, point MeshCore Companion software, meshcli, and motatool at interface 00. When enabled and rebooted, point a plaintext reader or USB-connected MQTT service at interface 02. On ESP32, use its only USB TTY for Binary Companion while logging is off, or for the plaintext CLI/logger while logging is on; close one consumer before switching modes. Turning logging off returns to ASCII, so the normal stop token or a valid framed probe is still required before Binary Companion owns the port.

    ESP32 Full Companion exposes this same text terminal on TCP port 5002. Connect with nc DEVICE_IP 5002; no USB control token is needed. USB terminal mode and the TCP terminal share recipient, login, command, trace, and display state, so only one may own the terminal at a time. The idle startup USB prompt yields to a TCP connection when no USB data client or partial command is present, and is restored when TCP disconnects. An active USB session rejects TCP; entering USB terminal mode later closes an active TCP session. On USB-Serial-JTAG and USB-to-UART hardware, the firmware cannot observe an idle host open, so actual buffered USB activity\u2014not the physical cable alone\u2014claims ownership. Disconnecting TCP clears pending terminal-only state without cancelling Binary Companion delivery or radio retries.

    Both terminal transports accept reboot. The reply is sent first and the device reboots one second later, so a script can distinguish an accepted reboot from an abruptly lost connection.

    Port 5002 is plaintext and has no device-local login gate. A remote-admin password entered with login is sent across the LAN connection as typed even though the terminal does not echo it. Use port 5002 only on a trusted LAN or a temporary setup network.

    For example:

    channels\nchannel #rgdata Hello from Eugene \ud83d\udc4b\nshow\nshow channels on\nto Hilltop Repeater\npath A1B2C3,D4E5F6\npath 7773D0 7E7662\nlogin my-admin-password\ncmd ver\ntrace\n

    The terminal list [n] command displays favorite contacts first and orders each favorite/non-favorite group by its most recent advertisement. This does not alter the binary Companion contact-list protocol.

    Unsolicited terminal output starts in a quiet mode: advertisements and ordinary channel messages are hidden, while #emergency messages remain visible. Use show adverts on|off, show channels on|off, and show emergency on|off to control each category independently; plain show reports their state. These runtime filters affect terminal printing only and reset to their defaults after reboot.

    The to command selects the remote-administration target. path shows its saved outgoing route; path direct, path clear, or a list separated by spaces, commas, or both changes the route used by subsequent login, send, and cmd commands. Every hop must use the same 2-, 4-, or 6-digit hexadecimal width. Login passwords are masked during entry and limited by the radio protocol to 15 UTF-8 bytes. Wait for the asynchronous login result before using cmd; command replies appear as CLI -> from <name> and use a response window of 300% of the route estimate. DIRECT via path <hop,...> displays the exact saved prefixes copied into the packet. Remote ACL permissions determine which commands the target accepts. The matching reply reports its round-trip time from local queueing through result reception, including radio transit and remote execution. Only one terminal cmd can be pending at a time.

    Incoming unicast replies are labeled ROUTED. Their exact return prefixes are not available at the destination because each forwarder consumes its prefix; use trace to verify the return route.

    With no argument, trace uses the current to recipient. A name-prefix argument traces that contact directly without changing the current recipient. The contact must already have a known direct path; results show the SNR at each hop, or a timeout if the round trip does not return.

    An explicit route can use 1-, 2-, or 4-byte hexadecimal prefixes. Spaces, commas, and mixed separators are accepted:

    trace path 1 12 34 56 34 12\ntrace path 2 1234,ABCD,5678,ABCD,1234\ntrace path 4 12345678, ABCDEF01 89ABCDEF, ABCDEF01,12345678\n

    The entered route must include both the outward and return prefixes. Exact three-byte traces are not supported.

    Return to Binary mode with:

    +++MESHCORE-TERM-STOP\n

    Closing an armed ASCII USB data connection also changes the port to Binary mode when the hardware can report disconnect. A USB-to-UART bridge may not be able to report this event. A different baud rate, including 57600, does not select ASCII mode.

    On an ESP32 Full Companion built with OTA_FOLDER_SERIAL, motatool can keep the USB serial port open as an mOTA folder source when WiFi is unavailable:

    motatool serve --serial /dev/ttyACM0 --dir ./motas -v\n

    The tool sends the exact completed ota folder on line when it opens the port. Firmware recognizes it in startup ASCII or idle Binary Companion mode, then transfers the port to exclusive USB mOTA ownership before binary request/reply frames begin. The text terminal and Binary Companion are unavailable on that TTY until motatool detaches the folder. This matches the nRF52 ownership behavior described below. TCP port 5001 remains the preferred unattended source transport.

    "},{"location":"companion_radio_full/#nrf52-usb-mota-mode","title":"nRF52 USB mOTA mode","text":"

    The nRF52 full target has a third, exclusive USB mode for the host folder. Unmodified motatool serve --serial sends ota folder on when it opens the port. The startup ASCII terminal recognizes that exact completed line, leaves terminal mode, and gives the stream directly to exclusive mOTA handling. If the port is already in Binary Companion mode, the idle binary parser recognizes the same control sequence. The sequence is not examined inside a framed Binary Companion packet. See the switcher guide for the ownership transitions.

    While mOTA mode owns USB:

    No modified motatool build, terminal token, or preliminary mode change is required.

    "},{"location":"companion_radio_full/#nrf52-bluetooth-mota-source","title":"nRF52 Bluetooth mOTA source","text":"

    Protocol v14 also lets a phone, tablet, or Bluetooth-capable Linux host feed the .mota catalog to an nRF52 Full Companion. Normal Companion commands stay on the Nordic UART service. Firmware data uses a separate GATT service, so binary app traffic cannot be mistaken for a firmware block.

    The client must pair with the Companion PIN, subscribe to the mOTA Device Request characteristic, and send CMD_BLE_MOTA_SOURCE action start over the normal Binary Companion connection. The source is available only while that encrypted MITM-authenticated connection remains active. Disconnecting, unsubscribing, overflowing a frame, or receiving malformed data automatically detaches the catalog. USB and BLE source modes are mutually exclusive.

    The included Raspberry Pi reference client validates each .mota with motatool, schedules the local TempRadio window, serves until interrupted, then detaches and restores the normal radio tuple:

    python3 tools/ble_mota/ble_mota_seeder.py \\\n  --device MeshCore-MyCompanion \\\n  --dir ./motas \\\n  --local 'tempradio 909.950,250,5,5,120'\n

    Prefer this relative tempradio form when the phone/Pi and radio clocks may disagree. It starts a duration on the Companion and does not compare their wall clocks. Use the absolute tempradioat scheduler only after synchronizing the participating nodes.

    Use --pair when the Linux host has not already bonded. BlueZ must have an agent capable of entering or confirming the six-digit PIN. Use --source status without --dir for a read-only channel/status check. The complete UUID, frame, action, and status definitions are in the Companion protocol.

    This reference process stands in for the phone application. A mobile app can use the same sequence while retaining its normal contact and Repeater Admin UI: log in to the destination, put each required node on the same bounded TempRadio tuple, start the local Bluetooth catalog, then send the normal remote ota ls, ota pull, and ota install commands. The destination still checks container geometry, hardware identity, hashes, signature policy, and the OTAFIX bootloader before installation.

    "},{"location":"companion_radio_full/#serve-mota-images-manually","title":"Serve mOTA images manually","text":"

    First put the destination, required relays, controller, and source on the same bounded TempRadio tuple. The example frequency below is not legal everywhere; choose a legal tuple supported by every participating radio.

    "},{"location":"companion_radio_full/#esp32-source","title":"ESP32 source","text":"

    Use the local console to start TempRadio:

    nc 192.168.1.50 5002\n
    tempradio 909.950,250,5,5,120\nota status\n

    Then start the dedicated TCP seeder:

    motatool serve --dir ./motas --tcp 192.168.1.50:5001 -v\n
    "},{"location":"companion_radio_full/#nrf52-source","title":"nRF52 source","text":"

    Use the USB terminal briefly to schedule TempRadio, then return to Binary mode and close the terminal:

    picocom -b 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM1\n
    tempradio 909.950,250,5,5,120\n+++MESHCORE-TERM-STOP\n

    After sending the stop token, exit picocom with Ctrl-A, Ctrl-X.

    Start the serial seeder on that same port:

    motatool serve --dir ./motas --serial /dev/ttyACM1 --baud 115200 -v\n

    motatool switches the port into mOTA mode automatically. Stop it with Ctrl-C to detach the folder. Reopen the terminal and use normalradio if the source should return early; otherwise the saved radio settings return when the bounded window expires.

    As a cable-free alternative, keep the normal Companion BLE session open and run the Bluetooth reference client shown in the nRF52 Bluetooth section. Do not run the USB seeder at the same time.

    Both platforms intentionally refuse firmware installation commands such as:

    ota pull <id> flash\nota install\nota dev ...\n
    "},{"location":"companion_radio_full/#script-a-complete-update","title":"Script a complete update","text":"

    The Bash and PowerShell wrappers accept a release ZIP or ready .mota, set up TempRadio, run motatool, monitor the exact image, install it on the destination, and restore the radio path. Use a separate Companion as the controller.

    For an ESP32 full source:

    export MESHCORE_ADMIN_PASSWORD='target-admin-password'\n\n./tools/lora_ota/lora_ota.sh ./release.zip \"Roof Node\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp 192.168.1.50:5001 \\\n  --source-cli-tcp 192.168.1.50:5002\n

    For an nRF52 full source, the script automatically detects the token-switched terminal and uses the same source port sequentially for control and seeding:

    export MESHCORE_ADMIN_PASSWORD='target-admin-password'\n\n./tools/lora_ota/lora_ota.sh ./release.zip \"Roof Node\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1\n
    $env:MESHCORE_ADMIN_PASSWORD = 'target-admin-password'\n\n& .\\tools\\lora_ota\\lora_ota.ps1 '.\\release.zip' 'Roof Node' `\n  --controller-serial COM7 `\n  --source-serial COM8\n

    See the start-to-finish LoRa OTA guide for package selection, nRF52 in-place deltas, relays, trust checks, and recovery behavior.

    "},{"location":"docs/","title":"Local Documentation","text":"

    This document explains how to build and view the MeshCore documentation locally.

    "},{"location":"docs/#building-and-viewing-docs","title":"Building and viewing Docs","text":"
    pip install mkdocs\npip install mkdocs-material\n
    "},{"location":"esp32_memory_budget/","title":"Classic ESP32 image memory budget","text":"

    Classic ESP32 has 320 KiB of internal DRAM, but at most 160 KiB can hold statically allocated data. The remaining DRAM is available only through the runtime heap. Bluetooth, tracing, and SDK reservations further constrain the usable region. See Espressif's memory types documentation.

    An image can therefore approach its static limit while PlatformIO reports less than 40% of the 320 KiB total in use. PSRAM does not expand the region used by ordinary internal .data and .bss allocations.

    "},{"location":"esp32_memory_budget/#build-enforcement","title":"Build enforcement","text":"

    Every classic ESP32 environment inheriting esp32_base runs scripts/check_esp32_dram.py. The release builder also adds the script when an ESP32 profile replaces the inherited extra scripts. The check runs before image generation, merging, and uploading, including cached nobuild uploads. It requires the ELF and linker map to be present for those cached operations. The check script must load after merge-bin.py so the merge target retains its pre-action. A merge also requires the bootloader/partition flash layout; nobuild cannot create a bootable merged image when that layout is absent.

    The check reads dram0_0_seg from the final linker map. SDK reservations are already reflected in that region; Bluetooth's reservation is not subtracted a second time. The occupied span runs from the region origin to _heap_start, including .data, .bss, .noinit, and alignment gaps. The allowed region is the smaller of the linker's region and 160 KiB.

    Builds must leave at least 8 KiB free within that static region. This is an additional project margin against near-full images, not an Espressif hardware limit or a prediction of free heap. A profile may require a larger margin with custom_esp32_static_dram_reserve in bytes; values below 8192 are rejected. Missing or unrecognized map boundaries also fail the check.

    For the Heltec V2 build used to investigate the repeater report:

    Shared repeater defaults Static span Static region Remaining Result Previous 255 scope slots 124,020 B 124,580 B 560 B Rejected Tuned 31 scope slots 108,308 B 124,580 B 16,272 B Passed

    These measurements remove the board's existing scope-capacity override to exercise the shared defaults. The normal Heltec V2 profile already used 31 scope slots. Exact sizes vary with build flags and SDK versions.

    To inspect an existing classic ESP32 build:

    python3 scripts/check_esp32_dram.py .pio/build/Heltec_v2_repeater/firmware.map\npython3 test/test_esp32_dram.py\n

    ESP32-S2, S3, and C-series chips have different memory maps and do not inherit this classic ESP32 check.

    "},{"location":"esp32_memory_budget/#boot-versus-running-memory","title":"Boot versus running memory","text":"

    The build gate checks static placement needed to load and start the image. It never counts heap-only RAM toward that static budget. Allocations made with new or malloc in global constructors consume heap and are not included in the static span. Moving a table there changes its placement, not total RAM consumption.

    Successful build checks do not prove that every runtime configuration will boot. Hardware validation must still cover startup allocations and the enabled Wi-Fi/Bluetooth services, recording minimum free internal heap and the largest free internal block. Available heap changes during initialization; for example, the Arduino 2.x repeater releases unused Bluetooth controller RAM in initArduino(), after global constructors have run.

    "},{"location":"faq/","title":"Frequently Asked Questions","text":"

    For this fork's 1.17.1.5 USA Cascade release, start with feature switches by role and the USB web console. For nRF52 OTAFIX use the exact board/storage build from OTAFIX 2.4.6; older bootloader links below provide upstream/background context.

    A list of frequently-asked questions and answers for MeshCore

    "},{"location":"faq/#1-introduction","title":"1. Introduction","text":""},{"location":"faq/#11-q-what-is-meshcore","title":"1.1. Q: What is MeshCore?","text":"

    A: MeshCore is a multi-platform system for enabling secure text-based communications utilizing LoRa radio hardware. It can be used for Off-Grid Communication, Emergency Response & Disaster Recovery, Outdoor Activities, Tactical Security including law enforcement and private security and also IoT sensor networks. (source)

    MeshCore is free and open source:

    Some more advanced, but optional features are available on T-Deck if you register your device for a key to unlock. On the MeshCore smartphone clients for Android and iOS/iPadOS, you can unlock the wait timer for repeater and room server remote management over RF feature.

    These features are completely optional and aren't needed for the core messaging experience. They're like super bonus features and to help the developers continue to work on these amazing features, they may charge a small fee for an unlock code to utilize the advanced features.

    Anyone is able to build anything they like on top of MeshCore without paying anything.

    "},{"location":"faq/#12-q-what-do-you-need-to-start-using-meshcore","title":"1.2. Q: What do you need to start using MeshCore?","text":"

    A: Everything you need for MeshCore is available at:

    You need LoRa hardware devices to run MeshCore firmware as clients or server (repeater and room server).

    "},{"location":"faq/#121-hardware","title":"1.2.1. Hardware","text":"

    MeshCore is available on a variety of 433MHz, 868MHz and 915MHz LoRa devices. For example, Lilygo T-Deck, T-Pager, RAK Wireless WisBlock RAK4631 devices (e.g. 19003, 19007, 19026), Heltec V3, Xiao S3 WIO, Xiao C3, Heltec T114, Station G2, Nano G2 Ultra, Seeed Studio T1000-E. More devices are being added regularly.

    For an up-to-date list of supported devices, please go to https://flasher.meshcore.io

    To use MeshCore without using a phone as the client interface, you can run MeshCore on a LilyGo T-Deck, T-Deck Plus, T-Pager, T-Watch, or T-Display Pro. MeshCore Ultra firmware running on these devices is a complete off-grid secure communication solution.

    "},{"location":"faq/#122-firmware","title":"1.2.2. Firmware","text":"

    MeshCore firmware is organized by role and, for companions, by host transport. The main roles are Companion, Repeater, Room Server, and Sensor. The tree also contains specialized terminal-chat and KISS-modem builds. Availability depends on the board; use the flasher or that board's PlatformIO environments as the current source of truth.

    "},{"location":"faq/#123-companion-radio-firmware","title":"1.2.3. Companion Radio Firmware","text":"

    Companion radios connect MeshCore client software to LoRa. Depending on the board and build profile, the companion protocol can be exposed over BLE, USB serial, Wi-Fi, or Ethernet. Some boards also provide a Full Companion profile with additional local administration features. Check the exact build name and transport before flashing. Pending messages are held in a volatile shared queue; capacity depends on the platform and memory profile. See the Companion offline message queue for the exact defaults and limitations. See https://meshcore.io and the web client at https://app.meshcore.nz.

    "},{"location":"faq/#124-repeater","title":"1.2.4. Repeater","text":"

    Repeaters are used to extend the range of a MeshCore network. Repeater firmware runs on the same devices that run client firmware. A repeater's job is to forward MeshCore packets to the destination device. It does not forward or retransmit every packet it receives, unlike other LoRa mesh systems.

    A repeater can be remotely administered using a T-Deck running the MeshCore firmware with remote administration features unlocked, or from a BLE Companion client connected to a smartphone running the MeshCore app.

    "},{"location":"faq/#125-room-server","title":"1.2.5. Room Server","text":"

    A room server is a simple BBS server for sharing posts. T-Deck devices running MeshCore firmware or a BLE Companion client connected to a smartphone running the MeshCore app can connect to a room server.

    Room servers store message history on them and push the stored messages to users. Room servers allow roaming users to come back later and retrieve message history. With channels, messages are either received when it's sent, or not received and missed if the channel user is out of range. Room servers are different and more like email servers where you can come back later and get your emails from your mail server.

    A room server can be remotely administered using a T-Deck running the MeshCore firmware with remote administration features unlocked, or from a BLE Companion client connected to a smartphone running the MeshCore app.

    When a client logs into a room server, the client will receive the previously 32 unseen messages.

    Although room server can also repeat with the command line command set repeat on, it is not recommended nor encouraged. A room server with repeat set to on lacks the full set of repeater and remote administration features that are only available in the repeater firmware.

    The recommendation is to run repeater and room server on separate devices for the best experience.

    "},{"location":"faq/#2-initial-setup","title":"2. Initial Setup","text":""},{"location":"faq/#21-q-how-many-devices-do-i-need-to-start-using-meshcore","title":"2.1. Q: How many devices do I need to start using MeshCore?","text":"

    A: If you have one supported device, flash the BLE Companion firmware and use your device as a client. You can connect to the device using the Android or iOS client via Bluetooth. You can start communicating with other MeshCore users near you.

    If you have two supported devices, and there are not many MeshCore users near you, flash both to BLE Companion firmware so you can use your devices to communicate with your nearby friends and family.

    If you have two supported devices, and there are other MeshCore users nearby, you can flash one of your devices with BLE Companion firmware and flash another supported device to repeater firmware. Place the repeater high above ground to extend your MeshCore network's reach.

    After you flashed the latest firmware onto your repeater device, keep the device connected to your computer via USB serial, use the console feature on the web flasher and set the frequency for your region or country, so your client can remote administer the repeater or room server over RF:

    set freq {frequency}

    The repeater and room server CLI reference is here: https://docs.meshcore.io/cli_commands

    If you have more supported devices, you can use your additional devices with the room server firmware.

    "},{"location":"faq/#22-q-does-meshcore-cost-any-money","title":"2.2. Q: Does MeshCore cost any money?","text":"

    A: All radio firmware versions (e.g. for Heltec V3, RAK, T-1000E, etc.) are free and open source developed by Scott at Ripple Radios.

    The native Android and iOS client uses the freemium model and is developed by Liam Cottle, developer of meshtastic map at meshtastic.liamcottle.net on GitHub and reticulum-meshchat on GitHub.

    The T-Deck firmware is free to download and most features are available without cost. To support the firmware developer, you can pay for a registration key to unlock your T-Deck for deeper map zoom and remote server administration over RF using the T-Deck. You do not need to pay for the registration to use your T-Deck for direct messaging and connecting to repeaters and room servers.

    "},{"location":"faq/#23-q-what-frequencies-are-supported-by-meshcore","title":"2.3. Q: What frequencies are supported by MeshCore?","text":"

    A: Supported frequencies depend on the radio hardware and the rules for the country in which it operates. Common MeshCore hardware covers portions of the 433, 868, and 915 MHz ISM bands.

    Use the current regional preset offered by the client or flasher, confirm it with the local MeshCore community, and comply with local frequency, bandwidth, duty-cycle, and power limits. Do not copy an old frequency from this FAQ. Narrow presets commonly use BW62.5 with a lower spreading factor; their exact frequency and SF are region-specific and can change as deployments coordinate.

    If you have consensus from your community in your region to update your region's preset recommendation, please post your update request on the #meshcore-app channel on the MeshCore Discord server to let Liam Cottle know.

    "},{"location":"faq/#24-q-what-is-an-advert-in-meshcore","title":"2.4. Q: What is an \"advert\" in MeshCore?","text":"

    A: Advert means to advertise yourself on the network. In Reticulum terms it would be to announce. In Meshtastic terms it would be the node sending its node info.

    MeshCore allows you to manually broadcast your name, position and public encryption key, which is also signed to prevent spoofing. When you click the advert button, it broadcasts that data over LoRa. MeshCore calls that an Advert. There's two ways to advert, \"zero hop\" and \"flood\".

    MeshCore clients only advertise themselves when the user initiates it. A repeater sends a flood advert once every 12 hours by default. This interval can be configured using the following command:

    set flood.advert.interval {hours}

    The separate set advert.interval {minutes} command controls the local zero-hop advert timer.

    "},{"location":"faq/#25-q-is-there-a-hop-limit","title":"2.5. Q: Is there a hop limit?","text":"

    A: Internally the firmware has maximum limit of 64 hops. In real world settings it will be difficult to get close to the limit due to the environments and timing as packets travel further and further. We want to hear how far your MeshCore conversations go.

    "},{"location":"faq/#3-server-administration","title":"3. Server Administration","text":""},{"location":"faq/#31-q-how-do-you-configure-a-repeater-or-a-room-server","title":"3.1. Q: How do you configure a repeater or a room server?","text":"

    A: When MeshCore is flashed onto a LoRa device for the first time, it is necessary to set the server device's frequency to make it utilize the frequency that is legal in your country or region.

    Repeater or room server can be administered with one of the options below:

    https://buymeacoffee.com/ripplebiz/e/249834

    "},{"location":"faq/#32-q-do-i-need-to-set-the-location-for-a-repeater","title":"3.2. Q: Do I need to set the location for a repeater?","text":"

    A: While not required, with location set for a repeater it will show up on the MeshCore map in the future. Set location with the following command:

    set lat <GPS Lat>

    set lon <GPS Lon>

    You can get the latitude and longitude from Google Maps by right-clicking the location you are at on the map.

    "},{"location":"faq/#33-q-what-is-the-password-to-administer-a-repeater-or-a-room-server","title":"3.3. Q: What is the password to administer a repeater or a room server?","text":"

    A: The default admin password to a repeater and room server is password. Use the following command to change the admin password:

    password {new-password}

    "},{"location":"faq/#34-q-what-is-the-password-to-join-a-room-server","title":"3.4. Q: What is the password to join a room server?","text":"

    A: The default guest password to a room server is hello. Use the following command to change the guest password:

    set guest.password {guest-password}

    "},{"location":"faq/#35-q-can-i-retrieve-a-repeaters-private-key-or-set-a-repeaters-private-key","title":"3.5. Q: Can I retrieve a repeater's private key or set a repeater's private key?","text":"

    A: You can issue these commands to get or set a repeater's private key using a USB serial connection.

    get prv.key to print a repeater's private key on the serial console set prv.key <hex> to set a repeater's private key on the serial console

    Reboot the repeater after set prv.key <hex> command for the new private key to take effect.

    "},{"location":"faq/#36-q-the-first-byte-of-my-repeaters-public-key-collides-with-an-existing-repeater-on-the-mesh-how-do-i-get-a-new-private-key-with-a-matching-public-key-that-has-its-first-byte-of-my-choosing","title":"3.6. Q: The first byte of my repeater's public key collides with an existing repeater on the mesh. How do I get a new private key with a matching public key that has its first byte of my choosing?","text":"

    A: You can generate a new private key and specify the first byte of its public key here: https://gessaman.com/mc-keygen

    Having multiple repeaters with the same first byte ID does not negatively affect the mesh or its functionality. Flood and pathed packets will still reach their destinations. First byte ID collision makes traceroute and path analysis harder because these tools don't know exactly which of the two (or more) colliding repeaters is the one in the path.

    Best practice is when you set up a new repeater, choose a public key that is not in use. If it is not possible to find a unique first byte for your repeater's public key, choose one that is unique within about 10 miles (16 km) to minimize collision with nearby repeaters.

    "},{"location":"faq/#37-q-my-repeater-may-be-suffering-from-deafness-due-to-high-power-interference-near-my-meshs-frequency-it-is-not-hearing-other-in-range-meshcore-radios-what-can-i-do","title":"3.7. Q: My repeater may be suffering from deafness due to high power interference near my mesh's frequency, it is not hearing other in-range MeshCore radios. What can I do?","text":"

    A: This may be due to the SX1262 radio's auto gain control feature. You can use this command to periodically reset its AGC.

    set agc.reset.interval <number>

    The <number> unit is in seconds and is incremented by 4. set agc.reset.interval 4 works well to cure deafness.

    The reset is skipped while a packet is pending, being received, or being transmitted. When safe, RadioLibWrapper::resetAGC() warm-sleeps the radio, returns the wrapper to idle so receive mode is re-armed, reapplies the cached RX boost setting, and starts a fresh noise-floor calibration.

    "},{"location":"faq/#371-q-can-firmware-recover-an-external-radio-whose-busy-pin-is-stuck-high","title":"3.7.1. Q: Can firmware recover an external radio whose BUSY pin is stuck high?","text":"

    A: Current firmware first attempts a non-destructive RX/AGC re-arm and then uses a physical radio reset when the board exposes one. A normal RadioLib reinitialization is not reported as a hard reset unless firmware can actually pulse NRST or cycle the radio's power rail.

    Most supported external-radio boards expose NRST directly. RAK3401 pulses its dedicated NRST while leaving the shared peripheral rail and SPI controller intact, and LilyGo T-Beam 1W can cycle the radio regulator. The generic Tenstar C3 target describes a user-wired radio and has no reset connection by default. For unattended recovery on that target, wire NRST to a free GPIO and override P_LORA_RESET with that GPIO in the build configuration.

    The SenseCAP T1000-E LR1110 path was hardware-qualified with 50 successful radio-only resets: 25 in continuous receive and 25 with RX power saving active. The saved radio tuple, TX power, RX gain, and RX power-saving configuration were restored after every reset, bidirectional LoRa status traffic still passed, and the nRF52840 uptime remained continuous.

    An integrated STM32WL radio has no independently resettable RF peripheral, so it intentionally remains on the bounded soft-recovery path. If its radio core cannot recover, resetting only the radio is electrically impossible; the MCU must be restarted.

    "},{"location":"faq/#372-q-does-firmware-automatically-update-lr1110-transceiver-firmware","title":"3.7.2. Q: Does firmware automatically update LR1110 transceiver firmware?","text":"

    A: No. A transceiver update erases and rewrites the LR1110 itself, so it is not performed as a side effect of installing ordinary device firmware. Use the hardware vendor's physical-access maintenance procedure and verify the exact LR1110 and bootloader versions before changing them.

    Full remediation of Semtech SEM-PSA-2026-001 requires LR1110 BL2 0x1001 as well as the applicable transceiver firmware. It is a separate two-stage maintenance procedure and is not performed automatically by MeshCore.

    "},{"location":"faq/#373-q-how-does-firmware-handle-lr2021-lowhigh-band-changes","title":"3.7.3. Q: How does firmware handle LR2021 low/high-band changes?","text":"

    A: Firmware first selects the board's matching RF path, retunes the radio, and then reapplies output power so the LR2021 selects the correct low- or high-frequency PA. It also uses the matching RF-switch mode when RX or TX is launched. LR2021 high-band output power is limited to 12 dBm; a lower board-specific limit still takes precedence.

    On Meshnology W12, the two external FEM supplies are mutually exclusive: GPIO4 powers the sub-GHz GC1109 path and GPIO3 powers the 2.4 GHz RFX2402E path. Both supplies are disabled before the selected one is enabled. Temporary radio settings preserve the saved TX power and safely clamp only the active band; returning to the saved settings restores its applicable power. A permanent frequency change clamps the saved TX power to the new band's limit.

    The external SX1262, SX1268, LLCC68, LR1110, and LR2021 drivers also bound the post-SetTx BUSY wait. A failed PA ramp therefore returns an error and can enter the physical-reset recovery path instead of hanging the node indefinitely.

    "},{"location":"faq/#38-q-how-do-i-make-my-repeater-an-observer-on-the-mesh","title":"3.8. Q: How do I make my repeater an observer on the mesh?","text":"

    A: The observer instruction is available here: https://analyzer.letsmesh.net/observer/onboard

    "},{"location":"faq/#39-q-what-is-multibyte-support-what-do-1-byte-2-byte-3-byte-adverts-and-messages-mean","title":"3.9. Q: What is multibyte support? What do 1-byte, 2-byte, 3-byte adverts and messages mean?","text":"

    A: The original MeshCore protocol design uses the first byte of a repeater's public key to denote the repeater in a path. And with 1 byte for each repeater in the path, MeshCore packets can travel as many as 64 hops.

    However, with 1 byte, there are only 254 unique IDs (exclude 00 and FF which are reserved). Many meshes group have multiple repeaters with the same first byte in their public keys. Packets continue to pass through repeaters and the mesh is not harmed in any way. It does make it harder for tools to analyze paths with duplicated repeater IDs.

    Firmware version 1.14 and newer introduces the ability for repeaters to advert with 1-, 2-, or 3-byte adverts. Companions can also send out channel and direct messages with 1-, 2-, or 3-byte path. Adverts and messages sent in 1-byte path is compatible with repeater firmware older or newer than 1.14. They will travel up to 64 hops. 2-byte adverts and messages will travel up to 32 hops. 3-byte adverts and messages will travel up to 21 hops.

    "},{"location":"faq/#391-q-what-path-hash-sizes-will-my-repeater-forward","title":"3.9.1. Q: What path hash sizes will my repeater forward?","text":"

    Repeaters running firmware 1.14+ repeat packets sent with 1-, 2-, or 3-byte path hash. Repeaters on firmware older than 1.14 only repeat 1-byte path hash packets and silently drop 2- and 3-byte packets.

    "},{"location":"faq/#392-q-what-determines-a-packets-path-hash-size","title":"3.9.2. Q: What determines a packet's path hash size?","text":"

    The original packet sender determines the path hash size. The most common original sender is a companion app. The other common original sender is a repeater, when it broadcasts its advert.

    "},{"location":"faq/#393-q-how-do-i-change-my-companions-path-hash-size","title":"3.9.3. Q: How do I change my companion's path hash size?","text":"

    As of firmware version 1.14 and MeshCore app version 1.41.0, in the MeshCore app, you can set your companion's message path hash size in Settings (gear icon), Experimental Settings.

    Until your regional mesh has the vast majority of the repeaters updated to 1.14+ firmware, it is recommended to keep your companion at the default 1-byte because pre-1.14 repeaters will silently drop messages with larger path hashes.

    "},{"location":"faq/#394-q-what-does-the-cli-command-pathhashmode-do-on-a-repeater","title":"3.9.4. Q: What does the CLI command path.hash.mode do on a repeater?","text":"

    This CLI command path.hash.mode only controls the path hash size used in a repeater's own advert broadcasts. It does NOT affect which packets the repeater forwards. A repeater with firmware 1.14+ always forward 1-, 2-, and 3-byte packets regardless of this setting.

    Usage: set path.hash.mode {0|1|2}:

    +----------------+-----------------------+\n| path.hash.mode | Advert path hash size |\n+----------------+-----------------------+\n| 0              | 1 byte (default)      |\n+----------------+-----------------------+\n| 1              | 2 bytes               |\n+----------------+-----------------------+\n| 2              | 3 bytes               |\n+----------------+-----------------------+\n

    It is safe to set your 1.14+ repeaters to mode 1 or 2.

    "},{"location":"faq/#395-q-why-use-2-or-3-byte-path-hash-for-adverts","title":"3.9.5. Q: Why use 2- or 3-byte path hash for adverts?","text":"

    A longer path hash helps tools like the LetsMesh.net Analyzer and MeshMapper disambiguate repeaters more reliably. With only 1 byte, the chance of different repeaters having the same first byte in their public key is high, making it harder to tell them apart in mesh network analysis. Since this only affects adverts, there's no downside. 2- and 3-byte adverts don't travel as far as 1-byte adverts, but it is not important for MeshCore nodes to hear a repeater's advert that is 21 or 32 hops away.

    "},{"location":"faq/#396-q-when-can-we-move-away-from-1-byte-path-hash-for-channel-and-direct-messages","title":"3.9.6. Q: When can we move away from 1-byte path hash for channel and direct messages?","text":"

    You should move to send 2-byte or 3-byte channel and direct messages when the vast majority of the repeaters in your regional mesh are updated to firmware version 1.14 or newer. Setting your repeater's path.hash.mode to 1 (for 2-byte path hash) or 2 (for 3-byte path hash) now helps the community gauge to how many repeaters have updated to 1.14+. Please work with your MeshCore community together to decide when to switch to 2-byte path or 3-byte path for channel and direct messages.

    "},{"location":"faq/#4-t-deck-related","title":"4. T-Deck Related","text":""},{"location":"faq/#41-q-is-there-a-user-guide-for-t-deck-t-pager-t-watch-or-t-display-pro","title":"4.1. Q: Is there a user guide for T-Deck, T-Pager, T-Watch, or T-Display Pro?","text":"

    A: Yes, it is available on https://buymeacoffee.com/ripplebiz/ultra-v7-7-guide-meshcore-users

    "},{"location":"faq/#42-q-what-are-the-steps-to-get-a-t-deck-into-dfu-device-firmware-update-mode","title":"4.2. Q: What are the steps to get a T-Deck into DFU (Device Firmware Update) mode?","text":"

    A:

    1. Device off
    2. Connect USB cable to device
    3. Hold down trackball (keep holding)
    4. Turn on device
    5. Hear USB connection sound
    6. Release trackball
    7. T-Deck in DFU mode now
    8. At this point you can begin flashing using https://flasher.meshcore.io
    "},{"location":"faq/#43-q-why-is-my-t-deck-plus-not-getting-any-satellite-lock","title":"4.3. Q: Why is my T-Deck Plus not getting any satellite lock?","text":"

    A: For T-Deck Plus, the GPS baud rate should be set to 38400. Also, some T-Deck Plus devices were found to have the GPS module installed upside down, with the GPS antenna facing down instead of up. If your T-Deck Plus still doesn't get any satellite lock after setting the baud rate to 38400, you might need to open the device to check the GPS orientation.

    GPS on T-Deck is always enabled. You can skip the \"GPS clock sync\" and the T-Deck will continue to try to get a GPS lock. You can go to the GPS Info screen; you should see the Sentences: counter increasing if the baud rate is correct.

    Source

    "},{"location":"faq/#44-q-why-is-my-og-non-plus-t-deck-not-getting-any-satellite-lock","title":"4.4. Q: Why is my OG (non-Plus) T-Deck not getting any satellite lock?","text":"

    A: The OG (non-Plus) T-Deck doesn't come with a GPS. If you added a GPS to your OG T-Deck, please refer to the manual of your GPS to see what baud rate it requires. Alternatively, you can try to set the baud rate from 9600, 19200, etc., and up to 115200 to see which one works.

    "},{"location":"faq/#45-q-what-size-of-sd-card-does-the-t-deck-support","title":"4.5. Q: What size of SD card does the T-Deck support?","text":"

    A: Users have had no issues using 16GB or 32GB SD cards. Format the SD card to FAT32.

    "},{"location":"faq/#46-q-what-is-the-public-key-for-the-default-public-channel","title":"4.6. Q: What is the public key for the default public channel?","text":"

    A: T-Deck uses the same key the smartphone apps use but in base64 izOH6cXN6mrJ5e26oRXNcg==

    There is no = key on the T-Deck's hardware keyboard. You can use the on-screen software keyboard to enter =. Tap the text box to enable the on-screen software keyboard. The third character is the capital letter O (Oh), not zero 0

    The smartphone app key is in hex: 8b3387e9c5cdea6ac9e5edbaa115cd72

    Source

    "},{"location":"faq/#47-q-how-do-i-get-maps-on-t-deck","title":"4.7. Q: How do I get maps on T-Deck?","text":"

    A: You need map tiles. You can get pre-downloaded map tiles here (a good way to support development):

    Another way to download map tiles is to use this Python script to get the tiles in the areas you want: https://github.com/fistulareffigy/MTD-Script

    There is also a modified script that adds additional error handling and parallel downloads: https://github.com/TheBestJohn/MTD-Script

    "},{"location":"faq/#48-q-where-do-the-map-tiles-go","title":"4.8. Q: Where do the map tiles go?","text":"

    Once you have the tiles downloaded, copy the \\tiles folder to the root of your T-Deck's SD card.

    "},{"location":"faq/#49-q-how-to-unlock-deeper-map-zoom-and-server-management-features-on-t-deck","title":"4.9. Q: How to unlock deeper map zoom and server management features on T-Deck?","text":"

    A: You can download, install, and use the T-Deck firmware for free, but it has some features (map zoom, server administration) that are enabled if you purchase an unlock code for \\$10 per T-Deck device. Unlock page: https://buymeacoffee.com/ripplebiz/e/249834

    "},{"location":"faq/#410-q-how-to-decipher-the-diagnostics-screen-on-t-deck","title":"4.10. Q: How to decipher the diagnostics screen on T-Deck?","text":"

    A: Space is tight on T-Deck's screen, so the information is a bit cryptic. The format is : {hops} l:{packet-length}({payload-len}) t:{packet-type} snr:{n} rssi:{n}

    See here for packet-type: https://github.com/meshcore-dev/MeshCore/blob/main/src/Packet.h#L19

    Value Payload 0x00 Request 0x01 Response 0x02 Plain text message 0x03 Acknowledgment 0x04 Node advertisement 0x05 Group text 0x06 Group datagram 0x07 Anonymous request 0x08 Returned path 0x09 Trace 0x0A Multipart 0x0B Control/discovery data 0x0C OTA-over-LoRa data 0x0D-0x0E Reserved 0x0F Raw custom data

    See Packet Format and Payload Format for the maintained descriptions.

    Source

    "},{"location":"faq/#411-q-the-t-deck-sound-is-too-loud","title":"4.11. Q: The T-Deck sound is too loud?","text":""},{"location":"faq/#412-q-can-you-customize-the-sound","title":"4.12. Q: Can you customize the sound?","text":"

    A: You can customize the sounds on the T-Deck, by placing .mp3 files onto the root dir of the SD card. The files are:

    "},{"location":"faq/#413-q-what-is-the-import-from-clipboard-feature-on-the-t-deck-and-is-there-a-way-to-manually-add-nodes-without-having-to-receive-adverts","title":"4.13. Q: What is the 'Import from Clipboard' feature on the t-deck and is there a way to manually add nodes without having to receive adverts?","text":"

    A: 'Import from Clipboard' is for importing a contact via a file named 'clipboard.txt' on the SD card. The opposite, is in the Identity screen, the 'Card to Clipboard' menu, which writes to 'clipboard.txt' so you can share yourself (call these 'biz cards', that start with \"meshcore://...\")

    "},{"location":"faq/#414-q-how-to-capture-a-screenshot-on-t-deck","title":"4.14. Q: How to capture a screenshot on T-Deck?","text":"

    A: To capture a screenshot on a T-Deck, long press the top-left corner of the screen. The screenshot is saved to the microSD card, if one is inserted into the device.

    "},{"location":"faq/#5-general","title":"5. General","text":""},{"location":"faq/#51-q-what-are-bw-sf-and-cr","title":"5.1. Q: What are BW, SF, and CR?","text":"

    A:

    BW is bandwidth - width of frequency spectrum that is used for transmission

    SF is spreading factor - how much should the communication spread in time

    CR is coding rate - from: https://www.thethingsnetwork.org/docs/lorawan/fec-and-code-rate

    TL;DR: default CR to 5 for good stable links. If it is not a solid link and is intermittent, change CR to 7 or 8.

    Forward Error Correction is a process of adding redundant bits to the data to be transmitted. During the transmission, data may get corrupted by interference (changes from 0 to 1 / 1 to 0). These error correction bits are used at the receivers for restoring corrupted bits.

    The Code Rate of a forward error correction expresses the proportion of bits in a data stream that actually carry useful information.

    There are 4 code rates used in LoRaWAN:

    4/5 4/6 5/7 4/8

    For example, if the code rate is 5/7, for every 5 bits of useful information, the coder generates a total of 7 bits of data, of which 2 bits are redundant.

    Making the bandwidth 2x wider (from BW125 to BW250) allows you to send 2x more bytes in the same time. Making the spreading factor 1 step lower (from SF10 to SF9) allows you to send 2x more bytes in the same time.

    Lowering the spreading factor makes it more difficult for the gateway to receive a transmission, as it will be more sensitive to noise. You could compare this to two people talking in a noisy place (a bar for example). If you're far from each other, you have to talk slow (SF10), but if you're close, you can talk faster (SF7)

    So, it's a balancing act between speed of the transmission and resistance to noise. The Things Network is mainly focused on LoRaWAN, but the LoRa low-level stuff still checks out for any LoRa project

    "},{"location":"faq/#52-q-do-meshcore-clients-repeat","title":"5.2. Q: Do MeshCore clients repeat?","text":"

    A: Companion clients do not repeat by default. Supported builds can opt into bounded client repeating on permitted frequencies, and the emergency-channel path has a bounded delayed relay behavior. Dedicated repeaters remain the normal and recommended way to extend network coverage. Room servers can repeat when explicitly configured, but separating the roles is generally preferable.

    "},{"location":"faq/#53-q-what-happens-when-a-node-learns-a-route-via-a-mobile-repeater-and-that-repeater-is-gone","title":"5.3. Q: What happens when a node learns a route via a mobile repeater, and that repeater is gone?","text":"

    A: If you used to reach a node through a repeater and the repeater is no longer reachable, the client will send the message using the existing (but now broken) known path, the message will fail after 3 retries, and the app will reset the path and send the message as flood on the last retry by default. This can be turned off in settings. If the destination is reachable directly or through another repeater, the new path will be used going forward. Or you can set the path manually if you know a specific repeater to use to reach that destination.

    In the case if users are moving around frequently, and the paths are breaking, they just see the phone client retries and revert to flood to attempt to re-establish a path.

    "},{"location":"faq/#54-q-how-does-a-node-discover-a-path-to-its-destination-and-then-use-it-to-send-messages-in-the-future-instead-of-flooding-every-message-it-sends-like-meshtastic","title":"5.4. Q: How does a node discover a path to its destination and then use it to send messages in the future, instead of flooding every message it sends like Meshtastic?","text":"

    Routes are stored in sender's contact list. When you send a message the first time, the message first gets to your destination by flood routing. When your destination node gets the message, it will send back a delivery report to the sender with all repeaters that the original message went through. This delivery report is flood-routed back to you the sender and is a basis for future direct path. When you send the next message, the path will get embedded into the packet and be evaluated by repeaters. If the hop and address of the repeater matches, it will retransmit the message, otherwise it will not retransmit, hence minimizing utilization.

    Source

    "},{"location":"faq/#55-q-do-public-channels-always-flood-do-private-channels-always-flood","title":"5.5. Q: Do public channels always flood? Do private channels always flood?","text":"

    A: Yes, group channels are A to B, so there is no defined path. They have to flood. Repeaters can however deny flood traffic up to some hop limit, with the set flood.max CLI command. Administrators of repeaters get to set the rules of their repeaters.

    Source

    "},{"location":"faq/#56-q-what-is-the-public-key-for-the-default-public-channel","title":"5.6. Q: What is the public key for the default public channel?","text":"

    A: The smartphone app key is in hex:

    8b3387e9c5cdea6ac9e5edbaa115cd72

    T-Deck uses the same key but in base64:

    izOH6cXN6mrJ5e26oRXNcg==

    The third character is the capital letter O, not zero 0.

    Source

    "},{"location":"faq/#57-q-is-meshcore-open-source","title":"5.7. Q: Is MeshCore open source?","text":"

    A: Most of the firmware is freely available. Everything is open source except the T-Deck firmware and Liam's native mobile apps.

    Firmware repo: https://github.com/meshcore-dev/MeshCore

    "},{"location":"faq/#58-q-how-can-i-support-meshcore","title":"5.8. Q: How can I support MeshCore?","text":"

    A: Provide your honest feedback on GitHub and on MeshCore Discord server. Spread the word of MeshCore to your friends and communities; help them get started with MeshCore. Support Scott's MeshCore development at https://buymeacoffee.com/ripplebiz.

    Support Liam Cottle's smartphone client development by unlocking the server administration wait gate with in-app purchase

    Support Rastislav Vysoky (recrof)'s flasher website and the map website development through PayPal or Revolut

    "},{"location":"faq/#59-q-how-do-i-build-meshcore-firmware-from-source","title":"5.9. Q: How do I build MeshCore firmware from source?","text":"

    A: See instructions here: https://discord.com/channels/826570251612323860/1330643963501351004/1341826372120608769

    Build instructions for MeshCore:

    For Windows, first install WSL and Python+pip via: https://plainenglish.io/python/setting-up-python-on-windows-subsystem-for-linux-wsl-26510f1b2d80

    (Linux, Windows+WSL) In the terminal/shell:

    sudo apt update\nsudo apt install libpython3-dev\nsudo apt install python3-venv\n

    Mac: python3 should be already installed.

    Then it should be the same for all platforms:

    python3 -m venv meshcore\ncd meshcore && source bin/activate\npip install -U platformio\ngit clone https://github.com/meshcore-dev/MeshCore.git\ncd MeshCore\n

    Choose an exact environment listed by pio project config or in the target board's variants/*/platformio.ini. For example:

    pio run -e RAK_4631_repeater\n

    The output is under .pio/build/RAK_4631_repeater/. Set the radio parameters to your current regional preset after flashing; if changing build defaults, keep that local change out of commits unless it is intended for every user.

    "},{"location":"faq/#510-q-are-there-other-meshcore-related-open-source-projects","title":"5.10. Q: Are there other MeshCore related open source projects?","text":"

    A: Liam Cottle's MeshCore web client and MeshCore JavaScript library are open source under MIT license.

    Web client: https://github.com/liamcottle/meshcore-web JavaScript: https://github.com/meshcore-dev/meshcore.js

    "},{"location":"faq/#511-q-does-meshcore-support-atak","title":"5.11. Q: Does MeshCore support ATAK?","text":"

    A: This repository does not ship or document an official ATAK integration. An external integration must account for LoRa airtime, moving endpoints, stale direct paths, and the collision cost of frequent flood fallback. Optional, bounded Companion repeating does not turn every mobile client into a dedicated repeater and does not remove those capacity constraints. Check current community projects before designing a deployment.

    Source

    "},{"location":"faq/#512-q-how-do-i-add-a-node-to-the-meshcore-map","title":"5.12. Q: How do I add a node to the MeshCore Map?","text":"

    A:

    To add a BLE Companion radio, connect to the BLE Companion radio from the MeshCore smartphone app. In the app, tap the 3 dot menu icon at the top right corner, then tap Internet Map. Tap the 3 dot menu icon again and choose Add me to the Map

    To add a Repeater or Room Server to the map, go to the Contact List, tap the 3 dot next to the Repeater or Room Server you want to add to the Internet Map, tap Share, then tap Upload to Internet Map.

    You can use the same companion (same public key) that you used to add your repeaters or room servers to remove them from the Internet Map.

    "},{"location":"faq/#513-q-can-i-use-a-raspberry-pi-to-update-a-meshcore-radio","title":"5.13. Q: Can I use a Raspberry Pi to update a MeshCore radio?","text":"

    A: Yes. Below are the instructions to flash firmware onto a supported LoRa device using a Raspberry Pi over USB serial.

    Instructions for nRF devices like RAK, T1000-E, T114 are immediately after the ESP instructions

    For ESP-based devices (e.g. Heltec V3) you need:

    1. Download the firmware file from https://flasher.meshcore.io.
      • Select the exact board and role. Artifact names and release URLs include a changing version and commit, so copy the current download URL instead of using an example URL from a guide.
      • A non-merged application image is for an already compatible bootloader and partition table. A merged image also contains low-level flash data. Back up settings before either operation; do not assume a merged flash preserves configuration, pairing data, or a previous partition layout.
      • To download a copied URL from the shell, use curl -L '<copied-url>' -o <firmware>.bin.
    2. Confirm the ttyXXXX device path on your Raspberry Pi.
      • Run ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null before and after connecting the board. ESP devices can appear under either name.
    3. Install esptool in a virtual environment.
      • python3 -m venv .venv && . .venv/bin/activate
      • python -m pip install --upgrade esptool
    4. Flash the firmware.
      • For non-merged bin:
        • python -m esptool --port /dev/ttyUSB0 --chip auto write-flash 0x10000 <non-merged-firmware>.bin
      • For merged bin:
        • python -m esptool --port /dev/ttyUSB0 --chip auto write-flash 0x00000 <merged-firmware>.bin

    Instructions for nRF devices:

    For nRF devices (e.g. RAK, Heltec T114) you need the following:

    1. Download the firmware file from https://flasher.meshcore.io.
      • Select the exact board and role, and download its current ZIP package. Do not rename a package from another board or reuse a static versioned URL.
    2. Confirm the ttyXXXX device path on your Raspberry Pi.
      • Run ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null; nRF bootloaders commonly appear as /dev/ttyACM0, but the number can change.
    3. In a virtual environment, install adafruit-nrfutil:
      • python3 -m venv .venv && . .venv/bin/activate
      • python -m pip install --upgrade adafruit-nrfutil
    4. Flash the nRF device.
      • adafruit-nrfutil --verbose dfu serial --package <firmware-package>.zip -p /dev/ttyACM0 -b 115200 --singlebank --touch 1200

    To manage a repeater or room server connected to a Pi over USB serial using shell commands, you need to install picocom. To install picocom, run the following command:

    To start managing your USB serial-connected device using picocom, use the following command:

    From here, reference repeater and room server command line commands in the MeshCore docs here:

    "},{"location":"faq/#514-q-are-there-projects-built-around-meshcore","title":"5.14. Q: Are there projects built around MeshCore?","text":"

    A: Yes, there are many. MeshCore's protocol is open source using the MIT license. The MIT license and the open source protocol makes it very easy for the MeshCore community to build new firmware for radios, applications on mobile devices, map tools, and analysis tools, and integration with other projects like Home Assistant.

    As new MeshCore community projects become available on a weekly basis, we have stopped tracking them here in this FAQ. samuk maintains a very exhaustive list of MeshCore community project at https://github.com/samuk/awesome-meshcore/blob/main/README.md. samuk accepts PRs and merges them regularly.

    "},{"location":"faq/#515-q-are-there-client-applications-for-windows-or-mac","title":"5.15. Q: Are there client applications for Windows or Mac?","text":"

    A: Yes, the same iOS and Android client is also available for Windows and Mac. You can find them together with the Android APK here: https://files.liamcottle.net/MeshCore

    Both the Windows and Mac versions of the client app are fully unlocked and are free to use.

    "},{"location":"faq/#516-q-are-there-any-resources-that-compare-meshcore-to-other-lora-systems","title":"5.16. Q: Are there any resources that compare MeshCore to other LoRa systems?","text":"

    A: Here is a list of MeshCore comparison resources:

    "},{"location":"faq/#6-troubleshooting","title":"6. Troubleshooting","text":""},{"location":"faq/#61-q-my-client-says-another-client-or-a-repeater-or-a-room-server-was-last-seen-many-many-days-ago","title":"6.1. Q: My client says another client or a repeater or a room server was last seen many, many days ago.","text":""},{"location":"faq/#62-q-a-repeater-or-a-client-or-a-room-server-i-expect-to-see-on-my-discover-list-on-t-deck-or-contact-list-on-a-smart-device-client-are-not-listed","title":"6.2. Q: A repeater or a client or a room server I expect to see on my discover list (on T-Deck) or contact list (on a smart device client) are not listed.","text":"

    A:

    You can get the epoch time on https://www.epochconverter.com and use it to set your T-Deck clock. For a repeater and room server, the admin can use a T-Deck to remotely set their clock (clock sync), or use the time command in the USB serial console with the server device connected.

    "},{"location":"faq/#63-q-how-to-connect-to-a-repeater-via-ble-bluetooth","title":"6.3. Q: How to connect to a repeater via BLE (Bluetooth)?","text":"

    A: You can't connect to a device running repeater firmware via Bluetooth. You can connect to devices running the BLE companion firmware via Bluetooth using the Android app.

    "},{"location":"faq/#64-q-my-companion-isnt-showing-up-over-bluetooth","title":"6.4. Q: My companion isn't showing up over Bluetooth?","text":"

    A: Make sure that you flashed the Bluetooth companion firmware and not the USB-only companion firmware.

    "},{"location":"faq/#65-q-i-cant-connect-via-bluetooth-what-is-the-bluetooth-pairing-code","title":"6.5. Q: I can't connect via Bluetooth, what is the Bluetooth pairing code?","text":"

    A: The default Bluetooth pairing code is 123456 on builds without a physical display. Display-equipped Companion builds generate a six-digit PIN at boot and show it when a client requests pairing. The display stays awake on the PIN page until Bluetooth connects or the two-minute pairing window expires.

    "},{"location":"faq/#66-q-my-heltec-v3-keeps-disconnecting-from-my-smartphone-it-cant-hold-a-solid-bluetooth-connection","title":"6.6. Q: My Heltec V3 keeps disconnecting from my smartphone. It can't hold a solid Bluetooth connection.","text":"

    A: Heltec V3 has a very small coil antenna on its PCB for Wi-Fi and Bluetooth connectivity. It has a very short range, only a few feet. It is possible to remove the coil antenna and replace it with a 31mm wire. The BT range is much improved with the modification.

    "},{"location":"faq/#67-q-my-rakt1000-exiao_nrf52-device-seems-to-be-corrupted-how-do-i-wipe-it-clean-to-start-fresh","title":"6.7. Q: My RAK/T1000-E/xiao_nRF52/... device seems to be corrupted, how do I wipe it clean to start fresh?","text":"

    A: If you're able to connect to the device from your MeshCore app 1. Navigate to Gear icon(Settings) on the upper right corner 2. Click on Export Settings button, choose Select All and Confirm. This will save your Node configuration 3. Now choose Factory Reset option 4. Confirm that you want to reset your device 5. Go to Bluetooth System settings and remove pairing of the device, so you can connect again 6. Connect your device in the app and enter the PIN 7. Navigate to Gear icon(Settings) on the upper right corner 8. Click on Import Settings and choose the file from 2., click on Select All and Confirm 9. Find Reboot button on of the Settings list

    B: You're not able to connect to the App 1. Connect USB cable to your device 2. Go to https://flasher.meshcore.io 3. Search for your device in the list 4. Choose Companion Bluetooth 5. Press Enter DFU mode button, choose your USB device 6. Press Erase Flash button, choose the device again and wait until it completes 7. Press Flash! button and choose the USB device last time 8. The device is erased and newest firmware is installed 9. You might need to remove the pairing in Bluetooth System Settings in order to re-pair the app again.

    An erase application or ordinary application ZIP/UF2 does not repair a damaged or partially overwritten SoftDevice. This can happen after flashing an nRF52 image for a different board or SoftDevice layout. A characteristic failure is that the application can boot and read its filesystem, but reboots or hangs when it writes or formats internal flash. Repeating Erase Flash is not a recovery for that condition.

    Recover it with a combined bootloader + SoftDevice package for the exact board (including XIAO Sense versus non-Sense), installed through serial/BLE DFU or SWD. With adafruit-nrfutil, the serial form is:

    adafruit-nrfutil dfu serial -p <port> \\\n  -pkg <exact-board-bootloader-with-softdevice>.zip -sb -t 1200\n

    Then install the exact-board MeshCore application. Do not use an application- only UF2, filesystem formatter, or a similarly named package from another nRF52 board as a substitute. See the confirmed XIAO recovery in issue #3284 and obtain the matching package from the OTAFIX releases.

    Separately, starting in firmware version 1.7.0, there is a CLI Rescue mode. If your device has a user button (e.g. some RAK, T114), you can activate the rescue mode by holding down the user button of the device within 8 seconds of boot. Then you can use the 'Console' on https://flasher.meshcore.io

    "},{"location":"faq/#68-q-webflasher-fails-on-linux-with-failed-to-open","title":"6.8. Q: WebFlasher fails on Linux with failed to open","text":"

    A: If the usb port doesn't have the right ownership for this task, the process fails with the following error:

    NetworkError: Failed to execute 'open' on 'SerialPort': Failed to open serial port.

    Allow user access on your USB port:

    sudo setfacl -m u:$USER:rw /dev/ttyUSB0

    "},{"location":"faq/#7-other-questions","title":"7. Other Questions:","text":""},{"location":"faq/#71-q-how-to-update-nrf-rak-t114-seeed-xiao-companion-repeater-and-room-server-firmware-over-the-air-using-the-new-simpler-dfu-app","title":"7.1. Q: How to update nRF (RAK, T114, Seeed XIAO) companion, repeater and room server firmware over the air using the new simpler DFU app?","text":"

    A: The steps below work on both Android and iOS as nRF has made both apps' user interface the same on both platforms:

    1. Download nRF's DFU app from iOS App Store or Android's Play Store, you can find the app by searching for nrf dfu, the app's full name is nRF Device Firmware Update
    2. On https://flasher.meshcore.io, download the ZIP version of the firmware for your nRF device (e.g. RAK or Heltec T114 or Seeed Studio's Xiao)
    3. From the MeshCore app, log in remotely to the repeater you want to update with admin privileges
    4. Go to the Command Line tab, type start ota and hit enter.
    5. You should see OK to confirm the repeater device is now in OTA mode
    6. Run the DFU app, then tap Settings in the top-right corner
    7. Enable Packet receipt notifications, and change Number of Packets to 10 for RAK, 8 for T114. 8 also works for RAK.
    8. Select the firmware zip file you downloaded
    9. Select the device you want to update. If the device you want to update is not on the list, try enabling OTA on the device again
    10. If the device is not found, enable Force Scanning in the DFU app
    11. Tap Upload to begin OTA update
    12. If it fails, try toggling Bluetooth on your phone. If that doesn't work, try rebooting your phone. If you keep getting failures at the \"Enabling Bootloader\" step, try forgetting the nRF board in your iOS or Android device's Bluetooth settings and re-pair it through the DFU app.
    13. Wait for the update to complete. It can take a few minutes.
    14. It is strongly recommended that you install and use the OTAFIX bootloader at https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX.
    15. A companion must be running a build that exposes start ota; consult the current flasher release notes rather than relying on a version threshold in this FAQ.
    16. Please see the MeshCore Blog for additional information on OTA firmware flashing:
      • https://blog.meshcore.io/2026/04/06/otafix-bootloader
      • https://blog.meshcore.io/2026/04/02/nrf-ota-update
    "},{"location":"faq/#711-q-can-i-update-seeed-studio-wio-tracker-l1-pro-using-ota","title":"7.1.1 Q: Can I update Seeed Studio Wio Tracker L1 Pro using OTA?","text":"

    A: You can flash this safer bootloader to the Wio Tracker L1 Pro https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX

    After this bootloader is flashed onto the device, you can trigger an over-the-air update using Bluetooth by holding the button next to the D-Pad and then clicking the reset button. Then follow the same OTA update instructions above. You can skip the start ota instruction and start the update using the DFU app.

    "},{"location":"faq/#72-q-how-to-update-esp32-based-devices-over-the-air","title":"7.2. Q: How to update ESP32-based devices over the air?","text":"

    A: For ESP32-based devices (e.g. Heltec V3):

    1. On https://flasher.meshcore.io, download the current non-merged application image for the exact ESP32 board and role (no merged in the filename).
    2. From the MeshCore app, log in remotely to the repeater you want to update with admin privileges.
    3. Go to the Command Line tab, type start ota and hit enter.
    4. You should see OK to confirm the repeater device is now in OTA mode.
    5. If the ESP32 is not already joined to Wi-Fi, start ota starts an open hotspot named MeshCore-OTA at 192.168.4.1. If it is already joined, the command reports and uses its router-assigned address instead.
    6. From your phone or computer, connect to the MeshCore-OTA hotspot when one was started.
    7. Open the URL reported by start ota and upload the non-merged bin from the flasher. When the device started MeshCore-OTA, the URL is http://192.168.4.1/update.

    On an MQTT observer, stop WebConfig before running start ota; both servers use HTTP port 80. Likewise, stop the OTA uploader before running start webconfig.

    "},{"location":"faq/#73-q-is-there-a-way-to-lower-the-chance-of-a-failed-ota-device-firmware-update-dfu","title":"7.3. Q: Is there a way to lower the chance of a failed OTA device firmware update (DFU)?","text":"

    A: Yes, developer che aporeps has an enhanced OTA DFU bootloader for nRF52 based devices. With this bootloader, if it detects that the application firmware is invalid, it falls back to OTA DFU mode so you can attempt to flash again to recover. This bootloader has other changes to make the OTA DFU process more fault tolerant.

    Refer to https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX for the latest information.

    Currently, the following boards are supported:

    "},{"location":"faq/#74-q-are-the-meshcore-logo-and-font-available","title":"7.4. Q: Are the MeshCore logo and font available?","text":"

    A: Yes, it is on the MeshCore GitHub repo here: https://github.com/meshcore-dev/MeshCore/tree/main/logo

    "},{"location":"faq/#75-q-what-is-the-format-of-a-contact-or-channel-qr-code","title":"7.5. Q: What is the format of a contact or channel QR code?","text":"

    A:

    Channel: meshcore://channel/add?name=<name>&secret=<secret>

    Contact: meshcore://contact/add?name=<name>&public_key=<secret>&type=<type>

    Where &type is:

    "},{"location":"faq/#76-q-how-do-i-connect-to-the-companion-via-wi-fi-eg-using-a-heltec-v3","title":"7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?","text":"

    A: Flash an ESP32 companion Wi-Fi build. Its shared WebUI is enabled by default. If it has no saved Wi-Fi configuration, it starts an open MeshCore-Setup-XXXX access point. Join it and browse to http://192.168.4.1/. The wizard configures WiFi, node identity, and radio settings; MQTT companion builds add an MQTT step and MQTT tab, while non-MQTT companions remove those controls. After saving, the page and device screen show the station IP assigned by your router.

    If a saved network remains unreachable for two minutes, the setup AP starts as a fallback. If the original network returns, the companion closes that recovery portal and resumes normal LAN operation.

    The setup AP always uses 192.168.4.1; the companion's station IP is assigned by DHCP and can be different. A real WIFI_SSID and WIFI_PWD can still be supplied at compile time as initial defaults, but they are no longer required.

    On the LAN, the WebUI remains available at the companion's station IP and the companion protocol remains available on TCP port 5000. Companion builds do not have an admin CLI password, so this configuration page trusts the local LAN; do not put the companion on an untrusted WiFi network.

    ESP32 repeater and room-server builds use the same page, but keep it off by default. Run set webui on from the admin CLI, open the reported LAN URL, and sign in with the admin password; get webui reports its current address. Their MQTT observer variants expose the MQTT controls, while ordinary builds do not. The two 4 MB LilyGo_TLora_V2_1_1_6_*_observer_mqtt builds retain CLI-only configuration so their flash can keep two app slots for LoRa OTA.

    "},{"location":"faq/#77-q-i-have-a-station-g2-or-a-heltec-v4-or-an-ikoka-stick-or-a-radio-with-an-ebyte-e22-900m30s-or-an-ebyte-e22-900m33s-module-what-should-their-transmit-power-be-set-to","title":"7.7. Q: I have a Station G2, or a Heltec V4, or an Ikoka Stick, or a radio with an EByte E22-900M30S or an EByte E22-900M33S module, what should their transmit power be set to?","text":"

    A: For companion radios, you can set these radios' transmit power in the smartphone app. For repeater and room server radios, you can set their transmit power using the command line command set tx. You can get their current value using command line command get tx

    [WARN] WARNING: Set these values at your own risk. Incorrect power settings can permanently damage your radio hardware.

    Device / Model Region / Description In-App Setting (dBm) Target Radio Output Notes Station G2 Reference US915 Max Output 19 dBm 36.5 dBm (4.46W) US915 Max at 1dB compression point 16 dBm 35 dBm (3.16W) 1dB compression point EU868 Max at 1dB compression point 15 dBm 34.5 dBm (2.82W) 1dB compression point US915 1W Output 10 dBm 1W Refer to your local government's requirements EU868 1W Output 9 dBm 1W Refer to your local government's requirements Ikoka Stick E22-900M30S 1W Model 19 dBm 1W DO NOT EXCEED (Risk of burn out) data sheet Ikoka Stick E22-900M33S 2W Model 9 dBm 2W DO NOT EXCEED (Risk of burn out) data sheet Refer to your local government's requirements Heltec V4 Standard Output 10 dBm 22 dBm (~0.15W) High Output 22 dBm 28 dBm (~0.5W to 0.6W)"},{"location":"faq/#78-q-how-do-i-use-ethernet-with-a-rak4631","title":"7.8. Q: How do I use Ethernet with a RAK4631?","text":"

    A: MeshCore supports Ethernet on RAK4631 boards using the RAK13800 WisBlock Ethernet module (based on the W5100S chip).

    Hardware required: - RAK4631 WisBlock Core - RAK19007 or RAK19018 WisBlock Base Board (with an available IO slot) - RAK13800 WisBlock Ethernet module - Ethernet cable connected to a network with a DHCP server

    Firmware: Flash one of the Ethernet-enabled firmware variants: - RAK_4631_repeater_ethernet - Repeater with Ethernet CLI access - RAK_4631_room_server_ethernet - Room server with Ethernet CLI access - RAK_4631_companion_radio_ethernet - Companion radio over Ethernet (replaces BLE)

    Connecting: - The device obtains an IP address via DHCP automatically on boot. - For repeaters and room servers, connect to the device on TCP port 23 using any TCP client (e.g. nc <ip> 23 or PuTTY in raw mode). This gives you the same CLI available over serial/USB. - For companion radio firmware, the Ethernet interface replaces BLE as the transport to companion apps. Connect on TCP port 5000 (same as the WiFi companion radio). - Use the eth.status CLI command to check connection status and see the assigned IP address.

    "},{"location":"filter_tool/","title":"Filter policy playground","text":"

    Build a forwarding policy, see its readable definition, and simulate how a repeater handles a packet. Rules match received packet facts, then apply actions such as dropping, scoping, rate-limiting, or retrying a flood.

    Everything runs locally in this browser. Channel keys, packet facts, and policy drafts are not uploaded anywhere.

    Policy design preview

    The phases and core conditions model current FPF7 behavior, including its forward rows, scope rewrites, and shared blacklist. The readable policy language, JSON, and Base64 bundle are still a prototype: current firmware is configured with set flood.* commands and cannot install a bundle from this page.

    "},{"location":"filter_tool/#build-a-policy","title":"Build a policy","text":"

    Start with an example or build a rule, then test the draft against packet facts in the simulator below. The examples draw from Flood Filtering and Moderation.

    Remote login and direct routes

    This policy only decides whether a relay retransmits a flood. Direct packets and local delivery are unaffected. Rules matching the login/admin family can still reduce multi-hop remote-login reach, so the analyzer flags them.

    Example policies Choose one to load its full rules into the builder and draft.

    Read each summary as when all conditions match, do the actions. A match alone does not stop forwarding.

    Payload selectors type= valueMatches grp_dataOnly the named payload type; see every exact type below anyEvery payload type class:groupGRP_TXT and GRP_DATA class:loginREQ, RESPONSE, TXT_MSG, ANON_REQ, and PATH class:otherEvery remaining payload type that is not group or login Other conditions and actions FieldMeaning hops=Received hop count: all, 3+, 2-6, or 3 channel=* means no channel condition; a name or key authenticates one group channel; hash:XX is an unauthenticated one-byte fallback rx.scope=Original incoming transport scope path=Path prefix, blacklist, bucket, or loop match tempradio=Temporary-radio state do dropDo not retransmit do scope=Set the outgoing transport scope do rate=Apply a per-minute rate and burst do timing=Select fast, normal, or slow scheduling

    There is no class:txt. Use type=grp_txt for channel text or type=txt_msg for peer text.

    Set #BlackHole86 scope on all #rgdata group traffic when type=class:group hops=all channel=#rgdata do scope=#BlackHole86 timing=fast Add #BlackHole86 scope to unscoped #rgdata data when type=grp_data hops=all channel=#rgdata rx.scope=none do scope=#BlackHole86 timing=fast Replace #usa with #BlackHole86 on #rgdata data when type=grp_data hops=all channel=#rgdata rx.scope=scope:usa do scope=#BlackHole86 timing=fast Rate-limit packets whose path starts with 860C when type=any hops=all path=prefix:860C do rate=10/min burst=10 Drop selected flood types at their hop limits when type=control hops=1+ do dropwhen type=req hops=3+ do drop; same for type=grp_datawhen type=response hops=9+ do drop; same for type=anon_req and type=path Rate-limit Public messages from \u201cNoisy User\u201d when type=grp_txt hops=all channel=public sender=\"Noisy User\" do rate=5/min burst=5 Stop forwarding traffic from blacklisted internet gateways when type=any hops=all path=blacklist do drop Drop OTA outside temporary-radio mode and distant #wardriving when type=ota hops=all tempradio=inactive do dropwhen type=any channel=#wardriving hops=5+ do drop Set #BlackHole86 scope on login and bucket-matched other traffic when type=class:login hops=all do scope=#BlackHole86 timing=fastwhen type=class:other hops=all path=bucket:2 do scope=#BlackHole86 timing=slow

    Rule builder

    Reset form Common match settings Payload type or class Any payload type Class: group text and data Class: login/admin family Class: every other payload REQ RESPONSE TXT_MSG ACK ADVERT GRP_TXT GRP_DATA ANON_REQ PATH TRACE MULTIPART CONTROL OTA Reserved type 13 Reserved type 14 RAW_CUSTOM Received hops Channel (optional) Original incoming scope Path matcher No path condition Ordered 1/2/3-byte pbyte prefix Passive blacklist Path bucket 1 Path bucket 2 Path bucket 3 Path bucket 4 Path bucket 5 Path bucket 6 Own-ID loop: strict Own-ID loop: moderate Own-ID loop: minimal Ordered pbyte prefix Decrypted sender (optional) Temporary-radio state Either state Inactive Active Common actions

    Choosing a phase-specific action automatically selects a compatible phase and ACL owner. Open the advanced section to inspect or override those choices.

    Forwarding verdict No verdict change Drop / do not retransmit Region-gate decision Leave global gate unchanged Require original allowed region Bypass global gate for this packet Scope rewrite target Do not rewrite scope Regionless public scope Configured region Target name Token rate per minute (optional) Burst tokens Rewrite timing Inherit normal behavior Fast-track rewrite Normal queue timing Slow receive/transmit timing Queue priority Inherit High Normal Low Retry path bucket No retry action Bucket 1 Bucket 2 Bucket 3 Bucket 4 Bucket 5 Bucket 6 Retry attempts Decision tag (optional) Advanced execution, ownership, and flood route

    Direct packets already carry a supplied path and are intentionally outside this engine. The route condition below only distinguishes unscoped floods from transport-scoped floods.

    Stable rule ID Processing phase 1 - Incoming scope gate 2 - Scope rewrite 3 - Forwarding decision 4 - Decrypted content 5 - Scheduling and retry Rule owner / capability ACL 4 - Region/scope manager ACL 5 - Filter manager Administrator Firmware-managed system rule Priority Mode Active Shadow - observe only Disabled Rule processing after a match Continue to later rules Skip later rules in this phase Skip all later policy rules Flood route Either flood route Unscoped flood only Transport-scoped flood only Readable policy definition

    Add rule to policy Copy definition

    Policy draft

    Clear policy Approximate target budget Compact STM32 - 2 KB nRF52 - 8 KB Classic ESP32 - 16 KB Roomy ESP32 - 64 KB

    Rules match the same immutable packet facts. Ordering is phase, then descending priority, then authenticated channel, raw hash, or wildcard specificity, then stable rule ID. Drop decisions are sticky.

    Add a rule or load an example to start exploring.

    Immutable packet simulator

    Reset packet Read a raw MeshCore packet

    Paste an on-air packet as hexadecimal. The decoder follows the MeshCore wire format, displays its header, route, pbyte path, and clear payload envelope, then loads only facts actually present on the wire into the simulator.

    Runs locally Raw packet hex 014e912ceebb98918b86772df5dacf1bcba9e4127ffffaa8665596aa3e2903a3b1901fdc53497dfca6b5d7df2d771bea68de Decode and load packet Received route Unscoped flood Transport-scoped flood Payload type REQ RESPONSE TXT_MSG ACK ADVERT GRP_TXT GRP_DATA ANON_REQ PATH TRACE MULTIPART CONTROL OTA Reserved type 13 Reserved type 14 RAW_CUSTOM Received hops Channel Received pbyte path Original scope status Unscoped Allowed region Unknown or denied scope Original scope name Resolved region name Decrypted sender Passive blacklist result No match Matched Matching path buckets Own-ID loop result No loop match Strict only Moderate and strict Minimal, moderate, and strict Temporary radio is active Run policy simulation

    Import and explain

    Accepts one-line policy set ... when ... do ... definitions, playground JSON, or a playground Base64 bundle.

    Explain input Load into builder

    Export

    Readable policy Policy JSON Playground Base64

    Proposed human-readable definition. Current firmware does not accept it yet.

    Structured draft used by this page.

    Browser-playground interchange only. It is not the final packed firmware codec.

    Copy visible export Download JSON"},{"location":"filter_tool/#filter-builder-title","title":"Create a policy rule","text":""},{"location":"filter_tool/#filter-policy-title","title":"Rules in execution order","text":""},{"location":"filter_tool/#filter-simulator-title","title":"Explain an evaluation","text":""},{"location":"filter_tool/#filter-import-title","title":"Paste readable policy or a saved draft","text":""},{"location":"filter_tool/#filter-export-title","title":"Move or save this design","text":""},{"location":"filter_tool/#payload-type-reference","title":"Payload type reference","text":"

    Use an exact type= value when a rule should match only one payload type. The class column shows which broader selector also matches it.

    type= value Payload Class req Request class:login response Response class:login txt_msg Peer text message class:login ack Acknowledgment class:other advert Node advertisement class:other grp_txt Group-channel text class:group grp_data Group-channel datagram class:group anon_req Anonymous request class:login path Returned path class:login trace Path trace class:other multipart One frame in a multipart sequence class:other control Control or discovery data class:other ota OTA-over-LoRa data class:other 13 Reserved payload type 13 class:other 14 Reserved payload type 14 class:other raw_custom Application-defined raw data class:other"},{"location":"filter_tool/#current-fpf7-command-mapping","title":"Current FPF7 command mapping","text":"Firmware command FPF7 role flood.rule / flood.filter Forward-phase match and action rows flood.channel.data Compatibility view over one visible type=grp_data forward drop row flood.channel.scope Scope-rewrite phase rows flood.filter.blacklist One shared unordered path-ID set referenced by path=blacklist rows

    Generalized repeaters expose 63 forward rows and commit those sections together. The blacklist is useful for refusing to retransmit floods associated with internet gateways dumping bulk traffic, but a path ID is truncated and unauthenticated; it identifies a routing pattern, not a person.

    "},{"location":"filter_tool/#proposed-evaluation-contract","title":"Proposed evaluation contract","text":"

    The simulator uses these rules:

    1. Only flood retransmission enters the policy. Direct routing and local packet delivery remain outside it.
    2. Every matcher reads the same immutable receive-time packet facts.
    3. Rules run by phase and descending numeric priority. At an equal priority, authenticated channel matches run before raw one-byte hash matches, which run before wildcard channel matches; stable rule ID breaks the remaining tie. An explicitly higher numeric priority overrides channel specificity.
    4. A drop decision is sticky and cannot be undone by a later rule.
    5. The first matching scope, timing, queue, and retry action in execution order wins.
    6. All matching token-bucket rate constraints remain attached to the decision.
    7. stop=phase skips later rules in that phase. stop=policy skips later configurable rules, but never mandatory packet validation or radio safety.
    8. Shadow rules report what they would do without changing the decision or stopping other rules.
    9. Expensive facts such as channel-key matching, decryption, and path-table lookup are resolved once per packet and reused by every matching rule.

    The byte-budget display is deliberately approximate until the packed firmware codec exists. It demonstrates why simple mappings should not reserve a maximum- sized structure for every possible condition and action.

    "},{"location":"firmware_memory_budget/","title":"Firmware memory checks","text":"

    Every firmware environment runs scripts/check_firmware_ram.py against its linked ELF before producing or uploading an image. build.sh, including option 3, also requires a passing report before collecting release files. Native host tests do not use a microcontroller RAM budget.

    The check reserves room for enabled runtime allocations as well as static data. A firmware image fitting its board's reported RAM total is insufficient: the display, packet pool, USB, Bluetooth workers and WiFi can allocate after startup. The T096 Full 1.17.1.5 report exposed this distinction.

    "},{"location":"firmware_memory_budget/#what-is-counted","title":"What is counted","text":"Platform Source of available runtime RAM nRF52 Actual __HeapBase and __HeapLimit; excludes SoftDevice, retained state, ISR stack and the dedicated 64 KiB mOTA arena where present ESP32, S3, C3, C6 Linked ESP-IDF memory-region, capability and reservation tables; only internal, byte-addressable heap counts RP2040/RP2350 __end__ to __HeapLimit, according to the selected linker STM32 _end to _estack, minus _Min_Stack_Size

    ESP32 PSRAM, instruction-only RAM and RTC RAM never increase the internal budget. On chips other than classic ESP32, the late-reclaimed ROM stack region is excluded because its silicon-specific reservations are only known at boot. Classic ESP32 additionally retains its existing 8 KiB static DRAM check.

    The policy adds allowances for task stacks, radio packet pools, screen objects and pixel buffers, filesystem/sensor allocations, enabled wireless stacks, MQTT connections, OTA scratch and transient allocations. A 160x80 ST7735 framebuffer needs 25,602 bytes; an OLED allowance is 4 KiB. nRF52 Full with that color framebuffer must have at least 72 KiB available before startup allocations. Headless and OLED devices use their own smaller totals. The JSON lists each component and checks the largest available region against the largest planned single allocation.

    The small-screen message layout retains complete 160-byte messages. Its expanded preview records add 2,816 bytes to the startup allowance and increase the contiguous history allocation budget. The V4 can allocate that history in PSRAM; the guard conservatively reserves internal capacity so that PSRAM availability cannot hide a RAM shortage.

    These are engineering allowances for supported configurations, not measured free heap after boot or a guarantee against every future allocation failure. Unknown platforms, unknown display drivers and missing linker metadata fail closed. MESH_MIN_RUNTIME_HEAP can raise a profile's requirement; it cannot lower the calculated requirement. Add an allocation allowance when adding a display, transport or other substantial feature.

    Wireless Paper Full keeps 350 contacts and 256 offline frames by lending the upper 128 queue slots to its mOTA workspace during a session. An idle WiFi mOTA listener leaves all 256 slots available. More than 128 unread frames refuses the loan; sync messages with an app first. USB/TCP source detach or disconnect returns all 256 slots and releases the ESP32 proof/leaf scratch buffers. The display and simultaneous USB, Bluetooth and WiFi remain enabled.

    "},{"location":"firmware_memory_budget/#release-evidence-and-regression-tests","title":"Release evidence and regression tests","text":"

    Full Companions without PSRAM also use 16-entry path and shared-secret caches. The optional NimBLE capacity trials retain the same RAM guards at 350 contacts and 256 normal offline frames.

    Each newly built firmware has a matching .memory.json report. It records the linked ELF SHA-256, available internal RAM, required RAM, largest region, and SHA-256 hashes for the actual firmware files and capability manifest. Packaging and resumed builds reject absent reports, failures, stale ELFs, missing files and changed firmware. Do not reuse a report for another build.

    Run PlatformIO commands sequentially in this checkout:

    python3 -B test/test_firmware_ram.py\npython3 -B test/test_t096_full_memory.py\npython3 -B test/test_nrf52_ble_startup.py\npython3 -B test/test_shared_mota_queue.py\npython3 -B test/test_cascade_release_package.py\npio test -e native -f test_ota\n

    Tests cover all resolved firmware environments' hooks, real ELF parsing, allocator table formats, excluded memory, allocation failure, package/report binding and the published T096 failing budget. Shared mOTA tests exercise complete transfers, queue wraparound, unread-message order, source ownership, stop/disconnect and repeated reuse. Bluetooth tests inject task and service startup failures. The manual staging buffer also has allocation-failure and repeated release tests. ESP32 tests run the actual WiFi mOTA listener and source framing through complete transfers, idle polling, queue-full refusal, network loss, CLI detach and USB/TCP ownership changes under address/leak sanitizers.

    For older releases without saved ELFs, an audit can compare their ESP allocator tables against a matching pinned SDK ELF and read reservations from the published application itself using scripts/audit_esp32_image_ram.py. Unrecognized layouts require another matching reference or a historical rebuild. An audit must identify original-log/linker calculations separately from new ELF checks and verify the published firmware hashes.

    Physical validation remains necessary: boot with and without USB, pair and exchange Bluetooth messages, visit every screen, wake with the button, enable logging/MQTT, transfer mOTA, and monitor heap during a sustained workload. See memory monitoring for runtime diagnostics.

    "},{"location":"firmware_picker/","title":"Firmware picker","text":"

    Each result now includes Restore your settings after flashing. Its commands follow the selected logging mode and the exact image's verified role/hardware controls. Select On, Off, or Check to view and copy the commands; Companion MQTT and GPS show their app/WebConfig steps instead. See feature switches by role for the full reference. The USB web console works with the default ASCII terminal on Full Companion and infrastructure roles.

    Pick the choices in any order. Every selection narrows all the other controls to firmware combinations that were actually built in the current release set. The optional chip-family filter (ESP32, nRF52, RP2040, or STM32) can narrow the hardware list first. You can skip it: picking hardware fills it in automatically.

    The picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly from the published firmware assets.

    Current release set

    Loading release information...

    For a new installation, choose the exact board and role, prefer a FULL / complete profile when it is available, and select Full install / layout migration (merged .bin). Narrower profiles remain available when their reduced transport or feature set is intentional.

    Pick in any order. Use Any to clear one choice, or clear everything with the button below. The address bar updates with your choices; copy its URL to reopen or share the same selection.

    Optional: chip family

    Skip this if you know your board. Picking hardware selects its chip family automatically. Choose Any to clear this filter.

    Chip family Any chip family - skip this filter Hardware Loading hardware... Hardware variant Choose hardware first Install operation Loading install choices... Firmware role Loading roles... Logging / MQTT Loading logging choices... OTA capability Loading OTA choices... Connection / bridge mode Loading modes... Feature profile Loading profiles... Firmware variant Loading variants... Clear all choices Copy link to settings Link to these settings

    Loading the current firmware catalog...

    Exact firmware match

    Recommended download

    No exact firmware matched

    Browse all firmware releases Advanced: search current release filenames

    Use this for uncommon board suffixes or expert recovery. A filename match is not a board-identity check.

    Filename contains"},{"location":"firmware_picker/#what-the-choices-mean","title":"What the choices mean","text":"Choice Use Companion A phone, computer, or host application controls the radio Repeater Standalone mesh relay Room Server Hosts room conversations and history Sensor / telemetry Publishes supported sensor data Terminal Chat Standalone serial-terminal interface USB logging / USB-connected MQTT Node remains attached to a computer over a data-capable USB cable Wi-Fi MQTT observer Firmware connects directly to MQTT over Wi-Fi; this is not USB logging USB logging + Wi-Fi MQTT Unified FULL image sends to both paths; avoid two publishers aimed at the same broker unless messages are deduplicated No logging Normal standalone operation without the dedicated logging/MQTT profile LoRa OTA repeater Repeater profile that can stage an exact matching update received over LoRa LoRa OTA source only Full Companion serving a host-supplied update to another node without self-installing it

    Connection and bridge choices depend on the selected role. Companion firmware may offer Full, combined USB + Bluetooth, Bluetooth, USB, Wi-Fi, serial, or Ethernet transports. Normal repeater firmware includes runtime-controlled RS-232 support where the board has room; use set bridge.enabled on after configuring bridge.uart and bridge.baud. The Wio-E5 remains the capacity exception and offers a separate RS-232 image. Choose Wi-Fi MQTT under Logging / MQTT; it is an output mode, not a second connection choice. Repeaters may still offer separate ESP-NOW or Ethernet bridge firmware because those paths use different compiled drivers.

    An ESP-NOW bridge target keeps LoRa as its primary mesh radio. Its runtime bridge.format setting chooses the peer protocol: wrapped (the backward-compatible bridge-to-bridge default using bridge.secret) or raw (direct MeshCore ESP-NOW LR frames for Generic_ESPNOW, SenseCapIndicator-ESPNow, and other primary-ESP-NOW nodes). This is one firmware choice, not two board images. Match bridge.channel to the primary nodes' espnow.channel before selecting set bridge.format raw. For Heltec V4 specifically, companion_radio_full is still a LoRa-primary Companion; choose the existing heltec_v4_repeater_bridge_espnow firmware to make that board the LoRa/ESP-NOW gateway. Use its exact merged artifact when changing roles or partition layouts.

    "},{"location":"firmware_picker/#share-a-selection","title":"Share a selection","text":"

    Select any combination of choices, then copy the address bar or use Copy link to settings. Opening the link restores those choices after the release catalog loads. Partial selections work too, so you can share a board and role while leaving other choices open. Clear all choices removes the picker parameters from the URL. Changes update the current browser-history entry without reloading the page or adding a Back-button entry for every click.

    For example, RAK3401 repeater with internal storage preselects that board, role, OTA capability, and storage profile.

    The query parameters are chipFamily, hardwareFamily, hardware, role, logging, ota, mode, feature, variant, and install. Values use the picker's internal identifiers rather than the displayed labels. The generated link also records chipAuto so automatic chip-family selection or an explicit Any choice behaves the same after reopening. Existing section anchors and unrelated query parameters are preserved.

    Links use the current release catalog. If a linked choice is no longer available or conflicts with another choice, the picker identifies it and asks you to review the remaining selections. The downloadable HTML's Copy link to settings button creates a public website link that other people can open.

    "},{"location":"firmware_picker/#full-versus-standard","title":"FULL versus standard","text":"

    For a new installation, use the FULL / complete profile when it exists and the board has enough flash. FULL profiles keep the complete supported feature set and CLI. Standard profiles remain useful for boards without a FULL build, for an intentionally narrower transport, or when retaining an existing compatible partition layout.

    Changing between standard and FULL ESP32 layouts requires the exact-board merged image over USB. A running application cannot safely move its own active and inactive partitions.

    Current full-usb-wifi profiles use one binary for no external output, USB packet logging/USB-connected MQTT, direct WiFi MQTT, or both. The picker shows that same exact binary for each compatible logging choice; select the saved runtime mode with set logging.output off|usb|wifi|both. A FULL logging-fallback profile is listed only when no WiFi MQTT sibling exists; it appears for both the no-output and USB choices because set usb.logging off|on is persistent. On a fresh unified FULL install with no saved SSID, the setup AP and WiFi radio remain available for 30 minutes per boot, then turn off automatically until the next reboot or power cycle. An explicit administrator start webconfig remains available as an override. A saved SSID switches to the normal indefinite reconnect behavior instead.

    Full Companion profiles use one binary for USB, BLE, ordinary Wi-Fi on ESP32, source-only LoRa OTA, Terminal Chat, optional USB packet logging, and any board-qualified serial or Ethernet Companion transport. Bulk builds therefore omit separate attached-transport, Terminal Chat, and USB-logging artifacts whenever the exact Full recipe exists. RAK4631 repeater and room-server Ethernet images remain separate roles. Fresh installs default to logging off. Heltec V3 and base OLED V4 Full images also contain the former direct Wi-Fi MQTT Companion capability, configured at runtime through WebConfig, so their separate companion_radio_wifi_mqtt artifacts are omitted from canonical builds as well.

    When Full Companion does not fit but a matching USB Companion does, that USB artifact also supplies Terminal Chat and replaces its standalone release image. Heltec E290 and T190 now publish one Full USB + BLE + WiFi Companion; their old combined and single-transport names are explicit-build compatibility aliases. SSD1306 Full Companion builds use set display.rotation 90|180|270; 0 restores the board default, so a separate rotated release image is not recommended.

    Ordinary non-OTA roles also use one artifact for normal operation and USB logging. On ESP32 1.17.1.5, run set powersaving off before set usb.logging on. Select the saved mode with set usb.logging off|on; no -logging- artifact is emitted. KISS, BLE-only Companion, and constrained LoRa OTA repeater images retain their protocol/partition contracts and do not inherit plaintext USB logging.

    nRF52 Full Companion keeps the multi-role primary interface on 00; it starts as an ASCII terminal and automatically hands a complete < frame to Binary Companion. set usb.logging on reboot adds its plaintext interface 02.

    Every ESP32 Full Companion instead exposes one USB TTY. Logging is off by default, so the TTY serves the ASCII/Binary Companion switcher. On 1.17.1.5, run these two text commands to enable USB logging:

    set powersaving off\nset usb.logging on\n

    The second command turns that same TTY into an input-capable plaintext CLI/logging stream; framed Binary Companion is unavailable on USB while logging owns it. set usb.logging off stops the logs and leaves the TTY in the normal ASCII terminal, matching a fresh Full installation. Send +++MESHCORE-TERM-STOP, or let a Companion app send a valid framed probe, to switch from there to Binary Companion. BLE and Wi-Fi Companion remain usable while the USB TTY is logging. ESP32 Full builds use the repository's Arduino-ESP32 2.x base where the board supports it; RC32 and ESP32-C6 retain their board-required Arduino 3.x platform but still expose only one USB TTY. A second ESP32 CDC interface is not part of the release profile.

    The picker includes the power-saving workaround when selecting USB or USB + WiFi logging on ESP32 1.17.1.5. WiFi/MQTT-only logging does not need it while the Repeater/Room Server bridge is running; check get mqtt.running. The workaround is not added to nRF52 directions. See logging by role for the saved settings and the original firmware's USB sleep issue.

    "},{"location":"firmware_picker/#installation-methods","title":"Installation methods","text":"File Use -merged.bin Erase/fresh install, recovery, role migration, or partition-profile change on ESP32 over USB Non-merged .bin Update an existing same-board, same-role, same-partition installation .zip Native nRF52 Serial DFU update package; it is not an extra archive .uf2 UF2 bootloader drag-and-drop install or update .hex Erase/recovery flash with a supported wired programmer

    Never send a merged ESP32 image through browser OTA or LoRa OTA. Back up the node configuration and verify every filename suffix before flashing.

    "},{"location":"firmware_picker/#lora-ota-and-otafix","title":"LoRa OTA and OTAFIX","text":"

    A LoRa OTA repeater build installs repeater firmware that can receive and stage updates. A later LoRa update still needs an exact target identity, compatible partition signature, matching radio settings, and the correct update package.

    nRF52 LoRa OTA requires an OTAFIX bootloader built for the exact board. There is no universal bootloader file. Use the OTAFIX 2.4.6 for this release and select the hardware-matched HEX, Serial DFU ZIP, or bootloader-update UF2.

    "},{"location":"firmware_picker/#hardware-and-variant-names","title":"Hardware and variant names","text":"

    Hardware families with multiple released targets get a second hardware-variant menu. It separates revisions, display type, expansion kit, radio/PA layout, pin map, and other physical differences without crowding the first menu. The firmware-variant menu separately exposes choices that still require different code or wiring, such as serial port or the legacy no_external_sensors target suffix. For RAK3401 and RAK4631, the picker labels that compact LoRa OTA option Internal storage (no external storage board). Its result details explain the omitted optional environmental/ranging sensor drivers; the reduction does not disable generic I2C or unrelated board-integrated peripherals. Other boards retain the Reduced optional environmental/ranging drivers label. Reduced RAK3401 and RAK4631 targets retain INA219/INA226/INA260/INA3221 as voltage/current entries in the optional sensor table. They are not the only I2C users: the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals in compatible recipes. RAK12501/L76K GPS uses Serial1 instead. The explicit RAK4631 Serial1 bridge omits the combined GPS provider because its bridge owns the RAK12501 UART, so that legacy image does not expose RAK12500 either. The firmware-configured INA3221 and RAK12500 addresses are both 0x42; to install both, keep RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use a build with -DTELEM_INA3221_ADDRESS=0x43. Companion power saving, controllable FEM receive gain, and radio-chip receive gain are saved settings rather than separate recommended firmware files. Do not substitute a similarly named physical target.

    The header-wired RAK19007 W25Q16 LoRa-OTA recipes are also exact hardware variants, labeled External storage board (W25Q16) in the picker. W25Q16 is the flash-memory part on the added storage board; RAK13302 identifies the radio module, not the storage board. Choose this variant for RAK4631 or for RAK3401 + RAK13302 only when that core/radio combination, the EF4015 flash wiring, and its matching OTAFIX bootloader are all present. The common base-board wiring does not make the two firmware or bootloader identities interchangeable.

    The picker recommends one Full Companion image instead of separate USB, BLE, ordinary WiFi, and USB-logging images. On ESP32, logging is off by default so the one USB TTY starts in ASCII and automatically changes to framed Companion when a complete < frame arrives. Enabling logging gives that TTY to the plaintext CLI/logger and disables framed USB Companion until logging is turned off and the normal ASCII-to-binary mode switch occurs. nRF52 Full Companion retains its optional dedicated interface 02. Exact filename search still finds old aliases from earlier releases.

    "},{"location":"firmware_picker/#maintaining-the-runtime-directions","title":"Maintaining the runtime directions","text":"

    The online picker and downloadable HTML use the same command renderer. Hardware-specific controls are enabled only when _data/firmware_controls.json matches the selected release family and exact target. If that metadata is missing or belongs to another release, the picker retains basic role/logging directions and links the complete guide without inventing hardware support.

    After qualifying a new release, resolve its PlatformIO configuration with no other PlatformIO process running, then generate the controls from that source revision and its staged manifests:

    pio project config --json-output > /tmp/meshcore-picker-pio-config.json\npython3 scripts/generate_picker_controls.py \\\n  --stage /path/to/staged-release \\\n  --pio-config /tmp/meshcore-picker-pio-config.json\n

    For the downloadable version, save the release family's public GitHub release objects as a JSON array, then package the same picker UI and controls:

    python3 scripts/package_firmware_picker.py \\\n  --releases-json /path/to/releases.json \\\n  --output /path/to/FIRMWARE-PICKER.html\n

    This HTML embeds its catalog and directions, so selections work without an internet connection. Firmware downloads and the USB web console still need network access. When replacing the downloadable picker on release pages, update its entry in each page's SHA256SUMS.txt as well.

    "},{"location":"firmware_picker/#memory-corrected-11715-downloads","title":"Memory-corrected 1.17.1.5 downloads","text":"

    Corrected downloads retain the 1.17.1.5 release page and use source suffix aa20e927, with 1e4d1e16 for Wireless Paper Full's 350-contact follow-up. The picker accepts replacement source hashes within that exact release version. Its installation directions identify the nRF52 queue-sharing behavior, Wireless Paper Full's 350 contacts with a shared 256/128-slot queue, and the 150-contact limit on the other six affected ESP32 Full profiles. Those notices apply only to the corrected files. See the memory correction details and each replacement's .memory.json report before updating.

    "},{"location":"flood_filtering/","title":"Flood Filtering and Moderation","text":"

    This guide explains the Keymind forwarding filters. Repeaters expose the full set of channel, rule, blacklist, and moderation controls described here. FULL-profile ESP32 room servers expose the generalized flood.rule table (and its flood.filter alias) with 31 forward-rule slots, but not the repeater's scope-rewrite, passive-blacklist, or text-moderation phases. Standard room-server profiles do not compile the rule table. Filters decide whether the node retransmits a packet and can assign a transport scope before that decision. They do not stop local reception, packet logging, or MQTT observation.

    Only flood routes are filtered:

    Direct routes 0x02 and 0x03 are never affected by these rules. The route and payload values follow the upstream packet-format reference and payload layouts, with this fork's LoRa OTA assignment noted below.

    "},{"location":"flood_filtering/#automatic-flood-advert-limits","title":"Automatic flood-advert limits","text":"

    Repeaters, room servers (including non-FULL builds), and forwarding sensors automatically limit relayed ADVERT packets. No CLI rule is needed. Companions and the repeater's receive-only MQTT observer do not allocate this history. This is separate from discovery-response limits, self-advert timers, hop caps, and the per-rule rate=N/min setting below.

    The ordinary quota is shared by the first 12 public-key hex characters (six bytes of the advertised origin key, not a path hash). Each prefix has a fixed 180-minute window starting with its first tracked valid flood advert.

    Shortest received hops in that window Distinct adverts forwarded per 180 minutes 0\u20131 10 2 9 3 8 4 6 5 5 6 4 7 3 8+ 2

    The minimum received hop count, before adding this relay, sets the quota; one-, two-, and three-byte path hashes have the same policy. A shorter valid duplicate may increase the quota without restarting the window or clearing its count. Longer routes never decrease the allowance during that window. The payload signature must verify before any history changes. Path metadata itself is not signed, so this is a received-distance heuristic, not proof of physical distance.

    "},{"location":"flood_filtering/#continuing-abuse-and-recovery","title":"Continuing abuse and recovery","text":""},{"location":"flood_filtering/#inspect-the-current-limits","title":"Inspect the current limits","text":"

    Use get flood.advert to list rate-limited origins, three per page. Continue with get flood.advert 2, etc. Rows show a 12-hex public-key prefix, the reason (quota, history, or bad), and the wait before another new advert is eligible under the automatic limiter. get flood.advert key 1 shows the full key and counters for row 1, including the remaining bad-list recovery time. A bad-listed key remains visible with wait=0s when its next single forward is due. Prefix collisions appear as separate full-key entries.

    This is a live, read-only view: expiry can change row indices between queries, but reading never resets quotas or extends recovery. Sources rejected solely because the protected-history table is full are not retained, so they cannot be listed. See the CLI reference.

    "},{"location":"flood_filtering/#clear-history","title":"Clear history","text":"

    All timers use rollover-safe uptime, so setting the clock forward or backward cannot clear the restriction. This history is currently RAM-only: rebooting the relay clears it, and time while powered off is not tracked. A manual clear also resets both the ordinary quota and abuse history for its selected target:

    clear flood.advert all\nclear flood.advert <64-hex-full-public-key>\n

    These use the ordinary local/admin CLI authorization, including authenticated admin LoRa CLI. A prefix is never accepted for a targeted clear; missing or invalid selectors do not clear anything. Clearing an exact key preserves other full keys even when their 12-hex prefixes match. No preferences, contacts, neighbours, replay timestamps, or manually configured flood rules are erased.

    "},{"location":"flood_filtering/#scope-and-resource-bounds","title":"Scope and resource bounds","text":"

    Only forwarding is suppressed. Valid adverts remain available to local contact/neighbour handling and can still appear in raw RX logs or MQTT. This cannot prevent the origin or upstream repeaters from transmitting. Direct adverts, discovery control responses, messages, OTA packets, and this node's own scheduled adverts are not charged. Existing physical retry attempts of an admitted advert are not additional distinct adverts.

    The limiter retains up to eleven 64-bit payload hashes per full key per window: ten possible ordinary admissions plus evidence of exceeding the largest quota. Paths and transport scopes do not change these hashes. Retained duplicate hashes cannot consume additional quota even if the general packet-seen cache has evicted them. Once the eleven distinct receive slots are full, additional unretained payloads are suppressed until the next window. Only packets passing all ordinary forwarding gates consume forwarding quota; valid received traffic still supplies abuse evidence when another gate blocks it.

    The default is 128 full-key slots (18,432 bytes of entry storage plus small bookkeeping), 96 on nRF52 (13,824 bytes, preserving the mOTA runtime RAM reserve), or eight on RAM-constrained STM32 builds (1,152 bytes). FLOOD_ADVERT_SOURCE_SLOTS overrides the capacity at compile time. When full, the least-recently-heard normal key is removed first. Valid retained duplicates update last-heard too. Evicting a normal key discards its ordinary quota history, so capacity should still be sized for the deployment's active origins. First-strike evidence and bad-list entries are protected from eviction: if every slot protects abuse history, untracked origins are not forwarded until a slot expires or an administrator clears history. Table churn cannot erase an existing bad-list penalty. No heap allocation or per-packet filesystem writes are used.

    "},{"location":"flood_filtering/#before-making-changes","title":"Before making changes","text":"

    On a repeater, show the current forwarding controls:

    get repeat\nget flood.max\nget flood.max.unscoped\nget flood.max.advert\nget flood.channel.data\nget flood.channel.data.hops\nget flood.channel.scope\nget flood.channel.scope.require\nget flood.filter\nget flood.rule\nget flood.filter.blacklist\nget flood.moderation\n

    flood.rule is an alias for flood.filter, not another table. Generalized repeater FPF7 has 63 forward-rule slots plus scope-rewrite and shared-blacklist sections in the same atomic policy file. FULL room servers have 31 forward slots and empty repeater-only sections. Compact target profiles retain their separate FPF6-era controls. flood.moderation has 16 slots. A new repeater FPF7 table starts with ota all suspend=tempradio in slot 1 and an authenticated #wardriving hops=5+ drop in slot 2; FULL room servers seed only the OTA row. flood.moderation starts empty. A row can opt into suspend=tempradio; temporary radio is not synonymous with OTA and can carry normal packet types too. A corrupt or truncated table fails open, so corrupt storage does not silently enable blocking.

    On a FULL ESP32 room server, use get flood.rule (or get flood.filter) for the available table. Remote rule changes require room-server administrator access. flood.filter.blacklist* and path=blacklist are repeater-only; use the ordered prefix= condition on a room server.

    "},{"location":"flood_filtering/#force-floods-into-a-transport-scope","title":"Force floods into a transport scope","text":"

    flood.channel.scope can add a scope to a received unscoped flood or replace the scope of a transport-scoped flood before this repeater forwards it:

    set flood.channel.scope <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]\nset flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]\nget flood.channel.scope\nget flood.channel.scope.<slot>\ndel flood.channel.scope.<slot>\ndel flood.channel.scope all\n

    The channel may be public, #channel, or a 128/256-bit hex key. A bare target names an existing region with a usable transport key. Use scope=<name> instead to derive a regionless public hashtag scope exactly as flood.filter scope=<name> does. The direct name is normalized with a leading #, may contain up to 30 characters, and does not need a region-list entry. Keyed rules first check the one-byte channel hash carried in the packet, then validate the MAC by decrypting with the configured channel key. A hash collision alone cannot force a scope.

    For example, this authenticates only #rgdata and rewrites it to #BlackHole86 without creating a region:

    set flood.channel.scope #rgdata scope=BlackHole86\nget flood.channel.scope.1\n

    If that channel arrives scoped to #usa, the rule replaces #usa with #BlackHole86. It also handles unscoped packets and replaces any other incoming scope; the source scope is not a condition on the rule.

    Add path=blacklist to make a channel-scope row eligible only when the received path matches the passive flood.filter.blacklist ID table. It does not require an enabled flood.filter drop row. With 3-byte paths, one exact listed ID qualifies. With 2-byte paths, two received path entries must match the first two bytes of listed IDs. A 1-byte path never qualifies.

    Use path=bucket:<1-6> to match one of the existing flood.retry.bucket tables instead. Each bridge bucket holds up to 17 three-byte IDs and remains usable by channel scoping while flood.retry.bridge is off. Bucket matching uses the same thresholds as the blacklist: one exact hit for 3-byte paths, two qualifying entries for 2-byte paths, and no matches for 1-byte paths. Channel scoping reads the configured IDs directly; recent.repeater freshness and flood.retry.ignore do not change this match.

    There are three independent wildcard classes:

    login:* and other:* classify only the visible outer payload type; they do not authenticate its contents. Exact channel rows with usable targets always take precedence over txt:*, even if that wildcard has a lower slot number. Within the exact class, matching path-qualified rows are tried before ordinary fallback rows. The same qualified-then-fallback order applies within each wildcard class. A missing or unusable target is skipped, so later rows remain eligible. The lowest usable slot wins within each priority tier.

    For example, this uses bridge bucket 1 to assign east to public packets whose received 3-byte path contains 7576FB, and assigns west to all other authenticated public packets:

    set flood.retry.bucket 1 7576FB\nset flood.channel.scope public west\nset flood.channel.scope public east path=bucket:1\n

    More 3-byte IDs can be added to bucket 1 later. Any one of them qualifies the east row. Replacing or clearing that bucket changes which paths qualify but leaves both channel-scope rows intact. Bridge retry does not need to be enabled.

    On a successful match, an unscoped route changes from ROUTE_TYPE_FLOOD to ROUTE_TYPE_TRANSPORT_FLOOD; an already-scoped route remains transport-flood but receives replacement codes. Transport code 0 is calculated with the target region or direct hashtag key over the payload type and payload, and code 1 becomes zero. The change happens before region enforcement, forwarding filters, and deduplication. Consequently flood.max.unscoped no longer applies to a packet converted from unscoped, while flood.max, flood.filter, loop detection, and moderation still apply to every rewritten packet. A region target must be flood-allowed. A regionless target is trusted for this matched receive pass, but it neither creates a region nor changes how unrelated packets with the same transport code pass the region gate. By default, if the selected scope differs and the rewritten packet passes those checks, its initial retransmission uses zero txdelay and the highest outbound queue priority so the newly scoped copy can win at the next hop. Add tx=slow to use an effective inbound rxdelay base of max(2, configured rxdelay * 2), retain normal outbound queue priority, and force the maximum txdelay factor of 2.0. The actual transmit delay is still randomized, from zero through ten packet airtimes. It does not preempt an active radio transmission or bypass CAD and airtime-budget limits. Selecting the scope already present is a no-op and does not grant special treatment.

    Direct routes are never rewritten. Standard traceroute is direct-routed and therefore remains outside this flood-only table. A custom flood-form TRACE is treated like every other flood: an applicable wildcard may rewrite it and the normal region/unknown-code gates still apply. Scope assignment does not override normal payload validation or make an otherwise non-forwardable packet type forwardable.

    LoRa OTA (0x0C) falls under other:*. A matching row adds the selected transport code or replaces the existing one, but OTA still operates normally during the temporary-radio window because the OTA handler accepts both unscoped and transport-scoped flood routes. A region target must allow flooding; a direct target uses the regionless trust behavior above. A new repeater also seeds ota all suspend=tempradio in flood-filter slot 1. That visible rule blocks OTA forwarding at every received hop outside temporary-radio operation and is skipped while temporary radio is active. Independently, the OTA core refuses OTA receive, relay, and transmit outside an actually active temporary-radio window, even if the seeded row is deleted or replaced.

    Capacity is selected at build time:

    Classic ESP32 uses the 31-slot default even on boards with PSRAM because these tables live in internal static DRAM. The separate channel-requirement table defaults to the same capacity and uses another 34 bytes per slot. Together, the 31-slot defaults save 15,712 bytes of static RAM compared with 255 slots. Custom builds can override FLOOD_CHANNEL_SCOPE_SLOTS and FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS with build flags.

    Each rule retains its 36-byte record. A separate table holds 32-byte normalized names for up to the smaller of the rule count or 32 distinct regionless targets, except that very-tight STM32WL builds retain one reusable direct target. Both configured regions and regionless targets can be reused by any number of rules.

    On generalized builds these records are the FPF7 rewrite phase, and the file stores only through the highest occupied slot. Compact FPF6 builds retain the standalone FCS5 file and the file sizes described by their build profile.

    "},{"location":"flood_filtering/#require-valid-incoming-scopes-only-on-selected-channels","title":"Require valid incoming scopes only on selected channels","text":"

    flood.channel.scope.require changes region enforcement for received flood GRP_TXT and GRP_DATA packets from a global policy to a channel opt-in policy:

    set flood.channel.scope.require <public|#channel|128/256-bit-key>\nset flood.channel.scope.require.<slot> <public|#channel|128/256-bit-key>\nget flood.channel.scope.require\nget flood.channel.scope.require.<slot>\ndel flood.channel.scope.require.<slot>\ndel flood.channel.scope.require all\n

    An empty table preserves the normal global region behavior. Once at least one row exists, a group-channel packet that authenticates against a listed key must arrive as ROUTE_TYPE_TRANSPORT_FLOOD with a transport code matching a locally flood-allowed region. An unscoped packet, an unknown transport code, or a code for a denied region is not retransmitted. The check uses the original incoming scope before flood.channel.scope or flood.filter scope= can rewrite it. Those rewrite actions are skipped for a rejected listed channel, so they cannot rescue it or grant special receive/transmit timing.

    Group-channel packets that do not authenticate against any listed key bypass the region/unknown-code forwarding gate. They still pass through repeat, flood.max*, flood.filter, loop detection, payload validation, and moderation. Non-channel flood payload types retain the normal global region behavior. A one-byte channel-hash collision is only a prefilter; the packet must also pass MAC validation/decryption with the configured key.

    Without .slot, setting an existing key updates its row and a new key uses the first empty row. Numbered set replaces that slot. Detail output displays only the first four derived hash bytes and key size, never the secret. The table has the same build-dependent slot count as flood.channel.scope; each row consumes 34 bytes of RAM and storage, plus a five-byte file header. ACL permission 4 can manage it.

    For example, this requires an allowed incoming scope on #bot, while every other group channel bypasses region enforcement:

    set flood.channel.scope.require #bot\nget flood.channel.scope.require\n
    "},{"location":"flood_filtering/#interaction-with-duplicate-detection","title":"Interaction with duplicate detection","text":"

    The seen-packet hash contains the payload type and exact payload bytes. It does not contain the route type, either transport code, or the ordinary flood path. For TRACE only, the encoded path_len byte is also included. Therefore an unscoped packet and the same packet after this repeater adds a transport code are the same duplicate. A later copy with a different scope is also the same duplicate; changing or adding scope cannot evade the seen table.

    When equivalent non-TRACE flood copies overlap in rxdelay, the normal receive-quality timing still chooses the packet to process, but that winner takes a scope from the queued copies whose transport code matches an allowed region in this repeater. Unknown and denied scopes are ignored. If eligible copies have different scopes, the shortest received path supplies the scope. Equal path lengths prefer the deepest matching child region (the narrowest configured scope). A remaining tie keeps queue order. The winner keeps its own path, SNR, and delay schedule; only its route and transport codes can change, including replacement of a less-preferred scope it already carried.

    Scope selection happens at dequeue so the original scopes remain available for comparison. It applies only while copies are queued and cannot alter a copy already processed into the seen table. Flood-form TRACE participates in the same arbitration. Direct traceroute never enters this flood queue.

    A packet that already matches a configured fast flood.channel.scope or flood.filter scope= action and needs its scope changed bypasses this inbound rxdelay queue entirely. A tx=slow row remains in the queue with twice the configured base, floored at 2.0, and participates in normal queued-copy scope arbitration.

    "},{"location":"flood_filtering/#runtime-flood-rules","title":"Runtime flood rules","text":"

    On repeaters with the rule engine enabled and on FULL-profile ESP32 room servers, flood.rule and flood.filter are two names for the same persistent table. The evaluator is fixed firmware, but every row is data, so an authenticated operator can add, replace, inspect, or delete a row without an OTA or reboot. Existing flood.filter commands remain compatible. Only FPF6 and FPF7 files are accepted; FPF1-FPF5 files are rejected and filtering fails open. A row saved by the extended engine uses FPF7. Older firmware cannot preserve an FPF7 table containing the packed retry action or one-byte hash:XX matcher; remove those rows before downgrading.

    The former flood.channel.block table is now represented by ordinary FPF7 rows. On a generalized repeater, an existing FCB2 file is imported once into free FPF7 slots and then removed. For example, an old #wardriving h=4 row becomes type=any channel=#wardriving hops=5+ drop. The 63-row table can hold all 31 former general filter rows, all 15 legacy channel-block rows, and the migrated global flood.channel.data gate. Compact STM32WL FPF6 builds cannot match authenticated channels and retain the older separate gate.

    On generalized repeaters, flood.channel.data* is a compatibility view over one ordinary visible FPF7 type=grp_data ... drop row. Turning it off creates or updates that row; turning it on removes the row. Its hop setting maps to hops=all or hops=N+1+. There is no hidden GRP_DATA forwarding check ahead of FPF7. Normal ordering applies, so a matching higher-priority stop row can exempt selected traffic. The compact rule list marks the managed row with ~data.

    FPF7 binds in=region:<name> and region=<name> to canonical region names, not numeric region IDs. Removing, reordering, or reusing a region ID cannot silently redirect a rule. If the saved name is missing, an input-region match does not match and a target-region rewrite is skipped. Re-adding the same name reactivates the rule.

    The extended form is:

    set flood.rule[.<slot>] type=<type> [hops=<range>] [channel=<channel>]\n    [prefix=<ID[,ID...]>] [in=<input-scope>] <action> [rate=<N>/min]\n    [priority=<0-255>] [stop] [tx=fast|slow] [suspend=tempradio]\nget flood.rule\nget flood.rule.<slot>\ndel flood.rule.<slot>\ndel flood.rule all\n

    The command must be entered on one line. Match fields in one row are ANDed. Every row is matched against the same immutable packet state captured on receive, before any rule rewrites its scope. Matching rows are then processed by descending numeric priority. At an equal numeric priority, authenticated channel matches run before raw hash:XX matches, raw hashes run before channel=*, and lower slot number breaks the remaining tie. Priority defaults to 0; an explicitly higher numeric priority overrides specificity.

    The first matching stop row ends the FPF7 forward phase after that row. Higher-order matches and the stop row still apply; lower-order matches do not. A stop-only row is therefore an exception to lower-priority FPF7 rows. It cannot undo a higher-priority drop and it does not bypass hard forwarding gates or the scope-rewrite and moderation phases. Without a stop row, matching drop and rate rows remain independent and the highest-order matching scope or region rewrite wins.

    Match fields:

    Actions:

    When several rows use the same channel key, authentication is performed once for that packet and reused by those rows. This cache lives only for the current receive evaluation; it is not persisted and never stores plaintext or a password. A hash:XX row deliberately skips authentication and uses only the one visible byte.

    The global retry controls remain hard gates. A retry row does not override a zero flood.retry.count, path/type attempt caps, disabled forwarding, flood.retry.advert, a drop verdict, or any other forwarding rejection. flood.retry.bridge independently chooses ordinary retry or bridge-bucket completion for an allowed packet. The selector applies to received floods; locally originated floods retain the normal global behavior.

    The exact requested examples are:

    # If #rgdata arrives unscoped with more than 3 received hops, add\n# the regionless #BlackHole86 scope.\nset flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86\n\n# Rewrite the exact incoming #usa scope to #BlackHole86 for #rgdata.\nset flood.rule.3 type=grp_data channel=#rgdata in=scope:usa scope=BlackHole86\n\n# Match a two-byte source-path prefix and cap forwarding at 10 per minute.\nset flood.rule.4 type=any prefix=860C rate=10/min\n\n# Keep authenticated #rgdata at two hops or less out of lower-priority FPF7\n# rules. Hard gates and separate tables still apply.\nset flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop\n\n# Retry received Public and #hamradio floods, using bridge completion.\n# Once these rows exist, other received floods are not retried.\nset flood.retry.bridge on\nset flood.rule.6 type=any channel=public retry\nset flood.rule.7 type=any channel=#hamradio retry\n\n# Fall back to an unauthenticated one-byte match if only the visible hash is known.\nset flood.rule.8 type=any channel=hash:A7 retry\n\nget flood.rule.2\nget flood.rule.3\nget flood.rule.4\n

    Public's visible channel hash is 0x11, so a hash:11 drop row also matches Public unless an earlier exact-channel rule stops it. This standalone example preserves authenticated Public while dropping other packets that use the same visible byte:

    set flood.rule.2 type=any channel=public retry stop\nset flood.rule.3 type=any channel=hash:11 drop\n

    The Public rule's MAC/decrypt check must succeed before its stop applies. A colliding channel misses that rule and reaches the hash drop. With equal numeric priorities, authenticated-channel specificity automatically puts the Public rule first regardless of slot order. Without stop, both rows match Public and the sticky drop wins. Remove retry from the Public row if the exemption should not also opt Public into flood retry. An explicitly higher numeric priority on the hash rule remains an operator override.

    The 240 KB STM32WL profiles keep MESH_ENABLE_FLOOD_RULE_ENGINE=0 and retain the persistent compact FPF6 flood.filter and blacklist syntax below. They still perform filtering, but omit the generalized flood.rule parser and extended fields. No partition size is changed by this feature.

    The compatible filter and blacklist commands are:

    set flood.filter.blacklist <ID[,ID...]>\nset flood.filter.blacklist.<slot> <ID[,ID...]>\nget flood.filter.blacklist\nget flood.filter.blacklist.<slot>\ndel flood.filter.blacklist\ndel flood.filter.blacklist.<slot>\nset flood.filter <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]\nset flood.filter.<slot> <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]\nget flood.filter\nget flood.filter.<slot>\ndel flood.filter.<slot>\ndel flood.filter all\n

    The blacklist holds up to 255 unique 3-byte repeater IDs on ESP32 builds and 18 on other builds. Each is written as six hexadecimal digits. For example:

    set flood.filter.blacklist A1B2C3,D4E5F6,112233\nset flood.filter.blacklist.4 445566\nset flood.filter any all path=blacklist\n

    This is intended for abuse containment, such as refusing to retransmit floods that repeatedly enter the mesh through known internet gateways dumping bulk traffic. The list is shared by every FPF7 row and scope-rewrite row that uses path=blacklist; it is not copied into each rule.

    An unnumbered set replaces the list with up to 18 IDs, the largest command that fits every CLI transport. A numbered set writes a batch of up to 18 IDs beginning at an existing slot or the next consecutive slot. This is how an ESP32 list grows beyond 18. Deleting a numbered entry compacts the entries after it. Unnumbered get reports the total and prints the leading IDs that fit; numbered get retrieves one specific entry.

    path=blacklist is an unordered precondition on that row. With 3-byte path hashes, one or more exact blacklist hits qualifies the packet. With 2-byte path hashes, two or more received path entries must match the first two bytes of listed IDs. Each received entry is counted at most once. A 1-byte path never qualifies. The IDs may occur anywhere in the received path; neither their list order nor their path order matters.

    Without a slot number, set reuses an identical row or selects the first empty slot. Use a slot number to replace a row whose match or action is changing. Omitting the hop expression means all (0-63).

    Numbered get normally uses the long field names. If a rule containing several maximum-length names would exceed one CLI reply, it switches to a non-truncating compact spelling that set also accepts: c= is channel=, p= is prefix=, i=*|n|s|a|u|s:<scope>|r:<region> represents in=, q=N is rate=N/min, and f=str combines slow timing (s), temporary-radio suspension (t), and retry allowance (r). The fallback prints packet type numerically.

    A legacy row without scope= is the existing drop action. On extended builds, an explicit drop has the same result, while rate= by itself creates a rate-only row. A row with scope= adds transport scope to an unscoped packet or replaces the codes on an already-scoped packet. The scope name is normalized with a leading #, and the 128-bit transport key is derived directly from that hashtag. The name does not need to exist in the region list and is not added to it. Public names up to 30 characters are accepted; private $ scopes are not.

    require=region is the legacy spelling of in=allowed. The repeater evaluates the packet's original route before any rewrite in that receive pass. An incoming transport scope must match a locally allowed region; an unscoped flood must be allowed by the wildcard region. If the check fails, that row is skipped, the filter does not grant a region bypass, and the unchanged packet is allowed to fail normal region enforcement. Other independently configured scope rows still apply in their normal order.

    When multiple scope or region rows match, the highest numeric-priority row wins. Equal priorities use authenticated channel, raw hash, then wildcard specificity before the slot-number tie-break. Rewrite rows do not approve a packet: any matching drop row and every remaining forwarding gate can still reject it. A filter-assigned scope is trusted without local region-list validation, but repeat, flood.max, loop detection, and moderation still apply. By default, a changed scope bypasses inbound rxdelay, then is retransmitted with zero txdelay and the highest outbound queue priority. Add tx=slow to use an effective inbound rxdelay base of max(2, configured rxdelay * 2), retain normal queue priority, and force the maximum txdelay factor of 2.0; the randomized transmit delay ranges from zero through ten packet airtimes. tx=fast explicitly restores the default. Selecting the scope already present does not grant special treatment. Active radio transmission, CAD, and airtime-budget limits are unchanged.

    On generalized repeaters, forward rules, flood.channel.scope rewrite rows, the shared blacklist, and the flood.channel.data compatibility state are one atomic FPF7 policy image. Existing /flood_ch_scope, /flood_filter_bl, FPF6, and FCB2 data is imported once; the old files are removed only after the new image verifies and commits. Compact FPF6 repeaters retain separate files. Deleting the blacklist leaves path=blacklist rows in place but dormant until IDs are configured again. Path hashes are truncated routing identifiers and are not authenticated proof that a particular repeater\u2014or a particular person\u2014handled a packet. FULL room servers reject blacklist commands.

    On first initialization, flood-filter slot 1 is seeded with:

    set flood.filter.1 0x0C all suspend=tempradio\n

    This is a normal editable row. After the table has been saved, deleting it remains persistent across reboot; the firmware does not recreate it. Run the same command to restore the exact seeded row, or omit .1 to preserve existing slot assignments and use the first empty slot. Operators may add suspend=tempradio to any other row that should be skipped while the radio is on a temporary channel.

    Suspension does not approve a packet or bypass the rest of the filter table. It skips that row, then evaluation continues with the next row and the remaining forwarding gates. An ordinary drop any row therefore still applies during the temporary-radio window unless an earlier matching stop row ends FPF7 processing. repeat, flood.max*, region handling, loop detection, and the OTA subsystem's own hop limit also remain in force.

    Standard traceroute uses direct routing and never enters flood.filter. For a custom flood-form trace, type=any, explicit trace, scope, region, rate, and drop rows all behave normally. The stock core does not normally flood-forward TRACE packets.

    "},{"location":"flood_filtering/#remote-administration-lockout-warning","title":"Remote-administration lockout warning","text":"

    There are no hidden payload-type or short-hop exemptions in FPF7. Drop and rate rules can block req, response, txt_msg, anon_req, path, ACK, and multipart traffic beginning at hop 0 when their match fields say so.

    A flooded login starts as ANON_REQ; its reply is commonly a PATH packet carrying an encrypted RESPONSE. Before a direct return path is established, administrative replies and CLI text can also be flooded. Transit repeaters do not have the session key and cannot distinguish those encrypted admin exchanges from ordinary peer packets with the same outer type. A rule therefore affects the complete outer packet class, not only packets that ultimately authenticate as administrators. Keep a serial or other recovery path and stage broad deny/rate rules carefully.

    Hop expressions are based on the path count when this repeater receives the packet:

    Examples:

    # Stop forwarding group data once it arrives with four or more path entries.\nset flood.filter grp_data 4+\n\n# Stop long adverts, while still allowing shorter adverts.\nset flood.filter.2 advert 6+\n\n# Keep LoRa OTA floods from crossing this repeater at path counts 2 through 4.\nset flood.filter.3 ota 2-4\n\n# Assign #local scope to group text without requiring #local in the region map.\nset flood.filter grp_txt all scope=local\n\n# Rewrite only packets whose incoming region was already acceptable.\nset flood.filter grp_data all scope=local require=region\n\n# Rewrite matching blacklisted paths without fast-tracking their retransmission.\nset flood.filter grp_data all path=blacklist scope=local tx=slow\n\n# Drop matching flood types after the unordered path blacklist qualifies.\nset flood.filter.blacklist A1B2C3,D4E5F6,112233\nset flood.filter any all path=blacklist\n\n# Apply a hard ceiling to flood payload types at 12 or more received hops.\nset flood.filter any 12+\n
    "},{"location":"flood_filtering/#high-traffic-mesh-example","title":"High-traffic mesh example","text":"

    This preset limits request and group-data propagation early while allowing the login-capable response, anonymous-request, and path types to travel farther:

    set flood.filter req 3+\nset flood.filter response 9+\nset flood.filter 0x06 3+\nset flood.filter 0x07 9+\nset flood.filter path 9+\nset flood.filter control 1+\nget flood.filter\n
    Rule Stops retransmission when received with req 3+ 3 or more path entries response 9+ 9 or more path entries 0x06 3+ (grp_data) 3 or more path entries 0x07 9+ (anon_req) 9 or more path entries path 9+ 9 or more path entries control 1+ 1 or more path entries

    On a new table, the factory OTA rule occupies slot 1, so these unnumbered commands normally fill slots 2 through 7. Existing tables may choose different free slots. All six rules take effect at the thresholds shown; there are no hidden short-hop exceptions. The Control rule allows a flood received with path count 0 to be forwarded once, then stops it at the next repeater. Normal node-discovery Control packets are direct zero-hop packets and never enter flood.filter. These rules affect only retransmission by the repeater; local reception and logging remain unchanged.

    Accepted payload names are:

    Value Short name Full name 0x00 req PAYLOAD_TYPE_REQ 0x01 response PAYLOAD_TYPE_RESPONSE 0x02 txt_msg PAYLOAD_TYPE_TXT_MSG 0x03 ack PAYLOAD_TYPE_ACK 0x04 advert PAYLOAD_TYPE_ADVERT 0x05 grp_txt PAYLOAD_TYPE_GRP_TXT 0x06 grp_data PAYLOAD_TYPE_GRP_DATA 0x07 anon_req PAYLOAD_TYPE_ANON_REQ 0x08 path PAYLOAD_TYPE_PATH 0x09 trace PAYLOAD_TYPE_TRACE 0x0A multipart PAYLOAD_TYPE_MULTIPART 0x0B control PAYLOAD_TYPE_CONTROL 0x0C ota PAYLOAD_TYPE_OTA in this fork 0x0D 13 reserved 0x0E 14 reserved 0x0F raw_custom PAYLOAD_TYPE_RAW_CUSTOM

    Decimal values 0 through 15, hexadecimal values 0x00 through 0x0F, the full PAYLOAD_TYPE_* names, and any are also accepted. Upstream currently reserves 0x0C; this fork assigns it to LoRa OTA. Rows are suspended during temporary-radio operation only when explicitly configured that way.

    "},{"location":"flood_filtering/#moderate-group-text-by-channel-and-username","title":"Moderate group text by channel and username","text":"

    Use flood.moderation for flood GRP_TXT messages. The repeater validates and decrypts the selected channel, extracts the display name before the first :, then applies the rule:

    set flood.moderation <channel> <sender> <action> [action...]\nset flood.moderation.<slot> <channel> <sender> <action> [action...]\nget flood.moderation\nget flood.moderation.<slot>\ndel flood.moderation.<slot>\ndel flood.moderation all\n

    Channels can be specified as:

    The key is stored locally so packets can be authenticated and decrypted. It is not included in get flood.moderation output.

    Available actions are:

    At least one of drop, rate=X/min, or hops=N is required. Rate and hop limits can be combined. rate=0/min is equivalent to drop.

    "},{"location":"flood_filtering/#per-user-per-channel-rate-limits","title":"Per-user, per-channel rate limits","text":"

    Rate limits require an exact username; * is not accepted for a rate rule. Username comparison is ASCII case-insensitive, and names containing spaces must be quoted. A rule's counter is independent from rules for the same name on other channels, so this directly supports \"X messages per minute from user X on channel Y.\" Counters are local to this repeater and reset on reboot.

    # At most five Public-channel messages per minute from this display name.\nset flood.moderation public \"Noisy User\" rate=5/min\n\n# A separate limit for the same name on #local.\nset flood.moderation #local \"Noisy User\" rate=10/min\n\n# Combine a rate limit with a maximum forwarding distance.\nset flood.moderation public alice rate=4/min hops=5\n
    "},{"location":"flood_filtering/#match-the-start-of-a-path","title":"Match the start of a path","text":"

    Path matching accepts one-, two-, or three-byte hashes. Every hash in one rule must use the same width, and matching always starts at the beginning of the received path:

    set flood.moderation #local bot drop path=A1B2C3,D4E5F6\nset flood.moderation public alice rate=3/min path=71\n

    A path-qualified rule cannot match a zero-hop packet and does not match until the packet contains all path entries listed by the rule.

    "},{"location":"flood_filtering/#how-the-forwarding-controls-combine","title":"How the forwarding controls combine","text":"

    A flood packet is retransmitted only if it passes every applicable control. In other words, the controls combine as deny rules:

    1. flood.channel.scope.require evaluates a listed group channel against the original incoming scope; unlisted group channels bypass the later region gate while the table is active.
    2. flood.channel.scope tries a path-qualified channel row before that channel's ordinary fallback, then adds or replaces the scope from either a configured region or a direct scope=<name> target.
    3. All extended flood.rule match fields are evaluated against the same original incoming packet. Matches are ordered by descending numeric priority, then authenticated/raw-hash/wildcard channel specificity, then ascending slot. The first matching stop row removes every later FPF7 match. The highest-order remaining scope= or region= row may replace the channel-scope result; a direct scope does not require a region-list entry.
    4. repeat and flood.max* are checked.
    5. The FPF7 forward phase, including any row managed through flood.channel.data*, applies drop and rate decisions using that saved match result. No packet type or short-hop range is silently exempted.
    6. Region and loop-detection rules are checked; a regionless scope assigned by either table is already trusted when it has no region-list match, except that it cannot rescue a channel rejected by flood.channel.scope.require.
    7. flood.moderation checks decrypted group text, username, rate, hops, and path. If it accepts the packet, matching general-rule rate counters are charged immediately before retransmission is approved.

    The first denial is enough to prevent retransmission. A packet that is denied can still appear in local logs or MQTT output. Moderation runs last because its rate counters are charged only for packets that pass every other forwarding control and will actually be retransmitted.

    "},{"location":"flood_filtering/#delegate-filter-management","title":"Delegate filter management","text":"

    On repeaters, ACL permission 5 is the filter-manager role:

    setperm <companion-public-key-hex> 5\n

    A filter manager can read non-secret operational status and manage repeat, loop.detect, flood.max*, flood.channel.data*, flood.filter*, flood.rule*, and flood.moderation*. Delegated get access uses an explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions, ACL entries, radio settings, or unrelated administrator settings. Because flood.filter scope= derives a public hashtag key directly, a filter manager can configure that action without region-manager permission; it still cannot edit the region hierarchy.

    FULL ESP32 room servers use their existing administrator check for remote flood.rule and flood.filter commands; they do not grant this table through permission 5.

    ACL permission 4, the region/scope-manager role, can read, add, replace, and delete flood.channel.scope and flood.channel.scope.require rows and manage regions. This lets the same delegate create target regions, assign forced scopes from regions or direct hashtag names, and select the channels that require valid incoming scopes.

    "},{"location":"flood_filtering/#security-limitations","title":"Security limitations","text":"

    Public and hashtag channels use shared, well-known keys. A valid channel MAC proves that the sender knew the channel key; it does not identify a person. The <sender> value is an unverified display name and can be spoofed. Path hashes are truncated routing hints and can collide or be manipulated; they are not authenticated user identities.

    Use username and path rules as traffic moderation, not as an authorization boundary. For a strict network boundary, combine these tools with region ACLs, private transport/channel keys, and controlled device access.

    "},{"location":"flood_filtering/#restore-the-factory-seeded-rows","title":"Restore the factory-seeded rows","text":"

    The repeater's factory-seeded forwarding rows can be restored through the CLI:

    set flood.rule.1 type=ota hops=all drop suspend=tempradio\nset flood.rule.2 type=any channel=#wardriving hops=5+ drop\n

    These commands explicitly replace the two seeded generalized-repeater slots. Inspect them first if they may now contain other rules. Compact FPF6 builds use only the first command's flood.filter.1 0x0C all suspend=tempradio form.

    "},{"location":"flood_filtering/#remove-the-custom-rules","title":"Remove the custom rules","text":"

    To save both tables in an empty state:

    del flood.filter all\ndel flood.moderation all\nget flood.filter\nget flood.moderation\n

    This does not change the older flood.max*, loop-detection, or region settings; inspect or reset those separately when troubleshooting.

    "},{"location":"full_companion_features/","title":"Full Companion: turn features on and off","text":"

    For side-by-side Companion and infrastructure commands, see feature switches by role. MQTT and logging controls differ between these roles.

    Use the exact companion_radio_full image for your board. Full includes its supported transports and MOTA sending in one firmware; ordinary settings do not require rebuilding it. USB remains the normal way to update the Companion itself. WiFi/Bluetooth updates are optional, and Full does not install LoRa MOTA packages on itself.

    The Wireless Paper Full memory-sharing update keeps 350 contacts and 256 queued messages normally. During an mOTA session, 128 message slots are available while the other half holds its workspace. If more than 128 unread messages are queued, sync them with an app before starting mOTA. Disconnecting the source or turning it off returns all 256 slots. Idle WiFi mOTA listening does not borrow the queue. See the 1.17.1.5 release guide for the corrected download; the earlier aa20e927 Wireless Paper image has 150 contacts.

    "},{"location":"full_companion_features/#open-the-text-terminal","title":"Open the text terminal","text":"

    Close the app or serial monitor using the USB port, then open a terminal at 115200 baud, or use the USB web console. A fresh Full Companion boot starts in ASCII text mode. If it is already in Binary Companion mode, send this line once and wait for the terminal banner:

    +++MESHCORE-TERM-START\n

    Run help, board, and version to inspect the device. On ESP32 you can alternatively open the node's LAN address and select CLI, or connect with nc DEVICE_IP 5002. Both use the same terminal commands as USB, including import meshcore://..., list, to, and send. Use disconnect to release a browser/TCP session. See the terminal guide. When finished, send +++MESHCORE-TERM-STOP, close the terminal, and reconnect your Companion app. These are local text commands; an app's custom-variable editor does not necessarily forward them.

    "},{"location":"full_companion_features/#common-switches","title":"Common switches","text":"Feature Turn on Turn off When it takes effect Device power saving set powersaving on set powersaving off Immediately; saved LoRa receive power saving set radio.rxps on set radio.rxps off Saved; radio applies when available Radio chip RX boost set radio.rxgain on set radio.rxgain off Immediately; saved; supported radios only External FEM RX boost set radio.fem.rxgain on set radio.fem.rxgain off Immediately; saved; controllable FEM only External FEM TX gain set radio.fem.txgain on set radio.fem.txgain off Immediately; saved; controllable PA only ESP32 USB packet/debug logging For 1.17.1.5: set powersaving off, then set usb.logging on set usb.logging off Immediately; saved; logging owns the USB terminal nRF52 separate USB logging port set usb.logging on reboot set usb.logging off reboot Saves and reboots to add/remove the second USB port ESP32 browser settings set webui on set webui off Saved; starts/stops WebConfig ESP32 temporary setup portal start webconfig ap stop webconfig This session; opens a setup network/QR where available ESP32 WiFi firmware uploader start ota or start ota ap stop ota This session; only usable with two application slots Temporary MOTA radio window tempradio 910.525,250,5,5,120 normalradio Bounded window; saved normal settings return afterward

    Check a saved switch with the corresponding get command, for example get usb.logging, get radio.rxps, or get webui. Use get powersaving to inspect device power saving. Fresh installations enable device power saving and leave USB logging off. Existing saved preferences take precedence.

    For an ESP32 1.17.1.5 USB logging session, use this sequence in the text terminal:

    set powersaving off\nset usb.logging on\n

    The power-saving step is the documented USB-session workaround for this release. It is saved separately; set usb.logging off does not restore power saving. nRF52 does not need the ESP32 workaround. WiFi-only MQTT logging does not need this USB step; see logging by role.

    ESP32 logging and Binary Companion share one USB port. After turning logging off, return to Binary Companion with +++MESHCORE-TERM-STOP. nRF52 keeps its optional logging port separate from its primary USB port and BLE connection. See the USB switching guide.

    "},{"location":"full_companion_features/#wifi-bluetooth-gps-and-mqtt","title":"WiFi, Bluetooth, GPS, and MQTT","text":"

    On ESP32, configure WiFi through the setup portal or these commands:

    set wifi.ssid MyNetwork\nset wifi.pwd my-password\nget wifi.status\n

    Credentials are saved. A credential change schedules a reconnect; a TCP terminal will disconnect. The WiFi switch on supported device displays or their assigned WiFi button controls the Companion WiFi services. Stopping WebConfig alone stops its browser service, while the Companion's TCP and MOTA services can remain available.

    WiFi modem sleep is independent of device power saving. Use set wifi.powersave min for normal BLE/WiFi coexistence; inspect the effective state with get wifi.powersave. max is available only where the firmware's radio coexistence policy permits it.

    Most Full Companions provide BLE alongside their other transports. There is no universal set bluetooth off command. SenseCAP Indicator selects one secondary wireless transport per boot:

    set companion.transport wifi\nreboot\n

    Use set companion.transport ble followed by reboot to select Bluetooth instead. USB stays available. The ESP-NOW layout keeps its primary ESP-NOW mesh active in either mode.

    For GPS-equipped boards, use get gps, set gps on, and set gps off. The Companion app's gps=1 / gps=0 custom setting controls the same GPS. Only boards with a compiled GPS provider expose this setting. Sharing location with contacts is a separate setting.

    When the exact Full image includes MQTT, use WebConfig's MQTT cards or the same CLI settings used by infrastructure, such as set mqtt1.preset custom and set mqtt1.server broker.example.com. set mqtt.enabled on|off controls MQTT without erasing the configured slots; check get mqtt.enabled, get mqtt.running, and get mqtt.status. The MQTT tab's Enable MQTT checkbox controls the same saved switch. set logging.output off|usb|wifi|both selects USB and MQTT outputs together. Status, packets, raw, receive, and transmit switches control their individual publications. Builds without MQTT omit these controls. There is no need for a separate logging or WiFi-MQTT Companion image when that feature is included in Full.

    "},{"location":"full_companion_features/#send-mota-from-any-full-companion","title":"Send MOTA from any Full Companion","text":"

    Place valid, destination-specific .mota files in a host directory. Set the source, destination, controller, and required relays to the same bounded temporary radio tuple. For USA Cascade on LoRa hardware:

    tempradio 910.525,250,5,5,120\n

    On the computer connected to the Full Companion, run:

    motatool serve --serial /dev/ttyACM0 --dir ./motas -v\n

    This works on both ESP32 and nRF52 Full. Close the text terminal first. motatool automatically sends ota folder on and owns USB while serving. Stop the host tool to detach the source; use normalradio afterward to return early, or let the window expire. A .bin, .uf2, or DFU .zip is not itself a .mota; use motatool to prepare the package for the exact destination firmware and hardware.

    ESP32 also accepts a host over WiFi:

    motatool serve --tcp DEVICE_IP:5001 --dir ./motas -v\n

    nRF52 also accepts an authenticated Bluetooth host:

    python3 tools/ble_mota/ble_mota_seeder.py \\\n  --device MeshCore-MyCompanion --dir ./motas \\\n  --local 'tempradio 910.525,250,5,5,120'\n

    Use one MOTA source transport at a time. See the complete MOTA instructions for pairing, remote destination commands, and automation. ESP-NOW hardware uses its ESP-NOW primary radio rather than transmitting LoRa.

    "},{"location":"full_companion_features/#update-the-companion-itself","title":"Update the Companion itself","text":"

    USB is supported for every Full Companion. Use the exact board's UF2/DFU package or ESP32 image and the normal flasher. Preserve the target's existing partition layout unless deliberately performing a documented USB migration.

    For ESP32 with two application slots, start ota returns the WiFi uploader URL, normally http://DEVICE_IP:8080/update. start ota ap explicitly opens MeshCore-OTA; join it and use the returned URL. Upload the exact board's application .bin. The device reboots when the upload succeeds. Use stop ota to close an unused uploader. Port 8080 keeps WebConfig on port 80 available. Use a trusted local network or a temporary setup network.

    The 4 MB Full layouts and T-Beam 1W Full use a single application slot and require USB; the command reports that limitation. The artifact's .capabilities.json lists verified ota_update_methods when a wireless self-update path is usable.

    nRF52 Full exposes Bluetooth DFU to paired clients. Use the matching application DFU .zip with a compatible Nordic/Adafruit Legacy DFU client and the board's matching BLE-DFU-capable bootloader/SoftDevice. USB remains the fallback when the installed bootloader does not support that procedure. Bluetooth DFU updates the Companion; the separate Bluetooth MOTA source service feeds packages to other mesh nodes.

    For OTAFIX installations, use the exact board/storage profile from OTAFIX 2.4.6. It includes the Bluetooth reconnect fix from 2.4.5. Its release notes cover upgrades from older bootloaders, including the special UF2-drive precautions for version 2.4.3. The new 64 KiB retained-RAM staging feature applies to qualified internal-flash nRF52840 receivers; Full Companions continue to serve host-supplied packages without needing that receiver storage layout.

    "},{"location":"full_companion_usb_switcher/","title":"Full Companion USB CLI and binary switcher","text":"

    Full Companion uses one primary USB serial interface for two incompatible wire formats:

    The primary interface starts in the ASCII terminal after each boot. A Binary Companion client does not need to send a special mode command: its first valid frame automatically hands the interface to the binary parser.

    This automatic behavior is compiled only into companion_radio_full targets. Ordinary USB Companion builds continue to use the explicit +++MESHCORE-TERM-START and +++MESHCORE-TERM-STOP controls described in the Terminal Chat CLI guide.

    "},{"location":"full_companion_usb_switcher/#wire-formats","title":"Wire formats","text":"

    Host-to-device Binary Companion frames use this layout:

    '<'  length-low  length-high  payload[length]\n

    Device-to-host frames use the same little-endian length with a different marker:

    '>'  length-low  length-high  payload[length]\n

    For example, a representative two-byte device query is:

    3C 02 00 16 03\n

    The ASCII terminal is line-oriented and accepts commands such as:

    get radio.cad\nset display.rotation 90\nreboot\n

    Changing the configured baud rate does not select a mode. Use 115200 for compatibility even though native USB CDC hardware does not use UART timing.

    "},{"location":"full_companion_usb_switcher/#state-transitions","title":"State transitions","text":"
                             complete '<' frame\n                 +--------------------------------+\n                 |                                v\nboot ------> ASCII terminal                  Binary Companion\n                 ^                                |\n                 |                                | +++MESHCORE-TERM-START\n                 | incomplete '<' probe           |\n                 | (one-second timeout)            |\n                 +--------------------------------+\n\nASCII terminal -- +++MESHCORE-TERM-STOP ------> Binary Companion\nASCII terminal -- observable USB disconnect ---> Binary Companion\nESP32 logging terminal -- usb.logging off -----> ASCII terminal\nany mode ------- reboot ------------------------> ASCII terminal\n

    Serial mOTA and single-TTY logging add exclusive ownership states described below. BLE, WiFi, Ethernet, and hardware-serial Companion transports are not switched; they remain binary.

    "},{"location":"full_companion_usb_switcher/#how-automatic-detection-works","title":"How automatic detection works","text":"
    1. Full Companion initializes the normal USB Binary Companion interface, then gives its primary stream to the ASCII terminal before normal loop service begins.
    2. While the prompt has no buffered input, the terminal peeks at the next byte. It does not remove that byte.
    3. If the byte is <, the terminal temporarily releases the stream and enables the existing ArduinoSerialInterface frame parser.
    4. The parser consumes the original <, the two-byte length, and the payload. There is no second parser and no copied or synthetic frame.
    5. A monotonically increasing completed-frame counter confirms that the parser received a complete frame. The interface then remains in Binary Companion mode.
    6. If no complete frame arrives within one second, the parser state is reset and the ASCII terminal prints a new banner and prompt.

    The switcher checks framing, not client identity. Any syntactically complete Binary Companion frame confirms binary mode; it does not require the first command to be CMD_APP_START or CMD_DEVICE_QUERY. Normal command validation still occurs after the frame parser returns the payload.

    The empty-prompt requirement prevents a literal < in the middle of a command from silently changing modes. A literal < typed as the first character does start a probe, but the terminal returns after the one-second timeout if no binary header and payload follow.

    "},{"location":"full_companion_usb_switcher/#manual-controls","title":"Manual controls","text":"

    The original controls remain available.

    From Binary Companion, send this exact unframed line while the parser is idle:

    +++MESHCORE-TERM-START\n

    Terminate it with CR or LF. The binary parser accepts control tokens only as complete delimiter-bounded lines; prefixes, suffixes, and partial tokens are ignored.

    From the ASCII terminal, send this exact sequence to return to binary mode:

    +++MESHCORE-TERM-STOP\n

    The stop sequence takes effect as soon as its last byte arrives in ASCII mode. The start sequence is recognized only as a completed line while the binary parser is idle and is not examined inside a length-prefixed frame.

    meshcli can normally connect directly after boot:

    meshcli -s /dev/ttyACM0 -b 115200 ver\n

    An explicit terminal start token is still useful when the device is already in binary mode:

    picocom -b 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM0\n
    "},{"location":"full_companion_usb_switcher/#logging-and-mota-ownership","title":"Logging and mOTA ownership","text":"

    The switcher never attempts to mix ASCII, framed Companion traffic, or binary mOTA traffic on the same stream.

    Situation Primary USB behavior Full Companion after boot ASCII; a complete < frame switches to Binary Companion nRF52 logging enabled Primary interface still follows the switcher; logs use the optional second interface ESP32 logging enabled at boot Logging terminal owns the only USB TTY; automatic < detection is disabled nRF52 USB serial mOTA active mOTA owns primary USB; ASCII and Binary Companion are unavailable there BLE/WiFi/Ethernet/hardware serial Always Binary Companion and unaffected by the USB mode

    On every ESP32 Full Companion, use set usb.logging off in its logging terminal before trying to use USB with an app. That command stops logging and returns to the ordinary ASCII terminal; it does not select Binary Companion. The app's first valid framed probe can perform the normal automatic switch, or a human can send +++MESHCORE-TERM-STOP first. The framed/binary Companion parser is disabled while logging owns that TTY. BLE and WiFi Companion transports remain available while USB is logging. ESP32 Full builds use the Arduino-ESP32 2.x base where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but follow the same one-TTY policy. No ESP32 Full build enumerates a second CDC interface.

    On nRF52, serial motatool is also text-first. Its exact initial ota folder on line is recognized in either startup ASCII or Binary Companion mode. From ASCII, the firmware leaves terminal mode and directly enters exclusive mOTA ownership; from binary, the idle frame parser recognizes the same control sequence. The following mOTA request/reply frames therefore cannot be consumed by the ASCII line editor. motatool serve --serial can be the first client after boot and does not require a terminal token or disconnect workaround.

    Only the exact completed line selects mOTA from ASCII. Extra arguments, leading/trailing whitespace, or a partial line remain ordinary terminal input.

    "},{"location":"full_companion_usb_switcher/#shortcomings-and-edge-cases","title":"Shortcomings and edge cases","text":"

    This mechanism is deliberately small and deterministic, but it is not a full protocol negotiation layer.

    "},{"location":"full_companion_usb_switcher/#it-is-startup-selection-not-per-connection-negotiation","title":"It is startup selection, not per-connection negotiation","text":"

    After the first complete binary frame, the device stays in Binary Companion mode. Closing meshcli does not automatically restore ASCII. Use the terminal start token or reboot when an ASCII prompt is needed again.

    Conversely, closing an ASCII terminal on native USB normally changes the port to binary mode because the firmware can observe USB DTR/data disconnect. The next client therefore sees binary mode, not a new ASCII session. A USB-to-UART bridge often cannot report disconnect, so it can remain in ASCII until the stop token or a reboot.

    "},{"location":"full_companion_usb_switcher/#detection-works-only-at-an-empty-prompt","title":"Detection works only at an empty prompt","text":"

    If part of an ASCII command is already buffered, an incoming < is treated as ordinary terminal input. Clear or submit the line before starting a Binary Companion client. Only one process should have the serial port open.

    "},{"location":"full_companion_usb_switcher/#the-first-frame-has-a-one-second-deadline","title":"The first frame has a one-second deadline","text":"

    The complete marker, length, and payload must arrive within the probe window. This is generous for local USB but may reject a heavily buffered serial proxy, a debugger that pauses the MCU, or a tool that writes the header and body with a long delay. A timed-out client can retry after the ASCII banner appears.

    "},{"location":"full_companion_usb_switcher/#framing-confirmation-is-not-authentication","title":"Framing confirmation is not authentication","text":"

    Any complete length-prefixed frame selects binary mode, even if its command is unknown or malformed at the application layer. This is safe for stream separation but means a random complete frame can leave the device in binary mode until manually switched back.

    "},{"location":"full_companion_usb_switcher/#the-terminal-banner-is-best-effort","title":"The terminal banner is best effort","text":"

    The firmware enters ASCII mode during boot, often before a host opens the CDC device. The banner may therefore be absent even though the terminal is ready; send a newline or a harmless get command rather than treating a missing banner as proof of binary mode.

    A host that remains connected across a reboot may receive ASCII banner and prompt bytes before the first binary response. Binary clients should discard leading bytes until a plausible > frame marker and length are found, reject implausible lengths, and resynchronize. The ordinary open-after-boot path has been tested with meshcli, but third-party clients that assume byte zero is always > may fail.

    "},{"location":"full_companion_usb_switcher/#text-and-binary-output-cannot-be-interleaved","title":"Text and binary output cannot be interleaved","text":"

    Primary USB suppresses Binary Companion output while the terminal owns the stream. Packet/debug logging must use its dedicated CDC interface or the exclusive single-TTY logging mode. Writing diagnostic text directly to the primary binary stream will corrupt clients regardless of the switcher.

    "},{"location":"full_companion_usb_switcher/#a-literal-leading-briefly-hides-the-prompt","title":"A literal leading < briefly hides the prompt","text":"

    Typing < as the first terminal character begins a binary probe. With no complete frame, the prompt returns after one second and the banner is printed again. There is currently no escape syntax for entering a literal leading <; prefix it with another character if it is needed as command text.

    "},{"location":"full_companion_usb_switcher/#troubleshooting","title":"Troubleshooting","text":"

    If meshcli cannot connect:

    1. close every terminal or logging reader using the primary interface;
    2. on ESP32, confirm that saved USB logging is off;
    3. reboot and let meshcli be the first process to open the data interface;
    4. use the stable /dev/serial/by-id/*-if00 path on Linux when available;
    5. if a prompt appears after one second, the client's first frame was not completed inside the probe window.

    If an ASCII terminal shows no banner, press Enter and issue a harmless query such as get radio.cad. If binary bytes appear, send the exact terminal start token or reboot.

    The switch policy is implemented in UsbAsciiBinarySwitch.h, with stream ownership in main.cpp and framing in ArduinoSerialInterface.cpp.

    "},{"location":"gps_tracking/","title":"GPS Tracking","text":"

    This document describes how GPS telemetry works for companion/client nodes and sensor nodes.

    "},{"location":"gps_tracking/#scope","title":"Scope","text":"

    GPS tracking uses the existing CayenneLPP GPS telemetry field. It does not add a new phone app protocol field.

    The GPS telemetry value contains latitude, longitude, and altitude. Speed, heading, and freshness are not sent as separate telemetry fields.

    If the firmware does not have a valid fresh GPS cache, it omits the GPS field from telemetry. This is intentional: stale or missing fixes are not reported as zero coordinates.

    "},{"location":"gps_tracking/#freshness","title":"Freshness","text":"

    GPS telemetry is cached separately from advert location.

    The cache behavior is:

    When a telemetry request asks for location, GPS is kept on for 2 hours after the latest location request. During that hold window, later location telemetry requests can use fresh GPS data as soon as valid fixes are available.

    If GPS is manually enabled, it stays on and valid fixes continue to update the cache.

    If no contact or ACL client can receive location telemetry, the scheduled 2-hour refresh does not run. A real location telemetry request still turns GPS on for the 2-hour hold window, and manual GPS-on still keeps the cache updated.

    "},{"location":"gps_tracking/#companionclient-nodes","title":"Companion/Client Nodes","text":"

    Companion/client telemetry uses the existing companion telemetry permission system:

    Location telemetry is sent only when the requester's effective telemetry permissions include location. Those permissions are derived from the companion telemetry mode settings and contact flags.

    The scheduled GPS cache refresh runs only when at least one stored contact has effective location telemetry access:

    No new phone app behavior is required. Existing clients see the existing GPS telemetry field when it is present.

    "},{"location":"gps_tracking/#sensor-nodes","title":"Sensor Nodes","text":"

    Sensor telemetry access is controlled by:

    get telemetry.access\nset telemetry.access all\nset telemetry.access acl\n

    all is the default and matches the previous sensor telemetry behavior. A request that reaches the normal sensor telemetry request path can receive the requested telemetry fields, including GPS, subject to the request mask and GPS freshness rules.

    acl gates telemetry through the sensor ACL. A requester with read-only or higher permissions receives the existing telemetry set, including GPS. Guest or unknown requesters receive no telemetry fields.

    The scheduled GPS cache refresh follows the same access setting:

    Use the existing ACL command to grant access:

    setperm <pubkey> 1\n

    Permission values:

    "},{"location":"gps_tracking/#advert-location","title":"Advert Location","text":"

    Advert location is separate from telemetry GPS. The advert policy is controlled with:

    gps advert\ngps advert none\ngps advert share\ngps advert prefs\n

    Policies:

    prefs is the first-boot default for every repeater, room-server, and sensor build. Firmware updates retain an explicitly saved policy from the existing preferences filesystem.

    Telemetry GPS can be fresh while advert location is fixed or disabled, depending on this policy.

    "},{"location":"gps_tracking/#recommended-setup","title":"Recommended Setup","text":"

    For private sensor tracking:

    set telemetry.access acl\nsetperm <owner_pubkey> 3\nsetperm <trusted_pubkey> 1\n

    For compatibility with the older sensor behavior:

    set telemetry.access all\n

    For GPS telemetry behavior, leave the phone app unchanged. The firmware omits GPS when it is stale or missing and sends the existing GPS telemetry field when fresh data is available.

    "},{"location":"halo_keymind_settings/","title":"Halo and Keymind Branch Settings","text":"

    This file covers only CLI settings and helper commands added by the Halo or Keymind branches. Use CLI commands for the general infrastructure CLI. For MQTT, logging, and former variant controls, use feature switches by role; Full Companion uses different controls for some of these settings. See Repeater Flood Filtering and Moderation for a focused filter setup and troubleshooting guide.

    "},{"location":"halo_keymind_settings/#quick-start","title":"Quick Start","text":"
    set retry.preset rooftop\nset direct.retry.heard on\nset flood.retry.advert off\nset flood.retry.bridge off\nset flood.retry.prefixes none\nset flood.retry.ignore none\n

    Then verify:

    get retry.preset\nget direct.retry.heard\nget flood.retry.advert\nget flood.retry.prefixes\nget flood.retry.ignore\n

    Use prefixes from the analyzer or neighbors list or get recent.repeater after the repeater has been online for a few hours.

    "},{"location":"halo_keymind_settings/#common-examples","title":"Common Examples","text":"

    Disable retrying advert packets:

    set flood.retry.advert off\nget flood.retry.advert\n

    Ignore a repeater as a successful flood retry echo: Use this if you have a car repeater and a house repeater; have the house ignore the car.

    set flood.retry.ignore 71CE82,C7618C\nget flood.retry.ignore\n

    Only accept specific downstream relays as flood retry success: You're in a hole and need to hit a mountain top repeater to get out; keep trying till one you see one of these send out your packet.

    set flood.retry.prefixes A58296,860CCA,425E5C\nget flood.retry.prefixes\n

    Bridge two groups of repeaters:

    set flood.retry.bridge on\nset flood.retry.bucket 1 71CE82,C7618C\nset flood.retry.bucket 2 BEEBB0,425E5C\nget flood.retry.bucket.1\nget flood.retry.bucket.2\n

    Return to simple non-bridge flood retry:

    set flood.retry.bridge off\nset flood.retry.prefixes none\nset flood.retry.ignore none\n
    "},{"location":"halo_keymind_settings/#added-settings","title":"Added Settings","text":"Setting What it does How to use Example telemetry.temp, telemetry.volt, telemetry.volt.i2c, telemetry.gps Repeater and room builds record 30-minute MCU temperature and battery samples for seven days. Detected INA voltage channels retain four days at 0.02 V resolution through 655.34 V; all-zero channels are omitted as disconnected. GPS-capable builds retain GPS separately. Repeater telemetry.tx sends temperature, battery, and per-channel I2C RAW_CUSTOM packets over a configured direct path on a persistent 1-30 day schedule (default two days) or immediately for testing; GPS is never included. History resets on reboot. get telemetry.temp [page], get telemetry.volt [page], get telemetry.volt.i2c [channel [page]], get telemetry.gps [page], set telemetry.gps <1-30>, get telemetry.tx, set telemetry.tx <off|direct|path>, set telemetry.tx schedule <off|1-30d>, send telemetry.tx now get telemetry.volt.i2c 2 1 battery.alert Sends opt-in, region-scoped low-battery warnings to #repeaters after 30 minutes of uptime. get battery.alert, get battery.alert.region, set battery.alert on [region], set battery.alert off set battery.alert on sea battery.alert.low Warning threshold percentage. Must be greater than battery.alert.critical. get battery.alert.low, set battery.alert.low <1-100> set battery.alert.low 20 battery.alert.critical Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. get battery.alert.critical, set battery.alert.critical <0-99> set battery.alert.critical 10 recent.repeater Shows, searches, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. Search results include the last-recorded age. Entries older than 24 hours are removed by a three-hour sweep. get recent.repeater [page], get recent.repeaters search <2|4|6 hex> [page], set recent.repeater <prefix> <snr_db>, clear recent.repeater get recent.repeaters search A1B2 flood.channel.data Compatibility view for a visible FPF7 type=grp_data drop row on generalized repeaters. off creates the row and on removes it; compact FPF6 builds retain the legacy gate. Use an authenticated channel= row when only selected channels should be blocked. get flood.channel.data, set flood.channel.data on/off set flood.channel.data off flood.channel.data.hops Sets the compatibility row's hop range: all maps to hops=all; 1-7 repeats through that hop count and maps to hops=N+1+. get flood.channel.data.hops, set flood.channel.data.hops <all|1-7> set flood.channel.data.hops 7 flood.channel.scope FPF7 rewrite-phase rows that add a transport scope to received unscoped floods or replace the scope of already-scoped floods. A bare target uses an existing flood-allowed region; scope=<name> derives a public hashtag target directly without creating a region, exactly like flood.filter scope=. By default, a changed packet bypasses inbound rxdelay and is forwarded at the highest outbound queue priority with zero initial txdelay, so the selected scope can win at the next hop. tx=slow uses an effective inbound rxdelay base of max(2, configured rxdelay * 2), retains normal queue priority, and forces the maximum txdelay factor of 2.0; its actual randomized transmit delay ranges from zero through ten packet airtimes. path=blacklist and path=bucket:<1-6> make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat txt:*; path-qualified rows beat the ordinary channel fallback. login:* covers the remote-login family, and other:* covers every remaining flood type, including flood-form TRACE and OTA. Direct traceroute remains outside the flood table. Generalized builds commit these rows with the forward phase and blacklist; compact FPF6 builds retain separate storage. ACL permission 4 can manage the table. get flood.channel.scope[.n], set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow], del flood.channel.scope.<n>|all set flood.channel.scope #rgdata scope=BlackHole86 flood.channel.scope.require Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated GRP_TXT/GRP_DATA channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission 4 can manage the table. get flood.channel.scope.require[.n], set flood.channel.scope.require[.n] <public|#channel|key>, del flood.channel.scope.require.<n>|all set flood.channel.scope.require #bot flood.filter Persistent flood-route rules selected by payload type and optional hop range. Generalized repeaters have 63 FPF7 forward slots; FULL ESP32 room servers have 31. Repeaters store their scope-rewrite phase, shared unordered blacklist, and channel-data compatibility state in the same atomic FPF7 image. path=blacklist is intended for forwarding abuse containment, including bulk internet-to-mesh dumping, but truncated path IDs are not authenticated identities. Fixed 240 KB STM32WL repeaters retain compact FPF6 filtering and separate blacklist storage. New generalized repeater tables seed slot 1 with ota all suspend=tempradio and slot 2 with an authenticated #wardriving hops=5+ drop. Direct routes and local receive/logging are unchanged. get flood.filter[.n], set flood.filter[.n] <type> [N|N+|N-M|all] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio], del flood.filter.<n>|all; repeater only: get/set/del flood.filter.blacklist[.n] set flood.filter grp_txt all scope=local tx=slow flood.rule Live alias for extended flood.filter on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, optional channel match, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, allow flood retry, and/or stop lower-priority FPF7 rules. channel=* means no channel condition; public, #name, and keys authenticate; hash:XX is an unauthenticated one-byte fallback with collision/spoof risk. With no retry rows, global retry behavior is unchanged; once any exists, matching retry rows allow-list received floods while the global retry gates and flood.retry.bridge algorithm still apply. scope=BlackHole86 directly derives a regionless sink scope; region=BlackHole86 would require a configured flood-allowed region. All rows match the original receive-time packet. Higher numeric priority runs first; equal values automatically order authenticated channels before raw hashes before channel=*, then use lower slot as the final tie-break. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region makes its region= rewrite and paired stop inert until the name returns, allowing lower safety rows to run. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. get flood.rule[.n], set flood.rule[.n] type=<type> [hops=...] [channel=...] [prefix=...] [in=...] <drop|scope=...|region=...|rate=N/min|retry|stop> [priority=0-255], del flood.rule.<n>|all set flood.rule.2 type=any channel=#hamradio retry flood.moderation Decrypts keyed GRP_TXT channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports public, #channel, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. get flood.moderation[.n], set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...], del flood.moderation.<n>|all set flood.moderation public \"Noisy User\" rate=5/min hops=4 clock.sync.mesh Defaults on for all repeater, sensor, and room-server builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. clock.sync.mesh now queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its .table and .1 through .16 forms inspect collected samples. get clock.sync.mesh, set clock.sync.mesh <on|off>, clock.sync.mesh now, get clock.sync.status[.table|.1-.16] set clock.sync.mesh on clock.sync.mesh.edge Defaults on so edge infrastructure nodes can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so disabled forwarding and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. get clock.sync.mesh.edge, set clock.sync.mesh.edge <on|off> set clock.sync.mesh.edge on clock.sync.internet Adds a read-only internet/NTP estimate at the same initial and seven-day checks on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. get clock.sync.internet, set clock.sync.internet <on|off> set clock.sync.internet on clock.sync.drift Absolute correction threshold in seconds; the default is 600 seconds (10 minutes). The clock is moved forward or backward only when the estimate differs by more than this value. get clock.sync.drift, set clock.sync.drift <30-86400> set clock.sync.drift 600 clock.sync.samples Minimum fresh evidence count before mesh time can be used: distinct receive paths in normal mode or distinct advert keys/Public-channel display names in edge mode. A strict majority of all fresh samples is also required. Range 3-16; default 9. get clock.sync.samples, set clock.sync.samples <3-16> set clock.sync.samples 9 outpath Overrides the primary direct route used for replies to the current remote client. get outpath, set outpath <hops>, set outpath direct, set outpath clear, set outpath flood set outpath A1B2C3,D4E5F6 altpath Adds a secondary direct route for repeater replies to the current remote client. get altpath, set altpath <hops>, set altpath direct, set altpath clear, set altpath flood set altpath 71CE82,BA09F0"},{"location":"halo_keymind_settings/#other-keymind-commands","title":"Other Keymind Commands","text":"Command What it does How to use Example send text.flood Sends a #repeaters flood text message formatted as <node_name>: <message>, with : in the node name sent as ;. send text.flood <message> send text.flood checking ridge link"},{"location":"halo_keymind_settings/#battery-alerts","title":"Battery Alerts","text":"

    Battery alerts are off by default. Enabling requires a named region. With no region argument, the repeater selects the single deepest (most narrow) region in the hierarchy; if multiple regions tie, the command asks for an explicit region. For example, after region def west pnw wa w-wa sea, set battery.alert on selects sea, while set battery.alert on w-wa overrides the default. Alerts are never sent as unscoped floods, and removing the selected region stops alerts until a valid scope is selected again.

    The repeater suppresses alerts for its first 30 minutes of uptime. It then checks every 30 minutes and sends a flood text warning to #repeaters when voltage is above 1 V and the estimated battery percent is below battery.alert.low.

    Warnings and critical alerts both use a 12-hour resend cooldown, beginning only after the radio reports that the alert transmission completed.

    Defaults:

    Setting Default battery.alert off battery.alert.region <unset> battery.alert.low 20 battery.alert.critical 10

    Example:

    set battery.alert.low 20\nset battery.alert.critical 10\nset battery.alert on\nget battery.alert\nget battery.alert.region\n

    CPU power saving remains compatible with the check. The battery timer never requests a wake earlier than its 30-minute deadline; when the normal loop is already awake after that deadline, no additional wake is needed. Sleeping time counts toward the startup delay, and an outbound warning prevents another sleep until the queued packet has been handled. This is separate from RX duty-cycle power saving, which only cycles the LoRa receiver and does not stop the main loop's battery timer.

    "},{"location":"halo_keymind_settings/#recent-repeater-table","title":"Recent Repeater Table","text":"

    Direct retry uses the recent repeater table when direct.retry.heard is on. Bridge buckets also use this table: a configured bucket prefix is active only when it was heard within the last hour.

    Show learned rows:

    get recent.repeater\nget recent.repeater 2\nget recent.repeaters 2\nget recent.repeater page 3\nget recent.repeaters search A1\nget recent.repeaters search A1B2 page 2\n

    Search matches overlapping 1-, 2-, and 3-byte path hashes. Each matching row shows its stored SNR and a whole s, m, or h age measured from the most recent recording. Search pages contain up to six rows.

    Seed or correct a prefix:

    set recent.repeater A1B2C3 8.5\n

    Clear learned and manually seeded rows:

    clear recent.repeater\n

    Rows are sorted by prefix width, then SNR. A full direct retry failure lowers the matching row by 0.25 dB. Unfiltered paged replies contain up to 10 rows; an unpaged local serial query prints the full table.

    "},{"location":"halo_keymind_settings/#direct-path-overrides","title":"Direct Path Overrides","text":"

    outpath and altpath apply to the current remote client ACL entry. They need remote client context, so they are not useful from the local serial CLI.

    Set paths with comma-separated hop hashes. Each hop must be 2, 4, or 6 hex characters, and all hops in one path must use the same width. Hex input is case-insensitive. Replies use uppercase hex and retain the commas, so the value can be copied directly into another set outpath or set altpath command.

    get outpath\nget outpath path\nset outpath A1B2C3,D4E5F6\nset outpath path\nset outpath direct\nset outpath clear\nset outpath flood\nget altpath\nset altpath 71CE82,BA09F0\nset altpath clear\n

    For example, both set altpath 600000,0d2784,f8dada and Set altpath 600000,0d2784,f8dada store the same path and reply with:

    > 600000,0D2784,F8DADA\n

    The first CLI word is case-insensitive (set, Set, and SET are the same, as are get, Get, and the other command verbs). Argument case is preserved.

    get outpath path reports the reciprocal path received after the latest flood login without changing outpath. That packet arrives asynchronously, so retry shortly if the first query reports > path pending; the observation window expires after one minute. The login path is not automatically selected. set outpath path copies the observed route into outpath and saves it; it leaves the current route unchanged when no observed route is available.

    set outpath direct sets a zero-hop direct route for a client reachable without repeaters. set outpath clear forgets the override, replies > outpath cleared, and lets normal path discovery fill it again. set outpath flood forces replies to use flood packets until the client logs in again.

    When outpath is a valid direct path and altpath is also a valid, different direct path, repeater DM replies send two packets: one on outpath and one on altpath. The secondary altpath copy does not create its own direct-retry state, so retry tracking stays attached to the primary outpath packet. altpath clear disables the secondary direct reply. altpath flood is accepted for command symmetry, but it does not create a second flood reply; only a valid direct altpath sends the second packet.

    "},{"location":"halo_keymind_settings/#direct-retry-settings","title":"Direct Retry Settings","text":"

    Direct retry applies to direct-routed packets. A queued resend is canceled when the next-hop echo is heard. Repeaters expose the settings below; non-repeater firmware uses the same packet-type timing rules with fixed shared base/step timing.

    Setting What it does How to use Example retry.preset Applies shared direct and flood retry defaults. Values: infra, rooftop, mobile or 0, 1, 2. get retry.preset, set retry.preset <value> set retry.preset rooftop direct.retry.heard Uses the recent repeater table as the direct retry eligibility gate. get direct.retry.heard, set direct.retry.heard on/off set direct.retry.heard on direct.retry.margin SNR margin in dB above the SF-specific receive floor. get direct.retry.margin, set direct.retry.margin <0-40> set direct.retry.margin 5 direct.retry.count Maximum direct retry attempts after initial TX. Direct-routed type 2 text packets always use 21 attempts regardless of this setting or the short-path cap. get direct.retry.count, set direct.retry.count <1-15> set direct.retry.count 15 direct.retry.base Base wait in milliseconds before retry; packet-length add-on is 3x for TRACE and ANON_REQ/type 7, 7x for TXT_MSG/type 2, and 6x for other direct retry packets. get direct.retry.base, set direct.retry.base <10-5000> set direct.retry.base 175 direct.retry.step Milliseconds added per retry attempt after the base, packet-length add-on, and random forwarding jitter. get direct.retry.step, set direct.retry.step <0-5000> set direct.retry.step 100 direct.retry.cr Adaptive coding-rate thresholds for repeater direct retry packets. Repeaters use CR4, CR5, CR7, or CR8, then escalate by attempt: CR4, CR5, CR7, CR7, then CR8 from a CR4 start; CR5, CR7, CR7, then CR8 from a CR5 start. Non-repeaters start at the current radio CR and follow the same escalation pattern, clamped at CR8. get direct.retry.cr, set direct.retry.cr <cr4_min>,<cr5_min>,<cr7_min>,<cr8_max>, set direct.retry.cr off set direct.retry.cr 10.0,7.5,2.5,0

    The default adaptive coding-rate profile is 10.0,7.5,2.5,2.5. SNR 10.0 dB and up uses CR4, 7.5 dB and up uses CR5, 2.5 dB and down uses CR8, and the middle band uses CR7. If no recent repeater table entry is available, retry packets use CR5. Use set direct.retry.cr off to disable adaptive coding-rate overrides. Repeater attempts escalate from the adaptive starting CR: CR4, CR5, CR7, CR7, then CR8 from a CR4 start; CR5, CR7, CR7, then CR8 from a CR5 start. Non-repeaters use the current radio CR as the first retry CR and follow the same pattern up to CR8.

    Preset details:

    Preset Base Count Step SNR gate infra 275 ms 4 150 ms SF floor + 15 dB rooftop 175 ms 15 100 ms SF floor + 5 dB mobile 175 ms 15 50 ms SF floor

    Example for a quiet fixed repeater:

    set retry.preset rooftop\nset direct.retry.heard on\nset direct.retry.margin 5\n

    Example for a moving or weak-link node:

    set retry.preset mobile\nset direct.retry.margin 0\n
    "},{"location":"halo_keymind_settings/#flood-and-advert-settings","title":"Flood And Advert Settings","text":"

    Flood retry applies to flood-routed packets. A queued retry is canceled when the same packet is heard from a qualifying, non-ignored repeater. Bridge mode uses the bucket rules below instead.

    Setting What it does How to use Example flood.retry.count Base flood retry attempts after initial TX. Role path scaling happens first, then all builds apply payload caps: REQ is 0; GRP_TXT keeps up to 15; remote-login RESPONSE, TXT_MSG, ANON_REQ, and PATH keep up to 15 at path count 0 and cap at 2 in transit; every other flood type caps at 1. A lower calculated value is preserved, and 0 disables flood retry. get flood.retry.count, set flood.retry.count <0-15> set flood.retry.count 7 flood.retry.path Maximum path hash count eligible for flood retry, or off to disable the gate. get flood.retry.path, set flood.retry.path <0-63/off> set flood.retry.path 1 flood.retry.group.path Additional path gate for group data (type=6) flood retries. The stricter of this and flood.retry.path applies; off disables only this additional gate. Setting the general path gate to 0 forces this setting to off; a named preset restores the default of 1. get flood.retry.group.path, set flood.retry.group.path <0-63/off> set flood.retry.group.path 1 flood.retry.advert Allows or blocks retry for node advert packets (type=4). Default is off. get flood.retry.advert, set flood.retry.advert on/off set flood.retry.advert off flood.retry.prefixes Target prefixes. If set, only same-packet echoes from matching last-hop prefixes cancel a retry. get flood.retry.prefixes, set flood.retry.prefixes <prefixes/none/off> set flood.retry.prefixes BEEBB0,425E5C flood.retry.ignore Ignored prefixes. In non-bridge retry, ignored last-hop echoes do not cancel retry. get flood.retry.ignore, set flood.retry.ignore <prefixes/none/off> set flood.retry.ignore 71CE82,C7618C flood.retry.bridge Enables bucket-based bridge retry logic. get flood.retry.bridge, set flood.retry.bridge on/off set flood.retry.bridge on flood.retry.bucket.<n> Shows one bridge bucket. Buckets are numbered 1-6. get flood.retry.bucket.<n> get flood.retry.bucket.1 flood.retry.bucket Sets bridge bucket prefixes. set flood.retry.bucket <1-6> <prefixes/none/off> set flood.retry.bucket 1 71CE82,C7618C

    Extended flood.rule rows can select which received floods may use these settings. For example, enable bridge completion only for Public and #hamradio with set flood.rule type=any channel=public retry and a second row using channel=#hamradio. Adding the first active retry row changes received retry selection into an allow-list; deleting the last restores the legacy global selection. channel=hash:XX is available when only the visible one-byte hash is known, but it is not authenticated and is collision-prone. For example, hash:11 includes Public and any other packet using 0x11; channel=public alone performs the Public-key MAC/decrypt check.

    Bridge buckets may also be reused as passive flood.channel.scope path selectors with path=bucket:<1-6>. This does not require flood.retry.bridge to be enabled and does not depend on recent-repeater freshness or the retry ignore list.

    Forwarded neighbor adverts also use an automatic echo guard in every build. If this node hears a downstream, longer-path echo after transmitting an advert whose signed timestamp is less than six hours old, it will not forward that exact advert again during the six-hour age window. This guard works even when flood.retry.advert is off and does not affect self-originated adverts.

    Self-originated advert retries are deliberately slow: the first retry waits at least one extra minute beyond the normal airtime-aware delay. Queueing a newer self advert retires queued and future retry attempts for older self adverts without disturbing other flood retry sequences. Companion firmware allows the one slow retry for its own adverts but not for neighbor adverts it relays.

    The shared retry preset sets these flood defaults:

    Preset Retry count Path gate Group-data path gate infra 1 1 1 rooftop 3 2 1 mobile 15 1 1

    Hop-zero flood retries use the direct-retry CR ladder starting at the active radio CR. With CR5, infra allows CR5, CR7; rooftop allows CR5, CR7, CR7, CR8, CR8, CR8; mobile allows CR5, CR7, CR7, then CR8 through retry 15. These lists exclude the initial send and remain subject to payload caps and echo cancellation. Floods with recorded hops keep the active CR. flood.retry.count 0 still disables every automatic flood retry, including hop zero, without changing direct-retry settings or their CR schedule. See hop-zero flood retry coding rate for every starting CR and the payload limits.

    Example for path-gated retry:

    set retry.preset rooftop\nset flood.retry.path 1\nset flood.retry.group.path 1\nset flood.retry.advert off\nset flood.retry.ignore 71CE82,C7618C\n
    "},{"location":"halo_keymind_settings/#north-south-buckets","title":"North South Buckets","text":"

    Buckets describe groups of repeaters on different sides of this relay. Bucket numbers do not have built-in meanings; this example uses bucket 1 for North and bucket 2 for South.

                  North bucket 1\n        +-----------------------+\n        | A1B2C3       D4E5F6   |\n        | North A      North B  |\n        +-----------+-----------+\n                    |\n                    v\n              +-----------+\n              | This node |\n              +-----------+\n                    ^\n                    |\n        +-----------+-----------+\n        | 71CE82       C7618C   |\n        | South A      South B  |\n        +-----------------------+\n              South bucket 2\n

    Configure the buckets:

    set flood.retry.bridge on\nset flood.retry.bucket 1 A1B2C3,D4E5F6\nset flood.retry.bucket 2 71CE82,C7618C\nset flood.retry.ignore none\n

    Packet heard from the North:

         heard source\n         |\n         v\n  +--------------+        retry targets\n  | North bucket | -----> South bucket\n  | bucket 1     | -----> Other fresh/unbucketed relays\n  +--------------+\n

    Packet heard from the South:

         heard source\n         |\n         v\n  +--------------+        retry targets\n  | South bucket | -----> North bucket\n  | bucket 2     | -----> Other fresh/unbucketed relays\n  +--------------+\n

    Packet heard from an unbucketed or pathless source:

         heard source\n         |\n         v\n  +--------------+        retry targets\n  | Other bucket | -----> North bucket\n  | implicit     | -----> South bucket\n  +--------------+\n

    Bridge retry stays eligible until every target bucket has been heard or flood.retry.count is exhausted. A configured bucket is a target only when at least one of its prefixes is fresh in recent.repeater. Prefixes in flood.retry.ignore never count as bucket hits.

    Configuration reports a warning when prefixes in different buckets, including bucket 7 (flood.retry.prefixes), share the same first byte. A 1-byte path cannot distinguish those buckets. Bridge mode therefore excludes every matching bucket when that short prefix is the source, and credits every matching target bucket when it is heard as an echo. This prevents an ambiguous short prefix from keeping an impossible target outstanding through every retry.

    Each flood retry wait retains the fixed maximum-frame plus 20 packet-airtime delay, then adds random jitter from zero to 200 percent of one additional packet airtime. This keeps nearby repeaters from repeating a collision on fixed timing while capping the added wait at two frames.

    Only one enhanced retry sequence can be active for the same logical flood packet. Identical floods still receive their normal transmission, but do not multiply the extra attempts. Evicted queued retries release their bridge state, and the final echo wait does not reserve a packet-pool entry.

    Earlier path hops from a successful bridge echo refresh a separate per-bucket reachability cache without an SNR value. Only the final hop, which actually sent the received RF frame, updates recent.repeater and its SNR. Indirect path hops therefore cannot change direct-retry SNR gating or coding-rate selection.

    "},{"location":"halo_keymind_settings/#troubleshooting","title":"Troubleshooting","text":"

    If advert packets are still retrying:

    get flood.retry.advert\nset flood.retry.advert off\n

    If ignored prefixes still appear in flood retry good logs:

    get flood.retry.ignore\nset flood.retry.ignore <prefix>\n

    The ignored prefix must match the last hop shown as heard=<prefix>. For example, this log needs C7618C in the ignore list:

    flood retry good (... path=7773D0>C7618C, heard=C7618C ...)\n

    If retries are too aggressive:

    set flood.retry.count 1\nset flood.retry.path 1\nset direct.retry.count 4\n

    If retries are too sparse:

    set flood.retry.count 7\nset flood.retry.path 2\n
    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/","title":"XIAO Bluetooth stealth hardware validation - 2026-09-07","text":"

    Historical command syntax: the initial run used stealth as a MAC mode. Current firmware uses the independent bluetooth.stealth on|off flag instead and does not migrate that development mode. See current commands and the independent-flag follow-up below. Each observation applies only to the exact artifact listed in its section.

    This is a hardware observation report, not an all-board or phone qualification. The test host was the MercerWoodMesh Raspberry Pi Zero 2 W, accessed remotely over Tailscale. Tests used its built-in Bluetooth adapter, not USB resets or hub power switching. Local timestamps below use America/Los_Angeles.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#exact-target-and-firmware","title":"Exact target and firmware","text":"

    The application was installed with Nordic Legacy BLE DFU. The tool verified the exact ZIP hash, bootloader advertisement and live DIS model XIAO nRF52840 before transmitting 616,584 application bytes. Target image validation and target-initiated activation disconnect passed. The transfer reported about 2,223 bytes/second (about 277 seconds for payload transmission). Authenticated MeshCore device queries subsequently returned protocol 14, board Seeed Xiao-nrf52, and the expected unique test-version prefix.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#observed-results","title":"Observed results","text":"Check Result and evidence First-pair discovery set bluetooth.mac stealth saved a new random-static address; reboot returned that exact address with the normal name and Nordic UART advertisement. First authenticated pairing The Pi received a passkey request and completed pairing. BlueZ reported both Paired and Bonded. MITM-protected Nordic UART notifications and real command/reply exchanges worked at MTU 247. Pairing transition keeps the session alive The initial connection returned stealth; bonded-peer-only advertising, remained usable for five more seconds, and answered another command without disconnecting or pairing again. Advertising after disconnect A public-address passive scan captured ADV_DIRECT_IND from the exact target, with advertising data length zero. The capture contained no undirected advertisement or scan response from that address. Bonded reconnect Subsequent direct connections by saved BlueZ device identity succeeded without a new passkey, including live version, core-statistics and Bluetooth-mode replies. Warm reboot persistence Two commanded reboots retained the address and saved bond. Directed advertising was captured after reboot, followed by authenticated reconnect. Device uptime decreased from 61 seconds before the second reboot to 35 seconds at the next query; core error flags were zero. Slow advertising interval A successful bonded reconnect occurred about 61 seconds after reboot, beyond the 30-second fast-advertising interval. Reopen pairing Setting stealth again from an existing bonded connection generated a new address, and the next boot allowed a fresh first pairing. Restore default set bluetooth.mac default plus reboot restored E5:C3:A8:B0:60:66 and normal discovery. Fresh PIN pairing, version/core-statistics queries and repeated factory address (default) replies passed. The bench target was left in this discoverable mode.

    The newly completed pairing reaches Bluefruit's secured callback before its bonded flag is necessarily ready. Recording the completed pairing in the pair-complete callback as well, waiting for the deferred bond write, and deferring identity-list reconfiguration until disconnect are covered by this fresh-pair hardware run. Static source contracts also check those paths.

    The binary reboot command can reset the target before BlueZ receives its ATT write acknowledgement. One initial harness run reported Unlikely Error after successful pre-reboot commands. Later checks require an observed disconnect, a live post-reboot query and decreased device uptime; they do not treat the write exception or tool exit code alone as a successful reboot.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#discovery-and-dfu-cautions-found-during-testing","title":"Discovery and DFU cautions found during testing","text":"

    An ordinary BlueZ discovery scan used a temporary random local address and did not show the directed target. A passive scan using the Pi's public identity did show it. Reconnection must use the saved peer/device identity; absence from an ordinary scan is not proof that the node stopped working. Conversely, BlueZ can retain a cached name after pairing, so a displayed name is not proof that the name is still being advertised. The packet-type and zero-length-payload capture is the relevant evidence here.

    The existing DFU handoff helper assumed the application and bootloader shared one address. That assumption is false with a custom application address: the authenticated handoff worked, but the helper timed out looking for DFU at the application address. A fresh exact-address scan found the known factory-address XIAO_DFU instead. An old factory-address host bond also caused the first DFU service-discovery attempt to disconnect; clearing only that old lab record allowed DFU. No bootloader code was changed for this test.

    An earlier application-address host pairing record was also removed before its saved stealth state was known. A direct, known-address connection from the same Pi identity allowed fresh PIN pairing and protected commands again. This does not prove that an arbitrary replacement phone can rediscover or recover a stealth node after forgetting its bond.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#remaining-hardware-gates","title":"Remaining hardware gates","text":""},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#repeatable-regression-procedure","title":"Repeatable regression procedure","text":"
    1. Record the exact board, application version, ZIP hash, local/remote BLE identities and original Bluetooth setting. Keep a working recovery path.
    2. Set the desired custom or random MAC, enable bluetooth.stealth on, reboot, and capture normal discovery before pairing.
    3. Pair once. Require a stored bond and successful protected MeshCore commands, then keep the original session open and issue another command.
    4. Disconnect. Capture the actual advertising type and payload with an appropriate receiving identity; do not use cached scan names as evidence.
    5. Reconnect by the saved peer without another PIN and exchange commands.
    6. For custom and saved-random addresses, repeat bluetooth.stealth on, reboot twice, and verify unchanged identity/bond, decreased uptime and live commands. Include a reconnect after the fast-advertising period ends.
    7. Separately remove all target power and repeat the persistence checks.
    8. Test bluetooth.stealth off, then on, as a pairing reset, and off as the exit from stealth. Verify live discovery, pairing, commands and an unchanged MAC policy. Changing the address to default is a separate step.
    9. With each rotating MAC policy, verify that its rotation trigger still works, stealth stays enabled, and the new address permits fresh pairing.
    10. Repeat relevant gates for a phone with address privacy and each BLE backend.

    Raw VM-side logs, the exact artifact and bounded test helper are in /home/mesh/mercer-stealth-validation.jUDW0v/. Raw Bluetooth captures are local diagnostics and should not be published without privacy review.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#mercer-crash-capture","title":"Mercer crash capture","text":"

    During this run the Pi used its existing dwc_otg.speed=1 full-speed USB configuration. Kernel and health logs were streamed to this VM. No new kernel errors or Pi lockup were observed during these tests; that does not establish the cause of the earlier lockup.

    The live test streams and all temporary scans were stopped at approximately 22:56 PDT. Journald remained active, the restore service remained enabled, and the test XIAO was disconnected in default mode. Disk journals occupied about 4.9 MiB at the final check.

    At 22:35 PDT, temporary persistent journaling was enabled with a 16 MiB configured disk-use limit, 2 MiB journal files, one-day retention and a 30-second normal sync interval. Recent buffered messages can still be lost in a sudden failure. The original Storage=volatile configuration is intact.

    mercer-crash-capture-restore.service is enabled for the next Pi boot. It checks the armed boot ID, removes only the unchanged managed override, switches journald back to RAM and leaves the old disk journals for collection. Its same-boot no-op guard and unit syntax passed. The next-boot restoration has not yet been exercised; no Pi crash or reboot was deliberately induced.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#independent-flag-follow-up-dev-stflag","title":"Independent-flag follow-up - dev-stflag","text":"

    The same XIAO and Pi were used later on 2026-09-07 to test the separate bluetooth.stealth on|off flag, with no development-mode migration. This run used Xiao_nrf52_companion_radio_ble, target radio preset, default profile, and firmware dev-stflag-4b400ea5 with the uncommitted flag changes.

    Check Observed result Default and rejected input Initial mode was factory address with stealth off. set bluetooth.mac stealth and set bluetooth.stealth maybe returned errors, leaving both settings unchanged. Custom plus stealth D2:27:5A:40:06:99 advertised normally before first pairing. Fresh PIN pairing, stored bond, protected commands, and a five-second held session passed with stealth on. Repeated on and warm reboot Repeating set bluetooth.stealth on retained the paired state. After reboot, the same custom address accepted protected commands without another PIN. Uptime decreased from 84 to 67 seconds. Actual advertising after reboot A public-address passive capture showed ADV_DIRECT_IND for the custom address, data length zero, and no undirected advertisement or scan response from that address. The scan was explicitly disabled afterward. Off preserves custom address set ble.stealth off and reboot restored normal discovery at the same custom address. A live connection returned stealth off and the unchanged custom policy. Saved random plus stealth F2:2E:E5:65:18:5E passed first pairing and held commands. After reboot, it retained its address, saved-random policy and stealth bond; protected reconnect needed no PIN. Uptime decreased from 55 to 30 seconds. Random-after-connect plus stealth Selecting this policy kept stealth on and reopened pairing at C7:FD:29:44:4F:3E. Pairing produced both bonded-peer-only status and an armed next-boot rotation. Reboot changed the address to F5:F3:89:B7:B5:26, where normal discovery, fresh pairing and protected commands passed with stealth still on. Random-every-boot plus stealth The first boot used F9:B2:03:DD:CC:43, completed fresh pairing, and answered commands with stealth on. The next commanded reboot advertised F8:C7:C0:96:1E:00; fresh PIN pairing and protected commands passed again with the rotating policy and stealth on. Restore the bench target Setting the MAC to default kept stealth on until the separate set bluetooth.stealth off. After reboot, factory-address discovery, fresh PIN pairing and protected commands confirmed default MAC and stealth off. The obsolete factory-address host bond was replaced during this final pairing.

    These checks used actual encrypted BLE connections at MTU 247, not scan sightings alone. Two first-pair harness attempts stopped before connecting because their unpaired BlueZ discovery object no longer existed. A fresh bounded scan immediately followed by pairing was used for retries; this was not a firmware GATT command failure. Reboot-write/disconnect races were accepted only with an observed disconnect and subsequent live device checks.

    Local automated verification passed 39 native cases, 32 source/persistence/UI contracts, and 11 real-browser cases. Supported-wrapper builds passed for XIAO nRF52, XIAO C3, M5Stack Unit C6L and XIAO S3 WIO Full Companion, covering nRF52, ESP32 NimBLE and ESP32 Bluedroid integration. The contracts and browser suite are included in the unit-test workflow; a browser must be available for the optional browser suite to execute instead of skip.

    Exact artifacts, build logs, command logs and the private packet capture are in /home/mesh/bluetooth-stealth-flag.ApLB8o/ on the VM. The remaining hardware gates above still apply; these results do not qualify every board or phone.

    At 23:42 PDT the XIAO was disconnected in normal discoverable mode. The Pi retained the same boot ID throughout, remained responsive, and had no new kernel messages in the streamed log. Journald was active, the next-boot volatile-log restoration service was still enabled, and disk journals used about 5.9 MiB. No USB reset, Pi reboot, or target battery removal was used.

    "},{"location":"hardware_validation_checklist/","title":"Hardware validation checklist","text":"

    This file records dated hardware observations and open validation tasks. Historical defaults, versions, and test frequencies below describe the tested revision, not necessarily the current release. For current ASCII USB defaults and runtime switches, use feature switches by role.

    Use this checklist for release-candidate hardware runs. A check is complete only when its log identifies the physical device, firmware artifact, artifact hash, command result, and cold/warm boot outcome. Do not infer success from a tool's exit code when the tool has a documented false-success mode.

    "},{"location":"hardware_validation_checklist/#bluetooth-stealth-qualification-2026-09-07","title":"Bluetooth stealth qualification - 2026-09-07","text":"

    The XIAO stealth report records exact artifacts for the initial implementation and independent-flag follow-up. The flag run covers custom/saved-random pairing and bonded reboot reconnect, idempotent on, off preserving the MAC, directed advertising, and both rotating policies with stealth still enabled. It also gives the repeatable hardware regression procedure. True battery-disconnected cold boot, phone address-privacy behavior, a second unbonded central, and ESP32 hardware remain separate uncompleted gates; do not infer them from the XIAO results.

    "},{"location":"hardware_validation_checklist/#current-marathon-ledger-through-2026-09-04","title":"Current marathon ledger (through 2026-09-04)","text":"Hardware Stable identity Current state Next blocking check Seeed XIAO nRF52840 B35E71C1C3726CE7 OTAFIX candidate 0x02040405 passed exact serial install and corrected Legacy DIS model gates; the patched Bluetooth repeater-updater application was restored over BLE and returned as exact 2886:8044 USB Use the updater for an identity-gated Bluetooth transfer to another repeater, then prove current-build cold persistence Seeed Tracker T1000-E 34A9141999729D5D OTAFIX candidate 0x02040405 and the uf2reset-fixed Full Companion are installed. The real text command returned exact 2886:0057 boot USB, the same fixed app was restored, and refreshed bonded BLE services pass as MeshCore-09848C15 Restore the protected Repeater identity when Companion qualification is complete; cold boot, then force LR1110 reset RAK3401 0B81C9C68D8D01B4; FICR 8D8D01B4 0B81C9C6 OTAFIX test version 0x02040403 passes bidirectional application UF2, exact SWD application/bootloader readback, unchanged UICR, and a 74-block direct V4 LoRa apply from exact 3caf9dcf to current HEAD 9fd580c8. Post-apply USB and LoRa identity/hash checks pass and the temporary 0 dBm bench setting is restored to 22 dBm Add exact RF packet counters, then repeat the supported-bandwidth and controlled/passive/mixed routed-hop matrix Heltec T096 651F8E496197F882 OTAFIX candidate 0x02040405 and the uf2reset-fixed no-footer Full Companion are installed. The real text command returned exact boot USB, and final USB terminal/Binary mode, dual CDC, version, help, and BLE identity pass Perform a privileged or physical host-driven USB bus reset without resetting the MCU; complete the deferred multi-click/long-press physical matrix Heltec MeshTower V2 with SD 9352162A72082314 OTAFIX candidate 0x02040405 passed serial combined install, the historical cached DIS handles, BLE application restore, and exact application USB return. Card format/cooldown/forced-format/raw-erase/remount and the live SD apply contract passed earlier Signed interrupted-download resume, corruption/signature rejection, then full/delta apply Heltec V4 USB MAC 44:1B:F6:6A:E8:44; BLE 44:1B:F6:6A:E8:45 USB/BLE/Wi-Fi and all three TCP services pass on hardware. The final fresh-NTP-gated Full Companion image builds with 4.31 MB app space free Flash the exact final NTP build, prove NTP-before-TLS success/failure ordering, then OTA seeding SenseCAP Indicator LoRa CH340 plus USB MAC D8:3B:DA:75:23:AC; BLE D8:3B:DA:75:23:AD The current dark-layout application is SHA-256 81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b; identity-gated flash, USB ASCII/Binary switching, runtime logging, and separate configured-Wi-Fi, BLE, and TCP checks pass. Fresh-NTP HTTPS recovery, strict Range resume, STAGEV2 install, and RP USB readback passed on earlier exact artifacts; the final coordinator source still needs its blocked-UDP/123 hardware gate Physical display-wrap and four-mode render/heap checks, exact-final blocked-NTP recovery gate, LoRa/TempRadio, and non-empty OTA seeding

    The current source checkpoint passes 1,132/1,132 native cases, 68/68 host broker/endpoint cases, all 318 LoRa-OTA chain/route/bundle cases, and 41/41 MOTA format/signature/delta cases under the documented pipx detools interpreter. Indicator geometry, dark-theme, and font-recovery contracts pass 4/4, 6/6, and 25/25. A serialized real Indicator ESP32-S3 Wi-Fi/USB profile also compiles at 95,944 bytes RAM and 1,589,433 bytes flash. The direct system-Python MOTA invocation predictably lacks detools; that environment boundary is not a firmware failure and must not be worked around by installing the package globally.

    The RAK failure log is /home/mikec/hwtest/runs/rak3401-syncidle-clean-tSp7PLgA/result.txt on the Mercerwood Pi. The tested UF2 SHA-256 was 650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7. cp returned success in 95 ms, but sync -f failed with EIO after 157,188 ms. Post-failure SWD readback proved that all seven ordinary sectors whose bytes differed between the old and intended builds still contained the old values, and the settings page was still erased. The only available pre-copy SWD dump was taken several hours earlier, however, so it cannot prove that zero unchanged blocks were written during this attempt or establish when the stale S140-v7 tail in the first application page appeared. The next run must capture an immediate pre-copy dump as well as the post-copy dump. Serial DFU recovery then completed in 35,698 ms with the exact Device programmed. marker. All 1,830 UF2 payload blocks matched SWD readback after recovery, and the application passed a subsequent real power-removal boot.

    The corrected 2.4.4-preview.2 candidate was installed through identity-gated SWD from combined-image SHA-256 9b1654baccd2a920f68651893e31d79170400d1a7982a3f854ae640f1d3513a8. An immediate pre-copy dump closed the earlier evidence gap. A deliberately old UF2 then copied and flushed successfully in 16,568 ms, and the exact formerly failing UF2 above copied and flushed successfully in 16,601 ms. After each direction, all 1,830 payload blocks matched a full SWD readback with zero header or payload mismatches. The return update changed 123 bytes across five expected application pages, UICR remained byte-identical, and a subsequent downstream USB power removal plus Pi reboot returned the RAK as the same stable identity. It reported the intended 3caf9dcf application, board, node name, USA radio tuple, and 22 dBm setting after that cold boot. The new bootloader's CLI source string is truncated before the injected preview number, so the independently hashed SWD artifact is the authoritative candidate identity for this run.

    The later TinyUSB lifecycle/generation candidate identifies itself as test version 0x02040403. Its exact 40,960-byte bootloader readback SHA-256 is f609eb18754070c54f127325b1cb48547baf5c6b1c695d36fd0c8e0874713c13. With that candidate installed, a full CURRENT.UF2 read completed before each write. The 936,960-byte bf092b24 application UF2 (SHA-256 12477a9734cf12eb920361bd21024e39bf303953fcf79d0bf720e00847e2ae92) copied and flushed in 28,203 ms and returned to application mode four seconds later. The exact 3caf9dcf application UF2 (SHA-256 650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7) then copied and flushed in 26,479 ms and also returned in four seconds. Both boots retained RAK3401-OTA-BENCH and 910.525/62.5/SF7/CR5.

    Identity-gated SWD then read the entire 468,480-byte padded UF2 application range. Its SHA-256 exactly matched the converted source on both sides at b2e37a7681027ca91d1b2bd8e39b55395d9e91d4863aed17131e50c1ba1d2227. The bootloader still matched the installed 0x02040403 image byte-for-byte, and UICR still matched the pre-test image at d029ea630c2f632a1b690cb52b2a96a28b6f870c656c3e1bd74ba3a571c53b9c. The authoritative logs are under /home/mikec/hwtest/runs/rak3401-uf2-02040403-20260830-170829 and /home/mikec/hwtest/runs/rak3401-uf2-return-02040403-sfHiGEqA on Mercerwood.

    The subsequent direct LoRa qualification used the V4 as controller/seeder and the RAK's exact bf092b24 body 91CF4EC2882F23D6 as the base. Its mandatory fixed three-minute rehearsal passed on 909.95/250/SF5/CR5, including natural expiry and exact normal-radio recovery. The 2,132-byte, two-block in-place delta reached 2/2 and ready in six seconds; install was accepted, and the new body appeared at the 60-second reboot probe. The automation took 514 seconds including rehearsal, clock/radio safety checks, and post-install verification. The final body was 27A223DDFC3A8F47, version 1.17.1.5 at 3caf9dcf, and the bootloader reported blrc:B8, which is OTAFIX's successful application apply result rather than an error. Exact target TX power was wrapped from 22 dBm to 0 dBm for the co-located transfer and restored to 22 dBm afterward; the source RXPS configuration and both normal radio tuples were also restored.

    Identity-gated SWD then read the live application at 0x26000. All 468,384 bytes matched the intended release image exactly (SHA-256 b496e3f4419c9f771564ed3c74b3407822f45acdf60cbd6c4625a54b181ca084), and the independently recomputed EndF hash was 27A223DDFC3A8F47. The target ID was 2FA509C1, hardware ID was RAK_3401, and version word decoded to 1.17.1.5. The 40,024-byte bootloader readback also matched the exact installed ffb1580/test-version-0x02040402 artifact byte-for-byte; UICR was unchanged. After SWD reset-halt/resume, USB again reported the expected application, identity, name, radio, 22 dBm, no TempRadio lease, and blrc:00 (the retained success diagnostic is intentionally cleared by the next normal boot).

    The current 0x02040403 bootloader candidate then passed a second direct V4 LoRa qualification using a materially larger application change. The unsigned in-place package was 75,614 bytes with 75,108 payload bytes and 74 transfer blocks. Its mandatory fixed three-minute rehearsal passed, including natural TempRadio expiry and exact normal-channel recovery; one lost read-only ota status reply was reconciled by its bounded exact retry. The transfer reached 74/74 and READY in 122 seconds, the explicit unsigned install was accepted, and reboot produced exact HEAD 9fd580c8 with body 744BC4443891ED10. The complete safety-wrapped run took 589.487 seconds, including rehearsal, recovery, and final verification. The first postboot ver reply was lost and its exact read-only retry passed. Independent USB checks then confirmed target 2FA509C1, hardware RAK_3401, unchanged identity/name/radio/RXPS/watchdog state, bootloader CRC 6683C066, ABI 3, apply result blrc:B8, and no active TempRadio/download/seeder/key state. After the separate host-recovery exercise, the near-field TX setting was restored from 0 dBm to its exact original 22 dBm and read back over an authenticated LoRa session. The sanitized authoritative log is /home/mikec/hwtest/runs/rak3401-02040403-head-vzWOXVxT/qualification-redacted.log with SHA-256 3e53dcaa5922c7c18a7890963b8fc092311d2db01caa5603640f43f1482802f6.

    A serialized VM build of the six connected-board qualification profiles then completed 6/6 in 136.122 seconds: XIAO nRF52 BLE Companion, T1000-E BLE Companion, RAK3401 LoRa-OTA Repeater, SenseCAP Indicator LoRa USB/Wi-Fi Companion, MeshTower V2 SD LoRa-OTA Repeater, and the consolidated Heltec V4 V4.2/V4.3 Full Companion. The earlier V4 selector typo was rejected before any compile began and is not a firmware build failure.

    After the fresh-NTP download gate, shared SNTP coordinator, MQTT-to-MeshCore RTC handoff, and daily refresh policy were added, the complete native matrix passed 1,102/1,102 cases in 93.953 seconds. The scheduler is host-tested at 24-hours-minus-one-millisecond, the exact 24-hour boundary, normal and wrapped millis() values, a short retry that overrides the daily cadence, the zero deadline sentinel, and a Wi-Fi event latch. A reconnect after a successful sample preserves the existing deadline, so successful samples are requested after boot and then only once per day. All current Python and shell contract suites passed, and the expanded LoRa-OTA automation suite passed 317/317 cases.

    Serialized real builds also passed after that change. The final event-safe implementation uses an atomic GOT_IP latch so even a short initial connection before the 10-second MQTT status sample cannot skip the required boot NTP request. The final Heltec V4 MQTT Observer application is 1,853,304 bytes with SHA-256 1938ae88fcdbe601cf8698df44b548656079562d899801886b808af913e5f9ac. The consolidated Full V4 image used 2,250,801/6,553,600 flash bytes and left 4,302,799 bytes of app-image space. Its 2,251,272-byte application image has SHA-256 05131eac29164be46d1bcceeb4f03455f5ea8d61c8d4f529b77d8adfe25fdb6a; the 2,316,808-byte merged image has SHA-256 c4c1fc639b6d0087c795a81aca79c07eba56dc4e071eb374dc70da0bec81815e. The focused daily-NTP policy suites passed all 42 cases after the latch was added, and the Full capability manifest verified all ten expected features. The reduced RAK3401 LoRa-OTA Repeater also compiled with the verified WisBlock I2C aliases. At that source state its link used 471,344/815,104 flash bytes; the reduced external-sensor contract retains INA219/INA226/INA260/INA3221 as the voltage/current entries in the optional environmental-telemetry table. These are not the image's only I2C consumers. The image separately retains its SSD1306 display, auto-discovered I2C RTCs, RAK12500 I2C GPS, and RAK12501/L76K UART GPS paths. The configured INA3221 and RAK12500 both default to I2C address 0x42; those two devices cannot coexist at those addresses. The supported combined arrangement leaves RAK12500 at 0x42, straps INA3221 A0 to SCL for 0x43, and uses a build with -DTELEM_INA3221_ADDRESS=0x43. The nRF52840 die-temperature and ADC battery-voltage paths are also retained. The 472,227-byte DFU ZIP SHA-256 is fdd369005a61635756efa9e38b217376a952f334b0864f927a46799b65264fa9, and the matching 943,104-byte UF2 SHA-256 is 2155632fde730e0ae2e4a72ce64f89910861f6c5105a4bd7ac96aaa538a33dca. These VM results do not replace the remaining exact-final hardware test with UDP/123 blocked.

    The later serialized RAK3401 contract build, after the non-blocking nRF52 USB logging change, used 471,576 bytes before its 56-byte EndF trailer. Its EndF body hash is 026749684bf6ee8f, target ID is 2FA509C1, hardware ID is RAK_3401, and version word is 0x01110105. The 472,435-byte DFU ZIP has SHA-256 cf0880b26f2d6bd8157f8af76a6718fe1f998de8ea7df8cd952e734448201fb9; the 943,616-byte UF2 has SHA-256 f893a8cebf309af12a4a6f5483acb28f94158e18632f660e011658c846e8ca7f. The linked ELF contains GPS, SSD1306, RTC, and the four INA families, while an exact symbol audit finds none of AHT10/20, BME280, BMP280, SHTC3, SHT4X, LPS22HB, MLX90614, VL53L0X, BME680/BSEC, BMP085, or RAK12035. The complete native matrix passed 1,112/1,112 cases at that intermediate source state. These are VM gates only; flash the exact artifacts and repeat the physical peripheral and blocked-clock checks before marking any later source state hardware-qualified.

    Subsequent source hardening changed RAK peripheral discovery: the shared 3V3_S rail remains enabled, UART GPS requires a complete checksum-valid NMEA sentence, an exact INA3221 identity blocks a conflicting u-blox probe, and a successful I2C GPS claim suppresses a sensor only on the same bus and address. The hashes and sizes above describe their recorded intermediate artifacts; they are not current-HEAD evidence and must not be reused for the hardened source.

    The immutable font endpoint was re-probed only after the VM reported NTPSynchronized=yes. GitHub returned HTTP 206 for bytes 0-65535, an exact 65,536-byte match to the checked-in font (range SHA-256 8b0dd308eb01469d1f9e732fba260720a80cc86f6cb5385c28e8a16c76dcfbf3), with total length 1,302,608 and strong ETag \"39ff0cfbe37e36905507697d042869c05374c3202dd96d5a9f98812153f324c1\". This validates the current strict Range assumptions against the live service; the exact-final Indicator still needs the negative hardware run proving that blocked UDP/123 prevents every TLS request and RP2040 staging write.

    Mercerwood subsequently recovered without a target write. Both its Tailscale and LAN addresses again accepted SSH, Bluetooth was idle, and the exact T1000-E stable serial 34A9141999729D5D was present as application USB 239a:8029. The RAK3401 simultaneously uses the same VID/PID, which confirms that every continuation must resolve the stable serial/by-id identity rather than VID/PID. The T1000-E remained in the exact bonded Full Companion application; no stale DFU, scanner, meshcli, or HCI-capture process was running.

    The post-reboot seven-TTY inventory was also resolved without assuming that one board owned adjacent device names. ttyUSB0 is the SenseCAP Indicator's ESP32-S3 CH340 UART and ttyACM5 is its RP2040; their sibling physical USB paths and the Indicator's live TCP board reply agree. The separately attached ttyACM4 is the MeshTower V2 native HT-n5262 endpoint and returned the qualified MeshTower build over its own console. The remaining ACM endpoints independently identify the RAK3401, XIAO nRF52840, Heltec V4, and T1000-E.

    ModemManager had restarted after the Pi reboot and could probe otherwise idle radio consoles. Six serial-bearing boards now use stable-serial-only ID_MM_DEVICE_IGNORE rules. Because the Indicator CH340 has no stable serial, its separate rule requires the exact current physical port chain and exact 1a86:7523 identity; there is no broad CH340, product, or VID/PID-only rule. A controlled ModemManager restart reported zero modems, displaced no TTY owner, caused no USB disconnect/re-enumeration, and left all seven endpoints ignored. The same mctomqtt process retained sole ownership of the RAK, while a subsequent Indicator TCP query still returned the expected board and build. Reversible root-only backups were retained on the Pi before each rule update.

    "},{"location":"hardware_validation_checklist/#run-wide-gates","title":"Run-wide gates","text":""},{"location":"hardware_validation_checklist/#compute-placement-gate","title":"Compute-placement gate","text":""},{"location":"hardware_validation_checklist/#lora-controlled-host-recovery-gate","title":"LoRa-controlled host recovery gate","text":"

    The sanitized host-recovery evidence bundle is under out/host-actions-live-20260831/. Its SHA256SUMS manifest has SHA-256 5acff71ed98f483bb769e8c99c577bf358d0491355ac72855ee7093cd658bf21, and sha256sum -c verifies every deployed-hash, pre/post-state, radio, OTA, network, and reboot record in that manifest.

    "},{"location":"hardware_validation_checklist/#mercerwood-pi-usb-power-cycle-gate","title":"Mercerwood Pi USB power-cycle gate","text":""},{"location":"hardware_validation_checklist/#seeed-xiao-nrf52840","title":"Seeed XIAO nRF52840","text":""},{"location":"hardware_validation_checklist/#seeed-tracker-t1000-e","title":"Seeed Tracker T1000-E","text":""},{"location":"hardware_validation_checklist/#rak3401","title":"RAK3401","text":""},{"location":"hardware_validation_checklist/#rak4631-reduced-profiles","title":"RAK4631 reduced profiles","text":""},{"location":"hardware_validation_checklist/#heltec-t096","title":"Heltec T096","text":""},{"location":"hardware_validation_checklist/#heltec-meshtower-v2-with-sd","title":"Heltec MeshTower V2 with SD","text":""},{"location":"hardware_validation_checklist/#heltec-v4","title":"Heltec V4","text":""},{"location":"hardware_validation_checklist/#seeed-sensecap-indicator-lora","title":"Seeed SenseCAP Indicator LoRa","text":""},{"location":"hardware_validation_checklist/#cross-radio-lora-ota-acceptance","title":"Cross-radio LoRa OTA acceptance","text":""},{"location":"home_text_spacing/","title":"Home-screen text spacing","text":"

    On non-Indicator companions, a visible Bluetooth PIN or connection status owns a separate bottom-aligned block. It replaces the ordinary inbox instruction, clock and WiFi rows while displayed. The PIN stays below the inbox title; the label and value use measured font heights, including the MeshPocket's larger e-paper font. An unusually short viewport may omit the label, never overlap the title or clip the PIN. Indicator's dedicated pairing layout is unchanged.

    The repeater home page also stacks measured font-height rows instead of fixed ten-pixel offsets. Long names and status lines are ellipsized horizontally.

    Run python3 -m unittest discover -s test -p test_home_text_spacing.py -v on a host with a C++ compiler. This renders the actual companion and repeater home branches, recording every text rectangle and rejecting overlaps or clipping. It covers 128x64 OLED, 160x80 T096, 250x122 MeshPocket and larger viewports, PIN/connected/disabled/prompt states, WiFi variants, long strings and a sweep of font heights. It also checks the static reader hint. The separate native display and T096 tests exercise the driver drawing/measurement methods.

    Hardware acceptance: check that the full six-digit PIN is near the bottom, with no text over it; connect/disconnect Bluetooth and verify no stale digits; then check the repeater's five rows and the V4's non-blinking reader footer.

    "},{"location":"host_cli_service/","title":"LoRa CLI Host Service","text":"

    The repeater host <text> command lets an authenticated LoRa administrator request a small, explicitly allowed operation from a USB-connected computer. The included Raspberry Pi endpoint supports these exact requests:

    cmd host help\ncmd host cpu-temp\ncmd host hostname\ncmd host uptime\ncmd host load\ncmd host memory\ncmd host disk-free\ncmd host clock status\ncmd host clock sync\ncmd host clock set <unix_epoch>\ncmd host network restart\ncmd host reboot\ncmd host action status <operation_id>\ncmd host run <alias> [arguments]\n

    The first seven actions after help are read-only. Clock changes, network restart, and reboot are opt-in actions and are disabled unless explicitly enabled. Text such as reboot now, cpu-temp; reboot, and embedded newlines is not a command: the endpoint accepts only an exact allowlist match.

    The bridge is included in normal repeater firmware except on Wio-E5. Its specialized RS232 bridge image omits the host service because the combined application exceeds the fixed 240 KiB partition; use the normal Wio-E5 repeater image when the USB/MQTT host service is needed.

    "},{"location":"host_cli_service/#run-alongside-meshcoretomqtt","title":"Run alongside meshcoretomqtt","text":"

    meshcoretomqtt must remain the only process that opens the repeater USB serial port. The endpoint communicates with it through the same MQTT broker, which can be local to the Raspberry Pi:

    LoRa -> repeater -> USB -> meshcoretomqtt -> MQTT -> host endpoint\nLoRa <- repeater <- USB <- meshcoretomqtt <- MQTT <- host endpoint\n

    Clock-recovery deployments must run the loopback broker, meshcoretomqtt, and endpoint on the same Pi. A wrong Pi clock can prevent TLS validation against a remote broker, while split host clocks can reject the signed live claim before the correction arrives. Remote brokers remain suitable for ordinary commands when the connection and both clocks are already healthy.

    Launch meshcoretomqtt with its existing arguments plus --debug; its current debug-topic parser needs that flag to publish the repeater request record. Do not open the serial TTY from the host endpoint too.

    This feature does not compare the repeater wall clock with the Pi clock. Minutes of drift, an unset repeater clock, and later clock corrections are safe: request freshness comes from a live one-time challenge. The normal meshcoretomqtt sync_time setting may remain enabled for its other uses, but host-command authorization does not depend on it.

    Generate a dedicated service key using the Python environment installed by meshcoretomqtt:

    sudo /opt/mctomqtt/venv/bin/python3 host_cli_service.py \\\n  --generate-key /etc/mctomqtt/host-cli-key.json\nsudo chown mctomqtt:mctomqtt /etc/mctomqtt/host-cli-key.json\nsudo chmod 600 /etc/mctomqtt/host-cli-key.json\n

    Add the printed public key to the existing meshcoretomqtt configuration:

    [remote_serial]\nenabled = true\nallowed_companions = [\n  \"SERVICE_PUBLIC_KEY_PRINTED_ABOVE\"\n]\nnonce_ttl = 120\ncommand_timeout = 10\n

    Restart meshcoretomqtt, then run the endpoint. Replace USA with the exact three-character IATA namespace configured in the broker and supply the repeater's complete 64-character public key:

    sudo -u mctomqtt /opt/mctomqtt/venv/bin/python3 host_cli_service.py \\\n  --broker 127.0.0.1 \\\n  --iata USA \\\n  --repeater-key REPEATER_PUBLIC_KEY \\\n  --service-key /etc/mctomqtt/host-cli-key.json\n

    Add --username NAME --password-file FILE for broker authentication. Add --tls, and optionally --ca-cert FILE, for TLS. Custom installations can use --request-topic and --command-topic to match their broker namespace. The endpoint source and its systemd/configuration details are in examples/host_cli_service.

    "},{"location":"host_cli_service/#allowlisted-programs-and-arguments","title":"Allowlisted programs and arguments","text":"

    Add --programs-file FILE to expose locally selected programs as host run <alias> [arguments]. The example JSON file fixes each executable, fixed leading arguments, maximum 1-5 second runtime, and the exact remote argument schema. For example:

    cmd host run fan on 15\n

    The example maps that request to the fixed process argument vector:

    /usr/local/bin/mesh-fan-control --source lora on 15\n

    The supported argument rules are an explicit non-option choice, a bounded nonnegative integer, or a short token using a restricted ASCII character set. Alias lookup and every argument validation happen before process creation. Execution uses an absolute configured path, no stdin, shell=False, a minimal environment, and / as its working directory. Extra arguments, leading-option injection, shell syntax, control characters, and invalid quoting are rejected. The allowlist file and executable must not be group- or world-writable.

    Copy and edit programs.example.json, then give the service account only the operating-system permissions needed by those trusted programs.

    "},{"location":"host_cli_service/#opt-in-network-and-reboot-recovery","title":"Opt-in network and reboot recovery","text":"

    These actions use their own socket-activated root broker, independently of the clock-control broker. Installing or enabling clock control does not grant host recovery actions. Install the following as root-owned files:

    sudo install -o root -g root -m 0755 meshcore_host_actions.py \\\n  /usr/local/sbin/meshcore-host-actions\nsudo install -o root -g root -m 0644 meshcore-host-actions.socket \\\n  /etc/systemd/system/meshcore-host-actions.socket\nsudo install -o root -g root -m 0644 meshcore-host-actions.service \\\n  /etc/systemd/system/meshcore-host-actions.service\nsudo install -o root -g root -m 0644 meshcore-networkmanager-restart.service \\\n  /etc/systemd/system/meshcore-networkmanager-restart.service\nsudo install -o root -g root -m 0644 meshcore-host-reboot.timer \\\n  /etc/systemd/system/meshcore-host-reboot.timer\nsudo install -o root -g root -m 0644 meshcore-host-reboot.service \\\n  /etc/systemd/system/meshcore-host-reboot.service\n

    The root-owned broker policy enables nothing by default. Create a systemd drop-in and select network-restart, reboot, or both as an exact comma-separated list:

    sudo systemctl edit meshcore-host-actions.service\n
    [Service]\nEnvironment=MESHCORE_HOST_ACTIONS=network-restart,reboot\n

    Reload systemd and enable only the broker socket:

    sudo systemctl daemon-reload\nsudo systemctl enable --now meshcore-host-actions.socket\n

    After changing the policy on an already active installation, restart meshcore-host-actions.service so the broker reads the new value.

    Finally add --allow-network-restart, --allow-reboot, or both to the unprivileged endpoint. An action must pass both gates: the endpoint flag and the root-owned broker policy. The endpoint never invokes sudo; remove legacy wifi-restart program aliases and host-action sudoers entries because sudo aliases are not a fallback for this hardened service. Use the exact host network restart action instead.

    Systemd creates /run/meshcore-host-actions.sock as root:mctomqtt mode 0660. The root broker authenticates both the endpoint UID and primary GID with SO_PEERCRED, while the endpoint authenticates the root-created listener. The protocol is one bounded ASCII line. Neither the caller nor request text can choose an executable, unit, path, or argument. The broker can start only the fixed static NetworkManager restart service or the fixed reboot timer; the broker and action units have empty capability sets and systemd sandboxing. The configured mctomqtt UID and its effective primary GID are the delegated local trust boundary; supplementary group membership alone is rejected.

    Side effects follow a fail-closed two-phase sequence. The endpoint reserves a canonical 128-bit operation ID with PREPARE, publishes its reply with MQTT QoS 1, and requires wait_for_publish plus is_published confirmation before sending one COMMIT. It does not block the Paho network callback while waiting. No confirmation means no commit. Reboot uses a fixed approximately 10-second timer that starts only after commit. The MQTT confirmation proves acceptance by the local broker, not delivery over USB and LoRa; there is no correlated serial delivery acknowledgement, so the physical reply is best effort.

    network restart deliberately drops Wi-Fi and Tailscale management while NetworkManager restarts. Use a loopback MQTT broker and keep the broker, meshcoretomqtt, host endpoint, and USB device services independent of NetworkManager: they must not have Requires=, BindsTo=, or ordering dependencies on it. This preserves the local reply/commit path while remote management temporarily disappears.

    The operation ID is derived from the authenticated repeater key, request ID, and nonce. The reply displays it, and its state can be queried later:

    cmd host action status 0123456789ABCDEF0123456789ABCDEF\n

    The root broker retains at most 64 records for the current boot. It persists prepared before replying and in-progress before scheduling, schedules a committed operation at most once, and never automatically retries an ambiguous outcome. A broker restart converts recovered in-progress to ambiguous. Only an old uncommitted prepared record may be evicted; otherwise a full store rejects new work. Reusing an operation ID for a different action is rejected. scheduled means PID 1 accepted the fixed unit job; it does not claim that the subsequent network restart or reboot completed successfully.

    A distinct reboot is rejected while another reboot is committed or ambiguous, so a second request cannot falsely promise a fresh 10-second timer while the first timer is already running. A new reboot reservation may supersede only an older, uncommitted reboot reservation; the superseded operation can no longer be committed.

    "},{"location":"host_cli_service/#opt-in-clock-recovery","title":"Opt-in clock recovery","text":"

    clock status reports the Pi epoch and NTP synchronization state without root access. To enable the exact clock sync and clock set <unix_epoch> actions, install meshcore_clock_control.py as /usr/local/sbin/meshcore-clock-control, owned by root and mode 0755, then install the accompanying meshcore-clock-control.socket and meshcore-clock-control.service and static meshcore-chrony-step.service files in /etc/systemd/system as root-owned mode-0644 files:

    sudo install -o root -g root -m 0755 meshcore_clock_control.py \\\n  /usr/local/sbin/meshcore-clock-control\nsudo install -o root -g root -m 0644 meshcore-clock-control.socket \\\n  /etc/systemd/system/meshcore-clock-control.socket\nsudo install -o root -g root -m 0644 meshcore-clock-control.service \\\n  /etc/systemd/system/meshcore-clock-control.service\nsudo install -o root -g root -m 0644 meshcore-chrony-step.service \\\n  /etc/systemd/system/meshcore-chrony-step.service\nsudo systemctl daemon-reload\nsudo systemctl enable --now meshcore-clock-control.socket\n

    The socket is fixed at /run/meshcore-clock-control.sock; systemd creates it as root:mctomqtt with mode 0660. Run the endpoint as user and primary group mctomqtt, then add --allow-clock-control. The endpoint validates the socket metadata and authenticates the connected server as root with Linux SO_PEERCRED. The root service independently requires the peer's UID and primary GID to match mctomqtt:mctomqtt before it reads or executes a request. No clock-control sudo rule is used; remove old clock-helper sudoers lines when upgrading.

    Both layers require one canonical unsigned decimal argument with no sign, leading zero, whitespace, or trailing text. The accepted epoch range is 2020 through 2099. The private protocol accepts only the complete bounded ASCII lines sync and set <epoch>. Every child process uses a fixed absolute argv, shell=False, and a timeout no longer than 1.5 seconds.

    clock set changes CLOCK_REALTIME without disabling NTP, then requests an NTP step. If the NTP request fails, the response honestly reports that the clock changed and which synchronization step remains incomplete. If clock sync enables NTP but the explicit step/restart fails, that partial outcome is also reported. When chrony is available, the root broker starts only the fixed static meshcore-chrony-step.service. That hardened one-shot runs /usr/bin/chronyc -a makestep as _chrony:_chrony, with no capabilities, AF_UNIX only, and a one-second start timeout; it can therefore reach chrony's private runtime socket without broadening the root broker. The _chrony account comes from the Debian/Raspberry Pi chrony package. Without chrony, the broker restarts systemd-timesyncd. Clock drift cannot authorize a captured command: the signed request still needs the repeater's live one-time claim, whose deadline is monotonic rather than wall-clock based.

    "},{"location":"host_cli_service/#trust-and-injection-controls","title":"Trust and injection controls","text":"

    The MQTT broker transports records but does not establish their authenticity. The endpoint checks the configured repeater identity and verifies its Ed25519 signature over the complete request ID, random nonce, and Base64URL request. It also validates framing, UTF-8, and byte limits. It does not execute that first record.

    Instead, the endpoint stores the request in memory, creates a random 64-bit challenge, and sends @claim=<random> through meshcoretomqtt's signed serial channel. The repeater accepts it only from physical USB while the exact ID and nonce are pending, then signs a CLAIMED proof containing the challenge. Only a matching live proof lets the endpoint perform allowlist matching and execute the action. It consumes the proof before execution, so MQTT redelivery cannot execute an action twice.

    A captured request only causes a new challenge that an idle repeater refuses. A captured proof does not match a new challenge, and restarting the endpoint forgets pending challenges. Repeater/Pi clock drift therefore cannot turn an old reboot or run record into a valid action. The reply and claim commands still use short-lived JWTs; meshcoretomqtt checks their signer allowlist, target, expiration, signature, and separate replay nonce. Wall-clock expiry and its in-memory nonce cache are not sufficient across a backward clock jump plus restart; the firmware's live one-time claim is what protects these host actions. Keep recovery-mode allowed_companions limited to the dedicated service key, with the endpoint and meshcoretomqtt on the same Pi.

    Newlines and other reply control characters are converted to spaces and are also independently rejected by the firmware parser.

    Protect the service private key and use broker ACLs that allow it to subscribe only to the selected repeater debug topic and publish only to that repeater serial-command topic. A private key listed in allowed_companions is trusted to sign serial requests.

    "},{"location":"host_cli_service/#limits","title":"Limits","text":"

    Use cmd get host to report the bridge state and limits.

    "},{"location":"kiss_modem_protocol/","title":"MeshCore KISS Modem Protocol","text":"

    Standard KISS TNC firmware for MeshCore LoRa radios. Compatible with any KISS client (Direwolf, APRSdroid, YAAC, etc.) for sending and receiving raw packets. MeshCore-specific extensions (cryptography, radio configuration, telemetry) are available through the standard SetHardware (0x06) command.

    "},{"location":"kiss_modem_protocol/#serial-configuration","title":"Serial Configuration","text":"

    115200 baud, 8N1, no flow control.

    "},{"location":"kiss_modem_protocol/#frame-format","title":"Frame Format","text":"

    Standard KISS framing per the KA9Q/K3MC specification.

    Byte Name Description 0xC0 FEND Frame delimiter 0xDB FESC Escape character 0xDC TFEND Escaped FEND (FESC + TFEND = 0xC0) 0xDD TFESC Escaped FESC (FESC + TFESC = 0xDB)
    +------+-----------+--------------+------+\n| FEND | Type Byte | Data (escaped)| FEND |\n| 0xC0 |  1 byte   | 0-510 bytes  | 0xC0 |\n+------+-----------+--------------+------+\n
    "},{"location":"kiss_modem_protocol/#type-byte","title":"Type Byte","text":"

    The type byte is split into two nibbles:

    Bits Field Description 7-4 Port Port number (0 for single-port TNC) 3-0 Command Command number

    Maximum unescaped frame size: 512 bytes.

    "},{"location":"kiss_modem_protocol/#standard-kiss-commands","title":"Standard KISS Commands","text":""},{"location":"kiss_modem_protocol/#host-to-tnc","title":"Host to TNC","text":"Command Value Data Description Data 0x00 Raw packet Queue packet for transmission (one pending at a time) TXDELAY 0x01 Delay (1 byte) Transmitter keyup delay in 10ms units (default: 50 = 500ms) Persistence 0x02 P (1 byte) CSMA persistence parameter 0-255 (default: 63) SlotTime 0x03 Interval (1 byte) CSMA slot interval in 10ms units (default: 10 = 100ms) TXtail 0x04 Delay (1 byte) Post-TX hold time in 10ms units (default: 0) FullDuplex 0x05 Mode (1 byte) 0 = half duplex, nonzero = full duplex (default: 0) SetHardware 0x06 Sub-command + data MeshCore extensions (see below) Return 0xFF - Exit KISS mode (no-op)"},{"location":"kiss_modem_protocol/#tnc-to-host","title":"TNC to Host","text":"Type Value Data Description Data 0x00 Raw packet Received packet from radio

    Data frames carry raw packet data only, with no metadata prepended. The Data command payload is limited to 255 bytes to match the MeshCore maximum transmission unit (MAX_TRANS_UNIT); frames larger than 255 bytes are silently dropped. The KISS specification recommends at least 1024 bytes for general-purpose TNCs; this modem is intended for MeshCore packets only, whose protocol MTU is 255 bytes.

    Only one packet may be pending for radio transmission at a time. If the host sends a second Data frame before the first has completed, the modem responds with Error (0xF1) and TxBusy (0x07).

    "},{"location":"kiss_modem_protocol/#host-output-backpressure","title":"Host Output Backpressure","text":"

    Outbound frames are encoded into a 2-slot queue and flushed when serial output space is available; loop() never blocks on writes. Radio TX state advances independently of host read speed. TxDone is retained until it can be queued. If the outbound queue is full, the modem responds with Error (0xF1) and TxBusy (0x07). Hosts should read serial promptly to avoid delayed responses.

    "},{"location":"kiss_modem_protocol/#csma-behavior","title":"CSMA Behavior","text":"

    The TNC implements p-persistent CSMA for half-duplex operation:

    1. When a packet is queued, monitor carrier detect
    2. When the channel clears, generate a random value 0-255
    3. If the value is less than or equal to P (Persistence), wait TXDELAY then transmit
    4. Otherwise, wait SlotTime and repeat from step 1

    In full-duplex mode, CSMA is bypassed and packets transmit after TXDELAY.

    "},{"location":"kiss_modem_protocol/#sethardware-extensions-0x06","title":"SetHardware Extensions (0x06)","text":"

    MeshCore-specific functionality uses the standard KISS SetHardware command. The first byte of SetHardware data is a sub-command. Standard KISS clients ignore these frames.

    "},{"location":"kiss_modem_protocol/#frame-format_1","title":"Frame Format","text":"
    +------+------+-------------+--------------+------+\n| FEND | 0x06 | Sub-command | Data (escaped)| FEND |\n| 0xC0 |      |   1 byte    |   variable   | 0xC0 |\n+------+------+-------------+--------------+------+\n
    "},{"location":"kiss_modem_protocol/#request-sub-commands-host-to-tnc","title":"Request Sub-commands (Host to TNC)","text":"Sub-command Value Data GetIdentity 0x01 - GetRandom 0x02 Length (1 byte, 1-64) VerifySignature 0x03 PubKey (32) + Signature (64) + Data SignData 0x04 Data to sign EncryptData 0x05 Key (32) + Plaintext DecryptData 0x06 Key (32) + MAC (2) + Ciphertext KeyExchange 0x07 Remote PubKey (32) Hash 0x08 Data to hash SetRadio 0x09 Freq (4) + BW (4) + SF (1) + CR (1) SetTxPower 0x0A Power dBm (1) GetRadio 0x0B - GetTxPower 0x0C - GetCurrentRssi 0x0D - IsChannelBusy 0x0E - GetAirtime 0x0F Packet length (1) GetNoiseFloor 0x10 - GetVersion 0x11 - GetStats 0x12 - GetBattery 0x13 - GetMCUTemp 0x14 - GetSensors 0x15 Permissions (1) GetDeviceName 0x16 - Ping 0x17 - Reboot 0x18 - SetSignalReport 0x19 Enable (1): 0x00=disable, nonzero=enable GetSignalReport 0x1A -"},{"location":"kiss_modem_protocol/#response-sub-commands-tnc-to-host","title":"Response Sub-commands (TNC to Host)","text":"

    Response codes use the high-bit convention: response = command | 0x80. Generic and unsolicited responses use the 0xF0+ range.

    Sub-command Value Data Identity 0x81 PubKey (32) Random 0x82 Random bytes (1-64) Verify 0x83 Result (1): 0x00=invalid, 0x01=valid Signature 0x84 Signature (64) Encrypted 0x85 MAC (2) + Ciphertext Decrypted 0x86 Plaintext SharedSecret 0x87 Shared secret (32) Hash 0x88 SHA-256 hash (32) Radio 0x8B Freq (4) + BW (4) + SF (1) + CR (1) TxPower 0x8C Power dBm (1) CurrentRssi 0x8D RSSI dBm (1, signed) ChannelBusy 0x8E Result (1): 0x00=clear, 0x01=busy Airtime 0x8F Milliseconds (4) NoiseFloor 0x90 dBm (2, signed) Version 0x91 Version (1) + Reserved (1) Stats 0x92 RX (4) + TX (4) + Errors (4) Battery 0x93 Millivolts (2) MCUTemp 0x94 Temperature (2, signed) Sensors 0x95 CayenneLPP payload DeviceName 0x96 Name (variable, UTF-8) Pong 0x97 - SignalReport 0x9A Status (1): 0x00=disabled, 0x01=enabled OK 0xF0 - Error 0xF1 Error code (1) TxDone 0xF8 Result (1): 0x00=failed, 0x01=success RxMeta 0xF9 SNR (1) + RSSI (1)"},{"location":"kiss_modem_protocol/#error-codes","title":"Error Codes","text":"Code Value Description InvalidLength 0x01 Request data too short InvalidParam 0x02 Invalid parameter value NoCallback 0x03 Feature not available MacFailed 0x04 MAC verification failed UnknownCmd 0x05 Unknown sub-command EncryptFailed 0x06 Encryption failed TxBusy 0x07 Radio TX busy, or host output queue full"},{"location":"kiss_modem_protocol/#unsolicited-events","title":"Unsolicited Events","text":"

    The TNC sends these SetHardware frames without a preceding request:

    TxDone (0xF8): Sent after radio transmission completes. Contains a single byte: 0x01 for success, 0x00 for failure. Delivery to the host may be delayed under serial backpressure but is not dropped.

    RxMeta (0xF9): Sent after each standard data frame (type 0x00) with SNR (1 byte, signed, value x4) and RSSI (1 byte, signed, dBm). Queued with the data frame; omitted if the data frame cannot be queued. Enabled by default; toggle with SetSignalReport. Standard KISS clients ignore this frame.

    "},{"location":"kiss_modem_protocol/#data-formats","title":"Data Formats","text":""},{"location":"kiss_modem_protocol/#radio-parameters-setradio-radio-response","title":"Radio Parameters (SetRadio / Radio response)","text":"

    All values little-endian.

    Field Size Description Frequency 4 bytes Hz (e.g., 869618000) Bandwidth 4 bytes Hz (e.g., 62500) SF 1 byte Spreading factor (5-12) CR 1 byte Coding rate (5-8)"},{"location":"kiss_modem_protocol/#version-version-response","title":"Version (Version response)","text":"Field Size Description Version 1 byte Firmware version Reserved 1 byte Always 0"},{"location":"kiss_modem_protocol/#encrypted-encrypted-response","title":"Encrypted (Encrypted response)","text":"Field Size Description MAC 2 bytes HMAC-SHA256 truncated to 2 bytes Ciphertext variable AES-128 block-encrypted data with zero padding"},{"location":"kiss_modem_protocol/#airtime-airtime-response","title":"Airtime (Airtime response)","text":"

    All values little-endian.

    Field Size Description Airtime 4 bytes uint32_t, estimated air time in milliseconds"},{"location":"kiss_modem_protocol/#noise-floor-noisefloor-response","title":"Noise Floor (NoiseFloor response)","text":"

    All values little-endian.

    Field Size Description Noise floor 2 bytes int16_t, dBm (signed)

    The modem recalibrates the noise floor every 2 seconds with an AGC reset every 30 seconds.

    "},{"location":"kiss_modem_protocol/#stats-stats-response","title":"Stats (Stats response)","text":"

    All values little-endian.

    Field Size Description RX 4 bytes Packets received TX 4 bytes Packets transmitted Errors 4 bytes Receive errors"},{"location":"kiss_modem_protocol/#battery-battery-response","title":"Battery (Battery response)","text":"

    All values little-endian.

    Field Size Description Millivolts 2 bytes uint16_t, battery voltage in mV"},{"location":"kiss_modem_protocol/#mcu-temperature-mcutemp-response","title":"MCU Temperature (MCUTemp response)","text":"

    All values little-endian.

    Field Size Description Temperature 2 bytes int16_t, tenths of degC (e.g., 253 = 25.3degC)

    Returns NoCallback error if the board does not support temperature readings.

    "},{"location":"kiss_modem_protocol/#device-name-devicename-response","title":"Device Name (DeviceName response)","text":"Field Size Description Name variable UTF-8 string, no null terminator"},{"location":"kiss_modem_protocol/#reboot","title":"Reboot","text":"

    Sends an OK response, flushes serial, then reboots the device. The host should expect the connection to drop.

    "},{"location":"kiss_modem_protocol/#sensor-permissions-getsensors","title":"Sensor Permissions (GetSensors)","text":"Bit Value Description 0 0x01 Base (battery) 1 0x02 Location (GPS) 2 0x04 Environment (temp, humidity, pressure)

    Use 0x07 for all permissions.

    "},{"location":"kiss_modem_protocol/#sensor-data-sensors-response","title":"Sensor Data (Sensors response)","text":"

    Data returned in CayenneLPP format. See CayenneLPP documentation for parsing.

    "},{"location":"kiss_modem_protocol/#cryptographic-algorithms","title":"Cryptographic Algorithms","text":"Operation Algorithm Identity / Signing / Verification Ed25519 Key Exchange X25519 (ECDH) Encryption AES-128 block encryption with zero padding + HMAC-SHA256 (MAC truncated to 2 bytes) Hashing SHA-256"},{"location":"kiss_modem_protocol/#notes","title":"Notes","text":""},{"location":"lora_ota_automation/","title":"Scripted LoRa OTA from start to finish","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    The dedicated RAK3401 chain report records the physical failures of withdrawn migrations and the physically qualified compact nine-step replacement. Its runner blocks the withdrawn chains and pins every accepted bridge image by SHA-256.

    tools/lora_ota/lora_ota.sh and tools/lora_ota/lora_ota.ps1 automate a MeshCore LoRa firmware update from a release .zip or ready .mota. They identify the destination, validate the hardware and running firmware, prepare the right container, move the participating nodes to a temporary radio channel, serve and monitor the download, request installation, restore the controller, and check the rebooted node.

    The script cannot install the destination's first OTA-capable firmware. It can stage signed v3 bootloader containers with --no-install, but never sends the privileged bootloader install command. Invalid v2 containers carrying the bootloader flag remain rejected. Do the first nRF52 bootloader installation over USB. An already-capable, explicitly supported internal-flash, XIAO QSPI, or exact MeshTower V2 microSD repeater may later use the separate manual ota bootloader install workflow.

    "},{"location":"lora_ota_automation/#required-topology","title":"Required topology","text":"

    The reliable serial topology uses two local radios:

                                          authenticated admin commands\ncomputer -- MeshCore binary API --> controller Companion -------------------+\n   |                                                                         |\n   +-- raw text CLI + mOTA seeder --> OTA source ---- LoRa OTA blocks ----> target\n                                                        |                    ^\n                                                        +-- relay(s) --------+\n

    One serial port cannot serve both controller roles: meshcli must keep reopening the controller while motatool owns the source port. The script rejects an attempt to use the same port for both.

    The USB ASCII switch (+++MESHCORE-TERM-START) is the local control path, not the mOTA data framing. On an nRF52 dual-CDC Full Companion, the script uses that mode briefly for ota status and TempRadio commands. It then closes the CLI and starts motatool; the exact ota folder on preamble switches the same USB interface 00 into exclusive mOTA mode from either startup ASCII or Binary Companion mode. If USB logging was enabled and the Full Companion rebooted, its separate interface 02 continues to provide plaintext logging and is not a controller or source port. With default logging off, interface 02 is not enumerated. BLE remains available.

    ESP32 Full Companion normally uses its dedicated TCP seeder on port 5001. ESP32 builds that also retain serial folder support use the same exclusive USB mOTA ownership state as nRF52. The exact ota folder on line transfers the USB port from startup ASCII or idle Binary Companion mode before binary mOTA frames begin; neither console mode shares that port until the folder detaches.

    "},{"location":"lora_ota_automation/#bootloader-packages-and-station-identifiers","title":"Bootloader packages and station identifiers","text":"

    Both wrappers use the same package-aware runner. It identifies application versus bootloader updates from container metadata, not filenames. A ZIP mixing application inputs and bootloader mOTAs requires --zip-member; their versions and target IDs are independent and must not be ranked together.

    Signed format-3 nRF52 bootloader packages can be staged with --no-install. Use the same controller/source/TempRadio arguments as an application transfer, but provide the bootloader .mota (or its ZIP member) and --no-install. Without that flag, even --yes stops before opening a radio. Bootloader staging requires Python cryptography in the interpreter running the wrapper and a current motatool supporting format 3. The runner reuses tools/mota/motalib.py for strict geometry, embedded identity/CRC, capability, hash and signature validation, and still runs motatool verify including any --public-key pin.

    If Python cannot load cryptography, an interactive run offers:

    Install cryptography privately and retry signature verification? [y/N]\n

    After approval, it uses the exact running Python interpreter to install cryptography==50.0.1 and its dependencies from PyPI into a separate per-user, per-interpreter cache. Binary wheels only are accepted; the repair does not compile native code, overwrite system/virtual-environment packages, change PATH, use sudo, or bypass externally-managed Python protections. It checks Ed25519 against a known valid signature and a tampered message, then retries the original full package verification. A later run can reuse a working cache without another download. This works for direct .mota files and bootloader members inside ZIPs; ordinary application updates and raw --tcp-cli commands do not need this library.

    The interpreter must have working pip. If pip is missing, the script gives an exact-interpreter ensurepip command and operating-system pip/venv guidance; it does not silently install system tools. No compatible binary wheel, an old pip, network/download failures, an unwritable cache, or a failed signature check stop the run before radio access. Pip output is shown and installation is bounded at ten minutes. The radio admin-password environment variable is not passed to pip. Partial installation files may remain in the displayed private directory. The cache is under meshcore-lora-ota/python/<interpreter-id>/cryptography==50.0.1 in the same per-user cache base described below for motatool.

    Only y or yes approves this repair; --yes is not installation consent. Noninteractive runs print a copyable private-install command and stop. After running that command manually, rerun the original OTA command; no Python path changes are needed. Declining never skips signature verification.

    It probes actual motatool inspect support before radio access: older and newer builds may advertise the same version. If the tool is missing or incompatible, an interactive run offers an automatic repair:

    Install this bootloader-capable motatool and continue after verification? [y/N]\n

    Only y or yes approves installation. --yes does not approve host software installation. Noninteractive runs stop and print a copyable manual installation command plus the --motatool argument to use afterward.

    The motatool repair builds the mikecarper/motatool fork at pinned revision 8c38369e7d35ad50cf74261869676d52dd24adf7, with Cargo's --locked dependencies, in a separate per-user cache directory. It needs matching stable Cargo and rustc 1.86.0 or newer and a native linker already installed; otherwise it points to Rust installation and stops without installing anything. The pinned dependency set needs more than the older rust-version in motatool's own manifest: zeroize 1.9.0 needs edition 2024, and ICU/idna dependencies need Rust 1.86. Cargo 1.75 is a version problem, not a root-permission problem; no nightly toolchain is necessary.

    Before asking to build or downloading crates, the runner checks both executable versions. It keeps a compatible default pair; otherwise it looks for versioned executables on PATH (such as Ubuntu's cargo-1.91 and rustc-1.91) and already installed stable rustup toolchains. It displays the selected paths and pins both for the build process, so a newer Cargo cannot silently invoke an old rustc. Rustup proxies are resolved to their actual toolchain binaries before changing to the build directory. No default toolchain, symlink, system PATH, or package installation is changed, and probes disable rustup's automatic toolchain install. Tool version probes have ten-second timeouts. A working cached motatool does not need Cargo or Rust checks at all.

    To choose a pair explicitly, append:

    --cargo cargo-1.91 --rustc rustc-1.91\n

    These options also accept executable paths, not shell aliases or a command with embedded arguments. Explicit selections and RUSTC/CARGO_BUILD_RUSTC compiler overrides are honored; if incompatible, they fail instead of silently selecting another compiler. --rustc takes precedence over those environment variables. The manual build command includes the chosen compiler as well as Cargo. Native linker, SDK, and system-library availability are still checked by the real build; a passed version check is not a guarantee that every host prerequisite is present. Source/dependency downloads, build time and disk use are disclosed before confirmation. Cargo progress is displayed, with a one-hour build timeout. The radio admin-password environment variable is not passed to the build.

    The installer does not overwrite the selected/system motatool or alter PATH. It may replace only a previous copy in its displayed private install directory. After installation it repeats the bootloader-package capability check and uses the new binary for this run only if that check passes. A later run using the default motatool selection can reuse the checked cache without downloading again; switching from an explicit --motatool path still asks permission. Refusal, a failed build, or a failed recheck stops before opening any radio. Failed build files may remain in the displayed cache directory for diagnostics.

    The install root is meshcore-lora-ota/motatool/<revision> under %LOCALAPPDATA% on Windows, ~/Library/Caches on macOS, or $XDG_CACHE_HOME (default ~/.cache) on Linux. Cargo uses an explicit --root, --rev, and --locked instead of replacing a global installation or following a moving branch.

    Before transfer it queries the destination's ota bootloader status, checks its bootloader-specific target/hardware identity, installed ABI/codecs and matching internal/QSPI/SD storage profile. It does not compare an application version or application target ID to a bootloader package. After transfer it checks the destination's exact staged MID and image-hash confirmation, then prints the manual command; it never sends a bootloader install command:

    ota bootloader status\nota bootloader install <MID8> <HASH16>\n

    Review and send those commands on the destination, not the source. The device independently enforces signer authorization, safe live storage, continuity and upgrade-only policy at installation. --base, --allow-non-upgrade, and --prepare-only remain application-only. See the bootloader prerequisites and recovery limits.

    TARGET_NODE, --relay, and --source-contact accept a contact name, full public key, or unique hexadecimal key prefix. The runner reads the controller's existing contact table and binds each selection to a full key before remote commands, so emoji names need not be typed and later name changes cannot redirect the run. Duplicate names, ambiguous prefixes, or the same radio under different participant aliases stop with an actionable error. Missing destination and relay contacts must first be imported or discovered. A missing connected source can be imported with the confirmation prompt below; a key alone does not create a contact.

    For a separate source with a managed USB/TCP console, automatic source selection reads the physical source's full public key and matches it in the controller's contacts. Repeaters use get public.key. Full Companion does not implement that repeater command: on both USB and TCP, its key is read from a fresh terminal welcome banner, with a supported ver reply on the same connection proving the terminal is live. USB briefly uses the existing STOP/START/STOP terminal wrapper to obtain that banner, even for an ASCII-first Full Companion; TCP reads the greeting from a new connection. Identity rechecks repeat this exchange rather than trusting a key cached from an earlier connection.

    ota status identifies the Full Companion seeder role before this probe. For unrecognized firmware, only an explicit unsupported get public.key response can trigger the Companion fallback. Timeouts, permission errors, malformed keys, missing/ambiguous banners, and failed ver replies still stop the run. Firmware that exposes neither identity mechanism needs a supported terminal firmware; the runner does not guess a key from the node name or skip the identity check.

    Source selection does not require the source's current name to match an old saved advert, and duplicate/emoji names cannot redirect this lookup. An explicit --source-contact must identify that same physical source. If its key is missing, an interactive run offers:

    Add the connected OTA source to this controller's contacts? [y/N]\n

    This works through both the shell and PowerShell launchers. The runner reads the source's existing card command over USB/TCP, checks the full key in its contact card against the connected source, and shows its name/key before asking. On approval it rechecks the source/controller identities and live contact table, then uses MeshCore's existing import_contact API. Signature validation remains with the controller's normal advert importer. For manual-add controllers it can approve only that exact source through add_pending; it does not enable general auto-add. A fresh reload_contacts must confirm the full source key before OTA continues. No LoRa advertisement or firmware update is required for this import.

    The default answer is No; --yes does not approve adding a contact. A noninteractive run stops with instructions to rerun in a terminal. Unsupported card firmware, malformed/mismatched cards, and unverified imports stop with manual-import/discovery guidance. To protect existing contacts, the controller's overwrite-oldest policy must be verifiably disabled; otherwise the runner stops and shows how to disable that bit without changing other auto-add flags. It does not change this policy itself. A full table or rejected import cannot be worked around by deleting another contact; an uncertain import is not automatically retried. An approved contact remains on the controller for subsequent runs.

    The normal/TempRadio on-air ACK checks still run; adding a contact does not prove radio reachability or skip those checks. When the controller and source are the same TCP Full Companion, use the verified --source-shares-controller topology; it does not need a contact for itself.

    "},{"location":"lora_ota_automation/#destination-requirements","title":"Destination requirements","text":"Destination Package installed One-time prerequisite Raw ZIP handling ESP32 Full application image OTA-enabled image with an A/B partition table Builds a full mOTA from the matching non-merged application .bin nRF52, internal flash In-place delta Exact-board OTAFIX bootloader with mOTA apply support Requires --base with the exact image currently running nRF52 repeater, external QSPI Full image or in-place delta QSPI-aware exact-board OTAFIX bootloader and matched repeater build Builds a full mOTA; adding --base requests a delta MeshTower V2 nRF52, microSD Full image or in-place delta SD-aware exact-board OTAFIX bootloader and compatible card Builds a full mOTA; adding --base requests a delta

    MeshTower V2 microSD application and bootloader containers require an Ed25519 signature from a key in the device allowlist. Supply --sign-key when the runner must build a container, and install the BLM2-capable SD-aware bootloader locally before attempting application or bootloader OTA. Preview.12 requires USB/BLE DFU or SWD; see MeshTower V2 microSD LoRa OTA.

    The firmware inside a raw ZIP must have a valid MeshCore EndF trailer. An ESP32 merged/factory image is not an application image and is rejected. A generic vendor DFU ZIP may also be unusable if it does not contain the raw EndF-bearing .hex or .bin.

    For an internal-staging nRF52, the exact base image is irreducible information. The node reports its eight-byte body hash, but that hash cannot reconstruct the firmware bytes needed to create a delta. Keep the .pio/build/ENV/firmware.hex that was actually flashed. A matching filename or version alone is not enough.

    "},{"location":"lora_ota_automation/#1-install-the-host-tools","title":"1. Install the host tools","text":"

    Install Python 3.10 or newer, Rust, Git, the official meshcore-cli 1.6.0 or newer, and the official motatool.

    For signed bootloader staging, see the automatic cryptography and bootloader-capable motatool repairs above. Pip is needed only when installing the missing Python dependency; Rust/Cargo and a native linker are needed only when building motatool, not when using an already compatible binary. The runner checks the selected meshcli version before opening radios. Keep its serial/BLE dependencies in meshcli's own environment (pipx handles these); installing cryptography in meshcli's pipx environment does not make it available to the separate Python interpreter running this wrapper. A ready bootloader .mota does not need the Python detools or intelhex build dependencies.

    On Bash:

    python3 -m pip install --user pipx\npython3 -m pipx ensurepath\npipx install meshcore-cli\n\ngit clone https://github.com/vk496/motatool.git\ncargo install --path ./motatool\n\nmeshcli -v\nmotatool --version\n

    On PowerShell:

    py -m pip install --user pipx\npy -m pipx ensurepath\npipx install meshcore-cli\n\ngit clone https://github.com/vk496/motatool.git\ncargo install --path .\\motatool\n\nmeshcli -v\nmotatool --version\n

    Restart the shell if pipx or Cargo reports that it changed PATH.

    "},{"location":"lora_ota_automation/#2-identify-and-test-both-local-links","title":"2. Identify and test both local links","text":"

    List serial devices:

    meshcli -l\n

    The examples below assume /dev/ttyACM0 is the controller and /dev/ttyACM1 is the OTA source. On Windows they might be COM7 and COM8. Close picocom, a serial monitor, the phone app, and any other program holding either link.

    Test the controller's binary API:

    meshcli -s /dev/ttyACM0 -b 115200 ver\n

    For an ordinary raw-text source, test its OTA support:

    meshcli -r -s /dev/ttyACM1 -b 115200 \"ota status\"\n

    The command must print an OTA | ... target:XXXXXXXX status.

    For an nRF52 full Companion, open the source port with terminal mode selected automatically:

    picocom -b 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM1\n

    Run ota status. It must report OTA seeder, install:disabled, and target 00000000; send +++MESHCORE-TERM-STOP before closing the terminal. The automation detects and performs this token-wrapped preflight itself, so no extra command-line option is needed.

    For an ESP32 full Companion, test its separate WiFi control console instead:

    ./tools/lora_ota/lora_ota.sh --tcp-cli 192.168.1.50 \"ota status\"\n

    It must report OTA seeder, install:disabled, and target 00000000.

    This standalone fallback uses Python's raw TCP socket directly. It works with meshcli versions whose -r mode rejects -t; it does not need meshcli, motatool, nc, or a firmware package. It defaults to text port 5002; port 5000 speaks the binary Companion protocol and port 5001 serves mOTA data. The firmware must expose the text console; this option does not enable one.

    Specify a port or adjust the total connection/command timeout (10 seconds by default):

    ./tools/lora_ota/lora_ota.sh --tcp-cli 192.168.1.50:5002 --timeout 15 \"ver\"\n

    PowerShell uses the same option:

    .\\tools\\lora_ota\\lora_ota.ps1 --tcp-cli 192.168.1.50 \"ota status\"\n

    Place --tcp-cli first. Each invocation sends one command and waits for the complete reply and terminal prompt. Connection failures, timeouts, missing replies, and rejected commands exit nonzero. If USB owns the terminal, close the active USB terminal and try again. Commands are never automatically resent in this standalone mode: a timeout after sending may mean the command executed but its reply was lost. Inspect the device state before repeating a change.

    For an actual OTA run, continue using --source-tcp HOST:5001 with --source-cli-tcp HOST:5002; that existing control path also bypasses meshcli raw mode. The standalone fallback does not transfer a firmware image itself.

    Changing a terminal to 57600 baud does not select ASCII mode. USB Companion builds and the normal raw management CLI use 115200 unless a particular build was explicitly configured otherwise.

    "},{"location":"lora_ota_automation/#3-check-the-destination-once","title":"3. Check the destination once","text":"

    The destination must be in the controller's contacts and remotely reachable on the normal channel. The script runs these authenticated checks itself:

    ota status\nget bootloader.ver\nota self\nota stats\n

    The script uses get bootloader.ver as an optional platform/version hint, not as an install gate. It makes one attempt without an interactive retry loop. unknown, an unsupported/missing command, an unrecognized reply, or a lost reply produces a warning and does not by itself block the run. In particular, older MeshCore firmware can report unknown for the installed signed OTAFIX 2.4.6 MeshTower V2 SD image because it omits the legacy UF2 version string. Both the shell and PowerShell launchers use this same behavior; no application or Companion firmware update is required for this host-side workaround. The runner also accepts the new OTAFIX2.4.6 and explicitly labelled 0.11.0 (base) formats.

    The runner still requires ota self to report bootloader: apply OK, bootloader: QSPI apply OK, or bootloader: SD apply OK and checks the reported bootloader ABI and codec mask against the selected package. If the version command is unavailable on older firmware, the script warns and falls back to the legacy ota self platform marker. If an nRF52 bootloader lacks the required capabilities, install the exact-board OTAFIX bootloader first. Positive nRF52 capability markers take precedence over a generic unsupported getter response, so it cannot silently skip nRF52 safety checks. Bootloader packages still require the independently checked ota bootloader status board, target, ABI, codec and storage identity; missing version text never bypasses package signature checks or the explicit bootloader-install workflow. The separate hardware-qualified rak3401_mota_chain.py retains its exact deployed version restriction; it is not a general OTAFIX 2.4.6 update runner. Current firmware also reports maxblk:2048 near the front of both OTA replies. The runner treats a missing marker as the deployed 1 KiB receive limit, rejects a ready 2 KiB package for such a target before transfer, and passes the live limit to motatool --block-size when it builds from raw firmware. Thus updating an older target from a release ZIP produces a compatible 1 KiB package while a current target retains the 2 KiB application default. ota stats is only an optional EndF version probe. It uses one bounded retry cycle; unsupported firmware or a lost reply falls back directly to the required ver command instead of entering an operator continuation loop.

    The default TempRadio tuple is:

    909.950,250,5,5,120\n

    The test default is 250 kHz bandwidth, SF5, and CR5. The frequency is only a North American example: choose a legal frequency supported by every participating radio and appropriate to your location. Older radios that do not support SF5 require a complete replacement tuple passed with --temp-radio.

    Before that long window is allowed, the live runner performs a mandatory independent three-minute rehearsal. Its exact normal-channel ota status and ota self identity proof gets one shared four-minute, read-only budget before any schedule, radio override, seeder, or transfer is armed; this tolerates a marginal link without consuming or extending a live lease. It advances a stale managed controller with exact readback and treats a source terminal's whole-minute display as an uncertainty window, never as an exact epoch. A source minute overlapping the host is preserved; a completely stale minute is pinned to a guarded future value and read back once. Neither clock is moved backward, and the runner stops if a managed clock cannot be proven within the ten-minute limit. Each remote participant must expose an empty tempradioat schedule. The runner projects one fixed start/end interval into each participant's independently sampled RTC, sends each mutation once, proves every exact identity on the temporary tuple, waits for natural expiry, and proves the complete normal path again. A lost schedule reply is resolved by those on-air proofs; it is never blindly replayed with a fresh duration.

    The published RAK3401 v1.16.7-c1caa5ad LoRa-OTA image includes get/set/del tempradioat and can take this safe first step. A truly older or reduced build without fixed TempRadio scheduling is rejected before mutation; bootstrap it locally or with another explicitly controlled maintenance path. The automation does not substitute an immediate tempradio command whose first delivery could remain queued after cleanup.

    "},{"location":"lora_ota_automation/#rxps-handling-during-tempradio","title":"RXPS handling during TempRadio","text":"

    RXPS improves receive performance per unit of radio-on time, so the runner keeps the destination's saved RXPS preference enabled whenever the selected fast tuple has a qualified timing window. Before changing any radio, it reads ver from the destination, controller, source, and every named relay. It treats v1.17.1.5 as the first forward contract in which every SF5-SF8 transmission, including a retry, uses the same tuple-selected physical preamble: normally 32 symbols, 64 only when 32 cannot enable RXPS, and 128 only when neither 32 nor 64 can. A saved RXPS level is also safely retuned after a radio change. An older or unparseable destination version fails closed: the runner temporarily sends set radio.rxps off instead of assuming an ad-hoc build contains the timing fixes. Automation deliberately treats the version as a wire-format contract.

    For the default SF5/BW250 tuple, the runner keeps destination RXPS on only when the destination and every possible sender are v1.17.1.5 or newer. It does not overwrite an existing level-based preference. Current firmware retunes that preference against the tuple-selected 64-symbol wire preamble; the qualified boundary is effective level 8, preamble 64 (1252 / 6424 us). A manually configured node may save set radio.rxps level 8 preamble 32 before entering TempRadio: when 32 symbols cannot cover the TCXO transition, firmware safely selects the real 64-symbol preamble. If the saved setting uses fixed manual timings, or even one participant is older or unknown, RXPS is temporarily off. This avoids the receive gap that a 32-symbol sender or an unretuned manual window would create.

    The complete qualified SX1262+TCXO policy is:

    TempRadio SF/BW Saved reference setting Qualified boundary Automation SF7/BW500 level 7 preamble 32 7 / 32 RXPS on for a current destination SF6/BW250 level 7 preamble 32 7 / 32 RXPS on for a current destination SF5/BW125 level 7 preamble 32 7 / 32 RXPS on for a current destination SF5/BW250 level 8 preamble 32 8 / 64 RXPS on only when every participant follows the 64-symbol contract SF6/BW500 level 8 preamble 32 8 / 64 RXPS on only when every participant follows the adaptive-preamble contract SF5/BW500 level 8 preamble 32 8 / 128 RXPS on only when every participant follows the adaptive-preamble contract SF5/BW62.5 level 10 preamble 16 10 / 16 RXPS on for a current destination Unqualified tuple none continuous RX RXPS temporarily off

    If RXPS was already off, the runner leaves it off. Otherwise it writes the original level, preamble assumption, receive/sleep periods, and temporary decision to protected target-rxps-settings.json in the run's working directory, verifies the temporary state, and restores the exact original setting after the target is back on its normal radio. Current firmware exposes this complete state through get radio.rxps.config; a legacy reply has only on/off and periods, so the runner can restore those periods but cannot reconstruct an unreported saved level. A radio change later recalculates from a preserved saved minimum, so moving back to a slower tuple returns to the operator's saved level.

    The OTA source has a stricter policy than the destination. For every source with a managed serial or TCP CLI, the runner reads and retains its exact RXPS preference, using the legacy fixed-period query only when the detailed query is unavailable. It builds and verifies the package, completes the read-only target checks, and obtains confirmation while that preference remains unchanged. Immediately before the first radio mutation, it reads the source RXPS state again, disables RXPS, and verifies the readback. Source RXPS stays off through catalog serving, download, installation, and post-install identity verification. Cleanup first proves that the source has returned to its normal radio, then restores and verifies the exact saved level/preamble or fixed-period state once. A source whose RXPS state cannot be read, disabled, or restored safely fails closed. If current firmware explicitly rejects an RXPS disable or restore with radio busy; retry, the runner retries that idempotent mutation at staggered 210\u2013378 ms intervals. All 32 delays are distinct and contribute about 9.4 seconds of waits; source-CLI command round-trip time is additional. This avoids repeatedly sampling one radio phase while retaining a strict attempt cap. Other rejections are not replayed.

    "},{"location":"lora_ota_automation/#4-run-an-esp32-update","title":"4. Run an ESP32 update","text":"

    The ZIP can contain a compatible ready .mota or the exact board-and-role non-merged application .bin:

    export MESHCORE_ADMIN_PASSWORD='target-admin-password'\n\n./tools/lora_ota/lora_ota.sh ./release.zip \"Roof ESP32\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1\n

    The script shows the detected target, hardware, running hash, chosen package, version, manifest ID, and action before asking for confirmation. For an unattended job, add --yes:

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Roof ESP32\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1 \\\n  --yes\n

    PowerShell equivalents:

    $env:MESHCORE_ADMIN_PASSWORD = 'target-admin-password'\n\n& .\\tools\\lora_ota\\lora_ota.ps1 '.\\release.zip' 'Roof ESP32' `\n  --controller-serial COM7 `\n  --source-serial COM8\n\n& .\\tools\\lora_ota\\lora_ota.ps1 '.\\release.mota' 'Roof ESP32' `\n  --controller-serial COM7 `\n  --source-serial COM8 `\n  --yes\n

    Prefer the environment variable or the interactive password prompt. Passing --password works, but the wrapper's own command line may be visible to other local processes. The runner keeps the password out of child meshcli command lines and removes its protected temporary command file after each call.

    "},{"location":"lora_ota_automation/#5-run-an-nrf52-update","title":"5. Run an nRF52 update","text":"

    If the input ZIP already contains a compatible in-place delta .mota, no base argument is needed: its embedded base hash is compared with the live node. If the ZIP contains raw new firmware, supply the exact running image:

    ./tools/lora_ota/lora_ota.sh ./nrf52-new-release.zip \"Hill nRF52\" \\\n  --base ./firmware-that-is-running.hex \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1\n
    & .\\tools\\lora_ota\\lora_ota.ps1 '.\\nrf52-new-release.zip' 'Hill nRF52' `\n  --base '.\\firmware-that-is-running.hex' `\n  --controller-serial COM7 `\n  --source-serial COM8\n

    Before building a delta, the runner proves that the base's target ID, hardware identity, firmware version when available, and EndF body hash match the live destination. It then asks motatool for codec 2, the nRF52 in-place format. The normal workspace is 0x98000.

    For a QSPI-backed repeater or the SD-backed MeshTower V2 target, a raw ZIP becomes a full image without --base. Supplying an exact base requests a smaller in-place delta and automatically selects the conservative external workspace 0xC6000, which is safe for S140 v6 and v7 application layouts. An explicit --inplace-memory overrides the automatic value.

    The live runner detects QSPI from ota self (QSPI apply OK) or ota status (bl:QSPI) and refuses an explicit QSPI store:ERR 0K report even when the bootloader itself advertises QSPI apply support. Offline --prepare-only runs must supply --nrf-qspi; do not use that switch for a board that only exposes QSPI pins or uses the chip as a Companion filesystem. The application and bootloader must both be from the matched repeater list in the nRF52 QSPI guide.

    "},{"location":"lora_ota_automation/#6-add-intermediate-relays","title":"6. Add intermediate relays","text":"

    List relays from farthest to nearest so each command is sent before its route moves to TempRadio. A bare relay name uses the destination password; use NAME=PASSWORD when it differs:

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Remote Target\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1 \\\n  --relay \"Far Relay=far-password\" \\\n  --relay \"Near Relay=near-password\"\n

    PowerShell uses the same arguments:

    & .\\tools\\lora_ota\\lora_ota.ps1 '.\\release.mota' 'Remote Target' `\n  --controller-serial COM7 `\n  --source-serial COM8 `\n  --relay 'Far Relay=far-password' `\n  --relay 'Near Relay=near-password'\n
    "},{"location":"lora_ota_automation/#other-connection-choices","title":"Other connection choices","text":"

    The controller can use any one of:

    --controller-serial PORT\n--controller-tcp HOST[:PORT]       # default port 5000\n--controller-ble ADDRESS_OR_NAME\n

    An ESP32 FULL/repeater source can serve over its dedicated WiFi seeder port while its raw USB CLI is used to start TempRadio:

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Remote Target\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp 192.168.1.50:5001 \\\n  --source-cli-serial /dev/ttyACM1\n

    An ESP32 companion_radio_full uses WiFi for both dedicated source links:

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Remote Target\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp 192.168.1.50:5001 \\\n  --source-cli-tcp 192.168.1.50:5002\n

    Port 5002 defaults automatically when it is omitted from --source-cli-tcp. Current Full Companion firmware exposes its complete role-specific text terminal there; the runner uses only ota, tempradio, and normalradio, and remains compatible with older bounded port-5002 consoles. The source-only Companion never stages or installs the image itself; it streams the host folder to other nodes over LoRa. See the full Companion guide for manual operation and interface details.

    An nRF52 companion_radio_full uses one USB source port sequentially. The runner automatically wraps local control commands in the terminal tokens, and unmodified motatool switches that port into mOTA mode when seeding starts:

    The wrapper sends STOP, then START, before each command. This makes fallback independent of whether a prior raw probe left an unobservable USB-UART connection in ASCII or Binary mode. Seeder startup is reported only after the verbose device log contains its COUNT acknowledgement; an immediate device ERR or a missing acknowledgement fails during startup instead of surfacing later as a catalog timeout.

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Remote Target\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1\n

    If the source is already on the exact TempRadio tuple through a scheduled or manual operation, --source-already-temp lets a TCP source run without a raw CLI link. The script cannot verify, extend, or shorten that source window, so leave a comfortable time margin. It also cannot inspect or change that unmanaged source's RXPS state; disable source RXPS yourself before starting the run and restore it only after returning the source to its normal radio.

    Use --controller-baud or --source-baud only for a build whose corresponding interface is genuinely configured to another speed.

    "},{"location":"lora_ota_automation/#package-selection-and-safety-gates","title":"Package selection and safety gates","text":"

    For a ZIP, the runner first examines every .mota without extracting paths. It keeps only packages matching the live target, hardware, base, platform, codec, and bootloader capabilities. It chooses the newest compatible version and prefers a delta over a full image at the same version. If equally suitable files differ, select one explicitly:

    --zip-member path/inside/archive/update.mota\n

    If no ready mOTA is usable, it searches .bin and .hex members for a valid, matching EndF, then builds the platform-appropriate container. Every result is structurally checked by the runner and independently passed through motatool verify before any radio changes. Direct firmware and mOTA inputs, as well as individual ZIP members, are rejected above 64 MiB before being loaded.

    Useful controls:

    For offline package preparation only:

    ./tools/lora_ota/lora_ota.sh ./release.zip offline \\\n  --prepare-only \\\n  --platform nrf52 \\\n  --target-id 1234ABCD \\\n  --target-base-hash 0011223344556677 \\\n  --target-hw Heltec_T114 \\\n  --base ./firmware-that-is-running.hex\n

    Live operation is safer because the script obtains these values directly from the destination.

    "},{"location":"lora_ota_automation/#what-happens-during-a-run","title":"What happens during a run","text":"
    1. Validate the input paths and host tools, then prove the source is either an OTA-enabled raw CLI or a source-only full Companion control interface. Read and durably save a managed source's exact RXPS preference without changing it.
    2. Before sending any remote packet, prove the managed source has no active, pending, or fixed TempRadio work, then gate the source and controller clocks. Advance a stale exact clock to host time; for a minute-resolution source, preserve an overlapping minute or use one guarded forward value with readback. Preserve a small future lead and fail closed above the ten-minute drift limit. Then authenticate to the target, query its target ID, hardware, running body hash, firmware version, bootloader version, and nRF52 bootloader capabilities, and save the controller's normal radio tuple.
    3. Select or build one compatible v2 application mOTA and verify all block hashes, Merkle root, full-image hash where applicable, identity fields, signature, codec, base, and the live firmware's reported maximum block size (1 KiB for legacy replies without maxblk, currently 2 KiB otherwise). Version-3 bootloader packages are refused before any target state changes.
    4. Read every participant's version, save the destination's RXPS state, select the qualified destination policy, and show the confirmation prompt.
    5. Run the fixed-window three-minute rehearsal described above. This phase does not disable RXPS, start a seeder, request a pull, or install anything; the long transfer remains completely unarmed until temporary and normal reachability both pass.
    6. Fresh-read, disable, and verify source RXPS; apply and verify the destination RXPS policy; then start TempRadio on the target, far-to-near relays, and source. A separate controller is moved and read back through Binary; a shared Full Companion instead schedules its bounded local override while Binary remains the transport. The runner rejects a TempRadio window that cannot cover setup, seeder startup, discovery, the transfer timeout, final polling, and install checks.
    7. Start motatool serve, discover the exact eight-hex manifest ID, request ota pull <id> flash, and poll until that same ID reports ready. A seeder process exit stops the run immediately. For --no-install, schedule all script-controlled nodes back to their normal radios before restoring the controller, unless --leave-controller-radio was requested.
    8. Recheck that exact ID, give the target a short final TempRadio safety window, and request ota install. Then shorten each relay's TempRadio window so the normal multi-hop route returns, stop the seeder, shorten the source window, restore the controller, and probe ota self every 10 seconds through the configured readiness window (five minutes by default). The exact new body hash is the readiness signal; only then does the runner require the exact package version. A relayed run continues the 10-second probes through the mandatory relay-return window. A source supplied with --source-already-temp is never modified. --leave-controller-radio moves the controller back to TempRadio only after this normal-channel verification. Restore the destination's exact original RXPS setting after normal-channel identity is proven. A managed source stays RXPS-off through that verification and its exact setting is restored only after its own TempRadio state is proven inactive.

    Remote replies are matched only after queued messages have been drained and only when they come from the intended contact and fit the command. A ready status for another manifest ID is an error, never permission to install it.

    "},{"location":"lora_ota_automation/#transmission-loss-and-retries","title":"Transmission loss and retries","text":"

    Read-only and replay-safe transmissions retry up to three times. Three retries or 90 seconds, whichever comes first, opens a 10-second stop-or-continue prompt when stdin is an interactive terminal. Continue remains the default on timeout or Enter, so an operator can persist through a temporary outage; enter s or stop to end the run, and Ctrl-C remains immediate. With non-interactive stdin, the runner stops after that finite automatic retry cycle instead of silently starting another cycle forever. Optional participant-version probes always use a bounded cycle and degrade to unknown; optional destination ota stats falls back directly to ver. The mandatory normal-channel destination baseline is the narrow exception: it may make nine total read-only attempts but is stopped by one hard four-minute deadline before any TempRadio lease or OTA mutation exists. Proofs inside the live rehearsal retain the smaller four-attempt limit.

    Commands that change OTA state are reconciled before replay:

    Retries and operator-selected continuation can outlast the original TempRadio budget. If a bounded window expires, rerun the same package after the nodes return to their normal channel; the manifest-ID check resumes its partial download without replacing it.

    The working directory is created before a managed source can be changed, then retained and printed at exit. It contains the exact served mOTA, motatool-serve.log, extracted build inputs when needed, and controller-radio.txt. A managed source also gets a protected source-rxps-settings.json containing its exact original preference and idempotent restore command. Its contents and directory entry are flushed before RXPS is disabled. When the destination started with RXPS enabled, protected target-rxps-settings.json records its manual recovery state. The RAK3401 chain points every nested step at one chain-root source record, so a rerun after host power loss does not adopt the temporary RXPS-off state as the original. A retained record is accepted only for the same managed CLI endpoint. These files contain no saved admin password. After exact source restoration, a standalone run atomically retires its record; the chain retains its shared record between steps and retires it only after the verified endpoint restoration completes.

    "},{"location":"lora_ota_automation/#interruption-and-recovery","title":"Interruption and recovery","text":"

    Ctrl-C stops the seeder, detaches its serial folder, makes a bounded attempt to shorten a source TempRadio window started by the script, and attempts to restore the controller. For a managed source it leaves RXPS off until the source is proven back on its normal radio, then restores and verifies the saved source preference. A transient success-path restore failure remains armed for one more idempotent cleanup attempt. The target and relays remain on TempRadio only until their bounded windows end; rebooting also restores their saved radio settings. A normal cleanup restores the destination's exact RXPS periods. If that remote restore cannot be confirmed, use target-rxps-settings.json after the target returns to its normal channel. A partial download remains safe. Once the target is reachable again (after its TempRadio window ends, or after putting the controller back on that tuple), rerunning the same package recognizes its manifest ID and resumes the existing session instead of clearing it.

    When a chained run has already proved the newly running body hash, its retained previous package can briefly report verifying staged blocks after TempRadio reactivates the OTA manager. The runner waits only through the configured discovery timeout, keeps checking source liveness, and accepts only the same manifest becoming ready to install or the manager becoming idle. A changed ID, failed or incomplete state, or timeout stops the chain. It then proves the exact installed body again. If that same session is still attached and ready, the runner detaches it with ota cancel; if the manager has become idle, it sends no cancel. An ordinary-channel no download status proves only that the manager is idle, not that persistent staging was erased. The runner therefore does not issue or describe an IDLE cancel as durable cleanup. The next chain transition explicitly re-adopts and proves the expected previous MID before detaching it; after the final install OTAFIX has consumed the approval word, so any retained container is inert and is replaced by the next valid pull.

    A hard process kill or host power loss cannot run cleanup. Recover a serial controller using the tuple saved in the printed work directory:

    radio=$(tr -d '\\r\\n' < ./meshcore-lora-ota-20260807-123456-1234/controller-radio.txt)\nmeshcli -s /dev/ttyACM0 set radio \"$radio\"\n
    $radio = (Get-Content '.\\meshcore-lora-ota-...\\controller-radio.txt' -Raw).Trim()\nmeshcli -s COM7 set radio $radio\n

    For a managed source, first return it to its ordinary radio, then inspect source-rxps-settings.json and issue its exact restore_command through the same serial or TCP-console endpoint recorded in that file. The command is idempotent; confirm the full setting with get radio.rxps.config before resuming an update.

    If you stop during final confirmation, reconnect on the node's normal channel and run ota self and ver. A completed run returns success only when ota self reports a valid new body hash and ver exactly matches the package; an unverified install returns status 2. Do not immediately replace a staged image: the default active-download guard preserves it until you explicitly use --replace-active-download or run ota cancel.

    Exit status is 0 for success, 2 for a validation or operational error, and 130 for Ctrl-C.

    "},{"location":"mbedtls-tls-footprint/","title":"Shrinking the per-connection TLS footprint on non-PSRAM observers","text":""},{"location":"mbedtls-tls-footprint/#why","title":"Why","text":"

    On a non-PSRAM Heltec V3 running two WSS/JWT broker slots, the largest allocatable block in internal DRAM walks down in ~16 KiB steps at every TLS reconnect while total free heap stays flat. Measured on hardware over 50 reconnect cycles: 62,452 -> 16,372 bytes, permanently.

    The step size is not a coincidence. framework-arduinoespressif32 3.20017 (Arduino 2.0.17, IDF 4.4) builds mbedTLS with the symmetric buffer configuration:

    CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN 16384      # sizes BOTH the in and out record buffers\nCONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE 1\n# CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN       -- not defined\n# CONFIG_MBEDTLS_DYNAMIC_BUFFER               -- not defined\n# CONFIG_MBEDTLS_SSL_VARIABLE_BUFFER_LENGTH   -- not defined\n

    Read from packages/framework-arduinoespressif32/tools/sdk/esp32s3/qio_qspi/include/sdkconfig.h. Note the separate framework-arduinoespressif32-libs package in ~/.platformio belongs to the esp32c6 env (pioarduino, IDF 5.3) and is not what this env links against -- though it happens to carry identical mbedTLS values.

    So each broker slot costs 2 x 16 KiB = 32 KiB of contiguous internal DRAM in record buffers alone, and two slots cost 64 KiB on a board with roughly 80 KiB free. Every reconnect frees and re-allocates those buffers, and anything that lands in the vacated hole in between prevents them from going back, which is the ratchet.

    Confirmed by two independent observations: losing a whole TLS session returned exactly 16,384 bytes of contiguity on one device and exactly 32,768 on another, and per-connection teardown frees ~41.6-44.7 KB total.

    "},{"location":"mbedtls-tls-footprint/#what-the-firmware-could-already-do-and-its-limit","title":"What the firmware could already do, and its limit","text":"

    softDisconnect() (branch perf/mqtt-renewal-no-stop, commit 6c51e468) stops the JWT renewal bounce from destroying and recreating the esp-mqtt task, keeping its 6 KiB stack out of the hole. Measured: the staircase arrests after 2 steps at 36,852 through cycle 16, where the baseline took 4 steps and settled at 16,372 by cycle 11 -- about 20 KB better.

    That is as far as the application layer reaches. MQTT 3.1.1 has no re-authentication packet, so presenting a fresh JWT requires a transport reconnect; mbedTLS's internal allocation order during the handshake is not controllable from the application. The remaining cost is the record buffers themselves.

    "},{"location":"mbedtls-tls-footprint/#the-changes","title":"The changes","text":"

    All three are compile-time in mbedTLS, and the Arduino framework ships precompiled .a archives (tools/sdk/esp32s3/lib/libmbedtls.a), so a project-level -D cannot change them. A custom framework build is required.

    Setting From To Saving per connection CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN unset y enables the two below CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN 16384 (implied) 16384 none -- keep it CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN 16384 (implied) 4096 ~12 KiB CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE 1 n ~4 KiB

    Roughly 16 KiB per connection, 32 KiB across two slots.

    "},{"location":"mbedtls-tls-footprint/#why-inbound-stays-at-16-kib","title":"Why inbound stays at 16 KiB","text":"

    A peer may legitimately send a 16 KiB TLS record. Lowering the inbound limit only works if both brokers negotiate the max-fragment-length extension or provably never send larger, and getting it wrong produces invalid-record and handshake failures rather than a clean error. Outbound is ours to choose: this firmware's MQTT and WebSocket frames are far below 4 KiB (MAX_TRANS_UNIT-bounded packets plus small JSON), so 4 KiB is comfortable.

    "},{"location":"mbedtls-tls-footprint/#risk-on-the-peer-certificate","title":"Risk on the peer certificate","text":"

    Dropping KEEP_PEER_CERTIFICATE means mbedtls_ssl_get_peer_cert() returns NULL after the handshake. Chain validation still happens -- only retention of the parsed leaf changes. This firmware verifies against a CA (GTS_ROOT_R4 / the bundle) and never inspects the peer certificate or a fingerprint, so it should be safe. It does change mbedtls_ssl_session layout, which is exactly why the whole framework must be rebuilt together rather than swapping a single archive in.

    "},{"location":"mbedtls-tls-footprint/#build-procedure","title":"Build procedure","text":""},{"location":"mbedtls-tls-footprint/#do-not-use-esp32-arduino-lib-builder-for-this","title":"Do not use esp32-arduino-lib-builder for this","text":"

    release/v4.4 is the branch matching Arduino 2.0.x, but its update-components.sh clones every dependency at master, so it no longer resolves. Five successive failures, each a different repo: the arduino branch name it passes to -A does not exist; jq is absent from the IDF image and its absence makes build.sh's target loop a silent no-op that still exits 0; esp_littlefs and esp32-camera master require IDF >=5.0/>=5.1; esp32-camera later needs an esp_jpeg version the 4.4 registry cannot satisfy; and tinyusb's source layout no longer matches arduino_tinyusb/CMakeLists.txt. Pinning each one in turn just surfaces the next.

    "},{"location":"mbedtls-tls-footprint/#rebuild-only-the-mbedtls-archives","title":"Rebuild only the mbedTLS archives","text":"

    More rigorous anyway, because it reuses the shipped sdkconfig verbatim -- so the archives differ from stock only by the intended change, with no arduino-version or DYNAMIC_BUFFER drift.

    This is ABI-safe for the content-length change specifically: ssl.h declares in_buf and out_buf as unsigned char *, allocated in ssl_setup(), and no public struct embeds a CONTENT_LEN-sized array. The other precompiled archives (esp-tls, esp_http_client, esp-mqtt) therefore remain compatible. It is not safe for CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE, which changes mbedtls_ssl_session layout -- that one needs everything rebuilt together, so it is excluded here.

    1. Minimal IDF project whose only component requirement is mbedtls.
    2. sdkconfig.defaults = the shipped packages/framework-arduinoespressif32/tools/sdk/esp32s3/sdkconfig, with CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384 replaced by the three asymmetric lines. Verify the diff is exactly 5 lines before building.
    3. docker run --rm -v $PWD:/project -w /project espressif/idf:v4.4.7 idf.py -DIDF_TARGET=esp32s3 build
    4. Confirm build/config/sdkconfig.h carries OUT_CONTENT_LEN 4096.
    5. Stage the four archives under the framework's names -- note the rename:
    built framework name stock size rebuilt esp-idf/mbedtls/libmbedtls.a libmbedtls.a 113,914 113,338 esp-idf/mbedtls/mbedtls/library/libmbedtls.a libmbedtls_2.a 1,252,150 1,245,718 .../libmbedcrypto.a libmbedcrypto.a 4,302,698 4,259,458 .../libmbedx509.a libmbedx509.a 676,590 673,318

    All within ~1% of stock, which is a good check that only the config differs.

    "},{"location":"mbedtls-tls-footprint/#wire-it-in-with-l-never-platform_packages","title":"Wire it in with -L, never platform_packages","text":"
    PLATFORMIO_BUILD_FLAGS=\"-L/path/to/staged/archives\" pio run -e Heltec_v3_repeater_observer_mqtt\n

    Verify with grep -oE \"[^ ]*libmbed[a-z0-9_]*\\.a\" .pio/build/<env>/firmware.map | sort -u -- every path must be the staged directory.

    Do not point platform_packages at a file:// copy of the framework. PlatformIO installs it over the shared ~/.platformio/packages/framework-arduinoespressif32, silently changing mbedTLS for every other ESP32 env and project on the machine. It does this even when the copy's package.json version differs -- verified twice here, and both times the fix was rm -rf the package and pio pkg install to re-download stock. A prepended library search path keeps the change scoped to one env, because the linker takes each archive member from the first archive that satisfies an undefined symbol.

    "},{"location":"mbedtls-tls-footprint/#how-to-verify-it-worked","title":"How to verify it worked","text":"
    1. strings/grep the new sdkconfig.h for the four settings.
    2. Build and check the RAM figure; static usage should be unchanged (these are heap buffers).
    3. On hardware, get mqtt.stats at boot with two slots connected: the largest free block should start roughly 24-32 KiB higher than the current 62-67 KiB.
    4. Soak across reconnect cycles and compare the floor against the two recorded runs: baseline settled 16,372 (cycle 11); softDisconnect holds 36,852 (cycle 16).
    "},{"location":"mbedtls-tls-footprint/#prior-art-in-this-investigation","title":"Prior art in this investigation","text":"

    .scratch/mqtt-non-psram-heap-staircase-analysis-2026-08-05.md (untracked -- .scratch/ is globally gitignored) holds the full allocation inventory. ~/mqtt-soak/STATE.md holds the soak evidence, including two retracted hypotheses worth not repeating: the perf commits were not the cause, and waev does not cap connections per IP.

    "},{"location":"nimble_companion_trial/","title":"ESP32-S3 NimBLE Full Companion trial","text":"

    These optional builds use NimBLE-Arduino 2.5.1 with the existing ESP32 Arduino 2.0.17 toolchain. They are hardware qualification builds, outside the normal release matrix and firmware picker.

    See the 2026-09-08 hardware results for measured RAM, MAC-policy checks, WiFi updates, and remaining qualification.

    The contact-cache guide adds six optional 350-contact trials for ESP32 boards without PSRAM, including Heltec V3. Their qualification results include linked RAM margins, V4/RAK hardware tests and the mOTA queue policy.

    Hardware Trial environment Contacts Channels Offline queue Heltec V4.2/V4.3 OLED, FEM on heltec_v4_2_v4_3_companion_radio_full_femon_nimble 350 40 512 XIAO ESP32-S3 with WIO SX1262 Xiao_S3_WIO_companion_radio_full_nimble 350 40 256

    The XIAO profile uses WIO radio pins CS 41, DIO1 39, BUSY 40 and RESET 42. It is not the generic XIAO profile for separately wired radio modules. Both boards retain their parent Full Companion features, USB mOTA sender, WiFi update slots, and PSRAM-backed offline queue. The V4 also retains direct MQTT.

    The small-screen message layout uses 6-pixel capitals on 128x64 panels and 5-pixel capitals on very tiny panels. It includes a compact channel/sender line and full message previews. Font selection is automatic.

    "},{"location":"nimble_companion_trial/#build","title":"Build","text":"

    Enable the optional configuration in the ignored platformio.local.ini:

    [platformio]\nextra_configs =\n  variants/*/platformio.ini\n  platformio.nimble.ini\n

    If that file already contains local settings, merge this list with them. Run one PlatformIO/build.sh command at a time. Both commands below use USA Cascade:

    OUTPUT_DIR=.releases/nimble-v4 bash build.sh build-firmware \\\n  heltec_v4_2_v4_3_companion_radio_full_femon_nimble \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \\\n  --radio-preset usa-cascadia --profile cascade --standard --require-ota\n\nOUTPUT_DIR=.releases/nimble-xiao bash build.sh build-firmware \\\n  Xiao_S3_WIO_companion_radio_full_nimble \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \\\n  --radio-preset usa-cascadia --profile cascade --standard --require-ota\n

    Use the merged image at address 0 for a USB installation. The V4 uses 16 MB flash; the XIAO uses 8 MB. Both use DIO flash mode. Use the application-only .bin for an existing, matching WiFi OTA layout. Remove the optional config entry when finished to return to the ordinary release matrix.

    "},{"location":"nimble_companion_trial/#bluetooth-identity-and-pairing","title":"Bluetooth identity and pairing","text":"

    All existing MAC settings remain available from the ASCII USB terminal and the app's authenticated local CLI. A setting takes effect after reboot.

    Command Behavior get bluetooth.mac Show the saved MAC policy. set bluetooth.mac C2:17:15:04:00:01 Use this custom random-static address. set bluetooth.mac random Generate one address and retain it across boots. set bluetooth.mac random-every-boot Generate a new address on each boot. set bluetooth.mac random-after-connect Rotate on the next boot after an authenticated connection; otherwise retain the address. set bluetooth.mac default Restore the factory Bluetooth address. set bluetooth.stealth on Pair once, then accept the saved bonded peer. set bluetooth.stealth off Restore ordinary discovery without changing the MAC policy. get bluetooth.stealth Show pairing or bonded-peer-only state.

    The ble.mac and ble.stealth aliases also work. A literal custom address must be a valid BLE random-static address (first byte C0-FF). Forget the old entry in the phone's Bluetooth settings and pair again after an address change or switching Bluetooth libraries. Existing Bluedroid bonds are not migrated.

    Stealth does not disable the selected rotation policy. If rotation changes the address on reboot, first pairing opens again for the new identity.

    Pairing requires encryption, PIN authentication and a stored bond. NimBLE's controller byte order is converted explicitly, so a custom address appears over the air in the order entered. Failed identity setup stops advertising and retries initialization instead of advertising the wrong address.

    "},{"location":"nimble_companion_trial/#v4-wifi-setup-qr","title":"V4 WiFi setup QR","text":"

    The active WiFi setup page displays a compact QR code with a one-pixel white border. The normal open setup network fits a 44 x 44 pixel code, with 2 x 2 pixel modules. The SSID, portal address and HOLD STOP hint remain beside it. Short-click to leave the page; hold the button on this page to start or stop the setup AP. From USB, start webconfig ap starts the same setup session.

    When the setup AP is inactive and the node has joined WiFi, this page shows WIFI CONNECTED, IP <address>, SETUP AP INACTIVE, then HOLD START AP from top to bottom. The address appears once assigned and clears when the station disconnects. While the setup AP is active, the QR page shows the setup portal's address instead.

    "},{"location":"nimble_companion_trial/#regression-checks","title":"Regression checks","text":"
    python3 -B test/test_nimble_companion.py\npython3 -B test/test_bluetooth_mac_contract.py\npython3 -B test/test_bluetooth_pairing_ui.py\npython3 -B test/test_heltec_v4_wifi_setup_page.py\npython3 -B test/test_firmware_ram.py\npython3 -B test/test_esp32_dram.py\npython3 -B test/test_shared_mota_queue.py\npython3 -B test/test_companion_mqtt_stack.py\npio test -e native -f test_companion_node_prefs \\\n  -f test_ble_tx_stall_watchdog -f test_companion_frame_queue \\\n  -f test_display_driver\n

    The adapter test compiles the actual transport under address/undefined-behavior sanitizers with API doubles. It injects allocation, identity and bond failures; checks PIN authentication and stealth; and exercises 176-byte frames, MTU and notification subscription gating, retry and duplicate prevention. Embedded builds compile against the pinned real library. RAM reports retain the existing wireless budget; linked headroom is not a measurement of live free heap. The MQTT save callback also has a compiler-enforced stack-frame regression check: nested preference copies previously overflowed the V4 loop task when saving MQTT settings through WebConfig, despite sufficient free heap.

    Hardware qualification must additionally verify pairing and reconnection, advertised addresses for every MAC policy, contact synchronization, WiFi/BLE coexistence, and uptime under load. BlueZ results do not establish iOS or Android camera/pairing compatibility.

    "},{"location":"nimble_companion_trial_results/","title":"ESP32-S3 NimBLE trial results, 2026-09-08","text":"

    These are local hardware trial results for USA Cascade Full Companions on a Heltec V4.3 OLED and a Seeed XIAO ESP32-S3 with WIO SX1262. The V4.2 shares the profile but was not physically tested. Build and command instructions are in the trial guide.

    The final source revision is 9dbab431, using NimBLE-Arduino 2.5.1 and the existing Arduino-ESP32 2.0.17 toolchain. These optional profiles are outside the ordinary release matrix. This trial does not change GitHub release assets or the firmware picker.

    "},{"location":"nimble_companion_trial_results/#capacity","title":"Capacity","text":"Measurement Heltec V4 XIAO S3 WIO Contacts / channels 350 / 40 350 / 40 PSRAM-backed offline queue 512 256 Application image, bytes 1,774,600 1,510,536 OTA application slot, bytes 6,553,600 3,342,336 Linked internal capacity before runtime allocation, bytes 263,944 265,576 Required runtime RAM budget, bytes 173,056 148,480 Headroom beyond that budget, bytes 90,888 117,096 Live free heap with Bluetooth and 350 test contacts, bytes 142,644 147,444 Minimum free heap during that contact test, bytes 138,024 142,788

    The live contact-test readings are from revision 7f1bd8b1. Revision 9dbab431 retains the same Bluetooth adapter, contact allocation and queue sizes, and fixes the MQTT save stack use described below. Linked capacity is not live free heap; the firmware memory reports label that distinction.

    A matching V4 build using Bluedroid had 248,840 bytes of linked internal capacity. NimBLE recovers 15,104 bytes at link time. The matching Bluedroid application was 2,169,848 bytes. There was no matching physical Bluedroid heap measurement, so these figures do not establish a live heap saving.

    "},{"location":"nimble_companion_trial_results/#bluetooth-hardware-checks","title":"Bluetooth hardware checks","text":"

    Both boards passed the following cases with BlueZ on the Mercerwood Pi:

    The complete MAC suite ran on 7f1bd8b1. Final-image connection and update checks also verify the unchanged adapter after the MQTT stack fix. Synthetic contacts were removed after testing; factory MAC mode and stealth off were restored.

    "},{"location":"nimble_companion_trial_results/#mqtt-save-stack-overflow","title":"MQTT save stack overflow","text":"

    The V4 test reproduced an actual reset while saving MQTT settings through WebConfig. Serial output identified Stack canary watchpoint triggered (loopTask). onConfigBatchEnd() kept a second 2.8 KB preference copy while the loader and NVS code also used stack space. Free heap was not the limiting resource.

    The loader already updates its destination only after validation, so the callback now passes the live preference object directly. The actual Xtensa callback frame shrank from 2,928 bytes to 32 bytes. The regression test compiles the real callback with a 512-byte frame ceiling and demonstrates that the previous nested-copy implementation fails the check.

    On the corrected 9dbab431 image, the 11-setting WebConfig batch completed with all entries accepted and no reboot. The V4 connected to a local Mosquitto broker, published MQTT traffic, and kept its authenticated Bluetooth session alive through a 30-second load interval with continuous uptime and zero reported error flags. Free heap measured 120,684 then 122,140 bytes; the minimum was 118,332 bytes and the largest free block was 110,580 bytes. Disabling the test MQTT slot also completed successfully without reboot.

    "},{"location":"nimble_companion_trial_results/#wifi-updates-and-qr","title":"WiFi updates and QR","text":"

    Both boards completed real application-image uploads over WiFi with Bluetooth connected, followed by reboot and reconnection using the saved bond. Repeated HTTP page requests and Bluetooth status requests ran together before upload. The image hash was checked before transmission, and the running firmware version was checked after reboot.

    The V4 WiFi setup page again contains a QR code. The default open setup SSID fits a 44 x 44 pixel code: 2 x 2 pixel modules and an exact one-pixel white border. The native test rendered the actual display/QRCode code and decoded the result with ZXing. SSID, portal address and the hold-to-stop hint fit beside it. Physical phone-camera scanning has not been verified.

    "},{"location":"nimble_companion_trial_results/#observed-limits","title":"Observed limits","text":"

    One XIAO warm reboot left its USB endpoint unresponsive while Bluetooth still answered. The user's physical unplug/replug restored USB; the later full MAC suite completed its commanded reboots successfully. The cause of that one USB failure is unconfirmed.

    The XIAO initially failed to associate with the saved WiFi network. Reapplying the SSID scheduled a fresh connection, after which it joined at -53 dBm and completed WiFi OTA. No antenna change was needed for that recovery.

    This was functional hardware testing, not a long-duration soak. iOS/Android app pairing, phone camera scanning, multiple TLS MQTT brokers, and an end-to-end LoRa mOTA transfer were not qualified in this trial. The XIAO parent profile does not include direct MQTT; the V4 does.

    "},{"location":"nimble_companion_trial_results/#final-application-hashes","title":"Final application hashes","text":"Hardware SHA-256 V4 e6a1522ced4ed38ddd00e7cdf8393857224adbb622888a86370c0f7a3ee28d88 XIAO S3 WIO ecf7afe6222e3897556a04e022515f9dcfcb825172dae9eaaa72eb92396dd004"},{"location":"nrf52-usb-ready-fix/","title":"nRF52 USB READY hang","text":"

    The RAK3401 on Mercer was found stuck in the framework's TinyUSB power handler: USBD was enabled and attached, but its write-one-to-clear READY event was already consumed. The duplicate-event guard also required HFCLK to be running. If that guard missed the completed initialization, the following unbounded READY wait could stop application startup. A bootloader update alone cannot replace this USB driver in an already installed MeshCore application.

    The wait and guard were inherited through the pinned Adafruit nRF52 Arduino framework, not introduced by the recent OTAFIX upstream merge. TinyUSB's wait dates back at least to 2019; the guard was added in upstream commit 7d9efd0697. OTAFIX's August 24 HFCLK backport addressed a later clock wait, leaving this earlier READY wait uncovered. The exact live interrupt ordering is not known; the stalled program counter and consumed event were read directly over SWD.

    All nRF52 environments inherit pre:scripts/nrf52_usb_power_fix.py. It compiles a build-local replacement for the matching framework driver, without changing PlatformIO's shared SDK or affecting ESP32 builds. A changed/unrecognized framework implementation stops the build for review. The backport:

    The poll budget is an iteration limit, not a promised elapsed-time timeout. If the clock/peripheral never becomes ready, the caller returns instead of freezing the application. A later power event can retry; permanent electrical failure can still require reconnecting USB. This does not establish or fix the separate Pi hub/controller fault.

    Run python3 -B test/test_nrf52_usb_power.py -v. The tests compile the actual patched handler from the pinned-framework fixture, model W1C event semantics, and cover duplicate/nested callbacks, delayed/missing clocks and READY, removal, retry, and detached USB. Restoring the inherited READY prefix must reproduce the infinite wait under a subprocess deadline. Additional tests check SDK isolation, idempotence, fail-closed patching, and all nRF52 environment hooks. The harness is shared with OTAFIX's TinyUSB fork at test/otafix/nrf5x_power_test.py; keep the two copies in sync when extending it.

    "},{"location":"nrf52_companion_storage_recovery/","title":"nRF52 Companion automatic ExtraFS recovery","text":"

    Companion builds using internal ExtraFS reserve 100 KiB at 0xD4000..0xED000. Identity and preferences remain in the separate 28 KiB primary store at 0xED000..0xF4000. This applies to internal-ExtraFS Companion builds, including Full, not repeaters or external QSPI storage.

    At startup the firmware:

    1. Mounts primary storage without erasing nonblank media. Only proven-blank primary storage may be initialized automatically.
    2. Checks the exact ExtraFS address, size, block geometry, and application linker reservation, then attempts a non-formatting secondary mount.
    3. Validates primary filesystem metadata before any destructive secondary recovery. An unavailable primary store blocks recovery and identity replacement.
    4. Keeps an already mounted, metadata-valid ExtraFS unchanged. If its initial mount or validation failed, unmounts and retries once without formatting.
    5. If the retry remains unusable, automatically formats only the reserved 100 KiB region, then remounts and validates it. There is one format attempt per boot; failure keeps the contact/channel write quarantine active.
    6. Runs the existing verified migration from primary into ExtraFS. Primary contact/channel sources are retired only after every destination is copied, read back, and the migration transaction is committed. Identity and preferences stay on primary.

    No terminal repair command, phone intervention, or bootloader update is needed for this recovery path when a compatible application/layout is already used. After successful recovery, the Companion storage API reports 100 KiB total rather than the 28 KiB primary fallback.

    "},{"location":"nrf52_companion_storage_recovery/#data-loss-boundary","title":"Data-loss boundary","text":"

    Automatic rebuilding prioritizes a usable contact/channel store over retaining an unreadable secondary filesystem. Data stored only in that damaged region can be lost. Surviving primary files are migrated, but may not contain the latest secondary-only changes. An API backup remains advisable before flashing. Primary storage is never formatted by this recovery path.

    This is mount/metadata recovery, not forensic file recovery. An invalid migration journal, unreadable individual contact page, primary-source error, full filesystem, or failed migration cleanup does not on its own trigger a format of a mountable, metadata-valid secondary store. Those cases retain the existing validation, retry, and incomplete-load protections.

    "},{"location":"nrf52_companion_storage_recovery/#regression-coverage","title":"Regression coverage","text":"

    test/test_internal_secondary_fs_repair exercises healthy/no-format startup, successful remount, failed remount/traversal followed by one repair, and format/remount/validation failure. test/test_nrf52_extrafs_contract.py checks primary-first ordering, internal-only geometry guards, quarantine handling, and the separation between migration failures and destructive recovery.

    "},{"location":"nrf52_companion_storage_recovery/#t1000-e-hardware-qualification-2026-09-03","title":"T1000-E hardware qualification (2026-09-03)","text":"

    A device reporting 25/28 KiB primary fallback and contact enumeration error 5 was flashed with the recovery build. With no explicit repair command, startup activated 100 KiB ExtraFS and loaded all 125 contacts and 40 channel slots. Identity, preferences, radio settings, and channels matched the pre-update API snapshot. Add, edit, and delete of an owned temporary contact each survived a separate normal reboot; the device ended with the original 125 contacts and 27/100 KiB usage. The 161 offline/native regression tests and target build passed.

    This was not lossless recovery of every historical RAM value: 21 contacts had older advertisement/local-modification timestamps, including one older advertised location. No contact was missing, and no computer-backup restoration was used. A subsequent read-only scan found no readable cached advert for any of the 125 contacts. Without a surviving newer copy, firmware cannot infer those earlier values; receiving new verified advertisements can refresh them normally.

    "},{"location":"nrf52_power_management/","title":"nRF52 Power Management","text":""},{"location":"nrf52_power_management/#overview","title":"Overview","text":"

    The nRF52 Power Management module provides battery protection features to prevent over-discharge, minimise likelihood of brownout and flash corruption conditions existing, and enable safe voltage-based recovery.

    "},{"location":"nrf52_power_management/#features","title":"Features","text":""},{"location":"nrf52_power_management/#boot-voltage-protection","title":"Boot Voltage Protection","text":""},{"location":"nrf52_power_management/#voltage-wake-lpcomp-vbus","title":"Voltage Wake (LPCOMP + VBUS)","text":""},{"location":"nrf52_power_management/#early-boot-register-capture","title":"Early Boot Register Capture","text":""},{"location":"nrf52_power_management/#shutdown-reason-tracking","title":"Shutdown Reason Tracking","text":"

    Shutdown reason codes (stored in GPREGRET2):

    Code Name Description 0x00 NONE Normal boot / no previous shutdown 0x4C LOW_VOLTAGE Runtime low voltage threshold reached 0x55 USER User requested powerOff() 0x42 BOOT_PROTECT Boot voltage protection triggered"},{"location":"nrf52_power_management/#supported-boards","title":"Supported Boards","text":"Board family Implemented LPCOMP wake VBUS wake Seeed Studio XIAO nRF52840 (xiao_nrf52) Yes Yes Yes SenseCAP Solar Yes Yes Yes RAK4631 / RAK3401 Yes Yes Yes GAT562 30S / EVB Pro / Tracker Pro / Watch13 Yes Yes Yes Heltec T096 / T114 / T1 / Tower V2 Yes Yes Yes Muzi Works R1 Neo Partial Inactive Inactive Promicro nRF52840 No No No RAK WisMesh Tag No No No Heltec Mesh Solar No No No LilyGo T-Echo / T-Echo Lite / T-Impulse Plus No No No WIO Tracker L1 / L1 E-Ink / WM1110 No No No Mesh Pocket / Meshtiny / Nano G2 Ultra No No No ThinkNode M1 / M3 / M6 No No No T1000-E No No No Ikoka Nano / Stick / Handheld (nRF) No No No Keepteen LT1 / Minewsemi ME25LS01 No No No

    Notes: - \"Implemented\" reflects Phase 1 (boot lockout + shutdown reason capture). - R1 Neo has the integration compiled, but its board configuration deliberately sets PWRMGT_VOLTAGE_BOOTLOCK to 0; automatic protective shutdown and its voltage/VBUS recovery wake are therefore inactive. - User power-off does not enable LPCOMP wake; voltage recovery is armed only for boot protection and automated low-voltage shutdown. - VBUS detection is used to skip boot lockout on external power, and VBUS wake is configured alongside LPCOMP when supported hardware exposes VBUS to the nRF52.

    "},{"location":"nrf52_power_management/#technical-details","title":"Technical Details","text":""},{"location":"nrf52_power_management/#architecture","title":"Architecture","text":"

    The power management functionality is integrated into the NRF52Board base class in src/helpers/NRF52Board.cpp. Board variants provide hardware-specific configuration via a PowerMgtConfig struct and override initiateShutdown(uint8_t reason) to perform board-specific power-down work and conditionally enable voltage wake (LPCOMP + VBUS).

    "},{"location":"nrf52_power_management/#early-boot-capture","title":"Early Boot Capture","text":"

    A static constructor with priority 101 in NRF52Board.cpp captures the RESETREAS and GPREGRET2 registers before: - SystemInit() (priority 102) - which clears RESETREAS - Static C++ constructors (default priority 65535)

    This ensures we capture the true reset reason before any initialisation code runs.

    "},{"location":"nrf52_power_management/#board-implementation","title":"Board Implementation","text":"

    To enable power management on a board variant:

    1. Enable in platformio.ini: ini -D NRF52_POWER_MANAGEMENT

    2. Define configuration in variant.h: c #define PWRMGT_VOLTAGE_BOOTLOCK 3300 // Won't boot below this voltage (mV) #define PWRMGT_LPCOMP_AIN 7 // AIN channel for voltage sensing #define PWRMGT_LPCOMP_REFSEL 2 // REFSEL (0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16)

    3. Implement in board .cpp file: ```cpp #ifdef NRF52_POWER_MANAGEMENT const PowerMgtConfig power_config = { .lpcomp_ain_channel = PWRMGT_LPCOMP_AIN, .lpcomp_refsel = PWRMGT_LPCOMP_REFSEL, .voltage_bootlock = PWRMGT_VOLTAGE_BOOTLOCK };

    void MyBoard::initiateShutdown(uint8_t reason) { // Board-specific shutdown preparation (e.g., disable peripherals) bool enable_lpcomp = (reason == SHUTDOWN_REASON_LOW_VOLTAGE || reason == SHUTDOWN_REASON_BOOT_PROTECT);

     if (enable_lpcomp) {\n   configureVoltageWake(power_config.lpcomp_ain_channel, power_config.lpcomp_refsel);\n }\n\n enterSystemOff(reason);\n

    } #endif

    void MyBoard::begin() { NRF52Board::begin(); // or NRF52BoardDCDC::begin() // ... board setup ...

    #ifdef NRF52_POWER_MANAGEMENT checkBootVoltage(&power_config); #endif } ```

    For user-initiated shutdowns, powerOff() remains board-specific. Power management only arms LPCOMP for automated shutdown reasons (boot protection/low voltage).

    1. Declare override in board .h file: cpp #ifdef NRF52_POWER_MANAGEMENT void initiateShutdown(uint8_t reason) override; #endif
    "},{"location":"nrf52_power_management/#voltage-wake-configuration","title":"Voltage Wake Configuration","text":"

    The LPCOMP (Low Power Comparator) is configured to: - Monitor the specified AIN channel (0-7 corresponding to P0.02-P0.05, P0.28-P0.31) - Compare against VDD fraction reference (REFSEL: 0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16) - Detect UP events (voltage rising above threshold) - Use no hysteresis so the configured recovery threshold is not widened by the battery divider - Wake the device from SYSTEMOFF when triggered

    VBUS wake is enabled via the POWER peripheral USBDETECTED event whenever configureVoltageWake() is used. This requires USB VBUS to be routed to the nRF52 (typical on nRF52840 boards with native USB).

    LPCOMP Reference Selection (PWRMGT_LPCOMP_REFSEL):

    REFSEL Fraction VBAT @ 1M/1M divider (VDD=3.0-3.3) VBAT @ 1.5M/1M divider (VDD=3.0-3.3) 0 1/8 0.75-0.82 V 0.94-1.03 V 1 2/8 1.50-1.65 V 1.88-2.06 V 2 3/8 2.25-2.47 V 2.81-3.09 V 3 4/8 3.00-3.30 V 3.75-4.12 V 4 5/8 3.75-4.12 V 4.69-5.16 V 5 6/8 4.50-4.95 V 5.62-6.19 V 6 7/8 5.25-5.77 V 6.56-7.22 V 7 ARef - - 8 1/16 0.38-0.41 V 0.47-0.52 V 9 3/16 1.12-1.24 V 1.41-1.55 V 10 5/16 1.88-2.06 V 2.34-2.58 V 11 7/16 2.62-2.89 V 3.28-3.61 V 12 9/16 3.38-3.71 V 4.22-4.64 V 13 11/16 4.12-4.54 V 5.16-5.67 V 14 13/16 4.88-5.36 V 6.09-6.70 V 15 15/16 5.62-6.19 V 7.03-7.73 V

    Important: For boards with a voltage divider on the battery sense pin, LPCOMP measures the divided voltage. Use: VBAT_threshold ~ (VDD * fraction) * divider_scale, where divider_scale = (Rtop + Rbottom) / Rbottom (e.g., 2.0 for 1M/1M, 2.5 for 1.5M/1M, 3.0 for XIAO).

    "},{"location":"nrf52_power_management/#softdevice-compatibility","title":"SoftDevice Compatibility","text":"

    The power management code checks whether SoftDevice is enabled and uses the appropriate API: - When SD enabled: sd_power_* functions - When SD disabled: Direct register access (NRF_POWER->*)

    This ensures compatibility regardless of BLE stack state.

    "},{"location":"nrf52_power_management/#cli-commands","title":"CLI Commands","text":"

    Power management status can be queried via the CLI:

    Command Description get pwrmgt.support Returns \"supported\" or \"unsupported\" get pwrmgt.source Returns current power source - \"battery\" or \"external\" (5V/USB power) get pwrmgt.bootreason Returns reset and shutdown reason strings get pwrmgt.bootmv Returns boot voltage in millivolts

    On boards without power management enabled, all commands except get pwrmgt.support return:

    ERROR: Power management not supported\n
    "},{"location":"nrf52_power_management/#debug-output","title":"Debug Output","text":"

    When MESH_DEBUG=1 is enabled, the power management module outputs:

    DEBUG: PWRMGT: Reset = Wake from LPCOMP (0x20000); Shutdown = Low Voltage (0x4C)\nDEBUG: PWRMGT: Boot voltage = 3450 mV (threshold = 3300 mV)\nDEBUG: PWRMGT: LPCOMP wake configured (AIN7, ref=3/8 VDD)\n
    "},{"location":"nrf52_power_management/#phase-2-planned","title":"Phase 2 (Planned)","text":""},{"location":"nrf52_power_management/#references","title":"References","text":""},{"location":"number_allocations/","title":"Number Allocations","text":"

    This document lists unique numbers/identifiers used in various MeshCore protocol payloads.

    "},{"location":"number_allocations/#group-data-types","title":"Group Data Types","text":"

    The PAYLOAD_TYPE_GRP_DATA payloads have a 16-bit data-type field, which identifies which application the packet is for.

    To make sure multiple applications can function without interfering with each other, the table below is for reserving various ranges of data-type values. Just modify this table, adding a row, then submit a PR to have it authorised/merged.

    NOTE: the range FF00 - FFFF is for use while you're developing, doing POC, and for these you don't need to request to use/allocate.

    Once you have a working app/project, you need to be able to demonstrate it exists/works, and THEN request type IDs. So, just use the testing/dev range while developing, then request IDs before you transition to publishing your project.

    Data-Type range App name Contact 0000 - 00FF -reserved for internal use- 0100 MeshCore Open zsylvester@monitormx.com - https://github.com/zjs81/meshcore-open 0110 - 011F Ripple ripple_biz@protonmail.com - https://buymeacoffee.com/ripplebiz 0120 MCO Advanced most.original.address@gmail.com - https://github.com/HDDen/meshcore-open/tree/rename-mco-advanced FF00 - FFFF -reserved for testing/dev-

    (add rows, inside the range 0100 - FEFF for custom apps)

    "},{"location":"ota_easy/","title":"Easy firmware updates over LoRa","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    This guide shows the shortest manual path for sending firmware from a computer to a MeshCore node over LoRa. Choose the package type for the destination node:

    For an end-to-end controller that accepts a release ZIP or ready mOTA, see Scripted LoRa OTA from start to finish.

    Destination Update type Files needed to build the .mota Installer ESP32 Full firmware New non-merged application .bin ESP32 A/B firmware slots nRF52, internal staging In-place delta Exact running firmware.hex and new firmware.hex Exact-board OTAFIX bootloader Supported nRF52 QSPI repeater Full firmware or in-place delta New firmware.hex; a delta also needs the exact running firmware.hex Matching QSPI-aware OTAFIX bootloader MeshTower V2 SD target Full firmware or in-place delta New firmware.hex; a delta also needs the exact running firmware.hex Matching SD-aware OTAFIX bootloader

    An internal-staging nRF52 target accepts only an in-place delta built against its exact running firmware. Matched QSPI repeater targets and the MeshTower V2 microSD target can also install a full image because the complete container stays off-chip. See nRF52 repeater OTA with external QSPI and MeshTower V2 microSD LoRa OTA.

    MeshTower V2 microSD application and bootloader containers must be Ed25519-signed by a key in the device allowlist; pass --sign to motatool build (or --sign-key to the end-to-end runner) when building one. Its BLM2-capable SD-aware bootloader is also required before ordinary application or bootloader OTA can use the reset-retained authorization record. Upgrade preview.12 locally over USB/BLE DFU or SWD first; there is no raw-card compatibility handoff.

    "},{"location":"ota_easy/#temporary-ota-channel-used-in-this-guide","title":"Temporary OTA channel used in this guide","text":"Setting Value Center frequency 910.525 MHz Bandwidth 250 kHz Spreading factor SF5 Coding rate used in this guide CR5 Example window 120 minutes

    The copy/paste command is:

    tempradio 910.525,250,5,5,120\n

    The fourth value is the transmit coding rate. This guide uses CR5, but the participating nodes' coding rates do not need to match.

    tempradio is not saved and the node returns to its normal radio settings when the window ends or the node reboots. Current full-parser firmware also accepts normalradio, which cancels the temporary window and restores the saved tuple after replying on the current channel. This frequency is intended for North American configurations. Confirm that it is permitted in your location and change it when necessary.

    At SF5/BW250, an SX1262+TCXO destination can keep RXPS enabled only when it and every sender that can reach it (controller, source, and relays) follow the v1.17.1.5-or-newer adaptive-preamble contract. The qualified destination boundary is effective level 8, preamble 64 and 1252 / 6424 us. For manual setup, save set radio.rxps level 8 preamble 32 before entering TempRadio; the 32 is a conservative saved timing assumption and current firmware selects the real 64-symbol wire preamble when it retunes the temporary radio. For an older, unknown, or mixed-version path, use set radio.rxps off for the maintenance window. First record get radio.rxps.config; after normalradio, restore a reported level with set radio.rxps level N preamble P (omit preamble P when P is 0). A legacy node returns only on/off and periods, which can be restored with set radio.rxps RX_US SLEEP_US. The scripted runner performs the version checks, temporary change, verification, and restoration automatically while preserving automatic retuning. SF5/BW500 is too fast for RXPS even with a 64-symbol preamble, so current firmware selects 128 and uses the qualified level-8 626 / 6398 us timing when every participant supports the adaptive contract.

    "},{"location":"ota_easy/#before-you-start","title":"Before you start","text":"

    Both paths require:

    LoRa OTA packets are generated, consumed, and relayed only while tempradio is active. Intermediate repeaters apply their normal forwarding filters, duplicate checks, and flood limits; they do not interpret the OTA payload. If any required window closes, the transfer stops making progress and can resume during a later overlapping window.

    build.sh provides a *_repeater_lora_ota_no_external_sensors build for standalone ESP32 and nRF52 repeater targets that need a smaller internal update workspace. Those siblings omit selected optional environmental and ranging drivers while retaining board-native features such as displays, buttons, battery monitoring, and GPS where the target uses the GPS-preserving lean profile. RAK3401 and RAK4631 reduced builds also retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors; together these drivers cost less than 5 KiB in the measured profile. Those four monitor drivers are not the only retained I2C support: the board's SSD1306 OLED, supported autodiscovered RTCs, and the RAK12500 GPS remain separate I2C peripherals in GPS-compatible RAK recipes. RAK12501/L76K GPS instead uses UART Serial1. The RAK3401 OTA repeater supports either GPS module in sensor slot A; slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The RAK4631 OTA repeater defaults its runtime RS-232 bridge to Serial2 so the UART RAK12501 can retain Serial1. The runtime guard always reserves Serial1 because a bounded silent probe cannot prove a cold RAK12501 is absent; the shared WB_IO2/3V3_S rail keeps a fitted module powered and driving the UART. Use Serial2. Serial1 requires an explicit no-GPS/dedicated image. The merged image remains fail-closed even when it detects an I2C RAK12500. The explicit legacy Serial1 bridge omits the combined GPS provider, including the otherwise non-UART RAK12500 path.

    The firmware-configured INA3221 address and the RAK12500 address are both 0x42; they cannot coexist on one bus at those addresses. Keep RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use a build with -DTELEM_INA3221_ADDRESS=0x43 when both are installed.

    Selected nRF52 boards with matched external QSPI application and bootloader support can instead make the normal full-sensor repeater install-capable; those targets do not need to reserve internal flash for the downloaded container. SolarXiao 30S and 33S use this matched external-QSPI path and therefore do not emit redundant no-external-sensors siblings. A RAK19007 can use the same separately wired W25Q16 with either RAK4631 or RAK3401 + RAK13302: the flash consumes no WisBlock slot and GPS remains supported in slot A. This is not the RAK15001 arrangement; RAK15001 and RAK13302 share a chip-select and cannot be used together. Other normal repeaters can still serve as intermediate relays but cannot necessarily install an update themselves. ESP32 -ota- siblings also retain the lightweight browser WiFi uploader (start ota), the complete CLI, and up to 254 neighbors; constrained targets use 50 as recorded in their capability manifests. RP2040 and STM32 repeaters do not currently have a safe self-apply path, but current repeater firmware can still relay OTA packets opaquely during TempRadio.

    For one explicitly selected internal-flash nRF52 repeater, the default --build-profile auto run emits two install-capable choices. The *-full-ota-* artifact retains external sensors, while the *_lora_ota_no_external_sensors-reduced-ota-* artifact trades only those optional drivers not explicitly retained by the board profile for more delta-staging space. They retain the same OTA target identity so update tooling does not confuse the optimization choice with a different board. QSPI- and SD-backed repeaters emit only the complete choice unless a measured size failure requires a fallback.

    nRF52 -ota- siblings are compiled with size optimization instead of the Adafruit platform's default speed optimization. This prevents the retained software Ed25519 fallback from expanding beyond the fixed in-place workspace; CC310 hardware crypto, hardware RNG mixing, telemetry history, and board-native features remain enabled.

    ESP32 *-full-usb-wifi-ota-* artifacts retain all compiled features and enable LoRa OTA for every FULL role, including room servers, sensors, observers, and bridges. A FULL image requires its expanded partition table: install the matching merged image over USB once before installing later non-merged FULL updates over LoRa. ESP32 MQTT observers and ESP-NOW bridges are always emitted as FULL artifacts; compact-CLI variants are no longer built. The *-full-usb-wifi-ota-* profile compiles USB packet logging and direct WiFi MQTT into one image. Use set logging.output off|usb|wifi|both to persist the desired path. A *-full-logging-ota-* artifact is now only a fallback for a hardware/role combination without a WiFi MQTT target and can produce substantial serial output.

    "},{"location":"ota_easy/#choose-the-source-radio","title":"Choose the source radio","text":"

    Use an OTA-enabled MeshCore node as the source. It receives the update folder from the computer, then advertises it over LoRa. ESP32 USB/WiFi companions and FULL ESP32 roles include the required transport. A *_companion_radio_full target keeps only the source half of LoRa OTA: it serves host images but cannot stage or install one for itself. ESP32 full combines USB, BLE, and WiFi; nRF52 full combines USB and BLE because nRF52840 has no WiFi. A small set of high-capacity, non-PSRAM classic ESP32 companions keep their normal image and provide a separate -full-logging-ota- fallback with 100 contacts, 8 group channels, a 16-frame offline queue, and persistent USB output selection. Install that variant's merged image over USB once before using it. Connect the source by USB serial or, when supported, by WiFi. For an ordinary raw-text USB source, confirm that its USB CLI accepts:

    ota folder on\n

    If an older build reports that OTA_FOLDER_SERIAL is not compiled in, install a current -ota-, -full-usb-wifi-ota-, or applicable -full-logging-ota- build first. Do not use a KISS modem: KISS firmware is a TNC/KISS frame interface and does not provide the MeshCore CLI or the OTA-folder transport that motatool serve requires.

    An nRF52 companion_radio_full starts in its USB ASCII terminal and automatically changes to Binary Companion when it receives a complete < frame. It also recognizes motatool's exact initial ota folder on line and enters exclusive mOTA mode directly, so motatool serve --serial can be the first client after boot. No terminal token or preliminary disconnect is required. Stopping the tool or disconnecting resets USB to Binary. BLE remains available throughout. See the Full Companion USB switcher guide for the complete ownership transitions. Protocol v14 can instead take the catalog from a paired phone or Linux host over a separate encrypted BLE mOTA service while Binary Companion remains active. USB and BLE catalog sources are mutually exclusive. See the Full Companion Bluetooth source guide.

    For an ESP32 WiFi companion or FULL ESP32 source with active WiFi, use its dedicated OTA seeder:

    motatool serve --dir ./motas --tcp <source-host>:5001 -v\n

    Port 5001 is separate from the companion application port (5000) and the HTTP configuration/browser-OTA port (80, depending on the role). An ESP32 companion_radio_full also has its complete role-specific text terminal on port 5002; OTA automation uses its ota and TempRadio subset. See the full Companion guide. On a FULL repeater or room server, start webconfig can bring up the saved WiFi connection. Other FULL roles with browser OTA support can raise MeshCore-OTA with start ota and use 192.168.4.1:5001. The TCP seeder auto-attaches; do not also run ota folder on for USB serial.

    "},{"location":"ota_easy/#install-motatool","title":"Install motatool","text":"

    Install Rust if necessary, then install the standalone packaging and serving tool:

    git clone https://github.com/vk496/motatool\ncargo install --path ./motatool\n
    "},{"location":"ota_easy/#esp32-package-a-full-firmware-image","title":"ESP32: package a full firmware image","text":"

    The destination must be an OTA-capable ESP32 with an A/B partition table. Download or build the new non-merged .bin application for the destination's exact board and role. Do not use an ESP32 -merged.bin factory image.

    Put the application firmware in a working directory, then build a full .mota container. For example:

    mkdir -p ./motas\nmotatool build --fw ./Heltec_v3_repeater-ota-v1.16.05.bin --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    Replace the example filename with the firmware for the destination's exact target. With no --base argument, motatool build creates a full-image update. The firmware must contain its MeshCore EndF identity trailer so motatool and the destination can verify the target, hardware, and version.

    Do not continue if motatool verify reports a failure.

    "},{"location":"ota_easy/#nrf52-package-a-full-image-or-in-place-delta","title":"nRF52: package a full image or in-place delta","text":""},{"location":"ota_easy/#1-install-and-check-the-otafix-bootloader","title":"1. Install and check the OTAFIX bootloader","text":"

    This is a one-time prerequisite. Install the OTAFIX bootloader built for the destination's exact board from the OTAFIX nRF52 bootloader releases. Internal-staging delta updates require OTAFIX 2.4 or newer. External QSPI and SD staging require OTAFIX 2.4.1 or newer and release notes that explicitly list the exact board and storage mode. Follow the release's board-specific installation and erase instructions. If it does not contain the destination's exact board, this LoRa install path is not yet available for that board; never substitute a similar board's bootloader.

    Before preparing or downloading a LoRa update, run this on the destination:

    get bootloader.ver\nota self\n

    The first command identifies the installed nRF52 bootloader. Continue only if the ota self reply includes the apply mode required by this target:

    bootloader: apply OK\nbootloader: QSPI apply OK\nbootloader: SD apply OK\n

    The reply also contains the running firmware's base_hash. Save it for the package check below. A stock, legacy, or older OTAFIX bootloader without .mota in-place-apply support will report that apply support is missing, and ota install will refuse to reboot into it.

    "},{"location":"ota_easy/#2-choose-full-or-delta-packaging","title":"2. Choose full or delta packaging","text":"

    For a QSPI- or SD-backed target, a full update needs only the new raw firmware.hex:

    mkdir -p ./motas\nmotatool build --fw ./LilyGo_T-Echo_repeater-new.hex --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    Use a full package when an exact base image is unavailable. Use the delta path below when reducing airtime is worth retaining the exact running image.

    "},{"location":"ota_easy/#3-keep-the-exact-current-and-new-application-images-for-a-delta","title":"3. Keep the exact current and new application images for a delta","text":"

    You need the raw .pio/build/<environment>/firmware.hex from the build that is actually running, plus the corresponding firmware.hex from the new build. Save the current file before building the new version, because PlatformIO reuses that path. For example:

    # Save this immediately after building/flashing the version now running on the node.\ncp .pio/build/Heltec_t114_repeater_lora_ota_no_external_sensors/firmware.hex \\\n  ./Heltec_t114_repeater_lora_ota_no_external_sensors-running.hex\n\n# After checking out and building the new version, save its image separately.\ncp .pio/build/Heltec_t114_repeater_lora_ota_no_external_sensors/firmware.hex \\\n  ./Heltec_t114_repeater_lora_ota_no_external_sensors-new.hex\n

    Replace Heltec_t114_repeater_lora_ota_no_external_sensors with the destination's exact PlatformIO environment. The two images must be for the same board and role, and both must contain their EndF trailers. Do not pass a release .uf2 or BLE-DFU .zip to motatool; those are installation containers rather than raw application images.

    Keeping a file with the same version label is not enough: the base must be byte-for-byte identical to the running application. The hash check in the next step proves that it is the right file.

    On RAK4631 repeaters, use the RAK_4631_repeater_lora_ota_no_external_sensors environment. It retains built-in battery monitoring but omits selected optional environmental/ranging drivers so the delta fits the safe in-place workspace. INA219, INA226, INA260, and INA3221 I2C voltage/current monitors remain supported despite the legacy target name. That trim does not remove the SSD1306 OLED, autodiscovered I2C RTCs, or GPS-compatible RAK12500 I2C path. The 0x42 RAK12500/INA3221 addressing rule above still applies. If the device has a RAK15001 installed in sensor slot C and the matching RAK15001 OTAFIX bootloader, use RAK_4631_repeater_rak15001_slot_c_lora_ota instead. That target retains the full sensor/GPS set and can install either a full image or a delta from the external 2 MiB store.

    With the separately wired Winbond breakout, use RAK_4631_repeater_w25q16_lora_ota for RAK4631 or RAK_3401_repeater_rak13302_w25q16_lora_ota for RAK3401 + RAK13302. Both require an EF4015 2 MiB device. As a one-time prerequisite, install wiscore_rak4631_w25q16 for RAK4631 or wiscore_rak3401_rak13302_w25q16 for RAK3401 + RAK13302. Wire CLK/MISO/MOSI to the RAK19007 IO connector pins 26/27/28, CS to J11 AIN1, and VCC/GND to J12 VDD/GND. Fit an approximately 10 kOhm CS-to-VDD pull-up; never use VBAT. The SPI pins require underside taps or an interposer, but no sensor slot is occupied. See the nRF52 QSPI guide before installing either image.

    "},{"location":"ota_easy/#4-build-and-check-the-in-place-delta","title":"4. Build and check the in-place delta","text":"
    mkdir -p ./motas\nmotatool build \\\n  --base ./Heltec_t114_repeater_lora_ota_no_external_sensors-running.hex \\\n  --fw ./Heltec_t114_repeater_lora_ota_no_external_sensors-new.hex \\\n  --patch-type in-place \\\n  --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    motatool prints the generated filename. Inspect that file:

    motatool inspect ./motas/GENERATED_FILENAME.mota\n

    Check all three of these before serving it:

    Current layout-aware motatool derives the safe workspace from the new firmware's authenticated EndF layout record, so the normal recipe deliberately omits --inplace-memory. If older tooling requires an explicit override, use 0xC6000 only for matched external SD/QSPI staging and 0x98000 for an internal-staging nRF52 such as the RAK4631 lean OTA build. Do not substitute the external value on an internal target, and do not override either value unless you have verified the exact app base, bootloader, and package geometry. Do not continue if verification or any identity check fails.

    "},{"location":"ota_easy/#transfer-and-install-either-package","title":"Transfer and install either package","text":""},{"location":"ota_easy/#1-start-the-temporary-ota-channel","title":"1. Start the temporary OTA channel","text":"

    First save the current ota config output. For a direct source-to-destination link, set both OTA-enabled endpoints to direct-only mode before starting the transfer:

    ota config hops 0\n

    Do not use hops 1 as extra margin on a direct link. It tells a receiving repeater to retransmit each direct DATA/PROOF packet; that unnecessary half-duplex transmission can make it miss the source's next fragment, especially with older single-block receivers. Use hops 1 only when one real intermediate relay is required, hops 2 for two, and so on. This setting is saved, so restore the original value after the maintenance window.

    On the source node, destination node, and every intermediate repeater, then run:

    tempradio 910.525,250,5,5,120\n

    All participating nodes must use the same frequency, bandwidth, and spreading factor. Their time windows must overlap. Start with the farthest hop (the destination) and work back toward the source when using tempradio.

    If you administer the destination over LoRa, the controller used to send later ota commands must also be able to communicate on this temporary channel. For unattended nodes, use synchronized tempradioat entries instead of manually starting the windows. Ensure the nodes' clocks are set before using tempradioat.

    "},{"location":"ota_easy/#2-serve-the-update-from-the-computer","title":"2. Serve the update from the computer","text":"

    Close any serial terminal using the source node's USB port, find its device name, and start the server:

    motatool serve --dir ./motas --serial /dev/ttyACM0 -v\n

    Replace /dev/ttyACM0 with the USB serial device of the source companion selected above. motatool attaches the folder to the source, which advertises the update over LoRa while its temporary-radio window is active. KISS modem serial ports cannot be used here.

    For an nRF52 Full Companion and a Bluetooth-capable host, the equivalent cable-free source is:

    python3 tools/ble_mota/ble_mota_seeder.py \\\n  --device MeshCore-MyCompanion \\\n  --dir ./motas \\\n  --local 'tempradio 910.525,250,5,5,120'\n

    Leave this command running until the destination finishes downloading.

    "},{"location":"ota_easy/#3-find-and-download-the-update","title":"3. Find and download the update","text":"

    On the destination node, check its state and ask for nearby updates:

    ota status\nota ls\n

    Discovery is asynchronous. ota ls says refreshing; wait a few seconds and run it again even if it first shows an older row. Select [same target]: it can say full for ESP32 and external SD/QSPI nRF52 targets, or delta for any supported nRF52 target. Do not select [unsupported] (for example, a source's self-served full image on an internal-staging nRF52). Rows labelled bootloader are also outside this ordinary application flow: they are never automatic and ota install refuses them. Only an already-capable, allowlisted internal-flash, XIAO-QSPI, or exact MeshTower V2 microSD nRF52 target can use the separate explicit bootloader workflow. Use the row's stable eight-hex manifest ID rather than its changing list position:

    ota pull 838B8169 flash\n

    If a legacy internal-flash nRF52 reports no EndF, only a row marked [rescue] is eligible. Current rescue-capable firmware requires ota pull <mid8> flash rescue, followed after completion by ota rescue install <base_hash16>. Older running firmware without those commands must be recovered over USB. Shared-internal bootloader-update builds are stricter: without a valid live EndF, every internal pull is refused before erase because the normal application may extend through 0xED000. Recover those builds over USB/BLE DFU or SWD instead of relying on the older 608 KiB rescue estimate.

    Monitor the transfer:

    ota status\n

    For a powered bench update where restart-resume is not needed, ota config checkpoint 0 on the destination removes periodic progress writes. It is a smaller optimization than selecting the correct hop count and it trades away persisted mid-download resume; restore the previous checkpoint cadence afterward.

    Current repeater firmware automatically uses the full transmit budget during a bounded TempRadio window. Older receivers do not: if get af reports a nonzero value on such a node, record it, use set af 0 for the maintenance window, and restore it afterward. This affects how promptly the legacy receiver can send its next block/proof request; it does not increase LoRa transmit power. Use a full duty budget only where the selected frequency and local rules permit it.

    The update is ready when the status says ready to install. Discovery is background traffic, while an active OTA download is primary mesh traffic. At the temporary-radio settings in this guide, allow roughly one hour for a typical ESP32 full image over a quiet, direct link. That is a planning estimate, not an upper bound: repeaters, retries, weak links, and normal mesh traffic can extend it well past an hour. The 120-minute example window is intentional. If necessary, start another overlapping tempradio window; the download resumes rather than starting over.

    "},{"location":"ota_easy/#4-verify-and-install","title":"4. Verify and install","text":"

    Once the destination reports ready to install, run:

    ota install\n

    The destination verifies the complete package again before approving it. ESP32 installs the full image into its inactive A/B slot. nRF52 checks the bootloader and storage capabilities and, for a delta, the base hash, then reboots into OTAFIX. The bootloader independently rechecks the package, installs the external full image or applies the delta in place, and verifies the resulting image. Pre-install failures leave the running firmware unchanged and report the reason. If power is lost after an nRF52 in-place apply has begun, OTAFIX will not boot a partial image; it enters recovery DFU so a known-good application can be restored.

    After the node returns, reconnect on its normal radio channel and confirm:

    ota status\n
    "},{"location":"ota_easy/#quick-troubleshooting","title":"Quick troubleshooting","text":"

    For additional commands and safety details, see the full OTA user guide. For protocol and container internals, see the OTA protocol specification.

    "},{"location":"ota_meshtower_v2_sdcard/","title":"MeshTower V2 microSD LoRa OTA","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    The Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors target uses the MeshTower V2 onboard microSD socket as persistent storage for its own LoRa OTA downloads. It accepts full application images, in-place application deltas, and explicitly selected signed v3 bootloader packages. After verification, the matching SD-aware OTAFIX bootloader reads the staged file from the card and programs the selected nRF52840 application or bootloader region.

    The pin assignment follows the Heltec MeshTower V2 partial reference circuit:

    Signal nRF52840 pin Arduino pin number SD CS P1.00 32 SD MOSI P1.01 33 SD SCK P0.06 6 SD MISO P0.26 26

    The SD socket uses its own SPI peripheral, so card traffic does not change the LoRa radio pinout.

    "},{"location":"ota_meshtower_v2_sdcard/#card-requirements","title":"Card requirements","text":"

    Use a FAT16, FAT32, or exFAT card accepted by the bundled SdFat version. The normal update path imposes no additional MBR/sector-1 layout requirement; filesystem layouts SdFat cannot mount are still rejected. MeshCore creates /meshcore-ota.mota as a contiguous file and passes its exact sector range to OTAFIX in reset-retained MCU RAM.

    MeshCore never reads or writes raw sector 1 and does not infer ownership from blank card sectors. Both application and bootloader OTA require a locally provisioned BLM2-capable bootloader that understands the retained-RAM record. Preview.12 must first be upgraded over USB/BLE DFU or SWD.

    "},{"location":"ota_meshtower_v2_sdcard/#capacity-and-update-types","title":"Capacity and update types","text":"

    The card removes the internal-flash staging limit. The .mota container may be much larger than the old internal staging gap, and either a full image or an in-place delta may be downloaded. The installed firmware itself must still fit the nRF52840 application region below InternalFS (ending at 0xED000); SD storage does not increase the MCU's executable flash.

    For this S140 v6 target, the maximum application image including its EndF trailer is 0xC7000 bytes (815,104 bytes). To package a full self-update:

    motatool build --fw ./Heltec_tower_v2_sdcard-new.hex \\\n  --sign ./trusted-signer.key --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    A delta uses the exact installed image as its base. The normal 0x98000 workspace remains compatible. If either image is larger than that legacy limit, build the patch with the SD target's larger workspace:

    motatool build \\\n  --base ./Heltec_tower_v2_sdcard-running.hex \\\n  --fw ./Heltec_tower_v2_sdcard-new.hex \\\n  --patch-type in-place \\\n  --inplace-memory 0xC7000 \\\n  --sign ./trusted-signer.key \\\n  --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    The download is resumable because the partial .mota stays on the card. Once it reaches ready, ota install performs the final verification, publishes a one-reset authorization record, and reboots. SD application installation requires a valid signature from a key in the node's allowlist. Keep the card inserted through the reboot and installation.

    The BLM2 retained-auth SD-aware bootloader is mandatory for application OTA. ota install refuses to reboot if its continuity metadata does not match the running S140 family/FWID/application layout or if its capability marker does not advertise SD staging and the selected codec. Existing Heltec_tower_v2_repeater firmware continues to use the internal-flash delta path and is unchanged.

    "},{"location":"ota_meshtower_v2_sdcard/#signed-bootloader-update","title":"Signed bootloader update","text":"

    Only this exact SD build exposes the privileged LoRa bootloader-update command. It requires an already installed exact-board ABI-3 OTAFIX bootloader whose one unambiguous capability marker is exactly 0x09 (SD|BOOT_UPDATE) and whose codec mask is 0x0005 (FULL|INPLACE). Requiring both application codecs prevents a bootloader self-update from disabling either normal SD application path. A stock bootloader still requires USB/BLE DFU or SWD. The sole remote bootstrap is intentionally absent: preview.12 and any other legacy-v1 image must be upgraded locally before either SD application or bootloader OTA.

    The signed v3 container is exactly 41,330 bytes and carries a 40 KiB candidate for the installed 239A0071 / TOWER_V2_OTA identity (boot target 1150F50E). It uses the same contiguous /meshcore-ota.mota file as an application update. GPREGRET 0x6B plus the distinct SD source marker 0x53 selects the bootloader path; LoRa transport remains payload type 0x0C.

    The application linker still ends at 0xED000, but OTAFIX needs 0xE0000..0xEA000 as temporary scratch while replacing itself. Before a boot package can be downloaded or approved, MeshCore requires a hash-valid live EndF proving the complete running image ends by 0xE0000; OTAFIX repeats that no-overlap check before its first erase. An application extending above that boundary can still receive normal FULL or delta application updates from SD, but its bootloader must be updated locally.

    MeshCore also checks the live boot settings before touching that scratch page. Erased settings and a valid-app record with CRC disabled are allowed. When a nonzero bank CRC is active, its recorded bank_0_size must cover the complete EndF-inclusive running image and must end by 0xE0000; an undersized or oversized record refuses the operation and requires local DFU/SWD.

    Because the card is removable, approval is bound to the exact bytes that were authenticated. For both fmt2 application and fmt3 bootloader packages, MeshCore authenticates one exact signed manifest, requires every streamed manifest byte to match it, and verifies the leaves, payload, and image. During that same pass it hashes the entire container with only the mutable four-byte APRV field normalized to zero. After writing and syncing APRV, MeshCore publishes a 72-byte MOTASDA2 record at reset-retained RAM address 0x20006008. The record binds package purpose/format, exact LBA range, card size, container length, and that normalized SHA-256. OTAFIX consumes and clears the record before reading the card; changing the card or file can only fail. A power loss erases the authorization and also fails closed.

    For fmt3, MeshCore additionally writes and readback-verifies the temporary 64-byte MOTASDBL token at 0xE0000, binding the exact total and signed manifest image_hash. OTAFIX requires the parsed manifest, streamed payload, and final scratch image to match it. The token page becomes scratch during a successful boot update and is not permanently reserved.

    Every new candidate also carries a backward-compatible BLM2/SOFT continuity extension next to its legacy embedded manifest. The complete 76-byte envelope is fixed at final raw-image offset 0x9FB4; a relocated copy is not a valid candidate. Its embedded bootloader version must equal the outer package version, match the runtime SoftDevice family/FWID, application base, and layout ABI, and be strictly newer than an installed BLM2 version. Preview values use low bytes 1..254 and a stable release uses 0xFF; zero-preview and all-ones versions are invalid. Remote bootloader rollback is not supported; use local DFU/SWD when rollback or migration is intentional.

    Bootloader packages are never autofetched or autoinstalled. Select and confirm one exact package manually:

    ota ls\nota pull <MID8> flash\n# wait for ota status to report the bootloader download ready\nota bootloader\nota bootloader install <MID8> <HASH16>\n

    Copy the MID and first 16 image-hash hex digits from ota bootloader. Ordinary ota install rejects this package, and the bootloader command rejects an application package. Keep the SD card inserted through the reboot. A later ota status value of blup:C8 reports a successful bootloader replacement.

    After reboot, ota bootloader displays the installed board identity, CRC and capabilities. Its mid and hash fields describe a currently staged download, not the installed bootloader, so staged:none mid=- hash=- is not an install failure. blrc:00 means no retained apply result is available; it does not by itself prove either success or failure.

    Older MeshCore application firmware can report get bootloader.ver as unknown for OTAFIX 2.4.6 because this bootloader image omits the legacy UF2 Bootloader text. The updated getter also reads the CRC-validated BLM2 version. For the official signed MeshTower V2 SD 2.4.6 .mota, the installed identity is TOWER_V2_OTA, CRC 5DACDB3D, ABI 3, and capabilities 09. Compare the exact board/storage package when interpreting these values; the non-SD image differs.

    "},{"location":"ota_meshtower_v2_sdcard/#sd-card-cli","title":"SD card CLI","text":"

    The SD-backed target provides these CLI commands:

    set sdcard format [--force]\nset sdcard erase [--force]\nget sdcard\nget sdcard *\nget sdcard format\nget sdcard erase\nget sdcard free\nget sdcard ls\nget sdcard ls 2\nget sdcard dir 3\n

    format creates a new FAT16, FAT32, or exFAT filesystem according to card size. erase first uses the card's raw media erase command and then formats it, so a successful erase finishes with a usable filesystem. Both operations destroy all data on the card and cancel any staged OTA download.

    The firmware records successful format and erase completion times in RAM. Repeating the same operation within five minutes is rejected unless --force is present. Format and erase have independent cooldowns. Because erase also formats the card, a successful erase updates both timestamps. The timestamps reset when the device reboots. The get sdcard age queries report how long ago each operation completed. get sdcard free reports used and free filesystem space in human-readable binary units.

    get sdcard ls and get sdcard dir recursively list files on the card, two files per page. A bare command shows page 1; append a positive page number to move through the remaining results. Each row includes the path and a compact file size. The header reports the selected page, total pages, and total files.

    "},{"location":"ota_meshtower_v2_sdcard/#persistent-ota-archive-and-seeder","title":"Persistent OTA archive and seeder","text":"

    On the SD-backed target, automatic OTA archiving is on by default. While the temporary OTA radio is active, the node requests full catalogs from seeders and saves every complete mOTA it discovers, including firmware for other hardware targets and codecs that this node cannot install. Archive downloads use the same per-block Merkle proof checks as an install download, but archived images are never selected for local installation.

    Completed containers are stored as /mota/<manifest-id>.mota. An interrupted download remains /mota/<manifest-id>.part and resumes when that mOTA is seen again. Completed files survive reboot, are enumerated on the first archive access or TempRadio window, and are advertised and served directly from SD. The SD target supports the protocol maximum served set: its own running firmware plus up to 254 archived mOTAs.

    Automatic capture preserves an 8 MiB free-space reserve for manual /meshcore-ota.mota installation staging. It stops starting new archive files when the next file would cross that reserve. Archive allocation first tries the fast contiguous path and then falls back to an ordinary fragmented FAT file; only the bootloader staging file requires contiguous sectors.

    "},{"location":"ota_meshtower_v2_sdcard/#preload-many-motas-from-a-computer","title":"Preload many mOTAs from a computer","text":"

    You can populate the archive much faster on a computer than over LoRa. Use only complete, verified .mota containers. Do not copy firmware .bin, .hex, .zip, or .part files into the archive.

    Install the standalone motatool first if it is not already available:

    git clone https://github.com/vk496/motatool.git\ncargo install --path ./motatool\n

    The on-card filename is part of the archive index and has a strict format:

    /mota/<merkle_root>.mota\n

    <merkle_root> is the eight-hex-digit value printed by motatool inspect. It is also the mOTA's four-byte manifest ID. The extension must be lowercase, the file must be directly inside /mota, and descriptive release filenames are not indexed. For example, if inspection reports:

    merkle_root    : ABCD1234\n

    copy that container to:

    /mota/abcd1234.mota\n

    If two containers have the same Merkle root, they have the same protocol ID and cannot both be present. Keep only the intended one. The current SD seeder can index up to 254 archived files. A served file must use a logical block size of at most 1024 bytes and contain at most 2048 blocks; check block_size and block_count in motatool inspect when importing unusually large images. Files outside that serve geometry are not counted or advertised. If a malformed /mota/<id>.mota conflicts with a newly discovered valid image, the repeater preserves the malformed file as <id>.bad through <id>.bad9 and downloads a clean replacement instead of treating path existence as a valid cache hit.

    To prepare and load the card:

    1. Format it as described under Card requirements. The easiest route is to insert it in the repeater, run set sdcard format, power the repeater off, and then move the card to the computer. Formatting destroys the existing card contents.
    2. Mount the card on the computer and create a directory named mota at the filesystem root. Do not use a nested directory such as /firmware/mota.
    3. Run motatool verify FILE.mota for every source file. Do not copy a file that reports FAIL.
    4. Run motatool inspect FILE.mota, read its merkle_root, and copy the file to /mota/<lowercase-merkle-root>.mota on the card.
    5. Flush pending writes, safely eject the card, power the repeater off, insert the card, and boot Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors.

    On Linux or macOS, this Bash example verifies and imports every .mota from ./motas. Replace the example mount path before running it:

    card_mount=/media/YOU/MESHCORE\nmkdir -p \"$card_mount/mota\"\n\nshopt -s nullglob\nfor image in ./motas/*.mota; do\n  motatool verify \"$image\" || exit 1\n  mid=$(motatool inspect \"$image\" |\n    awk '$1 == \"merkle_root\" { print tolower($3) }')\n  if [[ ! $mid =~ ^[0-9a-f]{8}$ ]]; then\n    echo \"Could not read the Merkle root from: $image\" >&2\n    exit 1\n  fi\n\n  destination=\"$card_mount/mota/$mid.mota\"\n  if [[ -e $destination ]]; then\n    cmp -s \"$image\" \"$destination\" || {\n      echo \"Different containers have the same ID: $mid\" >&2\n      exit 1\n    }\n  else\n    cp \"$image\" \"$destination\"\n  fi\ndone\nsync\n

    On Windows, create E:\\mota, inspect each source with motatool inspect, and rename it to the reported lowercase root in the same way. Safely eject the drive after all copies finish.

    After boot, scan the archive and confirm the card contents from the repeater console:

    get sdcard ls\nget sdcard ls 2\nota cache\nota folder\n

    ota cache should report the imported count. ota folder reports the current served count; once OTA serving starts in TempRadio, that set also includes the repeater's running firmware. If the files appear in get sdcard ls but not in the served count, check their exact names, run motatool verify again, and inspect their block geometry. It is fine to run ota cache off for a curated, read-mostly archive: that disables capture of new mOTAs but continues serving every valid file already on the card.

    "},{"location":"ota_meshtower_v2_sdcard/#serve-the-preloaded-archive-over-tempradio","title":"Serve the preloaded archive over TempRadio","text":"

    LoRa OTA traffic exists only during an active temporary-radio window. The SD repeater, each receiving node, and every intermediate repeater in the path need overlapping windows on the same temporary channel. Use a frequency permitted for the node's configured region. This North American example uses the recommended fast OTA settings and a 120-minute window:

    tempradio 909.950,250,5,5,120\n

    Start the farthest receiving node first, then intermediate repeaters, and the SD source last so their windows overlap for as long as possible. Before or during the source window, ota cache makes the source scan and attach the SD archive. Entry into TempRadio automatically triggers an OTA advertisement burst; ota announce can send another advertisement immediately.

    On a receiving OTA-capable node, allow a few seconds for catalog exchange, then run:

    ota ls\nota ls 2\nota get <mid8> flash\nota status\n

    Use ota ls 2, ota ls 3, and so on when the source advertises more than the two rows that fit in one remote CLI reply. Each row includes a stable eight-hex-digit manifest ID. Use that ID instead of a list number, because asynchronous catalog refreshes can reorder rows between the list and pull commands. A receiver retains the complete protocol catalog and verifies every transferred block. It still applies its normal target, hardware, codec, signature, and installation checks. The SD repeater may advertise images for many hardware families; it never installs those archive files merely because it serves them.

    When a temporary window expires or a node reboots, it returns to its saved radio settings and OTA transfer stops. The archive remains on the card. Start another set of overlapping tempradio windows to resume an interrupted download, or use synchronized tempradioat entries for a scheduled window.

    Archive capture is lower priority than operator work. An explicit ota pull to install storage or a host folder immediately takes the single receive slot; the archive partial is checkpointed and resumes later. Existing cached files can still answer peer requests while another archive file is downloading.

    Use these commands to inspect or control automatic capture:

    ota cache\nota cache on\nota cache off\nota config cache on\nota config cache off\n

    The on/off choice is saved on the SD card. Turning capture off stops new downloads but keeps serving files already cached. Formatting or erasing the card removes both the archive and its off marker, so a newly formatted card returns to the default-on setting. ota config sdseed on|off is also accepted as an alias.

    "},{"location":"ota_nrf52_bootloader_update/","title":"nRF52 bootloader updates over LoRa","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    Selected nRF52840 repeater LoRa-OTA builds can replace their matching OTAFIX bootloader without replacing the running application. This is a privileged maintenance path, not a normal firmware update. A stock bootloader must first be replaced with the exact ABI-3 self-update-capable OTAFIX build over USB/BLE DFU or SWD. MeshTower SD also requires the BLM2 retained-auth version to be provisioned locally before either application or bootloader OTA.

    "},{"location":"ota_nrf52_bootloader_update/#storage-layouts","title":"Storage layouts","text":"Layout Application limit Staged boot package Work area Handoff / capability XIAO-module raw QSPI below 0xE0000 external QSPI offset 0 dedicated internal 0xE0000..0xEA000 scratch source 0x51, flags 0x0E Qualified internal-flash target normal 0xED000 flash limit; top 64 KiB SRAM reserved for application-delta staging shared internal slot, exact start 0xE2000 the same eleven-page slot; no second flash reservation source 0xED, flags 0x0A MeshTower V2 microSD normal 0xED000 limit contiguous /meshcore-ota.mota dynamic internal 0xE0000..0xEA000 scratch; live image must end by 0xE0000 source 0x53, flags 0x09

    The exact SD target is Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors. Its normal application FULL and delta updates continue to use the SD file without the bootloader-update scratch restriction. For a bootloader package only, both MeshCore and OTAFIX require a hash-valid live EndF proving the complete running image ends at or below 0xE0000. If boot settings carry a nonzero app bank CRC, the recorded bank size must also cover that full EndF-inclusive image and stop by 0xE0000; erased or explicitly CRC-disabled settings remain valid. OTAFIX then copies the verified 40 KiB payload from SD into 0xE0000..0xEA000 and uses the MBR copy operation to replace 0xF4000..0xFE000. The application linker remains at 0xED000; a future application extending above 0xE0000 can still use application mOTA but must update its bootloader through local DFU/SWD.

    The removable SD authorization is fail-closed for both application and bootloader packages. MeshCore directly authenticates one exact signed manifest, requires the streamed copy to remain byte-identical, verifies all leaves/payload/image bytes, and computes a normalized full-container SHA-256 in that same pass (APRV bytes are treated as zero). After syncing APRV, it publishes a 72-byte MOTASDA2 record in reset-retained RAM at 0x20006008. That record binds purpose/format, first LBA, sector count, total length, card sector count, and the normalized digest. OTAFIX copies, validates, and clears the record before SD access. A card swap/mutation or a power cycle therefore fails closed; there is no general sector-1 ownership inference.

    For fmt3, MeshCore also writes and readback-verifies a 64-byte MOTASDBL token at 0xE0000. That token contains the exact container length and the exact authenticated signed manifest image_hash. OTAFIX requires the same hash in the parsed manifest, streamed payload, and final scratch image. The page is consumed as scratch during success and is not permanently reserved.

    The first BLM2-capable MeshTower bootloader cannot be delivered to preview.12 through the retained-RAM protocol that preview.12 predates. MeshCore does not write a raw-sector compatibility record because a blank sector is not proof of ownership. Upgrade preview.12 through USB/BLE DFU or SWD. Both fmt2 application and fmt3 bootloader OTA then require the installed BLM2 metadata to match the live SoftDevice/application layout; neither MeshCore nor OTAFIX uses sector 1.

    The internal path does not change the application's 0xED000 flash limit or permanently set aside separate app-OTA, boot-package, and flash-scratch regions. Qualified applications do use a dedicated linker that reserves the top 64 KiB of SRAM for hybrid application-delta staging and a retained authorization record. That SRAM is never part of a bootloader-update package. The ordinary bottom-aligned internal flash store holds one container prefix at a time: either an application delta or the complete bootloader package.

    The exact bootloader container is 41,330 bytes: 365 bytes of signed mOTA metadata, a 40 KiB payload, and the five-byte trailer. Below the normal 0xED000 store ceiling it bottom-aligns at 0xE2000. Admission requires a hash-valid live EndF proving the current application, including its trailer, ends at or before 0xE2000. OTAFIX then reads each source window before erasing and compacts the payload forward in place to the page-aligned raw range 0xE2000..0xEC000; it verifies every page and the whole image before asking the MBR to copy that image over 0xF4000..0xFE000.

    An ordinary application delta can be smaller or larger than this eleven-page shape. On the qualified profile, a delta larger than one page uses the minimum page-aligned flash prefix ending at 0xED000 and keeps up to 64 KiB of its logical tail in reset-retained SRAM; its detools workspace must stop at the actual flash-prefix start. A one-page-or-smaller delta stays wholly in flash. In either case the reconstructed application must stop below 0xED000. The two package kinds are mutually exclusive because they use the same flash store, and a bootloader package never uses the volatile SRAM suffix.

    For internal-self-update builds, an absent or corrupt live EndF disables all internal staging before the first erase. The older 608 KiB rescue estimate is unsafe when a normally linked application may extend to 0xED000. Builds without this feature retain the legacy rescue behavior.

    "},{"location":"ota_nrf52_bootloader_update/#internal-flash-target-inventory","title":"Internal-flash target inventory","text":"

    The release builder consumes the allowlist in tools/mota/nrf52_internal_bootloader_targets.txt. Ten names are also literal PlatformIO environments and are enabled automatically when built directly. The other eleven are release aliases assembled from a base environment plus the lean OTA overlay; build them through build.sh, which passes the same allowlist decision to the common pre-build guard. These lean repeater/bridge targets have no OTA-owned SD/QSPI store and have an exact curated OTAFIX manifest identity:

    Build target(s) Installed OTAFIX identity Boot target ID Heltec_tower_v2_repeater_lora_ota_no_external_sensors 239A0071 / TOWER_V2_OTA 1150F50E T096 lean repeater and RS232 bridge 239A0071 / T096_DFU 42354C85 Heltec_t1_repeater_lora_ota_no_external_sensors 239A0071 / T1_DFU FC556FFC T114 display and without-display lean repeaters 239A0071 / T114_DFU 0C3F2902 Mesh_pocket_repeater_lora_ota_no_external_sensors 239A0071 / MESH_POCKET_OTA 059277F4 KeepteenLT1_repeater_lora_ota_no_external_sensors 239A00B3 / KeepteenLT1_OTA DB2E7B51 Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors 239A0029 / MX25_DFU 026AA982 ProMicro_repeater_lora_ota_no_external_sensors 239A00B3 / PROM_DFU AF79E8CC t1000e_repeater_lora_ota_no_external_sensors 28860057 / T1KE_DFU E6F5F03F ThinkNode_M3_repeater_lora_ota_no_external_sensors 239A00DA / TNM3_DFU 0CA41DB2 RAK_3401_repeater_lora_ota_no_external_sensors 239A0029 / 3401_DFU 23818A80 RAK4631 lean repeater and both lean RS232 bridges 239A0029 / 4631_DFU 2D0DF000 GAT562 30S/Tracker Pro/EVB Pro lean targets 239A0029 / GAT562_DFU D50D2D44 R1Neo_repeater_lora_ota_no_external_sensors 239A0029 / 4631_DFU 2D0DF000 RAK_WisMesh_Tag_repeater_lora_ota_no_external_sensors 239A0029 / RTAG_DFU C72E9C9C

    Board IDs are not globally unique. For generic targets, the signed hardware ID is the exact NUL-padded 32-byte value NRF_BL_<BOARD_ID>_<DEVICE_NAME>. The wire target is the little-endian first four SHA-256 bytes of all 32 padded bytes. The installed and candidate embedded manifest pairs must match exactly. XIAO retains its deployed XIAO_BL_28860044 / XIAO_BL_28860045 identity and raw board-ID target.

    The Python reference builder and release tooling audit these boot targets for duplicates and collisions with application target IDs. Generic image parsing can inspect a future canonical identity, but signing/building a package fails until that exact identity is in the qualified inventory.

    Earlier GAT562 installations may report the RAK-compatible 4631_DFU identity. The installed and candidate identity pair must match exactly, so a remote bootloader update cannot migrate one of those devices to GAT562_DFU. Provision the GAT562-specific OTAFIX bootloader once through USB/BLE DFU or SWD; subsequent signed bootloader mOTA updates use target D50D2D44.

    "},{"location":"ota_nrf52_bootloader_update/#embedded-continuity-and-version-policy","title":"Embedded continuity and version policy","text":"

    Every remotely supplied successor retains the legacy 44-byte CRC-valid BLMF v1 record for identity continuity and diagnostics. Immediately after that record is a 32-byte BLM2/SOFT extension. The complete 76-byte envelope is fixed at the final raw-image offset 0x9FB4; relocated candidates are refused, while installed legacy-v1 discovery remains a generic diagnostic scan. The extension carries the actual packed bootloader version, SoftDevice family and FWID, application base, layout ABI, and zero compatibility/reserved fields; the legacy whole-image CRC covers the extension too. Scanners first count CRC-valid 44-byte base manifests, then interpret continuity only after one base identity remains. A malformed claimed extension therefore cannot make a duplicate identity disappear, and a sole half-present extension is rejected rather than treated as legacy.

    MeshCore requires the candidate extension, exact equality between its embedded version and the signed outer mOTA version, and exact agreement with the running SoftDevice family/FWID/application layout. Qualified internal/QSPI paths may bootstrap a CRC-valid installed legacy-v1 image once; the SD path never does and requires local BLM2 provisioning. Once the installed bootloader has BLM2 metadata, the candidate version must be strictly greater. Preview low bytes are 1..254, a stable release uses 0xFF, and low-byte zero or all-ones values are rejected. There is no remote rollback/migration override; intentional rollback uses USB/BLE DFU or SWD.

    The legacy no_external_sensors profiles trim selected optional environmental and ranging drivers to preserve flash headroom; they do not generally disable the I2C bus. Reduced RAK3401 and RAK4631 recipes retain INA219, INA226, INA260, and INA3221 voltage/current monitors. Those are entries in the optional sensor table, not the complete set of I2C consumers: the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals where the exact board recipe enables them. RAK3401 retains its compatible RAK12500 I2C and RAK12501/L76K UART GPS paths in sensor slot A. The plain RAK4631 repeater and Serial2 bridge retain GPS. Its RAK12501 path uses Serial1; the explicitly compiled Serial1 RS232 bridge therefore omits the combined GPS provider, including RAK12500, even though RAK12500 itself is I2C rather than UART.

    The firmware-configured INA3221 and RAK12500 addresses are both 0x42. They cannot coexist on the same bus at those addresses. Leave RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use firmware built with -DTELEM_INA3221_ADDRESS=0x43 when both modules are installed.

    Boards with onboard external flash are not silently redirected to internal staging. Mesh Solar, Nano G2 Ultra, T-Impulse Plus, ThinkNode M8, T-Echo Lite/Card, MeshTracker X1, and Wio WM1110 have board-specific QSPI hardware and need a separately matched QSPI path where available. Full Companions, other SD/QSPI/ExtraFS roles, Ethernet roles, source-only roles, and unqualified full-sensor roles are excluded from the internal-flash inventory. The exact MeshTower V2 SD role above is separately qualified for its SD path.

    This internal layout is limited to nRF52840 devices with 1 MiB internal flash, the exact S140 v6/v7 map, and the 40 KiB boot region at 0xF4000..0xFE000. nRF52833 and smaller nRF52 parts cannot provide that map plus a non-overlapping 41,330-byte live staging slot, so configuration fails closed instead of selecting smaller or overlapping geometry.

    "},{"location":"ota_nrf52_bootloader_update/#explicit-install-workflow","title":"Explicit install workflow","text":"

    The tools/lora_ota/lora_ota.sh and .ps1 runners can perform the discovery, transfer, verification and radio cleanup for a bootloader mOTA with --no-install. They detect format 3 automatically and check the destination's bootloader-specific identity/capabilities. They do not install it: the final MID/hash confirmation below remains an explicit operator action. A mixed application/bootloader ZIP requires --zip-member.

    Check the installed identity and capability marker:

    ota bootloader\n

    The response must show a CRC-valid exact identity, ABI 3 or newer, both FULL and INPLACE application codecs (codecs=0x5), and exact flags 0x09 for MeshTower V2 SD, 0x0A for internal shared storage, or 0x0E for XIAO QSPI. A bootloader row is visible in ota ls, but it is never autofetched or autoinstalled. Fetch its exact MID, then explicitly arm it:

    ota pull <MID8> flash\n# wait for ota status to report the download ready\nota bootloader\nota bootloader install <MID8> <HASH16>\n

    Copy both confirmation values from the second ota bootloader response. Ordinary ota install rejects a bootloader package; the bootloader command rejects an application package. The FULL-codec exception exists only for that manual bootloader MID. Ordinary application FULL remains disabled on an internal single-slot node, bootloader autofetch remains off, and a partial bootloader package is not automatically resumed after an application reboot.

    Before writing APRV, the application authenticates and authorizes the package: exact v3 geometry, trusted Ed25519 signer, signed/embedded identity, one unambiguous capability marker, embedded CRC, sane vectors, complete Merkle/payload/image hashes, storage-specific safe live placement, and the typed MID/hash confirmation. The manifest root and target inside the received manifest must also equal the MID/target that opened the fetch before any store is allocated. On SD, APRV, the retained geometry/container authorization, and the internal signed-image-hash token are published in that order before the reset trigger. OTAFIX consumes that application-written authorization and then independently rechecks the safety/integrity subset: strict v3 structure, canonical identity/capabilities, vectors, full payload SHA, embedded manifest CRC, the applicable live EndF/bank-settings no-overlap geometry, the SD token binding where applicable, scratch readback, and final copy hash. It does not re-run Ed25519, the signer allowlist, Merkle leaves/root, or the typed operator confirmation. Success is reported as blup:C8.

    "},{"location":"ota_nrf52_bootloader_update/#failure-behavior","title":"Failure behavior","text":"

    The feature fails closed when the MCU/map is wrong, required valid EndF headroom is insufficient, an unsupported external/ExtraFS role owns the target, the exact installed capability marker is absent or ambiguous, identity cannot be derived unambiguously, or any package check fails. Before the storage-specific scratch/copy step, the application and bootloader are unchanged. OTAFIX consumes the trigger and authorization before its first destructive action, so an interrupted operation cannot automatically retry a partly consumed package. On SD, the retained-RAM authorization is zero-consumed before media access; APRV and the fmt3 flash token may persist but are inert without a new explicit authenticated re-arm and reset trigger. No SD raw-sector handoff is created or consumed. Use USB/BLE DFU or SWD for initial provisioning and local recovery.

    For XIAO and ordinary external-QSPI details, see nRF52 repeater LoRa OTA with external QSPI.

    "},{"location":"ota_nrf52_qspi/","title":"nRF52 repeater LoRa OTA with external QSPI","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    Selected nRF52840 repeater builds use their dedicated external QSPI NOR flash as a raw LoRa OTA staging device. This removes the internal-flash staging conflict: the complete .mota stays off-chip, and the bootloader can use the entire internal application region while it installs either a full image or an in-place delta.

    This is a matched application-and-bootloader feature. A board merely having an nRF52840, free RAM, or pins named QSPI is not enough. Both halves must use the exact flash wiring, and ota self must confirm the store and bootloader before an update is downloaded.

    "},{"location":"ota_nrf52_qspi/#supported-repeater-families","title":"Supported repeater families","text":"

    QSPI staging is enabled only for explicitly matched repeater-role environments (and RS232 bridge variants that directly inherit one of those repeater targets) on these currently matched families:

    Heltec T114 is intentionally not in this list. Its public V1, V2.0, and V2.1 schematics show U9 (MX25R1635F) as an optional QSPI footprint, so standard T114 application and bootloader targets do not assume that external NOR is populated.

    The XIAO-module derivatives use the matching XIAO or XIAO Sense OTAFIX bootloader shown by the module's INFO_UF2.TXT. Do not substitute a similar bootloader for a board with different QSPI pins. For example, the T-Echo Card and T-Echo Lite have different flash wiring and are not enabled by the original T-Echo target.

    Companion builds are intentionally excluded. Some companion targets use the same external QSPI as a LittleFS message/data store, where raw OTA staging would corrupt the filesystem; other companions simply do not assign that chip to OTA. Room-server, sensor, and KISS roles are unchanged. Raw QSPI OTA is scoped to the explicitly matched repeater-derived environments.

    "},{"location":"ota_nrf52_qspi/#rak15001-placement-and-module-conflicts","title":"RAK15001 placement and module conflicts","text":"

    RAK15001 is a 2 MiB GD25Q16C standard SPI module, not a quad-I/O flash. The dedicated RAK4631 target uses the nRF52840 QSPI peripheral in its single-data-line FAST_READ/page-program modes at 8 MHz and accepts only the module's exact C8 40 15 JEDEC ID. ota self reports QSPI store:2048K only when the expected module responds. An empty slot or a different SPI device reports QSPI store:ERR 0K, and install is refused.

    The module is electrically usable in sensor slot A-D because those slots share the SPI signals and RAK15001 has onboard 10 kOhm pull-ups on WP# and HOLD#. The supported MeshCore/OTAFIX combination nevertheless requires slot C. It is the placement that remains safe when a RAK12501 GNSS is fitted in either of its supported slots, A or D: slot C avoids the GNSS PPS/reset nets on IO1/IO2 and IO5/IO6. In particular, GPS in A plus flash in B would share IO1/IO2 through the modules' auxiliary pins.

    Only one device that uses the shared WisBlock SPI chip-select may be fitted. Do not combine this target with RAK13800 Ethernet, RAK15002 SD, or another SPI module. RAK13800 and RAK15001 cannot coexist because they use the same SPI chip-select. Update a RAK13800 Ethernet build locally over USB using the release's Manual UF2 or Serial DFU (.zip) download; it cannot use this RAK15001 LoRa-OTA staging target.

    RAK3401 with RAK13302 remains incompatible with RAK15001. The 1 W radio uses the same WisBlock SPI clock/data pins and the same chip-select as RAK15001. Firmware cannot independently select or detect those two chips, so a stock RAK3401 + RAK15001 assembly cannot provide reliable OTA staging without a hardware chip-select rework. This restriction does not apply to the separate-CS W25Q16 wiring below.

    "},{"location":"ota_nrf52_qspi/#one-w25q16-wiring-for-rak4631-or-rak3401-rak13302","title":"One W25Q16 wiring for RAK4631 or RAK3401 + RAK13302","text":"

    An external Winbond W25Q16JV breakout can remain attached to a RAK19007 while the core is changed between RAK4631 and RAK3401 + RAK13302. It shares only the WisBlock SPI clock/data nets and has its own chip-select on AIN1:

    W25Q16 breakout RAK19007 connection nRF52840 / Arduino pin CLK IO connector pin 26, SPI_CLK P0.03 / 3 DO / MISO IO connector pin 27, SPI_MISO P0.29 / 29 DI / MOSI IO connector pin 28, SPI_MOSI P0.30 / 30 CS J11 AIN1 P0.31 / 31 VCC J12 VDD regulated 3.3 V GND J12 GND ground

    Fit a physical approximately 10 kOhm pull-up from CS to J12 VDD. That keeps the flash deselected during reset, bootloader entry, and a core-module swap. Power the breakout only from J12 VDD; never use J11 VBAT, which can exceed the flash's supply rating. The six-pin breakout does not expose WP#/IO2 or HOLD#/IO3; do not add nRF IO2/IO3 wiring. A breakout with an onboard power LED works, but that LED draws continuously and is undesirable for low-power or solar operation.

    This arrangement consumes no WisBlock sensor slot. However, SPI_CLK, SPI_MISO, and SPI_MOSI are not available on the easy 2.54 mm J10/J11/J12 headers. Those three signals require a short underside-pad tap or a suitable IO-connector interposer at pins 26-28. J11 and J12 alone are not sufficient.

    GPS remains supported in sensor slot A. RAK12500 uses I2C, while RAK12501/L76K uses UART plus its auxiliary control signals; neither uses the shared SPI clock/data bus. On RAK4631 the built-in SX1262 has a separate private SPI bus. On RAK3401 the RAK13302 and flash share clock/data, but have independent chip-selects: RAK13302 NSS is P0.26 and flash CS is P0.31. The matched firmware holds the radio NSS high while it temporarily hands the bus to the flash, then restores the radio SPI interface.

    The breakout target accepts only a 2 MiB W25Q16 with JEDEC ID EF 40 15 and runs it at 8 MHz in standard single-data-line SPI mode. A substituted W25Q32, W25Q64, or W25Q128 fails closed instead of being treated as the OTA store. Use the complete application/bootloader identity row that matches the fitted core:

    Core and radio PlatformIO environment MOTA hardware identity OTAFIX board / DFU device name RAK4631 built-in SX1262 RAK_4631_repeater_w25q16_lora_ota RAK4631_W25Q16 wiscore_rak4631_w25q16 / 4631_W25Q16_DFU RAK3401 + RAK13302 1 W RAK_3401_repeater_rak13302_w25q16_lora_ota RAK3401_RAK13302_W25Q16 wiscore_rak3401_rak13302_w25q16 / 3401_W25Q16_DFU

    The physical flash wiring is identical, but the two application and bootloader pairs are not interchangeable.

    "},{"location":"ota_nrf52_qspi/#one-time-prerequisite","title":"One-time prerequisite","text":"

    Install a QSPI-capable OTAFIX 2.4.1 preview.9 or newer bootloader for the exact board from the OTAFIX releases before using LoRa OTA. The release notes must explicitly list that board's QSPI mode. Also install the SoftDevice version expected by that target. The application refuses the install handoff when the bootloader does not advertise QSPI support.

    For the external W25Q16 option, install the corresponding bootloader once before installing its MeshCore application: wiscore_rak4631_w25q16 (DEVICE_NAME=4631_W25Q16_DFU) for RAK4631, or wiscore_rak3401_rak13302_w25q16 (DEVICE_NAME=3401_W25Q16_DFU) for RAK3401 plus RAK13302. Use only a release that explicitly names that exact pairing. Do not substitute the RAK15001 bootloader or swap the two W25Q16 bootloaders merely because the base-board wiring is the same.

    For the first migration from the ordinary wiscore_rak4631_board bootloader to wiscore_rak4631_board_rak15001_slot_c, use Nordic serial DFU or a compatible BLE DFU client with the exact slot-C OTAFIX combined bootloader + SoftDevice DFU package (or use SWD), then reinstall the slot-C MeshCore application. The release filename has this form:

    wiscore_rak4631_board_rak15001_slot_c_bootloader-<OTAFIX-version>_s140_6.1.1.zip\n

    Do not copy the slot-C bootloader-update UF2 onto the stock UF2 drive. The stock loader is bound to DEVICE_NAME=4631_DFU, while the slot-C image is bound to 4631_15001C_DFU, so that UF2 is intentionally rejected. After the one-time DFU/SWD migration, later canonical slot-C bootloader UF2 files work normally. The combined OTAFIX package above is not the MeshCore application's Serial DFU .zip; an application package does not migrate the bootloader.

    After installing the repeater application, check:

    get bootloader.ver\nota self\nota status\nota qspi\n

    A ready RAK15001 target reports all of the following:

    QSPI store:2048K\nbootloader: QSPI apply OK\nbl:QSPI\nQSPI jedec=C84015 size=2048K sr1=00 stage=jedec\n

    A ready external W25Q16 target instead identifies the Winbond part:

    QSPI store:2048K\nbootloader: QSPI apply OK\nbl:QSPI\nQSPI jedec=EF4015 size=2048K sr1=00 stage=jedec\n

    Other supported boards can report a capacity different from 2048K; the RAK15001 and W25Q16 targets must report exactly 2048K and their respective exact JEDEC IDs. QSPI store:ERR 0K, NO QSPI, or bl:NO-QSPI means the flash wiring, flash power, chip-select pull-up, application, or bootloader does not match. Do not start an install in that state.

    ota qspi is a read-only diagnostic probe available on QSPI OTA builds. It reports the exact JEDEC ID, status-register byte, last store stage, and the first latched storage error. Run it after an immediate storage error before starting another pull; later capacity probes preserve that failure detail.

    "},{"location":"ota_nrf52_qspi/#capacity-and-package-types","title":"Capacity and package types","text":"

    The store reads the JEDEC capacity at runtime and accepts supported 1 MiB through 16 MiB devices using 24-bit addressing. QSPI capacity is not the final firmware limit. The reconstructed application, including its 56-byte EndF trailer, must fit its build's linked application region:

    SoftDevice layout Application region Maximum image S140 v7, boot-update-capable XIAO module 0x27000..0xE0000 0xB9000 (757,760 bytes) S140 v7, app base 0x27000 0x27000..0xED000 0xC6000 (811,008 bytes) S140 v6, app base 0x26000 0x26000..0xED000 0xC7000 (815,104 bytes)

    The XIAO limit is deliberately 52 KiB smaller. Its linker and post-link record cap every ordinary application at 0xE0000; OTAFIX reserves 0xE0000..0xEA000 as a 40 KiB self-update scratch bank, with the remaining gap left unused before InternalFS. Full and delta application packages for these targets derive their effective application end from that 0xE0000 record and are rejected if their reconstructed image or detools geometry crosses it.

    A full package needs only the new raw firmware.hex or non-merged application image. An in-place delta still needs the exact image currently running. The automation uses a conservative 0xC6000 detools workspace for external nRF52 staging so one package setting is safe for both layouts.

    For online automation, QSPI is detected from ota self or ota status. For offline preparation, identify it explicitly:

    ./tools/lora_ota/lora_ota.sh ./release.zip target-name \\\n  --prepare-only \\\n  --platform nrf52 \\\n  --nrf-qspi \\\n  --target-id 12345678 \\\n  --target-hw Xiao_nrf52\n

    Use the real target ID and hardware identity from the destination. A ready full .mota normally uses that target ID for discovery and routing. An operator can deliberately override the routing target for a role change, so target_id is not an apply-time safety assertion; the destination still enforces the package's hardware identity before approval.

    "},{"location":"ota_nrf52_qspi/#explicit-xiao-bootloader-updates-over-lora","title":"Explicit XIAO bootloader updates over LoRa","text":"

    This section documents the deployed XIAO raw-QSPI layout. Curated nRF52840 targets without external staging use a separate internal-flash layout with the same explicit operator safety model; see nRF52 bootloader updates over LoRa.

    Bootloader delivery is available only when all of these are already true:

    This is not part of normal automation. Bootloader catalog rows are labelled bootloader; even a capable node never autofetches or autoinstalls them, and ordinary ota install rejects them in every application backend. The tools/lora_ota deployment runner also refuses v3 packages. Fetch and arm one only with the explicit flow:

    ota ls\nota pull <MID8> flash\n# wait for ota status to report the same bootloader download as ready\nota bootloader\n# copy the exact mid= and hash= values printed above\nota bootloader install <MID8> <HASH16>\n

    The pull command is only transport intent; it does not authorize an install. The final command must reproduce both the complete staged manifest ID and the first eight bytes of its signed image hash. Before approval the application again checks package/root/payload/image hashes, Ed25519 signature and trusted allowlist, exact signed XIAO_BL_... ID, installed and incoming embedded manifest/CRC/name/board identity, vector table, ABI-3 QSPI+boot-update marker, and the adjacent CRC-covered BLM2/SOFT continuity extension at the exact final-image offset 0x9FB4. The embedded boot version must equal the signed outer package version, the SoftDevice family/FWID/application base/layout ABI must match the running platform, and a successor to installed BLM2 must be strictly newer. Low-byte-zero and all-ones versions are invalid. Remote rollback is refused; use local DFU/SWD.

    After the reply drains, GPREGRET 0x6B and GPREGRET2 0x51 enter the special OTAFIX path. APRV carries the app's signature/allowlist and explicit operator authorization decision. OTAFIX independently rechecks the strict structure, identity/capabilities, vectors, payload SHA, embedded CRC, and copy hashes before using the reserved scratch bank to replace its own 0xF4000..0xFE000 region. The running application remains preserved; a rejected candidate returns to it unchanged. Post-reboot ota status reports bootloader-update diagnostics as blup:C0 to blup:CF (blup:C8 is success), separately from ordinary application blrc.

    This feature cannot update a stock or old bootloader that lacks the capability marker: perform the first exact-board combined bootloader+SoftDevice migration over USB/BLE DFU or SWD. Do not attempt to bootstrap it with a v3 package.

    "},{"location":"ota_nrf52_qspi/#storage-ownership-and-recovery","title":"Storage ownership and recovery","text":"

    The QSPI store is raw, not a file inside LittleFS. It owns the flash from offset zero, erases 4 KiB sectors as blocks arrive, writes data before progress metadata, and verifies every programmed page. A previously interrupted download is reopened only when its header and trailer are valid; every claimed block is re-hashed before it is trusted. Between a probe, transfer operation, or checkpoint, firmware puts the NOR into deep power-down, deactivates the nRF QSPI peripheral, and turns off a board-provided flash power-enable pin. The next operation powers the chip, shifts the NOR's 0xAB wake command over GPIO, then activates the nRF QSPI peripheral and identifies the flash. Waking it before peripheral activation is required because a flash in deep power-down ignores the activation traffic itself. Merely running ota self therefore does not leave QSPI drawing active-mode current or prevent the following operation from reactivating it.

    Installing a QSPI repeater build over a former companion build therefore repurposes the external flash and destroys companion filesystem data as OTA sectors are written. Back up anything important first. Returning to a companion build may require formatting its external data store.

    Before changing internal application flash, the application verifies package integrity, hardware identity, signature policy, and bootloader capabilities. The target ID selects discovery/fetch routing and can be deliberately overridden; it is not a second hardware gate. The bootloader then verifies a full payload before its first application erase. For a delta, the application rejects invalid detools geometry before approval and the bootloader independently repeats the base and geometry checks before applying it. It clears the one-shot approval marker before invalidating the running image. If power is lost after application writes begin, the bank remains invalid and OTAFIX enters USB/BLE recovery rather than booting a partial image.

    See Easy firmware updates over LoRa for the transfer commands and the OTA protocol for the container and handoff details.

    "},{"location":"ota_protocol/","title":"MeshCore OTA - .mota container & LoRa protocol","text":"

    This is the single source of truth for MeshCore's over-the-air firmware update system (\"mOTA\"). It is written for developers who want to implement an interoperable peer (server, fetcher, relay, or host tool) in another codebase or project. Everything below is implemented in this repository and covered by host, simulation, build, or hardware tests as noted in the relevant section. Hardware qualification is target- and chain-specific; do not infer it from implementation alone. Where a section names a source file, that file is the authoritative reference for byte-level details.

    Just want to update your node? See the plain-language OTA user guide - this document is the technical/wire specification.

    Design goals

    Source map (all under src/helpers/ota/ unless noted)

    Concern File Constants, enums, flags OtaFormat.h Container/manifest parse MotaContainer.{h,cpp} Merkle tree + proofs MerkleTree.{h,cpp} EndF self-identity FirmwareInfo.{h,cpp} Wire message codec OtaProtocol.{h,cpp} Session engine (serve+fetch+discovery) OtaManager.{h,cpp} Multi-mota / folder relay OtaSource.h, MotaSourceSerial.{h,cpp}, MotaSeederProto.h Staging stores OtaStore.h, OtaStoreFlashNrf52.*, OtaStoreFlashEsp32.* Apply OtaApply.*, bootloader Adafruit_nRF52_Bootloader_OTAFIX Device glue (CLI/context) OtaCli.cpp, OtaContext.h Host tooling motatool (standalone Rust CLI: build/verify/inspect/serve); tools/mota/ (Python reference lib motalib.py + build/test glue)"},{"location":"ota_protocol/#1-conventions","title":"1. Conventions","text":"

    Reference constants (OtaFormat.h):

    Name Value ASCII / note Container MAGIC 6D 4F 54 41 mOTA Container TRAILER 76 6B 34 39 36 vk496 EndF marker 45 6E 64 46 EndF hash_algo (sha2-256) 0x12 multihash code application format_ver 0x02 ordinary full/delta application package bootloader format_ver 0x03 privileged exact-identity nRF52 bootloader package only approval = not approved FF FF FF FF erased NOR word approval = approved 41 50 52 56 APRV MFLAG_FULL 0x01 flags bit0 MFLAG_SIGNED 0x02 flags bit1 MFLAG_BOOTLOADER 0x04 flags bit2; valid only in the exact v3 bootloader profile CODEC_FULL / _SEQUENTIAL / _INPLACE 0 / 1 / 2 Section 5 PAYLOAD_TYPE_OTA 0x0C MeshCore packet type (src/Packet.h) MAX_PACKET_PAYLOAD 184 usable bytes per packet (src/MeshCore.h) Default application block size 2048 block_size_log2 = 0x0B; deployed 1 KiB application packages remain accepted Bootloader-package block size 1024 fixed block_size_log2 = 0x0A for format 3 OTA discovery TX priority 250 background (OTA_TX_PRIORITY, src/Mesh.h) OTA active-transfer TX priority 0 primary (OTA_TRANSFER_TX_PRIORITY, src/Mesh.h)

    Current application firmware publishes its receive/reassembly ceiling as the compact maxblk:2048 field near the front of both ota status and ota self. Keeping the field ahead of variable diagnostics ensures it survives the 160-byte remote-admin reply limit. Host updaters must treat an absent field as the deployed legacy capability (1024), reject a ready package whose manifest block size exceeds that capability, and use the reported size when turning a raw firmware image into a new application package. This capability does not change the format-3 bootloader package's fixed 1 KiB geometry.

    "},{"location":"ota_protocol/#2-firmware-image-the-endf-trailer","title":"2. Firmware image & the EndF trailer","text":"

    Every OTA-capable build appends a fixed 56-byte EndF trailer to its flashed image so a running node can discover its own size and self-describing identity on any MCU (no linker symbols needed). Every field is always present at a constant offset. Implemented by FirmwareInfo.cpp; appended at build time by tools/mota/pio_endf.py (post-build hook).

    flashed image = BODY (image bytes) || EndF trailer\nEndF trailer (fixed 56 bytes):\n  off 0   4   \"EndF\"        45 6E 64 46\n  off 4   4   body_len      uint32 LE - length of BODY (excludes the whole trailer)\n  off 8   8   body_hash     sha2-256:8 of BODY\n  off 16  4   fw_version    uint32 LE, packed MAJOR<<24|MINOR<<16|PATCH<<8|pre  (0 = unknown)\n  off 20  4   target_id     uint32 LE - sha2-256:4(pio_env): hardware + role + partition (fetch routing)\n  off 24  32  hw_id         NUL-padded ASCII hardware tag (brick-safety), e.g. \"RAK4631\" (\"\" = unknown)\n

    The \"reconstructed image\" referenced by the manifest is the full BODY || EndF (what gets flashed).

    "},{"location":"ota_protocol/#esp32-application-slot-profiles","title":"ESP32 application-slot profiles","text":"

    ESP32 Companion firmware is exempt from the portable-slot limit. When an exact Full recipe exists, one expanded-partition image supplies USB, BLE, WiFi, source-only LoRa OTA, and persistent USB logging instead of separate transport artifacts. A small set of high-capacity, non-PSRAM classic ESP32 companions use 100 contacts, 8 group channels, and a 16-frame offline queue in that combined image to preserve internal-DRAM headroom. MQTT observers and ESP-NOW bridges always use FULL builds because fitting them into the legacy slot would require removing CLI and role features. Except for those FULL roles and the ESP32-C6 case below, non-companion ESP32 artifacts, including room, sensor, and repeater roles, must fit the legacy slot from 0x10000 up to 0x150000 (0x140000, 1,310,720 bytes), including the 56-byte EndF trailer. The build checks both that limit and the target's actual app partition. The ESP32-C6 no_external_sensors OTA siblings are the narrow exception: the Arduino 3.x WiFi runtime cannot fit that cross-family ceiling, so those images retain their established target-specific 1920 KiB or larger A/B app layout and are checked against the actual app partition. For standalone ESP32 and nRF52 repeaters that need a lean staging profile, build.sh also exposes an explicit *_lora_ota_no_external_sensors artifact: the ordinary repeater remains sensor-enabled, while that sibling trims selected optional environmental/ranging drivers for LoRa distribution. SolarXiao 30S and 33S use matched external QSPI staging, so their ordinary full-sensor repeater is already install-capable and no redundant lean sibling is generated. Integrated GPS and other board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. The legacy suffix describes a driver trim, not removal of the generic I2C bus. Reduced RAK3401 and RAK4631 profiles retain INA219, INA226, INA260, and INA3221 voltage/current monitors. These are retained optional-sensor-table drivers, not the only I2C consumers: SSD1306 display, supported autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals where selected by the board recipe. The RAK3401 OTA repeater also retains RAK12500 I2C and RAK12501/L76K UART GPS support; install either GPS module in sensor slot A because slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The plain RAK4631 OTA repeater and its Serial2 bridge retain GPS. RAK12501 uses Serial1. Its explicitly compiled Serial1 bridge therefore omits the combined GPS provider, including RAK12500, even though RAK12500 itself does not use the UART.

    The firmware-configured INA3221 address and RAK12500 address are both 0x42, so those devices cannot share one bus at those addresses. Keep RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use firmware built with -DTELEM_INA3221_ADDRESS=0x43 when both are installed.

    ESP32 siblings retain the compact browser WiFi updater and use up to 254 neighbors. Internal-DRAM-constrained targets use 50 as recorded in the artifact capability manifest; see ESP32 memory budget. RP2040 and STM32 targets are not offered because those platforms do not yet have a safe bootloader/apply path.

    nRF52 LoRa-OTA siblings use size optimization rather than the Adafruit platform's default -Ofast. This keeps the runtime software Ed25519 fallback from being expanded into tens of kilobytes of repeated curve arithmetic while retaining CC310 hardware crypto, hardware RNG mixing, telemetry history, and board-native features.

    WiFi-heavy non-companion roles are not reduced to fit the legacy application slot. build.sh automatically promotes every ESP32 MQTT observer and ESP-NOW bridge to the expanded FULL partition profile. These artifacts retain the complete role CLI, WebConfig where supported, display and optional sensor support, full timezone and TLS behavior, and the board's normal power-management implementation. The compact CLI is not compiled into any build. Ordinary repeater builds remain sensor-enabled; only explicitly named *_lora_ota_no_external_sensors siblings trim selected optional environmental/ranging drivers for LoRa distribution, and those siblings retain the complete CLI and target-declared I2C peripherals.

    MQTT observer radio and bridge preferences use verified temporary files plus a recoverable backup. A reset during a settings save restores the last committed common preference image or publishes the completed new image; it does not leave a partially written /com_prefs file to fail on the next boot. A truncated legacy image is rejected before any partial radio or string fields are applied, then rewritten from safe defaults.

    Option 3 in build.sh emits one *-full-usb-wifi-ota-* ESP32 artifact for each FULL-capable non-companion hardware/role that has a matching MQTT environment. It compiles USB packet logging and direct WiFi MQTT together. A *-full-logging-ota-* fallback is emitted only when there is no MQTT sibling; ordinary non-OTA roles compile runtime USB logging into their canonical image, so separate standard-logging artifacts are not emitted. Non-MQTT FULL twins are also skipped for covered ESP32 roles. MQTT observers and ESP-NOW bridges are emitted only with expanded FULL partitions. Menu option 8, or build-full-esp32-firmwares, builds the unified profiles plus necessary fallbacks. Menu option 9, or build-full-esp32-logging-firmwares, builds only those fallbacks. FULL builds restore WebConfig, display support, optional external sensors, and the full role CLI and feature set, full ElegantOTA where that target declares the required library, and LoRa OTA for every included role, including room servers, sensors, observers, and bridges. They use expanded A/B partition tables: 1984 KiB application slots on 4 MiB boards and the framework's larger dual-OTA tables on 8 MiB and 16 MiB boards. Explicit *_lora_ota_no_external_sensors targets are not duplicated; their ordinary repeater build is the FULL, sensor-enabled counterpart. The *-full-usb-wifi-ota-* profile enables USB packet logging and MQTT, with a persistent logging.output selector; its verbose internal debug remains off. The fallback *-full-logging-ota-* profile enables USB debug and packet logging and has no MQTT target. Install a matching *-full-usb-wifi-ota-*-merged.bin or *-full-logging-ota-*-merged.bin over USB once to write the expanded partition table. After that, its matching non-merged FULL application image can be installed through USB, WiFi OTA, or LoRa OTA. Do not install a non-merged FULL image onto a node that still has its old partition table.

    Implementer note: the bootloader (and any non-Arduino consumer) MUST locate the body extent by scanning for EndF, never by trusting a stored size - see the bootloader contract in Section 12.

    "},{"location":"ota_protocol/#3-the-mota-container","title":"3. The .mota container","text":"

    The distributed form (host-built, wire-transferred). Parsed by mota_parse() in MotaContainer.cpp.

    off            size   field\n0              4      MAGIC = 6D 4F 54 41\n4              4      MOTA_TOTAL_SIZE  uint32 LE - total container bytes (incl. manifest, leaves[],\n                                       payload, trailer). Lets a node pre-reserve staging and compute\n                                       write_start = staging_region_end - MOTA_TOTAL_SIZE.\n8              M      MANIFEST         (Section 4; M = 197 fixed + leaves[], 4*BC; no length field - BC from payload_size)\n8 + M          P      PAYLOAD          (payload_size bytes; delta or full image)\n8 + M + P      5      TRAILER = 76 6B 34 39 36\n

    MOTA_TOTAL_SIZE = 4 + 4 + M + P + 5. The manifest M includes leaves[]; the manifest-minus-leaves prefix (mfl, sent over the wire as OTA_MANIFEST) is [8, leaves_off).

    Staged (in-flash) form. Written bottom-aligned so TRAILER ends at staging_region_end. Identical bytes, except the device mutates two regions in place (both NOR-safe, no re-erase): the leaves[] slots (filled as blocks arrive - Section 7) and the 4-byte approval field (on owner consent - Section 4.2). Everything else is immutable.

    "},{"location":"ota_protocol/#4-the-manifest","title":"4. The manifest","text":"

    Fixed layout. Every field sits at a constant offset and is always present - base_hash, signer_pubkey and signature are zero-filled when not applicable (a full image / an unsigned container). Only leaves[] is variable (one 4-byte hash per block). So the manifest-minus-leaves (mfl) is always 197 bytes and the parser is plain offset reads - no conditionals. Parsed by mota_parse_manifest().

    off  size   field            notes\n0    1      format_ver       = 0x02 application, or 0x03 privileged bootloader package\n1    1      flags            bit0 FULL; bit1 SIGNED; bit2 BOOTLOADER; bits3-7 reserved 0\n2    1      hash_algo        0x12 = sha2-256\n3    4      target_id        device/arch/role discriminator (Section 9)\n7    4      fw_version       MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre   (comparable uint32)\n11   4      image_size       size of the reconstructed image (BODY||EndF)\n15   4      payload_size     PAYLOAD bytes in this container\n19   1      block_size_log2  e.g. 0x0B = 2048 (new application default); 0x0A = deployed 1024\n20   4      merkle_root      sha2-256:4 over PAYLOAD blocks (Section 6) - also the manifest_id\n24   32     image_hash       sha2-256:32 of the reconstructed image - SECURITY anchor\n56   1      codec_id         0=full/raw, 1=detools-sequential, 2=detools-in-place\n57   32     hw_id            NUL-padded ASCII hardware tag (e.g. \"RAK4631\"); same tag => bootable-compatible.\n                             SIGNED. Applier refuses a mismatch (brick-safety); empty on either side = skip.\n89   8      base_hash        sha2-256:8 of the BASE image's BODY (== that build's EndF.body_hash). 0 if FULL.\n97   32     signer_pubkey    Ed25519 public key. 0 if not SIGNED.\n129  64     signature        Ed25519 over manifest[0, 129). 0 if not SIGNED.\n193  4      approval         FF FF FF FF = not approved; 41 50 52 56 (\"APRV\") = approved\n--- end of manifest-minus-leaves: mfl = 197 (constant); leaves_off = 8 + 197 = 205 in the container ---\n197  4*BC   leaves[]         BC = ceil(payload_size / 2^block_size_log2). sha2-256:4 each (the only variable field)\n

    The signature always covers manifest[0, 129) (the head + base_hash + signer_pubkey). approval is outside the signed region so it can be flipped in place on consent without breaking the signature.

    Manifest-minus-leaves size (mfl) is a constant 197 bytes for every container (full or delta, signed or unsigned). At 197 bytes the manifest exceeds one packet, so OTA_MANIFEST is always sent multi-fragment (Section 8.4, 2 fragments) and reassembled by the fetcher.

    The two versions are deliberately disjoint. Version 2 accepts application packages only and rejects the BOOTLOADER bit. Version 3 accepts only flags exactly FULL|SIGNED|BOOTLOADER; a non-bootloader v3 package is invalid. Consequently, deployed v2-only application parsers reject a bootloader package before they can mistake its raw 40 KiB payload for an application image.

    "},{"location":"ota_protocol/#41-signed-region","title":"4.1 Signed region","text":"

    signature covers manifest bytes [0, 129) - the head + base_hash + signer_pubkey. It does not cover approval or leaves[]:

    "},{"location":"ota_protocol/#42-the-approval-field","title":"4.2 The approval field","text":""},{"location":"ota_protocol/#43-privileged-nrf52-bootloader-package-profile","title":"4.3 Privileged nRF52 bootloader package profile","text":"

    A v3 bootloader package has a deliberately narrow, non-extensible profile:

    The incoming embedded identity must exactly match the installed CRC-valid bootloader identity. Both scans consider every aligned structurally valid candidate so magic bytes in a literal pool cannot shadow the real manifest. Duplicate accounting counts each CRC-valid 44-byte base record before interpreting adjacent continuity metadata, so a corrupt or half-present BLM2 extension cannot hide a second identity; after exactly one base record is selected, malformed claimed continuity fails closed. A package must be signed by a key already in the device's trusted allowlist; unlike ordinary application packages, there is no unsigned manual-install exception. The signed outer fw_version must equal the embedded boot version. Qualified internal/QSPI targets may bootstrap a CRC-valid legacy-v1 installed bootloader once; MeshTower SD instead requires local BLM2 provisioning because it has no safe legacy media handoff. After bootstrap every remote successor must be strictly newer and match the live SoftDevice/application layout. Low-byte zero and all-ones boot versions are invalid. Remote rollback has no override and must use local DFU/SWD.

    "},{"location":"ota_protocol/#5-payload-codecs-delta-base","title":"5. Payload, codecs & delta base","text":"

    PAYLOAD is either the full reconstructed image (FULL) or a delta (!FULL).

    codec_id Meaning Used by 0 full / raw PAYLOAD = reconstructed image (BODY||EndF). ESP32 A/B or an external SD/QSPI nRF52 target. 1 detools sequential random read of base + sequential write of result -> ESP32 A->B inactive slot. 2 detools in-place bounded scratch; rewrites the app region in place -> nRF52 single-slot.

    For deltas, base_hash = the base build's EndF.body_hash (sha2-256:8 of its BODY). A node applies a delta only if base_hash matches its own EndF.body_hash. After applying, the result MUST hash (sha2-256:32) to image_hash before it is booted - the hard security gate.

    A fetcher only requests firmware it can apply. Each node declares the codec(s) it can apply (set_apply_codec/set_apply_codec2): ESP32 accepts full + sequential (+ in-place). Internal-staging nRF52 targets accept only in-place because internal flash cannot hold a second full application image. Matched SD and raw-QSPI nRF52 targets accept full + in-place because external media holds the container. A .mota with an unsupported codec is rejected at discovery time, before any blocks are requested. A manual pull to an external folder may accept other codecs because that path captures bytes and never installs them.

    Compression is internal to the detools patch and must be supported by the applier. Patches are produced by detools 0.53.0 (tools/mota -> detools.create_patch) and decoded on-device by detools' embeddable C decoder, vendored verbatim at src/helpers/ota/detools/ (see its README.meshcore.txt). That build enables only the self-contained NONE + CRLE compressions (no malloc/liblzma/heatshrink), so MeshCore deltas use --compression crle. Do not reimplement the codec - use the vendored decoder.

    "},{"location":"ota_protocol/#6-merkle-tree-sha2-2564","title":"6. Merkle tree (sha2-256:4)","text":"

    Verifies each PAYLOAD block against the signed merkle_root before the whole payload exists, so corruption/forgery is localized to a block. Implemented in MerkleTree.cpp.

    "},{"location":"ota_protocol/#61-proofs","title":"6.1 Proofs","text":"

    A proof for block i is the ordered list of sibling digests from leaf to root. Promoted levels contribute no element. Verification (needs BC to know the tree shape):

    h = leaf_i ; idx = i ; n = BC ; p = 0\nwhile n > 1:\n    if (n is odd) and (idx == n-1):        # this node was promoted\n        pass\n    else:\n        sib, side = proof[p] ; p += 1\n        h = sha2-256:4( sib || h ) if side==left else sha2-256:4( h || sib )\n    idx //= 2 ; n = (n + 1) // 2\naccept iff h == merkle_root and p == len(proof)\n

    Over LoRa, leaves[] are omitted from the manifest transfer. A serving node computes a block's proof on demand from its stored leaves[] and normally sends OTA_PROOF immediately after that block's paced OTA_DATA. OTA_REQ_PROOF remains the fallback for an older source or a lost proactive proof. The fetcher fills its own leaves[i] as each verified block lands.

    "},{"location":"ota_protocol/#7-block-availability-staging-resume","title":"7. Block availability, staging & resume","text":"

    There is no separate availability structure. Block i is present <=> leaves[i] is non-erased (!= FF FF FF FF). Because leaves[] live in the staged flash region, availability survives reboot.

    Commit order per block (crash-safe): (1) verify proof, (2) write block payload to its offset, (3) write leaves[i] last. A power loss before step 3 leaves the slot erased -> the block is simply re-fetched (idempotent). On boot a node rebuilds an in-RAM present-bitmap by scanning leaves[] for a persistent, reopenable store. A hybrid nRF52 transfer is the deliberate exception: its SRAM-backed payload suffix is volatile, so the application refuses to adopt that staged header instead of rebuilding partial progress.

    Resume (OtaManager::resumeStaged + OtaStore::checkpoint/reopen): an interrupted fetch resumes from the staged container after a reboot - re-parse the stored manifest, recompute geometry, count present blocks, continue fetching the holes (or jump straight to COMPLETE). The checkpoint cadence (persist progress every N committed blocks) is runtime-tunable (ota config checkpoint <N>, 0 = only finalized containers resume). Boot-time adoption is an automatic fetch decision: current autofetch must be enabled, the stored target must equal the node target, policy signed requires the signed bit, and an enabled running-version floor requires a strictly newer manifest. An explicit MID pull may deliberately resume an older or unsigned package and keeps target 0 as a MID-only wildcard. Stores keep leaves[] in RAM until flush and never auto-GC, preserving resumable progress. The debug/operator equivalent is ota dev resume <MID8>; after a reboot the MID is mandatory, while a no-argument form may only reuse a still-active session MID. It never uses the nullptr automatic-adoption path, so a malformed MID or no active MID fails closed. Hybrid nRF52 staging cannot enter this resume path after an application restart, even if its flash prefix still contains metadata; the complete logical container must be fetched again.

    Flash-store note (RX-safe writes): a flash page-erase halts the CPU (~85 ms on nRF52) and starves LoRa RX, so the flash stores (OtaStoreFlashNrf52/OtaStoreFlashEsp32) coalesce writes to the erase unit (4 KB page / sector) and commit each once off the per-packet path. Ordinary stores keep RAM at O(one page), not O(image); a qualified hybrid nRF52 profile additionally reserves one fixed 64 KiB staging arena. A small delta that fits page 0 does zero flash I/O until COMPLETE.

    "},{"location":"ota_protocol/#8-lora-ota-protocol","title":"8. LoRa OTA protocol","text":"

    Carried in MeshCore packets with PAYLOAD_TYPE_OTA = 0x0C. Every OTA packet payload is:

    [0]    ota_msg_type      (OtaMsgType, OtaFormat.h)\n[1..]  body              (fixed per type; encode/decode in OtaProtocol.cpp)\n

    Message types:

    ota_msg_type val routing purpose OTA_ADV 0x01 discovery tiny per-node beacon (discovery tier 1) OTA_QUERY 0x02 discovery ask a source for its catalog (discovery tier 2) OTA_HAVE 0x03 discovery the catalog reply (fragmented, digest-tagged) OTA_GET_MANIFEST 0x04 transfer request a manifest's fragments (want_mask) by manifest_id OTA_MANIFEST 0x05 transfer the manifest-minus-leaves, fragmented OTA_REQ 0x06 transfer request fragments from an adaptive flight of 1-4 blocks (want_mask per block) OTA_DATA 0x07 transfer one self-describing fragment of a block's data OTA_REQ_PROOF 0x08 transfer request/re-request a missing proof OTA_PROOF 0x09 transfer the merkle proof for one block OTA_GET_LEAVES 0x0A transfer request the target's leaves[] fragments (want_mask) - warm-start only OTA_LEAVES 0x0B transfer a fragment of the leaves[] array (for host-side seed leaf-diff) "},{"location":"ota_protocol/#81-two-tier-discovery","title":"8.1 Two-tier discovery","text":"

    Because a node may serve many mOTAs (its own firmware plus an external folder - Section 10), discovery is split so the periodic beacon stays tiny regardless of catalog size:

    Tier 1 - OTA_ADV beacon (10 bytes, constant). Flooded as a short burst at boot, then every advert_mins minutes (default 24h; runtime-tunable via ota config advert, 0 disables the periodic re-advertise). It is also emitted immediately whenever the served set changes (e.g. a motatool folder is attached/detached), so peers learn about newly-available firmware without waiting for the next interval:

    seeder_id[4]    advertiser node id = pubkey[0:4]; the QUERY address + distinct-source id\nn_motas         uint8 - count of complete servable mOTAs (saturates at 255)\nset_digest[4]   sha2-256:4 over the SORTED set of served manifest_ids (see below)\n

    set_digest is a content hash of the offering, not a counter: canonical across nodes, and it changes iff the set of served mids changes. A peer that has already catalogued this {seeder, set_digest} ignores the beacon (steady state is query-free). For a single served mota, set_digest = sha2-256:4(mid).

    Tier 2 - OTA_QUERY -> OTA_HAVE (on interest only):

    OTA_QUERY  (flood):  seeder_id[4]  set_digest[4]  filter_target(uint32) want_fragments(uint32)\n                     # filter_target 0 = everything; want_fragments 0 = every fragment\nOTA_HAVE   (flood):  seeder_id[4]  set_digest[4]  frag_idx(1) frag_total(1) n_rows(1)  rows[]\n  HaveRow (16 bytes, OTA_HAVE_ROW_BYTES): mid[4] target_id(4) fw_version(4) codec_id(1) flags(1) have_count(2)\n

    have_count is the number of blocks the source holds (== block_count for a complete offered image). Receivers do not advertise partial or completed downloads as new sources.

    A node interested in a source's offering schedules a QUERY; the source replies with its full catalog as OTA_HAVE rows (fragmented if they exceed one packet - 10 rows per fragment). A receiver marks the catalog complete only after all frag_total fragments arrive. If any are missing after the recovery timeout, it sends another QUERY whose want_fragments bitmap names only the holes. want_fragments is an append-only extension: an original 13-byte QUERY is still accepted and means \"send every fragment.\" The heavy manifest is fetched per-mid only on commit (Section 8.3).

    Fragment numbers are canonical pages of the complete catalog sorted by manifest_id. filter_target may remove rows from a requested page (and can therefore produce an empty fragment), but it never renumbers pages or changes frag_total. This keeps missing-fragment recovery unambiguous when filtered and unfiltered queries for the same {seeder, set_digest} are overheard together.

    "},{"location":"ota_protocol/#82-anti-storm-mandatory-at-mesh-scale","title":"8.2 Anti-storm (mandatory at mesh scale)","text":"

    If 50 neighbours all queried a new beacon at once, the mesh would collapse. Mitigations (gossip/mDNS pattern), all in OtaManager:

    Net effect: a digest change costs ~1 query + ~1 HAVE flood mesh-wide; a stable mesh is query-free.

    "},{"location":"ota_protocol/#83-fetch-handshake","title":"8.3 Fetch handshake","text":"
    fetcher                                   server (any node that has the mid)\n  OTA_GET_MANIFEST(mid, want_mask)  >     (want_mask=0xFFFF first; only missing fragments on retry)\n                               <-------   OTA_MANIFEST(mid, frag_idx, frag_total, bytes)   x requested frags\n  (reassemble manifest, verify, compute geometry: BC, block_size, payload_size)\n  for each adaptive flight of missing blocks (starts at 1, grows on clean flights):\n    OTA_REQ(mid, {block_idx, want_mask}[]) >  (one packet; all fragments first, only holes on recovery)\n                               <-------   OTA_DATA(mid, block_idx, frag_off, data) x requested frags/block\n                               <-------   OTA_PROOF(mid, block_idx, n_proof, proof) x requested blocks\n    (independently reassemble + verify each block, but remain RX-silent until the flight drains)\n    [after adaptive deadline: recover one block's holes, or OTA_REQ_PROOF for a missing proof]\n    (clean flight grows by one block; recovered flight halves the next width)\n  when all blocks present: verify full merkle_root + image_hash -> COMPLETE\n

    Before allocating or writing the selected store, the receiver parses the reassembled manifest and requires its merkle_root to equal the requested/wire manifest_id and its target_id to equal the catalog or explicit-pull target that opened the receive slot. The wire envelope and HAVE row are advisory; they cannot label and stage a different manifest.

    "},{"location":"ota_protocol/#84-message-bodies-transfer","title":"8.4 Message bodies (transfer)","text":"

    All offsets after the 1-byte type. Encoders/decoders in OtaProtocol.cpp; constants in OtaManager.h.

    OTA_GET_MANIFEST:  manifest_id[4]  want_mask(uint16)   # bit k = send manifest fragment k; 0xFFFF = all\nOTA_MANIFEST:      manifest_id[4]  frag_idx(1)  frag_total(1)  bytes[]     # up to OTA_MF_FRAG=176 B/frag\nOTA_REQ:           manifest_id[4]  { block_idx(uint16)  want_mask(uint16) }[1..4]\n                   # legacy: bit k requests its 160-byte fragment\n                   # v2: bit15=marker, bit14=allow transport DEFLATE,\n                   #     bit13=2 KiB descriptor, bits0..12=fragment bitmap;\n                   #     bit12 must be zero when bit13 is set (0xFFFF stays legacy)\nOTA_DATA legacy:   manifest_id[4]  block_idx(uint16)  frag_off(uint16)  data[<=160]\nOTA_DATA v2:       manifest_id[4]  block_idx(uint16)  descriptor(uint16)\n                   stream_id[4]  data[<=171]\n                   # deployed 1 KiB descriptor: bit15=marker, bit14=DEFLATE,\n                   #   bits13..10=fragment, bits9..0=complete encoded length minus one\n                   # negotiated 2 KiB descriptor: bit15=marker, bits14..11=fragment,\n                   #   bits10..0=complete encoded length minus one; DEFLATE iff that\n                   #   length is strictly less than the manifest-derived raw block length\nOTA_REQ_PROOF:     manifest_id[4]  block_idx(uint16)\nOTA_PROOF:         manifest_id[4]  block_idx(uint16)  n_proof(1)  proof[]   # n_proof x 4 bytes\nOTA_GET_LEAVES:    manifest_id[4]  want_mask(uint16)   # bit k = send leaves fragment k; 0xFFFF = all\nOTA_LEAVES:        manifest_id[4]  frag_idx(1)  frag_total(1)  bytes[]      # up to OTA_LEAVES_FRAG=176 leaf bytes\n
    "},{"location":"ota_protocol/#85-sizing-against-max_packet_payload-184","title":"8.5 Sizing against MAX_PACKET_PAYLOAD = 184","text":"message fixed overhead payload/packet OTA_DATA legacy 9 B (type+mid4+idx2+off2) OTA_FRAG_DATA = 160 -> 7 raw frags/1 KiB or 13/2 KiB OTA_DATA v2 13 B (legacy header + stream ID) OTA_FRAG_DATA_V2 = 171 -> 6 raw frags/1 KiB or 12/2 KiB; fewer when compressed OTA_MANIFEST 7 B OTA_MF_FRAG = 176 -> signed manifest ~ 2 frags OTA_HAVE 12 B 10 rows x 16 B per fragment OTA_PROOF 8 B up to ~44 sibling digests (>> any real tree)

    A served mota supports up to 1024 leaves in the default 4 KiB proof scratch (about 2 MiB of payload at the new 2 KiB default, or 1 MiB for a deployed 1 KiB container); larger self-images pass a bigger scratch buffer.

    "},{"location":"ota_protocol/#86-temporary-radio-and-transfer-boundary","title":"8.6 Temporary-radio and transfer boundary","text":"

    OTA packets may cross normal mesh relay hops, but each participating node processes or relays them only while its tempradio window is actually running. A receiver selects missing blocks in serial order into a bounded request flight. Every session starts with one block. A clean completed flight increases the next request by one block; a flight requiring fragment/proof recovery halves the next width (4 -> 2, 3 -> 2, 2 -> 1). The default compiled cap is two blocks; the RAK3401 LoRa-OTA target caps at four, so it probes 1 -> 2 -> 3 -> 4. All rows are sent in one backward-compatible OTA_REQ, and no freed slot is refilled until the current flight is finished. It never serves partial blocks. A normal install receiver never re-advertises its completed download. An SD archive node is the deliberate exception: after a fully proof-verified container is published to its persistent archive, it registers that complete file as a MotaSource and advertises it as a new seeder. This keeps each active transfer as one transmitter and one receiver while still allowing active temporary-radio repeaters between them and persistent archive nodes to improve future availability.

    TempRadio is treated as a private maintenance network. Active transfer packets use priority 0, bypass the public-flood receive holdoff, use the full transmit budget without overwriting the saved normal-radio airtime factor, retain the relay role's airtime-scaled transmit collision window, and do not schedule generic flood retries. Deployed firmware predating that TempRadio budget override can be accelerated manually with a saved get af / temporary set af 0 / restore sequence. The bounded serving queue admits at most two DATA/PROOF packets ahead of the radio while preserving at least four free packet-pool entries. CAD remains enabled to arbitrate the half-duplex channel, but its busy retry is scaled to one-quarter of a packet airtime and clamped to 5-50 ms instead of the ordinary 120-360 ms cadence. Discovery traffic keeps collision jitter and background priority. The fetch deadline uses the active radio's measured maximum-packet airtime, remaining DATA/PROOF packet count, dispatcher airtime factor, and longest observed path (falling back to the configured hop horizon before one is observed), with bounded guard time. Faster SF/BW settings therefore recover loss sooner; slower or multi-hop settings do not spuriously re-request a response still on air. These changes remove software waits and duplicate bursts; they do not remove the one required forwarding transmission per hop.

    "},{"location":"ota_protocol/#9-identity-trust-versioning","title":"9. Identity, trust & versioning","text":""},{"location":"ota_protocol/#10-multi-mota-serve-the-external-folder-relay","title":"10. Multi-mota serve & the external \"folder\" relay","text":"

    A node serves a set of mOTAs: its own firmware plus, optionally, an external folder of .mota files it relays without holding them in flash. To peers it simply \"has N mOTAs\"; the relay is trustless (fetchers verify everything). The serve side (OtaManager) keeps a lightweight registry of what it advertises and two resident \"views\": view0 (its own firmware) and one on-demand view loaded from a source when a request targets an external mota. Every fetch message carries manifest_id, so dispatch is a registry lookup.

    The USB/TCP host-folder link can also be a pull destination (the reverse direction): ota pull <mid8> folder fetches a .mota off the mesh and streams it onto the host as <mid>.mota via the seeder STORAGE ops (OP_STAT/BEGIN/WRITE/SREAD/FIN, see MotaSeederProto.h), using a FolderMotaStore as the fetch's OtaStore instead of RAM/flash. This captures an exact copy of a device's firmware - e.g. to build a delta against firmware you don't have. Resume is bookkeeping-free: BEGIN 0xFF-fills the file and, on reconnect after a link drop (the fetch PAUSES, holding progress on the host - no RAM/flash fallback), STAT+SREAD let the fetcher recompute and refill only the missing blocks. The phone-oriented BLE link is deliberately source-only and does not register a folder destination.

    "},{"location":"ota_protocol/#101-the-motasource-abstraction-otasourceh","title":"10.1 The MotaSource abstraction (OtaSource.h)","text":"

    Transport-agnostic provider of one or more complete .mota as random-access bytes. The same serve code drives USB-serial, BLE, a WiFi URL list, an NFS/samba mount, etc. - only read() differs.

    struct MotaDesc {                      // catalog metadata + region offsets (no whole image in RAM)\n  uint8_t mid[4]; uint32_t target_id, fw_version; uint8_t codec_id, flags;\n  uint8_t block_size_log2, source_caps;\n  uint32_t total_size, leaves_off, block_count, payload_off, payload_size;\n};\nclass MotaSource {\n  virtual uint8_t count();                                  // # mOTAs offered\n  virtual bool    describe(uint8_t idx, MotaDesc& out);     // metadata + offsets\n  virtual bool    read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len);   // random-access bytes\n  virtual bool    read_deflated_block(uint8_t idx, uint16_t block,\n                                      uint8_t* buf, uint16_t cap, uint16_t* len); // optional raw RFC 1951\n};\n

    To serve an external mota the node reads its manifest-minus-leaves + leaves[] into RAM (<=4 KiB for <=1024 blocks) and streams payload blocks from the source on demand; proofs are generated from the read leaves.

    "},{"location":"ota_protocol/#102-the-mota-seeder-transport-motaseederprotoh","title":"10.2 The mota-seeder transport (MotaSeederProto.h)","text":"

    A MotaSource is fed by a host that serves a folder over the device's USB serial (the same console the CLI uses - no extra hardware), on an ESP32 WiFi companion or FULL ESP32 role over WiFi (TCP), or on an nRF52 Full Companion over an encrypted BLE GATT service. The host is the standalone Rust tool motatool (motatool serve --serial <port> / --tcp <host[:port]>, which also builds + verifies + inspects .mota). The device only emits request frames while actively serving a fetch, and reads the reply synchronously, so over the shared USB console binary frames coexist with the text CLI/logs (resync on magic + checksum). Little-endian, XOR-checksummed:

    request  (device -> host):  'M' 'S'  op(1)  args...                 xsum(1 = XOR of op+args)\nresponse (host -> device):  'm' 's'  op(1)  status(1)  payload...    xsum(1 = XOR of all prior)\n\nOP_COUNT     0x01   args: -            -> payload: count(1)\nOP_DESCRIBE  0x02   args: idx(1)       -> payload: MotaDesc wire (38 B)\nOP_READ      0x03   args: idx(1) off(4) len(2)  -> payload: len bytes\nOP_DEFLATE_BLOCK 0x09 args: idx(1) block(2) off(2) len(2)\n                         -> payload: total_encoded_len(2) + requested bytes\nMotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1)\n                      total_size(4) leaves_off(4) block_count(4) payload_off(4) payload_size(4)\n                      block_size_log2(1) source_caps(1) reserved(2)\nstatus: 0 = OK, non-zero = error (out of range / past EOF).\n

    SerialMotaSource splits logical reads into replies of at most 192 payload bytes. A manifest's leaf table can exceed 256 bytes and new payload blocks are normally 2 KiB; requesting either in one transaction can overrun common USB CDC/UART receive rings even though the host successfully wrote the complete reply. Chunking is internal to the transport and does not change OP_READ or the MotaSource random-access contract.

    OP_DEFLATE_BLOCK lets a host-folder source perform the optional transport compression without linking an encoder into the embedded seeder. len=0, off=0 queries the exact encoded length; subsequent chunks are at most 190 bytes, keeping the total response payload at 192 bytes. The host independently encodes each manifest payload block as ordinary raw RFC 1951 at level 9. It returns an error for an invalid range, an unsupported operation, or a result that is not smaller than the raw block; SerialMotaSource then serves the ordinary raw v2 representation. A supporting host sets source_caps bit 0 in every descriptor. Older hosts leave that formerly-reserved byte zero, so upgraded firmware skips the optional request instead of waiting for an old daemon that silently ignores unknown operations.

    Manifest fragments are retained as bounded response jobs and admitted one at a time. Their source-side gap follows the active maximum packet airtime and dispatcher duty spacing, clamped to 100-1000 ms. The 100 ms floor protects fast radios' TX-to-RX turnaround; the cap keeps the receiver's one-second manifest progress/retry observation responsive. The source uses the same radio-aware 100-3000 ms drain/turnaround gap before an unsolicited block proof, but immediately serves a legacy receiver's explicit OTA_REQ_PROOF. Relay collision delay is a separate setting: active OTA floods honor the relay role's configured txdelay, and the deployment runner temporarily uses 0.3 on managed relays.

    What to plug into --serial. Use the USB serial console of an OTA-enabled MeshCore node built with OTA_FOLDER_SERIAL. The node must have a working LoRa radio plus an ota folder on command; that command confirms it can host and advertise the folder. A KISS modem will not work: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI and mota-seeder request/response transport. An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative source connection: use its dedicated seeder port with motatool serve --tcp <host>:5001. An nRF52 Full Companion can instead pair with a phone or Linux host, subscribe to its mOTA request characteristic, and use protocol-v14 CMD_BLE_MOTA_SOURCE. That BLE path is source-only; it does not expose the reverse FolderMotaStore capture operations.

    Device CLI: ota folder on (attach + announce), ota folder (list), ota folder off. Build flag OTA_FOLDER_SERIAL (default stream = console Serial; override OTA_FOLDER_SERIAL_STREAM + define OTA_FOLDER_SERIAL_BEGIN for a dedicated UART). ESP32 WiFi companions and FULL ESP32 roles run a WiFiServer on the dedicated seeder port (OTA_SEEDER_TCP_PORT, default 5001) while WiFi is usable. On a companion it is separate from the app port (TCP_PORT, default 5000); on infrastructure roles it is separate from WebConfig and browser OTA on port 80. The node auto-attaches the source when a seeder client connects and detaches when it closes (no ota folder on needed over TCP). An already-active serial folder causes a TCP client to be rejected instead of silently replacing it. Verified on hardware: a RAK4631 relays a host folder to a Heltec V3 over one USB cable, and a host feeds a Heltec V3 over WiFi (:5001) while the companion serves a phone on :5000 - every block merkle-checked.

    The attach reply and bare ota folder report host=advertised/offered. The registry is RAM-bounded (OTA_MAX_SERVE, with the node's own firmware consuming one slot), so a host may correctly index more valid files than this particular firmware can advertise. Omitted entries are now reported instead of silently disappearing. Operators should split a large chain or use a higher-capacity/SD seeder when the two counts differ.

    Transport-agnostic by design. The request/response semantics (COUNT / DESCRIBE(idx) / READ(idx, off, len) over a folder catalog) are independent of the link. The 2-byte magic + XOR checksum + resync framing above exists for the shared USB-UART (an unframed byte stream); it is harmless over a reliable stream and the WiFi (TCP) transport reuses it as-is - both ends just treat the socket as a byte stream (on-device, SerialMotaSource runs verbatim over an Arduino Stream-compatible WiFiClient; motatool's TcpTransport mirrors its SerialTransport). The nRF52 Full Companion's BLE GATT path also reuses the exact frame and checksum. Device requests are notifications on a dedicated characteristic and host responses are ordered write-with-response fragments on a second characteristic. Keeping the same framing makes retries and corruption handling identical across USB, TCP, and GATT. The Linux reference implementation is tools/ble_mota/ble_mota_seeder.py; a phone app can implement the same transport-free catalog operations.

    "},{"location":"ota_protocol/#11-cli-surface-otaclicpp","title":"11. CLI surface (OtaCli.cpp)","text":"

    User-facing OTA data should travel via CMD_OTA_* companion binary frames; the text CLI below is debug/operator oriented and replies are snprintf-bounded into a 160-byte buffer.

    Commands take intuitive aliases (matched by the first word; see is_cmd in OtaCli.cpp) so they're easy to type and read - status/neighbors/pull/drop/applydelta are the canonical names, the aliases are the recommended user-facing forms. Output is plain-language (a user-facing guide lives at ota_user_guide.md).

    ota help | ? | h                   list the commands\nota status | st  (or bare `ota`)   plain-language: running fw, the one fetch session (state/%/id), serving, keys\nota ls | neighbors | nbrs | updates | n [page]   paged updates (queries sources; rows arrive async via OTA_HAVE)\nota get | pull | download <mid8|#index> flash [rescue] | folder [validate]\n                                      fetch by stable mid8 (preferred) or current page index\nota install | apply | applydelta   verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader\nota rescue install <base_hash16>  internal-flash nRF52 only: recover from failed app-side EndF validation\nota bootloader [status]           capable allowlisted nRF52 repeater: installed BL identity/caps + staged confirmation\nota bootloader install <MID8> <HASH16>\n                                      explicitly verify/arm one complete trusted v3 package; never automatic\nota cancel | drop | stop           drop the fetch; durably invalidate device staging, or retain a folder partial for resume\nota announce | adv                 serve self + send a beacon now\nota self | id                      print this firmware's EndF (body/image size, base_hash)\nota qspi | storage                 QSPI nRF52 only: JEDEC/SR1/stage/latched storage error (read-only)\nota folder | fold [on|off]         attach/detach an external .mota folder (host daemon) ; bare = list\nota config | cfg | set [autofetch|autoinstall|checkpoint] ...   show/set persisted policy\nota key | keys [add|rm <hex>]      trusted signer allowlist ; bare = list\nota dev ...                        bring-up helpers (stage/recv/serve/resume <MID8>/verify)\n

    For a device-backed pull, current firmware returns success only after the persistent store can no longer be reopened; flash/SD/QSPI I/O or readback failure is reported as an error even though the in-memory manager session was dropped. For a folder pull, cancellation detaches the live transfer but deliberately leaves the host .part file available for a later resume. If the shared receive engine is currently performing the MeshTower SD auto-archive capture, cancellation likewise detaches that archive transfer and retains its .part file; it does not erase the unrelated manual-install store.

    "},{"location":"ota_protocol/#12-apply-bootloader-contract","title":"12. Apply & bootloader contract","text":"

    A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned v2 application packages remain installable when local policy permits them. A v3 bootloader package is always signed and trusted. The one-shot approval marker records local consent before the bootloader may apply it.

    Bootloader testing note: always test apply with a real different image (base != target). A same-image (X->X) \"delta\" trivially reproduces the target and gives a false positive.

    "},{"location":"ota_protocol/#13-versioning-of-this-spec","title":"13. Versioning of this spec","text":"

    The fixed byte layout has two intentionally disjoint profiles: format_ver = 2 for ordinary application packages and format_ver = 3 only for the exact privileged bootloader profile in Section 4.3. A parser must reject v2+BOOTLOADER, v3 without exact FULL|SIGNED|BOOTLOADER, and every other version. The multihash hash_algo separately allows swapping the digest family without a format bump. Unknown codec_id / ota_msg_type values are ignored (a node simply will not fetch what it cannot apply).

    "},{"location":"ota_user_guide/","title":"Updating your node over the air (OTA) - user guide","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    This guide is for node operators: how to update your MeshCore device's firmware over the radio, in plain language. No cables, no programmer - your node can download a new firmware from a neighbour and install it. (For the technical wire format, see the OTA protocol spec.)

    LoRa OTA download and installation are present only in supported Keymind destination artifacts; the receiver must already be running one of those install-capable builds. Some internal-staging nRF52 targets use a lean lora_ota_no_external_sensors target, while matched external-QSPI boards can retain their normal full-sensor repeater features. Release filenames include an OTA marker, but capability must still be confirmed on the running device. A source can be an OTA-enabled infrastructure node or a source-only Full Companion backed by motatool. Intermediate repeaters do not need OTA-enabled firmware: current repeater builds transport OTA floods opaquely, subject to their normal forwarding filters, duplicate checks, and flood limits. OTA radio traffic is accepted, generated, and relayed only while tempradio is actually running on that node. Every source, receiver, and intermediate repeater must therefore have an overlapping temporary-radio window.

    The recommended temporary OTA settings use 250 kHz bandwidth, SF5, CR5, and a 120-minute window. For a North American node currently configured for 910.525 MHz, run this on every participating node:

    tempradio 910.525,250,5,5,120\n

    Use the node's current permitted regional frequency in place of 910.525 when necessary.

    The scripted updater checks the firmware version of the receiver and every sender before deciding whether RX power saving can remain active. At SF5/BW250, an all-v1.17.1.5-or-newer path can use effective RXPS level 8 with the tuple-selected 64-symbol physical preamble. SF5/BW500 can use level 8 with 128 symbols. Other fast tuples remain at the shortest viable preamble, normally 32. A mixed, older, or unknown long-preamble path is put in continuous receive for the update, then the receiver's exact saved RXPS setting is restored. This preserves the RX benefit without risking missed packets from a legacy 32-symbol sender.

    Can my node install the update? Choose a release-table artifact explicitly labelled LoRa-OTA capable, then confirm ota self and ota status expose install support; do not infer support from the filename alone. LoRa OTA firmware is available for supported ESP32 boards and nRF52 repeater targets. Every nRF52 installation also requires the OTAFIX bootloader built for that exact board; having an OTA-capable application image alone is not enough. An intermediate repeater only relays packets and needs neither an install-capable image nor OTAFIX. Check the bootloader release for an exact board match before attempting an update.

    The following nRF52 repeater families gained firmware-side LoRa OTA targets in this release. Their ordinary repeater keeps its complete declared sensor recipe; the install-capable lora_ota_no_external_sensors sibling is smaller:

    The RAK3401 RAK_3401_repeater_lora_ota_no_external_sensors image trims selected optional environmental/ranging drivers but does not disable I2C. It retains the INA219, INA226, INA260, and INA3221 voltage/current monitors, board display and RTC support, and both RAK12500 I2C and RAK12501/L76K UART GPS paths. The four INA drivers are only the retained voltage/current entries in the optional environmental-sensor table; they are not the only I2C users. The SSD1306 OLED, autodiscovered DS3231/RV3028/PCF8563/RX8130CE RTCs, and RAK12500 GPS use I2C as separate board peripherals. Install one GPS module in sensor slot A. Slot D's reset/PPS lines conflict with the RAK13302 radio's BUSY/DIO1 lines.

    The firmware-configured INA3221 address and the RAK12500 address are both 0x42, so those devices cannot share one bus at those addresses. To install both, leave the RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use a firmware build with -DTELEM_INA3221_ADDRESS=0x43.

    The reduced RAK4631 profiles likewise retain the four INA monitors. The plain repeater and Serial2 bridge retain GPS: a RAK12501 can use sensor slot A or D, and a RAK12500 can use slot A or C. The runtime RS-232 bridge defaults to Serial2 so the UART RAK12501 can retain Serial1. The merged runtime image always reserves Serial1: a bounded silent probe cannot prove that a cold RAK12501 is absent, and turning the GPS setting off cannot stop a fitted module from driving the shared UART while WB_IO2/3V3_S remains powered. Use Serial2. Serial1 requires an explicit no-GPS/dedicated image. The reservation remains fail-closed even when an I2C RAK12500 is detected. The explicitly compiled RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors target omits the combined GPS provider because its bridge owns the UART used by RAK12501; consequently that legacy image does not expose the I2C RAK12500 path either, even though RAK12500 itself does not use the UART.

    Selected nRF52 repeaters with dedicated external QSPI can now stage the complete package off-chip, so their normal full-sensor repeater build can install a full image or an in-place delta. The current matched families are XIAO nRF52840 and its XIAO-module derivatives, original LilyGo T-Echo, ThinkNode M1/M6, Wio Tracker L1, SenseCAP Solar, and the dedicated RAK4631 + RAK15001 slot-C target. The list also includes a separately wired W25Q16JV on RAK19007 with either RAK4631 or RAK3401 + RAK13302. These require the corresponding exact QSPI-aware OTAFIX bootloader; see the nRF52 QSPI guide.

    The W25Q16 option uses one permanent RAK19007 wiring for either core: CLK/MISO/ MOSI tap IO-connector pins 26/27/28, flash CS uses J11 AIN1, and power uses J12 VDD (3.3 V) and GND. Add an approximately 10 kOhm CS-to-VDD pull-up and never power the flash from VBAT. The three SPI signals require underside pads or an IO-connector interposer; the easy J10/J11/J12 headers do not expose them. The flash consumes no WisBlock slot, so a supported GPS can remain in slot A.

    The ordinary full-sensor RAK_4631_repeater image remains too large for the safe internal in-place update limit. Without external flash, use RAK_4631_repeater_lora_ota_no_external_sensors; it trims selected optional environmental/ranging drivers while retaining generic I2C, battery monitoring, the four INA monitors, and target-compatible GPS. A RAK4631 fitted with RAK15001 in sensor slot C can instead use RAK_4631_repeater_rak15001_slot_c_lora_ota to retain the full sensor/GPS set and stage full images or deltas off-chip.

    For the external Winbond breakout, use RAK_4631_repeater_w25q16_lora_ota on RAK4631 or RAK_3401_repeater_rak13302_w25q16_lora_ota on RAK3401 + RAK13302. They retain the full sensor/GPS recipe and require exactly a 2 MiB EF4015 W25Q16 plus the bootloader for that same core/radio/flash combination. Install wiscore_rak4631_w25q16 for RAK4631 or wiscore_rak3401_rak13302_w25q16 for RAK3401 + RAK13302 as a one-time prerequisite. The physical wiring is shared, but the two firmware identities are deliberately different and cannot be substituted for one another.

    "},{"location":"ota_user_guide/#the-important-part-first-its-safe","title":"The important part first: it's safe","text":""},{"location":"ota_user_guide/#how-to-talk-to-your-node","title":"How to talk to your node","text":"

    Connect to your node's console - usually a USB serial terminal at 115200 baud (or whatever tool you already use to manage the node). You type ota ... commands and the node replies in plain words.

    The commands have short, friendly names (and most accept aliases, so you don't have to remember exact spelling): type ota help any time to see the list, or just ota for a status summary.

    "},{"location":"ota_user_guide/#common-tasks","title":"Common tasks","text":""},{"location":"ota_user_guide/#1-see-what-im-running-and-whether-anything-is-going-on","title":"1. See what I'm running and whether anything is going on","text":"
    ota status\n

    Shows your current firmware version, your node's update \"target\" (its hardware/role id), and whether a download is in progress.

    For a denser admin view - your firmware's content id (mid) and its body hash, the fingerprint of the set you're serving, live download progress, and the current policy - use:

    ota stats\n

    On a remote node this is admin-only (the remote command console requires the admin password) - send it from the app's repeater command screen, or the WiFi/serial OTA console.

    "},{"location":"ota_user_guide/#2-find-updates-available-near-me","title":"2. Find updates available near me","text":"
    ota ls\nota ls 2                 # page 2 when more than two updates are available\n

    Your node asks around and lists the firmware updates other nodes nearby are offering, in plain words - each with a temporary number, a stable eight-hex manifest ID, its version, whether it's a full image or a small delta, how many nodes have it, and how recently it was seen. For example:

    Updates 1/1 (2 src; refreshing):\n 1) 838B8169 v1.2.3 delta [same target] 3n 5s\n 2) BF0AB0C4 v1.2.0 full [unsupported] 1n 12s\n

    Each row shows the version, full-vs-delta, whether it fits your node, how many nodes have it, and how long ago it was seen. The fit marker:

    Run it again after a few seconds - discovery happens in the background, so the list fills in. Nothing is downloaded yet; this is just looking around. refreshing means the command has just sent asynchronous catalog queries, so run it again even when an older row is already visible. Two updates fit in each remote CLI reply; use ota ls 2, ota ls 3, and so on for later pages. Catalog rows can change while replies arrive, so use the displayed manifest ID for scripts and important operations rather than a numeric position. (ota neighbors / ota updates also work.)

    "},{"location":"ota_user_guide/#3-download-an-update","title":"3. Download an update","text":"

    Pick one from the list by its stable manifest ID (a number also works for interactive use), and say where to put it:

    ota pull 838B8169 flash            # stage it in this node's flash, to install here\nota pull 838B8169 folder           # capture it onto a connected motatool folder as <id>.mota\nota pull 838B8169 folder validate  # warm-start capture from a motatool --seed build (much faster; below)\n

    The destination is required - ota pull 838B8169 on its own just shows the choices. flash is always available (stage here, then ota install). folder appears only while a motatool serve link is attached (it shows the link, e.g. folder: tcp 192.168.4.5); it streams the firmware straight onto the host folder - nothing is staged on this node. That's how you grab an exact copy of another device's firmware off the mesh (to a .mota file) so you can later build a delta against firmware you don't otherwise have. (ota get is an alias.)

    validate (warm-start, advanced). Capturing a full image over the radio is slow. If you have a similar build on the computer (e.g. a fresh recompile of the same firmware), run motatool with --seed <that.mota> and add validate: the node fetches just the target's block fingerprints, keeps every block your seed already matches, and pulls over the radio only the handful that actually differ - turning a ~30-minute capture into seconds. The result is still a byte-exact, verified copy of the target.

    Where the seed comes from: it is the --seed <file> you pass to motatool serve - not a file you drop into the capture (--dir) folder, which is only the destination and starts empty. There is exactly one configured seed. When you run ... folder validate, the node asks motatool to begin the capture and motatool stamps that seed's payload into the fresh .part in the same step - so it is always the file you named, with no guessing. validate is the switch: a plain folder pull ignores any seed and fetches from scratch; re-running a validate pull re-begins fresh (it never resumes a stale partial). Nothing about the seed is trusted - every kept block is checked against the target's own fingerprints, so a mismatched or missing seed just means those blocks are fetched over the radio (correct result, only slower).

    The node fetches from one source as primary traffic, with bounded adaptive request flights. Every session probes with one manifest block, then clean flights grow 1 -> 2 -> 3 -> 4 blocks on RAK3401 OTA builds. New application packages use 2 KiB blocks; deployed 1 KiB packages remain supported. All blocks in a flight share one request packet, and the receiver stays silent until the source/relays finish returning them. A recovery halves the next flight. Retry timing follows the active SF/BW airtime, duty budget, and path length, so faster settings recover sooner without a fixed one-second request colliding with a valid multi-hop response. Mesh repeaters carry the packets only while their temporary-radio windows are active. Check progress with ota status.

    If a folder pull loses its link mid-transfer, ota status shows paused - the host keeps the partial and the pull resumes (filling only what's missing) the moment you reconnect motatool; it never falls back to flash. To stop a download you no longer want:

    ota cancel\n

    For a download staged on the node, success means current firmware also invalidated the persistent flash/SD/QSPI copy; it reports an error if that media operation cannot be verified. For a folder capture, cancel only detaches the live session and retains the host partial so it can be resumed or removed on the host deliberately. On a MeshTower SD auto-archive capture, cancel also detaches the live archive session and retains its card partial for the archive service to resume later; use ota cache off to stop new archive captures.

    "},{"location":"ota_user_guide/#4-install-a-downloaded-update","title":"4. Install a downloaded update","text":"

    Once ota status shows the download is ready to install:

    ota install\n

    The node verifies the firmware one last time, and if everything checks out it installs it and reboots into the new version. If the check fails, it tells you why and does not install. Unsigned images normally install only through this explicit command. The MeshTower V2 SD target is stricter and requires an allowlisted signature even for a manual application install, because it authorizes removable-media bytes for the bootloader. A signed image whose signer is not in the device allowlist is rejected; trusted signed images can auto-install only when that policy is enabled and the signed version is strictly newer than the running hash-valid EndF version. Manual ota install remains the deliberate equal-version/rollback override.

    After it reboots, run ota status to confirm the new version.

    "},{"location":"ota_user_guide/#updating-an-nrf52-bootloader-advanced-explicit-only","title":"Updating an nRF52 bootloader (advanced, explicit only)","text":"

    This is available on specially marked no-external-flash nRF52840 lean repeater/bridge builds, on the legacy XIAO nRF52840/Sense raw-QSPI builds, and on the exact Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors microSD build, after a one-time exact-board ABI-3 OTAFIX installation over USB/BLE DFU or SWD. It is not the normal firmware update path. Check support first:

    ota bootloader\n

    The reply must show a CRC-valid installed identity plus ABI 3 and the exact storage/boot-update capability bits for that build (0x09 for MeshTower V2 microSD, 0x0A for the shared internal store, or 0x0E for XIAO raw QSPI). A bootloader package appears as bootloader in ota ls. It is never downloaded or installed automatically, even if both OTA automation settings are enabled. Use its stable ID explicitly:

    ota pull <MID8> flash\n# wait until ota status says this bootloader download is ready\nota bootloader\nota bootloader install <MID8> <HASH16>\n

    Copy both confirmation values exactly from the second ota bootloader reply. Ordinary ota install deliberately refuses this package. The privileged command requires an exact 40 KiB candidate payload in the fixed 41,330-byte container, a valid exact embedded identity/CRC and vector table, continued boot-update support, required BLM2/SOFT continuity metadata at canonical raw-image offset 0x9FB4, a boot version that exactly matches the package and is newer than installed BLM2, and a valid signature from a key already in ota key's trusted allowlist. It preserves the running application while OTAFIX replaces itself; blup:C8 in post-reboot ota status means success. Remote rollback is refused. Any node lacking this command or those capabilities must update its bootloader locally instead.

    On an internal-flash target, the package shares the ordinary store below 0xED000 and bottom-aligns at 0xE2000; there is no separate reserved scratch bank. A valid live EndF must prove the current image ends at or below that address before any page is erased. If EndF is missing/corrupt or the app is too large, the pull is refused and local DFU/SWD is required. On the MeshTower V2 SD target, the application linker remains at 0xED000, but bootloader replacement needs temporary scratch beginning at 0xE0000. A hash-valid live EndF must therefore prove the complete current image ends by 0xE0000. A CRC-bound boot-settings bank must cover that complete image while also ending by 0xE0000. MeshCore binds both application and bootloader SD approval to purpose, exact raw geometry, and a normalized SHA-256 in a reset-retained MOTASDA2 record. Boot updates also bind the exact authenticated signed image hash in the E0000 token. OTAFIX consumes the retained record before media access, so a later card change or power cycle fails instead of authorizing different bytes. MeshCore never claims or writes raw sector 1. Both application and bootloader OTA wait until a matching BLM2 bootloader has been provisioned locally; preview.12 requires USB/BLE DFU or SWD first. Larger applications can continue to use normal application mOTA; only bootloader self-update is refused. See the nRF52 bootloader-update guide for the complete target inventory, storage layouts, and safety contract.

    "},{"location":"ota_user_guide/#5-if-something-goes-wrong","title":"5. If something goes wrong","text":"

    text ota pull <mid8> flash rescue # wait for ota status to say ready to install ota rescue install <base_hash16>

    This is not a force option. It refuses a normally valid EndF, a different package hash, hardware or target mismatch, corrupt payload, and invalid/untrusted signatures. The bootloader independently hashes the running app and rejects a wrong base before writing the app. If the physical EndF is absent or the rescue commands were not already in the running firmware, recover over USB. Release chains should put this command in their first bridge and keep it in every bridge after that. The shared-internal bootloader-update builds are intentionally excluded: without valid live EndF, they refuse every internal pull before erase because their normal application can extend through 0xED000. Recover one of those builds over USB/BLE DFU or SWD. - If an install fails, the node won't boot a broken image - it lands in recovery mode: - nRF52: it appears as a USB drive; drag a known-good firmware .uf2 for that exact board onto it to recover. - ESP32: it keeps the previous firmware in the other slot and rolls back. - When in doubt, you can always re-flash over USB the normal way.

    "},{"location":"ota_user_guide/#optional-let-it-update-automatically","title":"Optional: let it update automatically","text":"

    By default your node only discovers updates - it won't download or install on its own. If you want more automation (e.g. for a remote node you can't easily reach), you can opt in. These settings are saved.

    ota config autofetch any        # auto-DOWNLOAD any compatible update for this node (still won't install)\nota config autofetch signed     # auto-download only signed updates\nota config autofetch off        # back to manual (default)\n\nota config autoinstall trusted  # auto-INSTALL only a trusted signed version newer than the running EndF\nota config autoinstall off      # never auto-install (default)\n\nota config advert 1440          # re-advertise every N minutes while temp radio is running\nota config advert 0             # only advertise when a temp-radio window starts\n\nota config hops 3               # how far OTA travels: accept from / relay up to N repeater hops (default 3)\nota config hops 0               # only exchange OTA with directly-connected nodes (never relay)\n\nota config                      # show the current settings\n

    These policies also govern automatic adoption of an interrupted staged download after reboot. off leaves it untouched, signed requires the stored manifest's signed flag, and automatic resume requires the stored target to match this node and its version to be newer than the running valid EndF. Reissuing an explicit ota pull <MID8> remains the deliberate override for an older or unsigned partial. For bring-up/debugging, ota dev resume <MID8> performs the same explicit MID-bound re-adoption without starting a new network fetch. After reboot it requires the MID; bare ota dev resume is accepted only while an active/requested session MID still exists, and malformed or missing identifiers are rejected.

    Recommended for most people: leave both off and update by hand. Use autoinstall trusted only once you've added the signer's key (next section) and you trust them to push updates unattended. Automatic admission and final apply both reject zero, equal, or older signed versions; a dishonest catalog version cannot bypass the manifest check. Use manual ota pull plus ota install for an intentional rollback.

    The MeshTower V2 SD OTA target has a separate, default-on archive policy. It saves all mOTAs it sees to the SD card so the repeater can seed them later; this does not install them and does not change the install-oriented autofetch default above. Use ota cache for status and ota cache off or ota config cache off to stop new archive captures. Already cached files remain available to peers. Manual ota pull commands take priority and an interrupted archive capture resumes later. See Preload many mOTAs from a computer for the required /mota/<manifest-id>.mota filenames and the complete TempRadio seeding workflow.

    "},{"location":"ota_user_guide/#optional-only-trust-updates-from-specific-people","title":"Optional: only trust updates from specific people","text":"

    If you'll use auto-install, tell your node which signing keys to trust. The firmware author shares their public key (a hex string); you add it:

    ota key add <public-key-hex>    # trust this signer\nota key list                    # show trusted signers\nota key rm <public-key-hex>     # stop trusting one\n

    Only strictly newer updates signed by a trusted key are eligible for auto-install. Manual ota install permits an unsigned package after all integrity, hardware, base, and bootloader checks pass, except on the MeshTower V2 removable-SD path where all application installs must be signed and allowlisted. A signed package whose signer is not in the device allowlist is rejected rather than silently treated as unsigned.

    "},{"location":"ota_user_guide/#sharing-updates-with-others-advanced","title":"Sharing updates with others (advanced)","text":""},{"location":"ota_user_guide/#relay-a-folder-of-firmware-from-a-computer","title":"Relay a folder of firmware from a computer","text":"

    If your node is connected to a computer (e.g. a gateway on a Raspberry Pi), it can hand out a whole folder of firmware files to the mesh - without storing them itself. Useful for seeding a new release to a remote area.

    1. Put the firmware files (.mota files - see below) in a folder on the computer.
    2. Install the helper tool once - the standalone motatool CLI (https://github.com/vk496/motatool) - then point it at your node and the folder - over the node's USB serial, over WiFi if it is an ESP32 WiFi companion or FULL ESP32 node, or over encrypted Bluetooth if it is an nRF52 Full Companion: git clone https://github.com/vk496/motatool && cargo install --path ./motatool # over USB serial: motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v # ...or over WiFi: the seeder is on dedicated TCP port 5001: motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v # ...or over paired BLE to an nRF52 Full Companion (protocol v14): python3 tools/ble_mota/ble_mota_seeder.py \\ --device MeshCore-MyCompanion --dir ./my_firmware/ It answers the node's requests; your node then advertises those updates to neighbours, who can ota get them like any other. (A WiFi node prints its IP + seeder port to the serial log on connect. Details: https://github.com/vk496/motatool.)

    Check the device's attach reply or run ota folder: host=X/Y means the firmware is advertising X of the Y valid entries reported by the host. Serve registries are deliberately RAM-bounded on smaller builds, and the node's own firmware also consumes a slot. If X < Y, split the chain across seeders/folders or use a higher-capacity seeder; motatool saying that every file is valid does not mean every file fit on-device.

    To stop, just stop the daemon - over WiFi the node auto-detaches when the connection closes; over USB you can also run ota folder off on the node. ota folder on its own lists what your node is offering. On a FULL repeater or room server, run start webconfig first if WiFi is not already active. Other FULL roles with browser OTA support can use the MeshCore-OTA access point from start ota and connect to 192.168.4.1:5001. Every LoRa participant still needs an overlapping tempradio window.

    "},{"location":"ota_user_guide/#everyone-helps-share","title":"Everyone helps share","text":"

    You don't have to be a gateway to help. Once any node finishes downloading an update, it automatically offers it to its neighbours too. So a new firmware spreads outward node-to-node, instead of everyone hammering the one node that had it first. Discovery remains background traffic; an actual transfer is primary traffic for the duration of its TempRadio maintenance window.

    "},{"location":"ota_user_guide/#where-firmware-files-come-from","title":"Where firmware files come from","text":"

    OTA distributes .mota files - a packaged, verifiable firmware image (full image or a small \"delta\" that only contains what changed). You get them by:

    "},{"location":"ota_user_guide/#quick-reference","title":"Quick reference","text":"I want to... Command List all commands ota help See my firmware + any download ota status (or just ota) Admin: ids/hashes + serving + policy ota stats (admin-only remotely) Find updates nearby ota ls Download a listed update for installation ota get <mid8> flash Cancel a download ota cancel Install a finished download ota install Recover app-side no EndF on a legacy app-only internal nRF52 ota rescue install <base_hash16> Turn on auto-download ota config autofetch any Turn on auto-install (trusted only) ota config autoinstall trusted Trust a signer ota key add <hex> Relay a folder (gateway) ota folder on + the seeder daemon List what I'm offering ota folder

    (Older names still work too: neighbors/updates = ls, pull = get, applydelta/apply = install, drop/stop = cancel.)

    "},{"location":"ota_user_guide/#a-few-terms","title":"A few terms","text":"

    For the full technical details (the file format and the radio protocol), see the OTA protocol spec.

    "},{"location":"packet_format/","title":"Packet Format","text":"

    This document describes the MeshCore packet format.

    "},{"location":"packet_format/#version-1-packet-format","title":"Version 1 Packet Format","text":"

    This is the current version-1 MeshCore wire packet structure. Older firmware may support only a subset of the path encodings and payload types described here.

    [header][transport_codes(optional)][path_length][path][payload]\n
    "},{"location":"packet_format/#packet-format_1","title":"Packet Format","text":"Field Size (bytes) Description header 1 Contains routing type, payload type, and payload version transport_codes 4 (optional) 2x 16-bit transport codes (if ROUTE_TYPE_TRANSPORT_*) path_length 1 Encodes path hash size in bits 6-7 and hop count in bits 0-5 path up to 64 (MAX_PATH_SIZE) Stores hop_count * hash_size bytes of path data if applicable payload up to 184 (MAX_PACKET_PAYLOAD) Data for the provided Payload Type

    NOTE: see the Payloads documentation for more information about the content of specific payload types.

    "},{"location":"packet_format/#header-format","title":"Header Format","text":"

    Bit 0 means the lowest bit (1s place)

    Bits Mask Field Description 0-1 0x03 Route Type Flood, Direct, etc 2-5 0x3C Payload Type Request, Response, ACK, etc 6-7 0xC0 Payload Version Versioning of the payload format"},{"location":"packet_format/#route-types","title":"Route Types","text":"Value Name Description 0x00 ROUTE_TYPE_TRANSPORT_FLOOD Flood Routing + Transport Codes 0x01 ROUTE_TYPE_FLOOD Flood Routing 0x02 ROUTE_TYPE_DIRECT Direct Routing 0x03 ROUTE_TYPE_TRANSPORT_DIRECT Direct Routing + Transport Codes"},{"location":"packet_format/#path-length-encoding","title":"Path Length Encoding","text":"

    path_length is not a raw byte count. It packs both hash size and hop count:

    Bits Field Meaning 0-5 Hop Count Number of path hashes (0-63) 6-7 Hash Size Code Stored as hash_size - 1

    Hash size codes:

    Bits 6-7 Hash Size Notes 0b00 1 byte Legacy / default mode 0b01 2 bytes Supported in current firmware 0b10 3 bytes Supported in current firmware 0b11 4 bytes Reserved / invalid

    Examples:

    "},{"location":"packet_format/#payload-types","title":"Payload Types","text":"Value Name Description 0x00 PAYLOAD_TYPE_REQ Request (destination/source hashes + MAC) 0x01 PAYLOAD_TYPE_RESPONSE Response to REQ or ANON_REQ 0x02 PAYLOAD_TYPE_TXT_MSG Plain text message 0x03 PAYLOAD_TYPE_ACK Acknowledgment 0x04 PAYLOAD_TYPE_ADVERT Node advertisement 0x05 PAYLOAD_TYPE_GRP_TXT Group text message (unverified) 0x06 PAYLOAD_TYPE_GRP_DATA Group datagram (unverified) 0x07 PAYLOAD_TYPE_ANON_REQ Anonymous request 0x08 PAYLOAD_TYPE_PATH Returned path 0x09 PAYLOAD_TYPE_TRACE Trace a path, collecting SNR for each hop 0x0A PAYLOAD_TYPE_MULTIPART Packet is part of a sequence of packets 0x0B PAYLOAD_TYPE_CONTROL Control packet data (unencrypted) 0x0C PAYLOAD_TYPE_OTA OTA-over-LoRa firmware distribution 0x0D reserved reserved 0x0E reserved reserved 0x0F PAYLOAD_TYPE_RAW_CUSTOM Custom packet (raw bytes, custom encryption)"},{"location":"packet_format/#payload-versions","title":"Payload Versions","text":"Value Version Description 0x00 1 1-byte src/dest hashes, 2-byte MAC 0x01 2 Future version (e.g., 2-byte hashes, 4-byte MAC) 0x02 3 Future version 0x03 4 Future version"},{"location":"payloads/","title":"Payload Format","text":"

    Inside each MeshCore Packet is a payload, identified by the payload type in the packet header. The types of payloads are:

    This document describes the shared payload envelopes implemented by the core. Application-specific request, response, control, and custom bodies can add their own formats.

    NOTE: all 16 and 32-bit integer fields are Little Endian.

    "},{"location":"payloads/#important-concepts","title":"Important concepts:","text":""},{"location":"payloads/#node-advertisement","title":"Node advertisement","text":"

    This kind of payload notifies receivers that a node exists, and gives information about the node

    Field Size (bytes) Description public key 32 Ed25519 public key of the node timestamp 4 unix timestamp of advertisement signature 64 Ed25519 signature of public key, timestamp, and app data appdata rest of payload optional, see below

    Appdata

    Field Size (bytes) Description flags 1 specifies which of the fields are present, see below latitude 4 (optional) decimal latitude multiplied by 1000000, integer longitude 4 (optional) decimal longitude multiplied by 1000000, integer feature 1 2 (optional) reserved for future use feature 2 2 (optional) reserved for future use name rest of appdata name of the node

    Appdata Flags

    Value Name Description 0x01 is chat node advert is for a chat node 0x02 is repeater advert is for a repeater 0x03 is room server advert is for a room server 0x04 is sensor advert is for a sensor server 0x10 has location appdata contains lat/long information 0x20 has feature 1 Reserved for future use. 0x40 has feature 2 Reserved for future use. 0x80 has name appdata contains a node name"},{"location":"payloads/#acknowledgement","title":"Acknowledgement","text":"

    An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the \"extra\" payload (see Returned Path) instead of as a separate acknowledgement packet. Current CLI_DATA commands do not cause acknowledgement responses, neither discrete nor extra; their text reply is the application-level result. Repeaters still ACK the legacy plain-text form before processing it.

    Repeater remote CLI keeps one volatile copy of the most recently completed reply, keyed by the authenticated sender, request timestamp, and command text. Repeating that same logical request re-sends the text reply without executing the command again. A retry must therefore preserve the original timestamp and command text. The cache is cleared by reboot and replaced by the next completed remote command; commands that intentionally produce no text reply remain silent.

    Field Size (bytes) Description checksum 4 CRC checksum of message timestamp, text, and sender pubkey"},{"location":"payloads/#returned-path-request-response-and-plain-text-message","title":"Returned path, request, response, and plain text message","text":"

    Returned path, request, response, and plain text messages are all formatted in the same way. See the subsection for more details about the ciphertext's associated plaintext representation.

    Field Size (bytes) Description destination hash 1 first byte of destination node public key source hash 1 first byte of source node public key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see subsections below for details"},{"location":"payloads/#returned-path","title":"Returned path","text":"

    Returned path messages provide a description of the route a packet took from the original author. Receivers will send returned path messages to the author of the original message.

    Field Size (bytes) Description path descriptor 1 low 6 bits are the hash count; high 2 bits encode hash size minus one path count \u00d7 size encoded node-hash prefixes, each 1-3 bytes; the four-byte code is reserved extra type 1 low nibble is the bundled payload type, such as acknowledgment or response; high nibble is reserved extra rest of data extra, bundled payload content, follows same format as main content defined by this document"},{"location":"payloads/#request","title":"Request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) request data rest of payload application-defined request payload body

    For the common chat/server helpers in BaseChatMesh, the current request type values are:

    Value Name Description 0x01 get stats get stats of repeater or room server 0x02 keepalive keep-alive request used for maintained connections"},{"location":"payloads/#get-stats","title":"Get stats","text":"

    Gets information about the node, possibly including the following:

    "},{"location":"payloads/#get-telemetry-data","title":"Get telemetry data","text":"

    Not defined in BaseChatMesh. Sensor- and application-specific request payloads may be implemented by higher-level firmware.

    "},{"location":"payloads/#get-telemetry","title":"Get Telemetry","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#get-minmaxave-sensor-nodes","title":"Get Min/Max/Ave (Sensor nodes)","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#get-access-list","title":"Get Access List","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#get-neighbors","title":"Get Neighbors","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#get-owner-info","title":"Get Owner Info","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#response","title":"Response","text":"Field Size (bytes) Description content rest of payload application-defined response body

    Response contents are opaque application data. There is no single generic response envelope beyond the encrypted payload wrapper shown above.

    "},{"location":"payloads/#plain-text-message","title":"Plain text message","text":"Field Size (bytes) Description timestamp 4 send time (unix timestamp) txt_type + attempt 1 upper six bits are txt_type (see below), lower two bits are attempt number (0..3) message rest of payload the message content, see next table

    txt_type

    Value Description Message content 0x00 plain text message the plain text of the message 0x01 CLI data CLI command OR reply text 0x02 signed plain text message first four bytes is sender pubkey prefix, followed by plain text message 0x03 CLI command (since v1.18+) CLI command text (explicit)

    For a room post, companion firmware uses its own monotonic clock for the on-air timestamp and preserves that timestamp across application retries. Room servers track post timestamps separately from login, request, and CLI traffic. They also remember recent accepted posts by sender, timestamp, and text: an exact retry is ACKed again without storing a duplicate, while stale or same-timestamp mismatches are rejected.

    "},{"location":"payloads/#anonymous-request","title":"Anonymous request","text":"Field Size (bytes) Description destination hash 1 first byte of destination node public key public key 32 sender's Ed25519 public key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see below for details"},{"location":"payloads/#room-server-login","title":"Room server login","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) sync timestamp 4 sender's \"sync messages SINCE x\" timestamp password rest of message password for room"},{"location":"payloads/#repeatersensor-login","title":"Repeater/Sensor login","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) password rest of message password for repeater/sensor"},{"location":"payloads/#repeater-regions-request","title":"Repeater - Regions request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x01 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#repeater-owner-info-request","title":"Repeater - Owner info request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x02 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#repeater-clock-and-status-request","title":"Repeater - Clock and status request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x03 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#group-text-message","title":"Group text message","text":"Field Size (bytes) Description channel hash 1 first byte of SHA256 of channel's shared key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see below for details

    The plaintext contained in the ciphertext matches the format described in plain text message. Specifically, it consists of a four byte timestamp, a flags byte, and the message. The flags byte will generally be 0x00 because it is a \"plain text message\". The message will be of the form <sender name>: <message body> (eg., user123: I'm on my way).

    The sender name is unverified message text. Group messages contain no sender signature, so any channel-key holder can choose any sender name.

    "},{"location":"payloads/#group-datagram","title":"Group datagram","text":"Field Size (bytes) Description channel hash 1 first byte of SHA256 of channel's shared key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted data, see below for details

    The data contained in the ciphertext uses the format below:

    Field Size (bytes) Description data type 2 Identifier for type of data. (See number_allocations.md) data len 1 byte length of data data rest of payload (depends on data type)"},{"location":"payloads/#control-data","title":"Control data","text":"Field Size (bytes) Description flags 1 upper 4 bits is sub_type data rest of payload typically unencrypted data"},{"location":"payloads/#discover_req-sub_type","title":"DISCOVER_REQ (sub_type)","text":"Field Size (bytes) Description flags 1 0x8 (upper 4 bits), prefix_only (lowest bit) type_filter 1 bit for each ADV_TYPE_* tag 4 randomly generate by sender since 4 (optional) epoch timestamp (0 by default)"},{"location":"payloads/#discover_resp-sub_type","title":"DISCOVER_RESP (sub_type)","text":"Field Size (bytes) Description flags 1 0x9 (upper 4 bits), node_type (lower 4) snr 1 signed, SNR*4 tag 4 reflected back from DISCOVER_REQ pubkey 8 or 32 node's ID (or prefix)"},{"location":"payloads/#trace","title":"Trace","text":"

    Trace packets use direct routing. Their normal direct-path header is empty; instead, the intended route follows a fixed nine-byte trace header in the payload. Each forwarding node appends its received SNR multiplied by four to the packet's route-accumulator field.

    Field Size (bytes) Description tag 4 Sender-selected trace identifier. auth code 4 Application-defined authentication/correlation value. flags 1 Low two bits encode the route hash size. Current senders use the legacy 1 << value interpretation (1, 2, 4, or 8 bytes); receivers also accept the packed 1-4-byte interpretation where it is unambiguous. route rest Concatenated node-hash prefixes for the requested direct route.

    At the destination, the application receives the tag, auth code, flags, accumulated SNR bytes, and original route bytes. Trace therefore differs from a normal direct packet whose path is carried entirely in the packet route field.

    "},{"location":"payloads/#multipart","title":"Multipart","text":"

    The first payload byte identifies the inner payload and how many packets remain:

    Field Size (bytes) Description remaining + type 1 Upper nibble is the remaining-packet count; lower nibble is the inner payload type. inner data rest Data for the inner payload type.

    The core currently creates and consumes multipart acknowledgments. For that form, the low nibble is 0x03 and the next four bytes are the acknowledgment CRC. Other multipart inner types are reserved for application or future use.

    "},{"location":"payloads/#ota","title":"OTA","text":"

    An OTA payload contains one OTA protocol message and is normally sent by flood. Its message types, integrity fields, and transfer state are defined in the OTA-over-LoRa protocol. Builds without OTA support can still relay an opaque OTA packet when their routing policy permits it.

    "},{"location":"payloads/#custom-packet","title":"Custom packet","text":"

    Custom packets have no defined format.

    "},{"location":"qr_codes/","title":"QR Codes","text":"

    This document provides an overview of QR Code formats that can be used for sharing MeshCore channels and contacts. The formats described below are supported by the MeshCore mobile app.

    "},{"location":"qr_codes/#add-channel","title":"Add Channel","text":"

    Example URL:

    meshcore://channel/add?name=Public&secret=8b3387e9c5cdea6ac9e5edbaa115cd72\n

    Parameters:

    "},{"location":"qr_codes/#add-contact","title":"Add Contact","text":"

    Example URL:

    meshcore://contact/add?name=Example+Contact&public_key=9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1&type=1\n

    Parameters:

    "},{"location":"radio_receive_calibration/","title":"Radio receive calibration and recovery","text":"

    Noise calibration collects 64 idle RSSI samples at least 50 ms apart. A quiet block takes about 3.2 seconds. Both continuous RX and temporary RXPS calibration windows allow up to 30 seconds; a partial block times out without publishing. Periodic requests coalesce while a block is active. RXPS resumes after a complete block, including a held block, or at timeout. Receive and transmit activity are never interrupted just to finish calibration.

    The block estimate is the 12.5th percentile: the average of the eighth and ninth sorted samples, retaining fractional dBm. A published update uses 25% of the previous floor and 75% of the new estimate, with a -120 dBm lower bound. Initial calibration and explicit gain/tuning changes seed a new baseline. The old published value remains available while collecting. An AGC reset drops partial samples while retaining a valid baseline for channel checks.

    An estimate more than 15 dB above the established floor is held twice. The third consecutive complete high block is accepted, allowing recovery after a permanent ambient rise. A normal or quieter complete block clears the hold. Partial blocks do not advance it. There is no admission threshold based on the old floor, so an old low baseline cannot reject every sample from a higher floor.

    Hardware captures with sustained different-SF/BW traffic showed the median and lower quartile following the interferer. The 12.5th percentile retained the quiet background in those captures. It needs at least nine background samples out of 64 to avoid higher-valued traffic entirely. Nearly continuous interference or enough low outliers can still mislead it. The rise hold delays large changes but cannot identify their source.

    RSSI sampling waits 20 ms after every receive re-arm, including CAD and TX completion, to avoid unsettled frontend readings. The ten-second trial window often failed to complete during heavy traffic with that settling guard; the 30-second cap allows more idle opportunities. Partial blocks still time out without publishing. Longer collection spends more time in continuous RX when RXPS is enabled. A quiet minimum is about 3.2 seconds; real completion time also depends on packet activity and main-loop scheduling.

    On radios with a mode probe, each eligible RSSI sample first checks that the chip has not silently left RX. Confirmed standby/non-RX discards the partial block without changing the published floor. Rejected attempts are also spaced 50 ms apart. BUSY and packet ownership block sampling; families without a mode probe retain their existing RSSI path.

    CAD has a bounded wait, stops RX duty cycling before scanning, and re-arms the configured receive mode on success, busy detection, or error. A completed or currently receiving packet owns the radio and blocks CAD. A packet interrupt arriving during re-arm is retained. Failed re-arm and a newly arrived packet both defer transmission.

    SX126x wrappers additionally run a recovery watchdog at most once per 10 seconds while continuous RX is expected. RXPS sleep, busy SPI, pending packets, and invalid status reads do not count as non-RX evidence. Two confirmed non-RX observations request soft recovery; a third uses the existing hard recovery path where supported. An observed healthy RX state clears the sequence. Recovery preserves tuning, TX power, receive gain, and the operator's power-saving intent. The existing radio liveness and RXPS watchdogs remain active for other failures.

    The status read uses the two-byte GetStatus transaction. The pinned RadioLib getStatus() helper requests zero copied data bytes, so its return value is not usable for this mode check. The dedicated read restores the normal SPI command layout afterward and does not wake a duty-cycling radio to inspect its mode.

    Validation lives in test_noise_floor_estimator, test_rx_power_saving, test_radio_receive_contract.py, and test_sx126x_receive_mode.py.

    "},{"location":"rak3401_mota_chain/","title":"RAK3401 1W repeater compact LoRa update chain","text":"

    Status: published prerelease for controlled, recoverable lab use. On 29-Aug-2026 steps 1-9 completed on the target RAK3401 and are byte-for-byte identical to the corresponding transitions in the physically passed fd98bc90 chain. The replacement step 10 installs the current 3f6eddd5 endpoint. That replacement package and the hardened current runner have passed offline reconstruction plus the exact deployed bootloader simulator, but have not had a new physical end-to-end run. Use the lab gate and keep local DFU/SWD recovery available. Multi-hop and alternate-bandwidth estimates remain planning data, not physical qualification.

    The prerelease is rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.1.6-3f6eddd5. Use this asset:

    RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip\n

    --verify-only remains the safe default. Live preflight and installation of this prerelease require --accept-test-candidate. That switch bypasses only the prerelease-status gate; it does not bypass the archive, identity, bootloader, route, watchdog, reachability, or post-boot checks.

    "},{"location":"rak3401_mota_chain/#exact-destination","title":"Exact destination","text":"

    This chain is intentionally specific to one firmware lineage and hardware target:

    The exact starting application is the lora-ota-v1.16.07-halo-keymind-cascade-dev-3a6af3bf RAK3401 asset. Its outer ZIP SHA-256 is 58ead2ea18efafda186bc9d983fe08cdf65f69b0e5a3a1c72c18fe35948a254c; its 566,912-byte firmware.bin SHA-256 is 4301fc63ebd661c70f9bc40e7eca44d3cb8a358cb3c0075e700d9676a57478cb. The endpoint build ZIP SHA-256 is a9fca9be145a96feb650b57c43b5ed173d74d2f3194da12e31b1c54352635b56.

    "},{"location":"rak3401_mota_chain/#retained-and-omitted-hardware-support","title":"Retained and omitted hardware support","text":"

    Despite the legacy no_external_sensors target name, the endpoint retains the RAK12501 GPS provider and the common INA3221, INA219, INA226, and INA260 I2C voltage/current monitors. Install RAK12501 in sensor slot A. Do not use slot D with the RAK13302 1 W radio because the GPS reset/PPS signals overlap the radio's BUSY/DIO1 wiring.

    The reduced profile omits optional external environmental and ranging devices: AHTX0, BME280, BMP280, SHTC3, SHT4x, LPS22HB, MLX90614, VL53L0X, BME680, BMP085, RAK12035, and BME680 BSEC. Board radio, display, buttons, battery telemetry, GPS, and the INA monitor family remain. No additional \u201cremove I2C temperature\u201d image is needed: the optional I2C temperature/environment drivers are already in the omitted set, while removing the remaining I2C/INA support would discard desired voltage/current monitoring and create another firmware identity without helping this route.

    Do not use the chain on another target ID, hardware family, starting image, or firmware body hash. The runner checks all four.

    "},{"location":"rak3401_mota_chain/#why-the-deployed-otafix24-and-old-staging-ceiling-work","title":"Why the deployed OTAFIX2.4 and old staging ceiling work","text":"

    The deployed bootloader is not changed and no package uses the newer expanded 0xED000 ceiling. Each package remains bottom-aligned below the old bootloader's 0xD4000 scan ceiling. Its detools workspace is page-aligned and must satisfy:

    0x26000 + inplace_memory <= staged_package_start < 0xD4000\n

    The old bootloader already reads and validates that encoded detools geometry before its first destructive write. The previous application receiver was the limiting part: it reserved a fixed 0x98000 workspace before accepting a download. Step 1 therefore remains byte-for-byte identical to the physically passed 89,844-byte package. Step 2 is a 46,364-byte fixed-workspace package that installs the compatibility receiver. Later receivers stage above the real EndF image extent and let the bootloader enforce each package's selected workspace.

    Every package in this candidate is an ordinary application container using format ABI 2 and in-place codec 2. The historical bridge applications scan the bootloader capability marker byte-by-byte, so they recognize the released 0.9.2-OTAFIX2.4 marker at absolute address 0xFCD16. That immutable marker advertises ABI 2, codec mask 0x0004, and internal storage profile 0. The 3f6eddd5 endpoint retains the matching legacy application-update fallback. Its separately compiled internal bootloader-update feature remains unavailable because privileged self-update requires a current ABI 3 marker with the exact storage profile. Installing this chain therefore does not replace the bootloader, and the final application can still accept a future valid ABI-2/codec-2 application update.

    The exact installed bootloader is the 0.9.2-OTAFIX2.4 RAK4631 asset. Its outer ZIP SHA-256 is 5e29e7a8982cc2ed5f8868435556eb09387250fe5b9a7e8443bac325247dfed1; its 191,040-byte sd_bl.bin SHA-256 is a97f834388db0f1da6c29f6d8f458ff6d3e6a26034709044566384838919bb23. The exact simulator was compiled from release commit d73de8372e89b8ef352747c8bc7a1aaeab80fbfe; its executable SHA-256 is 9dacf24b1023fe2f4c620419417649ccc9538dd4a611b47bc19b7fedb97ceba6.

    The schema-2 exhaustive search checked 1,799 candidate geometries and found 12 feasible forward edges in the declared exact 12-node inventory. It found one shortest ten-package route and selected the smallest total container bytes as the secondary objective. This minimum applies only to that declared inventory and geometry table, not every conceivable new bridge image. The selected route is:

    Step From To Workspace Package Margin 1 1.16.7.0 1.16.7.9 0x98000 89,844 0 2 1.16.7.9 1.16.7.10 0x98000 46,364 40,960 3 1.16.7.10 1.16.8.7 0x91000 106,029 12,288 4 1.16.8.7 1.16.9.105 0x89000 149,927 0 5 1.16.9.105 1.16.9.110 0x8E000 111,312 16,384 6 1.16.9.110 1.16.9.113 0x8F000 120,624 4,096 7 1.16.9.113 1.16.9.117 0x8B000 139,637 0 8 1.16.9.117 1.16.10.0 0x7E000 174,202 20,480 9 1.16.10.0 1.17.1.3 0x6B000 271,569 0 10 1.17.1.3 1.17.1.6 0x76000 197,825 28,672

    Total mOTA transfer data is 1,407,333 bytes. ROUTE.json, CHAIN.csv, and validation-results.json in the bundle pin the exact geometry and image hash for every transition.

    Steps 1-9 are byte-for-byte identical to the transitions that passed on the physical RAK3401. The replacement step 10, from the physically reached FE65A6135A1E7B3F v1.17.1.3 body to 9BD7CF682EE065AE, has offline qualification only. All ten packages passed independent container checks, zero-filled and erased-workspace reconstruction, and the exact deployed 0.9.2-OTAFIX2.4 simulator. Those results prove the package bytes and apply geometry; they are not a physical claim for the replacement step 10 or the hardened runner.

    The 171-byte transport DEFLATE support lands only in the final 3f6eddd5 application. The starting receiver and every receiver that accepts steps 1-10 lack that inflate path, so all ten legacy-chain transfers remain raw and the 1,407,333-byte total above is not automatically reduced over LoRa. A later update can negotiate transport compression after this endpoint is running; the application inflates before staging, and the immutable bootloader still applies an ordinary ABI-2/codec-2 container.

    "},{"location":"rak3401_mota_chain/#host-requirements","title":"Host requirements","text":"

    Install Python 3.9 or newer, meshcli 1.6.0 or newer, the current motatool, and an OTA-enabled Full Companion or repeater that can seed mOTA files.

    "},{"location":"rak3401_mota_chain/#verify-offline","title":"Verify offline","text":"

    No password or device is needed:

    python3 tools/lora_ota/rak3401_mota_chain.py \\\n  --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip \\\n  --work-dir ./rak3401-mota-chain-work \\\n  --motatool /path/to/motatool \\\n  --verify-only\n

    When given the ZIP, the runner checks its pinned outer hash and binds the extraction cache to that exact archive. An explicitly supplied extracted root starts at the pinned inner checksum list. In either case it checks complete inner coverage, all ten manifests, continuity, image anchors, both recovery images, and every container with motatool. Passing this command means offline and simulator qualification only; it does not unlock live use.

    "},{"location":"rak3401_mota_chain/#guarded-prerelease-lab-command","title":"Guarded prerelease lab command","text":"

    These direct-link arguments match the physical transition setup, but the current package replaces step 10 and the current host runner contains later cleanup and recovery fixes. Neither has been cleanly rerun through the whole chain. Keep local USB/SWD recovery available. The candidate switch bypasses only the prerelease-status block; all identity, checksum, bootloader, route, watchdog, reachability, and post-boot gates still run.

    Restore the test start locally with recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.uf2, then run:

    export MESHCORE_ADMIN_PASSWORD='password'\n\npython3 tools/lora_ota/rak3401_mota_chain.py \\\n  --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip \\\n  --work-dir ./rak3401-mota-chain-work \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp 192.168.1.51:5001 \\\n  --source-cli-tcp 192.168.1.51:5002 \\\n  --source-shares-controller \\\n  --target-key 63d8df63 \\\n  --temp-radio 909.950,500,5,5,120 \\\n  --ota-hops 0 \\\n  --legacy-full-airtime \\\n  --motatool /path/to/motatool \\\n  --accept-test-candidate \\\n  --yes\n

    Keep the work directory. Rerunning the same command resumes only when the live EndF body hash matches an exact chain node. Never manually skip a package. --legacy-full-airtime temporarily sets the destination airtime factor to zero and restores it at the endpoint. Use that option only where the selected frequency and local duty-cycle rules permit a full transmit budget; omit it otherwise.

    "},{"location":"rak3401_mota_chain/#superseded-b40d2e6c-prerelease","title":"Superseded b40d2e6c prerelease","text":"

    The immediately preceding prerelease remains pinned for offline provenance:

    Its first nine packages are byte-for-byte identical to the current release and the physical fd98bc90 evidence. Its different step 10 had offline qualification only. The runner recognizes this exact archive separately and keeps live use gated; it is not interchangeable with the current 3f6eddd5 asset.

    "},{"location":"rak3401_mota_chain/#legacy-fd98bc90-physical-and-swd-qualification","title":"Legacy fd98bc90 physical and SWD qualification","text":"

    The earlier local candidate remains the exact physical evidence source for this route:

    RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip\n

    All ten exact fd98bc90 transitions completed on the RAK3401 with its deployed 0.9.2-OTAFIX2.4 bootloader and a Heltec V4 source at 909.950 MHz / 500 kHz / SF5 / CR5. Every intermediate EndF hash matched. Independent SWD readback then matched the endpoint application, original bootloader, SoftDevice/MBR, and UICR byte-for-byte. Steps 1-9 are also the exact first nine packages in the b40d2e6c and 3f6eddd5 prereleases. All three candidates have different step 10 packages or endpoints, so the physical result and step-10 timing must not be attributed to either later prerelease.

    "},{"location":"rak3401_mota_chain/#measured-direct-link-timing-for-fd98bc90","title":"Measured direct-link timing for fd98bc90","text":"

    The first ready to install transfer ages were:

    Step Blocks Transfer 1 88 598 s 2 45 42 s 3 103 81 s 4 146 105 s 5 109 76 s 6 118 94 s 7 136 102 s 8 170 127 s 9 264 187 s 10 169 193 s

    The physically observed bulk-transfer total for those exact legacy packages is 1,605 seconds (26m45s). The log came from the pre-cleanup-fix runner revision. Step 1 was measured before the host began forcing source RXPS off and is therefore a conservative outlier. A new complete run with that fix is projected at roughly 18\u201319 minutes of bulk transfer, but that projection is not a substitute for the measured total. Allow about 70\u201375 minutes direct at BW500 for validation, ten transfers, installs/reboots, retained-store checks, and final restoration.

    The legacy step 10's longer per-block time was not a V4 TempRadio delay: the source RXPS readback was off. Its v1.17.1.3 requester uses non-overlapping adaptive flights and a five-second recovery floor and logged more block reloads than step 8. The V4 itself intentionally schedules its local radio handoff after 1.5 seconds so the normal-channel command reply can drain; the host waits three seconds and then checks the live TempRadio state.

    "},{"location":"rak3401_mota_chain/#live-progress-and-speed-sanity-checks","title":"Live progress and speed sanity checks","text":"

    The runner watches motatool serve -v locally, so its quarter-progress reports do not consume LoRa airtime. At BW500 direct, physically measured steps 2\u20139 averaged about 1.1\u20131.4 newly requested payload blocks per second; the legacy step 10 averaged about 0.88 blocks/s, and step 1's 0.15 blocks/s was the known pre-RXPS-fix outlier. Use those only as bench sanity ranges, not guarantees. Relays, narrower bandwidths, interference, and recovery reads lower the rate.

    Healthy output keeps advancing from [download] passive source progress x/y to source read all y payload blocks, followed by destination status ready to install y/y (100%) for the same manifest ID. Aggregate reads above the unique block count indicate recovery/retry traffic. A radio-scaled no-progress window causes one destination status check; the transfer timeout leaves the partial download staged for an exact-package resume. Stop and diagnose instead of installing if the manifest changes, progress remains flat through those checks, the final receiver count is not y/y, or the measured rate is far below the matching bandwidth/hop estimate without an understood RF cause.

    For planning only, scale the conservative measured fd98bc90 26m45s bulk baseline by the qualified adaptive-preamble airtime and by each additional relay transmission:

    Bandwidth, SF5/CR5 Direct / 0 relays 1 relay 2 relays 500 kHz 26m45s 53m30s 1h20m15s 250 kHz 46m51s 1h33m42s 2h20m33s 125 kHz 1h27m03s 2h54m07s 4h21m11s 62.5 kHz 2h54m07s 5h48m15s 8h42m23s

    These are transfer-only estimates, not measured alternate-bandwidth or relay results. Packet loss, relay contention, discovery, administrative commands, staged verification, and ten reboot cycles add time. The bandwidth factors are not simple powers of two because the adaptive wire preamble is 128 symbols at 500 kHz, 64 at 250 kHz, and 32 at 125/62.5 kHz.

    "},{"location":"rak3401_mota_chain/#manual-operation","title":"Manual operation","text":"

    The automated runner is preferred because it binds every transition to the expected MID and body hash. The manual details below document the checks used during qualification and recovery; they are not permission to skip the runner's candidate gate. Keep a written copy of each original setting and never skip a step even when a later package appears in ota ls.

    "},{"location":"rak3401_mota_chain/#1-verify-and-extract-the-asset","title":"1. Verify and extract the asset","text":"
    sha256sum RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip\nunzip RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip\ncd RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5\nsha256sum -c SHA256SUMS.txt\nfor package in motas/*.mota; do motatool verify \"$package\" || exit 1; done\n

    The outer hash must be 135783dd8777490db2422a7543f7d80e3cf03f621d5ac362da930abbc1850dc4, and the SHA-256 of the extracted SHA256SUMS.txt must be 93835ed497c579be0a2322292e7f15ab095e7c8ab7892833fe7df3130dbdaba4. Do not continue after any checksum or verification error.

    "},{"location":"rak3401_mota_chain/#2-record-and-prepare-the-destination","title":"2. Record and prepare the destination","text":"

    Before changing anything, save the complete replies from:

    ver\nota self\nget bootloader.ver\nget system.watchdog\nget radio.rxps\nget powersaving\nget rxdelay\nget af\nota config\n

    The starting ota self hash must be 71F4026CBE4B8B74, the target must be 2FA509C1, and the hardware must be RAK_3401. Disable the nRF52 system watchdog before the chain:

    set system.watchdog off\n

    The hardware watchdog cannot stop immediately. The RAK will reset once within about 60 seconds. Reconnect, wait at least 90 seconds from the command, and require get system.watchdog to report > off before continuing.

    For a direct bench link, apply these temporary transfer guardrails to the RAK after every bridge reboot:

    set powersaving off\nset radio.rxps off\nset rxdelay 0\nota config hops 0\ntempradio 909.950,500,5,5,120\n

    Where local duty-cycle rules permit it, set af 0 can also remove the legacy firmware's saved airtime wait during this bounded maintenance window. Record get af first and restore that exact value after the final step. Current firmware grants the bounded TempRadio transfer budget without overwriting the saved airtime factor.

    set powersaving off is an isolation guardrail, not the fix for the observed failure. The actual source failure was an oversized USB CDC reply; current firmware splits those reads. Some historical bridge builds may report RXPS as unsupported while already operating continuously. Verify the reported state and do not substitute a normal reboot for the one-time watchdog reset. Direct tests use hops 0: hops 1 makes the target echo each source response despite there being no intermediate relay, increasing half-duplex loss and legacy three-second retries.

    The exact candidate cannot use fast RXPS throughout because its historical bridge applications predate the v1.17.1.5 adaptive-preamble contract. Keep RXPS off for all ten steps whether using SF5/BW250 or the faster direct-bench SF5/BW500 tuple. After the final endpoint is proven, a later update whose target, controller, source, and relays are all v1.17.1.5 or newer can use the generic LoRa OTA runner and its qualified adaptive-preamble boundaries.

    Put the source on the identical TempRadio tuple. A current ASCII-first Full Companion recognizes motatool's initial ota folder on line directly:

    motatool serve \\\n  --dir ./motas \\\n  --serial /dev/ttyACM1 \\\n  --baud 115200 \\\n  -v\n

    The same command also works with older Full Companion firmware in binary mode, where the idle parser recognizes the identical preamble. Restart motatool for every step so the source emits a fresh catalog advert. Leave it running during the download and stop it with Ctrl-C only after the destination reports ready to install.

    "},{"location":"rak3401_mota_chain/#3-install-all-ten-packages-in-order","title":"3. Install all ten packages in order","text":"Step Manifest ID Before hash Expected version After hash 1 C147BCEF 71F4026CBE4B8B74 1.16.7.9 42BC53A64288E845 2 BEB578FC 42BC53A64288E845 1.16.7.10 1AD2AA8B6C478DA6 3 6D8EF814 1AD2AA8B6C478DA6 1.16.8.7 D709A25308BC1831 4 40AC4CEB D709A25308BC1831 1.16.9.105 457BEDA5E6406C81 5 F6C8890B 457BEDA5E6406C81 1.16.9.110 E76BFA205634CAB8 6 82405E89 E76BFA205634CAB8 1.16.9.113 65328FC8A1FBED2D 7 80F40DB6 65328FC8A1FBED2D 1.16.9.117 AF7532E13337FADD 8 DBF6310D AF7532E13337FADD 1.16.10.0 22002359BBDFA76E 9 C2979E08 22002359BBDFA76E 1.17.1.3 FE65A6135A1E7B3F 10 B5011420 FE65A6135A1E7B3F 1.17.1.5 1FA6AAA3C28D8BD8

    For each row, first prove that ota self exactly matches its Before hash, then inspect the manager before changing it:

    ota status\n

    Proceed to discovery only after that status is reconciled. When the manager is idle, run:

    ota ls\nota pull MANIFEST_ID flash\nota status\n

    If the first status reports this row's MID as partial, use ota pull with that same MID to resume it. If this row's MID is already ready, do not pull or cancel it; continue with the install checks below. If status reports the immediately previous row's MID as verifying staged blocks, wait through the bounded discovery window; require either that exact MID to become ready to install or the manager to become no download, and re-prove the current Before hash. Cancel only the same previous MID while it is visibly attached and ready. If it has become idle, send no cancel. Stop on any other MID, failed or incomplete state, ambiguous reply, or timeout. Never issue a blind ota cancel: it can discard a resumable or unrelated session.

    Repeat ota status at a restrained interval until it says ready to install. If the manifest is initially absent, wait for a fresh source advert, run ota ls again, or restart motatool; do not pull a different ID. Stop the seeder and install:

    get system.watchdog\nota install\n

    The watchdog reply must still be > off. Allow the current default five-minute readiness window for the USB/LoRa identity to return; automation probes every 10 seconds and returns early as soon as the exact body is visible. Then run ver, ota self, and ota help. Require the row's exact After hash, require bootloader: apply OK, and require rescue install <hash16> in the help before exposing that bridge to the next package. This rescue-help gate applies to steps 1-9; step 10 is the endpoint and is not exposed to another package in this chain. A normal-radio ota status response of no download proves only that the manager is idle; legacy internal-flash firmware does not durably erase a retained container by accepting an IDLE ota cancel. Do not claim otherwise. When the same expected manifest reports verifying staged blocks after the next TempRadio starts, wait only for the bounded discovery window and require that it become ready to install or no download; stop on a changed ID, failed/incomplete state, or timeout. Prove the exact running body before cancelling an attached completed session. OTAFIX consumes the approval word during a successful install, so a retained endpoint container is inert and a later valid pull replaces it. Reapply the transfer guardrails and TempRadio tuple, restart motatool, and continue with the next row.

    If the board boots but ota self says the EndF is invalid, stop. Only when ota status still identifies that row's complete staged package may the guarded ota rescue install BEFORE_HASH command be used. It is not a force option and must use that row's exact Before hash. If the board does not boot, recover the documented start/recovery UF2 locally over USB. Step 10 is the endpoint and has no successor package in this chain. If its EndF is invalid, use local DFU/SWD rather than attempting an unqualified LoRa package.

    "},{"location":"rak3401_mota_chain/#4-intermediate-relays-and-restoration","title":"4. Intermediate relays and restoration","text":"

    On every managed intermediate relay, save get rxdelay and get txdelay, then use the airtime-scaled collision window during the maintenance session:

    set rxdelay 0\nset txdelay 0.3\n

    Current OTA transfer packets honor that configured relay txdelay; setting 0.3 gives competing relays a small randomized, airtime-scaled forwarding window. The source's manifest-fragment gap independently follows active packet airtime and duty spacing, clamped to 100-1000 ms. These two delays solve different problems.

    After step 10 is proven, restore each saved destination and relay value exactly, including rxdelay, RXPS, CPU power saving, af, ota config hops, and relay timing. Current full-parser repeater firmware accepts normalradio and restores the saved tuple after replying on the temporary channel:

    normalradio\n

    The b40d2e6c endpoint supports that command. If a recovery build unexpectedly replies Unknown command, shorten its lease to one minute with the same tuple:

    tempradio 909.950,500,5,5,1\n

    Wait for the lease to expire, return the controller and ordinary source to their saved normal channels, and then re-enable the RAK system watchdog:

    set system.watchdog on\nget system.watchdog\n

    A Full Companion source is the exception: its local TCP console supports normalradio and the automated runner uses it before restoring the shared Binary API radio tuple.

    Final lab success requires version v1.17.1.5-halo-keymind-cascade-dev-b40d2e6c, body hash 1FA6AAA3C28D8BD8, target 2FA509C1, hardware RAK_3401, and get system.watchdog reporting > on. If a relay cannot be restored before its TempRadio lease ends, wait for it to return to the normal channel and restore its saved rxdelay and txdelay there.

    "},{"location":"rak3401_mota_chain/#two-relay-deployment","title":"Two-relay deployment","text":"

    The first nine direct package transitions above were physically observed as the byte-identical first nine transitions of the legacy chain. Replacement step 10, this hardened host-runner revision, and multi-hop operation are not cleanly end-to-end physically qualified. A controlled, recoverable multi-hop lab can exercise two intermediate relays by listing them farthest-to-nearest and using three OTA hops. Run a non-mutating preflight first; even preflight requires the prerelease candidate override because the command connects to live devices:

    python3 tools/lora_ota/rak3401_mota_chain.py \\\n  --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip \\\n  --work-dir ./rak3401-mota-chain-work \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp SOURCE_IP:5001 \\\n  --source-cli-tcp SOURCE_IP:5002 \\\n  --source-shares-controller \\\n  --target-key 63d8df63 \\\n  --relay 'Far Repeater' \\\n  --relay 'Near Repeater' \\\n  --temp-radio 909.950,250,5,5,120 \\\n  --ota-hops 3 \\\n  --motatool /path/to/motatool \\\n  --accept-test-candidate \\\n  --preflight-only\n

    After preflight succeeds, rerun with --yes in place of --preflight-only. Confirm that the selected frequency and bandwidth are legal at the deployment location.

    "},{"location":"rak3401_mota_chain/#rescue-and-completion","title":"Rescue and completion","text":"

    The guarded ota rescue install <base_body_hash> command is present after step 1 and remains available through the step-9 bridge. It is not a force command: it refuses a valid normal EndF, a base mismatch, wrong target/hardware, or invalid payload. A failure before step 1 completes still requires local USB recovery because the deployed start image predates the rescue command. The final b40d2e6c endpoint uses the stricter shared-slot profile; its invalid-EndF recovery is local DFU/SWD, not the rescue command.

    Before the first mutation, the runner saves the destination's RXPS periods, CPU power-saving state, RX flood delay, airtime factor, and OTA hop reach in the persistent work directory, flushing both the file and its directory entry before changing a guardrail. It also saves the managed source's exact RXPS preference in the chain-root source-rxps-settings.json before disabling it. Every step reuses that protected record, so a rerun after a killed host process restores the chain-start preference instead of treating the temporary off state as original. The record is accepted only for the same CLI endpoint. The runner retires that chain-root record only after it has proved the source is back on its normal radio and restored the exact preference at the verified endpoint. The runner reads the destination, controller, source, and relay versions before applying the same RXPS policy as the generic runner. The mixed historical chain therefore verifies radio.rxps off after every bridge reboot; a future all-v1.17.1.5-or-newer SF5/BW250 chain would instead keep RXPS on under the qualified level-8/preamble-64 boundary. It also verifies set powersaving off and rxdelay 0, plus af 0 when --legacy-full-airtime was explicitly selected. It restores every original value only after the exact endpoint is proven. An interrupted run deliberately leaves those transfer guardrails active; rerun the same command with the same work directory to resume and restore them. CPU power saving was not the cause of the observed folder-source failure: that was a USB CDC receive-ring overrun fixed by bounded serial reads. The runtime settings remain conservative isolation and fast-link reliability guardrails.

    For every named --relay, the per-step runner also captures rxdelay and txdelay, writes them to relay-timing-settings.json, applies rxdelay 0 and txdelay 0.3 during transfer, verifies the readback, and restores both before the relay leaves TempRadio.

    The runner also keeps the watchdog off across the chain, rechecks identity and OTA reach before every package, requires the exact post-boot EndF hash, and only re-enables the watchdog after step 10. Lab success requires endpoint body hash 1FA6AAA3C28D8BD8, target 2FA509C1, hardware RAK_3401, normal radio 910.525 MHz / 62.5 kHz / SF7 / CR5, and the watchdog verified on.

    "},{"location":"rak3401_mota_chain/#external-radio-startup-and-manual-usb-recovery","title":"External-radio startup and manual USB recovery","text":"

    RAK3401 is a distinct target from RAK4631. Its RAK13300/RAK13302 radio is on the WisBlock SPI bus (SPI1 in the Adafruit nRF52 core), with BUSY and DIO1 on P0.09/P0.10. Current builds explicitly enable those NFC-capable pads as GPIO and do not advertise the radio bus as an on-board QSPI flash device.

    The RAK13302's FEM and boost supply use switched 3V3_S, but the SX1262 core uses unswitched 3V3. An MCU reset, a 1200-baud DFU touch, or disconnecting only USB may therefore leave a battery-powered SX1262 in its previous state. At startup, current firmware quiesces the FEM, reinitializes the dedicated SPI instance, asserts NRESET, and, if BUSY remains high, sends the Semtech NSS/GET_STATUS wake sequence without waiting on RadioLib first. The one-time board startup cold-starts 3V3_S; later radio retries leave that shared rail enabled so they do not reset GPS or sensor modules.

    If BUSY still cannot be released, firmware must not enter a reset loop:

    A permanently high BUSY after NRESET and the direct wake transaction indicates an electrically unavailable radio, loose module, or power-domain fault; an MCU reboot cannot manufacture a response. If local access is possible, disconnect the battery as well as USB and reseat the WisBlock module. Remote firmware will continue retrying without requiring that physical intervention or churning the USB device.

    For an existing deployed tower using Preview 5, do not change the bootloader as part of this application chain. Its exact local recovery package is the Preview 5 RAK4631 ZIP. For a new RAK3401 installation or local recovery, use the OTAFIX 2.4.3 RAK3401 ZIP from the OTAFIX 2.4.3 release. These are local DFU/SWD recovery assets, not mOTA chain steps.

    For a local Serial DFU recovery with the repository helper:

    cd /path/to/meshfirmware\nMCFIRMWARE_NO_SUDO=1 ./mcfirmware.sh\n

    Select the serial entry whose USB identity is WisCore RAK3401 Board, then choose Custom, nrf52, and the exact RAK3401 .zip. Use flash-update to preserve InternalFS preferences and identity. Use flash-wipe + flash only when erasing application data is intentional. The helper follows the same USB device into bootloader mode, so do not substitute another /dev/ttyACM* merely because its number appears first.

    "},{"location":"role_feature_switches/","title":"Feature switches by role \u2014 1.17.1.5 USA Cascade","text":"

    Use these settings with the exact board's canonical release image. Former logging, power-saving, FEM-gain, and rotated-display variants are now runtime choices where the hardware supports them. A setting cannot add missing radio hardware, MQTT code, storage, or an OTA partition. Check the download's .capabilities.json and the firmware picker. For exact old device/variant names, search the 1.17.1.5 variant map: it maps all 1,361 previous release entries to 1,325 covered choices or 36 excluded entries. Many old choices share one current image; the command tables below explain how to select their former behavior. Excluded entries have no download.

    Search terms are alternative wording for finding these instructions, not CLI aliases. Use the commands in the role-specific tables and examples.

    "},{"location":"role_feature_switches/#open-the-usb-web-console","title":"Open the USB web console","text":"

    Search terms: USB terminal, serial console, ASCII terminal, browser terminal, web serial.

    Open the MeshCore USB web console in Chrome or Edge, connect a data-capable USB cable, close other applications using that port, and select the device. Use 115200 baud when prompted. Full Companion, Repeater, Room Server, and Sensor start with an ASCII USB terminal. Run board, then ver on any role.

    If a Companion app has already switched USB to its binary protocol, send:

    +++MESHCORE-TERM-START\n

    To hand the port back to a Companion app or USB MOTA host, send +++MESHCORE-TERM-STOP, close the console, and connect the app/tool. Dedicated USB/BLE/WiFi Companion images can start in binary mode; use the start token there too. KISS firmware uses its modem protocol and is outside this release.

    The linked console runs in your computer's browser over USB. It does not need node WiFi or set webui on. WebConfig is a separate settings website served by supported ESP32 images. Both infrastructure and WiFi Companion WebConfig include a browser command terminal (set wifi.cli on), enabled by default on the LAN. The Companion browser CLI uses the same terminal commands as USB, including contact import, chat, recipient selection, and streaming replies. Full ESP32 Companion also exposes this terminal at TCP port 5002. See the terminal command guide.

    "},{"location":"role_feature_switches/#which-old-variant-setting-should-i-use","title":"Which old variant setting should I use?","text":"

    Search terms: old firmware variants, restore features, turn features on or off, enable MQTT, disable MQTT.

    These controls apply to every board with the corresponding compiled feature; check the hardware requirements below.

    Former choice Current image / runtime control USB logging or Station G2/G3 logging variant Same role's canonical image; set usb.logging on / off USB logging, WiFi MQTT, or both MQTT-capable image with USB logging; set logging.output usb, wifi, both, or off Companion WiFi-MQTT variant set mqtt.enabled on / off; configure broker slots through the CLI or WebConfig _ps power-saving variant set powersaving on / off; check get powersaving on every role _femoff / FEM-gain variant set radio.fem.rxgain on / off; separate TX switch where controllable RX boost variant set radio.rxgain on / off on a supported radio iKOKA rotated-display variant Full Companion: set display.rotation 180; 0 restores the board default Separate USB, BLE, WiFi, Ethernet, or Terminal Chat Companion Exact board's Full Companion where listed; supported transports are included, with SenseCAP selection below RS232 repeater variant consolidated into the ordinary repeater set bridge.enabled on / off on a build containing the RS232 bridge ESP-NOW bridge or primary ESP-NOW radio Select the matching hardware/role image first; bridge and primary-radio channel commands differ LoRa OTA / external-storage variant Still choose the exact receiver/storage image and matching bootloader; this is not a software on/off switch

    Companion, Repeater, Room Server, and Sensor use the same command names for shared settings. Use get <setting> to check and set <setting> <value> to change them. A command requires its feature to be compiled into the build. set usb.logging on|off changes only USB logging. On MQTT-capable builds, set mqtt.enabled on|off changes only MQTT and keeps broker settings; set logging.output off|usb|wifi|both selects both outputs together.

    "},{"location":"role_feature_switches/#full-companion-commands","title":"Full Companion commands","text":"Setting Enable / select Disable / restore Read back Device power saving set powersaving on set powersaving off get powersaving LoRa RX power saving set radio.rxps on set radio.rxps off get radio.rxps Radio RX boost set radio.rxgain on set radio.rxgain off get radio.rxgain External FEM RX gain set radio.fem.rxgain on set radio.fem.rxgain off get radio.fem.rxgain External FEM TX gain set radio.fem.txgain on set radio.fem.txgain off get radio.fem.txgain ESP32 USB logging set powersaving off, then set usb.logging on set usb.logging off get powersaving, get usb.logging nRF52 second USB logging port set usb.logging on reboot set usb.logging off reboot get usb.logging MQTT master set mqtt.enabled on set mqtt.enabled off get mqtt.enabled, get mqtt.running, get mqtt.status ESP32 persistent WebConfig set webui on set webui off get webui ESP32 WebConfig browser console set wifi.cli on set wifi.cli off get wifi.cli ESP32 temporary setup AP start webconfig ap stop webconfig get webui Display rotation set display.rotation 90, 180, or 270 set display.rotation 0 get display.rotation Temporary MOTA radio window tempradio 910.525,250,5,5,120 normalradio tempradio

    Saved settings apply immediately unless noted. Rotation and gain controls report unsupported hardware instead of creating that feature. Fresh Full Companion preferences enable device power saving and leave USB logging off; existing saved preferences win after an update.

    ESP32: logging and binary Companion traffic share one USB port. Turn logging off before handing USB to an app/MOTA host. nRF52: the optional second CDC port is for logs; use the primary port for Companion/MOTA. Adding/removing the second port requires a reboot; the optional suffix shown above requests it.

    "},{"location":"role_feature_switches/#mqtt-controls-shared-by-companion-and-infrastructure","title":"MQTT controls shared by Companion and infrastructure","text":"

    Search terms: Companion MQTT settings, MQTT on, MQTT off, broker configuration.

    Configure a broker through the MQTT tab or with the same commands on either role. For example:

    set mqtt.iata SEA\nset mqtt1.preset custom\nset mqtt1.server broker.example.com\nset mqtt1.port 1883\nset mqtt.enabled on\nget mqtt.enabled\nget mqtt.running\nget mqtt.status\n

    set mqtt.enabled off disconnects brokers while preserving their settings. set mqtt.enabled on allows configured brokers to reconnect. An enabled switch is separate from a running connection: check get mqtt.running and get mqtt.status. The MQTT tab's Enable MQTT checkbox controls this same saved switch. Slot credentials and presets are available through get/set mqtt1.*, mqtt2.*, and the remaining supported slots; secrets stay masked in the browser. WiFi Companion accepts these commands through its USB terminal and browser CLI; Full Companion also accepts TCP port 5002.

    set mqtt1.preset none disables just that slot. Publication controls such as set mqtt.status off do not disconnect brokers. USB logging is independent. Images without MQTT code do not show MQTT cards or accept MQTT controls.

    "},{"location":"role_feature_switches/#companion-wifi-bluetooth-gps-and-board-exceptions","title":"Companion WiFi, Bluetooth, GPS, and board exceptions","text":"

    Configure ESP32 WiFi with:

    set wifi.ssid MyNetwork\nset wifi.pwd my-password\nget wifi.status\n

    set wifi.powersave min enables WiFi modem sleep; none disables it. max is accepted only where the BLE/WiFi coexistence policy allows it. Read back with get wifi.powersave. This is separate from device power saving and RXPS. The assigned WiFi button/display switch controls WiFi services on supported boards. stop webconfig closes only the portal; it is not a WiFi master switch. There is no universal Bluetooth-off or Ethernet-off text command.

    SenseCAP Indicator Full only: set companion.transport wifi followed by reboot selects WiFi; set companion.transport ble followed by reboot selects Bluetooth. Check get companion.transport. USB remains available. Primary ESP-NOW Indicator images keep their mesh radio in either selection.

    GPS-equipped builds: use get gps, set gps on, and set gps off on Companion and infrastructure. The Companion app's gps=1 / gps=0 custom setting controls the same GPS. See GPS tracking for location-sharing settings.

    "},{"location":"role_feature_switches/#repeater-room-server-and-sensor-commands","title":"Repeater, Room Server, and Sensor commands","text":"

    These roles use CommonCLI. Feature-dependent commands require the relevant hardware/build; MQTT is present in observer builds, and WebConfig is absent from some portable builds. The USB browser console still works without it.

    Setting Enable Disable Read back Live USB logging ESP32 1.17.1.5: set powersaving off, then set usb.logging on; other platforms: set usb.logging on set usb.logging off get powersaving, get usb.logging Capture RX log to node storage log start log stop log prints the capture locally RS232 / ESP-NOW bridge master set bridge.enabled on set bridge.enabled off get bridge.enabled, get bridge.running, get bridge.type MQTT periodic status publication set mqtt.status on set mqtt.status off get mqtt.status shows connection status MQTT packet publication set mqtt.packets on set mqtt.packets off get mqtt.packets SNMP on supported MQTT infrastructure set snmp on, then reboot set snmp off, then reboot get snmp MQTT raw packet publication set mqtt.raw on set mqtt.raw off get mqtt.raw MQTT receive capture set mqtt.rx on set mqtt.rx off get mqtt.rx MQTT transmit capture set mqtt.tx on (or advert) set mqtt.tx off get mqtt.tx MQTT master set mqtt.enabled on set mqtt.enabled off get mqtt.enabled, get mqtt.running, get mqtt.status ESP32 persistent WebConfig set webui on set webui off get webui WebConfig browser command terminal set wifi.cli on set wifi.cli off get wifi.cli LoRa RX power saving set radio.rxps on set radio.rxps off get radio.rxps Radio RX boost set radio.rxgain on set radio.rxgain off get radio.rxgain Controllable FEM RX / TX gain set radio.fem.rxgain on / set radio.fem.txgain on set radio.fem.rxgain off / set radio.fem.txgain off Corresponding get radio.fem.rxgain / get radio.fem.txgain GPS, when compiled set gps on set gps off get gps

    Use set usb.logging on|off on every role. Adding the optional reboot suffix requests a reboot only when changing the USB interfaces requires it, as on nRF52 Full Companion. log start/stop records to storage independently of live USB logging. Use log erase to delete that capture.

    For ESP32 1.17.1.5 USB logging, run these as separate commands in the role's text terminal (or remote admin CLI on infrastructure):

    set powersaving off\nset usb.logging on\nget powersaving\nget usb.logging\n

    Check for power saving off and USB logging on. This avoids the released ESP32 USB sleep bug, including the G3 report. Both settings are saved; turning logging off later does not automatically restore power saving. LoRa RXPS is independent. nRF52 logging does not need this ESP32 workaround.

    With the G3 sleep correction, enabled live USB logging keeps ESP32 USB serviced and blocks light sleep, including when a host closes the port or disconnects. CPU idle/yield remains available. set usb.logging off removes that blocker; an attached native USB host still prevents sleep. On Full infrastructure, set logging.output usb and both enable the same USB blocker; wifi and off remove it. File capture with log start is independent. The original 1.17.1.5 binaries require the set powersaving off workaround described in the release note.

    "},{"location":"role_feature_switches/#shared-mqtt-and-logging-output","title":"Shared MQTT and logging output","text":"

    Search terms: MQTT settings, MQTT on, MQTT off, logging output, USB and WiFi logging.

    On any Companion or infrastructure build with both MQTT and USB logging compiled:

    Command USB logs MQTT bridge set logging.output off Off Off set powersaving off, then set logging.output usb On Off set logging.output wifi Off On set powersaving off, then set logging.output both On On

    get logging.output reports the selection. Fresh unified Full infrastructure preferences select both; Full Companion starts with USB logging off. Saved settings override these defaults. To toggle only MQTT while keeping USB logging unchanged, use set mqtt.enabled off / on. Neither setting turns LoRa repeating off. Repeater forwarding uses set repeat off / on and get repeat separately.

    The set powersaving off step above is the 1.17.1.5 ESP32 USB workaround. WiFi/MQTT-only logging does not need it while the Repeater/Room Server MQTT bridge is running: that sleep guard already exists in the released firmware. Use get mqtt.running to check that MQTT is running; an enabled preference alone is not the running state. set logging.output wifi keeps USB logging off and uses that MQTT guard.

    For a custom broker on an MQTT-capable Companion, Repeater, or Room Server:

    set wifi.ssid MyNetwork\nset wifi.pwd my-password\nset mqtt.iata SEA\nset mqtt1.preset custom\nset mqtt1.server broker.example.com\nset mqtt1.port 1883\nset mqtt.enabled on\nget mqtt.status\n

    Set mqtt1.username / mqtt1.password if required by that broker. Configure only as many slots as the board supports. set mqtt1.preset none disables slot 1; other configured slots remain enabled, within the build's supported slot count. Disabled and partially configured slots remain saved across reboot. For a custom server URL, set mqtt1.port 0 clears the port override so the URI supplies it. set mqtt.status off disables status messages, not MQTT itself. See the MQTT reference for presets, TLS, credentials, and slot limits.

    "},{"location":"role_feature_switches/#infrastructure-power-saving-and-bridges","title":"Infrastructure power saving and bridges","text":"

    Use get powersaving, set powersaving on, and set powersaving off on every role. The preference is saved. Hardware and active USB/network services decide when sleeping is possible; the command names do not change between roles. Device power saving is separate from WiFi modem sleep and LoRa RXPS.

    On RS232-capable repeater images, stop the bridge before changing its serial port or baud rate, then restart it:

    set bridge.enabled off\nset bridge.baud 115200\nset bridge.enabled on\n

    RAK4631: set bridge.uart 2 selects UART2 while the bridge is stopped. Canonical GPS-enabled builds reserve UART1 for GPS; UART1 requires a dedicated GPS-free image. ESP-NOW bridge: use set bridge.channel <1..13> and set bridge.format wrapped / raw where supported. Primary ESP-NOW mesh: use set espnow.channel <1..13> and reboot; this is a different radio setting.

    For infrastructure WebConfig on the LAN, use start webconfig / stop webconfig. To force a setup AP on an observer, first run set mqtt.enabled off, then start webconfig ap. When finished, run stop webconfig and restore set mqtt.enabled on if you did not reboot.

    "},{"location":"role_feature_switches/#updating-and-sending-mota","title":"Updating and sending MOTA","text":"

    Search terms: mOTA, LoRa OTA, update over LoRa, wireless firmware transfer.

    All released Full Companions can serve MOTA to other nodes. Close the console and run motatool serve --serial /dev/ttyACM0 --dir ./motas -v on the USB host. See Full Companion instructions for WiFi/BLE source commands and the bounded temporary radio setup.

    Role / hardware Start self-update Stop / requirement ESP32 Repeater, Room Server, Sensor with WiFi updater start ota or start ota ap; open the returned URL (normally port 80, /update) stop ota; close WebConfig first if it shares port 80 ESP32 Full Companion with two application slots start ota or start ota ap; returned URL uses port 8080, /update stop ota; single-slot Full builds use USB nRF52 infrastructure with Bluetooth DFU start ota enters the Bluetooth update flow Matching application DFU ZIP and board bootloader required Qualified LoRa OTA receiver Follow LoRa OTA directions Exact destination package, storage profile, and overlapping temporary radio windows

    For nRF52 OTAFIX installations use the exact board/storage build from OTAFIX 2.4.6. Its retained-RAM handoff is required by the new internal-flash hybrid receiver images. Full Companions are sources and remain normally USB-updated.

    The complete infrastructure CLI, role/build matrix, and Full Companion feature guide provide details.

    "},{"location":"spiffs_regular_file_reads/","title":"SPIFFS regular-file reads and login replay state","text":"

    Arduino-ESP32 SPIFFS can return a truthy directory handle from a read-open of a nonexistent filename. File::operator bool() alone does not prove that a regular file exists. A directory's size() and read() are zero.

    The first privileged login on an upgraded G2 encountered this in ClientACL::writeClientLoginReplayCeiling(): opening the not-yet-created /s_login_replay appeared successful, then subtracting the eight-byte trailer from size zero underflowed. Copying the supposed records failed and login was rejected. Waiting or changing the repeater clock cannot fix that file-open bug.

    "},{"location":"spiffs_regular_file_reads/#fix","title":"Fix","text":"

    Use mesh::openFileRead() for regular-file reads. It checks existence and rejects directory handles, while preserving real empty files. Replay record counts also validate the minimum trailer size, record alignment, and maximum count before subtraction. An existing replay file that becomes unreadable is not treated as a new store.

    No replay records are cleared and authentication is not weakened. Invalid or unwritable replay state still fails closed. The first successful privileged login creates a 44-byte file: one 36-byte identity/ceiling record plus its eight-byte integrity trailer.

    Setting the repeater clock must not clear this file. Admission compares the sender's timestamp with that sender's saved boundary, not with the repeater's current time. Clearing the boundary could make captured requests reusable. A sender clock rollback is a separate condition and remains subject to the saved boundary after this fix.

    Companion directory enumeration uses the separate openDirectory() API, which deliberately permits SPIFFS virtual directories. Regular-file reads are also enforced for companion data, repeater/room logs, flood-rule verification, and HTTP packet-log downloads (missing logs return 404; real empty logs 200).

    "},{"location":"spiffs_regular_file_reads/#audit-boundary","title":"Audit boundary","text":"

    The audit covered file opens and size arithmetic in src and examples. Identity, region, clock, and common preference loaders already gate reads with filesystem existence checks; SPIFFS's exists() explicitly excludes directory handles. Relevant MQTT length subtraction follows validated headers and exact reads. ESP32 OTA staging uses partition APIs, not these SPIFFS file handles. Intentional directory enumeration must not be changed to a regular-file read.

    "},{"location":"spiffs_regular_file_reads/#regression-checks","title":"Regression checks","text":"

    The pre-fix actual ACL code was reproduced as first_login=rejected, one missing read-open, and no replay file. The fixed code accepted that identical scenario without a missing read-open and created the valid 44-byte record. Host simulations do not replace a post-flash LoRa login test on the G2.

    "},{"location":"stats_binary_frames/","title":"Stats Binary Frame Structures","text":"

    Binary frame structures for companion radio stats commands. All multi-byte integers use little-endian byte order.

    "},{"location":"stats_binary_frames/#command-codes","title":"Command Codes","text":"Command Code Description CMD_GET_STATS 56 Get statistics (2-byte command: code + sub-type)"},{"location":"stats_binary_frames/#stats-sub-types","title":"Stats Sub-Types","text":"

    The CMD_GET_STATS command uses a 2-byte frame structure: - Byte 0: CMD_GET_STATS (56) - Byte 1: Stats sub-type: - STATS_TYPE_CORE (0) - Get core device statistics - STATS_TYPE_RADIO (1) - Get radio statistics - STATS_TYPE_PACKETS (2) - Get packet statistics

    "},{"location":"stats_binary_frames/#response-codes","title":"Response Codes","text":"Response Code Description RESP_CODE_STATS 24 Statistics response (2-byte response: code + sub-type)"},{"location":"stats_binary_frames/#stats-response-sub-types","title":"Stats Response Sub-Types","text":"

    The RESP_CODE_STATS response uses a 2-byte header structure: - Byte 0: RESP_CODE_STATS (24) - Byte 1: Stats sub-type (matches command sub-type): - STATS_TYPE_CORE (0) - Core device statistics response - STATS_TYPE_RADIO (1) - Radio statistics response - STATS_TYPE_PACKETS (2) - Packet statistics response

    "},{"location":"stats_binary_frames/#resp_code_stats-stats_type_core-24-0","title":"RESP_CODE_STATS + STATS_TYPE_CORE (24, 0)","text":"

    Total Frame Size: 11 bytes

    Offset Size Type Field Name Description Range/Notes 0 1 uint8_t response_code Always 0x18 (24) - 1 1 uint8_t stats_type Always 0x00 (STATS_TYPE_CORE) - 2 2 uint16_t battery_mv Battery voltage in millivolts 0 - 65,535 4 4 uint32_t uptime_secs Device uptime in seconds 0 - 4,294,967,295 8 2 uint16_t errors Error flags bitmask - 10 1 uint8_t queue_len Outbound packet queue length 0 - 255"},{"location":"stats_binary_frames/#example-structure-cc","title":"Example Structure (C/C++)","text":"
    struct StatsCore {\n    uint8_t  response_code;  // 0x18\n    uint8_t  stats_type;     // 0x00 (STATS_TYPE_CORE)\n    uint16_t battery_mv;\n    uint32_t uptime_secs;\n    uint16_t errors;\n    uint8_t  queue_len;\n} __attribute__((packed));\n
    "},{"location":"stats_binary_frames/#resp_code_stats-stats_type_radio-24-1","title":"RESP_CODE_STATS + STATS_TYPE_RADIO (24, 1)","text":"

    Total Frame Size: 14 bytes

    Offset Size Type Field Name Description Range/Notes 0 1 uint8_t response_code Always 0x18 (24) - 1 1 uint8_t stats_type Always 0x01 (STATS_TYPE_RADIO) - 2 2 int16_t noise_floor Radio noise floor in dBm -140 to +10 4 1 int8_t last_rssi Last received signal strength in dBm -128 to +127 5 1 int8_t last_snr SNR scaled by 4 Divide by 4.0 for dB 6 4 uint32_t tx_air_secs Cumulative transmit airtime in seconds 0 - 4,294,967,295 10 4 uint32_t rx_air_secs Cumulative receive airtime in seconds 0 - 4,294,967,295"},{"location":"stats_binary_frames/#example-structure-cc_1","title":"Example Structure (C/C++)","text":"
    struct StatsRadio {\n    uint8_t  response_code;  // 0x18\n    uint8_t  stats_type;     // 0x01 (STATS_TYPE_RADIO)\n    int16_t  noise_floor;\n    int8_t   last_rssi;\n    int8_t   last_snr;       // Divide by 4.0 to get actual SNR in dB\n    uint32_t tx_air_secs;\n    uint32_t rx_air_secs;\n} __attribute__((packed));\n
    "},{"location":"stats_binary_frames/#resp_code_stats-stats_type_packets-24-2","title":"RESP_CODE_STATS + STATS_TYPE_PACKETS (24, 2)","text":"

    Total Frame Size: 26 bytes (legacy) or 30 bytes (includes recv_errors)

    Offset Size Type Field Name Description Range/Notes 0 1 uint8_t response_code Always 0x18 (24) - 1 1 uint8_t stats_type Always 0x02 (STATS_TYPE_PACKETS) - 2 4 uint32_t recv Total packets received 0 - 4,294,967,295 6 4 uint32_t sent Total packets sent 0 - 4,294,967,295 10 4 uint32_t flood_tx Packets sent via flood routing 0 - 4,294,967,295 14 4 uint32_t direct_tx Packets sent via direct routing 0 - 4,294,967,295 18 4 uint32_t flood_rx Packets received via flood routing 0 - 4,294,967,295 22 4 uint32_t direct_rx Packets received via direct routing 0 - 4,294,967,295 26 4 uint32_t recv_errors Receive/CRC errors (RadioLib); present only in 30-byte frame 0 - 4,294,967,295"},{"location":"stats_binary_frames/#notes","title":"Notes","text":""},{"location":"stats_binary_frames/#example-structure-cc_2","title":"Example Structure (C/C++)","text":"
    struct StatsPackets {\n    uint8_t  response_code;  // 0x18\n    uint8_t  stats_type;     // 0x02 (STATS_TYPE_PACKETS)\n    uint32_t recv;\n    uint32_t sent;\n    uint32_t flood_tx;\n    uint32_t direct_tx;\n    uint32_t flood_rx;\n    uint32_t direct_rx;\n    uint32_t recv_errors;    // present when frame size is 30\n} __attribute__((packed));\n
    "},{"location":"stats_binary_frames/#command-usage-example-python","title":"Command Usage Example (Python)","text":"
    # Send CMD_GET_STATS command\ndef send_get_stats_core(serial_interface):\n    \"\"\"Send command to get core stats\"\"\"\n    cmd = bytes([56, 0])  # CMD_GET_STATS (56) + STATS_TYPE_CORE (0)\n    serial_interface.write(cmd)\n\ndef send_get_stats_radio(serial_interface):\n    \"\"\"Send command to get radio stats\"\"\"\n    cmd = bytes([56, 1])  # CMD_GET_STATS (56) + STATS_TYPE_RADIO (1)\n    serial_interface.write(cmd)\n\ndef send_get_stats_packets(serial_interface):\n    \"\"\"Send command to get packet stats\"\"\"\n    cmd = bytes([56, 2])  # CMD_GET_STATS (56) + STATS_TYPE_PACKETS (2)\n    serial_interface.write(cmd)\n
    "},{"location":"stats_binary_frames/#response-parsing-example-python","title":"Response Parsing Example (Python)","text":"
    import struct\n\ndef parse_stats_core(frame):\n    \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_CORE frame (11 bytes)\"\"\"\n    response_code, stats_type, battery_mv, uptime_secs, errors, queue_len = \\\n        struct.unpack('<B B H I H B', frame)\n    assert response_code == 24 and stats_type == 0, \"Invalid response type\"\n    return {\n        'battery_mv': battery_mv,\n        'uptime_secs': uptime_secs,\n        'errors': errors,\n        'queue_len': queue_len\n    }\n\ndef parse_stats_radio(frame):\n    \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_RADIO frame (14 bytes)\"\"\"\n    response_code, stats_type, noise_floor, last_rssi, last_snr, tx_air_secs, rx_air_secs = \\\n        struct.unpack('<B B h b b I I', frame)\n    assert response_code == 24 and stats_type == 1, \"Invalid response type\"\n    return {\n        'noise_floor': noise_floor,\n        'last_rssi': last_rssi,\n        'last_snr': last_snr / 4.0,  # Unscale SNR\n        'tx_air_secs': tx_air_secs,\n        'rx_air_secs': rx_air_secs\n    }\n\ndef parse_stats_packets(frame):\n    \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_PACKETS frame (26 or 30 bytes)\"\"\"\n    assert len(frame) >= 26, \"STATS_TYPE_PACKETS frame too short\"\n    response_code, stats_type, recv, sent, flood_tx, direct_tx, flood_rx, direct_rx = \\\n        struct.unpack('<B B I I I I I I', frame[:26])\n    assert response_code == 24 and stats_type == 2, \"Invalid response type\"\n    result = {\n        'recv': recv,\n        'sent': sent,\n        'flood_tx': flood_tx,\n        'direct_tx': direct_tx,\n        'flood_rx': flood_rx,\n        'direct_rx': direct_rx\n    }\n    if len(frame) >= 30:\n        (recv_errors,) = struct.unpack('<I', frame[26:30])\n        result['recv_errors'] = recv_errors\n    return result\n
    "},{"location":"stats_binary_frames/#command-usage-example-javascripttypescript","title":"Command Usage Example (JavaScript/TypeScript)","text":"
    // Send CMD_GET_STATS command\nconst CMD_GET_STATS = 56;\nconst STATS_TYPE_CORE = 0;\nconst STATS_TYPE_RADIO = 1;\nconst STATS_TYPE_PACKETS = 2;\n\nfunction sendGetStatsCore(serialInterface: SerialPort): void {\n    const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_CORE]);\n    serialInterface.write(cmd);\n}\n\nfunction sendGetStatsRadio(serialInterface: SerialPort): void {\n    const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_RADIO]);\n    serialInterface.write(cmd);\n}\n\nfunction sendGetStatsPackets(serialInterface: SerialPort): void {\n    const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_PACKETS]);\n    serialInterface.write(cmd);\n}\n
    "},{"location":"stats_binary_frames/#response-parsing-example-javascripttypescript","title":"Response Parsing Example (JavaScript/TypeScript)","text":"
    interface StatsCore {\n    battery_mv: number;\n    uptime_secs: number;\n    errors: number;\n    queue_len: number;\n}\n\ninterface StatsRadio {\n    noise_floor: number;\n    last_rssi: number;\n    last_snr: number;\n    tx_air_secs: number;\n    rx_air_secs: number;\n}\n\ninterface StatsPackets {\n    recv: number;\n    sent: number;\n    flood_tx: number;\n    direct_tx: number;\n    flood_rx: number;\n    direct_rx: number;\n    recv_errors?: number;  // present when frame is 30 bytes\n}\n\nfunction parseStatsCore(buffer: ArrayBuffer): StatsCore {\n    const view = new DataView(buffer);\n    const response_code = view.getUint8(0);\n    const stats_type = view.getUint8(1);\n    if (response_code !== 24 || stats_type !== 0) {\n        throw new Error('Invalid response type');\n    }\n    return {\n        battery_mv: view.getUint16(2, true),\n        uptime_secs: view.getUint32(4, true),\n        errors: view.getUint16(8, true),\n        queue_len: view.getUint8(10)\n    };\n}\n\nfunction parseStatsRadio(buffer: ArrayBuffer): StatsRadio {\n    const view = new DataView(buffer);\n    const response_code = view.getUint8(0);\n    const stats_type = view.getUint8(1);\n    if (response_code !== 24 || stats_type !== 1) {\n        throw new Error('Invalid response type');\n    }\n    return {\n        noise_floor: view.getInt16(2, true),\n        last_rssi: view.getInt8(4),\n        last_snr: view.getInt8(5) / 4.0,  // Unscale SNR\n        tx_air_secs: view.getUint32(6, true),\n        rx_air_secs: view.getUint32(10, true)\n    };\n}\n\nfunction parseStatsPackets(buffer: ArrayBuffer): StatsPackets {\n    const view = new DataView(buffer);\n    if (buffer.byteLength < 26) {\n        throw new Error('STATS_TYPE_PACKETS frame too short');\n    }\n    const response_code = view.getUint8(0);\n    const stats_type = view.getUint8(1);\n    if (response_code !== 24 || stats_type !== 2) {\n        throw new Error('Invalid response type');\n    }\n    const result: StatsPackets = {\n        recv: view.getUint32(2, true),\n        sent: view.getUint32(6, true),\n        flood_tx: view.getUint32(10, true),\n        direct_tx: view.getUint32(14, true),\n        flood_rx: view.getUint32(18, true),\n        direct_rx: view.getUint32(22, true)\n    };\n    if (buffer.byteLength >= 30) {\n        result.recv_errors = view.getUint32(26, true);\n    }\n    return result;\n}\n
    "},{"location":"stats_binary_frames/#field-size-considerations","title":"Field Size Considerations","text":""},{"location":"telemetry_decoder/","title":"Telemetry decoder","text":"

    Paste raw hexadecimal packet data copied from the Let's Mesh packet analyzer to decode a repeater's scheduled temperature, battery-voltage, or external I2C-voltage snapshot. The decoder also accepts the payload hex without its MeshCore packet header and the Base64 pages returned by repeater or room-server CLI commands. Decoding happens entirely in this browser; pasted data is not uploaded or sent anywhere.

    The Source ID in a raw-packet result is the first eight bytes of the sending repeater's public key. Match that 16-character hex value against the public-key prefix recorded for your repeaters. It comes from the telemetry payload itself, so it is available even when only the payload was copied.

    "},{"location":"telemetry_decoder/#send-raw-telemetry-packets","title":"Send raw telemetry packets","text":"

    From an administrator CLI session, first configure a direct route to the MQTT observer that will receive and upload the raw packets. Use direct when the observer is a zero-hop neighbor:

    set telemetry.tx direct\nsend telemetry.tx now\n

    For a routed observer, provide its comma-separated hop hashes instead:

    set telemetry.tx A1B2,C3D4\nsend telemetry.tx now\n

    Configuring the route also enables the default two-day schedule. To keep the route but use it only for manual tests, turn off the schedule before sending:

    set telemetry.tx schedule off\nsend telemetry.tx now\n

    The send telemetry.tx now command queues one TTB1 temperature packet, one TVB1 battery-voltage packet, and IVB1 chunks for every connected I2C voltage channel. TTB1 and TVB1 carry up to 165 samples. Each IVB1 carries 64, so a full 192-point/four-day channel takes three packets. Channels whose retained history is entirely zero are treated as disconnected and are not sent. The command requires at least one collected base sample and works even when the automatic schedule is off.

    "},{"location":"telemetry_decoder/#decode-a-packet","title":"Decode a packetDecoded telemetry","text":"Try an analyzer example: Temperature packet Voltage packet I2C voltage packet I2C voltage CLI page Raw packet or payload hex

    Spaces, line breaks, colons, dashes, a leading 0x, and a quoted JSON field are accepted. CLI Base64 replies are also auto-detected. Paste multiple compatible packet or reply lines together to merge them by timestamp before downloading one CSV. Press Ctrl/Command+Enter to decode.

    Decode telemetry Clear Show browser-local time Download CSV Decode notes"},{"location":"telemetry_decoder/#analyzer-hex-examples","title":"Analyzer hex examples","text":"

    The buttons load synthetic, protocol-valid zero-hop RAW_CUSTOM packets. A real scheduled snapshot normally has 165 samples and is much longer. Routed packets also contain path bytes before the TTB1 or TVB1 payload magic; the decoder finds and validates the payload automatically.

    "},{"location":"telemetry_decoder/#temperature","title":"Temperature","text":"
    3E00545442311122334455667788800092651E0008000102354A4E5082\n
    "},{"location":"telemetry_decoder/#battery-voltage","title":"Battery voltage","text":"
    3E00545642311122334455667788800092651E000800010264C8FEFFDC\n
    "},{"location":"telemetry_decoder/#external-i2c-voltage","title":"External I2C voltage","text":"
    3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF\n

    All three examples identify the source as 1122334455667788.

    "},{"location":"telemetry_decoder/#cli-history-pages","title":"CLI history pages","text":"

    The same page continues to decode the padded Base64 returned by these administrator commands:

    Data Newest page Older-page example Samples per page MCU temperature get telemetry.temp get telemetry.temp 2 48 (24 hours) Battery voltage get telemetry.volt get telemetry.volt 3 48 (24 hours) I2C voltage get telemetry.volt.i2c 2 get telemetry.volt.i2c 2 4 48 (24 hours) GPS position get telemetry.gps get telemetry.gps 2 24 (12 hours)

    Paste either the complete reply beginning with > or Base64 alone. For example:

    get telemetry.volt 1\n> EkDUcWoeMAAB5+bl5eTj4uLh4ODf3t7d3Nvb2tnZ2NfX1tXU1NPS0tHQ0M/Ozc3My8vKycnI/w==\n

    Run get telemetry.volt.i2c without a channel first to list connected channels. For each channel, collect pages 1 through 4. Paste all four reply lines into the decoder at once; it merges their timestamps into one 192-point, four-day table and downloads them as one CSV. The same merging works for the three IVB1 analyzer packets from a full channel. Inputs must have the same telemetry type, source, LPP channel, and sample interval.

    An INA3221 exposes its three enabled hardware inputs as three consecutive LPP channels. When it is the only external sensor these are normally 2, 3, and 4, in hardware-input order. Other sensors can shift the numbers, so copy the IDs reported by get telemetry.volt.i2c rather than assuming them.

    "},{"location":"telemetry_decoder/#reading-the-table","title":"Reading the table","text":"

    GPS history remains available through the administrator CLI, but telemetry.tx never puts GPS in RAW_CUSTOM packets. Location data therefore must come from a CLI Base64 page rather than analyzer hex.

    For the byte-level layouts, see Read repeater telemetry history.

    "},{"location":"terminal_chat_cli/","title":"Terminal Chat CLI","text":"

    Below are the commands you can enter into the Terminal Chat clients:

    "},{"location":"terminal_chat_cli/#companion-wifi-browser-terminal","title":"Companion WiFi browser terminal","text":"

    On ESP32 WiFi Companions with WebConfig, open the node's LAN IP address and select CLI. The browser runs the same commands as the USB terminal and Full Companion's TCP terminal on port 5002, including contact import, contact selection, messages, remote login/commands, and delayed RF replies. Run help to list commands compiled into the device. Enable the tab with set wifi.cli on; the open setup AP does not expose it.

    For example, copy the complete meshcore://... string from card on another Companion, then run these commands on the receiving Companion:

    import meshcore://<full-contact-card-data>\nlist\nto <contact-name-or-prefix>\n

    Replace the placeholders; a bare public key is not a contact card. Import is queued for signature validation, so use list to confirm the contact appears. Explicit import works with manual contact addition enabled and bypasses the automatic discovery type/hop filters. It still respects contact storage limits; a full contact table follows the configured overwrite policy. to remains selected for subsequent send, path, login, and cmd commands within this terminal session.

    Only one USB, TCP, or browser text terminal owns the session at a time. An idle startup USB prompt can be borrowed by WiFi. Use disconnect or the browser's disconnect button to release it. Closing the page releases the session; if the connection disappears, it expires after 60 seconds without a poll. Long replies page into the browser automatically, and incoming replies continue appearing after a command finishes. Terminal scrollback starts at 4 KiB and can grow to 32 KiB for unread output, preferring PSRAM; it shrinks after reading and is freed when the session expires. If output exceeds the available buffer while unread, the browser reports the missing output.

    USB protocol-switch and USB MOTA ownership commands still apply to the USB connection. Use the WiFi MOTA seeder on port 5001 for a host folder over WiFi.

    "},{"location":"terminal_chat_cli/#local-maintenance-commands","title":"Local maintenance commands","text":"

    Local means a connection directly to the node: USB, BLE or WiFi/Ethernet Companion protocol, a TCP terminal, or the browser CLI on the LAN. These connections can use the following commands when the role and build include the corresponding feature:

    Command Result stats-core, stats-radio, stats-radio-diag, stats-packets Runtime diagnostics get prv.key Node identity private key; Companion requires private key export enabled in the build get password Infrastructure admin password; Companion reports that it has no admin password erase Erase stored identity and settings; reboot for a fresh node get wifi.pwd Stored WiFi password on WiFi builds get mqttN.password, get mqttN.token Stored credentials for MQTT slot N get acl, log Infrastructure ACL or captured packet log; these stores do not exist on Companion

    The command text is the same for direct connections. A binary Companion app can send 0x42 (CMD_RUN_CLI_COMMAND) followed by the ASCII command, without switching into terminal mode. The response is 0x1D (RESP_CODE_CLI_REPLY) followed by the reply text. For example, payload 42 67 65 74 20 70 61 73 73 77 6f 72 64 runs get password. USB framing remains the normal Companion frame; BLE and TCP use their existing Companion transport framing.

    set freq <MHz> is available on these local connections and through authorized LoRa administration, just like set radio. It saves the frequency without changing bandwidth, spreading factor, or coding rate and requires a reboot to apply. Infrastructure roles also apply the board's transmit-power limit.

    Commands relayed to another node using cmd travel over LoRa and retain the remote restrictions on local-only maintenance and secret reads. These local permissions do not add an ACL or packet-log store to Companion. Use list or the binary contacts operations for Companion contacts. Interactive chat commands still use the text terminal or their corresponding binary protocol operations.

    The browser CLI requires station/LAN mode and wifi.cli on. Infrastructure uses its admin login; Companion uses the trusted LAN. Configuration forms keep password fields masked; explicit local CLI getters return their values.

    "},{"location":"terminal_chat_cli/#companion-usb-mode","title":"Companion USB mode","text":"

    An ordinary Companion USB build starts in the normal binary Companion protocol at 115200 baud. Use this command to switch the same USB connection into terminal mode as soon as picocom opens it:

    picocom --baud 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM0\n

    --initstring sends this exact terminal-start line automatically:

    +++MESHCORE-TERM-START\n

    The carriage return is required in Binary mode. Control tokens are recognized only as complete CR/LF-delimited lines, so the same text embedded in unrelated unframed input cannot switch modes accidentally.

    Binary Companion frames can contain terminal control bytes. The spchex input map renders those bytes as bracketed hexadecimal during the short transition instead of allowing them to change the local terminal's character set or display state. It leaves high-bit bytes unchanged so a UTF-8 terminal displays emoji and non-ASCII text normally. Do not add 8bithex unless you explicitly want UTF-8 bytes displayed as sequences such as [f0][9f][91][8b]. Once the terminal banner appears, the start sequence has already succeeded; do not enter it again as a terminal command.

    Send the following exact sequence to return to the binary protocol:

    +++MESHCORE-TERM-STOP\n

    Closing the serial connection also returns native-USB devices to binary mode. Boards whose USB connector is implemented by a USB-to-UART bridge cannot observe the host closing the port; on those boards, use the stop sequence or reboot the device.

    Both modes use the same port at 115200. Selecting 57600 is not a portable mode switch: native USB CDC devices ignore the requested baud, while USB-to-UART devices really change the UART timing and receive corrupt data. Binary mode is the framed Companion API used by apps and meshcli; close the terminal before opening that port from an app.

    Full Companion differs: its primary USB interface starts in ASCII after boot and automatically switches when it sees a complete <-prefixed Companion frame at an empty prompt. The explicit start/stop tokens remain available. See Full Companion USB CLI and binary switcher for the state machine and limitations.

    "},{"location":"terminal_chat_cli/#commands","title":"Commands","text":"

    ESP32 and nRF52 Full Companions include an offline World English Bible lookup:

    get John 3:16\n

    This returns a single complete verse from John, using plain ASCII punctuation with unchanged wording and capitalization, through the local USB/TCP terminal. See John lookup and compression for the source, supported build profiles and memory costs.

    set freq {frequency}\n

    Set the saved LoRa frequency. Example: set freq 915.8. Available over local connections and authorized LoRa CLI; reboot to apply.

    set tx {tx-power-dbm}\n

    Sets LoRa-chip transmit power in dBm. The firmware rejects values outside the radio/board limit; external-PA boards can have a lower input-power ceiling than the radio chip itself.

    set name {name}\n

    Sets your advertisement name.

    get bluetooth.name\nset bluetooth.name {name|default}\n

    Shows or changes the complete Bluetooth device name without changing the mesh advertisement identity. A custom name may contain spaces and is limited to 31 valid UTF-8 bytes. default restores MeshCore-<node name>. The saved change takes effect after reboot. get ble.name and set ble.name are short aliases.

    get bluetooth.mac\nset bluetooth.mac {address|random|random-every-boot|random-after-connect|default}\nget bluetooth.stealth\nset bluetooth.stealth {on|off}\n

    Shows or changes the Bluetooth identity on Companion builds with BLE. A literal address must be BLE random-static, such as C2:11:22:33:44:55. random generates and saves one address; random-every-boot generates a new one on each startup (random everyboot is also accepted). random-after-connect keeps its address across unused power cycles, then rotates on the next boot after an authenticated connection. default or clear restores the factory address without changing the stealth flag. Reboot, forget the old phone entry, and pair again after an address change. get ble.mac and set ble.mac are short aliases.

    The independent bluetooth.stealth flag defaults off and preserves the chosen address policy. With it on, the node is discoverable until its first authenticated pairing, then accepts only that bonded peer. Custom and saved random addresses keep the bond across boots; rotating modes reopen pairing when the address rotates, leaving stealth enabled. It still transmits the directed or allowlisted packets BLE requires for reconnection. Repeating on keeps the bond. Send off, then on, then reboot to reopen pairing manually. ble.stealth is the short alias. Flag changes require reboot.

    set lat {latitude}\n

    Sets your advertisement map latitude. (decimal degrees)

    set lon {longitude}\n

    Sets your advertisement map longitude. (decimal degrees)

    set dutycycle {percent}\n

    Sets the transmit duty cycle limit (1-100%). Example: set dutycycle 10 for 10%.

    set af {air-time-factor}\n

    Sets the transmit air-time-factor. Deprecated - use set dutycycle instead.

    get powersaving\nset powersaving {on|off}\n

    Shows or changes Companion device power saving. On ESP32 this controls CPU and GPS idle behavior; it does not change LoRa RXPS or WiFi modem sleep.

    get usb.logging\nset usb.logging {on|off} [reboot]\n

    Shows or changes persistent live USB debug and packet output in an ordinary USB-loggable Companion or Full Companion. USB Companion and Full start off on a fresh install with logging disabled to protect framed traffic. nRF52 Full changes its interface count after a reboot and keeps Companion on interface 00. Every ESP32 Full Companion has one TTY and needs no reboot: logging uses the active text terminal, disables framed Binary Companion on USB, and continues accepting set usb.logging off. Turning logging off leaves the port in the normal ASCII terminal, matching fresh firmware. Send +++MESHCORE-TERM-STOP, or let a Companion app send a valid framed probe, to switch it to Binary Companion afterward.

    reboot\n

    Sends an acknowledgement, then reboots the Companion one second later. The delay gives either the USB terminal or the Full Companion TCP terminal on port 5002 time to deliver the reply before its transport disappears.

    get radio.rxps\nget radio.rxps.config\nset radio.rxps {off|on|level 1-10 [preamble 16|32]|rx_us sleep_us}\n

    Shows or changes LoRa receive duty cycling on supported radios. get radio.rxps.config also reports the saved level and preamble assumption so automation can restore a level-based preference exactly. Fresh Cascade builds select level 8 with a 16-symbol timing assumption. The preamble argument controls the RXPS calculation; it does not change the physical wire preamble. A configured level is the minimum: when faster SF/BW settings shorten the timing window, firmware raises the effective level only as far as needed, up to level 10. Starting with v1.17.1.5, SF5-SF8 packets normally use a 32-symbol physical preamble. Firmware selects 64, then 128, only when every shorter choice fails to enable RXPS at any level. The configured values remain unchanged, so every radio change recalculates from the saved minimum. A slower tuple returns to that exact level and the shortest viable wire preamble.

    If neither adjustment leaves enough time for the radio to wake, RXPS stays logically enabled but receives continuously instead of rejecting the radio setting or starting an invalid duty cycle. The Companion terminal's get radio.rxps reports this as mode=continuous-fast and reports any effective-level or effective-preamble adjustment; get radio.rxps.config reports the persisted preference. A later compatible SF/BW change resumes duty cycling without another RXPS command.

    For the SX1262+TCXO boards tested here, these fast combinations are the useful RXPS boundary profiles (CR does not change the RXPS preamble timing):

    SF BW (kHz) Wire preamble Effective preamble Minimum effective level RX / sleep 7 500 32 32 7 2731 / 6101 us 6 250 32 32 7 2731 / 6101 us 5 125 32 32 7 2731 / 6101 us 5 250 64 64 8 1252 / 6424 us 6 500 64 64 8 1252 / 6424 us 5 500 128 128 8 626 / 6398 us 5 62.5 32 16 10 4096 / 6272 us

    The 64- and 128-symbol rows require the v1.17.1.5-or-newer adaptive-preamble contract on every sender that may reach the RXPS receiver. Cascade/USA builds on a Heltec V4 and WisMesh Tag (RAK4631 target) passed 16/16 packets in each direction at both SF5/BW250/64 and SF5/BW500/128, CR5, 909.950 MHz. An older sender makes a long-preamble timing window unsafe, so use continuous RX for a mixed deployment. Retry packets use the same physical preamble as other packets.

    get wifi.powersave\nset wifi.powersave {none|min|max}\n

    Shows or changes the persisted WiFi modem-sleep policy on ESP32 WiFi Companion builds. Full Companion requires at least min while BLE is present and rejects none. The WebConfig WiFi card and the normal binary Companion protocol expose the same setting; binary clients do not need the terminal-start token. On an ESP32 Full Companion whose primary mesh radio is ESP-NOW, max is also unavailable because maximum modem sleep can make the station miss ESP-NOW broadcasts, which the access point does not buffer. A previously saved max value is capped to and reported as min, and a new max selection is rejected. Such a WiFi/BLE/primary-ESP-NOW build therefore uses min.

    get espnow.channel\nset espnow.channel <1-13>\n

    On builds whose primary mesh radio is ESP-NOW, this shows or saves the primary radio's channel. The default is channel 1. Use only a channel permitted in your region, and restart or power-cycle the node after changing it. Every primary ESP-NOW peer must use the same channel.

    On Full Companion, the setup AP and infrastructure-WiFi station also share that channel, so the router's 2.4 GHz radio must remain fixed to it. Power saving does not permit separate channels. wifi.powersave max is unavailable on these ESP32 Full builds and min is the coexistence setting. This channel setting is also distinct from bridge.channel, which controls the optional ESP-NOW bridge transport. An updated LoRa-primary ESP-NOW bridge can join this raw primary transport by selecting the same channel and running set bridge.format raw; its default wrapped format remains the bridge-to-bridge protocol.

    get radio.rxgain\nset radio.rxgain {on|off}\nget radio.fem.rxgain\nset radio.fem.rxgain {on|off}\nget radio.fem.txgain\nset radio.fem.txgain {on|off}\n

    radio.rxgain changes the radio chip's boosted receive-gain mode. The FEM settings control the external receive-path LNA or transmit-path gain on supported boards. Changes are applied immediately and saved across reboots; changing either receive-gain path also recalibrates the radio noise floor. Boards without the respective control report it as unsupported.

    time {epoch-secs}\n

    Set the device clock using UNIX epoch seconds. Example: time 1738242833

    advert\n

    Sends an advertisement packet

    clock\n

    Displays current time per device's clock.

    ver\n

    Shows the device version and firmware build date.

    card\n

    Displays your 'business card', for others to manually import

    import {card}\n

    Imports the given card to your contacts.

    list {n}\n

    Lists favorite contacts first, then all remaining contacts. Each group is ordered by the most recent advertisement. Optional {n} limits the displayed contacts after applying that order.

    show\nshow adverts {on|off}\nshow channels {on|off}\nshow emergency {on|off}\n

    Controls unsolicited receive output in the USB terminal. Plain show reports the current settings, and show {category} reports one category. At boot, advertisements and ordinary channel messages are hidden while #emergency messages are shown. The three controls are independent, so show channels on does not override show emergency off.

    These filters affect terminal printing only. Messages still enter the offline queue and are delivered through the binary Companion protocol. Changes remain active when switching between terminal and binary mode and reset to their defaults after reboot.

    to\n

    Shows the name of current recipient contact. (for subsequent 'send' commands)

    to {name-prefix}\n

    Sets the recipient to the first matching contact (in 'list') by the name prefix. (ie. you don't have to type whole name)

    path\n

    Shows the saved outgoing path for the current to recipient. This command and all path changes require a recipient to be selected first.

    path direct\npath clear\npath {hop-hash...}\n

    Sets the outgoing path used by subsequent login, send, and cmd commands. direct selects a zero-hop route. clear forgets the saved route, causing the next operation to use flood routing and allowing normal path discovery to learn a replacement.

    Explicit paths use spaces, commas, or a mixture of both between hop hashes. Each hop must contain exactly 2, 4, or 6 hexadecimal digits, and every hop in one path must use the same width. Whitespace and hexadecimal letter case do not matter. The setting is saved with the selected contact.

    For example:

    to Hilltop Repeater\npath A1B2C3,D4E5F6\npath 7773D0 7E7662\npath\nlogin my-admin-password\n
    login {admin-password}\n

    Sends a remote login request to the current recipient. Select a repeater, room, or other remotely managed node with to {name-prefix} first. The password is masked with * while it is entered and must be 1-15 UTF-8 bytes; longer passwords are rejected instead of truncated. Login uses the route shown by path: a known or explicitly set route is direct, while an unknown route is flooded.

    Login results arrive asynchronously. A successful modern response displays the remote ACL permissions byte and server protocol level. A wrong password, an unreachable target, or a server that does not support remote login normally produces a timeout because those nodes do not send a rejection packet.

    cmd {remote-command}\n

    Sends CLI data to the current recipient. Wait for the login result before sending the first command. The remote node applies its own ACL permissions, and any reply appears asynchronously as CLI -> from {name}. The response window is 300% of the route estimate. A routed send is displayed as DIRECT via path {hop,...} with the exact prefixes copied into the packet; in MeshCore, DIRECT is the route class for an explicit path, not a synonym for zero hops. A matched reply also shows its local round-trip time, measured from queueing the command through receiving the result. This includes both radio directions and remote execution; it is not execution-only CPU time. Only one terminal cmd can be pending at a time.

    For example:

    to Hilltop Repeater\nlogin my-admin-password\nLOGIN -> Hilltop Repeater accepted (ACL permissions 0x03, server v13)\ncmd ver\ncmd get radio\n

    The exact commands and permissions depend on the target firmware. cmd does not run a command on the local Companion; it sends the text over LoRa to the selected node.

    Incoming direct-route messages are labeled ROUTED, not DIRECT. Forwarders consume direct-route prefixes as the packet travels, so the destination cannot recover the reply's actual hop history from the received packet. Use trace when the return route itself must be verified.

    send {text}\n

    Sends the text message (as DM) to current recipient.

    trace\n

    Traces the saved round-trip route to the current recipient and displays the SNR at each hop. Select the recipient first with to {name-prefix}.

    trace {name-prefix}\n

    Traces a recipient directly without changing the current to selection. A trace requires a known direct path; use normal messaging or path discovery first if the terminal reports that no valid path is available. Only one terminal trace can be pending at a time, and a missing response is reported as a timeout.

    For example:

    to Hilltop Repeater\ntrace\ntrace Downtown\n

    The displayed route uses one- or two-byte node hashes and per-hop SNR values. Saved three-byte paths are traced with two-byte prefixes because the trace packet format has no three-byte hash-size mode.

    To trace an explicit route instead of a saved contact path, provide the prefix size followed by the complete ordered route:

    trace path 1 12 34 56 34 12\ntrace path 2 1234,ABCD,5678,ABCD,1234\ntrace path 4 12345678, ABCDEF01 89ABCDEF, ABCDEF01,12345678\n

    Prefix separators may be spaces, commas, or any mixture of them. Each prefix must contain exactly 2, 4, or 8 hexadecimal digits for a 1-, 2-, or 4-byte trace respectively. Three-byte traces are not supported.

    The prefixes are used exactly in the order entered. To receive the trace result, enter the complete outward route followed by its return route, as in the mirrored examples above. A route that does not return to this node will eventually report a timeout.

    reset path\n

    Resets the path to current recipient, for new path discovery. This is retained as an alias for path clear.

    public {text}\n

    Sends the text message to the built-in Public group channel.

    channels\n

    Lists the configured channel slots and names without exposing their secrets.

    channel {name-or-slot} {text}\n

    Sends a message to any configured channel by its exact name or numeric slot. Use the slot shown by channels when a channel name contains spaces.

    For example:

    channels\nchannel #rgdata Hello from Eugene\nchannel 2 Another message\nshow channels on\n

    Messages are UTF-8. Emoji use multiple bytes toward the available message length, which also includes the sender-name prefix added over the air.

    "},{"location":"usb_serial_backpressure/","title":"Native USB backpressure and radio liveness","text":"

    ESP32-S2/S3 builds using native TinyUSB CDC (ARDUINO_USB_MODE=0 and CDC-on-boot) must not wait for a computer to read USB output. In the bundled Arduino-ESP32 2.0.17 core, USBCDC::write() can wait indefinitely for transmit space; its configured timeout bounds a mutex, not that wait. This can stop the same loop that services LoRa, even while the USB connection still appears open.

    "},{"location":"usb_serial_backpressure/#firmware-behavior","title":"Firmware behavior","text":"

    Host software must also keep reading independently of command writes. A serial relay should start its reader before the first command, use finite write and response deadlines, avoid discarding received packet logs, and cancel I/O during shutdown. A response timeout must not allow a late reply to satisfy another command: the CLI has no transaction identifiers. Updating firmware alone does not correct an indefinite host-side serial write.

    "},{"location":"usb_serial_backpressure/#regression-checks","title":"Regression checks","text":"

    Run only one PlatformIO process in the checkout at a time.

    python test/test_esp32_tinyusb_nonblocking.py\npython test/test_esp32_tinyusb_role_hygiene.py\npython test/test_esp32_tinyusb_cooperative_output.py\npython test/test_esp32_usb_serial_hygiene.py\npython test/test_nrf52_usb_logging_contract.py\npio test -e native -f test_nrf52_debug_output -f test_serial_packet_log -f test_serial_mode_switch -f test_mesh_tables\n

    The first test compiles the real USB facade against a simulated 64-byte FIFO, including stopped readers, reconnects, protocol transitions, and other-platform fallbacks. Host simulations cannot establish that every real USB driver or endpoint failure has recovered.

    A Full Station G2 validation build with USA Cascadia radio settings and the Cascade profile can be made using the normal build entry point. The portable standard recipe preserves the deployed partition layout but omits LoRa OTA; it still has the browser firmware uploader. The auto/full recipe instead enables the expanded feature set and partition layout: its merged image is not an app-only update for a device with the legacy layout.

    MESHDEBUG_OVERRIDE=on PACKET_LOGGING_OVERRIDE=on \\\n  bash build.sh build-firmware Station_G2_repeater --build-profile full \\\n  --radio-preset usa-cascadia --profile cascade\n

    Before installing on hardware, preserve the device identity, preferences, and existing partition layout. Then verify USB command responses and repeated LoRa logins with the relay running, paused, and stopped, including a host that leaves USB open without reading. Check LoRa recovery separately from USB OUT recovery; fixing transmit backpressure does not prove an unrelated OUT endpoint fault is resolved. Do not erase or repartition the radio as part of this test.

    "},{"location":"v4_pixel5_font_trial/","title":"Small-screen message fonts","text":"

    Small-screen Companion builds automatically select a compact font for received message text and the channel/sender line, including Ch 0 Public. SSD1306 and SH1106 OLEDs and the U8g2 T-Echo Card display use this on panels smaller than 160 pixels on both axes. ST7735 TFTs now use their native 160x80 dimensions and normal font. The shared renderer selects by panel dimensions, including rotation:

    Panel size Font Capital height Line spacing At least 128 x 64, or 64 x 128 rotated; both axes below 160 Squeezed Regular 6 6 pixels 8 pixels Smaller panels, including 72 x 40, 128 x 32 and 64 x 48 Picopixel 5 pixels 7 pixels Either axis at least 160 Normal display font Depends on panel Depends on panel

    Both fonts allow one pixel for descenders and one blank pixel row between lines. Character widths vary: most letters advance by 4 pixels, with narrow letters taking less space and M and W taking 6 pixels. Squeezed Regular 6 is a public-domain font by Oliver Kraus. Its bitmap data is stored as constants without heap allocation.

    On a 128 x 64 OLED with the single-button message reader, the compact channel/sender line starts at y=8 and message text at y=16. The header uses the same small font. Five complete 6px-font rows fit above the navigation hint at y=56. The software comparison fits three representative 160-character messages in those five rows. Capacity depends on the characters; messages with many wide letters can still overflow. Text wraps at character boundaries, with ... on the last line if necessary. Unsupported characters appear as ?. Long channel/sender names are ellipsized to stay on their own line. An enabled channel footer reserves its own space. Readers without the button hint retain the y=14 origin and y=22 message text.

    On V4 and other single-button builds using this reader, the home screen says hold button: inbox. Hold the user button for about 1.2 seconds to open it. The bottom continuously shows 4 <<- 2 <- tap -> 1 ->> 3 hold: Exit without alternating or blinking. Double tap goes to the previous message, one tap advances, three taps select the next channel, and four taps select the previous channel. Channel selection cycles through All, configured channels, and direct messages, starting at the newest message in each filter. The header shows All, Ch N, or DM, including when a channel is empty. Hold to return home; advancing past the last message also returns home. The complete hint fits in the V4's existing 8-pixel footer at y=56, leaving five message rows. The footer keeps the same 6px font, using 108 pixels with 10-pixel margins on the V4. Narrower screens first use the compact hold:X hint without the extra spaces or arrow dashes, then split the same controls across fixed lines. The hint appears even when the inbox is empty. These are button taps. Touchscreen and joystick builds retain instructions appropriate to their controls. During the first eight seconds after startup, holding the button on an ordinary home page enters CLI rescue instead; wait for that startup window to finish before opening the inbox. Exiting a message preview and the WiFi setup page's hold action remain available immediately.

    The hidden John reader, opened by a long press on the radio page, uses the same button hints. Three taps jump to the first verse of the next chapter; four taps jump to the first verse of the previous chapter. Chapter navigation stops at the beginning and end of the book. It reserves the hint before pagination and resumes saved bookmarks at the page containing the same text, even after the available page size changes.

    Larger display classes keep their existing font. Menus, Bluetooth PINs and WiFi setup QR codes keep their normal layout. TFT drivers use native panel coordinates; the SenseCAP Indicator retains its existing scaling. The normal OLED font uses 7-pixel letters, 8-pixel line spacing and 6-pixel character advances.

    The preview buffer holds a complete 160-byte MeshCore message plus its terminator. Previously the main message UI allocated 78 bytes, leaving room for only 77 bytes of text. It still retains 32 previews; larger records add about 2.8 KB of RAM. The firmware RAM guard includes that increase. This history is separate from the offline queue and its mOTA policy.

    The tiny 72 x 40 T-Echo Card interface now previews the latest received message below its status bar, with three small-font message rows. A button press dismisses it. It stores one full message, and shows ... when the screen fills. Incoming text does not replace an active Bluetooth pairing PIN.

    "},{"location":"v4_pixel5_font_trial/#v4-hardware-trial","title":"V4 hardware trial","text":"

    Enable platformio.nimble.ini in the ignored platformio.local.ini as shown in the NimBLE trial guide, then run:

    OUTPUT_DIR=.releases/v4-smallfont bash build.sh build-firmware \\\n  heltec_v4_2_v4_3_companion_radio_full_femon_nimble \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-squeezed6-trial \\\n  --radio-preset usa-cascadia --profile cascade --standard --require-ota\n

    This build keeps NimBLE, 350 contacts, 40 channels, the V4's 512-frame PSRAM queue, USB mOTA sending and WiFi OTA support. The smaller font is also the default in ordinary small-screen Companion builds from this source. The older _nimble_pixel5 environment name remains available, but also uses the automatic 5px/6px selection.

    Use the application .bin for WiFi OTA. A clean USB install uses the merged image at address 0. When manually writing the application at 0x10000, an existing OTA selector may still boot app1. Check the running ver afterward. On the matching V4 16 MB layout only, clearing the 8 KB otadata partition at 0xe000 selects the newly written app0 without clearing NVS/settings. Confirm the partition table and verify the app write before changing that selector.

    A custom build can set -D UI_SMALL_MESSAGE_FONT=0 to restore the old font and spacing. Remove any explicit UI_MSG_PREVIEW_SIZE flag too if the old preview capacity is desired. These are compile-time options, not CLI commands. No settings erase is necessary when changing between matching V4 layouts.

    "},{"location":"v4_pixel5_font_trial/#verification","title":"Verification","text":"
    python3 -B test/test_ssd1306_picopixel.py\npython3 -B test/test_firmware_ram.py\npio test -e native -f test_display_driver -f test_companion_message_history\n

    The native tests exercise both capital heights, automatic font selection, 160-character messages, complete rows at display edges, long sender lines, navigation hints, reserved footers, overflow markers and tiny/rotated screen geometry. The additional rendering comparison checks all 95 printable ASCII glyphs in each font, under address/undefined-behavior sanitizers. Picopixel is compared pixel for pixel with Adafruit GFX; Squeezed Regular 6 is compared with the upstream BDF fixture independently of the converted C++ tables. This needs a cached PlatformIO Adafruit GFX library; set MESHCORE_GFX_LIBRARY to its directory if needed. It reports a skip when the library is absent. The native tests do not require that dependency. Run only one PlatformIO command at a time.

    "},{"location":"v4_pixel5_font_trial/#automatic-5px6px-results-2026-09-08","title":"Automatic 5px/6px results, 2026-09-08","text":"

    Source revision 469b47d1 passed five representative firmware builds. All five retained the same static RAM usage and startup heap margin as their earlier 5px builds. The four Full profiles also passed required OTA packaging.

    Hardware/profile Display Selected capital height RAM beyond the required startup budget, bytes V4.2/V4.3 Full NimBLE SSD1306 6 pixels 88,072 T096 Full, FEM on ST7735 6 pixels 24,778 Station G3 ESP32 Full SH1106 6 pixels 82,080 RAK3401 Full SSD1306 6 pixels 46,108 T-Echo Card BLE Companion U8g2, 72 x 40 5 pixels 30,992

    The native display/history suites passed 36 tests. The Python font, RAM, pairing, display-profile, queue and QR checks passed 38 tests, including pixel comparisons for all 95 ASCII glyphs in each font. The actual SSD1306 software rendering matches the approved 6px comparison image pixel for pixel.

    Both NimBLE-V4-VM and NimBLE-V4-Trial were flashed with v1.17.1.5-halo-keymind-cascade-squeezed6-trial-469b47d1. Their partition tables and application hashes were verified, their settings retained, and running versions confirmed. Each passed 201 USB protocol requests without error flags. The Mercerwood V4 reconnected over bonded Bluetooth at MTU 179 and retained the factory-address policy.

    The XIAO sent a private 160-byte LoRa message to the Mercerwood V4. All bytes arrived, and the V4 stayed responsive through 20 seconds of display refreshes with no uptime reset or error flags. Both temporary channel settings were restored. After that interval the V4 had 147,196 bytes of free internal heap, a minimum of 146,328 bytes, and a largest free block of 139,252 bytes.

    These are short functional checks. Physical readability remains a user judgment. A message with many wide characters can still need truncation.

    V4 application size: 1,778,936 bytes (1,152 bytes larger than the earlier 5px trial). SHA-256: 8e6797b90741bf013b3757668e5a23378ac8c0b0ed9c75830939f13305c56a68.

    "},{"location":"v4_pixel5_font_trial/#earlier-5px-hardware-and-build-results-2026-09-08","title":"Earlier 5px hardware and build results, 2026-09-08","text":"

    Source revision eeea15ef passed seven representative firmware builds:

    Hardware/profile Display RAM beyond the required startup budget, bytes V4.2/V4.3 Full NimBLE Picopixel SSD1306 88,072 T096 Full, FEM on ST7735 24,778 Station G3 ESP32 Full SH1106 82,080 RAK3401 Full SSD1306 46,108 Wireless Tracker Full NimBLE capacity trial ST7735 28,022 Wio Tracker L1 Full SH1106 45,744 T-Echo Card BLE Companion U8g2 30,992

    These are linked-capacity checks before runtime allocation, not live free heap measurements. The six Full profiles also passed their required OTA packaging checks. The T-Echo Card row is a BLE Companion build.

    Both physical V4.3 nodes, NimBLE-V4-VM and NimBLE-V4-Trial, were flashed, their image hashes verified, and their running versions confirmed. Each passed 201 USB protocol requests with zero reported error flags. The Mercerwood V4 also reconnected over authenticated, bonded Bluetooth with MTU 179 and the existing factory-address policy.

    A private XIAO-to-V4 LoRa test delivered all 160 message bytes. The V4 continued responding during 20 seconds of message redraws, with zero error flags. Both temporary channel configurations were restored. Free internal heap after that interval was 147,200 bytes, minimum 146,340 bytes, and the largest free block was 139,252 bytes. These are short functional checks; physical readability remains a user judgment.

    A matching pre/post-boot Mercerwood measurement showed the longer preview using 2,816 additional PSRAM bytes, with unchanged internal free heap. Boards without PSRAM use their ordinary RAM for the larger preview records.

    The native display/history suites passed 31 tests. The Python font, RAM, pairing, display-profile, queue and QR checks passed 38 tests. The Adafruit comparison covers all 95 printable ASCII glyphs.

    V4 application SHA-256: d914d80124b499d7b719f8427f3794ab1b6d15fd0112e3ef89ba2fac26b5a974.

    "},{"location":"releases/1.17.1.5-doc-audit/","title":"Markdown audit \u2014 1.17.1.5 USA Cascade","text":"

    Audited on 2026-09-05 against firmware source 26303793, the resolved build configuration, and the qualified 1.17.1.5 staging manifests. The scope was all 71 tracked Markdown files present at the start, including root documents, docs, examples, tools, tests, variants, and vendored library documentation. Generated/ignored build directories and external repositories were outside that scope.

    "},{"location":"releases/1.17.1.5-doc-audit/#corrections","title":"Corrections","text":""},{"location":"releases/1.17.1.5-doc-audit/#verification-and-limits","title":"Verification and limits","text":"

    All original Markdown files were scanned for relative links, section anchors, source-path references, version/default claims, and role/OTA terminology. Flagged operator instructions were checked against CommonCLI, its observer extension, Companion command handlers, board configuration, and release manifests. Brace-expanded source references and SDK-relative paths were retained as such. Historical measurements, changelogs, upstream findings, contribution/security policies, and licenses were not rewritten as new validation results.

    The documentation site passed a strict MkDocs Material build. Relative repository links and section anchors were checked separately, including documents outside the MkDocs source directory. Console and current bootloader links were checked on their primary sites. This was not a crawl of every third-party URL or a new hardware/firmware qualification; no claim is made that every board was physically tested or every protocol statement independently re-proved.

    "},{"location":"releases/1.17.1.5-doc-audit/#original-file-coverage","title":"Original file coverage","text":"

    \u201cRetained\u201d means no correction was identified by this documentation audit; it does not certify all historical statements as current hardware behavior.

    File Disposition AGENTS.md Scanned; retained ALERTS.md Scanned; retained CHANGELOG.md Historical record retained CONTRIBUTING.md Scanned; retained MEMORY_MONITORING.md Scanned; retained MQTT_IMPLEMENTATION.md Corrected or clarified MQTT_INTERNALS.md Corrected or clarified MQTT_OWNERSHIP.md Historical record retained MQTT_SNMP.md Corrected or clarified README.md Corrected or clarified RELEASE.md Corrected or clarified RESTORE_UPSTREAM_NOTES.md Historical record retained SECURITY.md Scanned; retained STABILITY_TESTABILITY_HANDOFF.md Historical record retained UPSTREAM_BUGS.md Scanned; retained WEB_CONFIG_REVIEW.md Corrected or clarified arch/stm32/Adafruit_LittleFS_stm32/README.md Vendored documentation/license retained arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/LICENSE.md Vendored documentation/license retained arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/README.md Vendored documentation/license retained docs/WiFi.md Corrected or clarified docs/cli_build_matrix.md Corrected or clarified docs/cli_command_availability.md Corrected or clarified docs/cli_commands.md Corrected or clarified docs/companion_offline_queue.md Scanned; retained docs/companion_protocol.md Scanned; retained docs/companion_radio_full.md Corrected or clarified docs/docs.md Scanned; retained docs/esp32_memory_budget.md Scanned; retained docs/faq.md Corrected or clarified docs/filter_tool.md Scanned; retained docs/firmware_picker.md Corrected or clarified docs/flood_filtering.md Scanned; retained docs/full_companion_features.md Corrected or clarified docs/full_companion_usb_switcher.md Corrected or clarified docs/gps_tracking.md Scanned; retained docs/halo_keymind_settings.md Corrected or clarified docs/hardware_validation_checklist.md Corrected or clarified docs/host_cli_service.md Scanned; retained docs/index.md Corrected or clarified docs/kiss_modem_protocol.md Scanned; retained docs/lora_ota_automation.md Corrected or clarified docs/mbedtls-tls-footprint.md Scanned; retained docs/nrf52_companion_storage_recovery.md Scanned; retained docs/nrf52_power_management.md Scanned; retained docs/number_allocations.md Scanned; retained docs/ota_easy.md Corrected or clarified docs/ota_meshtower_v2_sdcard.md Corrected or clarified docs/ota_nrf52_bootloader_update.md Corrected or clarified docs/ota_nrf52_qspi.md Corrected or clarified docs/ota_protocol.md Corrected or clarified docs/ota_user_guide.md Corrected or clarified docs/packet_format.md Scanned; retained docs/payloads.md Scanned; retained docs/qr_codes.md Scanned; retained docs/rak3401_mota_chain.md Historical record retained docs/releases/1.17.1.5.md Corrected or clarified docs/spiffs_regular_file_reads.md Scanned; retained docs/stats_binary_frames.md Scanned; retained docs/telemetry_decoder.md Scanned; retained docs/terminal_chat_cli.md Scanned; retained docs/usb_serial_backpressure.md Scanned; retained examples/host_cli_service/README.md Scanned; retained lib/PsychicMqttClient/CHANGELOG.md Vendored documentation/license retained lib/PsychicMqttClient/README.md Corrected or clarified mesh-america/README.md Corrected or clarified test/README.md Scanned; retained test/test_mqtt_prefs_codec/FIXTURES.md Historical record retained tools/mota/README.md Scanned; retained tools/sensecap_indicator_font/README.md Scanned; retained tools/sensecap_indicator_rp2040/README.md Scanned; retained variants/sensecap_indicator-espnow/README.md Scanned; retained"},{"location":"releases/1.17.1.5/","title":"MeshCore 1.17.1.5 Dev - USA Cascade","text":"

    Use feature switches by role for exact on/off commands for Companion, Repeater, Room Server, and Sensor, including MQTT, logging, power saving, GPS, and board exceptions. Open the USB web console for the default ASCII terminal on Full Companion and infrastructure images.

    The firmware identifier and main release tag follow v1.17.1.5-halo-keymind-cascade-dev-<commit>, matching the 1.17.1.4 naming convention. Supplemental pages retain the repeater-room-, utility-, logging-, lora-ota-, and full-profiles- prefixes where those outputs exist. The Companion page is marked Latest; the supplemental pages remain development prereleases.

    This release uses the USA/Canada radio preset and Cascade runtime defaults. The release build records the resolved frequency, bandwidth, spreading factor, and coding rate. The offline USA Cascadia fallback is 910.525 MHz, BW62.5, SF7, CR5. Saved node settings survive ordinary firmware updates.

    "},{"location":"releases/1.17.1.5/#memory-corrections-in-aa20e927","title":"Memory corrections in aa20e927","text":"

    All 583 published profiles (the 540-profile matrix plus 43 supplemental Full repeaters) were checked against the new runtime RAM policy. All 11 failing profiles have replacements, and the same queue sharing has been applied to the other 41 nRF52 Full profiles: 52 corrected profiles in total, initially built from aa20e927. The existing release tags stay in place; corrected filenames end in aa20e927, except Wireless Paper Full's capacity-restoring follow-up, 1e4d1e16. Companion remains Latest. Both Mesh America catalogs and the firmware picker point to the replacement files.

    All 43 nRF52 Full Companions keep 256 offline frames normally and lend 128 slots to mOTA while needed. Wireless Paper Full now also shares its queue and keeps 350 contacts and 40 channels. The other six corrected ESP32 Full profiles below use 150 contacts, preserving their 256-frame queue and Full transports. Export contacts before upgrading if you have more than 150; entries beyond that limit may be unavailable and a later save may omit them. This warning applies to those six profiles and the superseded aa20e927 Wireless Paper image.

    Wireless Paper Full's 1e4d1e16 update has 256 message slots normally and 128 while an mOTA session borrows its workspace. Idle WiFi listening leaves all 256 slots available. If more than 128 unread messages are queued, sync them with an app before starting mOTA. Source stop/disconnect restores all 256 slots and frees ESP32 scratch buffers. USB, Bluetooth, WiFi, the display and the partition layout are retained. The application is 1,869,976 bytes; use its app-only .bin for a compatible WiFi update, or its merged image for USB installation.

    The T096 and T1 LoRa OTA repeater corrections allocate the diagnostic manual staging buffer only when requested, recovering about 4 KiB at startup. Ordinary mOTA uses its existing staging store. ota dev clear releases a manual stage; allocation failure reports an error before writing. These same-version repairs require an explicit exact-target manual LoRa installation or USB; forward-only trusted autoinstall does not select an equal-version repair.

    Available RAM below means capacity before startup allocations, not free heap after boot. The Wireless Paper row includes its latest 1e4d1e16 update:

    Failing original profile Original bytes Replacement bytes Required bytes Generic_ESPNOW_companion_radio_full 101,920 139,456 136,192 Heltec_Wireless_Paper_companion_radio_full 150,144 161,336 152,576 Heltec_Wireless_Tracker_companion_radio_full 148,496 186,032 169,986 Heltec_ct62_companion_radio_full 99,104 136,640 136,192 Heltec_t096_companion_radio_full_femon 54,724 74,060 73,728 Heltec_t1_companion_radio_full 55,044 74,380 73,728 Heltec_v3_companion_radio_full 143,840 181,376 173,056 Xiao_C3_companion_radio_full 98,880 136,416 136,192 heltec_tracker_v2_companion_radio_full_femon 133,136 170,672 169,986 Heltec_t096_repeater_lora_ota_no_external_sensors 61,588 65,676 65,538 Heltec_t1_repeater_lora_ota_no_external_sensors 61,964 66,052 65,538

    Each replacement has a matching .memory.json with its ELF hash, RAM budget and firmware-file hashes. The complete audit distinguishes original binary/log calculations from new ELF checks. All 52 exact firmware builds, their native file pairs, and 150 native mOTA tests passed, alongside memory, allocation-failure, shared-queue and picker regressions. Wireless Paper's follow-up also passed complete TCP mOTA transfers with address/leak sanitizers, repeated disconnects, queue-full refusal, idle listening and USB/TCP ownership changes. Physical boot/load/soak validation remains pending.

    "},{"location":"releases/1.17.1.5/#g3-usb-disappearance-with-power-saving-enabled","title":"G3 USB disappearance with power saving enabled","text":"

    The published 26303793 firmware has an ESP32 native-USB sleep bug that matches a Station G3 report: USB disappears from the connected Pi after a few minutes, the display button stops responding, but the repeater still answers over LoRa. Hardware confirmation of this report is pending.

    Cascade enables device power saving by default on fresh settings. After two minutes, the repeater can enter light sleep when no serial terminal is open, even though the Pi has enumerated the USB device. Light sleep stops USB service; the radio GPIO can still wake the CPU, while the G3 display button is only polled. Closing a terminal must not be treated as unplugging the USB host. Espressif documents the underlying native USB sleep limitation.

    To enable USB logging on an ESP32 1.17.1.5 image with logging support, disable power saving first. Use the role's text terminal, or the repeater's remote admin CLI if USB has already disappeared:

    set powersaving off\nset usb.logging on\nget powersaving\nget usb.logging\n

    The read-back commands should report power saving off and USB logging on. For an image without USB logging, use just set powersaving off and get powersaving to apply/check the sleep workaround. Disabling power saving saves the setting and takes effect immediately; it does not erase the node or change LoRa RX power saving (radio.rxps). If USB does not reappear after disabling sleep remotely, power-cycle once and check that the port and display button remain usable beyond the two-minute mark. Ordinary reflashing can retain the saved power-saving setting, so check it during comparisons with another firmware build too.

    On unified Full infrastructure, also run set powersaving off before enabling USB through set logging.output usb or set logging.output both. These are separate commands. Turning logging off does not automatically restore the saved power-saving setting. nRF52 logging does not need this ESP32 workaround.

    WiFi/MQTT-only logging does not need set powersaving off while the Repeater/Room Server MQTT bridge is running. The original 26303793 code already prevents device sleep in that state. Select set logging.output wifi where supported and check get mqtt.running; get mqtt.enabled alone only checks the saved preference. MQTT-capable Full Companion now uses the same mqtt.enabled and logging.output commands as infrastructure. Disabling MQTT preserves broker settings; the MQTT tab exposes the same master switch.

    The source correction keeps native USB serviced while a host is enumerated, including when its terminal is closed. Enabled live USB logging also blocks ESP32 light sleep, even without a host. FreeRTOS can still idle/yield; this does not disable the saved power-saving setting or Companion CPU scaling. With logging off, USB power from a charger still permits sleep.

    The corrected G3 uses its user button as a light-sleep wake source alongside LoRa. Button activity keeps the CPU and display awake for at least two minutes; holding or pressing the button again extends the interval. The usual display timeout can keep the screen on longer. An intentional long-press power-off retains its existing action. After the wake interval, sleep is allowed when power saving is enabled, logging is off, no native USB host is attached, and no running bridge or pending work needs service.

    Host simulations cover both native USB modes, terminal close/reconnect, logging toggles, OTA inhibition, simultaneous button/LoRa wake configuration, and the two-minute interval across timer rollover. The Station G3 repeater download remains the original 26303793 build; continue to use the logging workaround above on that image.

    "},{"location":"releases/1.17.1.5/#t096-full-companion-bluetooth-and-menu-freeze-report","title":"T096 Full Companion Bluetooth and menu freeze report","text":"

    A report against Heltec_t096_companion_radio_full_femon describes no usable Bluetooth despite a displayed PIN, followed by a freeze while stepping through the device menu. Reflashing both with and without a full wipe gave the same result. The exact freeze and Bluetooth behavior still need confirmation on a physical T096 with the source correction below.

    The published 26303793 Full image leaves only 54,724 bytes (53.4 KiB) between the linked heap boundaries. Its static RAM check did not account for the allocations made at startup: the 160x80 color framebuffer alone needs 25,602 bytes, followed by task stacks, the packet pool, filesystem buffers, and message previews. This leaves inadequate runtime headroom. The pinned Bluefruit library also ignores failure to create its BLE/SOC worker tasks, and the sensor menu could write through a failed CayenneLPP allocation. Those are code paths consistent with this report; a PIN is not proof of a working Bluetooth radio.

    The correction applies to every nRF52 Full Companion, keeping its offline queue at 256 frames during normal use. mOTA borrows the upper 128 slots for its roughly 19 KiB session state only when needed, leaving 128 message slots during mOTA use. Starting a source or a TempRadio discovery session acquires this workspace. Stopping or disconnecting the USB/Bluetooth source restores all 256 slots, even if the temporary radio window is still open. A discovery-only session returns the slots when its TempRadio window ends. No heap resizing is involved.

    Unread messages keep their order across both transitions. If more than 128 frames are pending, mOTA refuses to start and asks you to sync unread messages with a Companion app first; it does not discard them to make room. OTA configuration and signer keys survive reuse of the workspace. While mOTA owns it, the normal queue overflow policy applies to the remaining 128 slots.

    The T096 profile requires at least 72 KiB of heap space at link time. It retains 350 contacts, 40 channels, the color display, sensors, USB, Bluetooth, Bluetooth DFU, and USB/Bluetooth mOTA sending. This reserve is space for runtime allocations, not a measurement of free heap after boot. Bluetooth startup now checks both worker tasks and all registered services; a failed partial initialization does not repeat every five seconds. The sensor page reports low memory if its buffer could not be allocated.

    Regression tests transfer complete mOTA containers through the actual shared context, check queue wraparound and message order, and cover source stop, disconnect, conflicting USB/Bluetooth ownership and repeated sessions. They also inject worker-task and GATT-service failures, verify that retries do not initialize the stack again, open the sensor path with a failed allocation, and use the real linker assertion to reject the old heap budget. The original T096 Full download has been replaced by the corrected aa20e927 image on the same release page. Another wipe is not a fix for the old compiled memory budget.

    For the corrected T096 build, use its matching application UF2 through the existing USB bootloader. Preserve the bootloader and node data. Check Bluetooth discovery, pairing and message exchange on battery power and with USB attached; then step through every menu page, return from screen-off, and check USB and Bluetooth mOTA sending. Hardware validation is pending.

    "},{"location":"releases/1.17.1.5/#firmware-roles-and-update-support","title":"Firmware roles and update support","text":"

    Use the exact board's Full Companion image when available. Every Full Companion must pass linked-firmware checks for MOTA sending, its text terminal, and the platform's source transports. ESP32 supplies USB and WiFi sources; nRF52 supplies USB and encrypted Bluetooth sources. Full Companions remain USB-updateable and do not require LoRa self-installation. Optional ESP32 WiFi self-update uses port 8080 where the existing partition layout has two application slots; nRF52 retains Bluetooth DFU with a compatible bootloader.

    Read Full Companion feature switches for on/off commands, USB logging, power saving, WiFi/Bluetooth selection, GPS, MQTT, MOTA sending, and update directions.

    Infrastructure artifacts must have a verified wireless self-update path. Option 3 reports and omits infrastructure on platforms without a wireless updater. The report is ota-excluded-targets.txt. Source/seeder capability alone does not count as self-update support. ESP32 qualification checks both application slots and OTA metadata; nRF52 Bluetooth qualification checks the linked DFU service and its application ZIP. LoRa installation on nRF52 still requires the exact matching OTAFIX bootloader and storage arrangement.

    Classic ESP32 capacity limits are recorded in each artifact's capability manifest. Generic E22, Heltec V2, Meshadventurer, and T-Beam OTA repeaters, plus the TLora MQTT repeater, use 50 neighbors where needed to preserve the required internal RAM reserve. T-Beam MQTT repeater profiles retain the full rule engine with 31 flood rules; constrained classic ESP32 ESP-NOW bridges with logging use 47. Meshadventurer Full uses 100 contacts, 30 group channels, and a 16-frame offline queue while retaining its MOTA and transport features.

    Since 1.17.1.4, this branch also includes USB session and contact-persistence fixes, automatic nRF52 ExtraFS recovery, improved Indicator setup/display handling, compressed MOTA transport, and measured ESP32 memory limits. Qualified internal-flash nRF52840 receivers now reserve 64 KiB of retained RAM to extend application MOTA staging beyond the available flash prefix. This handoff requires the exact board's OTAFIX 2.4.6 bootloader. Install that bootloader before relying on LoRa updates from these new hybrid receiver images: they reject application containers larger than one flash page when the installed bootloader lacks the retained-RAM capability. QSPI/microSD roles and Full Companions retain their existing storage layouts. An interrupted hybrid download must be fetched again after an application restart or power cycle. See the protocol for the exact admission and reset checks.

    "},{"location":"releases/1.17.1.5/#build-using-option-3","title":"Build using option 3","text":"

    The equivalent reproducible command is:

    OUTPUT_DIR=.releases/1.17.1.5-halo-keymind-cascade-dev \\\nbash build.sh build-firmwares-logging-matrix \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-dev \\\n  --radio-preset usa-cascadia --profile cascade \\\n  --require-ota --skip-kiss --resume --background\n

    Or run bash build.sh, select option 3, set version v1.17.1.5-halo-keymind-cascade-dev, and retain USA Cascadia/Cascade. Option 3 enables the infrastructure OTA requirement by default. It merges supported runtime USB logging into canonical artifacts and builds the expanded ESP32 profiles. It runs one PlatformIO process at a time.

    The background launcher prints its persistent log and status paths. Each target also gets a log in OUTPUT_DIR/build-logs/. Resume accepts only artifacts with the current capability schema and a successful qualification; infrastructure also needs a verified update method. Any failed target leaves the matrix unsuccessful and must be resolved before publication.

    --allow-no-ota is an explicit development override for cable-only infrastructure. Those outputs do not belong in this OTA-required release. Use a separate output directory when changing the radio or settings profile. Use a fresh release output directory after changing the source revision or firmware version so packaging cannot mix old and new binaries.

    "},{"location":"releases/1.17.1.5/#local-release-staging","title":"Local release staging","text":"

    After the matrix completes successfully, scripts/package_cascade_release.py groups the qualified images using the previous GitHub release naming. Each group includes the firmware files, capability manifests, TSV/JSON inventory, checksums, build notes, and Full Companion directions. The staging directory's root FIRMWARE-PICKER-1.17.1.5.html opens the local downloads; the copies inside each release group use the planned GitHub download URLs. Staging does not push commits, create remote tags, or publish a GitHub release.

    "},{"location":"releases/1.17.1.5/#published-release-set-and-small-slot-heltec-v4-updates","title":"Published release set and small-slot Heltec V4 updates","text":"

    The capability JSON and target inventories now report both Bluetooth DFU and LoRa mOTA for all 60 nRF52 receiver profiles. The original report omitted the nRF52 LoRa method. Each affected manifest includes ota_update_requirements.lora with its storage type, accepted package types, exact-base requirement for deltas, and the matching OTAFIX bootloader link. Internal-flash receivers accept in-place deltas; supported QSPI/SD receivers also accept full application packages. The 20 retained-RAM receivers require OTAFIX 2.4.6's matching handoff support. These reports verify the released application bytes and storage layout; they do not certify the bootloader installed on a physical device. Firmware binaries and tags are unchanged. Full Companion mOTA sending remains separate from self-update capability.

    Published firmware tags remain at source commit 26303793. Documentation can advance independently. The original matrix qualified 540 profiles (1,080 firmware files) from 551 attempts; 11 oversized portable attempts have expanded Full replacements. The variant map maps all 1,361 previous-release choices to 1,325 covered choices and 36 excluded entries. Multiple old choices map to the same current artifact.

    For Heltec V4 with an existing compatible 1.25 MiB OTA application slot, the standard heltec_v4_repeater application is 1,179,200 bytes and the lean heltec_v4_repeater_lora_ota_no_external_sensors application is 1,232,024 bytes. Both fit that slot. Start the WiFi uploader with start ota ap, join MeshCore-OTA, and open http://192.168.4.1/update. Upload the application .bin; use stop ota to close an unused uploader. Their merged USB installers write two 6.25 MiB application slots. Expanded Full V4 applications require the larger layout and do not fit a 1.25 MiB slot.

    "}]} \ No newline at end of file +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"Introduction","text":"

    Welcome to the MeshCore documentation.

    Below are a few quick start guides.

    If you find a mistake in any of our documentation, or find something is missing, please feel free to open a pull request for us to review.

    "},{"location":"WiFi/","title":"WiFi and MQTT by Firmware Type","text":"

    MeshCore itself does not require WiFi or the internet. LoRa packet exchange, repeating, room servers, companions, and sensors can all operate without either. WiFi is added by particular ESP32 firmware targets for one or more of these purposes:

    The firmware role and the build profile are separate choices. USB logging does not itself provide MQTT. MQTT-capable Full Companions include that feature without mqtt in their filename; check the artifact capability manifest. See feature switches by role for the different Companion and infrastructure controls and the USB web console.

    "},{"location":"WiFi/#quick-reference","title":"Quick reference","text":"Firmware target or role Infrastructure WiFi MQTT What WiFi does *_repeater Build-dependent on ESP32 No FULL builds provide WebConfig, browser OTA, and the TCP 5001 LoRa-OTA seeder while WiFi is active *_repeater_observer_mqtt Yes Yes Uplinks heard and selected transmitted LoRa packets; repeating remains enabled by default *_room_server Build-dependent on ESP32 No FULL builds provide WebConfig, browser OTA, and the TCP 5001 LoRa-OTA seeder while WiFi is active *_room_server_observer_mqtt Yes Yes Runs the room server and uplinks radio traffic *_companion_radio_wifi Yes No Exposes the MeshCore companion protocol on TCP port 5000 *_companion_radio_wifi_mqtt Yes Yes Runs both the TCP companion interface and the MQTT uplink *_companion_radio_full* ESP32, subject to board/transport selection Where compiled on ESP32 Includes supported USB/wireless transports; configure MQTT through WebConfig Dedicated USB, BLE, or serial companion (not Full) No No Uses the transport named by the target instead *_repeater_bridge_espnow Build-dependent on ESP32 No Uses ESP-NOW for its bridge; bridge.format wrapped|raw selects legacy bridge framing or primary-ESP-NOW compatibility; a FULL build also exposes the TCP 5001 LoRa-OTA seeder whenever WiFi is usable RS232 bridge No No Bridges through a serial interface Ethernet repeater/room server No WiFi No Uses wired Ethernet for its role-specific network interface *_sensor Build-dependent on ESP32 No A FULL ESP32 sensor can expose the TCP 5001 LoRa-OTA seeder through its browser-OTA setup AP Terminal-chat or KISS modem No in current targets No Uses LoRa and its role-specific local interface *_lora_ota_no_external_sensors On demand on ESP32 No Lean LoRa-OTA repeater image; ESP32 builds retain the compact start ota browser uploader

    Direct on-device WiFi and MQTT are currently ESP32 features. nRF52, STM32, and the currently enabled RP2040 targets do not run this MQTT bridge. An nRF52 connected to a Raspberry Pi can still be logged or uplinked by software on the Pi, but that is a separate host-side bridge rather than MQTT running in the radio firmware.

    "},{"location":"WiFi/#how-the-mqtt-bridge-works","title":"How the MQTT bridge works","text":"

    The MQTT bridge is an outbound observer. It does not subscribe to MQTT topics and does not inject broker messages into LoRa.

    For a received packet, the flow is:

    LoRa radio -> successful packet parse -> MQTT capture queue -> broker slots\n                                  \\----> normal MeshCore filtering/handling\n

    Packets transmitted by the node can also be queued according to mqtt.tx. Receive capture happens before the packet is passed to the normal MeshCore flood filters and forwarding decision. A packet may therefore be observed on MQTT even when a later scope, path, region, duplicate, or repeat rule prevents the node from forwarding it over LoRa.

    On infrastructure observer builds, MQTT publication and LoRa repetition are separate:

    Fresh MQTT observer settings are:

    Up to six broker configurations can be saved. The number that can be active at once depends on the target and available memory. A non-PSRAM ESP32 should normally use no more than two TLS/WSS brokers; some classic ESP32 targets are reliable with only one. Excess configured slots remain saved but show as inactive in get mqtt.status.

    The bridge has a bounded packet queue for broker or WiFi outages and reconnects automatically. A non-PSRAM build holds 6 packets and a PSRAM build holds 50. When the queue is full, the oldest item is replaced; after five minutes with no broker connected, stale queued packets are flushed. MQTT packet delivery is best effort rather than durable storage. Each enabled slot publishes independently, so one failed broker does not intentionally stop the other slots.

    See MQTT_IMPLEMENTATION.md for the complete preset list, custom broker configuration, topic formats, authentication, diagnostics, and memory limits.

    "},{"location":"WiFi/#mqtt-observer-setup","title":"MQTT observer setup","text":"

    Most full-size ESP32 MQTT observer builds have the shared WebConfig portal. On a fresh device with no saved SSID:

    1. Join the open MeshCore-Setup-XXXX access point.
    2. If the captive page does not open, browse to http://192.168.4.1/.
    3. Enter WiFi, radio, identity, and MQTT settings.
    4. Select Save & Reboot.
    5. After reboot, check get wifi.status and get mqtt.status.

    The setup AP is unauthenticated unless the firmware was built with WEBCONFIG_AP_PASSWORD. It uses plain HTTP, so provision it in a trusted location. When WebConfig is running on the normal LAN, repeater and room-server builds require the node's admin password.

    On an expanded FULL profile with no saved SSID, this automatic setup AP has an absolute 30-minute window. Browser activity or a phone left associated with the AP does not extend it. If no SSID has been saved when the window expires, WebConfig closes and the ESP32 WiFi radio remains off automatically for the remainder of that boot. A reboot or power cycle starts a new 30-minute setup window; the timeout is deliberately not written to preferences. An administrator can still override the automatic cutoff with an explicit start webconfig command.

    Once an SSID is saved, the provisioning cutoff no longer applies. When WiFi is selected by logging.output wifi|both, the station stays enabled and keeps trying the saved network indefinitely: ESP automatic reconnect remains on and the explicit fallback advances through 15, 30, 60, 120, then 300-second retry intervals, remaining at five minutes until it reconnects. MQTT broker retries use their own backoff; a repeatedly failing broker eventually receives one probe every 30 minutes. Selecting logging.output off|usb keeps the MQTT bridge off, so saved credentials alone do not force WiFi on unless WebConfig is also enabled explicitly.

    The equivalent MQTT observer CLI setup is:

    set wifi.ssid Your WiFi Name\nset wifi.pwd Your WiFi Password\nset mqtt.iata SEA\nset name MyObserver\nreboot\n

    The SSID and password values are the rest of the command line. Spaces are allowed and quotes must not be added. SSIDs may contain at most 31 characters and passwords at most 63. Leave the password value empty for an open network.

    Useful checks are:

    get wifi.ssid\nget wifi.status\nget wifi.powersave\nget wifi.cli\nget mqtt.enabled\nget mqtt.rx\nget mqtt.tx\nget mqtt.status\nget mqtt1.diag\nget mqtt2.diag\n

    To change a configured observer through WebConfig without leaving the portal enabled after every reboot:

    start webconfig\n

    This uses the LAN when WiFi is connected. To force the setup AP, the MQTT bridge must release WiFi first:

    set mqtt.enabled off\nstart webconfig ap\n

    After provisioning, use Save & Reboot, or stop the temporary portal and restart the bridge:

    stop webconfig\nset mqtt.enabled on\n

    Current MQTT observer artifacts use the expanded FULL partition profile so WebConfig and the complete role CLI are retained. Install the matching merged image over USB once when moving a device from the legacy partition layout.

    "},{"location":"WiFi/#wifi-companion-setup","title":"WiFi companion setup","text":"

    A *_companion_radio_wifi build replaces the BLE or USB companion link with the MeshCore companion protocol over TCP port 5000. The phone or computer must be able to reach the device on the same LAN.

    Most ESP32 *_companion_radio_full targets keep all three Companion links at once: USB, BLE, and TCP port 5000. The two SenseCAP Indicator Full targets are the exception: USB remains available, while a saved next-boot selector starts exactly one secondary wireless Companion transport, either BLE or infrastructure WiFi/TCP. On the ESP-NOW Indicator, this selector never stops the primary ESP-NOW mesh radio. Full ESP32 images also provide a source-only LoRa mOTA service on ports 5001 and 5002 whenever their WiFi transport is active. See the full Companion guide for its build, terminal mode, and complete update-source workflow.

    The LilyGo T-Beam 1W Full Companion maps one press of the physical BOOT button (GPIO0) to a persistent WiFi on/off toggle. Turning WiFi off closes WebConfig, Companion TCP, mOTA, and MQTT network services while USB, BLE, the display, GPS, and LoRa remain available. Another BOOT press turns WiFi back on even after reboot. WiFi/WebConfig starts first; BLE starts two seconds later so the ESP32-S3 can reserve the setup server's internal heap before both radios run together.

    The companion loads runtime credentials saved in NVS. A non-placeholder compile-time WIFI_SSID/WIFI_PWD can be used as a first-boot fallback, but saved credentials take priority. With no credentials, its WebConfig portal starts in setup-AP mode. With credentials, the WebUI is enabled by default on the station IP.

    Full Companion can also be provisioned from its USB terminal or TCP port 5002:

    get wifi.ssid\nget wifi.status\nset wifi.ssid SlowFi\nset wifi.pwd your-password\n

    Credential writes are persisted immediately. After the reply has drained, the Companion restarts its WiFi station with the saved pair. A port-5002 client is expected to disconnect and can reconnect at the new LAN IP. USB masks the password as it is entered; get wifi.pwd is intentionally unavailable. The standalone credential store accepts an empty password, ordinary passphrases up to 63 characters, or an exact 64-character hexadecimal WPA/WPA2 PSK. A non-hexadecimal 64-character value and every longer value are rejected.

    After the saved station first connects following boot, every ESP32 WiFi Companion requests a fresh UTC time from NTP. A verified reply updates both the MeshCore clock and any detected hardware RTC; the next explicit refresh is due 24 hours after that success. A failed request times out without blocking the radio loop and retries after five minutes. While WiFi is disconnected or only the setup AP is available, the request waits for a station connection. Builds with a configured MQTT bridge use its boot-and-daily NTP owner instead, so two firmware services never reconfigure the ESP32 SNTP client at the same time. A later WiFi reconnect does not add another successful-sync request; it preserves the existing 24-hour deadline. Until a fresh internet reply succeeds, LoRa clock consensus remains available as the fallback.

    On Full Companion targets whose primary mesh radio is ESP-NOW, ESP-NOW, the setup AP, and the infrastructure-WiFi station must share one 2.4 GHz channel. The persisted channel defaults to 1 and can be inspected or changed from the text terminal:

    get espnow.channel\nset espnow.channel 6\nreboot\n

    Valid values are 1 through 13; use only a channel permitted in your region and supported by the router. set espnow.channel saves the new value but does not move the running radio; reboot the node to apply it. Every primary ESP-NOW node that should communicate with it must use the same channel, and the configured router's 2.4 GHz radio must be fixed to that channel as well. A router left on automatic channel selection can move the infrastructure connection away from the ESP-NOW mesh.

    espnow.channel configures the node's primary ESP-NOW mesh radio. It is distinct from bridge.channel, which applies only to the separate ESP-NOW bridge feature. WiFi power saving does not let ESP-NOW and infrastructure WiFi operate on different channels. On an ESP32 Full build whose primary radio is ESP-NOW, wifi.powersave max is unavailable because a station using maximum modem sleep can miss ESP-NOW broadcasts; use min for coexistence. The primary mesh transport holds the driver's RF wake reference continuously, so min does not suspend its ESP-NOW receiver.

    ESP-NOW compatibility also depends on the bytes and PHY used above the common Espressif transport. The historical *_repeater_bridge_espnow firmware wraps each MeshCore packet with a magic value, checksum, and secret-based XOR, while Generic_ESPNOW and SenseCapIndicator-ESPNow send the serialized MeshCore packet directly using the ESP-NOW LR PHY. Updated bridge firmware can select either behavior without changing the board target:

    get bridge.format\nset bridge.channel 1\nset bridge.format raw\n

    On a Heltec V4, the ordinary companion_radio_full image remains a LoRa-primary Companion and does not instantiate this bridge. Select and flash the existing heltec_v4_repeater_bridge_espnow role before using these commands. Treat that Companion-to-repeater change as a role/partition migration: back up the node and use the exact V4 merged install artifact.

    wrapped remains the default and interoperates with existing bridge nodes that use the same channel and bridge.secret. raw interoperates with primary-ESP-NOW nodes and other raw bridges; it requires channel 1-13, enables LR reception, forces LR transmission, and ignores bridge.secret. Receive parsing is strict to the selected format, so migrate peers deliberately rather than expecting one bridge to accept both. Match bridge.channel on the LoRa gateway to the primary nodes' espnow.channel; a primary-node channel change takes effect after its reboot.

    Raw mode retains MeshCore's normal packet parsing, authentication/encryption, and the bridge's duplicate suppression, but it does not provide the wrapper's secret-based network isolation. That XOR wrapper is itself only lightweight isolation, not cryptographic security. Multiple LoRa/ESP-NOW gateways covering the same nodes can temporarily amplify duplicates, even though seen-packet tracking prevents an individual bridge from immediately echoing a packet back.

    If the configured network remains unavailable for two minutes, the companion opens its setup AP so the credentials can be repaired. It continues retrying the saved network. WiFi modem sleep has its own persisted wifi.powersave setting and is independent of the Companion device power-saving setting. The WiFi card in Companion WebConfig exposes none, min, and max. Fresh Cascade-profile builds select min; target-default builds select none. A saved setting takes precedence after an upgrade.

    WiFi-only Companions can use all three modes. A Full Companion that runs WiFi and BLE simultaneously requires at least minimum modem sleep. It reports min when an old or target-default none value is found and rejects a new none selection. When ESP-NOW is also the primary mesh radio, max is unavailable: the access point does not buffer ESP-NOW broadcasts for a sleeping station, so maximum modem sleep can lose them. Firmware caps a previously saved max value to and reports it as min, and rejects a new max selection.

    The SenseCAP Indicator Full targets select BLE or infrastructure WiFi instead of running both. On a WiFi-selected boot, the LoRa layout accepts none|min|max, while the ESP-NOW layout accepts none|min and still rejects max. On a BLE-selected boot infrastructure WiFi is off; the LoRa layout accepts min|max for the saved WiFi setting, while ESP-NOW + BLE requires min. Device power saving remains independent of this modem policy.

    WiFi companions do not have the repeater/room-server admin CLI password model, so their LAN WebConfig page is intentionally unauthenticated. Use them only on a trusted LAN.

    The WebConfig Advanced card exposes device power saving on WiFi Companion, repeater, and room-server builds. It also exposes RX power saving on radio chips that support receive duty cycling. RXPS can select continuous receive, levels 1-10, automatic or explicit 16/32-symbol timing assumptions, or manual receive/sleep windows. The timing assumption does not change the radio's actual wire preamble. The WebConfig WiFi card exposes WiFi modem power saving. All three settings are persisted across reboots.

    The three settings are independent. A WiFi Companion keeps its transports available while device power saving reduces CPU and GPS idle power. An infrastructure node can sleep when device power saving is enabled, so its WiFi services may be temporarily unavailable. RXPS only duty-cycles the LoRa receiver. Fresh Cascade-profile builds default to device power saving on, RXPS on at level 8 with a 16-symbol preamble, and WiFi modem power saving at min on every build that includes ESP32 WiFi. The RXPS preamble value is a saved timing assumption, not the wire length: starting with v1.17.1.5, SF5-SF8 packets normally use a 32-symbol physical preamble. Firmware selects 64 for the whole SF/BW tuple only when 32 cannot enable RXPS at any level and 64 can (SF5/BW250 and SF6/BW500), then 128 only when neither shorter length works (SF5/BW500).

    When ENABLE_OTA is included, a WiFi companion also listens on:

    These ports do not replace the companion protocol on TCP 5000. On a companion_radio_full build, port 5002 is the same role-specific text terminal available over USB, including chat, remote administration, radio and power settings, WiFi/WebConfig management, tempradio, and the source-only ota commands. Other OTA-enabled Companion builds expose the same local maintenance commands as binary command 0x42, plus ota ... commands. LoRa staging and installation on the Full Companion itself remain disabled.

    Port 5002 is plaintext and has no independent login gate. Use it only on a trusted LAN or temporary setup network, especially when entering a remote-node admin password with the terminal's login command.

    FULL ESP32 builds share the port 5001 folder seeder. It starts whenever that role has a usable WiFi station or setup access point and stops when WiFi stops. For example, a FULL repeater can run start webconfig to join its saved network, then accept:

    motatool serve --dir ./motas --tcp <repeater-ip>:5001 -v\n

    The TCP connection supplies .mota files for the node to advertise and relay over LoRa; it is not a raw .bin uploader. start ota continues to provide the direct browser uploader on HTTP port 80. A FULL role without WebConfig but with browser OTA support can use the MeshCore-OTA access point raised by start ota; its seeder address is 192.168.4.1:5001.

    Only one external folder link can be active. A TCP client is rejected while ota folder on is using USB serial, and disconnecting motatool automatically removes the TCP folder. Port 5001 has no login layer, so expose it only on a trusted LAN or temporary setup network. Firmware target and hash checks still apply at the receiving node, along with its configured signature/trust policy.

    When the shared seeder is running, get wifi.status appends its live state:

    OTA TCP 5001: listening\nOTA TCP 5001: client connected\n

    The first state means WiFi is usable and the node is waiting for motatool serve --tcp. The second means a motatool folder is currently attached and available for LoRa OTA service.

    "},{"location":"WiFi/#wifi-companion-with-mqtt","title":"WiFi companion with MQTT","text":"

    A *_companion_radio_wifi_mqtt build combines both systems:

    The companion owns WiFi connection and recovery in this build. The MQTT bridge waits for that connection rather than creating a second one. Stopping MQTT does not disable the TCP companion service.

    MQTT-capable Full Companions also combine these services. The WebConfig page contains MQTT settings, and the USB/TCP and browser terminals accept the same mqtt.* settings as infrastructure for shared features. Use set mqtt.enabled on|off to control MQTT without discarding broker settings. Use set logging.output off|usb|wifi|both to select both outputs together.

    "},{"location":"WiFi/#webconfig-without-mqtt","title":"WebConfig without MQTT","text":"

    Full-size ESP32 repeater and room-server builds can include WebConfig even when MQTT is absent. The portal then shows node, radio, and WiFi-related controls but removes the MQTT wizard step and MQTT tab.

    In this case WebConfig owns WiFi only while it is needed. Stopping the portal disconnects WiFi and turns the WiFi radio off. There is no persistent MQTT connection keeping WiFi active.

    Unified FULL USB + WiFi and FULL logging-fallback repeater/room-server builds both provide these CLI controls and status checks. The unified profile includes the MQTT bridge; the fallback is used only where no matching MQTT environment exists:

    get wifi.ssid\nget wifi.status\nget wifi.powersave\nget wifi.cli\nget webui\nset wifi.ssid SlowFi\nset wifi.pwd your-password\nset wifi.powersave none\nset wifi.cli on\n

    get wifi.status distinguishes an unconfigured node, an inactive WiFi radio, a station connection attempt, the setup AP, a connection failure, and a working LAN connection. For a LAN connection it reports the SSID, IP address, and RSSI. If the shared OTA seeder is active, the same reply also reports whether TCP port 5001 is listening or has a motatool client attached. get wifi.powersave reports the saved standalone WebConfig setting as none, min, or max. An inactive status is normal when webui is off: run start webconfig to connect temporarily. Standalone credentials can be changed through WebConfig or with the listed CLI commands. Changing the SSID or password stops an active WebConfig session; start it again to connect with the new values. Use set wifi.pwd with no value for an open network. get wifi.pwd returns the saved password on a local connection; LoRa callers cannot read it. Standalone WiFi accepts ordinary passphrases up to 63 characters and exact 64-character hexadecimal WPA/WPA2 PSKs; other 64-character values and all longer values are rejected. MQTT observer WiFi passwords remain limited to 63 characters by their fixed persisted layout.

    get webui starts with the saved boot setting, then reports the current session. For example, > off, http://192.168.1.130/ means automatic WebConfig startup is saved as off, but a temporary session started by start webconfig is currently active at that URL.

    "},{"location":"WiFi/#webconfig-cli-terminal","title":"WebConfig CLI terminal","text":"

    The terminal defaults to on. Enable or disable it from an existing admin CLI:

    set wifi.cli on\nset wifi.cli off\nget wifi.cli\n

    The saved on setting becomes active only when the WiFi station client is connected and WebConfig is running in LAN mode. It is never exposed on the open setup access point. When active, the WebConfig page has a CLI tab. Repeater and room-server commands use their local CLI after admin authentication. The browser can run get password, get prv.key, erase, set freq, stats-core, stats-radio, stats-radio-diag, stats-packets, and explicit WiFi/MQTT credential getters. It also streams get acl and raw log dumps back to the browser. Log reads pause for slow clients and resume as output is read, using a fixed 160-byte file chunk. A dump snapshots the file length, so live logging cannot extend it indefinitely. The browser keeps the complete requested dump, including files larger than 64 KiB.

    Companion uses the same complete terminal as USB and Full Companion TCP port 5002, including card, import meshcore://..., list, to, messages, remote login, and delayed replies. It uses the Companion's trusted LAN. See Companion browser terminal for contact import, terminal ownership, and disconnect behavior.

    Type a command at the prompt, or paste multiple lines and confirm the sequence. Blank lines and comments are ignored. The device reports the supported batch limit. The browser sends each line after the previous command finishes and continues polling for incoming text after the sequence. help lists the device's actual terminal commands; Tab completes command names and settings.

    The up/down arrow keys recall commands entered during the current browser session. Commands available in the terminal still depend on the firmware role and build profile. Commands such as stop webconfig, set wifi.cli off, WiFi credential changes, and reboot operations stop the remaining block and can disconnect the page before it receives their final reply.

    "},{"location":"WiFi/#build-profiles","title":"Build profiles","text":"

    build.sh produces several profiles. A profile changes the features compiled into a selected target; it does not change that target into another firmware role.

    This fork defaults every build.sh target to the USA Cascadia radio preset and the Cascade firmware profile. The script resolves the live USA/Canada entry by name, so its changing number in the downloaded menu does not affect builds. If the preset service is unavailable, it falls back to 910.525 MHz / BW62.5 / SF7 / CR5. Use --radio-preset target --profile default only when a build intentionally needs the target's original radio and profile defaults. --radio-preset usa-cascadia is the stable explicit name; legacy numbered choices remain accepted but their meaning can change when the service reorders or adds presets.

    Build profile WiFi/MQTT behavior Standard Uses the selected target's role. Where USB is a safe plaintext console, the same artifact embeds debug/packet logging behind persistent get/set usb.logging. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. Legacy logging No separate artifact is emitted; USB logging is part of the ordinary image. Logging output itself is not a direct MQTT uplink. MQTT Builds explicit MQTT observer or WiFi-companion-MQTT targets with USB packet logging off. Non-companion ESP32 MQTT observers always use FULL expanded partitions. FULL ESP32 USB + WiFi Uses the board's MQTT target with USB packet logging and direct WiFi MQTT together, expanded dual-OTA partitions, up to 254 neighbors, LoRa OTA, and full-size ESP32 features such as WebConfig where supported. get/set logging.output off\\|usb\\|wifi\\|both persists the active paths. Classic T-Beam MQTT observers retain their 50-entry table because their persistent discovery state exhausts internal DRAM at 254. FULL ESP32 logging fallback Uses the board's non-MQTT target only when no matching WiFi MQTT environment exists. It keeps debug and packet logging, expanded dual-OTA partitions, up to 254 neighbors, and LoRa OTA. Persistent usb.logging off also provides normal output-off operation, so ESP-NOW FULL roles need no second non-logging image. LoRa-OTA no-external-sensors A lean repeater image with no MQTT; ESP32 builds retain the compact on-demand browser WiFi uploader and up to 254 neighbors, subject to target RAM limits.

    Repeater profiles use up to 254 neighbors. Selected Generic E22, Heltec V2, Meshadventurer, T-Beam OTA/MQTT, and TLora MQTT profiles use 50 to preserve internal-DRAM reserves. Consult the artifact capability manifest and ESP32 memory budget for the exact target.

    The interactive Option 1 FULL everything choice and the standalone FULL command select the unified USB + WiFi image when a matching MQTT target exists; otherwise they select the logging fallback. The build matrix no longer emits any separate standard logging image, or a non-MQTT FULL twin for a role covered by the unified image. Expanded Full infrastructure profiles include LoRa OTA, WebConfig where supported, up to 254 neighbors, and expanded dual-OTA partitions. Target-specific internal-DRAM limits still apply.

    FULL images change the ESP32 partition layout. Flash the matching *-merged.bin once when installing that layout. A partition-layout change can invalidate NVS, including saved WiFi, MQTT, name, and admin settings. Later updates using the same layout normally preserve them.

    "},{"location":"WiFi/#wifi-power-behavior","title":"WiFi power behavior","text":"

    MQTT observers and FULL standalone ESP32 repeater/room-server builds support:

    set wifi.powersave none\nset wifi.powersave min\nset wifi.powersave max\n

    Fresh Cascade-profile builds default to min; target-default builds use none, which gives the most predictable MQTT and radio performance at the highest power use. min and max reduce power but may add latency or reduce reliability on busy nodes. A saved operator setting takes precedence on an upgrade. ESP32 WiFi Companions expose the same values in their WebConfig WiFi card and USB text terminal. Full Companion also accepts the text commands from TCP port 5002. The normal binary Companion protocol can read or write the setting over USB, BLE, or TCP port 5000 without entering terminal mode. A Full Companion that runs BLE and infrastructure WiFi simultaneously rejects none because coexistence requires WiFi modem sleep. An ESP32 Full Companion whose primary mesh radio is ESP-NOW also rejects max; maximum modem sleep can cause the station to miss ESP-NOW broadcasts because the access point does not buffer them. A previously saved max value is capped to and reported as min. The SenseCAP Indicator exception uses the active-mode rules above rather than assuming BLE and infrastructure WiFi are active together. ESP-NOW and infrastructure WiFi still must use the same fixed channel.

    MQTT observer targets normally limit ESP32 WiFi transmit power to 11 dBm unless the board configuration overrides MQTT_WIFI_TX_POWER. This setting affects WiFi only, not LoRa transmit power.

    "},{"location":"WiFi/#recognizing-the-wrong-firmware","title":"Recognizing the wrong firmware","text":"

    get wifi.status, get wifi.ssid, get wifi.powersave, and get wifi.cli are available on MQTT observers and on FULL non-MQTT repeater/room-server builds with WebConfig. ESP32 WiFi Companions also expose all four commands, credential setters, and start webconfig [ap] through their USB text terminal. Their browser CLI defaults to on in station/LAN mode. Full Companion also exposes its complete role-specific text terminal on TCP port 5002, including chat and streaming commands. MQTT commands such as get mqtt.status and set mqtt1.preset ... require MQTT code in the image, on Companion or infrastructure. Unknown settings return Error: unknown setting: <name>. Older firmware that used the discontinued compact CLI can instead report Unsupported in this firmware when a command was cut for space.

    Check the complete firmware filename and role. In particular:

    Rolling firmware back does not restore settings erased by a full flash or a partition-table change. If the correct MQTT target still has no configuration, provision WiFi and MQTT again.

    "},{"location":"WiFi/#troubleshooting","title":"Troubleshooting","text":"

    For an MQTT observer:

    get wifi.status\nget mqtt.enabled\nget mqtt.status\nget mqtt1.diag\nget mqtt2.diag\n

    Common causes are:

    For a WiFi companion, find its station IP in the router, connect the client to TCP port 5000, and use the open MeshCore-Setup-XXXX AP at http://192.168.4.1/ if it cannot join the saved network. Current firmware normalizes both ESP32 WiFi interfaces to standard b/g/n before advertising the setup AP. Full Companion targets whose primary mesh radio is ESP-NOW use b/g/n+LR instead and keep the setup AP, infrastructure station, and mesh on the persisted espnow.channel (channel 1 by default). Their configured 2.4 GHz router and every other primary ESP-NOW node must use that same channel. MQTT-capable Companions also accept get mqtt.enabled, get mqtt.running, and get mqtt.status in their USB/TCP and browser terminals. Extended slot and NTP diagnostics remain infrastructure features.

    "},{"location":"cli_build_matrix/","title":"CLI Availability by Firmware Build","text":"

    MeshCore command availability is determined in three layers:

    1. Role - repeater, room server, sensor, companion, bridge, or KISS modem.
    2. Build profile - standard, logging, OTA, or FULL.
    3. Compiled hardware features - WiFi, MQTT, GPS, external sensors, PSRAM, Ethernet, and similar optional support.

    The complete command descriptions are in CLI Commands. This page describes the commands intentionally omitted or limited by build profile. The CLI Command Availability Matrix expands this summary into separate nRF52 and ESP32 command tables.

    "},{"location":"cli_build_matrix/#selecting-a-build-profile","title":"Selecting a build profile","text":"

    The runtime settings profile and the feature profile are separate switches:

    bash build.sh build-firmware RAK_3401_repeater \\\n  --profile cascade \\\n  --build-profile auto\n

    --profile default|cascade selects saved-setting defaults. It does not select which code is linked. --build-profile auto|standard|full controls features and partition policy:

    Selection Behavior auto For one explicit target, pass 1 builds the complete supported LoRa-OTA-capable recipe. ESP32 boards with a qualified expanded profile use it; other repeaters attempt the complete recipe in their current application region. A non-repeater keeps every capability declared by its resolved PlatformIO recipe, including LoRa OTA, and fails instead of silently removing one that does not fit. A measured repeater flash/partition overflow starts the standard no_external_sensors LoRa OTA pass. Internal-flash nRF52 repeaters publish that reduced pass even when the complete image fits, because the smaller running image leaves more room to stage a delta; matched QSPI/SD repeaters do not need the redundant artifact. Compiler errors and missing-capability checks never trigger or conceal a reduced build. Canonical bulk commands keep their established standard partition contract. standard Immediately uses the deployed/portable partition contract and its documented reductions. This is useful when the operator already knows the expanded or complete image is unsuitable. full Requires a qualified ESP32 expanded-partition target (or an explicitly named Full Companion). Install a matching merged image when this changes the partition table.

    Successful builds also emit <firmware>.capabilities.json. The sidecar records the effective profile, logical OTA target, actual PlatformIO base, artifact name target, promised capabilities, and every reduction selected by the script. The build fails if a promised linked marker is absent. Current invariants include retry.preset for repeater/room-server roles, WebConfig for the Indicator and ESP32 Full Companion, TempRadio/OTA controls for install-capable Companions, host-mOTA controls for Full Companion, and the advanced flood-rule engine for Full room servers. Resume mode will not accept an old or failed artifact without a verified sidecar.

    For a single ESP32 repeater or room-server target, auto selects its matching combined Full MQTT/USB/WiFi image when that image covers the plain Full recipe. The direct observer and Full-matrix paths use the same full-usb-wifi-ota artifact and runtime output controls. Plain Full T-Beam SX1262/SX1276 profiles and the TLora V2 repeater remain alternatives because their MQTT siblings have lower routing-table capacities.

    Standard and reduced OTA images, including images that fit the 1.25 MiB portable application slot, remain available and remain in the standard matrix pass. Select --build-profile standard to retain the original target and partition contract. Moving from a plain target to its MQTT sibling changes the LoRa OTA target identity; existing nodes need an appropriate wired migration. The default cascade settings profile enables device and LoRa RX power saving; saved preferences take precedence, and active USB/network services can prevent device sleep. --profile default instead uses the upstream power-saving defaults.

    "},{"location":"cli_build_matrix/#role-comes-first","title":"Role comes first","text":"Role Text administration CLI Repeater Full repeater administration surface, subject to the profile differences below Room server Room-server administration surface, subject to the profile differences below Sensor Sensor command surface; it does not acquire the repeater administration tree Full, serial, Ethernet, USB, BLE, or WiFi companion Uses the companion protocol; Full combines every qualified transport for that exact board KISS modem Uses the KISS/TNC frame interface, not the repeater text CLI Bridge Uses its base role plus commands for the bridge transport compiled into that target

    A command belonging to a different role is not considered a profile cut. For example, adding FULL features to a sensor does not turn it into a repeater administrator.

    Repeater profiles use up to 254 neighbor entries. Measured internal-DRAM limits reduce selected Generic E22, Heltec V2, Meshadventurer, T-Beam OTA/MQTT, and TLora MQTT profiles to 50. Check the artifact capability manifest and ESP32 memory budget for the exact target.

    See local maintenance commands for USB, BLE/binary Companion, TCP/Ethernet, and browser CLI privileges. A direct connection can access local diagnostics and explicit credential getters; relaying a command over LoRa keeps its remote restrictions.

    "},{"location":"cli_build_matrix/#profile-matrix","title":"Profile matrix","text":"Build/profile Command availability Standard non-MQTT repeater or room server Keeps the normal role CLI and, where USB is a safe plaintext console, embeds debug/packet logging behind persistent get/set usb.logging. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. Legacy standard logging No longer emitted separately. Its behavior is compiled into the ordinary artifact. Size-constrained STM32 targets embed packet logging without verbose MESH_DEBUG. LoRa-OTA (-ota-) LoRa OTA adds the ota ... commands; it does not otherwise reduce the role CLI. ESP32 no_external_sensors artifacts retain the compact browser WiFi uploader, the complete CLI, and up to 254 neighbors, subject to recorded internal-DRAM reductions. Internal-flash nRF52 repeater auto pair full-ota retains the board's external-sensor drivers; reduced-ota omits the declared optional sensors to leave additional internal-flash staging room. RAK3401 and RAK4631 reduced builds retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors at a measured cost below 5 KiB. Both artifacts carry the same stable OTA target identity and are checked for ota ... and retry.preset; RAK artifacts also verify the retained monitor drivers. ESP32 MQTT observer or ESP-NOW bridge Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. FULL ESP32 USB + WiFi Uses the matching MQTT target with packet logging on, verbose debug off, and the complete command surface supported by that role and hardware. get/set logging.output off\\|usb\\|wifi\\|both selects and persists the active output paths. FULL ESP32 logging fallback Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. nRF52 dual-CDC Full Companion Fresh installs expose only interface 00; it starts as an ASCII terminal and automatically hands a complete < frame to framed Companion. The same interface also carries exclusive serial mOTA traffic. Enabling logging and rebooting adds interface 02 for plaintext logs. BLE and source-only LoRa OTA remain available. get/set usb.logging persistently controls whether the logging interface is present. ESP32 single-TTY Full Companion Every ESP32 Full image starts with the ASCII terminal on its one USB TTY and automatically hands a complete < frame to framed Companion. On 1.17.1.5, run set powersaving off first. set usb.logging on switches that TTY to an input-capable plaintext logging terminal and makes framed Companion unavailable on USB; set usb.logging off stops logging but leaves the TTY in normal ASCII mode. The terminal stop token or a valid incoming framed probe then performs the ordinary switch to Binary Companion. A saved logging-on setting starts directly in that logging terminal and disables automatic frame detection. BLE, WiFi, and source-only LoRa OTA remain available. ESP32 Full uses Arduino-ESP32 2.x where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but still expose only one TTY. no_external_sensors Trims selected optional environmental/ranging drivers and their settings; it does not remove generic I2C, core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because RAK12501/L76K GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility.

    The four retained INA drivers are entries in the optional environmental-sensor table, not the complete set of RAK I2C consumers. Compatible reduced profiles also retain the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 I2C GPS as separate board peripherals. RAK12501/L76K GPS uses UART Serial1. The explicit RAK4631 Serial1 bridge omits the combined GPS provider, including the otherwise non-UART RAK12500 path.

    The firmware-configured INA3221 and RAK12500 addresses are both 0x42. To use both devices on one bus, leave RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and build with -DTELEM_INA3221_ADDRESS=0x43.

    logging, OTA, and FULL describe independent build features in historical filenames. Current standard artifacts use no -logging- infix because their USB logging is runtime controlled.

    "},{"location":"cli_build_matrix/#canonical-bulk-build-policy","title":"Canonical bulk-build policy","text":"

    Bulk and release-matrix commands omit legacy names whose behavior is already available from a canonical image:

    The old aliases still work with build-firmware and build-matching-firmwares. Dedicated LoRa OTA repeater images are not collapsed; they retain their exact storage, bootloader, role, and target identity contracts. Companion boards keep transport-specific canonical images only when no exact Full recipe has passed the combined flash/RAM qualification. ESP32 deliberately uses one TTY: Binary Companion and plaintext USB logging are mutually exclusive there. nRF52 retains its optional second CDC port.

    "},{"location":"cli_build_matrix/#complete-cli-policy","title":"Complete CLI policy","text":"

    The compact ESP32 CLI has been removed. retry.preset is a checked invariant for every repeater and room-server artifact, not a FULL-only command. MQTT observers and ESP-NOW bridges are automatically promoted to FULL builds with expanded partitions rather than dropping administration commands. This keeps tempradio, LoRa OTA, power saving, RXPS, logging, statistics, sensor, ACL, routing, and advanced radio commands whenever the role and compiled hardware support them.

    Some commands still have hardware or feature limits. MQTT neighbor-table publishing and discover.scopes require the compiled WITH_MQTT_NEIGHBORS feature. PSRAM boards enable it automatically, and selected non-PSRAM variants opt in with MQTT_NEIGHBORS_WITHOUT_PSRAM. discover.neighbors does not require MQTT or PSRAM.

    "},{"location":"cli_build_matrix/#discovery-invariant","title":"Discovery invariant","text":"

    discover.neighbors sends the zero-hop node-discovery request used to refresh the repeater neighbor table. It is available in every repeater build profile, including standard, logging, OTA, unified FULL, and FULL logging-fallback builds.

    The exact command is:

    discover.neighbors\n

    It accepts no options and returns:

    OK - Discover sent\n

    Some MQTT room-server targets also expose the command as part of their compiled neighbor-table feature. It is not a cross-role guarantee: companion, KISS, sensor, and ordinary room-server firmware use different interfaces or do not maintain the repeater administrator neighbor table.

    "},{"location":"cli_build_matrix/#feature-dependent-commands","title":"Feature-dependent commands","text":"

    Even in a FULL build, a command can be unavailable when its underlying feature does not exist on that target:

    When diagnosing an unavailable command, check the role first, then the filename profile, then the target's compiled hardware features and its adjacent .capabilities.json file. A current artifact that promises the command but does not contain its linked marker is rejected during the build.

    "},{"location":"cli_command_availability/","title":"CLI Command Availability Matrix","text":"

    This page is the command-by-command companion to CLI Availability by Firmware Build. Each command name links to its detailed documentation.

    The tables cover the text administration CLI used by repeater, room-server, sensor, and bridge firmware. Full Companion also has its own ASCII USB/TCP terminal; see feature switches by role. Companion apps use the companion protocol, KISS firmware uses framed KISS/TNC commands, and terminal-chat firmware has its own interface, so those build roles are not represented in the main matrices. The Companion section below lists its framed maintenance commands. Infrastructure get password is available locally in every profile; LoRa requests cannot read it. get acl and stored packet log are infrastructure features, not Companion contact or live USB-logging commands.

    Build columns mean:

    The firmware-configured INA3221 and RAK12500 addresses are both 0x42; they cannot coexist on one bus at those addresses. Leave RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use a build with -DTELEM_INA3221_ADDRESS=0x43 when both are installed.

    Cell values mean:

    Runtime state can still make an included command fail. Examples include no GPS fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without .mota apply support.

    "},{"location":"cli_command_availability/#companion-framed-cli","title":"Companion framed CLI","text":"

    Companion builds are not represented by the role/profile matrices below. Their local terminal and command 0x42 (CMD_RUN_CLI_COMMAND) expose these settings over the normal binary USB, BLE, or TCP connection:

    Command Availability stats-core, stats-radio, stats-radio-diag, stats-packets Local terminal and binary command 0x42 erase Local terminal and binary command 0x42; reboot after erasing stored settings set freq <MHz> Local terminal, binary command 0x42, and authorized LoRa CLI; reboot to apply get prv.key Local terminal and binary command 0x42; requires private key export enabled get password Reports that Companion has no admin password; infrastructure returns its password locally get wifi.pwd, get mqttN.password, get mqttN.token Local terminal and binary command 0x42; corresponding WiFi/MQTT feature required board Every Companion text terminal and command 0x42 version Every Companion text terminal and protocol-v14 command 0x42; returns the untruncated build identity get storage.layout Every Companion text terminal and command 0x42; reports the running platform's storage layout memory ESP32 Full Companion text terminal and command 0x42 get/set radio.rxgain Companion with a supported radio get/set radio.fem.rxgain Companion on a board with controllable LoRa FEM LNA get/set wifi.powersave ESP32 WiFi Companion; active transport constraints still apply get/set bluetooth.name Companion firmware get/set bluetooth.mac Every Companion build with Bluetooth get/set display.touch Touchscreen Companions using the shared UI; text terminal and framed CLI; off after reboot get/set display.inbox Shared Companion UI; history (default), pending, or unread; saved across reboot uf2reset Every nRF52 Companion text terminal and local command 0x42; local only

    Deprecated binary aliases remain receive-only for older clients; new clients should use command 0x42. See Companion radio binary protocol.

    "},{"location":"cli_command_availability/#nrf52","title":"nRF52","text":"Area Command Scope Standard Logging LoRa OTA Operational reboot All text CLI roles Yes Yes Yes Operational poweroff; shutdown Board power-off support Yes Yes Yes Operational uf2reset Local connection; supported UF2 boards Local Local Local Operational clkreboot All text CLI roles Yes Yes Yes Operational clock sync All text CLI roles Yes Yes Yes Operational clock All text CLI roles Yes Yes Yes Operational time <epoch_seconds> Clock only moves forward Yes Yes Yes Operational advert Advert-capable role Yes Yes Yes Operational advert.zerohop Advert-capable role Yes Yes Yes Operational erase Local connection Local Local Local Information ver All text CLI roles Yes Yes Yes Information board All text CLI roles Yes Yes Yes Diagnostics get storage.layout Internal layout and configured QSPI or SD storage Yes Yes Yes Diagnostics memory ESP32 only No No No Diagnostics sensor Hardware wiring summary Yes Yes Yes Diagnostics powerlog Reset, shutdown, and boot-voltage summary Yes Yes Yes Neighbors neighbors Role with a neighbor table Yes Yes Yes Neighbors neighbor.remove [pubkey_prefix] Role with a mutable neighbor table; omit prefix to clear all Yes Yes Yes Neighbors discover.neighbors Repeater; some MQTT room servers Yes Yes Yes Neighbors discover.scopes MQTT observer with compiled neighbor support No No No Statistics clear stats All full-parser text CLI roles Yes Yes Yes Statistics stats-core Local connection Local Local Local Statistics stats-radio Local connection Local Local Local Statistics stats-radio-diag Local connection Local Local Local Statistics stats-packets Local connection Local Local Local Statistics get telemetry.temp/volt/volt.i2c; optional GPS history Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator Yes Yes Yes Statistics set telemetry.gps; get/set/send telemetry.tx Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator Yes Yes Yes Logging log start; log stop; log erase Storage-backed roles retain data; other roles can return empty data Yes Yes Yes Logging log Local connection Local Local Local Logging get/set usb.logging; unified FULL get/set logging.output Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; nRF52 Full Companion can add a reboot-controlled second CDC Yes Yes No Radio get radio; set radio ... All text CLI roles Yes Yes Yes Radio get tx; set tx <dbm> Board TX-power limits apply Yes Yes Yes Radio tempradio ...; normalradio Full parser Yes Yes Yes Radio get/set/del radioat; get/set/del tempradioat Full parser Yes Yes Yes Radio get freq; set freq <mhz> Local or authorized LoRa CLI Yes Yes Yes Radio get/set radio.rxgain Supported radio Feature Feature Feature Radio get/set radio.fem.rxgain Controllable LoRa FEM Feature Feature Feature Radio get/set radio.fem.txgain Controllable LoRa FEM Feature Feature Feature Radio get/set radio.rxps; get rxps.wd RX power-saving support Feature Feature Feature System get/set name All text CLI roles Yes Yes Yes System get/set lat All text CLI roles Yes Yes Yes System get/set lon All text CLI roles Yes Yes Yes System get/set prv.key get requires a local connection Yes Yes Yes System get password; password <new_password> get requires a local connection; setter requires administrator Yes Yes Yes System get/set guest.password Role with guest administration Yes Yes Yes System get/set owner.info All text CLI roles Yes Yes Yes System get/set adc.multiplier Board ADC override support Feature Feature Feature System send text.flood <message> Repeater Yes Yes Yes System host <text>; get host Repeater with host bridge; host requires a remote administrator and a USB/MQTT service Yes Yes Yes System get/set battery.alert; get battery.alert.region Repeater Yes Yes Yes System get/set battery.alert.low; get/set battery.alert.critical Repeater Yes Yes Yes System get/set rx.watchdog Repeater Yes Yes Yes System get/set system.watchdog nRF52 Yes Yes Yes System get public.key All text CLI roles Yes Yes Yes System get role All text CLI roles Yes Yes Yes System get powersaving; set powersaving on/off Supported repeater board Feature Feature Feature System get/set reboot.interval Full parser Yes Yes Yes Clock sync get/set clock.sync.*; clock.sync.mesh now Repeater, sensor, and room server; clock.sync.internet needs MQTT repeater Yes Yes Yes Routing get/set repeat Forwarding-capable role Yes Yes Yes Routing get/set path.hash.mode Role that supports path-hash selection Yes Yes Yes Routing get/set loop.detect Repeater Yes Yes Yes Routing get/set txdelay All text CLI roles, including bridges Yes Yes Yes Routing get/set direct.txdelay Full parser Yes Yes Yes Routing get/set rxdelay All text CLI roles, including bridges Yes Yes Yes Routing get/set dutycycle Full parser Yes Yes Yes Routing get/set af Full parser Yes Yes Yes Routing get/set int.thresh All text CLI roles Yes Yes Yes Routing get/set cad Radio CAD support Feature Feature Feature Routing get/set agc.reset.interval All text CLI roles Yes Yes Yes Routing get/set radio.watchdog MQTT observer No No No Routing get/set multi.acks Full parser Yes Yes Yes Routing get/set flood.advert.interval Repeater Yes Yes Yes Routing get/set advert.interval Advert-capable role Yes Yes Yes Routing get/set flood.max Repeater Yes Yes Yes Routing get/set flood.max.unscoped Repeater Yes Yes Yes Routing get/set flood.max.advert Repeater Yes Yes Yes Routing get/set flood.channel.data; get/set flood.channel.data.hops Repeater Yes Yes Yes Routing get/set/del flood.channel.scope* Repeater Yes Yes Yes Routing get/set/del flood.channel.scope.require* Repeater Yes Yes Yes Routing get/set/del flood.rule*; get/set/del flood.filter*; get/set/del flood.filter.blacklist* Repeater; flood.rule/flood.filter also on FULL ESP32 room server (no blacklist) Yes Yes Yes Routing get/set/del flood.moderation* Repeater Yes Yes Yes Routing get/set outpath Repeater remote-client context Yes Yes Yes Routing get/set altpath Repeater remote-client context Yes Yes Yes ACL setperm <pubkey> <permissions> Repeater, room server, or sensor Yes Yes Yes ACL get acl [page] Repeater, room server, or sensor; admin over LoRa Yes Yes Yes ACL get/set allow.read.only Room server Yes Yes No Regions region load; region save Role with region storage Yes Yes Yes Regions region allowf; region denyf Role with region storage Yes Yes Yes Regions region get; region list; region Role with region storage Yes Yes Yes Regions region home; region default Role with region storage Yes Yes Yes Regions region put; region def; region remove Role with region storage Yes Yes Yes Retry get/set direct.retry; get/set direct.retry.heard Role with basic retry support Yes Yes Yes Retry get/set retry.preset Role with retry support Yes Yes Yes Retry get/set flood.retry.count; get/set flood.retry.path; get/set flood.retry.group.path Repeater Yes Yes Yes Retry get/set flood.retry.advert; get/set flood.retry.prefixes; get/set flood.retry.ignore Repeater Yes Yes Yes Retry get/set flood.retry.bridge; get/set flood.retry.bucket Repeater bridge retry support Feature Feature Feature Retry get/set direct.retry.count; get/set direct.retry.base; get/set direct.retry.step Role with retry support Yes Yes Yes Retry get/set direct.retry.margin; get/set direct.retry.cr Role with retry support Yes Yes Yes Retry get/set/clear recent.repeater; get recent.repeaters Repeater Yes Yes Yes GPS get gps; set gps on/off Compiled GPS Feature Feature Feature GPS gps sync Compiled GPS Feature Feature Feature GPS gps setloc Compiled GPS Feature Feature Feature GPS gps advert [none/share/prefs] Compiled GPS Feature Feature Feature Sensors get/set telemetry.access Sensor-capable role Feature Feature Limited Sensors sensor list [start] Compiled sensor manager; reduced OTA trims target-selected optional environmental/ranging drivers Feature Feature Limited Sensors sensor get; sensor set Compiled sensor setting; reduced OTA trims target-selected optional environmental/ranging drivers Feature Feature Limited Sensors io [r/s/t]<hex> Simple sensor role with board GPIO support Feature Feature No Bridge get bridge.type Compiled bridge Feature Feature Feature Bridge get/set bridge.enabled; get bridge.running Compiled bridge Feature Feature Feature Bridge get/set bridge.delay Compiled bridge Feature Feature Feature Bridge get/set bridge.source Compiled bridge Feature Feature Feature Bridge get/set bridge.baud RS-232 bridge Feature Feature Feature Bridge get/set bridge.uart RS-232 bridge Feature Feature Feature Bridge get/set bridge.channel ESP-NOW is ESP32 only No No No Bridge get/set bridge.format ESP-NOW bridge No No No Bridge get/set bridge.secret ESP-NOW is ESP32 only No No No Board get bootloader.ver nRF52 bootloader metadata Yes Yes Yes Board get pwrmgt.support; get pwrmgt.source; get pwrmgt.bootreason; get pwrmgt.bootmv Board power-management implementation Feature Feature Feature Ethernet eth.status Ethernet target Feature Feature No Browser OTA start ota [ap]; stop ota ESP32 browser uploader No No No WebConfig start webconfig [ap]; stop webconfig; get/set webui ESP32 WebConfig No No No WiFi get/set wifi.ssid; set wifi.pwd; get wifi.status; get/set wifi.powersave ESP32 WiFi No No No WiFi get/set wifi.cli ESP32 WebConfig No No No LoRa OTA ota help; ota ?; ota h LoRa OTA build No No Yes LoRa OTA ota; ota status; ota st LoRa OTA build No No Yes LoRa OTA ota stats LoRa OTA build No No Yes LoRa OTA ota ls; ota neighbors; ota nbrs; ota updates; ota n LoRa OTA build No No Yes LoRa OTA ota get; ota pull; ota download LoRa OTA build; internal-store nRF52 installs in-place deltas, while matched SD/QSPI nRF52 also accepts full images No No Yes LoRa OTA ota install; ota apply; ota applydelta Compatible bootloader and completed update No No Yes LoRa OTA ota rescue install <base_hash16> Legacy internal-flash nRF52 LoRa OTA build with failed app-side EndF validation; shared bootloader-update builds require valid EndF No No Feature LoRa OTA ota bootloader; ota bootloader install <MID8> <HASH16> Allowlisted internal-flash, XIAO-QSPI, or exact MeshTower V2 microSD nRF52 repeater with ABI-3 exact-board OTAFIX No No Feature LoRa OTA ota cancel; ota drop; ota stop LoRa OTA build No No Yes LoRa OTA ota announce; ota adv LoRa OTA build No No Yes LoRa OTA ota self; ota id Firmware with EndF trailer No No Yes LoRa OTA ota qspi; ota storage nRF52 build with raw-QSPI OTA staging Feature Feature Feature LoRa OTA ota folder; ota fold on needs compiled folder transport No No Feature LoRa OTA ota config; ota cfg; ota set LoRa OTA build No No Yes LoRa OTA ota key; ota keys LoRa OTA build No No Yes LoRa OTA ota dev ... Developer diagnostics No No Yes MQTT get/set mqttN.preset MQTT observer No No No MQTT get/set mqttN.server; get/set mqttN.port; get/set mqttN.username; get/set mqttN.password MQTT observer No No No MQTT get/set mqttN.token; get/set mqttN.topic; get/set mqttN.audience MQTT observer No No No MQTT get mqttN.diag MQTT observer No No No MQTT get/set mqtt.origin; get/set mqtt.iata; get mqtt.presets MQTT observer No No No MQTT get mqtt.stats MQTT observer No No No MQTT get/set mqtt.status; get/set mqtt.packets; get/set mqtt.raw; get/set mqtt.interval MQTT observer No No No MQTT get/set mqtt.rx; get/set mqtt.tx MQTT observer No No No MQTT get/set mqtt.neighbors; get/set mqtt.neighbors.interval MQTT observer with compiled neighbor support No No No MQTT get/set mqtt.ntp MQTT observer No No No MQTT get mqtt.ntp.diag Full MQTT observer No No No MQTT get/set timezone; get/set timezone.offset MQTT observer No No No MQTT get/set mqtt.analyzer.us; get/set mqtt.analyzer.eu Legacy MQTT aliases No No No MQTT get/set mqtt.owner; get/set mqtt.email MQTT observer; get requires a local connection No No No MQTT get mqtt.config.valid MQTT observer No No No SNMP get/set snmp; get/set snmp.community MQTT target compiled with SNMP No No No Alerts get/set alert; get/set alert.psk; get/set alert.hashtag; get/set alert.region; get/set alert.wifi; get/set alert.mqtt; get/set alert.interval MQTT observer No No No Alerts alert test [message] MQTT observer with configured alert channel No No No TLS tls.bundletest <host> MQTT target with embedded certificate bundle No No No Manifest OTA ota check MQTT target with OTA_MANIFEST_BASE No No No Manifest OTA ota update MQTT target with OTA_MANIFEST_BASE No No No"},{"location":"cli_command_availability/#esp32","title":"ESP32","text":"Area Command Scope Standard Logging LoRa OTA FULL unified FULL fallback Operational reboot All text CLI roles Yes Yes Yes Yes Yes Operational poweroff; shutdown Board power-off support Yes Yes Yes Yes Yes Operational uf2reset nRF52 only No No No No No Operational clkreboot Full parser Yes Yes Yes Yes Yes Operational clock sync All text CLI roles Yes Yes Yes Yes Yes Operational clock All text CLI roles Yes Yes Yes Yes Yes Operational time <epoch_seconds> Clock only moves forward Yes Yes Yes Yes Yes Operational advert Advert-capable role Yes Yes Yes Yes Yes Operational advert.zerohop Advert-capable role Yes Yes Yes Yes Yes Operational erase Local connection Local Local Local Local Local Display get/set display.mode; display.timeout; display.usb.mode; display.usb.timeout Display-equipped roles; pairing modes require BLE Companion Feature Feature Feature Feature Feature Display get/set display.flip MQTT observer with supported display driver No No No Feature No Information ver All text CLI roles Yes Yes Yes Yes Yes Information board All text CLI roles Yes Yes Yes Yes Yes Diagnostics get storage.layout Detected flash size and live partition table Yes Yes Yes Yes Yes Diagnostics memory ESP32 heap and PSRAM summary Yes Yes Yes Yes Yes Diagnostics sensor Hardware wiring summary Yes Yes Yes Yes Yes Diagnostics powerlog Reset-reason summary Yes Yes Yes Yes Yes Neighbors neighbors Role with a neighbor table Yes Yes Yes Yes Yes Neighbors neighbor.remove [pubkey_prefix] Role with a mutable neighbor table; omit prefix to clear all Yes Yes Yes Yes Yes Neighbors discover.neighbors Repeater; some MQTT room servers Yes Yes Yes Yes Yes Neighbors discover.scopes MQTT observer with compiled neighbor support No No No Feature No Statistics clear stats Full parser Yes Yes Yes Yes Yes Statistics stats-core Local connection Local Local Local Local Local Statistics stats-radio Local connection Local Local Local Local Local Statistics stats-radio-diag Local connection Local Local Local Local Local Statistics stats-packets Local connection Local Local Local Local Local Statistics get telemetry.temp/volt/volt.i2c; optional GPS history Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator Yes Yes Yes Yes Yes Statistics set telemetry.gps; get/set/send telemetry.tx Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator Yes Yes Yes Yes Yes Logging log start; log stop; log erase Storage-backed roles retain data Yes Yes Yes Yes Yes Logging log Local connection Local Local Local Local Local Logging get/set usb.logging; unified FULL get/set logging.output Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; every ESP32 Full Companion uses an input-capable single-TTY logging terminal with framed USB Companion disabled while logging Yes Yes No No Yes Radio get radio; set radio ... All text CLI roles Yes Yes Yes Yes Yes Radio get tx; set tx <dbm> Board TX-power limits apply Yes Yes Yes Yes Yes Radio tempradio ...; normalradio Full parser Yes Yes Yes Yes Yes Radio get/set/del radioat; get/set/del tempradioat Full parser Yes Yes Yes Yes Yes Radio get freq; set freq <mhz> Local or authorized LoRa CLI Yes Yes Yes Yes Yes Radio get/set radio.rxgain Supported radio Feature Feature Feature Feature Feature Radio get/set espnow.channel Primary ESP-NOW mesh radio Feature Feature Feature Feature Feature Radio get/set radio.fem.rxgain Controllable LoRa FEM Feature Feature Feature Feature Feature Radio get/set radio.fem.txgain Controllable LoRa FEM Feature Feature Feature Feature Feature Radio get/set radio.rxps; get rxps.wd Full parser and RX power-saving support Feature Feature Feature Feature Feature System get/set name All full-parser text CLI roles Yes Yes Yes Yes Yes System get/set lat All full-parser text CLI roles Yes Yes Yes Yes Yes System get/set lon All full-parser text CLI roles Yes Yes Yes Yes Yes System get/set prv.key get requires a local connection Yes Yes Yes Yes Yes System get password; password <new_password> get requires a local connection; setter requires administrator Yes Yes Yes Yes Yes System get/set guest.password Role with guest administration Yes Yes Yes Yes Yes System get/set owner.info Full parser Yes Yes Yes Yes Yes System get/set adc.multiplier Board ADC override support Feature Feature Feature Feature Feature System send text.flood <message> Repeater Yes Yes Yes Yes Yes System host <text>; get host Repeater with host bridge; host requires a remote administrator and a USB/MQTT service Yes Yes Yes Yes Yes System get/set battery.alert; get battery.alert.region Repeater Yes Yes Yes Yes Yes System get/set battery.alert.low; get/set battery.alert.critical Repeater Yes Yes Yes Yes Yes System get/set rx.watchdog Repeater Yes Yes Yes Yes Yes System get/set system.watchdog nRF52 only No No No No No System get public.key Full parser Yes Yes Yes Yes Yes System get role All text CLI roles Yes Yes Yes Yes Yes System get powersaving; set powersaving on/off Supported repeater board Feature Feature Feature Feature Feature System get/set reboot.interval Full parser Yes Yes Yes Yes Yes Clock sync get/set clock.sync.*; clock.sync.mesh now Repeater, sensor, and room server; clock.sync.internet needs MQTT repeater Yes Yes Yes Yes Yes Routing get/set repeat Forwarding-capable role Yes Yes Yes Yes Yes Routing get/set path.hash.mode Full parser Yes Yes Yes Yes Yes Routing get/set loop.detect Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set txdelay All text CLI roles, including bridges Yes Yes Yes Yes Yes Routing get/set direct.txdelay Full parser Yes Yes Yes Yes Yes Routing get/set rxdelay All text CLI roles, including bridges Yes Yes Yes Yes Yes Routing get/set dutycycle Full parser Yes Yes Yes Yes Yes Routing get/set af Full parser Yes Yes Yes Yes Yes Routing get/set int.thresh All text CLI roles Yes Yes Yes Yes Yes Routing get/set cad Radio CAD support Feature Feature Feature Feature Feature Routing get/set agc.reset.interval All text CLI roles Yes Yes Yes Yes Yes Routing get/set radio.watchdog MQTT observer No No No Yes No Routing get/set multi.acks Full parser Yes Yes Yes Yes Yes Routing get/set flood.advert.interval Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set advert.interval Full common parser Yes Yes Yes Yes Yes Routing get/set flood.max Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set flood.max.unscoped Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set flood.max.advert Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set flood.channel.data; get/set flood.channel.data.hops Repeater, full common parser Yes Yes Yes Yes Yes Routing get/set/del flood.channel.scope* Repeater role handler Yes Yes Yes Yes Yes Routing get/set/del flood.channel.scope.require* Repeater role handler Yes Yes Yes Yes Yes Routing get/set/del flood.rule*; get/set/del flood.filter*; get/set/del flood.filter.blacklist* Repeater role handler; flood.rule/flood.filter also on FULL ESP32 room server (no blacklist) Yes Yes Yes Yes Yes Routing get/set/del flood.moderation* Repeater role handler Yes Yes Yes Yes Yes Routing get/set outpath Repeater remote-client context Yes Yes Yes Yes Yes Routing get/set altpath Repeater remote-client context Yes Yes Yes Yes Yes ACL setperm <pubkey> <permissions> Repeater, room server, or sensor Yes Yes Yes Yes Yes ACL get acl [page] Repeater, room server, or sensor; admin over LoRa Yes Yes Yes Yes Yes ACL get/set allow.read.only Room server Yes Yes No Yes Yes Regions region load; region save Role with region storage Yes Yes Yes Yes Yes Regions region allowf; region denyf Role with region storage Yes Yes Yes Yes Yes Regions region get; region list; region Role with region storage Yes Yes Yes Yes Yes Regions region home; region default Role with region storage Yes Yes Yes Yes Yes Regions region put; region def; region remove Role with region storage Yes Yes Yes Yes Yes Retry get/set direct.retry; get/set direct.retry.heard Role with basic retry support Yes Yes Yes Yes Yes Retry get/set retry.preset Role with retry support Yes Yes Yes Yes Yes Retry get/set flood.retry.count; get/set flood.retry.path; get/set flood.retry.group.path Repeater Yes Yes Yes Yes Yes Retry get/set flood.retry.advert; get/set flood.retry.prefixes; get/set flood.retry.ignore Repeater Yes Yes Yes Yes Yes Retry get/set flood.retry.bridge; get/set flood.retry.bucket Repeater bridge retry support Feature Feature Feature Feature Feature Retry get/set direct.retry.count; get/set direct.retry.base; get/set direct.retry.step Role with retry support Yes Yes Yes Yes Yes Retry get/set direct.retry.margin; get/set direct.retry.cr Role with retry support Yes Yes Yes Yes Yes Retry get/set/clear recent.repeater; get recent.repeaters Repeater Yes Yes Yes Yes Yes GPS get gps; set gps on/off Compiled onboard GPS Feature Feature Feature Feature Feature GPS gps sync Compiled onboard GPS Feature Feature Feature Feature Feature GPS gps setloc Compiled onboard GPS Feature Feature Feature Feature Feature GPS gps advert [none/share/prefs] Compiled onboard GPS Feature Feature Feature Feature Feature Sensors get/set telemetry.access Sensor-capable full parser Feature Feature Limited Feature Feature Sensors sensor list [start] Compiled sensor manager; no_external_sensors trims target-selected environmental/ranging drivers, not generic I2C Feature Feature Limited Feature Feature Sensors sensor get; sensor set Compiled sensor setting; no_external_sensors trims target-selected environmental/ranging drivers, not generic I2C Feature Feature Limited Feature Feature Sensors io [r/s/t]<hex> Simple sensor role with board GPIO support Feature Feature No No Feature Bridge get bridge.type Compiled bridge Feature Feature Feature Yes Feature Bridge get/set bridge.enabled; get bridge.running Compiled bridge Feature Feature Feature Yes Feature Bridge get/set bridge.delay Compiled bridge Feature Feature Feature Yes Feature Bridge get/set bridge.source Compiled bridge Feature Feature Feature Yes Feature Bridge get/set bridge.baud RS-232 bridge Feature Feature Feature No Feature Bridge get/set bridge.uart RS-232 bridge Feature Feature Feature No Feature Bridge get/set bridge.channel ESP-NOW bridge No No No Feature Feature Bridge get/set bridge.format ESP-NOW bridge No No No Feature Feature Bridge get/set bridge.secret ESP-NOW bridge No No No Feature Feature Board get bootloader.ver nRF52 only No No No No No Board get pwrmgt.support; get pwrmgt.source; get pwrmgt.bootreason; get pwrmgt.bootmv nRF52 only No No No No No Ethernet eth.status Ethernet target Feature Feature No No Feature Browser OTA start ota [ap]; stop ota Compiled browser uploader No No Yes Feature Feature WebConfig start webconfig [ap]; stop webconfig; get/set webui Compiled WebConfig No No No Feature Feature WiFi get/set wifi.ssid; set wifi.pwd; get wifi.status; get/set wifi.powersave MQTT WiFi, or standalone FULL WebConfig; standalone has no get wifi.pwd No No No Yes Feature WiFi get/set wifi.cli Compiled WebConfig No No No Feature Feature LoRa OTA ota help; ota ?; ota h LoRa OTA build No No Yes Yes Yes LoRa OTA ota; ota status; ota st LoRa OTA build No No Yes Yes Yes LoRa OTA ota stats LoRa OTA build No No Yes Yes Yes LoRa OTA ota ls; ota neighbors; ota nbrs; ota updates; ota n LoRa OTA build No No Yes Yes Yes LoRa OTA ota get; ota pull; ota download LoRa OTA build No No Yes Yes Yes LoRa OTA ota install; ota apply; ota applydelta Compatible completed update No No Yes Yes Yes LoRa OTA ota rescue install <base_hash16> Legacy internal-flash nRF52 LoRa OTA build with failed app-side EndF validation; shared bootloader-update builds require valid EndF No No Feature No No LoRa OTA ota bootloader; ota bootloader install <MID8> <HASH16> Allowlisted nRF52 internal/QSPI repeater feature No No No No No LoRa OTA ota cancel; ota drop; ota stop LoRa OTA build No No Yes Yes Yes LoRa OTA ota announce; ota adv LoRa OTA build No No Yes Yes Yes LoRa OTA ota self; ota id Firmware with EndF trailer No No Yes Yes Yes LoRa OTA ota qspi; ota storage nRF52 build with raw-QSPI OTA staging No No No No No LoRa OTA ota folder; ota fold on needs compiled serial or TCP folder transport No No Feature Feature Feature LoRa OTA ota config; ota cfg; ota set LoRa OTA build No No Yes Yes Yes LoRa OTA ota key; ota keys LoRa OTA build No No Yes Yes Yes LoRa OTA ota dev ... Developer diagnostics No No Yes Yes Yes MQTT get/set mqttN.preset MQTT observer No No No Yes No MQTT get/set mqttN.server; get/set mqttN.port; get/set mqttN.username; get/set mqttN.password MQTT observer No No No Yes No MQTT get/set mqttN.token; get/set mqttN.topic; get/set mqttN.audience MQTT observer No No No Yes No MQTT get mqttN.diag MQTT observer No No No Yes No MQTT get/set mqtt.origin; get/set mqtt.iata; get mqtt.presets MQTT observer No No No Yes No MQTT get mqtt.stats MQTT observer No No No Yes No MQTT get/set mqtt.status; get/set mqtt.packets; get/set mqtt.raw; get/set mqtt.interval MQTT observer No No No Yes No MQTT get/set mqtt.rx; get/set mqtt.tx MQTT observer No No No Yes No MQTT get/set mqtt.neighbors; get/set mqtt.neighbors.interval MQTT observer with compiled neighbor support No No No Feature No MQTT get/set mqtt.ntp MQTT observer No No No Yes No MQTT get mqtt.ntp.diag MQTT observer No No No Yes No MQTT get/set timezone; get/set timezone.offset MQTT observer No No No Yes No MQTT get/set mqtt.analyzer.us; get/set mqtt.analyzer.eu Legacy MQTT aliases No No No Yes No MQTT get/set mqtt.owner; get/set mqtt.email MQTT observer; get requires a local connection No No No Yes No MQTT get mqtt.config.valid MQTT observer No No No Yes No SNMP get/set snmp; get/set snmp.community MQTT target compiled with SNMP No No No Feature No Alerts get/set alert; get/set alert.psk; get/set alert.hashtag; get/set alert.region; get/set alert.wifi; get/set alert.mqtt; get/set alert.interval MQTT observer No No No Yes No Alerts alert test [message] MQTT observer with configured alert channel No No No Yes No TLS tls.bundletest <host> MQTT target with embedded certificate bundle No No No Feature No Manifest OTA ota check MQTT target with OTA_MANIFEST_BASE No No No Manifest No Manifest OTA ota update MQTT target with OTA_MANIFEST_BASE No No No Manifest No"},{"location":"cli_command_availability/#supplemental-command-notes","title":"Supplemental command notes","text":"

    These short entries cover implemented commands that do not yet have their own section in the primary command reference.

    "},{"location":"cli_command_availability/#memory","title":"memory","text":"

    ESP32 only. Prints free heap, minimum heap, largest allocation, queue depth, internal heap, and PSRAM totals.

    "},{"location":"cli_command_availability/#sensor-hardware-summary","title":"sensor hardware summary","text":"

    Prints the configured I2C pins and GPS serial pins. This is a wiring/build diagnostic, distinct from sensor list, sensor get, and sensor set.

    "},{"location":"cli_command_availability/#powerlog","title":"powerlog","text":"

    Prints the last reset reason. nRF52 also prints the captured shutdown reason and boot voltage.

    "},{"location":"cli_command_availability/#stats-radio-diag","title":"stats-radio-diag","text":"

    Local-serial diagnostic that prints the extended radio diagnostic counters.

    "},{"location":"cli_command_availability/#radiorxps-and-rxpswd","title":"radio.rxps and rxps.wd","text":"

    get radio.rxps reports RX power-saving state and timing. set radio.rxps ... changes that configuration. get rxps.wd reports the soft and hard RX power-saving watchdog counters.

    "},{"location":"cli_command_availability/#rebootinterval","title":"reboot.interval","text":"

    get reboot.interval shows the scheduled reboot interval. Use set reboot.interval <hours> for 1-255 hours, or set reboot.interval 0 to disable it.

    "},{"location":"cli_command_availability/#sensor-io","title":"Sensor io","text":"

    The simple-sensor role exposes its board GPIO word as hexadecimal. io reads it; io <hex> replaces it; and io r<hex>, io s<hex>, or io t<hex> clear, set, or toggle selected bits.

    "},{"location":"cli_command_availability/#get-mqttndiag","title":"get mqttN.diag","text":"

    Prints the runtime diagnostic summary for MQTT slot N, including its connection state and failure history.

    "},{"location":"cli_command_availability/#get-mqttstats","title":"get mqtt.stats","text":"

    Prints the MQTT bridge runtime statistics summary.

    "},{"location":"cli_command_availability/#get-mqttconfigvalid","title":"get mqtt.config.valid","text":"

    Reports whether the saved MQTT configuration has the minimum values needed to run.

    "},{"location":"cli_command_availability/#tlsbundletest","title":"tls.bundletest","text":"

    tls.bundletest <host[:port]|url> tests the embedded TLS certificate bundle against a remote host without changing the MQTT configuration.

    "},{"location":"cli_command_availability/#manifest-ota-check-and-ota-update","title":"Manifest ota check and ota update","text":"

    On MQTT targets with OTA_MANIFEST_BASE, ota check fetches and checks the target manifest. ota update downloads and applies the compatible update selected by that manifest.

    "},{"location":"cli_commands/","title":"CLI Commands","text":"

    For copy/paste on/off recipes and the differences from Full Companion, see feature switches by role. The USB web console opens the default ASCII terminal at 115200 baud; it does not require on-device WebConfig or WiFi.

    This document provides an overview of CLI commands that can be sent to MeshCore Repeaters, Room Servers and Sensors.

    See CLI Availability by Firmware Build for the role and profile matrix. Commands depend on compiled features; some portable builds omit WebConfig while retaining the complete role CLI and compact WiFi updater.

    See CLI Command Availability Matrix for the command-by-command nRF52 and ESP32 build tables.

    The first word of a command is case-insensitive, so set, Set, and SET are equivalent, as are get, Get, and the other command verbs. The case of arguments such as node names, passwords, and keys is left unchanged.

    Use the site search or your browser's Find command with everyday wording such as tx retries, retry attempts, serial logging, or tx power. Search terms below are alternative wording to help find a command, not additional CLI aliases. Enter the syntax shown under Usage; supported command aliases are listed there explicitly.

    "},{"location":"cli_commands/#navigation","title":"Navigation","text":""},{"location":"cli_commands/#operational","title":"Operational","text":""},{"location":"cli_commands/#reboot-the-node","title":"Reboot the node","text":"

    Search terms: restart, restart node, reboot device.

    Usage: - reboot

    Note: No reply is sent.

    "},{"location":"cli_commands/#power-off-the-node","title":"Power-off the node","text":"

    Search terms: turn off device, shut down, power off.

    Usage: - poweroff, or - shutdown

    Note: No reply is sent.

    "},{"location":"cli_commands/#enter-the-uf2-bootloader-nrf52-only","title":"Enter the UF2 bootloader (nRF52 only)","text":"

    Search terms: bootloader mode, USB firmware update, UF2 mode.

    Usage: - uf2reset

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    Note: Reboots directly into the UF2 bootloader on supported nRF52 boards. This includes the Repeater, Room Server, Sensor, Companion, and Terminal Chat local serial command surfaces. It is never accepted as a remote mesh command.

    "},{"location":"cli_commands/#reset-the-clock-and-reboot","title":"Reset the clock and reboot","text":"

    Usage: - clkreboot

    Note: No reply is sent.

    "},{"location":"cli_commands/#sync-the-clock-with-the-remote-device","title":"Sync the clock with the remote device","text":"

    Usage: - clock sync

    "},{"location":"cli_commands/#display-current-time-in-utc","title":"Display current time in UTC","text":"

    Usage: - clock

    "},{"location":"cli_commands/#set-the-time-to-a-specific-timestamp","title":"Set the time to a specific timestamp","text":"

    Usage: - time <epoch_seconds>

    Parameters: - epoch_seconds: Unix epoch time

    "},{"location":"cli_commands/#send-a-flood-advert","title":"Send a flood advert","text":"

    Usage: - advert

    "},{"location":"cli_commands/#send-a-zero-hop-advert","title":"Send a zero-hop advert","text":"

    Usage: - advert.zerohop

    "},{"location":"cli_commands/#ask-a-usb-connected-host-service-over-lora-repeater-only","title":"Ask a USB-connected host service over LoRa (Repeater Only)","text":"

    Usage:

    host hands one remote LoRa CLI request to a service on the repeater's USB host and returns that service's reply over LoRa. It does not execute the text inside the firmware. The service has 4 seconds to prove the exact request is still pending, then 6 seconds to execute and reply. A second remote command receives busy while either phase is pending.

    Request text is limited to 155 UTF-8 bytes, or 152 bytes when a companion uses its legacy three-byte correlation prefix. The complete LoRa reply is limited to 162 bytes. USB records use Base64URL text, a random 64-bit nonce, and the repeater identity's Ed25519 signature. Before executing anything, the service must return a random one-time challenge and receive the repeater's signed live proof. This avoids any dependency on repeater/Pi clock agreement. Every host.reply USB command must carry the matching request ID and nonce; it is not accepted from LoRa or the Ethernet CLI.

    The included Raspberry Pi endpoint supports exact commands for help, cpu-temp, hostname, uptime, load, memory, disk-free, and clock status. It also provides strictly validated clock sync and clock set <unix_epoch> recovery actions, opt-in network restart and reboot actions, action status <operation_id>, and run <alias> [arguments] for locally allowlisted executables with typed arguments. Clock changes and host recovery actions are disabled by default, arbitrary executables are never accepted, and no action uses a shell. Clock set accepts only canonical unsigned decimal epochs from 2020 through 2099; a root-owned Unix-socket service revalidates the request and authenticates the local service account with SO_PEERCRED before changing system time. Network restart and reboot use a separate root-owned Unix-socket broker with fixed systemd units; installing clock control does not grant those actions. The endpoint never uses sudo for privileged host actions. See LoRa CLI host service for the complete meshcoretomqtt setup and security model.

    "},{"location":"cli_commands/#start-or-stop-an-over-the-air-ota-firmware-update","title":"Start or stop an Over-The-Air (OTA) firmware update","text":"

    Search terms: WiFi OTA, wireless firmware update, OTA uploader, update firmware.

    Usage: - start ota - start ota ap - stop ota

    On nRF52, start ota invokes Bluetooth DFU with the matching bootloader and application DFU ZIP. The WiFi/AP instructions below apply to ESP32.

    On ESP32, start ota serves the web upload page on the station IP when connected to WiFi; otherwise it raises the MeshCore-OTA access point. start ota ap always raises the access point, which is useful when the normal network uses client isolation.

    On an ESP32 build with WebConfig, the manual OTA uploader and WebConfig both use HTTP port 80 and cannot run together. Stop WebConfig before start ota, or stop OTA before start webconfig.

    FULL ESP32 builds also expose the .mota folder seeder on TCP port 5001 whenever WiFi is usable. This listener is independent of port 80, so a host can run motatool serve --dir ./motas --tcp <node-ip>:5001 while WebConfig is active. The TCP connection auto-attaches and detaches the folder; do not run ota folder on at the same time because that command selects the USB-serial folder transport.

    "},{"location":"cli_commands/#browser-configuration-portal-esp32-repeater-and-room-server","title":"Browser configuration portal (ESP32 repeater and room server)","text":"

    Search terms: web UI, web interface, WiFi settings page, configuration website, WebConfig.

    Usage:

    set webui on is the persistent master switch. It starts the portal now and again after future reboots; set webui off closes it and disables that boot start. get webui reports the saved on/off state plus whether the portal is inactive, joining WiFi, serving a setup AP, or serving a LAN URL. The default is off on repeater and room-server builds. On display-equipped repeaters, an otherwise-unused triple click toggles the same saved setting. Consequently, > off, http://192.168.1.130/ means the saved boot setting is off while a temporary WebConfig session is currently active at that URL.

    start webconfig is a temporary start that does not change the saved switch. It serves the shared WiFi, radio, flood, loop, and status page and reports its URL. MQTT builds also show the MQTT tab and wizard step; non-MQTT builds remove them entirely. Sign in with the node's admin password. If the node has no saved WiFi SSID, the command starts the open MeshCore-Setup-XXXX captive AP at http://192.168.4.1/ instead.

    start webconfig ap forces captive-AP mode. It will not interrupt an active MQTT bridge, so run set mqtt.enabled off first. Use stop webconfig to close either mode for the current boot. (stop webconfig does not change a saved webui on.) LAN mode otherwise remains active until reboot. On expanded FULL builds, an unconfigured automatic setup AP receives one absolute 30-minute window per boot, then powers WiFi off even if a client remains attached; rebooting starts a new automatic window. An administrator can explicitly run start webconfig again without rebooting. Once an SSID is saved, the cutoff no longer applies and the selected WiFi/MQTT mode keeps reconnecting. Other setup sessions retain their profile's idle timeout.

    Every ESP32 build with WebConfig supports the browser command terminal, including WiFi Companion and Full Companion. Companions use their complete USB/TCP terminal on the trusted LAN, including contact import, chat, recipient selection, and delayed replies. Repeater and Room Server use their local CLI after admin authentication.

    To add a Companion contact, run import meshcore://<full-contact-card-data>, then list to confirm it appears. Get the complete card with card on the other Companion. A public key alone is insufficient. See the terminal command guide for to, send, login, and cmd examples and session ownership.

    The saved wifi.cli setting defaults to on. Use set wifi.cli off to disable the CLI tab. get wifi.cli reports off, on, waiting for WiFi client, or on, active. The saved setting becomes active only in station/LAN mode while the WiFi client is connected. It is deliberately unavailable on the open setup access point. The tab displays each reply in the browser, including local diagnostics, get password, private-key and WiFi/MQTT credential reads, and erase. Infrastructure get acl and raw log stream to the requesting browser, with backpressure for slow clients. These maintenance commands retain their LoRa restrictions. set freq works both here and through authorized LoRa CLI. The bounded /api/cli endpoint accepts local maintenance commands; use /api/terminal for complete ACL and log listings. Select Command block to paste up to 100 commands with one command per line. Blank lines are ignored, and every nonblank line must fit the normal 159-byte CLI command limit. The browser sends the lines sequentially and waits for each reply before sending the next line. The queue exists only in that browser page; closing it stops any commands that have not yet been sent. A lost connection also stops the remaining block.

    Up/down arrow keys recall commands from the current browser session in single-command mode. A command that stops WebConfig, changes its WiFi connection, disables wifi.cli, or reboots the node stops the remaining block and can close the page before its reply is collected.

    On unified FULL USB + WiFi and FULL logging-fallback ESP32 repeater/room-server builds, get wifi.ssid reports the saved standalone WebConfig network and get wifi.status reports whether WiFi is unconfigured, off, connecting, running the setup AP, failed, or connected. A connected result includes the SSID, LAN IP, and RSSI. When the shared OTA seeder is running, the reply appends OTA TCP 5001: listening or OTA TCP 5001: client connected. WiFi being off is normal while WebConfig is inactive; run start webconfig when a temporary connection is wanted. get wifi.powersave reports the saved standalone setting as none, min, or max. Fresh Cascade-profile builds default to min; target-default builds use none. A saved setting takes precedence after an upgrade.

    The WiFi set commands work on MQTT observers and on FULL standalone ESP32 repeater/room-server builds. On a standalone build, changing the SSID or password stops an active WebConfig session; run start webconfig again to use the new credentials. set wifi.pwd with no value selects an open network. Standalone WiFi also accepts an exact 64-character hexadecimal WPA/WPA2 PSK; ordinary passphrases remain limited to 63 characters. Other 64-character values and all longer values are rejected. MQTT observer WiFi passwords retain their fixed 63-character limit. Power-save changes are applied immediately when WiFi is running and otherwise take effect on the next connection. get wifi.pwd returns the saved password on a local connection. It remains unavailable or masked over LoRa, and the configuration form continues to mask stored secrets.

    ESP32 WiFi Companion WebConfig exposes the same wifi.powersave values in its WiFi card. Every ESP32 WiFi Companion with WebConfig exposes the standalone wifi.ssid, wifi.status, wifi.powersave, wifi.cli, and WebConfig command families through its USB text terminal. Full Companion exposes the same role-specific terminal on TCP port 5002. Credential writes reply before restarting the WiFi station, so a TCP client should expect to reconnect at the new address; USB password input is masked. Binary Companion clients can use USB, BLE, or TCP port 5000 without the terminal-start token: send command 0x42 (CMD_RUN_CLI_COMMAND) followed by the same CLI text, such as get wifi.powersave, get wifi.pwd, stats-core, or set wifi.powersave min. See local maintenance commands for the complete access rules. WiFi-only Companions accept all three modes. A Full Companion that runs BLE and infrastructure WiFi simultaneously rejects none because coexistence requires modem sleep. Companion device power saving and LoRa radio.rxps remain independent. On an ESP32 Full Companion whose primary mesh radio is ESP-NOW, max is also unavailable: a station using maximum modem sleep can miss ESP-NOW broadcasts, which the access point does not buffer for it. A previously saved conflicting value is capped to and reported as min, and a new conflicting selection is rejected. The primary mesh radio also holds the ESP-IDF WiFi wake reference continuously so unsolicited ESP-NOW frames remain receivable; selecting min does not put that primary receiver to sleep.

    SenseCAP Indicator Full is the exclusive-secondary exception. On a WiFi-selected boot, LoRa accepts none|min|max and ESP-NOW accepts none|min. On a BLE-selected boot infrastructure WiFi is not started; LoRa accepts min|max for the saved WiFi setting, while ESP-NOW + BLE requires min. USB and the primary LoRa or ESP-NOW radio remain available in every mode.

    "},{"location":"cli_commands/#view-or-change-the-primary-esp-nowwifi-channel","title":"View or change the primary ESP-NOW/WiFi channel","text":"

    Usage:

    Parameters:

    Default: 1

    This command is available when ESP-NOW is the node's primary mesh radio. The setting is persisted. A set reply reports that reboot is required; until reboot, the running ESP-NOW radio remains on its previous channel. On builds that also provide ordinary WiFi, its station and setup AP share that channel.

    After reboot, ESP-NOW, the setup AP, and the infrastructure-WiFi station use the selected channel. Every primary ESP-NOW node that must communicate with this node, plus the configured router's 2.4 GHz radio, must use the same fixed channel. WiFi power saving does not allow the transports to use different channels. On an ESP32 Full build with primary ESP-NOW, wifi.powersave max is unavailable because maximum modem sleep can miss ESP-NOW broadcasts; use min for coexistence. The firmware keeps the primary ESP-NOW receiver awake while still using the min WiFi/Bluetooth coexistence policy.

    espnow.channel is the channel of the primary ESP-NOW mesh transport. It is separate from bridge.channel, which configures only an ESP-NOW bridge on a firmware role whose primary mesh radio is LoRa.

    The browser portal is not compiled into the two 4 MB LilyGo_TLora_V2_1_1_6_*_observer_mqtt targets because it does not fit while retaining the two app slots required for LoRa OTA. Their normal CLI settings remain available.

    "},{"location":"cli_commands/#erasefactory-reset","title":"Erase/Factory Reset","text":"

    Search terms: factory defaults, reset settings, erase configuration.

    Usage: - erase

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    Warning: This is destructive!

    "},{"location":"cli_commands/#neighbors-repeater-only","title":"Neighbors (Repeater Only)","text":""},{"location":"cli_commands/#list-nearby-neighbors","title":"List nearby neighbors","text":"

    Search terms: nearby nodes, neighbor list, neighbour list.

    Usage: - neighbors

    Note: The output of this command is limited to the 8 most recent adverts.

    Note: Each line is encoded as {pubkey-prefix}:{timestamp}:{snr*4}

    "},{"location":"cli_commands/#remove-a-neighbor","title":"Remove a neighbor","text":"

    Usage: - neighbor.remove <pubkey_prefix> - neighbor.remove \u2014 remove all neighbors

    Parameters: - pubkey_prefix: An even-length hexadecimal prefix (up to the full 64-character public key). All neighbors matching it are removed. Invalid keys are rejected.

    Omitting the prefix clears the table. An empty or whitespace-only prefix is also accepted for compatibility with the mobile app's Remove All Neighbors action. Newly heard zero-hop adverts can populate the table again.

    "},{"location":"cli_commands/#discover-zero-hop-neighbors","title":"Discover zero hop neighbors","text":"

    Usage: - discover.neighbors

    This command is available in every repeater build profile, including portable MQTT, standard, logging, OTA, unified FULL, and FULL logging-fallback artifacts. It does not require MQTT or PSRAM.

    "},{"location":"cli_commands/#discover-neighbor-scopes-mqtt-observer-neighbors-feature","title":"Discover neighbor scopes (MQTT observer, neighbors feature)","text":"

    Refreshes the zero-hop neighbor table, then queries each neighbor for its region scopes and publishes the assembled table to the MQTT neighbors topic once.

    Usage: - discover.scopes

    Note: Requires an MQTT observer build with the neighbors feature compiled in (all PSRAM boards, plus non-PSRAM boards built with MQTT_NEIGHBORS_WITHOUT_PSRAM). Elsewhere it replies Err - neighbors not enabled in this build. If a discover.neighbors refresh is already in flight, the scope pass is queued behind it.

    "},{"location":"cli_commands/#statistics","title":"Statistics","text":""},{"location":"cli_commands/#list-rate-limited-flood-adverts","title":"List Rate-Limited Flood Adverts","text":"

    Usage: get flood.advert [page] or get flood.advert key <index>

    Lists tracked advert origins currently subject to a forwarding limit. Page 1 is the default, with three entries per page so replies fit both USB and admin LoRa CLI. Each row shows its index, the first 12 public-key hex characters, the limit reason, and the minimum wait before another new advert can pass the automatic limiter. Ordinary forwarding filters still apply.

    get flood.advert\nget flood.advert 2\nget flood.advert key 1\n

    Reasons are quota (the prefix's three-hour forwarding allowance is spent), history (distinct receive history is full), and bad (the full key is under the stricter one-advert-per-12-hours rule). A bad key remains listed when wait=0s: one forward is eligible, but its seven-day recovery is not complete. If several limits apply, bad takes precedence over history, then quota; the displayed wait accounts for all applicable limits.

    The key form returns the complete 64-hex public key, reason, wait, shared prefix forwarding count/quota, shortest received hop count, and remaining bad-list recovery time. Different full keys with the same 12-hex prefix remain separate entries. Indices refer to the current live list and can change as history expires; read the full key before using a targeted clear.

    Queries do not clear history, spend quota, or refresh abuse/recovery timers. Elapsed history expires normally. Origins rejected only because every table slot protects abuse history are not retained and cannot be enumerated. Available on repeaters, room servers, and forwarding sensors through their ordinary local/admin CLI. Companions do not have this table.

    "},{"location":"cli_commands/#clear-automatic-flood-advert-history","title":"Clear Automatic Flood Advert History","text":"

    Usage: clear flood.advert all or clear flood.advert <64-hex-full-public-key>

    Clears automatic advert forwarding quotas, first strikes, and seven-day bad-list history for all keys or one exact full key. Available on repeaters, room servers, and forwarding sensors through their ordinary local/admin CLI (including admin LoRa CLI). A missing selector, a key prefix, or a malformed key is rejected. It does not change saved settings or manual flood rules. Reboot also clears this RAM-only history. See automatic advert limits.

    "},{"location":"cli_commands/#clear-stats","title":"Clear Stats","text":"

    Usage: clear stats

    "},{"location":"cli_commands/#system-stats-battery-uptime-queue-length-and-debug-flags","title":"System Stats - Battery, Uptime, Queue Length and Debug Flags","text":"

    Usage: - stats-core

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    "},{"location":"cli_commands/#radio-stats-noise-floor-last-rssisnr-airtime-receive-errors","title":"Radio Stats - Noise floor, Last RSSI/SNR, Airtime, Receive errors","text":"

    Search terms: signal strength, signal quality, RSSI, SNR, radio noise.

    Usage: stats-radio

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    "},{"location":"cli_commands/#packet-stats-packet-counters-received-sent","title":"Packet stats - Packet counters: Received, Sent","text":"

    Usage: stats-packets

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    "},{"location":"cli_commands/#read-repeater-and-room-server-telemetry-history","title":"Read repeater and room-server telemetry history","text":"

    Repeater and room-server firmware record one UTC-aligned sample every 30 minutes. Temperature and battery voltage retain 336 samples (seven rolling days). Each detected INA219, INA226, INA260, or INA3221 voltage channel retains 192 samples (four rolling days). GPS-capable builds retain three GPS days by default; repeaters request a seven-day default at startup. Builds without a GPS provider omit the GPS history commands to conserve flash. All history and any runtime retention change are held in RAM and reset after a reboot.

    The feature is omitted from flash-constrained STM32 repeater and room images.

    Usage:

    Parameters:

    Local serial and remote administrator CLI sessions can read the history on both roles. Collection uses the MCU temperature, battery voltage, external I2C voltage monitors, and an already-valid onboard GPS fix. It does not wake GPS, so an off, sleeping, or unfixed GPS produces a missing location sample without changing its power-saving schedule.

    External voltage history allocates 360 bytes for each detected monitor channel (1,080 bytes for all three INA3221 inputs). The allocation reserves all detected inputs so a sensor connected later can start recording, but all-zero inputs are omitted from command replies and LoRa transmission.

    Replies contain > followed by standard padded Base64. After decoding, all multi-byte integers are little-endian. Packed fields are written most significant bit first, oldest sample first.

    Use the browser-based Telemetry history decoder to turn a reply into a timestamped table or downloadable CSV without uploading the data.

    telemetry.tx is disabled by default. Its schedule and direct path are stored across reboots. Configuring direct or a routed path enables the default 2d schedule; set telemetry.tx schedule changes it from one through 30 days or turns it off. An automatic run waits until 165 half-hour positions are available, then sends one maximum-size temperature packet, one maximum-size battery-voltage packet, and up to three IVB1 packets for every populated I2C voltage channel. GPS is never included in this raw transmission. The history remains boot-local, so the first scheduled run after a reboot needs about 82.5 hours to fill. On the default two-day interval, each 82.5-hour temperature or battery packet overlaps its predecessor by 34.5 hours. Packets are paced two seconds apart. A queue failure is retried after 30 minutes only for the packet that did not queue; successfully queued packets are not duplicated.

    send telemetry.tx now is an administrator-only test action. It queues temperature and battery-voltage snapshots plus the available I2C voltage chunks over the configured path. It uses all currently available base positions up to 165 and all available I2C positions up to 192 per populated channel. It works while the schedule is off and does not move the next scheduled send time. RAW_CUSTOM direct packets do not enter the normal encrypted direct-message retry mechanism.

    Full snapshots are 184-byte RAW_CUSTOM payloads. Manual tests can be shorter while history fills. Both formats are binary, not Base64 and not encrypted:

    Bytes Meaning 0-3 ASCII magic TTB1 for temperature or TVB1 for voltage 4-11 First eight bytes of the repeater public key 12-15 First sample UTC epoch, unsigned 32-bit little-endian 16-17 Sample interval in minutes, unsigned 16-bit little-endian (30) 18 Sample count (1-165) 19 onward Oldest-to-newest encoded samples for the selected series

    Temperature codes reserve 0 for no reading, 1 for below -50 C, and 2 for above +77 C. Codes 3 through 130 represent exact whole degrees from -50 C through +77 C; decode them as code - 53. Voltage codes use the same encoding as the paged voltage payload documented below.

    External voltage snapshots use a separate packed layout. A full four-day channel takes three 140-byte IVB1 payloads of 64 points each:

    Bytes Meaning 0-3 ASCII magic IVB1 4-11 First eight bytes of the source public key 12-15 First sample UTC epoch, unsigned 32-bit little-endian 16-17 Sample interval in minutes, unsigned 16-bit little-endian (30) 18 Cayenne LPP voltage channel 19 Sample count (1-64) 20 onward Oldest-to-newest packed 15-bit voltage codes

    Code 0 means missing or disconnected. Codes 1-32767 represent 0.02 V through 655.34 V in 0.02 V steps; decode millivolts as code * 20.

    Match bytes 4-11 to the first 16 hex characters of the repeater public key shown by its advert or get public.key. This compact identifier is useful for association but is not authenticated and can be spoofed. An MQTT observer can upload the received raw packet to LetsMesh Analyzer /packets; its packet hex contains the direct-route header and path followed by this payload.

    Temperature payload (0x11, 61 bytes):

    Bytes Meaning 0 Format/type 0x11 1-4 First sample UTC epoch, unsigned 32-bit 5 Sample interval in minutes (30) 6 Sample count (48) 7-18 48 packed 2-bit temperature statuses 19-60 48 packed 7-bit temperatures

    Temperature status codes are 0 none, 1 value, 2 below range, and 3 above range. For status 1, the 7-bit temperature is an exact whole-degree integer from -50 C through +77 C; decode it as code - 50. Low values use code 0, and high values use code 127. The separate status map is required because 7 bits contain exactly 128 codes, leaving no spare code for none, low, or high when the complete range is represented at 1 C resolution. No fractional temperature is stored or transmitted.

    Voltage payload (0x12, 55 bytes):

    Bytes Meaning 0 Format/type 0x12 1-4 First sample UTC epoch, unsigned 32-bit 5 Sample interval in minutes (30) 6 Sample count (48) 7-54 48 8-bit voltage codes

    Voltage codes reserve 0 for no reading, 1 for below 1.88 V, and 255 for above 4.40 V. Codes 2-254 represent 1.88 V through 4.40 V in 0.01 V steps; decode millivolts as 1880 + (code - 2) * 10.

    External I2C voltage payload (0x14, 98 bytes):

    Bytes Meaning 0 Format/type 0x14 1-4 First sample UTC epoch, unsigned 32-bit 5 Sample interval in minutes (30) 6 Sample count (48) 7 Cayenne LPP voltage channel 8-97 48 packed 15-bit voltage codes

    The codes have the same 0.02 V through 655.34 V meaning as IVB1.

    GPS payload (0x13, 101 bytes):

    Bytes Meaning 0 Format/type 0x13 1-4 First sample UTC epoch, unsigned 32-bit 5 Sample interval in minutes (30) 6 Sample count (24) 7-10 Page origin latitude in signed degrees times 10^7 11-14 Page origin longitude in signed degrees times 10^7 15 Origin sample index, or 255 when the page has no GPS fix 16 Flags; bit 0 means at least one differential was clipped 17-100 24 records: signed 14-bit north then signed 14-bit east

    GPS differentials use signed 14-bit two's-complement values at 10-meter resolution and are applied to the preceding decoded valid point. The origin sample begins at the header coordinates. A no-fix slot encodes 0,0 and does not advance the reference; a stationary valid fix also quantizes to 0,0. When a page has no fixes, its origin is 0,0, origin index is 255, and all differentials are 0,0. Values outside -8192 through 8191 are clipped and set flag bit 0.

    "},{"location":"cli_commands/#set-companion-display-rotation","title":"Set Companion display rotation","text":"

    SSD1306 Full Companion builds support a persisted runtime orientation:

    get display.rotation\nset display.rotation 0\nset display.rotation 90\nset display.rotation 180\nset display.rotation 270\n

    The values are clockwise degrees. 0 clears the override and restores the board's compiled default. Unsupported display drivers return an error.

    "},{"location":"cli_commands/#show-touchscreen-touch-areas","title":"Show touchscreen touch areas","text":"

    All touchscreen Companion builds using the shared touch UI support temporary diagnostic outlines, including both SenseCAP Indicator rendering sizes:

    get display.touch\nset display.touch on\nset display.touch off\n

    The default is off at every boot. Enabling wakes the display without selecting anything. Dotted yellow borders mark the actual scaled tap targets; the area under a held finger turns green and returns to yellow on release. Disabling removes the borders on the next redraw. Pairing screens suppress the overlay while they consume navigation. This option does not change the font, touch calibration, navigation actions, or saved settings, and is not persisted.

    Use the text terminal or Companion framed CLI (0x42) through an available USB, BLE, or TCP connection. Non-touchscreen, missing-display, and legacy UI builds report Error: touchscreen diagnostics unsupported. No Indicator-only build flag is needed.

    "},{"location":"cli_commands/#set-display-modes-and-timeouts","title":"Set display modes and timeouts","text":"

    Display-equipped Companions, repeaters, room servers, and sensors have four saved settings. The WebConfig Display card exposes the same controls:

    Setting CLI Default Battery mode get/set display.mode button-pairing on BLE Companions; button elsewhere Battery timeout, seconds get/set display.timeout 15 USB mode get/set display.usb.mode Same as battery mode USB timeout, seconds get/set display.usb.timeout 15

    Modes are off, on, button, pairing, button-pairing, and automatic. Pairing modes are available only on builds supporting BLE pairing.

    Timeouts are whole seconds from 1 to 3600. Use mode on for a permanent display rather than timeout 0. The timeout is retained but unused in off, on, and pairing modes. Settings apply immediately, including while blanked; changing power source applies the other profile without carrying over a wake from the previous profile. Settings survive reboot and filesystem-preserving updates in /display_prefs; erasing the filesystem restores the defaults. An existing observer timeout that differs from its old 60-second default is migrated once to both power profiles; an old zero timeout becomes mode on.

    set display.mode button-pairing\nset display.timeout 15\nset display.usb.mode on\nset display.usb.timeout 60\n

    USB profile selection uses the board's external-power detector, with USB-host detection as a fallback. VBUS-capable boards detect chargers without a data connection. Heltec V4 R8 uses either a detected USB host or a calibrated battery voltage above 4.21 V. The voltage estimate is checked every five seconds and clears at 4.20 V or below; host detection takes effect immediately. This estimate can miss a charger while the battery is low, depends on ADC calibration, and cannot distinguish USB from solar charging. Other boards without a power detector may require a USB host to recognize USB power. Wi-Fi/MQTT connection or BLE pairing is not evidence of USB power. E-paper panels can retain their last image when powered off.

    "},{"location":"cli_commands/#set-companion-inbox-behavior","title":"Set Companion inbox behavior","text":"

    The shared Companion UI (including T114 and R8) defaults to History: the last 32 received text previews remain available on the radio after an app downloads them. Pending separately counts frames waiting for the app, including non-text frames. Downloading a frame clears it from Pending; it does not mean someone read it. The phone's delivery queue is independent of the 32-entry preview history and is never drained by reading on the radio.

    get display.inbox\nset display.inbox history\nset display.inbox pending\nset display.inbox unread\n
    Mode Display behavior history (default) Shows HISTORY with the retained preview count, plus Pending. Downloaded previews remain browseable. pending Shows INBOX with the app queue count and limits the reader to previews still waiting for the app. Non-text frames count toward Pending but have no text preview. unread Shows UNREAD with the number of retained previews not yet displayed on the radio, plus Pending. Opening a preview on a lit display marks it read; app downloads do not. Read previews remain browseable.

    All modes retain up to 32 previews internally; switching from pending back to history makes retained downloaded previews visible again. Evicted or rejected queue entries are not shown as pending. Previews and local read state are in RAM and reset on reboot. The mode applies immediately and survives reboot in /display_prefs; existing version-1 display settings load with history as the default. Legacy and tiny Companion UIs report Error: inbox modes unsupported.

    Incoming messages automatically open their preview only when no client is connected. While connected, new messages neither select another page nor move the preview being read; history stays accessible by button or touch. The home screen identifies connected USB and Bluetooth clients (or both), and TCP, Ethernet, or serial clients where supported. Actual BLE pairing prompts retain priority over connection status.

    "},{"location":"cli_commands/#set-mqtt-observer-display-flip","title":"Set MQTT observer display flip","text":"

    Supported observer displays, including the R8 OLED and ST7789 panels, can also be turned 180 degrees relative to their compiled orientation:

    get display.flip\nset display.flip off\nset display.flip on\n

    0 and 1 are accepted aliases for off and on. This is intentionally different from Full Companion display.rotation: observer display.flip is a relative 180-degree mounting choice and cannot switch between portrait and landscape. The value is harmless on an observer display driver that does not support flipping.

    Flip survives reboot and firmware updates that preserve the filesystem; erasing flash restores the off default. The boot log reports the saved flip state on display-enabled observer builds.

    "},{"location":"cli_commands/#logging","title":"Logging","text":"

    Builds compiled with MESH_PACKET_LOGGING emit one RAW: line for every received radio frame. Serial output uses backpressure: if a connected host temporarily stops reading, packet processing waits for USB transmit space instead of silently omitting the record. A disconnected host cannot retain an unbounded capture, so logging deployments should keep the reader attached and draining the serial port.

    Every valid received frame also emits the decoded RX summary, including signal, timing, hash, type, route, and payload information. Frames that cannot be decoded still emit their RAW: line. Transmitted packets emit the decoded TX summary.

    Ordinary non-OTA artifacts compile packet logging into the canonical image and control its live USB output at runtime; no separate -logging- artifact is emitted. Use the separately named -ota- artifact when LoRa OTA is required. A -full-usb-wifi-ota- artifact combines USB packet logging, direct WiFi MQTT, LoRa OTA, and the expanded FULL feature set. A -full-logging-ota- artifact is emitted only when that hardware/role has no matching WiFi MQTT environment.

    "},{"location":"cli_commands/#control-live-usb-logging","title":"Control live USB logging","text":"

    Search terms: serial logging, USB debug log, enable logging, disable logging, debug output.

    Usage:

    get usb.logging\nset usb.logging on\nset usb.logging off\nset usb.logging on reboot\nset usb.logging off reboot\n

    ESP32 1.17.1.5 USB logging procedure: disable device sleep before enabling the log stream, using separate text commands:

    set powersaving off\nset usb.logging on\n

    Verify with get powersaving (expect off) and get usb.logging (expect on). Both settings are saved; disabling logging later does not restore power saving. This works around the released USB sleep bug. nRF52 does not need this ESP32 workaround.

    These commands are compiled into ordinary USB-loggable artifacts and every Full Companion. They control live USB debug and packet output. CommonCLI roles save the setting in /com_prefs, so it survives reboot; their first boot defaults to on. Full Companion and ordinary USB Companion start off on a fresh installation so diagnostics cannot corrupt framed traffic.

    On Full Companion these lines belong to its text terminal, not meshcli's Binary get/set parameter namespace. Open interface 00, send +++MESHCORE-TERM-START, and then issue the command. Running meshcli ... get usb.logging directly can instead return Unknown var usb.logging because that is a different protocol operation.

    nRF52 Full Companion defaults logging to off and enumerates only USB interface 00, which carries Companion, terminal, and serial mOTA traffic. Enabling logging adds interface 02, its dedicated plaintext logging port, on the next boot. Disabling it removes interface 02 on the next boot. A command without the optional reboot argument saves the choice and reports that a reboot is required when the USB interface count must change. The exact set usb.logging on reboot and set usb.logging off reboot forms save the choice, send their reply, and reboot one second later only when needed.

    On every ESP32 Full Companion, enter the USB text terminal and use set usb.logging on (preceded by set powersaving off on 1.17.1.5) to turn that TTY into a logging-repeater-style plaintext stream. Framed Binary Companion is unavailable on USB while logging is on. The TTY remains an input-capable CLI, so set usb.logging off works on the same TTY. After its reply, logging stops and the TTY remains in the normal ASCII terminal, just as it does after a fresh Full installation. Send +++MESHCORE-TERM-STOP, or let a Companion app send a valid framed probe, to switch it to Binary Companion. No reboot is needed because the USB interface count does not change.

    Turning USB logging off does not disable CLI replies. nRF52 keeps Companion frames active on interface 00; ESP32 resumes the ordinary ASCII/Binary switcher after the logging terminal turns logging off. This setting does not change the node-storage capture controlled by log start and log stop.

    Companion, Repeater, Room Server, and Sensor builds with both MQTT and USB logging provide the same saved selector for both output paths:

    get logging.output\nset logging.output off\nset logging.output usb\nset logging.output wifi\nset logging.output both\n

    usb emits RAW: packets for a USB-connected service such as meshcoretomqtt. wifi enables the direct MQTT bridge configured by the wifi.* and mqtt.* commands. both intentionally duplicates the radio stream to both consumers; do not point both consumers at the same broker unless the downstream setup deduplicates messages. Fresh unified Full infrastructure installs start in both mode; Full Companion starts with USB logging off. To change only MQTT, use set mqtt.enabled on|off; get mqtt.enabled checks the saved switch and get mqtt.running checks whether the MQTT service is running. Turning MQTT off preserves all broker slots and credentials.

    On 1.17.1.5 ESP32, run set powersaving off before selecting set logging.output usb or set logging.output both, since those modes enable USB logging. WiFi/MQTT-only logging does not need this workaround while the Repeater/Room Server MQTT bridge is running; the released code already blocks device sleep in that state. Check get mqtt.running, not only get mqtt.enabled. WiFi modem power saving is a separate setting.

    "},{"location":"cli_commands/#begin-capture-of-rx-log-to-node-storage","title":"Begin capture of rx log to node storage","text":"

    Search terms: save logs, record received packets, RX logging, stored packet log.

    Usage: log start

    "},{"location":"cli_commands/#end-capture-of-rx-log-to-node-storage","title":"End capture of rx log to node storage","text":"

    Usage: log stop

    "},{"location":"cli_commands/#erase-captured-log","title":"Erase captured log","text":"

    Usage: log erase

    "},{"location":"cli_commands/#print-the-captured-log-to-the-requesting-terminal","title":"Print the captured log to the requesting terminal","text":"

    Usage: log

    Local connection only: USB, direct network CLI, or Companion binary command 0x42 where the role supports the command; unavailable over LoRa.

    "},{"location":"cli_commands/#info","title":"Info","text":""},{"location":"cli_commands/#get-the-version","title":"Get the Version","text":"

    Usage: ver

    On Full Companion, the Binary device-info frame retains its legacy fixed-width version field. Use version in the text terminal, or send version through Binary protocol command 0x42 (CMD_RUN_CLI_COMMAND), to read the complete untruncated firmware string together with protocol and build date.

    "},{"location":"cli_commands/#show-the-hardware-name","title":"Show the hardware name","text":"

    Usage: board

    "},{"location":"cli_commands/#show-the-storage-layout","title":"Show the storage layout","text":"

    Usage: get storage.layout

    Reports a compact, read-only summary of the storage compiled into the running firmware:

    The command does not write, format, or resize storage. A raw-QSPI or SD build may briefly probe, wake, or mount its configured external storage to read its capacity. Remote use follows the existing administrator-command permissions.

    "},{"location":"cli_commands/#configuration","title":"Configuration","text":""},{"location":"cli_commands/#radio","title":"Radio","text":""},{"location":"cli_commands/#view-or-change-this-nodes-radio-parameters","title":"View or change this node's radio parameters","text":"

    Usage: - get radio - set radio <freq>,<bw>,<sf>,<cr>

    Parameters: - freq: Frequency in MHz - bw: Bandwidth in kHz. Most targets allow 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500. LR1110 targets allow 62.5, 125, 250, 500. - sf: Spreading factor (5-12) - cr: Coding rate (5-8)

    Set by build flag: LORA_FREQ, LORA_BW, LORA_SF, LORA_CR

    Default: 869.525,250,11,5

    Note: Requires reboot to apply. If RXPS is enabled and the saved minimum level/preamble cannot safely cover the new radio timing, the command reply reports the effective level and preamble, or RXPS continuous-fast when no level through 10 is safe. Slower settings recalculate from the saved RXPS minimum and return to it exactly when it is safe.

    "},{"location":"cli_commands/#view-or-change-this-nodes-transmit-power","title":"View or change this node's transmit power","text":"

    Search terms: tx power, transmit strength, radio output power, dBm.

    Usage: - get tx - set tx <dbm>

    Parameters: - dbm: Requested radio-chip power in dBm. The valid range depends on the radio family, selected PA path, and any board-specific external-PA limit.

    Set by build flag: LORA_TX_POWER

    Default: Varies by board

    Notes: This setting only controls the power level of the LoRa chip. Some nodes have an additional power amplifier stage which increases the total output. Refer to the node's manual for the correct setting to use. Setting a value too high may violate the laws in your country.

    The command strictly rejects malformed values and saves the new preference only after the active radio driver accepts it. On LR2021, the chip range is -9 to 22 dBm below 1500 MHz and -19 to 12 dBm above 1500 MHz. A lower board-specific external-PA limit still takes precedence. A saved profile is clamped to the applicable limit when its frequency changes; temporary radio settings use a safe effective power without replacing the saved preference.

    "},{"location":"cli_commands/#change-the-radio-parameters-for-a-set-duration","title":"Change the radio parameters for a set duration","text":"

    Usage: - tempradio <freq>,<bw>,<sf>,<cr>,<timeout_mins> - normalradio

    Parameters: - freq: Frequency in MHz (150-2500) - bw: Bandwidth in kHz (same allowed values as set radio) - sf: Spreading factor (5-12) - cr: Coding rate (5-8) - timeout_mins: Duration in minutes (must be > 0)

    Notes: - tempradio is not saved to preferences and clears on reboot. - normalradio cancels pending and active temporary-radio windows, then restores the saved radio tuple after its CLI reply has drained on the current channel. Permanent radioat entries are not removed.

    "},{"location":"cli_commands/#schedule-radio-parameter-changes","title":"Schedule radio parameter changes","text":"

    Usage: - set radioat <freq>,<bw>,<sf>,<cr>,<start_time> - get radioat [n|all] - del radioat [n|all] - set tempradioat <freq>,<bw>,<sf>,<cr>,<start_time>,<end_time> - get tempradioat [n|all] - del tempradioat [n|all]

    Parameters: - freq: Frequency in MHz (150-2500) - bw: Bandwidth in kHz (same allowed values as set radio) - sf: Spreading factor (5-12) - cr: Coding rate (5-8) - start_time: Unix epoch time when the setting starts - end_time: Unix epoch time when a temporary setting reverts - n: Scheduled entry number from get radioat or get tempradioat

    Notes: - get radioat and get tempradioat list all entries when n is omitted. - del radioat and del tempradioat delete all entries when n is omitted. - Each queue supports 3 entries. Scheduled entries are not saved across reboot. - radioat saves the new radio preferences when it fires. tempradioat applies temporarily, then reverts to the saved radio preferences. - A successful scheduling reply notes any RXPS effective-level/preamble change required by the scheduled tuple. RXPS continuous-fast means the tuple is accepted but will use continuous receive because no safe duty-cycle level is available. Returning to slower settings recalculates from the saved RXPS minimum.

    With the measured RXPS model and the default 1600 us TCXO startup delay (2600 us total transition), a requested minimum level 1 gives:

    SF BW (kHz) Wire preamble Effective timing preamble Effective RXPS level RX / sleep 7 500 32 32 2 5594 / 3022 us 6 250 32 32 2 5844 / 3022 us 5 125 32 32 2 5844 / 3022 us 5 250 64 64 1 3829 / 5100 us 6 500 64 64 1 3829 / 5100 us 5 500 128 128 1 2563 / 6647 us 5 62.5 32 16 6 11922 / 2975 us

    These values include the actual transmitted preamble, explicit header, and a one-symbol timer margin. SF5/SF6 need two more sync symbols than SF7, so their receive windows differ even when symbol duration and sleep time match.

    The wire preamble remains compatible with the existing Cascade convention: 32 symbols at SF5-SF8, extended to 64 for SF5/BW250 and SF6/BW500, or 128 for SF5/BW500; SF9-SF12 use 16. Local RXPS settings and oscillator delays never change the sender's preamble choice. The receiver may use a shorter catch assumption, but its header timeout covers the full transmitted preamble.

    Automatic adjustment stops at guarded level 8. If no guarded timing fits, the radio receives continuously until the tuple changes. For example, a board configured with the previous 5000 us TCXO delay uses continuous RX at SF7/BW500; it cannot fit the new guard within that tuple's 32-symbol wire preamble. Experimental levels 9 and 10 require an explicit selection.

    Earlier firmware passed 16/16 packets in each direction between Heltec V4 and WisMesh Tag at SF5/BW250/64 and SF5/BW500/128, CR5, 909.950 MHz. That result established the existing wire-preamble convention; it does not validate the new timing model. All senders to an RXPS receiver must use a compatible wire preamble. LoRa OTA treats v1.17.1.5 as that capability boundary.

    "},{"location":"cli_commands/#view-or-change-this-nodes-frequency","title":"View or change this node's frequency","text":"

    Search terms: radio frequency, LoRa frequency, MHz.

    Usage: - get freq - set freq <frequency>

    Parameters: - frequency: Frequency in MHz

    Default: 869.525

    Note: Requires reboot to apply

    Connections: USB, browser/TCP/Ethernet CLI, Companion binary command 0x42 (USB/BLE/TCP), and authorized LoRa CLI. set freq saves the frequency; bandwidth, spreading factor, and coding rate stay as configured. Infrastructure roles also clamp transmit power to the board's limit for that frequency. Like set radio, it requires permission to configure the destination node.

    "},{"location":"cli_commands/#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141","title":"View or change this node's rx boosted gain mode (SX12xx and LR1110, v1.14.1+)","text":"

    Search terms: RX boost, receive gain, receiver sensitivity, boosted reception.

    Usage: - get radio.rxgain - set radio.rxgain <state>

    Parameters: - state: on|off

    Default: Target-specific. Most SX1262 and LR1110 targets default to on; Station G2/G3 targets default to off.

    Notes: - The saved setting is applied immediately and persists across reboots. - Periodic AGC resets restore the saved runtime setting; they do not replace it with the target's compile-time default. - Existing installations retain their previously saved value after an upgrade.

    "},{"location":"cli_commands/#view-or-change-rx-duty-cycle-power-saving","title":"View or change RX duty-cycle power saving","text":"

    Search terms: RX power saving, receiver sleep, radio power saving, RXPS.

    Usage: - get radio.rxps - get radio.rxps.config - get radio.rxps.rfrx_disabled - get rxps.wd - set radio.rxps.rfrx_disabled <state> - set radio.rxps off - set radio.rxps on - set radio.rxps conservative - set radio.rxps balanced - set radio.rxps max - set radio.rxps max preamble <16|32> - set radio.rxps overdrive - set radio.rxps overdrive preamble <16|32> - set radio.rxps riskyWorkingMax - set radio.rxps riskyWorkingMax preamble <16|32> - set radio.rxps <1-10> - set radio.rxps level <1-10> - set radio.rxps level <1-10> preamble <16|32> - set radio.rxps <rx_us> <sleep_us>

    Parameters: - rx_us, sleep_us: Receive and sleep durations in microseconds (1000-30000000). - level: Guarded levels 1-8, followed by experimental levels 9 and 10. Higher guarded levels allow more sleep. - preamble: RXPS timing assumption in symbols; 16 or 32. This does not change the radio's actual transmitted preamble. - state: on or off.

    Notes: - get rxps.wd reports the radio watchdogs' soft and hard recovery counts. See receive calibration and recovery for noise sampling, CAD handling, and the continuous-RX mode check. - get radio.rxps.config adds the persisted level and preamble assumption to the on/off and timing values. Deployment tools use it to restore a level-based preference without converting it to fixed manual timings. - radio.rxps.rfrx_disabled is a runtime-only diagnostic setting and resets to off after reboot. - Its default off state keeps the host-controlled SX1262 receive path enabled during RX duty-cycle mode. Setting it to on reproduces the old missing-RF_RX behavior and can significantly reduce receive sensitivity, making remote commands harder to receive. - radio.rxps.rfrx_disabled is supported only on SX1262 targets with a host-controlled RX enable pin. - on and conservative select level 3, balanced selects level 6, and max selects level 8; each named profile assumes a 16-symbol preamble. - The guarded catch ladders for levels 1-8 are 15,14,13,12,11,10,9,8 symbols for preamble 16 and 24,20,16,14,12,10,9,8 for preamble 32 or longer. Generated sleep leaves another 0.2 symbols of margin. The receive timer covers the actual wire preamble, sync, explicit header, and one extra symbol. - overdrive selects level 9; riskyWorkingMax selects level 10. Both operate outside the guarded timer condition. Upstream bench measurements found packet loss at level 10; neither profile is a general guarantee of reliable delivery. They are never selected by automatic level adjustment. - Named experimental profiles accept preamble 32; numeric levels keep the existing automatic-preamble selection. Manual timings bypass the level model. - MC_TCXO_DELAY_US defaults to 1600 us. Initialization applies it and checks TCXO/calibration errors. A board can override it for its oscillator. - Fresh Cascade-profile builds start with RXPS on at level 8 and a 16-symbol preamble. Saved operator settings still take precedence after an upgrade. - Level-based settings automatically recalculate their timings when the spreading factor or bandwidth changes. Custom <rx_us> <sleep_us> timings remain fixed. - get radio.rxps keeps the legacy on/off, RX, and sleep reply. The new get radio.rxps.config reply adds the saved level and preamble assumption. Radio-change replies and the Full Companion terminal status report any effective level/preamble adjustment. Effective preamble 64 or 128 appears only when the active tuple's physical wire preamble has that length. - The selected mode is applied immediately, persisted, and restored after reboot.

    "},{"location":"cli_commands/#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards","title":"View or change the LoRa FEM receive-path gain state on supported boards","text":"

    Usage: - get radio.fem.rxgain - set radio.fem.rxgain <state>

    Parameters: - state: on|off

    Notes: - This controls the external LoRa FEM receive-path LNA where the board supports it. - This is separate from radio.rxgain, which controls the radio chip receive gain mode.

    "},{"location":"cli_commands/#view-or-change-the-lora-fem-transmit-path-gain-state-on-supported-boards","title":"View or change the LoRa FEM transmit-path gain state on supported boards","text":"

    Usage: - get radio.fem.txgain - set radio.fem.txgain <state>

    Parameters: - state: on|off

    Notes: - This controls a software-selectable external LoRa FEM transmit gain where the board supports it. - On Station G3, remove the PA PL1 jumper to allow software control. on selects PA PL1 high/short and off selects PA PL1 low/open. The PA PL2 hardware jumper determines whether this switches between power levels 1/3 or 2/4. - Select an operating level and SX1262 transmit power that comply with local RF limits and the Station G3 power-supply requirements. - The setting is saved immediately, but on Station G3 the level is applied to the hardware at the start of the next transmit, so that the PA supply rail is never re-targeted while the PA is being driven. get reports the configured state, which may lead the hardware until the node next transmits.

    "},{"location":"cli_commands/#system","title":"System","text":""},{"location":"cli_commands/#view-or-change-this-nodes-name","title":"View or change this node's name","text":"

    Usage: - get name - set name <name>

    Parameters: - name: Node name

    Set by build flag: ADVERT_NAME

    Default: Varies by board

    Note: Advertised names can use up to 23 bytes when location is included and 31 bytes otherwise. Emoji and Unicode characters may take more than one byte. Names that exceed the available advert space are truncated at a valid UTF-8 code point boundary.

    "},{"location":"cli_commands/#view-or-change-the-independent-bluetooth-name-companion","title":"View or change the independent Bluetooth name (Companion)","text":"

    Usage:

    get ble.name and set ble.name ... are accepted aliases. This command is specific to Companion firmware. The default is MeshCore-<advert name>; default (or clear) removes a custom override. Names are limited to 31 valid UTF-8 bytes without control characters and take effect after reboot.

    A binary Companion client should carry the same text in command 0x42 (CMD_RUN_CLI_COMMAND), which works over USB, BLE, or TCP without entering USB terminal mode. See Companion radio binary protocol for the frame and reply format.

    "},{"location":"cli_commands/#view-or-change-the-bluetooth-address-ble-companion","title":"View or change the Bluetooth address (BLE Companion)","text":"

    Usage:

    get ble.mac and set ble.mac ... are accepted aliases. The setting is available on every Companion build that includes Bluetooth.

    Value Result after reboot C2:11:22:33:44:55 Save and use that custom address. random Generate one random address now, save it, and reuse it on later boots. random-every-boot Generate a new address once at each boot. random everyboot is also accepted. random-after-connect Keep one random address through unused boots. After an authenticated connection, arm a new address for the next boot. default or clear Remove the override and use the chipset's factory Bluetooth address.

    A custom value must be a valid BLE random-static address in XX:XX:XX:XX:XX:XX form. Its first byte must be from C0 through FF, and you are responsible for keeping addresses unique. Hyphens are also accepted on input; status output uses uppercase colons.

    Every change requires a reboot and invalidates the phone's previous view of the device. Forget the old device entry and pair again. random-every-boot also clears the Companion's saved peer bonds during each startup, so pairing and PIN entry are expected again after every reboot.

    random-after-connect keeps the same address across any number of reboots when nobody successfully authenticated. As soon as an authenticated BLE connection succeeds, the next boot rotates the address and clears stale bonds. The marker is saved while connected, so a normal reboot, power switch, or battery pull after that connection all produce the same next-boot rotation.

    The address setting does not enable or disable stealth. Use the separate bluetooth.stealth flag below. default changes only the address policy.

    "},{"location":"cli_commands/#view-or-change-bluetooth-stealth-ble-companion","title":"View or change Bluetooth stealth (BLE Companion)","text":"
    get bluetooth.stealth\nset bluetooth.stealth on\nset bluetooth.stealth off\n

    get ble.stealth and set ble.stealth on|off are short aliases. This flag defaults to off and is independent of the Bluetooth address policy. Enabling or disabling it keeps the configured custom/random/factory address policy unchanged and requires a reboot. Repeating on keeps an existing stealth pairing; repeating off does not alter address settings.

    For a custom address with stealth:

    set bluetooth.mac C2:11:22:33:44:55\nset bluetooth.stealth on\nreboot\n

    Use set bluetooth.mac random instead of the first line for one saved random address. Both combinations keep their address and bond across later boots. Rotating policies still rotate: random-every-boot reopens pairing at every boot, while random-after-connect reopens it on the first boot after an authenticated connection. Stealth stays enabled; each new address begins with a fresh pairing window so it cannot get hidden behind an unusable bond.

    With stealth on, the node is normally discoverable until the first authenticated pairing. After that, nRF52 uses directed advertising to the saved peer. ESP32 uses a minimal unnamed advertisement and a controller allowlist because its common Arduino BLE API does not expose the directed-advertising peer field. Both reject connection and scan requests from other peers. BLE cannot be completely radio silent and still accept a connection, so a passive radio sniffer may still observe packets. To deliberately clear the saved stealth peer, send set bluetooth.stealth off, then set bluetooth.stealth on, then reboot over USB or an existing authenticated connection. To leave stealth, set it off and reboot. If the saved node-side bond is lost, recovery reopens pairing. The former set bluetooth.mac stealth command is no longer accepted.

    This changes only the running MeshCore Companion application. On nRF52, the OTAFIX bootloader still uses its own address, which is visible only while the board is in BLE DFU/recovery mode. USB and LoRa updates are unaffected. Bluetooth update tools must follow that separate bootloader address after the handoff; they must not assume that a custom application address remains the DFU address. See the hardware observations for the tested XIAO handoff and host-cache caveats.

    "},{"location":"cli_commands/#view-or-change-this-nodes-latitude","title":"View or change this node's latitude","text":"

    Usage: - get lat - set lat <degrees>

    Set by build flag: ADVERT_LAT

    Default: 0

    Parameters: - degrees: Latitude in degrees

    "},{"location":"cli_commands/#view-or-change-this-nodes-longitude","title":"View or change this node's longitude","text":"

    Usage: - get lon - set lon <degrees>

    Set by build flag: ADVERT_LON

    Default: 0

    Parameters: - degrees: Longitude in degrees

    "},{"location":"cli_commands/#view-or-change-this-nodes-identity-private-key","title":"View or change this node's identity (Private Key)","text":"

    Usage: - get prv.key - set prv.key <private_key>

    Parameters: - private_key: Private key in hex format (64 hex characters)

    Local connection only: get prv.key. Companion also requires ENABLE_PRIVATE_KEY_EXPORT=1; use binary command 0x42 or the text terminal. Infrastructure set prv.key retains its administrator access rules.

    Note: Requires reboot to take effect after setting

    "},{"location":"cli_commands/#view-or-change-this-nodes-admin-password","title":"View or change this node's admin password","text":"

    Usage: - get password - password <new_password>

    Parameters: - new_password: New admin password

    Set by build flag: ADMIN_PASSWORD

    Default: password

    get password returns the current admin password only on a local connection: USB, Ethernet, or the authenticated LAN browser CLI. It is rejected over LoRa. Companion has no local admin password and reports that explicitly, including through binary command 0x42; this command does not report the Bluetooth PIN.

    Note: The password setter replies with confirmation.

    Note: Any node using this password will be added to the admin ACL list.

    "},{"location":"cli_commands/#view-or-change-this-nodes-guest-password","title":"View or change this node's guest password","text":"

    Usage: - get guest.password - set guest.password <password>

    Parameters: - password: Guest password

    Set by build flag: ROOM_PASSWORD (Room Server only)

    Default: <blank>

    "},{"location":"cli_commands/#view-or-change-this-nodes-owner-info","title":"View or change this node's owner info","text":"

    Usage: - get owner.info - set owner.info <text>

    Parameters: - text: Owner information text

    Default: <blank>

    Note: | characters are translated to newlines

    Note: Requires firmware 1.12+

    "},{"location":"cli_commands/#fine-tune-the-battery-reading","title":"Fine-tune the battery reading","text":"

    Usage: - get adc.multiplier - set adc.multiplier <value>

    Parameters: - value: ADC multiplier (0.0-10.0)

    Default: 0.0 (value defined by board)

    Note: Returns \"Error: unsupported by this board\" if hardware doesn't support it

    "},{"location":"cli_commands/#send-a-repeater-flood-text","title":"Send a repeater flood text","text":"

    Usage: - send text.flood <message>

    Parameters: - message: Text to send to the shared #repeaters flood channel, prefixed with this node's name. Any : in the node name is sent as ; so the prefix delimiter stays unambiguous.

    Example:

    send text.flood checking ridge link\n
    "},{"location":"cli_commands/#view-or-change-battery-alert-state","title":"View or change battery alert state","text":"

    Usage: - get battery.alert - get battery.alert.region - set battery.alert on [region] - set battery.alert off

    Parameters: - region: Optional named region scope. When omitted, the repeater selects the single deepest (most narrow) named region in the configured hierarchy. If multiple regions tie for deepest, specify one explicitly.

    Defaults: - battery.alert: off - battery.alert.region: <unset>

    Notes: - Enabling fails until at least one usable named region is defined. Alerts are never sent as unscoped floods. If the selected region is later removed, alerts stop until battery alerts are enabled again with a valid region. - Region hierarchy edits are not persistent until region save is run. After region def west pnw wa w-wa sea, run region save before enabling the alert if the hierarchy must survive a reboot. - A region must have a usable transport key. Public named regions derive one automatically; a private region without an available key is rejected. - The first alert is suppressed until the repeater has been up for at least 30 minutes. After that, the repeater checks every 30 minutes and sends low-battery warnings to the #repeaters channel in the selected region. - Once an alert finishes transmitting, another battery alert is suppressed for at least 12 hours. A queue rejection, stale-queue drop, or radio send failure does not start the cooldown. Battery recovery or toggling alerts off and back on does not bypass a completed alert's cooldown during the same boot. - With region def west pnw wa w-wa sea, set battery.alert on selects sea; set battery.alert on w-wa overrides that default. - get battery.alert.region returns the selected scope, for example > sea. - The battery check never requests a wake earlier than its 30-minute deadline. If the normal loop is already awake when that deadline has elapsed, the check is effectively free of an additional wake. Time in light/event sleep counts toward the startup delay, and a pending alert keeps the repeater awake until the packet is handled.

    "},{"location":"cli_commands/#view-or-change-battery-alert-thresholds","title":"View or change battery alert thresholds","text":"

    Usage: - get battery.alert.low - set battery.alert.low <1-100> - get battery.alert.critical - set battery.alert.critical <0-99>

    Defaults: - battery.alert.low: 20 - battery.alert.critical: 10

    Note: The low threshold must be greater than the critical threshold. Alerts at or below the critical threshold use CRITICAL BATTERY in the message; both severities use the same 12-hour resend cooldown.

    "},{"location":"cli_commands/#enable-or-disable-the-rx-inactivity-watchdog-repeater-only","title":"Enable or disable the RX inactivity watchdog (Repeater Only)","text":"

    Usage: - get rx.watchdog - set rx.watchdog on - set rx.watchdog off

    Default: off

    Notes: - When enabled, the first check is due after a full 12-hour observation window. The repeater then checks roughly every 12 hours and reboots if it has not successfully received a radio packet during the preceding 12 hours. - Enabling the watchdog starts a new 12-hour observation window. Rebooting also starts a new window, so a quiet mesh can reboot no more often than once every 12 hours. - The check reuses the radio driver's existing last-receive timestamp. It does not poll, sample, or wake the radio or CPU. A due check waits for the next normal loop/wake, so its actual cadence can drift around the 12-hour target. With RX power saving enabled, packets received during normal listening windows count as activity; the watchdog does not alter the RX/sleep timing.

    "},{"location":"cli_commands/#enable-or-disable-the-nrf52-system-watchdog","title":"Enable or disable the nRF52 system watchdog","text":"

    Usage: - get system.watchdog - set system.watchdog on - set system.watchdog off

    Default: on

    Notes: - This nRF52-only hardware watchdog resets the device if the application loop stops for 60 seconds, including an indefinite SoftDevice flash-write wait. - Enabling takes effect without a reboot. - The nRF52 hardware cannot stop a watchdog after it has started. Disabling is persisted immediately, then the current firmware stops feeding it so the board performs one watchdog restart within 60 seconds. It remains off after that restart. - This setting is nRF52-only. The SoftDevice flash deadlock does not apply to ESP32, whose existing watchdog behavior is unchanged.

    "},{"location":"cli_commands/#estimate-and-correct-infrastructure-node-time-after-startup","title":"Estimate and correct infrastructure-node time after startup","text":"

    Usage: - get clock.sync - get clock.sync.status - get clock.sync.status.table - get clock.sync.status.<1-16> - get clock.sync.mesh - set clock.sync.mesh <on|off> - get clock.sync.mesh.edge - set clock.sync.mesh.edge <on|off> - clock.sync.mesh now - get clock.sync.internet - set clock.sync.internet <on|off> - get clock.sync.drift - set clock.sync.drift <30-86400> - get clock.sync.samples - set clock.sync.samples <3-16>

    Defaults: - clock.sync.mesh: on for all repeater, sensor, and room-server builds - clock.sync.mesh.edge: on - clock.sync.internet: off - clock.sync.drift: 600 seconds (10 minutes) - clock.sync.samples: 9

    When either source is enabled, the node makes its first clock-bootstrap attempt after 30 minutes of uptime, or immediately when the configured number of fresh evidence sources has been collected, whichever comes first. A successful estimate changes the RTC only when the absolute difference is greater than clock.sync.drift; correction can move the clock forward or backward. A valid estimate within the threshold counts as a successful sync without changing the clock. Seven days after each successful estimate, the node evaluates time again; the seven-day deadline therefore starts from the last successful estimate rather than from boot. This is a lazy uptime deadline: the check runs on the first normal loop/wake after it becomes due and does not wake the device by itself. If no source or consensus is available, the node retries every 30 minutes, and newly collected evidence triggers another immediate evaluation once the configured source count is present. Every reboot starts with the initial bootstrap attempt. An existing saved setting always overrides the platform default.

    clock.sync.mesh now bypasses the startup/seven-day deadline and queues a LoRa-only consensus evaluation on the next normal loop, even when the internet source is also enabled. It uses any currently fresh samples without clearing the 16-slot table. If there is not yet enough evidence, mesh collection remains open and the next attempt follows the normal 30-minute retry. The command requires clock.sync.mesh to be on and does not bypass CLI, GPS, or NTP suppression; it also retains the normal quorum, timestamp-validity, and drift checks. The separate clock command only displays the current RTC and does not request a synchronization attempt.

    clock.sync.mesh collects signature-verified advert timestamps and MAC-valid, decrypted Public-channel plain-text timestamps. In normal path mode, collection occurs only after the packet passes every forwarding filter. Sources are deduplicated by advert public key or case-insensitive Public-channel display name. Every fresh sample must also have a different full received path; all direct, zero-hop receptions count as the same empty path. This prevents repeated packets or multiple names arriving over one route from increasing the vote count.

    For a node at the edge of the network where every packet arrives through one relay path, set clock.sync.mesh.edge on changes the evidence requirement from distinct receive paths to distinct sources. Signature-verified adverts are deduplicated by public key, and Public-channel timestamps are deduplicated by case-insensitive display name. Repeated packets from one source still count once. Edge mode observes this verified evidence on the receive path, independently of the forwarding decision, so repeat off and forwarding filters do not prevent clock collection. Other packet types are observed normally but cannot be clock evidence because they do not provide a suitable authenticated Unix timestamp. Changing edge mode clears the in-memory sample table so evidence collected under the other policy is not reused. The setting is persistent and defaults on.

    At least the configured number of distinct fresh evidence sources (nine by default) and a strict majority of all fresh samples must fall within ten minutes of the median. In normal mode each source must use a distinct receive path. In edge mode signed adverts are distinct by public key and Public-channel messages are distinct by display name, but all may use the same receive path. The effective quorum is therefore the larger of clock.sync.samples and half the fresh sample count plus one. For example, a 9-vs-7 split can succeed but an 8-vs-8 split cannot. The median is used. clock.sync.samples accepts 3 through 16; samples older than two hours are ignored. Status reports mode=paths or mode=edge and labels the collected evidence as paths or sources. It reports reason=need-more-paths or reason=need-more-sources when fewer than the configured number exist, and reason=no-consensus when enough evidence exists but the effective quorum does not agree. Mesh collection begins immediately after boot. Following a successful estimate, it resumes two hours before the next seven-day deadline so only evidence that can still be fresh at evaluation time is processed.

    get clock.sync.status reports whether the clock was set and a reason when it was not. Common reasons include waiting-deadline, need-more-sources, need-more-paths, no-consensus, within-drift, mesh-off, and suppression by CLI, GPS, or internet time. It also reports whether collection is active, the fresh evidence count, the number of occupied table slots, and the next evaluation deadline.

    get clock.sync.status.table shows the active sample table in compact form. Each item is slot:type:id-prefix:age, where type A is a signed advert and type P is a Public-channel message. A trailing ! marks a stale sample. If the compact reply is truncated, query any slot with get clock.sync.status.<1-16>. The detail view reports the full source and path hashes, age-adjusted epoch, difference from the local clock, and freshness.

    A timestamp is eligible for a clock-sync sample only when it falls between the UTC build epoch embedded by build.sh and that time plus ten calendar years. Direct developer builds that bypass build.sh fall back to the compiler timestamp. Validation happens before a slot is selected or written, so an advert or Public-channel timestamp outside that window is not recorded as a clock sample.

    Before voting, the node advances each packet timestamp by an estimated transit time. The estimate sums the radio airtime at the original packet length and at each progressively longer relay length, plus the expected midpoint of the random flood-forward delay at every prior hop. The normal elapsed time since the radio recorded local receipt is then added when consensus is evaluated, so local signature/decryption/filter processing time is included too. This is better than using hop count alone because LoRa airtime changes with packet length and radio settings. Transit compensation is capped at the ten-minute consensus window. It cannot know sender queueing, channel contention, or a remote relay's non-matching txdelay, so the consensus window and median still absorb residual error.

    If a clock sync or time <epoch> CLI command successfully sets the clock, or a GPS provider writes a valid GPS time, LoRa-derived clock collection and correction are suppressed for the rest of that boot. Turning clock.sync.mesh off and back on does not clear this safety latch; only a reboot does. get clock.sync.mesh and get clock.sync.status report whether CLI or GPS time caused the suppression. On a WiFi MQTT build, a successful NTP sync is authoritative and also suppresses LoRa correction for the rest of that boot. Source selection starts fresh after a reboot, so LoRa remains the fallback when NTP cannot obtain internet time during that boot.

    Public-channel display names are not authenticated and can be spoofed. Received path hashes are also truncated, unauthenticated routing hints; requiring unique paths prevents ordinary duplicate-route inflation but is not a cryptographic identity check. Signed adverts authenticate the advert contents but do not prove that the advertising node's own clock is correct. Mesh time is therefore a consensus estimate, not an authoritative time service. Edge mode intentionally gives up receive-path diversity. Public-channel display names can be spoofed, so one sender can claim multiple names and inflate the edge-mode vote count.

    clock.sync.internet is available on WiFi MQTT repeater-observer builds. Its initial and seven-day queries run on the MQTT/WiFi task and are read-only until the repeater applies the configured drift test. Failed queries retry after 30 minutes. On other infrastructure-node builds, the preference can be stored but status reports that internet time is unavailable. MQTT builds retain their existing startup NTP behavior required for MQTT/TLS/JWT operation; this setting controls the additional delayed drift checks. Startup NTP is always preferred when it succeeds, regardless of this setting.

    Sensor and room-server builds support mesh clock consensus and report clock.sync.internet as unavailable. Changing any clock.sync.* setting starts a new attempt for the current boot. Settings are persistent in /clock_sync; samples and schedule state are not.

    A backward correction is intentionally allowed, but peers that already recorded a later timestamp from this node may temporarily reject its lower timestamps as replays until corrected time passes the previously observed value.

    Example:

    set clock.sync.drift 600\nset clock.sync.samples 3\nset clock.sync.mesh on\nset clock.sync.mesh.edge on\nclock.sync.mesh now\nget clock.sync.status\n
    "},{"location":"cli_commands/#view-this-nodes-public-key","title":"View this node's public key","text":"

    Usage: get public.key

    "},{"location":"cli_commands/#view-this-nodes-firmware-version","title":"View this node's firmware version","text":"

    Usage: ver

    "},{"location":"cli_commands/#view-this-nodes-configured-role","title":"View this node's configured role","text":"

    Usage: get role

    "},{"location":"cli_commands/#view-or-change-this-nodes-power-saving-flag","title":"View or change this node's power saving flag","text":"

    Search terms: battery saver, device power saving, low power mode.

    Usage: - get powersaving - set powersaving on - set powersaving off

    Parameters: - on: enable power saving - off: disable power saving

    Default: on for fresh Cascade-profile builds and Companion firmware; off for other infrastructure profiles

    Note: get powersaving, set powersaving on, and set powersaving off are shared by Companion and infrastructure. The saved preference controls whether power saving is allowed; active USB, logging and network services may keep the hardware awake. Actual sleep depends on the board.

    For the 1.17.1.5 G3 USB-disconnect report, use set powersaving off as the workaround. The released ESP32 sleep code can lose native USB after two minutes when the terminal is closed, even with a computer attached. The source fix blocks ESP32 light sleep while a native USB host is attached or live USB logging is enabled; ordinary CPU idle/yield remains available. The G3 button also wakes the device for at least two minutes. See the G3 release note for verification steps and the distinction between the fix and the published binaries.

    Companion firmware defaults this setting to on. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. USB and each active wireless transport remain available; SenseCAP Indicator Full keeps only its selected BLE or infrastructure-WiFi secondary transport active. set powersaving off restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (radio.rxps) and WiFi modem power save (wifi.powersave). Infrastructure uses the same commands; its hardware and active-service sleep guards determine when the node can sleep.

    "},{"location":"cli_commands/#view-or-set-the-reboot-interval-repeater-and-room-server","title":"View or set the reboot interval (Repeater and room server)","text":"

    Usage: - get reboot.interval - set reboot.interval <hours>

    Parameters: - hours: 0-255; 0 disables scheduled reboots.

    Default: 0 (disabled)

    "},{"location":"cli_commands/#control-an-exposed-gpio","title":"Control an exposed GPIO","text":"

    Availability: ESP32 Repeater, Room Server, Bridge, and Sensor firmware. Companion firmware does not expose these commands. On nRF52, the commands are enabled only for Sensor builds on the Heltec T096, ProMicro, RAK3401, and RAK4631. GPIO expanders are not supported.

    Usage:

    Examples:

    An integer duration has seconds as its default unit, so 5 means 5 seconds. Add ms for milliseconds (5ms); an explicit s suffix is also accepted (5s). The maximum duration is 24 hours (86,400 seconds or 86,400,000 milliseconds). on or off without a duration remains in that state until another command, reset, or reboot.

    reset changes the pin to an input with no pull resistor (high impedance). It does not reboot the node. It also cancels any pending timer for that pin. A new timed command for the same pin cancels and replaces the previous timer. GPIOs begin in reset; states and timers are not saved and are lost on reboot.

    The immediate reply confirms the applied state and any pending transition. When a timed transition finishes, a command issued over the authenticated remote CLI receives a second report such as > GPIO 16 timer complete: off. Retries of the same authenticated timed command are recognized and do not restart its countdown.

    The pin number is the Arduino pin number used by that target (the normal GPIO number on ESP32 and the board's D/pin index on nRF52). The available-pin list is build-specific. Radio, flash/PSRAM, USB, serial console, display, GPS, I2C, buttons, LEDs, battery measurement, power control, bridge, Ethernet, watchdog, and other pins claimed by the firmware are rejected. A pin must also be physically broken out on your board; get gpio cannot detect wiring or an attached peripheral that is not represented by the firmware configuration.

    Electrical warning: GPIOs use 3.3 V logic and have limited drive current. Do not power a relay, motor, solenoid, or other load directly from a GPIO. Use a suitable transistor, MOSFET, optocoupler, or driver with the required protection components.

    "},{"location":"cli_commands/#routing","title":"Routing","text":""},{"location":"cli_commands/#view-or-set-the-direct-path-override-for-the-current-remote-client","title":"View or set the direct path override for the current remote client","text":"

    Usage: - get outpath - get outpath path - set outpath <hop1_hex,hop2_hex,...> - set outpath path - set outpath direct - set outpath clear - set outpath flood

    Parameters: - hopN_hex: Hop hash with 2, 4, or 6 hexadecimal characters. Every hop must use the same width.

    Notes: - These commands require remote client context and update the caller's ACL entry. - get outpath path reports the reciprocal PAYLOAD_TYPE_PATH received after the caller's latest flood login without changing the selected output route. Because that packet is asynchronous, an immediate query can report > path pending; retry shortly. The observation window expires after one minute, and the captured login path is not automatically selected. - set outpath path copies that observed route to outpath and saves it. It returns an error without changing outpath if no route was received. - direct selects a zero-hop route for a directly reachable caller. - clear forgets the override, replies > outpath cleared, and allows normal path discovery to repopulate it. - flood forces replies to use flood packets until the client logs in again.

    "},{"location":"cli_commands/#view-or-change-this-nodes-repeat-flag","title":"View or change this node's repeat flag","text":"

    Search terms: enable repeating, disable repeating, packet forwarding, stop relaying.

    Usage: - get repeat - set repeat <state>

    Parameters: - state: on|off

    Default: flood.channel.data on; flood.channel.data.hops h=all

    "},{"location":"cli_commands/#view-or-change-this-nodes-advert-path-hash-size","title":"View or change this node's advert path hash size","text":"

    Usage: - get path.hash.mode - set path.hash.mode <value>

    Parameters: - value: Path hash size (0-2) - 0: 1 Byte hash size (256 unique ids)[64 max flood] - 1: 2 Byte hash size (65,536 unique ids)[32 max flood] - 2: 3 Byte hash size (16,777,216 unique ids)[21 max flood] - 3: DO NOT USE (Reserved)

    Default: 0

    Note: the 'path.hash.mode' sets the low-level ID/hash encoding size used when the repeater adverts. This setting has no impact on what packet ID/hash size this repeater forwards, all sizes should be forwarded on firmware >= 1.14. This feature was added in firmware 1.14

    Temporary Note: adverts with ID/hash sizes of 2 or 3 bytes may have limited flood propagation in your network while this feature is new as v1.13.0 firmware and older will drop packets with multibyte path ID/hashes as only 1-byte hashes are supported. Consider your install base of firmware >=1.14 has reached a criticality for effective network flooding before implementing higher ID/hash sizes.

    "},{"location":"cli_commands/#view-or-change-this-nodes-loop-detection","title":"View or change this node's loop detection","text":"

    Usage: - get loop.detect - set loop.detect <state>

    Parameters: - state: - off: no loop detection is performed - minimal: packets are dropped if repeater's ID/hash appears 4 or more times (1-byte), 2 or more (2-byte), 1 or more (3-byte) - moderate: packets are dropped if repeater's ID/hash appears 2 or more times (1-byte), 1 or more (2-byte), 1 or more (3-byte) - strict: packets are dropped if repeater's ID/hash appears 1 or more times (1-byte), 1 or more (2-byte), 1 or more (3-byte)

    Default: off

    Note: When it is enabled, repeaters will now reject flood packets which look like they are in a loop. This has been happening recently in some meshes when there is just a single 'bad' repeater firmware out there (probably some forked or custom firmware). If the payload is messed with, then forwarded, the same packet ends up causing a packet storm, repeated up to the max 64 hops. This feature was added in firmware 1.14

    Example: If preference is loop.detect minimal, and a 1-byte path size packet is received, the repeater will see if its own ID/hash is already in the path. If it's already encoded 4 times, it will reject the packet. If the packet uses 2-byte path size, and repeater's own ID/hash is already encoded 2 times, it rejects. If the packet uses 3-byte path size, and the repeater's own ID/hash is already encoded 1 time, it rejects.

    "},{"location":"cli_commands/#view-or-change-the-retransmit-delay-factor-for-flood-traffic","title":"View or change the retransmit delay factor for flood traffic","text":"

    Search terms: flood forwarding delay, flood retransmit delay, flood TX delay.

    Usage: - get txdelay - set txdelay <value>

    Parameters: - value: Transmit delay factor (0-2)

    Default: 0.5

    Note: When multiple nearby repeaters all hear the same flood packet, each waits a random amount of time before retransmitting to avoid simultaneous collisions. This factor scales the size of that random window. Higher values reduce collision risk at the cost of added latency. 0 disables the window entirely.

    "},{"location":"cli_commands/#view-or-change-the-retransmit-delay-factor-for-direct-traffic","title":"View or change the retransmit delay factor for direct traffic","text":"

    Search terms: direct forwarding delay, direct retransmit delay, direct TX delay.

    Usage: - get direct.txdelay - set direct.txdelay <value>

    Parameters: - value: Direct transmit delay factor (0-2)

    Default: 0.2

    Note: Same collision-avoidance random window as txdelay, but applied to direct (non-flood, routed) traffic. The default is lower because direct packets are addressed to a specific next hop, so far fewer nodes compete to retransmit them.

    "},{"location":"cli_commands/#experimental-view-or-change-the-processing-delay-for-received-traffic","title":"[Experimental] View or change the processing delay for received traffic","text":"

    Usage: - get rxdelay - set rxdelay <value>

    Parameters: - value: Receive delay base (0-20)

    Default: 0.0

    Note: When enabled, repeaters that received a flood packet with a weak signal are held in a delay queue before processing, while those that received it with a strong signal process it immediately. This gives strong-signal paths forwarding priority. By the time weak-signal nodes process their copy, the packet may have already propagated and will be suppressed as a duplicate, reducing redundant retransmissions.

    "},{"location":"cli_commands/#view-or-change-the-duty-cycle-limit","title":"View or change the duty cycle limit","text":"

    Usage: - get dutycycle - set dutycycle <value>

    Parameters: - value: Duty cycle percentage (1-100)

    Default: 50% (equivalent to airtime factor 1.0)

    Examples: - set dutycycle 100 - no duty cycle limit - set dutycycle 50 - 50% duty cycle (default) - set dutycycle 10 - 10% duty cycle - set dutycycle 1 - 1% duty cycle (strictest EU requirement)

    Note: Added in firmware v1.15.0

    "},{"location":"cli_commands/#view-or-change-the-airtime-factor-duty-cycle-limit","title":"View or change the airtime factor (duty cycle limit)","text":"

    Deprecated as of firmware v1.15.0. Use get/set dutycycle instead.

    Usage: - get af - set af <value>

    Parameters: - value: Airtime factor (0-9). After each transmission, the repeater enforces a silent period of approximately the on-air transmission time multiplied by the value. This results in a long-term duty cycle of roughly 1 divided by (1 plus the value). For example: - af = 1 -> ~50% duty - af = 2 -> ~33% duty - af = 3 -> ~25% duty - af = 9 -> ~10% duty You are responsible for choosing a value that is appropriate for your jurisdiction and channel plan (for example EU 868 Mhz 10% duty cycle regulation).

    Default: 1.0

    "},{"location":"cli_commands/#view-or-change-the-local-interference-threshold","title":"View or change the local interference threshold","text":"

    Usage: - get int.thresh - set int.thresh <value>

    Parameters: - value: Interference threshold value

    Default: 0.0

    "},{"location":"cli_commands/#enable-or-disable-hardware-channel-activity-detection-cad","title":"Enable or disable hardware Channel Activity Detection (CAD)","text":"

    Usage: - get cad - set cad <on|off>

    Description: When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of int.thresh - either, both, or none may be active.

    Fresh Cascade-profile builds default CAD to off across all roles, including Companion. Saved CAD settings take precedence after an upgrade. Use set cad off to disable it on an existing node or set cad on to enable it explicitly. The repeater applies the saved toggle to the radio during its periodic noise-floor service. With one runnable packet, a busy result uses the normal CAD retry delay and allows roughly four seconds of continuous busy results. As the runnable transmit queue grows, both delays are divided by its depth: retry spacing will not fall below 50 ms and the busy ceiling will not fall below 500 ms. Reaching the busy ceiling records a CAD-timeout error and attempts the next queued transmission rather than waiting indefinitely. Future-scheduled packets do not accelerate CAD. get cad includes the hardware busy-result count.

    Parameters: - on|off: Enable or disable hardware CAD

    Default: off

    "},{"location":"cli_commands/#view-or-change-the-agc-reset-interval","title":"View or change the AGC Reset Interval","text":"

    Usage: - get agc.reset.interval - set agc.reset.interval <value>

    Parameters: - value: Interval in seconds rounded down to a multiple of 4 (17 becomes 16). 0 to disable.

    Default: 0.0

    "},{"location":"cli_commands/#view-or-change-the-radio-watchdog-interval-mqtt-observer-only","title":"View or change the radio watchdog interval (MQTT observer only)","text":"

    Usage: - get radio.watchdog - set radio.watchdog <minutes>

    Parameters: - minutes: 0 to disable, or 1-120 minutes

    Default: 5

    Note: This watchdog belongs to the MQTT observer runtime and is not available on a standalone FULL repeater. On quiet meshes, increasing it can reduce false recoveries when no traffic is expected.

    "},{"location":"cli_commands/#enable-or-disable-multi-acks-support","title":"Enable or disable Multi-Acks support","text":"

    Usage: - get multi.acks - set multi.acks <state>

    Parameters: - state: 0 (disable) or 1 (enable)

    Default: 0

    "},{"location":"cli_commands/#view-or-change-the-flood-advert-interval","title":"View or change the flood advert interval","text":"

    Usage: - get flood.advert.interval - set flood.advert.interval <hours>

    Parameters: - hours: Interval in hours (3-168)

    Default: 12 (Repeater) - 0 (Sensor)

    "},{"location":"cli_commands/#view-or-change-the-zero-hop-advert-interval","title":"View or change the zero-hop advert interval","text":"

    Usage: - get advert.interval - set advert.interval <minutes>

    Parameters: - minutes: Interval in minutes rounded down to the nearest multiple of 2 (61 becomes 60) (60-240)

    Default: 0

    "},{"location":"cli_commands/#limit-the-number-of-hops-for-a-flood-message","title":"Limit the number of hops for a flood message","text":"

    Search terms: hop limit, maximum hops, max hops, flood distance.

    Usage: - get flood.max - set flood.max <value>

    Parameters: - value: Maximum flood hop count (0-64)

    Default: 64

    "},{"location":"cli_commands/#limit-the-number-of-hops-for-an-unscoped-flood-message","title":"Limit the number of hops for an unscoped flood message","text":"

    Usage: - get flood.max.unscoped - set flood.max.unscoped <value>

    Parameters: - value: Maximum flood hop count (0-64) for a packet without a scope (no region set)

    Default: 0xFF - indicates it hasn't been set, will track flood.max until it is.

    Note: An alternative to region denyf *, setting flood.max.unscoped to a lower value such as 3 would allow for local unscoped messages to propagate, while preventing noisy neighbors from flooding a local region.

    "},{"location":"cli_commands/#limit-the-number-of-hops-for-an-advert-flood-message","title":"Limit the number of hops for an advert flood message","text":"

    Usage: - get flood.max.advert - set flood.max.advert <value>

    Parameters: - value: Maximum flood hop count (0-64) for an advert packet

    Default: 8

    "},{"location":"cli_commands/#forward-flood-group-data-packets-on-repeaters","title":"Forward flood group data packets on repeaters","text":"

    Usage: - get flood.channel.data - get flood.channel.data.hops - set flood.channel.data <on|off> - set flood.channel.data.hops <all|1-7>

    Parameters: - on: Retransmit received flood GRP_DATA channel packets. - off: Do not retransmit received flood GRP_DATA channel packets. - all: When flood.channel.data is off, block GRP_DATA at any received flood hop count. - 1-7: When flood.channel.data is off, repeat GRP_DATA at this hop count or lower and block longer paths.

    Default: flood.channel.data on; flood.channel.data.hops h=all

    Forwarding behavior: Repeater firmware only. The repeater still receives and logs the packet when logging is enabled; this only blocks retransmission. On generalized repeaters these commands manage an ordinary visible FPF7 type=grp_data drop row. off with all maps to hops=all; off with N maps to hops=N+1+. The 240 KB compact FPF6 profiles retain the legacy hard gate. Because it is an ordinary FPF7 row, a matching higher-priority stop rule can exempt traffic from it. The compact get flood.filter list marks the managed row with ~data. Flood group text (GRP_TXT) is unaffected by this setting.

    get flood.channel.data includes the active hop gate as h=all or h>N.

    "},{"location":"cli_commands/#block-selected-flood-channels-with-fpf7","title":"Block selected flood channels with FPF7","text":"

    The separate flood.channel.block command and 15-row table have been retired. Generalized repeaters use the 63-row FPF7 forward phase for authenticated channel blocks:

    set flood.rule type=any channel=#test hops=all drop\nset flood.rule.2 type=any channel=#wardriving hops=5+ drop\nset flood.rule type=any channel=9cd8fcf22a47333b591d96a2b848b73f hops=4+ drop\nget flood.rule\ndel flood.rule.2\n

    type=any with a channel condition can authenticate only GRP_TXT and GRP_DATA, so it does not match other payload types. Use hops=all to block at every received hop count. To preserve the old h=N meaning of repeating through N hops and blocking longer paths, use hops=N+1+; old h=4 is therefore hops=5+.

    New generalized repeater tables seed slot 2 with the second example. Existing FCB2 rows are imported once into free FPF7 slots and the retired file is then removed. The fixed-size STM32WL FPF6 build cannot match authenticated channels.

    "},{"location":"cli_commands/#force-a-transport-scope-onto-floods","title":"Force a transport scope onto floods","text":"

    Usage: - get flood.channel.scope - get flood.channel.scope.<n> - set flood.channel.scope <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow] - set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow] - del flood.channel.scope.<n> - del flood.channel.scope all

    Parameters: - n: Slot number within the table compiled for the target. Roomy ESP32 builds provide 1-255; classic ESP32 repeaters and nRF52/other normal constrained builds provide 1-31; very-tight STM32WL repeaters provide 1-15; the no-PSRAM LilyGo T-LoRa V2.1 repeater/observer provides 1-4. - channel: public, a public #channel, or a 128/256-bit channel key in hex. - txt:*: Unauthenticated fallback for otherwise-unmatched GRP_TXT and GRP_DATA. Plain * is an alias for txt:*. - login:*: Type-based wildcard for the remote-login/admin family: REQ, RESPONSE, TXT_MSG, ANON_REQ, and PATH (0x00, 0x01, 0x02, 0x07, and 0x08). It classifies the outer type; a transit repeater cannot authenticate whether a packet is actually part of a login session. - other:*: Type-based wildcard for every remaining flood payload type, including flood-form TRACE, ACK, advert, multipart, control, OTA, reserved types, and raw custom. - region: Existing named region with a usable transport key. A unique region name prefix is accepted; wildcard region * is not a scope target. - scope=<name>: Regionless alternative to region. The public name is normalized with a leading #, and its 128-bit transport key is derived directly from that hashtag exactly as for flood.filter scope=<name>. It does not need to exist in the region list. Public names up to 30 characters are accepted; private $ scopes are not. - path=blacklist: Optional. Require the received path to match the passive flood.filter.blacklist ID table. No flood.filter drop row needs to be enabled. One exact listed ID qualifies a 3-byte path. A 2-byte path requires two matching received path entries, while a 1-byte path never qualifies. - path=bucket:<1-6>: Optional alternative to path=blacklist. Match IDs in the selected persistent flood.retry.bucket. Each bucket holds up to 17 three-byte IDs and remains usable when flood.retry.bridge is off. It uses the same 3-byte, 2-byte, and 1-byte thresholds as path=blacklist. recent.repeater freshness and flood.retry.ignore do not affect this passive match. - tx=slow: Optional. Use an effective inbound rxdelay base of max(2, configured rxdelay * 2), keep normal outbound queue priority, and schedule retransmission with the maximum supported txdelay factor of 2.0 after changing the scope. The default is fast; tx=fast may be supplied explicitly when replacing a slow row.

    Default: No forced scopes.

    Remote ACL permission 4 (region/scope manager) can use all get, set, and del flood.channel.scope forms. Filter managers and other non-admin roles cannot change this table.

    Without .n, set updates the row for the same exact channel key or wildcard class with the same path selector, otherwise it uses the first empty slot. This permits an ordinary fallback and separate blacklist or bridge-bucket rows for the same channel. With .n, it replaces that slot. The three wildcard classes are independent and consume one slot each. get flood.channel.scope reports active/total slot counts; use the numbered form for row detail. Keyed rows are displayed by the first four bytes of their derived channel hash because channel secrets are never returned. Regionless targets are displayed with their normalized leading #.

    This acts on received ROUTE_TYPE_FLOOD and ROUTE_TYPE_TRANSPORT_FLOOD packets. An unscoped packet gains the configured scope; an already-scoped packet has its existing transport codes replaced. For GRP_TXT and GRP_DATA, all exact channel-key rows are tried first and must validate the packet MAC/decryption. Matching path-qualified exact rows are tried before ordinary exact fallback rows. A region-backed row whose target is missing or unusable is skipped; later exact rows and then txt:* are tried. Exact keyed rows with a usable target therefore beat txt:* regardless of slot number. Within each wildcard class, path-qualified rows similarly precede ordinary fallback rows. login:* and other:* select their non-overlapping outer-type families without decrypting the payload. The lowest usable slot wins within each priority tier.

    Standard traceroute is direct-routed and is therefore outside this flood-only table. A custom flood-form TRACE is treated like every other flood: an applicable wildcard may rewrite it and region/unknown-code gates still apply.

    On a match, the repeater sets the route to ROUTE_TYPE_TRANSPORT_FLOOD, computes transport code 0 from the selected region or direct hashtag key and packet payload, and sets transport code 1 to zero. This occurs before region enforcement, forwarding filters, and the seen-packet lookup. For an already-scoped packet, the selected code replaces both incoming transport-code fields. Direct routes are never rewritten. A packet converted from unscoped is no longer subject to flood.max.unscoped; all rewritten packets remain subject to normal payload handling, flood.max, flood.filter, loop detection, and moderation. Assigning a scope does not make a packet type forwardable if the core would otherwise reject it. By default, if the selected scope differs and the rewritten packet is accepted for forwarding, its initial retransmission uses zero txdelay and the highest outbound queue priority so the newly scoped copy can win at the next hop. Adding tx=slow uses an effective inbound rxdelay base of max(2, configured rxdelay * 2), keeps the ordinary queue priority, and uses the maximum txdelay factor of 2.0 for the retransmission. As with ordinary txdelay, the actual transmit delay is randomized from zero through the resulting window; factor 2.0 gives a maximum of ten packet airtimes. Neither mode preempts an active radio transmission or bypasses CAD and airtime-budget limits. Selecting the scope already carried by the packet is a no-op and does not grant special transmit treatment.

    A region-backed target must be locally flood-allowed and remains subject to the normal region gate. A scope=<name> target is trusted for this matched receive pass even though it has no region-list entry, matching the behavior of flood.filter scope=<name>. It does not create a region, consume a region slot, or change the allow/deny state for unrelated packets carrying the same transport code.

    If a region-backed row's target has been removed or has no usable key, the repeater tries the next applicable row. For group packets this means later authenticated exact rows followed by txt:*; wildcard duplicates likewise fall through to the next usable slot. When no usable mapping exists, the packet retains its original unscoped or scoped route.

    LoRa OTA remains functional when other:* is configured. OTA packets are given that target's transport code, replacing an existing code when necessary, but the OTA handler still accepts and re-floods them during the temporary-radio window. A region target must allow flooding; a direct target follows the regionless trust behavior above. The OTA core itself is dormant outside that window; no default flood filter row is needed for that behavior. Forced scope does not make OTA operate outside the window.

    Capacity cost: Each rule slot retains its 36-byte runtime and persistent record. A separate 32-byte-name table holds up to the smaller of the rule count or 32 distinct regionless targets; very-tight STM32WL builds hold one reusable direct target. Region-backed targets do not consume this table. The four-slot minimum uses 272 bytes RAM and a 278-byte file; it has room for the three wildcard classes plus one exact channel mapping. Very-tight 15-slot builds use 572 bytes RAM and a 578-byte file. The 31-slot table uses 2,108 bytes RAM and a 2,114-byte file. Roomy ESP32 builds use 255 rule slots and 32 direct-target slots: 10,204 bytes RAM and a 10,210-byte file. Classic ESP32 LoRa-OTA builds that cannot afford the 255-rule table use 31 slots instead. Both configured regions and regionless targets can be reused by any number of rules.

    Duplicate behavior: Mesh dedup hashes payload type and payload bytes; it does not hash route type, transport codes, or the ordinary flood path. Adding a transport scope therefore does not create a new duplicate identity. If the same payload later arrives scoped, unscoped, or through a different region, it is still the same seen packet. TRACE is the exception only in that its encoded path_len byte is also hashed.

    While equivalent non-TRACE flood copies are waiting in rxdelay, the normal receive-quality timing still selects the packet to process, but that winner receives a scope from the queued scoped copies with the same dedupe identity. If the copies carry different locally allowed scopes, the scope from the shortest received path wins. Unknown and denied transport codes are not candidates and therefore cannot overwrite an unscoped winner. With equal path lengths, the deeper child region wins because it is narrower. A remaining tie keeps queue order. The winner's own path, SNR reading, and scheduled time are not changed, and an already-scoped winner may have its code replaced by the better queued scope.

    The comparison is deferred until dequeue so each copy retains its original scope and path for arbitration. It can only use copies still present in rxdelay; it cannot replace a packet that already won the dedupe race. Flood-form TRACE participates in this arbitration; direct traceroute does not enter the flood queue.

    A packet that matches a fast flood.channel.scope or flood.filter scope= action and needs its scope changed bypasses the inbound rxdelay queue. A tx=slow row remains in that queue with twice the configured base, floored at 2.0, and participates in normal queued-copy scope arbitration.

    Examples:

    region put west\nregion save\nset flood.channel.scope #local west\nset flood.channel.scope.2 txt:* west tx=slow\nset flood.channel.scope.3 login:* west\nset flood.channel.scope.4 other:* west\nget flood.channel.scope\nget flood.channel.scope.1\ndel flood.channel.scope.2\n

    A regionless exact mapping needs no region command:

    set flood.channel.scope #rgdata scope=BlackHole86\nget flood.channel.scope\nget flood.channel.scope.1\n

    For example, if an authenticated #rgdata packet arrives carrying scope #usa, that rule replaces #usa with #BlackHole86 before forwarding. The rule also assigns #BlackHole86 when the packet is unscoped or carries any other scope; it is a channel-to-target mapping, not an incoming-scope filter.

    To use bridge bucket 1 to assign east to public packets whose received 3-byte path contains 7576FB, while assigning west to every other authenticated public packet:

    set flood.retry.bucket 1 7576FB\nset flood.channel.scope public west\nset flood.channel.scope public east path=bucket:1\n

    Additional 3-byte IDs may be added to bucket 1 later; any one exact hit qualifies the east row. This use is passive and does not require flood.retry.bridge to be enabled. The separate blacklist selector remains available for tables shared with flood.filter path=blacklist rules.

    "},{"location":"cli_commands/#require-valid-incoming-scopes-only-on-selected-channels","title":"Require valid incoming scopes only on selected channels","text":"

    Usage: - get flood.channel.scope.require - get flood.channel.scope.require.<n> - set flood.channel.scope.require <public|#channel|128/256-bit-key> - set flood.channel.scope.require.<n> <public|#channel|128/256-bit-key> - del flood.channel.scope.require.<n> - del flood.channel.scope.require all

    Default: Empty; normal global region enforcement remains active.

    Once this table contains a row, received flood GRP_TXT and GRP_DATA packets use selective region enforcement. A packet authenticating against a listed channel key must already carry a transport scope matching a locally flood-allowed region. Listed channels arriving unscoped, with an unknown code, or with a denied region are not retransmitted. This tests the original incoming scope before any flood.channel.scope or flood.filter scope= rewrite. Those rewrite actions are skipped for a rejected listed channel, so they cannot rescue it or grant special receive/transmit timing.

    Other group channels bypass the region/unknown-code gate while the table is active. They remain subject to every other forwarding control, including repeat, flood.max*, packet filters, loop detection, payload validation, and moderation. Non-channel flood payload types retain normal global region enforcement.

    Channel matching validates the packet MAC/decryption with the configured key; the visible one-byte channel hash is only a prefilter. Public hashtag channels use their derived public key. Without .n, setting an existing key updates it and a new key uses the first empty slot. Numbered set replaces that slot. get ...<n> reports a four-byte derived prefix and key size without exposing the key. The table uses the same build-dependent slot count as flood.channel.scope.

    Remote ACL permission 4 can manage this table. Deleting its final row restores normal global region enforcement for group channels.

    Example:

    set flood.channel.scope.require #bot\nget flood.channel.scope.require\nget flood.channel.scope.require.1\n
    "},{"location":"cli_commands/#change-persistent-flood-rules-in-the-field","title":"Change persistent flood rules in the field","text":"

    For setup guidance, interactions with the existing forwarding controls, and worked moderation examples, see Flood Filtering and Moderation.

    Usage: - get flood.rule - get flood.rule.<n> - set flood.rule[.<n>] type=<type> [hops=<range>] [channel=<channel>] [prefix=<path-prefix>] [in=<input-scope>] <drop|scope=<name>|region=<name>|rate=<N>/min|retry|stop> [priority=<0-255>] [tx=slow] [suspend=tempradio] - del flood.rule.<n> - del flood.rule all - get flood.filter - get flood.filter.<n> - get flood.filter.blacklist - get flood.filter.blacklist.<n> - set flood.filter.blacklist <ID[,ID...]> - set flood.filter.blacklist.<n> <ID[,ID...]> - del flood.filter.blacklist - del flood.filter.blacklist.<n> - set flood.filter <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio] - set flood.filter.<n> <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio] - del flood.filter.<n> - del flood.filter all

    The extended table is available on repeaters with the rule engine enabled and on FULL-profile ESP32 room servers. A FULL room server exposes both flood.rule and flood.filter, has 31 slots, and requires an administrator for remote changes. It does not have the repeater's passive path blacklist, so flood.filter.blacklist* and path=blacklist are repeater-only; use the ordered prefix= match on a room server. Standard room-server profiles do not compile this table.

    Parameters: - n: Forward-rule slot in the build's compiled table (1-63 on generalized repeaters and 1-31 on FULL room servers; compact profiles may use fewer). - type: Payload type name, full PAYLOAD_TYPE_* name, decimal value 0-15, hexadecimal value 0x00-0x0F, or any. - hops: Optional; omitted means all. - N: Match only at received hop count N. - N+: Match at received hop count N and higher. - N-M: Match the inclusive received-hop range. - all: Match every received hop count (0-63). - 0+, all, and an omitted hop expression are equivalent. The CLI displays the saved range as all. - channel=*|public|#name|hash:XX|128-bit-key|256-bit-key: Optional channel match. channel=* means no channel condition at all, so the row matches everything selected by type= (including all flood payload types with type=any). It does not authenticate a packet. public, #name, and raw keys authenticate one channel and therefore narrow the row to GRP_TXT/GRP_DATA. hash:XX matches only the visible one-byte group-channel hash; short:XX and a bare two-digit byte are accepted aliases and are displayed as hash:XX. This form is deliberately unauthenticated. It can collide with another channel once in 256 hash values, and a sender can choose the byte, so use an exact channel name or key whenever it is available. In particular, channel=hash:11 does not mean Public: it matches Public plus every collision or deliberately selected 0x11 value. channel=public performs the deeper MAC/decrypt check with the Public channel key. That distinguishes an ordinary 0x11 collision, but it is channel authentication rather than sender authentication: the group MAC is two bytes and the Public key is shared. - prefix=<ID[,ID...]>: Optional ordered source-path prefix of one to three pbyte IDs. IDs must all be 2, 4, or 6 hex characters, matching a packet's 1-, 2-, or 3-byte pbyte width. path=<prefix> is an alias. - in=any|none|scoped|allowed|unknown|scope:<name>|region:<name>: Optional condition on the original incoming scope, before any rule rewrites it. none is an unscoped flood. scope:name is the exact public hashtag-derived scope. region:name is an exact allowed region match. - drop: Explicit drop action. The flood.rule form requires an explicit action. For compatibility, a legacy flood.filter row with no rewrite, rate, or stop action is treated as drop. - scope=<name>: Direct public-name scope rewrite. It derives a transport key from the name and does not require a configured region. For example, scope=BlackHole86 is a regionless sink scope; region=BlackHole86 would instead require a real configured, flood-allowed region with that name. - region=<name>: Rewrite using an existing locally allowed region and one of that region's transport keys. - rate=N/min: Per-node, per-row fixed one-minute forwarding limit. It can be the only action or accompany scope=/region=. Counters are charged only for packets that pass all forwarding gates. - retry, retry=on, retry=allow, or action=retry: Allow a matching received flood packet to enter the configured flood-retry sequence. With no active retry rows, retry eligibility remains backward compatible and is controlled by the global retry settings. Once any active retry row exists, the matching rows become a retry allow-list: a received flood must match at least one surviving retry row. flood.retry.bridge still selects ordinary or bridge-bucket completion for the allowed packet. This action can accompany rewrite, rate, or stop, but not drop; compact syntax uses f=r. - priority=0-255: Optional primary processing order. Higher values run first. At the same numeric priority, authenticated channel matches run before raw hash:XX matches, which run before channel=*; lower slot number breaks the remaining tie. The default is 0; pri= is an alias. An explicitly higher numeric priority still overrides this automatic specificity ordering. - stop or action=stop: Apply this matching row, then stop lower-order FPF7 rows from processing. It can stand alone or accompany drop, rewrite, or rate. A stop-only row acts as an exception to lower-priority FPF7 rules. If the same row uses region= and that configured region is missing, denied, wildcard, or has no usable transport key, both the rewrite and its stop are inert so lower-order safety rows still run. A direct scope= target does not depend on region configuration. - suspend=tempradio: Optional. Skip this row only while the temporary radio is actually active. - require=region: Legacy alias for in=allowed. Apply the row only if the original incoming packet already passes this repeater's region gate. An incoming transport scope must resolve to a locally allowed region; an unscoped flood must be allowed by the wildcard region. The check occurs before any scope rewrite during this receive pass. - tx=slow: Optional and valid with scope= or region=. Use an effective inbound rxdelay base of max(2, configured rxdelay * 2), keep normal outbound queue priority, and retransmit with the maximum supported txdelay factor of 2.0. Scope rows default to fast; tx=fast explicitly restores that default when replacing a slow row. - path=blacklist: Optional unordered path condition. The persistent blacklist is repeater-only. It contains up to 255 unique 3-byte repeater IDs on ESP32 builds and 18 on other builds, each written as six hexadecimal digits. A packet with 3-byte path hashes matches after one exact ID hit. A packet with 2-byte path hashes matches after two path entries match the first two bytes of listed IDs. Packets with 1-byte path hashes never match this condition. Each received path entry is counted at most once.

    The payload names follow the MeshCore packet-format allocation:

    Value Short name Full name 0x00 req PAYLOAD_TYPE_REQ 0x01 response PAYLOAD_TYPE_RESPONSE 0x02 txt_msg PAYLOAD_TYPE_TXT_MSG 0x03 ack PAYLOAD_TYPE_ACK 0x04 advert PAYLOAD_TYPE_ADVERT 0x05 grp_txt PAYLOAD_TYPE_GRP_TXT 0x06 grp_data PAYLOAD_TYPE_GRP_DATA 0x07 anon_req PAYLOAD_TYPE_ANON_REQ 0x08 path PAYLOAD_TYPE_PATH 0x09 trace PAYLOAD_TYPE_TRACE 0x0A multipart PAYLOAD_TYPE_MULTIPART 0x0B control PAYLOAD_TYPE_CONTROL 0x0C ota PAYLOAD_TYPE_OTA (this fork's LoRa OTA extension; reserved upstream) 0x0D 13 reserved 0x0E 14 reserved 0x0F raw_custom PAYLOAD_TYPE_RAW_CUSTOM

    Route scope: Rules are evaluated only for the two flood route values: ROUTE_TYPE_TRANSPORT_FLOOD (0x00, flood plus transport codes) and ROUTE_TYPE_FLOOD (0x01, unscoped flood). Direct routes 0x02 and 0x03 are never affected.

    Behavior: Match fields within one row are ANDed. Every FPF7 row is matched against the same immutable receive-time packet, before any rule changes its scope. Matching rows are processed in descending priority. At equal numeric priority, authenticated channel matches precede raw hashes, which precede an unrestricted channel matcher; lower slot wins after that. The first matching stop row is included and all lower-order FPF7 matches are discarded. A stop cannot undo an earlier drop or bypass hard forwarding gates or the other policy phases. A row with path=blacklist must meet the path condition as well as its other conditions; blacklist IDs can occur anywhere in the received path and their configured order is irrelevant. In contrast, prefix= begins at the first received path entry and preserves order. A matching drop row prevents retransmission. The highest-order remaining matching scope/region row wins; matching drop and rate rows remain independent and can still block the rewritten packet. Scope rewriting happens before region enforcement and is trusted even when its name is absent from the local region list. It does not bypass repeat, flood.max, other drop rows, loop detection, or moderation.

    Retry selection uses that same ordered, stop-truncated match set. With at least one active retry row, a received flood starts a retry sequence only when one of those matching rows includes retry. This selector cannot override flood.retry.count, path/type attempt caps, flood.retry.advert, disabled forwarding, a drop decision, or any other hard forwarding gate. Locally originated floods retain the normal global retry behavior.

    With require=region, a failed check makes that scope row ineligible. It leaves the packet unchanged and does not set the filter-scope trust bypass, so an unknown or denied incoming region is rejected normally unless another independent scope rule rewrites it. Later eligible filter scope rows may still match.

    By default, when a scope row will change the packet's transport codes, the packet bypasses inbound rxdelay; its retransmission then uses zero txdelay and the highest outbound queue priority. With tx=slow, the rewrite instead uses an effective inbound rxdelay base of max(2, configured rxdelay * 2), normal queue priority, and the maximum txdelay factor of 2.0. The actual randomized transmit wait ranges from zero to ten packet airtimes. Selecting the scope already carried is a no-op and does not grant special treatment. An active radio transmission is not preempted, and CAD and airtime-budget limits still apply.

    The packet is still received and can still be logged. Rules are persistent data and can be changed over serial or authenticated remote CLI without an OTA or reboot. flood.rule and flood.filter address the same table on extended builds; FPF6 files are migrated in memory and the next save writes FPF7. FPF1-FPF5 files are rejected and filtering fails open. FPF7 stores canonical region names rather than transient numeric region IDs. Removing, reordering, or reusing a region ID therefore cannot silently retarget a rule. If a saved input or target region name is absent, that input match or rewrite is inert; restoring the same region name reactivates it. While the temporary radio is active, only rows explicitly marked suspend=tempradio are skipped. tempradio is a radio state, not an OTA mode; normal payload types can also use the temporary channel. Other rows remain in force. A malformed persisted table fails open (no general rules are applied).

    Within one receive evaluation, rows that use the same channel key share one authentication result. The cache is discarded after that packet and stores neither plaintext nor passwords; different keys are authenticated separately. Raw hash:XX rows skip this authentication and compare only the one visible byte.

    Per-channel retry examples:

    # Use bridge-bucket retry only for authenticated Public and #hamradio traffic.\nset flood.retry.bridge on\nset flood.rule.2 type=any channel=public retry\nset flood.rule.3 type=any channel=#hamradio retry\n\n# If the channel key/name is unavailable, select visible channel hash A7.\n# This is an unauthenticated 1-byte hint, not a channel identity.\nset flood.rule.4 type=any channel=hash:A7 retry\n

    Because Public's visible hash is 0x11, a bare hash:11 drop row also matches Public. To exempt authenticated Public while dropping other packets that carry the same visible byte, put the exact Public rule first and stop lower-priority rules after it:

    # Retry and preserve authenticated Public; drop other channel-hash 11 packets.\nset flood.rule.2 type=any channel=public retry stop\nset flood.rule.3 type=any channel=hash:11 drop\n

    The Public row matches only after its MAC/decrypt check succeeds. A colliding channel therefore misses that stop and reaches the raw-hash drop row. Both rows use the default numeric priority, but authenticated-channel specificity automatically orders Public first even if its slot number is higher. Without stop, both rows match Public and the sticky drop action wins. Omit retry from the Public row when only the forwarding exemption is wanted. An operator can deliberately reverse this order by assigning the hash row a higher numeric priority.

    Deleting or replacing the last active retry row restores the legacy global retry eligibility. Firmware that predates the retry/hash:XX FPF7 extension cannot preserve tables containing those rows; remove them before downgrading.

    Default row: Repeater firmware and FULL ESP32 room-server firmware seed a new flood-filter table with ota all suspend=tempradio in slot 1. This blocks repeated LoRa OTA (0x0C) floods at every received hop unless temporary radio is actually active. The OTA core independently refuses OTA receive, relay, and transmit outside temporary radio. The row is editable and deletable; once the table is saved, deletion is persistent. Restore the exact seeded row with:

    set flood.filter.1 0x0C all suspend=tempradio\n

    Omitting all is equivalent. Omit .1 as well to reuse an identical rule or the first empty slot instead of replacing slot 1.

    Remote-admin lockout warning: There are no hidden payload-type or short-hop exceptions. FPF7 drop and rate rows may block req, response, txt_msg, anon_req, path, ACK, and multipart traffic beginning at hop 0 when their match fields say so. Transit repeaters cannot decrypt these outer types to distinguish an admin exchange from ordinary peer traffic. Keep a serial or other recovery path and stage broad deny/rate rules carefully.

    Without .n, set reuses an identical rule or uses the first empty slot. With .n, it replaces that slot, which is the intended way to change a row's match or action. get flood.filter or get flood.rule gives a compact list. Use the numbered form for full details, including channel, prefix, original-scope condition, action, timing, rate, and temporary-radio suspension.

    If all of those fields plus long names would exceed one CLI reply, the numbered form automatically switches to a non-truncating compact spelling. The compact aliases are also accepted by set: c= means channel=, p= means prefix=, i=*|n|s|a|u|s:<scope>|r:<region> means the corresponding in= condition, q=N means rate=N/min, pri=N means priority, and f=str combines slow timing (s), temporary-radio suspension (t), and retry allowance (r). Packet type is shown numerically in that fallback. Normal-sized rows keep the descriptive spelling above.

    On generalized repeaters, filter rows, scope-rewrite rows, the shared blacklist, and flood.channel.data compatibility state are committed in one atomic FPF7 image. Compact FPF6 profiles retain separate files. Replacing or deleting the blacklist does not delete rows containing path=blacklist; such rows remain dormant while the list is empty. Path hashes are truncated routing identifiers, not authenticated identities, so this is a forwarding signal rather than proof that a particular repeater handled a packet.

    A common use is containment of bulk internet-to-mesh dumping: list the path IDs associated with the offending gateways, then add a broad type=any hops=all path=blacklist drop row. This prevents this repeater from retransmitting matching floods; it does not delete them from local logs or prove who originated them.

    The unnumbered blacklist set replaces the whole list and accepts up to 18 IDs so it fits every CLI transport. Numbered set writes up to 18 consecutive entries beginning at an existing slot or exactly the next slot, allowing an ESP32 list to grow to 255 entries in batches. Numbered deletion compacts subsequent slots. The unnumbered get reports the total and as many leading IDs as fit in one reply; use numbered get to inspect entries beyond that reply.

    Standard traceroute is direct-routed and therefore outside flood.filter entirely. A custom flood-form trace participates normally: type=any, explicit trace, rewrite, rate, drop, and stop rows can all apply.

    Examples:

    set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86\nset flood.rule.3 type=grp_data channel=#rgdata in=scope:usa scope=BlackHole86\nset flood.rule.4 type=any prefix=860C rate=10/min\nset flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop\nget flood.rule.2\nset flood.filter grp_data 4+\nset flood.filter.2 PAYLOAD_TYPE_ADVERT 6+\nset flood.filter ota 2-4\nset flood.filter.1 0x0C all suspend=tempradio\nset flood.filter grp_data all suspend=tempradio\nset flood.filter grp_txt all scope=local\nset flood.filter grp_data all scope=local require=region\nset flood.filter grp_data all path=blacklist scope=local tx=slow\nset flood.filter.blacklist A1B2C3,D4E5F6,112233\nset flood.filter.blacklist.4 445566\nset flood.filter.blacklist.19 778899,AABBCC,DDEEFF\nset flood.filter any all path=blacklist\nget flood.filter.blacklist\nget flood.filter.blacklist.4\nset flood.filter any 12+\nget flood.filter\nget flood.filter.2\ndel flood.filter.2\n

    The first rule authenticates #rgdata, requires more than three received hops, and adds #BlackHole86 only when no scope was present. The second rewrites the exact incoming #usa scope. The third demonstrates a two-byte pbyte source prefix and a global per-row rate cap. The fourth authenticates #rgdata at zero through two hops, applies no FPF7 action of its own, and stops lower-order FPF7 forward rows; hard gates and the rewrite/moderation phases still apply.

    The fixed 240 KB STM32WL profiles leave MESH_ENABLE_FLOOD_RULE_ENGINE=0 and retain the compact, persistent FPF6 flood.filter and blacklist commands. They still filter floods, but do not expose the flood.rule alias or its extended channel, prefix, input-scope, region-action, or rate fields. No partition size changes are required.

    "},{"location":"cli_commands/#moderate-flood-group-text-by-channel-sender-and-source-path","title":"Moderate flood group text by channel, sender, and source path","text":"

    Usage: - get flood.moderation - get flood.moderation.<n> - set flood.moderation <channel> <sender> <action> [action...] - set flood.moderation.<n> <channel> <sender> <action> [action...] - del flood.moderation.<n> - del flood.moderation all

    Parameters: - n: Moderation slot from 1 to 16. - channel: - public: Built-in Public channel. - #channel: Derive the well-known hashtag-channel key. - A 128-bit or 256-bit channel key in hex, for any other/private channel. - sender: Exact group-text display name. Matching is ASCII case-insensitive. Quote names containing spaces, for example \"Field User\". - drop: Do not forward matching messages. Equivalent to rate=0/min. - rate=X/min: Forward at most X matching messages per 60-second local window. This option requires an exact sender rather than *. - hops=N: Do not forward a matching message whose received flood path count is N or higher. hops=all removes this constraint. - path=H1[,H2,H3]: Match the start of the flood path. One to three hashes are accepted; every hash must have the same 1-, 2-, or 3-byte width. - path=*: Match any source path (the default).

    At least one of drop, rate=X/min, or hops=N is required. Rate and hop limits can be combined. Rate counters are local to this repeater and rule, use a 60-second window beginning with the first matching message, and reset on reboot.

    Decode and identity behavior: Moderation applies only to flood PAYLOAD_TYPE_GRP_TXT. The repeater first checks the packet's channel-hash byte, then validates and decrypts with the configured key. It extracts the text before the first : from the standard <sender>: <message> plaintext. The channel key is stored locally but is never printed by get.

    The group-text sender is an unverified display name, not a public key. It can be spoofed. Combining it with the first one to three path hashes makes a more useful moderation signal, but path hashes are truncated and are not proof of the originating user. A path-qualified rule begins matching only after the packet contains all configured starting hops; it cannot identify a first hop on a zero-hop packet.

    As with general filtering, matching messages are still received/logged; only retransmission is denied. There are no moderation rules by default.

    Examples:

    set flood.moderation public \"Noisy User\" rate=5/min\nset flood.moderation #local bot drop path=A1B2C3,D4E5F6\nset flood.moderation.3 00112233445566778899AABBCCDDEEFF alice rate=10/min hops=4 path=71CE82\nget flood.moderation\nget flood.moderation.3\ndel flood.moderation.3\n
    "},{"location":"cli_commands/#acl","title":"ACL","text":""},{"location":"cli_commands/#add-update-or-remove-permissions-for-a-companion","title":"Add, update or remove permissions for a companion","text":"

    Usage: - setperm <pubkey> <permissions>

    Parameters: - pubkey: Companion public key - permissions: - 0: Guest - 1: Read-only - 2: Read-write - 3: Admin - 4: Region/scope manager (repeater delegated region and forced-scope management) - 5: Filter manager (repeater delegated forwarding-filter management)

    Filter manager scope: Permission 5 can use an explicit allowlist of non-secret operational/filter status commands and can change the forwarding controls repeat, loop.detect, flood.max*, flood.channel.data*, flood.filter*, flood.rule*, and flood.moderation*. It cannot read guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions, ACL entries, radio settings, or other admin configuration. Permission 4 is limited to region commands, flood.channel.scope*, and the same non-secret status allowlist. Both delegated manager roles are protected from least-recently-active ACL eviction like administrators.

    Note: Removes the entry when permissions is omitted

    "},{"location":"cli_commands/#view-the-current-acl","title":"View the current ACL","text":"

    Usage:

    Available on repeaters, room servers, and sensors. Over LoRa, an authenticated admin session is required; guests, read-only/read-write clients, and delegated region/filter managers cannot read the ACL.

    Each page starts with ACL <page>/<pages> and contains up to two entries. Each entry is a two-digit hexadecimal permission byte followed by the full 64-character public key. For example, use get acl 2 for the second page. An empty list returns ACL: empty. Deleted/guest entries with permission byte 00 are omitted, matching the local listing. Pages reflect the current live ACL, so entries can move between pages if permissions or clients change.

    Bare get acl keeps the full streamed listing on USB and direct network CLI, or via Companion binary command 0x42 where the role supports the command. The listing contains public keys and permissions, not passwords or shared secrets, and does not change the ACL.

    "},{"location":"cli_commands/#recover-a-repeaters-future-dated-replay-timestamp","title":"Recover a repeater's future-dated replay timestamp","text":"

    This is an explicit recovery operation after correcting a bad clock, not a contact deletion. It lowers selected replay timestamps to the repeater's current UTC epoch only when they are later than that epoch. Earlier values, public keys, permissions, stored paths, and historical identity records remain. The replay file is committed before the live table changes. No extra 60-second login reservation is added by this command.

    First set/synchronize and verify the repeater's clock (clock); a build-default clock without a manual or observed synchronization is not accepted. Also fix the companion's clock before its next login.

    USB console:

    replay reset <full-64-hex-public-key>\nreplay reset all CONFIRM\n

    Authenticated LoRa admin, including a resumed admin session:

    replay reset <full-64-hex-public-key>\n

    The reply shows now=<epoch>, ttl=<remaining-seconds>s, and a confirmation command containing the same full key and a one-use 32-hex-character token. Verify the displayed time and send that command before the original 300-second deadline. During the first 120 seconds, repeated requests for the same key by the same admin return the same token without restarting either timer. From 120 through 300 seconds, the token is retained for confirmation only: requests do not resend or replace it. This leaves at least 180 seconds to deliver a confirmation after the last permitted token response is generated (radio transit time still counts toward expiry). At 300 seconds it expires and a new request can receive a new token. Confirmation can succeed immediately; there is no requirement to wait for the resend window to close. The displayed TTL decreases on retries and is measured when the reply is generated, not when it reaches the companion.

    It can target the caller's own key or another exact key; prefixes, self, wildcards, and all are not allowed over LoRa. Tokens are bound to both the requesting admin and target, expire on reboot, and are invalid after use or a clock correction outside the five-second confirmation tolerance. A failed write requires a new confirmation. Challenge requests consult live token state instead of replaying cached challenge text; completed confirmation results remain cacheable without executing the reset again. Normal packet freshness checks still apply to retries.

    Only the physical serial console grants all access. Ethernet, browser and internal command callbacks do not count as USB. Guest, read-only, region-manager and filter-manager roles cannot reset replay state. This command is implemented in repeater firmware; room-server and sensor CLI are unchanged.

    Normal login and command admission checks remain in force: a fully locked-out caller that cannot send an accepted admin command needs another working admin or USB access. A corrupt/unreadable replay file fails closed and is not erased or formatted by this operation. Unknown keys do not create records.

    Security trade-off: lowering a replay boundary can admit previously captured future-dated login/command packets above the new boundary, including packets that could raise it again. The one-use token prevents the recovery command itself from being repeatedly executed; it does not replace the protocol's timestamp-based replay protection. Use recovery only after verifying clocks. Setting a clock alone never automatically resets this table.

    "},{"location":"cli_commands/#view-or-change-this-room-servers-read-only-flag","title":"View or change this room server's 'read-only' flag","text":"

    Usage: - get allow.read.only - set allow.read.only <state>

    Parameters: - state: on (enable) or off (disable)

    Default: off

    "},{"location":"cli_commands/#region-management-v110","title":"Region Management (v1.10.+)","text":""},{"location":"cli_commands/#bulk-load-region-lists","title":"Bulk-load region lists","text":"

    Usage: - region load - region load <name> [flood_flag]

    Parameters: - name: A name of a region. * represents the wildcard region

    Note: flood_flag: Optional F to allow flooding

    Note: Indentation creates parent-child relationships (max 8 levels)

    Note: region load with an empty name will not work remotely (it's interactive)

    "},{"location":"cli_commands/#save-any-changes-to-regions-made-since-reboot","title":"Save any changes to regions made since reboot","text":"

    Usage: - region save

    "},{"location":"cli_commands/#allow-a-region","title":"Allow a region","text":"

    Usage: - region allowf <name>

    Parameters: - name: Region name (or * for wildcard)

    Note: Setting on wildcard * allows packets without region transport codes

    "},{"location":"cli_commands/#block-a-region","title":"Block a region","text":"

    Usage: - region denyf <name>

    Parameters: - name: Region name (or * for wildcard)

    Note: Setting on wildcard * drops packets without region transport codes

    "},{"location":"cli_commands/#show-information-for-a-region","title":"Show information for a region","text":"

    Usage: - region get <name>

    Parameters: - name: Region name (or * for wildcard)

    "},{"location":"cli_commands/#view-or-change-the-home-region-for-this-node","title":"View or change the home region for this node","text":"

    Usage: - region home - region home <name>

    Parameters: - name: Region name

    "},{"location":"cli_commands/#view-or-change-the-default-scope-region-for-this-node","title":"View or change the default scope region for this node","text":"

    Usage: - region default - region default {name|<null>}

    Parameters: - name: Region name, or to reset/clear"},{"location":"cli_commands/#create-a-new-region","title":"Create a new region","text":"

    Usage: - region put <name> [parent_name]

    Parameters: - name: Region name - parent_name: Parent region name (optional, defaults to wildcard)

    Note: In firmware v1.15.0 and later, region put enables flooding for that region by default (you do not need a separate region allowf <name> after each put). On v1.14.0 and earlier, new regions may still require region allowf for flooding-see region allowf.

    "},{"location":"cli_commands/#define-region-hierarchy-single-line","title":"Define region hierarchy (single line)","text":"

    Usage: - region def <token> [<token> ...]

    Parameters (tokens): Space-separated. A logical cursor starts at the wildcard *.

    Behavior: Each created region defaults to flood-allowed (same as region put). The reply is the resulting region tree (same format as bare region); review it before running region save to persist. The command is transactional: invalid names, unknown or ambiguous jumps, table overflow, and hierarchy cycles return Err - ... without changing the existing tree.

    Existing regions: region def does not clear the existing tree - if a name already exists, its parent is updated to the current cursor; otherwise a new region is created. To start from scratch, region remove the unwanted regions first.

    Limits: Repeater serial accepts one line up to 160 characters. For larger trees, split across multiple region def commands; the cursor resets to * between commands, so lead the next command with child|ancestor to reposition. Each token splits at most once on | - region def a|b|c|d is not a flat-list shorthand; see the flat-list example below.

    Example - linear chain (each token becomes a child of the previous):

    region def a b c d e\nregion save\n

    Example - branched tree (equivalent to region put a, region put b a, region put c b, region put d c, region put e b, region put f e):

    region def a b c d|b e f\nregion save\n

    Example - transactional error:

    region def a b c|nope d\n

    The reply is Err - unknown or ambiguous jump: nope. The existing tree is unchanged; re-run with a corrected jump.

    Example - flat list (each region a child of *). Use |* after each token to pop the cursor back to the root before the next token:

    region def a|* b|* c|* d|* e|* f\nregion save\n
    "},{"location":"cli_commands/#remove-a-region","title":"Remove a region","text":"

    Usage: - region remove <name>

    Parameters: - name: Region name

    Note: Must remove all child regions before the region can be removed

    "},{"location":"cli_commands/#view-all-regions","title":"View all regions","text":"

    Usage: - region list <filter>

    Serial Only: Yes

    Parameters: - filter: allowed|denied

    Note: Requires firmware 1.12+

    "},{"location":"cli_commands/#dump-all-defined-regions-and-flood-permissions","title":"Dump all defined regions and flood permissions","text":"

    Usage: - region

    Serial Only: For firmware older than 1.12.0

    "},{"location":"cli_commands/#region-examples","title":"Region Examples","text":"

    Example 1: Using F Flag with Named Public Region

    region load\n#Europe F\n<blank line to end region load>\nregion save\n

    Explanation: - Creates a region named #Europe with flooding enabled - Packets from this region will be flooded to other nodes

    Example 2: Using Wildcard with F Flag

    region load \n* F\n<blank line to end region load>\nregion save\n

    Explanation: - Creates a wildcard region * with flooding enabled - Enables flooding for all regions automatically - Applies only to packets without transport codes

    Example 3: Using Wildcard Without F Flag

    region load \n*\n<blank line to end region load>\nregion save\n

    Explanation: - Creates a wildcard region * without flooding - This region exists but doesn't affect packet distribution - Used as a default/empty region

    Example 4: Nested Public Region with F Flag

    region load \n#Europe F\n  #UK\n    #London\n    #Manchester\n  #France\n    #Paris\n    #Lyon\n<blank line to end region load>\nregion save\n

    Explanation: - Creates #Europe region with flooding enabled - Adds nested child regions (#UK, #France) - All nested regions inherit the flooding flag from parent

    Example 5: Wildcard with Nested Public Regions

    region load \n* F\n  #NorthAmerica\n    #USA\n      #NewYork\n      #California\n    #Canada\n      #Ontario\n      #Quebec\n<blank line to end region load>\nregion save\n

    Explanation: - Creates wildcard region * with flooding enabled - Adds nested #NorthAmerica hierarchy - Enables flooding for all child regions automatically - Useful for global networks with specific regional rules

    "},{"location":"cli_commands/#direct-retry","title":"Direct Retry","text":"

    Direct retry resends direct-routed packets when the downstream echo is not heard. It applies to direct messages, ACK packets, multipart packets carrying ACK payloads, and TRACE packets.

    The shared state, count, base, and step controls work on repeater, room-server, and sensor firmware. Recent-repeater/SNR controls are repeater-only because the other roles do not keep the repeater reachability table they require.

    "},{"location":"cli_commands/#view-or-change-direct-retry-state","title":"View or change direct retry state","text":"

    Search terms: enable tx retries, disable tx retries, stop direct retries, turn off retransmissions.

    Usage: - get direct.retry - set direct.retry <state>

    Parameters: - state: on|off

    Default: on

    Notes: - New installs and older preference files without direct retry settings default to on with the rooftop preset.

    Examples:

    get direct.retry\nset direct.retry on\nset direct.retry off\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-heard-table-gate","title":"View or change direct retry heard-table gate","text":"

    Usage: - get direct.retry.heard - set direct.retry.heard <state>

    Parameters: - state: on|off

    Default: on

    Note: This command is repeater-only. When enabled, the recent repeater table is the direct retry eligibility gate. Prefixes missing from the table are assumed reachable; prefixes in the table below the active SNR gate are blocked.

    Examples:

    get direct.retry.heard\nset direct.retry.heard on\nset direct.retry.heard off\n
    "},{"location":"cli_commands/#view-or-apply-a-retry-preset","title":"View or apply a retry preset","text":"

    Search terms: retry profile, retry defaults, rooftop retries, mobile retries, infrastructure retries.

    Usage: - get retry.preset - set retry.preset <preset>

    Parameters: - preset: infra|rooftop|mobile

    Notes: - Applies shared direct retry and flood retry defaults. - infra: fewer, slower retries for stable fixed infrastructure. - rooftop: default long retry window for weak rooftop links. - mobile: long retry count with shorter spacing for moving or changing links; flood retry count is 15. - Changing direct.retry.count, direct.retry.base, direct.retry.step, direct.retry.margin, flood.retry.count, flood.retry.path, or flood.retry.group.path makes the preset report as custom.

    Examples:

    get retry.preset\nset retry.preset infra\nset retry.preset rooftop\nset retry.preset mobile\n
    "},{"location":"cli_commands/#flood-retry","title":"Flood Retry","text":"

    Flood retry resends flood-routed packets when the same packet is not heard from another qualifying repeater.

    The count, path, group-data path, and advert controls work on repeater, room-server, and sensor firmware. Flood forwarding must also be enabled for retries to run. Prefix, ignore, bridge, and bucket controls are repeater-only.

    "},{"location":"cli_commands/#hop-zero-flood-retry-coding-rate","title":"Hop-zero flood retry coding rate","text":"

    Flood packets with no path hops recorded yet use the same CR escalation ladder as direct retries. The starting point is the active radio CR, since a flood has no single next-hop repeater whose SNR can select a starting CR. Each retry uses its attempt number and the active radio setting; it does not restart the ladder from the previous retry's override. Forwarded packets with one or more recorded hops use the active radio CR on every retry, including bridge retries whose effective path length discounts ignored hops.

    The initial transmission uses the active radio CR. This table lists retries after that initial transmission; any later retries remain at CR8:

    Starting CR Retry 1 Retry 2 Retry 3 Retry 4 Retry 5+ CR4 CR4 CR5 CR7 CR7 CR8 CR5 CR5 CR7 CR7 CR8 CR8 CR6 CR6 CR7 CR7 CR8 CR8 CR7 CR7 CR7 CR8 CR8 CR8 CR8 CR8 CR8 CR8 CR8 CR8

    The ladder is shared across presets. Presets change the retry budget, not the CR steps. At CR5, the maximum hop-zero schedules are:

    Preset Configured flood count Hop-zero retries CR schedule after the initial send infra 1 2 CR5, CR7 rooftop 3 6 CR5, CR7, CR7, CR8, CR8, CR8 mobile 15 15 CR5, CR7, CR7, then CR8 for retries 4-15

    These budgets apply to group text and the origin login/message family (RESPONSE, TXT_MSG, ANON_REQ, PATH). Existing payload caps, echo cancellation, and retry eligibility still apply: REQ and OTA do not use generic flood retries; other payloads allow at most one retry. An eligible advert at CR5 therefore gets only its first CR5 retry.

    set flood.retry.count 0 continues to disable all automatic flood retries, including hop zero. It leaves the initial transmission and all direct-retry settings and CR schedules unchanged. Direct retry's direct.retry.cr switch and SNR thresholds continue to control direct retries only. Per-packet CR overrides are restored after transmission and do not change the saved radio setting or physical preamble.

    "},{"location":"cli_commands/#view-or-change-flood-retry-count","title":"View or change flood retry count","text":"

    Search terms: flood tx retries, flood retry attempts, flood retransmissions, broadcast retries.

    Usage: - get flood.retry.count - set flood.retry.count <count>

    Parameters: - count: Base retry attempts after the original send, from 0 to 15. 0 disables flood retry.

    Note: The role first calculates its retry count: path count 0 uses count * 2, path count 1 uses count * 1.5 rounded up, and path count 2 and higher uses the configured base count, with a hard cap of 15. A shared payload policy then applies to every build: REQ never retries; GRP_TXT keeps the role-calculated count; remote-login-critical RESPONSE, TXT_MSG, ANON_REQ, and PATH packets keep up to 15 at the originating node (path count 0) and cap at 2 after entering the path; all other flood payload types cap at 1. These caps never raise a lower role-calculated count. Setting count to 0 immediately removes queued and future flood retries; a packet already transmitting is allowed to finish.

    Forwarded neighbor adverts have an additional loop guard independent of the advert retry setting. After this node completes an advert transmission and hears a downstream copy with a longer path, it does not forward that same advert again while the advert's signed timestamp is less than six hours old. Self-originated adverts, adverts without a heard echo, and adverts six hours old or older are unaffected.

    An enabled self-originated advert retry waits at least one additional minute beyond the normal airtime-aware retry delay. Once a newer self advert has successfully entered the outbound queue, its retry sequence replaces queued or future retries for older self adverts; an older advert already transmitting is allowed to finish. Companion firmware permits this single slow retry for its own adverts while continuing to block retry attempts for neighbor adverts it forwards.

    Defaults: - infra: 1 - rooftop: 3 - mobile: 15

    Examples:

    get flood.retry.count\nset flood.retry.count 0\nset flood.retry.count 15\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-path-gate","title":"View or change flood retry path gate","text":"

    Usage: - get flood.retry.path - set flood.retry.path <count|off>

    Parameters: - count: Maximum flood path hash count eligible for retry, from 0 to 63. - off: Disable the path-length gate.

    Defaults: - infra: 1 - rooftop: 2 - mobile: 1

    Examples:

    get flood.retry.path\nset flood.retry.path 1\nset flood.retry.path off\n
    "},{"location":"cli_commands/#view-or-change-the-group-data-flood-retry-path-gate","title":"View or change the group-data flood retry path gate","text":"

    Usage: - get flood.retry.group.path - set flood.retry.group.path <count|off>

    Parameters: - count: Maximum flood path hash count eligible for retry for group data packets (PAYLOAD_TYPE_GRP_DATA/type 6), from 0 to 63. - off: Disable only the group-data-specific gate. The general flood.retry.path gate still applies.

    Default: 1 for infra, rooftop, and mobile presets.

    Note: The stricter of flood.retry.path and flood.retry.group.path is used. A value of 1 allows retry sequences at path counts 0 and 1; group data at path count 2 or higher is still forwarded normally but does not start a flood retry sequence. A value of 0 allows retries only at the originating sender.

    Setting flood.retry.path to 0 also sets flood.retry.group.path to off because the general zero-hop gate is already stricter. While the general gate remains 0, attempts to set the group-data gate keep it off. Applying a named retry preset restores the group-data default of 1.

    Examples:

    get flood.retry.group.path\nset flood.retry.group.path 1\nset flood.retry.group.path off\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-advert-handling","title":"View or change flood retry advert handling","text":"

    Usage: - get flood.retry.advert - set flood.retry.advert <on|off>

    Parameters: - on: Retry node advert floods. - off: Do not retry node advert floods.

    Default: off

    Examples:

    get flood.retry.advert\nset flood.retry.advert off\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-target-prefixes","title":"View or change flood retry target prefixes","text":"

    Usage: - get flood.retry.prefixes - set flood.retry.prefixes <prefixes|none|off>

    Parameters: - prefixes: Comma-separated 3-byte path hash prefixes, up to 8 entries. - none or off: Clear the list.

    Note: When set, non-bridge flood retry only accepts same-packet echoes whose last hop matches one of these prefixes. When unset, any non-ignored last hop can cancel the retry.

    Examples:

    get flood.retry.prefixes\nset flood.retry.prefixes A58296,860CCA,425E5C\nset flood.retry.prefixes none\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-ignored-prefixes","title":"View or change flood retry ignored prefixes","text":"

    Usage: - get flood.retry.ignore - set flood.retry.ignore <prefixes|none|off>

    Parameters: - prefixes: Comma-separated 3-byte path hash prefixes, up to 8 entries. - none or off: Clear the list.

    Note: Non-bridge flood retry does not cancel on same-packet echoes whose last hop matches this list. Bridge mode also excludes these prefixes from bucket and other hits.

    Examples:

    get flood.retry.ignore\nset flood.retry.ignore 71CE82,C7618C\nset flood.retry.ignore none\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-bridge-mode","title":"View or change flood retry bridge mode","text":"

    Usage: - get flood.retry.bridge - set flood.retry.bridge <on|off>

    Note: Bridge mode retries until each configured fresh bucket, plus the non-source other bucket, has been heard or the retry count is exhausted. If prefixes in different buckets share their first byte, configuration commands return a warning because a 1-byte path cannot distinguish those buckets. The configuration remains valid: an ambiguous source is treated as belonging to every matching source bucket, and an ambiguous echo credits every matching target bucket so it cannot force retry exhaustion.

    Flood retry timing retains its fixed maximum-frame plus 20 packet-airtime wait, then adds a random 0-200% of one additional packet airtime on every attempt. This de-synchronizes repeaters that may have missed the same echo while capping the added wait at two frames.

    Only one active retry sequence is kept for a given logical flood packet. An identical flood can still transmit normally, but it does not create a second sequence of extra attempts. Retry state is released if a queued packet is evicted, and the final echo window retains metadata without reserving a packet-pool entry.

    Bridge reachability learned from earlier hops in a successful echo is cached separately from recent.repeater. Only the final RF hop updates recent.repeater and its SNR, so indirect path entries cannot affect direct-retry SNR gating or coding-rate selection.

    Examples:

    get flood.retry.bridge\nset flood.retry.bridge on\n
    "},{"location":"cli_commands/#view-or-change-flood-retry-bridge-buckets","title":"View or change flood retry bridge buckets","text":"

    Usage: - get flood.retry.bucket.<n> - set flood.retry.bucket <n> <prefixes|none|off>

    Parameters: - n: Bucket number from 1 to 6. - prefixes: Comma-separated 3-byte path hash prefixes, up to 17 entries per bucket. - none or off: Clear the bucket.

    Examples:

    get flood.retry.bucket.1\nset flood.retry.bucket 1 71CE82,C7618C\nset flood.retry.bucket 2 none\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-count","title":"View or change direct retry count","text":"

    Search terms: tx retries, transmit retries, direct retries, retry count, retry attempts, retransmission count, message retries, DM retries.

    Usage: - get direct.retry.count - set direct.retry.count <count>

    Parameters: - count: Maximum retry attempts after the original send, from 1 to 15.

    Default: 15 with the rooftop preset

    Note: This setting limits retries for eligible direct-routed packets such as traces, requests, responses, and ACKs. Direct-routed text messages (type 2) are an exception: they allow up to 21 retries after the original send, regardless of direct.retry.count or the repeater short-path cap. For example, set direct.retry.count 1 limits eligible non-text packets to one retry, but does not reduce the text-message limit.

    These are maximums, not a fixed number of transmissions. Retries stop early when the node hears the next hop forward the packet. The special final-hop retry sends only one duplicate because the destination does not forward the packet. Repeater non-text retries are also capped at 8 for a retry path of up to 3 hops, 12 for 4 hops, and 15 for longer paths; these caps never increase a lower configured count.

    Use set direct.retry off to disable this node's direct retries, including text-message retries, and set direct.retry on to enable them again. A count of 0 is not supported. Sending apps and other nodes may have their own retry behavior; this setting does not change it.

    Examples:

    get direct.retry.count\nset direct.retry.count 1\nset direct.retry.count 4\nset direct.retry.count 15\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-base-delay","title":"View or change direct retry base delay","text":"

    Search terms: retry delay, retry timeout, retransmission timeout, wait between retries.

    Usage: - get direct.retry.base - set direct.retry.base <ms>

    Parameters: - ms: First retry wait in milliseconds, from 10 to 5000.

    Default: 175 with the rooftop preset

    Explanation: - The first retry waits for base + packet-length add-on + random forwarding jitter after the preceding transmission completes. - TRACE and ANON_REQ/type 7 packets use a 3x line-time add-on. TXT_MSG/type 2 packets use 7x. Other direct retry packets use 6x. - Room-server and sensor firmware use this configured base with the same packet-type add-ons. - Larger values reduce channel pressure and give slow repeaters more time. - Smaller values recover faster but create tighter retry bursts.

    Examples:

    get direct.retry.base\nset direct.retry.base 175\nset direct.retry.base 275\nset direct.retry.base 500\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-step-delay","title":"View or change direct retry step delay","text":"

    Search terms: retry backoff, increasing retry delay, retry interval step.

    Usage: - get direct.retry.step - set direct.retry.step <ms>

    Parameters: - ms: Extra milliseconds added for each subsequent retry, from 0 to 5000.

    Default: 100 with the rooftop preset

    Explanation: - Retry delay is base + packet-length add-on + random forwarding jitter + attempt_index * step. - TRACE and ANON_REQ/type 7 packets use a 3x packet-length add-on. TXT_MSG/type 2 packets use 7x. Other direct retry packets use 6x. - Room-server and sensor firmware use this configured step with the same packet-type add-ons. - With base=175 and step=100, the fixed portion is 175, 275, 375, 475 ms, and so on, before the packet-length add-on and random jitter. - step=0 keeps every retry at the same delay. - Larger steps spread retries over time and are safer on busy channels.

    Examples:

    get direct.retry.step\nset direct.retry.step 0\nset direct.retry.step 50\nset direct.retry.step 100\nset direct.retry.step 250\n
    "},{"location":"cli_commands/#view-or-change-direct-retry-snr-margin","title":"View or change direct retry SNR margin","text":"

    Search terms: retry signal threshold, retry SNR threshold, retry signal margin.

    Usage: - get direct.retry.margin - set direct.retry.margin <snr_db>

    Parameters: - snr_db: Extra SNR margin above the SF receive floor, from 0 to 40.

    Default: 5.00 with the rooftop preset

    Notes: - This command is repeater-only. - Unknown repeaters are still retried. - Known repeaters below the receive floor plus this margin are skipped. - Failed attempts lower the recent repeater SNR estimate by 0.25 dB.

    Examples:

    get direct.retry.margin\nset direct.retry.margin 0\nset direct.retry.margin 2.5\nset direct.retry.margin 5\nset direct.retry.margin 10\n
    "},{"location":"cli_commands/#view-or-change-adaptive-direct-retry-coding-rate","title":"View or change adaptive direct retry coding rate","text":"

    Usage: - get direct.retry.cr - set direct.retry.cr off - set direct.retry.cr on (room-server and sensor) - set direct.retry.cr <cr4_min>,<cr5_min>,<cr7_min>,<cr8_max>

    Parameters: - cr4_min: Minimum SNR in dB to retry at CR4. - cr5_min: Minimum SNR in dB to retry at CR5. - cr7_min: Minimum SNR in dB to retry at CR7. - cr8_max: Maximum SNR in dB that forces CR8.

    Default: 10.00,7.50,2.50,2.50

    Explanation: - Higher SNR uses faster coding rates. - Lower SNR uses more robust coding rates. - Repeater retry attempts escalate from the adaptive starting CR. CR4 starts as CR4, CR5, CR7, CR7, then CR8. CR5 starts as CR5, CR7, CR7, then CR8. CR7 gets two attempts, then CR8. - Repeater adaptive CR selection intentionally skips CR6. - Non-repeater retry packets start at the current radio CR and follow the same escalation pattern, clamped at CR8. With the normal CR5 radio setting this is CR5, CR7, CR7, then CR8. - Room-server and sensor firmware accept on or off; numeric SNR thresholds are repeater-only because those roles do not keep recent-repeater SNR data. - off disables per-packet direct-retry CR overrides and uses the current radio CR. - Retry packets may use a different coding rate, but they keep the radio's normal physical preamble. - Unknown repeaters start at +3.00 dB for adaptive CR selection. - A failed unknown repeater is seeded at +2.75 dB. - Each later failure lowers the SNR estimate by 0.25 dB.

    Examples:

    get direct.retry.cr\nset direct.retry.cr off\nset direct.retry.cr on\nset direct.retry.cr 10.0,7.5,2.5,2.5\nset direct.retry.cr 12.0,8.0,4.0,1.0\nset direct.retry.cr 8.0,5.0,1.5,0\nset direct.retry.cr 6.0,3.0,0,-2.0\nset direct.retry.cr 20.0,12.0,6.0,2.0\nset direct.retry.cr 4.0,2.0,0,-4.0\n

    Example profiles: - Conservative weak-link profile:

    set direct.retry.cr 12.0,8.0,4.0,1.0\n
    set direct.retry.cr 10.0,7.5,2.5,2.5\n
    set direct.retry.cr 6.0,3.0,0,-2.0\n
    set direct.retry.cr 20.0,12.0,6.0,2.0\n
    "},{"location":"cli_commands/#view-seed-or-clear-the-recent-repeater-table","title":"View, seed, or clear the recent repeater table","text":"

    Usage: - get recent.repeater - get recent.repeater <page> - get recent.repeaters <page> - get recent.repeaters search <prefix> [page] - set recent.repeater <prefix> [snr_db] - clear recent.repeater

    Parameters: - prefix: Repeater path-hash prefix as 2, 4, or 6 hex characters. - snr_db: Optional SNR in dB. If omitted or invalid, defaults to 3.0. - page: 1-based result page.

    Note: These commands are repeater-only.

    The default capacity is 256 entries on classic ESP32, 2,048 on other ESP32 chips, 512 on nRF52, and 64 on other platforms. Builds can override it with MAX_RECENT_REPEATERS. Classic ESP32's history uses 3,072 bytes of startup heap instead of 24,576 bytes, leaving more memory for the packet pool and Wi-Fi.

    Output order: - get recent.repeater lists 3-byte prefixes first, then 2-byte prefixes, then 1-byte prefixes. - Within each prefix length, entries are sorted from highest SNR to lowest SNR. - search returns every overlapping path-hash entry. For example, searching 860C can return 86, 860C, and 860CCA; it does not return a different branch such as 86D0. - Search rows include the monotonic age of the entry's most recent recording, compacted to a whole s, m, or h field. Search pages contain up to six rows so the result remains within the remote CLI reply limit.

    SNR details: - Recent repeater SNR is stored internally in quarter-dB units. - Heard repeater samples update an existing table entry with a weighted blend: 75% existing SNR and 25% new heard SNR, rounded up. - Direct retry success also feeds the heard echo SNR back into the same weighted table. - Direct retry failure is not weighted: each final echo-timeout failure lowers that repeater's SNR by 0.25 dB. - Unknown repeaters start at +3.00 dB for adaptive CR selection. - If an unknown repeater fails, it is seeded into the table at +2.75 dB. - set recent.repeater <prefix> [snr_db] seeds a missing prefix or adds another weighted sample for an existing prefix. - Successful set recent.repeater replies include the stored prefix and SNR, for example OK - set A1B2C3 at 3.0 SNR. - Entries strictly older than 24 hours are removed during a sweep every three hours, so an entry can remain for at most approximately 27 hours.

    Examples:

    get recent.repeater\nget recent.repeater 2\nget recent.repeaters search 86\nget recent.repeaters search 860C page 2\nset recent.repeater A1B2C3 8.5\nset recent.repeater 71CE82 -3.25\nset recent.repeater A1B2C3\nclear recent.repeater\n
    "},{"location":"cli_commands/#gps-when-gps-support-is-compiled-in","title":"GPS (When GPS support is compiled in)","text":""},{"location":"cli_commands/#view-or-change-gps-state","title":"View or change GPS state","text":"

    Search terms: enable GPS, disable GPS, turn GPS on, turn GPS off.

    Usage: - get gps - set gps <state>

    Parameters: - state: on|off

    Default: off

    Note: These command names are shared with GPS-capable Companion builds. Companion reports on or off; infrastructure includes receiver details: - off when the GPS hardware is disabled - on, {active|deactivated}, {fix|no fix}, {sat count} sats when the GPS hardware is enabled

    "},{"location":"cli_commands/#sync-this-nodes-clock-with-gps-time","title":"Sync this node's clock with GPS time","text":"

    Usage: - gps sync

    The GPS must be enabled. When GPS power saving has put an enabled receiver to sleep, this command schedules a sync and wakes it; after set gps off, it reports gps is off without scheduling work.

    "},{"location":"cli_commands/#set-this-nodes-location-based-on-the-gps-coordinates","title":"Set this node's location based on the GPS coordinates","text":"

    Usage: - gps setloc

    "},{"location":"cli_commands/#view-or-change-the-gps-advert-policy","title":"View or change the GPS advert policy","text":"

    Usage: - gps advert - gps advert <policy>

    Parameters: - policy: none|share|prefs - none: don't include location in adverts - share: share gps location (from SensorManager) - prefs: location stored in node's lat and lon settings

    Default: prefs on every repeater, room-server, and sensor build that uses the common advert policy. A previously saved none, share, or prefs choice still overrides the first-boot default after an update.

    "},{"location":"cli_commands/#sensors-when-sensor-support-is-compiled-in","title":"Sensors (When sensor support is compiled in)","text":""},{"location":"cli_commands/#view-or-change-telemetry-access-mode","title":"View or change telemetry access mode","text":"

    Usage: - get telemetry.access - set telemetry.access <mode>

    Parameters: - mode: all|acl - all: allow telemetry requests using the requester-provided telemetry mask - acl: require ACL read-only or higher for telemetry, including GPS

    Default: all

    Note: all matches the previous sensor telemetry behavior.

    "},{"location":"cli_commands/#view-the-list-of-sensors-on-this-node","title":"View the list of sensors on this node","text":"

    Usage: sensor list [start]

    Parameters: - start: Optional starting index (defaults to 0)

    Note: Output format: <var_name>=<value>\\n

    "},{"location":"cli_commands/#view-or-change-the-value-of-a-sensor","title":"View or change the value of a sensor","text":"

    Usage: - sensor get <key> - sensor set <key> <value>

    Parameters: - key: Sensor setting name - value: The value to set the sensor to

    "},{"location":"cli_commands/#bridge-when-bridge-support-is-compiled-in","title":"Bridge (When bridge support is compiled in)","text":""},{"location":"cli_commands/#view-the-compiled-bridge-type","title":"View the compiled bridge type","text":"

    Usage: get bridge.type

    "},{"location":"cli_commands/#view-or-change-the-bridge-enabled-flag","title":"View or change the bridge enabled flag","text":"

    Search terms: enable bridge, disable bridge, serial bridge, RS232 bridge.

    Usage: - get bridge.enabled - get bridge.running - set bridge.enabled <state>

    Parameters: - state: on|off

    bridge.enabled is the saved intent. bridge.running reports whether the bridge actually started in this boot; they can differ after a hardware conflict, missing credentials, or a transient initialization failure. Normal merged repeater images default to off; dedicated bridge images may default to on.

    "},{"location":"cli_commands/#add-a-delay-to-packets-routed-through-this-bridge","title":"Add a delay to packets routed through this bridge","text":"

    Usage: - get bridge.delay - set bridge.delay <ms>

    Parameters: - ms: Delay in milliseconds (0-10000)

    Default: 500

    "},{"location":"cli_commands/#view-or-change-the-source-of-packets-bridged-to-the-external-interface","title":"View or change the source of packets bridged to the external interface","text":"

    Usage: - get bridge.source - set bridge.source <source>

    Parameters: - source: - rx: bridges received packets - tx: bridges transmitted packets

    Default: tx

    Note: For MQTT, use mqtt.rx and mqtt.tx to control each direction independently. Both can be enabled together. Use mqtt.enabled as the MQTT master switch; bridge.* configures RS232 and ESP-NOW bridges.

    "},{"location":"cli_commands/#view-or-change-the-mqtt-master-switch","title":"View or change the MQTT master switch","text":"

    Search terms: enable MQTT, disable MQTT, MQTT on, MQTT off, WiFi logging.

    Usage:

    These commands are the same on MQTT-capable Companion, Repeater, Room Server, and Sensor builds. The master switch is saved; turning it off disconnects MQTT while keeping broker presets and credentials. Turning it on allows those brokers to reconnect. get mqtt.running checks the service's runtime state; get mqtt.status reports individual broker connections. Enabling MQTT does not imply that a broker is connected.

    USB logging remains independent. On images with both outputs, set logging.output off|usb|wifi|both selects USB and MQTT together.

    "},{"location":"cli_commands/#view-or-change-mqtt-rx-packet-uplinking","title":"View or change MQTT RX packet uplinking","text":"

    Search terms: MQTT receive logging, MQTT RX capture, publish received packets.

    Usage: - get mqtt.rx - set mqtt.rx <on|off>

    Parameters: - on: uplink received (RX) packets to MQTT brokers - off: disable RX packet uplinking

    Default: on

    "},{"location":"cli_commands/#view-or-change-mqtt-tx-packet-uplinking","title":"View or change MQTT TX packet uplinking","text":"

    Search terms: MQTT transmit logging, MQTT TX capture, publish sent packets.

    Usage: - get mqtt.tx - set mqtt.tx <on|off|advert>

    Parameters: - on: uplink all transmitted (TX) packets to MQTT brokers - advert: uplink only this node's own advert packets (self-originated advertisements only - forwarded adverts from other nodes are filtered out) - off: disable TX packet uplinking

    Default: advert

    Note: mqtt.rx and mqtt.tx take effect immediately - no restart required. Both can be enabled simultaneously.

    "},{"location":"cli_commands/#view-or-change-periodic-neighbors-publishing-mqtt-observer-neighbors-feature","title":"View or change periodic neighbors publishing (MQTT observer, neighbors feature)","text":"

    Usage: - get mqtt.neighbors - set mqtt.neighbors <on|off>

    Parameters: - on: periodically discover neighbor scopes and publish the neighbor table to the neighbors topic - off: disable periodic neighbors publishing

    Default: off

    Note: Requires a build with the neighbors feature compiled in (all PSRAM boards, plus non-PSRAM boards built with MQTT_NEIGHBORS_WITHOUT_PSRAM); elsewhere this replies Err - neighbors not enabled in this build. Non-PSRAM builds publish at most 20 neighbours per pass to bound internal-DRAM use, and set truncated with the true total_neighbors when the table is larger. The setting is read live by the mesh loop - no restart required; enabling it triggers a discovery on the next pass. While enabled, get mqtt.status gains a trailing nbr: <next>/<last> field (time to next publish, and how the last publish went).

    "},{"location":"cli_commands/#view-or-change-the-neighbors-publish-interval-mqtt-observer-neighbors-feature","title":"View or change the neighbors publish interval (MQTT observer, neighbors feature)","text":"

    Usage: - get mqtt.neighbors.interval - set mqtt.neighbors.interval <hours>

    Parameters: - hours: how often to publish the neighbor table (12-336, default 24)

    Default: 24 (hours)

    Note: Out-of-range values are rejected (not clamped). Requires a build with WITH_MQTT_NEIGHBORS; PSRAM boards enable it automatically and selected non-PSRAM variants opt in with MQTT_NEIGHBORS_WITHOUT_PSRAM.

    "},{"location":"cli_commands/#view-or-change-the-ntp-server-mqtt-observer-only","title":"View or change the NTP server (MQTT observer only)","text":"

    Usage: - get mqtt.ntp - set mqtt.ntp <hostname> - set mqtt.ntp none

    Description: Sets the primary NTP server used for clock sync (required for JWT MQTT auth). On set, the device attempts an immediate sync of the just-configured server (primary only, so a typo fails fast) when WiFi is connected and the MQTT bridge is running.

    Fallbacks: If the primary fails, the firmware tries pool.ntp.org, time.google.com, time.cloudflare.com, time.aws.com, and time.nist.gov in order (skipping duplicates).

    Default: pool.ntp.org (when unset or none)

    "},{"location":"cli_commands/#diagnose-ntp-server-connectivity-mqtt-observer-only","title":"Diagnose NTP server connectivity (MQTT observer only)","text":"

    Usage: - get mqtt.ntp.diag

    Description: Probes every configured NTP server (the custom primary, if set, plus the built-in fallbacks) and reports whether each responds. This is a pure connectivity diagnostic - it does not change the system clock.

    Requires WiFi connected and the MQTT bridge running.

    "},{"location":"cli_commands/#view-or-change-the-speed-of-the-bridge-rs-232-only","title":"View or change the speed of the bridge (RS-232 only)","text":"

    Usage: - get bridge.baud - set bridge.baud <rate>

    Parameters: - rate: Integer baud rate from 9600 through the board's compiled BRIDGE_MAX_BAUD (commonly 500000); for example 115200. Stop the bridge with set bridge.enabled off before changing it, then enable it again.

    Default: 115200

    "},{"location":"cli_commands/#view-or-change-the-uart-used-by-the-bridge-rs-232-only","title":"View or change the UART used by the bridge (RS-232 only)","text":"

    Usage: - get bridge.uart - set bridge.uart <port>

    Parameters: - port: Hardware UART number compiled for the board. Most boards expose one fixed UART. RAK4631 accepts 1 or 2; UART 2 is the default so UART 1 can remain available to the RAK12501/L76K GPS. RAK12500 GPS uses I2C rather than this UART, but the explicit legacy Serial1 bridge omits the combined GPS provider and therefore does not expose either GPS path.

    The setting is persistent and restarts an enabled bridge immediately. Normal repeater artifacts start with bridge.enabled off; configure the UART and baud rate before running set bridge.enabled on. On the canonical RAK4631 runtime image, UART 1 is reserved even if the bounded boot probe hears no RAK12501. Silence cannot prove that a cold L76K is physically absent, and that module remains powered by the shared WB_IO2/3V3_S rail. Use UART 2. UART 1 requires an explicit no-GPS/dedicated Serial1 bridge image. This fail-closed reservation also applies when the detected GPS is an I2C RAK12500; that receiver does not electrically use UART 1, but its presence cannot rule out another silent UART module.

    "},{"location":"cli_commands/#view-or-change-the-channel-used-for-bridging-espnow-only","title":"View or change the channel used for bridging (ESPNow only)","text":"

    Usage: - get bridge.channel - set bridge.channel <channel>

    Parameters: - channel: Channel number from 1 through 13 in either format. This matches the primary-ESP-NOW policy and the default regulatory range used by the supported ESP32 targets.

    This controls the optional ESP-NOW bridge transport; it does not change a node's primary ESP-NOW mesh channel. Primary-ESP-NOW firmware uses get espnow.channel and set espnow.channel <1-13> instead.

    "},{"location":"cli_commands/#view-or-change-the-esp-now-bridge-wire-format","title":"View or change the ESP-NOW bridge wire format","text":"

    Usage: - get bridge.format - set bridge.format <format>

    Parameters: - format: - wrapped: the original ESP-NOW bridge framing (magic, checksum, and XOR using bridge.secret) - raw: the exact serialized MeshCore packet used by primary ESPNOWRadio firmware

    Default: wrapped

    The setting is persistent and restarts an enabled bridge immediately. Receive parsing is strict: wrapped accepts only wrapped frames and raw accepts only raw MeshCore frames. Coordinate the format and channel on every ESP-NOW peer; there is no automatic dual-format receive mode because it would permit ambiguous, asymmetric bridge deployments.

    Use raw to connect a LoRa-primary *_repeater_bridge_espnow node to Generic_ESPNOW, SenseCapIndicator-ESPNow, or another primary-ESP-NOW node. Raw mode enables the ESP-NOW LR PHY and sends at the same LR rate used by those targets. All nodes must use the same 1-13 channel. On a primary-ESP-NOW node, change that side with set espnow.channel <1-13> and reboot; on the bridge, use set bridge.channel <1-13>.

    Raw mode ignores bridge.secret and removes the bridge wrapper's lightweight network isolation. MeshCore's own packet authentication/encryption still applies where the packet type provides it, but public frames and routing metadata remain visible. The wrapped format's XOR is isolation, not strong cryptography. Bridge duplicate tracking remains active in both formats, but deploying multiple gateways between the same LoRa and ESP-NOW coverage areas can still increase duplicate traffic while their seen-packet tables converge.

    One ESP-NOW frame carries at most 250 payload bytes. In raw mode, serialized MeshCore packets up to that size remain byte-for-byte compatible with existing raw endpoints. Updated raw endpoints split 251-255-byte transport units into two versioned fragments and reassemble them by source MAC, length, and CRC; both endpoints must include this support for those sizes. Current valid MeshCore packet geometry reaches 254 bytes, while 255 is retained as transport headroom. Wrapped mode retains its legacy 246-byte maximum after the four-byte magic and checksum overhead, and drops larger packets instead of truncating them.

    "},{"location":"cli_commands/#set-the-esp-now-secret","title":"Set the ESP-Now secret","text":"

    Usage: - get bridge.secret - set bridge.secret <secret>

    Parameters: - secret: ESP-NOW bridge secret, 1-15 characters

    Default: Varies by board

    This setting is used only by bridge.format wrapped; raw mode ignores it.

    "},{"location":"cli_commands/#view-the-bootloader-version-nrf52-only","title":"View the bootloader version (nRF52 only)","text":"

    Usage: get bootloader.ver

    Applies to every nRF52 board/role that exposes this getter, including Companion, repeater, and room-server firmware. It reads the installed bootloader region selected by the MBR/UICR boot address and bounded by the chip's flash geometry; it does not search application firmware or staged update files.

    The reader preserves a complete Adafruit/UF2 version string, including OTAFIX, preview, and vendor/build suffixes. If that string is absent or ambiguous, it uses the existing whole-image CRC and unique BLMF/BLM2 validation to read the embedded OTAFIX version (for example, > OTAFIX2.4.6). Some current release images, including MeshTower V2 SD 2.4.6, omit the UF2 text entirely. As a last fallback, the Adafruit core's startup-captured version is explicitly labelled (base); it cannot identify an OTAFIX/fork revision.

    unknown remains possible for a bootloader exposing none of these version sources, invalid geometry/vectors, or unresolved ambiguity. There is no universal version field shared by every third-party bootloader. This is diagnostic only: displaying a version neither grants OTA capability nor bypasses install checks. The fix is in MeshCore application firmware; installing another bootloader alone does not update an older application's version reader.

    "},{"location":"cli_commands/#view-power-management-support","title":"View power management support","text":"

    Usage: get pwrmgt.support

    "},{"location":"cli_commands/#view-the-current-power-source","title":"View the current power source","text":"

    Usage: get pwrmgt.source

    Note: Returns an error on boards without power management support.

    "},{"location":"cli_commands/#view-the-boot-reset-and-shutdown-reasons","title":"View the boot reset and shutdown reasons","text":"

    Usage: get pwrmgt.bootreason

    Note: Returns an error on boards without power management support.

    "},{"location":"cli_commands/#view-the-boot-voltage","title":"View the boot voltage","text":"

    Usage: get pwrmgt.bootmv

    Note: Returns an error on boards without power management support.

    "},{"location":"cli_commands/#ethernet-when-ethernet-support-is-compiled-in","title":"Ethernet (when Ethernet support is compiled in)","text":"

    Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Ethernet module. Use the _ethernet firmware variants (e.g. RAK_4631_repeater_ethernet) to enable this feature.

    "},{"location":"cli_commands/#view-ethernet-connection-status","title":"View Ethernet connection status","text":"

    Usage: - eth.status

    Output: - ETH: <ip>:<port> when connected (e.g. ETH: 192.168.1.50:23) - ETH: not connected when Ethernet is not active

    Notes: - Available on repeater and room server firmware only. Companion radio ethernet firmware does not expose a CLI. - The Ethernet interface obtains an IP address via DHCP automatically on boot. - A TCP server listens on port 23 (default) for CLI connections. - Connect with any TCP client (e.g. nc, PuTTY) to access the same CLI available over serial.

    "},{"location":"companion_contact_cache/","title":"Full Companion contact caches","text":"

    Full Companions on ESP32 and nRF52 without PSRAM now keep 16 outgoing paths and 16 shared secrets in RAM. Every contact remains in the contact table; selecting a different contact loads its saved path as needed. This recovers about 25.4 KiB of internal RAM in the 350-contact ESP32 qualification builds.

    See the qualification results for the six-board RAM comparison, physical tests, and shared-secret timings.

    PSRAM boards retain their existing complete contact table in external RAM. Legacy USB/BLE/WiFi Companion profiles also retain their inline paths and secrets. The build flag MESH_CONTACT_CACHE=0 or 1 overrides this policy for qualification; it is not a runtime feature switch.

    "},{"location":"companion_contact_cache/#paths-and-persistence","title":"Paths and persistence","text":"

    The 16 resident paths use least-recently-used replacement. Contact records keep small handles to their saved paths, including 1-, 2-, and 3-byte hop hashes. The existing 152-byte contact record and Companion app frame formats are unchanged. ESP32 uses /contacts3; nRF52 also supports its paged contact store and migration from /contacts3. An erase is not required.

    A hot path needs no flash read. A cold path is read from its existing contact record. ESP32 reuses one open contact-file reader to avoid repeated SPIFFS metadata scans during synchronization and saving. It closes that reader before replacing the file or recovering a saved transaction.

    Changing a copied contact preserves the old path for a pending app response or rollback. Dirty entries are saved before eviction. If storage cannot be read, written, or represented completely, the operation fails instead of silently losing a contact or sending along a different route. Reboot retries an incomplete load from the preserved files. The path checksum also rejects a cold path changed underneath a live handle; it does not add a new checksum to the legacy ESP32 on-disk format.

    "},{"location":"companion_contact_cache/#shared-secrets-and-power","title":"Shared secrets and power","text":"

    Both platforms reuse the 16 most recent secrets from RAM. Cache misses use a platform-specific policy:

    Platform Miss behavior ESP32 Recalculate the secret without reading or writing a secret file. nRF52 Try a saved LittleFS entry, then recalculate if it is unavailable.

    On the V4, a measured SPIFFS secret lookup took 132,427 microseconds while key exchange took 21,932 microseconds. ESP32 therefore uses recalculation on a RAM-cache miss. It avoids secret-file writes and reduces time awake for this measured workload. These are elapsed times, not measurements of energy in joules; other ESP32 hardware has not been physically benchmarked.

    On a RAK3401, a LittleFS lookup took 7,812 microseconds versus 31,250 microseconds for key exchange. The nRF52 backend therefore uses saved flash entries to avoid repeat key exchange when a usable entry exists, including after reboot. Saved entries match the full peer public key and a fingerprint of the local key pair, and have a record checksum. Importing a different private or public key invalidates old results. A corrupt or missing saved entry is recalculated. The native tests use the firmware's actual Ed25519 key-exchange library and verify agreement with the peer's independently calculated secret.

    Derived secrets are expendable. Their storage keeps a reserve for contacts, preferences and filesystem metadata; a full filesystem leaves the calculated secret usable in RAM. nRF52 packs 56 entries into a 3,844-byte page to use its 4 KiB flash blocks efficiently. Small filesystems may not have space to persist secrets for all 350 contacts. A miss without a usable saved entry still performs key exchange.

    MESH_CONTACT_SECRET_FLASH_CACHE=0 or 1 overrides the miss policy for qualification builds. The optional ESP32 flash backend groups eight entries per file. The normal ESP32 policy does not create or use those files.

    "},{"location":"companion_contact_cache/#inspect-the-cache","title":"Inspect the cache","text":"

    From the Full Companion's ASCII USB terminal or authenticated local CLI:

    get contact.cache\n

    Cached builds report paths=16 secrets=16, miss=calculate or miss=flash, plus these boot-session counters:

    Field Meaning ram_hits Shared secret reused from RAM. flash_hits Shared secret loaded from flash without recalculation. calculations Shared-secret key exchanges performed. save_skips Calculated secrets that could not be persisted, including storage reserve/backoff.

    Uncached builds report paths=inline secrets=inline. The normal memory command on supported ESP32 Full Companions shows live heap and queue capacity.

    get contact.cache.timing reports the last successful flash lookup and last key calculation in microseconds. Zero means no such operation has completed since boot. These timings exclude secret writes and are useful for comparing the work avoided on a valid flash hit.

    "},{"location":"companion_contact_cache/#offline-messages-during-mota","title":"Offline messages during mOTA","text":"

    Contact caches are independent of the offline message queue. Builds using the shared mOTA queue retain 256 frames normally and 128 while mOTA owns its workspace, then restore 256. No unread frames are discarded to start mOTA: synchronize the queue and retry if it exceeds the retained capacity. PSRAM-backed queues keep their existing capacity and allocation policy.

    Each queue frame occupies 177 bytes. Retaining 32 instead of 128 would make another 16,992 bytes (16.6 KiB) available inside the shared storage. It would not increase free heap by itself: the normal 256-frame storage is statically reserved, and the current mOTA context already fits in the loaned half. More mOTA scratch data would have to share that space to obtain an additional RAM saving. These changes keep the existing 128-frame mOTA limit.

    "},{"location":"companion_contact_cache/#build-the-six-capacity-trials","title":"Build the six capacity trials","text":"

    The optional environments in platformio.nimble.ini combine NimBLE, these contact caches, and the shared mOTA queue at 350 contacts, 40 channels and 256 normal offline frames. They remain outside the ordinary release matrix. The ordinary six constrained release recipes still use their established 150-contact limits pending wider hardware qualification.

    Enable platformio.nimble.ini as described in the NimBLE trial guide, then build one environment at a time. For example:

    OUTPUT_DIR=.releases/contact-cache-v3 bash build.sh build-firmware \\\n  Heltec_v3_ram_trial_companion_radio_full_nimble \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-cache-trial \\\n  --radio-preset usa-cascadia --profile cascade --standard --require-ota\n
    Hardware Trial environment Heltec V3 Heltec_v3_ram_trial_companion_radio_full_nimble Wireless Tracker Heltec_Wireless_Tracker_ram_trial_companion_radio_full_nimble Tracker V2, FEM on heltec_tracker_v2_ram_trial_companion_radio_full_femon_nimble Heltec CT62 Heltec_ct62_ram_trial_companion_radio_full_nimble XIAO ESP32-C3 Xiao_C3_ram_trial_companion_radio_full_nimble Generic ESP-NOW Generic_ESPNOW_ram_trial_companion_radio_full_nimble

    The firmware RAM guard remains enabled. A successful linked budget is distinct from live free heap and physical Bluetooth/LoRa qualification on each board.

    The smaller NimBLE applications let the CT62, XIAO C3 and Generic ESP-NOW trials use two 1.5 MiB WiFi OTA slots on 4 MiB flash. Their 896 KiB SPIFFS and coredump stay at the same addresses as the ordinary huge_app.csv Full layout. Install the trial's merged image over USB first to install its partition table. Subsequent WiFi updates must fit the new slots. An application-only update from the ordinary single-slot Full layout cannot change that layout or enable WiFi OTA.

    "},{"location":"companion_contact_cache/#regression-checks","title":"Regression checks","text":"
    python3 -B test/test_contact_cache.py\npython3 -B test/test_companion_contact_persistence_contract.py\npython3 -B test/test_companion_contact_stream_contract.py\npython3 -B test/test_nrf52_extrafs_contract.py\npython3 -B test/test_shared_mota_queue.py\npython3 -B test/test_t096_full_memory.py\npython3 -B test/test_firmware_ram.py\n

    The cache tests compile the production path/secret cache code and extracted production persistence, routing and request functions with filesystem/radio adapters. They exercise 350 routes, eviction, snapshot rollback, page migration, identity changes, read/write/rename faults, handle exhaustion, packet release on a failed path read, and recovery after a simulated reset between file renames. They do not simulate a physical flash power cut.

    tools/hil/contact_cache_serial_stress.py checks all 350 paths over USB, replaces them with a different generation, and optionally exercises secret eviction. Use an otherwise empty test node and exclusive access to its Companion data port. --populate writes test contacts; --secrets transmits 23 directed LoRa datagrams to synthetic peers. For an ESP32 cache trial:

    python3 tools/hil/contact_cache_serial_stress.py --port /dev/ttyACM0 \\\n  --populate --generation 1 --secrets --expect-calculate --reboot\npython3 tools/hil/contact_cache_serial_stress.py --port /dev/ttyACM0 \\\n  --generation 1 --secrets --expect-calculate\n

    Use the actual device path. Omit --expect-calculate for the nRF52 flash policy. The second invocation verifies the persisted paths after reboot.

    "},{"location":"companion_contact_cache_results/","title":"Contact-cache and NimBLE RAM qualification","text":"

    Test date: 2026-09-08. Firmware source: b68aa1c6.

    All six constrained ESP32 Full Companion trials pass the linked RAM budget at 350 contacts, 40 channels and 256 normal offline frames. Their final capability manifests confirm compiled Bluetooth/mOTA support and valid WiFi OTA layouts. These are the optional environments in the contact-cache guide. Ordinary release recipes retain their established contact limits pending qualification on those boards.

    "},{"location":"companion_contact_cache_results/#linked-internal-ram","title":"Linked internal RAM","text":"

    Every column below uses 350 contacts, 40 channels and 256 normal offline frames. Values are bytes available for runtime allocations after linking. The final margin subtracts the existing conservative runtime reservation; it is not a measurement of live free heap.

    Hardware Bluedroid NimBLE NimBLE + mOTA queue loan Plus contact caches Final margin Heltec V3 143,776 158,896 170,152 196,168 23,112 Wireless Tracker 148,432 163,536 174,792 200,824 30,838 Tracker V2, FEM on 133,064 148,168 159,416 185,456 15,470 Heltec CT62 99,040 114,096 125,312 151,344 15,152 XIAO ESP32-C3 98,816 113,888 125,120 151,152 14,960 Generic ESP-NOW 99,776 114,800 126,032 152,064 15,872

    NimBLE recovers about 14.7 KiB. Contact caches add about 25.4 KiB on top of NimBLE and the shared mOTA workspace. NimBLE alone fails the unchanged budget at this capacity on all six; adding the queue loan passes only on the original Wireless Tracker. Adding the contact caches passes all six, with 14.6-30.1 KiB of margin. The same Bluetooth runtime allowance is used in every comparison.

    The shared mOTA queue stays at 256 frames normally and retains 128 while mOTA borrows its workspace, then returns to 256. A queue above 128 must be synchronized before that workspace can be borrowed; unread frames are not discarded to start mOTA. PSRAM-backed queues retain their existing policy.

    The baseline builds use b041fc7f; the final builds use b68aa1c6, with the same ESP32 Arduino 2.0.17 toolchain and NimBLE-Arduino 2.5.1 where applicable.

    The CT62, XIAO C3 and Generic ESP-NOW applications fit two 1.5 MiB OTA slots on 4 MiB flash. Their explicit trial layouts retain 896 KiB SPIFFS and the existing SPIFFS/coredump addresses. Install the merged image over USB when changing from the ordinary single-slot Full layout. The other three trials also retain verified dual WiFi OTA slots. All six remain mOTA senders.

    Additional builds check the platform and default-policy branches:

    Build Available bytes Reserved runtime bytes Margin bytes Policy T096 Full, FEM on 100,076 73,728 26,348 nRF52 path/secret caches RAK3401 Full 99,900 50,976 48,924 nRF52 path/secret caches Wireless Paper Full 187,360 152,576 34,784 ESP32 caches, existing Bluedroid V4 Full NimBLE 263,944 173,056 90,888 Complete contacts in PSRAM XIAO nRF52 legacy BLE Companion 84,368 38,688 45,680 Existing inline contact storage

    The V4 test-only forced-cache build also passes its RAM budget. The V4 normally keeps complete contacts in PSRAM; forcing caches adds internal RAM overhead. The six-board savings above apply to boards without PSRAM.

    "},{"location":"companion_contact_cache_results/#physical-checks","title":"Physical checks","text":"

    The VM V4 used the test-only cache override. USB tests verified all 350 contacts and all 64 saved path bytes per contact, cold individual lookups, replacement of the complete path set, and persistence after reboot. A secret workload contacted 21 distinct synthetic peers, then the first peer twice: 22 calculations, one RAM hit, and no secret-file access under the ESP32 policy.

    A sustained send test exposed a false receive watchdog recovery. RX could occur inside the main loop between two successful transmissions, while the watchdog observed TX at both loop boundaries. It counted from the start of the whole burst and could reset a working radio during a later CAD pause. 468cc3cf restarts the receive recovery allowance after each successful TX. The native regression fails before that change and passes afterward; it also checks that a stuck receiver still gets recovered.

    The corrected V4 passed the same 23-datagram workload with zero radio error flags. It also passed a complete 350-path replacement, another send workload and a post-reboot path check with zero radio error flags. The replacement took about 127 seconds: dirty-cache eviction still performs synchronous SPIFFS contact-file saves, so bulk path updates remain a latency workload to qualify on the other ESP32 boards. After that run it reported 133,828 bytes free internal heap, a minimum of 130,976 bytes, and a largest free block of 122,868 bytes. These readings belong to the forced-cache V4 workload, not the six other boards.

    After qualification, the VM V4 was erased to remove synthetic contacts and installed with the normal PSRAM-backed V4 NimBLE trial from b68aa1c6. It is named NimBLE-V4-VM, uses the PIN shown on its display, and has powersaving off for USB use. That clean setup reported 143,612 bytes of free internal heap and passed 201 local USB requests with zero radio error flags. The Mercer V4 and XIAO S3 were left on their previously qualified NimBLE builds.

    The Mercer RAK3401 Full Companion verified 350 paths, eviction and persistence. Its first secret workload produced 21 calculations, one flash hit and one RAM hit with no skipped saves. After reboot, the same workload produced 22 flash hits, one RAM hit and zero calculations. Both completed with zero radio error flags. The final b68aa1c6 RAK image repeated the 350-path and saved-secret checks with zero radio errors and zero calculations. The RAK was returned to the published 26303793 repeater application with its original repeater name and USA Cascade settings; the Pi's MQTT logger was restarted after the exclusive USB tests.

    "},{"location":"companion_contact_cache_results/#shared-secret-timing","title":"Shared-secret timing","text":"Hardware/filesystem Successful saved-secret lookup Key exchange Selected miss policy V4 / SPIFFS 132,427 us 21,932 us Recalculate RAK3401 / LittleFS 7,812-8,789 us 31,250 us Read saved entry, calculate on miss

    The SPIFFS timing comes from the earlier forced flash-cache experiment. The final ESP32 policy does no secret-file I/O. Both platforms reuse the 16 most recent secrets from RAM. These are elapsed-time measurements, not energy measurements; timing on other ESP32/nRF52 boards remains to be measured.

    "},{"location":"companion_contact_cache_results/#regression-checks-and-evidence","title":"Regression checks and evidence","text":"

    The contact-cache suite exercises the production cache and persistence code under address/undefined-behavior sanitizers, with the real Ed25519 key-exchange implementation. Its cases include 350 paths, identity changes, snapshots, legacy migration, storage faults, handle exhaustion and packet release when a cold path cannot be read. The related contract suites passed 62 checks. The native queue/store suites passed 27 cases, the radio-liveness suite passed eight, and the partition-selection suite passed 11.

    Hardware reproduction commands are in the contact-cache guide. The tracked harness is tools/hil/contact_cache_serial_stress.py. It requires exclusive access to the specified Companion data port.

    Local evidence in the qualification workspace:

    Each final RAM report is checked against its ELF hash. All 12 final builds pass their RAM and OTA capability checks. Physical testing here covers the V4 and RAK3401; the six constrained ESP32 boards still need pairing, WiFi/BLE/LoRa coexistence and sustained-load testing on their own hardware. No GitHub release assets or firmware-picker entries were replaced by these experimental builds.

    "},{"location":"companion_offline_queue/","title":"Companion Offline Message Queue","text":"

    Companion firmware keeps received channel data, channel messages, and direct messages in one pending queue until a Companion client requests them with the sync-next-message command. This is volatile RAM, not message history in flash. A reboot clears it.

    "},{"location":"companion_offline_queue/#default-capacities","title":"Default capacities","text":"Platform or memory profile Pending frames ESP32 with configured PSRAM 512 ESP32 without PSRAM 256 nRF52840 256 nRF52 Full Companion with the memory correction 256 normally; 128 while mOTA owns shared storage RP2040 256 STM32 16 Known constrained classic ESP32 target override 128 Meshadventurer Full Companion 16 Constrained Full ESP32 fallback 16

    An explicit target OFFLINE_QUEUE_SIZE overrides the platform default. The Heltec V2 and TLora V2 Full Companion profiles, for example, use 16 frames so their combined WiFi, BLE, and LoRa mOTA image retains enough internal DRAM. Meshadventurer SX1262 and SX1268 Full Companion use 16 frames together with 100 contacts and 30 group channels; their ordinary transport-specific images keep 128 frames and 40 channels.

    The nRF52 Full memory correction applies queue sharing to every nRF52 Full Companion. It keeps 256 frames normally, retaining each board's contacts, channels, and all Full transports. The upper 128 slots temporarily hold the mOTA context when a source or TempRadio discovery session starts. Stopping or disconnecting the USB/Bluetooth source returns all 256 slots; a discovery-only session returns them when TempRadio ends. This shares a fixed memory region and avoids heap fragmentation from resizing.

    Existing unread messages retain their order. If more than 128 frames are pending, mOTA refuses the loan and asks you to sync messages with a Companion app first. While the loan is active, the overflow policy below applies at 128 frames. The original 26303793 1.17.1.5 builds reserve the queue and mOTA state separately. Sharing recovers about 19 KiB on nRF52 Full. The ST7735 color-display Full profiles require at least 72 KiB of heap space at link time; OLED and headless profiles use their own memory budgets.

    Standard, logging, MQTT, and Cascade build overlays retain the selected target capacity; they do not silently shrink the queue.

    Each queue slot currently costs 177 bytes. A 256-frame queue reserves 45,312 bytes, while a 512-frame queue reserves 90,624 bytes. There is no 256-frame protocol limit: the queue length and indexes can represent 512 or more. The practical limit is available RAM and the heap and stack headroom required by the transports and display.

    On ESP32 boards marked with BOARD_HAS_PSRAM, the queue is allocated from PSRAM before WiFi and BLE start. A failed 512-frame allocation retries at 256, then 128, and finally uses a 16-frame internal fallback. Full Companion prints the capacity actually allocated in its startup memory line as offline_queue=<frames>.

    "},{"location":"companion_offline_queue/#full-queue-behavior","title":"Full queue behavior","text":"

    The capacity is shared across Public, other channels, channel data, and direct messages. It is not a per-channel count. When the queue is full, firmware replaces the oldest queued channel frame so newer traffic can still arrive. If the full queue contains only direct messages, a new frame is dropped rather than deleting a direct message.

    Queue order is preserved. Removal uses a ring index, so delivering one pending message no longer copies every remaining frame; only the less-common removal of an old channel frame from a full queue may shift entries.

    Capacity is selected when firmware is compiled. There is no CLI or Companion protocol setting to resize it at runtime.

    "},{"location":"companion_protocol/","title":"Companion Protocol","text":"

    The command and response catalogs track examples/companion_radio/MyMesh.cpp. Applications should negotiate the protocol and validate lengths because older firmware exposes a subset.

    This document is a practical guide to MeshCore's binary companion protocol. The same protocol frames can be carried by the enabled BLE, USB serial, Wi-Fi, or Ethernet companion interface; connection details differ by build.

    On builds exposing more than one transport, delivery-required replies follow the interface which supplied the command. The multi-frame contact-list response holds that route until END_OF_CONTACTS; best-effort asynchronous observations may still be broadcast to enabled clients. Treat the device as one Companion session rather than as independent per-transport sessions.

    The examples focus on BLE, but the packet formats are transport-independent.

    "},{"location":"companion_protocol/#official-libraries","title":"Official Libraries","text":"

    Please see the following repos for existing MeshCore Companion Protocol libraries.

    "},{"location":"companion_protocol/#important-security-note","title":"Important Security Note","text":"

    All secrets, hashes, and cryptographic values shown in this guide are example values only.

    "},{"location":"companion_protocol/#table-of-contents","title":"Table of Contents","text":"
    1. BLE Connection
    2. Packet Structure
    3. Commands
    4. Channel Management
    5. Message Handling
    6. Response Parsing
    7. Example Implementation Flow
    8. Best Practices
    9. Troubleshooting
    "},{"location":"companion_protocol/#ble-connection","title":"BLE Connection","text":""},{"location":"companion_protocol/#service-and-characteristics","title":"Service and Characteristics","text":"

    MeshCore Companion devices expose a BLE service with the following UUIDs:

    An nRF52 Full Companion also exposes a separate LoRa mOTA source service. It does not replace or multiplex the normal Companion UART service:

    All three mOTA attributes require an encrypted, MITM-authenticated connection using the Companion's six-digit PIN. The source remains inactive until the client subscribes to Device Request and explicitly starts it with command 0x4B. See Bluetooth LoRa mOTA source.

    ESP32 and nRF52 Companion UART characteristics require the same PIN-protected, MITM-authenticated link. ESP32 advertises DisplayOnly capability so a central must enter the PIN shown by the Companion; a Just Works bond is insufficient.

    "},{"location":"companion_protocol/#connection-steps","title":"Connection Steps","text":"
    1. Scan for Devices

      • Scan for BLE devices advertising the MeshCore Service UUID
      • Optionally filter by device name (typically contains \"MeshCore\" prefix)
      • Note the device MAC address for reconnection
    2. Connect to GATT

      • Connect to the device using the discovered MAC address
      • Wait for connection to be established
    3. Discover Services and Characteristics

      • Discover the service with UUID 6E400001-B5A3-F393-E0A9-E50E24DCCA9E
      • Discover the RX characteristic 6E400002-B5A3-F393-E0A9-E50E24DCCA9E
        • Your app writes to this, the firmware reads from this
      • Discover the TX characteristic 6E400003-B5A3-F393-E0A9-E50E24DCCA9E
        • The firmware writes to this, your app reads from this
    4. Enable Notifications

      • Subscribe to notifications on the TX characteristic to receive data from the firmware
    5. Send Initial Commands

      • Send CMD_APP_START to identify your app to firmware and get radio settings
      • Send CMD_DEVICE_QUERY to fetch device info and negotiate supported protocol versions
      • Send CMD_SET_DEVICE_TIME to set the firmware clock
      • Send CMD_GET_CONTACTS to fetch all contacts
      • Send CMD_GET_CHANNEL multiple times to fetch all channel slots
      • Send CMD_SYNC_NEXT_MESSAGE to fetch the next message stored in firmware
      • Setup listeners for push codes, such as PUSH_CODE_MSG_WAITING or PUSH_CODE_ADVERT
      • See Commands section for information on other commands

    Note: MeshCore devices may disconnect after periods of inactivity. Implement auto-reconnect logic with exponential backoff.

    "},{"location":"companion_protocol/#ble-write-type","title":"BLE Write Type","text":"

    When writing commands to the RX characteristic, specify the write type:

    Platform-specific:

    Recommendation: Use write with response for reliability.

    "},{"location":"companion_protocol/#mtu-maximum-transmission-unit","title":"MTU (Maximum Transmission Unit)","text":"

    The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like SET_CHANNEL (50 bytes), you may need to:

    1. Request Larger MTU: Request MTU of 512 bytes if supported
      • Android: gatt.requestMtu(512)
      • iOS: peripheral.maximumWriteValueLength(for:)
      • Python (bleak): MTU is negotiated automatically
    "},{"location":"companion_protocol/#command-sequencing","title":"Command Sequencing","text":"

    Critical: Commands must be sent in the correct sequence:

    1. After Connection:

      • Wait for BLE connection to be established
      • Wait for services/characteristics to be discovered
      • Wait for notifications to be enabled
      • Now you can safely send commands to the firmware
    2. Command-Response Matching:

      • Send one command at a time
      • Wait for a response before sending another command
      • Use a timeout (typically 5 seconds)
      • Match response to command by type (e.g: CMD_GET_CHANNEL -> RESP_CODE_CHANNEL_INFO)
    "},{"location":"companion_protocol/#command-queue-management","title":"Command Queue Management","text":"

    For reliable operation, implement a command queue.

    Queue Structure:

    Error Handling:

    "},{"location":"companion_protocol/#packet-structure","title":"Packet Structure","text":"

    The MeshCore protocol uses a binary format with the following structure:

    Most packets follow this format:

    [Packet Type (1 byte)] [Data (variable length)]\n

    The first byte indicates the packet type (see Response Parsing).

    "},{"location":"companion_protocol/#commands","title":"Commands","text":"

    The first byte selects the command. This is the current protocol-v14 command catalog; bytes 0x2C-0x31 are parked and 0x35 is unused.

    Byte Firmware name Purpose 0x01 CMD_APP_START Start an app session and request self information. 0x02 CMD_SEND_TXT_MSG Send text to a contact. 0x03 CMD_SEND_CHANNEL_TXT_MSG Send channel text. 0x04 CMD_GET_CONTACTS Enumerate contacts, optionally modified since a timestamp. 0x05 / 0x06 CMD_GET_DEVICE_TIME / CMD_SET_DEVICE_TIME Read or set the device clock. 0x07 / 0x08 CMD_SEND_SELF_ADVERT / CMD_SET_ADVERT_NAME Advertise self or change the advertised name. 0x09 CMD_ADD_UPDATE_CONTACT Add or update a contact. 0x0A CMD_SYNC_NEXT_MESSAGE Dequeue the next pending message. 0x0B / 0x0C CMD_SET_RADIO_PARAMS / CMD_SET_RADIO_TX_POWER Set radio parameters or transmit power. 0x0D CMD_RESET_PATH Reset a contact's learned path. 0x0E CMD_SET_ADVERT_LATLON Set advertised coordinates. 0x0F CMD_REMOVE_CONTACT Remove a contact. 0x10 / 0x11 / 0x12 CMD_SHARE_CONTACT / CMD_EXPORT_CONTACT / CMD_IMPORT_CONTACT Share, export, or import contact data. 0x13 CMD_REBOOT Reboot after the required confirmation body. 0x14 CMD_GET_BATT_AND_STORAGE Read battery and storage usage. 0x15 CMD_SET_TUNING_PARAMS Set tuning parameters. 0x16 CMD_DEVICE_QUERY Negotiate protocol support and read device information. 0x17 / 0x18 CMD_EXPORT_PRIVATE_KEY / CMD_IMPORT_PRIVATE_KEY Export or import identity key material when enabled. 0x19 CMD_SEND_RAW_DATA Send an application raw-data packet. 0x1A-0x1D CMD_SEND_LOGIN through CMD_LOGOUT Manage a server connection. 0x1E CMD_GET_CONTACT_BY_KEY Look up a contact by public-key prefix. 0x1F / 0x20 CMD_GET_CHANNEL / CMD_SET_CHANNEL Read or write a channel slot. 0x21-0x23 CMD_SIGN_START through CMD_SIGN_FINISH Stream data for identity signing. 0x24 CMD_SEND_TRACE_PATH Trace a direct route. 0x25 CMD_SET_DEVICE_PIN Set or clear the device PIN. 0x26 CMD_SET_OTHER_PARAMS Set telemetry, location, ACK, and related preferences. 0x27 CMD_SEND_TELEMETRY_REQ Send the legacy telemetry request. 0x28 / 0x29 CMD_GET_CUSTOM_VARS / CMD_SET_CUSTOM_VAR Read or set custom variables. 0x2A CMD_GET_ADVERT_PATH Read a cached advertisement path. 0x2B CMD_GET_TUNING_PARAMS Read tuning parameters. 0x32 CMD_SEND_BINARY_REQ Send an application binary request. 0x33 CMD_FACTORY_RESET Factory-reset after the required confirmation body. 0x34 CMD_SEND_PATH_DISCOVERY_REQ Request path discovery. 0x36 CMD_SET_FLOOD_SCOPE_KEY Select scoped or unscoped flood behavior. 0x37 CMD_SEND_CONTROL_DATA Send zero-hop control data. 0x38 CMD_GET_STATS Read core, radio, or packet statistics. 0x39 CMD_SEND_ANON_REQ Send an anonymous request. 0x3A / 0x3B CMD_SET_AUTOADD_CONFIG / CMD_GET_AUTOADD_CONFIG Write or read automatic-contact policy. 0x3C CMD_GET_ALLOWED_REPEAT_FREQ Read allowed client-repeat frequency ranges. 0x3D CMD_SET_PATH_HASH_MODE Set path-hash width mode. 0x3E CMD_SEND_CHANNEL_DATA Send a channel binary datagram. 0x3F / 0x40 CMD_SET_DEFAULT_FLOOD_SCOPE / CMD_GET_DEFAULT_FLOOD_SCOPE Write or read the default flood scope. 0x41 CMD_SEND_RAW_PACKET Queue a fully encoded raw mesh packet. 0x42 CMD_RUN_CLI_COMMAND Run a local CLI command (protocol v14+). 0x4A CMD_EXEC_LOCAL_OTA_CONTROL Run one bounded local TempRadio or OTA command when the Companion includes the OTA CLI. 0x4B CMD_BLE_MOTA_SOURCE Query, start, or stop an nRF52 Full Companion's Bluetooth-backed LoRa mOTA source. 0x78-0x7F Deprecated hardware-setting aliases Receive-only compatibility for clients shipped before command 0x42 became the canonical settings path.

    The sections below detail the most common frames. Refer to the source named above for command bodies that are not expanded here.

    CMD_RUN_CLI_COMMAND is followed by the local CLI text without a terminating NUL. The device returns RESP_CODE_CLI_REPLY (0x1D) followed by the reply text. This is separate from sending a remote on-air CLI command with CMD_SEND_TXT_MSG and TXT_TYPE_CLI_COMMAND. The body must contain at least one byte and must not contain an embedded NUL. An unknown command is returned as the normal CLI reply text Unknown command, not as an error frame. Clients may prefix the CLI text with any two-character correlation tag and | (for example, A7|get radio.rxgain); the reply preserves that prefix.

    Direct USB, BLE, WiFi TCP, and Ethernet clients can also use this command for stats-core, stats-radio, stats-radio-diag, stats-packets, erase, and get prv.key (when private key export is enabled). WiFi/MQTT builds accept get wifi.pwd, get mqttN.password, and get mqttN.token. get password reports that Companion has no admin password; infrastructure roles return their own admin password on a local CLI connection. These privileges do not apply to CLI commands relayed over LoRa. set freq <MHz> works through command 0x42 and authorized LoRa CLI, like set radio; it saves the frequency and requires reboot to apply. See local maintenance commands for role differences and the text-terminal equivalents.

    Full Companion clients can send version through this command to receive the untruncated build identity, for example Companion 1.17.1.5-... (protocol 14, build 31-Aug-2026). This deliberately supplements rather than changes the 20-byte legacy version field in RESP_CODE_DEVICE_INFO, so existing clients keep the same frame layout.

    Firmware from this fork predating the upstream 0x42 allocation used 0x42-0x49 for these eight settings. This firmware accepts those values as deprecated inbound aliases so existing clients continue to work. A one-byte 0x42 frame is the legacy FEM-gain GET; 0x42 followed by text is the official CMD_RUN_CLI_COMMAND. New clients should use CMD_RUN_CLI_COMMAND for all of these settings, rather than allocating additional command bytes. For example, send 0x42 followed by get radio.rxgain or set radio.rxgain on. The reply is RESP_CODE_CLI_REPLY followed by the normal CLI reply text.

    Two deprecated binary alias blocks remain receive-only for compatibility:

    Setting Original alias Later fork alias GET body/reply SET body/reply FEM receive gain 0x42 / 0x43 0x78 / 0x79 No body; OK, state One byte 0/1; OK Radio receive gain 0x44 / 0x45 0x7A / 0x7B No body; OK, state One byte 0/1; OK WiFi power save 0x46 / 0x47 0x7C / 0x7D No body; OK, mode One mode byte 0-2; OK Bluetooth name 0x48 / 0x49 0x7E / 0x7F No body; OK, custom, name Zero to 31 UTF-8 bytes; OK

    Each pair lists GET then SET. Here OK is RESP_CODE_OK; the remaining reply bytes have the same meanings as the CLI settings below. A bare 0x42 is the old FEM GET, while 0x42 plus at least one text byte is CMD_RUN_CLI_COMMAND. New clients must use the framed CLI form; these aliases exist only so deployed clients do not break after a firmware update.

    The equivalent framed CLI commands are:

    Setting Commands Radio receive gain get radio.rxgain; set radio.rxgain on|off FEM receive gain get radio.fem.rxgain; set radio.fem.rxgain on|off WiFi power save get wifi.powersave; set wifi.powersave none|min|max Bluetooth name get bluetooth.name; set bluetooth.name <name|default> Bluetooth address get bluetooth.mac; set bluetooth.mac <address|random|random-every-boot|random-after-connect|default> Bluetooth stealth get bluetooth.stealth; set bluetooth.stealth on|off

    The framed form works over the normal binary USB, BLE, or TCP transport and does not need the USB terminal-start token. Unsupported settings return the same explanatory text as the local CLI.

    WiFi power-save modes are:

    Value Mode 0 min - minimum modem sleep 1 none - no modem sleep 2 max - maximum modem sleep

    A Full Companion that runs BLE and infrastructure WiFi simultaneously rejects WiFi mode none because coexistence requires modem sleep. A Full Companion using ESP-NOW as its primary mesh radio also rejects max, because maximum modem sleep can miss broadcasts that the access point cannot buffer. If an older image saved a conflicting value, the effective mode is capped to and reported as min. Device power saving remains independent.

    The SenseCAP Indicator Full profiles run exactly one secondary wireless Companion transport per boot. Their active-mode constraints are:

    Indicator mode Accepted wifi.powersave values LoRa + infrastructure WiFi none, min, max LoRa + BLE min, max; infrastructure WiFi is not started ESP-NOW + infrastructure WiFi none, min; max conflicts with primary ESP-NOW ESP-NOW + BLE min; infrastructure WiFi is not started and primary ESP-NOW remains active

    The Bluetooth name can be configured over USB, BLE, or TCP. Use set bluetooth.name default to restore MeshCore-<advert name>; an empty CLI value is rejected. (clear is also accepted as an alias for default.) A custom name is limited to 31 valid UTF-8 bytes and takes effect after reboot.

    Bluetooth Companion builds also accept bluetooth.mac (ble.mac is an alias). A literal address must be a BLE random-static address; random generates and saves one, random-every-boot rotates it at each startup, and random-after-connect retains it through unused boots but rotates it on the first boot after an authenticated connection. default or clear restores the factory address. The change takes effect after reboot.

    bluetooth.stealth on|off (ble.stealth is an alias) is a separate flag, defaulting to off. It preserves the address policy and can be combined with custom, saved random, rotating random, or factory addresses. With stealth on, the node advertises normally until the first authenticated pairing, then permits only that bonded peer to reconnect. Address changes clear the saved peer and reopen pairing without disabling stealth. Rotating policies retain their normal rotation triggers. Repeating on does not clear an existing bond. Both flag changes require reboot. The old MAC value stealth is not accepted.

    BLE still transmits directed or allowlisted advertisements; stealth is not radio silence. These are application settings; an nRF52 OTAFIX bootloader continues to advertise its separate DFU identity.

    "},{"location":"companion_protocol/#bluetooth-lora-mota-source","title":"Bluetooth LoRa mOTA source","text":"

    Protocol v14 lets a phone control an install-capable Companion's own LoRa OTA session. An nRF52 Full Companion can additionally act as the source for a remote repeater update without a USB computer. The normal Companion service still carries contacts, repeater login, CLI messages, and these two control commands. The separate mOTA service carries only host-folder request/response frames.

    CMD_EXEC_LOCAL_OTA_CONTROL (0x4A) is followed by 1-174 printable ASCII bytes. A Companion built with the OTA CLI accepts only these local command families:

    tempradio <freq_kHz>,<bw_kHz>,<sf>,<cr>,<minutes>\nnormalradio\nota ...\n

    ota folder ... is deliberately rejected because USB and Bluetooth source ownership must not be changed through the wrong transport. Embedded NUL, CR, LF, other control bytes, non-ASCII bytes, empty commands, and oversized frames return ERR_CODE_ILLEGAL_ARG. A recognized command replies with RESP_CODE_OK, one unsigned reply-length byte, and exactly that many printable result bytes. Shell metacharacters are rejected as well; the text is dispatched only to the in-firmware parser and is never passed to a host shell. Firmware without the OTA CLI feature returns ERR_CODE_UNSUPPORTED_CMD.

    CMD_BLE_MOTA_SOURCE (0x4B) has one action byte:

    Action Meaning 0 Read status without changing it. 1 Attach and enumerate the subscribed Bluetooth host's .mota catalog. 2 Detach the Bluetooth source.

    Current firmware returns eleven bytes (legacy protocol-v14 previews returned the seven-byte prefix only):

    00 action flags offered_le16 advertised_le16 source_packets_sent_le32\n

    Flag bit 0x01 means the encrypted GATT channel is connected and Device Request notifications are enabled. Bit 0x02 means the Bluetooth catalog is attached. Bit 0x04 means USB or another folder transport currently owns the source slot. Start without a ready subscription, or while another source link owns the slot, returns ERR_CODE_BAD_STATE. A non-nRF52 Full Companion returns ERR_CODE_UNSUPPORTED_CMD. source_packets_sent is a per-attachment count of OTA packets accepted by the Companion's LoRa transmit adapter, including catalog/manifest traffic, data, proofs, and retries. It wraps as an unsigned 32-bit value. Clients should accept the legacy seven-byte response and display the packet counter as unavailable.

    After a successful start, the device sends the same bounded seeder frames used by motatool serve on Device Request:

    device -> host: 'M' 'S' op args... xor(op || args)\nhost -> device: 'm' 's' op status payload... xor(all prior bytes)\n

    Device requests are at most 11 bytes. A source response is at most 197 bytes. The host may split one response across multiple write-with-response operations when the negotiated ATT payload is smaller; it must preserve byte order and must not interleave another response. Bad checksums, partial frames, overflow, unsubscribe, loss of encryption, or disconnect fail closed. The firmware then detaches the catalog and stops advertising its entries. USB and Bluetooth folder sources are mutually exclusive.

    A Linux reference controller and seeder is provided at tools/ble_mota/ble_mota_seeder.py. It verifies every input with motatool before offering it. A mobile implementation should apply the same complete container verification before serving files.

    "},{"location":"companion_protocol/#1-app-start","title":"1. App Start","text":"

    Purpose: Initialize communication with the device. Must be sent first after connection.

    Command Format:

    Byte 0: 0x01\nBytes 1-7: Reserved (currently ignored by firmware)\nBytes 8+: Application name (UTF-8, optional)\n

    Example (hex):

    01 00 00 00 00 00 00 00 6d 63 63 6c 69\n

    Response: PACKET_SELF_INFO (0x05)

    "},{"location":"companion_protocol/#2-device-query","title":"2. Device Query","text":"

    Purpose: Query device information.

    Command Format:

    Byte 0: 0x16\nByte 1: Highest companion protocol version understood by the app\n

    Example (hex):

    16 0E\n

    Response: PACKET_DEVICE_INFO (0x0D) with device information

    "},{"location":"companion_protocol/#3-get-channel-info","title":"3. Get Channel Info","text":"

    Purpose: Retrieve information about a specific channel.

    Command Format:

    Byte 0: 0x1F\nByte 1: Channel index (0 through max_channels - 1)\n

    Example (get channel 1):

    1F 01\n

    Response: PACKET_CHANNEL_INFO (0x12) with channel details

    "},{"location":"companion_protocol/#4-set-channel","title":"4. Set Channel","text":"

    Purpose: Create or update a channel on the device.

    Command Format:

    Byte 0: 0x20\nByte 1: Channel index (0 through max_channels - 1)\nBytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)\nBytes 34-49: Secret (16 bytes)\n

    Total Length: 50 bytes

    Channel index: - Slot count is build-specific. Read max_channels from byte 3 of PACKET_DEVICE_INFO; current profiles commonly expose 1, 8, or 40 slots. - No slot number has an intrinsic public/private meaning.

    Channel Name: - UTF-8 encoded - Maximum 32 bytes - Padded with null bytes (0x00) if shorter

    Secret Field (16 bytes): - Supply the exact 16-byte channel key. A private channel normally uses a cryptographically random key; known public and hashtag channels use their defined or derived key. - An all-zero key is not the public-channel key.

    Example (create channel \"SMS\" at index 1 with secret):

    20 01 53 4D 53 00 00 ... (name padded to 32 bytes)\n    [16 bytes of secret]\n

    Note: The 32-byte secret variant is unsupported and returns PACKET_ERROR.

    Response: PACKET_OK (0x00) on success, PACKET_ERROR (0x01) on failure

    "},{"location":"companion_protocol/#5-send-channel-message","title":"5. Send Channel Message","text":"

    Purpose: Send a text message to a channel.

    Command Format:

    Byte 0: 0x03\nByte 1: 0x00\nByte 2: Channel index (0 through max_channels - 1)\nBytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds)\nBytes 7+: Message Text (UTF-8, variable length)\n

    Timestamp: Unix timestamp in seconds (32-bit unsigned integer, little-endian)

    Example (send \"Hello\" to channel 1 at timestamp 1234567890):

    03 00 01 D2 02 96 49 48 65 6C 6C 6F\n

    Response: PACKET_MSG_SENT (0x06) on success

    "},{"location":"companion_protocol/#6-send-channel-data-datagram","title":"6. Send Channel Data Datagram","text":"

    Purpose: Send a binary datagram to a channel. Unlike channel text messages, datagrams carry no built-in sender identity and no timestamp - applications needing either must encode them inside the binary payload.

    Command Format:

    Byte 0:                         0x3E\nByte 1:                         Channel index (0 through max_channels - 1)\nByte 2:                         Encoded path descriptor (0xFF = flood)\nBytes 3+:                       Encoded path bytes (omitted for 0xFF)\nNext 2 bytes (little-endian):   Data Type (`data_type`, uint16)\nRemaining bytes:                Binary payload (variable length)\n

    For a direct send, the descriptor's low six bits are the hash count and its high two bits are the hash size minus one. Current mesh packets accept one-, two-, or three-byte hashes; the four-byte code is reserved. The following path therefore occupies hash_count * hash_size bytes; the descriptor itself is not a raw byte count.

    Example (flood, DATA_TYPE_DEV, payload A1 B2 C3, channel 1):

    3E 01 FF FF FF A1 B2 C3\n

    Data Type / Transport Mapping: - 0x0000 (DATA_TYPE_RESERVED) is invalid and rejected with PACKET_ERROR. - 0xFFFF (DATA_TYPE_DEV) is the developer namespace for experimenting and developing apps. - Registered application/community namespaces occupy 0x0100-0xFEFF; the remaining nonzero ranges are reserved for internal or development use. See the Registered data_type values table below.

    Limits: - Maximum payload length is MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 167 bytes. - Larger payloads are rejected with PACKET_ERROR (ERR_CODE_ILLEGAL_ARG).

    Response: PACKET_OK (0x00) on success, or PACKET_ERROR (0x01) with one of: - ERR_CODE_NOT_FOUND (2) - unknown channel_idx - ERR_CODE_ILLEGAL_ARG (6) - invalid path_len, reserved data_type (0x0000), or payload larger than MAX_CHANNEL_DATA_LENGTH - ERR_CODE_TABLE_FULL (3) - outbound send queue is full; retry later

    Inbound datagrams are delivered to the host via RESP_CODE_CHANNEL_DATA_RECV (0x1B); see Receive Channel Data Datagram.

    "},{"location":"companion_protocol/#registered-data_type-values","title":"Registered data_type values","text":"

    data_type is an application identifier, not a payload-format identifier. Each registered value identifies an application that owns its own internal payload schemas. The firmware does not inspect payload contents - data_type is transported opaquely.

    Value Constant Purpose 0x0000 DATA_TYPE_RESERVED Reserved; invalid on send 0x0001 - 0x00FF - Reserved for internal use 0x0100 - 0xFEFF - Registered application namespaces (see number_allocations.md) 0xFF00 - 0xFFFE - Testing/development; no registration required 0xFFFF DATA_TYPE_DEV Developer/experimental namespace

    To register a new application, submit a PR adding a row to the table in docs/number_allocations.md. Internal sub-formats within an allocated application ID are owned by that application and are not tracked in MeshCore firmware or this document.

    "},{"location":"companion_protocol/#receive-channel-data-datagram","title":"Receive Channel Data Datagram","text":"

    Inbound group datagrams (radio-level PAYLOAD_TYPE_GRP_DATA, 0x06) are forwarded to the host as RESP_CODE_CHANNEL_DATA_RECV notifications.

    Frame Format (RESP_CODE_CHANNEL_DATA_RECV, 0x1B):

    Byte 0:                 0x1B (packet type)\nByte 1:                 SNR (signed int8, scaled x4 - divide by 4.0 to recover dB)\nBytes 2-3:              Reserved (clients MUST ignore)\nByte 4:                 Channel index (0 through max_channels - 1)\nByte 5:                 Path Length (actual path length when flooded, otherwise 0xFF for direct)\nBytes 6-7:              Data Type (uint16 little-endian)\nByte 8:                 Data Length\nBytes 9 .. 8+data_len:  Payload\n

    Path bytes are not forwarded: Only path_len is reported in the receive frame - the path itself is not copied to the host. There are no path bytes between byte 5 and the data_type field at bytes 6-7, regardless of path_len.

    Path Length semantics differ between send and receive:

    Direction path_len = 0xFF path_len != 0xFF Send Flood the network Direct route; the encoded path follows (low 6 bits = hash count, top 2 bits + 1 = hash size; on-wire byte count = hash_count x hash_size) Receive Packet arrived via direct route Packet was flooded; this is the encoded pkt->path_len field as observed (no path bytes follow)

    In other words, the meaning of 0xFF is inverted between the two directions, and on receive the field carries metadata only - never a routable path. path_len is an encoded byte (see Packet::isValidPathLen / Packet::writePath in src/Packet.cpp), not a raw byte count.

    Note: The device may also emit PACKET_MESSAGES_WAITING (0x83) to notify the host that datagrams are queued; poll with CMD_SYNC_NEXT_MESSAGE (0x0A) to retrieve them.

    Parsing Pseudocode:

    def parse_channel_data_recv(data):\n    if len(data) < 9:\n        return None\n    snr_byte = data[1]\n    snr = (snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0\n    channel_idx = data[4]\n    path_len = data[5]\n    data_type = int.from_bytes(data[6:8], 'little')\n    data_len = data[8]\n    if 9 + data_len > len(data):\n        return None\n    payload = data[9:9 + data_len]\n    return {\n        'snr': snr,\n        'channel_idx': channel_idx,\n        'path_len': path_len,\n        'data_type': data_type,\n        'payload': bytes(payload),\n    }\n
    "},{"location":"companion_protocol/#7-get-message","title":"7. Get Message","text":"

    Purpose: Request the next queued message from the device.

    Command Format:

    Byte 0: 0x0A\n

    Example (hex):

    0A\n

    Response: - PACKET_CHANNEL_MSG_RECV (0x08) or PACKET_CHANNEL_MSG_RECV_V3 (0x11) for channel messages - PACKET_CONTACT_MSG_RECV (0x07) or PACKET_CONTACT_MSG_RECV_V3 (0x10) for contact messages - PACKET_CHANNEL_DATA_RECV (0x1B) for channel data datagrams - PACKET_NO_MORE_MSGS (0x0A) if no messages available

    Note: Poll this command periodically to retrieve queued messages. The device may also send PACKET_MESSAGES_WAITING (0x83) as a notification when messages are available.

    "},{"location":"companion_protocol/#8-get-battery-and-storage","title":"8. Get Battery and Storage","text":"

    Purpose: Query device battery voltage and storage usage.

    Command Format:

    Byte 0: 0x14\n

    Example (hex):

    14\n

    Response: PACKET_BATTERY (0x0C) with battery millivolts and storage information

    "},{"location":"companion_protocol/#channel-management","title":"Channel Management","text":""},{"location":"companion_protocol/#channel-types","title":"Channel Types","text":"
    1. Public Channel
      • Uses a publicly known 16-byte key: 8b3387e9c5cdea6ac9e5edbaa115cd72
      • Anyone can join this channel, messages should be considered public
      • Used as the default public group chat
    2. Hashtag Channels
      • Uses a secret key derived from the channel name
      • It is the first 16 bytes of sha256(\"#test\")
      • For example hashtag channel #test has the key: 9cd8fcf22a47333b591d96a2b848b73f
      • Traffic is encrypted on air, but anyone who knows or guesses the channel name can derive the key. Hashtag channels should not be treated as private.
      • Used as a topic based public group chat, separate from the default public channel
    3. Private Channels
      • Uses a randomly generated 16-byte secret key
      • Messages should be considered private between those that know the secret
      • Users should keep the key secret, and only share with those you want to communicate with
      • Used as a secure private group chat
    "},{"location":"companion_protocol/#channel-lifecycle","title":"Channel Lifecycle","text":"
    1. Set Channel:
      • Read max_channels from device info, fetch those slots, and choose an unused slot (normally an empty name and zeroed key)
      • Generate or provide a 16-byte secret
      • Send CMD_SET_CHANNEL with name and a 16-byte secret
    2. Get Channel:
      • Send CMD_GET_CHANNEL with channel index
      • Parse RESP_CODE_CHANNEL_INFO response
    3. Delete Channel:
      • Send CMD_SET_CHANNEL with empty name and all-zero secret
      • Or overwrite with a new channel
    "},{"location":"companion_protocol/#message-handling","title":"Message Handling","text":""},{"location":"companion_protocol/#receiving-messages","title":"Receiving Messages","text":"

    Messages are received via the TX characteristic (notifications). The device sends:

    1. Channel Messages:
    2. PACKET_CHANNEL_MSG_RECV (0x08) - Standard format
    3. PACKET_CHANNEL_MSG_RECV_V3 (0x11) - Version 3 with SNR

    4. Contact Messages:

    5. PACKET_CONTACT_MSG_RECV (0x07) - Standard format
    6. PACKET_CONTACT_MSG_RECV_V3 (0x10) - Version 3 with SNR

    7. Notifications:

    8. PACKET_MESSAGES_WAITING (0x83) - Indicates messages are queued
    "},{"location":"companion_protocol/#contact-message-format","title":"Contact Message Format","text":"

    Standard Format (PACKET_CONTACT_MSG_RECV, 0x07):

    Byte 0: 0x07 (packet type)\nBytes 1-6: Public Key Prefix (6 bytes, hex)\nByte 7: Path Length\nByte 8: Text Type\nBytes 9-12: Timestamp (32-bit little-endian)\nBytes 13-16: Signature (4 bytes, only if txt_type == 2)\nBytes 17+: Message Text (UTF-8)\n

    V3 Format (PACKET_CONTACT_MSG_RECV_V3, 0x10):

    Byte 0: 0x10 (packet type)\nByte 1: SNR (signed byte, multiplied by 4)\nBytes 2-3: Reserved\nBytes 4-9: Public Key Prefix (6 bytes, hex)\nByte 10: Path Length\nByte 11: Text Type\nBytes 12-15: Timestamp (32-bit little-endian)\nBytes 16-19: Signature (4 bytes, only if txt_type == 2)\nBytes 20+: Message Text (UTF-8)\n

    Parsing Pseudocode:

    def parse_contact_message(data):\n    packet_type = data[0]\n    offset = 1\n\n    # Check for V3 format\n    if packet_type == 0x10:  # V3\n        snr_byte = data[offset]\n        snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)\n        offset += 3  # Skip SNR + reserved\n\n    pubkey_prefix = data[offset:offset+6].hex()\n    offset += 6\n\n    path_len = data[offset]\n    txt_type = data[offset + 1]\n    offset += 2\n\n    timestamp = int.from_bytes(data[offset:offset+4], 'little')\n    offset += 4\n\n    # If txt_type == 2, skip 4-byte signature\n    if txt_type == 2:\n        offset += 4\n\n    message = data[offset:].decode('utf-8')\n\n    return {\n        'pubkey_prefix': pubkey_prefix,\n        'path_len': path_len,\n        'txt_type': txt_type,\n        'timestamp': timestamp,\n        'message': message,\n        'snr': snr if packet_type == 0x10 else None\n    }\n
    "},{"location":"companion_protocol/#channel-message-format","title":"Channel Message Format","text":"

    Standard Format (PACKET_CHANNEL_MSG_RECV, 0x08):

    Byte 0: 0x08 (packet type)\nByte 1: Channel index (0 through max_channels - 1)\nByte 2: Path Length\nByte 3: Text Type\nBytes 4-7: Timestamp (32-bit little-endian)\nBytes 8+: Message Text (UTF-8)\n

    V3 Format (PACKET_CHANNEL_MSG_RECV_V3, 0x11):

    Byte 0: 0x11 (packet type)\nByte 1: SNR (signed byte, multiplied by 4)\nBytes 2-3: Reserved\nByte 4: Channel index (0 through max_channels - 1)\nByte 5: Path Length\nByte 6: Text Type\nBytes 7-10: Timestamp (32-bit little-endian)\nBytes 11+: Message Text (UTF-8)\n

    Parsing Pseudocode:

    def parse_channel_message(data):\n    packet_type = data[0]\n    offset = 1\n\n    # Check for V3 format\n    if packet_type == 0x11:  # V3\n        snr_byte = data[offset]\n        snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)\n        offset += 3  # Skip SNR + reserved\n\n    channel_idx = data[offset]\n    path_len = data[offset + 1]\n    txt_type = data[offset + 2]\n    timestamp = int.from_bytes(data[offset+3:offset+7], 'little')\n    message = data[offset+7:].decode('utf-8')\n\n    return {\n        'channel_idx': channel_idx,\n        'timestamp': timestamp,\n        'message': message,\n        'snr': snr if packet_type == 0x11 else None\n    }\n
    "},{"location":"companion_protocol/#sending-messages","title":"Sending Messages","text":"

    Use the SEND_CHANNEL_MESSAGE command (see Commands).

    Important: - The shared text envelope permits up to 160 UTF-8 bytes. For channel text, firmware prepends <sender name>: inside that envelope, so the available message body is 160 - prefix_bytes and varies with the configured name. - Count encoded UTF-8 bytes, not Unicode characters. Split a longer message at valid UTF-8 boundaries. - Include a chunk indicator (e.g., \"[1/3] message text\")

    "},{"location":"companion_protocol/#response-parsing","title":"Response Parsing","text":""},{"location":"companion_protocol/#terminology","title":"Terminology","text":"

    This document uses a spec-level naming convention (PACKET_*) for bytes the firmware sends back to the host. In the firmware source these same values are split across two #define families by purpose:

    Byte values are authoritative; names are aliases. When reading firmware source, RESP_CODE_X / PUSH_CODE_X correspond to this doc's PACKET_X of the same numeric value.

    "},{"location":"companion_protocol/#response-types","title":"Response types","text":"Value Firmware name Description 0x00 RESP_CODE_OK Command succeeded. 0x01 RESP_CODE_ERR Command failed; byte 1 is the error code. 0x02 RESP_CODE_CONTACTS_START Contact enumeration started. 0x03 RESP_CODE_CONTACT One contact record. 0x04 RESP_CODE_END_OF_CONTACTS Contact enumeration ended. 0x05 RESP_CODE_SELF_INFO Device self-information. 0x06 RESP_CODE_SENT Send accepted, with route/tag/timeout data. 0x07 / 0x08 RESP_CODE_CONTACT_MSG_RECV / RESP_CODE_CHANNEL_MSG_RECV Queued legacy-format message. 0x09 RESP_CODE_CURR_TIME Current device time. 0x0A RESP_CODE_NO_MORE_MESSAGES Offline queue is empty. 0x0B RESP_CODE_EXPORT_CONTACT Exported contact bytes. 0x0C RESP_CODE_BATT_AND_STORAGE Battery and storage values. 0x0D RESP_CODE_DEVICE_INFO Protocol and build information. 0x0E RESP_CODE_PRIVATE_KEY Exported identity key, when enabled. 0x0F RESP_CODE_DISABLED Requested sensitive feature is disabled. 0x10 / 0x11 RESP_CODE_CONTACT_MSG_RECV_V3 / RESP_CODE_CHANNEL_MSG_RECV_V3 Queued message with SNR fields. 0x12 RESP_CODE_CHANNEL_INFO Channel slot information. 0x13 / 0x14 RESP_CODE_SIGN_START / RESP_CODE_SIGNATURE Signing capacity or completed signature. 0x15 RESP_CODE_CUSTOM_VARS Custom-variable data. 0x16 RESP_CODE_ADVERT_PATH Cached advertisement path. 0x17 RESP_CODE_TUNING_PARAMS Tuning parameters. 0x18 RESP_CODE_STATS Requested statistics subtype. 0x19 RESP_CODE_AUTOADD_CONFIG Automatic-contact policy. 0x1A RESP_ALLOWED_REPEAT_FREQ Allowed repeat-frequency ranges. 0x1B RESP_CODE_CHANNEL_DATA_RECV Queued channel datagram. 0x1C RESP_CODE_DEFAULT_FLOOD_SCOPE Default flood-scope data. 0x1D RESP_CODE_CLI_REPLY Text returned by CMD_RUN_CLI_COMMAND."},{"location":"companion_protocol/#asynchronous-push-types","title":"Asynchronous push types","text":"Value Firmware name Description 0x80 PUSH_CODE_ADVERT Advertisement received. 0x81 PUSH_CODE_PATH_UPDATED A contact path changed. 0x82 PUSH_CODE_SEND_CONFIRMED A sent message was acknowledged. 0x83 PUSH_CODE_MSG_WAITING One or more offline frames are waiting. 0x84 PUSH_CODE_RAW_DATA Raw application data received. 0x85 / 0x86 PUSH_CODE_LOGIN_SUCCESS / PUSH_CODE_LOGIN_FAIL Server login result. 0x87 PUSH_CODE_STATUS_RESPONSE Server status response. 0x88 PUSH_CODE_LOG_RX_DATA Radio receive log data. 0x89 PUSH_CODE_TRACE_DATA Completed trace data. 0x8A PUSH_CODE_NEW_ADVERT Newly stored contact advertisement. 0x8B PUSH_CODE_TELEMETRY_RESPONSE Telemetry response. 0x8C PUSH_CODE_BINARY_RESPONSE Binary request response. 0x8D PUSH_CODE_PATH_DISCOVERY_RESPONSE Path-discovery response. 0x8E PUSH_CODE_CONTROL_DATA Control/discovery data. 0x8F PUSH_CODE_CONTACT_DELETED Oldest contact was deleted while making room. 0x90 PUSH_CODE_CONTACTS_FULL Contact storage is full."},{"location":"companion_protocol/#parsing-responses","title":"Parsing Responses","text":"

    PACKET_OK (0x00):

    Byte 0: 0x00\nBytes 1-4: Optional value (32-bit little-endian integer)\n

    PACKET_ERROR (0x01):

    Byte 0: 0x01\nByte 1: Error code (optional)\n

    PACKET_CHANNEL_INFO (0x12):

    Byte 0: 0x12\nByte 1: Channel Index\nBytes 2-33: Channel Name (32 bytes, null-terminated)\nBytes 34-49: Secret (16 bytes)\n

    Note: The device returns the 16-byte channel secret in this response.

    PACKET_DEVICE_INFO (0x0D):

    Byte 0: 0x0D\nByte 1: Firmware Version (uint8)\nBytes 2+: Variable length based on firmware version\n\nFor firmware version >= 3:\nByte 2: Max Contacts Raw (uint8, actual = value * 2)\nByte 3: Max Channels (uint8)\nBytes 4-7: Active BLE PIN (32-bit little-endian; includes a generated session PIN)\nBytes 8-19: Firmware Build (12 bytes, UTF-8, null-padded)\nBytes 20-59: Model (40 bytes, UTF-8, null-padded)\nBytes 60-79: Version (20 bytes, UTF-8, null-padded)\nByte 80: Client repeat enabled/preferred (firmware v9+)\nByte 81: Path hash mode (firmware v10+)\n

    Parsing Pseudocode:

    def parse_device_info(data):\n    if len(data) < 2:\n        return None\n\n    fw_ver = data[1]\n    info = {'fw_ver': fw_ver}\n\n    if fw_ver >= 3 and len(data) >= 80:\n        info['max_contacts'] = data[2] * 2\n        info['max_channels'] = data[3]\n        info['ble_pin'] = int.from_bytes(data[4:8], 'little')\n        info['fw_build'] = data[8:20].decode('utf-8').rstrip('\\x00').strip()\n        info['model'] = data[20:60].decode('utf-8').rstrip('\\x00').strip()\n        info['ver'] = data[60:80].decode('utf-8').rstrip('\\x00').strip()\n\n    if fw_ver >= 9 and len(data) >= 81:\n        info['client_repeat'] = data[80] != 0\n    if fw_ver >= 10 and len(data) >= 82:\n        info['path_hash_mode'] = data[81]\n\n    return info\n

    PACKET_BATTERY (0x0C):

    Byte 0: 0x0C\nBytes 1-2: Battery Voltage (16-bit little-endian, millivolts)\nBytes 3-6: Used Storage (32-bit little-endian, KB)\nBytes 7-10: Total Storage (32-bit little-endian, KB)\n

    Parsing Pseudocode:

    def parse_battery(data):\n    if len(data) < 3:\n        return None\n\n    mv = int.from_bytes(data[1:3], 'little')\n    info = {'battery_mv': mv}\n\n    if len(data) >= 11:\n        info['used_kb'] = int.from_bytes(data[3:7], 'little')\n        info['total_kb'] = int.from_bytes(data[7:11], 'little')\n\n    return info\n

    PACKET_SELF_INFO (0x05):

    Byte 0: 0x05\nByte 1: Advertisement Type\nByte 2: TX Power\nByte 3: Max TX Power\nBytes 4-35: Public Key (32 bytes, hex)\nBytes 36-39: Advertisement Latitude (32-bit little-endian, divided by 1e6)\nBytes 40-43: Advertisement Longitude (32-bit little-endian, divided by 1e6)\nByte 44: Multi ACKs\nByte 45: Advertisement Location Policy\nByte 46: Telemetry Mode (bitfield)\nByte 47: Manual Add Contacts (bool)\nBytes 48-51: Radio Frequency (32-bit little-endian, divided by 1000.0)\nBytes 52-55: Radio Bandwidth (32-bit little-endian, divided by 1000.0)\nByte 56: Radio Spreading Factor\nByte 57: Radio Coding Rate\nBytes 58+: Device Name (UTF-8, variable length, no null terminator required)\n

    Parsing Pseudocode:

    def parse_self_info(data):\n    if len(data) < 36:\n        return None\n\n    offset = 1\n    info = {\n        'adv_type': data[offset],\n        'tx_power': data[offset + 1],\n        'max_tx_power': data[offset + 2],\n        'public_key': data[offset + 3:offset + 35].hex()\n    }\n    offset += 35\n\n    lat = int.from_bytes(data[offset:offset+4], 'little') / 1e6\n    lon = int.from_bytes(data[offset+4:offset+8], 'little') / 1e6\n    info['adv_lat'] = lat\n    info['adv_lon'] = lon\n    offset += 8\n\n    info['multi_acks'] = data[offset]\n    info['adv_loc_policy'] = data[offset + 1]\n    telemetry_mode = data[offset + 2]\n    info['telemetry_mode_env'] = (telemetry_mode >> 4) & 0b11\n    info['telemetry_mode_loc'] = (telemetry_mode >> 2) & 0b11\n    info['telemetry_mode_base'] = telemetry_mode & 0b11\n    info['manual_add_contacts'] = data[offset + 3] > 0\n    offset += 4\n\n    freq = int.from_bytes(data[offset:offset+4], 'little') / 1000.0\n    bw = int.from_bytes(data[offset+4:offset+8], 'little') / 1000.0\n    info['radio_freq'] = freq\n    info['radio_bw'] = bw\n    info['radio_sf'] = data[offset + 8]\n    info['radio_cr'] = data[offset + 9]\n    offset += 10\n\n    if offset < len(data):\n        name_bytes = data[offset:]\n        info['name'] = name_bytes.decode('utf-8').rstrip('\\x00').strip()\n\n    return info\n

    PACKET_MSG_SENT (0x06):

    Byte 0: 0x06\nByte 1: Route Flag (0 = direct, 1 = flood)\nBytes 2-5: Tag / Expected ACK (4 bytes, little-endian)\nBytes 6-9: Suggested Timeout (32-bit little-endian, milliseconds)\n

    PACKET_SEND_CONFIRMED (0x82):

    Byte 0: 0x82\nBytes 1-4: ACK code (32-bit little-endian)\nBytes 5-8: Round-trip time (32-bit little-endian, milliseconds)\n
    "},{"location":"companion_protocol/#error-codes","title":"Error Codes","text":"

    PACKET_ERROR (0x01) carries a single-byte error code in byte 1. Values match the ERR_CODE_* constants defined in examples/companion_radio/MyMesh.cpp:

    Code Constant (firmware) Description 1 ERR_CODE_UNSUPPORTED_CMD Unknown or unsupported command byte / sub-command 2 ERR_CODE_NOT_FOUND Target not found (channel, contact, message, etc.) 3 ERR_CODE_TABLE_FULL Internal queue or table is full - retry later 4 ERR_CODE_BAD_STATE Operation not valid in current device state (e.g. iterator already running) 5 ERR_CODE_FILE_IO_ERROR Filesystem or storage I/O failure 6 ERR_CODE_ILLEGAL_ARG Invalid argument (bad length, out-of-range value, reserved field, etc.)

    Note: Error codes may vary by firmware version. Always check byte 1 of PACKET_ERROR response, and treat unknown codes as generic errors.

    "},{"location":"companion_protocol/#frame-handling","title":"Frame Handling","text":"

    BLE implementations enqueue and deliver one protocol frame per BLE write/notification at the firmware layer.

    "},{"location":"companion_protocol/#response-handling","title":"Response Handling","text":"
    1. Command-Response Pattern:
    2. Send command via RX characteristic
    3. Wait for response via TX characteristic (notification)
    4. Match the response by the expected response type; frames do not carry a general command sequence number
    5. Handle timeout (typically 5 seconds)
    6. Use command queue to prevent concurrent commands

    7. Asynchronous Messages:

    8. Device may send messages at any time via TX characteristic
    9. Handle PACKET_MESSAGES_WAITING (0x83) by polling GET_MESSAGE command
    10. Parse incoming messages and route to appropriate handlers
    11. Validate frame length before decoding

    12. Response Matching:

    13. Match responses to commands by expected packet type:

      • APP_START -> PACKET_SELF_INFO
      • DEVICE_QUERY -> PACKET_DEVICE_INFO
      • GET_CHANNEL -> PACKET_CHANNEL_INFO
      • SET_CHANNEL -> PACKET_OK or PACKET_ERROR
      • SEND_CHANNEL_MESSAGE -> PACKET_MSG_SENT
      • GET_MESSAGE -> PACKET_CHANNEL_MSG_RECV, PACKET_CONTACT_MSG_RECV, PACKET_CHANNEL_DATA_RECV, or PACKET_NO_MORE_MSGS
      • SEND_CHANNEL_DATA -> PACKET_OK or PACKET_ERROR
      • GET_BATTERY -> PACKET_BATTERY
    14. Timeout Handling:

    15. Default timeout: 5 seconds per command
    16. On timeout: Log error, clear current command, proceed to next in queue
    17. Some commands may take longer (e.g., SET_CHANNEL may need 1-2 seconds)
    18. Consider longer timeout for channel operations

    19. Error Recovery:

    20. On PACKET_ERROR: Log error code, clear current command
    21. On connection loss: Clear command queue, attempt reconnection
    22. On invalid response: Log warning, clear current command, proceed
    "},{"location":"companion_protocol/#example-implementation-flow","title":"Example Implementation Flow","text":""},{"location":"companion_protocol/#initialization","title":"Initialization","text":"
    # 1. Scan for MeshCore device\ndevice = scan_for_device(\"MeshCore\")\n\n# 2. Connect to BLE GATT\ngatt = connect_to_device(device)\n\n# 3. Discover services and characteristics\nservice = discover_service(gatt, \"6E400001-B5A3-F393-E0A9-E50E24DCCA9E\")\nrx_char = discover_characteristic(service, \"6E400002-B5A3-F393-E0A9-E50E24DCCA9E\")\ntx_char = discover_characteristic(service, \"6E400003-B5A3-F393-E0A9-E50E24DCCA9E\")\n\n# 4. Enable notifications on TX characteristic\nenable_notifications(tx_char, on_notification_received)\n\n# 5. Send AppStart command\nsend_command(rx_char, build_app_start())\nwait_for_response(PACKET_SELF_INFO)\n
    "},{"location":"companion_protocol/#creating-a-private-channel","title":"Creating a Private Channel","text":"
    # 1. Generate 16-byte secret\nsecret_16_bytes = generate_secret(16)  # Use CSPRNG\nsecret_hex = secret_16_bytes.hex()\n\n# 2. Build SET_CHANNEL command\nchannel_name = \"YourChannelName\"\nchannel_index = choose_unused_slot(max_channels)\ncommand = build_set_channel(channel_index, channel_name, secret_16_bytes)\n\n# 3. Send command\nsend_command(rx_char, command)\nresponse = wait_for_response(PACKET_OK)\n\n# 4. Store secret locally\nstore_channel_secret(channel_index, secret_hex)\n
    "},{"location":"companion_protocol/#sending-a-message","title":"Sending a Message","text":"
    # 1. Build channel message command\nchannel_index = 1\nmessage = \"Hello, MeshCore!\"\ntimestamp = int(time.time())\ncommand = build_channel_message(channel_index, message, timestamp)\n\n# 2. Send command\nsend_command(rx_char, command)\nresponse = wait_for_response(PACKET_MSG_SENT)\n
    "},{"location":"companion_protocol/#receiving-messages_1","title":"Receiving Messages","text":"
    def on_notification_received(data):\n    packet_type = data[0]\n\n    if packet_type == PACKET_CHANNEL_MSG_RECV or packet_type == PACKET_CHANNEL_MSG_RECV_V3:\n        message = parse_channel_message(data)\n        handle_channel_message(message)\n    elif packet_type == PACKET_MESSAGES_WAITING:\n        # Poll for messages\n        send_command(rx_char, build_get_message())\n
    "},{"location":"companion_protocol/#best-practices","title":"Best Practices","text":"
    1. Connection Management:
    2. Implement auto-reconnect with exponential backoff
    3. Handle disconnections gracefully
    4. Store last connected device address for quick reconnection

    5. Secret Management:

    6. Always use cryptographically secure random number generators
    7. Store secrets securely (encrypted storage)
    8. Never log or transmit secrets in plain text

    9. Message Handling:

    10. Send CMD_SYNC_NEXT_MESSAGE when PUSH_CODE_MSG_WAITING is received
    11. Implement message deduplication to avoid displaying the same message twice

    12. Channel Management:

      • Fetch all channel slots even if you encounter an empty slot
      • Ideally save new channels into the first empty slot
    13. Error Handling:

    14. Implement timeouts for all commands (typically 5 seconds)
    15. Handle RESP_CODE_ERR responses appropriately
    "},{"location":"companion_protocol/#troubleshooting","title":"Troubleshooting","text":""},{"location":"companion_protocol/#connection-issues","title":"Connection Issues","text":""},{"location":"companion_protocol/#command-issues","title":"Command Issues","text":""},{"location":"companion_protocol/#message-issues","title":"Message Issues","text":""},{"location":"companion_radio_full/","title":"Full Companion","text":"

    For runtime on/off commands and board exceptions, see feature switches by role. Open the USB web console for the default ASCII terminal.

    For practical on/off commands and update directions, see Full Companion feature switches.

    companion_radio_full combines every qualified Companion transport for its platform into one artifact and acts as a host-backed LoRa mOTA source for updating other nodes. Most targets can run those transports simultaneously; the SenseCAP Indicator exception selects one secondary wireless transport per boot as described below.

    The full Companion is deliberately not a LoRa OTA destination: it has no firmware staging store, refuses ota install, and never advertises its own firmware as an mOTA image.

    Capability ESP32 full nRF52 full USB Binary Companion Yes Yes BLE Binary Companion Yes Yes USB ASCII terminal Yes Yes Dedicated USB plaintext logging No - shares the one USB TTY Yes Host-backed LoRa mOTA source WiFi TCP 5001 Exclusive USB mode or encrypted BLE WiFi Companion/WebConfig Yes No - nRF52840 has no WiFi Hardware serial Companion On targets with assigned serial pins On targets with assigned serial pins Ethernet Companion On targets with an Ethernet module On RAK4631 with RAK13800 LoRa self-update No No Optional self-update WiFi on existing dual-app layouts; USB on single-app layouts Bluetooth DFU with a compatible bootloader; USB always supported"},{"location":"companion_radio_full/#sensecap-indicator-transport-and-rendering-exception","title":"SenseCAP Indicator transport and rendering exception","text":"

    Both SenseCAP Indicator Full layouts keep USB available and start exactly one secondary wireless Companion transport per boot: BLE or infrastructure WiFi. The saved companion.transport selection takes effect after reboot. BLE mode does not start infrastructure WiFi, WebConfig, MQTT, TCP Companion, or network OTA services. WiFi mode never initializes BLE and releases its controller and host memory. LoRa remains the primary radio on the LoRa layout. ESP-NOW remains the primary mesh radio in both ESP-NOW modes, including BLE mode; only the infrastructure-WiFi services are omitted.

    The requested 4-bit internal render profiles are:

    Primary radio Secondary Companion transport Canvas LoRa Infrastructure WiFi Native 480x480 LoRa BLE Native 480x480 ESP-NOW Infrastructure WiFi Native 480x480 ESP-NOW BLE 320x320, scaled 1.5x to the 480x480 panel

    All four modes retain the same 160x160 logical UI. If a contiguous native DMA-capable block is unavailable, startup keeps the device usable with the 320x320 emergency fallback and reports the canvas actually retained. Such a fallback is not a successful native-480 validation result.

    "},{"location":"companion_radio_full/#build-and-install","title":"Build and install","text":"

    The target is synthesized by build.sh only for an exact board recipe that has passed the combined-transport size check:

    The measured ESP32 additions are M5Stack Unit C6L; XIAO C6/S3, Meshimi, WHY2025 Badge, LilyGo T-LoRa C6, T3S3 SX1262/SX1276, T-Deck, TETH Elite, classic T-Beam SX1262/SX1276, and T-Beam S3 Supreme; Heltec Wireless Tracker, Wireless Paper, E213, E290, T190, CT62, and V4 expansion-kit TFT; Generic ESP-NOW; SenseCAP Indicator ESP-NOW/LoRa; Ebyte EoRa-S3; and Meshadventurer SX1262/SX1268. The measured nRF52 additions are GAT562 Mesh Watch13, LilyGo T-Echo Lite, LilyGo T-Impulse Plus, and Wio Tracker L1 E-Ink. Their old transport-specific names remain available for explicit compatibility builds, but the Full image is the canonical release artifact.

    Every ESP32 Full image includes ordinary WiFi Companion, WebConfig, and the TCP mOTA services, even when its historical build base was USB- or BLE-only. On Generic_ESPNOW and SenseCapIndicator-ESPNow, ESP-NOW is also the primary mesh radio and shares the same 2.4 GHz hardware. Those two Full images retain B/G/N for ordinary clients alongside ESP-NOW LR. Their ESP-NOW mesh, setup AP, and infrastructure connection all use one persisted channel, which defaults to 1. Configure the router's 2.4 GHz radio and every other primary ESP-NOW node for that same fixed channel. Turning Companion WiFi off stops its TCP/AP services but deliberately leaves the ESP-NOW mesh radio running on the selected channel.

    Every ESP32 Full Companion with a station connection synchronizes its UTC clock from NTP after boot and explicitly refreshes it every 24 hours. The common WiFi path owns this for non-MQTT and runtime-unconfigured nodes; a configured MQTT bridge owns the same boot-and-daily schedule. Successful sync updates an attached hardware RTC as well as the ESP32 clock and suppresses LoRa clock fallback for that boot. Reconnecting WiFi preserves the existing daily deadline. A timeout leaves the existing clock and fallback intact and retries without blocking radio work.

    Inspect or change the shared channel from the Full Companion text terminal:

    get espnow.channel\nset espnow.channel 6\nreboot\n

    The accepted range is 1 through 13; use only a channel permitted in your region and supported by the router. The setter persists the selection, but the running radio stays on its current channel until reboot. Coordinate the change across every primary ESP-NOW node and the router before rebooting, or the node will lose one or both links. This primary-radio setting is not bridge.channel; that command belongs to the separate ESP-NOW bridge feature. To attach a LoRa-primary *_repeater_bridge_espnow gateway to these nodes, match its bridge.channel and select set bridge.format raw; the bridge's backward-compatible default is the distinct wrapped format. WiFi power saving cannot put infrastructure WiFi and ESP-NOW on different channels. On these two primary-ESP-NOW Full targets, max is unavailable because maximum modem sleep can make the station miss ESP-NOW broadcasts; use min for WiFi/BLE/ESP-NOW coexistence. The firmware also holds the ESP-IDF RF wake reference for the primary mesh radio, so the ESP-NOW receiver remains continuous even though the reported coexistence setting is min.

    List the available targets:

    bash build.sh list | grep companion_radio_full\n

    Build by using one exact listed name:

    bash build.sh build-firmware heltec_v4_r8_companion_radio_full \\\n  --firmware-version v1.17.0\n\nbash build.sh build-firmware RAK_4631_companion_radio_full \\\n  --firmware-version v1.17.0\n

    To build every canonical full Companion target, select the corresponding interactive menu item or run:

    bash build.sh build-full-companion-firmwares \\\n  --firmware-version v1.17.0\n

    Canonical Companion bulk builds also omit legacy _ps and _femoff aliases. Power saving and controllable FEM receive gain are persisted runtime settings; the old names remain available through an explicit build-firmware command for compatibility. Full Companion replaces separate USB, BLE, ordinary WiFi, hardware-serial, Ethernet Companion, Terminal Chat, and USB-only packet-logging release artifacts whenever the exact board supports those combined transports. Direct builds of the legacy targets remain available. RAK4631 repeater and room server Ethernet builds remain separate because they are different standalone roles, not Companion transports. nRF52 separates framed traffic and logs; ESP32 makes those modes mutually exclusive on its one USB TTY. In WebConfig, use the FEM RX boost switch. From the text terminal (USB, or TCP 5002 on ESP32), use:

    get radio.rxgain\nset radio.rxgain off\nset radio.rxgain on\nget radio.fem.rxgain\nset radio.fem.rxgain off\nset radio.fem.rxgain on\nget radio.fem.txgain\nset radio.fem.txgain off\nset radio.fem.txgain on\n

    radio.rxgain controls the radio chip's boosted receive-gain mode; the FEM commands control the external receive and transmit paths. The selected states are applied immediately and retained after reboot. FEM TX gain is reported as unsupported on boards without software-selectable PA gain.

    SSD1306 display builds also persist a runtime orientation. This replaces the separate rotated Full Companion release image:

    get display.rotation\nset display.rotation 90\nset display.rotation 180\nset display.rotation 270\nset display.rotation 0\n

    0 resets the screen to that board's compiled default orientation.

    Heltec E290 and T190 now use their Full Companion artifacts for simultaneous USB, BLE, and WiFi. Their older usb_ble, USB-only, and BLE-only names remain available only as explicit compatibility builds.

    Heltec V3 and base OLED V4 Full Companion also include the former direct WiFi MQTT Companion capability. Configure and enable MQTT at runtime through WebConfig; the canonical release therefore publishes the Full image instead of a second companion_radio_wifi_mqtt image. V4 TFT and expansion-kit layouts remain separate hardware images because their display and I2C wiring differs.

    Device power saving is separate from LoRa RXPS. It can be changed in WebConfig with the Device power saving switch or from the text terminal:

    get powersaving\nset powersaving on\nset powersaving off\n

    On ESP32, WiFi modem power saving is a third independent setting. Select it in the WebConfig WiFi card, or use the Full Companion text terminal:

    get wifi.ssid\nget wifi.status\nget wifi.powersave\nget webui\nset wifi.ssid MyNetwork\nset wifi.pwd my-password\nset wifi.powersave min\nset wifi.powersave max\nstart webconfig\nstop webconfig\n

    ESP32 WiFi Companions with WebConfig, including Full Companion, expose a CLI tab at the node's LAN address. It defaults to on; use get wifi.cli, set wifi.cli on, or set wifi.cli off. Older builds that report the browser terminal as unavailable need a firmware update. The open setup AP does not expose this tab.

    The browser accepts Companion configuration commands, including get powersaving, set usb.logging on|off, WiFi controls, and MQTT settings. set mqtt.enabled on|off controls MQTT without erasing broker settings; get mqtt.enabled, get mqtt.running, and get mqtt.status distinguish the saved switch from current connections. set logging.output off|usb|wifi|both selects both outputs together. The browser also uses the complete Companion terminal for card, import meshcore://..., list, to, send, login, cmd, and delayed replies, with the same syntax as USB and TCP port 5002. See the terminal command guide for contact import and session ownership. USB MOTA session commands require the USB connection; WiFi host folders use port 5001.

    On the two primary-ESP-NOW Full targets, the same terminal also provides get espnow.channel and set espnow.channel <1-13>. A channel change is persisted and requires a reboot, unlike a WiFi power-save change.

    SSID and password writes return their reply first, then restart the Companion WiFi station with the saved credentials. A TCP terminal therefore disconnects shortly after either write; reconnect to the IP reported by the new network. Password entry is masked over USB. get wifi.pwd reads the saved password from any local connection, including binary command 0x42; LoRa cannot read it. It may be empty for an open network, an ordinary passphrase of up to 63 characters, or an exact 64-character hexadecimal WPA/WPA2 PSK. Other 64-character values and all longer values are rejected.

    The normal binary Companion connection can also read or write this setting over USB, BLE, or TCP port 5000 without entering terminal mode. The mode values are 0 for min, 1 for none, and 2 for max; see the Companion protocol. A Full Companion that runs WiFi and BLE simultaneously rejects none because coexistence requires modem sleep. A primary-ESP-NOW Full target also rejects max; unlike infrastructure traffic, peer ESP-NOW broadcasts cannot be buffered by the access point while the station sleeps. If an older image saved a conflicting value, firmware applies and reports min instead. The SenseCAP Indicator uses the active-mode constraints in the exception above: LoRa + WiFi accepts all three values, ESP-NOW + WiFi accepts none|min, LoRa + BLE accepts min|max for the inactive WiFi setting, and ESP-NOW + BLE requires min. Fresh Cascade builds select min, and an existing valid saved selection takes precedence.

    On radios with RX duty-cycle support, WebConfig and the text terminal also expose the persisted RXPS setting:

    get radio.rxps\nset radio.rxps off\nset radio.rxps on\nset radio.rxps level 8 preamble 16\nset radio.rxps 65625 60000\n

    Fresh Cascade-profile Full Companion builds start with RXPS on at level 8 and a 16-symbol preamble. Changing it takes effect immediately and remains selected after reboot.

    Companion firmware defaults device power saving to on. Version 1.17.1.2 also turns it on once when upgrading an older Companion preference file, including one written by the short-lived default-off regression. After that one-time migration, an explicit set powersaving off selection remains persistent.

    On ESP32, enabling it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. Disabling it restores the normal CPU clock and keeps GPS awake. Active Full Companion transports remain available in both states; on the SenseCAP Indicator this means USB plus the selected BLE or infrastructure-WiFi secondary transport. WiFi modem sleep stays enabled when BLE and the ESP32 WiFi radio are active together because coexistence requires it. Changing device power saving does not overwrite the saved WiFi power-save mode. While a native-USB host is enumerated, the platform sleep attempt is held off so USB CDC remains responsive; detaching the host releases that guard. CPU, radio-modem, and GPS power-saving settings remain active, and USB power from a charger alone does not create a Companion session. The selected state is retained after reboot.

    On the LilyGo T-Beam 1W Full Companion, press the physical BOOT button once to turn the ESP32 WiFi radio and all WiFi services off or on. The screen confirms WiFi: OFF or WiFi: ON, and the selected state is retained after reboot. When WiFi is off, TCP ports 5000-5002, WebConfig, and MQTT are stopped; USB, BLE, the display, GPS, and LoRa continue to operate. Press BOOT again to restore WiFi, including the saved station or setup-AP mode. On boot, BLE starts two seconds after WiFi/WebConfig so their peak startup allocations do not overlap.

    Artifacts are written to out/ by default.

    Full Companion behavior is selected with independent capability macros for TempRadio, the OTA CLI, the TCP terminal, USB folder seeding, and memory diagnostics. The legacy COMPANION_RADIO_FULL flag remains an input for older target recipes, but application behavior no longer uses that umbrella as an unrelated compile guard. In particular, ESP32 WiFi/WebConfig terminal controls are compiled from their actual WiFi/WebConfig capability. On targets that run ESP32 WiFi and BLE simultaneously, BLE is started first to avoid heap fragmentation. The SenseCAP Indicator starts only its selected secondary transport. Compile-time prerequisite checks reject inconsistent feature flags. After linking, the capability sidecar verifies USB, BLE, the OTA CLI, TempRadio, and each platform's host-folder transport; it also verifies the TCP terminal, WebConfig, and WiFi seeder on ESP32, plus dedicated logging on nRF52.

    On 4 MB ESP32 boards, the full target uses a single 3 MB application partition so WiFi, BLE, WebConfig, and source-only mOTA fit in one artifact. The T-Beam 1W Full Companion uses that same LilyGo factory-compatible boot layout on its 16 MB flash because this source-only role does not install updates into a second app slot. Flash the generated -merged.bin when first installing this partition layout. Other boards with 8 MB or more retain dual application partitions. Heltec V2 and TLora V2 use 100 contacts, 8 group channels, and a 16-frame offline queue in this combined profile because of internal DRAM limits. Meshadventurer SX1262 and SX1268 use 100 contacts, 30 group channels, and a 16-frame queue. These capacities preserve the required 8 KiB static internal-DRAM reserve alongside MOTA and all Full transports; their ordinary transport-specific images retain 160 contacts, 40 channels, and 128 queued frames.

    The 1.17.1.5 memory replacements for Generic ESP-NOW, Heltec Wireless Paper, Wireless Tracker, CT62, V3, Tracker V2, and XIAO C3 Full Companion use 150 contacts and retain their 256-frame queue and Full transports. Export contacts before updating if you have more than 150; entries beyond the new limit may be unavailable and a later save may omit them.

    Full Companions normally retain 256 pending Companion message frames. ESP32 boards with configured PSRAM retain 512 and allocate that queue from PSRAM before WiFi and BLE start. If PSRAM is unavailable at runtime, allocation falls back through 256 and 128 frames, then to a 16-frame internal buffer. The Full Companion startup memory line reports the capacity actually allocated as offline_queue=<frames>. The queue is volatile and shared by all channels and direct messages; it is not flash-backed history. See Companion offline message queue for all platform defaults and full-queue behavior.

    Every corrected nRF52 Full profile keeps 256 offline frames normally and temporarily lends 128 slots to mOTA to leave room for Bluetooth tasks, displays and UI allocations. Queue sharing preserves the board's contacts, channels, and USB/Bluetooth mOTA sending. See the memory correction.

    The nRF52 target inherits the board's ordinary USB Companion installation format and adds BLE plus the serial mOTA source. It does not enable an SD cache or any other board-specific storage behavior; host files are streamed as they are requested. Its image is bounded by the board's normal application region, not the smaller OTAFIX in-place workspace reserved for firmware that can update itself.

    "},{"location":"companion_radio_full/#interfaces","title":"Interfaces","text":"Platform Interface Purpose Both USB, 115200 baud ASCII after boot; automatically switches on the first complete Binary Companion frame Both BLE Binary Companion; display builds show a random session PIN, while headless builds default to 123456 ESP32 TCP 5000 Binary Companion over WiFi ESP32 HTTP 80 Companion WebConfig and first-boot WiFi setup ESP32 TCP 5001 Host .mota folder from motatool serve --tcp ESP32 TCP 5002 Full Companion text terminal; same role commands as the USB terminal nRF52 USB mOTA mode Host .mota folder from motatool serve --serial nRF52 Encrypted BLE mOTA service Paired phone/tablet/Linux host .mota catalog

    Delivery-required replies are returned only to the interface which supplied the latest command. A contact-list stream keeps that route locked from CONTACTS_START through END_OF_CONTACTS; commands waiting on another interface are read after the stream finishes. Best-effort asynchronous observations such as adverts remain broadcast so passive clients can refresh their views. Companion session state is device-wide, so use one active Companion application at a time. On nRF52, BLE remains available while USB is in terminal or mOTA mode.

    USB Binary output is queued as complete length-prefixed frames. Temporary CDC or UART backpressure pauses the contact stream; a frame may drain through a smaller hardware FIFO in ordered chunks, but its remainder is retained and no later frame can interleave with it or cause it to be discarded.

    When a BLE client requests pairing, a display-equipped build wakes the screen, switches to the first home page, and keeps the active six-digit PIN visible until Bluetooth connects or the two-minute pairing window expires. USB, WiFi, Ethernet, and hardware-serial connections do not suppress this screen. With no saved BLE PIN, display builds generate a new PIN at boot; builds without a physical display use 123456. A PIN saved through the Companion protocol takes effect after reboot.

    The Bluetooth device name is independently configurable. In the text terminal, use get bluetooth.name and set bluetooth.name <name>; use set bluetooth.name default to restore MeshCore-<node name>. The WebConfig Node card exposes the same optional field on Bluetooth-capable ESP32 builds. Custom names replace the complete Bluetooth label rather than inheriting the prefix, accept up to 31 valid UTF-8 bytes, and take effect after reboot. This does not change the node's mesh advertisement name.

    Every Bluetooth Companion also supports get bluetooth.mac and set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>. A literal address is a BLE random-static identity, random creates one persistent identity, random-every-boot rotates it on startup, and random-after-connect rotates it on the first boot after an authenticated connection while leaving unused boots unchanged. default (or clear) restores the chipset address. None of these values changes the stealth flag. Reboot and re-pair after changing modes or after a rotation. Address rotation clears stale local bonds. On nRF52 this affects the MeshCore application only, not the OTAFIX bootloader's separate BLE DFU identity.

    get bluetooth.stealth and set bluetooth.stealth on|off control an independent flag, defaulting to off. Combine it with a custom address or random to retain the address and bond across boots. With stealth on, normal discovery ends after the first authenticated pairing and only the saved peer may reconnect. Rotating MAC policies still rotate and reopen first pairing when they do; stealth remains enabled. Repeating on keeps an existing bond. Toggle off, then on, then reboot to reset first pairing. All flag changes need reboot. The node still sends directed or allowlisted BLE packets; it is not radio silent.

    ESP32 ports 5000, 5001, 5002, and WebConfig have no independent login layer. Expose them only on a trusted LAN or temporary setup network. See WiFi setup for credential setup and reconnect behavior.

    "},{"location":"companion_radio_full/#usb-binary-and-text-terminal-modes","title":"USB Binary and text terminal modes","text":"

    Full Companion USB starts in the ASCII terminal after boot. MeshCore apps and meshcli send a <-prefixed framed command, which automatically hands the untouched frame to the Binary Companion parser:

    meshcli -s /dev/ttyACM0 -b 115200 ver\n

    The automatic probe runs only at an empty prompt. A complete frame confirms binary mode; an incomplete probe returns to ASCII after one second. Binary mode then remains selected until reboot or the explicit terminal start token. See Full Companion USB CLI and binary switcher for the byte-level state machine, logging and mOTA ownership, recovery paths, and known limitations.

    Immediately after boot, an ordinary terminal can issue ASCII commands without a start token. If the device is already in Binary Companion mode, open the port with the terminal start token sent automatically:

    picocom -b 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM0\n

    The input map prevents any Binary Companion control bytes received during the mode transition from changing the local terminal's character set or display state while leaving UTF-8 emoji intact. The banner confirms that terminal mode is active; do not enter the start token again after it appears.

    The terminal supports Companion chat commands, including channels, channel <name-or-slot> <message>, remote administration with login <admin-password> and cmd <remote-command>, and routed trace [recipient-name-or-prefix], plus local ota, tempradio, and normalradio controls. board reports the exact hardware target. ESP32 Full Companion also provides memory, which reports heap, internal allocation, PSRAM, and offline-queue usage; it is omitted from non-ESP32 help. ESP32 builds also provide local WiFi credential, status, WebConfig, and power-save controls. Every Full Companion provides persistent get/set usb.logging and starts with logging off on a fresh installation.

    "},{"location":"companion_radio_full/#esp32-single-usb-serial-port","title":"ESP32 single USB serial port","text":"

    Every ESP32 Full Companion exposes one USB TTY with two exclusive modes. It starts as the ASCII terminal unless a saved logging-on preference boots directly into the logging terminal. If it is already binary, enter its text terminal with +++MESHCORE-TERM-START. For an ESP32 1.17.1.5 USB logging session, run these separate commands:

    set powersaving off\nset usb.logging on\n

    The first command is the release's USB power-saving workaround. The same TTY then emits plaintext packet/debug logs and continues accepting CLI commands, including set usb.logging off. Framed Binary Companion is unavailable on USB while logging owns the TTY. Turning logging off sends the command reply, stops the logs, and leaves that TTY in the normal ASCII terminal, matching a fresh Full installation. Send +++MESHCORE-TERM-STOP, or let a Companion app send a valid framed probe, to switch it to Binary Companion afterward. A saved logging-on preference boots directly into this input-capable logging terminal. BLE and Wi-Fi Companion remain available while USB is logging on simultaneous targets. On the SenseCAP Indicator, only the selected BLE or Wi-Fi Companion transport remains available alongside the logging terminal.

    ESP32 Full Companion uses the repository's Arduino-ESP32 2.x platform base where the board supports it. RC32 and ESP32-C6 retain their board-required Arduino 3.x platform, but follow the same one-TTY policy. No ESP32 Full image creates an optional second CDC interface. The single-TTY behavior applies to native-USB ESP32-S3 boards and boards using a USB-UART bridge.

    "},{"location":"companion_radio_full/#nrf52-dual-usb-serial-ports","title":"nRF52 dual USB serial ports","text":"

    Current nRF52 Full Companion firmware can expose two CDC ACM serial interfaces on one physical USB cable:

    A fresh Full Companion starts with USB logging off and therefore enumerates only interface 00. Use set usb.logging on to save logging on; the reply says that a reboot is required. Use set usb.logging on reboot to save it and have the node reboot automatically after the reply. The second interface appears after that reboot. Likewise, set usb.logging off reboot removes interface 02. The optional reboot word is accepted only in these exact command forms and triggers a reboot only when the descriptor actually needs to change.

    These are Full Companion text-terminal commands. The superficially similar meshcli ... get usb.logging command uses the Binary Companion parameter registry and can report Unknown var usb.logging; it does not forward that line to the text terminal. Enter terminal mode on interface 00 with the +++MESHCORE-TERM-START token as described above, then issue the command.

    When logging is on, Linux normally shows two /dev/ttyACM* devices. Match the stable /dev/serial/by-id/*-if00 and *-if02 links, or use a udev rule matching ID_USB_INTERFACE_NUM, rather than assuming which tty number is assigned. On Windows they appear as two COM ports; identify them by USB interface instead of depending on a particular COM number. The nRF52 bootloader temporarily exposes its normal DFU serial interface during an update.

    Opening the nRF52 logging port prints MeshCore USB logging port followed by its portable identity, USB CDC 1; interface 02; Linux stable suffix: -if02. get usb.logging reports the same endpoint. Firmware cannot print the exact /dev/ttyACM* or COM* name because Linux, macOS, or Windows assigns that name after USB enumeration; use the *-if02 link on Linux to obtain the exact path. For example, readlink -f /dev/serial/by-id/*-if02 prints the host-assigned /dev/ttyACM* name.

    Every ESP32-S3 Full Companion image uses DIO flash mode, including the RAK3112 and RC32 profiles. The S3 ROM supports DIO while loading the software bootloader, and some flash configurations fail before the application starts when a merged image inherits QIO. DIO trades some maximum flash-read throughput for compatibility; it does not change a board's PSRAM type or any ordinary non-Full firmware profile.

    On nRF52, point MeshCore Companion software, meshcli, and motatool at interface 00. When enabled and rebooted, point a plaintext reader or USB-connected MQTT service at interface 02. On ESP32, use its only USB TTY for Binary Companion while logging is off, or for the plaintext CLI/logger while logging is on; close one consumer before switching modes. Turning logging off returns to ASCII, so the normal stop token or a valid framed probe is still required before Binary Companion owns the port.

    ESP32 Full Companion exposes this same text terminal on TCP port 5002. Connect with nc DEVICE_IP 5002; no USB control token is needed. USB terminal mode and the TCP terminal share recipient, login, command, trace, and display state, so only one may own the terminal at a time. The idle startup USB prompt yields to a TCP connection when no USB data client or partial command is present, and is restored when TCP disconnects. An active USB session rejects TCP; entering USB terminal mode later closes an active TCP session. On USB-Serial-JTAG and USB-to-UART hardware, the firmware cannot observe an idle host open, so actual buffered USB activity\u2014not the physical cable alone\u2014claims ownership. Disconnecting TCP clears pending terminal-only state without cancelling Binary Companion delivery or radio retries.

    Both terminal transports accept reboot. The reply is sent first and the device reboots one second later, so a script can distinguish an accepted reboot from an abruptly lost connection.

    Port 5002 is plaintext and has no device-local login gate. A remote-admin password entered with login is sent across the LAN connection as typed even though the terminal does not echo it. Use port 5002 only on a trusted LAN or a temporary setup network.

    For example:

    channels\nchannel #rgdata Hello from Eugene \ud83d\udc4b\nshow\nshow channels on\nto Hilltop Repeater\npath A1B2C3,D4E5F6\npath 7773D0 7E7662\nlogin my-admin-password\ncmd ver\ntrace\n

    The terminal list [n] command displays favorite contacts first and orders each favorite/non-favorite group by its most recent advertisement. This does not alter the binary Companion contact-list protocol.

    Unsolicited terminal output starts in a quiet mode: advertisements and ordinary channel messages are hidden, while #emergency messages remain visible. Use show adverts on|off, show channels on|off, and show emergency on|off to control each category independently; plain show reports their state. These runtime filters affect terminal printing only and reset to their defaults after reboot.

    The to command selects the remote-administration target. path shows its saved outgoing route; path direct, path clear, or a list separated by spaces, commas, or both changes the route used by subsequent login, send, and cmd commands. Every hop must use the same 2-, 4-, or 6-digit hexadecimal width. Login passwords are masked during entry and limited by the radio protocol to 15 UTF-8 bytes. Wait for the asynchronous login result before using cmd; command replies appear as CLI -> from <name> and use a response window of 300% of the route estimate. DIRECT via path <hop,...> displays the exact saved prefixes copied into the packet. Remote ACL permissions determine which commands the target accepts. The matching reply reports its round-trip time from local queueing through result reception, including radio transit and remote execution. Only one terminal cmd can be pending at a time.

    Incoming unicast replies are labeled ROUTED. Their exact return prefixes are not available at the destination because each forwarder consumes its prefix; use trace to verify the return route.

    With no argument, trace uses the current to recipient. A name-prefix argument traces that contact directly without changing the current recipient. The contact must already have a known direct path; results show the SNR at each hop, or a timeout if the round trip does not return.

    An explicit route can use 1-, 2-, or 4-byte hexadecimal prefixes. Spaces, commas, and mixed separators are accepted:

    trace path 1 12 34 56 34 12\ntrace path 2 1234,ABCD,5678,ABCD,1234\ntrace path 4 12345678, ABCDEF01 89ABCDEF, ABCDEF01,12345678\n

    The entered route must include both the outward and return prefixes. Exact three-byte traces are not supported.

    Return to Binary mode with:

    +++MESHCORE-TERM-STOP\n

    Closing an armed ASCII USB data connection also changes the port to Binary mode when the hardware can report disconnect. A USB-to-UART bridge may not be able to report this event. A different baud rate, including 57600, does not select ASCII mode.

    On an ESP32 Full Companion built with OTA_FOLDER_SERIAL, motatool can keep the USB serial port open as an mOTA folder source when WiFi is unavailable:

    motatool serve --serial /dev/ttyACM0 --dir ./motas -v\n

    The tool sends the exact completed ota folder on line when it opens the port. Firmware recognizes it in startup ASCII or idle Binary Companion mode, then transfers the port to exclusive USB mOTA ownership before binary request/reply frames begin. The text terminal and Binary Companion are unavailable on that TTY until motatool detaches the folder. This matches the nRF52 ownership behavior described below. TCP port 5001 remains the preferred unattended source transport.

    "},{"location":"companion_radio_full/#nrf52-usb-mota-mode","title":"nRF52 USB mOTA mode","text":"

    The nRF52 full target has a third, exclusive USB mode for the host folder. Unmodified motatool serve --serial sends ota folder on when it opens the port. The startup ASCII terminal recognizes that exact completed line, leaves terminal mode, and gives the stream directly to exclusive mOTA handling. If the port is already in Binary Companion mode, the idle binary parser recognizes the same control sequence. The sequence is not examined inside a framed Binary Companion packet. See the switcher guide for the ownership transitions.

    While mOTA mode owns USB:

    No modified motatool build, terminal token, or preliminary mode change is required.

    "},{"location":"companion_radio_full/#nrf52-bluetooth-mota-source","title":"nRF52 Bluetooth mOTA source","text":"

    Protocol v14 also lets a phone, tablet, or Bluetooth-capable Linux host feed the .mota catalog to an nRF52 Full Companion. Normal Companion commands stay on the Nordic UART service. Firmware data uses a separate GATT service, so binary app traffic cannot be mistaken for a firmware block.

    The client must pair with the Companion PIN, subscribe to the mOTA Device Request characteristic, and send CMD_BLE_MOTA_SOURCE action start over the normal Binary Companion connection. The source is available only while that encrypted MITM-authenticated connection remains active. Disconnecting, unsubscribing, overflowing a frame, or receiving malformed data automatically detaches the catalog. USB and BLE source modes are mutually exclusive.

    The included Raspberry Pi reference client validates each .mota with motatool, schedules the local TempRadio window, serves until interrupted, then detaches and restores the normal radio tuple:

    python3 tools/ble_mota/ble_mota_seeder.py \\\n  --device MeshCore-MyCompanion \\\n  --dir ./motas \\\n  --local 'tempradio 909.950,250,5,5,120'\n

    Prefer this relative tempradio form when the phone/Pi and radio clocks may disagree. It starts a duration on the Companion and does not compare their wall clocks. Use the absolute tempradioat scheduler only after synchronizing the participating nodes.

    Use --pair when the Linux host has not already bonded. BlueZ must have an agent capable of entering or confirming the six-digit PIN. Use --source status without --dir for a read-only channel/status check. The complete UUID, frame, action, and status definitions are in the Companion protocol.

    This reference process stands in for the phone application. A mobile app can use the same sequence while retaining its normal contact and Repeater Admin UI: log in to the destination, put each required node on the same bounded TempRadio tuple, start the local Bluetooth catalog, then send the normal remote ota ls, ota pull, and ota install commands. The destination still checks container geometry, hardware identity, hashes, signature policy, and the OTAFIX bootloader before installation.

    "},{"location":"companion_radio_full/#serve-mota-images-manually","title":"Serve mOTA images manually","text":"

    First put the destination, required relays, controller, and source on the same bounded TempRadio tuple. The example frequency below is not legal everywhere; choose a legal tuple supported by every participating radio.

    "},{"location":"companion_radio_full/#esp32-source","title":"ESP32 source","text":"

    Use the local console to start TempRadio:

    nc 192.168.1.50 5002\n
    tempradio 909.950,250,5,5,120\nota status\n

    Then start the dedicated TCP seeder:

    motatool serve --dir ./motas --tcp 192.168.1.50:5001 -v\n
    "},{"location":"companion_radio_full/#nrf52-source","title":"nRF52 source","text":"

    Use the USB terminal briefly to schedule TempRadio, then return to Binary mode and close the terminal:

    picocom -b 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM1\n
    tempradio 909.950,250,5,5,120\n+++MESHCORE-TERM-STOP\n

    After sending the stop token, exit picocom with Ctrl-A, Ctrl-X.

    Start the serial seeder on that same port:

    motatool serve --dir ./motas --serial /dev/ttyACM1 --baud 115200 -v\n

    motatool switches the port into mOTA mode automatically. Stop it with Ctrl-C to detach the folder. Reopen the terminal and use normalradio if the source should return early; otherwise the saved radio settings return when the bounded window expires.

    As a cable-free alternative, keep the normal Companion BLE session open and run the Bluetooth reference client shown in the nRF52 Bluetooth section. Do not run the USB seeder at the same time.

    Both platforms intentionally refuse firmware installation commands such as:

    ota pull <id> flash\nota install\nota dev ...\n
    "},{"location":"companion_radio_full/#script-a-complete-update","title":"Script a complete update","text":"

    The Bash and PowerShell wrappers accept a release ZIP or ready .mota, set up TempRadio, run motatool, monitor the exact image, install it on the destination, and restore the radio path. Use a separate Companion as the controller.

    For an ESP32 full source:

    export MESHCORE_ADMIN_PASSWORD='target-admin-password'\n\n./tools/lora_ota/lora_ota.sh ./release.zip \"Roof Node\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp 192.168.1.50:5001 \\\n  --source-cli-tcp 192.168.1.50:5002\n

    For an nRF52 full source, the script automatically detects the token-switched terminal and uses the same source port sequentially for control and seeding:

    export MESHCORE_ADMIN_PASSWORD='target-admin-password'\n\n./tools/lora_ota/lora_ota.sh ./release.zip \"Roof Node\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1\n
    $env:MESHCORE_ADMIN_PASSWORD = 'target-admin-password'\n\n& .\\tools\\lora_ota\\lora_ota.ps1 '.\\release.zip' 'Roof Node' `\n  --controller-serial COM7 `\n  --source-serial COM8\n

    See the start-to-finish LoRa OTA guide for package selection, nRF52 in-place deltas, relays, trust checks, and recovery behavior.

    "},{"location":"docs/","title":"Local Documentation","text":"

    This document explains how to build and view the MeshCore documentation locally.

    "},{"location":"docs/#building-and-viewing-docs","title":"Building and viewing Docs","text":"
    pip install mkdocs\npip install mkdocs-material\n
    "},{"location":"esp32_memory_budget/","title":"Classic ESP32 image memory budget","text":"

    Classic ESP32 has 320 KiB of internal DRAM, but at most 160 KiB can hold statically allocated data. The remaining DRAM is available only through the runtime heap. Bluetooth, tracing, and SDK reservations further constrain the usable region. See Espressif's memory types documentation.

    An image can therefore approach its static limit while PlatformIO reports less than 40% of the 320 KiB total in use. PSRAM does not expand the region used by ordinary internal .data and .bss allocations.

    "},{"location":"esp32_memory_budget/#build-enforcement","title":"Build enforcement","text":"

    Every classic ESP32 environment inheriting esp32_base runs scripts/check_esp32_dram.py. The release builder also adds the script when an ESP32 profile replaces the inherited extra scripts. The check runs before image generation, merging, and uploading, including cached nobuild uploads. It requires the ELF and linker map to be present for those cached operations. The check script must load after merge-bin.py so the merge target retains its pre-action. A merge also requires the bootloader/partition flash layout; nobuild cannot create a bootable merged image when that layout is absent.

    The check reads dram0_0_seg from the final linker map. SDK reservations are already reflected in that region; Bluetooth's reservation is not subtracted a second time. The occupied span runs from the region origin to _heap_start, including .data, .bss, .noinit, and alignment gaps. The allowed region is the smaller of the linker's region and 160 KiB.

    Builds must leave at least 8 KiB free within that static region. This is an additional project margin against near-full images, not an Espressif hardware limit or a prediction of free heap. A profile may require a larger margin with custom_esp32_static_dram_reserve in bytes; values below 8192 are rejected. Missing or unrecognized map boundaries also fail the check.

    For the Heltec V2 build used to investigate the repeater report:

    Shared repeater defaults Static span Static region Remaining Result Previous 255 scope slots 124,020 B 124,580 B 560 B Rejected Tuned 31 scope slots 108,308 B 124,580 B 16,272 B Passed

    These measurements remove the board's existing scope-capacity override to exercise the shared defaults. The normal Heltec V2 profile already used 31 scope slots. Exact sizes vary with build flags and SDK versions.

    To inspect an existing classic ESP32 build:

    python3 scripts/check_esp32_dram.py .pio/build/Heltec_v2_repeater/firmware.map\npython3 test/test_esp32_dram.py\n

    ESP32-S2, S3, and C-series chips have different memory maps and do not inherit this classic ESP32 check.

    "},{"location":"esp32_memory_budget/#boot-versus-running-memory","title":"Boot versus running memory","text":"

    The build gate checks static placement needed to load and start the image. It never counts heap-only RAM toward that static budget. Allocations made with new or malloc in global constructors consume heap and are not included in the static span. Moving a table there changes its placement, not total RAM consumption.

    Successful build checks do not prove that every runtime configuration will boot. Hardware validation must still cover startup allocations and the enabled Wi-Fi/Bluetooth services, recording minimum free internal heap and the largest free internal block. Available heap changes during initialization; for example, the Arduino 2.x repeater releases unused Bluetooth controller RAM in initArduino(), after global constructors have run.

    "},{"location":"faq/","title":"Frequently Asked Questions","text":"

    For this fork's 1.17.1.5 USA Cascade release, start with feature switches by role and the USB web console. For nRF52 OTAFIX use the exact board/storage build from OTAFIX 2.4.6; older bootloader links below provide upstream/background context.

    A list of frequently-asked questions and answers for MeshCore

    "},{"location":"faq/#1-introduction","title":"1. Introduction","text":""},{"location":"faq/#11-q-what-is-meshcore","title":"1.1. Q: What is MeshCore?","text":"

    A: MeshCore is a multi-platform system for enabling secure text-based communications utilizing LoRa radio hardware. It can be used for Off-Grid Communication, Emergency Response & Disaster Recovery, Outdoor Activities, Tactical Security including law enforcement and private security and also IoT sensor networks. (source)

    MeshCore is free and open source:

    Some more advanced, but optional features are available on T-Deck if you register your device for a key to unlock. On the MeshCore smartphone clients for Android and iOS/iPadOS, you can unlock the wait timer for repeater and room server remote management over RF feature.

    These features are completely optional and aren't needed for the core messaging experience. They're like super bonus features and to help the developers continue to work on these amazing features, they may charge a small fee for an unlock code to utilize the advanced features.

    Anyone is able to build anything they like on top of MeshCore without paying anything.

    "},{"location":"faq/#12-q-what-do-you-need-to-start-using-meshcore","title":"1.2. Q: What do you need to start using MeshCore?","text":"

    A: Everything you need for MeshCore is available at:

    You need LoRa hardware devices to run MeshCore firmware as clients or server (repeater and room server).

    "},{"location":"faq/#121-hardware","title":"1.2.1. Hardware","text":"

    MeshCore is available on a variety of 433MHz, 868MHz and 915MHz LoRa devices. For example, Lilygo T-Deck, T-Pager, RAK Wireless WisBlock RAK4631 devices (e.g. 19003, 19007, 19026), Heltec V3, Xiao S3 WIO, Xiao C3, Heltec T114, Station G2, Nano G2 Ultra, Seeed Studio T1000-E. More devices are being added regularly.

    For an up-to-date list of supported devices, please go to https://flasher.meshcore.io

    To use MeshCore without using a phone as the client interface, you can run MeshCore on a LilyGo T-Deck, T-Deck Plus, T-Pager, T-Watch, or T-Display Pro. MeshCore Ultra firmware running on these devices is a complete off-grid secure communication solution.

    "},{"location":"faq/#122-firmware","title":"1.2.2. Firmware","text":"

    MeshCore firmware is organized by role and, for companions, by host transport. The main roles are Companion, Repeater, Room Server, and Sensor. The tree also contains specialized terminal-chat and KISS-modem builds. Availability depends on the board; use the flasher or that board's PlatformIO environments as the current source of truth.

    "},{"location":"faq/#123-companion-radio-firmware","title":"1.2.3. Companion Radio Firmware","text":"

    Companion radios connect MeshCore client software to LoRa. Depending on the board and build profile, the companion protocol can be exposed over BLE, USB serial, Wi-Fi, or Ethernet. Some boards also provide a Full Companion profile with additional local administration features. Check the exact build name and transport before flashing. Pending messages are held in a volatile shared queue; capacity depends on the platform and memory profile. See the Companion offline message queue for the exact defaults and limitations. See https://meshcore.io and the web client at https://app.meshcore.nz.

    "},{"location":"faq/#124-repeater","title":"1.2.4. Repeater","text":"

    Repeaters are used to extend the range of a MeshCore network. Repeater firmware runs on the same devices that run client firmware. A repeater's job is to forward MeshCore packets to the destination device. It does not forward or retransmit every packet it receives, unlike other LoRa mesh systems.

    A repeater can be remotely administered using a T-Deck running the MeshCore firmware with remote administration features unlocked, or from a BLE Companion client connected to a smartphone running the MeshCore app.

    "},{"location":"faq/#125-room-server","title":"1.2.5. Room Server","text":"

    A room server is a simple BBS server for sharing posts. T-Deck devices running MeshCore firmware or a BLE Companion client connected to a smartphone running the MeshCore app can connect to a room server.

    Room servers store message history on them and push the stored messages to users. Room servers allow roaming users to come back later and retrieve message history. With channels, messages are either received when it's sent, or not received and missed if the channel user is out of range. Room servers are different and more like email servers where you can come back later and get your emails from your mail server.

    A room server can be remotely administered using a T-Deck running the MeshCore firmware with remote administration features unlocked, or from a BLE Companion client connected to a smartphone running the MeshCore app.

    When a client logs into a room server, the client will receive the previously 32 unseen messages.

    Although room server can also repeat with the command line command set repeat on, it is not recommended nor encouraged. A room server with repeat set to on lacks the full set of repeater and remote administration features that are only available in the repeater firmware.

    The recommendation is to run repeater and room server on separate devices for the best experience.

    "},{"location":"faq/#2-initial-setup","title":"2. Initial Setup","text":""},{"location":"faq/#21-q-how-many-devices-do-i-need-to-start-using-meshcore","title":"2.1. Q: How many devices do I need to start using MeshCore?","text":"

    A: If you have one supported device, flash the BLE Companion firmware and use your device as a client. You can connect to the device using the Android or iOS client via Bluetooth. You can start communicating with other MeshCore users near you.

    If you have two supported devices, and there are not many MeshCore users near you, flash both to BLE Companion firmware so you can use your devices to communicate with your nearby friends and family.

    If you have two supported devices, and there are other MeshCore users nearby, you can flash one of your devices with BLE Companion firmware and flash another supported device to repeater firmware. Place the repeater high above ground to extend your MeshCore network's reach.

    After you flashed the latest firmware onto your repeater device, keep the device connected to your computer via USB serial, use the console feature on the web flasher and set the frequency for your region or country, so your client can remote administer the repeater or room server over RF:

    set freq {frequency}

    The repeater and room server CLI reference is here: https://docs.meshcore.io/cli_commands

    If you have more supported devices, you can use your additional devices with the room server firmware.

    "},{"location":"faq/#22-q-does-meshcore-cost-any-money","title":"2.2. Q: Does MeshCore cost any money?","text":"

    A: All radio firmware versions (e.g. for Heltec V3, RAK, T-1000E, etc.) are free and open source developed by Scott at Ripple Radios.

    The native Android and iOS client uses the freemium model and is developed by Liam Cottle, developer of meshtastic map at meshtastic.liamcottle.net on GitHub and reticulum-meshchat on GitHub.

    The T-Deck firmware is free to download and most features are available without cost. To support the firmware developer, you can pay for a registration key to unlock your T-Deck for deeper map zoom and remote server administration over RF using the T-Deck. You do not need to pay for the registration to use your T-Deck for direct messaging and connecting to repeaters and room servers.

    "},{"location":"faq/#23-q-what-frequencies-are-supported-by-meshcore","title":"2.3. Q: What frequencies are supported by MeshCore?","text":"

    A: Supported frequencies depend on the radio hardware and the rules for the country in which it operates. Common MeshCore hardware covers portions of the 433, 868, and 915 MHz ISM bands.

    Use the current regional preset offered by the client or flasher, confirm it with the local MeshCore community, and comply with local frequency, bandwidth, duty-cycle, and power limits. Do not copy an old frequency from this FAQ. Narrow presets commonly use BW62.5 with a lower spreading factor; their exact frequency and SF are region-specific and can change as deployments coordinate.

    If you have consensus from your community in your region to update your region's preset recommendation, please post your update request on the #meshcore-app channel on the MeshCore Discord server to let Liam Cottle know.

    "},{"location":"faq/#24-q-what-is-an-advert-in-meshcore","title":"2.4. Q: What is an \"advert\" in MeshCore?","text":"

    A: Advert means to advertise yourself on the network. In Reticulum terms it would be to announce. In Meshtastic terms it would be the node sending its node info.

    MeshCore allows you to manually broadcast your name, position and public encryption key, which is also signed to prevent spoofing. When you click the advert button, it broadcasts that data over LoRa. MeshCore calls that an Advert. There's two ways to advert, \"zero hop\" and \"flood\".

    MeshCore clients only advertise themselves when the user initiates it. A repeater sends a flood advert once every 12 hours by default. This interval can be configured using the following command:

    set flood.advert.interval {hours}

    The separate set advert.interval {minutes} command controls the local zero-hop advert timer.

    "},{"location":"faq/#25-q-is-there-a-hop-limit","title":"2.5. Q: Is there a hop limit?","text":"

    A: Internally the firmware has maximum limit of 64 hops. In real world settings it will be difficult to get close to the limit due to the environments and timing as packets travel further and further. We want to hear how far your MeshCore conversations go.

    "},{"location":"faq/#3-server-administration","title":"3. Server Administration","text":""},{"location":"faq/#31-q-how-do-you-configure-a-repeater-or-a-room-server","title":"3.1. Q: How do you configure a repeater or a room server?","text":"

    A: When MeshCore is flashed onto a LoRa device for the first time, it is necessary to set the server device's frequency to make it utilize the frequency that is legal in your country or region.

    Repeater or room server can be administered with one of the options below:

    https://buymeacoffee.com/ripplebiz/e/249834

    "},{"location":"faq/#32-q-do-i-need-to-set-the-location-for-a-repeater","title":"3.2. Q: Do I need to set the location for a repeater?","text":"

    A: While not required, with location set for a repeater it will show up on the MeshCore map in the future. Set location with the following command:

    set lat <GPS Lat>

    set lon <GPS Lon>

    You can get the latitude and longitude from Google Maps by right-clicking the location you are at on the map.

    "},{"location":"faq/#33-q-what-is-the-password-to-administer-a-repeater-or-a-room-server","title":"3.3. Q: What is the password to administer a repeater or a room server?","text":"

    A: The default admin password to a repeater and room server is password. Use the following command to change the admin password:

    password {new-password}

    "},{"location":"faq/#34-q-what-is-the-password-to-join-a-room-server","title":"3.4. Q: What is the password to join a room server?","text":"

    A: The default guest password to a room server is hello. Use the following command to change the guest password:

    set guest.password {guest-password}

    "},{"location":"faq/#35-q-can-i-retrieve-a-repeaters-private-key-or-set-a-repeaters-private-key","title":"3.5. Q: Can I retrieve a repeater's private key or set a repeater's private key?","text":"

    A: You can issue these commands to get or set a repeater's private key using a USB serial connection.

    get prv.key to print a repeater's private key on the serial console set prv.key <hex> to set a repeater's private key on the serial console

    Reboot the repeater after set prv.key <hex> command for the new private key to take effect.

    "},{"location":"faq/#36-q-the-first-byte-of-my-repeaters-public-key-collides-with-an-existing-repeater-on-the-mesh-how-do-i-get-a-new-private-key-with-a-matching-public-key-that-has-its-first-byte-of-my-choosing","title":"3.6. Q: The first byte of my repeater's public key collides with an existing repeater on the mesh. How do I get a new private key with a matching public key that has its first byte of my choosing?","text":"

    A: You can generate a new private key and specify the first byte of its public key here: https://gessaman.com/mc-keygen

    Having multiple repeaters with the same first byte ID does not negatively affect the mesh or its functionality. Flood and pathed packets will still reach their destinations. First byte ID collision makes traceroute and path analysis harder because these tools don't know exactly which of the two (or more) colliding repeaters is the one in the path.

    Best practice is when you set up a new repeater, choose a public key that is not in use. If it is not possible to find a unique first byte for your repeater's public key, choose one that is unique within about 10 miles (16 km) to minimize collision with nearby repeaters.

    "},{"location":"faq/#37-q-my-repeater-may-be-suffering-from-deafness-due-to-high-power-interference-near-my-meshs-frequency-it-is-not-hearing-other-in-range-meshcore-radios-what-can-i-do","title":"3.7. Q: My repeater may be suffering from deafness due to high power interference near my mesh's frequency, it is not hearing other in-range MeshCore radios. What can I do?","text":"

    A: This may be due to the SX1262 radio's auto gain control feature. You can use this command to periodically reset its AGC.

    set agc.reset.interval <number>

    The <number> unit is in seconds and is incremented by 4. set agc.reset.interval 4 works well to cure deafness.

    The reset is skipped while a packet is pending, being received, or being transmitted. When safe, RadioLibWrapper::resetAGC() warm-sleeps the radio, returns the wrapper to idle so receive mode is re-armed, reapplies the cached RX boost setting, and starts a fresh noise-floor calibration.

    "},{"location":"faq/#371-q-can-firmware-recover-an-external-radio-whose-busy-pin-is-stuck-high","title":"3.7.1. Q: Can firmware recover an external radio whose BUSY pin is stuck high?","text":"

    A: Current firmware first attempts a non-destructive RX/AGC re-arm and then uses a physical radio reset when the board exposes one. A normal RadioLib reinitialization is not reported as a hard reset unless firmware can actually pulse NRST or cycle the radio's power rail.

    Most supported external-radio boards expose NRST directly. RAK3401 pulses its dedicated NRST while leaving the shared peripheral rail and SPI controller intact, and LilyGo T-Beam 1W can cycle the radio regulator. The generic Tenstar C3 target describes a user-wired radio and has no reset connection by default. For unattended recovery on that target, wire NRST to a free GPIO and override P_LORA_RESET with that GPIO in the build configuration.

    The SenseCAP T1000-E LR1110 path was hardware-qualified with 50 successful radio-only resets: 25 in continuous receive and 25 with RX power saving active. The saved radio tuple, TX power, RX gain, and RX power-saving configuration were restored after every reset, bidirectional LoRa status traffic still passed, and the nRF52840 uptime remained continuous.

    An integrated STM32WL radio has no independently resettable RF peripheral, so it intentionally remains on the bounded soft-recovery path. If its radio core cannot recover, resetting only the radio is electrically impossible; the MCU must be restarted.

    "},{"location":"faq/#372-q-does-firmware-automatically-update-lr1110-transceiver-firmware","title":"3.7.2. Q: Does firmware automatically update LR1110 transceiver firmware?","text":"

    A: No. A transceiver update erases and rewrites the LR1110 itself, so it is not performed as a side effect of installing ordinary device firmware. Use the hardware vendor's physical-access maintenance procedure and verify the exact LR1110 and bootloader versions before changing them.

    Full remediation of Semtech SEM-PSA-2026-001 requires LR1110 BL2 0x1001 as well as the applicable transceiver firmware. It is a separate two-stage maintenance procedure and is not performed automatically by MeshCore.

    "},{"location":"faq/#373-q-how-does-firmware-handle-lr2021-lowhigh-band-changes","title":"3.7.3. Q: How does firmware handle LR2021 low/high-band changes?","text":"

    A: Firmware first selects the board's matching RF path, retunes the radio, and then reapplies output power so the LR2021 selects the correct low- or high-frequency PA. It also uses the matching RF-switch mode when RX or TX is launched. LR2021 high-band output power is limited to 12 dBm; a lower board-specific limit still takes precedence.

    On Meshnology W12, the two external FEM supplies are mutually exclusive: GPIO4 powers the sub-GHz GC1109 path and GPIO3 powers the 2.4 GHz RFX2402E path. Both supplies are disabled before the selected one is enabled. Temporary radio settings preserve the saved TX power and safely clamp only the active band; returning to the saved settings restores its applicable power. A permanent frequency change clamps the saved TX power to the new band's limit.

    The external SX1262, SX1268, LLCC68, LR1110, and LR2021 drivers also bound the post-SetTx BUSY wait. A failed PA ramp therefore returns an error and can enter the physical-reset recovery path instead of hanging the node indefinitely.

    "},{"location":"faq/#38-q-how-do-i-make-my-repeater-an-observer-on-the-mesh","title":"3.8. Q: How do I make my repeater an observer on the mesh?","text":"

    A: The observer instruction is available here: https://analyzer.letsmesh.net/observer/onboard

    "},{"location":"faq/#39-q-what-is-multibyte-support-what-do-1-byte-2-byte-3-byte-adverts-and-messages-mean","title":"3.9. Q: What is multibyte support? What do 1-byte, 2-byte, 3-byte adverts and messages mean?","text":"

    A: The original MeshCore protocol design uses the first byte of a repeater's public key to denote the repeater in a path. And with 1 byte for each repeater in the path, MeshCore packets can travel as many as 64 hops.

    However, with 1 byte, there are only 254 unique IDs (exclude 00 and FF which are reserved). Many meshes group have multiple repeaters with the same first byte in their public keys. Packets continue to pass through repeaters and the mesh is not harmed in any way. It does make it harder for tools to analyze paths with duplicated repeater IDs.

    Firmware version 1.14 and newer introduces the ability for repeaters to advert with 1-, 2-, or 3-byte adverts. Companions can also send out channel and direct messages with 1-, 2-, or 3-byte path. Adverts and messages sent in 1-byte path is compatible with repeater firmware older or newer than 1.14. They will travel up to 64 hops. 2-byte adverts and messages will travel up to 32 hops. 3-byte adverts and messages will travel up to 21 hops.

    "},{"location":"faq/#391-q-what-path-hash-sizes-will-my-repeater-forward","title":"3.9.1. Q: What path hash sizes will my repeater forward?","text":"

    Repeaters running firmware 1.14+ repeat packets sent with 1-, 2-, or 3-byte path hash. Repeaters on firmware older than 1.14 only repeat 1-byte path hash packets and silently drop 2- and 3-byte packets.

    "},{"location":"faq/#392-q-what-determines-a-packets-path-hash-size","title":"3.9.2. Q: What determines a packet's path hash size?","text":"

    The original packet sender determines the path hash size. The most common original sender is a companion app. The other common original sender is a repeater, when it broadcasts its advert.

    "},{"location":"faq/#393-q-how-do-i-change-my-companions-path-hash-size","title":"3.9.3. Q: How do I change my companion's path hash size?","text":"

    As of firmware version 1.14 and MeshCore app version 1.41.0, in the MeshCore app, you can set your companion's message path hash size in Settings (gear icon), Experimental Settings.

    Until your regional mesh has the vast majority of the repeaters updated to 1.14+ firmware, it is recommended to keep your companion at the default 1-byte because pre-1.14 repeaters will silently drop messages with larger path hashes.

    "},{"location":"faq/#394-q-what-does-the-cli-command-pathhashmode-do-on-a-repeater","title":"3.9.4. Q: What does the CLI command path.hash.mode do on a repeater?","text":"

    This CLI command path.hash.mode only controls the path hash size used in a repeater's own advert broadcasts. It does NOT affect which packets the repeater forwards. A repeater with firmware 1.14+ always forward 1-, 2-, and 3-byte packets regardless of this setting.

    Usage: set path.hash.mode {0|1|2}:

    +----------------+-----------------------+\n| path.hash.mode | Advert path hash size |\n+----------------+-----------------------+\n| 0              | 1 byte (default)      |\n+----------------+-----------------------+\n| 1              | 2 bytes               |\n+----------------+-----------------------+\n| 2              | 3 bytes               |\n+----------------+-----------------------+\n

    It is safe to set your 1.14+ repeaters to mode 1 or 2.

    "},{"location":"faq/#395-q-why-use-2-or-3-byte-path-hash-for-adverts","title":"3.9.5. Q: Why use 2- or 3-byte path hash for adverts?","text":"

    A longer path hash helps tools like the LetsMesh.net Analyzer and MeshMapper disambiguate repeaters more reliably. With only 1 byte, the chance of different repeaters having the same first byte in their public key is high, making it harder to tell them apart in mesh network analysis. Since this only affects adverts, there's no downside. 2- and 3-byte adverts don't travel as far as 1-byte adverts, but it is not important for MeshCore nodes to hear a repeater's advert that is 21 or 32 hops away.

    "},{"location":"faq/#396-q-when-can-we-move-away-from-1-byte-path-hash-for-channel-and-direct-messages","title":"3.9.6. Q: When can we move away from 1-byte path hash for channel and direct messages?","text":"

    You should move to send 2-byte or 3-byte channel and direct messages when the vast majority of the repeaters in your regional mesh are updated to firmware version 1.14 or newer. Setting your repeater's path.hash.mode to 1 (for 2-byte path hash) or 2 (for 3-byte path hash) now helps the community gauge to how many repeaters have updated to 1.14+. Please work with your MeshCore community together to decide when to switch to 2-byte path or 3-byte path for channel and direct messages.

    "},{"location":"faq/#4-t-deck-related","title":"4. T-Deck Related","text":""},{"location":"faq/#41-q-is-there-a-user-guide-for-t-deck-t-pager-t-watch-or-t-display-pro","title":"4.1. Q: Is there a user guide for T-Deck, T-Pager, T-Watch, or T-Display Pro?","text":"

    A: Yes, it is available on https://buymeacoffee.com/ripplebiz/ultra-v7-7-guide-meshcore-users

    "},{"location":"faq/#42-q-what-are-the-steps-to-get-a-t-deck-into-dfu-device-firmware-update-mode","title":"4.2. Q: What are the steps to get a T-Deck into DFU (Device Firmware Update) mode?","text":"

    A:

    1. Device off
    2. Connect USB cable to device
    3. Hold down trackball (keep holding)
    4. Turn on device
    5. Hear USB connection sound
    6. Release trackball
    7. T-Deck in DFU mode now
    8. At this point you can begin flashing using https://flasher.meshcore.io
    "},{"location":"faq/#43-q-why-is-my-t-deck-plus-not-getting-any-satellite-lock","title":"4.3. Q: Why is my T-Deck Plus not getting any satellite lock?","text":"

    A: For T-Deck Plus, the GPS baud rate should be set to 38400. Also, some T-Deck Plus devices were found to have the GPS module installed upside down, with the GPS antenna facing down instead of up. If your T-Deck Plus still doesn't get any satellite lock after setting the baud rate to 38400, you might need to open the device to check the GPS orientation.

    GPS on T-Deck is always enabled. You can skip the \"GPS clock sync\" and the T-Deck will continue to try to get a GPS lock. You can go to the GPS Info screen; you should see the Sentences: counter increasing if the baud rate is correct.

    Source

    "},{"location":"faq/#44-q-why-is-my-og-non-plus-t-deck-not-getting-any-satellite-lock","title":"4.4. Q: Why is my OG (non-Plus) T-Deck not getting any satellite lock?","text":"

    A: The OG (non-Plus) T-Deck doesn't come with a GPS. If you added a GPS to your OG T-Deck, please refer to the manual of your GPS to see what baud rate it requires. Alternatively, you can try to set the baud rate from 9600, 19200, etc., and up to 115200 to see which one works.

    "},{"location":"faq/#45-q-what-size-of-sd-card-does-the-t-deck-support","title":"4.5. Q: What size of SD card does the T-Deck support?","text":"

    A: Users have had no issues using 16GB or 32GB SD cards. Format the SD card to FAT32.

    "},{"location":"faq/#46-q-what-is-the-public-key-for-the-default-public-channel","title":"4.6. Q: What is the public key for the default public channel?","text":"

    A: T-Deck uses the same key the smartphone apps use but in base64 izOH6cXN6mrJ5e26oRXNcg==

    There is no = key on the T-Deck's hardware keyboard. You can use the on-screen software keyboard to enter =. Tap the text box to enable the on-screen software keyboard. The third character is the capital letter O (Oh), not zero 0

    The smartphone app key is in hex: 8b3387e9c5cdea6ac9e5edbaa115cd72

    Source

    "},{"location":"faq/#47-q-how-do-i-get-maps-on-t-deck","title":"4.7. Q: How do I get maps on T-Deck?","text":"

    A: You need map tiles. You can get pre-downloaded map tiles here (a good way to support development):

    Another way to download map tiles is to use this Python script to get the tiles in the areas you want: https://github.com/fistulareffigy/MTD-Script

    There is also a modified script that adds additional error handling and parallel downloads: https://github.com/TheBestJohn/MTD-Script

    "},{"location":"faq/#48-q-where-do-the-map-tiles-go","title":"4.8. Q: Where do the map tiles go?","text":"

    Once you have the tiles downloaded, copy the \\tiles folder to the root of your T-Deck's SD card.

    "},{"location":"faq/#49-q-how-to-unlock-deeper-map-zoom-and-server-management-features-on-t-deck","title":"4.9. Q: How to unlock deeper map zoom and server management features on T-Deck?","text":"

    A: You can download, install, and use the T-Deck firmware for free, but it has some features (map zoom, server administration) that are enabled if you purchase an unlock code for \\$10 per T-Deck device. Unlock page: https://buymeacoffee.com/ripplebiz/e/249834

    "},{"location":"faq/#410-q-how-to-decipher-the-diagnostics-screen-on-t-deck","title":"4.10. Q: How to decipher the diagnostics screen on T-Deck?","text":"

    A: Space is tight on T-Deck's screen, so the information is a bit cryptic. The format is : {hops} l:{packet-length}({payload-len}) t:{packet-type} snr:{n} rssi:{n}

    See here for packet-type: https://github.com/meshcore-dev/MeshCore/blob/main/src/Packet.h#L19

    Value Payload 0x00 Request 0x01 Response 0x02 Plain text message 0x03 Acknowledgment 0x04 Node advertisement 0x05 Group text 0x06 Group datagram 0x07 Anonymous request 0x08 Returned path 0x09 Trace 0x0A Multipart 0x0B Control/discovery data 0x0C OTA-over-LoRa data 0x0D-0x0E Reserved 0x0F Raw custom data

    See Packet Format and Payload Format for the maintained descriptions.

    Source

    "},{"location":"faq/#411-q-the-t-deck-sound-is-too-loud","title":"4.11. Q: The T-Deck sound is too loud?","text":""},{"location":"faq/#412-q-can-you-customize-the-sound","title":"4.12. Q: Can you customize the sound?","text":"

    A: You can customize the sounds on the T-Deck, by placing .mp3 files onto the root dir of the SD card. The files are:

    "},{"location":"faq/#413-q-what-is-the-import-from-clipboard-feature-on-the-t-deck-and-is-there-a-way-to-manually-add-nodes-without-having-to-receive-adverts","title":"4.13. Q: What is the 'Import from Clipboard' feature on the t-deck and is there a way to manually add nodes without having to receive adverts?","text":"

    A: 'Import from Clipboard' is for importing a contact via a file named 'clipboard.txt' on the SD card. The opposite, is in the Identity screen, the 'Card to Clipboard' menu, which writes to 'clipboard.txt' so you can share yourself (call these 'biz cards', that start with \"meshcore://...\")

    "},{"location":"faq/#414-q-how-to-capture-a-screenshot-on-t-deck","title":"4.14. Q: How to capture a screenshot on T-Deck?","text":"

    A: To capture a screenshot on a T-Deck, long press the top-left corner of the screen. The screenshot is saved to the microSD card, if one is inserted into the device.

    "},{"location":"faq/#5-general","title":"5. General","text":""},{"location":"faq/#51-q-what-are-bw-sf-and-cr","title":"5.1. Q: What are BW, SF, and CR?","text":"

    A:

    BW is bandwidth - width of frequency spectrum that is used for transmission

    SF is spreading factor - how much should the communication spread in time

    CR is coding rate - from: https://www.thethingsnetwork.org/docs/lorawan/fec-and-code-rate

    TL;DR: default CR to 5 for good stable links. If it is not a solid link and is intermittent, change CR to 7 or 8.

    Forward Error Correction is a process of adding redundant bits to the data to be transmitted. During the transmission, data may get corrupted by interference (changes from 0 to 1 / 1 to 0). These error correction bits are used at the receivers for restoring corrupted bits.

    The Code Rate of a forward error correction expresses the proportion of bits in a data stream that actually carry useful information.

    There are 4 code rates used in LoRaWAN:

    4/5 4/6 5/7 4/8

    For example, if the code rate is 5/7, for every 5 bits of useful information, the coder generates a total of 7 bits of data, of which 2 bits are redundant.

    Making the bandwidth 2x wider (from BW125 to BW250) allows you to send 2x more bytes in the same time. Making the spreading factor 1 step lower (from SF10 to SF9) allows you to send 2x more bytes in the same time.

    Lowering the spreading factor makes it more difficult for the gateway to receive a transmission, as it will be more sensitive to noise. You could compare this to two people talking in a noisy place (a bar for example). If you're far from each other, you have to talk slow (SF10), but if you're close, you can talk faster (SF7)

    So, it's a balancing act between speed of the transmission and resistance to noise. The Things Network is mainly focused on LoRaWAN, but the LoRa low-level stuff still checks out for any LoRa project

    "},{"location":"faq/#52-q-do-meshcore-clients-repeat","title":"5.2. Q: Do MeshCore clients repeat?","text":"

    A: Companion clients do not repeat by default. Supported builds can opt into bounded client repeating on permitted frequencies, and the emergency-channel path has a bounded delayed relay behavior. Dedicated repeaters remain the normal and recommended way to extend network coverage. Room servers can repeat when explicitly configured, but separating the roles is generally preferable.

    "},{"location":"faq/#53-q-what-happens-when-a-node-learns-a-route-via-a-mobile-repeater-and-that-repeater-is-gone","title":"5.3. Q: What happens when a node learns a route via a mobile repeater, and that repeater is gone?","text":"

    A: If you used to reach a node through a repeater and the repeater is no longer reachable, the client will send the message using the existing (but now broken) known path, the message will fail after 3 retries, and the app will reset the path and send the message as flood on the last retry by default. This can be turned off in settings. If the destination is reachable directly or through another repeater, the new path will be used going forward. Or you can set the path manually if you know a specific repeater to use to reach that destination.

    In the case if users are moving around frequently, and the paths are breaking, they just see the phone client retries and revert to flood to attempt to re-establish a path.

    "},{"location":"faq/#54-q-how-does-a-node-discover-a-path-to-its-destination-and-then-use-it-to-send-messages-in-the-future-instead-of-flooding-every-message-it-sends-like-meshtastic","title":"5.4. Q: How does a node discover a path to its destination and then use it to send messages in the future, instead of flooding every message it sends like Meshtastic?","text":"

    Routes are stored in sender's contact list. When you send a message the first time, the message first gets to your destination by flood routing. When your destination node gets the message, it will send back a delivery report to the sender with all repeaters that the original message went through. This delivery report is flood-routed back to you the sender and is a basis for future direct path. When you send the next message, the path will get embedded into the packet and be evaluated by repeaters. If the hop and address of the repeater matches, it will retransmit the message, otherwise it will not retransmit, hence minimizing utilization.

    Source

    "},{"location":"faq/#55-q-do-public-channels-always-flood-do-private-channels-always-flood","title":"5.5. Q: Do public channels always flood? Do private channels always flood?","text":"

    A: Yes, group channels are A to B, so there is no defined path. They have to flood. Repeaters can however deny flood traffic up to some hop limit, with the set flood.max CLI command. Administrators of repeaters get to set the rules of their repeaters.

    Source

    "},{"location":"faq/#56-q-what-is-the-public-key-for-the-default-public-channel","title":"5.6. Q: What is the public key for the default public channel?","text":"

    A: The smartphone app key is in hex:

    8b3387e9c5cdea6ac9e5edbaa115cd72

    T-Deck uses the same key but in base64:

    izOH6cXN6mrJ5e26oRXNcg==

    The third character is the capital letter O, not zero 0.

    Source

    "},{"location":"faq/#57-q-is-meshcore-open-source","title":"5.7. Q: Is MeshCore open source?","text":"

    A: Most of the firmware is freely available. Everything is open source except the T-Deck firmware and Liam's native mobile apps.

    Firmware repo: https://github.com/meshcore-dev/MeshCore

    "},{"location":"faq/#58-q-how-can-i-support-meshcore","title":"5.8. Q: How can I support MeshCore?","text":"

    A: Provide your honest feedback on GitHub and on MeshCore Discord server. Spread the word of MeshCore to your friends and communities; help them get started with MeshCore. Support Scott's MeshCore development at https://buymeacoffee.com/ripplebiz.

    Support Liam Cottle's smartphone client development by unlocking the server administration wait gate with in-app purchase

    Support Rastislav Vysoky (recrof)'s flasher website and the map website development through PayPal or Revolut

    "},{"location":"faq/#59-q-how-do-i-build-meshcore-firmware-from-source","title":"5.9. Q: How do I build MeshCore firmware from source?","text":"

    A: See instructions here: https://discord.com/channels/826570251612323860/1330643963501351004/1341826372120608769

    Build instructions for MeshCore:

    For Windows, first install WSL and Python+pip via: https://plainenglish.io/python/setting-up-python-on-windows-subsystem-for-linux-wsl-26510f1b2d80

    (Linux, Windows+WSL) In the terminal/shell:

    sudo apt update\nsudo apt install libpython3-dev\nsudo apt install python3-venv\n

    Mac: python3 should be already installed.

    Then it should be the same for all platforms:

    python3 -m venv meshcore\ncd meshcore && source bin/activate\npip install -U platformio\ngit clone https://github.com/meshcore-dev/MeshCore.git\ncd MeshCore\n

    Choose an exact environment listed by pio project config or in the target board's variants/*/platformio.ini. For example:

    pio run -e RAK_4631_repeater\n

    The output is under .pio/build/RAK_4631_repeater/. Set the radio parameters to your current regional preset after flashing; if changing build defaults, keep that local change out of commits unless it is intended for every user.

    "},{"location":"faq/#510-q-are-there-other-meshcore-related-open-source-projects","title":"5.10. Q: Are there other MeshCore related open source projects?","text":"

    A: Liam Cottle's MeshCore web client and MeshCore JavaScript library are open source under MIT license.

    Web client: https://github.com/liamcottle/meshcore-web JavaScript: https://github.com/meshcore-dev/meshcore.js

    "},{"location":"faq/#511-q-does-meshcore-support-atak","title":"5.11. Q: Does MeshCore support ATAK?","text":"

    A: This repository does not ship or document an official ATAK integration. An external integration must account for LoRa airtime, moving endpoints, stale direct paths, and the collision cost of frequent flood fallback. Optional, bounded Companion repeating does not turn every mobile client into a dedicated repeater and does not remove those capacity constraints. Check current community projects before designing a deployment.

    Source

    "},{"location":"faq/#512-q-how-do-i-add-a-node-to-the-meshcore-map","title":"5.12. Q: How do I add a node to the MeshCore Map?","text":"

    A:

    To add a BLE Companion radio, connect to the BLE Companion radio from the MeshCore smartphone app. In the app, tap the 3 dot menu icon at the top right corner, then tap Internet Map. Tap the 3 dot menu icon again and choose Add me to the Map

    To add a Repeater or Room Server to the map, go to the Contact List, tap the 3 dot next to the Repeater or Room Server you want to add to the Internet Map, tap Share, then tap Upload to Internet Map.

    You can use the same companion (same public key) that you used to add your repeaters or room servers to remove them from the Internet Map.

    "},{"location":"faq/#513-q-can-i-use-a-raspberry-pi-to-update-a-meshcore-radio","title":"5.13. Q: Can I use a Raspberry Pi to update a MeshCore radio?","text":"

    A: Yes. Below are the instructions to flash firmware onto a supported LoRa device using a Raspberry Pi over USB serial.

    Instructions for nRF devices like RAK, T1000-E, T114 are immediately after the ESP instructions

    For ESP-based devices (e.g. Heltec V3) you need:

    1. Download the firmware file from https://flasher.meshcore.io.
      • Select the exact board and role. Artifact names and release URLs include a changing version and commit, so copy the current download URL instead of using an example URL from a guide.
      • A non-merged application image is for an already compatible bootloader and partition table. A merged image also contains low-level flash data. Back up settings before either operation; do not assume a merged flash preserves configuration, pairing data, or a previous partition layout.
      • To download a copied URL from the shell, use curl -L '<copied-url>' -o <firmware>.bin.
    2. Confirm the ttyXXXX device path on your Raspberry Pi.
      • Run ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null before and after connecting the board. ESP devices can appear under either name.
    3. Install esptool in a virtual environment.
      • python3 -m venv .venv && . .venv/bin/activate
      • python -m pip install --upgrade esptool
    4. Flash the firmware.
      • For non-merged bin:
        • python -m esptool --port /dev/ttyUSB0 --chip auto write-flash 0x10000 <non-merged-firmware>.bin
      • For merged bin:
        • python -m esptool --port /dev/ttyUSB0 --chip auto write-flash 0x00000 <merged-firmware>.bin

    Instructions for nRF devices:

    For nRF devices (e.g. RAK, Heltec T114) you need the following:

    1. Download the firmware file from https://flasher.meshcore.io.
      • Select the exact board and role, and download its current ZIP package. Do not rename a package from another board or reuse a static versioned URL.
    2. Confirm the ttyXXXX device path on your Raspberry Pi.
      • Run ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null; nRF bootloaders commonly appear as /dev/ttyACM0, but the number can change.
    3. In a virtual environment, install adafruit-nrfutil:
      • python3 -m venv .venv && . .venv/bin/activate
      • python -m pip install --upgrade adafruit-nrfutil
    4. Flash the nRF device.
      • adafruit-nrfutil --verbose dfu serial --package <firmware-package>.zip -p /dev/ttyACM0 -b 115200 --singlebank --touch 1200

    To manage a repeater or room server connected to a Pi over USB serial using shell commands, you need to install picocom. To install picocom, run the following command:

    To start managing your USB serial-connected device using picocom, use the following command:

    From here, reference repeater and room server command line commands in the MeshCore docs here:

    "},{"location":"faq/#514-q-are-there-projects-built-around-meshcore","title":"5.14. Q: Are there projects built around MeshCore?","text":"

    A: Yes, there are many. MeshCore's protocol is open source using the MIT license. The MIT license and the open source protocol makes it very easy for the MeshCore community to build new firmware for radios, applications on mobile devices, map tools, and analysis tools, and integration with other projects like Home Assistant.

    As new MeshCore community projects become available on a weekly basis, we have stopped tracking them here in this FAQ. samuk maintains a very exhaustive list of MeshCore community project at https://github.com/samuk/awesome-meshcore/blob/main/README.md. samuk accepts PRs and merges them regularly.

    "},{"location":"faq/#515-q-are-there-client-applications-for-windows-or-mac","title":"5.15. Q: Are there client applications for Windows or Mac?","text":"

    A: Yes, the same iOS and Android client is also available for Windows and Mac. You can find them together with the Android APK here: https://files.liamcottle.net/MeshCore

    Both the Windows and Mac versions of the client app are fully unlocked and are free to use.

    "},{"location":"faq/#516-q-are-there-any-resources-that-compare-meshcore-to-other-lora-systems","title":"5.16. Q: Are there any resources that compare MeshCore to other LoRa systems?","text":"

    A: Here is a list of MeshCore comparison resources:

    "},{"location":"faq/#6-troubleshooting","title":"6. Troubleshooting","text":""},{"location":"faq/#61-q-my-client-says-another-client-or-a-repeater-or-a-room-server-was-last-seen-many-many-days-ago","title":"6.1. Q: My client says another client or a repeater or a room server was last seen many, many days ago.","text":""},{"location":"faq/#62-q-a-repeater-or-a-client-or-a-room-server-i-expect-to-see-on-my-discover-list-on-t-deck-or-contact-list-on-a-smart-device-client-are-not-listed","title":"6.2. Q: A repeater or a client or a room server I expect to see on my discover list (on T-Deck) or contact list (on a smart device client) are not listed.","text":"

    A:

    You can get the epoch time on https://www.epochconverter.com and use it to set your T-Deck clock. For a repeater and room server, the admin can use a T-Deck to remotely set their clock (clock sync), or use the time command in the USB serial console with the server device connected.

    "},{"location":"faq/#63-q-how-to-connect-to-a-repeater-via-ble-bluetooth","title":"6.3. Q: How to connect to a repeater via BLE (Bluetooth)?","text":"

    A: You can't connect to a device running repeater firmware via Bluetooth. You can connect to devices running the BLE companion firmware via Bluetooth using the Android app.

    "},{"location":"faq/#64-q-my-companion-isnt-showing-up-over-bluetooth","title":"6.4. Q: My companion isn't showing up over Bluetooth?","text":"

    A: Make sure that you flashed the Bluetooth companion firmware and not the USB-only companion firmware.

    "},{"location":"faq/#65-q-i-cant-connect-via-bluetooth-what-is-the-bluetooth-pairing-code","title":"6.5. Q: I can't connect via Bluetooth, what is the Bluetooth pairing code?","text":"

    A: The default Bluetooth pairing code is 123456 on builds without a physical display. Display-equipped Companion builds generate a six-digit PIN at boot and show it when a client requests pairing. The display stays awake on the PIN page until Bluetooth connects or the two-minute pairing window expires.

    "},{"location":"faq/#66-q-my-heltec-v3-keeps-disconnecting-from-my-smartphone-it-cant-hold-a-solid-bluetooth-connection","title":"6.6. Q: My Heltec V3 keeps disconnecting from my smartphone. It can't hold a solid Bluetooth connection.","text":"

    A: Heltec V3 has a very small coil antenna on its PCB for Wi-Fi and Bluetooth connectivity. It has a very short range, only a few feet. It is possible to remove the coil antenna and replace it with a 31mm wire. The BT range is much improved with the modification.

    "},{"location":"faq/#67-q-my-rakt1000-exiao_nrf52-device-seems-to-be-corrupted-how-do-i-wipe-it-clean-to-start-fresh","title":"6.7. Q: My RAK/T1000-E/xiao_nRF52/... device seems to be corrupted, how do I wipe it clean to start fresh?","text":"

    A: If you're able to connect to the device from your MeshCore app 1. Navigate to Gear icon(Settings) on the upper right corner 2. Click on Export Settings button, choose Select All and Confirm. This will save your Node configuration 3. Now choose Factory Reset option 4. Confirm that you want to reset your device 5. Go to Bluetooth System settings and remove pairing of the device, so you can connect again 6. Connect your device in the app and enter the PIN 7. Navigate to Gear icon(Settings) on the upper right corner 8. Click on Import Settings and choose the file from 2., click on Select All and Confirm 9. Find Reboot button on of the Settings list

    B: You're not able to connect to the App 1. Connect USB cable to your device 2. Go to https://flasher.meshcore.io 3. Search for your device in the list 4. Choose Companion Bluetooth 5. Press Enter DFU mode button, choose your USB device 6. Press Erase Flash button, choose the device again and wait until it completes 7. Press Flash! button and choose the USB device last time 8. The device is erased and newest firmware is installed 9. You might need to remove the pairing in Bluetooth System Settings in order to re-pair the app again.

    An erase application or ordinary application ZIP/UF2 does not repair a damaged or partially overwritten SoftDevice. This can happen after flashing an nRF52 image for a different board or SoftDevice layout. A characteristic failure is that the application can boot and read its filesystem, but reboots or hangs when it writes or formats internal flash. Repeating Erase Flash is not a recovery for that condition.

    Recover it with a combined bootloader + SoftDevice package for the exact board (including XIAO Sense versus non-Sense), installed through serial/BLE DFU or SWD. With adafruit-nrfutil, the serial form is:

    adafruit-nrfutil dfu serial -p <port> \\\n  -pkg <exact-board-bootloader-with-softdevice>.zip -sb -t 1200\n

    Then install the exact-board MeshCore application. Do not use an application- only UF2, filesystem formatter, or a similarly named package from another nRF52 board as a substitute. See the confirmed XIAO recovery in issue #3284 and obtain the matching package from the OTAFIX releases.

    Separately, starting in firmware version 1.7.0, there is a CLI Rescue mode. If your device has a user button (e.g. some RAK, T114), you can activate the rescue mode by holding down the user button of the device within 8 seconds of boot. Then you can use the 'Console' on https://flasher.meshcore.io

    "},{"location":"faq/#68-q-webflasher-fails-on-linux-with-failed-to-open","title":"6.8. Q: WebFlasher fails on Linux with failed to open","text":"

    A: If the usb port doesn't have the right ownership for this task, the process fails with the following error:

    NetworkError: Failed to execute 'open' on 'SerialPort': Failed to open serial port.

    Allow user access on your USB port:

    sudo setfacl -m u:$USER:rw /dev/ttyUSB0

    "},{"location":"faq/#7-other-questions","title":"7. Other Questions:","text":""},{"location":"faq/#71-q-how-to-update-nrf-rak-t114-seeed-xiao-companion-repeater-and-room-server-firmware-over-the-air-using-the-new-simpler-dfu-app","title":"7.1. Q: How to update nRF (RAK, T114, Seeed XIAO) companion, repeater and room server firmware over the air using the new simpler DFU app?","text":"

    A: The steps below work on both Android and iOS as nRF has made both apps' user interface the same on both platforms:

    1. Download nRF's DFU app from iOS App Store or Android's Play Store, you can find the app by searching for nrf dfu, the app's full name is nRF Device Firmware Update
    2. On https://flasher.meshcore.io, download the ZIP version of the firmware for your nRF device (e.g. RAK or Heltec T114 or Seeed Studio's Xiao)
    3. From the MeshCore app, log in remotely to the repeater you want to update with admin privileges
    4. Go to the Command Line tab, type start ota and hit enter.
    5. You should see OK to confirm the repeater device is now in OTA mode
    6. Run the DFU app, then tap Settings in the top-right corner
    7. Enable Packet receipt notifications, and change Number of Packets to 10 for RAK, 8 for T114. 8 also works for RAK.
    8. Select the firmware zip file you downloaded
    9. Select the device you want to update. If the device you want to update is not on the list, try enabling OTA on the device again
    10. If the device is not found, enable Force Scanning in the DFU app
    11. Tap Upload to begin OTA update
    12. If it fails, try toggling Bluetooth on your phone. If that doesn't work, try rebooting your phone. If you keep getting failures at the \"Enabling Bootloader\" step, try forgetting the nRF board in your iOS or Android device's Bluetooth settings and re-pair it through the DFU app.
    13. Wait for the update to complete. It can take a few minutes.
    14. It is strongly recommended that you install and use the OTAFIX bootloader at https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX.
    15. A companion must be running a build that exposes start ota; consult the current flasher release notes rather than relying on a version threshold in this FAQ.
    16. Please see the MeshCore Blog for additional information on OTA firmware flashing:
      • https://blog.meshcore.io/2026/04/06/otafix-bootloader
      • https://blog.meshcore.io/2026/04/02/nrf-ota-update
    "},{"location":"faq/#711-q-can-i-update-seeed-studio-wio-tracker-l1-pro-using-ota","title":"7.1.1 Q: Can I update Seeed Studio Wio Tracker L1 Pro using OTA?","text":"

    A: You can flash this safer bootloader to the Wio Tracker L1 Pro https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX

    After this bootloader is flashed onto the device, you can trigger an over-the-air update using Bluetooth by holding the button next to the D-Pad and then clicking the reset button. Then follow the same OTA update instructions above. You can skip the start ota instruction and start the update using the DFU app.

    "},{"location":"faq/#72-q-how-to-update-esp32-based-devices-over-the-air","title":"7.2. Q: How to update ESP32-based devices over the air?","text":"

    A: For ESP32-based devices (e.g. Heltec V3):

    1. On https://flasher.meshcore.io, download the current non-merged application image for the exact ESP32 board and role (no merged in the filename).
    2. From the MeshCore app, log in remotely to the repeater you want to update with admin privileges.
    3. Go to the Command Line tab, type start ota and hit enter.
    4. You should see OK to confirm the repeater device is now in OTA mode.
    5. If the ESP32 is not already joined to Wi-Fi, start ota starts an open hotspot named MeshCore-OTA at 192.168.4.1. If it is already joined, the command reports and uses its router-assigned address instead.
    6. From your phone or computer, connect to the MeshCore-OTA hotspot when one was started.
    7. Open the URL reported by start ota and upload the non-merged bin from the flasher. When the device started MeshCore-OTA, the URL is http://192.168.4.1/update.

    On an MQTT observer, stop WebConfig before running start ota; both servers use HTTP port 80. Likewise, stop the OTA uploader before running start webconfig.

    "},{"location":"faq/#73-q-is-there-a-way-to-lower-the-chance-of-a-failed-ota-device-firmware-update-dfu","title":"7.3. Q: Is there a way to lower the chance of a failed OTA device firmware update (DFU)?","text":"

    A: Yes, developer che aporeps has an enhanced OTA DFU bootloader for nRF52 based devices. With this bootloader, if it detects that the application firmware is invalid, it falls back to OTA DFU mode so you can attempt to flash again to recover. This bootloader has other changes to make the OTA DFU process more fault tolerant.

    Refer to https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX for the latest information.

    Currently, the following boards are supported:

    "},{"location":"faq/#74-q-are-the-meshcore-logo-and-font-available","title":"7.4. Q: Are the MeshCore logo and font available?","text":"

    A: Yes, it is on the MeshCore GitHub repo here: https://github.com/meshcore-dev/MeshCore/tree/main/logo

    "},{"location":"faq/#75-q-what-is-the-format-of-a-contact-or-channel-qr-code","title":"7.5. Q: What is the format of a contact or channel QR code?","text":"

    A:

    Channel: meshcore://channel/add?name=<name>&secret=<secret>

    Contact: meshcore://contact/add?name=<name>&public_key=<secret>&type=<type>

    Where &type is:

    "},{"location":"faq/#76-q-how-do-i-connect-to-the-companion-via-wi-fi-eg-using-a-heltec-v3","title":"7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?","text":"

    A: Flash an ESP32 companion Wi-Fi build. Its shared WebUI is enabled by default. If it has no saved Wi-Fi configuration, it starts an open MeshCore-Setup-XXXX access point. Join it and browse to http://192.168.4.1/. The wizard configures WiFi, node identity, and radio settings; MQTT companion builds add an MQTT step and MQTT tab, while non-MQTT companions remove those controls. After saving, the page and device screen show the station IP assigned by your router.

    If a saved network remains unreachable for two minutes, the setup AP starts as a fallback. If the original network returns, the companion closes that recovery portal and resumes normal LAN operation.

    The setup AP always uses 192.168.4.1; the companion's station IP is assigned by DHCP and can be different. A real WIFI_SSID and WIFI_PWD can still be supplied at compile time as initial defaults, but they are no longer required.

    On the LAN, the WebUI remains available at the companion's station IP and the companion protocol remains available on TCP port 5000. Companion builds do not have an admin CLI password, so this configuration page trusts the local LAN; do not put the companion on an untrusted WiFi network.

    ESP32 repeater and room-server builds use the same page, but keep it off by default. Run set webui on from the admin CLI, open the reported LAN URL, and sign in with the admin password; get webui reports its current address. Their MQTT observer variants expose the MQTT controls, while ordinary builds do not. The two 4 MB LilyGo_TLora_V2_1_1_6_*_observer_mqtt builds retain CLI-only configuration so their flash can keep two app slots for LoRa OTA.

    "},{"location":"faq/#77-q-i-have-a-station-g2-or-a-heltec-v4-or-an-ikoka-stick-or-a-radio-with-an-ebyte-e22-900m30s-or-an-ebyte-e22-900m33s-module-what-should-their-transmit-power-be-set-to","title":"7.7. Q: I have a Station G2, or a Heltec V4, or an Ikoka Stick, or a radio with an EByte E22-900M30S or an EByte E22-900M33S module, what should their transmit power be set to?","text":"

    A: For companion radios, you can set these radios' transmit power in the smartphone app. For repeater and room server radios, you can set their transmit power using the command line command set tx. You can get their current value using command line command get tx

    [WARN] WARNING: Set these values at your own risk. Incorrect power settings can permanently damage your radio hardware.

    Device / Model Region / Description In-App Setting (dBm) Target Radio Output Notes Station G2 Reference US915 Max Output 19 dBm 36.5 dBm (4.46W) US915 Max at 1dB compression point 16 dBm 35 dBm (3.16W) 1dB compression point EU868 Max at 1dB compression point 15 dBm 34.5 dBm (2.82W) 1dB compression point US915 1W Output 10 dBm 1W Refer to your local government's requirements EU868 1W Output 9 dBm 1W Refer to your local government's requirements Ikoka Stick E22-900M30S 1W Model 19 dBm 1W DO NOT EXCEED (Risk of burn out) data sheet Ikoka Stick E22-900M33S 2W Model 9 dBm 2W DO NOT EXCEED (Risk of burn out) data sheet Refer to your local government's requirements Heltec V4 Standard Output 10 dBm 22 dBm (~0.15W) High Output 22 dBm 28 dBm (~0.5W to 0.6W)"},{"location":"faq/#78-q-how-do-i-use-ethernet-with-a-rak4631","title":"7.8. Q: How do I use Ethernet with a RAK4631?","text":"

    A: MeshCore supports Ethernet on RAK4631 boards using the RAK13800 WisBlock Ethernet module (based on the W5100S chip).

    Hardware required: - RAK4631 WisBlock Core - RAK19007 or RAK19018 WisBlock Base Board (with an available IO slot) - RAK13800 WisBlock Ethernet module - Ethernet cable connected to a network with a DHCP server

    Firmware: Flash one of the Ethernet-enabled firmware variants: - RAK_4631_repeater_ethernet - Repeater with Ethernet CLI access - RAK_4631_room_server_ethernet - Room server with Ethernet CLI access - RAK_4631_companion_radio_ethernet - Companion radio over Ethernet (replaces BLE)

    Connecting: - The device obtains an IP address via DHCP automatically on boot. - For repeaters and room servers, connect to the device on TCP port 23 using any TCP client (e.g. nc <ip> 23 or PuTTY in raw mode). This gives you the same CLI available over serial/USB. - For companion radio firmware, the Ethernet interface replaces BLE as the transport to companion apps. Connect on TCP port 5000 (same as the WiFi companion radio). - Use the eth.status CLI command to check connection status and see the assigned IP address.

    "},{"location":"filter_tool/","title":"Filter policy playground","text":"

    Build a forwarding policy, see its readable definition, and simulate how a repeater handles a packet. Rules match received packet facts, then apply actions such as dropping, scoping, rate-limiting, or retrying a flood.

    Everything runs locally in this browser. Channel keys, packet facts, and policy drafts are not uploaded anywhere.

    Policy design preview

    The phases and core conditions model current FPF7 behavior, including its forward rows, scope rewrites, and shared blacklist. The readable policy language, JSON, and Base64 bundle are still a prototype: current firmware is configured with set flood.* commands and cannot install a bundle from this page.

    "},{"location":"filter_tool/#build-a-policy","title":"Build a policy","text":"

    Start with an example or build a rule, then test the draft against packet facts in the simulator below. The examples draw from Flood Filtering and Moderation.

    Remote login and direct routes

    This policy only decides whether a relay retransmits a flood. Direct packets and local delivery are unaffected. Rules matching the login/admin family can still reduce multi-hop remote-login reach, so the analyzer flags them.

    Example policies Choose one to load its full rules into the builder and draft.

    Read each summary as when all conditions match, do the actions. A match alone does not stop forwarding.

    Payload selectors type= valueMatches grp_dataOnly the named payload type; see every exact type below anyEvery payload type class:groupGRP_TXT and GRP_DATA class:loginREQ, RESPONSE, TXT_MSG, ANON_REQ, and PATH class:otherEvery remaining payload type that is not group or login Other conditions and actions FieldMeaning hops=Received hop count: all, 3+, 2-6, or 3 channel=* means no channel condition; a name or key authenticates one group channel; hash:XX is an unauthenticated one-byte fallback rx.scope=Original incoming transport scope path=Path prefix, blacklist, bucket, or loop match tempradio=Temporary-radio state do dropDo not retransmit do scope=Set the outgoing transport scope do rate=Apply a per-minute rate and burst do timing=Select fast, normal, or slow scheduling

    There is no class:txt. Use type=grp_txt for channel text or type=txt_msg for peer text.

    Set #BlackHole86 scope on all #rgdata group traffic when type=class:group hops=all channel=#rgdata do scope=#BlackHole86 timing=fast Add #BlackHole86 scope to unscoped #rgdata data when type=grp_data hops=all channel=#rgdata rx.scope=none do scope=#BlackHole86 timing=fast Replace #usa with #BlackHole86 on #rgdata data when type=grp_data hops=all channel=#rgdata rx.scope=scope:usa do scope=#BlackHole86 timing=fast Rate-limit packets whose path starts with 860C when type=any hops=all path=prefix:860C do rate=10/min burst=10 Drop selected flood types at their hop limits when type=control hops=1+ do dropwhen type=req hops=3+ do drop; same for type=grp_datawhen type=response hops=9+ do drop; same for type=anon_req and type=path Rate-limit Public messages from \u201cNoisy User\u201d when type=grp_txt hops=all channel=public sender=\"Noisy User\" do rate=5/min burst=5 Stop forwarding traffic from blacklisted internet gateways when type=any hops=all path=blacklist do drop Drop OTA outside temporary-radio mode and distant #wardriving when type=ota hops=all tempradio=inactive do dropwhen type=any channel=#wardriving hops=5+ do drop Set #BlackHole86 scope on login and bucket-matched other traffic when type=class:login hops=all do scope=#BlackHole86 timing=fastwhen type=class:other hops=all path=bucket:2 do scope=#BlackHole86 timing=slow

    Rule builder

    Reset form Common match settings Payload type or class Any payload type Class: group text and data Class: login/admin family Class: every other payload REQ RESPONSE TXT_MSG ACK ADVERT GRP_TXT GRP_DATA ANON_REQ PATH TRACE MULTIPART CONTROL OTA Reserved type 13 Reserved type 14 RAW_CUSTOM Received hops Channel (optional) Original incoming scope Path matcher No path condition Ordered 1/2/3-byte pbyte prefix Passive blacklist Path bucket 1 Path bucket 2 Path bucket 3 Path bucket 4 Path bucket 5 Path bucket 6 Own-ID loop: strict Own-ID loop: moderate Own-ID loop: minimal Ordered pbyte prefix Decrypted sender (optional) Temporary-radio state Either state Inactive Active Common actions

    Choosing a phase-specific action automatically selects a compatible phase and ACL owner. Open the advanced section to inspect or override those choices.

    Forwarding verdict No verdict change Drop / do not retransmit Region-gate decision Leave global gate unchanged Require original allowed region Bypass global gate for this packet Scope rewrite target Do not rewrite scope Regionless public scope Configured region Target name Token rate per minute (optional) Burst tokens Rewrite timing Inherit normal behavior Fast-track rewrite Normal queue timing Slow receive/transmit timing Queue priority Inherit High Normal Low Retry path bucket No retry action Bucket 1 Bucket 2 Bucket 3 Bucket 4 Bucket 5 Bucket 6 Retry attempts Decision tag (optional) Advanced execution, ownership, and flood route

    Direct packets already carry a supplied path and are intentionally outside this engine. The route condition below only distinguishes unscoped floods from transport-scoped floods.

    Stable rule ID Processing phase 1 - Incoming scope gate 2 - Scope rewrite 3 - Forwarding decision 4 - Decrypted content 5 - Scheduling and retry Rule owner / capability ACL 4 - Region/scope manager ACL 5 - Filter manager Administrator Firmware-managed system rule Priority Mode Active Shadow - observe only Disabled Rule processing after a match Continue to later rules Skip later rules in this phase Skip all later policy rules Flood route Either flood route Unscoped flood only Transport-scoped flood only Readable policy definition

    Add rule to policy Copy definition

    Policy draft

    Clear policy Approximate target budget Compact STM32 - 2 KB nRF52 - 8 KB Classic ESP32 - 16 KB Roomy ESP32 - 64 KB

    Rules match the same immutable packet facts. Ordering is phase, then descending priority, then authenticated channel, raw hash, or wildcard specificity, then stable rule ID. Drop decisions are sticky.

    Add a rule or load an example to start exploring.

    Immutable packet simulator

    Reset packet Read a raw MeshCore packet

    Paste an on-air packet as hexadecimal. The decoder follows the MeshCore wire format, displays its header, route, pbyte path, and clear payload envelope, then loads only facts actually present on the wire into the simulator.

    Runs locally Raw packet hex 014e912ceebb98918b86772df5dacf1bcba9e4127ffffaa8665596aa3e2903a3b1901fdc53497dfca6b5d7df2d771bea68de Decode and load packet Received route Unscoped flood Transport-scoped flood Payload type REQ RESPONSE TXT_MSG ACK ADVERT GRP_TXT GRP_DATA ANON_REQ PATH TRACE MULTIPART CONTROL OTA Reserved type 13 Reserved type 14 RAW_CUSTOM Received hops Channel Received pbyte path Original scope status Unscoped Allowed region Unknown or denied scope Original scope name Resolved region name Decrypted sender Passive blacklist result No match Matched Matching path buckets Own-ID loop result No loop match Strict only Moderate and strict Minimal, moderate, and strict Temporary radio is active Run policy simulation

    Import and explain

    Accepts one-line policy set ... when ... do ... definitions, playground JSON, or a playground Base64 bundle.

    Explain input Load into builder

    Export

    Readable policy Policy JSON Playground Base64

    Proposed human-readable definition. Current firmware does not accept it yet.

    Structured draft used by this page.

    Browser-playground interchange only. It is not the final packed firmware codec.

    Copy visible export Download JSON"},{"location":"filter_tool/#filter-builder-title","title":"Create a policy rule","text":""},{"location":"filter_tool/#filter-policy-title","title":"Rules in execution order","text":""},{"location":"filter_tool/#filter-simulator-title","title":"Explain an evaluation","text":""},{"location":"filter_tool/#filter-import-title","title":"Paste readable policy or a saved draft","text":""},{"location":"filter_tool/#filter-export-title","title":"Move or save this design","text":""},{"location":"filter_tool/#payload-type-reference","title":"Payload type reference","text":"

    Use an exact type= value when a rule should match only one payload type. The class column shows which broader selector also matches it.

    type= value Payload Class req Request class:login response Response class:login txt_msg Peer text message class:login ack Acknowledgment class:other advert Node advertisement class:other grp_txt Group-channel text class:group grp_data Group-channel datagram class:group anon_req Anonymous request class:login path Returned path class:login trace Path trace class:other multipart One frame in a multipart sequence class:other control Control or discovery data class:other ota OTA-over-LoRa data class:other 13 Reserved payload type 13 class:other 14 Reserved payload type 14 class:other raw_custom Application-defined raw data class:other"},{"location":"filter_tool/#current-fpf7-command-mapping","title":"Current FPF7 command mapping","text":"Firmware command FPF7 role flood.rule / flood.filter Forward-phase match and action rows flood.channel.data Compatibility view over one visible type=grp_data forward drop row flood.channel.scope Scope-rewrite phase rows flood.filter.blacklist One shared unordered path-ID set referenced by path=blacklist rows

    Generalized repeaters expose 63 forward rows and commit those sections together. The blacklist is useful for refusing to retransmit floods associated with internet gateways dumping bulk traffic, but a path ID is truncated and unauthenticated; it identifies a routing pattern, not a person.

    "},{"location":"filter_tool/#proposed-evaluation-contract","title":"Proposed evaluation contract","text":"

    The simulator uses these rules:

    1. Only flood retransmission enters the policy. Direct routing and local packet delivery remain outside it.
    2. Every matcher reads the same immutable receive-time packet facts.
    3. Rules run by phase and descending numeric priority. At an equal priority, authenticated channel matches run before raw one-byte hash matches, which run before wildcard channel matches; stable rule ID breaks the remaining tie. An explicitly higher numeric priority overrides channel specificity.
    4. A drop decision is sticky and cannot be undone by a later rule.
    5. The first matching scope, timing, queue, and retry action in execution order wins.
    6. All matching token-bucket rate constraints remain attached to the decision.
    7. stop=phase skips later rules in that phase. stop=policy skips later configurable rules, but never mandatory packet validation or radio safety.
    8. Shadow rules report what they would do without changing the decision or stopping other rules.
    9. Expensive facts such as channel-key matching, decryption, and path-table lookup are resolved once per packet and reused by every matching rule.

    The byte-budget display is deliberately approximate until the packed firmware codec exists. It demonstrates why simple mappings should not reserve a maximum- sized structure for every possible condition and action.

    "},{"location":"firmware_memory_budget/","title":"Firmware memory checks","text":"

    Every firmware environment runs scripts/check_firmware_ram.py against its linked ELF before producing or uploading an image. build.sh, including option 3, also requires a passing report before collecting release files. Native host tests do not use a microcontroller RAM budget.

    The check reserves room for enabled runtime allocations as well as static data. A firmware image fitting its board's reported RAM total is insufficient: the display, packet pool, USB, Bluetooth workers and WiFi can allocate after startup. The T096 Full 1.17.1.5 report exposed this distinction.

    "},{"location":"firmware_memory_budget/#what-is-counted","title":"What is counted","text":"Platform Source of available runtime RAM nRF52 Actual __HeapBase and __HeapLimit; excludes SoftDevice, retained state, ISR stack and the dedicated 64 KiB mOTA arena where present ESP32, S3, C3, C6 Linked ESP-IDF memory-region, capability and reservation tables; only internal, byte-addressable heap counts RP2040/RP2350 __end__ to __HeapLimit, according to the selected linker STM32 _end to _estack, minus _Min_Stack_Size

    ESP32 PSRAM, instruction-only RAM and RTC RAM never increase the internal budget. On chips other than classic ESP32, the late-reclaimed ROM stack region is excluded because its silicon-specific reservations are only known at boot. Classic ESP32 additionally retains its existing 8 KiB static DRAM check.

    The policy adds allowances for task stacks, radio packet pools, screen objects and pixel buffers, filesystem/sensor allocations, enabled wireless stacks, MQTT connections, OTA scratch and transient allocations. A 160x80 ST7735 framebuffer needs 25,602 bytes; an OLED allowance is 4 KiB. nRF52 Full with that color framebuffer must have at least 72 KiB available before startup allocations. Headless and OLED devices use their own smaller totals. The JSON lists each component and checks the largest available region against the largest planned single allocation.

    The small-screen message layout retains complete 160-byte messages. Its expanded preview records add 2,816 bytes to the startup allowance and increase the contiguous history allocation budget. The V4 can allocate that history in PSRAM; the guard conservatively reserves internal capacity so that PSRAM availability cannot hide a RAM shortage.

    These are engineering allowances for supported configurations, not measured free heap after boot or a guarantee against every future allocation failure. Unknown platforms, unknown display drivers and missing linker metadata fail closed. MESH_MIN_RUNTIME_HEAP can raise a profile's requirement; it cannot lower the calculated requirement. Add an allocation allowance when adding a display, transport or other substantial feature.

    Wireless Paper Full keeps 350 contacts and 256 offline frames by lending the upper 128 queue slots to its mOTA workspace during a session. An idle WiFi mOTA listener leaves all 256 slots available. More than 128 unread frames refuses the loan; sync messages with an app first. USB/TCP source detach or disconnect returns all 256 slots and releases the ESP32 proof/leaf scratch buffers. The display and simultaneous USB, Bluetooth and WiFi remain enabled.

    "},{"location":"firmware_memory_budget/#release-evidence-and-regression-tests","title":"Release evidence and regression tests","text":"

    Full Companions without PSRAM also use 16-entry path and shared-secret caches. The optional NimBLE capacity trials retain the same RAM guards at 350 contacts and 256 normal offline frames.

    Each newly built firmware has a matching .memory.json report. It records the linked ELF SHA-256, available internal RAM, required RAM, largest region, and SHA-256 hashes for the actual firmware files and capability manifest. Packaging and resumed builds reject absent reports, failures, stale ELFs, missing files and changed firmware. Do not reuse a report for another build.

    Run PlatformIO commands sequentially in this checkout:

    python3 -B test/test_firmware_ram.py\npython3 -B test/test_t096_full_memory.py\npython3 -B test/test_nrf52_ble_startup.py\npython3 -B test/test_shared_mota_queue.py\npython3 -B test/test_cascade_release_package.py\npio test -e native -f test_ota\n

    Tests cover all resolved firmware environments' hooks, real ELF parsing, allocator table formats, excluded memory, allocation failure, package/report binding and the published T096 failing budget. Shared mOTA tests exercise complete transfers, queue wraparound, unread-message order, source ownership, stop/disconnect and repeated reuse. Bluetooth tests inject task and service startup failures. The manual staging buffer also has allocation-failure and repeated release tests. ESP32 tests run the actual WiFi mOTA listener and source framing through complete transfers, idle polling, queue-full refusal, network loss, CLI detach and USB/TCP ownership changes under address/leak sanitizers.

    For older releases without saved ELFs, an audit can compare their ESP allocator tables against a matching pinned SDK ELF and read reservations from the published application itself using scripts/audit_esp32_image_ram.py. Unrecognized layouts require another matching reference or a historical rebuild. An audit must identify original-log/linker calculations separately from new ELF checks and verify the published firmware hashes.

    Physical validation remains necessary: boot with and without USB, pair and exchange Bluetooth messages, visit every screen, wake with the button, enable logging/MQTT, transfer mOTA, and monitor heap during a sustained workload. See memory monitoring for runtime diagnostics.

    "},{"location":"firmware_picker/","title":"Firmware picker","text":"

    Each result now includes Restore your settings after flashing. Its commands follow the selected logging mode and the exact image's verified role/hardware controls. Select On, Off, or Check to view and copy the commands; Companion MQTT and GPS show their app/WebConfig steps instead. See feature switches by role for the full reference. The USB web console works with the default ASCII terminal on Full Companion and infrastructure roles.

    Pick the choices in any order. Every selection narrows all the other controls to firmware combinations that were actually built in the current release set. The optional chip-family filter (ESP32, nRF52, RP2040, or STM32) can narrow the hardware list first. You can skip it: picking hardware fills it in automatically.

    The picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly from the published firmware assets.

    Current release set

    Loading release information...

    For a new installation, choose the exact board and role, prefer a FULL / complete profile when it is available, and select Full install / layout migration (merged .bin). Narrower profiles remain available when their reduced transport or feature set is intentional.

    Pick in any order. Use Any to clear one choice, or clear everything with the button below. The address bar updates with your choices; copy its URL to reopen or share the same selection.

    Optional: chip family

    Skip this if you know your board. Picking hardware selects its chip family automatically. Choose Any to clear this filter.

    Chip family Any chip family - skip this filter Hardware Loading hardware... Hardware variant Choose hardware first Install operation Loading install choices... Firmware role Loading roles... Logging / MQTT Loading logging choices... OTA capability Loading OTA choices... Connection / bridge mode Loading modes... Feature profile Loading profiles... Firmware variant Loading variants... Clear all choices Copy link to settings Link to these settings

    Loading the current firmware catalog...

    Exact firmware match

    Recommended download

    No exact firmware matched

    Browse all firmware releases Advanced: search current release filenames

    Use this for uncommon board suffixes or expert recovery. A filename match is not a board-identity check.

    Filename contains"},{"location":"firmware_picker/#what-the-choices-mean","title":"What the choices mean","text":"Choice Use Companion A phone, computer, or host application controls the radio Repeater Standalone mesh relay Room Server Hosts room conversations and history Sensor / telemetry Publishes supported sensor data Terminal Chat Standalone serial-terminal interface USB logging / USB-connected MQTT Node remains attached to a computer over a data-capable USB cable Wi-Fi MQTT observer Firmware connects directly to MQTT over Wi-Fi; this is not USB logging USB logging + Wi-Fi MQTT Unified FULL image sends to both paths; avoid two publishers aimed at the same broker unless messages are deduplicated No logging Normal standalone operation without the dedicated logging/MQTT profile LoRa OTA repeater Repeater profile that can stage an exact matching update received over LoRa LoRa OTA source only Full Companion serving a host-supplied update to another node without self-installing it

    Connection and bridge choices depend on the selected role. Companion firmware may offer Full, combined USB + Bluetooth, Bluetooth, USB, Wi-Fi, serial, or Ethernet transports. Normal repeater firmware includes runtime-controlled RS-232 support where the board has room; use set bridge.enabled on after configuring bridge.uart and bridge.baud. The Wio-E5 remains the capacity exception and offers a separate RS-232 image. Choose Wi-Fi MQTT under Logging / MQTT; it is an output mode, not a second connection choice. Repeaters may still offer separate ESP-NOW or Ethernet bridge firmware because those paths use different compiled drivers.

    An ESP-NOW bridge target keeps LoRa as its primary mesh radio. Its runtime bridge.format setting chooses the peer protocol: wrapped (the backward-compatible bridge-to-bridge default using bridge.secret) or raw (direct MeshCore ESP-NOW LR frames for Generic_ESPNOW, SenseCapIndicator-ESPNow, and other primary-ESP-NOW nodes). This is one firmware choice, not two board images. Match bridge.channel to the primary nodes' espnow.channel before selecting set bridge.format raw. For Heltec V4 specifically, companion_radio_full is still a LoRa-primary Companion; choose the existing heltec_v4_repeater_bridge_espnow firmware to make that board the LoRa/ESP-NOW gateway. Use its exact merged artifact when changing roles or partition layouts.

    "},{"location":"firmware_picker/#share-a-selection","title":"Share a selection","text":"

    Select any combination of choices, then copy the address bar or use Copy link to settings. Opening the link restores those choices after the release catalog loads. Partial selections work too, so you can share a board and role while leaving other choices open. Clear all choices removes the picker parameters from the URL. Changes update the current browser-history entry without reloading the page or adding a Back-button entry for every click.

    For example, RAK3401 repeater with internal storage preselects that board, role, OTA capability, and storage profile.

    The query parameters are chipFamily, hardwareFamily, hardware, role, logging, ota, mode, feature, variant, and install. Values use the picker's internal identifiers rather than the displayed labels. The generated link also records chipAuto so automatic chip-family selection or an explicit Any choice behaves the same after reopening. Existing section anchors and unrelated query parameters are preserved.

    Links use the current release catalog. If a linked choice is no longer available or conflicts with another choice, the picker identifies it and asks you to review the remaining selections. The downloadable HTML's Copy link to settings button creates a public website link that other people can open.

    "},{"location":"firmware_picker/#full-versus-standard","title":"FULL versus standard","text":"

    For a new installation, use the FULL / complete profile when it exists and the board has enough flash. FULL profiles keep the complete supported feature set and CLI. Standard profiles remain useful for boards without a FULL build, for an intentionally narrower transport, or when retaining an existing compatible partition layout.

    Changing between standard and FULL ESP32 layouts requires the exact-board merged image over USB. A running application cannot safely move its own active and inactive partitions.

    Current full-usb-wifi profiles use one binary for no external output, USB packet logging/USB-connected MQTT, direct WiFi MQTT, or both. The picker shows that same exact binary for each compatible logging choice; select the saved runtime mode with set logging.output off|usb|wifi|both. A FULL logging-fallback profile is listed only when no WiFi MQTT sibling exists; it appears for both the no-output and USB choices because set usb.logging off|on is persistent. On a fresh unified FULL install with no saved SSID, the setup AP and WiFi radio remain available for 30 minutes per boot, then turn off automatically until the next reboot or power cycle. An explicit administrator start webconfig remains available as an override. A saved SSID switches to the normal indefinite reconnect behavior instead.

    Full Companion profiles use one binary for USB, BLE, ordinary Wi-Fi on ESP32, source-only LoRa OTA, Terminal Chat, optional USB packet logging, and any board-qualified serial or Ethernet Companion transport. Bulk builds therefore omit separate attached-transport, Terminal Chat, and USB-logging artifacts whenever the exact Full recipe exists. RAK4631 repeater and room-server Ethernet images remain separate roles. Fresh installs default to logging off. Heltec V3 and base OLED V4 Full images also contain the former direct Wi-Fi MQTT Companion capability, configured at runtime through WebConfig, so their separate companion_radio_wifi_mqtt artifacts are omitted from canonical builds as well.

    When Full Companion does not fit but a matching USB Companion does, that USB artifact also supplies Terminal Chat and replaces its standalone release image. Heltec E290 and T190 now publish one Full USB + BLE + WiFi Companion; their old combined and single-transport names are explicit-build compatibility aliases. SSD1306 Full Companion builds use set display.rotation 90|180|270; 0 restores the board default, so a separate rotated release image is not recommended.

    Ordinary non-OTA roles also use one artifact for normal operation and USB logging. On ESP32 1.17.1.5, run set powersaving off before set usb.logging on. Select the saved mode with set usb.logging off|on; no -logging- artifact is emitted. KISS, BLE-only Companion, and constrained LoRa OTA repeater images retain their protocol/partition contracts and do not inherit plaintext USB logging.

    nRF52 Full Companion keeps the multi-role primary interface on 00; it starts as an ASCII terminal and automatically hands a complete < frame to Binary Companion. set usb.logging on reboot adds its plaintext interface 02.

    Every ESP32 Full Companion instead exposes one USB TTY. Logging is off by default, so the TTY serves the ASCII/Binary Companion switcher. On 1.17.1.5, run these two text commands to enable USB logging:

    set powersaving off\nset usb.logging on\n

    The second command turns that same TTY into an input-capable plaintext CLI/logging stream; framed Binary Companion is unavailable on USB while logging owns it. set usb.logging off stops the logs and leaves the TTY in the normal ASCII terminal, matching a fresh Full installation. Send +++MESHCORE-TERM-STOP, or let a Companion app send a valid framed probe, to switch from there to Binary Companion. BLE and Wi-Fi Companion remain usable while the USB TTY is logging. ESP32 Full builds use the repository's Arduino-ESP32 2.x base where the board supports it; RC32 and ESP32-C6 retain their board-required Arduino 3.x platform but still expose only one USB TTY. A second ESP32 CDC interface is not part of the release profile.

    The picker includes the power-saving workaround when selecting USB or USB + WiFi logging on ESP32 1.17.1.5. WiFi/MQTT-only logging does not need it while the Repeater/Room Server bridge is running; check get mqtt.running. The workaround is not added to nRF52 directions. See logging by role for the saved settings and the original firmware's USB sleep issue.

    "},{"location":"firmware_picker/#installation-methods","title":"Installation methods","text":"File Use -merged.bin Erase/fresh install, recovery, role migration, or partition-profile change on ESP32 over USB Non-merged .bin Update an existing same-board, same-role, same-partition installation .zip Native nRF52 Serial DFU update package; it is not an extra archive .uf2 UF2 bootloader drag-and-drop install or update .hex Erase/recovery flash with a supported wired programmer

    Never send a merged ESP32 image through browser OTA or LoRa OTA. Back up the node configuration and verify every filename suffix before flashing.

    "},{"location":"firmware_picker/#lora-ota-and-otafix","title":"LoRa OTA and OTAFIX","text":"

    A LoRa OTA repeater build installs repeater firmware that can receive and stage updates. A later LoRa update still needs an exact target identity, compatible partition signature, matching radio settings, and the correct update package.

    nRF52 LoRa OTA requires an OTAFIX bootloader built for the exact board. There is no universal bootloader file. Use the OTAFIX 2.4.6 for this release and select the hardware-matched HEX, Serial DFU ZIP, or bootloader-update UF2.

    "},{"location":"firmware_picker/#hardware-and-variant-names","title":"Hardware and variant names","text":"

    Hardware families with multiple released targets get a second hardware-variant menu. It separates revisions, display type, expansion kit, radio/PA layout, pin map, and other physical differences without crowding the first menu. The firmware-variant menu separately exposes choices that still require different code or wiring, such as serial port or the legacy no_external_sensors target suffix. For RAK3401 and RAK4631, the picker labels that compact LoRa OTA option Internal storage (no external storage board). Its result details explain the omitted optional environmental/ranging sensor drivers; the reduction does not disable generic I2C or unrelated board-integrated peripherals. Other boards retain the Reduced optional environmental/ranging drivers label. Reduced RAK3401 and RAK4631 targets retain INA219/INA226/INA260/INA3221 as voltage/current entries in the optional sensor table. They are not the only I2C users: the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals in compatible recipes. RAK12501/L76K GPS uses Serial1 instead. The explicit RAK4631 Serial1 bridge omits the combined GPS provider because its bridge owns the RAK12501 UART, so that legacy image does not expose RAK12500 either. The firmware-configured INA3221 and RAK12500 addresses are both 0x42; to install both, keep RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use a build with -DTELEM_INA3221_ADDRESS=0x43. Companion power saving, controllable FEM receive gain, and radio-chip receive gain are saved settings rather than separate recommended firmware files. Do not substitute a similarly named physical target.

    The header-wired RAK19007 W25Q16 LoRa-OTA recipes are also exact hardware variants, labeled External storage board (W25Q16) in the picker. W25Q16 is the flash-memory part on the added storage board; RAK13302 identifies the radio module, not the storage board. Choose this variant for RAK4631 or for RAK3401 + RAK13302 only when that core/radio combination, the EF4015 flash wiring, and its matching OTAFIX bootloader are all present. The common base-board wiring does not make the two firmware or bootloader identities interchangeable.

    The picker recommends one Full Companion image instead of separate USB, BLE, ordinary WiFi, and USB-logging images. On ESP32, logging is off by default so the one USB TTY starts in ASCII and automatically changes to framed Companion when a complete < frame arrives. Enabling logging gives that TTY to the plaintext CLI/logger and disables framed USB Companion until logging is turned off and the normal ASCII-to-binary mode switch occurs. nRF52 Full Companion retains its optional dedicated interface 02. Exact filename search still finds old aliases from earlier releases.

    "},{"location":"firmware_picker/#maintaining-the-runtime-directions","title":"Maintaining the runtime directions","text":"

    The online picker and downloadable HTML use the same command renderer. Hardware-specific controls are enabled only when _data/firmware_controls.json matches the selected release family and exact target. If that metadata is missing or belongs to another release, the picker retains basic role/logging directions and links the complete guide without inventing hardware support.

    After qualifying a new release, resolve its PlatformIO configuration with no other PlatformIO process running, then generate the controls from that source revision and its staged manifests:

    pio project config --json-output > /tmp/meshcore-picker-pio-config.json\npython3 scripts/generate_picker_controls.py \\\n  --stage /path/to/staged-release \\\n  --pio-config /tmp/meshcore-picker-pio-config.json\n

    For the downloadable version, save the release family's public GitHub release objects as a JSON array, then package the same picker UI and controls:

    python3 scripts/package_firmware_picker.py \\\n  --releases-json /path/to/releases.json \\\n  --output /path/to/FIRMWARE-PICKER.html\n

    This HTML embeds its catalog and directions, so selections work without an internet connection. Firmware downloads and the USB web console still need network access. When replacing the downloadable picker on release pages, update its entry in each page's SHA256SUMS.txt as well.

    "},{"location":"firmware_picker/#memory-corrected-11715-downloads","title":"Memory-corrected 1.17.1.5 downloads","text":"

    Corrected downloads retain the 1.17.1.5 release page and use source suffix aa20e927, with 1e4d1e16 for Wireless Paper Full's 350-contact follow-up. The picker accepts replacement source hashes within that exact release version. Its installation directions identify the nRF52 queue-sharing behavior, Wireless Paper Full's 350 contacts with a shared 256/128-slot queue, and the 150-contact limit on the other six affected ESP32 Full profiles. Those notices apply only to the corrected files. See the memory correction details and each replacement's .memory.json report before updating.

    "},{"location":"flood_filtering/","title":"Flood Filtering and Moderation","text":"

    This guide explains the Keymind forwarding filters. Repeaters expose the full set of channel, rule, blacklist, and moderation controls described here. FULL-profile ESP32 room servers expose the generalized flood.rule table (and its flood.filter alias) with 31 forward-rule slots, but not the repeater's scope-rewrite, passive-blacklist, or text-moderation phases. Standard room-server profiles do not compile the rule table. Filters decide whether the node retransmits a packet and can assign a transport scope before that decision. They do not stop local reception, packet logging, or MQTT observation.

    Only flood routes are filtered:

    Direct routes 0x02 and 0x03 are never affected by these rules. The route and payload values follow the upstream packet-format reference and payload layouts, with this fork's LoRa OTA assignment noted below.

    "},{"location":"flood_filtering/#automatic-flood-advert-limits","title":"Automatic flood-advert limits","text":"

    Repeaters, room servers (including non-FULL builds), and forwarding sensors automatically limit relayed ADVERT packets. No CLI rule is needed. Companions and the repeater's receive-only MQTT observer do not allocate this history. This is separate from discovery-response limits, self-advert timers, hop caps, and the per-rule rate=N/min setting below.

    The ordinary quota is shared by the first 12 public-key hex characters (six bytes of the advertised origin key, not a path hash). Each prefix has a fixed 180-minute window starting with its first tracked valid flood advert.

    Shortest received hops in that window Distinct adverts forwarded per 180 minutes 0\u20131 10 2 9 3 8 4 6 5 5 6 4 7 3 8+ 2

    The minimum received hop count, before adding this relay, sets the quota; one-, two-, and three-byte path hashes have the same policy. A shorter valid duplicate may increase the quota without restarting the window or clearing its count. Longer routes never decrease the allowance during that window. The payload signature must verify before any history changes. Path metadata itself is not signed, so this is a received-distance heuristic, not proof of physical distance.

    "},{"location":"flood_filtering/#continuing-abuse-and-recovery","title":"Continuing abuse and recovery","text":""},{"location":"flood_filtering/#inspect-the-current-limits","title":"Inspect the current limits","text":"

    Use get flood.advert to list rate-limited origins, three per page. Continue with get flood.advert 2, etc. Rows show a 12-hex public-key prefix, the reason (quota, history, or bad), and the wait before another new advert is eligible under the automatic limiter. get flood.advert key 1 shows the full key and counters for row 1, including the remaining bad-list recovery time. A bad-listed key remains visible with wait=0s when its next single forward is due. Prefix collisions appear as separate full-key entries.

    This is a live, read-only view: expiry can change row indices between queries, but reading never resets quotas or extends recovery. Sources rejected solely because the protected-history table is full are not retained, so they cannot be listed. See the CLI reference.

    "},{"location":"flood_filtering/#clear-history","title":"Clear history","text":"

    All timers use rollover-safe uptime, so setting the clock forward or backward cannot clear the restriction. This history is currently RAM-only: rebooting the relay clears it, and time while powered off is not tracked. A manual clear also resets both the ordinary quota and abuse history for its selected target:

    clear flood.advert all\nclear flood.advert <64-hex-full-public-key>\n

    These use the ordinary local/admin CLI authorization, including authenticated admin LoRa CLI. A prefix is never accepted for a targeted clear; missing or invalid selectors do not clear anything. Clearing an exact key preserves other full keys even when their 12-hex prefixes match. No preferences, contacts, neighbours, replay timestamps, or manually configured flood rules are erased.

    "},{"location":"flood_filtering/#scope-and-resource-bounds","title":"Scope and resource bounds","text":"

    Only forwarding is suppressed. Valid adverts remain available to local contact/neighbour handling and can still appear in raw RX logs or MQTT. This cannot prevent the origin or upstream repeaters from transmitting. Direct adverts, discovery control responses, messages, OTA packets, and this node's own scheduled adverts are not charged. Existing physical retry attempts of an admitted advert are not additional distinct adverts.

    The limiter retains up to eleven 64-bit payload hashes per full key per window: ten possible ordinary admissions plus evidence of exceeding the largest quota. Paths and transport scopes do not change these hashes. Retained duplicate hashes cannot consume additional quota even if the general packet-seen cache has evicted them. Once the eleven distinct receive slots are full, additional unretained payloads are suppressed until the next window. Only packets passing all ordinary forwarding gates consume forwarding quota; valid received traffic still supplies abuse evidence when another gate blocks it.

    The default is 128 full-key slots (18,432 bytes of entry storage plus small bookkeeping), 96 on nRF52 (13,824 bytes, preserving the mOTA runtime RAM reserve), or eight on RAM-constrained STM32 builds (1,152 bytes). FLOOD_ADVERT_SOURCE_SLOTS overrides the capacity at compile time. When full, the least-recently-heard normal key is removed first. Valid retained duplicates update last-heard too. Evicting a normal key discards its ordinary quota history, so capacity should still be sized for the deployment's active origins. First-strike evidence and bad-list entries are protected from eviction: if every slot protects abuse history, untracked origins are not forwarded until a slot expires or an administrator clears history. Table churn cannot erase an existing bad-list penalty. No heap allocation or per-packet filesystem writes are used.

    "},{"location":"flood_filtering/#before-making-changes","title":"Before making changes","text":"

    On a repeater, show the current forwarding controls:

    get repeat\nget flood.max\nget flood.max.unscoped\nget flood.max.advert\nget flood.channel.data\nget flood.channel.data.hops\nget flood.channel.scope\nget flood.channel.scope.require\nget flood.filter\nget flood.rule\nget flood.filter.blacklist\nget flood.moderation\n

    flood.rule is an alias for flood.filter, not another table. Generalized repeater FPF7 has 63 forward-rule slots plus scope-rewrite and shared-blacklist sections in the same atomic policy file. FULL room servers have 31 forward slots and empty repeater-only sections. Compact target profiles retain their separate FPF6-era controls. flood.moderation has 16 slots. A new repeater FPF7 table starts with ota all suspend=tempradio in slot 1 and an authenticated #wardriving hops=5+ drop in slot 2; FULL room servers seed only the OTA row. flood.moderation starts empty. A row can opt into suspend=tempradio; temporary radio is not synonymous with OTA and can carry normal packet types too. A corrupt or truncated table fails open, so corrupt storage does not silently enable blocking.

    On a FULL ESP32 room server, use get flood.rule (or get flood.filter) for the available table. Remote rule changes require room-server administrator access. flood.filter.blacklist* and path=blacklist are repeater-only; use the ordered prefix= condition on a room server.

    "},{"location":"flood_filtering/#force-floods-into-a-transport-scope","title":"Force floods into a transport scope","text":"

    flood.channel.scope can add a scope to a received unscoped flood or replace the scope of a transport-scoped flood before this repeater forwards it:

    set flood.channel.scope <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]\nset flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]\nget flood.channel.scope\nget flood.channel.scope.<slot>\ndel flood.channel.scope.<slot>\ndel flood.channel.scope all\n

    The channel may be public, #channel, or a 128/256-bit hex key. A bare target names an existing region with a usable transport key. Use scope=<name> instead to derive a regionless public hashtag scope exactly as flood.filter scope=<name> does. The direct name is normalized with a leading #, may contain up to 30 characters, and does not need a region-list entry. Keyed rules first check the one-byte channel hash carried in the packet, then validate the MAC by decrypting with the configured channel key. A hash collision alone cannot force a scope.

    For example, this authenticates only #rgdata and rewrites it to #BlackHole86 without creating a region:

    set flood.channel.scope #rgdata scope=BlackHole86\nget flood.channel.scope.1\n

    If that channel arrives scoped to #usa, the rule replaces #usa with #BlackHole86. It also handles unscoped packets and replaces any other incoming scope; the source scope is not a condition on the rule.

    Add path=blacklist to make a channel-scope row eligible only when the received path matches the passive flood.filter.blacklist ID table. It does not require an enabled flood.filter drop row. With 3-byte paths, one exact listed ID qualifies. With 2-byte paths, two received path entries must match the first two bytes of listed IDs. A 1-byte path never qualifies.

    Use path=bucket:<1-6> to match one of the existing flood.retry.bucket tables instead. Each bridge bucket holds up to 17 three-byte IDs and remains usable by channel scoping while flood.retry.bridge is off. Bucket matching uses the same thresholds as the blacklist: one exact hit for 3-byte paths, two qualifying entries for 2-byte paths, and no matches for 1-byte paths. Channel scoping reads the configured IDs directly; recent.repeater freshness and flood.retry.ignore do not change this match.

    There are three independent wildcard classes:

    login:* and other:* classify only the visible outer payload type; they do not authenticate its contents. Exact channel rows with usable targets always take precedence over txt:*, even if that wildcard has a lower slot number. Within the exact class, matching path-qualified rows are tried before ordinary fallback rows. The same qualified-then-fallback order applies within each wildcard class. A missing or unusable target is skipped, so later rows remain eligible. The lowest usable slot wins within each priority tier.

    For example, this uses bridge bucket 1 to assign east to public packets whose received 3-byte path contains 7576FB, and assigns west to all other authenticated public packets:

    set flood.retry.bucket 1 7576FB\nset flood.channel.scope public west\nset flood.channel.scope public east path=bucket:1\n

    More 3-byte IDs can be added to bucket 1 later. Any one of them qualifies the east row. Replacing or clearing that bucket changes which paths qualify but leaves both channel-scope rows intact. Bridge retry does not need to be enabled.

    On a successful match, an unscoped route changes from ROUTE_TYPE_FLOOD to ROUTE_TYPE_TRANSPORT_FLOOD; an already-scoped route remains transport-flood but receives replacement codes. Transport code 0 is calculated with the target region or direct hashtag key over the payload type and payload, and code 1 becomes zero. The change happens before region enforcement, forwarding filters, and deduplication. Consequently flood.max.unscoped no longer applies to a packet converted from unscoped, while flood.max, flood.filter, loop detection, and moderation still apply to every rewritten packet. A region target must be flood-allowed. A regionless target is trusted for this matched receive pass, but it neither creates a region nor changes how unrelated packets with the same transport code pass the region gate. By default, if the selected scope differs and the rewritten packet passes those checks, its initial retransmission uses zero txdelay and the highest outbound queue priority so the newly scoped copy can win at the next hop. Add tx=slow to use an effective inbound rxdelay base of max(2, configured rxdelay * 2), retain normal outbound queue priority, and force the maximum txdelay factor of 2.0. The actual transmit delay is still randomized, from zero through ten packet airtimes. It does not preempt an active radio transmission or bypass CAD and airtime-budget limits. Selecting the scope already present is a no-op and does not grant special treatment.

    Direct routes are never rewritten. Standard traceroute is direct-routed and therefore remains outside this flood-only table. A custom flood-form TRACE is treated like every other flood: an applicable wildcard may rewrite it and the normal region/unknown-code gates still apply. Scope assignment does not override normal payload validation or make an otherwise non-forwardable packet type forwardable.

    LoRa OTA (0x0C) falls under other:*. A matching row adds the selected transport code or replaces the existing one, but OTA still operates normally during the temporary-radio window because the OTA handler accepts both unscoped and transport-scoped flood routes. A region target must allow flooding; a direct target uses the regionless trust behavior above. A new repeater also seeds ota all suspend=tempradio in flood-filter slot 1. That visible rule blocks OTA forwarding at every received hop outside temporary-radio operation and is skipped while temporary radio is active. Independently, the OTA core refuses OTA receive, relay, and transmit outside an actually active temporary-radio window, even if the seeded row is deleted or replaced.

    Capacity is selected at build time:

    Classic ESP32 uses the 31-slot default even on boards with PSRAM because these tables live in internal static DRAM. The separate channel-requirement table defaults to the same capacity and uses another 34 bytes per slot. Together, the 31-slot defaults save 15,712 bytes of static RAM compared with 255 slots. Custom builds can override FLOOD_CHANNEL_SCOPE_SLOTS and FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS with build flags.

    Each rule retains its 36-byte record. A separate table holds 32-byte normalized names for up to the smaller of the rule count or 32 distinct regionless targets, except that very-tight STM32WL builds retain one reusable direct target. Both configured regions and regionless targets can be reused by any number of rules.

    On generalized builds these records are the FPF7 rewrite phase, and the file stores only through the highest occupied slot. Compact FPF6 builds retain the standalone FCS5 file and the file sizes described by their build profile.

    "},{"location":"flood_filtering/#require-valid-incoming-scopes-only-on-selected-channels","title":"Require valid incoming scopes only on selected channels","text":"

    flood.channel.scope.require changes region enforcement for received flood GRP_TXT and GRP_DATA packets from a global policy to a channel opt-in policy:

    set flood.channel.scope.require <public|#channel|128/256-bit-key>\nset flood.channel.scope.require.<slot> <public|#channel|128/256-bit-key>\nget flood.channel.scope.require\nget flood.channel.scope.require.<slot>\ndel flood.channel.scope.require.<slot>\ndel flood.channel.scope.require all\n

    An empty table preserves the normal global region behavior. Once at least one row exists, a group-channel packet that authenticates against a listed key must arrive as ROUTE_TYPE_TRANSPORT_FLOOD with a transport code matching a locally flood-allowed region. An unscoped packet, an unknown transport code, or a code for a denied region is not retransmitted. The check uses the original incoming scope before flood.channel.scope or flood.filter scope= can rewrite it. Those rewrite actions are skipped for a rejected listed channel, so they cannot rescue it or grant special receive/transmit timing.

    Group-channel packets that do not authenticate against any listed key bypass the region/unknown-code forwarding gate. They still pass through repeat, flood.max*, flood.filter, loop detection, payload validation, and moderation. Non-channel flood payload types retain the normal global region behavior. A one-byte channel-hash collision is only a prefilter; the packet must also pass MAC validation/decryption with the configured key.

    Without .slot, setting an existing key updates its row and a new key uses the first empty row. Numbered set replaces that slot. Detail output displays only the first four derived hash bytes and key size, never the secret. The table has the same build-dependent slot count as flood.channel.scope; each row consumes 34 bytes of RAM and storage, plus a five-byte file header. ACL permission 4 can manage it.

    For example, this requires an allowed incoming scope on #bot, while every other group channel bypasses region enforcement:

    set flood.channel.scope.require #bot\nget flood.channel.scope.require\n
    "},{"location":"flood_filtering/#interaction-with-duplicate-detection","title":"Interaction with duplicate detection","text":"

    The seen-packet hash contains the payload type and exact payload bytes. It does not contain the route type, either transport code, or the ordinary flood path. For TRACE only, the encoded path_len byte is also included. Therefore an unscoped packet and the same packet after this repeater adds a transport code are the same duplicate. A later copy with a different scope is also the same duplicate; changing or adding scope cannot evade the seen table.

    When equivalent non-TRACE flood copies overlap in rxdelay, the normal receive-quality timing still chooses the packet to process, but that winner takes a scope from the queued copies whose transport code matches an allowed region in this repeater. Unknown and denied scopes are ignored. If eligible copies have different scopes, the shortest received path supplies the scope. Equal path lengths prefer the deepest matching child region (the narrowest configured scope). A remaining tie keeps queue order. The winner keeps its own path, SNR, and delay schedule; only its route and transport codes can change, including replacement of a less-preferred scope it already carried.

    Scope selection happens at dequeue so the original scopes remain available for comparison. It applies only while copies are queued and cannot alter a copy already processed into the seen table. Flood-form TRACE participates in the same arbitration. Direct traceroute never enters this flood queue.

    A packet that already matches a configured fast flood.channel.scope or flood.filter scope= action and needs its scope changed bypasses this inbound rxdelay queue entirely. A tx=slow row remains in the queue with twice the configured base, floored at 2.0, and participates in normal queued-copy scope arbitration.

    "},{"location":"flood_filtering/#runtime-flood-rules","title":"Runtime flood rules","text":"

    On repeaters with the rule engine enabled and on FULL-profile ESP32 room servers, flood.rule and flood.filter are two names for the same persistent table. The evaluator is fixed firmware, but every row is data, so an authenticated operator can add, replace, inspect, or delete a row without an OTA or reboot. Existing flood.filter commands remain compatible. Only FPF6 and FPF7 files are accepted; FPF1-FPF5 files are rejected and filtering fails open. A row saved by the extended engine uses FPF7. Older firmware cannot preserve an FPF7 table containing the packed retry action or one-byte hash:XX matcher; remove those rows before downgrading.

    The former flood.channel.block table is now represented by ordinary FPF7 rows. On a generalized repeater, an existing FCB2 file is imported once into free FPF7 slots and then removed. For example, an old #wardriving h=4 row becomes type=any channel=#wardriving hops=5+ drop. The 63-row table can hold all 31 former general filter rows, all 15 legacy channel-block rows, and the migrated global flood.channel.data gate. Compact STM32WL FPF6 builds cannot match authenticated channels and retain the older separate gate.

    On generalized repeaters, flood.channel.data* is a compatibility view over one ordinary visible FPF7 type=grp_data ... drop row. Turning it off creates or updates that row; turning it on removes the row. Its hop setting maps to hops=all or hops=N+1+. There is no hidden GRP_DATA forwarding check ahead of FPF7. Normal ordering applies, so a matching higher-priority stop row can exempt selected traffic. The compact rule list marks the managed row with ~data.

    FPF7 binds in=region:<name> and region=<name> to canonical region names, not numeric region IDs. Removing, reordering, or reusing a region ID cannot silently redirect a rule. If the saved name is missing, an input-region match does not match and a target-region rewrite is skipped. Re-adding the same name reactivates the rule.

    The extended form is:

    set flood.rule[.<slot>] type=<type> [hops=<range>] [channel=<channel>]\n    [prefix=<ID[,ID...]>] [in=<input-scope>] <action> [rate=<N>/min]\n    [priority=<0-255>] [stop] [tx=fast|slow] [suspend=tempradio]\nget flood.rule\nget flood.rule.<slot>\ndel flood.rule.<slot>\ndel flood.rule all\n

    The command must be entered on one line. Match fields in one row are ANDed. Every row is matched against the same immutable packet state captured on receive, before any rule rewrites its scope. Matching rows are then processed by descending numeric priority. At an equal numeric priority, authenticated channel matches run before raw hash:XX matches, raw hashes run before channel=*, and lower slot number breaks the remaining tie. Priority defaults to 0; an explicitly higher numeric priority overrides specificity.

    The first matching stop row ends the FPF7 forward phase after that row. Higher-order matches and the stop row still apply; lower-order matches do not. A stop-only row is therefore an exception to lower-priority FPF7 rows. It cannot undo a higher-priority drop and it does not bypass hard forwarding gates or the scope-rewrite and moderation phases. Without a stop row, matching drop and rate rows remain independent and the highest-order matching scope or region rewrite wins.

    Match fields:

    Actions:

    When several rows use the same channel key, authentication is performed once for that packet and reused by those rows. This cache lives only for the current receive evaluation; it is not persisted and never stores plaintext or a password. A hash:XX row deliberately skips authentication and uses only the one visible byte.

    The global retry controls remain hard gates. A retry row does not override a zero flood.retry.count, path/type attempt caps, disabled forwarding, flood.retry.advert, a drop verdict, or any other forwarding rejection. flood.retry.bridge independently chooses ordinary retry or bridge-bucket completion for an allowed packet. The selector applies to received floods; locally originated floods retain the normal global behavior.

    The exact requested examples are:

    # If #rgdata arrives unscoped with more than 3 received hops, add\n# the regionless #BlackHole86 scope.\nset flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86\n\n# Rewrite the exact incoming #usa scope to #BlackHole86 for #rgdata.\nset flood.rule.3 type=grp_data channel=#rgdata in=scope:usa scope=BlackHole86\n\n# Match a two-byte source-path prefix and cap forwarding at 10 per minute.\nset flood.rule.4 type=any prefix=860C rate=10/min\n\n# Keep authenticated #rgdata at two hops or less out of lower-priority FPF7\n# rules. Hard gates and separate tables still apply.\nset flood.rule.5 type=grp_data hops=0-2 channel=#rgdata priority=200 stop\n\n# Retry received Public and #hamradio floods, using bridge completion.\n# Once these rows exist, other received floods are not retried.\nset flood.retry.bridge on\nset flood.rule.6 type=any channel=public retry\nset flood.rule.7 type=any channel=#hamradio retry\n\n# Fall back to an unauthenticated one-byte match if only the visible hash is known.\nset flood.rule.8 type=any channel=hash:A7 retry\n\nget flood.rule.2\nget flood.rule.3\nget flood.rule.4\n

    Public's visible channel hash is 0x11, so a hash:11 drop row also matches Public unless an earlier exact-channel rule stops it. This standalone example preserves authenticated Public while dropping other packets that use the same visible byte:

    set flood.rule.2 type=any channel=public retry stop\nset flood.rule.3 type=any channel=hash:11 drop\n

    The Public rule's MAC/decrypt check must succeed before its stop applies. A colliding channel misses that rule and reaches the hash drop. With equal numeric priorities, authenticated-channel specificity automatically puts the Public rule first regardless of slot order. Without stop, both rows match Public and the sticky drop wins. Remove retry from the Public row if the exemption should not also opt Public into flood retry. An explicitly higher numeric priority on the hash rule remains an operator override.

    The 240 KB STM32WL profiles keep MESH_ENABLE_FLOOD_RULE_ENGINE=0 and retain the persistent compact FPF6 flood.filter and blacklist syntax below. They still perform filtering, but omit the generalized flood.rule parser and extended fields. No partition size is changed by this feature.

    The compatible filter and blacklist commands are:

    set flood.filter.blacklist <ID[,ID...]>\nset flood.filter.blacklist.<slot> <ID[,ID...]>\nget flood.filter.blacklist\nget flood.filter.blacklist.<slot>\ndel flood.filter.blacklist\ndel flood.filter.blacklist.<slot>\nset flood.filter <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]\nset flood.filter.<slot> <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]\nget flood.filter\nget flood.filter.<slot>\ndel flood.filter.<slot>\ndel flood.filter all\n

    The blacklist holds up to 255 unique 3-byte repeater IDs on ESP32 builds and 18 on other builds. Each is written as six hexadecimal digits. For example:

    set flood.filter.blacklist A1B2C3,D4E5F6,112233\nset flood.filter.blacklist.4 445566\nset flood.filter any all path=blacklist\n

    This is intended for abuse containment, such as refusing to retransmit floods that repeatedly enter the mesh through known internet gateways dumping bulk traffic. The list is shared by every FPF7 row and scope-rewrite row that uses path=blacklist; it is not copied into each rule.

    An unnumbered set replaces the list with up to 18 IDs, the largest command that fits every CLI transport. A numbered set writes a batch of up to 18 IDs beginning at an existing slot or the next consecutive slot. This is how an ESP32 list grows beyond 18. Deleting a numbered entry compacts the entries after it. Unnumbered get reports the total and prints the leading IDs that fit; numbered get retrieves one specific entry.

    path=blacklist is an unordered precondition on that row. With 3-byte path hashes, one or more exact blacklist hits qualifies the packet. With 2-byte path hashes, two or more received path entries must match the first two bytes of listed IDs. Each received entry is counted at most once. A 1-byte path never qualifies. The IDs may occur anywhere in the received path; neither their list order nor their path order matters.

    Without a slot number, set reuses an identical row or selects the first empty slot. Use a slot number to replace a row whose match or action is changing. Omitting the hop expression means all (0-63).

    Numbered get normally uses the long field names. If a rule containing several maximum-length names would exceed one CLI reply, it switches to a non-truncating compact spelling that set also accepts: c= is channel=, p= is prefix=, i=*|n|s|a|u|s:<scope>|r:<region> represents in=, q=N is rate=N/min, and f=str combines slow timing (s), temporary-radio suspension (t), and retry allowance (r). The fallback prints packet type numerically.

    A legacy row without scope= is the existing drop action. On extended builds, an explicit drop has the same result, while rate= by itself creates a rate-only row. A row with scope= adds transport scope to an unscoped packet or replaces the codes on an already-scoped packet. The scope name is normalized with a leading #, and the 128-bit transport key is derived directly from that hashtag. The name does not need to exist in the region list and is not added to it. Public names up to 30 characters are accepted; private $ scopes are not.

    require=region is the legacy spelling of in=allowed. The repeater evaluates the packet's original route before any rewrite in that receive pass. An incoming transport scope must match a locally allowed region; an unscoped flood must be allowed by the wildcard region. If the check fails, that row is skipped, the filter does not grant a region bypass, and the unchanged packet is allowed to fail normal region enforcement. Other independently configured scope rows still apply in their normal order.

    When multiple scope or region rows match, the highest numeric-priority row wins. Equal priorities use authenticated channel, raw hash, then wildcard specificity before the slot-number tie-break. Rewrite rows do not approve a packet: any matching drop row and every remaining forwarding gate can still reject it. A filter-assigned scope is trusted without local region-list validation, but repeat, flood.max, loop detection, and moderation still apply. By default, a changed scope bypasses inbound rxdelay, then is retransmitted with zero txdelay and the highest outbound queue priority. Add tx=slow to use an effective inbound rxdelay base of max(2, configured rxdelay * 2), retain normal queue priority, and force the maximum txdelay factor of 2.0; the randomized transmit delay ranges from zero through ten packet airtimes. tx=fast explicitly restores the default. Selecting the scope already present does not grant special treatment. Active radio transmission, CAD, and airtime-budget limits are unchanged.

    On generalized repeaters, forward rules, flood.channel.scope rewrite rows, the shared blacklist, and the flood.channel.data compatibility state are one atomic FPF7 policy image. Existing /flood_ch_scope, /flood_filter_bl, FPF6, and FCB2 data is imported once; the old files are removed only after the new image verifies and commits. Compact FPF6 repeaters retain separate files. Deleting the blacklist leaves path=blacklist rows in place but dormant until IDs are configured again. Path hashes are truncated routing identifiers and are not authenticated proof that a particular repeater\u2014or a particular person\u2014handled a packet. FULL room servers reject blacklist commands.

    On first initialization, flood-filter slot 1 is seeded with:

    set flood.filter.1 0x0C all suspend=tempradio\n

    This is a normal editable row. After the table has been saved, deleting it remains persistent across reboot; the firmware does not recreate it. Run the same command to restore the exact seeded row, or omit .1 to preserve existing slot assignments and use the first empty slot. Operators may add suspend=tempradio to any other row that should be skipped while the radio is on a temporary channel.

    Suspension does not approve a packet or bypass the rest of the filter table. It skips that row, then evaluation continues with the next row and the remaining forwarding gates. An ordinary drop any row therefore still applies during the temporary-radio window unless an earlier matching stop row ends FPF7 processing. repeat, flood.max*, region handling, loop detection, and the OTA subsystem's own hop limit also remain in force.

    Standard traceroute uses direct routing and never enters flood.filter. For a custom flood-form trace, type=any, explicit trace, scope, region, rate, and drop rows all behave normally. The stock core does not normally flood-forward TRACE packets.

    "},{"location":"flood_filtering/#remote-administration-lockout-warning","title":"Remote-administration lockout warning","text":"

    There are no hidden payload-type or short-hop exemptions in FPF7. Drop and rate rules can block req, response, txt_msg, anon_req, path, ACK, and multipart traffic beginning at hop 0 when their match fields say so.

    A flooded login starts as ANON_REQ; its reply is commonly a PATH packet carrying an encrypted RESPONSE. Before a direct return path is established, administrative replies and CLI text can also be flooded. Transit repeaters do not have the session key and cannot distinguish those encrypted admin exchanges from ordinary peer packets with the same outer type. A rule therefore affects the complete outer packet class, not only packets that ultimately authenticate as administrators. Keep a serial or other recovery path and stage broad deny/rate rules carefully.

    Hop expressions are based on the path count when this repeater receives the packet:

    Examples:

    # Stop forwarding group data once it arrives with four or more path entries.\nset flood.filter grp_data 4+\n\n# Stop long adverts, while still allowing shorter adverts.\nset flood.filter.2 advert 6+\n\n# Keep LoRa OTA floods from crossing this repeater at path counts 2 through 4.\nset flood.filter.3 ota 2-4\n\n# Assign #local scope to group text without requiring #local in the region map.\nset flood.filter grp_txt all scope=local\n\n# Rewrite only packets whose incoming region was already acceptable.\nset flood.filter grp_data all scope=local require=region\n\n# Rewrite matching blacklisted paths without fast-tracking their retransmission.\nset flood.filter grp_data all path=blacklist scope=local tx=slow\n\n# Drop matching flood types after the unordered path blacklist qualifies.\nset flood.filter.blacklist A1B2C3,D4E5F6,112233\nset flood.filter any all path=blacklist\n\n# Apply a hard ceiling to flood payload types at 12 or more received hops.\nset flood.filter any 12+\n
    "},{"location":"flood_filtering/#high-traffic-mesh-example","title":"High-traffic mesh example","text":"

    This preset limits request and group-data propagation early while allowing the login-capable response, anonymous-request, and path types to travel farther:

    set flood.filter req 3+\nset flood.filter response 9+\nset flood.filter 0x06 3+\nset flood.filter 0x07 9+\nset flood.filter path 9+\nset flood.filter control 1+\nget flood.filter\n
    Rule Stops retransmission when received with req 3+ 3 or more path entries response 9+ 9 or more path entries 0x06 3+ (grp_data) 3 or more path entries 0x07 9+ (anon_req) 9 or more path entries path 9+ 9 or more path entries control 1+ 1 or more path entries

    On a new table, the factory OTA rule occupies slot 1, so these unnumbered commands normally fill slots 2 through 7. Existing tables may choose different free slots. All six rules take effect at the thresholds shown; there are no hidden short-hop exceptions. The Control rule allows a flood received with path count 0 to be forwarded once, then stops it at the next repeater. Normal node-discovery Control packets are direct zero-hop packets and never enter flood.filter. These rules affect only retransmission by the repeater; local reception and logging remain unchanged.

    Accepted payload names are:

    Value Short name Full name 0x00 req PAYLOAD_TYPE_REQ 0x01 response PAYLOAD_TYPE_RESPONSE 0x02 txt_msg PAYLOAD_TYPE_TXT_MSG 0x03 ack PAYLOAD_TYPE_ACK 0x04 advert PAYLOAD_TYPE_ADVERT 0x05 grp_txt PAYLOAD_TYPE_GRP_TXT 0x06 grp_data PAYLOAD_TYPE_GRP_DATA 0x07 anon_req PAYLOAD_TYPE_ANON_REQ 0x08 path PAYLOAD_TYPE_PATH 0x09 trace PAYLOAD_TYPE_TRACE 0x0A multipart PAYLOAD_TYPE_MULTIPART 0x0B control PAYLOAD_TYPE_CONTROL 0x0C ota PAYLOAD_TYPE_OTA in this fork 0x0D 13 reserved 0x0E 14 reserved 0x0F raw_custom PAYLOAD_TYPE_RAW_CUSTOM

    Decimal values 0 through 15, hexadecimal values 0x00 through 0x0F, the full PAYLOAD_TYPE_* names, and any are also accepted. Upstream currently reserves 0x0C; this fork assigns it to LoRa OTA. Rows are suspended during temporary-radio operation only when explicitly configured that way.

    "},{"location":"flood_filtering/#moderate-group-text-by-channel-and-username","title":"Moderate group text by channel and username","text":"

    Use flood.moderation for flood GRP_TXT messages. The repeater validates and decrypts the selected channel, extracts the display name before the first :, then applies the rule:

    set flood.moderation <channel> <sender> <action> [action...]\nset flood.moderation.<slot> <channel> <sender> <action> [action...]\nget flood.moderation\nget flood.moderation.<slot>\ndel flood.moderation.<slot>\ndel flood.moderation all\n

    Channels can be specified as:

    The key is stored locally so packets can be authenticated and decrypted. It is not included in get flood.moderation output.

    Available actions are:

    At least one of drop, rate=X/min, or hops=N is required. Rate and hop limits can be combined. rate=0/min is equivalent to drop.

    "},{"location":"flood_filtering/#per-user-per-channel-rate-limits","title":"Per-user, per-channel rate limits","text":"

    Rate limits require an exact username; * is not accepted for a rate rule. Username comparison is ASCII case-insensitive, and names containing spaces must be quoted. A rule's counter is independent from rules for the same name on other channels, so this directly supports \"X messages per minute from user X on channel Y.\" Counters are local to this repeater and reset on reboot.

    # At most five Public-channel messages per minute from this display name.\nset flood.moderation public \"Noisy User\" rate=5/min\n\n# A separate limit for the same name on #local.\nset flood.moderation #local \"Noisy User\" rate=10/min\n\n# Combine a rate limit with a maximum forwarding distance.\nset flood.moderation public alice rate=4/min hops=5\n
    "},{"location":"flood_filtering/#match-the-start-of-a-path","title":"Match the start of a path","text":"

    Path matching accepts one-, two-, or three-byte hashes. Every hash in one rule must use the same width, and matching always starts at the beginning of the received path:

    set flood.moderation #local bot drop path=A1B2C3,D4E5F6\nset flood.moderation public alice rate=3/min path=71\n

    A path-qualified rule cannot match a zero-hop packet and does not match until the packet contains all path entries listed by the rule.

    "},{"location":"flood_filtering/#how-the-forwarding-controls-combine","title":"How the forwarding controls combine","text":"

    A flood packet is retransmitted only if it passes every applicable control. In other words, the controls combine as deny rules:

    1. flood.channel.scope.require evaluates a listed group channel against the original incoming scope; unlisted group channels bypass the later region gate while the table is active.
    2. flood.channel.scope tries a path-qualified channel row before that channel's ordinary fallback, then adds or replaces the scope from either a configured region or a direct scope=<name> target.
    3. All extended flood.rule match fields are evaluated against the same original incoming packet. Matches are ordered by descending numeric priority, then authenticated/raw-hash/wildcard channel specificity, then ascending slot. The first matching stop row removes every later FPF7 match. The highest-order remaining scope= or region= row may replace the channel-scope result; a direct scope does not require a region-list entry.
    4. repeat and flood.max* are checked.
    5. The FPF7 forward phase, including any row managed through flood.channel.data*, applies drop and rate decisions using that saved match result. No packet type or short-hop range is silently exempted.
    6. Region and loop-detection rules are checked; a regionless scope assigned by either table is already trusted when it has no region-list match, except that it cannot rescue a channel rejected by flood.channel.scope.require.
    7. flood.moderation checks decrypted group text, username, rate, hops, and path. If it accepts the packet, matching general-rule rate counters are charged immediately before retransmission is approved.

    The first denial is enough to prevent retransmission. A packet that is denied can still appear in local logs or MQTT output. Moderation runs last because its rate counters are charged only for packets that pass every other forwarding control and will actually be retransmitted.

    "},{"location":"flood_filtering/#delegate-filter-management","title":"Delegate filter management","text":"

    On repeaters, ACL permission 5 is the filter-manager role:

    setperm <companion-public-key-hex> 5\n

    A filter manager can read non-secret operational status and manage repeat, loop.detect, flood.max*, flood.channel.data*, flood.filter*, flood.rule*, and flood.moderation*. Delegated get access uses an explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions, ACL entries, radio settings, or unrelated administrator settings. Because flood.filter scope= derives a public hashtag key directly, a filter manager can configure that action without region-manager permission; it still cannot edit the region hierarchy.

    FULL ESP32 room servers use their existing administrator check for remote flood.rule and flood.filter commands; they do not grant this table through permission 5.

    ACL permission 4, the region/scope-manager role, can read, add, replace, and delete flood.channel.scope and flood.channel.scope.require rows and manage regions. This lets the same delegate create target regions, assign forced scopes from regions or direct hashtag names, and select the channels that require valid incoming scopes.

    "},{"location":"flood_filtering/#security-limitations","title":"Security limitations","text":"

    Public and hashtag channels use shared, well-known keys. A valid channel MAC proves that the sender knew the channel key; it does not identify a person. The <sender> value is an unverified display name and can be spoofed. Path hashes are truncated routing hints and can collide or be manipulated; they are not authenticated user identities.

    Use username and path rules as traffic moderation, not as an authorization boundary. For a strict network boundary, combine these tools with region ACLs, private transport/channel keys, and controlled device access.

    "},{"location":"flood_filtering/#restore-the-factory-seeded-rows","title":"Restore the factory-seeded rows","text":"

    The repeater's factory-seeded forwarding rows can be restored through the CLI:

    set flood.rule.1 type=ota hops=all drop suspend=tempradio\nset flood.rule.2 type=any channel=#wardriving hops=5+ drop\n

    These commands explicitly replace the two seeded generalized-repeater slots. Inspect them first if they may now contain other rules. Compact FPF6 builds use only the first command's flood.filter.1 0x0C all suspend=tempradio form.

    "},{"location":"flood_filtering/#remove-the-custom-rules","title":"Remove the custom rules","text":"

    To save both tables in an empty state:

    del flood.filter all\ndel flood.moderation all\nget flood.filter\nget flood.moderation\n

    This does not change the older flood.max*, loop-detection, or region settings; inspect or reset those separately when troubleshooting.

    "},{"location":"full_companion_features/","title":"Full Companion: turn features on and off","text":"

    For side-by-side Companion and infrastructure commands, see feature switches by role. MQTT and logging controls differ between these roles.

    Use the exact companion_radio_full image for your board. Full includes its supported transports and MOTA sending in one firmware; ordinary settings do not require rebuilding it. USB remains the normal way to update the Companion itself. WiFi/Bluetooth updates are optional, and Full does not install LoRa MOTA packages on itself.

    The Wireless Paper Full memory-sharing update keeps 350 contacts and 256 queued messages normally. During an mOTA session, 128 message slots are available while the other half holds its workspace. If more than 128 unread messages are queued, sync them with an app before starting mOTA. Disconnecting the source or turning it off returns all 256 slots. Idle WiFi mOTA listening does not borrow the queue. See the 1.17.1.5 release guide for the corrected download; the earlier aa20e927 Wireless Paper image has 150 contacts.

    "},{"location":"full_companion_features/#open-the-text-terminal","title":"Open the text terminal","text":"

    Close the app or serial monitor using the USB port, then open a terminal at 115200 baud, or use the USB web console. A fresh Full Companion boot starts in ASCII text mode. If it is already in Binary Companion mode, send this line once and wait for the terminal banner:

    +++MESHCORE-TERM-START\n

    Run help, board, and version to inspect the device. On ESP32 you can alternatively open the node's LAN address and select CLI, or connect with nc DEVICE_IP 5002. Both use the same terminal commands as USB, including import meshcore://..., list, to, and send. Use disconnect to release a browser/TCP session. See the terminal guide. When finished, send +++MESHCORE-TERM-STOP, close the terminal, and reconnect your Companion app. These are local text commands; an app's custom-variable editor does not necessarily forward them.

    "},{"location":"full_companion_features/#common-switches","title":"Common switches","text":"Feature Turn on Turn off When it takes effect Device power saving set powersaving on set powersaving off Immediately; saved LoRa receive power saving set radio.rxps on set radio.rxps off Saved; radio applies when available Radio chip RX boost set radio.rxgain on set radio.rxgain off Immediately; saved; supported radios only External FEM RX boost set radio.fem.rxgain on set radio.fem.rxgain off Immediately; saved; controllable FEM only External FEM TX gain set radio.fem.txgain on set radio.fem.txgain off Immediately; saved; controllable PA only ESP32 USB packet/debug logging For 1.17.1.5: set powersaving off, then set usb.logging on set usb.logging off Immediately; saved; logging owns the USB terminal nRF52 separate USB logging port set usb.logging on reboot set usb.logging off reboot Saves and reboots to add/remove the second USB port ESP32 browser settings set webui on set webui off Saved; starts/stops WebConfig ESP32 temporary setup portal start webconfig ap stop webconfig This session; opens a setup network/QR where available ESP32 WiFi firmware uploader start ota or start ota ap stop ota This session; only usable with two application slots Temporary MOTA radio window tempradio 910.525,250,5,5,120 normalradio Bounded window; saved normal settings return afterward

    Check a saved switch with the corresponding get command, for example get usb.logging, get radio.rxps, or get webui. Use get powersaving to inspect device power saving. Fresh installations enable device power saving and leave USB logging off. Existing saved preferences take precedence.

    For an ESP32 1.17.1.5 USB logging session, use this sequence in the text terminal:

    set powersaving off\nset usb.logging on\n

    The power-saving step is the documented USB-session workaround for this release. It is saved separately; set usb.logging off does not restore power saving. nRF52 does not need the ESP32 workaround. WiFi-only MQTT logging does not need this USB step; see logging by role.

    ESP32 logging and Binary Companion share one USB port. After turning logging off, return to Binary Companion with +++MESHCORE-TERM-STOP. nRF52 keeps its optional logging port separate from its primary USB port and BLE connection. See the USB switching guide.

    "},{"location":"full_companion_features/#wifi-bluetooth-gps-and-mqtt","title":"WiFi, Bluetooth, GPS, and MQTT","text":"

    On ESP32, configure WiFi through the setup portal or these commands:

    set wifi.ssid MyNetwork\nset wifi.pwd my-password\nget wifi.status\n

    Credentials are saved. A credential change schedules a reconnect; a TCP terminal will disconnect. The WiFi switch on supported device displays or their assigned WiFi button controls the Companion WiFi services. Stopping WebConfig alone stops its browser service, while the Companion's TCP and MOTA services can remain available.

    WiFi modem sleep is independent of device power saving. Use set wifi.powersave min for normal BLE/WiFi coexistence; inspect the effective state with get wifi.powersave. max is available only where the firmware's radio coexistence policy permits it.

    Most Full Companions provide BLE alongside their other transports. There is no universal set bluetooth off command. SenseCAP Indicator selects one secondary wireless transport per boot:

    set companion.transport wifi\nreboot\n

    Use set companion.transport ble followed by reboot to select Bluetooth instead. USB stays available. The ESP-NOW layout keeps its primary ESP-NOW mesh active in either mode.

    For GPS-equipped boards, use get gps, set gps on, and set gps off. The Companion app's gps=1 / gps=0 custom setting controls the same GPS. Only boards with a compiled GPS provider expose this setting. Sharing location with contacts is a separate setting.

    When the exact Full image includes MQTT, use WebConfig's MQTT cards or the same CLI settings used by infrastructure, such as set mqtt1.preset custom and set mqtt1.server broker.example.com. set mqtt.enabled on|off controls MQTT without erasing the configured slots; check get mqtt.enabled, get mqtt.running, and get mqtt.status. The MQTT tab's Enable MQTT checkbox controls the same saved switch. set logging.output off|usb|wifi|both selects USB and MQTT outputs together. Status, packets, raw, receive, and transmit switches control their individual publications. Builds without MQTT omit these controls. There is no need for a separate logging or WiFi-MQTT Companion image when that feature is included in Full.

    "},{"location":"full_companion_features/#send-mota-from-any-full-companion","title":"Send MOTA from any Full Companion","text":"

    Place valid, destination-specific .mota files in a host directory. Set the source, destination, controller, and required relays to the same bounded temporary radio tuple. For USA Cascade on LoRa hardware:

    tempradio 910.525,250,5,5,120\n

    On the computer connected to the Full Companion, run:

    motatool serve --serial /dev/ttyACM0 --dir ./motas -v\n

    This works on both ESP32 and nRF52 Full. Close the text terminal first. motatool automatically sends ota folder on and owns USB while serving. Stop the host tool to detach the source; use normalradio afterward to return early, or let the window expire. A .bin, .uf2, or DFU .zip is not itself a .mota; use motatool to prepare the package for the exact destination firmware and hardware.

    ESP32 also accepts a host over WiFi:

    motatool serve --tcp DEVICE_IP:5001 --dir ./motas -v\n

    nRF52 also accepts an authenticated Bluetooth host:

    python3 tools/ble_mota/ble_mota_seeder.py \\\n  --device MeshCore-MyCompanion --dir ./motas \\\n  --local 'tempradio 910.525,250,5,5,120'\n

    Use one MOTA source transport at a time. See the complete MOTA instructions for pairing, remote destination commands, and automation. ESP-NOW hardware uses its ESP-NOW primary radio rather than transmitting LoRa.

    "},{"location":"full_companion_features/#update-the-companion-itself","title":"Update the Companion itself","text":"

    USB is supported for every Full Companion. Use the exact board's UF2/DFU package or ESP32 image and the normal flasher. Preserve the target's existing partition layout unless deliberately performing a documented USB migration.

    For ESP32 with two application slots, start ota returns the WiFi uploader URL, normally http://DEVICE_IP:8080/update. start ota ap explicitly opens MeshCore-OTA; join it and use the returned URL. Upload the exact board's application .bin. The device reboots when the upload succeeds. Use stop ota to close an unused uploader. Port 8080 keeps WebConfig on port 80 available. Use a trusted local network or a temporary setup network.

    The 4 MB Full layouts and T-Beam 1W Full use a single application slot and require USB; the command reports that limitation. The artifact's .capabilities.json lists verified ota_update_methods when a wireless self-update path is usable.

    nRF52 Full exposes Bluetooth DFU to paired clients. Use the matching application DFU .zip with a compatible Nordic/Adafruit Legacy DFU client and the board's matching BLE-DFU-capable bootloader/SoftDevice. USB remains the fallback when the installed bootloader does not support that procedure. Bluetooth DFU updates the Companion; the separate Bluetooth MOTA source service feeds packages to other mesh nodes.

    For OTAFIX installations, use the exact board/storage profile from OTAFIX 2.4.6. It includes the Bluetooth reconnect fix from 2.4.5. Its release notes cover upgrades from older bootloaders, including the special UF2-drive precautions for version 2.4.3. The new 64 KiB retained-RAM staging feature applies to qualified internal-flash nRF52840 receivers; Full Companions continue to serve host-supplied packages without needing that receiver storage layout.

    "},{"location":"full_companion_usb_switcher/","title":"Full Companion USB CLI and binary switcher","text":"

    Full Companion uses one primary USB serial interface for two incompatible wire formats:

    The primary interface starts in the ASCII terminal after each boot. A Binary Companion client does not need to send a special mode command: its first valid frame automatically hands the interface to the binary parser.

    This automatic behavior is compiled only into companion_radio_full targets. Ordinary USB Companion builds continue to use the explicit +++MESHCORE-TERM-START and +++MESHCORE-TERM-STOP controls described in the Terminal Chat CLI guide.

    "},{"location":"full_companion_usb_switcher/#wire-formats","title":"Wire formats","text":"

    Host-to-device Binary Companion frames use this layout:

    '<'  length-low  length-high  payload[length]\n

    Device-to-host frames use the same little-endian length with a different marker:

    '>'  length-low  length-high  payload[length]\n

    For example, a representative two-byte device query is:

    3C 02 00 16 03\n

    The ASCII terminal is line-oriented and accepts commands such as:

    get radio.cad\nset display.rotation 90\nreboot\n

    Changing the configured baud rate does not select a mode. Use 115200 for compatibility even though native USB CDC hardware does not use UART timing.

    "},{"location":"full_companion_usb_switcher/#state-transitions","title":"State transitions","text":"
                             complete '<' frame\n                 +--------------------------------+\n                 |                                v\nboot ------> ASCII terminal                  Binary Companion\n                 ^                                |\n                 |                                | +++MESHCORE-TERM-START\n                 | incomplete '<' probe           |\n                 | (one-second timeout)            |\n                 +--------------------------------+\n\nASCII terminal -- +++MESHCORE-TERM-STOP ------> Binary Companion\nASCII terminal -- observable USB disconnect ---> Binary Companion\nESP32 logging terminal -- usb.logging off -----> ASCII terminal\nany mode ------- reboot ------------------------> ASCII terminal\n

    Serial mOTA and single-TTY logging add exclusive ownership states described below. BLE, WiFi, Ethernet, and hardware-serial Companion transports are not switched; they remain binary.

    "},{"location":"full_companion_usb_switcher/#how-automatic-detection-works","title":"How automatic detection works","text":"
    1. Full Companion initializes the normal USB Binary Companion interface, then gives its primary stream to the ASCII terminal before normal loop service begins.
    2. While the prompt has no buffered input, the terminal peeks at the next byte. It does not remove that byte.
    3. If the byte is <, the terminal temporarily releases the stream and enables the existing ArduinoSerialInterface frame parser.
    4. The parser consumes the original <, the two-byte length, and the payload. There is no second parser and no copied or synthetic frame.
    5. A monotonically increasing completed-frame counter confirms that the parser received a complete frame. The interface then remains in Binary Companion mode.
    6. If no complete frame arrives within one second, the parser state is reset and the ASCII terminal prints a new banner and prompt.

    The switcher checks framing, not client identity. Any syntactically complete Binary Companion frame confirms binary mode; it does not require the first command to be CMD_APP_START or CMD_DEVICE_QUERY. Normal command validation still occurs after the frame parser returns the payload.

    The empty-prompt requirement prevents a literal < in the middle of a command from silently changing modes. A literal < typed as the first character does start a probe, but the terminal returns after the one-second timeout if no binary header and payload follow.

    "},{"location":"full_companion_usb_switcher/#manual-controls","title":"Manual controls","text":"

    The original controls remain available.

    From Binary Companion, send this exact unframed line while the parser is idle:

    +++MESHCORE-TERM-START\n

    Terminate it with CR or LF. The binary parser accepts control tokens only as complete delimiter-bounded lines; prefixes, suffixes, and partial tokens are ignored.

    From the ASCII terminal, send this exact sequence to return to binary mode:

    +++MESHCORE-TERM-STOP\n

    The stop sequence takes effect as soon as its last byte arrives in ASCII mode. The start sequence is recognized only as a completed line while the binary parser is idle and is not examined inside a length-prefixed frame.

    meshcli can normally connect directly after boot:

    meshcli -s /dev/ttyACM0 -b 115200 ver\n

    An explicit terminal start token is still useful when the device is already in binary mode:

    picocom -b 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM0\n
    "},{"location":"full_companion_usb_switcher/#logging-and-mota-ownership","title":"Logging and mOTA ownership","text":"

    The switcher never attempts to mix ASCII, framed Companion traffic, or binary mOTA traffic on the same stream.

    Situation Primary USB behavior Full Companion after boot ASCII; a complete < frame switches to Binary Companion nRF52 logging enabled Primary interface still follows the switcher; logs use the optional second interface ESP32 logging enabled at boot Logging terminal owns the only USB TTY; automatic < detection is disabled nRF52 USB serial mOTA active mOTA owns primary USB; ASCII and Binary Companion are unavailable there BLE/WiFi/Ethernet/hardware serial Always Binary Companion and unaffected by the USB mode

    On every ESP32 Full Companion, use set usb.logging off in its logging terminal before trying to use USB with an app. That command stops logging and returns to the ordinary ASCII terminal; it does not select Binary Companion. The app's first valid framed probe can perform the normal automatic switch, or a human can send +++MESHCORE-TERM-STOP first. The framed/binary Companion parser is disabled while logging owns that TTY. BLE and WiFi Companion transports remain available while USB is logging. ESP32 Full builds use the Arduino-ESP32 2.x base where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but follow the same one-TTY policy. No ESP32 Full build enumerates a second CDC interface.

    On nRF52, serial motatool is also text-first. Its exact initial ota folder on line is recognized in either startup ASCII or Binary Companion mode. From ASCII, the firmware leaves terminal mode and directly enters exclusive mOTA ownership; from binary, the idle frame parser recognizes the same control sequence. The following mOTA request/reply frames therefore cannot be consumed by the ASCII line editor. motatool serve --serial can be the first client after boot and does not require a terminal token or disconnect workaround.

    Only the exact completed line selects mOTA from ASCII. Extra arguments, leading/trailing whitespace, or a partial line remain ordinary terminal input.

    "},{"location":"full_companion_usb_switcher/#shortcomings-and-edge-cases","title":"Shortcomings and edge cases","text":"

    This mechanism is deliberately small and deterministic, but it is not a full protocol negotiation layer.

    "},{"location":"full_companion_usb_switcher/#it-is-startup-selection-not-per-connection-negotiation","title":"It is startup selection, not per-connection negotiation","text":"

    After the first complete binary frame, the device stays in Binary Companion mode. Closing meshcli does not automatically restore ASCII. Use the terminal start token or reboot when an ASCII prompt is needed again.

    Conversely, closing an ASCII terminal on native USB normally changes the port to binary mode because the firmware can observe USB DTR/data disconnect. The next client therefore sees binary mode, not a new ASCII session. A USB-to-UART bridge often cannot report disconnect, so it can remain in ASCII until the stop token or a reboot.

    "},{"location":"full_companion_usb_switcher/#detection-works-only-at-an-empty-prompt","title":"Detection works only at an empty prompt","text":"

    If part of an ASCII command is already buffered, an incoming < is treated as ordinary terminal input. Clear or submit the line before starting a Binary Companion client. Only one process should have the serial port open.

    "},{"location":"full_companion_usb_switcher/#the-first-frame-has-a-one-second-deadline","title":"The first frame has a one-second deadline","text":"

    The complete marker, length, and payload must arrive within the probe window. This is generous for local USB but may reject a heavily buffered serial proxy, a debugger that pauses the MCU, or a tool that writes the header and body with a long delay. A timed-out client can retry after the ASCII banner appears.

    "},{"location":"full_companion_usb_switcher/#framing-confirmation-is-not-authentication","title":"Framing confirmation is not authentication","text":"

    Any complete length-prefixed frame selects binary mode, even if its command is unknown or malformed at the application layer. This is safe for stream separation but means a random complete frame can leave the device in binary mode until manually switched back.

    "},{"location":"full_companion_usb_switcher/#the-terminal-banner-is-best-effort","title":"The terminal banner is best effort","text":"

    The firmware enters ASCII mode during boot, often before a host opens the CDC device. The banner may therefore be absent even though the terminal is ready; send a newline or a harmless get command rather than treating a missing banner as proof of binary mode.

    A host that remains connected across a reboot may receive ASCII banner and prompt bytes before the first binary response. Binary clients should discard leading bytes until a plausible > frame marker and length are found, reject implausible lengths, and resynchronize. The ordinary open-after-boot path has been tested with meshcli, but third-party clients that assume byte zero is always > may fail.

    "},{"location":"full_companion_usb_switcher/#text-and-binary-output-cannot-be-interleaved","title":"Text and binary output cannot be interleaved","text":"

    Primary USB suppresses Binary Companion output while the terminal owns the stream. Packet/debug logging must use its dedicated CDC interface or the exclusive single-TTY logging mode. Writing diagnostic text directly to the primary binary stream will corrupt clients regardless of the switcher.

    "},{"location":"full_companion_usb_switcher/#a-literal-leading-briefly-hides-the-prompt","title":"A literal leading < briefly hides the prompt","text":"

    Typing < as the first terminal character begins a binary probe. With no complete frame, the prompt returns after one second and the banner is printed again. There is currently no escape syntax for entering a literal leading <; prefix it with another character if it is needed as command text.

    "},{"location":"full_companion_usb_switcher/#troubleshooting","title":"Troubleshooting","text":"

    If meshcli cannot connect:

    1. close every terminal or logging reader using the primary interface;
    2. on ESP32, confirm that saved USB logging is off;
    3. reboot and let meshcli be the first process to open the data interface;
    4. use the stable /dev/serial/by-id/*-if00 path on Linux when available;
    5. if a prompt appears after one second, the client's first frame was not completed inside the probe window.

    If an ASCII terminal shows no banner, press Enter and issue a harmless query such as get radio.cad. If binary bytes appear, send the exact terminal start token or reboot.

    The switch policy is implemented in UsbAsciiBinarySwitch.h, with stream ownership in main.cpp and framing in ArduinoSerialInterface.cpp.

    "},{"location":"gps_tracking/","title":"GPS Tracking","text":"

    This document describes how GPS telemetry works for companion/client nodes and sensor nodes.

    "},{"location":"gps_tracking/#scope","title":"Scope","text":"

    GPS tracking uses the existing CayenneLPP GPS telemetry field. It does not add a new phone app protocol field.

    The GPS telemetry value contains latitude, longitude, and altitude. Speed, heading, and freshness are not sent as separate telemetry fields.

    If the firmware does not have a valid fresh GPS cache, it omits the GPS field from telemetry. This is intentional: stale or missing fixes are not reported as zero coordinates.

    "},{"location":"gps_tracking/#freshness","title":"Freshness","text":"

    GPS telemetry is cached separately from advert location.

    The cache behavior is:

    When a telemetry request asks for location, GPS is kept on for 2 hours after the latest location request. During that hold window, later location telemetry requests can use fresh GPS data as soon as valid fixes are available.

    If GPS is manually enabled, it stays on and valid fixes continue to update the cache.

    If no contact or ACL client can receive location telemetry, the scheduled 2-hour refresh does not run. A real location telemetry request still turns GPS on for the 2-hour hold window, and manual GPS-on still keeps the cache updated.

    "},{"location":"gps_tracking/#companionclient-nodes","title":"Companion/Client Nodes","text":"

    Companion/client telemetry uses the existing companion telemetry permission system:

    Location telemetry is sent only when the requester's effective telemetry permissions include location. Those permissions are derived from the companion telemetry mode settings and contact flags.

    The scheduled GPS cache refresh runs only when at least one stored contact has effective location telemetry access:

    No new phone app behavior is required. Existing clients see the existing GPS telemetry field when it is present.

    "},{"location":"gps_tracking/#sensor-nodes","title":"Sensor Nodes","text":"

    Sensor telemetry access is controlled by:

    get telemetry.access\nset telemetry.access all\nset telemetry.access acl\n

    all is the default and matches the previous sensor telemetry behavior. A request that reaches the normal sensor telemetry request path can receive the requested telemetry fields, including GPS, subject to the request mask and GPS freshness rules.

    acl gates telemetry through the sensor ACL. A requester with read-only or higher permissions receives the existing telemetry set, including GPS. Guest or unknown requesters receive no telemetry fields.

    The scheduled GPS cache refresh follows the same access setting:

    Use the existing ACL command to grant access:

    setperm <pubkey> 1\n

    Permission values:

    "},{"location":"gps_tracking/#advert-location","title":"Advert Location","text":"

    Advert location is separate from telemetry GPS. The advert policy is controlled with:

    gps advert\ngps advert none\ngps advert share\ngps advert prefs\n

    Policies:

    prefs is the first-boot default for every repeater, room-server, and sensor build. Firmware updates retain an explicitly saved policy from the existing preferences filesystem.

    Telemetry GPS can be fresh while advert location is fixed or disabled, depending on this policy.

    "},{"location":"gps_tracking/#recommended-setup","title":"Recommended Setup","text":"

    For private sensor tracking:

    set telemetry.access acl\nsetperm <owner_pubkey> 3\nsetperm <trusted_pubkey> 1\n

    For compatibility with the older sensor behavior:

    set telemetry.access all\n

    For GPS telemetry behavior, leave the phone app unchanged. The firmware omits GPS when it is stale or missing and sends the existing GPS telemetry field when fresh data is available.

    "},{"location":"halo_keymind_settings/","title":"Halo and Keymind Branch Settings","text":"

    This file covers only CLI settings and helper commands added by the Halo or Keymind branches. Use CLI commands for the general infrastructure CLI. For MQTT, logging, and former variant controls, use feature switches by role; Full Companion uses different controls for some of these settings. See Repeater Flood Filtering and Moderation for a focused filter setup and troubleshooting guide.

    "},{"location":"halo_keymind_settings/#quick-start","title":"Quick Start","text":"
    set retry.preset rooftop\nset direct.retry.heard on\nset flood.retry.advert off\nset flood.retry.bridge off\nset flood.retry.prefixes none\nset flood.retry.ignore none\n

    Then verify:

    get retry.preset\nget direct.retry.heard\nget flood.retry.advert\nget flood.retry.prefixes\nget flood.retry.ignore\n

    Use prefixes from the analyzer or neighbors list or get recent.repeater after the repeater has been online for a few hours.

    "},{"location":"halo_keymind_settings/#common-examples","title":"Common Examples","text":"

    Disable retrying advert packets:

    set flood.retry.advert off\nget flood.retry.advert\n

    Ignore a repeater as a successful flood retry echo: Use this if you have a car repeater and a house repeater; have the house ignore the car.

    set flood.retry.ignore 71CE82,C7618C\nget flood.retry.ignore\n

    Only accept specific downstream relays as flood retry success: You're in a hole and need to hit a mountain top repeater to get out; keep trying till one you see one of these send out your packet.

    set flood.retry.prefixes A58296,860CCA,425E5C\nget flood.retry.prefixes\n

    Bridge two groups of repeaters:

    set flood.retry.bridge on\nset flood.retry.bucket 1 71CE82,C7618C\nset flood.retry.bucket 2 BEEBB0,425E5C\nget flood.retry.bucket.1\nget flood.retry.bucket.2\n

    Return to simple non-bridge flood retry:

    set flood.retry.bridge off\nset flood.retry.prefixes none\nset flood.retry.ignore none\n
    "},{"location":"halo_keymind_settings/#added-settings","title":"Added Settings","text":"Setting What it does How to use Example telemetry.temp, telemetry.volt, telemetry.volt.i2c, telemetry.gps Repeater and room builds record 30-minute MCU temperature and battery samples for seven days. Detected INA voltage channels retain four days at 0.02 V resolution through 655.34 V; all-zero channels are omitted as disconnected. GPS-capable builds retain GPS separately. Repeater telemetry.tx sends temperature, battery, and per-channel I2C RAW_CUSTOM packets over a configured direct path on a persistent 1-30 day schedule (default two days) or immediately for testing; GPS is never included. History resets on reboot. get telemetry.temp [page], get telemetry.volt [page], get telemetry.volt.i2c [channel [page]], get telemetry.gps [page], set telemetry.gps <1-30>, get telemetry.tx, set telemetry.tx <off|direct|path>, set telemetry.tx schedule <off|1-30d>, send telemetry.tx now get telemetry.volt.i2c 2 1 battery.alert Sends opt-in, region-scoped low-battery warnings to #repeaters after 30 minutes of uptime. get battery.alert, get battery.alert.region, set battery.alert on [region], set battery.alert off set battery.alert on sea battery.alert.low Warning threshold percentage. Must be greater than battery.alert.critical. get battery.alert.low, set battery.alert.low <1-100> set battery.alert.low 20 battery.alert.critical Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. get battery.alert.critical, set battery.alert.critical <0-99> set battery.alert.critical 10 recent.repeater Shows, searches, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. Search results include the last-recorded age. Entries older than 24 hours are removed by a three-hour sweep. get recent.repeater [page], get recent.repeaters search <2|4|6 hex> [page], set recent.repeater <prefix> <snr_db>, clear recent.repeater get recent.repeaters search A1B2 flood.channel.data Compatibility view for a visible FPF7 type=grp_data drop row on generalized repeaters. off creates the row and on removes it; compact FPF6 builds retain the legacy gate. Use an authenticated channel= row when only selected channels should be blocked. get flood.channel.data, set flood.channel.data on/off set flood.channel.data off flood.channel.data.hops Sets the compatibility row's hop range: all maps to hops=all; 1-7 repeats through that hop count and maps to hops=N+1+. get flood.channel.data.hops, set flood.channel.data.hops <all|1-7> set flood.channel.data.hops 7 flood.channel.scope FPF7 rewrite-phase rows that add a transport scope to received unscoped floods or replace the scope of already-scoped floods. A bare target uses an existing flood-allowed region; scope=<name> derives a public hashtag target directly without creating a region, exactly like flood.filter scope=. By default, a changed packet bypasses inbound rxdelay and is forwarded at the highest outbound queue priority with zero initial txdelay, so the selected scope can win at the next hop. tx=slow uses an effective inbound rxdelay base of max(2, configured rxdelay * 2), retains normal queue priority, and forces the maximum txdelay factor of 2.0; its actual randomized transmit delay ranges from zero through ten packet airtimes. path=blacklist and path=bucket:<1-6> make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat txt:*; path-qualified rows beat the ordinary channel fallback. login:* covers the remote-login family, and other:* covers every remaining flood type, including flood-form TRACE and OTA. Direct traceroute remains outside the flood table. Generalized builds commit these rows with the forward phase and blacklist; compact FPF6 builds retain separate storage. ACL permission 4 can manage the table. get flood.channel.scope[.n], set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow], del flood.channel.scope.<n>|all set flood.channel.scope #rgdata scope=BlackHole86 flood.channel.scope.require Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated GRP_TXT/GRP_DATA channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission 4 can manage the table. get flood.channel.scope.require[.n], set flood.channel.scope.require[.n] <public|#channel|key>, del flood.channel.scope.require.<n>|all set flood.channel.scope.require #bot flood.filter Persistent flood-route rules selected by payload type and optional hop range. Generalized repeaters have 63 FPF7 forward slots; FULL ESP32 room servers have 31. Repeaters store their scope-rewrite phase, shared unordered blacklist, and channel-data compatibility state in the same atomic FPF7 image. path=blacklist is intended for forwarding abuse containment, including bulk internet-to-mesh dumping, but truncated path IDs are not authenticated identities. Fixed 240 KB STM32WL repeaters retain compact FPF6 filtering and separate blacklist storage. New generalized repeater tables seed slot 1 with ota all suspend=tempradio and slot 2 with an authenticated #wardriving hops=5+ drop. Direct routes and local receive/logging are unchanged. get flood.filter[.n], set flood.filter[.n] <type> [N|N+|N-M|all] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio], del flood.filter.<n>|all; repeater only: get/set/del flood.filter.blacklist[.n] set flood.filter grp_txt all scope=local tx=slow flood.rule Live alias for extended flood.filter on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, optional channel match, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, allow flood retry, and/or stop lower-priority FPF7 rules. channel=* means no channel condition; public, #name, and keys authenticate; hash:XX is an unauthenticated one-byte fallback with collision/spoof risk. With no retry rows, global retry behavior is unchanged; once any exists, matching retry rows allow-list received floods while the global retry gates and flood.retry.bridge algorithm still apply. scope=BlackHole86 directly derives a regionless sink scope; region=BlackHole86 would require a configured flood-allowed region. All rows match the original receive-time packet. Higher numeric priority runs first; equal values automatically order authenticated channels before raw hashes before channel=*, then use lower slot as the final tie-break. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region makes its region= rewrite and paired stop inert until the name returns, allowing lower safety rows to run. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. get flood.rule[.n], set flood.rule[.n] type=<type> [hops=...] [channel=...] [prefix=...] [in=...] <drop|scope=...|region=...|rate=N/min|retry|stop> [priority=0-255], del flood.rule.<n>|all set flood.rule.2 type=any channel=#hamradio retry flood.moderation Decrypts keyed GRP_TXT channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports public, #channel, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. get flood.moderation[.n], set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...], del flood.moderation.<n>|all set flood.moderation public \"Noisy User\" rate=5/min hops=4 clock.sync.mesh Defaults on for all repeater, sensor, and room-server builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. clock.sync.mesh now queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its .table and .1 through .16 forms inspect collected samples. get clock.sync.mesh, set clock.sync.mesh <on|off>, clock.sync.mesh now, get clock.sync.status[.table|.1-.16] set clock.sync.mesh on clock.sync.mesh.edge Defaults on so edge infrastructure nodes can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so disabled forwarding and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. get clock.sync.mesh.edge, set clock.sync.mesh.edge <on|off> set clock.sync.mesh.edge on clock.sync.internet Adds a read-only internet/NTP estimate at the same initial and seven-day checks on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. get clock.sync.internet, set clock.sync.internet <on|off> set clock.sync.internet on clock.sync.drift Absolute correction threshold in seconds; the default is 600 seconds (10 minutes). The clock is moved forward or backward only when the estimate differs by more than this value. get clock.sync.drift, set clock.sync.drift <30-86400> set clock.sync.drift 600 clock.sync.samples Minimum fresh evidence count before mesh time can be used: distinct receive paths in normal mode or distinct advert keys/Public-channel display names in edge mode. A strict majority of all fresh samples is also required. Range 3-16; default 9. get clock.sync.samples, set clock.sync.samples <3-16> set clock.sync.samples 9 outpath Overrides the primary direct route used for replies to the current remote client. get outpath, set outpath <hops>, set outpath direct, set outpath clear, set outpath flood set outpath A1B2C3,D4E5F6 altpath Adds a secondary direct route for repeater replies to the current remote client. get altpath, set altpath <hops>, set altpath direct, set altpath clear, set altpath flood set altpath 71CE82,BA09F0"},{"location":"halo_keymind_settings/#other-keymind-commands","title":"Other Keymind Commands","text":"Command What it does How to use Example send text.flood Sends a #repeaters flood text message formatted as <node_name>: <message>, with : in the node name sent as ;. send text.flood <message> send text.flood checking ridge link"},{"location":"halo_keymind_settings/#battery-alerts","title":"Battery Alerts","text":"

    Battery alerts are off by default. Enabling requires a named region. With no region argument, the repeater selects the single deepest (most narrow) region in the hierarchy; if multiple regions tie, the command asks for an explicit region. For example, after region def west pnw wa w-wa sea, set battery.alert on selects sea, while set battery.alert on w-wa overrides the default. Alerts are never sent as unscoped floods, and removing the selected region stops alerts until a valid scope is selected again.

    The repeater suppresses alerts for its first 30 minutes of uptime. It then checks every 30 minutes and sends a flood text warning to #repeaters when voltage is above 1 V and the estimated battery percent is below battery.alert.low.

    Warnings and critical alerts both use a 12-hour resend cooldown, beginning only after the radio reports that the alert transmission completed.

    Defaults:

    Setting Default battery.alert off battery.alert.region <unset> battery.alert.low 20 battery.alert.critical 10

    Example:

    set battery.alert.low 20\nset battery.alert.critical 10\nset battery.alert on\nget battery.alert\nget battery.alert.region\n

    CPU power saving remains compatible with the check. The battery timer never requests a wake earlier than its 30-minute deadline; when the normal loop is already awake after that deadline, no additional wake is needed. Sleeping time counts toward the startup delay, and an outbound warning prevents another sleep until the queued packet has been handled. This is separate from RX duty-cycle power saving, which only cycles the LoRa receiver and does not stop the main loop's battery timer.

    "},{"location":"halo_keymind_settings/#recent-repeater-table","title":"Recent Repeater Table","text":"

    Direct retry uses the recent repeater table when direct.retry.heard is on. Bridge buckets also use this table: a configured bucket prefix is active only when it was heard within the last hour.

    Show learned rows:

    get recent.repeater\nget recent.repeater 2\nget recent.repeaters 2\nget recent.repeater page 3\nget recent.repeaters search A1\nget recent.repeaters search A1B2 page 2\n

    Search matches overlapping 1-, 2-, and 3-byte path hashes. Each matching row shows its stored SNR and a whole s, m, or h age measured from the most recent recording. Search pages contain up to six rows.

    Seed or correct a prefix:

    set recent.repeater A1B2C3 8.5\n

    Clear learned and manually seeded rows:

    clear recent.repeater\n

    Rows are sorted by prefix width, then SNR. A full direct retry failure lowers the matching row by 0.25 dB. Unfiltered paged replies contain up to 10 rows; an unpaged local serial query prints the full table.

    "},{"location":"halo_keymind_settings/#direct-path-overrides","title":"Direct Path Overrides","text":"

    outpath and altpath apply to the current remote client ACL entry. They need remote client context, so they are not useful from the local serial CLI.

    Set paths with comma-separated hop hashes. Each hop must be 2, 4, or 6 hex characters, and all hops in one path must use the same width. Hex input is case-insensitive. Replies use uppercase hex and retain the commas, so the value can be copied directly into another set outpath or set altpath command.

    get outpath\nget outpath path\nset outpath A1B2C3,D4E5F6\nset outpath path\nset outpath direct\nset outpath clear\nset outpath flood\nget altpath\nset altpath 71CE82,BA09F0\nset altpath clear\n

    For example, both set altpath 600000,0d2784,f8dada and Set altpath 600000,0d2784,f8dada store the same path and reply with:

    > 600000,0D2784,F8DADA\n

    The first CLI word is case-insensitive (set, Set, and SET are the same, as are get, Get, and the other command verbs). Argument case is preserved.

    get outpath path reports the reciprocal path received after the latest flood login without changing outpath. That packet arrives asynchronously, so retry shortly if the first query reports > path pending; the observation window expires after one minute. The login path is not automatically selected. set outpath path copies the observed route into outpath and saves it; it leaves the current route unchanged when no observed route is available.

    set outpath direct sets a zero-hop direct route for a client reachable without repeaters. set outpath clear forgets the override, replies > outpath cleared, and lets normal path discovery fill it again. set outpath flood forces replies to use flood packets until the client logs in again.

    When outpath is a valid direct path and altpath is also a valid, different direct path, repeater DM replies send two packets: one on outpath and one on altpath. The secondary altpath copy does not create its own direct-retry state, so retry tracking stays attached to the primary outpath packet. altpath clear disables the secondary direct reply. altpath flood is accepted for command symmetry, but it does not create a second flood reply; only a valid direct altpath sends the second packet.

    "},{"location":"halo_keymind_settings/#direct-retry-settings","title":"Direct Retry Settings","text":"

    Direct retry applies to direct-routed packets. A queued resend is canceled when the next-hop echo is heard. Repeaters expose the settings below; non-repeater firmware uses the same packet-type timing rules with fixed shared base/step timing.

    Setting What it does How to use Example retry.preset Applies shared direct and flood retry defaults. Values: infra, rooftop, mobile or 0, 1, 2. get retry.preset, set retry.preset <value> set retry.preset rooftop direct.retry.heard Uses the recent repeater table as the direct retry eligibility gate. get direct.retry.heard, set direct.retry.heard on/off set direct.retry.heard on direct.retry.margin SNR margin in dB above the SF-specific receive floor. get direct.retry.margin, set direct.retry.margin <0-40> set direct.retry.margin 5 direct.retry.count Maximum direct retry attempts after initial TX. Direct-routed type 2 text packets always use 21 attempts regardless of this setting or the short-path cap. get direct.retry.count, set direct.retry.count <1-15> set direct.retry.count 15 direct.retry.base Base wait in milliseconds before retry; packet-length add-on is 3x for TRACE and ANON_REQ/type 7, 7x for TXT_MSG/type 2, and 6x for other direct retry packets. get direct.retry.base, set direct.retry.base <10-5000> set direct.retry.base 175 direct.retry.step Milliseconds added per retry attempt after the base, packet-length add-on, and random forwarding jitter. get direct.retry.step, set direct.retry.step <0-5000> set direct.retry.step 100 direct.retry.cr Adaptive coding-rate thresholds for repeater direct retry packets. Repeaters use CR4, CR5, CR7, or CR8, then escalate by attempt: CR4, CR5, CR7, CR7, then CR8 from a CR4 start; CR5, CR7, CR7, then CR8 from a CR5 start. Non-repeaters start at the current radio CR and follow the same escalation pattern, clamped at CR8. get direct.retry.cr, set direct.retry.cr <cr4_min>,<cr5_min>,<cr7_min>,<cr8_max>, set direct.retry.cr off set direct.retry.cr 10.0,7.5,2.5,0

    The default adaptive coding-rate profile is 10.0,7.5,2.5,2.5. SNR 10.0 dB and up uses CR4, 7.5 dB and up uses CR5, 2.5 dB and down uses CR8, and the middle band uses CR7. If no recent repeater table entry is available, retry packets use CR5. Use set direct.retry.cr off to disable adaptive coding-rate overrides. Repeater attempts escalate from the adaptive starting CR: CR4, CR5, CR7, CR7, then CR8 from a CR4 start; CR5, CR7, CR7, then CR8 from a CR5 start. Non-repeaters use the current radio CR as the first retry CR and follow the same pattern up to CR8.

    Preset details:

    Preset Base Count Step SNR gate infra 275 ms 4 150 ms SF floor + 15 dB rooftop 175 ms 15 100 ms SF floor + 5 dB mobile 175 ms 15 50 ms SF floor

    Example for a quiet fixed repeater:

    set retry.preset rooftop\nset direct.retry.heard on\nset direct.retry.margin 5\n

    Example for a moving or weak-link node:

    set retry.preset mobile\nset direct.retry.margin 0\n
    "},{"location":"halo_keymind_settings/#flood-and-advert-settings","title":"Flood And Advert Settings","text":"

    Flood retry applies to flood-routed packets. A queued retry is canceled when the same packet is heard from a qualifying, non-ignored repeater. Bridge mode uses the bucket rules below instead.

    Setting What it does How to use Example flood.retry.count Base flood retry attempts after initial TX. Role path scaling happens first, then all builds apply payload caps: REQ is 0; GRP_TXT keeps up to 15; remote-login RESPONSE, TXT_MSG, ANON_REQ, and PATH keep up to 15 at path count 0 and cap at 2 in transit; every other flood type caps at 1. A lower calculated value is preserved, and 0 disables flood retry. get flood.retry.count, set flood.retry.count <0-15> set flood.retry.count 7 flood.retry.path Maximum path hash count eligible for flood retry, or off to disable the gate. get flood.retry.path, set flood.retry.path <0-63/off> set flood.retry.path 1 flood.retry.group.path Additional path gate for group data (type=6) flood retries. The stricter of this and flood.retry.path applies; off disables only this additional gate. Setting the general path gate to 0 forces this setting to off; a named preset restores the default of 1. get flood.retry.group.path, set flood.retry.group.path <0-63/off> set flood.retry.group.path 1 flood.retry.advert Allows or blocks retry for node advert packets (type=4). Default is off. get flood.retry.advert, set flood.retry.advert on/off set flood.retry.advert off flood.retry.prefixes Target prefixes. If set, only same-packet echoes from matching last-hop prefixes cancel a retry. get flood.retry.prefixes, set flood.retry.prefixes <prefixes/none/off> set flood.retry.prefixes BEEBB0,425E5C flood.retry.ignore Ignored prefixes. In non-bridge retry, ignored last-hop echoes do not cancel retry. get flood.retry.ignore, set flood.retry.ignore <prefixes/none/off> set flood.retry.ignore 71CE82,C7618C flood.retry.bridge Enables bucket-based bridge retry logic. get flood.retry.bridge, set flood.retry.bridge on/off set flood.retry.bridge on flood.retry.bucket.<n> Shows one bridge bucket. Buckets are numbered 1-6. get flood.retry.bucket.<n> get flood.retry.bucket.1 flood.retry.bucket Sets bridge bucket prefixes. set flood.retry.bucket <1-6> <prefixes/none/off> set flood.retry.bucket 1 71CE82,C7618C

    Extended flood.rule rows can select which received floods may use these settings. For example, enable bridge completion only for Public and #hamradio with set flood.rule type=any channel=public retry and a second row using channel=#hamradio. Adding the first active retry row changes received retry selection into an allow-list; deleting the last restores the legacy global selection. channel=hash:XX is available when only the visible one-byte hash is known, but it is not authenticated and is collision-prone. For example, hash:11 includes Public and any other packet using 0x11; channel=public alone performs the Public-key MAC/decrypt check.

    Bridge buckets may also be reused as passive flood.channel.scope path selectors with path=bucket:<1-6>. This does not require flood.retry.bridge to be enabled and does not depend on recent-repeater freshness or the retry ignore list.

    Forwarded neighbor adverts also use an automatic echo guard in every build. If this node hears a downstream, longer-path echo after transmitting an advert whose signed timestamp is less than six hours old, it will not forward that exact advert again during the six-hour age window. This guard works even when flood.retry.advert is off and does not affect self-originated adverts.

    Self-originated advert retries are deliberately slow: the first retry waits at least one extra minute beyond the normal airtime-aware delay. Queueing a newer self advert retires queued and future retry attempts for older self adverts without disturbing other flood retry sequences. Companion firmware allows the one slow retry for its own adverts but not for neighbor adverts it relays.

    The shared retry preset sets these flood defaults:

    Preset Retry count Path gate Group-data path gate infra 1 1 1 rooftop 3 2 1 mobile 15 1 1

    Hop-zero flood retries use the direct-retry CR ladder starting at the active radio CR. With CR5, infra allows CR5, CR7; rooftop allows CR5, CR7, CR7, CR8, CR8, CR8; mobile allows CR5, CR7, CR7, then CR8 through retry 15. These lists exclude the initial send and remain subject to payload caps and echo cancellation. Floods with recorded hops keep the active CR. flood.retry.count 0 still disables every automatic flood retry, including hop zero, without changing direct-retry settings or their CR schedule. See hop-zero flood retry coding rate for every starting CR and the payload limits.

    Example for path-gated retry:

    set retry.preset rooftop\nset flood.retry.path 1\nset flood.retry.group.path 1\nset flood.retry.advert off\nset flood.retry.ignore 71CE82,C7618C\n
    "},{"location":"halo_keymind_settings/#north-south-buckets","title":"North South Buckets","text":"

    Buckets describe groups of repeaters on different sides of this relay. Bucket numbers do not have built-in meanings; this example uses bucket 1 for North and bucket 2 for South.

                  North bucket 1\n        +-----------------------+\n        | A1B2C3       D4E5F6   |\n        | North A      North B  |\n        +-----------+-----------+\n                    |\n                    v\n              +-----------+\n              | This node |\n              +-----------+\n                    ^\n                    |\n        +-----------+-----------+\n        | 71CE82       C7618C   |\n        | South A      South B  |\n        +-----------------------+\n              South bucket 2\n

    Configure the buckets:

    set flood.retry.bridge on\nset flood.retry.bucket 1 A1B2C3,D4E5F6\nset flood.retry.bucket 2 71CE82,C7618C\nset flood.retry.ignore none\n

    Packet heard from the North:

         heard source\n         |\n         v\n  +--------------+        retry targets\n  | North bucket | -----> South bucket\n  | bucket 1     | -----> Other fresh/unbucketed relays\n  +--------------+\n

    Packet heard from the South:

         heard source\n         |\n         v\n  +--------------+        retry targets\n  | South bucket | -----> North bucket\n  | bucket 2     | -----> Other fresh/unbucketed relays\n  +--------------+\n

    Packet heard from an unbucketed or pathless source:

         heard source\n         |\n         v\n  +--------------+        retry targets\n  | Other bucket | -----> North bucket\n  | implicit     | -----> South bucket\n  +--------------+\n

    Bridge retry stays eligible until every target bucket has been heard or flood.retry.count is exhausted. A configured bucket is a target only when at least one of its prefixes is fresh in recent.repeater. Prefixes in flood.retry.ignore never count as bucket hits.

    Configuration reports a warning when prefixes in different buckets, including bucket 7 (flood.retry.prefixes), share the same first byte. A 1-byte path cannot distinguish those buckets. Bridge mode therefore excludes every matching bucket when that short prefix is the source, and credits every matching target bucket when it is heard as an echo. This prevents an ambiguous short prefix from keeping an impossible target outstanding through every retry.

    Each flood retry wait retains the fixed maximum-frame plus 20 packet-airtime delay, then adds random jitter from zero to 200 percent of one additional packet airtime. This keeps nearby repeaters from repeating a collision on fixed timing while capping the added wait at two frames.

    Only one enhanced retry sequence can be active for the same logical flood packet. Identical floods still receive their normal transmission, but do not multiply the extra attempts. Evicted queued retries release their bridge state, and the final echo wait does not reserve a packet-pool entry.

    Earlier path hops from a successful bridge echo refresh a separate per-bucket reachability cache without an SNR value. Only the final hop, which actually sent the received RF frame, updates recent.repeater and its SNR. Indirect path hops therefore cannot change direct-retry SNR gating or coding-rate selection.

    "},{"location":"halo_keymind_settings/#troubleshooting","title":"Troubleshooting","text":"

    If advert packets are still retrying:

    get flood.retry.advert\nset flood.retry.advert off\n

    If ignored prefixes still appear in flood retry good logs:

    get flood.retry.ignore\nset flood.retry.ignore <prefix>\n

    The ignored prefix must match the last hop shown as heard=<prefix>. For example, this log needs C7618C in the ignore list:

    flood retry good (... path=7773D0>C7618C, heard=C7618C ...)\n

    If retries are too aggressive:

    set flood.retry.count 1\nset flood.retry.path 1\nset direct.retry.count 4\n

    If retries are too sparse:

    set flood.retry.count 7\nset flood.retry.path 2\n
    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/","title":"XIAO Bluetooth stealth hardware validation - 2026-09-07","text":"

    Historical command syntax: the initial run used stealth as a MAC mode. Current firmware uses the independent bluetooth.stealth on|off flag instead and does not migrate that development mode. See current commands and the independent-flag follow-up below. Each observation applies only to the exact artifact listed in its section.

    This is a hardware observation report, not an all-board or phone qualification. The test host was the MercerWoodMesh Raspberry Pi Zero 2 W, accessed remotely over Tailscale. Tests used its built-in Bluetooth adapter, not USB resets or hub power switching. Local timestamps below use America/Los_Angeles.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#exact-target-and-firmware","title":"Exact target and firmware","text":"

    The application was installed with Nordic Legacy BLE DFU. The tool verified the exact ZIP hash, bootloader advertisement and live DIS model XIAO nRF52840 before transmitting 616,584 application bytes. Target image validation and target-initiated activation disconnect passed. The transfer reported about 2,223 bytes/second (about 277 seconds for payload transmission). Authenticated MeshCore device queries subsequently returned protocol 14, board Seeed Xiao-nrf52, and the expected unique test-version prefix.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#observed-results","title":"Observed results","text":"Check Result and evidence First-pair discovery set bluetooth.mac stealth saved a new random-static address; reboot returned that exact address with the normal name and Nordic UART advertisement. First authenticated pairing The Pi received a passkey request and completed pairing. BlueZ reported both Paired and Bonded. MITM-protected Nordic UART notifications and real command/reply exchanges worked at MTU 247. Pairing transition keeps the session alive The initial connection returned stealth; bonded-peer-only advertising, remained usable for five more seconds, and answered another command without disconnecting or pairing again. Advertising after disconnect A public-address passive scan captured ADV_DIRECT_IND from the exact target, with advertising data length zero. The capture contained no undirected advertisement or scan response from that address. Bonded reconnect Subsequent direct connections by saved BlueZ device identity succeeded without a new passkey, including live version, core-statistics and Bluetooth-mode replies. Warm reboot persistence Two commanded reboots retained the address and saved bond. Directed advertising was captured after reboot, followed by authenticated reconnect. Device uptime decreased from 61 seconds before the second reboot to 35 seconds at the next query; core error flags were zero. Slow advertising interval A successful bonded reconnect occurred about 61 seconds after reboot, beyond the 30-second fast-advertising interval. Reopen pairing Setting stealth again from an existing bonded connection generated a new address, and the next boot allowed a fresh first pairing. Restore default set bluetooth.mac default plus reboot restored E5:C3:A8:B0:60:66 and normal discovery. Fresh PIN pairing, version/core-statistics queries and repeated factory address (default) replies passed. The bench target was left in this discoverable mode.

    The newly completed pairing reaches Bluefruit's secured callback before its bonded flag is necessarily ready. Recording the completed pairing in the pair-complete callback as well, waiting for the deferred bond write, and deferring identity-list reconfiguration until disconnect are covered by this fresh-pair hardware run. Static source contracts also check those paths.

    The binary reboot command can reset the target before BlueZ receives its ATT write acknowledgement. One initial harness run reported Unlikely Error after successful pre-reboot commands. Later checks require an observed disconnect, a live post-reboot query and decreased device uptime; they do not treat the write exception or tool exit code alone as a successful reboot.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#discovery-and-dfu-cautions-found-during-testing","title":"Discovery and DFU cautions found during testing","text":"

    An ordinary BlueZ discovery scan used a temporary random local address and did not show the directed target. A passive scan using the Pi's public identity did show it. Reconnection must use the saved peer/device identity; absence from an ordinary scan is not proof that the node stopped working. Conversely, BlueZ can retain a cached name after pairing, so a displayed name is not proof that the name is still being advertised. The packet-type and zero-length-payload capture is the relevant evidence here.

    The existing DFU handoff helper assumed the application and bootloader shared one address. That assumption is false with a custom application address: the authenticated handoff worked, but the helper timed out looking for DFU at the application address. A fresh exact-address scan found the known factory-address XIAO_DFU instead. An old factory-address host bond also caused the first DFU service-discovery attempt to disconnect; clearing only that old lab record allowed DFU. No bootloader code was changed for this test.

    An earlier application-address host pairing record was also removed before its saved stealth state was known. A direct, known-address connection from the same Pi identity allowed fresh PIN pairing and protected commands again. This does not prove that an arbitrary replacement phone can rediscover or recover a stealth node after forgetting its bond.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#remaining-hardware-gates","title":"Remaining hardware gates","text":""},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#repeatable-regression-procedure","title":"Repeatable regression procedure","text":"
    1. Record the exact board, application version, ZIP hash, local/remote BLE identities and original Bluetooth setting. Keep a working recovery path.
    2. Set the desired custom or random MAC, enable bluetooth.stealth on, reboot, and capture normal discovery before pairing.
    3. Pair once. Require a stored bond and successful protected MeshCore commands, then keep the original session open and issue another command.
    4. Disconnect. Capture the actual advertising type and payload with an appropriate receiving identity; do not use cached scan names as evidence.
    5. Reconnect by the saved peer without another PIN and exchange commands.
    6. For custom and saved-random addresses, repeat bluetooth.stealth on, reboot twice, and verify unchanged identity/bond, decreased uptime and live commands. Include a reconnect after the fast-advertising period ends.
    7. Separately remove all target power and repeat the persistence checks.
    8. Test bluetooth.stealth off, then on, as a pairing reset, and off as the exit from stealth. Verify live discovery, pairing, commands and an unchanged MAC policy. Changing the address to default is a separate step.
    9. With each rotating MAC policy, verify that its rotation trigger still works, stealth stays enabled, and the new address permits fresh pairing.
    10. Repeat relevant gates for a phone with address privacy and each BLE backend.

    Raw VM-side logs, the exact artifact and bounded test helper are in /home/mesh/mercer-stealth-validation.jUDW0v/. Raw Bluetooth captures are local diagnostics and should not be published without privacy review.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#mercer-crash-capture","title":"Mercer crash capture","text":"

    During this run the Pi used its existing dwc_otg.speed=1 full-speed USB configuration. Kernel and health logs were streamed to this VM. No new kernel errors or Pi lockup were observed during these tests; that does not establish the cause of the earlier lockup.

    The live test streams and all temporary scans were stopped at approximately 22:56 PDT. Journald remained active, the restore service remained enabled, and the test XIAO was disconnected in default mode. Disk journals occupied about 4.9 MiB at the final check.

    At 22:35 PDT, temporary persistent journaling was enabled with a 16 MiB configured disk-use limit, 2 MiB journal files, one-day retention and a 30-second normal sync interval. Recent buffered messages can still be lost in a sudden failure. The original Storage=volatile configuration is intact.

    mercer-crash-capture-restore.service is enabled for the next Pi boot. It checks the armed boot ID, removes only the unchanged managed override, switches journald back to RAM and leaves the old disk journals for collection. Its same-boot no-op guard and unit syntax passed. The next-boot restoration has not yet been exercised; no Pi crash or reboot was deliberately induced.

    "},{"location":"hardware_validation_bluetooth_stealth_2026-09-07/#independent-flag-follow-up-dev-stflag","title":"Independent-flag follow-up - dev-stflag","text":"

    The same XIAO and Pi were used later on 2026-09-07 to test the separate bluetooth.stealth on|off flag, with no development-mode migration. This run used Xiao_nrf52_companion_radio_ble, target radio preset, default profile, and firmware dev-stflag-4b400ea5 with the uncommitted flag changes.

    Check Observed result Default and rejected input Initial mode was factory address with stealth off. set bluetooth.mac stealth and set bluetooth.stealth maybe returned errors, leaving both settings unchanged. Custom plus stealth D2:27:5A:40:06:99 advertised normally before first pairing. Fresh PIN pairing, stored bond, protected commands, and a five-second held session passed with stealth on. Repeated on and warm reboot Repeating set bluetooth.stealth on retained the paired state. After reboot, the same custom address accepted protected commands without another PIN. Uptime decreased from 84 to 67 seconds. Actual advertising after reboot A public-address passive capture showed ADV_DIRECT_IND for the custom address, data length zero, and no undirected advertisement or scan response from that address. The scan was explicitly disabled afterward. Off preserves custom address set ble.stealth off and reboot restored normal discovery at the same custom address. A live connection returned stealth off and the unchanged custom policy. Saved random plus stealth F2:2E:E5:65:18:5E passed first pairing and held commands. After reboot, it retained its address, saved-random policy and stealth bond; protected reconnect needed no PIN. Uptime decreased from 55 to 30 seconds. Random-after-connect plus stealth Selecting this policy kept stealth on and reopened pairing at C7:FD:29:44:4F:3E. Pairing produced both bonded-peer-only status and an armed next-boot rotation. Reboot changed the address to F5:F3:89:B7:B5:26, where normal discovery, fresh pairing and protected commands passed with stealth still on. Random-every-boot plus stealth The first boot used F9:B2:03:DD:CC:43, completed fresh pairing, and answered commands with stealth on. The next commanded reboot advertised F8:C7:C0:96:1E:00; fresh PIN pairing and protected commands passed again with the rotating policy and stealth on. Restore the bench target Setting the MAC to default kept stealth on until the separate set bluetooth.stealth off. After reboot, factory-address discovery, fresh PIN pairing and protected commands confirmed default MAC and stealth off. The obsolete factory-address host bond was replaced during this final pairing.

    These checks used actual encrypted BLE connections at MTU 247, not scan sightings alone. Two first-pair harness attempts stopped before connecting because their unpaired BlueZ discovery object no longer existed. A fresh bounded scan immediately followed by pairing was used for retries; this was not a firmware GATT command failure. Reboot-write/disconnect races were accepted only with an observed disconnect and subsequent live device checks.

    Local automated verification passed 39 native cases, 32 source/persistence/UI contracts, and 11 real-browser cases. Supported-wrapper builds passed for XIAO nRF52, XIAO C3, M5Stack Unit C6L and XIAO S3 WIO Full Companion, covering nRF52, ESP32 NimBLE and ESP32 Bluedroid integration. The contracts and browser suite are included in the unit-test workflow; a browser must be available for the optional browser suite to execute instead of skip.

    Exact artifacts, build logs, command logs and the private packet capture are in /home/mesh/bluetooth-stealth-flag.ApLB8o/ on the VM. The remaining hardware gates above still apply; these results do not qualify every board or phone.

    At 23:42 PDT the XIAO was disconnected in normal discoverable mode. The Pi retained the same boot ID throughout, remained responsive, and had no new kernel messages in the streamed log. Journald was active, the next-boot volatile-log restoration service was still enabled, and disk journals used about 5.9 MiB. No USB reset, Pi reboot, or target battery removal was used.

    "},{"location":"hardware_validation_checklist/","title":"Hardware validation checklist","text":"

    This file records dated hardware observations and open validation tasks. Historical defaults, versions, and test frequencies below describe the tested revision, not necessarily the current release. For current ASCII USB defaults and runtime switches, use feature switches by role.

    Use this checklist for release-candidate hardware runs. A check is complete only when its log identifies the physical device, firmware artifact, artifact hash, command result, and cold/warm boot outcome. Do not infer success from a tool's exit code when the tool has a documented false-success mode.

    "},{"location":"hardware_validation_checklist/#bluetooth-stealth-qualification-2026-09-07","title":"Bluetooth stealth qualification - 2026-09-07","text":"

    The XIAO stealth report records exact artifacts for the initial implementation and independent-flag follow-up. The flag run covers custom/saved-random pairing and bonded reboot reconnect, idempotent on, off preserving the MAC, directed advertising, and both rotating policies with stealth still enabled. It also gives the repeatable hardware regression procedure. True battery-disconnected cold boot, phone address-privacy behavior, a second unbonded central, and ESP32 hardware remain separate uncompleted gates; do not infer them from the XIAO results.

    "},{"location":"hardware_validation_checklist/#current-marathon-ledger-through-2026-09-04","title":"Current marathon ledger (through 2026-09-04)","text":"Hardware Stable identity Current state Next blocking check Seeed XIAO nRF52840 B35E71C1C3726CE7 OTAFIX candidate 0x02040405 passed exact serial install and corrected Legacy DIS model gates; the patched Bluetooth repeater-updater application was restored over BLE and returned as exact 2886:8044 USB Use the updater for an identity-gated Bluetooth transfer to another repeater, then prove current-build cold persistence Seeed Tracker T1000-E 34A9141999729D5D OTAFIX candidate 0x02040405 and the uf2reset-fixed Full Companion are installed. The real text command returned exact 2886:0057 boot USB, the same fixed app was restored, and refreshed bonded BLE services pass as MeshCore-09848C15 Restore the protected Repeater identity when Companion qualification is complete; cold boot, then force LR1110 reset RAK3401 0B81C9C68D8D01B4; FICR 8D8D01B4 0B81C9C6 OTAFIX test version 0x02040403 passes bidirectional application UF2, exact SWD application/bootloader readback, unchanged UICR, and a 74-block direct V4 LoRa apply from exact 3caf9dcf to current HEAD 9fd580c8. Post-apply USB and LoRa identity/hash checks pass and the temporary 0 dBm bench setting is restored to 22 dBm Add exact RF packet counters, then repeat the supported-bandwidth and controlled/passive/mixed routed-hop matrix Heltec T096 651F8E496197F882 OTAFIX candidate 0x02040405 and the uf2reset-fixed no-footer Full Companion are installed. The real text command returned exact boot USB, and final USB terminal/Binary mode, dual CDC, version, help, and BLE identity pass Perform a privileged or physical host-driven USB bus reset without resetting the MCU; complete the deferred multi-click/long-press physical matrix Heltec MeshTower V2 with SD 9352162A72082314 OTAFIX candidate 0x02040405 passed serial combined install, the historical cached DIS handles, BLE application restore, and exact application USB return. Card format/cooldown/forced-format/raw-erase/remount and the live SD apply contract passed earlier Signed interrupted-download resume, corruption/signature rejection, then full/delta apply Heltec V4 USB MAC 44:1B:F6:6A:E8:44; BLE 44:1B:F6:6A:E8:45 USB/BLE/Wi-Fi and all three TCP services pass on hardware. The final fresh-NTP-gated Full Companion image builds with 4.31 MB app space free Flash the exact final NTP build, prove NTP-before-TLS success/failure ordering, then OTA seeding SenseCAP Indicator LoRa CH340 plus USB MAC D8:3B:DA:75:23:AC; BLE D8:3B:DA:75:23:AD The current dark-layout application is SHA-256 81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b; identity-gated flash, USB ASCII/Binary switching, runtime logging, and separate configured-Wi-Fi, BLE, and TCP checks pass. Fresh-NTP HTTPS recovery, strict Range resume, STAGEV2 install, and RP USB readback passed on earlier exact artifacts; the final coordinator source still needs its blocked-UDP/123 hardware gate Physical display-wrap and four-mode render/heap checks, exact-final blocked-NTP recovery gate, LoRa/TempRadio, and non-empty OTA seeding

    The current source checkpoint passes 1,132/1,132 native cases, 68/68 host broker/endpoint cases, all 318 LoRa-OTA chain/route/bundle cases, and 41/41 MOTA format/signature/delta cases under the documented pipx detools interpreter. Indicator geometry, dark-theme, and font-recovery contracts pass 4/4, 6/6, and 25/25. A serialized real Indicator ESP32-S3 Wi-Fi/USB profile also compiles at 95,944 bytes RAM and 1,589,433 bytes flash. The direct system-Python MOTA invocation predictably lacks detools; that environment boundary is not a firmware failure and must not be worked around by installing the package globally.

    The RAK failure log is /home/mikec/hwtest/runs/rak3401-syncidle-clean-tSp7PLgA/result.txt on the Mercerwood Pi. The tested UF2 SHA-256 was 650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7. cp returned success in 95 ms, but sync -f failed with EIO after 157,188 ms. Post-failure SWD readback proved that all seven ordinary sectors whose bytes differed between the old and intended builds still contained the old values, and the settings page was still erased. The only available pre-copy SWD dump was taken several hours earlier, however, so it cannot prove that zero unchanged blocks were written during this attempt or establish when the stale S140-v7 tail in the first application page appeared. The next run must capture an immediate pre-copy dump as well as the post-copy dump. Serial DFU recovery then completed in 35,698 ms with the exact Device programmed. marker. All 1,830 UF2 payload blocks matched SWD readback after recovery, and the application passed a subsequent real power-removal boot.

    The corrected 2.4.4-preview.2 candidate was installed through identity-gated SWD from combined-image SHA-256 9b1654baccd2a920f68651893e31d79170400d1a7982a3f854ae640f1d3513a8. An immediate pre-copy dump closed the earlier evidence gap. A deliberately old UF2 then copied and flushed successfully in 16,568 ms, and the exact formerly failing UF2 above copied and flushed successfully in 16,601 ms. After each direction, all 1,830 payload blocks matched a full SWD readback with zero header or payload mismatches. The return update changed 123 bytes across five expected application pages, UICR remained byte-identical, and a subsequent downstream USB power removal plus Pi reboot returned the RAK as the same stable identity. It reported the intended 3caf9dcf application, board, node name, USA radio tuple, and 22 dBm setting after that cold boot. The new bootloader's CLI source string is truncated before the injected preview number, so the independently hashed SWD artifact is the authoritative candidate identity for this run.

    The later TinyUSB lifecycle/generation candidate identifies itself as test version 0x02040403. Its exact 40,960-byte bootloader readback SHA-256 is f609eb18754070c54f127325b1cb48547baf5c6b1c695d36fd0c8e0874713c13. With that candidate installed, a full CURRENT.UF2 read completed before each write. The 936,960-byte bf092b24 application UF2 (SHA-256 12477a9734cf12eb920361bd21024e39bf303953fcf79d0bf720e00847e2ae92) copied and flushed in 28,203 ms and returned to application mode four seconds later. The exact 3caf9dcf application UF2 (SHA-256 650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7) then copied and flushed in 26,479 ms and also returned in four seconds. Both boots retained RAK3401-OTA-BENCH and 910.525/62.5/SF7/CR5.

    Identity-gated SWD then read the entire 468,480-byte padded UF2 application range. Its SHA-256 exactly matched the converted source on both sides at b2e37a7681027ca91d1b2bd8e39b55395d9e91d4863aed17131e50c1ba1d2227. The bootloader still matched the installed 0x02040403 image byte-for-byte, and UICR still matched the pre-test image at d029ea630c2f632a1b690cb52b2a96a28b6f870c656c3e1bd74ba3a571c53b9c. The authoritative logs are under /home/mikec/hwtest/runs/rak3401-uf2-02040403-20260830-170829 and /home/mikec/hwtest/runs/rak3401-uf2-return-02040403-sfHiGEqA on Mercerwood.

    The subsequent direct LoRa qualification used the V4 as controller/seeder and the RAK's exact bf092b24 body 91CF4EC2882F23D6 as the base. Its mandatory fixed three-minute rehearsal passed on 909.95/250/SF5/CR5, including natural expiry and exact normal-radio recovery. The 2,132-byte, two-block in-place delta reached 2/2 and ready in six seconds; install was accepted, and the new body appeared at the 60-second reboot probe. The automation took 514 seconds including rehearsal, clock/radio safety checks, and post-install verification. The final body was 27A223DDFC3A8F47, version 1.17.1.5 at 3caf9dcf, and the bootloader reported blrc:B8, which is OTAFIX's successful application apply result rather than an error. Exact target TX power was wrapped from 22 dBm to 0 dBm for the co-located transfer and restored to 22 dBm afterward; the source RXPS configuration and both normal radio tuples were also restored.

    Identity-gated SWD then read the live application at 0x26000. All 468,384 bytes matched the intended release image exactly (SHA-256 b496e3f4419c9f771564ed3c74b3407822f45acdf60cbd6c4625a54b181ca084), and the independently recomputed EndF hash was 27A223DDFC3A8F47. The target ID was 2FA509C1, hardware ID was RAK_3401, and version word decoded to 1.17.1.5. The 40,024-byte bootloader readback also matched the exact installed ffb1580/test-version-0x02040402 artifact byte-for-byte; UICR was unchanged. After SWD reset-halt/resume, USB again reported the expected application, identity, name, radio, 22 dBm, no TempRadio lease, and blrc:00 (the retained success diagnostic is intentionally cleared by the next normal boot).

    The current 0x02040403 bootloader candidate then passed a second direct V4 LoRa qualification using a materially larger application change. The unsigned in-place package was 75,614 bytes with 75,108 payload bytes and 74 transfer blocks. Its mandatory fixed three-minute rehearsal passed, including natural TempRadio expiry and exact normal-channel recovery; one lost read-only ota status reply was reconciled by its bounded exact retry. The transfer reached 74/74 and READY in 122 seconds, the explicit unsigned install was accepted, and reboot produced exact HEAD 9fd580c8 with body 744BC4443891ED10. The complete safety-wrapped run took 589.487 seconds, including rehearsal, recovery, and final verification. The first postboot ver reply was lost and its exact read-only retry passed. Independent USB checks then confirmed target 2FA509C1, hardware RAK_3401, unchanged identity/name/radio/RXPS/watchdog state, bootloader CRC 6683C066, ABI 3, apply result blrc:B8, and no active TempRadio/download/seeder/key state. After the separate host-recovery exercise, the near-field TX setting was restored from 0 dBm to its exact original 22 dBm and read back over an authenticated LoRa session. The sanitized authoritative log is /home/mikec/hwtest/runs/rak3401-02040403-head-vzWOXVxT/qualification-redacted.log with SHA-256 3e53dcaa5922c7c18a7890963b8fc092311d2db01caa5603640f43f1482802f6.

    A serialized VM build of the six connected-board qualification profiles then completed 6/6 in 136.122 seconds: XIAO nRF52 BLE Companion, T1000-E BLE Companion, RAK3401 LoRa-OTA Repeater, SenseCAP Indicator LoRa USB/Wi-Fi Companion, MeshTower V2 SD LoRa-OTA Repeater, and the consolidated Heltec V4 V4.2/V4.3 Full Companion. The earlier V4 selector typo was rejected before any compile began and is not a firmware build failure.

    After the fresh-NTP download gate, shared SNTP coordinator, MQTT-to-MeshCore RTC handoff, and daily refresh policy were added, the complete native matrix passed 1,102/1,102 cases in 93.953 seconds. The scheduler is host-tested at 24-hours-minus-one-millisecond, the exact 24-hour boundary, normal and wrapped millis() values, a short retry that overrides the daily cadence, the zero deadline sentinel, and a Wi-Fi event latch. A reconnect after a successful sample preserves the existing deadline, so successful samples are requested after boot and then only once per day. All current Python and shell contract suites passed, and the expanded LoRa-OTA automation suite passed 317/317 cases.

    Serialized real builds also passed after that change. The final event-safe implementation uses an atomic GOT_IP latch so even a short initial connection before the 10-second MQTT status sample cannot skip the required boot NTP request. The final Heltec V4 MQTT Observer application is 1,853,304 bytes with SHA-256 1938ae88fcdbe601cf8698df44b548656079562d899801886b808af913e5f9ac. The consolidated Full V4 image used 2,250,801/6,553,600 flash bytes and left 4,302,799 bytes of app-image space. Its 2,251,272-byte application image has SHA-256 05131eac29164be46d1bcceeb4f03455f5ea8d61c8d4f529b77d8adfe25fdb6a; the 2,316,808-byte merged image has SHA-256 c4c1fc639b6d0087c795a81aca79c07eba56dc4e071eb374dc70da0bec81815e. The focused daily-NTP policy suites passed all 42 cases after the latch was added, and the Full capability manifest verified all ten expected features. The reduced RAK3401 LoRa-OTA Repeater also compiled with the verified WisBlock I2C aliases. At that source state its link used 471,344/815,104 flash bytes; the reduced external-sensor contract retains INA219/INA226/INA260/INA3221 as the voltage/current entries in the optional environmental-telemetry table. These are not the image's only I2C consumers. The image separately retains its SSD1306 display, auto-discovered I2C RTCs, RAK12500 I2C GPS, and RAK12501/L76K UART GPS paths. The configured INA3221 and RAK12500 both default to I2C address 0x42; those two devices cannot coexist at those addresses. The supported combined arrangement leaves RAK12500 at 0x42, straps INA3221 A0 to SCL for 0x43, and uses a build with -DTELEM_INA3221_ADDRESS=0x43. The nRF52840 die-temperature and ADC battery-voltage paths are also retained. The 472,227-byte DFU ZIP SHA-256 is fdd369005a61635756efa9e38b217376a952f334b0864f927a46799b65264fa9, and the matching 943,104-byte UF2 SHA-256 is 2155632fde730e0ae2e4a72ce64f89910861f6c5105a4bd7ac96aaa538a33dca. These VM results do not replace the remaining exact-final hardware test with UDP/123 blocked.

    The later serialized RAK3401 contract build, after the non-blocking nRF52 USB logging change, used 471,576 bytes before its 56-byte EndF trailer. Its EndF body hash is 026749684bf6ee8f, target ID is 2FA509C1, hardware ID is RAK_3401, and version word is 0x01110105. The 472,435-byte DFU ZIP has SHA-256 cf0880b26f2d6bd8157f8af76a6718fe1f998de8ea7df8cd952e734448201fb9; the 943,616-byte UF2 has SHA-256 f893a8cebf309af12a4a6f5483acb28f94158e18632f660e011658c846e8ca7f. The linked ELF contains GPS, SSD1306, RTC, and the four INA families, while an exact symbol audit finds none of AHT10/20, BME280, BMP280, SHTC3, SHT4X, LPS22HB, MLX90614, VL53L0X, BME680/BSEC, BMP085, or RAK12035. The complete native matrix passed 1,112/1,112 cases at that intermediate source state. These are VM gates only; flash the exact artifacts and repeat the physical peripheral and blocked-clock checks before marking any later source state hardware-qualified.

    Subsequent source hardening changed RAK peripheral discovery: the shared 3V3_S rail remains enabled, UART GPS requires a complete checksum-valid NMEA sentence, an exact INA3221 identity blocks a conflicting u-blox probe, and a successful I2C GPS claim suppresses a sensor only on the same bus and address. The hashes and sizes above describe their recorded intermediate artifacts; they are not current-HEAD evidence and must not be reused for the hardened source.

    The immutable font endpoint was re-probed only after the VM reported NTPSynchronized=yes. GitHub returned HTTP 206 for bytes 0-65535, an exact 65,536-byte match to the checked-in font (range SHA-256 8b0dd308eb01469d1f9e732fba260720a80cc86f6cb5385c28e8a16c76dcfbf3), with total length 1,302,608 and strong ETag \"39ff0cfbe37e36905507697d042869c05374c3202dd96d5a9f98812153f324c1\". This validates the current strict Range assumptions against the live service; the exact-final Indicator still needs the negative hardware run proving that blocked UDP/123 prevents every TLS request and RP2040 staging write.

    Mercerwood subsequently recovered without a target write. Both its Tailscale and LAN addresses again accepted SSH, Bluetooth was idle, and the exact T1000-E stable serial 34A9141999729D5D was present as application USB 239a:8029. The RAK3401 simultaneously uses the same VID/PID, which confirms that every continuation must resolve the stable serial/by-id identity rather than VID/PID. The T1000-E remained in the exact bonded Full Companion application; no stale DFU, scanner, meshcli, or HCI-capture process was running.

    The post-reboot seven-TTY inventory was also resolved without assuming that one board owned adjacent device names. ttyUSB0 is the SenseCAP Indicator's ESP32-S3 CH340 UART and ttyACM5 is its RP2040; their sibling physical USB paths and the Indicator's live TCP board reply agree. The separately attached ttyACM4 is the MeshTower V2 native HT-n5262 endpoint and returned the qualified MeshTower build over its own console. The remaining ACM endpoints independently identify the RAK3401, XIAO nRF52840, Heltec V4, and T1000-E.

    ModemManager had restarted after the Pi reboot and could probe otherwise idle radio consoles. Six serial-bearing boards now use stable-serial-only ID_MM_DEVICE_IGNORE rules. Because the Indicator CH340 has no stable serial, its separate rule requires the exact current physical port chain and exact 1a86:7523 identity; there is no broad CH340, product, or VID/PID-only rule. A controlled ModemManager restart reported zero modems, displaced no TTY owner, caused no USB disconnect/re-enumeration, and left all seven endpoints ignored. The same mctomqtt process retained sole ownership of the RAK, while a subsequent Indicator TCP query still returned the expected board and build. Reversible root-only backups were retained on the Pi before each rule update.

    "},{"location":"hardware_validation_checklist/#run-wide-gates","title":"Run-wide gates","text":""},{"location":"hardware_validation_checklist/#compute-placement-gate","title":"Compute-placement gate","text":""},{"location":"hardware_validation_checklist/#lora-controlled-host-recovery-gate","title":"LoRa-controlled host recovery gate","text":"

    The sanitized host-recovery evidence bundle is under out/host-actions-live-20260831/. Its SHA256SUMS manifest has SHA-256 5acff71ed98f483bb769e8c99c577bf358d0491355ac72855ee7093cd658bf21, and sha256sum -c verifies every deployed-hash, pre/post-state, radio, OTA, network, and reboot record in that manifest.

    "},{"location":"hardware_validation_checklist/#mercerwood-pi-usb-power-cycle-gate","title":"Mercerwood Pi USB power-cycle gate","text":""},{"location":"hardware_validation_checklist/#seeed-xiao-nrf52840","title":"Seeed XIAO nRF52840","text":""},{"location":"hardware_validation_checklist/#seeed-tracker-t1000-e","title":"Seeed Tracker T1000-E","text":""},{"location":"hardware_validation_checklist/#rak3401","title":"RAK3401","text":""},{"location":"hardware_validation_checklist/#rak4631-reduced-profiles","title":"RAK4631 reduced profiles","text":""},{"location":"hardware_validation_checklist/#heltec-t096","title":"Heltec T096","text":""},{"location":"hardware_validation_checklist/#heltec-meshtower-v2-with-sd","title":"Heltec MeshTower V2 with SD","text":""},{"location":"hardware_validation_checklist/#heltec-v4","title":"Heltec V4","text":""},{"location":"hardware_validation_checklist/#seeed-sensecap-indicator-lora","title":"Seeed SenseCAP Indicator LoRa","text":""},{"location":"hardware_validation_checklist/#cross-radio-lora-ota-acceptance","title":"Cross-radio LoRa OTA acceptance","text":""},{"location":"home_text_spacing/","title":"Home-screen text spacing","text":"

    On non-Indicator companions, a visible Bluetooth PIN or connection status owns a separate bottom-aligned block. It replaces the ordinary inbox instruction, clock and WiFi rows while displayed. The PIN stays below the inbox title; the label and value use measured font heights, including the MeshPocket's larger e-paper font. An unusually short viewport may omit the label, never overlap the title or clip the PIN. Indicator's dedicated pairing layout is unchanged.

    The repeater home page also stacks measured font-height rows instead of fixed ten-pixel offsets. Long names and status lines are ellipsized horizontally.

    Run python3 -m unittest discover -s test -p test_home_text_spacing.py -v on a host with a C++ compiler. This renders the actual companion and repeater home branches, recording every text rectangle and rejecting overlaps or clipping. It covers 128x64 OLED, 160x80 T096, 250x122 MeshPocket and larger viewports, PIN/connected/disabled/prompt states, WiFi variants, long strings and a sweep of font heights. It also checks the static reader hint. The separate native display and T096 tests exercise the driver drawing/measurement methods.

    Hardware acceptance: check that the full six-digit PIN is near the bottom, with no text over it; connect/disconnect Bluetooth and verify no stale digits; then check the repeater's five rows and the V4's non-blinking reader footer.

    "},{"location":"host_cli_service/","title":"LoRa CLI Host Service","text":"

    The repeater host <text> command lets an authenticated LoRa administrator request a small, explicitly allowed operation from a USB-connected computer. The included Raspberry Pi endpoint supports these exact requests:

    cmd host help\ncmd host cpu-temp\ncmd host hostname\ncmd host uptime\ncmd host load\ncmd host memory\ncmd host disk-free\ncmd host clock status\ncmd host clock sync\ncmd host clock set <unix_epoch>\ncmd host network restart\ncmd host reboot\ncmd host action status <operation_id>\ncmd host run <alias> [arguments]\n

    The first seven actions after help are read-only. Clock changes, network restart, and reboot are opt-in actions and are disabled unless explicitly enabled. Text such as reboot now, cpu-temp; reboot, and embedded newlines is not a command: the endpoint accepts only an exact allowlist match.

    The bridge is included in normal repeater firmware except on Wio-E5. Its specialized RS232 bridge image omits the host service because the combined application exceeds the fixed 240 KiB partition; use the normal Wio-E5 repeater image when the USB/MQTT host service is needed.

    "},{"location":"host_cli_service/#run-alongside-meshcoretomqtt","title":"Run alongside meshcoretomqtt","text":"

    meshcoretomqtt must remain the only process that opens the repeater USB serial port. The endpoint communicates with it through the same MQTT broker, which can be local to the Raspberry Pi:

    LoRa -> repeater -> USB -> meshcoretomqtt -> MQTT -> host endpoint\nLoRa <- repeater <- USB <- meshcoretomqtt <- MQTT <- host endpoint\n

    Clock-recovery deployments must run the loopback broker, meshcoretomqtt, and endpoint on the same Pi. A wrong Pi clock can prevent TLS validation against a remote broker, while split host clocks can reject the signed live claim before the correction arrives. Remote brokers remain suitable for ordinary commands when the connection and both clocks are already healthy.

    Launch meshcoretomqtt with its existing arguments plus --debug; its current debug-topic parser needs that flag to publish the repeater request record. Do not open the serial TTY from the host endpoint too.

    This feature does not compare the repeater wall clock with the Pi clock. Minutes of drift, an unset repeater clock, and later clock corrections are safe: request freshness comes from a live one-time challenge. The normal meshcoretomqtt sync_time setting may remain enabled for its other uses, but host-command authorization does not depend on it.

    Generate a dedicated service key using the Python environment installed by meshcoretomqtt:

    sudo /opt/mctomqtt/venv/bin/python3 host_cli_service.py \\\n  --generate-key /etc/mctomqtt/host-cli-key.json\nsudo chown mctomqtt:mctomqtt /etc/mctomqtt/host-cli-key.json\nsudo chmod 600 /etc/mctomqtt/host-cli-key.json\n

    Add the printed public key to the existing meshcoretomqtt configuration:

    [remote_serial]\nenabled = true\nallowed_companions = [\n  \"SERVICE_PUBLIC_KEY_PRINTED_ABOVE\"\n]\nnonce_ttl = 120\ncommand_timeout = 10\n

    Restart meshcoretomqtt, then run the endpoint. Replace USA with the exact three-character IATA namespace configured in the broker and supply the repeater's complete 64-character public key:

    sudo -u mctomqtt /opt/mctomqtt/venv/bin/python3 host_cli_service.py \\\n  --broker 127.0.0.1 \\\n  --iata USA \\\n  --repeater-key REPEATER_PUBLIC_KEY \\\n  --service-key /etc/mctomqtt/host-cli-key.json\n

    Add --username NAME --password-file FILE for broker authentication. Add --tls, and optionally --ca-cert FILE, for TLS. Custom installations can use --request-topic and --command-topic to match their broker namespace. The endpoint source and its systemd/configuration details are in examples/host_cli_service.

    "},{"location":"host_cli_service/#allowlisted-programs-and-arguments","title":"Allowlisted programs and arguments","text":"

    Add --programs-file FILE to expose locally selected programs as host run <alias> [arguments]. The example JSON file fixes each executable, fixed leading arguments, maximum 1-5 second runtime, and the exact remote argument schema. For example:

    cmd host run fan on 15\n

    The example maps that request to the fixed process argument vector:

    /usr/local/bin/mesh-fan-control --source lora on 15\n

    The supported argument rules are an explicit non-option choice, a bounded nonnegative integer, or a short token using a restricted ASCII character set. Alias lookup and every argument validation happen before process creation. Execution uses an absolute configured path, no stdin, shell=False, a minimal environment, and / as its working directory. Extra arguments, leading-option injection, shell syntax, control characters, and invalid quoting are rejected. The allowlist file and executable must not be group- or world-writable.

    Copy and edit programs.example.json, then give the service account only the operating-system permissions needed by those trusted programs.

    "},{"location":"host_cli_service/#opt-in-network-and-reboot-recovery","title":"Opt-in network and reboot recovery","text":"

    These actions use their own socket-activated root broker, independently of the clock-control broker. Installing or enabling clock control does not grant host recovery actions. Install the following as root-owned files:

    sudo install -o root -g root -m 0755 meshcore_host_actions.py \\\n  /usr/local/sbin/meshcore-host-actions\nsudo install -o root -g root -m 0644 meshcore-host-actions.socket \\\n  /etc/systemd/system/meshcore-host-actions.socket\nsudo install -o root -g root -m 0644 meshcore-host-actions.service \\\n  /etc/systemd/system/meshcore-host-actions.service\nsudo install -o root -g root -m 0644 meshcore-networkmanager-restart.service \\\n  /etc/systemd/system/meshcore-networkmanager-restart.service\nsudo install -o root -g root -m 0644 meshcore-host-reboot.timer \\\n  /etc/systemd/system/meshcore-host-reboot.timer\nsudo install -o root -g root -m 0644 meshcore-host-reboot.service \\\n  /etc/systemd/system/meshcore-host-reboot.service\n

    The root-owned broker policy enables nothing by default. Create a systemd drop-in and select network-restart, reboot, or both as an exact comma-separated list:

    sudo systemctl edit meshcore-host-actions.service\n
    [Service]\nEnvironment=MESHCORE_HOST_ACTIONS=network-restart,reboot\n

    Reload systemd and enable only the broker socket:

    sudo systemctl daemon-reload\nsudo systemctl enable --now meshcore-host-actions.socket\n

    After changing the policy on an already active installation, restart meshcore-host-actions.service so the broker reads the new value.

    Finally add --allow-network-restart, --allow-reboot, or both to the unprivileged endpoint. An action must pass both gates: the endpoint flag and the root-owned broker policy. The endpoint never invokes sudo; remove legacy wifi-restart program aliases and host-action sudoers entries because sudo aliases are not a fallback for this hardened service. Use the exact host network restart action instead.

    Systemd creates /run/meshcore-host-actions.sock as root:mctomqtt mode 0660. The root broker authenticates both the endpoint UID and primary GID with SO_PEERCRED, while the endpoint authenticates the root-created listener. The protocol is one bounded ASCII line. Neither the caller nor request text can choose an executable, unit, path, or argument. The broker can start only the fixed static NetworkManager restart service or the fixed reboot timer; the broker and action units have empty capability sets and systemd sandboxing. The configured mctomqtt UID and its effective primary GID are the delegated local trust boundary; supplementary group membership alone is rejected.

    Side effects follow a fail-closed two-phase sequence. The endpoint reserves a canonical 128-bit operation ID with PREPARE, publishes its reply with MQTT QoS 1, and requires wait_for_publish plus is_published confirmation before sending one COMMIT. It does not block the Paho network callback while waiting. No confirmation means no commit. Reboot uses a fixed approximately 10-second timer that starts only after commit. The MQTT confirmation proves acceptance by the local broker, not delivery over USB and LoRa; there is no correlated serial delivery acknowledgement, so the physical reply is best effort.

    network restart deliberately drops Wi-Fi and Tailscale management while NetworkManager restarts. Use a loopback MQTT broker and keep the broker, meshcoretomqtt, host endpoint, and USB device services independent of NetworkManager: they must not have Requires=, BindsTo=, or ordering dependencies on it. This preserves the local reply/commit path while remote management temporarily disappears.

    The operation ID is derived from the authenticated repeater key, request ID, and nonce. The reply displays it, and its state can be queried later:

    cmd host action status 0123456789ABCDEF0123456789ABCDEF\n

    The root broker retains at most 64 records for the current boot. It persists prepared before replying and in-progress before scheduling, schedules a committed operation at most once, and never automatically retries an ambiguous outcome. A broker restart converts recovered in-progress to ambiguous. Only an old uncommitted prepared record may be evicted; otherwise a full store rejects new work. Reusing an operation ID for a different action is rejected. scheduled means PID 1 accepted the fixed unit job; it does not claim that the subsequent network restart or reboot completed successfully.

    A distinct reboot is rejected while another reboot is committed or ambiguous, so a second request cannot falsely promise a fresh 10-second timer while the first timer is already running. A new reboot reservation may supersede only an older, uncommitted reboot reservation; the superseded operation can no longer be committed.

    "},{"location":"host_cli_service/#opt-in-clock-recovery","title":"Opt-in clock recovery","text":"

    clock status reports the Pi epoch and NTP synchronization state without root access. To enable the exact clock sync and clock set <unix_epoch> actions, install meshcore_clock_control.py as /usr/local/sbin/meshcore-clock-control, owned by root and mode 0755, then install the accompanying meshcore-clock-control.socket and meshcore-clock-control.service and static meshcore-chrony-step.service files in /etc/systemd/system as root-owned mode-0644 files:

    sudo install -o root -g root -m 0755 meshcore_clock_control.py \\\n  /usr/local/sbin/meshcore-clock-control\nsudo install -o root -g root -m 0644 meshcore-clock-control.socket \\\n  /etc/systemd/system/meshcore-clock-control.socket\nsudo install -o root -g root -m 0644 meshcore-clock-control.service \\\n  /etc/systemd/system/meshcore-clock-control.service\nsudo install -o root -g root -m 0644 meshcore-chrony-step.service \\\n  /etc/systemd/system/meshcore-chrony-step.service\nsudo systemctl daemon-reload\nsudo systemctl enable --now meshcore-clock-control.socket\n

    The socket is fixed at /run/meshcore-clock-control.sock; systemd creates it as root:mctomqtt with mode 0660. Run the endpoint as user and primary group mctomqtt, then add --allow-clock-control. The endpoint validates the socket metadata and authenticates the connected server as root with Linux SO_PEERCRED. The root service independently requires the peer's UID and primary GID to match mctomqtt:mctomqtt before it reads or executes a request. No clock-control sudo rule is used; remove old clock-helper sudoers lines when upgrading.

    Both layers require one canonical unsigned decimal argument with no sign, leading zero, whitespace, or trailing text. The accepted epoch range is 2020 through 2099. The private protocol accepts only the complete bounded ASCII lines sync and set <epoch>. Every child process uses a fixed absolute argv, shell=False, and a timeout no longer than 1.5 seconds.

    clock set changes CLOCK_REALTIME without disabling NTP, then requests an NTP step. If the NTP request fails, the response honestly reports that the clock changed and which synchronization step remains incomplete. If clock sync enables NTP but the explicit step/restart fails, that partial outcome is also reported. When chrony is available, the root broker starts only the fixed static meshcore-chrony-step.service. That hardened one-shot runs /usr/bin/chronyc -a makestep as _chrony:_chrony, with no capabilities, AF_UNIX only, and a one-second start timeout; it can therefore reach chrony's private runtime socket without broadening the root broker. The _chrony account comes from the Debian/Raspberry Pi chrony package. Without chrony, the broker restarts systemd-timesyncd. Clock drift cannot authorize a captured command: the signed request still needs the repeater's live one-time claim, whose deadline is monotonic rather than wall-clock based.

    "},{"location":"host_cli_service/#trust-and-injection-controls","title":"Trust and injection controls","text":"

    The MQTT broker transports records but does not establish their authenticity. The endpoint checks the configured repeater identity and verifies its Ed25519 signature over the complete request ID, random nonce, and Base64URL request. It also validates framing, UTF-8, and byte limits. It does not execute that first record.

    Instead, the endpoint stores the request in memory, creates a random 64-bit challenge, and sends @claim=<random> through meshcoretomqtt's signed serial channel. The repeater accepts it only from physical USB while the exact ID and nonce are pending, then signs a CLAIMED proof containing the challenge. Only a matching live proof lets the endpoint perform allowlist matching and execute the action. It consumes the proof before execution, so MQTT redelivery cannot execute an action twice.

    A captured request only causes a new challenge that an idle repeater refuses. A captured proof does not match a new challenge, and restarting the endpoint forgets pending challenges. Repeater/Pi clock drift therefore cannot turn an old reboot or run record into a valid action. The reply and claim commands still use short-lived JWTs; meshcoretomqtt checks their signer allowlist, target, expiration, signature, and separate replay nonce. Wall-clock expiry and its in-memory nonce cache are not sufficient across a backward clock jump plus restart; the firmware's live one-time claim is what protects these host actions. Keep recovery-mode allowed_companions limited to the dedicated service key, with the endpoint and meshcoretomqtt on the same Pi.

    Newlines and other reply control characters are converted to spaces and are also independently rejected by the firmware parser.

    Protect the service private key and use broker ACLs that allow it to subscribe only to the selected repeater debug topic and publish only to that repeater serial-command topic. A private key listed in allowed_companions is trusted to sign serial requests.

    "},{"location":"host_cli_service/#limits","title":"Limits","text":"

    Use cmd get host to report the bridge state and limits.

    "},{"location":"kiss_modem_protocol/","title":"MeshCore KISS Modem Protocol","text":"

    Standard KISS TNC firmware for MeshCore LoRa radios. Compatible with any KISS client (Direwolf, APRSdroid, YAAC, etc.) for sending and receiving raw packets. MeshCore-specific extensions (cryptography, radio configuration, telemetry) are available through the standard SetHardware (0x06) command.

    "},{"location":"kiss_modem_protocol/#serial-configuration","title":"Serial Configuration","text":"

    115200 baud, 8N1, no flow control.

    "},{"location":"kiss_modem_protocol/#frame-format","title":"Frame Format","text":"

    Standard KISS framing per the KA9Q/K3MC specification.

    Byte Name Description 0xC0 FEND Frame delimiter 0xDB FESC Escape character 0xDC TFEND Escaped FEND (FESC + TFEND = 0xC0) 0xDD TFESC Escaped FESC (FESC + TFESC = 0xDB)
    +------+-----------+--------------+------+\n| FEND | Type Byte | Data (escaped)| FEND |\n| 0xC0 |  1 byte   | 0-510 bytes  | 0xC0 |\n+------+-----------+--------------+------+\n
    "},{"location":"kiss_modem_protocol/#type-byte","title":"Type Byte","text":"

    The type byte is split into two nibbles:

    Bits Field Description 7-4 Port Port number (0 for single-port TNC) 3-0 Command Command number

    Maximum unescaped frame size: 512 bytes.

    "},{"location":"kiss_modem_protocol/#standard-kiss-commands","title":"Standard KISS Commands","text":""},{"location":"kiss_modem_protocol/#host-to-tnc","title":"Host to TNC","text":"Command Value Data Description Data 0x00 Raw packet Queue packet for transmission (one pending at a time) TXDELAY 0x01 Delay (1 byte) Transmitter keyup delay in 10ms units (default: 50 = 500ms) Persistence 0x02 P (1 byte) CSMA persistence parameter 0-255 (default: 63) SlotTime 0x03 Interval (1 byte) CSMA slot interval in 10ms units (default: 10 = 100ms) TXtail 0x04 Delay (1 byte) Post-TX hold time in 10ms units (default: 0) FullDuplex 0x05 Mode (1 byte) 0 = half duplex, nonzero = full duplex (default: 0) SetHardware 0x06 Sub-command + data MeshCore extensions (see below) Return 0xFF - Exit KISS mode (no-op)"},{"location":"kiss_modem_protocol/#tnc-to-host","title":"TNC to Host","text":"Type Value Data Description Data 0x00 Raw packet Received packet from radio

    Data frames carry raw packet data only, with no metadata prepended. The Data command payload is limited to 255 bytes to match the MeshCore maximum transmission unit (MAX_TRANS_UNIT); frames larger than 255 bytes are silently dropped. The KISS specification recommends at least 1024 bytes for general-purpose TNCs; this modem is intended for MeshCore packets only, whose protocol MTU is 255 bytes.

    Only one packet may be pending for radio transmission at a time. If the host sends a second Data frame before the first has completed, the modem responds with Error (0xF1) and TxBusy (0x07).

    "},{"location":"kiss_modem_protocol/#host-output-backpressure","title":"Host Output Backpressure","text":"

    Outbound frames are encoded into a 2-slot queue and flushed when serial output space is available; loop() never blocks on writes. Radio TX state advances independently of host read speed. TxDone is retained until it can be queued. If the outbound queue is full, the modem responds with Error (0xF1) and TxBusy (0x07). Hosts should read serial promptly to avoid delayed responses.

    "},{"location":"kiss_modem_protocol/#csma-behavior","title":"CSMA Behavior","text":"

    The TNC implements p-persistent CSMA for half-duplex operation:

    1. When a packet is queued, monitor carrier detect
    2. When the channel clears, generate a random value 0-255
    3. If the value is less than or equal to P (Persistence), wait TXDELAY then transmit
    4. Otherwise, wait SlotTime and repeat from step 1

    In full-duplex mode, CSMA is bypassed and packets transmit after TXDELAY.

    "},{"location":"kiss_modem_protocol/#sethardware-extensions-0x06","title":"SetHardware Extensions (0x06)","text":"

    MeshCore-specific functionality uses the standard KISS SetHardware command. The first byte of SetHardware data is a sub-command. Standard KISS clients ignore these frames.

    "},{"location":"kiss_modem_protocol/#frame-format_1","title":"Frame Format","text":"
    +------+------+-------------+--------------+------+\n| FEND | 0x06 | Sub-command | Data (escaped)| FEND |\n| 0xC0 |      |   1 byte    |   variable   | 0xC0 |\n+------+------+-------------+--------------+------+\n
    "},{"location":"kiss_modem_protocol/#request-sub-commands-host-to-tnc","title":"Request Sub-commands (Host to TNC)","text":"Sub-command Value Data GetIdentity 0x01 - GetRandom 0x02 Length (1 byte, 1-64) VerifySignature 0x03 PubKey (32) + Signature (64) + Data SignData 0x04 Data to sign EncryptData 0x05 Key (32) + Plaintext DecryptData 0x06 Key (32) + MAC (2) + Ciphertext KeyExchange 0x07 Remote PubKey (32) Hash 0x08 Data to hash SetRadio 0x09 Freq (4) + BW (4) + SF (1) + CR (1) SetTxPower 0x0A Power dBm (1) GetRadio 0x0B - GetTxPower 0x0C - GetCurrentRssi 0x0D - IsChannelBusy 0x0E - GetAirtime 0x0F Packet length (1) GetNoiseFloor 0x10 - GetVersion 0x11 - GetStats 0x12 - GetBattery 0x13 - GetMCUTemp 0x14 - GetSensors 0x15 Permissions (1) GetDeviceName 0x16 - Ping 0x17 - Reboot 0x18 - SetSignalReport 0x19 Enable (1): 0x00=disable, nonzero=enable GetSignalReport 0x1A -"},{"location":"kiss_modem_protocol/#response-sub-commands-tnc-to-host","title":"Response Sub-commands (TNC to Host)","text":"

    Response codes use the high-bit convention: response = command | 0x80. Generic and unsolicited responses use the 0xF0+ range.

    Sub-command Value Data Identity 0x81 PubKey (32) Random 0x82 Random bytes (1-64) Verify 0x83 Result (1): 0x00=invalid, 0x01=valid Signature 0x84 Signature (64) Encrypted 0x85 MAC (2) + Ciphertext Decrypted 0x86 Plaintext SharedSecret 0x87 Shared secret (32) Hash 0x88 SHA-256 hash (32) Radio 0x8B Freq (4) + BW (4) + SF (1) + CR (1) TxPower 0x8C Power dBm (1) CurrentRssi 0x8D RSSI dBm (1, signed) ChannelBusy 0x8E Result (1): 0x00=clear, 0x01=busy Airtime 0x8F Milliseconds (4) NoiseFloor 0x90 dBm (2, signed) Version 0x91 Version (1) + Reserved (1) Stats 0x92 RX (4) + TX (4) + Errors (4) Battery 0x93 Millivolts (2) MCUTemp 0x94 Temperature (2, signed) Sensors 0x95 CayenneLPP payload DeviceName 0x96 Name (variable, UTF-8) Pong 0x97 - SignalReport 0x9A Status (1): 0x00=disabled, 0x01=enabled OK 0xF0 - Error 0xF1 Error code (1) TxDone 0xF8 Result (1): 0x00=failed, 0x01=success RxMeta 0xF9 SNR (1) + RSSI (1)"},{"location":"kiss_modem_protocol/#error-codes","title":"Error Codes","text":"Code Value Description InvalidLength 0x01 Request data too short InvalidParam 0x02 Invalid parameter value NoCallback 0x03 Feature not available MacFailed 0x04 MAC verification failed UnknownCmd 0x05 Unknown sub-command EncryptFailed 0x06 Encryption failed TxBusy 0x07 Radio TX busy, or host output queue full"},{"location":"kiss_modem_protocol/#unsolicited-events","title":"Unsolicited Events","text":"

    The TNC sends these SetHardware frames without a preceding request:

    TxDone (0xF8): Sent after radio transmission completes. Contains a single byte: 0x01 for success, 0x00 for failure. Delivery to the host may be delayed under serial backpressure but is not dropped.

    RxMeta (0xF9): Sent after each standard data frame (type 0x00) with SNR (1 byte, signed, value x4) and RSSI (1 byte, signed, dBm). Queued with the data frame; omitted if the data frame cannot be queued. Enabled by default; toggle with SetSignalReport. Standard KISS clients ignore this frame.

    "},{"location":"kiss_modem_protocol/#data-formats","title":"Data Formats","text":""},{"location":"kiss_modem_protocol/#radio-parameters-setradio-radio-response","title":"Radio Parameters (SetRadio / Radio response)","text":"

    All values little-endian.

    Field Size Description Frequency 4 bytes Hz (e.g., 869618000) Bandwidth 4 bytes Hz (e.g., 62500) SF 1 byte Spreading factor (5-12) CR 1 byte Coding rate (5-8)"},{"location":"kiss_modem_protocol/#version-version-response","title":"Version (Version response)","text":"Field Size Description Version 1 byte Firmware version Reserved 1 byte Always 0"},{"location":"kiss_modem_protocol/#encrypted-encrypted-response","title":"Encrypted (Encrypted response)","text":"Field Size Description MAC 2 bytes HMAC-SHA256 truncated to 2 bytes Ciphertext variable AES-128 block-encrypted data with zero padding"},{"location":"kiss_modem_protocol/#airtime-airtime-response","title":"Airtime (Airtime response)","text":"

    All values little-endian.

    Field Size Description Airtime 4 bytes uint32_t, estimated air time in milliseconds"},{"location":"kiss_modem_protocol/#noise-floor-noisefloor-response","title":"Noise Floor (NoiseFloor response)","text":"

    All values little-endian.

    Field Size Description Noise floor 2 bytes int16_t, dBm (signed)

    The modem recalibrates the noise floor every 2 seconds with an AGC reset every 30 seconds.

    "},{"location":"kiss_modem_protocol/#stats-stats-response","title":"Stats (Stats response)","text":"

    All values little-endian.

    Field Size Description RX 4 bytes Packets received TX 4 bytes Packets transmitted Errors 4 bytes Receive errors"},{"location":"kiss_modem_protocol/#battery-battery-response","title":"Battery (Battery response)","text":"

    All values little-endian.

    Field Size Description Millivolts 2 bytes uint16_t, battery voltage in mV"},{"location":"kiss_modem_protocol/#mcu-temperature-mcutemp-response","title":"MCU Temperature (MCUTemp response)","text":"

    All values little-endian.

    Field Size Description Temperature 2 bytes int16_t, tenths of degC (e.g., 253 = 25.3degC)

    Returns NoCallback error if the board does not support temperature readings.

    "},{"location":"kiss_modem_protocol/#device-name-devicename-response","title":"Device Name (DeviceName response)","text":"Field Size Description Name variable UTF-8 string, no null terminator"},{"location":"kiss_modem_protocol/#reboot","title":"Reboot","text":"

    Sends an OK response, flushes serial, then reboots the device. The host should expect the connection to drop.

    "},{"location":"kiss_modem_protocol/#sensor-permissions-getsensors","title":"Sensor Permissions (GetSensors)","text":"Bit Value Description 0 0x01 Base (battery) 1 0x02 Location (GPS) 2 0x04 Environment (temp, humidity, pressure)

    Use 0x07 for all permissions.

    "},{"location":"kiss_modem_protocol/#sensor-data-sensors-response","title":"Sensor Data (Sensors response)","text":"

    Data returned in CayenneLPP format. See CayenneLPP documentation for parsing.

    "},{"location":"kiss_modem_protocol/#cryptographic-algorithms","title":"Cryptographic Algorithms","text":"Operation Algorithm Identity / Signing / Verification Ed25519 Key Exchange X25519 (ECDH) Encryption AES-128 block encryption with zero padding + HMAC-SHA256 (MAC truncated to 2 bytes) Hashing SHA-256"},{"location":"kiss_modem_protocol/#notes","title":"Notes","text":""},{"location":"lora_ota_automation/","title":"Scripted LoRa OTA from start to finish","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    The dedicated RAK3401 chain report records the physical failures of withdrawn migrations and the physically qualified compact nine-step replacement. Its runner blocks the withdrawn chains and pins every accepted bridge image by SHA-256.

    tools/lora_ota/lora_ota.sh and tools/lora_ota/lora_ota.ps1 automate a MeshCore LoRa firmware update from a release .zip or ready .mota. They identify the destination, validate the hardware and running firmware, prepare the right container, move the participating nodes to a temporary radio channel, serve and monitor the download, request installation, restore the controller, and check the rebooted node.

    The script cannot install the destination's first OTA-capable firmware. It can stage signed v3 bootloader containers with --no-install, but never sends the privileged bootloader install command. Invalid v2 containers carrying the bootloader flag remain rejected. Do the first nRF52 bootloader installation over USB. An already-capable, explicitly supported internal-flash, XIAO QSPI, or exact MeshTower V2 microSD repeater may later use the separate manual ota bootloader install workflow.

    "},{"location":"lora_ota_automation/#required-topology","title":"Required topology","text":"

    The reliable serial topology uses two local radios:

                                          authenticated admin commands\ncomputer -- MeshCore binary API --> controller Companion -------------------+\n   |                                                                         |\n   +-- raw text CLI + mOTA seeder --> OTA source ---- LoRa OTA blocks ----> target\n                                                        |                    ^\n                                                        +-- relay(s) --------+\n

    One serial port cannot serve both controller roles: meshcli must keep reopening the controller while motatool owns the source port. The script rejects an attempt to use the same port for both.

    The USB ASCII switch (+++MESHCORE-TERM-START) is the local control path, not the mOTA data framing. On an nRF52 dual-CDC Full Companion, the script uses that mode briefly for ota status and TempRadio commands. It then closes the CLI and starts motatool; the exact ota folder on preamble switches the same USB interface 00 into exclusive mOTA mode from either startup ASCII or Binary Companion mode. If USB logging was enabled and the Full Companion rebooted, its separate interface 02 continues to provide plaintext logging and is not a controller or source port. With default logging off, interface 02 is not enumerated. BLE remains available.

    ESP32 Full Companion normally uses its dedicated TCP seeder on port 5001. ESP32 builds that also retain serial folder support use the same exclusive USB mOTA ownership state as nRF52. The exact ota folder on line transfers the USB port from startup ASCII or idle Binary Companion mode before binary mOTA frames begin; neither console mode shares that port until the folder detaches.

    "},{"location":"lora_ota_automation/#bootloader-packages-and-station-identifiers","title":"Bootloader packages and station identifiers","text":"

    Both wrappers use the same package-aware runner. It identifies application versus bootloader updates from container metadata, not filenames. A ZIP mixing application inputs and bootloader mOTAs requires --zip-member; their versions and target IDs are independent and must not be ranked together.

    Signed format-3 nRF52 bootloader packages can be staged with --no-install. Use the same controller/source/TempRadio arguments as an application transfer, but provide the bootloader .mota (or its ZIP member) and --no-install. Without that flag, even --yes stops before opening a radio. Bootloader staging requires Python cryptography in the interpreter running the wrapper and a current motatool supporting format 3. The runner reuses tools/mota/motalib.py for strict geometry, embedded identity/CRC, capability, hash and signature validation, and still runs motatool verify including any --public-key pin.

    If Python cannot load cryptography, an interactive run offers:

    Install cryptography privately and retry signature verification? [y/N]\n

    After approval, it uses the exact running Python interpreter to install cryptography==50.0.1 and its dependencies from PyPI into a separate per-user, per-interpreter cache. Binary wheels only are accepted; the repair does not compile native code, overwrite system/virtual-environment packages, change PATH, use sudo, or bypass externally-managed Python protections. It checks Ed25519 against a known valid signature and a tampered message, then retries the original full package verification. A later run can reuse a working cache without another download. This works for direct .mota files and bootloader members inside ZIPs; ordinary application updates and raw --tcp-cli commands do not need this library.

    The interpreter must have working pip. If pip is missing, the script gives an exact-interpreter ensurepip command and operating-system pip/venv guidance; it does not silently install system tools. No compatible binary wheel, an old pip, network/download failures, an unwritable cache, or a failed signature check stop the run before radio access. Pip output is shown and installation is bounded at ten minutes. The radio admin-password environment variable is not passed to pip. Partial installation files may remain in the displayed private directory. The cache is under meshcore-lora-ota/python/<interpreter-id>/cryptography==50.0.1 in the same per-user cache base described below for motatool.

    Only y or yes approves this repair; --yes is not installation consent. Noninteractive runs print a copyable private-install command and stop. After running that command manually, rerun the original OTA command; no Python path changes are needed. Declining never skips signature verification.

    It probes actual motatool inspect support before radio access: older and newer builds may advertise the same version. If the tool is missing or incompatible, an interactive run offers an automatic repair:

    Install this bootloader-capable motatool and continue after verification? [y/N]\n

    Only y or yes approves installation. --yes does not approve host software installation. Noninteractive runs stop and print a copyable manual installation command plus the --motatool argument to use afterward.

    The motatool repair builds the mikecarper/motatool fork at pinned revision 8c38369e7d35ad50cf74261869676d52dd24adf7, with Cargo's --locked dependencies, in a separate per-user cache directory. It needs matching stable Cargo and rustc 1.86.0 or newer and a native linker already installed; otherwise it points to Rust installation and stops without installing anything. The pinned dependency set needs more than the older rust-version in motatool's own manifest: zeroize 1.9.0 needs edition 2024, and ICU/idna dependencies need Rust 1.86. Cargo 1.75 is a version problem, not a root-permission problem; no nightly toolchain is necessary.

    Before asking to build or downloading crates, the runner checks both executable versions. It keeps a compatible default pair; otherwise it looks for versioned executables on PATH (such as Ubuntu's cargo-1.91 and rustc-1.91) and already installed stable rustup toolchains. It displays the selected paths and pins both for the build process, so a newer Cargo cannot silently invoke an old rustc. Rustup proxies are resolved to their actual toolchain binaries before changing to the build directory. No default toolchain, symlink, system PATH, or package installation is changed, and probes disable rustup's automatic toolchain install. Tool version probes have ten-second timeouts. A working cached motatool does not need Cargo or Rust checks at all.

    To choose a pair explicitly, append:

    --cargo cargo-1.91 --rustc rustc-1.91\n

    These options also accept executable paths, not shell aliases or a command with embedded arguments. Explicit selections and RUSTC/CARGO_BUILD_RUSTC compiler overrides are honored; if incompatible, they fail instead of silently selecting another compiler. --rustc takes precedence over those environment variables. The manual build command includes the chosen compiler as well as Cargo. Native linker, SDK, and system-library availability are still checked by the real build; a passed version check is not a guarantee that every host prerequisite is present. Source/dependency downloads, build time and disk use are disclosed before confirmation. Cargo progress is displayed, with a one-hour build timeout. The radio admin-password environment variable is not passed to the build.

    The installer does not overwrite the selected/system motatool or alter PATH. It may replace only a previous copy in its displayed private install directory. After installation it repeats the bootloader-package capability check and uses the new binary for this run only if that check passes. A later run using the default motatool selection can reuse the checked cache without downloading again; switching from an explicit --motatool path still asks permission. Refusal, a failed build, or a failed recheck stops before opening any radio. Failed build files may remain in the displayed cache directory for diagnostics.

    The install root is meshcore-lora-ota/motatool/<revision> under %LOCALAPPDATA% on Windows, ~/Library/Caches on macOS, or $XDG_CACHE_HOME (default ~/.cache) on Linux. Cargo uses an explicit --root, --rev, and --locked instead of replacing a global installation or following a moving branch.

    Before transfer it queries the destination's ota bootloader status, checks its bootloader-specific target/hardware identity, installed ABI/codecs and matching internal/QSPI/SD storage profile. It does not compare an application version or application target ID to a bootloader package. After transfer it checks the destination's exact staged MID and image-hash confirmation, then prints the manual command; it never sends a bootloader install command:

    ota bootloader status\nota bootloader install <MID8> <HASH16>\n

    Review and send those commands on the destination, not the source. The device independently enforces signer authorization, safe live storage, continuity and upgrade-only policy at installation. --base, --allow-non-upgrade, and --prepare-only remain application-only. See the bootloader prerequisites and recovery limits.

    TARGET_NODE, --relay, and --source-contact accept a contact name, full public key, or unique hexadecimal key prefix. The runner reads the controller's existing contact table and binds each selection to a full key before remote commands, so emoji names need not be typed and later name changes cannot redirect the run. Duplicate names, ambiguous prefixes, or the same radio under different participant aliases stop with an actionable error. Missing destination and relay contacts must first be imported or discovered. A missing connected source can be imported with the confirmation prompt below; a key alone does not create a contact.

    For a separate source with a managed USB/TCP console, automatic source selection reads the physical source's full public key and matches it in the controller's contacts. Repeaters use get public.key. Full Companion does not implement that repeater command: on both USB and TCP, its key is read from a fresh terminal welcome banner, with a supported ver reply on the same connection proving the terminal is live. USB briefly uses the existing STOP/START/STOP terminal wrapper to obtain that banner, even for an ASCII-first Full Companion; TCP reads the greeting from a new connection. Identity rechecks repeat this exchange rather than trusting a key cached from an earlier connection.

    ota status identifies the Full Companion seeder role before this probe. For unrecognized firmware, only an explicit unsupported get public.key response can trigger the Companion fallback. Timeouts, permission errors, malformed keys, missing/ambiguous banners, and failed ver replies still stop the run. Firmware that exposes neither identity mechanism needs a supported terminal firmware; the runner does not guess a key from the node name or skip the identity check.

    Source selection does not require the source's current name to match an old saved advert, and duplicate/emoji names cannot redirect this lookup. An explicit --source-contact must identify that same physical source. If its key is missing, an interactive run offers:

    Add the connected OTA source to this controller's contacts? [y/N]\n

    This works through both the shell and PowerShell launchers. The runner reads the source's existing card command over USB/TCP, checks the full key in its contact card against the connected source, and shows its name/key before asking. On approval it rechecks the source/controller identities and live contact table, then uses MeshCore's existing import_contact API. Signature validation remains with the controller's normal advert importer. For manual-add controllers it can approve only that exact source through add_pending; it does not enable general auto-add. A fresh reload_contacts must confirm the full source key before OTA continues. No LoRa advertisement or firmware update is required for this import.

    The default answer is No; --yes does not approve adding a contact. A noninteractive run stops with instructions to rerun in a terminal. Unsupported card firmware, malformed/mismatched cards, and unverified imports stop with manual-import/discovery guidance. To protect existing contacts, the controller's overwrite-oldest policy must be verifiably disabled; otherwise the runner stops and shows how to disable that bit without changing other auto-add flags. It does not change this policy itself. A full table or rejected import cannot be worked around by deleting another contact; an uncertain import is not automatically retried. An approved contact remains on the controller for subsequent runs.

    The normal/TempRadio on-air ACK checks still run; adding a contact does not prove radio reachability or skip those checks. When the controller and source are the same TCP Full Companion, use the verified --source-shares-controller topology; it does not need a contact for itself.

    "},{"location":"lora_ota_automation/#destination-requirements","title":"Destination requirements","text":"Destination Package installed One-time prerequisite Raw ZIP handling ESP32 Full application image OTA-enabled image with an A/B partition table Builds a full mOTA from the matching non-merged application .bin nRF52, internal flash In-place delta Exact-board OTAFIX bootloader with mOTA apply support Requires --base with the exact image currently running nRF52 repeater, external QSPI Full image or in-place delta QSPI-aware exact-board OTAFIX bootloader and matched repeater build Builds a full mOTA; adding --base requests a delta MeshTower V2 nRF52, microSD Full image or in-place delta SD-aware exact-board OTAFIX bootloader and compatible card Builds a full mOTA; adding --base requests a delta

    MeshTower V2 microSD application and bootloader containers require an Ed25519 signature from a key in the device allowlist. Supply --sign-key when the runner must build a container, and install the BLM2-capable SD-aware bootloader locally before attempting application or bootloader OTA. Preview.12 requires USB/BLE DFU or SWD; see MeshTower V2 microSD LoRa OTA.

    The firmware inside a raw ZIP must have a valid MeshCore EndF trailer. An ESP32 merged/factory image is not an application image and is rejected. A generic vendor DFU ZIP may also be unusable if it does not contain the raw EndF-bearing .hex or .bin.

    For an internal-staging nRF52, the exact base image is irreducible information. The node reports its eight-byte body hash, but that hash cannot reconstruct the firmware bytes needed to create a delta. Keep the .pio/build/ENV/firmware.hex that was actually flashed. A matching filename or version alone is not enough.

    "},{"location":"lora_ota_automation/#1-install-the-host-tools","title":"1. Install the host tools","text":"

    Install Python 3.10 or newer, Rust, Git, the official meshcore-cli 1.6.0 or newer, and the official motatool.

    For signed bootloader staging, see the automatic cryptography and bootloader-capable motatool repairs above. Pip is needed only when installing the missing Python dependency; Rust/Cargo and a native linker are needed only when building motatool, not when using an already compatible binary. The runner checks the selected meshcli version before opening radios. Keep its serial/BLE dependencies in meshcli's own environment (pipx handles these); installing cryptography in meshcli's pipx environment does not make it available to the separate Python interpreter running this wrapper. A ready bootloader .mota does not need the Python detools or intelhex build dependencies.

    On Bash:

    python3 -m pip install --user pipx\npython3 -m pipx ensurepath\npipx install meshcore-cli\n\ngit clone https://github.com/vk496/motatool.git\ncargo install --path ./motatool\n\nmeshcli -v\nmotatool --version\n

    On PowerShell:

    py -m pip install --user pipx\npy -m pipx ensurepath\npipx install meshcore-cli\n\ngit clone https://github.com/vk496/motatool.git\ncargo install --path .\\motatool\n\nmeshcli -v\nmotatool --version\n

    Restart the shell if pipx or Cargo reports that it changed PATH.

    "},{"location":"lora_ota_automation/#2-identify-and-test-both-local-links","title":"2. Identify and test both local links","text":"

    List serial devices:

    meshcli -l\n

    The examples below assume /dev/ttyACM0 is the controller and /dev/ttyACM1 is the OTA source. On Windows they might be COM7 and COM8. Close picocom, a serial monitor, the phone app, and any other program holding either link.

    Test the controller's binary API:

    meshcli -s /dev/ttyACM0 -b 115200 ver\n

    For an ordinary raw-text source, test its OTA support:

    meshcli -r -s /dev/ttyACM1 -b 115200 \"ota status\"\n

    The command must print an OTA | ... target:XXXXXXXX status.

    For an nRF52 full Companion, open the source port with terminal mode selected automatically:

    picocom -b 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM1\n

    Run ota status. It must report OTA seeder, install:disabled, and target 00000000; send +++MESHCORE-TERM-STOP before closing the terminal. The automation detects and performs this token-wrapped preflight itself, so no extra command-line option is needed.

    For an ESP32 full Companion, test its separate WiFi control console instead:

    ./tools/lora_ota/lora_ota.sh --tcp-cli 192.168.1.50 \"ota status\"\n

    It must report OTA seeder, install:disabled, and target 00000000.

    This standalone fallback uses Python's raw TCP socket directly. It works with meshcli versions whose -r mode rejects -t; it does not need meshcli, motatool, nc, or a firmware package. It defaults to text port 5002; port 5000 speaks the binary Companion protocol and port 5001 serves mOTA data. The firmware must expose the text console; this option does not enable one.

    Specify a port or adjust the total connection/command timeout (10 seconds by default):

    ./tools/lora_ota/lora_ota.sh --tcp-cli 192.168.1.50:5002 --timeout 15 \"ver\"\n

    PowerShell uses the same option:

    .\\tools\\lora_ota\\lora_ota.ps1 --tcp-cli 192.168.1.50 \"ota status\"\n

    Place --tcp-cli first. Each invocation sends one command and waits for the complete reply and terminal prompt. Connection failures, timeouts, missing replies, and rejected commands exit nonzero. If USB owns the terminal, close the active USB terminal and try again. Commands are never automatically resent in this standalone mode: a timeout after sending may mean the command executed but its reply was lost. Inspect the device state before repeating a change.

    For an actual OTA run, continue using --source-tcp HOST:5001 with --source-cli-tcp HOST:5002; that existing control path also bypasses meshcli raw mode. The standalone fallback does not transfer a firmware image itself.

    Changing a terminal to 57600 baud does not select ASCII mode. USB Companion builds and the normal raw management CLI use 115200 unless a particular build was explicitly configured otherwise.

    "},{"location":"lora_ota_automation/#3-check-the-destination-once","title":"3. Check the destination once","text":"

    The destination must be in the controller's contacts and remotely reachable on the normal channel. The script runs these authenticated checks itself:

    ota status\nget bootloader.ver\nota self\nota stats\n

    The script uses get bootloader.ver as an optional platform/version hint, not as an install gate. It makes one attempt without an interactive retry loop. unknown, an unsupported/missing command, an unrecognized reply, or a lost reply produces a warning and does not by itself block the run. In particular, older MeshCore firmware can report unknown for the installed signed OTAFIX 2.4.6 MeshTower V2 SD image because it omits the legacy UF2 version string. Both the shell and PowerShell launchers use this same behavior; no application or Companion firmware update is required for this host-side workaround. The runner also accepts the new OTAFIX2.4.6 and explicitly labelled 0.11.0 (base) formats.

    The runner still requires ota self to report bootloader: apply OK, bootloader: QSPI apply OK, or bootloader: SD apply OK and checks the reported bootloader ABI and codec mask against the selected package. If the version command is unavailable on older firmware, the script warns and falls back to the legacy ota self platform marker. If an nRF52 bootloader lacks the required capabilities, install the exact-board OTAFIX bootloader first. Positive nRF52 capability markers take precedence over a generic unsupported getter response, so it cannot silently skip nRF52 safety checks. Bootloader packages still require the independently checked ota bootloader status board, target, ABI, codec and storage identity; missing version text never bypasses package signature checks or the explicit bootloader-install workflow. The separate hardware-qualified rak3401_mota_chain.py retains its exact deployed version restriction; it is not a general OTAFIX 2.4.6 update runner. Current firmware also reports maxblk:2048 near the front of both OTA replies. The runner treats a missing marker as the deployed 1 KiB receive limit, rejects a ready 2 KiB package for such a target before transfer, and passes the live limit to motatool --block-size when it builds from raw firmware. Thus updating an older target from a release ZIP produces a compatible 1 KiB package while a current target retains the 2 KiB application default. ota stats is only an optional EndF version probe. It uses one bounded retry cycle; unsupported firmware or a lost reply falls back directly to the required ver command instead of entering an operator continuation loop.

    The default TempRadio tuple is:

    909.950,250,5,5,120\n

    The test default is 250 kHz bandwidth, SF5, and CR5. The frequency is only a North American example: choose a legal frequency supported by every participating radio and appropriate to your location. Older radios that do not support SF5 require a complete replacement tuple passed with --temp-radio.

    Before that long window is allowed, the live runner performs a mandatory independent three-minute rehearsal. Its exact normal-channel ota status and ota self identity proof gets one shared four-minute, read-only budget before any schedule, radio override, seeder, or transfer is armed; this tolerates a marginal link without consuming or extending a live lease. It advances a stale managed controller with exact readback and treats a source terminal's whole-minute display as an uncertainty window, never as an exact epoch. A source minute overlapping the host is preserved; a completely stale minute is pinned to a guarded future value and read back once. Neither clock is moved backward, and the runner stops if a managed clock cannot be proven within the ten-minute limit. Each remote participant must expose an empty tempradioat schedule. The runner projects one fixed start/end interval into each participant's independently sampled RTC, sends each mutation once, proves every exact identity on the temporary tuple, waits for natural expiry, and proves the complete normal path again. A lost schedule reply is resolved by those on-air proofs; it is never blindly replayed with a fresh duration.

    The published RAK3401 v1.16.7-c1caa5ad LoRa-OTA image includes get/set/del tempradioat and can take this safe first step. A truly older or reduced build without fixed TempRadio scheduling is rejected before mutation; bootstrap it locally or with another explicitly controlled maintenance path. The automation does not substitute an immediate tempradio command whose first delivery could remain queued after cleanup.

    "},{"location":"lora_ota_automation/#rxps-handling-during-tempradio","title":"RXPS handling during TempRadio","text":"

    RXPS improves receive performance per unit of radio-on time, so the runner keeps the destination's saved RXPS preference enabled whenever the selected fast tuple has a qualified timing window. Before changing any radio, it reads ver from the destination, controller, source, and every named relay. It treats v1.17.1.5 as the first forward contract in which every SF5-SF8 transmission, including a retry, uses the same tuple-selected physical preamble: normally 32 symbols, 64 only when 32 cannot enable RXPS, and 128 only when neither 32 nor 64 can. A saved RXPS level is also safely retuned after a radio change. An older or unparseable destination version fails closed: the runner temporarily sends set radio.rxps off instead of assuming an ad-hoc build contains the timing fixes. Automation deliberately treats the version as a wire-format contract.

    For the default SF5/BW250 tuple, the runner keeps destination RXPS on only when the destination and every possible sender are v1.17.1.5 or newer. It does not overwrite an existing level-based preference. Current firmware retunes that preference against the tuple-selected 64-symbol wire preamble; the qualified boundary is effective level 8, preamble 64 (1252 / 6424 us). A manually configured node may save set radio.rxps level 8 preamble 32 before entering TempRadio: when 32 symbols cannot cover the TCXO transition, firmware safely selects the real 64-symbol preamble. If the saved setting uses fixed manual timings, or even one participant is older or unknown, RXPS is temporarily off. This avoids the receive gap that a 32-symbol sender or an unretuned manual window would create.

    The complete qualified SX1262+TCXO policy is:

    TempRadio SF/BW Saved reference setting Qualified boundary Automation SF7/BW500 level 7 preamble 32 7 / 32 RXPS on for a current destination SF6/BW250 level 7 preamble 32 7 / 32 RXPS on for a current destination SF5/BW125 level 7 preamble 32 7 / 32 RXPS on for a current destination SF5/BW250 level 8 preamble 32 8 / 64 RXPS on only when every participant follows the 64-symbol contract SF6/BW500 level 8 preamble 32 8 / 64 RXPS on only when every participant follows the adaptive-preamble contract SF5/BW500 level 8 preamble 32 8 / 128 RXPS on only when every participant follows the adaptive-preamble contract SF5/BW62.5 level 10 preamble 16 10 / 16 RXPS on for a current destination Unqualified tuple none continuous RX RXPS temporarily off

    If RXPS was already off, the runner leaves it off. Otherwise it writes the original level, preamble assumption, receive/sleep periods, and temporary decision to protected target-rxps-settings.json in the run's working directory, verifies the temporary state, and restores the exact original setting after the target is back on its normal radio. Current firmware exposes this complete state through get radio.rxps.config; a legacy reply has only on/off and periods, so the runner can restore those periods but cannot reconstruct an unreported saved level. A radio change later recalculates from a preserved saved minimum, so moving back to a slower tuple returns to the operator's saved level.

    The OTA source has a stricter policy than the destination. For every source with a managed serial or TCP CLI, the runner reads and retains its exact RXPS preference, using the legacy fixed-period query only when the detailed query is unavailable. It builds and verifies the package, completes the read-only target checks, and obtains confirmation while that preference remains unchanged. Immediately before the first radio mutation, it reads the source RXPS state again, disables RXPS, and verifies the readback. Source RXPS stays off through catalog serving, download, installation, and post-install identity verification. Cleanup first proves that the source has returned to its normal radio, then restores and verifies the exact saved level/preamble or fixed-period state once. A source whose RXPS state cannot be read, disabled, or restored safely fails closed. If current firmware explicitly rejects an RXPS disable or restore with radio busy; retry, the runner retries that idempotent mutation at staggered 210\u2013378 ms intervals. All 32 delays are distinct and contribute about 9.4 seconds of waits; source-CLI command round-trip time is additional. This avoids repeatedly sampling one radio phase while retaining a strict attempt cap. Other rejections are not replayed.

    "},{"location":"lora_ota_automation/#4-run-an-esp32-update","title":"4. Run an ESP32 update","text":"

    The ZIP can contain a compatible ready .mota or the exact board-and-role non-merged application .bin:

    export MESHCORE_ADMIN_PASSWORD='target-admin-password'\n\n./tools/lora_ota/lora_ota.sh ./release.zip \"Roof ESP32\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1\n

    The script shows the detected target, hardware, running hash, chosen package, version, manifest ID, and action before asking for confirmation. For an unattended job, add --yes:

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Roof ESP32\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1 \\\n  --yes\n

    PowerShell equivalents:

    $env:MESHCORE_ADMIN_PASSWORD = 'target-admin-password'\n\n& .\\tools\\lora_ota\\lora_ota.ps1 '.\\release.zip' 'Roof ESP32' `\n  --controller-serial COM7 `\n  --source-serial COM8\n\n& .\\tools\\lora_ota\\lora_ota.ps1 '.\\release.mota' 'Roof ESP32' `\n  --controller-serial COM7 `\n  --source-serial COM8 `\n  --yes\n

    Prefer the environment variable or the interactive password prompt. Passing --password works, but the wrapper's own command line may be visible to other local processes. The runner keeps the password out of child meshcli command lines and removes its protected temporary command file after each call.

    "},{"location":"lora_ota_automation/#5-run-an-nrf52-update","title":"5. Run an nRF52 update","text":"

    If the input ZIP already contains a compatible in-place delta .mota, no base argument is needed: its embedded base hash is compared with the live node. If the ZIP contains raw new firmware, supply the exact running image:

    ./tools/lora_ota/lora_ota.sh ./nrf52-new-release.zip \"Hill nRF52\" \\\n  --base ./firmware-that-is-running.hex \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1\n
    & .\\tools\\lora_ota\\lora_ota.ps1 '.\\nrf52-new-release.zip' 'Hill nRF52' `\n  --base '.\\firmware-that-is-running.hex' `\n  --controller-serial COM7 `\n  --source-serial COM8\n

    Before building a delta, the runner proves that the base's target ID, hardware identity, firmware version when available, and EndF body hash match the live destination. It then asks motatool for codec 2, the nRF52 in-place format. The normal workspace is 0x98000.

    For a QSPI-backed repeater or the SD-backed MeshTower V2 target, a raw ZIP becomes a full image without --base. Supplying an exact base requests a smaller in-place delta and automatically selects the conservative external workspace 0xC6000, which is safe for S140 v6 and v7 application layouts. An explicit --inplace-memory overrides the automatic value.

    The live runner detects QSPI from ota self (QSPI apply OK) or ota status (bl:QSPI) and refuses an explicit QSPI store:ERR 0K report even when the bootloader itself advertises QSPI apply support. Offline --prepare-only runs must supply --nrf-qspi; do not use that switch for a board that only exposes QSPI pins or uses the chip as a Companion filesystem. The application and bootloader must both be from the matched repeater list in the nRF52 QSPI guide.

    "},{"location":"lora_ota_automation/#6-add-intermediate-relays","title":"6. Add intermediate relays","text":"

    List relays from farthest to nearest so each command is sent before its route moves to TempRadio. A bare relay name uses the destination password; use NAME=PASSWORD when it differs:

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Remote Target\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1 \\\n  --relay \"Far Relay=far-password\" \\\n  --relay \"Near Relay=near-password\"\n

    PowerShell uses the same arguments:

    & .\\tools\\lora_ota\\lora_ota.ps1 '.\\release.mota' 'Remote Target' `\n  --controller-serial COM7 `\n  --source-serial COM8 `\n  --relay 'Far Relay=far-password' `\n  --relay 'Near Relay=near-password'\n
    "},{"location":"lora_ota_automation/#other-connection-choices","title":"Other connection choices","text":"

    The controller can use any one of:

    --controller-serial PORT\n--controller-tcp HOST[:PORT]       # default port 5000\n--controller-ble ADDRESS_OR_NAME\n

    An ESP32 FULL/repeater source can serve over its dedicated WiFi seeder port while its raw USB CLI is used to start TempRadio:

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Remote Target\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp 192.168.1.50:5001 \\\n  --source-cli-serial /dev/ttyACM1\n

    An ESP32 companion_radio_full uses WiFi for both dedicated source links:

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Remote Target\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp 192.168.1.50:5001 \\\n  --source-cli-tcp 192.168.1.50:5002\n

    Port 5002 defaults automatically when it is omitted from --source-cli-tcp. Current Full Companion firmware exposes its complete role-specific text terminal there; the runner uses only ota, tempradio, and normalradio, and remains compatible with older bounded port-5002 consoles. The source-only Companion never stages or installs the image itself; it streams the host folder to other nodes over LoRa. See the full Companion guide for manual operation and interface details.

    An nRF52 companion_radio_full uses one USB source port sequentially. The runner automatically wraps local control commands in the terminal tokens, and unmodified motatool switches that port into mOTA mode when seeding starts:

    The wrapper sends STOP, then START, before each command. This makes fallback independent of whether a prior raw probe left an unobservable USB-UART connection in ASCII or Binary mode. Seeder startup is reported only after the verbose device log contains its COUNT acknowledgement; an immediate device ERR or a missing acknowledgement fails during startup instead of surfacing later as a catalog timeout.

    ./tools/lora_ota/lora_ota.sh ./release.mota \"Remote Target\" \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-serial /dev/ttyACM1\n

    If the source is already on the exact TempRadio tuple through a scheduled or manual operation, --source-already-temp lets a TCP source run without a raw CLI link. The script cannot verify, extend, or shorten that source window, so leave a comfortable time margin. It also cannot inspect or change that unmanaged source's RXPS state; disable source RXPS yourself before starting the run and restore it only after returning the source to its normal radio.

    Use --controller-baud or --source-baud only for a build whose corresponding interface is genuinely configured to another speed.

    "},{"location":"lora_ota_automation/#package-selection-and-safety-gates","title":"Package selection and safety gates","text":"

    For a ZIP, the runner first examines every .mota without extracting paths. It keeps only packages matching the live target, hardware, base, platform, codec, and bootloader capabilities. It chooses the newest compatible version and prefers a delta over a full image at the same version. If equally suitable files differ, select one explicitly:

    --zip-member path/inside/archive/update.mota\n

    If no ready mOTA is usable, it searches .bin and .hex members for a valid, matching EndF, then builds the platform-appropriate container. Every result is structurally checked by the runner and independently passed through motatool verify before any radio changes. Direct firmware and mOTA inputs, as well as individual ZIP members, are rejected above 64 MiB before being loaded.

    Useful controls:

    For offline package preparation only:

    ./tools/lora_ota/lora_ota.sh ./release.zip offline \\\n  --prepare-only \\\n  --platform nrf52 \\\n  --target-id 1234ABCD \\\n  --target-base-hash 0011223344556677 \\\n  --target-hw Heltec_T114 \\\n  --base ./firmware-that-is-running.hex\n

    Live operation is safer because the script obtains these values directly from the destination.

    "},{"location":"lora_ota_automation/#what-happens-during-a-run","title":"What happens during a run","text":"
    1. Validate the input paths and host tools, then prove the source is either an OTA-enabled raw CLI or a source-only full Companion control interface. Read and durably save a managed source's exact RXPS preference without changing it.
    2. Before sending any remote packet, prove the managed source has no active, pending, or fixed TempRadio work, then gate the source and controller clocks. Advance a stale exact clock to host time; for a minute-resolution source, preserve an overlapping minute or use one guarded forward value with readback. Preserve a small future lead and fail closed above the ten-minute drift limit. Then authenticate to the target, query its target ID, hardware, running body hash, firmware version, bootloader version, and nRF52 bootloader capabilities, and save the controller's normal radio tuple.
    3. Select or build one compatible v2 application mOTA and verify all block hashes, Merkle root, full-image hash where applicable, identity fields, signature, codec, base, and the live firmware's reported maximum block size (1 KiB for legacy replies without maxblk, currently 2 KiB otherwise). Version-3 bootloader packages are refused before any target state changes.
    4. Read every participant's version, save the destination's RXPS state, select the qualified destination policy, and show the confirmation prompt.
    5. Run the fixed-window three-minute rehearsal described above. This phase does not disable RXPS, start a seeder, request a pull, or install anything; the long transfer remains completely unarmed until temporary and normal reachability both pass.
    6. Fresh-read, disable, and verify source RXPS; apply and verify the destination RXPS policy; then start TempRadio on the target, far-to-near relays, and source. A separate controller is moved and read back through Binary; a shared Full Companion instead schedules its bounded local override while Binary remains the transport. The runner rejects a TempRadio window that cannot cover setup, seeder startup, discovery, the transfer timeout, final polling, and install checks.
    7. Start motatool serve, discover the exact eight-hex manifest ID, request ota pull <id> flash, and poll until that same ID reports ready. A seeder process exit stops the run immediately. For --no-install, schedule all script-controlled nodes back to their normal radios before restoring the controller, unless --leave-controller-radio was requested.
    8. Recheck that exact ID, give the target a short final TempRadio safety window, and request ota install. Then shorten each relay's TempRadio window so the normal multi-hop route returns, stop the seeder, shorten the source window, restore the controller, and probe ota self every 10 seconds through the configured readiness window (five minutes by default). The exact new body hash is the readiness signal; only then does the runner require the exact package version. A relayed run continues the 10-second probes through the mandatory relay-return window. A source supplied with --source-already-temp is never modified. --leave-controller-radio moves the controller back to TempRadio only after this normal-channel verification. Restore the destination's exact original RXPS setting after normal-channel identity is proven. A managed source stays RXPS-off through that verification and its exact setting is restored only after its own TempRadio state is proven inactive.

    Remote replies are matched only after queued messages have been drained and only when they come from the intended contact and fit the command. A ready status for another manifest ID is an error, never permission to install it.

    "},{"location":"lora_ota_automation/#transmission-loss-and-retries","title":"Transmission loss and retries","text":"

    Read-only and replay-safe transmissions retry up to three times. Three retries or 90 seconds, whichever comes first, opens a 10-second stop-or-continue prompt when stdin is an interactive terminal. Continue remains the default on timeout or Enter, so an operator can persist through a temporary outage; enter s or stop to end the run, and Ctrl-C remains immediate. With non-interactive stdin, the runner stops after that finite automatic retry cycle instead of silently starting another cycle forever. Optional participant-version probes always use a bounded cycle and degrade to unknown; optional destination ota stats falls back directly to ver. The mandatory normal-channel destination baseline is the narrow exception: it may make nine total read-only attempts but is stopped by one hard four-minute deadline before any TempRadio lease or OTA mutation exists. Proofs inside the live rehearsal retain the smaller four-attempt limit.

    Commands that change OTA state are reconciled before replay:

    Retries and operator-selected continuation can outlast the original TempRadio budget. If a bounded window expires, rerun the same package after the nodes return to their normal channel; the manifest-ID check resumes its partial download without replacing it.

    The working directory is created before a managed source can be changed, then retained and printed at exit. It contains the exact served mOTA, motatool-serve.log, extracted build inputs when needed, and controller-radio.txt. A managed source also gets a protected source-rxps-settings.json containing its exact original preference and idempotent restore command. Its contents and directory entry are flushed before RXPS is disabled. When the destination started with RXPS enabled, protected target-rxps-settings.json records its manual recovery state. The RAK3401 chain points every nested step at one chain-root source record, so a rerun after host power loss does not adopt the temporary RXPS-off state as the original. A retained record is accepted only for the same managed CLI endpoint. These files contain no saved admin password. After exact source restoration, a standalone run atomically retires its record; the chain retains its shared record between steps and retires it only after the verified endpoint restoration completes.

    "},{"location":"lora_ota_automation/#interruption-and-recovery","title":"Interruption and recovery","text":"

    Ctrl-C stops the seeder, detaches its serial folder, makes a bounded attempt to shorten a source TempRadio window started by the script, and attempts to restore the controller. For a managed source it leaves RXPS off until the source is proven back on its normal radio, then restores and verifies the saved source preference. A transient success-path restore failure remains armed for one more idempotent cleanup attempt. The target and relays remain on TempRadio only until their bounded windows end; rebooting also restores their saved radio settings. A normal cleanup restores the destination's exact RXPS periods. If that remote restore cannot be confirmed, use target-rxps-settings.json after the target returns to its normal channel. A partial download remains safe. Once the target is reachable again (after its TempRadio window ends, or after putting the controller back on that tuple), rerunning the same package recognizes its manifest ID and resumes the existing session instead of clearing it.

    When a chained run has already proved the newly running body hash, its retained previous package can briefly report verifying staged blocks after TempRadio reactivates the OTA manager. The runner waits only through the configured discovery timeout, keeps checking source liveness, and accepts only the same manifest becoming ready to install or the manager becoming idle. A changed ID, failed or incomplete state, or timeout stops the chain. It then proves the exact installed body again. If that same session is still attached and ready, the runner detaches it with ota cancel; if the manager has become idle, it sends no cancel. An ordinary-channel no download status proves only that the manager is idle, not that persistent staging was erased. The runner therefore does not issue or describe an IDLE cancel as durable cleanup. The next chain transition explicitly re-adopts and proves the expected previous MID before detaching it; after the final install OTAFIX has consumed the approval word, so any retained container is inert and is replaced by the next valid pull.

    A hard process kill or host power loss cannot run cleanup. Recover a serial controller using the tuple saved in the printed work directory:

    radio=$(tr -d '\\r\\n' < ./meshcore-lora-ota-20260807-123456-1234/controller-radio.txt)\nmeshcli -s /dev/ttyACM0 set radio \"$radio\"\n
    $radio = (Get-Content '.\\meshcore-lora-ota-...\\controller-radio.txt' -Raw).Trim()\nmeshcli -s COM7 set radio $radio\n

    For a managed source, first return it to its ordinary radio, then inspect source-rxps-settings.json and issue its exact restore_command through the same serial or TCP-console endpoint recorded in that file. The command is idempotent; confirm the full setting with get radio.rxps.config before resuming an update.

    If you stop during final confirmation, reconnect on the node's normal channel and run ota self and ver. A completed run returns success only when ota self reports a valid new body hash and ver exactly matches the package; an unverified install returns status 2. Do not immediately replace a staged image: the default active-download guard preserves it until you explicitly use --replace-active-download or run ota cancel.

    Exit status is 0 for success, 2 for a validation or operational error, and 130 for Ctrl-C.

    "},{"location":"mbedtls-tls-footprint/","title":"Shrinking the per-connection TLS footprint on non-PSRAM observers","text":""},{"location":"mbedtls-tls-footprint/#why","title":"Why","text":"

    On a non-PSRAM Heltec V3 running two WSS/JWT broker slots, the largest allocatable block in internal DRAM walks down in ~16 KiB steps at every TLS reconnect while total free heap stays flat. Measured on hardware over 50 reconnect cycles: 62,452 -> 16,372 bytes, permanently.

    The step size is not a coincidence. framework-arduinoespressif32 3.20017 (Arduino 2.0.17, IDF 4.4) builds mbedTLS with the symmetric buffer configuration:

    CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN 16384      # sizes BOTH the in and out record buffers\nCONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE 1\n# CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN       -- not defined\n# CONFIG_MBEDTLS_DYNAMIC_BUFFER               -- not defined\n# CONFIG_MBEDTLS_SSL_VARIABLE_BUFFER_LENGTH   -- not defined\n

    Read from packages/framework-arduinoespressif32/tools/sdk/esp32s3/qio_qspi/include/sdkconfig.h. Note the separate framework-arduinoespressif32-libs package in ~/.platformio belongs to the esp32c6 env (pioarduino, IDF 5.3) and is not what this env links against -- though it happens to carry identical mbedTLS values.

    So each broker slot costs 2 x 16 KiB = 32 KiB of contiguous internal DRAM in record buffers alone, and two slots cost 64 KiB on a board with roughly 80 KiB free. Every reconnect frees and re-allocates those buffers, and anything that lands in the vacated hole in between prevents them from going back, which is the ratchet.

    Confirmed by two independent observations: losing a whole TLS session returned exactly 16,384 bytes of contiguity on one device and exactly 32,768 on another, and per-connection teardown frees ~41.6-44.7 KB total.

    "},{"location":"mbedtls-tls-footprint/#what-the-firmware-could-already-do-and-its-limit","title":"What the firmware could already do, and its limit","text":"

    softDisconnect() (branch perf/mqtt-renewal-no-stop, commit 6c51e468) stops the JWT renewal bounce from destroying and recreating the esp-mqtt task, keeping its 6 KiB stack out of the hole. Measured: the staircase arrests after 2 steps at 36,852 through cycle 16, where the baseline took 4 steps and settled at 16,372 by cycle 11 -- about 20 KB better.

    That is as far as the application layer reaches. MQTT 3.1.1 has no re-authentication packet, so presenting a fresh JWT requires a transport reconnect; mbedTLS's internal allocation order during the handshake is not controllable from the application. The remaining cost is the record buffers themselves.

    "},{"location":"mbedtls-tls-footprint/#the-changes","title":"The changes","text":"

    All three are compile-time in mbedTLS, and the Arduino framework ships precompiled .a archives (tools/sdk/esp32s3/lib/libmbedtls.a), so a project-level -D cannot change them. A custom framework build is required.

    Setting From To Saving per connection CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN unset y enables the two below CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN 16384 (implied) 16384 none -- keep it CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN 16384 (implied) 4096 ~12 KiB CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE 1 n ~4 KiB

    Roughly 16 KiB per connection, 32 KiB across two slots.

    "},{"location":"mbedtls-tls-footprint/#why-inbound-stays-at-16-kib","title":"Why inbound stays at 16 KiB","text":"

    A peer may legitimately send a 16 KiB TLS record. Lowering the inbound limit only works if both brokers negotiate the max-fragment-length extension or provably never send larger, and getting it wrong produces invalid-record and handshake failures rather than a clean error. Outbound is ours to choose: this firmware's MQTT and WebSocket frames are far below 4 KiB (MAX_TRANS_UNIT-bounded packets plus small JSON), so 4 KiB is comfortable.

    "},{"location":"mbedtls-tls-footprint/#risk-on-the-peer-certificate","title":"Risk on the peer certificate","text":"

    Dropping KEEP_PEER_CERTIFICATE means mbedtls_ssl_get_peer_cert() returns NULL after the handshake. Chain validation still happens -- only retention of the parsed leaf changes. This firmware verifies against a CA (GTS_ROOT_R4 / the bundle) and never inspects the peer certificate or a fingerprint, so it should be safe. It does change mbedtls_ssl_session layout, which is exactly why the whole framework must be rebuilt together rather than swapping a single archive in.

    "},{"location":"mbedtls-tls-footprint/#build-procedure","title":"Build procedure","text":""},{"location":"mbedtls-tls-footprint/#do-not-use-esp32-arduino-lib-builder-for-this","title":"Do not use esp32-arduino-lib-builder for this","text":"

    release/v4.4 is the branch matching Arduino 2.0.x, but its update-components.sh clones every dependency at master, so it no longer resolves. Five successive failures, each a different repo: the arduino branch name it passes to -A does not exist; jq is absent from the IDF image and its absence makes build.sh's target loop a silent no-op that still exits 0; esp_littlefs and esp32-camera master require IDF >=5.0/>=5.1; esp32-camera later needs an esp_jpeg version the 4.4 registry cannot satisfy; and tinyusb's source layout no longer matches arduino_tinyusb/CMakeLists.txt. Pinning each one in turn just surfaces the next.

    "},{"location":"mbedtls-tls-footprint/#rebuild-only-the-mbedtls-archives","title":"Rebuild only the mbedTLS archives","text":"

    More rigorous anyway, because it reuses the shipped sdkconfig verbatim -- so the archives differ from stock only by the intended change, with no arduino-version or DYNAMIC_BUFFER drift.

    This is ABI-safe for the content-length change specifically: ssl.h declares in_buf and out_buf as unsigned char *, allocated in ssl_setup(), and no public struct embeds a CONTENT_LEN-sized array. The other precompiled archives (esp-tls, esp_http_client, esp-mqtt) therefore remain compatible. It is not safe for CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE, which changes mbedtls_ssl_session layout -- that one needs everything rebuilt together, so it is excluded here.

    1. Minimal IDF project whose only component requirement is mbedtls.
    2. sdkconfig.defaults = the shipped packages/framework-arduinoespressif32/tools/sdk/esp32s3/sdkconfig, with CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384 replaced by the three asymmetric lines. Verify the diff is exactly 5 lines before building.
    3. docker run --rm -v $PWD:/project -w /project espressif/idf:v4.4.7 idf.py -DIDF_TARGET=esp32s3 build
    4. Confirm build/config/sdkconfig.h carries OUT_CONTENT_LEN 4096.
    5. Stage the four archives under the framework's names -- note the rename:
    built framework name stock size rebuilt esp-idf/mbedtls/libmbedtls.a libmbedtls.a 113,914 113,338 esp-idf/mbedtls/mbedtls/library/libmbedtls.a libmbedtls_2.a 1,252,150 1,245,718 .../libmbedcrypto.a libmbedcrypto.a 4,302,698 4,259,458 .../libmbedx509.a libmbedx509.a 676,590 673,318

    All within ~1% of stock, which is a good check that only the config differs.

    "},{"location":"mbedtls-tls-footprint/#wire-it-in-with-l-never-platform_packages","title":"Wire it in with -L, never platform_packages","text":"
    PLATFORMIO_BUILD_FLAGS=\"-L/path/to/staged/archives\" pio run -e Heltec_v3_repeater_observer_mqtt\n

    Verify with grep -oE \"[^ ]*libmbed[a-z0-9_]*\\.a\" .pio/build/<env>/firmware.map | sort -u -- every path must be the staged directory.

    Do not point platform_packages at a file:// copy of the framework. PlatformIO installs it over the shared ~/.platformio/packages/framework-arduinoespressif32, silently changing mbedTLS for every other ESP32 env and project on the machine. It does this even when the copy's package.json version differs -- verified twice here, and both times the fix was rm -rf the package and pio pkg install to re-download stock. A prepended library search path keeps the change scoped to one env, because the linker takes each archive member from the first archive that satisfies an undefined symbol.

    "},{"location":"mbedtls-tls-footprint/#how-to-verify-it-worked","title":"How to verify it worked","text":"
    1. strings/grep the new sdkconfig.h for the four settings.
    2. Build and check the RAM figure; static usage should be unchanged (these are heap buffers).
    3. On hardware, get mqtt.stats at boot with two slots connected: the largest free block should start roughly 24-32 KiB higher than the current 62-67 KiB.
    4. Soak across reconnect cycles and compare the floor against the two recorded runs: baseline settled 16,372 (cycle 11); softDisconnect holds 36,852 (cycle 16).
    "},{"location":"mbedtls-tls-footprint/#prior-art-in-this-investigation","title":"Prior art in this investigation","text":"

    .scratch/mqtt-non-psram-heap-staircase-analysis-2026-08-05.md (untracked -- .scratch/ is globally gitignored) holds the full allocation inventory. ~/mqtt-soak/STATE.md holds the soak evidence, including two retracted hypotheses worth not repeating: the perf commits were not the cause, and waev does not cap connections per IP.

    "},{"location":"nimble_companion_trial/","title":"ESP32-S3 NimBLE Full Companion trial","text":"

    These optional builds use NimBLE-Arduino 2.5.1 with the existing ESP32 Arduino 2.0.17 toolchain. They are hardware qualification builds, outside the normal release matrix and firmware picker.

    See the 2026-09-08 hardware results for measured RAM, MAC-policy checks, WiFi updates, and remaining qualification.

    The contact-cache guide adds six optional 350-contact trials for ESP32 boards without PSRAM, including Heltec V3. Their qualification results include linked RAM margins, V4/RAK hardware tests and the mOTA queue policy.

    Hardware Trial environment Contacts Channels Offline queue Heltec V4.2/V4.3 OLED, FEM on heltec_v4_2_v4_3_companion_radio_full_femon_nimble 350 40 512 XIAO ESP32-S3 with WIO SX1262 Xiao_S3_WIO_companion_radio_full_nimble 350 40 256

    The XIAO profile uses WIO radio pins CS 41, DIO1 39, BUSY 40 and RESET 42. It is not the generic XIAO profile for separately wired radio modules. Both boards retain their parent Full Companion features, USB mOTA sender, WiFi update slots, and PSRAM-backed offline queue. The V4 also retains direct MQTT.

    The small-screen message layout uses 6-pixel capitals on 128x64 panels and 5-pixel capitals on very tiny panels. It includes a compact channel/sender line and full message previews. Font selection is automatic.

    "},{"location":"nimble_companion_trial/#build","title":"Build","text":"

    Enable the optional configuration in the ignored platformio.local.ini:

    [platformio]\nextra_configs =\n  variants/*/platformio.ini\n  platformio.nimble.ini\n

    If that file already contains local settings, merge this list with them. Run one PlatformIO/build.sh command at a time. Both commands below use USA Cascade:

    OUTPUT_DIR=.releases/nimble-v4 bash build.sh build-firmware \\\n  heltec_v4_2_v4_3_companion_radio_full_femon_nimble \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \\\n  --radio-preset usa-cascadia --profile cascade --standard --require-ota\n\nOUTPUT_DIR=.releases/nimble-xiao bash build.sh build-firmware \\\n  Xiao_S3_WIO_companion_radio_full_nimble \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \\\n  --radio-preset usa-cascadia --profile cascade --standard --require-ota\n

    Use the merged image at address 0 for a USB installation. The V4 uses 16 MB flash; the XIAO uses 8 MB. Both use DIO flash mode. Use the application-only .bin for an existing, matching WiFi OTA layout. Remove the optional config entry when finished to return to the ordinary release matrix.

    "},{"location":"nimble_companion_trial/#bluetooth-identity-and-pairing","title":"Bluetooth identity and pairing","text":"

    All existing MAC settings remain available from the ASCII USB terminal and the app's authenticated local CLI. A setting takes effect after reboot.

    Command Behavior get bluetooth.mac Show the saved MAC policy. set bluetooth.mac C2:17:15:04:00:01 Use this custom random-static address. set bluetooth.mac random Generate one address and retain it across boots. set bluetooth.mac random-every-boot Generate a new address on each boot. set bluetooth.mac random-after-connect Rotate on the next boot after an authenticated connection; otherwise retain the address. set bluetooth.mac default Restore the factory Bluetooth address. set bluetooth.stealth on Pair once, then accept the saved bonded peer. set bluetooth.stealth off Restore ordinary discovery without changing the MAC policy. get bluetooth.stealth Show pairing or bonded-peer-only state.

    The ble.mac and ble.stealth aliases also work. A literal custom address must be a valid BLE random-static address (first byte C0-FF). Forget the old entry in the phone's Bluetooth settings and pair again after an address change or switching Bluetooth libraries. Existing Bluedroid bonds are not migrated.

    Stealth does not disable the selected rotation policy. If rotation changes the address on reboot, first pairing opens again for the new identity.

    Pairing requires encryption, PIN authentication and a stored bond. NimBLE's controller byte order is converted explicitly, so a custom address appears over the air in the order entered. Failed identity setup stops advertising and retries initialization instead of advertising the wrong address.

    "},{"location":"nimble_companion_trial/#v4-wifi-setup-qr","title":"V4 WiFi setup QR","text":"

    The active WiFi setup page displays a compact QR code with a one-pixel white border. The normal open setup network fits a 44 x 44 pixel code, with 2 x 2 pixel modules. The SSID, portal address and HOLD STOP hint remain beside it. Short-click to leave the page; hold the button on this page to start or stop the setup AP. From USB, start webconfig ap starts the same setup session.

    When the setup AP is inactive and the node has joined WiFi, this page shows WIFI CONNECTED, IP <address>, SETUP AP INACTIVE, then HOLD START AP from top to bottom. The address appears once assigned and clears when the station disconnects. While the setup AP is active, the QR page shows the setup portal's address instead.

    "},{"location":"nimble_companion_trial/#regression-checks","title":"Regression checks","text":"
    python3 -B test/test_nimble_companion.py\npython3 -B test/test_bluetooth_mac_contract.py\npython3 -B test/test_bluetooth_pairing_ui.py\npython3 -B test/test_heltec_v4_wifi_setup_page.py\npython3 -B test/test_firmware_ram.py\npython3 -B test/test_esp32_dram.py\npython3 -B test/test_shared_mota_queue.py\npython3 -B test/test_companion_mqtt_stack.py\npio test -e native -f test_companion_node_prefs \\\n  -f test_ble_tx_stall_watchdog -f test_companion_frame_queue \\\n  -f test_display_driver\n

    The adapter test compiles the actual transport under address/undefined-behavior sanitizers with API doubles. It injects allocation, identity and bond failures; checks PIN authentication and stealth; and exercises 176-byte frames, MTU and notification subscription gating, retry and duplicate prevention. Embedded builds compile against the pinned real library. RAM reports retain the existing wireless budget; linked headroom is not a measurement of live free heap. The MQTT save callback also has a compiler-enforced stack-frame regression check: nested preference copies previously overflowed the V4 loop task when saving MQTT settings through WebConfig, despite sufficient free heap.

    Hardware qualification must additionally verify pairing and reconnection, advertised addresses for every MAC policy, contact synchronization, WiFi/BLE coexistence, and uptime under load. BlueZ results do not establish iOS or Android camera/pairing compatibility.

    "},{"location":"nimble_companion_trial_results/","title":"ESP32-S3 NimBLE trial results, 2026-09-08","text":"

    These are local hardware trial results for USA Cascade Full Companions on a Heltec V4.3 OLED and a Seeed XIAO ESP32-S3 with WIO SX1262. The V4.2 shares the profile but was not physically tested. Build and command instructions are in the trial guide.

    The final source revision is 9dbab431, using NimBLE-Arduino 2.5.1 and the existing Arduino-ESP32 2.0.17 toolchain. These optional profiles are outside the ordinary release matrix. This trial does not change GitHub release assets or the firmware picker.

    "},{"location":"nimble_companion_trial_results/#capacity","title":"Capacity","text":"Measurement Heltec V4 XIAO S3 WIO Contacts / channels 350 / 40 350 / 40 PSRAM-backed offline queue 512 256 Application image, bytes 1,774,600 1,510,536 OTA application slot, bytes 6,553,600 3,342,336 Linked internal capacity before runtime allocation, bytes 263,944 265,576 Required runtime RAM budget, bytes 173,056 148,480 Headroom beyond that budget, bytes 90,888 117,096 Live free heap with Bluetooth and 350 test contacts, bytes 142,644 147,444 Minimum free heap during that contact test, bytes 138,024 142,788

    The live contact-test readings are from revision 7f1bd8b1. Revision 9dbab431 retains the same Bluetooth adapter, contact allocation and queue sizes, and fixes the MQTT save stack use described below. Linked capacity is not live free heap; the firmware memory reports label that distinction.

    A matching V4 build using Bluedroid had 248,840 bytes of linked internal capacity. NimBLE recovers 15,104 bytes at link time. The matching Bluedroid application was 2,169,848 bytes. There was no matching physical Bluedroid heap measurement, so these figures do not establish a live heap saving.

    "},{"location":"nimble_companion_trial_results/#bluetooth-hardware-checks","title":"Bluetooth hardware checks","text":"

    Both boards passed the following cases with BlueZ on the Mercerwood Pi:

    The complete MAC suite ran on 7f1bd8b1. Final-image connection and update checks also verify the unchanged adapter after the MQTT stack fix. Synthetic contacts were removed after testing; factory MAC mode and stealth off were restored.

    "},{"location":"nimble_companion_trial_results/#mqtt-save-stack-overflow","title":"MQTT save stack overflow","text":"

    The V4 test reproduced an actual reset while saving MQTT settings through WebConfig. Serial output identified Stack canary watchpoint triggered (loopTask). onConfigBatchEnd() kept a second 2.8 KB preference copy while the loader and NVS code also used stack space. Free heap was not the limiting resource.

    The loader already updates its destination only after validation, so the callback now passes the live preference object directly. The actual Xtensa callback frame shrank from 2,928 bytes to 32 bytes. The regression test compiles the real callback with a 512-byte frame ceiling and demonstrates that the previous nested-copy implementation fails the check.

    On the corrected 9dbab431 image, the 11-setting WebConfig batch completed with all entries accepted and no reboot. The V4 connected to a local Mosquitto broker, published MQTT traffic, and kept its authenticated Bluetooth session alive through a 30-second load interval with continuous uptime and zero reported error flags. Free heap measured 120,684 then 122,140 bytes; the minimum was 118,332 bytes and the largest free block was 110,580 bytes. Disabling the test MQTT slot also completed successfully without reboot.

    "},{"location":"nimble_companion_trial_results/#wifi-updates-and-qr","title":"WiFi updates and QR","text":"

    Both boards completed real application-image uploads over WiFi with Bluetooth connected, followed by reboot and reconnection using the saved bond. Repeated HTTP page requests and Bluetooth status requests ran together before upload. The image hash was checked before transmission, and the running firmware version was checked after reboot.

    The V4 WiFi setup page again contains a QR code. The default open setup SSID fits a 44 x 44 pixel code: 2 x 2 pixel modules and an exact one-pixel white border. The native test rendered the actual display/QRCode code and decoded the result with ZXing. SSID, portal address and the hold-to-stop hint fit beside it. Physical phone-camera scanning has not been verified.

    "},{"location":"nimble_companion_trial_results/#observed-limits","title":"Observed limits","text":"

    One XIAO warm reboot left its USB endpoint unresponsive while Bluetooth still answered. The user's physical unplug/replug restored USB; the later full MAC suite completed its commanded reboots successfully. The cause of that one USB failure is unconfirmed.

    The XIAO initially failed to associate with the saved WiFi network. Reapplying the SSID scheduled a fresh connection, after which it joined at -53 dBm and completed WiFi OTA. No antenna change was needed for that recovery.

    This was functional hardware testing, not a long-duration soak. iOS/Android app pairing, phone camera scanning, multiple TLS MQTT brokers, and an end-to-end LoRa mOTA transfer were not qualified in this trial. The XIAO parent profile does not include direct MQTT; the V4 does.

    "},{"location":"nimble_companion_trial_results/#final-application-hashes","title":"Final application hashes","text":"Hardware SHA-256 V4 e6a1522ced4ed38ddd00e7cdf8393857224adbb622888a86370c0f7a3ee28d88 XIAO S3 WIO ecf7afe6222e3897556a04e022515f9dcfcb825172dae9eaaa72eb92396dd004"},{"location":"nrf52-usb-ready-fix/","title":"nRF52 USB READY hang","text":"

    The RAK3401 on Mercer was found stuck in the framework's TinyUSB power handler: USBD was enabled and attached, but its write-one-to-clear READY event was already consumed. The duplicate-event guard also required HFCLK to be running. If that guard missed the completed initialization, the following unbounded READY wait could stop application startup. A bootloader update alone cannot replace this USB driver in an already installed MeshCore application.

    The wait and guard were inherited through the pinned Adafruit nRF52 Arduino framework, not introduced by the recent OTAFIX upstream merge. TinyUSB's wait dates back at least to 2019; the guard was added in upstream commit 7d9efd0697. OTAFIX's August 24 HFCLK backport addressed a later clock wait, leaving this earlier READY wait uncovered. The exact live interrupt ordering is not known; the stalled program counter and consumed event were read directly over SWD.

    All nRF52 environments inherit pre:scripts/nrf52_usb_power_fix.py. It compiles a build-local replacement for the matching framework driver, without changing PlatformIO's shared SDK or affecting ESP32 builds. A changed/unrecognized framework implementation stops the build for review. The backport:

    The poll budget is an iteration limit, not a promised elapsed-time timeout. If the clock/peripheral never becomes ready, the caller returns instead of freezing the application. A later power event can retry; permanent electrical failure can still require reconnecting USB. This does not establish or fix the separate Pi hub/controller fault.

    Run python3 -B test/test_nrf52_usb_power.py -v. The tests compile the actual patched handler from the pinned-framework fixture, model W1C event semantics, and cover duplicate/nested callbacks, delayed/missing clocks and READY, removal, retry, and detached USB. Restoring the inherited READY prefix must reproduce the infinite wait under a subprocess deadline. Additional tests check SDK isolation, idempotence, fail-closed patching, and all nRF52 environment hooks. The harness is shared with OTAFIX's TinyUSB fork at test/otafix/nrf5x_power_test.py; keep the two copies in sync when extending it.

    "},{"location":"nrf52_companion_storage_recovery/","title":"nRF52 Companion automatic ExtraFS recovery","text":"

    Companion builds using internal ExtraFS reserve 100 KiB at 0xD4000..0xED000. Identity and preferences remain in the separate 28 KiB primary store at 0xED000..0xF4000. This applies to internal-ExtraFS Companion builds, including Full, not repeaters or external QSPI storage.

    At startup the firmware:

    1. Mounts primary storage without erasing nonblank media. Only proven-blank primary storage may be initialized automatically.
    2. Checks the exact ExtraFS address, size, block geometry, and application linker reservation, then attempts a non-formatting secondary mount.
    3. Validates primary filesystem metadata before any destructive secondary recovery. An unavailable primary store blocks recovery and identity replacement.
    4. Keeps an already mounted, metadata-valid ExtraFS unchanged. If its initial mount or validation failed, unmounts and retries once without formatting.
    5. If the retry remains unusable, automatically formats only the reserved 100 KiB region, then remounts and validates it. There is one format attempt per boot; failure keeps the contact/channel write quarantine active.
    6. Runs the existing verified migration from primary into ExtraFS. Primary contact/channel sources are retired only after every destination is copied, read back, and the migration transaction is committed. Identity and preferences stay on primary.

    No terminal repair command, phone intervention, or bootloader update is needed for this recovery path when a compatible application/layout is already used. After successful recovery, the Companion storage API reports 100 KiB total rather than the 28 KiB primary fallback.

    "},{"location":"nrf52_companion_storage_recovery/#data-loss-boundary","title":"Data-loss boundary","text":"

    Automatic rebuilding prioritizes a usable contact/channel store over retaining an unreadable secondary filesystem. Data stored only in that damaged region can be lost. Surviving primary files are migrated, but may not contain the latest secondary-only changes. An API backup remains advisable before flashing. Primary storage is never formatted by this recovery path.

    This is mount/metadata recovery, not forensic file recovery. An invalid migration journal, unreadable individual contact page, primary-source error, full filesystem, or failed migration cleanup does not on its own trigger a format of a mountable, metadata-valid secondary store. Those cases retain the existing validation, retry, and incomplete-load protections.

    "},{"location":"nrf52_companion_storage_recovery/#regression-coverage","title":"Regression coverage","text":"

    test/test_internal_secondary_fs_repair exercises healthy/no-format startup, successful remount, failed remount/traversal followed by one repair, and format/remount/validation failure. test/test_nrf52_extrafs_contract.py checks primary-first ordering, internal-only geometry guards, quarantine handling, and the separation between migration failures and destructive recovery.

    "},{"location":"nrf52_companion_storage_recovery/#t1000-e-hardware-qualification-2026-09-03","title":"T1000-E hardware qualification (2026-09-03)","text":"

    A device reporting 25/28 KiB primary fallback and contact enumeration error 5 was flashed with the recovery build. With no explicit repair command, startup activated 100 KiB ExtraFS and loaded all 125 contacts and 40 channel slots. Identity, preferences, radio settings, and channels matched the pre-update API snapshot. Add, edit, and delete of an owned temporary contact each survived a separate normal reboot; the device ended with the original 125 contacts and 27/100 KiB usage. The 161 offline/native regression tests and target build passed.

    This was not lossless recovery of every historical RAM value: 21 contacts had older advertisement/local-modification timestamps, including one older advertised location. No contact was missing, and no computer-backup restoration was used. A subsequent read-only scan found no readable cached advert for any of the 125 contacts. Without a surviving newer copy, firmware cannot infer those earlier values; receiving new verified advertisements can refresh them normally.

    "},{"location":"nrf52_power_management/","title":"nRF52 Power Management","text":""},{"location":"nrf52_power_management/#overview","title":"Overview","text":"

    The nRF52 Power Management module provides battery protection features to prevent over-discharge, minimise likelihood of brownout and flash corruption conditions existing, and enable safe voltage-based recovery.

    "},{"location":"nrf52_power_management/#features","title":"Features","text":""},{"location":"nrf52_power_management/#boot-voltage-protection","title":"Boot Voltage Protection","text":""},{"location":"nrf52_power_management/#voltage-wake-lpcomp-vbus","title":"Voltage Wake (LPCOMP + VBUS)","text":""},{"location":"nrf52_power_management/#early-boot-register-capture","title":"Early Boot Register Capture","text":""},{"location":"nrf52_power_management/#shutdown-reason-tracking","title":"Shutdown Reason Tracking","text":"

    Shutdown reason codes (stored in GPREGRET2):

    Code Name Description 0x00 NONE Normal boot / no previous shutdown 0x4C LOW_VOLTAGE Runtime low voltage threshold reached 0x55 USER User requested powerOff() 0x42 BOOT_PROTECT Boot voltage protection triggered"},{"location":"nrf52_power_management/#supported-boards","title":"Supported Boards","text":"Board family Implemented LPCOMP wake VBUS wake Seeed Studio XIAO nRF52840 (xiao_nrf52) Yes Yes Yes SenseCAP Solar Yes Yes Yes RAK4631 / RAK3401 Yes Yes Yes GAT562 30S / EVB Pro / Tracker Pro / Watch13 Yes Yes Yes Heltec T096 / T114 / T1 / Tower V2 Yes Yes Yes Muzi Works R1 Neo Partial Inactive Inactive Promicro nRF52840 No No No RAK WisMesh Tag No No No Heltec Mesh Solar No No No LilyGo T-Echo / T-Echo Lite / T-Impulse Plus No No No WIO Tracker L1 / L1 E-Ink / WM1110 No No No Mesh Pocket / Meshtiny / Nano G2 Ultra No No No ThinkNode M1 / M3 / M6 No No No T1000-E No No No Ikoka Nano / Stick / Handheld (nRF) No No No Keepteen LT1 / Minewsemi ME25LS01 No No No

    Notes: - \"Implemented\" reflects Phase 1 (boot lockout + shutdown reason capture). - R1 Neo has the integration compiled, but its board configuration deliberately sets PWRMGT_VOLTAGE_BOOTLOCK to 0; automatic protective shutdown and its voltage/VBUS recovery wake are therefore inactive. - User power-off does not enable LPCOMP wake; voltage recovery is armed only for boot protection and automated low-voltage shutdown. - VBUS detection is used to skip boot lockout on external power, and VBUS wake is configured alongside LPCOMP when supported hardware exposes VBUS to the nRF52.

    "},{"location":"nrf52_power_management/#technical-details","title":"Technical Details","text":""},{"location":"nrf52_power_management/#architecture","title":"Architecture","text":"

    The power management functionality is integrated into the NRF52Board base class in src/helpers/NRF52Board.cpp. Board variants provide hardware-specific configuration via a PowerMgtConfig struct and override initiateShutdown(uint8_t reason) to perform board-specific power-down work and conditionally enable voltage wake (LPCOMP + VBUS).

    "},{"location":"nrf52_power_management/#early-boot-capture","title":"Early Boot Capture","text":"

    A static constructor with priority 101 in NRF52Board.cpp captures the RESETREAS and GPREGRET2 registers before: - SystemInit() (priority 102) - which clears RESETREAS - Static C++ constructors (default priority 65535)

    This ensures we capture the true reset reason before any initialisation code runs.

    "},{"location":"nrf52_power_management/#board-implementation","title":"Board Implementation","text":"

    To enable power management on a board variant:

    1. Enable in platformio.ini: ini -D NRF52_POWER_MANAGEMENT

    2. Define configuration in variant.h: c #define PWRMGT_VOLTAGE_BOOTLOCK 3300 // Won't boot below this voltage (mV) #define PWRMGT_LPCOMP_AIN 7 // AIN channel for voltage sensing #define PWRMGT_LPCOMP_REFSEL 2 // REFSEL (0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16)

    3. Implement in board .cpp file: ```cpp #ifdef NRF52_POWER_MANAGEMENT const PowerMgtConfig power_config = { .lpcomp_ain_channel = PWRMGT_LPCOMP_AIN, .lpcomp_refsel = PWRMGT_LPCOMP_REFSEL, .voltage_bootlock = PWRMGT_VOLTAGE_BOOTLOCK };

    void MyBoard::initiateShutdown(uint8_t reason) { // Board-specific shutdown preparation (e.g., disable peripherals) bool enable_lpcomp = (reason == SHUTDOWN_REASON_LOW_VOLTAGE || reason == SHUTDOWN_REASON_BOOT_PROTECT);

     if (enable_lpcomp) {\n   configureVoltageWake(power_config.lpcomp_ain_channel, power_config.lpcomp_refsel);\n }\n\n enterSystemOff(reason);\n

    } #endif

    void MyBoard::begin() { NRF52Board::begin(); // or NRF52BoardDCDC::begin() // ... board setup ...

    #ifdef NRF52_POWER_MANAGEMENT checkBootVoltage(&power_config); #endif } ```

    For user-initiated shutdowns, powerOff() remains board-specific. Power management only arms LPCOMP for automated shutdown reasons (boot protection/low voltage).

    1. Declare override in board .h file: cpp #ifdef NRF52_POWER_MANAGEMENT void initiateShutdown(uint8_t reason) override; #endif
    "},{"location":"nrf52_power_management/#voltage-wake-configuration","title":"Voltage Wake Configuration","text":"

    The LPCOMP (Low Power Comparator) is configured to: - Monitor the specified AIN channel (0-7 corresponding to P0.02-P0.05, P0.28-P0.31) - Compare against VDD fraction reference (REFSEL: 0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16) - Detect UP events (voltage rising above threshold) - Use no hysteresis so the configured recovery threshold is not widened by the battery divider - Wake the device from SYSTEMOFF when triggered

    VBUS wake is enabled via the POWER peripheral USBDETECTED event whenever configureVoltageWake() is used. This requires USB VBUS to be routed to the nRF52 (typical on nRF52840 boards with native USB).

    LPCOMP Reference Selection (PWRMGT_LPCOMP_REFSEL):

    REFSEL Fraction VBAT @ 1M/1M divider (VDD=3.0-3.3) VBAT @ 1.5M/1M divider (VDD=3.0-3.3) 0 1/8 0.75-0.82 V 0.94-1.03 V 1 2/8 1.50-1.65 V 1.88-2.06 V 2 3/8 2.25-2.47 V 2.81-3.09 V 3 4/8 3.00-3.30 V 3.75-4.12 V 4 5/8 3.75-4.12 V 4.69-5.16 V 5 6/8 4.50-4.95 V 5.62-6.19 V 6 7/8 5.25-5.77 V 6.56-7.22 V 7 ARef - - 8 1/16 0.38-0.41 V 0.47-0.52 V 9 3/16 1.12-1.24 V 1.41-1.55 V 10 5/16 1.88-2.06 V 2.34-2.58 V 11 7/16 2.62-2.89 V 3.28-3.61 V 12 9/16 3.38-3.71 V 4.22-4.64 V 13 11/16 4.12-4.54 V 5.16-5.67 V 14 13/16 4.88-5.36 V 6.09-6.70 V 15 15/16 5.62-6.19 V 7.03-7.73 V

    Important: For boards with a voltage divider on the battery sense pin, LPCOMP measures the divided voltage. Use: VBAT_threshold ~ (VDD * fraction) * divider_scale, where divider_scale = (Rtop + Rbottom) / Rbottom (e.g., 2.0 for 1M/1M, 2.5 for 1.5M/1M, 3.0 for XIAO).

    "},{"location":"nrf52_power_management/#softdevice-compatibility","title":"SoftDevice Compatibility","text":"

    The power management code checks whether SoftDevice is enabled and uses the appropriate API: - When SD enabled: sd_power_* functions - When SD disabled: Direct register access (NRF_POWER->*)

    This ensures compatibility regardless of BLE stack state.

    "},{"location":"nrf52_power_management/#cli-commands","title":"CLI Commands","text":"

    Power management status can be queried via the CLI:

    Command Description get pwrmgt.support Returns \"supported\" or \"unsupported\" get pwrmgt.source Returns current power source - \"battery\" or \"external\" (5V/USB power) get pwrmgt.bootreason Returns reset and shutdown reason strings get pwrmgt.bootmv Returns boot voltage in millivolts

    On boards without power management enabled, all commands except get pwrmgt.support return:

    ERROR: Power management not supported\n
    "},{"location":"nrf52_power_management/#debug-output","title":"Debug Output","text":"

    When MESH_DEBUG=1 is enabled, the power management module outputs:

    DEBUG: PWRMGT: Reset = Wake from LPCOMP (0x20000); Shutdown = Low Voltage (0x4C)\nDEBUG: PWRMGT: Boot voltage = 3450 mV (threshold = 3300 mV)\nDEBUG: PWRMGT: LPCOMP wake configured (AIN7, ref=3/8 VDD)\n
    "},{"location":"nrf52_power_management/#phase-2-planned","title":"Phase 2 (Planned)","text":""},{"location":"nrf52_power_management/#references","title":"References","text":""},{"location":"number_allocations/","title":"Number Allocations","text":"

    This document lists unique numbers/identifiers used in various MeshCore protocol payloads.

    "},{"location":"number_allocations/#group-data-types","title":"Group Data Types","text":"

    The PAYLOAD_TYPE_GRP_DATA payloads have a 16-bit data-type field, which identifies which application the packet is for.

    To make sure multiple applications can function without interfering with each other, the table below is for reserving various ranges of data-type values. Just modify this table, adding a row, then submit a PR to have it authorised/merged.

    NOTE: the range FF00 - FFFF is for use while you're developing, doing POC, and for these you don't need to request to use/allocate.

    Once you have a working app/project, you need to be able to demonstrate it exists/works, and THEN request type IDs. So, just use the testing/dev range while developing, then request IDs before you transition to publishing your project.

    Data-Type range App name Contact 0000 - 00FF -reserved for internal use- 0100 MeshCore Open zsylvester@monitormx.com - https://github.com/zjs81/meshcore-open 0110 - 011F Ripple ripple_biz@protonmail.com - https://buymeacoffee.com/ripplebiz 0120 MCO Advanced most.original.address@gmail.com - https://github.com/HDDen/meshcore-open/tree/rename-mco-advanced FF00 - FFFF -reserved for testing/dev-

    (add rows, inside the range 0100 - FEFF for custom apps)

    "},{"location":"ota_easy/","title":"Easy firmware updates over LoRa","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    This guide shows the shortest manual path for sending firmware from a computer to a MeshCore node over LoRa. Choose the package type for the destination node:

    For an end-to-end controller that accepts a release ZIP or ready mOTA, see Scripted LoRa OTA from start to finish.

    Destination Update type Files needed to build the .mota Installer ESP32 Full firmware New non-merged application .bin ESP32 A/B firmware slots nRF52, internal staging In-place delta Exact running firmware.hex and new firmware.hex Exact-board OTAFIX bootloader Supported nRF52 QSPI repeater Full firmware or in-place delta New firmware.hex; a delta also needs the exact running firmware.hex Matching QSPI-aware OTAFIX bootloader MeshTower V2 SD target Full firmware or in-place delta New firmware.hex; a delta also needs the exact running firmware.hex Matching SD-aware OTAFIX bootloader

    An internal-staging nRF52 target accepts only an in-place delta built against its exact running firmware. Matched QSPI repeater targets and the MeshTower V2 microSD target can also install a full image because the complete container stays off-chip. See nRF52 repeater OTA with external QSPI and MeshTower V2 microSD LoRa OTA.

    MeshTower V2 microSD application and bootloader containers must be Ed25519-signed by a key in the device allowlist; pass --sign to motatool build (or --sign-key to the end-to-end runner) when building one. Its BLM2-capable SD-aware bootloader is also required before ordinary application or bootloader OTA can use the reset-retained authorization record. Upgrade preview.12 locally over USB/BLE DFU or SWD first; there is no raw-card compatibility handoff.

    "},{"location":"ota_easy/#temporary-ota-channel-used-in-this-guide","title":"Temporary OTA channel used in this guide","text":"Setting Value Center frequency 910.525 MHz Bandwidth 250 kHz Spreading factor SF5 Coding rate used in this guide CR5 Example window 120 minutes

    The copy/paste command is:

    tempradio 910.525,250,5,5,120\n

    The fourth value is the transmit coding rate. This guide uses CR5, but the participating nodes' coding rates do not need to match.

    tempradio is not saved and the node returns to its normal radio settings when the window ends or the node reboots. Current full-parser firmware also accepts normalradio, which cancels the temporary window and restores the saved tuple after replying on the current channel. This frequency is intended for North American configurations. Confirm that it is permitted in your location and change it when necessary.

    At SF5/BW250, an SX1262+TCXO destination can keep RXPS enabled only when it and every sender that can reach it (controller, source, and relays) follow the v1.17.1.5-or-newer adaptive-preamble contract. The qualified destination boundary is effective level 8, preamble 64 and 1252 / 6424 us. For manual setup, save set radio.rxps level 8 preamble 32 before entering TempRadio; the 32 is a conservative saved timing assumption and current firmware selects the real 64-symbol wire preamble when it retunes the temporary radio. For an older, unknown, or mixed-version path, use set radio.rxps off for the maintenance window. First record get radio.rxps.config; after normalradio, restore a reported level with set radio.rxps level N preamble P (omit preamble P when P is 0). A legacy node returns only on/off and periods, which can be restored with set radio.rxps RX_US SLEEP_US. The scripted runner performs the version checks, temporary change, verification, and restoration automatically while preserving automatic retuning. SF5/BW500 is too fast for RXPS even with a 64-symbol preamble, so current firmware selects 128 and uses the qualified level-8 626 / 6398 us timing when every participant supports the adaptive contract.

    "},{"location":"ota_easy/#before-you-start","title":"Before you start","text":"

    Both paths require:

    LoRa OTA packets are generated, consumed, and relayed only while tempradio is active. Intermediate repeaters apply their normal forwarding filters, duplicate checks, and flood limits; they do not interpret the OTA payload. If any required window closes, the transfer stops making progress and can resume during a later overlapping window.

    build.sh provides a *_repeater_lora_ota_no_external_sensors build for standalone ESP32 and nRF52 repeater targets that need a smaller internal update workspace. Those siblings omit selected optional environmental and ranging drivers while retaining board-native features such as displays, buttons, battery monitoring, and GPS where the target uses the GPS-preserving lean profile. RAK3401 and RAK4631 reduced builds also retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors; together these drivers cost less than 5 KiB in the measured profile. Those four monitor drivers are not the only retained I2C support: the board's SSD1306 OLED, supported autodiscovered RTCs, and the RAK12500 GPS remain separate I2C peripherals in GPS-compatible RAK recipes. RAK12501/L76K GPS instead uses UART Serial1. The RAK3401 OTA repeater supports either GPS module in sensor slot A; slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The RAK4631 OTA repeater defaults its runtime RS-232 bridge to Serial2 so the UART RAK12501 can retain Serial1. The runtime guard always reserves Serial1 because a bounded silent probe cannot prove a cold RAK12501 is absent; the shared WB_IO2/3V3_S rail keeps a fitted module powered and driving the UART. Use Serial2. Serial1 requires an explicit no-GPS/dedicated image. The merged image remains fail-closed even when it detects an I2C RAK12500. The explicit legacy Serial1 bridge omits the combined GPS provider, including the otherwise non-UART RAK12500 path.

    The firmware-configured INA3221 address and the RAK12500 address are both 0x42; they cannot coexist on one bus at those addresses. Keep RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use a build with -DTELEM_INA3221_ADDRESS=0x43 when both are installed.

    Selected nRF52 boards with matched external QSPI application and bootloader support can instead make the normal full-sensor repeater install-capable; those targets do not need to reserve internal flash for the downloaded container. SolarXiao 30S and 33S use this matched external-QSPI path and therefore do not emit redundant no-external-sensors siblings. A RAK19007 can use the same separately wired W25Q16 with either RAK4631 or RAK3401 + RAK13302: the flash consumes no WisBlock slot and GPS remains supported in slot A. This is not the RAK15001 arrangement; RAK15001 and RAK13302 share a chip-select and cannot be used together. Other normal repeaters can still serve as intermediate relays but cannot necessarily install an update themselves. ESP32 -ota- siblings also retain the lightweight browser WiFi uploader (start ota), the complete CLI, and up to 254 neighbors; constrained targets use 50 as recorded in their capability manifests. RP2040 and STM32 repeaters do not currently have a safe self-apply path, but current repeater firmware can still relay OTA packets opaquely during TempRadio.

    For one explicitly selected internal-flash nRF52 repeater, the default --build-profile auto run emits two install-capable choices. The *-full-ota-* artifact retains external sensors, while the *_lora_ota_no_external_sensors-reduced-ota-* artifact trades only those optional drivers not explicitly retained by the board profile for more delta-staging space. They retain the same OTA target identity so update tooling does not confuse the optimization choice with a different board. QSPI- and SD-backed repeaters emit only the complete choice unless a measured size failure requires a fallback.

    nRF52 -ota- siblings are compiled with size optimization instead of the Adafruit platform's default speed optimization. This prevents the retained software Ed25519 fallback from expanding beyond the fixed in-place workspace; CC310 hardware crypto, hardware RNG mixing, telemetry history, and board-native features remain enabled.

    ESP32 *-full-usb-wifi-ota-* artifacts retain all compiled features and enable LoRa OTA for every FULL role, including room servers, sensors, observers, and bridges. A FULL image requires its expanded partition table: install the matching merged image over USB once before installing later non-merged FULL updates over LoRa. ESP32 MQTT observers and ESP-NOW bridges are always emitted as FULL artifacts; compact-CLI variants are no longer built. The *-full-usb-wifi-ota-* profile compiles USB packet logging and direct WiFi MQTT into one image. Use set logging.output off|usb|wifi|both to persist the desired path. A *-full-logging-ota-* artifact is now only a fallback for a hardware/role combination without a WiFi MQTT target and can produce substantial serial output.

    "},{"location":"ota_easy/#choose-the-source-radio","title":"Choose the source radio","text":"

    Use an OTA-enabled MeshCore node as the source. It receives the update folder from the computer, then advertises it over LoRa. ESP32 USB/WiFi companions and FULL ESP32 roles include the required transport. A *_companion_radio_full target keeps only the source half of LoRa OTA: it serves host images but cannot stage or install one for itself. ESP32 full combines USB, BLE, and WiFi; nRF52 full combines USB and BLE because nRF52840 has no WiFi. A small set of high-capacity, non-PSRAM classic ESP32 companions keep their normal image and provide a separate -full-logging-ota- fallback with 100 contacts, 8 group channels, a 16-frame offline queue, and persistent USB output selection. Install that variant's merged image over USB once before using it. Connect the source by USB serial or, when supported, by WiFi. For an ordinary raw-text USB source, confirm that its USB CLI accepts:

    ota folder on\n

    If an older build reports that OTA_FOLDER_SERIAL is not compiled in, install a current -ota-, -full-usb-wifi-ota-, or applicable -full-logging-ota- build first. Do not use a KISS modem: KISS firmware is a TNC/KISS frame interface and does not provide the MeshCore CLI or the OTA-folder transport that motatool serve requires.

    An nRF52 companion_radio_full starts in its USB ASCII terminal and automatically changes to Binary Companion when it receives a complete < frame. It also recognizes motatool's exact initial ota folder on line and enters exclusive mOTA mode directly, so motatool serve --serial can be the first client after boot. No terminal token or preliminary disconnect is required. Stopping the tool or disconnecting resets USB to Binary. BLE remains available throughout. See the Full Companion USB switcher guide for the complete ownership transitions. Protocol v14 can instead take the catalog from a paired phone or Linux host over a separate encrypted BLE mOTA service while Binary Companion remains active. USB and BLE catalog sources are mutually exclusive. See the Full Companion Bluetooth source guide.

    For an ESP32 WiFi companion or FULL ESP32 source with active WiFi, use its dedicated OTA seeder:

    motatool serve --dir ./motas --tcp <source-host>:5001 -v\n

    Port 5001 is separate from the companion application port (5000) and the HTTP configuration/browser-OTA port (80, depending on the role). An ESP32 companion_radio_full also has its complete role-specific text terminal on port 5002; OTA automation uses its ota and TempRadio subset. See the full Companion guide. On a FULL repeater or room server, start webconfig can bring up the saved WiFi connection. Other FULL roles with browser OTA support can raise MeshCore-OTA with start ota and use 192.168.4.1:5001. The TCP seeder auto-attaches; do not also run ota folder on for USB serial.

    "},{"location":"ota_easy/#install-motatool","title":"Install motatool","text":"

    Install Rust if necessary, then install the standalone packaging and serving tool:

    git clone https://github.com/vk496/motatool\ncargo install --path ./motatool\n
    "},{"location":"ota_easy/#esp32-package-a-full-firmware-image","title":"ESP32: package a full firmware image","text":"

    The destination must be an OTA-capable ESP32 with an A/B partition table. Download or build the new non-merged .bin application for the destination's exact board and role. Do not use an ESP32 -merged.bin factory image.

    Put the application firmware in a working directory, then build a full .mota container. For example:

    mkdir -p ./motas\nmotatool build --fw ./Heltec_v3_repeater-ota-v1.16.05.bin --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    Replace the example filename with the firmware for the destination's exact target. With no --base argument, motatool build creates a full-image update. The firmware must contain its MeshCore EndF identity trailer so motatool and the destination can verify the target, hardware, and version.

    Do not continue if motatool verify reports a failure.

    "},{"location":"ota_easy/#nrf52-package-a-full-image-or-in-place-delta","title":"nRF52: package a full image or in-place delta","text":""},{"location":"ota_easy/#1-install-and-check-the-otafix-bootloader","title":"1. Install and check the OTAFIX bootloader","text":"

    This is a one-time prerequisite. Install the OTAFIX bootloader built for the destination's exact board from the OTAFIX nRF52 bootloader releases. Internal-staging delta updates require OTAFIX 2.4 or newer. External QSPI and SD staging require OTAFIX 2.4.1 or newer and release notes that explicitly list the exact board and storage mode. Follow the release's board-specific installation and erase instructions. If it does not contain the destination's exact board, this LoRa install path is not yet available for that board; never substitute a similar board's bootloader.

    Before preparing or downloading a LoRa update, run this on the destination:

    get bootloader.ver\nota self\n

    The first command identifies the installed nRF52 bootloader. Continue only if the ota self reply includes the apply mode required by this target:

    bootloader: apply OK\nbootloader: QSPI apply OK\nbootloader: SD apply OK\n

    The reply also contains the running firmware's base_hash. Save it for the package check below. A stock, legacy, or older OTAFIX bootloader without .mota in-place-apply support will report that apply support is missing, and ota install will refuse to reboot into it.

    "},{"location":"ota_easy/#2-choose-full-or-delta-packaging","title":"2. Choose full or delta packaging","text":"

    For a QSPI- or SD-backed target, a full update needs only the new raw firmware.hex:

    mkdir -p ./motas\nmotatool build --fw ./LilyGo_T-Echo_repeater-new.hex --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    Use a full package when an exact base image is unavailable. Use the delta path below when reducing airtime is worth retaining the exact running image.

    "},{"location":"ota_easy/#3-keep-the-exact-current-and-new-application-images-for-a-delta","title":"3. Keep the exact current and new application images for a delta","text":"

    You need the raw .pio/build/<environment>/firmware.hex from the build that is actually running, plus the corresponding firmware.hex from the new build. Save the current file before building the new version, because PlatformIO reuses that path. For example:

    # Save this immediately after building/flashing the version now running on the node.\ncp .pio/build/Heltec_t114_repeater_lora_ota_no_external_sensors/firmware.hex \\\n  ./Heltec_t114_repeater_lora_ota_no_external_sensors-running.hex\n\n# After checking out and building the new version, save its image separately.\ncp .pio/build/Heltec_t114_repeater_lora_ota_no_external_sensors/firmware.hex \\\n  ./Heltec_t114_repeater_lora_ota_no_external_sensors-new.hex\n

    Replace Heltec_t114_repeater_lora_ota_no_external_sensors with the destination's exact PlatformIO environment. The two images must be for the same board and role, and both must contain their EndF trailers. Do not pass a release .uf2 or BLE-DFU .zip to motatool; those are installation containers rather than raw application images.

    Keeping a file with the same version label is not enough: the base must be byte-for-byte identical to the running application. The hash check in the next step proves that it is the right file.

    On RAK4631 repeaters, use the RAK_4631_repeater_lora_ota_no_external_sensors environment. It retains built-in battery monitoring but omits selected optional environmental/ranging drivers so the delta fits the safe in-place workspace. INA219, INA226, INA260, and INA3221 I2C voltage/current monitors remain supported despite the legacy target name. That trim does not remove the SSD1306 OLED, autodiscovered I2C RTCs, or GPS-compatible RAK12500 I2C path. The 0x42 RAK12500/INA3221 addressing rule above still applies. If the device has a RAK15001 installed in sensor slot C and the matching RAK15001 OTAFIX bootloader, use RAK_4631_repeater_rak15001_slot_c_lora_ota instead. That target retains the full sensor/GPS set and can install either a full image or a delta from the external 2 MiB store.

    With the separately wired Winbond breakout, use RAK_4631_repeater_w25q16_lora_ota for RAK4631 or RAK_3401_repeater_rak13302_w25q16_lora_ota for RAK3401 + RAK13302. Both require an EF4015 2 MiB device. As a one-time prerequisite, install wiscore_rak4631_w25q16 for RAK4631 or wiscore_rak3401_rak13302_w25q16 for RAK3401 + RAK13302. Wire CLK/MISO/MOSI to the RAK19007 IO connector pins 26/27/28, CS to J11 AIN1, and VCC/GND to J12 VDD/GND. Fit an approximately 10 kOhm CS-to-VDD pull-up; never use VBAT. The SPI pins require underside taps or an interposer, but no sensor slot is occupied. See the nRF52 QSPI guide before installing either image.

    "},{"location":"ota_easy/#4-build-and-check-the-in-place-delta","title":"4. Build and check the in-place delta","text":"
    mkdir -p ./motas\nmotatool build \\\n  --base ./Heltec_t114_repeater_lora_ota_no_external_sensors-running.hex \\\n  --fw ./Heltec_t114_repeater_lora_ota_no_external_sensors-new.hex \\\n  --patch-type in-place \\\n  --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    motatool prints the generated filename. Inspect that file:

    motatool inspect ./motas/GENERATED_FILENAME.mota\n

    Check all three of these before serving it:

    Current layout-aware motatool derives the safe workspace from the new firmware's authenticated EndF layout record, so the normal recipe deliberately omits --inplace-memory. If older tooling requires an explicit override, use 0xC6000 only for matched external SD/QSPI staging and 0x98000 for an internal-staging nRF52 such as the RAK4631 lean OTA build. Do not substitute the external value on an internal target, and do not override either value unless you have verified the exact app base, bootloader, and package geometry. Do not continue if verification or any identity check fails.

    "},{"location":"ota_easy/#transfer-and-install-either-package","title":"Transfer and install either package","text":""},{"location":"ota_easy/#1-start-the-temporary-ota-channel","title":"1. Start the temporary OTA channel","text":"

    First save the current ota config output. For a direct source-to-destination link, set both OTA-enabled endpoints to direct-only mode before starting the transfer:

    ota config hops 0\n

    Do not use hops 1 as extra margin on a direct link. It tells a receiving repeater to retransmit each direct DATA/PROOF packet; that unnecessary half-duplex transmission can make it miss the source's next fragment, especially with older single-block receivers. Use hops 1 only when one real intermediate relay is required, hops 2 for two, and so on. This setting is saved, so restore the original value after the maintenance window.

    On the source node, destination node, and every intermediate repeater, then run:

    tempradio 910.525,250,5,5,120\n

    All participating nodes must use the same frequency, bandwidth, and spreading factor. Their time windows must overlap. Start with the farthest hop (the destination) and work back toward the source when using tempradio.

    If you administer the destination over LoRa, the controller used to send later ota commands must also be able to communicate on this temporary channel. For unattended nodes, use synchronized tempradioat entries instead of manually starting the windows. Ensure the nodes' clocks are set before using tempradioat.

    "},{"location":"ota_easy/#2-serve-the-update-from-the-computer","title":"2. Serve the update from the computer","text":"

    Close any serial terminal using the source node's USB port, find its device name, and start the server:

    motatool serve --dir ./motas --serial /dev/ttyACM0 -v\n

    Replace /dev/ttyACM0 with the USB serial device of the source companion selected above. motatool attaches the folder to the source, which advertises the update over LoRa while its temporary-radio window is active. KISS modem serial ports cannot be used here.

    For an nRF52 Full Companion and a Bluetooth-capable host, the equivalent cable-free source is:

    python3 tools/ble_mota/ble_mota_seeder.py \\\n  --device MeshCore-MyCompanion \\\n  --dir ./motas \\\n  --local 'tempradio 910.525,250,5,5,120'\n

    Leave this command running until the destination finishes downloading.

    "},{"location":"ota_easy/#3-find-and-download-the-update","title":"3. Find and download the update","text":"

    On the destination node, check its state and ask for nearby updates:

    ota status\nota ls\n

    Discovery is asynchronous. ota ls says refreshing; wait a few seconds and run it again even if it first shows an older row. Select [same target]: it can say full for ESP32 and external SD/QSPI nRF52 targets, or delta for any supported nRF52 target. Do not select [unsupported] (for example, a source's self-served full image on an internal-staging nRF52). Rows labelled bootloader are also outside this ordinary application flow: they are never automatic and ota install refuses them. Only an already-capable, allowlisted internal-flash, XIAO-QSPI, or exact MeshTower V2 microSD nRF52 target can use the separate explicit bootloader workflow. Use the row's stable eight-hex manifest ID rather than its changing list position:

    ota pull 838B8169 flash\n

    If a legacy internal-flash nRF52 reports no EndF, only a row marked [rescue] is eligible. Current rescue-capable firmware requires ota pull <mid8> flash rescue, followed after completion by ota rescue install <base_hash16>. Older running firmware without those commands must be recovered over USB. Shared-internal bootloader-update builds are stricter: without a valid live EndF, every internal pull is refused before erase because the normal application may extend through 0xED000. Recover those builds over USB/BLE DFU or SWD instead of relying on the older 608 KiB rescue estimate.

    Monitor the transfer:

    ota status\n

    For a powered bench update where restart-resume is not needed, ota config checkpoint 0 on the destination removes periodic progress writes. It is a smaller optimization than selecting the correct hop count and it trades away persisted mid-download resume; restore the previous checkpoint cadence afterward.

    Current repeater firmware automatically uses the full transmit budget during a bounded TempRadio window. Older receivers do not: if get af reports a nonzero value on such a node, record it, use set af 0 for the maintenance window, and restore it afterward. This affects how promptly the legacy receiver can send its next block/proof request; it does not increase LoRa transmit power. Use a full duty budget only where the selected frequency and local rules permit it.

    The update is ready when the status says ready to install. Discovery is background traffic, while an active OTA download is primary mesh traffic. At the temporary-radio settings in this guide, allow roughly one hour for a typical ESP32 full image over a quiet, direct link. That is a planning estimate, not an upper bound: repeaters, retries, weak links, and normal mesh traffic can extend it well past an hour. The 120-minute example window is intentional. If necessary, start another overlapping tempradio window; the download resumes rather than starting over.

    "},{"location":"ota_easy/#4-verify-and-install","title":"4. Verify and install","text":"

    Once the destination reports ready to install, run:

    ota install\n

    The destination verifies the complete package again before approving it. ESP32 installs the full image into its inactive A/B slot. nRF52 checks the bootloader and storage capabilities and, for a delta, the base hash, then reboots into OTAFIX. The bootloader independently rechecks the package, installs the external full image or applies the delta in place, and verifies the resulting image. Pre-install failures leave the running firmware unchanged and report the reason. If power is lost after an nRF52 in-place apply has begun, OTAFIX will not boot a partial image; it enters recovery DFU so a known-good application can be restored.

    After the node returns, reconnect on its normal radio channel and confirm:

    ota status\n
    "},{"location":"ota_easy/#quick-troubleshooting","title":"Quick troubleshooting","text":"

    For additional commands and safety details, see the full OTA user guide. For protocol and container internals, see the OTA protocol specification.

    "},{"location":"ota_meshtower_v2_sdcard/","title":"MeshTower V2 microSD LoRa OTA","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    The Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors target uses the MeshTower V2 onboard microSD socket as persistent storage for its own LoRa OTA downloads. It accepts full application images, in-place application deltas, and explicitly selected signed v3 bootloader packages. After verification, the matching SD-aware OTAFIX bootloader reads the staged file from the card and programs the selected nRF52840 application or bootloader region.

    The pin assignment follows the Heltec MeshTower V2 partial reference circuit:

    Signal nRF52840 pin Arduino pin number SD CS P1.00 32 SD MOSI P1.01 33 SD SCK P0.06 6 SD MISO P0.26 26

    The SD socket uses its own SPI peripheral, so card traffic does not change the LoRa radio pinout.

    "},{"location":"ota_meshtower_v2_sdcard/#card-requirements","title":"Card requirements","text":"

    Use a FAT16, FAT32, or exFAT card accepted by the bundled SdFat version. The normal update path imposes no additional MBR/sector-1 layout requirement; filesystem layouts SdFat cannot mount are still rejected. MeshCore creates /meshcore-ota.mota as a contiguous file and passes its exact sector range to OTAFIX in reset-retained MCU RAM.

    MeshCore never reads or writes raw sector 1 and does not infer ownership from blank card sectors. Both application and bootloader OTA require a locally provisioned BLM2-capable bootloader that understands the retained-RAM record. Preview.12 must first be upgraded over USB/BLE DFU or SWD.

    "},{"location":"ota_meshtower_v2_sdcard/#capacity-and-update-types","title":"Capacity and update types","text":"

    The card removes the internal-flash staging limit. The .mota container may be much larger than the old internal staging gap, and either a full image or an in-place delta may be downloaded. The installed firmware itself must still fit the nRF52840 application region below InternalFS (ending at 0xED000); SD storage does not increase the MCU's executable flash.

    For this S140 v6 target, the maximum application image including its EndF trailer is 0xC7000 bytes (815,104 bytes). To package a full self-update:

    motatool build --fw ./Heltec_tower_v2_sdcard-new.hex \\\n  --sign ./trusted-signer.key --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    A delta uses the exact installed image as its base. The normal 0x98000 workspace remains compatible. If either image is larger than that legacy limit, build the patch with the SD target's larger workspace:

    motatool build \\\n  --base ./Heltec_tower_v2_sdcard-running.hex \\\n  --fw ./Heltec_tower_v2_sdcard-new.hex \\\n  --patch-type in-place \\\n  --inplace-memory 0xC7000 \\\n  --sign ./trusted-signer.key \\\n  --out-dir ./motas\nmotatool verify ./motas/*.mota\n

    The download is resumable because the partial .mota stays on the card. Once it reaches ready, ota install performs the final verification, publishes a one-reset authorization record, and reboots. SD application installation requires a valid signature from a key in the node's allowlist. Keep the card inserted through the reboot and installation.

    The BLM2 retained-auth SD-aware bootloader is mandatory for application OTA. ota install refuses to reboot if its continuity metadata does not match the running S140 family/FWID/application layout or if its capability marker does not advertise SD staging and the selected codec. Existing Heltec_tower_v2_repeater firmware continues to use the internal-flash delta path and is unchanged.

    "},{"location":"ota_meshtower_v2_sdcard/#signed-bootloader-update","title":"Signed bootloader update","text":"

    Only this exact SD build exposes the privileged LoRa bootloader-update command. It requires an already installed exact-board ABI-3 OTAFIX bootloader whose one unambiguous capability marker is exactly 0x09 (SD|BOOT_UPDATE) and whose codec mask is 0x0005 (FULL|INPLACE). Requiring both application codecs prevents a bootloader self-update from disabling either normal SD application path. A stock bootloader still requires USB/BLE DFU or SWD. The sole remote bootstrap is intentionally absent: preview.12 and any other legacy-v1 image must be upgraded locally before either SD application or bootloader OTA.

    The signed v3 container is exactly 41,330 bytes and carries a 40 KiB candidate for the installed 239A0071 / TOWER_V2_OTA identity (boot target 1150F50E). It uses the same contiguous /meshcore-ota.mota file as an application update. GPREGRET 0x6B plus the distinct SD source marker 0x53 selects the bootloader path; LoRa transport remains payload type 0x0C.

    The application linker still ends at 0xED000, but OTAFIX needs 0xE0000..0xEA000 as temporary scratch while replacing itself. Before a boot package can be downloaded or approved, MeshCore requires a hash-valid live EndF proving the complete running image ends by 0xE0000; OTAFIX repeats that no-overlap check before its first erase. An application extending above that boundary can still receive normal FULL or delta application updates from SD, but its bootloader must be updated locally.

    MeshCore also checks the live boot settings before touching that scratch page. Erased settings and a valid-app record with CRC disabled are allowed. When a nonzero bank CRC is active, its recorded bank_0_size must cover the complete EndF-inclusive running image and must end by 0xE0000; an undersized or oversized record refuses the operation and requires local DFU/SWD.

    Because the card is removable, approval is bound to the exact bytes that were authenticated. For both fmt2 application and fmt3 bootloader packages, MeshCore authenticates one exact signed manifest, requires every streamed manifest byte to match it, and verifies the leaves, payload, and image. During that same pass it hashes the entire container with only the mutable four-byte APRV field normalized to zero. After writing and syncing APRV, MeshCore publishes a 72-byte MOTASDA2 record at reset-retained RAM address 0x20006008. The record binds package purpose/format, exact LBA range, card size, container length, and that normalized SHA-256. OTAFIX consumes and clears the record before reading the card; changing the card or file can only fail. A power loss erases the authorization and also fails closed.

    For fmt3, MeshCore additionally writes and readback-verifies the temporary 64-byte MOTASDBL token at 0xE0000, binding the exact total and signed manifest image_hash. OTAFIX requires the parsed manifest, streamed payload, and final scratch image to match it. The token page becomes scratch during a successful boot update and is not permanently reserved.

    Every new candidate also carries a backward-compatible BLM2/SOFT continuity extension next to its legacy embedded manifest. The complete 76-byte envelope is fixed at final raw-image offset 0x9FB4; a relocated copy is not a valid candidate. Its embedded bootloader version must equal the outer package version, match the runtime SoftDevice family/FWID, application base, and layout ABI, and be strictly newer than an installed BLM2 version. Preview values use low bytes 1..254 and a stable release uses 0xFF; zero-preview and all-ones versions are invalid. Remote bootloader rollback is not supported; use local DFU/SWD when rollback or migration is intentional.

    Bootloader packages are never autofetched or autoinstalled. Select and confirm one exact package manually:

    ota ls\nota pull <MID8> flash\n# wait for ota status to report the bootloader download ready\nota bootloader\nota bootloader install <MID8> <HASH16>\n

    Copy the MID and first 16 image-hash hex digits from ota bootloader. Ordinary ota install rejects this package, and the bootloader command rejects an application package. Keep the SD card inserted through the reboot. A later ota status value of blup:C8 reports a successful bootloader replacement.

    After reboot, ota bootloader displays the installed board identity, CRC and capabilities. Its mid and hash fields describe a currently staged download, not the installed bootloader, so staged:none mid=- hash=- is not an install failure. blrc:00 means no retained apply result is available; it does not by itself prove either success or failure.

    Older MeshCore application firmware can report get bootloader.ver as unknown for OTAFIX 2.4.6 because this bootloader image omits the legacy UF2 Bootloader text. The updated getter also reads the CRC-validated BLM2 version. For the official signed MeshTower V2 SD 2.4.6 .mota, the installed identity is TOWER_V2_OTA, CRC 5DACDB3D, ABI 3, and capabilities 09. Compare the exact board/storage package when interpreting these values; the non-SD image differs.

    "},{"location":"ota_meshtower_v2_sdcard/#sd-card-cli","title":"SD card CLI","text":"

    The SD-backed target provides these CLI commands:

    set sdcard format [--force]\nset sdcard erase [--force]\nget sdcard\nget sdcard *\nget sdcard format\nget sdcard erase\nget sdcard free\nget sdcard ls\nget sdcard ls 2\nget sdcard dir 3\n

    format creates a new FAT16, FAT32, or exFAT filesystem according to card size. erase first uses the card's raw media erase command and then formats it, so a successful erase finishes with a usable filesystem. Both operations destroy all data on the card and cancel any staged OTA download.

    The firmware records successful format and erase completion times in RAM. Repeating the same operation within five minutes is rejected unless --force is present. Format and erase have independent cooldowns. Because erase also formats the card, a successful erase updates both timestamps. The timestamps reset when the device reboots. The get sdcard age queries report how long ago each operation completed. get sdcard free reports used and free filesystem space in human-readable binary units.

    get sdcard ls and get sdcard dir recursively list files on the card, two files per page. A bare command shows page 1; append a positive page number to move through the remaining results. Each row includes the path and a compact file size. The header reports the selected page, total pages, and total files.

    "},{"location":"ota_meshtower_v2_sdcard/#persistent-ota-archive-and-seeder","title":"Persistent OTA archive and seeder","text":"

    On the SD-backed target, automatic OTA archiving is on by default. While the temporary OTA radio is active, the node requests full catalogs from seeders and saves every complete mOTA it discovers, including firmware for other hardware targets and codecs that this node cannot install. Archive downloads use the same per-block Merkle proof checks as an install download, but archived images are never selected for local installation.

    Completed containers are stored as /mota/<manifest-id>.mota. An interrupted download remains /mota/<manifest-id>.part and resumes when that mOTA is seen again. Completed files survive reboot, are enumerated on the first archive access or TempRadio window, and are advertised and served directly from SD. The SD target supports the protocol maximum served set: its own running firmware plus up to 254 archived mOTAs.

    Automatic capture preserves an 8 MiB free-space reserve for manual /meshcore-ota.mota installation staging. It stops starting new archive files when the next file would cross that reserve. Archive allocation first tries the fast contiguous path and then falls back to an ordinary fragmented FAT file; only the bootloader staging file requires contiguous sectors.

    "},{"location":"ota_meshtower_v2_sdcard/#preload-many-motas-from-a-computer","title":"Preload many mOTAs from a computer","text":"

    You can populate the archive much faster on a computer than over LoRa. Use only complete, verified .mota containers. Do not copy firmware .bin, .hex, .zip, or .part files into the archive.

    Install the standalone motatool first if it is not already available:

    git clone https://github.com/vk496/motatool.git\ncargo install --path ./motatool\n

    The on-card filename is part of the archive index and has a strict format:

    /mota/<merkle_root>.mota\n

    <merkle_root> is the eight-hex-digit value printed by motatool inspect. It is also the mOTA's four-byte manifest ID. The extension must be lowercase, the file must be directly inside /mota, and descriptive release filenames are not indexed. For example, if inspection reports:

    merkle_root    : ABCD1234\n

    copy that container to:

    /mota/abcd1234.mota\n

    If two containers have the same Merkle root, they have the same protocol ID and cannot both be present. Keep only the intended one. The current SD seeder can index up to 254 archived files. A served file must use a logical block size of at most 1024 bytes and contain at most 2048 blocks; check block_size and block_count in motatool inspect when importing unusually large images. Files outside that serve geometry are not counted or advertised. If a malformed /mota/<id>.mota conflicts with a newly discovered valid image, the repeater preserves the malformed file as <id>.bad through <id>.bad9 and downloads a clean replacement instead of treating path existence as a valid cache hit.

    To prepare and load the card:

    1. Format it as described under Card requirements. The easiest route is to insert it in the repeater, run set sdcard format, power the repeater off, and then move the card to the computer. Formatting destroys the existing card contents.
    2. Mount the card on the computer and create a directory named mota at the filesystem root. Do not use a nested directory such as /firmware/mota.
    3. Run motatool verify FILE.mota for every source file. Do not copy a file that reports FAIL.
    4. Run motatool inspect FILE.mota, read its merkle_root, and copy the file to /mota/<lowercase-merkle-root>.mota on the card.
    5. Flush pending writes, safely eject the card, power the repeater off, insert the card, and boot Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors.

    On Linux or macOS, this Bash example verifies and imports every .mota from ./motas. Replace the example mount path before running it:

    card_mount=/media/YOU/MESHCORE\nmkdir -p \"$card_mount/mota\"\n\nshopt -s nullglob\nfor image in ./motas/*.mota; do\n  motatool verify \"$image\" || exit 1\n  mid=$(motatool inspect \"$image\" |\n    awk '$1 == \"merkle_root\" { print tolower($3) }')\n  if [[ ! $mid =~ ^[0-9a-f]{8}$ ]]; then\n    echo \"Could not read the Merkle root from: $image\" >&2\n    exit 1\n  fi\n\n  destination=\"$card_mount/mota/$mid.mota\"\n  if [[ -e $destination ]]; then\n    cmp -s \"$image\" \"$destination\" || {\n      echo \"Different containers have the same ID: $mid\" >&2\n      exit 1\n    }\n  else\n    cp \"$image\" \"$destination\"\n  fi\ndone\nsync\n

    On Windows, create E:\\mota, inspect each source with motatool inspect, and rename it to the reported lowercase root in the same way. Safely eject the drive after all copies finish.

    After boot, scan the archive and confirm the card contents from the repeater console:

    get sdcard ls\nget sdcard ls 2\nota cache\nota folder\n

    ota cache should report the imported count. ota folder reports the current served count; once OTA serving starts in TempRadio, that set also includes the repeater's running firmware. If the files appear in get sdcard ls but not in the served count, check their exact names, run motatool verify again, and inspect their block geometry. It is fine to run ota cache off for a curated, read-mostly archive: that disables capture of new mOTAs but continues serving every valid file already on the card.

    "},{"location":"ota_meshtower_v2_sdcard/#serve-the-preloaded-archive-over-tempradio","title":"Serve the preloaded archive over TempRadio","text":"

    LoRa OTA traffic exists only during an active temporary-radio window. The SD repeater, each receiving node, and every intermediate repeater in the path need overlapping windows on the same temporary channel. Use a frequency permitted for the node's configured region. This North American example uses the recommended fast OTA settings and a 120-minute window:

    tempradio 909.950,250,5,5,120\n

    Start the farthest receiving node first, then intermediate repeaters, and the SD source last so their windows overlap for as long as possible. Before or during the source window, ota cache makes the source scan and attach the SD archive. Entry into TempRadio automatically triggers an OTA advertisement burst; ota announce can send another advertisement immediately.

    On a receiving OTA-capable node, allow a few seconds for catalog exchange, then run:

    ota ls\nota ls 2\nota get <mid8> flash\nota status\n

    Use ota ls 2, ota ls 3, and so on when the source advertises more than the two rows that fit in one remote CLI reply. Each row includes a stable eight-hex-digit manifest ID. Use that ID instead of a list number, because asynchronous catalog refreshes can reorder rows between the list and pull commands. A receiver retains the complete protocol catalog and verifies every transferred block. It still applies its normal target, hardware, codec, signature, and installation checks. The SD repeater may advertise images for many hardware families; it never installs those archive files merely because it serves them.

    When a temporary window expires or a node reboots, it returns to its saved radio settings and OTA transfer stops. The archive remains on the card. Start another set of overlapping tempradio windows to resume an interrupted download, or use synchronized tempradioat entries for a scheduled window.

    Archive capture is lower priority than operator work. An explicit ota pull to install storage or a host folder immediately takes the single receive slot; the archive partial is checkpointed and resumes later. Existing cached files can still answer peer requests while another archive file is downloading.

    Use these commands to inspect or control automatic capture:

    ota cache\nota cache on\nota cache off\nota config cache on\nota config cache off\n

    The on/off choice is saved on the SD card. Turning capture off stops new downloads but keeps serving files already cached. Formatting or erasing the card removes both the archive and its off marker, so a newly formatted card returns to the default-on setting. ota config sdseed on|off is also accepted as an alias.

    "},{"location":"ota_nrf52_bootloader_update/","title":"nRF52 bootloader updates over LoRa","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    Selected nRF52840 repeater LoRa-OTA builds can replace their matching OTAFIX bootloader without replacing the running application. This is a privileged maintenance path, not a normal firmware update. A stock bootloader must first be replaced with the exact ABI-3 self-update-capable OTAFIX build over USB/BLE DFU or SWD. MeshTower SD also requires the BLM2 retained-auth version to be provisioned locally before either application or bootloader OTA.

    "},{"location":"ota_nrf52_bootloader_update/#storage-layouts","title":"Storage layouts","text":"Layout Application limit Staged boot package Work area Handoff / capability XIAO-module raw QSPI below 0xE0000 external QSPI offset 0 dedicated internal 0xE0000..0xEA000 scratch source 0x51, flags 0x0E Qualified internal-flash target normal 0xED000 flash limit; top 64 KiB SRAM reserved for application-delta staging shared internal slot, exact start 0xE2000 the same eleven-page slot; no second flash reservation source 0xED, flags 0x0A MeshTower V2 microSD normal 0xED000 limit contiguous /meshcore-ota.mota dynamic internal 0xE0000..0xEA000 scratch; live image must end by 0xE0000 source 0x53, flags 0x09

    The exact SD target is Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors. Its normal application FULL and delta updates continue to use the SD file without the bootloader-update scratch restriction. For a bootloader package only, both MeshCore and OTAFIX require a hash-valid live EndF proving the complete running image ends at or below 0xE0000. If boot settings carry a nonzero app bank CRC, the recorded bank size must also cover that full EndF-inclusive image and stop by 0xE0000; erased or explicitly CRC-disabled settings remain valid. OTAFIX then copies the verified 40 KiB payload from SD into 0xE0000..0xEA000 and uses the MBR copy operation to replace 0xF4000..0xFE000. The application linker remains at 0xED000; a future application extending above 0xE0000 can still use application mOTA but must update its bootloader through local DFU/SWD.

    The removable SD authorization is fail-closed for both application and bootloader packages. MeshCore directly authenticates one exact signed manifest, requires the streamed copy to remain byte-identical, verifies all leaves/payload/image bytes, and computes a normalized full-container SHA-256 in that same pass (APRV bytes are treated as zero). After syncing APRV, it publishes a 72-byte MOTASDA2 record in reset-retained RAM at 0x20006008. That record binds purpose/format, first LBA, sector count, total length, card sector count, and the normalized digest. OTAFIX copies, validates, and clears the record before SD access. A card swap/mutation or a power cycle therefore fails closed; there is no general sector-1 ownership inference.

    For fmt3, MeshCore also writes and readback-verifies a 64-byte MOTASDBL token at 0xE0000. That token contains the exact container length and the exact authenticated signed manifest image_hash. OTAFIX requires the same hash in the parsed manifest, streamed payload, and final scratch image. The page is consumed as scratch during success and is not permanently reserved.

    The first BLM2-capable MeshTower bootloader cannot be delivered to preview.12 through the retained-RAM protocol that preview.12 predates. MeshCore does not write a raw-sector compatibility record because a blank sector is not proof of ownership. Upgrade preview.12 through USB/BLE DFU or SWD. Both fmt2 application and fmt3 bootloader OTA then require the installed BLM2 metadata to match the live SoftDevice/application layout; neither MeshCore nor OTAFIX uses sector 1.

    The internal path does not change the application's 0xED000 flash limit or permanently set aside separate app-OTA, boot-package, and flash-scratch regions. Qualified applications do use a dedicated linker that reserves the top 64 KiB of SRAM for hybrid application-delta staging and a retained authorization record. That SRAM is never part of a bootloader-update package. The ordinary bottom-aligned internal flash store holds one container prefix at a time: either an application delta or the complete bootloader package.

    The exact bootloader container is 41,330 bytes: 365 bytes of signed mOTA metadata, a 40 KiB payload, and the five-byte trailer. Below the normal 0xED000 store ceiling it bottom-aligns at 0xE2000. Admission requires a hash-valid live EndF proving the current application, including its trailer, ends at or before 0xE2000. OTAFIX then reads each source window before erasing and compacts the payload forward in place to the page-aligned raw range 0xE2000..0xEC000; it verifies every page and the whole image before asking the MBR to copy that image over 0xF4000..0xFE000.

    An ordinary application delta can be smaller or larger than this eleven-page shape. On the qualified profile, a delta larger than one page uses the minimum page-aligned flash prefix ending at 0xED000 and keeps up to 64 KiB of its logical tail in reset-retained SRAM; its detools workspace must stop at the actual flash-prefix start. A one-page-or-smaller delta stays wholly in flash. In either case the reconstructed application must stop below 0xED000. The two package kinds are mutually exclusive because they use the same flash store, and a bootloader package never uses the volatile SRAM suffix.

    For internal-self-update builds, an absent or corrupt live EndF disables all internal staging before the first erase. The older 608 KiB rescue estimate is unsafe when a normally linked application may extend to 0xED000. Builds without this feature retain the legacy rescue behavior.

    "},{"location":"ota_nrf52_bootloader_update/#internal-flash-target-inventory","title":"Internal-flash target inventory","text":"

    The release builder consumes the allowlist in tools/mota/nrf52_internal_bootloader_targets.txt. Ten names are also literal PlatformIO environments and are enabled automatically when built directly. The other eleven are release aliases assembled from a base environment plus the lean OTA overlay; build them through build.sh, which passes the same allowlist decision to the common pre-build guard. These lean repeater/bridge targets have no OTA-owned SD/QSPI store and have an exact curated OTAFIX manifest identity:

    Build target(s) Installed OTAFIX identity Boot target ID Heltec_tower_v2_repeater_lora_ota_no_external_sensors 239A0071 / TOWER_V2_OTA 1150F50E T096 lean repeater and RS232 bridge 239A0071 / T096_DFU 42354C85 Heltec_t1_repeater_lora_ota_no_external_sensors 239A0071 / T1_DFU FC556FFC T114 display and without-display lean repeaters 239A0071 / T114_DFU 0C3F2902 Mesh_pocket_repeater_lora_ota_no_external_sensors 239A0071 / MESH_POCKET_OTA 059277F4 KeepteenLT1_repeater_lora_ota_no_external_sensors 239A00B3 / KeepteenLT1_OTA DB2E7B51 Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors 239A0029 / MX25_DFU 026AA982 ProMicro_repeater_lora_ota_no_external_sensors 239A00B3 / PROM_DFU AF79E8CC t1000e_repeater_lora_ota_no_external_sensors 28860057 / T1KE_DFU E6F5F03F ThinkNode_M3_repeater_lora_ota_no_external_sensors 239A00DA / TNM3_DFU 0CA41DB2 RAK_3401_repeater_lora_ota_no_external_sensors 239A0029 / 3401_DFU 23818A80 RAK4631 lean repeater and both lean RS232 bridges 239A0029 / 4631_DFU 2D0DF000 GAT562 30S/Tracker Pro/EVB Pro lean targets 239A0029 / GAT562_DFU D50D2D44 R1Neo_repeater_lora_ota_no_external_sensors 239A0029 / 4631_DFU 2D0DF000 RAK_WisMesh_Tag_repeater_lora_ota_no_external_sensors 239A0029 / RTAG_DFU C72E9C9C

    Board IDs are not globally unique. For generic targets, the signed hardware ID is the exact NUL-padded 32-byte value NRF_BL_<BOARD_ID>_<DEVICE_NAME>. The wire target is the little-endian first four SHA-256 bytes of all 32 padded bytes. The installed and candidate embedded manifest pairs must match exactly. XIAO retains its deployed XIAO_BL_28860044 / XIAO_BL_28860045 identity and raw board-ID target.

    The Python reference builder and release tooling audit these boot targets for duplicates and collisions with application target IDs. Generic image parsing can inspect a future canonical identity, but signing/building a package fails until that exact identity is in the qualified inventory.

    Earlier GAT562 installations may report the RAK-compatible 4631_DFU identity. The installed and candidate identity pair must match exactly, so a remote bootloader update cannot migrate one of those devices to GAT562_DFU. Provision the GAT562-specific OTAFIX bootloader once through USB/BLE DFU or SWD; subsequent signed bootloader mOTA updates use target D50D2D44.

    "},{"location":"ota_nrf52_bootloader_update/#embedded-continuity-and-version-policy","title":"Embedded continuity and version policy","text":"

    Every remotely supplied successor retains the legacy 44-byte CRC-valid BLMF v1 record for identity continuity and diagnostics. Immediately after that record is a 32-byte BLM2/SOFT extension. The complete 76-byte envelope is fixed at the final raw-image offset 0x9FB4; relocated candidates are refused, while installed legacy-v1 discovery remains a generic diagnostic scan. The extension carries the actual packed bootloader version, SoftDevice family and FWID, application base, layout ABI, and zero compatibility/reserved fields; the legacy whole-image CRC covers the extension too. Scanners first count CRC-valid 44-byte base manifests, then interpret continuity only after one base identity remains. A malformed claimed extension therefore cannot make a duplicate identity disappear, and a sole half-present extension is rejected rather than treated as legacy.

    MeshCore requires the candidate extension, exact equality between its embedded version and the signed outer mOTA version, and exact agreement with the running SoftDevice family/FWID/application layout. Qualified internal/QSPI paths may bootstrap a CRC-valid installed legacy-v1 image once; the SD path never does and requires local BLM2 provisioning. Once the installed bootloader has BLM2 metadata, the candidate version must be strictly greater. Preview low bytes are 1..254, a stable release uses 0xFF, and low-byte zero or all-ones values are rejected. There is no remote rollback/migration override; intentional rollback uses USB/BLE DFU or SWD.

    The legacy no_external_sensors profiles trim selected optional environmental and ranging drivers to preserve flash headroom; they do not generally disable the I2C bus. Reduced RAK3401 and RAK4631 recipes retain INA219, INA226, INA260, and INA3221 voltage/current monitors. Those are entries in the optional sensor table, not the complete set of I2C consumers: the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals where the exact board recipe enables them. RAK3401 retains its compatible RAK12500 I2C and RAK12501/L76K UART GPS paths in sensor slot A. The plain RAK4631 repeater and Serial2 bridge retain GPS. Its RAK12501 path uses Serial1; the explicitly compiled Serial1 RS232 bridge therefore omits the combined GPS provider, including RAK12500, even though RAK12500 itself is I2C rather than UART.

    The firmware-configured INA3221 and RAK12500 addresses are both 0x42. They cannot coexist on the same bus at those addresses. Leave RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use firmware built with -DTELEM_INA3221_ADDRESS=0x43 when both modules are installed.

    Boards with onboard external flash are not silently redirected to internal staging. Mesh Solar, Nano G2 Ultra, T-Impulse Plus, ThinkNode M8, T-Echo Lite/Card, MeshTracker X1, and Wio WM1110 have board-specific QSPI hardware and need a separately matched QSPI path where available. Full Companions, other SD/QSPI/ExtraFS roles, Ethernet roles, source-only roles, and unqualified full-sensor roles are excluded from the internal-flash inventory. The exact MeshTower V2 SD role above is separately qualified for its SD path.

    This internal layout is limited to nRF52840 devices with 1 MiB internal flash, the exact S140 v6/v7 map, and the 40 KiB boot region at 0xF4000..0xFE000. nRF52833 and smaller nRF52 parts cannot provide that map plus a non-overlapping 41,330-byte live staging slot, so configuration fails closed instead of selecting smaller or overlapping geometry.

    "},{"location":"ota_nrf52_bootloader_update/#explicit-install-workflow","title":"Explicit install workflow","text":"

    The tools/lora_ota/lora_ota.sh and .ps1 runners can perform the discovery, transfer, verification and radio cleanup for a bootloader mOTA with --no-install. They detect format 3 automatically and check the destination's bootloader-specific identity/capabilities. They do not install it: the final MID/hash confirmation below remains an explicit operator action. A mixed application/bootloader ZIP requires --zip-member.

    Check the installed identity and capability marker:

    ota bootloader\n

    The response must show a CRC-valid exact identity, ABI 3 or newer, both FULL and INPLACE application codecs (codecs=0x5), and exact flags 0x09 for MeshTower V2 SD, 0x0A for internal shared storage, or 0x0E for XIAO QSPI. A bootloader row is visible in ota ls, but it is never autofetched or autoinstalled. Fetch its exact MID, then explicitly arm it:

    ota pull <MID8> flash\n# wait for ota status to report the download ready\nota bootloader\nota bootloader install <MID8> <HASH16>\n

    Copy both confirmation values from the second ota bootloader response. Ordinary ota install rejects a bootloader package; the bootloader command rejects an application package. The FULL-codec exception exists only for that manual bootloader MID. Ordinary application FULL remains disabled on an internal single-slot node, bootloader autofetch remains off, and a partial bootloader package is not automatically resumed after an application reboot.

    Before writing APRV, the application authenticates and authorizes the package: exact v3 geometry, trusted Ed25519 signer, signed/embedded identity, one unambiguous capability marker, embedded CRC, sane vectors, complete Merkle/payload/image hashes, storage-specific safe live placement, and the typed MID/hash confirmation. The manifest root and target inside the received manifest must also equal the MID/target that opened the fetch before any store is allocated. On SD, APRV, the retained geometry/container authorization, and the internal signed-image-hash token are published in that order before the reset trigger. OTAFIX consumes that application-written authorization and then independently rechecks the safety/integrity subset: strict v3 structure, canonical identity/capabilities, vectors, full payload SHA, embedded manifest CRC, the applicable live EndF/bank-settings no-overlap geometry, the SD token binding where applicable, scratch readback, and final copy hash. It does not re-run Ed25519, the signer allowlist, Merkle leaves/root, or the typed operator confirmation. Success is reported as blup:C8.

    "},{"location":"ota_nrf52_bootloader_update/#failure-behavior","title":"Failure behavior","text":"

    The feature fails closed when the MCU/map is wrong, required valid EndF headroom is insufficient, an unsupported external/ExtraFS role owns the target, the exact installed capability marker is absent or ambiguous, identity cannot be derived unambiguously, or any package check fails. Before the storage-specific scratch/copy step, the application and bootloader are unchanged. OTAFIX consumes the trigger and authorization before its first destructive action, so an interrupted operation cannot automatically retry a partly consumed package. On SD, the retained-RAM authorization is zero-consumed before media access; APRV and the fmt3 flash token may persist but are inert without a new explicit authenticated re-arm and reset trigger. No SD raw-sector handoff is created or consumed. Use USB/BLE DFU or SWD for initial provisioning and local recovery.

    For XIAO and ordinary external-QSPI details, see nRF52 repeater LoRa OTA with external QSPI.

    "},{"location":"ota_nrf52_qspi/","title":"nRF52 repeater LoRa OTA with external QSPI","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    Selected nRF52840 repeater builds use their dedicated external QSPI NOR flash as a raw LoRa OTA staging device. This removes the internal-flash staging conflict: the complete .mota stays off-chip, and the bootloader can use the entire internal application region while it installs either a full image or an in-place delta.

    This is a matched application-and-bootloader feature. A board merely having an nRF52840, free RAM, or pins named QSPI is not enough. Both halves must use the exact flash wiring, and ota self must confirm the store and bootloader before an update is downloaded.

    "},{"location":"ota_nrf52_qspi/#supported-repeater-families","title":"Supported repeater families","text":"

    QSPI staging is enabled only for explicitly matched repeater-role environments (and RS232 bridge variants that directly inherit one of those repeater targets) on these currently matched families:

    Heltec T114 is intentionally not in this list. Its public V1, V2.0, and V2.1 schematics show U9 (MX25R1635F) as an optional QSPI footprint, so standard T114 application and bootloader targets do not assume that external NOR is populated.

    The XIAO-module derivatives use the matching XIAO or XIAO Sense OTAFIX bootloader shown by the module's INFO_UF2.TXT. Do not substitute a similar bootloader for a board with different QSPI pins. For example, the T-Echo Card and T-Echo Lite have different flash wiring and are not enabled by the original T-Echo target.

    Companion builds are intentionally excluded. Some companion targets use the same external QSPI as a LittleFS message/data store, where raw OTA staging would corrupt the filesystem; other companions simply do not assign that chip to OTA. Room-server, sensor, and KISS roles are unchanged. Raw QSPI OTA is scoped to the explicitly matched repeater-derived environments.

    "},{"location":"ota_nrf52_qspi/#rak15001-placement-and-module-conflicts","title":"RAK15001 placement and module conflicts","text":"

    RAK15001 is a 2 MiB GD25Q16C standard SPI module, not a quad-I/O flash. The dedicated RAK4631 target uses the nRF52840 QSPI peripheral in its single-data-line FAST_READ/page-program modes at 8 MHz and accepts only the module's exact C8 40 15 JEDEC ID. ota self reports QSPI store:2048K only when the expected module responds. An empty slot or a different SPI device reports QSPI store:ERR 0K, and install is refused.

    The module is electrically usable in sensor slot A-D because those slots share the SPI signals and RAK15001 has onboard 10 kOhm pull-ups on WP# and HOLD#. The supported MeshCore/OTAFIX combination nevertheless requires slot C. It is the placement that remains safe when a RAK12501 GNSS is fitted in either of its supported slots, A or D: slot C avoids the GNSS PPS/reset nets on IO1/IO2 and IO5/IO6. In particular, GPS in A plus flash in B would share IO1/IO2 through the modules' auxiliary pins.

    Only one device that uses the shared WisBlock SPI chip-select may be fitted. Do not combine this target with RAK13800 Ethernet, RAK15002 SD, or another SPI module. RAK13800 and RAK15001 cannot coexist because they use the same SPI chip-select. Update a RAK13800 Ethernet build locally over USB using the release's Manual UF2 or Serial DFU (.zip) download; it cannot use this RAK15001 LoRa-OTA staging target.

    RAK3401 with RAK13302 remains incompatible with RAK15001. The 1 W radio uses the same WisBlock SPI clock/data pins and the same chip-select as RAK15001. Firmware cannot independently select or detect those two chips, so a stock RAK3401 + RAK15001 assembly cannot provide reliable OTA staging without a hardware chip-select rework. This restriction does not apply to the separate-CS W25Q16 wiring below.

    "},{"location":"ota_nrf52_qspi/#one-w25q16-wiring-for-rak4631-or-rak3401-rak13302","title":"One W25Q16 wiring for RAK4631 or RAK3401 + RAK13302","text":"

    An external Winbond W25Q16JV breakout can remain attached to a RAK19007 while the core is changed between RAK4631 and RAK3401 + RAK13302. It shares only the WisBlock SPI clock/data nets and has its own chip-select on AIN1:

    W25Q16 breakout RAK19007 connection nRF52840 / Arduino pin CLK IO connector pin 26, SPI_CLK P0.03 / 3 DO / MISO IO connector pin 27, SPI_MISO P0.29 / 29 DI / MOSI IO connector pin 28, SPI_MOSI P0.30 / 30 CS J11 AIN1 P0.31 / 31 VCC J12 VDD regulated 3.3 V GND J12 GND ground

    Fit a physical approximately 10 kOhm pull-up from CS to J12 VDD. That keeps the flash deselected during reset, bootloader entry, and a core-module swap. Power the breakout only from J12 VDD; never use J11 VBAT, which can exceed the flash's supply rating. The six-pin breakout does not expose WP#/IO2 or HOLD#/IO3; do not add nRF IO2/IO3 wiring. A breakout with an onboard power LED works, but that LED draws continuously and is undesirable for low-power or solar operation.

    This arrangement consumes no WisBlock sensor slot. However, SPI_CLK, SPI_MISO, and SPI_MOSI are not available on the easy 2.54 mm J10/J11/J12 headers. Those three signals require a short underside-pad tap or a suitable IO-connector interposer at pins 26-28. J11 and J12 alone are not sufficient.

    GPS remains supported in sensor slot A. RAK12500 uses I2C, while RAK12501/L76K uses UART plus its auxiliary control signals; neither uses the shared SPI clock/data bus. On RAK4631 the built-in SX1262 has a separate private SPI bus. On RAK3401 the RAK13302 and flash share clock/data, but have independent chip-selects: RAK13302 NSS is P0.26 and flash CS is P0.31. The matched firmware holds the radio NSS high while it temporarily hands the bus to the flash, then restores the radio SPI interface.

    The breakout target accepts only a 2 MiB W25Q16 with JEDEC ID EF 40 15 and runs it at 8 MHz in standard single-data-line SPI mode. A substituted W25Q32, W25Q64, or W25Q128 fails closed instead of being treated as the OTA store. Use the complete application/bootloader identity row that matches the fitted core:

    Core and radio PlatformIO environment MOTA hardware identity OTAFIX board / DFU device name RAK4631 built-in SX1262 RAK_4631_repeater_w25q16_lora_ota RAK4631_W25Q16 wiscore_rak4631_w25q16 / 4631_W25Q16_DFU RAK3401 + RAK13302 1 W RAK_3401_repeater_rak13302_w25q16_lora_ota RAK3401_RAK13302_W25Q16 wiscore_rak3401_rak13302_w25q16 / 3401_W25Q16_DFU

    The physical flash wiring is identical, but the two application and bootloader pairs are not interchangeable.

    "},{"location":"ota_nrf52_qspi/#one-time-prerequisite","title":"One-time prerequisite","text":"

    Install a QSPI-capable OTAFIX 2.4.1 preview.9 or newer bootloader for the exact board from the OTAFIX releases before using LoRa OTA. The release notes must explicitly list that board's QSPI mode. Also install the SoftDevice version expected by that target. The application refuses the install handoff when the bootloader does not advertise QSPI support.

    For the external W25Q16 option, install the corresponding bootloader once before installing its MeshCore application: wiscore_rak4631_w25q16 (DEVICE_NAME=4631_W25Q16_DFU) for RAK4631, or wiscore_rak3401_rak13302_w25q16 (DEVICE_NAME=3401_W25Q16_DFU) for RAK3401 plus RAK13302. Use only a release that explicitly names that exact pairing. Do not substitute the RAK15001 bootloader or swap the two W25Q16 bootloaders merely because the base-board wiring is the same.

    For the first migration from the ordinary wiscore_rak4631_board bootloader to wiscore_rak4631_board_rak15001_slot_c, use Nordic serial DFU or a compatible BLE DFU client with the exact slot-C OTAFIX combined bootloader + SoftDevice DFU package (or use SWD), then reinstall the slot-C MeshCore application. The release filename has this form:

    wiscore_rak4631_board_rak15001_slot_c_bootloader-<OTAFIX-version>_s140_6.1.1.zip\n

    Do not copy the slot-C bootloader-update UF2 onto the stock UF2 drive. The stock loader is bound to DEVICE_NAME=4631_DFU, while the slot-C image is bound to 4631_15001C_DFU, so that UF2 is intentionally rejected. After the one-time DFU/SWD migration, later canonical slot-C bootloader UF2 files work normally. The combined OTAFIX package above is not the MeshCore application's Serial DFU .zip; an application package does not migrate the bootloader.

    After installing the repeater application, check:

    get bootloader.ver\nota self\nota status\nota qspi\n

    A ready RAK15001 target reports all of the following:

    QSPI store:2048K\nbootloader: QSPI apply OK\nbl:QSPI\nQSPI jedec=C84015 size=2048K sr1=00 stage=jedec\n

    A ready external W25Q16 target instead identifies the Winbond part:

    QSPI store:2048K\nbootloader: QSPI apply OK\nbl:QSPI\nQSPI jedec=EF4015 size=2048K sr1=00 stage=jedec\n

    Other supported boards can report a capacity different from 2048K; the RAK15001 and W25Q16 targets must report exactly 2048K and their respective exact JEDEC IDs. QSPI store:ERR 0K, NO QSPI, or bl:NO-QSPI means the flash wiring, flash power, chip-select pull-up, application, or bootloader does not match. Do not start an install in that state.

    ota qspi is a read-only diagnostic probe available on QSPI OTA builds. It reports the exact JEDEC ID, status-register byte, last store stage, and the first latched storage error. Run it after an immediate storage error before starting another pull; later capacity probes preserve that failure detail.

    "},{"location":"ota_nrf52_qspi/#capacity-and-package-types","title":"Capacity and package types","text":"

    The store reads the JEDEC capacity at runtime and accepts supported 1 MiB through 16 MiB devices using 24-bit addressing. QSPI capacity is not the final firmware limit. The reconstructed application, including its 56-byte EndF trailer, must fit its build's linked application region:

    SoftDevice layout Application region Maximum image S140 v7, boot-update-capable XIAO module 0x27000..0xE0000 0xB9000 (757,760 bytes) S140 v7, app base 0x27000 0x27000..0xED000 0xC6000 (811,008 bytes) S140 v6, app base 0x26000 0x26000..0xED000 0xC7000 (815,104 bytes)

    The XIAO limit is deliberately 52 KiB smaller. Its linker and post-link record cap every ordinary application at 0xE0000; OTAFIX reserves 0xE0000..0xEA000 as a 40 KiB self-update scratch bank, with the remaining gap left unused before InternalFS. Full and delta application packages for these targets derive their effective application end from that 0xE0000 record and are rejected if their reconstructed image or detools geometry crosses it.

    A full package needs only the new raw firmware.hex or non-merged application image. An in-place delta still needs the exact image currently running. The automation uses a conservative 0xC6000 detools workspace for external nRF52 staging so one package setting is safe for both layouts.

    For online automation, QSPI is detected from ota self or ota status. For offline preparation, identify it explicitly:

    ./tools/lora_ota/lora_ota.sh ./release.zip target-name \\\n  --prepare-only \\\n  --platform nrf52 \\\n  --nrf-qspi \\\n  --target-id 12345678 \\\n  --target-hw Xiao_nrf52\n

    Use the real target ID and hardware identity from the destination. A ready full .mota normally uses that target ID for discovery and routing. An operator can deliberately override the routing target for a role change, so target_id is not an apply-time safety assertion; the destination still enforces the package's hardware identity before approval.

    "},{"location":"ota_nrf52_qspi/#explicit-xiao-bootloader-updates-over-lora","title":"Explicit XIAO bootloader updates over LoRa","text":"

    This section documents the deployed XIAO raw-QSPI layout. Curated nRF52840 targets without external staging use a separate internal-flash layout with the same explicit operator safety model; see nRF52 bootloader updates over LoRa.

    Bootloader delivery is available only when all of these are already true:

    This is not part of normal automation. Bootloader catalog rows are labelled bootloader; even a capable node never autofetches or autoinstalls them, and ordinary ota install rejects them in every application backend. The tools/lora_ota deployment runner also refuses v3 packages. Fetch and arm one only with the explicit flow:

    ota ls\nota pull <MID8> flash\n# wait for ota status to report the same bootloader download as ready\nota bootloader\n# copy the exact mid= and hash= values printed above\nota bootloader install <MID8> <HASH16>\n

    The pull command is only transport intent; it does not authorize an install. The final command must reproduce both the complete staged manifest ID and the first eight bytes of its signed image hash. Before approval the application again checks package/root/payload/image hashes, Ed25519 signature and trusted allowlist, exact signed XIAO_BL_... ID, installed and incoming embedded manifest/CRC/name/board identity, vector table, ABI-3 QSPI+boot-update marker, and the adjacent CRC-covered BLM2/SOFT continuity extension at the exact final-image offset 0x9FB4. The embedded boot version must equal the signed outer package version, the SoftDevice family/FWID/application base/layout ABI must match the running platform, and a successor to installed BLM2 must be strictly newer. Low-byte-zero and all-ones versions are invalid. Remote rollback is refused; use local DFU/SWD.

    After the reply drains, GPREGRET 0x6B and GPREGRET2 0x51 enter the special OTAFIX path. APRV carries the app's signature/allowlist and explicit operator authorization decision. OTAFIX independently rechecks the strict structure, identity/capabilities, vectors, payload SHA, embedded CRC, and copy hashes before using the reserved scratch bank to replace its own 0xF4000..0xFE000 region. The running application remains preserved; a rejected candidate returns to it unchanged. Post-reboot ota status reports bootloader-update diagnostics as blup:C0 to blup:CF (blup:C8 is success), separately from ordinary application blrc.

    This feature cannot update a stock or old bootloader that lacks the capability marker: perform the first exact-board combined bootloader+SoftDevice migration over USB/BLE DFU or SWD. Do not attempt to bootstrap it with a v3 package.

    "},{"location":"ota_nrf52_qspi/#storage-ownership-and-recovery","title":"Storage ownership and recovery","text":"

    The QSPI store is raw, not a file inside LittleFS. It owns the flash from offset zero, erases 4 KiB sectors as blocks arrive, writes data before progress metadata, and verifies every programmed page. A previously interrupted download is reopened only when its header and trailer are valid; every claimed block is re-hashed before it is trusted. Between a probe, transfer operation, or checkpoint, firmware puts the NOR into deep power-down, deactivates the nRF QSPI peripheral, and turns off a board-provided flash power-enable pin. The next operation powers the chip, shifts the NOR's 0xAB wake command over GPIO, then activates the nRF QSPI peripheral and identifies the flash. Waking it before peripheral activation is required because a flash in deep power-down ignores the activation traffic itself. Merely running ota self therefore does not leave QSPI drawing active-mode current or prevent the following operation from reactivating it.

    Installing a QSPI repeater build over a former companion build therefore repurposes the external flash and destroys companion filesystem data as OTA sectors are written. Back up anything important first. Returning to a companion build may require formatting its external data store.

    Before changing internal application flash, the application verifies package integrity, hardware identity, signature policy, and bootloader capabilities. The target ID selects discovery/fetch routing and can be deliberately overridden; it is not a second hardware gate. The bootloader then verifies a full payload before its first application erase. For a delta, the application rejects invalid detools geometry before approval and the bootloader independently repeats the base and geometry checks before applying it. It clears the one-shot approval marker before invalidating the running image. If power is lost after application writes begin, the bank remains invalid and OTAFIX enters USB/BLE recovery rather than booting a partial image.

    See Easy firmware updates over LoRa for the transfer commands and the OTA protocol for the container and handoff details.

    "},{"location":"ota_protocol/","title":"MeshCore OTA - .mota container & LoRa protocol","text":"

    This is the single source of truth for MeshCore's over-the-air firmware update system (\"mOTA\"). It is written for developers who want to implement an interoperable peer (server, fetcher, relay, or host tool) in another codebase or project. Everything below is implemented in this repository and covered by host, simulation, build, or hardware tests as noted in the relevant section. Hardware qualification is target- and chain-specific; do not infer it from implementation alone. Where a section names a source file, that file is the authoritative reference for byte-level details.

    Just want to update your node? See the plain-language OTA user guide - this document is the technical/wire specification.

    Design goals

    Source map (all under src/helpers/ota/ unless noted)

    Concern File Constants, enums, flags OtaFormat.h Container/manifest parse MotaContainer.{h,cpp} Merkle tree + proofs MerkleTree.{h,cpp} EndF self-identity FirmwareInfo.{h,cpp} Wire message codec OtaProtocol.{h,cpp} Session engine (serve+fetch+discovery) OtaManager.{h,cpp} Multi-mota / folder relay OtaSource.h, MotaSourceSerial.{h,cpp}, MotaSeederProto.h Staging stores OtaStore.h, OtaStoreFlashNrf52.*, OtaStoreFlashEsp32.* Apply OtaApply.*, bootloader Adafruit_nRF52_Bootloader_OTAFIX Device glue (CLI/context) OtaCli.cpp, OtaContext.h Host tooling motatool (standalone Rust CLI: build/verify/inspect/serve); tools/mota/ (Python reference lib motalib.py + build/test glue)"},{"location":"ota_protocol/#1-conventions","title":"1. Conventions","text":"

    Reference constants (OtaFormat.h):

    Name Value ASCII / note Container MAGIC 6D 4F 54 41 mOTA Container TRAILER 76 6B 34 39 36 vk496 EndF marker 45 6E 64 46 EndF hash_algo (sha2-256) 0x12 multihash code application format_ver 0x02 ordinary full/delta application package bootloader format_ver 0x03 privileged exact-identity nRF52 bootloader package only approval = not approved FF FF FF FF erased NOR word approval = approved 41 50 52 56 APRV MFLAG_FULL 0x01 flags bit0 MFLAG_SIGNED 0x02 flags bit1 MFLAG_BOOTLOADER 0x04 flags bit2; valid only in the exact v3 bootloader profile CODEC_FULL / _SEQUENTIAL / _INPLACE 0 / 1 / 2 Section 5 PAYLOAD_TYPE_OTA 0x0C MeshCore packet type (src/Packet.h) MAX_PACKET_PAYLOAD 184 usable bytes per packet (src/MeshCore.h) Default application block size 2048 block_size_log2 = 0x0B; deployed 1 KiB application packages remain accepted Bootloader-package block size 1024 fixed block_size_log2 = 0x0A for format 3 OTA discovery TX priority 250 background (OTA_TX_PRIORITY, src/Mesh.h) OTA active-transfer TX priority 0 primary (OTA_TRANSFER_TX_PRIORITY, src/Mesh.h)

    Current application firmware publishes its receive/reassembly ceiling as the compact maxblk:2048 field near the front of both ota status and ota self. Keeping the field ahead of variable diagnostics ensures it survives the 160-byte remote-admin reply limit. Host updaters must treat an absent field as the deployed legacy capability (1024), reject a ready package whose manifest block size exceeds that capability, and use the reported size when turning a raw firmware image into a new application package. This capability does not change the format-3 bootloader package's fixed 1 KiB geometry.

    "},{"location":"ota_protocol/#2-firmware-image-the-endf-trailer","title":"2. Firmware image & the EndF trailer","text":"

    Every OTA-capable build appends a fixed 56-byte EndF trailer to its flashed image so a running node can discover its own size and self-describing identity on any MCU (no linker symbols needed). Every field is always present at a constant offset. Implemented by FirmwareInfo.cpp; appended at build time by tools/mota/pio_endf.py (post-build hook).

    flashed image = BODY (image bytes) || EndF trailer\nEndF trailer (fixed 56 bytes):\n  off 0   4   \"EndF\"        45 6E 64 46\n  off 4   4   body_len      uint32 LE - length of BODY (excludes the whole trailer)\n  off 8   8   body_hash     sha2-256:8 of BODY\n  off 16  4   fw_version    uint32 LE, packed MAJOR<<24|MINOR<<16|PATCH<<8|pre  (0 = unknown)\n  off 20  4   target_id     uint32 LE - sha2-256:4(pio_env): hardware + role + partition (fetch routing)\n  off 24  32  hw_id         NUL-padded ASCII hardware tag (brick-safety), e.g. \"RAK4631\" (\"\" = unknown)\n

    The \"reconstructed image\" referenced by the manifest is the full BODY || EndF (what gets flashed).

    "},{"location":"ota_protocol/#esp32-application-slot-profiles","title":"ESP32 application-slot profiles","text":"

    ESP32 Companion firmware is exempt from the portable-slot limit. When an exact Full recipe exists, one expanded-partition image supplies USB, BLE, WiFi, source-only LoRa OTA, and persistent USB logging instead of separate transport artifacts. A small set of high-capacity, non-PSRAM classic ESP32 companions use 100 contacts, 8 group channels, and a 16-frame offline queue in that combined image to preserve internal-DRAM headroom. MQTT observers and ESP-NOW bridges always use FULL builds because fitting them into the legacy slot would require removing CLI and role features. Except for those FULL roles and the ESP32-C6 case below, non-companion ESP32 artifacts, including room, sensor, and repeater roles, must fit the legacy slot from 0x10000 up to 0x150000 (0x140000, 1,310,720 bytes), including the 56-byte EndF trailer. The build checks both that limit and the target's actual app partition. The ESP32-C6 no_external_sensors OTA siblings are the narrow exception: the Arduino 3.x WiFi runtime cannot fit that cross-family ceiling, so those images retain their established target-specific 1920 KiB or larger A/B app layout and are checked against the actual app partition. For standalone ESP32 and nRF52 repeaters that need a lean staging profile, build.sh also exposes an explicit *_lora_ota_no_external_sensors artifact: the ordinary repeater remains sensor-enabled, while that sibling trims selected optional environmental/ranging drivers for LoRa distribution. SolarXiao 30S and 33S use matched external QSPI staging, so their ordinary full-sensor repeater is already install-capable and no redundant lean sibling is generated. Integrated GPS and other board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. The legacy suffix describes a driver trim, not removal of the generic I2C bus. Reduced RAK3401 and RAK4631 profiles retain INA219, INA226, INA260, and INA3221 voltage/current monitors. These are retained optional-sensor-table drivers, not the only I2C consumers: SSD1306 display, supported autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals where selected by the board recipe. The RAK3401 OTA repeater also retains RAK12500 I2C and RAK12501/L76K UART GPS support; install either GPS module in sensor slot A because slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The plain RAK4631 OTA repeater and its Serial2 bridge retain GPS. RAK12501 uses Serial1. Its explicitly compiled Serial1 bridge therefore omits the combined GPS provider, including RAK12500, even though RAK12500 itself does not use the UART.

    The firmware-configured INA3221 address and RAK12500 address are both 0x42, so those devices cannot share one bus at those addresses. Keep RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use firmware built with -DTELEM_INA3221_ADDRESS=0x43 when both are installed.

    ESP32 siblings retain the compact browser WiFi updater and use up to 254 neighbors. Internal-DRAM-constrained targets use 50 as recorded in the artifact capability manifest; see ESP32 memory budget. RP2040 and STM32 targets are not offered because those platforms do not yet have a safe bootloader/apply path.

    nRF52 LoRa-OTA siblings use size optimization rather than the Adafruit platform's default -Ofast. This keeps the runtime software Ed25519 fallback from being expanded into tens of kilobytes of repeated curve arithmetic while retaining CC310 hardware crypto, hardware RNG mixing, telemetry history, and board-native features.

    WiFi-heavy non-companion roles are not reduced to fit the legacy application slot. build.sh automatically promotes every ESP32 MQTT observer and ESP-NOW bridge to the expanded FULL partition profile. These artifacts retain the complete role CLI, WebConfig where supported, display and optional sensor support, full timezone and TLS behavior, and the board's normal power-management implementation. The compact CLI is not compiled into any build. Ordinary repeater builds remain sensor-enabled; only explicitly named *_lora_ota_no_external_sensors siblings trim selected optional environmental/ranging drivers for LoRa distribution, and those siblings retain the complete CLI and target-declared I2C peripherals.

    MQTT observer radio and bridge preferences use verified temporary files plus a recoverable backup. A reset during a settings save restores the last committed common preference image or publishes the completed new image; it does not leave a partially written /com_prefs file to fail on the next boot. A truncated legacy image is rejected before any partial radio or string fields are applied, then rewritten from safe defaults.

    Option 3 in build.sh emits one *-full-usb-wifi-ota-* ESP32 artifact for each FULL-capable non-companion hardware/role that has a matching MQTT environment. It compiles USB packet logging and direct WiFi MQTT together. A *-full-logging-ota-* fallback is emitted only when there is no MQTT sibling; ordinary non-OTA roles compile runtime USB logging into their canonical image, so separate standard-logging artifacts are not emitted. Non-MQTT FULL twins are also skipped for covered ESP32 roles. MQTT observers and ESP-NOW bridges are emitted only with expanded FULL partitions. Menu option 8, or build-full-esp32-firmwares, builds the unified profiles plus necessary fallbacks. Menu option 9, or build-full-esp32-logging-firmwares, builds only those fallbacks. FULL builds restore WebConfig, display support, optional external sensors, and the full role CLI and feature set, full ElegantOTA where that target declares the required library, and LoRa OTA for every included role, including room servers, sensors, observers, and bridges. They use expanded A/B partition tables: 1984 KiB application slots on 4 MiB boards and the framework's larger dual-OTA tables on 8 MiB and 16 MiB boards. Explicit *_lora_ota_no_external_sensors targets are not duplicated; their ordinary repeater build is the FULL, sensor-enabled counterpart. The *-full-usb-wifi-ota-* profile enables USB packet logging and MQTT, with a persistent logging.output selector; its verbose internal debug remains off. The fallback *-full-logging-ota-* profile enables USB debug and packet logging and has no MQTT target. Install a matching *-full-usb-wifi-ota-*-merged.bin or *-full-logging-ota-*-merged.bin over USB once to write the expanded partition table. After that, its matching non-merged FULL application image can be installed through USB, WiFi OTA, or LoRa OTA. Do not install a non-merged FULL image onto a node that still has its old partition table.

    Implementer note: the bootloader (and any non-Arduino consumer) MUST locate the body extent by scanning for EndF, never by trusting a stored size - see the bootloader contract in Section 12.

    "},{"location":"ota_protocol/#3-the-mota-container","title":"3. The .mota container","text":"

    The distributed form (host-built, wire-transferred). Parsed by mota_parse() in MotaContainer.cpp.

    off            size   field\n0              4      MAGIC = 6D 4F 54 41\n4              4      MOTA_TOTAL_SIZE  uint32 LE - total container bytes (incl. manifest, leaves[],\n                                       payload, trailer). Lets a node pre-reserve staging and compute\n                                       write_start = staging_region_end - MOTA_TOTAL_SIZE.\n8              M      MANIFEST         (Section 4; M = 197 fixed + leaves[], 4*BC; no length field - BC from payload_size)\n8 + M          P      PAYLOAD          (payload_size bytes; delta or full image)\n8 + M + P      5      TRAILER = 76 6B 34 39 36\n

    MOTA_TOTAL_SIZE = 4 + 4 + M + P + 5. The manifest M includes leaves[]; the manifest-minus-leaves prefix (mfl, sent over the wire as OTA_MANIFEST) is [8, leaves_off).

    Staged (in-flash) form. Written bottom-aligned so TRAILER ends at staging_region_end. Identical bytes, except the device mutates two regions in place (both NOR-safe, no re-erase): the leaves[] slots (filled as blocks arrive - Section 7) and the 4-byte approval field (on owner consent - Section 4.2). Everything else is immutable.

    "},{"location":"ota_protocol/#4-the-manifest","title":"4. The manifest","text":"

    Fixed layout. Every field sits at a constant offset and is always present - base_hash, signer_pubkey and signature are zero-filled when not applicable (a full image / an unsigned container). Only leaves[] is variable (one 4-byte hash per block). So the manifest-minus-leaves (mfl) is always 197 bytes and the parser is plain offset reads - no conditionals. Parsed by mota_parse_manifest().

    off  size   field            notes\n0    1      format_ver       = 0x02 application, or 0x03 privileged bootloader package\n1    1      flags            bit0 FULL; bit1 SIGNED; bit2 BOOTLOADER; bits3-7 reserved 0\n2    1      hash_algo        0x12 = sha2-256\n3    4      target_id        device/arch/role discriminator (Section 9)\n7    4      fw_version       MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre   (comparable uint32)\n11   4      image_size       size of the reconstructed image (BODY||EndF)\n15   4      payload_size     PAYLOAD bytes in this container\n19   1      block_size_log2  e.g. 0x0B = 2048 (new application default); 0x0A = deployed 1024\n20   4      merkle_root      sha2-256:4 over PAYLOAD blocks (Section 6) - also the manifest_id\n24   32     image_hash       sha2-256:32 of the reconstructed image - SECURITY anchor\n56   1      codec_id         0=full/raw, 1=detools-sequential, 2=detools-in-place\n57   32     hw_id            NUL-padded ASCII hardware tag (e.g. \"RAK4631\"); same tag => bootable-compatible.\n                             SIGNED. Applier refuses a mismatch (brick-safety); empty on either side = skip.\n89   8      base_hash        sha2-256:8 of the BASE image's BODY (== that build's EndF.body_hash). 0 if FULL.\n97   32     signer_pubkey    Ed25519 public key. 0 if not SIGNED.\n129  64     signature        Ed25519 over manifest[0, 129). 0 if not SIGNED.\n193  4      approval         FF FF FF FF = not approved; 41 50 52 56 (\"APRV\") = approved\n--- end of manifest-minus-leaves: mfl = 197 (constant); leaves_off = 8 + 197 = 205 in the container ---\n197  4*BC   leaves[]         BC = ceil(payload_size / 2^block_size_log2). sha2-256:4 each (the only variable field)\n

    The signature always covers manifest[0, 129) (the head + base_hash + signer_pubkey). approval is outside the signed region so it can be flipped in place on consent without breaking the signature.

    Manifest-minus-leaves size (mfl) is a constant 197 bytes for every container (full or delta, signed or unsigned). At 197 bytes the manifest exceeds one packet, so OTA_MANIFEST is always sent multi-fragment (Section 8.4, 2 fragments) and reassembled by the fetcher.

    The two versions are deliberately disjoint. Version 2 accepts application packages only and rejects the BOOTLOADER bit. Version 3 accepts only flags exactly FULL|SIGNED|BOOTLOADER; a non-bootloader v3 package is invalid. Consequently, deployed v2-only application parsers reject a bootloader package before they can mistake its raw 40 KiB payload for an application image.

    "},{"location":"ota_protocol/#41-signed-region","title":"4.1 Signed region","text":"

    signature covers manifest bytes [0, 129) - the head + base_hash + signer_pubkey. It does not cover approval or leaves[]:

    "},{"location":"ota_protocol/#42-the-approval-field","title":"4.2 The approval field","text":""},{"location":"ota_protocol/#43-privileged-nrf52-bootloader-package-profile","title":"4.3 Privileged nRF52 bootloader package profile","text":"

    A v3 bootloader package has a deliberately narrow, non-extensible profile:

    The incoming embedded identity must exactly match the installed CRC-valid bootloader identity. Both scans consider every aligned structurally valid candidate so magic bytes in a literal pool cannot shadow the real manifest. Duplicate accounting counts each CRC-valid 44-byte base record before interpreting adjacent continuity metadata, so a corrupt or half-present BLM2 extension cannot hide a second identity; after exactly one base record is selected, malformed claimed continuity fails closed. A package must be signed by a key already in the device's trusted allowlist; unlike ordinary application packages, there is no unsigned manual-install exception. The signed outer fw_version must equal the embedded boot version. Qualified internal/QSPI targets may bootstrap a CRC-valid legacy-v1 installed bootloader once; MeshTower SD instead requires local BLM2 provisioning because it has no safe legacy media handoff. After bootstrap every remote successor must be strictly newer and match the live SoftDevice/application layout. Low-byte zero and all-ones boot versions are invalid. Remote rollback has no override and must use local DFU/SWD.

    "},{"location":"ota_protocol/#5-payload-codecs-delta-base","title":"5. Payload, codecs & delta base","text":"

    PAYLOAD is either the full reconstructed image (FULL) or a delta (!FULL).

    codec_id Meaning Used by 0 full / raw PAYLOAD = reconstructed image (BODY||EndF). ESP32 A/B or an external SD/QSPI nRF52 target. 1 detools sequential random read of base + sequential write of result -> ESP32 A->B inactive slot. 2 detools in-place bounded scratch; rewrites the app region in place -> nRF52 single-slot.

    For deltas, base_hash = the base build's EndF.body_hash (sha2-256:8 of its BODY). A node applies a delta only if base_hash matches its own EndF.body_hash. After applying, the result MUST hash (sha2-256:32) to image_hash before it is booted - the hard security gate.

    A fetcher only requests firmware it can apply. Each node declares the codec(s) it can apply (set_apply_codec/set_apply_codec2): ESP32 accepts full + sequential (+ in-place). Internal-staging nRF52 targets accept only in-place because internal flash cannot hold a second full application image. Matched SD and raw-QSPI nRF52 targets accept full + in-place because external media holds the container. A .mota with an unsupported codec is rejected at discovery time, before any blocks are requested. A manual pull to an external folder may accept other codecs because that path captures bytes and never installs them.

    Compression is internal to the detools patch and must be supported by the applier. Patches are produced by detools 0.53.0 (tools/mota -> detools.create_patch) and decoded on-device by detools' embeddable C decoder, vendored verbatim at src/helpers/ota/detools/ (see its README.meshcore.txt). That build enables only the self-contained NONE + CRLE compressions (no malloc/liblzma/heatshrink), so MeshCore deltas use --compression crle. Do not reimplement the codec - use the vendored decoder.

    "},{"location":"ota_protocol/#6-merkle-tree-sha2-2564","title":"6. Merkle tree (sha2-256:4)","text":"

    Verifies each PAYLOAD block against the signed merkle_root before the whole payload exists, so corruption/forgery is localized to a block. Implemented in MerkleTree.cpp.

    "},{"location":"ota_protocol/#61-proofs","title":"6.1 Proofs","text":"

    A proof for block i is the ordered list of sibling digests from leaf to root. Promoted levels contribute no element. Verification (needs BC to know the tree shape):

    h = leaf_i ; idx = i ; n = BC ; p = 0\nwhile n > 1:\n    if (n is odd) and (idx == n-1):        # this node was promoted\n        pass\n    else:\n        sib, side = proof[p] ; p += 1\n        h = sha2-256:4( sib || h ) if side==left else sha2-256:4( h || sib )\n    idx //= 2 ; n = (n + 1) // 2\naccept iff h == merkle_root and p == len(proof)\n

    Over LoRa, leaves[] are omitted from the manifest transfer. A serving node computes a block's proof on demand from its stored leaves[] and normally sends OTA_PROOF immediately after that block's paced OTA_DATA. OTA_REQ_PROOF remains the fallback for an older source or a lost proactive proof. The fetcher fills its own leaves[i] as each verified block lands.

    "},{"location":"ota_protocol/#7-block-availability-staging-resume","title":"7. Block availability, staging & resume","text":"

    There is no separate availability structure. Block i is present <=> leaves[i] is non-erased (!= FF FF FF FF). Because leaves[] live in the staged flash region, availability survives reboot.

    Commit order per block (crash-safe): (1) verify proof, (2) write block payload to its offset, (3) write leaves[i] last. A power loss before step 3 leaves the slot erased -> the block is simply re-fetched (idempotent). On boot a node rebuilds an in-RAM present-bitmap by scanning leaves[] for a persistent, reopenable store. A hybrid nRF52 transfer is the deliberate exception: its SRAM-backed payload suffix is volatile, so the application refuses to adopt that staged header instead of rebuilding partial progress.

    Resume (OtaManager::resumeStaged + OtaStore::checkpoint/reopen): an interrupted fetch resumes from the staged container after a reboot - re-parse the stored manifest, recompute geometry, count present blocks, continue fetching the holes (or jump straight to COMPLETE). The checkpoint cadence (persist progress every N committed blocks) is runtime-tunable (ota config checkpoint <N>, 0 = only finalized containers resume). Boot-time adoption is an automatic fetch decision: current autofetch must be enabled, the stored target must equal the node target, policy signed requires the signed bit, and an enabled running-version floor requires a strictly newer manifest. An explicit MID pull may deliberately resume an older or unsigned package and keeps target 0 as a MID-only wildcard. Stores keep leaves[] in RAM until flush and never auto-GC, preserving resumable progress. The debug/operator equivalent is ota dev resume <MID8>; after a reboot the MID is mandatory, while a no-argument form may only reuse a still-active session MID. It never uses the nullptr automatic-adoption path, so a malformed MID or no active MID fails closed. Hybrid nRF52 staging cannot enter this resume path after an application restart, even if its flash prefix still contains metadata; the complete logical container must be fetched again.

    Flash-store note (RX-safe writes): a flash page-erase halts the CPU (~85 ms on nRF52) and starves LoRa RX, so the flash stores (OtaStoreFlashNrf52/OtaStoreFlashEsp32) coalesce writes to the erase unit (4 KB page / sector) and commit each once off the per-packet path. Ordinary stores keep RAM at O(one page), not O(image); a qualified hybrid nRF52 profile additionally reserves one fixed 64 KiB staging arena. A small delta that fits page 0 does zero flash I/O until COMPLETE.

    "},{"location":"ota_protocol/#8-lora-ota-protocol","title":"8. LoRa OTA protocol","text":"

    Carried in MeshCore packets with PAYLOAD_TYPE_OTA = 0x0C. Every OTA packet payload is:

    [0]    ota_msg_type      (OtaMsgType, OtaFormat.h)\n[1..]  body              (fixed per type; encode/decode in OtaProtocol.cpp)\n

    Message types:

    ota_msg_type val routing purpose OTA_ADV 0x01 discovery tiny per-node beacon (discovery tier 1) OTA_QUERY 0x02 discovery ask a source for its catalog (discovery tier 2) OTA_HAVE 0x03 discovery the catalog reply (fragmented, digest-tagged) OTA_GET_MANIFEST 0x04 transfer request a manifest's fragments (want_mask) by manifest_id OTA_MANIFEST 0x05 transfer the manifest-minus-leaves, fragmented OTA_REQ 0x06 transfer request fragments from an adaptive flight of 1-4 blocks (want_mask per block) OTA_DATA 0x07 transfer one self-describing fragment of a block's data OTA_REQ_PROOF 0x08 transfer request/re-request a missing proof OTA_PROOF 0x09 transfer the merkle proof for one block OTA_GET_LEAVES 0x0A transfer request the target's leaves[] fragments (want_mask) - warm-start only OTA_LEAVES 0x0B transfer a fragment of the leaves[] array (for host-side seed leaf-diff) "},{"location":"ota_protocol/#81-two-tier-discovery","title":"8.1 Two-tier discovery","text":"

    Because a node may serve many mOTAs (its own firmware plus an external folder - Section 10), discovery is split so the periodic beacon stays tiny regardless of catalog size:

    Tier 1 - OTA_ADV beacon (10 bytes, constant). Flooded as a short burst at boot, then every advert_mins minutes (default 24h; runtime-tunable via ota config advert, 0 disables the periodic re-advertise). It is also emitted immediately whenever the served set changes (e.g. a motatool folder is attached/detached), so peers learn about newly-available firmware without waiting for the next interval:

    seeder_id[4]    advertiser node id = pubkey[0:4]; the QUERY address + distinct-source id\nn_motas         uint8 - count of complete servable mOTAs (saturates at 255)\nset_digest[4]   sha2-256:4 over the SORTED set of served manifest_ids (see below)\n

    set_digest is a content hash of the offering, not a counter: canonical across nodes, and it changes iff the set of served mids changes. A peer that has already catalogued this {seeder, set_digest} ignores the beacon (steady state is query-free). For a single served mota, set_digest = sha2-256:4(mid).

    Tier 2 - OTA_QUERY -> OTA_HAVE (on interest only):

    OTA_QUERY  (flood):  seeder_id[4]  set_digest[4]  filter_target(uint32) want_fragments(uint32)\n                     # filter_target 0 = everything; want_fragments 0 = every fragment\nOTA_HAVE   (flood):  seeder_id[4]  set_digest[4]  frag_idx(1) frag_total(1) n_rows(1)  rows[]\n  HaveRow (16 bytes, OTA_HAVE_ROW_BYTES): mid[4] target_id(4) fw_version(4) codec_id(1) flags(1) have_count(2)\n

    have_count is the number of blocks the source holds (== block_count for a complete offered image). Receivers do not advertise partial or completed downloads as new sources.

    A node interested in a source's offering schedules a QUERY; the source replies with its full catalog as OTA_HAVE rows (fragmented if they exceed one packet - 10 rows per fragment). A receiver marks the catalog complete only after all frag_total fragments arrive. If any are missing after the recovery timeout, it sends another QUERY whose want_fragments bitmap names only the holes. want_fragments is an append-only extension: an original 13-byte QUERY is still accepted and means \"send every fragment.\" The heavy manifest is fetched per-mid only on commit (Section 8.3).

    Fragment numbers are canonical pages of the complete catalog sorted by manifest_id. filter_target may remove rows from a requested page (and can therefore produce an empty fragment), but it never renumbers pages or changes frag_total. This keeps missing-fragment recovery unambiguous when filtered and unfiltered queries for the same {seeder, set_digest} are overheard together.

    "},{"location":"ota_protocol/#82-anti-storm-mandatory-at-mesh-scale","title":"8.2 Anti-storm (mandatory at mesh scale)","text":"

    If 50 neighbours all queried a new beacon at once, the mesh would collapse. Mitigations (gossip/mDNS pattern), all in OtaManager:

    Net effect: a digest change costs ~1 query + ~1 HAVE flood mesh-wide; a stable mesh is query-free.

    "},{"location":"ota_protocol/#83-fetch-handshake","title":"8.3 Fetch handshake","text":"
    fetcher                                   server (any node that has the mid)\n  OTA_GET_MANIFEST(mid, want_mask)  >     (want_mask=0xFFFF first; only missing fragments on retry)\n                               <-------   OTA_MANIFEST(mid, frag_idx, frag_total, bytes)   x requested frags\n  (reassemble manifest, verify, compute geometry: BC, block_size, payload_size)\n  for each adaptive flight of missing blocks (starts at 1, grows on clean flights):\n    OTA_REQ(mid, {block_idx, want_mask}[]) >  (one packet; all fragments first, only holes on recovery)\n                               <-------   OTA_DATA(mid, block_idx, frag_off, data) x requested frags/block\n                               <-------   OTA_PROOF(mid, block_idx, n_proof, proof) x requested blocks\n    (independently reassemble + verify each block, but remain RX-silent until the flight drains)\n    [after adaptive deadline: recover one block's holes, or OTA_REQ_PROOF for a missing proof]\n    (clean flight grows by one block; recovered flight halves the next width)\n  when all blocks present: verify full merkle_root + image_hash -> COMPLETE\n

    Before allocating or writing the selected store, the receiver parses the reassembled manifest and requires its merkle_root to equal the requested/wire manifest_id and its target_id to equal the catalog or explicit-pull target that opened the receive slot. The wire envelope and HAVE row are advisory; they cannot label and stage a different manifest.

    "},{"location":"ota_protocol/#84-message-bodies-transfer","title":"8.4 Message bodies (transfer)","text":"

    All offsets after the 1-byte type. Encoders/decoders in OtaProtocol.cpp; constants in OtaManager.h.

    OTA_GET_MANIFEST:  manifest_id[4]  want_mask(uint16)   # bit k = send manifest fragment k; 0xFFFF = all\nOTA_MANIFEST:      manifest_id[4]  frag_idx(1)  frag_total(1)  bytes[]     # up to OTA_MF_FRAG=176 B/frag\nOTA_REQ:           manifest_id[4]  { block_idx(uint16)  want_mask(uint16) }[1..4]\n                   # legacy: bit k requests its 160-byte fragment\n                   # v2: bit15=marker, bit14=allow transport DEFLATE,\n                   #     bit13=2 KiB descriptor, bits0..12=fragment bitmap;\n                   #     bit12 must be zero when bit13 is set (0xFFFF stays legacy)\nOTA_DATA legacy:   manifest_id[4]  block_idx(uint16)  frag_off(uint16)  data[<=160]\nOTA_DATA v2:       manifest_id[4]  block_idx(uint16)  descriptor(uint16)\n                   stream_id[4]  data[<=171]\n                   # deployed 1 KiB descriptor: bit15=marker, bit14=DEFLATE,\n                   #   bits13..10=fragment, bits9..0=complete encoded length minus one\n                   # negotiated 2 KiB descriptor: bit15=marker, bits14..11=fragment,\n                   #   bits10..0=complete encoded length minus one; DEFLATE iff that\n                   #   length is strictly less than the manifest-derived raw block length\nOTA_REQ_PROOF:     manifest_id[4]  block_idx(uint16)\nOTA_PROOF:         manifest_id[4]  block_idx(uint16)  n_proof(1)  proof[]   # n_proof x 4 bytes\nOTA_GET_LEAVES:    manifest_id[4]  want_mask(uint16)   # bit k = send leaves fragment k; 0xFFFF = all\nOTA_LEAVES:        manifest_id[4]  frag_idx(1)  frag_total(1)  bytes[]      # up to OTA_LEAVES_FRAG=176 leaf bytes\n
    "},{"location":"ota_protocol/#85-sizing-against-max_packet_payload-184","title":"8.5 Sizing against MAX_PACKET_PAYLOAD = 184","text":"message fixed overhead payload/packet OTA_DATA legacy 9 B (type+mid4+idx2+off2) OTA_FRAG_DATA = 160 -> 7 raw frags/1 KiB or 13/2 KiB OTA_DATA v2 13 B (legacy header + stream ID) OTA_FRAG_DATA_V2 = 171 -> 6 raw frags/1 KiB or 12/2 KiB; fewer when compressed OTA_MANIFEST 7 B OTA_MF_FRAG = 176 -> signed manifest ~ 2 frags OTA_HAVE 12 B 10 rows x 16 B per fragment OTA_PROOF 8 B up to ~44 sibling digests (>> any real tree)

    A served mota supports up to 1024 leaves in the default 4 KiB proof scratch (about 2 MiB of payload at the new 2 KiB default, or 1 MiB for a deployed 1 KiB container); larger self-images pass a bigger scratch buffer.

    "},{"location":"ota_protocol/#86-temporary-radio-and-transfer-boundary","title":"8.6 Temporary-radio and transfer boundary","text":"

    OTA packets may cross normal mesh relay hops, but each participating node processes or relays them only while its tempradio window is actually running. A receiver selects missing blocks in serial order into a bounded request flight. Every session starts with one block. A clean completed flight increases the next request by one block; a flight requiring fragment/proof recovery halves the next width (4 -> 2, 3 -> 2, 2 -> 1). The default compiled cap is two blocks; the RAK3401 LoRa-OTA target caps at four, so it probes 1 -> 2 -> 3 -> 4. All rows are sent in one backward-compatible OTA_REQ, and no freed slot is refilled until the current flight is finished. It never serves partial blocks. A normal install receiver never re-advertises its completed download. An SD archive node is the deliberate exception: after a fully proof-verified container is published to its persistent archive, it registers that complete file as a MotaSource and advertises it as a new seeder. This keeps each active transfer as one transmitter and one receiver while still allowing active temporary-radio repeaters between them and persistent archive nodes to improve future availability.

    TempRadio is treated as a private maintenance network. Active transfer packets use priority 0, bypass the public-flood receive holdoff, use the full transmit budget without overwriting the saved normal-radio airtime factor, retain the relay role's airtime-scaled transmit collision window, and do not schedule generic flood retries. Deployed firmware predating that TempRadio budget override can be accelerated manually with a saved get af / temporary set af 0 / restore sequence. The bounded serving queue admits at most two DATA/PROOF packets ahead of the radio while preserving at least four free packet-pool entries. CAD remains enabled to arbitrate the half-duplex channel, but its busy retry is scaled to one-quarter of a packet airtime and clamped to 5-50 ms instead of the ordinary 120-360 ms cadence. Discovery traffic keeps collision jitter and background priority. The fetch deadline uses the active radio's measured maximum-packet airtime, remaining DATA/PROOF packet count, dispatcher airtime factor, and longest observed path (falling back to the configured hop horizon before one is observed), with bounded guard time. Faster SF/BW settings therefore recover loss sooner; slower or multi-hop settings do not spuriously re-request a response still on air. These changes remove software waits and duplicate bursts; they do not remove the one required forwarding transmission per hop.

    "},{"location":"ota_protocol/#9-identity-trust-versioning","title":"9. Identity, trust & versioning","text":""},{"location":"ota_protocol/#10-multi-mota-serve-the-external-folder-relay","title":"10. Multi-mota serve & the external \"folder\" relay","text":"

    A node serves a set of mOTAs: its own firmware plus, optionally, an external folder of .mota files it relays without holding them in flash. To peers it simply \"has N mOTAs\"; the relay is trustless (fetchers verify everything). The serve side (OtaManager) keeps a lightweight registry of what it advertises and two resident \"views\": view0 (its own firmware) and one on-demand view loaded from a source when a request targets an external mota. Every fetch message carries manifest_id, so dispatch is a registry lookup.

    The USB/TCP host-folder link can also be a pull destination (the reverse direction): ota pull <mid8> folder fetches a .mota off the mesh and streams it onto the host as <mid>.mota via the seeder STORAGE ops (OP_STAT/BEGIN/WRITE/SREAD/FIN, see MotaSeederProto.h), using a FolderMotaStore as the fetch's OtaStore instead of RAM/flash. This captures an exact copy of a device's firmware - e.g. to build a delta against firmware you don't have. Resume is bookkeeping-free: BEGIN 0xFF-fills the file and, on reconnect after a link drop (the fetch PAUSES, holding progress on the host - no RAM/flash fallback), STAT+SREAD let the fetcher recompute and refill only the missing blocks. The phone-oriented BLE link is deliberately source-only and does not register a folder destination.

    "},{"location":"ota_protocol/#101-the-motasource-abstraction-otasourceh","title":"10.1 The MotaSource abstraction (OtaSource.h)","text":"

    Transport-agnostic provider of one or more complete .mota as random-access bytes. The same serve code drives USB-serial, BLE, a WiFi URL list, an NFS/samba mount, etc. - only read() differs.

    struct MotaDesc {                      // catalog metadata + region offsets (no whole image in RAM)\n  uint8_t mid[4]; uint32_t target_id, fw_version; uint8_t codec_id, flags;\n  uint8_t block_size_log2, source_caps;\n  uint32_t total_size, leaves_off, block_count, payload_off, payload_size;\n};\nclass MotaSource {\n  virtual uint8_t count();                                  // # mOTAs offered\n  virtual bool    describe(uint8_t idx, MotaDesc& out);     // metadata + offsets\n  virtual bool    read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len);   // random-access bytes\n  virtual bool    read_deflated_block(uint8_t idx, uint16_t block,\n                                      uint8_t* buf, uint16_t cap, uint16_t* len); // optional raw RFC 1951\n};\n

    To serve an external mota the node reads its manifest-minus-leaves + leaves[] into RAM (<=4 KiB for <=1024 blocks) and streams payload blocks from the source on demand; proofs are generated from the read leaves.

    "},{"location":"ota_protocol/#102-the-mota-seeder-transport-motaseederprotoh","title":"10.2 The mota-seeder transport (MotaSeederProto.h)","text":"

    A MotaSource is fed by a host that serves a folder over the device's USB serial (the same console the CLI uses - no extra hardware), on an ESP32 WiFi companion or FULL ESP32 role over WiFi (TCP), or on an nRF52 Full Companion over an encrypted BLE GATT service. The host is the standalone Rust tool motatool (motatool serve --serial <port> / --tcp <host[:port]>, which also builds + verifies + inspects .mota). The device only emits request frames while actively serving a fetch, and reads the reply synchronously, so over the shared USB console binary frames coexist with the text CLI/logs (resync on magic + checksum). Little-endian, XOR-checksummed:

    request  (device -> host):  'M' 'S'  op(1)  args...                 xsum(1 = XOR of op+args)\nresponse (host -> device):  'm' 's'  op(1)  status(1)  payload...    xsum(1 = XOR of all prior)\n\nOP_COUNT     0x01   args: -            -> payload: count(1)\nOP_DESCRIBE  0x02   args: idx(1)       -> payload: MotaDesc wire (38 B)\nOP_READ      0x03   args: idx(1) off(4) len(2)  -> payload: len bytes\nOP_DEFLATE_BLOCK 0x09 args: idx(1) block(2) off(2) len(2)\n                         -> payload: total_encoded_len(2) + requested bytes\nMotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1)\n                      total_size(4) leaves_off(4) block_count(4) payload_off(4) payload_size(4)\n                      block_size_log2(1) source_caps(1) reserved(2)\nstatus: 0 = OK, non-zero = error (out of range / past EOF).\n

    SerialMotaSource splits logical reads into replies of at most 192 payload bytes. A manifest's leaf table can exceed 256 bytes and new payload blocks are normally 2 KiB; requesting either in one transaction can overrun common USB CDC/UART receive rings even though the host successfully wrote the complete reply. Chunking is internal to the transport and does not change OP_READ or the MotaSource random-access contract.

    OP_DEFLATE_BLOCK lets a host-folder source perform the optional transport compression without linking an encoder into the embedded seeder. len=0, off=0 queries the exact encoded length; subsequent chunks are at most 190 bytes, keeping the total response payload at 192 bytes. The host independently encodes each manifest payload block as ordinary raw RFC 1951 at level 9. It returns an error for an invalid range, an unsupported operation, or a result that is not smaller than the raw block; SerialMotaSource then serves the ordinary raw v2 representation. A supporting host sets source_caps bit 0 in every descriptor. Older hosts leave that formerly-reserved byte zero, so upgraded firmware skips the optional request instead of waiting for an old daemon that silently ignores unknown operations.

    Manifest fragments are retained as bounded response jobs and admitted one at a time. Their source-side gap follows the active maximum packet airtime and dispatcher duty spacing, clamped to 100-1000 ms. The 100 ms floor protects fast radios' TX-to-RX turnaround; the cap keeps the receiver's one-second manifest progress/retry observation responsive. The source uses the same radio-aware 100-3000 ms drain/turnaround gap before an unsolicited block proof, but immediately serves a legacy receiver's explicit OTA_REQ_PROOF. Relay collision delay is a separate setting: active OTA floods honor the relay role's configured txdelay, and the deployment runner temporarily uses 0.3 on managed relays.

    What to plug into --serial. Use the USB serial console of an OTA-enabled MeshCore node built with OTA_FOLDER_SERIAL. The node must have a working LoRa radio plus an ota folder on command; that command confirms it can host and advertise the folder. A KISS modem will not work: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI and mota-seeder request/response transport. An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative source connection: use its dedicated seeder port with motatool serve --tcp <host>:5001. An nRF52 Full Companion can instead pair with a phone or Linux host, subscribe to its mOTA request characteristic, and use protocol-v14 CMD_BLE_MOTA_SOURCE. That BLE path is source-only; it does not expose the reverse FolderMotaStore capture operations.

    Device CLI: ota folder on (attach + announce), ota folder (list), ota folder off. Build flag OTA_FOLDER_SERIAL (default stream = console Serial; override OTA_FOLDER_SERIAL_STREAM + define OTA_FOLDER_SERIAL_BEGIN for a dedicated UART). ESP32 WiFi companions and FULL ESP32 roles run a WiFiServer on the dedicated seeder port (OTA_SEEDER_TCP_PORT, default 5001) while WiFi is usable. On a companion it is separate from the app port (TCP_PORT, default 5000); on infrastructure roles it is separate from WebConfig and browser OTA on port 80. The node auto-attaches the source when a seeder client connects and detaches when it closes (no ota folder on needed over TCP). An already-active serial folder causes a TCP client to be rejected instead of silently replacing it. Verified on hardware: a RAK4631 relays a host folder to a Heltec V3 over one USB cable, and a host feeds a Heltec V3 over WiFi (:5001) while the companion serves a phone on :5000 - every block merkle-checked.

    The attach reply and bare ota folder report host=advertised/offered. The registry is RAM-bounded (OTA_MAX_SERVE, with the node's own firmware consuming one slot), so a host may correctly index more valid files than this particular firmware can advertise. Omitted entries are now reported instead of silently disappearing. Operators should split a large chain or use a higher-capacity/SD seeder when the two counts differ.

    Transport-agnostic by design. The request/response semantics (COUNT / DESCRIBE(idx) / READ(idx, off, len) over a folder catalog) are independent of the link. The 2-byte magic + XOR checksum + resync framing above exists for the shared USB-UART (an unframed byte stream); it is harmless over a reliable stream and the WiFi (TCP) transport reuses it as-is - both ends just treat the socket as a byte stream (on-device, SerialMotaSource runs verbatim over an Arduino Stream-compatible WiFiClient; motatool's TcpTransport mirrors its SerialTransport). The nRF52 Full Companion's BLE GATT path also reuses the exact frame and checksum. Device requests are notifications on a dedicated characteristic and host responses are ordered write-with-response fragments on a second characteristic. Keeping the same framing makes retries and corruption handling identical across USB, TCP, and GATT. The Linux reference implementation is tools/ble_mota/ble_mota_seeder.py; a phone app can implement the same transport-free catalog operations.

    "},{"location":"ota_protocol/#11-cli-surface-otaclicpp","title":"11. CLI surface (OtaCli.cpp)","text":"

    User-facing OTA data should travel via CMD_OTA_* companion binary frames; the text CLI below is debug/operator oriented and replies are snprintf-bounded into a 160-byte buffer.

    Commands take intuitive aliases (matched by the first word; see is_cmd in OtaCli.cpp) so they're easy to type and read - status/neighbors/pull/drop/applydelta are the canonical names, the aliases are the recommended user-facing forms. Output is plain-language (a user-facing guide lives at ota_user_guide.md).

    ota help | ? | h                   list the commands\nota status | st  (or bare `ota`)   plain-language: running fw, the one fetch session (state/%/id), serving, keys\nota ls | neighbors | nbrs | updates | n [page]   paged updates (queries sources; rows arrive async via OTA_HAVE)\nota get | pull | download <mid8|#index> flash [rescue] | folder [validate]\n                                      fetch by stable mid8 (preferred) or current page index\nota install | apply | applydelta   verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader\nota rescue install <base_hash16>  internal-flash nRF52 only: recover from failed app-side EndF validation\nota bootloader [status]           capable allowlisted nRF52 repeater: installed BL identity/caps + staged confirmation\nota bootloader install <MID8> <HASH16>\n                                      explicitly verify/arm one complete trusted v3 package; never automatic\nota cancel | drop | stop           drop the fetch; durably invalidate device staging, or retain a folder partial for resume\nota announce | adv                 serve self + send a beacon now\nota self | id                      print this firmware's EndF (body/image size, base_hash)\nota qspi | storage                 QSPI nRF52 only: JEDEC/SR1/stage/latched storage error (read-only)\nota folder | fold [on|off]         attach/detach an external .mota folder (host daemon) ; bare = list\nota config | cfg | set [autofetch|autoinstall|checkpoint] ...   show/set persisted policy\nota key | keys [add|rm <hex>]      trusted signer allowlist ; bare = list\nota dev ...                        bring-up helpers (stage/recv/serve/resume <MID8>/verify)\n

    For a device-backed pull, current firmware returns success only after the persistent store can no longer be reopened; flash/SD/QSPI I/O or readback failure is reported as an error even though the in-memory manager session was dropped. For a folder pull, cancellation detaches the live transfer but deliberately leaves the host .part file available for a later resume. If the shared receive engine is currently performing the MeshTower SD auto-archive capture, cancellation likewise detaches that archive transfer and retains its .part file; it does not erase the unrelated manual-install store.

    "},{"location":"ota_protocol/#12-apply-bootloader-contract","title":"12. Apply & bootloader contract","text":"

    A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned v2 application packages remain installable when local policy permits them. A v3 bootloader package is always signed and trusted. The one-shot approval marker records local consent before the bootloader may apply it.

    Bootloader testing note: always test apply with a real different image (base != target). A same-image (X->X) \"delta\" trivially reproduces the target and gives a false positive.

    "},{"location":"ota_protocol/#13-versioning-of-this-spec","title":"13. Versioning of this spec","text":"

    The fixed byte layout has two intentionally disjoint profiles: format_ver = 2 for ordinary application packages and format_ver = 3 only for the exact privileged bootloader profile in Section 4.3. A parser must reject v2+BOOTLOADER, v3 without exact FULL|SIGNED|BOOTLOADER, and every other version. The multihash hash_algo separately allows swapping the digest family without a format bump. Unknown codec_id / ota_msg_type values are ignored (a node simply will not fetch what it cannot apply).

    "},{"location":"ota_user_guide/","title":"Updating your node over the air (OTA) - user guide","text":"

    For 1.17.1.5, use the exact board/storage profile from OTAFIX 2.4.6 for nRF52 OTAFIX installations. New internal-flash hybrid receivers require its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own matching bootloader profiles. Earlier preview versions mentioned below describe compatibility/migration history, not the current recommended download. Full Companion is a MOTA source and normally updates itself over USB.

    This guide is for node operators: how to update your MeshCore device's firmware over the radio, in plain language. No cables, no programmer - your node can download a new firmware from a neighbour and install it. (For the technical wire format, see the OTA protocol spec.)

    LoRa OTA download and installation are present only in supported Keymind destination artifacts; the receiver must already be running one of those install-capable builds. Some internal-staging nRF52 targets use a lean lora_ota_no_external_sensors target, while matched external-QSPI boards can retain their normal full-sensor repeater features. Release filenames include an OTA marker, but capability must still be confirmed on the running device. A source can be an OTA-enabled infrastructure node or a source-only Full Companion backed by motatool. Intermediate repeaters do not need OTA-enabled firmware: current repeater builds transport OTA floods opaquely, subject to their normal forwarding filters, duplicate checks, and flood limits. OTA radio traffic is accepted, generated, and relayed only while tempradio is actually running on that node. Every source, receiver, and intermediate repeater must therefore have an overlapping temporary-radio window.

    The recommended temporary OTA settings use 250 kHz bandwidth, SF5, CR5, and a 120-minute window. For a North American node currently configured for 910.525 MHz, run this on every participating node:

    tempradio 910.525,250,5,5,120\n

    Use the node's current permitted regional frequency in place of 910.525 when necessary.

    The scripted updater checks the firmware version of the receiver and every sender before deciding whether RX power saving can remain active. At SF5/BW250, an all-v1.17.1.5-or-newer path can use effective RXPS level 8 with the tuple-selected 64-symbol physical preamble. SF5/BW500 can use level 8 with 128 symbols. Other fast tuples remain at the shortest viable preamble, normally 32. A mixed, older, or unknown long-preamble path is put in continuous receive for the update, then the receiver's exact saved RXPS setting is restored. This preserves the RX benefit without risking missed packets from a legacy 32-symbol sender.

    Can my node install the update? Choose a release-table artifact explicitly labelled LoRa-OTA capable, then confirm ota self and ota status expose install support; do not infer support from the filename alone. LoRa OTA firmware is available for supported ESP32 boards and nRF52 repeater targets. Every nRF52 installation also requires the OTAFIX bootloader built for that exact board; having an OTA-capable application image alone is not enough. An intermediate repeater only relays packets and needs neither an install-capable image nor OTAFIX. Check the bootloader release for an exact board match before attempting an update.

    The following nRF52 repeater families gained firmware-side LoRa OTA targets in this release. Their ordinary repeater keeps its complete declared sensor recipe; the install-capable lora_ota_no_external_sensors sibling is smaller:

    The RAK3401 RAK_3401_repeater_lora_ota_no_external_sensors image trims selected optional environmental/ranging drivers but does not disable I2C. It retains the INA219, INA226, INA260, and INA3221 voltage/current monitors, board display and RTC support, and both RAK12500 I2C and RAK12501/L76K UART GPS paths. The four INA drivers are only the retained voltage/current entries in the optional environmental-sensor table; they are not the only I2C users. The SSD1306 OLED, autodiscovered DS3231/RV3028/PCF8563/RX8130CE RTCs, and RAK12500 GPS use I2C as separate board peripherals. Install one GPS module in sensor slot A. Slot D's reset/PPS lines conflict with the RAK13302 radio's BUSY/DIO1 lines.

    The firmware-configured INA3221 address and the RAK12500 address are both 0x42, so those devices cannot share one bus at those addresses. To install both, leave the RAK12500 at 0x42, strap INA3221 A0 to SCL for 0x43, and use a firmware build with -DTELEM_INA3221_ADDRESS=0x43.

    The reduced RAK4631 profiles likewise retain the four INA monitors. The plain repeater and Serial2 bridge retain GPS: a RAK12501 can use sensor slot A or D, and a RAK12500 can use slot A or C. The runtime RS-232 bridge defaults to Serial2 so the UART RAK12501 can retain Serial1. The merged runtime image always reserves Serial1: a bounded silent probe cannot prove that a cold RAK12501 is absent, and turning the GPS setting off cannot stop a fitted module from driving the shared UART while WB_IO2/3V3_S remains powered. Use Serial2. Serial1 requires an explicit no-GPS/dedicated image. The reservation remains fail-closed even when an I2C RAK12500 is detected. The explicitly compiled RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors target omits the combined GPS provider because its bridge owns the UART used by RAK12501; consequently that legacy image does not expose the I2C RAK12500 path either, even though RAK12500 itself does not use the UART.

    Selected nRF52 repeaters with dedicated external QSPI can now stage the complete package off-chip, so their normal full-sensor repeater build can install a full image or an in-place delta. The current matched families are XIAO nRF52840 and its XIAO-module derivatives, original LilyGo T-Echo, ThinkNode M1/M6, Wio Tracker L1, SenseCAP Solar, and the dedicated RAK4631 + RAK15001 slot-C target. The list also includes a separately wired W25Q16JV on RAK19007 with either RAK4631 or RAK3401 + RAK13302. These require the corresponding exact QSPI-aware OTAFIX bootloader; see the nRF52 QSPI guide.

    The W25Q16 option uses one permanent RAK19007 wiring for either core: CLK/MISO/ MOSI tap IO-connector pins 26/27/28, flash CS uses J11 AIN1, and power uses J12 VDD (3.3 V) and GND. Add an approximately 10 kOhm CS-to-VDD pull-up and never power the flash from VBAT. The three SPI signals require underside pads or an IO-connector interposer; the easy J10/J11/J12 headers do not expose them. The flash consumes no WisBlock slot, so a supported GPS can remain in slot A.

    The ordinary full-sensor RAK_4631_repeater image remains too large for the safe internal in-place update limit. Without external flash, use RAK_4631_repeater_lora_ota_no_external_sensors; it trims selected optional environmental/ranging drivers while retaining generic I2C, battery monitoring, the four INA monitors, and target-compatible GPS. A RAK4631 fitted with RAK15001 in sensor slot C can instead use RAK_4631_repeater_rak15001_slot_c_lora_ota to retain the full sensor/GPS set and stage full images or deltas off-chip.

    For the external Winbond breakout, use RAK_4631_repeater_w25q16_lora_ota on RAK4631 or RAK_3401_repeater_rak13302_w25q16_lora_ota on RAK3401 + RAK13302. They retain the full sensor/GPS recipe and require exactly a 2 MiB EF4015 W25Q16 plus the bootloader for that same core/radio/flash combination. Install wiscore_rak4631_w25q16 for RAK4631 or wiscore_rak3401_rak13302_w25q16 for RAK3401 + RAK13302 as a one-time prerequisite. The physical wiring is shared, but the two firmware identities are deliberately different and cannot be substituted for one another.

    "},{"location":"ota_user_guide/#the-important-part-first-its-safe","title":"The important part first: it's safe","text":""},{"location":"ota_user_guide/#how-to-talk-to-your-node","title":"How to talk to your node","text":"

    Connect to your node's console - usually a USB serial terminal at 115200 baud (or whatever tool you already use to manage the node). You type ota ... commands and the node replies in plain words.

    The commands have short, friendly names (and most accept aliases, so you don't have to remember exact spelling): type ota help any time to see the list, or just ota for a status summary.

    "},{"location":"ota_user_guide/#common-tasks","title":"Common tasks","text":""},{"location":"ota_user_guide/#1-see-what-im-running-and-whether-anything-is-going-on","title":"1. See what I'm running and whether anything is going on","text":"
    ota status\n

    Shows your current firmware version, your node's update \"target\" (its hardware/role id), and whether a download is in progress.

    For a denser admin view - your firmware's content id (mid) and its body hash, the fingerprint of the set you're serving, live download progress, and the current policy - use:

    ota stats\n

    On a remote node this is admin-only (the remote command console requires the admin password) - send it from the app's repeater command screen, or the WiFi/serial OTA console.

    "},{"location":"ota_user_guide/#2-find-updates-available-near-me","title":"2. Find updates available near me","text":"
    ota ls\nota ls 2                 # page 2 when more than two updates are available\n

    Your node asks around and lists the firmware updates other nodes nearby are offering, in plain words - each with a temporary number, a stable eight-hex manifest ID, its version, whether it's a full image or a small delta, how many nodes have it, and how recently it was seen. For example:

    Updates 1/1 (2 src; refreshing):\n 1) 838B8169 v1.2.3 delta [same target] 3n 5s\n 2) BF0AB0C4 v1.2.0 full [unsupported] 1n 12s\n

    Each row shows the version, full-vs-delta, whether it fits your node, how many nodes have it, and how long ago it was seen. The fit marker:

    Run it again after a few seconds - discovery happens in the background, so the list fills in. Nothing is downloaded yet; this is just looking around. refreshing means the command has just sent asynchronous catalog queries, so run it again even when an older row is already visible. Two updates fit in each remote CLI reply; use ota ls 2, ota ls 3, and so on for later pages. Catalog rows can change while replies arrive, so use the displayed manifest ID for scripts and important operations rather than a numeric position. (ota neighbors / ota updates also work.)

    "},{"location":"ota_user_guide/#3-download-an-update","title":"3. Download an update","text":"

    Pick one from the list by its stable manifest ID (a number also works for interactive use), and say where to put it:

    ota pull 838B8169 flash            # stage it in this node's flash, to install here\nota pull 838B8169 folder           # capture it onto a connected motatool folder as <id>.mota\nota pull 838B8169 folder validate  # warm-start capture from a motatool --seed build (much faster; below)\n

    The destination is required - ota pull 838B8169 on its own just shows the choices. flash is always available (stage here, then ota install). folder appears only while a motatool serve link is attached (it shows the link, e.g. folder: tcp 192.168.4.5); it streams the firmware straight onto the host folder - nothing is staged on this node. That's how you grab an exact copy of another device's firmware off the mesh (to a .mota file) so you can later build a delta against firmware you don't otherwise have. (ota get is an alias.)

    validate (warm-start, advanced). Capturing a full image over the radio is slow. If you have a similar build on the computer (e.g. a fresh recompile of the same firmware), run motatool with --seed <that.mota> and add validate: the node fetches just the target's block fingerprints, keeps every block your seed already matches, and pulls over the radio only the handful that actually differ - turning a ~30-minute capture into seconds. The result is still a byte-exact, verified copy of the target.

    Where the seed comes from: it is the --seed <file> you pass to motatool serve - not a file you drop into the capture (--dir) folder, which is only the destination and starts empty. There is exactly one configured seed. When you run ... folder validate, the node asks motatool to begin the capture and motatool stamps that seed's payload into the fresh .part in the same step - so it is always the file you named, with no guessing. validate is the switch: a plain folder pull ignores any seed and fetches from scratch; re-running a validate pull re-begins fresh (it never resumes a stale partial). Nothing about the seed is trusted - every kept block is checked against the target's own fingerprints, so a mismatched or missing seed just means those blocks are fetched over the radio (correct result, only slower).

    The node fetches from one source as primary traffic, with bounded adaptive request flights. Every session probes with one manifest block, then clean flights grow 1 -> 2 -> 3 -> 4 blocks on RAK3401 OTA builds. New application packages use 2 KiB blocks; deployed 1 KiB packages remain supported. All blocks in a flight share one request packet, and the receiver stays silent until the source/relays finish returning them. A recovery halves the next flight. Retry timing follows the active SF/BW airtime, duty budget, and path length, so faster settings recover sooner without a fixed one-second request colliding with a valid multi-hop response. Mesh repeaters carry the packets only while their temporary-radio windows are active. Check progress with ota status.

    If a folder pull loses its link mid-transfer, ota status shows paused - the host keeps the partial and the pull resumes (filling only what's missing) the moment you reconnect motatool; it never falls back to flash. To stop a download you no longer want:

    ota cancel\n

    For a download staged on the node, success means current firmware also invalidated the persistent flash/SD/QSPI copy; it reports an error if that media operation cannot be verified. For a folder capture, cancel only detaches the live session and retains the host partial so it can be resumed or removed on the host deliberately. On a MeshTower SD auto-archive capture, cancel also detaches the live archive session and retains its card partial for the archive service to resume later; use ota cache off to stop new archive captures.

    "},{"location":"ota_user_guide/#4-install-a-downloaded-update","title":"4. Install a downloaded update","text":"

    Once ota status shows the download is ready to install:

    ota install\n

    The node verifies the firmware one last time, and if everything checks out it installs it and reboots into the new version. If the check fails, it tells you why and does not install. Unsigned images normally install only through this explicit command. The MeshTower V2 SD target is stricter and requires an allowlisted signature even for a manual application install, because it authorizes removable-media bytes for the bootloader. A signed image whose signer is not in the device allowlist is rejected; trusted signed images can auto-install only when that policy is enabled and the signed version is strictly newer than the running hash-valid EndF version. Manual ota install remains the deliberate equal-version/rollback override.

    After it reboots, run ota status to confirm the new version.

    "},{"location":"ota_user_guide/#updating-an-nrf52-bootloader-advanced-explicit-only","title":"Updating an nRF52 bootloader (advanced, explicit only)","text":"

    This is available on specially marked no-external-flash nRF52840 lean repeater/bridge builds, on the legacy XIAO nRF52840/Sense raw-QSPI builds, and on the exact Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors microSD build, after a one-time exact-board ABI-3 OTAFIX installation over USB/BLE DFU or SWD. It is not the normal firmware update path. Check support first:

    ota bootloader\n

    The reply must show a CRC-valid installed identity plus ABI 3 and the exact storage/boot-update capability bits for that build (0x09 for MeshTower V2 microSD, 0x0A for the shared internal store, or 0x0E for XIAO raw QSPI). A bootloader package appears as bootloader in ota ls. It is never downloaded or installed automatically, even if both OTA automation settings are enabled. Use its stable ID explicitly:

    ota pull <MID8> flash\n# wait until ota status says this bootloader download is ready\nota bootloader\nota bootloader install <MID8> <HASH16>\n

    Copy both confirmation values exactly from the second ota bootloader reply. Ordinary ota install deliberately refuses this package. The privileged command requires an exact 40 KiB candidate payload in the fixed 41,330-byte container, a valid exact embedded identity/CRC and vector table, continued boot-update support, required BLM2/SOFT continuity metadata at canonical raw-image offset 0x9FB4, a boot version that exactly matches the package and is newer than installed BLM2, and a valid signature from a key already in ota key's trusted allowlist. It preserves the running application while OTAFIX replaces itself; blup:C8 in post-reboot ota status means success. Remote rollback is refused. Any node lacking this command or those capabilities must update its bootloader locally instead.

    On an internal-flash target, the package shares the ordinary store below 0xED000 and bottom-aligns at 0xE2000; there is no separate reserved scratch bank. A valid live EndF must prove the current image ends at or below that address before any page is erased. If EndF is missing/corrupt or the app is too large, the pull is refused and local DFU/SWD is required. On the MeshTower V2 SD target, the application linker remains at 0xED000, but bootloader replacement needs temporary scratch beginning at 0xE0000. A hash-valid live EndF must therefore prove the complete current image ends by 0xE0000. A CRC-bound boot-settings bank must cover that complete image while also ending by 0xE0000. MeshCore binds both application and bootloader SD approval to purpose, exact raw geometry, and a normalized SHA-256 in a reset-retained MOTASDA2 record. Boot updates also bind the exact authenticated signed image hash in the E0000 token. OTAFIX consumes the retained record before media access, so a later card change or power cycle fails instead of authorizing different bytes. MeshCore never claims or writes raw sector 1. Both application and bootloader OTA wait until a matching BLM2 bootloader has been provisioned locally; preview.12 requires USB/BLE DFU or SWD first. Larger applications can continue to use normal application mOTA; only bootloader self-update is refused. See the nRF52 bootloader-update guide for the complete target inventory, storage layouts, and safety contract.

    "},{"location":"ota_user_guide/#5-if-something-goes-wrong","title":"5. If something goes wrong","text":"

    text ota pull <mid8> flash rescue # wait for ota status to say ready to install ota rescue install <base_hash16>

    This is not a force option. It refuses a normally valid EndF, a different package hash, hardware or target mismatch, corrupt payload, and invalid/untrusted signatures. The bootloader independently hashes the running app and rejects a wrong base before writing the app. If the physical EndF is absent or the rescue commands were not already in the running firmware, recover over USB. Release chains should put this command in their first bridge and keep it in every bridge after that. The shared-internal bootloader-update builds are intentionally excluded: without valid live EndF, they refuse every internal pull before erase because their normal application can extend through 0xED000. Recover one of those builds over USB/BLE DFU or SWD. - If an install fails, the node won't boot a broken image - it lands in recovery mode: - nRF52: it appears as a USB drive; drag a known-good firmware .uf2 for that exact board onto it to recover. - ESP32: it keeps the previous firmware in the other slot and rolls back. - When in doubt, you can always re-flash over USB the normal way.

    "},{"location":"ota_user_guide/#optional-let-it-update-automatically","title":"Optional: let it update automatically","text":"

    By default your node only discovers updates - it won't download or install on its own. If you want more automation (e.g. for a remote node you can't easily reach), you can opt in. These settings are saved.

    ota config autofetch any        # auto-DOWNLOAD any compatible update for this node (still won't install)\nota config autofetch signed     # auto-download only signed updates\nota config autofetch off        # back to manual (default)\n\nota config autoinstall trusted  # auto-INSTALL only a trusted signed version newer than the running EndF\nota config autoinstall off      # never auto-install (default)\n\nota config advert 1440          # re-advertise every N minutes while temp radio is running\nota config advert 0             # only advertise when a temp-radio window starts\n\nota config hops 3               # how far OTA travels: accept from / relay up to N repeater hops (default 3)\nota config hops 0               # only exchange OTA with directly-connected nodes (never relay)\n\nota config                      # show the current settings\n

    These policies also govern automatic adoption of an interrupted staged download after reboot. off leaves it untouched, signed requires the stored manifest's signed flag, and automatic resume requires the stored target to match this node and its version to be newer than the running valid EndF. Reissuing an explicit ota pull <MID8> remains the deliberate override for an older or unsigned partial. For bring-up/debugging, ota dev resume <MID8> performs the same explicit MID-bound re-adoption without starting a new network fetch. After reboot it requires the MID; bare ota dev resume is accepted only while an active/requested session MID still exists, and malformed or missing identifiers are rejected.

    Recommended for most people: leave both off and update by hand. Use autoinstall trusted only once you've added the signer's key (next section) and you trust them to push updates unattended. Automatic admission and final apply both reject zero, equal, or older signed versions; a dishonest catalog version cannot bypass the manifest check. Use manual ota pull plus ota install for an intentional rollback.

    The MeshTower V2 SD OTA target has a separate, default-on archive policy. It saves all mOTAs it sees to the SD card so the repeater can seed them later; this does not install them and does not change the install-oriented autofetch default above. Use ota cache for status and ota cache off or ota config cache off to stop new archive captures. Already cached files remain available to peers. Manual ota pull commands take priority and an interrupted archive capture resumes later. See Preload many mOTAs from a computer for the required /mota/<manifest-id>.mota filenames and the complete TempRadio seeding workflow.

    "},{"location":"ota_user_guide/#optional-only-trust-updates-from-specific-people","title":"Optional: only trust updates from specific people","text":"

    If you'll use auto-install, tell your node which signing keys to trust. The firmware author shares their public key (a hex string); you add it:

    ota key add <public-key-hex>    # trust this signer\nota key list                    # show trusted signers\nota key rm <public-key-hex>     # stop trusting one\n

    Only strictly newer updates signed by a trusted key are eligible for auto-install. Manual ota install permits an unsigned package after all integrity, hardware, base, and bootloader checks pass, except on the MeshTower V2 removable-SD path where all application installs must be signed and allowlisted. A signed package whose signer is not in the device allowlist is rejected rather than silently treated as unsigned.

    "},{"location":"ota_user_guide/#sharing-updates-with-others-advanced","title":"Sharing updates with others (advanced)","text":""},{"location":"ota_user_guide/#relay-a-folder-of-firmware-from-a-computer","title":"Relay a folder of firmware from a computer","text":"

    If your node is connected to a computer (e.g. a gateway on a Raspberry Pi), it can hand out a whole folder of firmware files to the mesh - without storing them itself. Useful for seeding a new release to a remote area.

    1. Put the firmware files (.mota files - see below) in a folder on the computer.
    2. Install the helper tool once - the standalone motatool CLI (https://github.com/vk496/motatool) - then point it at your node and the folder - over the node's USB serial, over WiFi if it is an ESP32 WiFi companion or FULL ESP32 node, or over encrypted Bluetooth if it is an nRF52 Full Companion: git clone https://github.com/vk496/motatool && cargo install --path ./motatool # over USB serial: motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v # ...or over WiFi: the seeder is on dedicated TCP port 5001: motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v # ...or over paired BLE to an nRF52 Full Companion (protocol v14): python3 tools/ble_mota/ble_mota_seeder.py \\ --device MeshCore-MyCompanion --dir ./my_firmware/ It answers the node's requests; your node then advertises those updates to neighbours, who can ota get them like any other. (A WiFi node prints its IP + seeder port to the serial log on connect. Details: https://github.com/vk496/motatool.)

    Check the device's attach reply or run ota folder: host=X/Y means the firmware is advertising X of the Y valid entries reported by the host. Serve registries are deliberately RAM-bounded on smaller builds, and the node's own firmware also consumes a slot. If X < Y, split the chain across seeders/folders or use a higher-capacity seeder; motatool saying that every file is valid does not mean every file fit on-device.

    To stop, just stop the daemon - over WiFi the node auto-detaches when the connection closes; over USB you can also run ota folder off on the node. ota folder on its own lists what your node is offering. On a FULL repeater or room server, run start webconfig first if WiFi is not already active. Other FULL roles with browser OTA support can use the MeshCore-OTA access point from start ota and connect to 192.168.4.1:5001. Every LoRa participant still needs an overlapping tempradio window.

    "},{"location":"ota_user_guide/#everyone-helps-share","title":"Everyone helps share","text":"

    You don't have to be a gateway to help. Once any node finishes downloading an update, it automatically offers it to its neighbours too. So a new firmware spreads outward node-to-node, instead of everyone hammering the one node that had it first. Discovery remains background traffic; an actual transfer is primary traffic for the duration of its TempRadio maintenance window.

    "},{"location":"ota_user_guide/#where-firmware-files-come-from","title":"Where firmware files come from","text":"

    OTA distributes .mota files - a packaged, verifiable firmware image (full image or a small \"delta\" that only contains what changed). You get them by:

    "},{"location":"ota_user_guide/#quick-reference","title":"Quick reference","text":"I want to... Command List all commands ota help See my firmware + any download ota status (or just ota) Admin: ids/hashes + serving + policy ota stats (admin-only remotely) Find updates nearby ota ls Download a listed update for installation ota get <mid8> flash Cancel a download ota cancel Install a finished download ota install Recover app-side no EndF on a legacy app-only internal nRF52 ota rescue install <base_hash16> Turn on auto-download ota config autofetch any Turn on auto-install (trusted only) ota config autoinstall trusted Trust a signer ota key add <hex> Relay a folder (gateway) ota folder on + the seeder daemon List what I'm offering ota folder

    (Older names still work too: neighbors/updates = ls, pull = get, applydelta/apply = install, drop/stop = cancel.)

    "},{"location":"ota_user_guide/#a-few-terms","title":"A few terms","text":"

    For the full technical details (the file format and the radio protocol), see the OTA protocol spec.

    "},{"location":"packet_format/","title":"Packet Format","text":"

    This document describes the MeshCore packet format.

    "},{"location":"packet_format/#version-1-packet-format","title":"Version 1 Packet Format","text":"

    This is the current version-1 MeshCore wire packet structure. Older firmware may support only a subset of the path encodings and payload types described here.

    [header][transport_codes(optional)][path_length][path][payload]\n
    "},{"location":"packet_format/#packet-format_1","title":"Packet Format","text":"Field Size (bytes) Description header 1 Contains routing type, payload type, and payload version transport_codes 4 (optional) 2x 16-bit transport codes (if ROUTE_TYPE_TRANSPORT_*) path_length 1 Encodes path hash size in bits 6-7 and hop count in bits 0-5 path up to 64 (MAX_PATH_SIZE) Stores hop_count * hash_size bytes of path data if applicable payload up to 184 (MAX_PACKET_PAYLOAD) Data for the provided Payload Type

    NOTE: see the Payloads documentation for more information about the content of specific payload types.

    "},{"location":"packet_format/#header-format","title":"Header Format","text":"

    Bit 0 means the lowest bit (1s place)

    Bits Mask Field Description 0-1 0x03 Route Type Flood, Direct, etc 2-5 0x3C Payload Type Request, Response, ACK, etc 6-7 0xC0 Payload Version Versioning of the payload format"},{"location":"packet_format/#route-types","title":"Route Types","text":"Value Name Description 0x00 ROUTE_TYPE_TRANSPORT_FLOOD Flood Routing + Transport Codes 0x01 ROUTE_TYPE_FLOOD Flood Routing 0x02 ROUTE_TYPE_DIRECT Direct Routing 0x03 ROUTE_TYPE_TRANSPORT_DIRECT Direct Routing + Transport Codes"},{"location":"packet_format/#path-length-encoding","title":"Path Length Encoding","text":"

    path_length is not a raw byte count. It packs both hash size and hop count:

    Bits Field Meaning 0-5 Hop Count Number of path hashes (0-63) 6-7 Hash Size Code Stored as hash_size - 1

    Hash size codes:

    Bits 6-7 Hash Size Notes 0b00 1 byte Legacy / default mode 0b01 2 bytes Supported in current firmware 0b10 3 bytes Supported in current firmware 0b11 4 bytes Reserved / invalid

    Examples:

    "},{"location":"packet_format/#payload-types","title":"Payload Types","text":"Value Name Description 0x00 PAYLOAD_TYPE_REQ Request (destination/source hashes + MAC) 0x01 PAYLOAD_TYPE_RESPONSE Response to REQ or ANON_REQ 0x02 PAYLOAD_TYPE_TXT_MSG Plain text message 0x03 PAYLOAD_TYPE_ACK Acknowledgment 0x04 PAYLOAD_TYPE_ADVERT Node advertisement 0x05 PAYLOAD_TYPE_GRP_TXT Group text message (unverified) 0x06 PAYLOAD_TYPE_GRP_DATA Group datagram (unverified) 0x07 PAYLOAD_TYPE_ANON_REQ Anonymous request 0x08 PAYLOAD_TYPE_PATH Returned path 0x09 PAYLOAD_TYPE_TRACE Trace a path, collecting SNR for each hop 0x0A PAYLOAD_TYPE_MULTIPART Packet is part of a sequence of packets 0x0B PAYLOAD_TYPE_CONTROL Control packet data (unencrypted) 0x0C PAYLOAD_TYPE_OTA OTA-over-LoRa firmware distribution 0x0D reserved reserved 0x0E reserved reserved 0x0F PAYLOAD_TYPE_RAW_CUSTOM Custom packet (raw bytes, custom encryption)"},{"location":"packet_format/#payload-versions","title":"Payload Versions","text":"Value Version Description 0x00 1 1-byte src/dest hashes, 2-byte MAC 0x01 2 Future version (e.g., 2-byte hashes, 4-byte MAC) 0x02 3 Future version 0x03 4 Future version"},{"location":"payloads/","title":"Payload Format","text":"

    Inside each MeshCore Packet is a payload, identified by the payload type in the packet header. The types of payloads are:

    This document describes the shared payload envelopes implemented by the core. Application-specific request, response, control, and custom bodies can add their own formats.

    NOTE: all 16 and 32-bit integer fields are Little Endian.

    "},{"location":"payloads/#important-concepts","title":"Important concepts:","text":""},{"location":"payloads/#node-advertisement","title":"Node advertisement","text":"

    This kind of payload notifies receivers that a node exists, and gives information about the node

    Field Size (bytes) Description public key 32 Ed25519 public key of the node timestamp 4 unix timestamp of advertisement signature 64 Ed25519 signature of public key, timestamp, and app data appdata rest of payload optional, see below

    Appdata

    Field Size (bytes) Description flags 1 specifies which of the fields are present, see below latitude 4 (optional) decimal latitude multiplied by 1000000, integer longitude 4 (optional) decimal longitude multiplied by 1000000, integer feature 1 2 (optional) reserved for future use feature 2 2 (optional) reserved for future use name rest of appdata name of the node

    Appdata Flags

    Value Name Description 0x01 is chat node advert is for a chat node 0x02 is repeater advert is for a repeater 0x03 is room server advert is for a room server 0x04 is sensor advert is for a sensor server 0x10 has location appdata contains lat/long information 0x20 has feature 1 Reserved for future use. 0x40 has feature 2 Reserved for future use. 0x80 has name appdata contains a node name"},{"location":"payloads/#acknowledgement","title":"Acknowledgement","text":"

    An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the \"extra\" payload (see Returned Path) instead of as a separate acknowledgement packet. Current CLI_DATA commands do not cause acknowledgement responses, neither discrete nor extra; their text reply is the application-level result. Repeaters still ACK the legacy plain-text form before processing it.

    Repeater remote CLI keeps one volatile copy of the most recently completed reply, keyed by the authenticated sender, request timestamp, and command text. Repeating that same logical request re-sends the text reply without executing the command again. A retry must therefore preserve the original timestamp and command text. The cache is cleared by reboot and replaced by the next completed remote command; commands that intentionally produce no text reply remain silent.

    Field Size (bytes) Description checksum 4 CRC checksum of message timestamp, text, and sender pubkey"},{"location":"payloads/#returned-path-request-response-and-plain-text-message","title":"Returned path, request, response, and plain text message","text":"

    Returned path, request, response, and plain text messages are all formatted in the same way. See the subsection for more details about the ciphertext's associated plaintext representation.

    Field Size (bytes) Description destination hash 1 first byte of destination node public key source hash 1 first byte of source node public key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see subsections below for details"},{"location":"payloads/#returned-path","title":"Returned path","text":"

    Returned path messages provide a description of the route a packet took from the original author. Receivers will send returned path messages to the author of the original message.

    Field Size (bytes) Description path descriptor 1 low 6 bits are the hash count; high 2 bits encode hash size minus one path count \u00d7 size encoded node-hash prefixes, each 1-3 bytes; the four-byte code is reserved extra type 1 low nibble is the bundled payload type, such as acknowledgment or response; high nibble is reserved extra rest of data extra, bundled payload content, follows same format as main content defined by this document"},{"location":"payloads/#request","title":"Request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) request data rest of payload application-defined request payload body

    For the common chat/server helpers in BaseChatMesh, the current request type values are:

    Value Name Description 0x01 get stats get stats of repeater or room server 0x02 keepalive keep-alive request used for maintained connections"},{"location":"payloads/#get-stats","title":"Get stats","text":"

    Gets information about the node, possibly including the following:

    "},{"location":"payloads/#get-telemetry-data","title":"Get telemetry data","text":"

    Not defined in BaseChatMesh. Sensor- and application-specific request payloads may be implemented by higher-level firmware.

    "},{"location":"payloads/#get-telemetry","title":"Get Telemetry","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#get-minmaxave-sensor-nodes","title":"Get Min/Max/Ave (Sensor nodes)","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#get-access-list","title":"Get Access List","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#get-neighbors","title":"Get Neighbors","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#get-owner-info","title":"Get Owner Info","text":"

    Not defined in BaseChatMesh.

    "},{"location":"payloads/#response","title":"Response","text":"Field Size (bytes) Description content rest of payload application-defined response body

    Response contents are opaque application data. There is no single generic response envelope beyond the encrypted payload wrapper shown above.

    "},{"location":"payloads/#plain-text-message","title":"Plain text message","text":"Field Size (bytes) Description timestamp 4 send time (unix timestamp) txt_type + attempt 1 upper six bits are txt_type (see below), lower two bits are attempt number (0..3) message rest of payload the message content, see next table

    txt_type

    Value Description Message content 0x00 plain text message the plain text of the message 0x01 CLI data CLI command OR reply text 0x02 signed plain text message first four bytes is sender pubkey prefix, followed by plain text message 0x03 CLI command (since v1.18+) CLI command text (explicit)

    For a room post, companion firmware uses its own monotonic clock for the on-air timestamp and preserves that timestamp across application retries. Room servers track post timestamps separately from login, request, and CLI traffic. They also remember recent accepted posts by sender, timestamp, and text: an exact retry is ACKed again without storing a duplicate, while stale or same-timestamp mismatches are rejected.

    "},{"location":"payloads/#anonymous-request","title":"Anonymous request","text":"Field Size (bytes) Description destination hash 1 first byte of destination node public key public key 32 sender's Ed25519 public key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see below for details"},{"location":"payloads/#room-server-login","title":"Room server login","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) sync timestamp 4 sender's \"sync messages SINCE x\" timestamp password rest of message password for room"},{"location":"payloads/#repeatersensor-login","title":"Repeater/Sensor login","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) password rest of message password for repeater/sensor"},{"location":"payloads/#repeater-regions-request","title":"Repeater - Regions request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x01 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#repeater-owner-info-request","title":"Repeater - Owner info request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x02 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#repeater-clock-and-status-request","title":"Repeater - Clock and status request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x03 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#group-text-message","title":"Group text message","text":"Field Size (bytes) Description channel hash 1 first byte of SHA256 of channel's shared key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see below for details

    The plaintext contained in the ciphertext matches the format described in plain text message. Specifically, it consists of a four byte timestamp, a flags byte, and the message. The flags byte will generally be 0x00 because it is a \"plain text message\". The message will be of the form <sender name>: <message body> (eg., user123: I'm on my way).

    The sender name is unverified message text. Group messages contain no sender signature, so any channel-key holder can choose any sender name.

    "},{"location":"payloads/#group-datagram","title":"Group datagram","text":"Field Size (bytes) Description channel hash 1 first byte of SHA256 of channel's shared key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted data, see below for details

    The data contained in the ciphertext uses the format below:

    Field Size (bytes) Description data type 2 Identifier for type of data. (See number_allocations.md) data len 1 byte length of data data rest of payload (depends on data type)"},{"location":"payloads/#control-data","title":"Control data","text":"Field Size (bytes) Description flags 1 upper 4 bits is sub_type data rest of payload typically unencrypted data"},{"location":"payloads/#discover_req-sub_type","title":"DISCOVER_REQ (sub_type)","text":"Field Size (bytes) Description flags 1 0x8 (upper 4 bits), prefix_only (lowest bit) type_filter 1 bit for each ADV_TYPE_* tag 4 randomly generate by sender since 4 (optional) epoch timestamp (0 by default)"},{"location":"payloads/#discover_resp-sub_type","title":"DISCOVER_RESP (sub_type)","text":"Field Size (bytes) Description flags 1 0x9 (upper 4 bits), node_type (lower 4) snr 1 signed, SNR*4 tag 4 reflected back from DISCOVER_REQ pubkey 8 or 32 node's ID (or prefix)"},{"location":"payloads/#trace","title":"Trace","text":"

    Trace packets use direct routing. Their normal direct-path header is empty; instead, the intended route follows a fixed nine-byte trace header in the payload. Each forwarding node appends its received SNR multiplied by four to the packet's route-accumulator field.

    Field Size (bytes) Description tag 4 Sender-selected trace identifier. auth code 4 Application-defined authentication/correlation value. flags 1 Low two bits encode the route hash size. Current senders use the legacy 1 << value interpretation (1, 2, 4, or 8 bytes); receivers also accept the packed 1-4-byte interpretation where it is unambiguous. route rest Concatenated node-hash prefixes for the requested direct route.

    At the destination, the application receives the tag, auth code, flags, accumulated SNR bytes, and original route bytes. Trace therefore differs from a normal direct packet whose path is carried entirely in the packet route field.

    "},{"location":"payloads/#multipart","title":"Multipart","text":"

    The first payload byte identifies the inner payload and how many packets remain:

    Field Size (bytes) Description remaining + type 1 Upper nibble is the remaining-packet count; lower nibble is the inner payload type. inner data rest Data for the inner payload type.

    The core currently creates and consumes multipart acknowledgments. For that form, the low nibble is 0x03 and the next four bytes are the acknowledgment CRC. Other multipart inner types are reserved for application or future use.

    "},{"location":"payloads/#ota","title":"OTA","text":"

    An OTA payload contains one OTA protocol message and is normally sent by flood. Its message types, integrity fields, and transfer state are defined in the OTA-over-LoRa protocol. Builds without OTA support can still relay an opaque OTA packet when their routing policy permits it.

    "},{"location":"payloads/#custom-packet","title":"Custom packet","text":"

    Custom packets have no defined format.

    "},{"location":"qr_codes/","title":"QR Codes","text":"

    This document provides an overview of QR Code formats that can be used for sharing MeshCore channels and contacts. The formats described below are supported by the MeshCore mobile app.

    "},{"location":"qr_codes/#add-channel","title":"Add Channel","text":"

    Example URL:

    meshcore://channel/add?name=Public&secret=8b3387e9c5cdea6ac9e5edbaa115cd72\n

    Parameters:

    "},{"location":"qr_codes/#add-contact","title":"Add Contact","text":"

    Example URL:

    meshcore://contact/add?name=Example+Contact&public_key=9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1&type=1\n

    Parameters:

    "},{"location":"radio_receive_calibration/","title":"Radio receive calibration and recovery","text":"

    Noise calibration collects 64 idle RSSI samples at least 50 ms apart. A quiet block takes about 3.2 seconds. Both continuous RX and temporary RXPS calibration windows allow up to 30 seconds; a partial block times out without publishing. Periodic requests coalesce while a block is active. RXPS resumes after a complete block, including a held block, or at timeout. Receive and transmit activity are never interrupted just to finish calibration.

    The block estimate is the 12.5th percentile: the average of the eighth and ninth sorted samples, retaining fractional dBm. A published update uses 25% of the previous floor and 75% of the new estimate, with a -120 dBm lower bound. Initial calibration and explicit gain/tuning changes seed a new baseline. The old published value remains available while collecting. An AGC reset drops partial samples while retaining a valid baseline for channel checks.

    An estimate more than 15 dB above the established floor is held twice. The third consecutive complete high block is accepted, allowing recovery after a permanent ambient rise. A normal or quieter complete block clears the hold. Partial blocks do not advance it. There is no admission threshold based on the old floor, so an old low baseline cannot reject every sample from a higher floor.

    Hardware captures with sustained different-SF/BW traffic showed the median and lower quartile following the interferer. The 12.5th percentile retained the quiet background in those captures. It needs at least nine background samples out of 64 to avoid higher-valued traffic entirely. Nearly continuous interference or enough low outliers can still mislead it. The rise hold delays large changes but cannot identify their source.

    RSSI sampling waits 20 ms after every receive re-arm, including CAD and TX completion, to avoid unsettled frontend readings. The ten-second trial window often failed to complete during heavy traffic with that settling guard; the 30-second cap allows more idle opportunities. Partial blocks still time out without publishing. Longer collection spends more time in continuous RX when RXPS is enabled. A quiet minimum is about 3.2 seconds; real completion time also depends on packet activity and main-loop scheduling.

    On radios with a mode probe, each eligible RSSI sample first checks that the chip has not silently left RX. Confirmed standby/non-RX discards the partial block without changing the published floor. Rejected attempts are also spaced 50 ms apart. BUSY and packet ownership block sampling; families without a mode probe retain their existing RSSI path.

    CAD has a bounded wait, stops RX duty cycling before scanning, and re-arms the configured receive mode on success, busy detection, or error. A completed or currently receiving packet owns the radio and blocks CAD. A packet interrupt arriving during re-arm is retained. Failed re-arm and a newly arrived packet both defer transmission.

    SX126x wrappers additionally run a recovery watchdog at most once per 10 seconds while continuous RX is expected. RXPS sleep, busy SPI, pending packets, and invalid status reads do not count as non-RX evidence. Two confirmed non-RX observations request soft recovery; a third uses the existing hard recovery path where supported. An observed healthy RX state clears the sequence. Recovery preserves tuning, TX power, receive gain, and the operator's power-saving intent. The existing radio liveness and RXPS watchdogs remain active for other failures.

    The status read uses the two-byte GetStatus transaction. The pinned RadioLib getStatus() helper requests zero copied data bytes, so its return value is not usable for this mode check. The dedicated read restores the normal SPI command layout afterward and does not wake a duty-cycling radio to inspect its mode.

    Validation lives in test_noise_floor_estimator, test_rx_power_saving, test_radio_receive_contract.py, and test_sx126x_receive_mode.py.

    "},{"location":"rak3401_mota_chain/","title":"RAK3401 1W repeater compact LoRa update chain","text":"

    Status: published prerelease for controlled, recoverable lab use. On 29-Aug-2026 steps 1-9 completed on the target RAK3401 and are byte-for-byte identical to the corresponding transitions in the physically passed fd98bc90 chain. The replacement step 10 installs the current 3f6eddd5 endpoint. That replacement package and the hardened current runner have passed offline reconstruction plus the exact deployed bootloader simulator, but have not had a new physical end-to-end run. Use the lab gate and keep local DFU/SWD recovery available. Multi-hop and alternate-bandwidth estimates remain planning data, not physical qualification.

    The prerelease is rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.1.6-3f6eddd5. Use this asset:

    RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip\n

    --verify-only remains the safe default. Live preflight and installation of this prerelease require --accept-test-candidate. That switch bypasses only the prerelease-status gate; it does not bypass the archive, identity, bootloader, route, watchdog, reachability, or post-boot checks.

    "},{"location":"rak3401_mota_chain/#exact-destination","title":"Exact destination","text":"

    This chain is intentionally specific to one firmware lineage and hardware target:

    The exact starting application is the lora-ota-v1.16.07-halo-keymind-cascade-dev-3a6af3bf RAK3401 asset. Its outer ZIP SHA-256 is 58ead2ea18efafda186bc9d983fe08cdf65f69b0e5a3a1c72c18fe35948a254c; its 566,912-byte firmware.bin SHA-256 is 4301fc63ebd661c70f9bc40e7eca44d3cb8a358cb3c0075e700d9676a57478cb. The endpoint build ZIP SHA-256 is a9fca9be145a96feb650b57c43b5ed173d74d2f3194da12e31b1c54352635b56.

    "},{"location":"rak3401_mota_chain/#retained-and-omitted-hardware-support","title":"Retained and omitted hardware support","text":"

    Despite the legacy no_external_sensors target name, the endpoint retains the RAK12501 GPS provider and the common INA3221, INA219, INA226, and INA260 I2C voltage/current monitors. Install RAK12501 in sensor slot A. Do not use slot D with the RAK13302 1 W radio because the GPS reset/PPS signals overlap the radio's BUSY/DIO1 wiring.

    The reduced profile omits optional external environmental and ranging devices: AHTX0, BME280, BMP280, SHTC3, SHT4x, LPS22HB, MLX90614, VL53L0X, BME680, BMP085, RAK12035, and BME680 BSEC. Board radio, display, buttons, battery telemetry, GPS, and the INA monitor family remain. No additional \u201cremove I2C temperature\u201d image is needed: the optional I2C temperature/environment drivers are already in the omitted set, while removing the remaining I2C/INA support would discard desired voltage/current monitoring and create another firmware identity without helping this route.

    Do not use the chain on another target ID, hardware family, starting image, or firmware body hash. The runner checks all four.

    "},{"location":"rak3401_mota_chain/#why-the-deployed-otafix24-and-old-staging-ceiling-work","title":"Why the deployed OTAFIX2.4 and old staging ceiling work","text":"

    The deployed bootloader is not changed and no package uses the newer expanded 0xED000 ceiling. Each package remains bottom-aligned below the old bootloader's 0xD4000 scan ceiling. Its detools workspace is page-aligned and must satisfy:

    0x26000 + inplace_memory <= staged_package_start < 0xD4000\n

    The old bootloader already reads and validates that encoded detools geometry before its first destructive write. The previous application receiver was the limiting part: it reserved a fixed 0x98000 workspace before accepting a download. Step 1 therefore remains byte-for-byte identical to the physically passed 89,844-byte package. Step 2 is a 46,364-byte fixed-workspace package that installs the compatibility receiver. Later receivers stage above the real EndF image extent and let the bootloader enforce each package's selected workspace.

    Every package in this candidate is an ordinary application container using format ABI 2 and in-place codec 2. The historical bridge applications scan the bootloader capability marker byte-by-byte, so they recognize the released 0.9.2-OTAFIX2.4 marker at absolute address 0xFCD16. That immutable marker advertises ABI 2, codec mask 0x0004, and internal storage profile 0. The 3f6eddd5 endpoint retains the matching legacy application-update fallback. Its separately compiled internal bootloader-update feature remains unavailable because privileged self-update requires a current ABI 3 marker with the exact storage profile. Installing this chain therefore does not replace the bootloader, and the final application can still accept a future valid ABI-2/codec-2 application update.

    The exact installed bootloader is the 0.9.2-OTAFIX2.4 RAK4631 asset. Its outer ZIP SHA-256 is 5e29e7a8982cc2ed5f8868435556eb09387250fe5b9a7e8443bac325247dfed1; its 191,040-byte sd_bl.bin SHA-256 is a97f834388db0f1da6c29f6d8f458ff6d3e6a26034709044566384838919bb23. The exact simulator was compiled from release commit d73de8372e89b8ef352747c8bc7a1aaeab80fbfe; its executable SHA-256 is 9dacf24b1023fe2f4c620419417649ccc9538dd4a611b47bc19b7fedb97ceba6.

    The schema-2 exhaustive search checked 1,799 candidate geometries and found 12 feasible forward edges in the declared exact 12-node inventory. It found one shortest ten-package route and selected the smallest total container bytes as the secondary objective. This minimum applies only to that declared inventory and geometry table, not every conceivable new bridge image. The selected route is:

    Step From To Workspace Package Margin 1 1.16.7.0 1.16.7.9 0x98000 89,844 0 2 1.16.7.9 1.16.7.10 0x98000 46,364 40,960 3 1.16.7.10 1.16.8.7 0x91000 106,029 12,288 4 1.16.8.7 1.16.9.105 0x89000 149,927 0 5 1.16.9.105 1.16.9.110 0x8E000 111,312 16,384 6 1.16.9.110 1.16.9.113 0x8F000 120,624 4,096 7 1.16.9.113 1.16.9.117 0x8B000 139,637 0 8 1.16.9.117 1.16.10.0 0x7E000 174,202 20,480 9 1.16.10.0 1.17.1.3 0x6B000 271,569 0 10 1.17.1.3 1.17.1.6 0x76000 197,825 28,672

    Total mOTA transfer data is 1,407,333 bytes. ROUTE.json, CHAIN.csv, and validation-results.json in the bundle pin the exact geometry and image hash for every transition.

    Steps 1-9 are byte-for-byte identical to the transitions that passed on the physical RAK3401. The replacement step 10, from the physically reached FE65A6135A1E7B3F v1.17.1.3 body to 9BD7CF682EE065AE, has offline qualification only. All ten packages passed independent container checks, zero-filled and erased-workspace reconstruction, and the exact deployed 0.9.2-OTAFIX2.4 simulator. Those results prove the package bytes and apply geometry; they are not a physical claim for the replacement step 10 or the hardened runner.

    The 171-byte transport DEFLATE support lands only in the final 3f6eddd5 application. The starting receiver and every receiver that accepts steps 1-10 lack that inflate path, so all ten legacy-chain transfers remain raw and the 1,407,333-byte total above is not automatically reduced over LoRa. A later update can negotiate transport compression after this endpoint is running; the application inflates before staging, and the immutable bootloader still applies an ordinary ABI-2/codec-2 container.

    "},{"location":"rak3401_mota_chain/#host-requirements","title":"Host requirements","text":"

    Install Python 3.9 or newer, meshcli 1.6.0 or newer, the current motatool, and an OTA-enabled Full Companion or repeater that can seed mOTA files.

    "},{"location":"rak3401_mota_chain/#verify-offline","title":"Verify offline","text":"

    No password or device is needed:

    python3 tools/lora_ota/rak3401_mota_chain.py \\\n  --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip \\\n  --work-dir ./rak3401-mota-chain-work \\\n  --motatool /path/to/motatool \\\n  --verify-only\n

    When given the ZIP, the runner checks its pinned outer hash and binds the extraction cache to that exact archive. An explicitly supplied extracted root starts at the pinned inner checksum list. In either case it checks complete inner coverage, all ten manifests, continuity, image anchors, both recovery images, and every container with motatool. Passing this command means offline and simulator qualification only; it does not unlock live use.

    "},{"location":"rak3401_mota_chain/#guarded-prerelease-lab-command","title":"Guarded prerelease lab command","text":"

    These direct-link arguments match the physical transition setup, but the current package replaces step 10 and the current host runner contains later cleanup and recovery fixes. Neither has been cleanly rerun through the whole chain. Keep local USB/SWD recovery available. The candidate switch bypasses only the prerelease-status block; all identity, checksum, bootloader, route, watchdog, reachability, and post-boot gates still run.

    Restore the test start locally with recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.uf2, then run:

    export MESHCORE_ADMIN_PASSWORD='password'\n\npython3 tools/lora_ota/rak3401_mota_chain.py \\\n  --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip \\\n  --work-dir ./rak3401-mota-chain-work \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp 192.168.1.51:5001 \\\n  --source-cli-tcp 192.168.1.51:5002 \\\n  --source-shares-controller \\\n  --target-key 63d8df63 \\\n  --temp-radio 909.950,500,5,5,120 \\\n  --ota-hops 0 \\\n  --legacy-full-airtime \\\n  --motatool /path/to/motatool \\\n  --accept-test-candidate \\\n  --yes\n

    Keep the work directory. Rerunning the same command resumes only when the live EndF body hash matches an exact chain node. Never manually skip a package. --legacy-full-airtime temporarily sets the destination airtime factor to zero and restores it at the endpoint. Use that option only where the selected frequency and local duty-cycle rules permit a full transmit budget; omit it otherwise.

    "},{"location":"rak3401_mota_chain/#superseded-b40d2e6c-prerelease","title":"Superseded b40d2e6c prerelease","text":"

    The immediately preceding prerelease remains pinned for offline provenance:

    Its first nine packages are byte-for-byte identical to the current release and the physical fd98bc90 evidence. Its different step 10 had offline qualification only. The runner recognizes this exact archive separately and keeps live use gated; it is not interchangeable with the current 3f6eddd5 asset.

    "},{"location":"rak3401_mota_chain/#legacy-fd98bc90-physical-and-swd-qualification","title":"Legacy fd98bc90 physical and SWD qualification","text":"

    The earlier local candidate remains the exact physical evidence source for this route:

    RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip\n

    All ten exact fd98bc90 transitions completed on the RAK3401 with its deployed 0.9.2-OTAFIX2.4 bootloader and a Heltec V4 source at 909.950 MHz / 500 kHz / SF5 / CR5. Every intermediate EndF hash matched. Independent SWD readback then matched the endpoint application, original bootloader, SoftDevice/MBR, and UICR byte-for-byte. Steps 1-9 are also the exact first nine packages in the b40d2e6c and 3f6eddd5 prereleases. All three candidates have different step 10 packages or endpoints, so the physical result and step-10 timing must not be attributed to either later prerelease.

    "},{"location":"rak3401_mota_chain/#measured-direct-link-timing-for-fd98bc90","title":"Measured direct-link timing for fd98bc90","text":"

    The first ready to install transfer ages were:

    Step Blocks Transfer 1 88 598 s 2 45 42 s 3 103 81 s 4 146 105 s 5 109 76 s 6 118 94 s 7 136 102 s 8 170 127 s 9 264 187 s 10 169 193 s

    The physically observed bulk-transfer total for those exact legacy packages is 1,605 seconds (26m45s). The log came from the pre-cleanup-fix runner revision. Step 1 was measured before the host began forcing source RXPS off and is therefore a conservative outlier. A new complete run with that fix is projected at roughly 18\u201319 minutes of bulk transfer, but that projection is not a substitute for the measured total. Allow about 70\u201375 minutes direct at BW500 for validation, ten transfers, installs/reboots, retained-store checks, and final restoration.

    The legacy step 10's longer per-block time was not a V4 TempRadio delay: the source RXPS readback was off. Its v1.17.1.3 requester uses non-overlapping adaptive flights and a five-second recovery floor and logged more block reloads than step 8. The V4 itself intentionally schedules its local radio handoff after 1.5 seconds so the normal-channel command reply can drain; the host waits three seconds and then checks the live TempRadio state.

    "},{"location":"rak3401_mota_chain/#live-progress-and-speed-sanity-checks","title":"Live progress and speed sanity checks","text":"

    The runner watches motatool serve -v locally, so its quarter-progress reports do not consume LoRa airtime. At BW500 direct, physically measured steps 2\u20139 averaged about 1.1\u20131.4 newly requested payload blocks per second; the legacy step 10 averaged about 0.88 blocks/s, and step 1's 0.15 blocks/s was the known pre-RXPS-fix outlier. Use those only as bench sanity ranges, not guarantees. Relays, narrower bandwidths, interference, and recovery reads lower the rate.

    Healthy output keeps advancing from [download] passive source progress x/y to source read all y payload blocks, followed by destination status ready to install y/y (100%) for the same manifest ID. Aggregate reads above the unique block count indicate recovery/retry traffic. A radio-scaled no-progress window causes one destination status check; the transfer timeout leaves the partial download staged for an exact-package resume. Stop and diagnose instead of installing if the manifest changes, progress remains flat through those checks, the final receiver count is not y/y, or the measured rate is far below the matching bandwidth/hop estimate without an understood RF cause.

    For planning only, scale the conservative measured fd98bc90 26m45s bulk baseline by the qualified adaptive-preamble airtime and by each additional relay transmission:

    Bandwidth, SF5/CR5 Direct / 0 relays 1 relay 2 relays 500 kHz 26m45s 53m30s 1h20m15s 250 kHz 46m51s 1h33m42s 2h20m33s 125 kHz 1h27m03s 2h54m07s 4h21m11s 62.5 kHz 2h54m07s 5h48m15s 8h42m23s

    These are transfer-only estimates, not measured alternate-bandwidth or relay results. Packet loss, relay contention, discovery, administrative commands, staged verification, and ten reboot cycles add time. The bandwidth factors are not simple powers of two because the adaptive wire preamble is 128 symbols at 500 kHz, 64 at 250 kHz, and 32 at 125/62.5 kHz.

    "},{"location":"rak3401_mota_chain/#manual-operation","title":"Manual operation","text":"

    The automated runner is preferred because it binds every transition to the expected MID and body hash. The manual details below document the checks used during qualification and recovery; they are not permission to skip the runner's candidate gate. Keep a written copy of each original setting and never skip a step even when a later package appears in ota ls.

    "},{"location":"rak3401_mota_chain/#1-verify-and-extract-the-asset","title":"1. Verify and extract the asset","text":"
    sha256sum RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip\nunzip RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5.zip\ncd RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.6-3f6eddd5\nsha256sum -c SHA256SUMS.txt\nfor package in motas/*.mota; do motatool verify \"$package\" || exit 1; done\n

    The outer hash must be 135783dd8777490db2422a7543f7d80e3cf03f621d5ac362da930abbc1850dc4, and the SHA-256 of the extracted SHA256SUMS.txt must be 93835ed497c579be0a2322292e7f15ab095e7c8ab7892833fe7df3130dbdaba4. Do not continue after any checksum or verification error.

    "},{"location":"rak3401_mota_chain/#2-record-and-prepare-the-destination","title":"2. Record and prepare the destination","text":"

    Before changing anything, save the complete replies from:

    ver\nota self\nget bootloader.ver\nget system.watchdog\nget radio.rxps\nget powersaving\nget rxdelay\nget af\nota config\n

    The starting ota self hash must be 71F4026CBE4B8B74, the target must be 2FA509C1, and the hardware must be RAK_3401. Disable the nRF52 system watchdog before the chain:

    set system.watchdog off\n

    The hardware watchdog cannot stop immediately. The RAK will reset once within about 60 seconds. Reconnect, wait at least 90 seconds from the command, and require get system.watchdog to report > off before continuing.

    For a direct bench link, apply these temporary transfer guardrails to the RAK after every bridge reboot:

    set powersaving off\nset radio.rxps off\nset rxdelay 0\nota config hops 0\ntempradio 909.950,500,5,5,120\n

    Where local duty-cycle rules permit it, set af 0 can also remove the legacy firmware's saved airtime wait during this bounded maintenance window. Record get af first and restore that exact value after the final step. Current firmware grants the bounded TempRadio transfer budget without overwriting the saved airtime factor.

    set powersaving off is an isolation guardrail, not the fix for the observed failure. The actual source failure was an oversized USB CDC reply; current firmware splits those reads. Some historical bridge builds may report RXPS as unsupported while already operating continuously. Verify the reported state and do not substitute a normal reboot for the one-time watchdog reset. Direct tests use hops 0: hops 1 makes the target echo each source response despite there being no intermediate relay, increasing half-duplex loss and legacy three-second retries.

    The exact candidate cannot use fast RXPS throughout because its historical bridge applications predate the v1.17.1.5 adaptive-preamble contract. Keep RXPS off for all ten steps whether using SF5/BW250 or the faster direct-bench SF5/BW500 tuple. After the final endpoint is proven, a later update whose target, controller, source, and relays are all v1.17.1.5 or newer can use the generic LoRa OTA runner and its qualified adaptive-preamble boundaries.

    Put the source on the identical TempRadio tuple. A current ASCII-first Full Companion recognizes motatool's initial ota folder on line directly:

    motatool serve \\\n  --dir ./motas \\\n  --serial /dev/ttyACM1 \\\n  --baud 115200 \\\n  -v\n

    The same command also works with older Full Companion firmware in binary mode, where the idle parser recognizes the identical preamble. Restart motatool for every step so the source emits a fresh catalog advert. Leave it running during the download and stop it with Ctrl-C only after the destination reports ready to install.

    "},{"location":"rak3401_mota_chain/#3-install-all-ten-packages-in-order","title":"3. Install all ten packages in order","text":"Step Manifest ID Before hash Expected version After hash 1 C147BCEF 71F4026CBE4B8B74 1.16.7.9 42BC53A64288E845 2 BEB578FC 42BC53A64288E845 1.16.7.10 1AD2AA8B6C478DA6 3 6D8EF814 1AD2AA8B6C478DA6 1.16.8.7 D709A25308BC1831 4 40AC4CEB D709A25308BC1831 1.16.9.105 457BEDA5E6406C81 5 F6C8890B 457BEDA5E6406C81 1.16.9.110 E76BFA205634CAB8 6 82405E89 E76BFA205634CAB8 1.16.9.113 65328FC8A1FBED2D 7 80F40DB6 65328FC8A1FBED2D 1.16.9.117 AF7532E13337FADD 8 DBF6310D AF7532E13337FADD 1.16.10.0 22002359BBDFA76E 9 C2979E08 22002359BBDFA76E 1.17.1.3 FE65A6135A1E7B3F 10 B5011420 FE65A6135A1E7B3F 1.17.1.5 1FA6AAA3C28D8BD8

    For each row, first prove that ota self exactly matches its Before hash, then inspect the manager before changing it:

    ota status\n

    Proceed to discovery only after that status is reconciled. When the manager is idle, run:

    ota ls\nota pull MANIFEST_ID flash\nota status\n

    If the first status reports this row's MID as partial, use ota pull with that same MID to resume it. If this row's MID is already ready, do not pull or cancel it; continue with the install checks below. If status reports the immediately previous row's MID as verifying staged blocks, wait through the bounded discovery window; require either that exact MID to become ready to install or the manager to become no download, and re-prove the current Before hash. Cancel only the same previous MID while it is visibly attached and ready. If it has become idle, send no cancel. Stop on any other MID, failed or incomplete state, ambiguous reply, or timeout. Never issue a blind ota cancel: it can discard a resumable or unrelated session.

    Repeat ota status at a restrained interval until it says ready to install. If the manifest is initially absent, wait for a fresh source advert, run ota ls again, or restart motatool; do not pull a different ID. Stop the seeder and install:

    get system.watchdog\nota install\n

    The watchdog reply must still be > off. Allow the current default five-minute readiness window for the USB/LoRa identity to return; automation probes every 10 seconds and returns early as soon as the exact body is visible. Then run ver, ota self, and ota help. Require the row's exact After hash, require bootloader: apply OK, and require rescue install <hash16> in the help before exposing that bridge to the next package. This rescue-help gate applies to steps 1-9; step 10 is the endpoint and is not exposed to another package in this chain. A normal-radio ota status response of no download proves only that the manager is idle; legacy internal-flash firmware does not durably erase a retained container by accepting an IDLE ota cancel. Do not claim otherwise. When the same expected manifest reports verifying staged blocks after the next TempRadio starts, wait only for the bounded discovery window and require that it become ready to install or no download; stop on a changed ID, failed/incomplete state, or timeout. Prove the exact running body before cancelling an attached completed session. OTAFIX consumes the approval word during a successful install, so a retained endpoint container is inert and a later valid pull replaces it. Reapply the transfer guardrails and TempRadio tuple, restart motatool, and continue with the next row.

    If the board boots but ota self says the EndF is invalid, stop. Only when ota status still identifies that row's complete staged package may the guarded ota rescue install BEFORE_HASH command be used. It is not a force option and must use that row's exact Before hash. If the board does not boot, recover the documented start/recovery UF2 locally over USB. Step 10 is the endpoint and has no successor package in this chain. If its EndF is invalid, use local DFU/SWD rather than attempting an unqualified LoRa package.

    "},{"location":"rak3401_mota_chain/#4-intermediate-relays-and-restoration","title":"4. Intermediate relays and restoration","text":"

    On every managed intermediate relay, save get rxdelay and get txdelay, then use the airtime-scaled collision window during the maintenance session:

    set rxdelay 0\nset txdelay 0.3\n

    Current OTA transfer packets honor that configured relay txdelay; setting 0.3 gives competing relays a small randomized, airtime-scaled forwarding window. The source's manifest-fragment gap independently follows active packet airtime and duty spacing, clamped to 100-1000 ms. These two delays solve different problems.

    After step 10 is proven, restore each saved destination and relay value exactly, including rxdelay, RXPS, CPU power saving, af, ota config hops, and relay timing. Current full-parser repeater firmware accepts normalradio and restores the saved tuple after replying on the temporary channel:

    normalradio\n

    The b40d2e6c endpoint supports that command. If a recovery build unexpectedly replies Unknown command, shorten its lease to one minute with the same tuple:

    tempradio 909.950,500,5,5,1\n

    Wait for the lease to expire, return the controller and ordinary source to their saved normal channels, and then re-enable the RAK system watchdog:

    set system.watchdog on\nget system.watchdog\n

    A Full Companion source is the exception: its local TCP console supports normalradio and the automated runner uses it before restoring the shared Binary API radio tuple.

    Final lab success requires version v1.17.1.5-halo-keymind-cascade-dev-b40d2e6c, body hash 1FA6AAA3C28D8BD8, target 2FA509C1, hardware RAK_3401, and get system.watchdog reporting > on. If a relay cannot be restored before its TempRadio lease ends, wait for it to return to the normal channel and restore its saved rxdelay and txdelay there.

    "},{"location":"rak3401_mota_chain/#two-relay-deployment","title":"Two-relay deployment","text":"

    The first nine direct package transitions above were physically observed as the byte-identical first nine transitions of the legacy chain. Replacement step 10, this hardened host-runner revision, and multi-hop operation are not cleanly end-to-end physically qualified. A controlled, recoverable multi-hop lab can exercise two intermediate relays by listing them farthest-to-nearest and using three OTA hops. Run a non-mutating preflight first; even preflight requires the prerelease candidate override because the command connects to live devices:

    python3 tools/lora_ota/rak3401_mota_chain.py \\\n  --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip \\\n  --work-dir ./rak3401-mota-chain-work \\\n  --controller-serial /dev/ttyACM0 \\\n  --source-tcp SOURCE_IP:5001 \\\n  --source-cli-tcp SOURCE_IP:5002 \\\n  --source-shares-controller \\\n  --target-key 63d8df63 \\\n  --relay 'Far Repeater' \\\n  --relay 'Near Repeater' \\\n  --temp-radio 909.950,250,5,5,120 \\\n  --ota-hops 3 \\\n  --motatool /path/to/motatool \\\n  --accept-test-candidate \\\n  --preflight-only\n

    After preflight succeeds, rerun with --yes in place of --preflight-only. Confirm that the selected frequency and bandwidth are legal at the deployment location.

    "},{"location":"rak3401_mota_chain/#rescue-and-completion","title":"Rescue and completion","text":"

    The guarded ota rescue install <base_body_hash> command is present after step 1 and remains available through the step-9 bridge. It is not a force command: it refuses a valid normal EndF, a base mismatch, wrong target/hardware, or invalid payload. A failure before step 1 completes still requires local USB recovery because the deployed start image predates the rescue command. The final b40d2e6c endpoint uses the stricter shared-slot profile; its invalid-EndF recovery is local DFU/SWD, not the rescue command.

    Before the first mutation, the runner saves the destination's RXPS periods, CPU power-saving state, RX flood delay, airtime factor, and OTA hop reach in the persistent work directory, flushing both the file and its directory entry before changing a guardrail. It also saves the managed source's exact RXPS preference in the chain-root source-rxps-settings.json before disabling it. Every step reuses that protected record, so a rerun after a killed host process restores the chain-start preference instead of treating the temporary off state as original. The record is accepted only for the same CLI endpoint. The runner retires that chain-root record only after it has proved the source is back on its normal radio and restored the exact preference at the verified endpoint. The runner reads the destination, controller, source, and relay versions before applying the same RXPS policy as the generic runner. The mixed historical chain therefore verifies radio.rxps off after every bridge reboot; a future all-v1.17.1.5-or-newer SF5/BW250 chain would instead keep RXPS on under the qualified level-8/preamble-64 boundary. It also verifies set powersaving off and rxdelay 0, plus af 0 when --legacy-full-airtime was explicitly selected. It restores every original value only after the exact endpoint is proven. An interrupted run deliberately leaves those transfer guardrails active; rerun the same command with the same work directory to resume and restore them. CPU power saving was not the cause of the observed folder-source failure: that was a USB CDC receive-ring overrun fixed by bounded serial reads. The runtime settings remain conservative isolation and fast-link reliability guardrails.

    For every named --relay, the per-step runner also captures rxdelay and txdelay, writes them to relay-timing-settings.json, applies rxdelay 0 and txdelay 0.3 during transfer, verifies the readback, and restores both before the relay leaves TempRadio.

    The runner also keeps the watchdog off across the chain, rechecks identity and OTA reach before every package, requires the exact post-boot EndF hash, and only re-enables the watchdog after step 10. Lab success requires endpoint body hash 1FA6AAA3C28D8BD8, target 2FA509C1, hardware RAK_3401, normal radio 910.525 MHz / 62.5 kHz / SF7 / CR5, and the watchdog verified on.

    "},{"location":"rak3401_mota_chain/#external-radio-startup-and-manual-usb-recovery","title":"External-radio startup and manual USB recovery","text":"

    RAK3401 is a distinct target from RAK4631. Its RAK13300/RAK13302 radio is on the WisBlock SPI bus (SPI1 in the Adafruit nRF52 core), with BUSY and DIO1 on P0.09/P0.10. Current builds explicitly enable those NFC-capable pads as GPIO and do not advertise the radio bus as an on-board QSPI flash device.

    The RAK13302's FEM and boost supply use switched 3V3_S, but the SX1262 core uses unswitched 3V3. An MCU reset, a 1200-baud DFU touch, or disconnecting only USB may therefore leave a battery-powered SX1262 in its previous state. At startup, current firmware quiesces the FEM, reinitializes the dedicated SPI instance, asserts NRESET, and, if BUSY remains high, sends the Semtech NSS/GET_STATUS wake sequence without waiting on RadioLib first. The one-time board startup cold-starts 3V3_S; later radio retries leave that shared rail enabled so they do not reset GPS or sensor modules.

    If BUSY still cannot be released, firmware must not enter a reset loop:

    A permanently high BUSY after NRESET and the direct wake transaction indicates an electrically unavailable radio, loose module, or power-domain fault; an MCU reboot cannot manufacture a response. If local access is possible, disconnect the battery as well as USB and reseat the WisBlock module. Remote firmware will continue retrying without requiring that physical intervention or churning the USB device.

    For an existing deployed tower using Preview 5, do not change the bootloader as part of this application chain. Its exact local recovery package is the Preview 5 RAK4631 ZIP. For a new RAK3401 installation or local recovery, use the OTAFIX 2.4.3 RAK3401 ZIP from the OTAFIX 2.4.3 release. These are local DFU/SWD recovery assets, not mOTA chain steps.

    For a local Serial DFU recovery with the repository helper:

    cd /path/to/meshfirmware\nMCFIRMWARE_NO_SUDO=1 ./mcfirmware.sh\n

    Select the serial entry whose USB identity is WisCore RAK3401 Board, then choose Custom, nrf52, and the exact RAK3401 .zip. Use flash-update to preserve InternalFS preferences and identity. Use flash-wipe + flash only when erasing application data is intentional. The helper follows the same USB device into bootloader mode, so do not substitute another /dev/ttyACM* merely because its number appears first.

    "},{"location":"role_feature_switches/","title":"Feature switches by role \u2014 1.17.1.5 USA Cascade","text":"

    Use these settings with the exact board's canonical release image. Former logging, power-saving, FEM-gain, and rotated-display variants are now runtime choices where the hardware supports them. A setting cannot add missing radio hardware, MQTT code, storage, or an OTA partition. Check the download's .capabilities.json and the firmware picker. For exact old device/variant names, search the 1.17.1.5 variant map: it maps all 1,361 previous release entries to 1,325 covered choices or 36 excluded entries. Many old choices share one current image; the command tables below explain how to select their former behavior. Excluded entries have no download.

    Search terms are alternative wording for finding these instructions, not CLI aliases. Use the commands in the role-specific tables and examples.

    "},{"location":"role_feature_switches/#open-the-usb-web-console","title":"Open the USB web console","text":"

    Search terms: USB terminal, serial console, ASCII terminal, browser terminal, web serial.

    Open the MeshCore USB web console in Chrome or Edge, connect a data-capable USB cable, close other applications using that port, and select the device. Use 115200 baud when prompted. Full Companion, Repeater, Room Server, and Sensor start with an ASCII USB terminal. Run board, then ver on any role.

    If a Companion app has already switched USB to its binary protocol, send:

    +++MESHCORE-TERM-START\n

    To hand the port back to a Companion app or USB MOTA host, send +++MESHCORE-TERM-STOP, close the console, and connect the app/tool. Dedicated USB/BLE/WiFi Companion images can start in binary mode; use the start token there too. KISS firmware uses its modem protocol and is outside this release.

    The linked console runs in your computer's browser over USB. It does not need node WiFi or set webui on. WebConfig is a separate settings website served by supported ESP32 images. Both infrastructure and WiFi Companion WebConfig include a browser command terminal (set wifi.cli on), enabled by default on the LAN. The Companion browser CLI uses the same terminal commands as USB, including contact import, chat, recipient selection, and streaming replies. Full ESP32 Companion also exposes this terminal at TCP port 5002. See the terminal command guide.

    "},{"location":"role_feature_switches/#which-old-variant-setting-should-i-use","title":"Which old variant setting should I use?","text":"

    Search terms: old firmware variants, restore features, turn features on or off, enable MQTT, disable MQTT.

    These controls apply to every board with the corresponding compiled feature; check the hardware requirements below.

    Former choice Current image / runtime control USB logging or Station G2/G3 logging variant Same role's canonical image; set usb.logging on / off USB logging, WiFi MQTT, or both MQTT-capable image with USB logging; set logging.output usb, wifi, both, or off Companion WiFi-MQTT variant set mqtt.enabled on / off; configure broker slots through the CLI or WebConfig _ps power-saving variant set powersaving on / off; check get powersaving on every role _femoff / FEM-gain variant set radio.fem.rxgain on / off; separate TX switch where controllable RX boost variant set radio.rxgain on / off on a supported radio iKOKA rotated-display variant Full Companion: set display.rotation 180; 0 restores the board default Separate USB, BLE, WiFi, Ethernet, or Terminal Chat Companion Exact board's Full Companion where listed; supported transports are included, with SenseCAP selection below RS232 repeater variant consolidated into the ordinary repeater set bridge.enabled on / off on a build containing the RS232 bridge ESP-NOW bridge or primary ESP-NOW radio Select the matching hardware/role image first; bridge and primary-radio channel commands differ LoRa OTA / external-storage variant Still choose the exact receiver/storage image and matching bootloader; this is not a software on/off switch

    Companion, Repeater, Room Server, and Sensor use the same command names for shared settings. Use get <setting> to check and set <setting> <value> to change them. A command requires its feature to be compiled into the build. set usb.logging on|off changes only USB logging. On MQTT-capable builds, set mqtt.enabled on|off changes only MQTT and keeps broker settings; set logging.output off|usb|wifi|both selects both outputs together.

    "},{"location":"role_feature_switches/#full-companion-commands","title":"Full Companion commands","text":"Setting Enable / select Disable / restore Read back Device power saving set powersaving on set powersaving off get powersaving LoRa RX power saving set radio.rxps on set radio.rxps off get radio.rxps Radio RX boost set radio.rxgain on set radio.rxgain off get radio.rxgain External FEM RX gain set radio.fem.rxgain on set radio.fem.rxgain off get radio.fem.rxgain External FEM TX gain set radio.fem.txgain on set radio.fem.txgain off get radio.fem.txgain ESP32 USB logging set powersaving off, then set usb.logging on set usb.logging off get powersaving, get usb.logging nRF52 second USB logging port set usb.logging on reboot set usb.logging off reboot get usb.logging MQTT master set mqtt.enabled on set mqtt.enabled off get mqtt.enabled, get mqtt.running, get mqtt.status ESP32 persistent WebConfig set webui on set webui off get webui ESP32 WebConfig browser console set wifi.cli on set wifi.cli off get wifi.cli ESP32 temporary setup AP start webconfig ap stop webconfig get webui Display rotation set display.rotation 90, 180, or 270 set display.rotation 0 get display.rotation Temporary MOTA radio window tempradio 910.525,250,5,5,120 normalradio tempradio

    Saved settings apply immediately unless noted. Rotation and gain controls report unsupported hardware instead of creating that feature. Fresh Full Companion preferences enable device power saving and leave USB logging off; existing saved preferences win after an update.

    ESP32: logging and binary Companion traffic share one USB port. Turn logging off before handing USB to an app/MOTA host. nRF52: the optional second CDC port is for logs; use the primary port for Companion/MOTA. Adding/removing the second port requires a reboot; the optional suffix shown above requests it.

    "},{"location":"role_feature_switches/#mqtt-controls-shared-by-companion-and-infrastructure","title":"MQTT controls shared by Companion and infrastructure","text":"

    Search terms: Companion MQTT settings, MQTT on, MQTT off, broker configuration.

    Configure a broker through the MQTT tab or with the same commands on either role. For example:

    set mqtt.iata SEA\nset mqtt1.preset custom\nset mqtt1.server broker.example.com\nset mqtt1.port 1883\nset mqtt.enabled on\nget mqtt.enabled\nget mqtt.running\nget mqtt.status\n

    set mqtt.enabled off disconnects brokers while preserving their settings. set mqtt.enabled on allows configured brokers to reconnect. An enabled switch is separate from a running connection: check get mqtt.running and get mqtt.status. The MQTT tab's Enable MQTT checkbox controls this same saved switch. Slot credentials and presets are available through get/set mqtt1.*, mqtt2.*, and the remaining supported slots; secrets stay masked in the browser. WiFi Companion accepts these commands through its USB terminal and browser CLI; Full Companion also accepts TCP port 5002.

    set mqtt1.preset none disables just that slot. Publication controls such as set mqtt.status off do not disconnect brokers. USB logging is independent. Images without MQTT code do not show MQTT cards or accept MQTT controls.

    "},{"location":"role_feature_switches/#companion-wifi-bluetooth-gps-and-board-exceptions","title":"Companion WiFi, Bluetooth, GPS, and board exceptions","text":"

    Configure ESP32 WiFi with:

    set wifi.ssid MyNetwork\nset wifi.pwd my-password\nget wifi.status\n

    set wifi.powersave min enables WiFi modem sleep; none disables it. max is accepted only where the BLE/WiFi coexistence policy allows it. Read back with get wifi.powersave. This is separate from device power saving and RXPS. The assigned WiFi button/display switch controls WiFi services on supported boards. stop webconfig closes only the portal; it is not a WiFi master switch. There is no universal Bluetooth-off or Ethernet-off text command.

    SenseCAP Indicator Full only: set companion.transport wifi followed by reboot selects WiFi; set companion.transport ble followed by reboot selects Bluetooth. Check get companion.transport. USB remains available. Primary ESP-NOW Indicator images keep their mesh radio in either selection.

    GPS-equipped builds: use get gps, set gps on, and set gps off on Companion and infrastructure. The Companion app's gps=1 / gps=0 custom setting controls the same GPS. See GPS tracking for location-sharing settings.

    "},{"location":"role_feature_switches/#repeater-room-server-and-sensor-commands","title":"Repeater, Room Server, and Sensor commands","text":"

    These roles use CommonCLI. Feature-dependent commands require the relevant hardware/build; MQTT is present in observer builds, and WebConfig is absent from some portable builds. The USB browser console still works without it.

    Setting Enable Disable Read back Live USB logging ESP32 1.17.1.5: set powersaving off, then set usb.logging on; other platforms: set usb.logging on set usb.logging off get powersaving, get usb.logging Capture RX log to node storage log start log stop log prints the capture locally RS232 / ESP-NOW bridge master set bridge.enabled on set bridge.enabled off get bridge.enabled, get bridge.running, get bridge.type MQTT periodic status publication set mqtt.status on set mqtt.status off get mqtt.status shows connection status MQTT packet publication set mqtt.packets on set mqtt.packets off get mqtt.packets SNMP on supported MQTT infrastructure set snmp on, then reboot set snmp off, then reboot get snmp MQTT raw packet publication set mqtt.raw on set mqtt.raw off get mqtt.raw MQTT receive capture set mqtt.rx on set mqtt.rx off get mqtt.rx MQTT transmit capture set mqtt.tx on (or advert) set mqtt.tx off get mqtt.tx MQTT master set mqtt.enabled on set mqtt.enabled off get mqtt.enabled, get mqtt.running, get mqtt.status ESP32 persistent WebConfig set webui on set webui off get webui WebConfig browser command terminal set wifi.cli on set wifi.cli off get wifi.cli LoRa RX power saving set radio.rxps on set radio.rxps off get radio.rxps Radio RX boost set radio.rxgain on set radio.rxgain off get radio.rxgain Controllable FEM RX / TX gain set radio.fem.rxgain on / set radio.fem.txgain on set radio.fem.rxgain off / set radio.fem.txgain off Corresponding get radio.fem.rxgain / get radio.fem.txgain GPS, when compiled set gps on set gps off get gps

    Use set usb.logging on|off on every role. Adding the optional reboot suffix requests a reboot only when changing the USB interfaces requires it, as on nRF52 Full Companion. log start/stop records to storage independently of live USB logging. Use log erase to delete that capture.

    For ESP32 1.17.1.5 USB logging, run these as separate commands in the role's text terminal (or remote admin CLI on infrastructure):

    set powersaving off\nset usb.logging on\nget powersaving\nget usb.logging\n

    Check for power saving off and USB logging on. This avoids the released ESP32 USB sleep bug, including the G3 report. Both settings are saved; turning logging off later does not automatically restore power saving. LoRa RXPS is independent. nRF52 logging does not need this ESP32 workaround.

    With the G3 sleep correction, enabled live USB logging keeps ESP32 USB serviced and blocks light sleep, including when a host closes the port or disconnects. CPU idle/yield remains available. set usb.logging off removes that blocker; an attached native USB host still prevents sleep. On Full infrastructure, set logging.output usb and both enable the same USB blocker; wifi and off remove it. File capture with log start is independent. The original 1.17.1.5 binaries require the set powersaving off workaround described in the release note.

    "},{"location":"role_feature_switches/#shared-mqtt-and-logging-output","title":"Shared MQTT and logging output","text":"

    Search terms: MQTT settings, MQTT on, MQTT off, logging output, USB and WiFi logging.

    On any Companion or infrastructure build with both MQTT and USB logging compiled:

    Command USB logs MQTT bridge set logging.output off Off Off set powersaving off, then set logging.output usb On Off set logging.output wifi Off On set powersaving off, then set logging.output both On On

    get logging.output reports the selection. Fresh unified Full infrastructure preferences select both; Full Companion starts with USB logging off. Saved settings override these defaults. To toggle only MQTT while keeping USB logging unchanged, use set mqtt.enabled off / on. Neither setting turns LoRa repeating off. Repeater forwarding uses set repeat off / on and get repeat separately.

    The set powersaving off step above is the 1.17.1.5 ESP32 USB workaround. WiFi/MQTT-only logging does not need it while the Repeater/Room Server MQTT bridge is running: that sleep guard already exists in the released firmware. Use get mqtt.running to check that MQTT is running; an enabled preference alone is not the running state. set logging.output wifi keeps USB logging off and uses that MQTT guard.

    For a custom broker on an MQTT-capable Companion, Repeater, or Room Server:

    set wifi.ssid MyNetwork\nset wifi.pwd my-password\nset mqtt.iata SEA\nset mqtt1.preset custom\nset mqtt1.server broker.example.com\nset mqtt1.port 1883\nset mqtt.enabled on\nget mqtt.status\n

    Set mqtt1.username / mqtt1.password if required by that broker. Configure only as many slots as the board supports. set mqtt1.preset none disables slot 1; other configured slots remain enabled, within the build's supported slot count. Disabled and partially configured slots remain saved across reboot. For a custom server URL, set mqtt1.port 0 clears the port override so the URI supplies it. set mqtt.status off disables status messages, not MQTT itself. See the MQTT reference for presets, TLS, credentials, and slot limits.

    "},{"location":"role_feature_switches/#infrastructure-power-saving-and-bridges","title":"Infrastructure power saving and bridges","text":"

    Use get powersaving, set powersaving on, and set powersaving off on every role. The preference is saved. Hardware and active USB/network services decide when sleeping is possible; the command names do not change between roles. Device power saving is separate from WiFi modem sleep and LoRa RXPS.

    On RS232-capable repeater images, stop the bridge before changing its serial port or baud rate, then restart it:

    set bridge.enabled off\nset bridge.baud 115200\nset bridge.enabled on\n

    RAK4631: set bridge.uart 2 selects UART2 while the bridge is stopped. Canonical GPS-enabled builds reserve UART1 for GPS; UART1 requires a dedicated GPS-free image. ESP-NOW bridge: use set bridge.channel <1..13> and set bridge.format wrapped / raw where supported. Primary ESP-NOW mesh: use set espnow.channel <1..13> and reboot; this is a different radio setting.

    For infrastructure WebConfig on the LAN, use start webconfig / stop webconfig. To force a setup AP on an observer, first run set mqtt.enabled off, then start webconfig ap. When finished, run stop webconfig and restore set mqtt.enabled on if you did not reboot.

    "},{"location":"role_feature_switches/#updating-and-sending-mota","title":"Updating and sending MOTA","text":"

    Search terms: mOTA, LoRa OTA, update over LoRa, wireless firmware transfer.

    All released Full Companions can serve MOTA to other nodes. Close the console and run motatool serve --serial /dev/ttyACM0 --dir ./motas -v on the USB host. See Full Companion instructions for WiFi/BLE source commands and the bounded temporary radio setup.

    Role / hardware Start self-update Stop / requirement ESP32 Repeater, Room Server, Sensor with WiFi updater start ota or start ota ap; open the returned URL (normally port 80, /update) stop ota; close WebConfig first if it shares port 80 ESP32 Full Companion with two application slots start ota or start ota ap; returned URL uses port 8080, /update stop ota; single-slot Full builds use USB nRF52 infrastructure with Bluetooth DFU start ota enters the Bluetooth update flow Matching application DFU ZIP and board bootloader required Qualified LoRa OTA receiver Follow LoRa OTA directions Exact destination package, storage profile, and overlapping temporary radio windows

    For nRF52 OTAFIX installations use the exact board/storage build from OTAFIX 2.4.6. Its retained-RAM handoff is required by the new internal-flash hybrid receiver images. Full Companions are sources and remain normally USB-updated.

    The complete infrastructure CLI, role/build matrix, and Full Companion feature guide provide details.

    "},{"location":"spiffs_regular_file_reads/","title":"SPIFFS regular-file reads and login replay state","text":"

    Arduino-ESP32 SPIFFS can return a truthy directory handle from a read-open of a nonexistent filename. File::operator bool() alone does not prove that a regular file exists. A directory's size() and read() are zero.

    The first privileged login on an upgraded G2 encountered this in ClientACL::writeClientLoginReplayCeiling(): opening the not-yet-created /s_login_replay appeared successful, then subtracting the eight-byte trailer from size zero underflowed. Copying the supposed records failed and login was rejected. Waiting or changing the repeater clock cannot fix that file-open bug.

    "},{"location":"spiffs_regular_file_reads/#fix","title":"Fix","text":"

    Use mesh::openFileRead() for regular-file reads. It checks existence and rejects directory handles, while preserving real empty files. Replay record counts also validate the minimum trailer size, record alignment, and maximum count before subtraction. An existing replay file that becomes unreadable is not treated as a new store.

    No replay records are cleared and authentication is not weakened. Invalid or unwritable replay state still fails closed. The first successful privileged login creates a 44-byte file: one 36-byte identity/ceiling record plus its eight-byte integrity trailer.

    Setting the repeater clock must not clear this file. Admission compares the sender's timestamp with that sender's saved boundary, not with the repeater's current time. Clearing the boundary could make captured requests reusable. A sender clock rollback is a separate condition and remains subject to the saved boundary after this fix.

    Companion directory enumeration uses the separate openDirectory() API, which deliberately permits SPIFFS virtual directories. Regular-file reads are also enforced for companion data, repeater/room logs, flood-rule verification, and HTTP packet-log downloads (missing logs return 404; real empty logs 200).

    "},{"location":"spiffs_regular_file_reads/#audit-boundary","title":"Audit boundary","text":"

    The audit covered file opens and size arithmetic in src and examples. Identity, region, clock, and common preference loaders already gate reads with filesystem existence checks; SPIFFS's exists() explicitly excludes directory handles. Relevant MQTT length subtraction follows validated headers and exact reads. ESP32 OTA staging uses partition APIs, not these SPIFFS file handles. Intentional directory enumeration must not be changed to a regular-file read.

    "},{"location":"spiffs_regular_file_reads/#regression-checks","title":"Regression checks","text":"

    The pre-fix actual ACL code was reproduced as first_login=rejected, one missing read-open, and no replay file. The fixed code accepted that identical scenario without a missing read-open and created the valid 44-byte record. Host simulations do not replace a post-flash LoRa login test on the G2.

    "},{"location":"stats_binary_frames/","title":"Stats Binary Frame Structures","text":"

    Binary frame structures for companion radio stats commands. All multi-byte integers use little-endian byte order.

    "},{"location":"stats_binary_frames/#command-codes","title":"Command Codes","text":"Command Code Description CMD_GET_STATS 56 Get statistics (2-byte command: code + sub-type)"},{"location":"stats_binary_frames/#stats-sub-types","title":"Stats Sub-Types","text":"

    The CMD_GET_STATS command uses a 2-byte frame structure: - Byte 0: CMD_GET_STATS (56) - Byte 1: Stats sub-type: - STATS_TYPE_CORE (0) - Get core device statistics - STATS_TYPE_RADIO (1) - Get radio statistics - STATS_TYPE_PACKETS (2) - Get packet statistics

    "},{"location":"stats_binary_frames/#response-codes","title":"Response Codes","text":"Response Code Description RESP_CODE_STATS 24 Statistics response (2-byte response: code + sub-type)"},{"location":"stats_binary_frames/#stats-response-sub-types","title":"Stats Response Sub-Types","text":"

    The RESP_CODE_STATS response uses a 2-byte header structure: - Byte 0: RESP_CODE_STATS (24) - Byte 1: Stats sub-type (matches command sub-type): - STATS_TYPE_CORE (0) - Core device statistics response - STATS_TYPE_RADIO (1) - Radio statistics response - STATS_TYPE_PACKETS (2) - Packet statistics response

    "},{"location":"stats_binary_frames/#resp_code_stats-stats_type_core-24-0","title":"RESP_CODE_STATS + STATS_TYPE_CORE (24, 0)","text":"

    Total Frame Size: 11 bytes

    Offset Size Type Field Name Description Range/Notes 0 1 uint8_t response_code Always 0x18 (24) - 1 1 uint8_t stats_type Always 0x00 (STATS_TYPE_CORE) - 2 2 uint16_t battery_mv Battery voltage in millivolts 0 - 65,535 4 4 uint32_t uptime_secs Device uptime in seconds 0 - 4,294,967,295 8 2 uint16_t errors Error flags bitmask - 10 1 uint8_t queue_len Outbound packet queue length 0 - 255"},{"location":"stats_binary_frames/#example-structure-cc","title":"Example Structure (C/C++)","text":"
    struct StatsCore {\n    uint8_t  response_code;  // 0x18\n    uint8_t  stats_type;     // 0x00 (STATS_TYPE_CORE)\n    uint16_t battery_mv;\n    uint32_t uptime_secs;\n    uint16_t errors;\n    uint8_t  queue_len;\n} __attribute__((packed));\n
    "},{"location":"stats_binary_frames/#resp_code_stats-stats_type_radio-24-1","title":"RESP_CODE_STATS + STATS_TYPE_RADIO (24, 1)","text":"

    Total Frame Size: 14 bytes

    Offset Size Type Field Name Description Range/Notes 0 1 uint8_t response_code Always 0x18 (24) - 1 1 uint8_t stats_type Always 0x01 (STATS_TYPE_RADIO) - 2 2 int16_t noise_floor Radio noise floor in dBm -140 to +10 4 1 int8_t last_rssi Last received signal strength in dBm -128 to +127 5 1 int8_t last_snr SNR scaled by 4 Divide by 4.0 for dB 6 4 uint32_t tx_air_secs Cumulative transmit airtime in seconds 0 - 4,294,967,295 10 4 uint32_t rx_air_secs Cumulative receive airtime in seconds 0 - 4,294,967,295"},{"location":"stats_binary_frames/#example-structure-cc_1","title":"Example Structure (C/C++)","text":"
    struct StatsRadio {\n    uint8_t  response_code;  // 0x18\n    uint8_t  stats_type;     // 0x01 (STATS_TYPE_RADIO)\n    int16_t  noise_floor;\n    int8_t   last_rssi;\n    int8_t   last_snr;       // Divide by 4.0 to get actual SNR in dB\n    uint32_t tx_air_secs;\n    uint32_t rx_air_secs;\n} __attribute__((packed));\n
    "},{"location":"stats_binary_frames/#resp_code_stats-stats_type_packets-24-2","title":"RESP_CODE_STATS + STATS_TYPE_PACKETS (24, 2)","text":"

    Total Frame Size: 26 bytes (legacy) or 30 bytes (includes recv_errors)

    Offset Size Type Field Name Description Range/Notes 0 1 uint8_t response_code Always 0x18 (24) - 1 1 uint8_t stats_type Always 0x02 (STATS_TYPE_PACKETS) - 2 4 uint32_t recv Total packets received 0 - 4,294,967,295 6 4 uint32_t sent Total packets sent 0 - 4,294,967,295 10 4 uint32_t flood_tx Packets sent via flood routing 0 - 4,294,967,295 14 4 uint32_t direct_tx Packets sent via direct routing 0 - 4,294,967,295 18 4 uint32_t flood_rx Packets received via flood routing 0 - 4,294,967,295 22 4 uint32_t direct_rx Packets received via direct routing 0 - 4,294,967,295 26 4 uint32_t recv_errors Receive/CRC errors (RadioLib); present only in 30-byte frame 0 - 4,294,967,295"},{"location":"stats_binary_frames/#notes","title":"Notes","text":""},{"location":"stats_binary_frames/#example-structure-cc_2","title":"Example Structure (C/C++)","text":"
    struct StatsPackets {\n    uint8_t  response_code;  // 0x18\n    uint8_t  stats_type;     // 0x02 (STATS_TYPE_PACKETS)\n    uint32_t recv;\n    uint32_t sent;\n    uint32_t flood_tx;\n    uint32_t direct_tx;\n    uint32_t flood_rx;\n    uint32_t direct_rx;\n    uint32_t recv_errors;    // present when frame size is 30\n} __attribute__((packed));\n
    "},{"location":"stats_binary_frames/#command-usage-example-python","title":"Command Usage Example (Python)","text":"
    # Send CMD_GET_STATS command\ndef send_get_stats_core(serial_interface):\n    \"\"\"Send command to get core stats\"\"\"\n    cmd = bytes([56, 0])  # CMD_GET_STATS (56) + STATS_TYPE_CORE (0)\n    serial_interface.write(cmd)\n\ndef send_get_stats_radio(serial_interface):\n    \"\"\"Send command to get radio stats\"\"\"\n    cmd = bytes([56, 1])  # CMD_GET_STATS (56) + STATS_TYPE_RADIO (1)\n    serial_interface.write(cmd)\n\ndef send_get_stats_packets(serial_interface):\n    \"\"\"Send command to get packet stats\"\"\"\n    cmd = bytes([56, 2])  # CMD_GET_STATS (56) + STATS_TYPE_PACKETS (2)\n    serial_interface.write(cmd)\n
    "},{"location":"stats_binary_frames/#response-parsing-example-python","title":"Response Parsing Example (Python)","text":"
    import struct\n\ndef parse_stats_core(frame):\n    \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_CORE frame (11 bytes)\"\"\"\n    response_code, stats_type, battery_mv, uptime_secs, errors, queue_len = \\\n        struct.unpack('<B B H I H B', frame)\n    assert response_code == 24 and stats_type == 0, \"Invalid response type\"\n    return {\n        'battery_mv': battery_mv,\n        'uptime_secs': uptime_secs,\n        'errors': errors,\n        'queue_len': queue_len\n    }\n\ndef parse_stats_radio(frame):\n    \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_RADIO frame (14 bytes)\"\"\"\n    response_code, stats_type, noise_floor, last_rssi, last_snr, tx_air_secs, rx_air_secs = \\\n        struct.unpack('<B B h b b I I', frame)\n    assert response_code == 24 and stats_type == 1, \"Invalid response type\"\n    return {\n        'noise_floor': noise_floor,\n        'last_rssi': last_rssi,\n        'last_snr': last_snr / 4.0,  # Unscale SNR\n        'tx_air_secs': tx_air_secs,\n        'rx_air_secs': rx_air_secs\n    }\n\ndef parse_stats_packets(frame):\n    \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_PACKETS frame (26 or 30 bytes)\"\"\"\n    assert len(frame) >= 26, \"STATS_TYPE_PACKETS frame too short\"\n    response_code, stats_type, recv, sent, flood_tx, direct_tx, flood_rx, direct_rx = \\\n        struct.unpack('<B B I I I I I I', frame[:26])\n    assert response_code == 24 and stats_type == 2, \"Invalid response type\"\n    result = {\n        'recv': recv,\n        'sent': sent,\n        'flood_tx': flood_tx,\n        'direct_tx': direct_tx,\n        'flood_rx': flood_rx,\n        'direct_rx': direct_rx\n    }\n    if len(frame) >= 30:\n        (recv_errors,) = struct.unpack('<I', frame[26:30])\n        result['recv_errors'] = recv_errors\n    return result\n
    "},{"location":"stats_binary_frames/#command-usage-example-javascripttypescript","title":"Command Usage Example (JavaScript/TypeScript)","text":"
    // Send CMD_GET_STATS command\nconst CMD_GET_STATS = 56;\nconst STATS_TYPE_CORE = 0;\nconst STATS_TYPE_RADIO = 1;\nconst STATS_TYPE_PACKETS = 2;\n\nfunction sendGetStatsCore(serialInterface: SerialPort): void {\n    const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_CORE]);\n    serialInterface.write(cmd);\n}\n\nfunction sendGetStatsRadio(serialInterface: SerialPort): void {\n    const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_RADIO]);\n    serialInterface.write(cmd);\n}\n\nfunction sendGetStatsPackets(serialInterface: SerialPort): void {\n    const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_PACKETS]);\n    serialInterface.write(cmd);\n}\n
    "},{"location":"stats_binary_frames/#response-parsing-example-javascripttypescript","title":"Response Parsing Example (JavaScript/TypeScript)","text":"
    interface StatsCore {\n    battery_mv: number;\n    uptime_secs: number;\n    errors: number;\n    queue_len: number;\n}\n\ninterface StatsRadio {\n    noise_floor: number;\n    last_rssi: number;\n    last_snr: number;\n    tx_air_secs: number;\n    rx_air_secs: number;\n}\n\ninterface StatsPackets {\n    recv: number;\n    sent: number;\n    flood_tx: number;\n    direct_tx: number;\n    flood_rx: number;\n    direct_rx: number;\n    recv_errors?: number;  // present when frame is 30 bytes\n}\n\nfunction parseStatsCore(buffer: ArrayBuffer): StatsCore {\n    const view = new DataView(buffer);\n    const response_code = view.getUint8(0);\n    const stats_type = view.getUint8(1);\n    if (response_code !== 24 || stats_type !== 0) {\n        throw new Error('Invalid response type');\n    }\n    return {\n        battery_mv: view.getUint16(2, true),\n        uptime_secs: view.getUint32(4, true),\n        errors: view.getUint16(8, true),\n        queue_len: view.getUint8(10)\n    };\n}\n\nfunction parseStatsRadio(buffer: ArrayBuffer): StatsRadio {\n    const view = new DataView(buffer);\n    const response_code = view.getUint8(0);\n    const stats_type = view.getUint8(1);\n    if (response_code !== 24 || stats_type !== 1) {\n        throw new Error('Invalid response type');\n    }\n    return {\n        noise_floor: view.getInt16(2, true),\n        last_rssi: view.getInt8(4),\n        last_snr: view.getInt8(5) / 4.0,  // Unscale SNR\n        tx_air_secs: view.getUint32(6, true),\n        rx_air_secs: view.getUint32(10, true)\n    };\n}\n\nfunction parseStatsPackets(buffer: ArrayBuffer): StatsPackets {\n    const view = new DataView(buffer);\n    if (buffer.byteLength < 26) {\n        throw new Error('STATS_TYPE_PACKETS frame too short');\n    }\n    const response_code = view.getUint8(0);\n    const stats_type = view.getUint8(1);\n    if (response_code !== 24 || stats_type !== 2) {\n        throw new Error('Invalid response type');\n    }\n    const result: StatsPackets = {\n        recv: view.getUint32(2, true),\n        sent: view.getUint32(6, true),\n        flood_tx: view.getUint32(10, true),\n        direct_tx: view.getUint32(14, true),\n        flood_rx: view.getUint32(18, true),\n        direct_rx: view.getUint32(22, true)\n    };\n    if (buffer.byteLength >= 30) {\n        result.recv_errors = view.getUint32(26, true);\n    }\n    return result;\n}\n
    "},{"location":"stats_binary_frames/#field-size-considerations","title":"Field Size Considerations","text":""},{"location":"telemetry_decoder/","title":"Telemetry decoder","text":"

    Paste raw hexadecimal packet data copied from the Let's Mesh packet analyzer to decode a repeater's scheduled temperature, battery-voltage, or external I2C-voltage snapshot. The decoder also accepts the payload hex without its MeshCore packet header and the Base64 pages returned by repeater or room-server CLI commands. Decoding happens entirely in this browser; pasted data is not uploaded or sent anywhere.

    The Source ID in a raw-packet result is the first eight bytes of the sending repeater's public key. Match that 16-character hex value against the public-key prefix recorded for your repeaters. It comes from the telemetry payload itself, so it is available even when only the payload was copied.

    "},{"location":"telemetry_decoder/#send-raw-telemetry-packets","title":"Send raw telemetry packets","text":"

    From an administrator CLI session, first configure a direct route to the MQTT observer that will receive and upload the raw packets. Use direct when the observer is a zero-hop neighbor:

    set telemetry.tx direct\nsend telemetry.tx now\n

    For a routed observer, provide its comma-separated hop hashes instead:

    set telemetry.tx A1B2,C3D4\nsend telemetry.tx now\n

    Configuring the route also enables the default two-day schedule. To keep the route but use it only for manual tests, turn off the schedule before sending:

    set telemetry.tx schedule off\nsend telemetry.tx now\n

    The send telemetry.tx now command queues one TTB1 temperature packet, one TVB1 battery-voltage packet, and IVB1 chunks for every connected I2C voltage channel. TTB1 and TVB1 carry up to 165 samples. Each IVB1 carries 64, so a full 192-point/four-day channel takes three packets. Channels whose retained history is entirely zero are treated as disconnected and are not sent. The command requires at least one collected base sample and works even when the automatic schedule is off.

    "},{"location":"telemetry_decoder/#decode-a-packet","title":"Decode a packetDecoded telemetry","text":"Try an analyzer example: Temperature packet Voltage packet I2C voltage packet I2C voltage CLI page Raw packet or payload hex

    Spaces, line breaks, colons, dashes, a leading 0x, and a quoted JSON field are accepted. CLI Base64 replies are also auto-detected. Paste multiple compatible packet or reply lines together to merge them by timestamp before downloading one CSV. Press Ctrl/Command+Enter to decode.

    Decode telemetry Clear Show browser-local time Download CSV Decode notes"},{"location":"telemetry_decoder/#analyzer-hex-examples","title":"Analyzer hex examples","text":"

    The buttons load synthetic, protocol-valid zero-hop RAW_CUSTOM packets. A real scheduled snapshot normally has 165 samples and is much longer. Routed packets also contain path bytes before the TTB1 or TVB1 payload magic; the decoder finds and validates the payload automatically.

    "},{"location":"telemetry_decoder/#temperature","title":"Temperature","text":"
    3E00545442311122334455667788800092651E0008000102354A4E5082\n
    "},{"location":"telemetry_decoder/#battery-voltage","title":"Battery voltage","text":"
    3E00545642311122334455667788800092651E000800010264C8FEFFDC\n
    "},{"location":"telemetry_decoder/#external-i2c-voltage","title":"External I2C voltage","text":"
    3E00495642311122334455667788800092651E00020800000004019026927109C427107FFF\n

    All three examples identify the source as 1122334455667788.

    "},{"location":"telemetry_decoder/#cli-history-pages","title":"CLI history pages","text":"

    The same page continues to decode the padded Base64 returned by these administrator commands:

    Data Newest page Older-page example Samples per page MCU temperature get telemetry.temp get telemetry.temp 2 48 (24 hours) Battery voltage get telemetry.volt get telemetry.volt 3 48 (24 hours) I2C voltage get telemetry.volt.i2c 2 get telemetry.volt.i2c 2 4 48 (24 hours) GPS position get telemetry.gps get telemetry.gps 2 24 (12 hours)

    Paste either the complete reply beginning with > or Base64 alone. For example:

    get telemetry.volt 1\n> EkDUcWoeMAAB5+bl5eTj4uLh4ODf3t7d3Nvb2tnZ2NfX1tXU1NPS0tHQ0M/Ozc3My8vKycnI/w==\n

    Run get telemetry.volt.i2c without a channel first to list connected channels. For each channel, collect pages 1 through 4. Paste all four reply lines into the decoder at once; it merges their timestamps into one 192-point, four-day table and downloads them as one CSV. The same merging works for the three IVB1 analyzer packets from a full channel. Inputs must have the same telemetry type, source, LPP channel, and sample interval.

    An INA3221 exposes its three enabled hardware inputs as three consecutive LPP channels. When it is the only external sensor these are normally 2, 3, and 4, in hardware-input order. Other sensors can shift the numbers, so copy the IDs reported by get telemetry.volt.i2c rather than assuming them.

    "},{"location":"telemetry_decoder/#reading-the-table","title":"Reading the table","text":"

    GPS history remains available through the administrator CLI, but telemetry.tx never puts GPS in RAW_CUSTOM packets. Location data therefore must come from a CLI Base64 page rather than analyzer hex.

    For the byte-level layouts, see Read repeater telemetry history.

    "},{"location":"terminal_chat_cli/","title":"Terminal Chat CLI","text":"

    Below are the commands you can enter into the Terminal Chat clients:

    "},{"location":"terminal_chat_cli/#companion-wifi-browser-terminal","title":"Companion WiFi browser terminal","text":"

    On ESP32 WiFi Companions with WebConfig, open the node's LAN IP address and select CLI. The browser runs the same commands as the USB terminal and Full Companion's TCP terminal on port 5002, including contact import, contact selection, messages, remote login/commands, and delayed RF replies. Run help to list commands compiled into the device. Enable the tab with set wifi.cli on; the open setup AP does not expose it.

    For example, copy the complete meshcore://... string from card on another Companion, then run these commands on the receiving Companion:

    import meshcore://<full-contact-card-data>\nlist\nto <contact-name-or-prefix>\n

    Replace the placeholders; a bare public key is not a contact card. Import is queued for signature validation, so use list to confirm the contact appears. Explicit import works with manual contact addition enabled and bypasses the automatic discovery type/hop filters. It still respects contact storage limits; a full contact table follows the configured overwrite policy. to remains selected for subsequent send, path, login, and cmd commands within this terminal session.

    Only one USB, TCP, or browser text terminal owns the session at a time. An idle startup USB prompt can be borrowed by WiFi. Use disconnect or the browser's disconnect button to release it. Closing the page releases the session; if the connection disappears, it expires after 60 seconds without a poll. Long replies page into the browser automatically, and incoming replies continue appearing after a command finishes. Terminal scrollback starts at 4 KiB and can grow to 32 KiB for unread output, preferring PSRAM; it shrinks after reading and is freed when the session expires. If output exceeds the available buffer while unread, the browser reports the missing output.

    USB protocol-switch and USB MOTA ownership commands still apply to the USB connection. Use the WiFi MOTA seeder on port 5001 for a host folder over WiFi.

    "},{"location":"terminal_chat_cli/#local-maintenance-commands","title":"Local maintenance commands","text":"

    Local means a connection directly to the node: USB, BLE or WiFi/Ethernet Companion protocol, a TCP terminal, or the browser CLI on the LAN. These connections can use the following commands when the role and build include the corresponding feature:

    Command Result stats-core, stats-radio, stats-radio-diag, stats-packets Runtime diagnostics get prv.key Node identity private key; Companion requires private key export enabled in the build get password Infrastructure admin password; Companion reports that it has no admin password erase Erase stored identity and settings; reboot for a fresh node get wifi.pwd Stored WiFi password on WiFi builds get mqttN.password, get mqttN.token Stored credentials for MQTT slot N get acl, log Infrastructure ACL or captured packet log; these stores do not exist on Companion

    The command text is the same for direct connections. A binary Companion app can send 0x42 (CMD_RUN_CLI_COMMAND) followed by the ASCII command, without switching into terminal mode. The response is 0x1D (RESP_CODE_CLI_REPLY) followed by the reply text. For example, payload 42 67 65 74 20 70 61 73 73 77 6f 72 64 runs get password. USB framing remains the normal Companion frame; BLE and TCP use their existing Companion transport framing.

    set freq <MHz> is available on these local connections and through authorized LoRa administration, just like set radio. It saves the frequency without changing bandwidth, spreading factor, or coding rate and requires a reboot to apply. Infrastructure roles also apply the board's transmit-power limit.

    Commands relayed to another node using cmd travel over LoRa and retain the remote restrictions on local-only maintenance and secret reads. These local permissions do not add an ACL or packet-log store to Companion. Use list or the binary contacts operations for Companion contacts. Interactive chat commands still use the text terminal or their corresponding binary protocol operations.

    The browser CLI requires station/LAN mode and wifi.cli on. Infrastructure uses its admin login; Companion uses the trusted LAN. Configuration forms keep password fields masked; explicit local CLI getters return their values.

    "},{"location":"terminal_chat_cli/#companion-usb-mode","title":"Companion USB mode","text":"

    An ordinary Companion USB build starts in the normal binary Companion protocol at 115200 baud. Use this command to switch the same USB connection into terminal mode as soon as picocom opens it:

    picocom --baud 115200 \\\n  --imap spchex \\\n  --initstring $'+++MESHCORE-TERM-START\\r' \\\n  /dev/ttyACM0\n

    --initstring sends this exact terminal-start line automatically:

    +++MESHCORE-TERM-START\n

    The carriage return is required in Binary mode. Control tokens are recognized only as complete CR/LF-delimited lines, so the same text embedded in unrelated unframed input cannot switch modes accidentally.

    Binary Companion frames can contain terminal control bytes. The spchex input map renders those bytes as bracketed hexadecimal during the short transition instead of allowing them to change the local terminal's character set or display state. It leaves high-bit bytes unchanged so a UTF-8 terminal displays emoji and non-ASCII text normally. Do not add 8bithex unless you explicitly want UTF-8 bytes displayed as sequences such as [f0][9f][91][8b]. Once the terminal banner appears, the start sequence has already succeeded; do not enter it again as a terminal command.

    Send the following exact sequence to return to the binary protocol:

    +++MESHCORE-TERM-STOP\n

    Closing the serial connection also returns native-USB devices to binary mode. Boards whose USB connector is implemented by a USB-to-UART bridge cannot observe the host closing the port; on those boards, use the stop sequence or reboot the device.

    Both modes use the same port at 115200. Selecting 57600 is not a portable mode switch: native USB CDC devices ignore the requested baud, while USB-to-UART devices really change the UART timing and receive corrupt data. Binary mode is the framed Companion API used by apps and meshcli; close the terminal before opening that port from an app.

    Full Companion differs: its primary USB interface starts in ASCII after boot and automatically switches when it sees a complete <-prefixed Companion frame at an empty prompt. The explicit start/stop tokens remain available. See Full Companion USB CLI and binary switcher for the state machine and limitations.

    "},{"location":"terminal_chat_cli/#commands","title":"Commands","text":"

    ESP32 and nRF52 Full Companions include an offline World English Bible lookup:

    get John 3:16\n

    This returns a single complete verse from John, using plain ASCII punctuation with unchanged wording and capitalization, through the local USB/TCP terminal. See John lookup and compression for the source, supported build profiles and memory costs.

    set freq {frequency}\n

    Set the saved LoRa frequency. Example: set freq 915.8. Available over local connections and authorized LoRa CLI; reboot to apply.

    set tx {tx-power-dbm}\n

    Sets LoRa-chip transmit power in dBm. The firmware rejects values outside the radio/board limit; external-PA boards can have a lower input-power ceiling than the radio chip itself.

    set name {name}\n

    Sets your advertisement name.

    get bluetooth.name\nset bluetooth.name {name|default}\n

    Shows or changes the complete Bluetooth device name without changing the mesh advertisement identity. A custom name may contain spaces and is limited to 31 valid UTF-8 bytes. default restores MeshCore-<node name>. The saved change takes effect after reboot. get ble.name and set ble.name are short aliases.

    get bluetooth.mac\nset bluetooth.mac {address|random|random-every-boot|random-after-connect|default}\nget bluetooth.stealth\nset bluetooth.stealth {on|off}\n

    Shows or changes the Bluetooth identity on Companion builds with BLE. A literal address must be BLE random-static, such as C2:11:22:33:44:55. random generates and saves one address; random-every-boot generates a new one on each startup (random everyboot is also accepted). random-after-connect keeps its address across unused power cycles, then rotates on the next boot after an authenticated connection. default or clear restores the factory address without changing the stealth flag. Reboot, forget the old phone entry, and pair again after an address change. get ble.mac and set ble.mac are short aliases.

    The independent bluetooth.stealth flag defaults off and preserves the chosen address policy. With it on, the node is discoverable until its first authenticated pairing, then accepts only that bonded peer. Custom and saved random addresses keep the bond across boots; rotating modes reopen pairing when the address rotates, leaving stealth enabled. It still transmits the directed or allowlisted packets BLE requires for reconnection. Repeating on keeps the bond. Send off, then on, then reboot to reopen pairing manually. ble.stealth is the short alias. Flag changes require reboot.

    set lat {latitude}\n

    Sets your advertisement map latitude. (decimal degrees)

    set lon {longitude}\n

    Sets your advertisement map longitude. (decimal degrees)

    set dutycycle {percent}\n

    Sets the transmit duty cycle limit (1-100%). Example: set dutycycle 10 for 10%.

    set af {air-time-factor}\n

    Sets the transmit air-time-factor. Deprecated - use set dutycycle instead.

    get powersaving\nset powersaving {on|off}\n

    Shows or changes Companion device power saving. On ESP32 this controls CPU and GPS idle behavior; it does not change LoRa RXPS or WiFi modem sleep.

    get usb.logging\nset usb.logging {on|off} [reboot]\n

    Shows or changes persistent live USB debug and packet output in an ordinary USB-loggable Companion or Full Companion. USB Companion and Full start off on a fresh install with logging disabled to protect framed traffic. nRF52 Full changes its interface count after a reboot and keeps Companion on interface 00. Every ESP32 Full Companion has one TTY and needs no reboot: logging uses the active text terminal, disables framed Binary Companion on USB, and continues accepting set usb.logging off. Turning logging off leaves the port in the normal ASCII terminal, matching fresh firmware. Send +++MESHCORE-TERM-STOP, or let a Companion app send a valid framed probe, to switch it to Binary Companion afterward.

    reboot\n

    Sends an acknowledgement, then reboots the Companion one second later. The delay gives either the USB terminal or the Full Companion TCP terminal on port 5002 time to deliver the reply before its transport disappears.

    get radio.rxps\nget radio.rxps.config\nset radio.rxps {off|on|level 1-10 [preamble 16|32]|rx_us sleep_us}\n

    Shows or changes LoRa receive duty cycling on supported radios. get radio.rxps.config also reports the saved level and preamble assumption so automation can restore a level-based preference exactly. Fresh Cascade builds select level 8 with a 16-symbol timing assumption. The preamble argument controls the RXPS calculation; it does not change the physical wire preamble. A configured level is the minimum: when faster SF/BW settings shorten the timing window, firmware raises the effective level only as far as needed, up to level 10. Starting with v1.17.1.5, SF5-SF8 packets normally use a 32-symbol physical preamble. Firmware selects 64, then 128, only when every shorter choice fails to enable RXPS at any level. The configured values remain unchanged, so every radio change recalculates from the saved minimum. A slower tuple returns to that exact level and the shortest viable wire preamble.

    If neither adjustment leaves enough time for the radio to wake, RXPS stays logically enabled but receives continuously instead of rejecting the radio setting or starting an invalid duty cycle. The Companion terminal's get radio.rxps reports this as mode=continuous-fast and reports any effective-level or effective-preamble adjustment; get radio.rxps.config reports the persisted preference. A later compatible SF/BW change resumes duty cycling without another RXPS command.

    For the SX1262+TCXO boards tested here, these fast combinations are the useful RXPS boundary profiles (CR does not change the RXPS preamble timing):

    SF BW (kHz) Wire preamble Effective preamble Minimum effective level RX / sleep 7 500 32 32 7 2731 / 6101 us 6 250 32 32 7 2731 / 6101 us 5 125 32 32 7 2731 / 6101 us 5 250 64 64 8 1252 / 6424 us 6 500 64 64 8 1252 / 6424 us 5 500 128 128 8 626 / 6398 us 5 62.5 32 16 10 4096 / 6272 us

    The 64- and 128-symbol rows require the v1.17.1.5-or-newer adaptive-preamble contract on every sender that may reach the RXPS receiver. Cascade/USA builds on a Heltec V4 and WisMesh Tag (RAK4631 target) passed 16/16 packets in each direction at both SF5/BW250/64 and SF5/BW500/128, CR5, 909.950 MHz. An older sender makes a long-preamble timing window unsafe, so use continuous RX for a mixed deployment. Retry packets use the same physical preamble as other packets.

    get wifi.powersave\nset wifi.powersave {none|min|max}\n

    Shows or changes the persisted WiFi modem-sleep policy on ESP32 WiFi Companion builds. Full Companion requires at least min while BLE is present and rejects none. The WebConfig WiFi card and the normal binary Companion protocol expose the same setting; binary clients do not need the terminal-start token. On an ESP32 Full Companion whose primary mesh radio is ESP-NOW, max is also unavailable because maximum modem sleep can make the station miss ESP-NOW broadcasts, which the access point does not buffer. A previously saved max value is capped to and reported as min, and a new max selection is rejected. Such a WiFi/BLE/primary-ESP-NOW build therefore uses min.

    get espnow.channel\nset espnow.channel <1-13>\n

    On builds whose primary mesh radio is ESP-NOW, this shows or saves the primary radio's channel. The default is channel 1. Use only a channel permitted in your region, and restart or power-cycle the node after changing it. Every primary ESP-NOW peer must use the same channel.

    On Full Companion, the setup AP and infrastructure-WiFi station also share that channel, so the router's 2.4 GHz radio must remain fixed to it. Power saving does not permit separate channels. wifi.powersave max is unavailable on these ESP32 Full builds and min is the coexistence setting. This channel setting is also distinct from bridge.channel, which controls the optional ESP-NOW bridge transport. An updated LoRa-primary ESP-NOW bridge can join this raw primary transport by selecting the same channel and running set bridge.format raw; its default wrapped format remains the bridge-to-bridge protocol.

    get radio.rxgain\nset radio.rxgain {on|off}\nget radio.fem.rxgain\nset radio.fem.rxgain {on|off}\nget radio.fem.txgain\nset radio.fem.txgain {on|off}\n

    radio.rxgain changes the radio chip's boosted receive-gain mode. The FEM settings control the external receive-path LNA or transmit-path gain on supported boards. Changes are applied immediately and saved across reboots; changing either receive-gain path also recalibrates the radio noise floor. Boards without the respective control report it as unsupported.

    time {epoch-secs}\n

    Set the device clock using UNIX epoch seconds. Example: time 1738242833

    advert\n

    Sends an advertisement packet

    clock\n

    Displays current time per device's clock.

    ver\n

    Shows the device version and firmware build date.

    card\n

    Displays your 'business card', for others to manually import

    import {card}\n

    Imports the given card to your contacts.

    list {n}\n

    Lists favorite contacts first, then all remaining contacts. Each group is ordered by the most recent advertisement. Optional {n} limits the displayed contacts after applying that order.

    show\nshow adverts {on|off}\nshow channels {on|off}\nshow emergency {on|off}\n

    Controls unsolicited receive output in the USB terminal. Plain show reports the current settings, and show {category} reports one category. At boot, advertisements and ordinary channel messages are hidden while #emergency messages are shown. The three controls are independent, so show channels on does not override show emergency off.

    These filters affect terminal printing only. Messages still enter the offline queue and are delivered through the binary Companion protocol. Changes remain active when switching between terminal and binary mode and reset to their defaults after reboot.

    to\n

    Shows the name of current recipient contact. (for subsequent 'send' commands)

    to {name-prefix}\n

    Sets the recipient to the first matching contact (in 'list') by the name prefix. (ie. you don't have to type whole name)

    path\n

    Shows the saved outgoing path for the current to recipient. This command and all path changes require a recipient to be selected first.

    path direct\npath clear\npath {hop-hash...}\n

    Sets the outgoing path used by subsequent login, send, and cmd commands. direct selects a zero-hop route. clear forgets the saved route, causing the next operation to use flood routing and allowing normal path discovery to learn a replacement.

    Explicit paths use spaces, commas, or a mixture of both between hop hashes. Each hop must contain exactly 2, 4, or 6 hexadecimal digits, and every hop in one path must use the same width. Whitespace and hexadecimal letter case do not matter. The setting is saved with the selected contact.

    For example:

    to Hilltop Repeater\npath A1B2C3,D4E5F6\npath 7773D0 7E7662\npath\nlogin my-admin-password\n
    login {admin-password}\n

    Sends a remote login request to the current recipient. Select a repeater, room, or other remotely managed node with to {name-prefix} first. The password is masked with * while it is entered and must be 1-15 UTF-8 bytes; longer passwords are rejected instead of truncated. Login uses the route shown by path: a known or explicitly set route is direct, while an unknown route is flooded.

    Login results arrive asynchronously. A successful modern response displays the remote ACL permissions byte and server protocol level. A wrong password, an unreachable target, or a server that does not support remote login normally produces a timeout because those nodes do not send a rejection packet.

    cmd {remote-command}\n

    Sends CLI data to the current recipient. Wait for the login result before sending the first command. The remote node applies its own ACL permissions, and any reply appears asynchronously as CLI -> from {name}. The response window is 300% of the route estimate. A routed send is displayed as DIRECT via path {hop,...} with the exact prefixes copied into the packet; in MeshCore, DIRECT is the route class for an explicit path, not a synonym for zero hops. A matched reply also shows its local round-trip time, measured from queueing the command through receiving the result. This includes both radio directions and remote execution; it is not execution-only CPU time. Only one terminal cmd can be pending at a time.

    For example:

    to Hilltop Repeater\nlogin my-admin-password\nLOGIN -> Hilltop Repeater accepted (ACL permissions 0x03, server v13)\ncmd ver\ncmd get radio\n

    The exact commands and permissions depend on the target firmware. cmd does not run a command on the local Companion; it sends the text over LoRa to the selected node.

    Incoming direct-route messages are labeled ROUTED, not DIRECT. Forwarders consume direct-route prefixes as the packet travels, so the destination cannot recover the reply's actual hop history from the received packet. Use trace when the return route itself must be verified.

    send {text}\n

    Sends the text message (as DM) to current recipient.

    trace\n

    Traces the saved round-trip route to the current recipient and displays the SNR at each hop. Select the recipient first with to {name-prefix}.

    trace {name-prefix}\n

    Traces a recipient directly without changing the current to selection. A trace requires a known direct path; use normal messaging or path discovery first if the terminal reports that no valid path is available. Only one terminal trace can be pending at a time, and a missing response is reported as a timeout.

    For example:

    to Hilltop Repeater\ntrace\ntrace Downtown\n

    The displayed route uses one- or two-byte node hashes and per-hop SNR values. Saved three-byte paths are traced with two-byte prefixes because the trace packet format has no three-byte hash-size mode.

    To trace an explicit route instead of a saved contact path, provide the prefix size followed by the complete ordered route:

    trace path 1 12 34 56 34 12\ntrace path 2 1234,ABCD,5678,ABCD,1234\ntrace path 4 12345678, ABCDEF01 89ABCDEF, ABCDEF01,12345678\n

    Prefix separators may be spaces, commas, or any mixture of them. Each prefix must contain exactly 2, 4, or 8 hexadecimal digits for a 1-, 2-, or 4-byte trace respectively. Three-byte traces are not supported.

    The prefixes are used exactly in the order entered. To receive the trace result, enter the complete outward route followed by its return route, as in the mirrored examples above. A route that does not return to this node will eventually report a timeout.

    reset path\n

    Resets the path to current recipient, for new path discovery. This is retained as an alias for path clear.

    public {text}\n

    Sends the text message to the built-in Public group channel.

    channels\n

    Lists the configured channel slots and names without exposing their secrets.

    channel {name-or-slot} {text}\n

    Sends a message to any configured channel by its exact name or numeric slot. Use the slot shown by channels when a channel name contains spaces.

    For example:

    channels\nchannel #rgdata Hello from Eugene\nchannel 2 Another message\nshow channels on\n

    Messages are UTF-8. Emoji use multiple bytes toward the available message length, which also includes the sender-name prefix added over the air.

    "},{"location":"usb_serial_backpressure/","title":"Native USB backpressure and radio liveness","text":"

    ESP32-S2/S3 builds using native TinyUSB CDC (ARDUINO_USB_MODE=0 and CDC-on-boot) must not wait for a computer to read USB output. In the bundled Arduino-ESP32 2.0.17 core, USBCDC::write() can wait indefinitely for transmit space; its configured timeout bounds a mutex, not that wait. This can stop the same loop that services LoRa, even while the USB connection still appears open.

    "},{"location":"usb_serial_backpressure/#firmware-behavior","title":"Firmware behavior","text":"

    Host software must also keep reading independently of command writes. A serial relay should start its reader before the first command, use finite write and response deadlines, avoid discarding received packet logs, and cancel I/O during shutdown. A response timeout must not allow a late reply to satisfy another command: the CLI has no transaction identifiers. Updating firmware alone does not correct an indefinite host-side serial write.

    "},{"location":"usb_serial_backpressure/#regression-checks","title":"Regression checks","text":"

    Run only one PlatformIO process in the checkout at a time.

    python test/test_esp32_tinyusb_nonblocking.py\npython test/test_esp32_tinyusb_role_hygiene.py\npython test/test_esp32_tinyusb_cooperative_output.py\npython test/test_esp32_usb_serial_hygiene.py\npython test/test_nrf52_usb_logging_contract.py\npio test -e native -f test_nrf52_debug_output -f test_serial_packet_log -f test_serial_mode_switch -f test_mesh_tables\n

    The first test compiles the real USB facade against a simulated 64-byte FIFO, including stopped readers, reconnects, protocol transitions, and other-platform fallbacks. Host simulations cannot establish that every real USB driver or endpoint failure has recovered.

    A Full Station G2 validation build with USA Cascadia radio settings and the Cascade profile can be made using the normal build entry point. The portable standard recipe preserves the deployed partition layout but omits LoRa OTA; it still has the browser firmware uploader. The auto/full recipe instead enables the expanded feature set and partition layout: its merged image is not an app-only update for a device with the legacy layout.

    MESHDEBUG_OVERRIDE=on PACKET_LOGGING_OVERRIDE=on \\\n  bash build.sh build-firmware Station_G2_repeater --build-profile full \\\n  --radio-preset usa-cascadia --profile cascade\n

    Before installing on hardware, preserve the device identity, preferences, and existing partition layout. Then verify USB command responses and repeated LoRa logins with the relay running, paused, and stopped, including a host that leaves USB open without reading. Check LoRa recovery separately from USB OUT recovery; fixing transmit backpressure does not prove an unrelated OUT endpoint fault is resolved. Do not erase or repartition the radio as part of this test.

    "},{"location":"v4_pixel5_font_trial/","title":"Small-screen message fonts","text":"

    Small-screen Companion builds automatically select a compact font for received message text and the channel/sender line, including Ch 0 Public. SSD1306 and SH1106 OLEDs and the U8g2 T-Echo Card display use this on panels smaller than 160 pixels on both axes. ST7735 TFTs now use their native 160x80 dimensions and normal font. The shared renderer selects by panel dimensions, including rotation:

    Panel size Font Capital height Line spacing At least 128 x 64, or 64 x 128 rotated; both axes below 160 Squeezed Regular 6 6 pixels 8 pixels Smaller panels, including 72 x 40, 128 x 32 and 64 x 48 Picopixel 5 pixels 7 pixels Either axis at least 160 Normal display font Depends on panel Depends on panel

    Both fonts allow one pixel for descenders and one blank pixel row between lines. Character widths vary: most letters advance by 4 pixels, with narrow letters taking less space and M and W taking 6 pixels. Squeezed Regular 6 is a public-domain font by Oliver Kraus. Its bitmap data is stored as constants without heap allocation.

    On a 128 x 64 OLED with the single-button message reader, the compact channel/sender line starts at y=8 and message text at y=16. The header uses the same small font. Five complete 6px-font rows fit above the navigation hint at y=56. The software comparison fits three representative 160-character messages in those five rows. Capacity depends on the characters; messages with many wide letters can still overflow. Text wraps at character boundaries, with ... on the last line if necessary. Unsupported characters appear as ?. Long channel/sender names are ellipsized to stay on their own line. An enabled channel footer reserves its own space. Readers without the button hint retain the y=14 origin and y=22 message text.

    On V4 and other single-button builds using this reader, the home screen says hold button: inbox. Hold the user button for about 1.2 seconds to open it. The bottom continuously shows 4 <<- 2 <- tap -> 1 ->> 3 hold: Exit without alternating or blinking. Double tap goes to the previous message, one tap advances, three taps select the next channel, and four taps select the previous channel. Channel selection cycles through All, configured channels, and direct messages, starting at the newest message in each filter. The header shows All, Ch N, or DM, including when a channel is empty. Hold to return home; advancing past the last message also returns home. The complete hint fits in the V4's existing 8-pixel footer at y=56, leaving five message rows. The footer keeps the same 6px font, using 108 pixels with 10-pixel margins on the V4. Narrower screens first use the compact hold:X hint without the extra spaces or arrow dashes, then split the same controls across fixed lines. The hint appears even when the inbox is empty. These are button taps. Touchscreen and joystick builds retain instructions appropriate to their controls. During the first eight seconds after startup, holding the button on an ordinary home page enters CLI rescue instead; wait for that startup window to finish before opening the inbox. Exiting a message preview and the WiFi setup page's hold action remain available immediately.

    The hidden John reader, opened by a long press on the radio page, uses the same button hints. Three taps jump to the first verse of the next chapter; four taps jump to the first verse of the previous chapter. Chapter navigation stops at the beginning and end of the book. It reserves the hint before pagination and resumes saved bookmarks at the page containing the same text, even after the available page size changes.

    Larger display classes keep their existing font. Menus, Bluetooth PINs and WiFi setup QR codes keep their normal layout. TFT drivers use native panel coordinates; the SenseCAP Indicator retains its existing scaling. The normal OLED font uses 7-pixel letters, 8-pixel line spacing and 6-pixel character advances.

    The preview buffer holds a complete 160-byte MeshCore message plus its terminator. Previously the main message UI allocated 78 bytes, leaving room for only 77 bytes of text. It still retains 32 previews; larger records add about 2.8 KB of RAM. The firmware RAM guard includes that increase. This history is separate from the offline queue and its mOTA policy.

    The tiny 72 x 40 T-Echo Card interface now previews the latest received message below its status bar, with three small-font message rows. A button press dismisses it. It stores one full message, and shows ... when the screen fills. Incoming text does not replace an active Bluetooth pairing PIN.

    "},{"location":"v4_pixel5_font_trial/#v4-hardware-trial","title":"V4 hardware trial","text":"

    Enable platformio.nimble.ini in the ignored platformio.local.ini as shown in the NimBLE trial guide, then run:

    OUTPUT_DIR=.releases/v4-smallfont bash build.sh build-firmware \\\n  heltec_v4_2_v4_3_companion_radio_full_femon_nimble \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-squeezed6-trial \\\n  --radio-preset usa-cascadia --profile cascade --standard --require-ota\n

    This build keeps NimBLE, 350 contacts, 40 channels, the V4's 512-frame PSRAM queue, USB mOTA sending and WiFi OTA support. The smaller font is also the default in ordinary small-screen Companion builds from this source. The older _nimble_pixel5 environment name remains available, but also uses the automatic 5px/6px selection.

    Use the application .bin for WiFi OTA. A clean USB install uses the merged image at address 0. When manually writing the application at 0x10000, an existing OTA selector may still boot app1. Check the running ver afterward. On the matching V4 16 MB layout only, clearing the 8 KB otadata partition at 0xe000 selects the newly written app0 without clearing NVS/settings. Confirm the partition table and verify the app write before changing that selector.

    A custom build can set -D UI_SMALL_MESSAGE_FONT=0 to restore the old font and spacing. Remove any explicit UI_MSG_PREVIEW_SIZE flag too if the old preview capacity is desired. These are compile-time options, not CLI commands. No settings erase is necessary when changing between matching V4 layouts.

    "},{"location":"v4_pixel5_font_trial/#verification","title":"Verification","text":"
    python3 -B test/test_ssd1306_picopixel.py\npython3 -B test/test_firmware_ram.py\npio test -e native -f test_display_driver -f test_companion_message_history\n

    The native tests exercise both capital heights, automatic font selection, 160-character messages, complete rows at display edges, long sender lines, navigation hints, reserved footers, overflow markers and tiny/rotated screen geometry. The additional rendering comparison checks all 95 printable ASCII glyphs in each font, under address/undefined-behavior sanitizers. Picopixel is compared pixel for pixel with Adafruit GFX; Squeezed Regular 6 is compared with the upstream BDF fixture independently of the converted C++ tables. This needs a cached PlatformIO Adafruit GFX library; set MESHCORE_GFX_LIBRARY to its directory if needed. It reports a skip when the library is absent. The native tests do not require that dependency. Run only one PlatformIO command at a time.

    "},{"location":"v4_pixel5_font_trial/#automatic-5px6px-results-2026-09-08","title":"Automatic 5px/6px results, 2026-09-08","text":"

    Source revision 469b47d1 passed five representative firmware builds. All five retained the same static RAM usage and startup heap margin as their earlier 5px builds. The four Full profiles also passed required OTA packaging.

    Hardware/profile Display Selected capital height RAM beyond the required startup budget, bytes V4.2/V4.3 Full NimBLE SSD1306 6 pixels 88,072 T096 Full, FEM on ST7735 6 pixels 24,778 Station G3 ESP32 Full SH1106 6 pixels 82,080 RAK3401 Full SSD1306 6 pixels 46,108 T-Echo Card BLE Companion U8g2, 72 x 40 5 pixels 30,992

    The native display/history suites passed 36 tests. The Python font, RAM, pairing, display-profile, queue and QR checks passed 38 tests, including pixel comparisons for all 95 ASCII glyphs in each font. The actual SSD1306 software rendering matches the approved 6px comparison image pixel for pixel.

    Both NimBLE-V4-VM and NimBLE-V4-Trial were flashed with v1.17.1.5-halo-keymind-cascade-squeezed6-trial-469b47d1. Their partition tables and application hashes were verified, their settings retained, and running versions confirmed. Each passed 201 USB protocol requests without error flags. The Mercerwood V4 reconnected over bonded Bluetooth at MTU 179 and retained the factory-address policy.

    The XIAO sent a private 160-byte LoRa message to the Mercerwood V4. All bytes arrived, and the V4 stayed responsive through 20 seconds of display refreshes with no uptime reset or error flags. Both temporary channel settings were restored. After that interval the V4 had 147,196 bytes of free internal heap, a minimum of 146,328 bytes, and a largest free block of 139,252 bytes.

    These are short functional checks. Physical readability remains a user judgment. A message with many wide characters can still need truncation.

    V4 application size: 1,778,936 bytes (1,152 bytes larger than the earlier 5px trial). SHA-256: 8e6797b90741bf013b3757668e5a23378ac8c0b0ed9c75830939f13305c56a68.

    "},{"location":"v4_pixel5_font_trial/#earlier-5px-hardware-and-build-results-2026-09-08","title":"Earlier 5px hardware and build results, 2026-09-08","text":"

    Source revision eeea15ef passed seven representative firmware builds:

    Hardware/profile Display RAM beyond the required startup budget, bytes V4.2/V4.3 Full NimBLE Picopixel SSD1306 88,072 T096 Full, FEM on ST7735 24,778 Station G3 ESP32 Full SH1106 82,080 RAK3401 Full SSD1306 46,108 Wireless Tracker Full NimBLE capacity trial ST7735 28,022 Wio Tracker L1 Full SH1106 45,744 T-Echo Card BLE Companion U8g2 30,992

    These are linked-capacity checks before runtime allocation, not live free heap measurements. The six Full profiles also passed their required OTA packaging checks. The T-Echo Card row is a BLE Companion build.

    Both physical V4.3 nodes, NimBLE-V4-VM and NimBLE-V4-Trial, were flashed, their image hashes verified, and their running versions confirmed. Each passed 201 USB protocol requests with zero reported error flags. The Mercerwood V4 also reconnected over authenticated, bonded Bluetooth with MTU 179 and the existing factory-address policy.

    A private XIAO-to-V4 LoRa test delivered all 160 message bytes. The V4 continued responding during 20 seconds of message redraws, with zero error flags. Both temporary channel configurations were restored. Free internal heap after that interval was 147,200 bytes, minimum 146,340 bytes, and the largest free block was 139,252 bytes. These are short functional checks; physical readability remains a user judgment.

    A matching pre/post-boot Mercerwood measurement showed the longer preview using 2,816 additional PSRAM bytes, with unchanged internal free heap. Boards without PSRAM use their ordinary RAM for the larger preview records.

    The native display/history suites passed 31 tests. The Python font, RAM, pairing, display-profile, queue and QR checks passed 38 tests. The Adafruit comparison covers all 95 printable ASCII glyphs.

    V4 application SHA-256: d914d80124b499d7b719f8427f3794ab1b6d15fd0112e3ef89ba2fac26b5a974.

    "},{"location":"releases/1.17.1.5-doc-audit/","title":"Markdown audit \u2014 1.17.1.5 USA Cascade","text":"

    Audited on 2026-09-05 against firmware source 26303793, the resolved build configuration, and the qualified 1.17.1.5 staging manifests. The scope was all 71 tracked Markdown files present at the start, including root documents, docs, examples, tools, tests, variants, and vendored library documentation. Generated/ignored build directories and external repositories were outside that scope.

    "},{"location":"releases/1.17.1.5-doc-audit/#corrections","title":"Corrections","text":""},{"location":"releases/1.17.1.5-doc-audit/#verification-and-limits","title":"Verification and limits","text":"

    All original Markdown files were scanned for relative links, section anchors, source-path references, version/default claims, and role/OTA terminology. Flagged operator instructions were checked against CommonCLI, its observer extension, Companion command handlers, board configuration, and release manifests. Brace-expanded source references and SDK-relative paths were retained as such. Historical measurements, changelogs, upstream findings, contribution/security policies, and licenses were not rewritten as new validation results.

    The documentation site passed a strict MkDocs Material build. Relative repository links and section anchors were checked separately, including documents outside the MkDocs source directory. Console and current bootloader links were checked on their primary sites. This was not a crawl of every third-party URL or a new hardware/firmware qualification; no claim is made that every board was physically tested or every protocol statement independently re-proved.

    "},{"location":"releases/1.17.1.5-doc-audit/#original-file-coverage","title":"Original file coverage","text":"

    \u201cRetained\u201d means no correction was identified by this documentation audit; it does not certify all historical statements as current hardware behavior.

    File Disposition AGENTS.md Scanned; retained ALERTS.md Scanned; retained CHANGELOG.md Historical record retained CONTRIBUTING.md Scanned; retained MEMORY_MONITORING.md Scanned; retained MQTT_IMPLEMENTATION.md Corrected or clarified MQTT_INTERNALS.md Corrected or clarified MQTT_OWNERSHIP.md Historical record retained MQTT_SNMP.md Corrected or clarified README.md Corrected or clarified RELEASE.md Corrected or clarified RESTORE_UPSTREAM_NOTES.md Historical record retained SECURITY.md Scanned; retained STABILITY_TESTABILITY_HANDOFF.md Historical record retained UPSTREAM_BUGS.md Scanned; retained WEB_CONFIG_REVIEW.md Corrected or clarified arch/stm32/Adafruit_LittleFS_stm32/README.md Vendored documentation/license retained arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/LICENSE.md Vendored documentation/license retained arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/README.md Vendored documentation/license retained docs/WiFi.md Corrected or clarified docs/cli_build_matrix.md Corrected or clarified docs/cli_command_availability.md Corrected or clarified docs/cli_commands.md Corrected or clarified docs/companion_offline_queue.md Scanned; retained docs/companion_protocol.md Scanned; retained docs/companion_radio_full.md Corrected or clarified docs/docs.md Scanned; retained docs/esp32_memory_budget.md Scanned; retained docs/faq.md Corrected or clarified docs/filter_tool.md Scanned; retained docs/firmware_picker.md Corrected or clarified docs/flood_filtering.md Scanned; retained docs/full_companion_features.md Corrected or clarified docs/full_companion_usb_switcher.md Corrected or clarified docs/gps_tracking.md Scanned; retained docs/halo_keymind_settings.md Corrected or clarified docs/hardware_validation_checklist.md Corrected or clarified docs/host_cli_service.md Scanned; retained docs/index.md Corrected or clarified docs/kiss_modem_protocol.md Scanned; retained docs/lora_ota_automation.md Corrected or clarified docs/mbedtls-tls-footprint.md Scanned; retained docs/nrf52_companion_storage_recovery.md Scanned; retained docs/nrf52_power_management.md Scanned; retained docs/number_allocations.md Scanned; retained docs/ota_easy.md Corrected or clarified docs/ota_meshtower_v2_sdcard.md Corrected or clarified docs/ota_nrf52_bootloader_update.md Corrected or clarified docs/ota_nrf52_qspi.md Corrected or clarified docs/ota_protocol.md Corrected or clarified docs/ota_user_guide.md Corrected or clarified docs/packet_format.md Scanned; retained docs/payloads.md Scanned; retained docs/qr_codes.md Scanned; retained docs/rak3401_mota_chain.md Historical record retained docs/releases/1.17.1.5.md Corrected or clarified docs/spiffs_regular_file_reads.md Scanned; retained docs/stats_binary_frames.md Scanned; retained docs/telemetry_decoder.md Scanned; retained docs/terminal_chat_cli.md Scanned; retained docs/usb_serial_backpressure.md Scanned; retained examples/host_cli_service/README.md Scanned; retained lib/PsychicMqttClient/CHANGELOG.md Vendored documentation/license retained lib/PsychicMqttClient/README.md Corrected or clarified mesh-america/README.md Corrected or clarified test/README.md Scanned; retained test/test_mqtt_prefs_codec/FIXTURES.md Historical record retained tools/mota/README.md Scanned; retained tools/sensecap_indicator_font/README.md Scanned; retained tools/sensecap_indicator_rp2040/README.md Scanned; retained variants/sensecap_indicator-espnow/README.md Scanned; retained"},{"location":"releases/1.17.1.5/","title":"MeshCore 1.17.1.5 Dev - USA Cascade","text":"

    Use feature switches by role for exact on/off commands for Companion, Repeater, Room Server, and Sensor, including MQTT, logging, power saving, GPS, and board exceptions. Open the USB web console for the default ASCII terminal on Full Companion and infrastructure images.

    The firmware identifier and main release tag follow v1.17.1.5-halo-keymind-cascade-dev-<commit>, matching the 1.17.1.4 naming convention. Supplemental pages retain the repeater-room-, utility-, logging-, lora-ota-, and full-profiles- prefixes where those outputs exist. The Companion page is marked Latest; the supplemental pages remain development prereleases.

    This release uses the USA/Canada radio preset and Cascade runtime defaults. The release build records the resolved frequency, bandwidth, spreading factor, and coding rate. The offline USA Cascadia fallback is 910.525 MHz, BW62.5, SF7, CR5. Saved node settings survive ordinary firmware updates.

    "},{"location":"releases/1.17.1.5/#memory-corrections-in-aa20e927","title":"Memory corrections in aa20e927","text":"

    All 583 published profiles (the 540-profile matrix plus 43 supplemental Full repeaters) were checked against the new runtime RAM policy. All 11 failing profiles have replacements, and the same queue sharing has been applied to the other 41 nRF52 Full profiles: 52 corrected profiles in total, initially built from aa20e927. The existing release tags stay in place; corrected filenames end in aa20e927, except Wireless Paper Full's capacity-restoring follow-up, 1e4d1e16. Companion remains Latest. Both Mesh America catalogs and the firmware picker point to the replacement files.

    All 43 nRF52 Full Companions keep 256 offline frames normally and lend 128 slots to mOTA while needed. Wireless Paper Full now also shares its queue and keeps 350 contacts and 40 channels. The other six corrected ESP32 Full profiles below use 150 contacts, preserving their 256-frame queue and Full transports. Export contacts before upgrading if you have more than 150; entries beyond that limit may be unavailable and a later save may omit them. This warning applies to those six profiles and the superseded aa20e927 Wireless Paper image.

    Wireless Paper Full's 1e4d1e16 update has 256 message slots normally and 128 while an mOTA session borrows its workspace. Idle WiFi listening leaves all 256 slots available. If more than 128 unread messages are queued, sync them with an app before starting mOTA. Source stop/disconnect restores all 256 slots and frees ESP32 scratch buffers. USB, Bluetooth, WiFi, the display and the partition layout are retained. The application is 1,869,976 bytes; use its app-only .bin for a compatible WiFi update, or its merged image for USB installation.

    The T096 and T1 LoRa OTA repeater corrections allocate the diagnostic manual staging buffer only when requested, recovering about 4 KiB at startup. Ordinary mOTA uses its existing staging store. ota dev clear releases a manual stage; allocation failure reports an error before writing. These same-version repairs require an explicit exact-target manual LoRa installation or USB; forward-only trusted autoinstall does not select an equal-version repair.

    Available RAM below means capacity before startup allocations, not free heap after boot. The Wireless Paper row includes its latest 1e4d1e16 update:

    Failing original profile Original bytes Replacement bytes Required bytes Generic_ESPNOW_companion_radio_full 101,920 139,456 136,192 Heltec_Wireless_Paper_companion_radio_full 150,144 161,336 152,576 Heltec_Wireless_Tracker_companion_radio_full 148,496 186,032 169,986 Heltec_ct62_companion_radio_full 99,104 136,640 136,192 Heltec_t096_companion_radio_full_femon 54,724 74,060 73,728 Heltec_t1_companion_radio_full 55,044 74,380 73,728 Heltec_v3_companion_radio_full 143,840 181,376 173,056 Xiao_C3_companion_radio_full 98,880 136,416 136,192 heltec_tracker_v2_companion_radio_full_femon 133,136 170,672 169,986 Heltec_t096_repeater_lora_ota_no_external_sensors 61,588 65,676 65,538 Heltec_t1_repeater_lora_ota_no_external_sensors 61,964 66,052 65,538

    Each replacement has a matching .memory.json with its ELF hash, RAM budget and firmware-file hashes. The complete audit distinguishes original binary/log calculations from new ELF checks. All 52 exact firmware builds, their native file pairs, and 150 native mOTA tests passed, alongside memory, allocation-failure, shared-queue and picker regressions. Wireless Paper's follow-up also passed complete TCP mOTA transfers with address/leak sanitizers, repeated disconnects, queue-full refusal, idle listening and USB/TCP ownership changes. Physical boot/load/soak validation remains pending.

    "},{"location":"releases/1.17.1.5/#g3-usb-disappearance-with-power-saving-enabled","title":"G3 USB disappearance with power saving enabled","text":"

    The published 26303793 firmware has an ESP32 native-USB sleep bug that matches a Station G3 report: USB disappears from the connected Pi after a few minutes, the display button stops responding, but the repeater still answers over LoRa. Hardware confirmation of this report is pending.

    Cascade enables device power saving by default on fresh settings. After two minutes, the repeater can enter light sleep when no serial terminal is open, even though the Pi has enumerated the USB device. Light sleep stops USB service; the radio GPIO can still wake the CPU, while the G3 display button is only polled. Closing a terminal must not be treated as unplugging the USB host. Espressif documents the underlying native USB sleep limitation.

    To enable USB logging on an ESP32 1.17.1.5 image with logging support, disable power saving first. Use the role's text terminal, or the repeater's remote admin CLI if USB has already disappeared:

    set powersaving off\nset usb.logging on\nget powersaving\nget usb.logging\n

    The read-back commands should report power saving off and USB logging on. For an image without USB logging, use just set powersaving off and get powersaving to apply/check the sleep workaround. Disabling power saving saves the setting and takes effect immediately; it does not erase the node or change LoRa RX power saving (radio.rxps). If USB does not reappear after disabling sleep remotely, power-cycle once and check that the port and display button remain usable beyond the two-minute mark. Ordinary reflashing can retain the saved power-saving setting, so check it during comparisons with another firmware build too.

    On unified Full infrastructure, also run set powersaving off before enabling USB through set logging.output usb or set logging.output both. These are separate commands. Turning logging off does not automatically restore the saved power-saving setting. nRF52 logging does not need this ESP32 workaround.

    WiFi/MQTT-only logging does not need set powersaving off while the Repeater/Room Server MQTT bridge is running. The original 26303793 code already prevents device sleep in that state. Select set logging.output wifi where supported and check get mqtt.running; get mqtt.enabled alone only checks the saved preference. MQTT-capable Full Companion now uses the same mqtt.enabled and logging.output commands as infrastructure. Disabling MQTT preserves broker settings; the MQTT tab exposes the same master switch.

    The source correction keeps native USB serviced while a host is enumerated, including when its terminal is closed. Enabled live USB logging also blocks ESP32 light sleep, even without a host. FreeRTOS can still idle/yield; this does not disable the saved power-saving setting or Companion CPU scaling. With logging off, USB power from a charger still permits sleep.

    The corrected G3 uses its user button as a light-sleep wake source alongside LoRa. Button activity keeps the CPU and display awake for at least two minutes; holding or pressing the button again extends the interval. The usual display timeout can keep the screen on longer. An intentional long-press power-off retains its existing action. After the wake interval, sleep is allowed when power saving is enabled, logging is off, no native USB host is attached, and no running bridge or pending work needs service.

    Host simulations cover both native USB modes, terminal close/reconnect, logging toggles, OTA inhibition, simultaneous button/LoRa wake configuration, and the two-minute interval across timer rollover. The Station G3 repeater download remains the original 26303793 build; continue to use the logging workaround above on that image.

    "},{"location":"releases/1.17.1.5/#t096-full-companion-bluetooth-and-menu-freeze-report","title":"T096 Full Companion Bluetooth and menu freeze report","text":"

    A report against Heltec_t096_companion_radio_full_femon describes no usable Bluetooth despite a displayed PIN, followed by a freeze while stepping through the device menu. Reflashing both with and without a full wipe gave the same result. The exact freeze and Bluetooth behavior still need confirmation on a physical T096 with the source correction below.

    The published 26303793 Full image leaves only 54,724 bytes (53.4 KiB) between the linked heap boundaries. Its static RAM check did not account for the allocations made at startup: the 160x80 color framebuffer alone needs 25,602 bytes, followed by task stacks, the packet pool, filesystem buffers, and message previews. This leaves inadequate runtime headroom. The pinned Bluefruit library also ignores failure to create its BLE/SOC worker tasks, and the sensor menu could write through a failed CayenneLPP allocation. Those are code paths consistent with this report; a PIN is not proof of a working Bluetooth radio.

    The correction applies to every nRF52 Full Companion, keeping its offline queue at 256 frames during normal use. mOTA borrows the upper 128 slots for its roughly 19 KiB session state only when needed, leaving 128 message slots during mOTA use. Starting a source or a TempRadio discovery session acquires this workspace. Stopping or disconnecting the USB/Bluetooth source restores all 256 slots, even if the temporary radio window is still open. A discovery-only session returns the slots when its TempRadio window ends. No heap resizing is involved.

    Unread messages keep their order across both transitions. If more than 128 frames are pending, mOTA refuses to start and asks you to sync unread messages with a Companion app first; it does not discard them to make room. OTA configuration and signer keys survive reuse of the workspace. While mOTA owns it, the normal queue overflow policy applies to the remaining 128 slots.

    The T096 profile requires at least 72 KiB of heap space at link time. It retains 350 contacts, 40 channels, the color display, sensors, USB, Bluetooth, Bluetooth DFU, and USB/Bluetooth mOTA sending. This reserve is space for runtime allocations, not a measurement of free heap after boot. Bluetooth startup now checks both worker tasks and all registered services; a failed partial initialization does not repeat every five seconds. The sensor page reports low memory if its buffer could not be allocated.

    Regression tests transfer complete mOTA containers through the actual shared context, check queue wraparound and message order, and cover source stop, disconnect, conflicting USB/Bluetooth ownership and repeated sessions. They also inject worker-task and GATT-service failures, verify that retries do not initialize the stack again, open the sensor path with a failed allocation, and use the real linker assertion to reject the old heap budget. The original T096 Full download has been replaced by the corrected aa20e927 image on the same release page. Another wipe is not a fix for the old compiled memory budget.

    For the corrected T096 build, use its matching application UF2 through the existing USB bootloader. Preserve the bootloader and node data. Check Bluetooth discovery, pairing and message exchange on battery power and with USB attached; then step through every menu page, return from screen-off, and check USB and Bluetooth mOTA sending. Hardware validation is pending.

    "},{"location":"releases/1.17.1.5/#firmware-roles-and-update-support","title":"Firmware roles and update support","text":"

    Use the exact board's Full Companion image when available. Every Full Companion must pass linked-firmware checks for MOTA sending, its text terminal, and the platform's source transports. ESP32 supplies USB and WiFi sources; nRF52 supplies USB and encrypted Bluetooth sources. Full Companions remain USB-updateable and do not require LoRa self-installation. Optional ESP32 WiFi self-update uses port 8080 where the existing partition layout has two application slots; nRF52 retains Bluetooth DFU with a compatible bootloader.

    Read Full Companion feature switches for on/off commands, USB logging, power saving, WiFi/Bluetooth selection, GPS, MQTT, MOTA sending, and update directions.

    Infrastructure artifacts must have a verified wireless self-update path. Option 3 reports and omits infrastructure on platforms without a wireless updater. The report is ota-excluded-targets.txt. Source/seeder capability alone does not count as self-update support. ESP32 qualification checks both application slots and OTA metadata; nRF52 Bluetooth qualification checks the linked DFU service and its application ZIP. LoRa installation on nRF52 still requires the exact matching OTAFIX bootloader and storage arrangement.

    Classic ESP32 capacity limits are recorded in each artifact's capability manifest. Generic E22, Heltec V2, Meshadventurer, and T-Beam OTA repeaters, plus the TLora MQTT repeater, use 50 neighbors where needed to preserve the required internal RAM reserve. T-Beam MQTT repeater profiles retain the full rule engine with 31 flood rules; constrained classic ESP32 ESP-NOW bridges with logging use 47. Meshadventurer Full uses 100 contacts, 30 group channels, and a 16-frame offline queue while retaining its MOTA and transport features.

    Since 1.17.1.4, this branch also includes USB session and contact-persistence fixes, automatic nRF52 ExtraFS recovery, improved Indicator setup/display handling, compressed MOTA transport, and measured ESP32 memory limits. Qualified internal-flash nRF52840 receivers now reserve 64 KiB of retained RAM to extend application MOTA staging beyond the available flash prefix. This handoff requires the exact board's OTAFIX 2.4.6 bootloader. Install that bootloader before relying on LoRa updates from these new hybrid receiver images: they reject application containers larger than one flash page when the installed bootloader lacks the retained-RAM capability. QSPI/microSD roles and Full Companions retain their existing storage layouts. An interrupted hybrid download must be fetched again after an application restart or power cycle. See the protocol for the exact admission and reset checks.

    "},{"location":"releases/1.17.1.5/#build-using-option-3","title":"Build using option 3","text":"

    The equivalent reproducible command is:

    OUTPUT_DIR=.releases/1.17.1.5-halo-keymind-cascade-dev \\\nbash build.sh build-firmwares-logging-matrix \\\n  --firmware-version v1.17.1.5-halo-keymind-cascade-dev \\\n  --radio-preset usa-cascadia --profile cascade \\\n  --require-ota --skip-kiss --resume --background\n

    Or run bash build.sh, select option 3, set version v1.17.1.5-halo-keymind-cascade-dev, and retain USA Cascadia/Cascade. Option 3 enables the infrastructure OTA requirement by default. It merges supported runtime USB logging into canonical artifacts and builds the expanded ESP32 profiles. It runs one PlatformIO process at a time.

    The background launcher prints its persistent log and status paths. Each target also gets a log in OUTPUT_DIR/build-logs/. Resume accepts only artifacts with the current capability schema and a successful qualification; infrastructure also needs a verified update method. Any failed target leaves the matrix unsuccessful and must be resolved before publication.

    --allow-no-ota is an explicit development override for cable-only infrastructure. Those outputs do not belong in this OTA-required release. Use a separate output directory when changing the radio or settings profile. Use a fresh release output directory after changing the source revision or firmware version so packaging cannot mix old and new binaries.

    "},{"location":"releases/1.17.1.5/#local-release-staging","title":"Local release staging","text":"

    After the matrix completes successfully, scripts/package_cascade_release.py groups the qualified images using the previous GitHub release naming. Each group includes the firmware files, capability manifests, TSV/JSON inventory, checksums, build notes, and Full Companion directions. The staging directory's root FIRMWARE-PICKER-1.17.1.5.html opens the local downloads; the copies inside each release group use the planned GitHub download URLs. Staging does not push commits, create remote tags, or publish a GitHub release.

    "},{"location":"releases/1.17.1.5/#published-release-set-and-small-slot-heltec-v4-updates","title":"Published release set and small-slot Heltec V4 updates","text":"

    The capability JSON and target inventories now report both Bluetooth DFU and LoRa mOTA for all 60 nRF52 receiver profiles. The original report omitted the nRF52 LoRa method. Each affected manifest includes ota_update_requirements.lora with its storage type, accepted package types, exact-base requirement for deltas, and the matching OTAFIX bootloader link. Internal-flash receivers accept in-place deltas; supported QSPI/SD receivers also accept full application packages. The 20 retained-RAM receivers require OTAFIX 2.4.6's matching handoff support. These reports verify the released application bytes and storage layout; they do not certify the bootloader installed on a physical device. Firmware binaries and tags are unchanged. Full Companion mOTA sending remains separate from self-update capability.

    Published firmware tags remain at source commit 26303793. Documentation can advance independently. The original matrix qualified 540 profiles (1,080 firmware files) from 551 attempts; 11 oversized portable attempts have expanded Full replacements. The variant map maps all 1,361 previous-release choices to 1,325 covered choices and 36 excluded entries. Multiple old choices map to the same current artifact.

    For Heltec V4 with an existing compatible 1.25 MiB OTA application slot, the standard heltec_v4_repeater application is 1,179,200 bytes and the lean heltec_v4_repeater_lora_ota_no_external_sensors application is 1,232,024 bytes. Both fit that slot. Start the WiFi uploader with start ota ap, join MeshCore-OTA, and open http://192.168.4.1/update. Upload the application .bin; use stop ota to close an unused uploader. Their merged USB installers write two 6.25 MiB application slots. Expanded Full V4 applications require the larger layout and do not fit a 1.25 MiB slot.

    "}]} \ No newline at end of file