fix: harden ESP-NOW and full companion networking

Add selectable shared ESP-NOW channels, raw/wrapped bridge compatibility, and bounded fragmentation for maximum-size packets.\n\nGive ESP32 Full profiles WiFi coexistence, stabilize WebConfig and USB terminal lifecycles, and keep disabled bridges stopped during observer updates.\n\nUpdate generated UI, firmware selection, documentation, and regression coverage.
This commit is contained in:
mikecarper committed 2026-08-28 16:36:34 -07:00
1 parent c6476b8ba8
commit 2b3b93ab2f
56 files changed
+5783 -2155

No files matched your search

+39
View File
@@ -2884,6 +2884,32 @@ apply_esp32_full_size_profile() {
fi
}
apply_esp32_full_async_tcp_profile() {
local env_name=$1
if [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" != "ESP32_PLATFORM" ] \
|| { [ "$ESP32_FULL_BUILD" != "1" ] \
&& ! is_esp32_companion_radio_full_target "$env_name"; }; then
return 0
fi
# AsyncTCP 3.x defaults to a 16 KiB task stack. A feature-complete image can
# have less than that as one contiguous internal-DRAM block by the time its
# WebConfig server starts, even when total/PSRAM capacity remains plentiful.
# Use the library's documented 4 KiB reduced configuration. Eight KiB let
# the task start on the most constrained measured Full Companion, but left
# too little internal heap to retire successive HTTP responses reliably.
# Pin its event/callback task to the application core as recommended by the
# library. Leaving it on the WiFi/Bluetooth core lets a long response to a
# lossy client starve the shared radio until even ARP recovery fails.
# Also disable ESPAsyncWebServer's optional 2-MSS in-flight gate. A slow peer
# can acknowledge one MSS at a time; after a fixed response crosses the TCP
# window the gate then waits forever for two MSS of space. WebConfig's
# response fillers are fast flash/memory copies, and the TCP send buffer still
# provides back-pressure without that gate.
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCONFIG_ASYNC_TCP_STACK_SIZE=4096 -DCONFIG_ASYNC_TCP_RUNNING_CORE=1 -DASYNCWEBSERVER_USE_CHUNK_INFLIGHT=0"
}
apply_repeater_neighbor_capacity() {
local env_name=$1
local max_neighbours=$REPEATER_MAX_NEIGHBOURS
@@ -3208,6 +3234,18 @@ apply_companion_radio_full_profile() {
# mode when WiFi is unavailable and serve the same folder protocol there.
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWIFI_OTA_SEEDER=1 -DCOMPANION_FEATURE_NETWORK_TERMINAL=1 -DCOMPANION_FEATURE_MEMORY_DIAGNOSTICS=1"
# ESP-NOW is the primary mesh radio on these two layouts, so conventional
# Companion WiFi must share its protocol mask and fixed channel instead of
# resetting the driver to B/G/N on an arbitrary access-point channel. The
# runtime policy keeps B/G/N enabled for phones and normal routers while LR
# remains available for the mesh packets.
case "${env_name,,}" in
generic_espnow_companion_radio_full|\
sensecapindicator-espnow_companion_radio_full)
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_ESPNOW_RADIO=1 -DMESH_ESPNOW_CHANNEL=1"
;;
esac
# Qualified BLE-based Full recipes did not previously need WiFi credentials.
# Supply the same first-boot setup placeholders used by ordinary WiFi
# Companion recipes; saved credentials and WebConfig replace them at runtime.
@@ -3689,6 +3727,7 @@ build_firmware() {
apply_esp32_lora_ota_size_profile "$env_name"
apply_esp32_constrained_companion_size_profile "$env_name"
apply_esp32_full_size_profile "$env_name"
apply_esp32_full_async_tcp_profile "$env_name"
apply_repeater_neighbor_capacity "$env_name"
apply_nrf52_size_profile "$env_name"
apply_lora_ota_no_external_sensors_profile "$env_name"
+90 -9
View File
@@ -27,7 +27,7 @@ to MQTT unless its target name also contains `mqtt`.
| `*_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 |
| USB, BLE, or serial companion | No | No | Uses the transport named by the target instead |
| `*_repeater_bridge_espnow` | Build-dependent on ESP32 | No | Uses ESP-NOW for its bridge; a FULL build also exposes the TCP 5001 LoRa-OTA seeder whenever WiFi is usable |
| `*_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 |
@@ -227,7 +227,71 @@ set wifi.pwd your-password
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.
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.
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:
```text
get espnow.channel
set espnow.channel 6
reboot
```
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.
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:
```text
get bridge.format
set bridge.channel 1
set bridge.format raw
```
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
@@ -237,11 +301,16 @@ 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 also runs BLE,
so ESP32 WiFi/Bluetooth coexistence requires at least minimum modem sleep. It
WiFi-only Companions can use all three modes. Full Companion also runs BLE, so
ESP32 WiFi/Bluetooth coexistence 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 while Bluetooth is compiled in. Device power saving can
therefore be turned on or off without changing the selected WiFi modem policy.
new `none` selection while Bluetooth is compiled in. When ESP-NOW is also the
Full Companion's 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. Thus a WiFi/BLE/primary-ESP-NOW Full
Companion uses `min`. Device power saving can still be turned on or off without
changing the selected WiFi 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
@@ -372,7 +441,10 @@ 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. The password is write-only
and is never returned by `get`.
and is never returned by `get`. 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
@@ -475,7 +547,13 @@ 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. Full
Companion rejects `none` because its simultaneous BLE transport requires WiFi
modem sleep.
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`. Such a primary-ESP-NOW Full Companion therefore uses `min`.
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
@@ -531,5 +609,8 @@ 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, including after an ESP-NOW image used its long-range protocol mode.
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 diagnostics apply only to a `wifi_mqtt` companion target.
+33 -18
View File
@@ -294,7 +294,7 @@
let value = String(tail);
if (role === "companion") {
value = value.replace(
/^(?:full|usb_ble|ble|usb|wifi|serial|ethernet)(?=$|[_-])/i,
/^(?:full|wifi_mqtt|usb_ble|usb_wifi|ble|usb|wifi|serial|ethernet)(?=$|[_-])/i,
" "
);
// Power saving and controllable FEM gain are persisted settings. Legacy
@@ -330,7 +330,9 @@
? "runtime"
: fullLoggingFallback
? "usb-runtime"
: lowerTarget.includes("observer_mqtt")
: lowerTarget.includes("observer_mqtt") ||
(parts.role === "companion" &&
/(?:^|[_-])wifi_mqtt(?=$|[_-])/.test(lowerTail))
? "wifi"
: lowerTarget.includes("-logging")
? "usb"
@@ -388,9 +390,17 @@
if (!profile || profile.role !== "companion" || profile.mode !== "full") {
return false;
}
// Native nRF52 Serial DFU packages use .zip. ESP32 Full Companion always
// has one USB TTY and never receives the dedicated-port capability.
return profile.installKinds.includes("zip");
const installKinds = Array.isArray(profile.installKinds)
? profile.installKinds
: [];
// Native nRF52 releases normally publish both Serial DFU ZIP and UF2.
// Treat a UF2-only Full profile as nRF52 too so an incomplete release page
// does not mislabel it as an ESP32 single-TTY image. ESP32 Full profiles
// always publish BIN assets.
return installKinds.includes("zip") ||
(installKinds.includes("uf2") &&
!installKinds.includes("bin") &&
!installKinds.includes("merged-bin"));
}
function isFullCompanion(profile) {
@@ -398,28 +408,31 @@
profile.mode === "full");
}
function replacementKey(profile) {
let hardware = String(profile && profile.hardware || "").toLowerCase();
// The generated base V4 Full image auto-detects both V4.2 and V4.3 FEMs.
// Older V4.3 transport artifacts therefore have the same physical
// replacement even though their filename hardware prefix differs.
if (hardware === "heltec_v4_3") hardware = "heltec_v4";
return hardware + "\n" + String(profile && profile.variant || "default");
}
function omitTransportsReplacedByFull(profiles) {
const fullKeys = new Set((profiles || []).filter(function (profile) {
return isFullCompanion(profile);
}).map(function (profile) {
return profile.hardware + "\n" + profile.variant;
}));
}).map(replacementKey));
const combinedUsbBleKeys = new Set((profiles || []).filter(
function (profile) {
return profile.role === "companion" && profile.mode === "usb-ble";
}
).map(function (profile) {
return profile.hardware + "\n" + profile.variant;
}));
).map(replacementKey));
const terminalReplacementKeys = new Set((profiles || []).filter(
function (profile) {
return profile.role === "companion" &&
(profile.mode === "full" || profile.mode === "usb" ||
profile.mode === "usb-ble");
}
).map(function (profile) {
return profile.hardware + "\n" + profile.variant;
}));
).map(replacementKey));
const targets = new Set((profiles || []).map(function (profile) {
return String(profile.target || "").toLowerCase();
}));
@@ -450,7 +463,7 @@
};
return (profiles || []).filter(function (profile) {
const key = profile.hardware + "\n" + profile.variant;
const key = replacementKey(profile);
const replacedAttachedTransport = profile.role === "companion" &&
(profile.mode === "usb" || profile.mode === "ble" ||
profile.mode === "wifi" || profile.mode === "serial" ||
@@ -474,9 +487,10 @@
return (profiles || []).map(function (profile) {
if (!isFullCompanion(profile)) return profile;
// Every Full Companion has a runtime USB logging mode. nRF52 adds an
// independent plaintext interface after reboot. Every ESP32 target
// switches its one TTY between framed and plaintext modes.
// Every Full Companion controls diagnostics with usb.logging. Some
// qualified boards also contain a direct MQTT bridge, but the Companion
// command parser does not implement the observer-only logging.output
// selector, so never advertise its off/USB/WiFi/both modes here.
profile.logging = "usb-runtime";
profile.loggingModes = ["none", "usb"];
profile.dedicatedUsbLogging = false;
@@ -1209,6 +1223,7 @@
facetValues: facetValues,
uniqueValues: uniqueValues,
canonicalAsset: canonicalAsset,
installSteps: installSteps,
humanizeHardware: humanizeHardware,
humanizeVariant: humanizeVariant,
labelFor: labelFor,
+3
View File
@@ -157,6 +157,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature |
| Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature |
| Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW is ESP32 only | No | No | No |
| Bridge | [`get/set bridge.format`](cli_commands.md#view-or-change-the-esp-now-bridge-wire-format) | ESP-NOW bridge | No | No | No |
| Bridge | [`get/set bridge.secret`](cli_commands.md#set-the-esp-now-secret) | ESP-NOW is ESP32 only | No | No | No |
| Board | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 bootloader metadata | Yes | Yes | Yes |
| Board | [`get pwrmgt.support`; `get pwrmgt.source`; `get pwrmgt.bootreason`; `get pwrmgt.bootmv`](nrf52_power_management.md#cli-commands) | Board power-management implementation | Feature | Feature | Feature |
@@ -243,6 +244,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` is local serial only | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature | Feature | Feature |
| Radio | [`get/set espnow.channel`](cli_commands.md#view-or-change-the-primary-esp-nowwifi-channel) | Primary ESP-NOW mesh radio | Feature | Feature | Feature | Feature | Feature |
| Radio | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | Feature | Feature |
| Radio | [`get/set radio.fem.txgain`](cli_commands.md#view-or-change-the-lora-fem-transmit-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | Feature | Feature |
| Radio | [`get/set radio.rxps`; `get rxps.wd`](#radio-rxps) | Full parser and RX power-saving support | Feature | Feature | Feature | Feature | Feature |
@@ -321,6 +323,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature |
| Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature |
| Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW bridge | No | No | No | Feature | Feature |
| Bridge | [`get/set bridge.format`](cli_commands.md#view-or-change-the-esp-now-bridge-wire-format) | ESP-NOW bridge | No | No | No | Feature | Feature |
| Bridge | [`get/set bridge.secret`](cli_commands.md#set-the-esp-now-secret) | ESP-NOW bridge | No | No | No | Feature | Feature |
| Board | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 only | No | No | No | No | No |
| Board | [`get pwrmgt.support`; `get pwrmgt.source`; `get pwrmgt.bootreason`; `get pwrmgt.bootmv`](nrf52_power_management.md#cli-commands) | nRF52 only | No | No | No | No | No |
+102 -4
View File
@@ -247,6 +247,10 @@ 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` is intentionally unavailable
so the standalone password is never returned by the CLI.
@@ -260,9 +264,45 @@ 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 command bytes
`0x46` and `0x47` over USB, BLE, or TCP port 5000 without the terminal-start
token. WiFi-only Companions accept all three modes; Full Companion rejects
token. WiFi-only Companions accept all three modes. Full Companion rejects
`none` because its simultaneous BLE transport requires modem sleep. Companion
device `powersaving` and LoRa `radio.rxps` remain independent.
device `powersaving` 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 `max` value is capped to and
reported as `min`, and a new `max` selection is rejected. These
WiFi/BLE/primary-ESP-NOW builds therefore use `min`.
#### View or change the primary ESP-NOW/WiFi channel
**Usage:**
- `get espnow.channel`
- `set espnow.channel <channel>`
**Parameters:**
- `channel`: Shared 2.4 GHz WiFi channel from `1` through `13`; choose one
permitted in your region and supported by the router.
**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.
`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
@@ -648,7 +688,10 @@ 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`.
Unified ESP32 FULL builds add one saved selector for both output paths:
Unified non-Companion ESP32 FULL USB+WiFi observer builds add one saved
selector for both output paths. Full Companion builds control USB diagnostics
with `usb.logging` instead; their text-command parser does not expose
`logging.output`:
```text
get logging.output
@@ -3490,7 +3533,60 @@ rate before running `set bridge.enabled on`.
- `set bridge.channel <channel>`
**Parameters:**
- `channel`: Channel number (1-14)
- `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.
---
#### View or change the ESP-NOW bridge wire format
**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.
---
@@ -3504,6 +3600,8 @@ rate before running `set bridge.enabled on`.
**Default:** Varies by board
This setting is used only by `bridge.format wrapped`; raw mode ignores it.
---
#### View the bootloader version (nRF52 only)
+9 -5
View File
@@ -261,7 +261,7 @@ text such as `set radio.rxgain off` still requires the USB terminal start token.
The WiFi power-save pair is available on ESP32 WiFi Companion builds over the
normal binary USB, BLE, or TCP port 5000 transport. `CMD_GET_WIFI_POWER_SAVE`
has no body and replies with `RESP_CODE_OK` followed by one mode byte.
has no body and replies with `RESP_CODE_OK` followed by the effective mode byte.
`CMD_SET_WIFI_POWER_SAVE` is followed by that mode byte and replies with
`RESP_CODE_OK` after saving it:
@@ -274,10 +274,14 @@ has no body and replies with `RESP_CODE_OK` followed by one mode byte.
An out-of-range value returns `ERR_CODE_ILLEGAL_ARG`. A non-WiFi Companion
returns `ERR_CODE_UNSUPPORTED_CMD`. Full Companion has Bluetooth enabled and
therefore returns `ERR_CODE_BAD_STATE` for `none`, because ESP32 WiFi/Bluetooth
coexistence requires modem sleep. A storage failure also returns
`ERR_CODE_BAD_STATE`. If an already-saved mode cannot be applied to the active
WiFi driver, SET still returns `RESP_CODE_OK` and the mode is applied on the
next connection. Device `powersaving` does not overwrite this setting.
coexistence requires modem sleep. On an ESP32 Full Companion whose primary mesh
radio is ESP-NOW, SET also returns `ERR_CODE_BAD_STATE` for `max` (value `2`): maximum
modem sleep can make the station miss ESP-NOW broadcasts, which the access point
cannot buffer. If an older image already saved `max`, GET reports `min` (value
`0`) and the running policy uses `min`. A storage failure also returns
`ERR_CODE_BAD_STATE`. If an otherwise permitted saved mode cannot be applied to
the active WiFi driver, SET still returns `RESP_CODE_OK` and the mode is applied
on the next connection. Device `powersaving` does not overwrite this setting.
The Bluetooth-name pair is available on Companion builds regardless of the
currently selected transport, so a host can configure a BLE-capable image over
+49 -4
View File
@@ -39,6 +39,39 @@ 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.
Inspect or change the shared channel from the Full Companion text terminal:
```text
get espnow.channel
set espnow.channel 6
reboot
```
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.
List the available targets:
```bash
@@ -142,17 +175,29 @@ start webconfig
stop webconfig
```
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.
The password is write-only and is masked while it is entered over USB.
The password is write-only and is masked while it is entered over USB. 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 saved values are
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](./companion_protocol.md#commands). Full Companion rejects
`none` because simultaneous WiFi and BLE require modem sleep. Fresh Cascade
builds select `min`, and an existing saved selection takes precedence.
`none` because simultaneous WiFi and BLE require modem sleep. The two
primary-ESP-NOW Full targets also reject `max`; unlike infrastructure traffic,
peer ESP-NOW
broadcasts cannot be buffered by the access point while the station sleeps. If
an older image saved `max`, firmware applies and reports `min` instead. 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:
+12
View File
@@ -148,6 +148,18 @@ board has room; use `set bridge.enabled on` after configuring `bridge.uart` and
RS-232 image. Repeaters may also offer separate ESP-NOW, Ethernet, or MQTT
observer modes.
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.
## FULL versus standard
For a new installation, use the FULL / complete profile when it exists and the
+24 -1
View File
@@ -189,7 +189,30 @@ set wifi.powersave {none|min|max}
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.
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
set espnow.channel <1-13>
```
On Full Companion builds whose primary mesh radio is ESP-NOW, this shows or
saves the single channel shared by ESP-NOW, the setup AP, and the
infrastructure-WiFi station. The default is channel 1. Use only a channel
permitted in your region and supported by the router. A saved change takes
effect only after reboot. Every primary ESP-NOW node and the router's 2.4 GHz
radio must use the same fixed channel; power saving does not permit separate
channels. On these ESP32 Full builds, `wifi.powersave max` is unavailable 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
+4 -2
View File
@@ -9,6 +9,7 @@ enum class CompanionWiFiPowerSaveResult : uint8_t {
SavedForNextConnection,
InvalidMode,
BluetoothConflict,
PrimaryEspNowConflict,
StorageError,
};
@@ -22,9 +23,10 @@ bool isCompanionWiFiEnabled();
void scheduleCompanionWiFiCredentialReload();
// Companion WiFi power save is stored in the shared mesh-wifi NVS namespace.
// The returned mode is always the effective mode: WiFi+BLE builds map a stale
// The returned mode is always the effective mode. WiFi+BLE builds map a stale
// or default "none" value to "min" because modem sleep is required for radio
// coexistence.
// coexistence. Primary ESP-NOW builds also map stale "max" to "min" so the
// receiver does not sleep through unbuffered ESP-NOW broadcasts.
uint8_t getCompanionWiFiPowerSave();
const char* getCompanionWiFiPowerSaveName();
const char* companionWiFiPowerSaveName(uint8_t mode);
+89 -1
View File
@@ -21,6 +21,10 @@
#include <helpers/ESP32TrueRandom.h>
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
#include <helpers/esp32/WiFiRadioPolicy.h>
#endif
#include <helpers/CLICommandUtils.h>
#ifdef ENABLE_USB_INTERFACE
#include <helpers/TracePathHelpers.h>
@@ -2118,6 +2122,22 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
return true;
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strcmp(command, "get espnow.channel") == 0) {
formatEspNowChannel(reply, reply_size);
return true;
}
if (strncmp(command, "set espnow.channel", 18) == 0
&& (command[18] == 0 || command[18] == ' '
|| command[18] == '\t')) {
const char* value = command + 18;
while (*value == ' ' || *value == '\t') value++;
applyAndSaveEspNowChannel(value, reply, reply_size);
return true;
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
#ifdef WITH_WEBCONFIG
if (strncmp(command, "get ", 4) == 0) {
@@ -2162,7 +2182,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
|| wifi_key == mesh::cli::StandaloneWiFiKey::Password) {
if (saved) {
char saved_ssid[32] = {0};
char saved_password[64] = {0};
char saved_password[65] = {0};
uint8_t saved_power_save = mesh::wifi::kDefaultPowerSave;
const bool has_ssid = WebConfigServer::loadStandaloneWiFi(
saved_ssid, sizeof(saved_ssid), saved_password,
@@ -2516,6 +2536,9 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
#endif
#if defined(ESP32) && defined(WIFI_SSID)
s.capabilities |= WebConfigServer::CAP_WIFI_POWER_SAVE;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
s.capabilities |= WebConfigServer::CAP_ESPNOW_CHANNEL;
#endif
if (radio_driver.supportsRxBoostedGainMode()) {
s.capabilities |= WebConfigServer::CAP_RX_GAIN;
@@ -2621,6 +2644,12 @@ void MyMesh::execCommand(char* cmd, char* reply) {
formatBluetoothNameStatus(reply, 160);
return;
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (cmd && strcmp(cmd, "get espnow.channel") == 0) {
formatEspNowChannel(reply, 160);
return;
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
if (cmd && strcmp(cmd, "get wifi.powersave") == 0) {
formatWiFiPowerSaving(reply, 160);
@@ -2653,6 +2682,12 @@ void MyMesh::execCommand(char* cmd, char* reply) {
applyAndSaveBluetoothName(value, reply, 160);
return;
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strcmp(key, "espnow.channel") == 0) {
applyAndSaveEspNowChannel(value, reply, 160);
return;
}
#endif
if (strcmp(key, "lat") == 0 || strcmp(key, "lon") == 0) {
double parsed;
const bool latitude = key[1] == 'a';
@@ -4413,6 +4448,50 @@ void MyMesh::formatBluetoothNameStatus(char* reply, size_t reply_size) const {
? "custom" : "default from node name");
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
void MyMesh::formatEspNowChannel(char* reply, size_t reply_size) const {
if (reply == NULL || reply_size == 0) return;
const uint8_t saved = mesh::wifi::loadConfiguredEspNowChannel();
const uint8_t active = mesh::wifi::activeEspNowChannel();
if (saved == active) {
snprintf(reply, reply_size, "> %u (saved and active)",
(unsigned)saved);
} else {
snprintf(reply, reply_size,
"> saved %u, active %u; reboot required",
(unsigned)saved, (unsigned)active);
}
}
bool MyMesh::applyAndSaveEspNowChannel(const char* value, char* reply,
size_t reply_size) {
if (reply == NULL || reply_size == 0) return false;
uint8_t channel = 0;
if (!mesh::wifi::parseEspNowChannel(value, channel)) {
snprintf(reply, reply_size, "Error: ESP-NOW channel must be 1-13");
return false;
}
// Snapshot the boot channel before writing NVS so even a CLI-only image
// cannot reinterpret the newly-saved setting as active in this boot.
const uint8_t active = mesh::wifi::activeEspNowChannel();
if (!mesh::wifi::saveConfiguredEspNowChannel(channel)) {
snprintf(reply, reply_size, "Error: failed to save ESP-NOW channel");
return false;
}
if (channel == active) {
snprintf(reply, reply_size,
"OK - ESP-NOW channel %u saved and active",
(unsigned)channel);
} else {
snprintf(reply, reply_size,
"OK - ESP-NOW channel %u saved; active %u; reboot required",
(unsigned)channel, (unsigned)active);
}
return true;
}
#endif
bool MyMesh::applyAndSavePowerSaving(const char* value, char* reply) {
bool enabled;
if (strcmp(value, "on") == 0) {
@@ -4465,6 +4544,11 @@ bool MyMesh::applyAndSaveWiFiPowerSaving(const char* value, char* reply,
const CompanionWiFiPowerSaveResult result =
setCompanionWiFiPowerSave(mode);
if (result == CompanionWiFiPowerSaveResult::PrimaryEspNowConflict) {
snprintf(reply, reply_size,
"Error: power save max is unavailable while ESP-NOW is the primary radio");
return false;
}
if (result == CompanionWiFiPowerSaveResult::BluetoothConflict) {
snprintf(reply, reply_size,
"Error: power save none is unavailable while Bluetooth is active");
@@ -5744,6 +5828,10 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" start webconfig [ap]\r\n");
terminalOutput().print(" stop webconfig\r\n");
#endif
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
terminalOutput().print(" get espnow.channel\r\n");
terminalOutput().print(" set espnow.channel <1-13>\r\n");
#endif
terminalOutput().print(" get radio.rxps\r\n");
terminalOutput().print(" get radio.rxps.config\r\n");
+5
View File
@@ -322,6 +322,11 @@ private:
bool applyAndSaveBluetoothName(const char* value, char* reply,
size_t reply_size);
void formatBluetoothNameStatus(char* reply, size_t reply_size) const;
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
bool applyAndSaveEspNowChannel(const char* value, char* reply,
size_t reply_size);
void formatEspNowChannel(char* reply, size_t reply_size) const;
#endif
bool applyAndSavePowerSaving(const char* value, char* reply);
bool applyAndSaveRxPowerSaving(const char* value, char* reply);
void appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size,
+132 -34
View File
@@ -64,8 +64,11 @@ MultiSerialInterface interface_manager;
// include esp32 wifi interface
#include <helpers/esp32/SerialWifiInterface.h>
#include <helpers/WiFiSetupPortal.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/WiFiReconnectPolicy.h>
#include <helpers/WiFiPowerSave.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/WiFiStationPolicy.h>
#include <Preferences.h>
#include <esp_wifi.h>
SerialWifiInterface wifi_interface;
@@ -98,6 +101,10 @@ MultiSerialInterface interface_manager;
#endif
#endif
#if COMPANION_FEATURE_NETWORK_TERMINAL
static bool isNetworkTerminalActive();
#endif
// include ethernet interface
#if defined(ETHERNET_ENABLED)
#include <helpers/ethernet/EthernetInterface.h>
@@ -595,18 +602,37 @@ static void serviceUsbTerminal() {
// A saved logging-on preference makes the one available TTY behave like a
// logging repeater: plaintext diagnostics plus an input-capable CLI. Put the
// Companion interface into passthrough before it can mix framed traffic with
// logs. `set usb.logging off` remains available here and returns this TTY to
// the normal ASCII terminal, matching a fresh Full install. Binary Companion
// starts only after the usual terminal-stop token or framed startup probe.
// logs. An active TCP terminal owns the role CLI, so logging must not reclaim
// it. Turning logging off restores the build's normal USB mode: ASCII for
// Full Companion and Binary Companion for every other single-TTY build.
#if MESH_USB_LOGGING_AVAILABLE
if (!mesh::hasDedicatedUsbLoggingPort()) {
if (mesh::isUsbLoggingEnabled()) {
const mesh::UsbLoggingTerminalAction logging_action =
mesh::selectUsbLoggingTerminalAction(
mesh::hasDedicatedUsbLoggingPort(), mesh::isUsbLoggingEnabled(),
the_mesh.isTerminalMode(), usb_logging_terminal_mode,
#if defined(COMPANION_RADIO_FULL)
true,
#else
false,
#endif
#if COMPANION_FEATURE_NETWORK_TERMINAL
isNetworkTerminalActive()
#else
false
#endif
);
switch (logging_action) {
case mesh::UsbLoggingTerminalAction::CLAIM_USB:
if (!the_mesh.isTerminalMode()) {
enterUsbLoggingTerminalMode();
return;
}
usb_logging_terminal_mode = true;
} else if (usb_logging_terminal_mode && the_mesh.isTerminalMode()) {
break;
case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY:
leaveUsbTerminalMode(true);
return;
case mesh::UsbLoggingTerminalAction::KEEP_ASCII:
// Logging may also be disabled over BLE/WiFi. Stop treating this session
// as the logging terminal, but keep the ordinary ASCII terminal active;
// do not silently change the USB protocol underneath an idle host. A
@@ -616,7 +642,9 @@ static void serviceUsbTerminal() {
clearUsbTerminalLine();
usb_terminal_discard_line = false;
Serial.print("\r\nUSB logging off; ASCII terminal active\r\n> ");
}
break;
case mesh::UsbLoggingTerminalAction::NONE:
break;
}
#endif
if (!the_mesh.isTerminalMode()) {
@@ -641,12 +669,20 @@ static void serviceUsbTerminal() {
return;
}
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// HWCDC has no DTR/open signal, and its bool operator is only a transient
// RX/TX activity heuristic. Do not interpret an idle interval as the host
// closing Full Companion's startup terminal. TCP handoff is separately
// arbitrated by hasObservableActiveUsbTerminalClient(), using actual input.
#else
if (isUsbTerminalDataConnected()) {
usb_terminal_disconnect_armed = true;
} else if (usb_terminal_disconnect_armed) {
leaveUsbTerminalMode(false);
return;
}
#endif
#if defined(COMPANION_RADIO_FULL)
// Full Companion boots as a useful ASCII terminal. MeshCLI's first framed
@@ -705,10 +741,15 @@ static void serviceUsbTerminal() {
#if MESH_USB_LOGGING_AVAILABLE
if (usb_logging_terminal_mode
&& !mesh::isUsbLoggingEnabled()) {
// The command reply belongs to the ASCII session. Leave that session
// active so a separate, observable mode switch is required before the
#if defined(COMPANION_RADIO_FULL)
// The command reply belongs to the Full Companion's normal ASCII
// session. A separate, observable mode switch is required before the
// port accepts framed Binary Companion traffic again.
usb_logging_terminal_mode = false;
#else
leaveUsbTerminalMode(true);
return;
#endif
}
#endif
Serial.print("> ");
@@ -764,7 +805,7 @@ void halt() {
unsigned long last_wifi_setup_attempt = 0;
bool wifi_setup_recovery_mode = false;
static char configured_wifi_ssid[32];
static char configured_wifi_password[64];
static char configured_wifi_password[65];
static bool companion_wifi_has_credentials = false;
static bool companion_wifi_requested = true;
static bool companion_wifi_active = false;
@@ -803,7 +844,8 @@ void halt() {
nvs.end();
}
companion_wifi_power_save = mesh::wifi::effectivePowerSave(
configured, companionWiFiBluetoothActive());
configured, companionWiFiBluetoothActive(),
mesh::wifi::kPrimaryEspNowRadio);
companion_wifi_power_save_loaded = true;
applyCompanionWiFiPowerSave();
}
@@ -831,11 +873,16 @@ void halt() {
if (mode > mesh::wifi::kPowerSaveMax) {
return CompanionWiFiPowerSaveResult::InvalidMode;
}
if (mesh::wifi::effectivePowerSave(mode, companionWiFiBluetoothActive())
if (mesh::wifi::kPrimaryEspNowRadio
&& mode == mesh::wifi::kPowerSaveMax) {
return CompanionWiFiPowerSaveResult::PrimaryEspNowConflict;
}
if (mesh::wifi::effectivePowerSave(
mode, companionWiFiBluetoothActive(),
mesh::wifi::kPrimaryEspNowRadio)
!= mode) {
return CompanionWiFiPowerSaveResult::BluetoothConflict;
}
Preferences nvs;
if (!nvs.begin("mesh-wifi", false)) {
return CompanionWiFiPowerSaveResult::StorageError;
@@ -860,7 +907,8 @@ void halt() {
configured_wifi_ssid, sizeof(configured_wifi_ssid),
configured_wifi_password, sizeof(configured_wifi_password));
if (!companion_wifi_has_credentials
&& !WiFiSetupPortal::isPlaceholderSSID(WIFI_SSID)) {
&& !WiFiSetupPortal::isPlaceholderSSID(WIFI_SSID)
&& mesh::cli::standaloneWiFiPasswordValid(WIFI_PWD)) {
strncpy(configured_wifi_ssid, WIFI_SSID, sizeof(configured_wifi_ssid) - 1);
configured_wifi_ssid[sizeof(configured_wifi_ssid) - 1] = '\0';
strncpy(configured_wifi_password, WIFI_PWD, sizeof(configured_wifi_password) - 1);
@@ -978,6 +1026,12 @@ void halt() {
static mesh::UsbTcpTerminalHandoff ota_console_usb_handoff;
#endif
#if COMPANION_FEATURE_NETWORK_TERMINAL
static bool isNetworkTerminalActive() {
return the_mesh.isNetworkTerminalMode(ota_console_client);
}
#endif
static void ota_console_clear_line() {
memset(ota_console_line, 0, sizeof(ota_console_line));
ota_console_len = 0;
@@ -1140,9 +1194,11 @@ void halt() {
}
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
mesh::wifi::setStationAutoReconnect(true);
WiFi.disconnect(false, false);
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
mesh::wifi::beginStation(
configured_wifi_ssid, configured_wifi_password);
wifi_reconnect_tracker.noteAttempt(millis());
WIFI_DEBUG_PRINTLN("WiFi credentials reloaded; reconnecting to saved SSID");
}
@@ -1151,13 +1207,15 @@ void halt() {
if (companion_wifi_active) return;
board.setInhibitSleep(true);
WiFi.setAutoReconnect(true);
mesh::wifi::setStationAutoReconnect(true);
resetCompanionWiFiRecoveryState();
if (companion_wifi_has_credentials) {
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
wifi_reconnect_tracker.noteDisconnected(millis());
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
mesh::wifi::beginStation(
configured_wifi_ssid, configured_wifi_password);
}
#ifndef WITH_WEBCONFIG
else if (!wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP,
@@ -1197,6 +1255,21 @@ void halt() {
companion_wifi_services_stopped = true;
}
static void stopCompanionInfrastructureWiFi() {
WiFi.setAutoReconnect(false);
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// ESP-NOW is the mesh radio on this target. Drop the station/AP services
// without stopping the driver, then restore its fixed LR mesh channel.
WiFi.disconnect(false, false);
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
mesh::wifi::restoreEspNowChannel();
#else
WiFi.disconnect(true, false);
WiFi.mode(WIFI_OFF);
#endif
}
static bool finishStoppingCompanionWiFi() {
stopCompanionWiFiServices();
#ifdef WITH_WEBCONFIG
@@ -1212,13 +1285,16 @@ void halt() {
if (wifiSetupPortal().isStopping()) return false;
#endif
WiFi.setAutoReconnect(false);
WiFi.disconnect(true, false);
WiFi.mode(WIFI_OFF);
stopCompanionInfrastructureWiFi();
board.setInhibitSleep(false);
resetCompanionWiFiRecoveryState();
companion_wifi_active = false;
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
WIFI_DEBUG_PRINTLN(
"Companion WiFi services disabled; ESP-NOW mesh radio remains active");
#else
WIFI_DEBUG_PRINTLN("WiFi radio disabled by BOOT/GPIO 0 control");
#endif
return true;
}
@@ -1455,10 +1531,29 @@ void setup() {
#ifdef WIFI_SSID
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){
if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) {
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
mesh::wifi::restoreEspNowChannel();
#endif
if (companion_wifi_requested) {
WIFI_DEBUG_PRINTLN("WiFi disconnected; automatic recovery is active");
}
} else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
if (!mesh::wifi::enforceStationChannel()) {
WIFI_DEBUG_PRINTLN(
"Rejected WiFi association outside ESP-NOW channel %u",
(unsigned)mesh::wifi::activeEspNowChannel());
return;
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// Association can replace the station protocol bitmap after the
// pre-connect setup. Restore LR receive support once the AP has
// finished configuring the shared radio; B/G/N remain enabled for
// the infrastructure connection.
if (mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) {
WIFI_DEBUG_PRINTLN(
"Failed to restore ESP-NOW/WiFi protocol coexistence");
}
#endif
WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)",
WiFi.localIP().toString().c_str(), TCP_PORT);
}
@@ -1469,8 +1564,7 @@ void setup() {
if (companion_wifi_requested) {
startCompanionWiFi();
} else {
WiFi.setAutoReconnect(false);
WiFi.mode(WIFI_OFF);
stopCompanionInfrastructureWiFi();
board.setInhibitSleep(false);
WIFI_DEBUG_PRINTLN("WiFi remains off from the saved BOOT/GPIO 0 setting");
}
@@ -1504,10 +1598,10 @@ void setup() {
// the kiss_modem tuning)
Serial.setTxBufferSize(4096);
Serial.setTxTimeoutMs(5);
// The ESP32 USB-Serial-JTAG peripheral (HWCDC) has no DTR concept at all:
// (bool)Serial only tells us the host has enumerated the device, which is
// already true when the cable is plugged into a powered port. Fall back to
// activity: a real client has to send us frames.
// The ESP32 USB-Serial-JTAG peripheral (HWCDC) has no DTR concept at all,
// and its bool operator is only a transient RX/TX activity heuristic. A real
// Binary Companion client therefore has to prove ownership by sending
// frames; an idle physical cable must not claim the interface.
usb_serial_interface.setConnectedCheck([]() {
uint32_t last = usb_serial_interface.getLastFrameMillis();
return (bool)Serial && usb_serial_interface.hasReceivedFrame()
@@ -1682,7 +1776,8 @@ void loop() {
ota_console_loop(); // service the OTA text console (port 5002)
#endif
const unsigned long wifi_now = millis();
if (WiFi.status() == WL_CONNECTED) {
const bool station_channel_ok = mesh::wifi::enforceStationChannel();
if (station_channel_ok && WiFi.status() == WL_CONNECTED) {
wifi_reconnect_tracker.noteConnected();
wifi_setup_attempted = false;
#ifdef WITH_WEBCONFIG
@@ -1692,7 +1787,7 @@ void loop() {
if (configured_wifi_ssid[0] && the_mesh.isWebConfigSetupActive()
&& (wifi_setup_recovery_mode
|| the_mesh.isWebConfigWiFiRecoveryActive())) {
WiFi.setAutoReconnect(true);
mesh::wifi::setStationAutoReconnect(true);
the_mesh.stopWebConfig();
wifi_setup_recovery_mode = false;
}
@@ -1742,8 +1837,10 @@ void loop() {
}
}
// The ESP stack normally reconnects by itself. If it gets wedged after an AP
// outage, reassert the saved credentials every five minutes. WebConfig's
// Reassert the saved credentials every five minutes after an AP outage.
// Primary ESP-NOW builds deliberately disable the driver's unconstrained
// auto-reconnect; ordinary builds retain it and use this as a recovery path.
// WebConfig's
// setup AP uses AP+STA mode, so the station retry can run without taking the
// recovery page down. The legacy portal owns its own identical retry timer.
const bool reconnect_owned_here =
@@ -1762,14 +1859,15 @@ void loop() {
#ifdef WITH_WEBCONFIG
if (!the_mesh.isWebConfigSetupActive()) {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
mesh::wifi::setStationAutoReconnect(true);
}
#else
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
mesh::wifi::setStationAutoReconnect(true);
#endif
WiFi.disconnect(false, false);
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
mesh::wifi::beginStation(
configured_wifi_ssid, configured_wifi_password);
}
#ifdef WITH_MQTT_BRIDGE
the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password);
+8 -2
View File
@@ -3345,6 +3345,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.bridge_pkt_src = 1; // logRx (RX packets)
_prefs.bridge_baud = 115200; // baud rate
_prefs.bridge_channel = 1; // channel 1
_prefs.bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
#ifdef WITH_RS232_BRIDGE
_prefs.bridge_uart = WITH_RS232_BRIDGE_UART;
#else
@@ -9995,6 +9996,9 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
| WebConfigServer::CAP_ADVERT | WebConfigServer::CAP_FLOOD
| WebConfigServer::CAP_LOOP | WebConfigServer::CAP_WIFI_POWER_SAVE
| WebConfigServer::CAP_POWER_SAVING;
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
s.capabilities |= WebConfigServer::CAP_ESPNOW_CHANNEL;
#endif
if (board.canControlLoRaFemLna()) {
s.capabilities |= WebConfigServer::CAP_FEM_RX_GAIN;
}
@@ -10148,14 +10152,16 @@ bool MyMesh::setWiFiCLI(const char* value, char* reply) {
void MyMesh::onConfigBatchEnd() {
_wc_batch_active = false;
#ifdef WITH_MQTT_BRIDGE
#ifdef WITH_BRIDGE
if (_wc_restart_pending) {
_wc_restart_pending = false;
_wc_slot_restart_mask = 0;
restartBridge();
if (_prefs.bridge_enabled) restartBridge();
return;
}
#endif
#ifdef WITH_MQTT_BRIDGE
const uint8_t mask = _wc_slot_restart_mask;
_wc_slot_restart_mask = 0;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
+18 -10
View File
@@ -994,7 +994,11 @@ public:
}
#endif
void setBridgeState(bool enable) override {
bool setBridgeState(bool enable) override {
// Disabling an already-absent heap-backed bridge is successful and must
// not allocate an instance merely to stop it. The embedded ESP-NOW bridge
// is always present, so it continues through the normal state check.
if (!enable && !activeBridge()) return true;
#ifdef WITH_MQTT_BRIDGE
if (!mqtt_bridge) {
MQTTNodeInfo node_info;
@@ -1007,17 +1011,17 @@ public:
node_info.repeat_when_nonzero = false;
mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(),
getRTCClock(), &self_id);
if (!mqtt_bridge) return;
if (!mqtt_bridge) return false;
}
#endif
#ifdef WITH_RS232_BRIDGE
if (enable && !bridge) {
bridge = createRS232Bridge();
if (!bridge) return;
if (!bridge) return false;
}
#endif
AbstractBridge* active_bridge = activeBridge();
if (!active_bridge) return;
if (!active_bridge) return false;
if (enable == active_bridge->isRunning()) {
#ifdef WITH_RS232_BRIDGE
if (!enable) {
@@ -1025,7 +1029,7 @@ public:
bridge = nullptr;
}
#endif
return;
return true;
}
if (enable)
{
@@ -1048,6 +1052,7 @@ public:
else
{
active_bridge->end();
const bool stopped = !active_bridge->isRunning();
#ifdef WITH_RS232_BRIDGE
delete bridge;
bridge = nullptr;
@@ -1055,24 +1060,26 @@ public:
#ifdef WITH_MQTT_BRIDGE
_alerter.setBridge(nullptr);
#endif
return stopped;
}
return active_bridge->isRunning();
}
void restartBridge() override {
bool restartBridge() override {
AbstractBridge* active_bridge = activeBridge();
if (!active_bridge || !active_bridge->isRunning()) return;
if (!active_bridge) return false;
#ifdef WITH_WEBCONFIG
if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd()
_wc_restart_pending = true;
return;
return true;
}
#endif
active_bridge->end();
if (active_bridge->isRunning()) active_bridge->end();
#ifdef WITH_RS232_BRIDGE
delete bridge;
bridge = createRS232Bridge();
if (bridge) bridge->begin();
return;
return bridge && bridge->isRunning();
#endif
#ifdef WITH_MQTT_BRIDGE
// Set device metadata before restarting bridge (same as in begin())
@@ -1086,6 +1093,7 @@ public:
mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms);
#endif
active_bridge->begin();
return active_bridge->isRunning();
}
void restartBridgeSlot(int slot) override {
+5 -1
View File
@@ -1236,6 +1236,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.bridge_pkt_src = 1; // logRx (RX packets)
_prefs.bridge_baud = 115200; // baud rate
_prefs.bridge_channel = 1; // channel 1
_prefs.bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
// MQTT/WiFi/timezone defaults live in /mqtt_prefs now (see applyMQTTDefaults).
@@ -1633,6 +1634,9 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
| WebConfigServer::CAP_ADVERT | WebConfigServer::CAP_FLOOD
| WebConfigServer::CAP_LOOP | WebConfigServer::CAP_WIFI_POWER_SAVE
| WebConfigServer::CAP_POWER_SAVING;
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
s.capabilities |= WebConfigServer::CAP_ESPNOW_CHANNEL;
#endif
if (board.canControlLoRaFemLna()) {
s.capabilities |= WebConfigServer::CAP_FEM_RX_GAIN;
}
@@ -1790,7 +1794,7 @@ void MyMesh::onConfigBatchEnd() {
if (_wc_restart_pending) {
_wc_restart_pending = false;
_wc_slot_restart_mask = 0;
restartBridge();
if (_prefs.bridge_enabled) restartBridge();
return;
}
const uint8_t mask = _wc_slot_restart_mask;
+12 -7
View File
@@ -467,7 +467,10 @@ public:
void loop();
#if defined(WITH_BRIDGE)
void setBridgeState(bool enable) override {
bool setBridgeState(bool enable) override {
// An absent MQTT bridge is already stopped. Do not allocate one solely to
// satisfy an idempotent disable request.
if (!enable && !bridge) return true;
if (!bridge) {
#ifdef WITH_MQTT_BRIDGE
MQTTNodeInfo node_info;
@@ -481,9 +484,9 @@ public:
bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(),
getRTCClock(), &self_id);
#endif
if (!bridge) return;
if (!bridge) return false;
}
if (enable == bridge->isRunning()) return;
if (enable == bridge->isRunning()) return true;
if (enable)
{
char device_id[65];
@@ -515,17 +518,18 @@ public:
_alerter.setBridge(nullptr);
#endif
}
return enable ? bridge->isRunning() : !bridge->isRunning();
}
void restartBridge() override {
if (!bridge || !bridge->isRunning()) return;
bool restartBridge() override {
if (!bridge) return false;
#ifdef WITH_WEBCONFIG
if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd()
_wc_restart_pending = true;
return;
return true;
}
#endif
bridge->end();
if (bridge->isRunning()) bridge->end();
char device_id[65];
mesh::LocalIdentity self_id = getSelfId();
mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE);
@@ -537,6 +541,7 @@ public:
bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms);
#endif
bridge->begin();
return bridge->isRunning();
}
void restartBridgeSlot(int slot) override {
+73 -6
View File
@@ -44,6 +44,41 @@ LEGACY_COMPANION_IDENTITY_ALIASES = {
"LilyGo_TBeam_1W_companion_radio_wifi",
}
# These legacy recipes do not use the regular
# ``<board>_companion_radio_<transport>`` spelling, or they carry a capability
# name that Full now provides at runtime. Keep the mapping beside the shared
# resolver so the main and logging catalog updaters cannot drift apart.
LEGACY_PLAIN_FULL_COMPANION_ALIASES = {
"generic_espnow_comp_radio_usb":
"Generic_ESPNOW_companion_radio_full",
"heltec_e290_companion_ble":
"Heltec_E290_companion_radio_full",
"heltec_e290_companion_usb":
"Heltec_E290_companion_radio_full",
"heltec_e290_companion_usb_ble":
"Heltec_E290_companion_radio_full",
"heltec_t190_companion_radio_usb_ble_":
"Heltec_T190_companion_radio_full_",
"sensecapindicator-espnow_comp_radio_usb":
"SenseCapIndicator-ESPNow_companion_radio_full",
"sensecapindicator-lora_comp_radio_usb_wifi":
"SenseCapIndicator-LoRa_companion_radio_full",
"heltec_v3_companion_radio_wifi_mqtt":
"Heltec_v3_companion_radio_full",
"heltec_v4_companion_radio_wifi_mqtt":
"heltec_v4_2_v4_3_companion_radio_full_femon",
"heltec_v4_companion_radio_wifi_mqtt_femon":
"heltec_v4_2_v4_3_companion_radio_full_femon",
}
LEGACY_COMPANION_ARTIFACT_SUFFIXES = (
"-full-logging-ota",
"-logging-ota",
"-ota-logging",
"-logging",
"-ota",
)
def parse_args() -> argparse.Namespace:
script_dir = Path(__file__).resolve().parent
@@ -392,16 +427,48 @@ def normalize_esp32_full_companion_metadata(
def canonical_full_identity_for_transport(identity: str) -> str | None:
# OTA and logging were artifact variants of the old transport-specific
# recipes. The consolidated target is a plain Full identity, so carrying
# either suffix through the transport substitution produces a name that
# can never exist (for example ``..._full-ota``).
legacy_identity = identity
while True:
lowered = legacy_identity.lower()
suffix = next(
(
suffix
for suffix in LEGACY_COMPANION_ARTIFACT_SUFFIXES
if lowered.endswith(suffix)
),
None,
)
if suffix is None:
break
legacy_identity = legacy_identity[: -len(suffix)]
legacy_identity = canonical_runtime_identity(legacy_identity)
fixed_alias = LEGACY_PLAIN_FULL_COMPANION_ALIASES.get(
legacy_identity.lower()
)
if fixed_alias is not None:
return fixed_alias
# A catalog can already use the Full recipe while retaining an old
# ``-logging`` or ``-ota`` artifact qualifier. Once the qualifier is
# removed there is no transport substitution left to perform.
if "_companion_radio_full" in legacy_identity.lower():
return canonical_runtime_identity(legacy_identity)
match = re.search(
r"_companion_radio_(?:usb|ble|wifi(?!_mqtt))(?=-|_|$)",
identity,
r"_companion_radio_(?:usb|ble|wifi(?!_mqtt)|serial|ethernet)(?=-|_|$)",
legacy_identity,
flags=re.IGNORECASE,
)
if match is None:
return None
full_identity = (
identity[:match.start()] + "_companion_radio_full" +
identity[match.end():]
legacy_identity[:match.start()] + "_companion_radio_full" +
legacy_identity[match.end():]
)
return canonical_runtime_identity(full_identity)
@@ -431,7 +498,7 @@ def resolve_release_identity(
if full_identity is not None and release_identity_is_full_companion(
release_files, full_identity
):
return full_identity, False
return full_identity, "_wifi_mqtt" in logging_base.lower()
# Canonical Full Companion replaces separate USB, BLE, and ordinary Wi-Fi
# artifacts. Full is source-only for LoRa OTA and needs no target-side
@@ -441,7 +508,7 @@ def resolve_release_identity(
if full_identity is not None and release_identity_is_full_companion(
release_files, full_identity
):
return full_identity, False
return full_identity, "_wifi_mqtt" in candidate.lower()
# Accept catalogs produced while portable MQTT observers were still
# emitted, and migrate them to the expanded-partition FULL artifact.
+6 -2
View File
@@ -84,7 +84,10 @@ build_flags = ${arduino_base.build_flags}
-Wl,--wrap=esp_transport_ws_init
; -D ESP32_CPU_FREQ=80 ; change it to your need
lib_deps = ${arduino_base.lib_deps}
ESP32Async/ESPAsyncWebServer @ 3.10.3
; Keep these as a matched pair. ESPAsyncWebServer 3.12.0 relies on
; AsyncTCP 3.5.0's synchronous close/abort behavior and request-lifetime fix.
ESP32Async/AsyncTCP @ 3.5.0
ESP32Async/ESPAsyncWebServer @ 3.12.0
bblanchon/ArduinoJson @ 7.4.3
build_src_filter = ${arduino_base.build_src_filter}
+<helpers/esp32/WebConfigServer.cpp>
@@ -95,7 +98,8 @@ build_src_filter = ${arduino_base.build_src_filter}
[esp32_ota]
lib_deps =
ESP32Async/ESPAsyncWebServer @ 3.10.3
ESP32Async/AsyncTCP @ 3.5.0
ESP32Async/ESPAsyncWebServer @ 3.12.0
file://arch/esp32/AsyncElegantOTA
; esp32c6 uses arduino framework 3.x
+35 -11
View File
@@ -11,9 +11,9 @@
# flash, so the page ships smaller than it reads.
#
# Output: src/helpers/esp32/WebConfigHtml.h
# WEBCONFIG_HTML_GZ[] - gzipped page (PROGMEM)
# WEBCONFIG_HTML_GZ_LEN - byte length
# WEBCONFIG_HTML_ETAG - quoted strong ETag (sha256 prefix of the gz body)
# WEBCONFIG_HTML_LOADER[] - small bootstrap page (PROGMEM)
# WEBCONFIG_HTML_GZ[] - complete gzipped page (PROGMEM)
# WEBCONFIG_HTML_ETAG - quoted strong ETag for the loader page
#
# Runnable outside SCons to inspect exactly what gets shipped:
# python3 scripts/generate_webconfig_html.py --emit /tmp/shipped.html
@@ -83,6 +83,14 @@ def shipped_page():
return raw, stripped
def append_byte_array(lines, name, data):
lines.append("const uint8_t %s[] PROGMEM = {" % name)
for i in range(0, len(data), 16):
chunk = data[i:i + 16]
lines.append(" " + "".join("0x%02x," % b for b in chunk))
lines.append("};")
def main():
if not os.path.isfile(SOURCE):
status("ERROR: %s not found" % SOURCE)
@@ -103,9 +111,24 @@ def main():
src, stripped = shipped_page()
raw = stripped.encode("utf-8")
# mtime=0 keeps the gzip output (and therefore the ETag) deterministic
# mtime=0 keeps the gzip output and its immutable URL deterministic.
gz = gzip.compress(raw, compresslevel=9, mtime=0)
etag = hashlib.sha256(gz).hexdigest()[:16]
version = hashlib.sha256(gz).hexdigest()[:16]
loader = ("<!doctype html><meta charset=utf-8><meta name=viewport "
"content=\"width=device-width,initial-scale=1\"><title>MeshCore "
"WebConfig</title><body>Loading MeshCore WebConfig&hellip;<script>"
"(async()=>{let p;for(let n=0;n<3&&p===undefined;n++){const C="
"window.AbortController,c=C&&new C(),t=c&&setTimeout(()=>c.abort()"
",90000);try{const r=await fetch(\"/ui?v=%s\",c?{signal:c.signal}"
":{});if(r.ok)p=await r.text();else if(r.status===409)return "
"location.reload()}catch(e){}finally{if(t)clearTimeout(t)}if(p==="
"undefined)await new Promise(r=>setTimeout(r,500*(n+1)))}if(p==="
"undefined)throw Error(\"transfer failed\");document.open();"
"document.write(p);document.close()})().catch(e=>"
"document.body.textContent=\"Unable to load WebConfig: \"+e+"
"\". Reload to retry.\")</script>" % version)
loader_bytes = loader.encode("utf-8")
etag = hashlib.sha256(loader_bytes).hexdigest()[:16]
lines = []
lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/"))
@@ -115,20 +138,21 @@ def main():
lines.append("#include <stdint.h>")
lines.append("#include <pgmspace.h>")
lines.append("")
lines.append("const uint32_t WEBCONFIG_HTML_RAW_LEN = %d;" % len(raw))
lines.append("const uint32_t WEBCONFIG_HTML_GZ_LEN = %d;" % len(gz))
lines.append('const char WEBCONFIG_HTML_ETAG[] = "\\"%s\\"";' % etag)
lines.append("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {")
for i in range(0, len(gz), 16):
chunk = gz[i:i + 16]
lines.append(" " + "".join("0x%02x," % b for b in chunk))
lines.append("};")
lines.append('const char WEBCONFIG_HTML_VERSION[] = "%s";' % version)
lines.append("const uint16_t WEBCONFIG_HTML_LOADER_LEN = %d;" % len(loader_bytes))
append_byte_array(lines, "WEBCONFIG_HTML_LOADER", loader_bytes)
lines.append("")
append_byte_array(lines, "WEBCONFIG_HTML_GZ", gz)
lines.append("")
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
with open(OUTPUT, "w") as f:
f.write("\n".join(lines))
status("%s -> %s (%d bytes source, %d stripped, %d gzipped)"
status("%s -> %s (%d bytes source, %d stripped, %d gzip)"
% (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(gz)))
+2
View File
@@ -102,6 +102,8 @@ ROUND_TRIPS = [
("set dutycycle 25", "get dutycycle", "25.0"),
("set alert.mqtt on", "get alert.mqtt", "on"),
("set bridge.source tx", "get bridge.source", "tx"),
("set bridge.channel 1", "get bridge.channel", "1"),
("set bridge.format raw", "get bridge.format", "raw"),
("set mqtt.neighbors on", "get mqtt.neighbors", "on"),
("set path.hash.mode 2", "get path.hash.mode", "2"),
("set mqtt.iata den", "get mqtt.iata", "DEN"),
+14 -9
View File
@@ -60,10 +60,11 @@ ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build
BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling
SCAN_SECS = 0.8
# Destination buffer sizes (chars, minus the NUL) -- mirrors the MQTTPrefs fields
# the firmware validates in CommonCLI_Observer.cpp.
# Destination buffer sizes (chars, minus the NUL). Most mirror MQTTPrefs;
# wifi.pwd models the standalone mesh-wifi NVS path and receives the additional
# exact-64-hex check in apply_set(). Slot credentials remain fixed-layout.
LEN_LIMITS = {
"name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31,
"name": 31, "wifi.ssid": 31, "wifi.pwd": 64, "mqtt.origin": 31,
"mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23,
}
# "filter" is absent on purpose: it is a bitmask, not a text buffer, so it has
@@ -89,11 +90,11 @@ PRESETS = (
)
SCAN_NETWORKS = [
{"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False},
{"ssid": "HomeNet", "rssi": -55, "enc": True},
{"ssid": "HomeNet-5G", "rssi": -61, "enc": True},
{"ssid": "Neighbor 2.4", "rssi": -78, "enc": True},
{"ssid": "OpenGuest", "rssi": -83, "enc": False},
{"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False, "channel": 6},
{"ssid": "HomeNet", "rssi": -55, "enc": True, "channel": 1},
{"ssid": "HomeNet-5G", "rssi": -61, "enc": True, "channel": 11},
{"ssid": "Neighbor 2.4", "rssi": -78, "enc": True, "channel": 9},
{"ssid": "OpenGuest", "rssi": -83, "enc": False, "channel": 3},
]
@@ -137,7 +138,8 @@ def default_config(setup_mode):
"alert.region": "", "alert.interval": 15,
"alert.mqtt": False, "alert.wifi": False,
"bridge.enabled": False, "bridge.source": "rx", "bridge.baud": 115200,
"bridge.delay": 0, "bridge.channel": 0, "bridge.secret": "",
"bridge.delay": 0, "bridge.channel": 0,
"bridge.format": "wrapped", "bridge.secret": "",
},
}
@@ -196,6 +198,7 @@ class State:
"role": "Repeater", "board": "Heltec V3 (mock)",
"uptime_s": int(time.time() - self.start),
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
"wifi_psk64": True,
# Every ordinary repeater feature except an external FEM, plus
# radio RX power saving (bit 12) and device power saving (bit 13).
"capabilities": 0x37FF,
@@ -262,6 +265,8 @@ def _rxps_level_timings(level, sf, bw, preamble):
def apply_set(cfg, key, val):
"""Return (ok, reply) and mutate cfg. Mirrors the firmware's validation for
the fields where it matters (length, IATA, owner key, port, radio combo)."""
if key == "wifi.pwd" and (len(val) > 64 or (len(val) == 64 and not _hex64(val))):
return False, "Error: WiFi password must be 0-63 characters or 64 hex characters"
# length guard for the plain string fields
if key in LEN_LIMITS and len(val) > LEN_LIMITS[key]:
return False, "Error: %s too long (max %d chars)" % (key, LEN_LIMITS[key])
+25 -1
View File
@@ -454,8 +454,32 @@ inline bool standaloneWiFiSSIDValid(const char* value) {
return len >= 1 && len <= 31;
}
// Observer settings may need a live bridge restart, but an allocated bridge
// object can remain present after `bridge.enabled` is turned off. Never invoke
// restart in that state: doing so would start runtime services while the saved
// enabled flag still says off. Propagate a real restart failure when enabled.
template <typename RestartFn>
inline bool restartBridgeIfEnabled(bool bridge_enabled, RestartFn restart) {
return !bridge_enabled || restart();
}
inline bool standaloneWiFiPasswordValid(const char* value) {
return value != nullptr && strlen(value) <= 63;
if (value == nullptr) return false;
const size_t len = strlen(value);
if (len <= 63) return true;
if (len != 64) return false;
// IEEE 802.11 permits a 64-character WPA/WPA2 PSK only in its raw
// hexadecimal form. Ordinary passphrases retain the historical 0-63
// character behavior above.
for (size_t i = 0; i < len; i++) {
const char c = value[i];
if (!((c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F'))) {
return false;
}
}
return true;
}
inline bool parseStandaloneWiFiPowerSave(const char* value,
+113 -16
View File
@@ -60,6 +60,9 @@ static void resetToUf2Bootloader() {
#else
#include <malloc.h> // mallinfo() for the `memory` command on nRF52/RP2040
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
#include <helpers/esp32/WiFiRadioPolicy.h>
#endif
#if defined(STM32_PLATFORM)
#include <InternalFileSystem.h>
#endif
@@ -779,6 +782,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
#else
_prefs->bridge_uart = 0;
#endif
_prefs->bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
#ifdef WITH_MQTT_BRIDGE
bool node_prefs_needs_migration = false;
@@ -1277,6 +1281,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
has_runtime_bridge_uart = true;
#endif
if (file.available() >= (int)sizeof(_prefs->bridge_format)) {
file.read((uint8_t *)&_prefs->bridge_format,
sizeof(_prefs->bridge_format));
}
}
}
}
@@ -1348,7 +1356,6 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000);
_prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1);
_prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD);
_prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14);
#ifdef WITH_RS232_BRIDGE
if (_prefs->bridge_uart != WITH_RS232_BRIDGE_UART
#ifdef WITH_RS232_BRIDGE_ALT
@@ -1361,6 +1368,17 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
#else
_prefs->bridge_uart = 0;
#endif
if (!mesh::bridge::isValidEspNowFormat(_prefs->bridge_format)) {
_prefs->bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
_com_prefs_needs_upgrade = true;
}
#ifdef WITH_ESPNOW_BRIDGE
if (!mesh::bridge::isValidEspNowBridgeChannel(
_prefs->bridge_channel, _prefs->bridge_format)) {
_prefs->bridge_channel = 1;
_com_prefs_needs_upgrade = true;
}
#endif
_prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1);
_prefs->reboot_interval = constrain(_prefs->reboot_interval, 0, 255);
@@ -1581,6 +1599,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) {
WRITE_COMMON_PREFS(&prefs->radio_fem_txgain); // 860
WRITE_COMMON_PREFS(&prefs->usb_logging_enabled); // 861
WRITE_COMMON_PREFS(&prefs->bridge_uart); // 862
WRITE_COMMON_PREFS(&prefs->bridge_format); // 863
#undef WRITE_COMMON_PREFS_BYTES
#undef WRITE_COMMON_PREFS
@@ -1734,7 +1753,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
file.write((uint8_t *)&_prefs->radio_fem_txgain, sizeof(_prefs->radio_fem_txgain)); // 860
file.write((uint8_t *)&_prefs->usb_logging_enabled, sizeof(_prefs->usb_logging_enabled)); // 861
file.write((uint8_t *)&_prefs->bridge_uart, sizeof(_prefs->bridge_uart)); // 862
// next: 863
file.write((uint8_t *)&_prefs->bridge_format, sizeof(_prefs->bridge_format)); // 863
// next: 864
#if defined(NRF52_PLATFORM)
if (!file.commit()) {
@@ -3137,6 +3157,34 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) {
void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strncmp(config, "espnow.channel", 14) == 0
&& (config[14] == 0 || config[14] == ' ' || config[14] == '\t')) {
const char* value = config + 14;
while (*value == ' ' || *value == '\t') value++;
uint8_t channel = 0;
if (!mesh::wifi::parseEspNowChannel(value, channel)) {
strcpy(reply, "Error: ESP-NOW channel must be 1-13");
} else {
// Snapshot the boot channel before writing NVS. The saved setting must
// never become the active value in this boot merely because this is the
// first policy helper reached by a CLI-only image.
const uint8_t active = mesh::wifi::activeEspNowChannel();
if (!mesh::wifi::saveConfiguredEspNowChannel(channel)) {
strcpy(reply, "Error: failed to save ESP-NOW channel");
} else if (channel == active) {
snprintf(reply, 160,
"OK - ESP-NOW channel %u saved and active",
(unsigned)channel);
} else {
snprintf(reply, 160,
"OK - ESP-NOW channel %u saved; active %u; reboot required",
(unsigned)channel, (unsigned)active);
}
}
return;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE
if (strncmp(config, "usb.logging", 11) == 0
&& (config[11] == 0 || config[11] == ' ' || config[11] == '\t')) {
@@ -4081,9 +4129,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
#endif
{
_prefs->bridge_enabled = enable;
_callbacks->setBridgeState(_prefs->bridge_enabled);
const bool applied = _callbacks->setBridgeState(_prefs->bridge_enabled);
savePrefs();
strcpy(reply, "OK");
if (applied) {
strcpy(reply, "OK");
} else {
strcpy(reply, enable ? "Error: bridge failed to start; setting saved"
: "Error: bridge failed to stop; setting saved");
}
}
} else if (memcmp(config, "bridge.delay ", 13) == 0) {
int delay = _atoi(&config[13]);
@@ -4115,9 +4168,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
uint32_t baud = atoi(&config[12]);
if (baud >= 9600 && baud <= BRIDGE_MAX_BAUD) {
_prefs->bridge_baud = (uint32_t)baud;
_callbacks->restartBridge();
const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge();
savePrefs();
strcpy(reply, "OK");
strcpy(reply, applied ? "OK"
: "Error: setting saved; bridge failed to restart");
} else {
sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD);
}
@@ -4144,21 +4198,28 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
#endif
else {
_prefs->bridge_uart = (uint8_t)uart;
_callbacks->restartBridge();
const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge();
savePrefs();
strcpy(reply, "OK");
strcpy(reply, applied ? "OK"
: "Error: setting saved; bridge failed to restart");
}
#endif
#ifdef WITH_ESPNOW_BRIDGE
} else if (memcmp(config, "bridge.channel ", 15) == 0) {
int ch = atoi(&config[15]);
if (ch > 0 && ch < 15) {
_prefs->bridge_channel = (uint8_t)ch;
_callbacks->restartBridge();
uint8_t channel = 0;
const uint8_t max_channel =
mesh::bridge::espNowMaxChannel(_prefs->bridge_format);
if (mesh::bridge::parseEspNowBridgeChannel(
&config[15], _prefs->bridge_format, channel)) {
_prefs->bridge_channel = channel;
const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge();
savePrefs();
strcpy(reply, "OK");
strcpy(reply, applied ? "OK"
: "Error: setting saved; bridge failed to restart");
} else {
strcpy(reply, "Error: channel must be between 1-14");
sprintf(reply, "Error: channel must be an integer between 1-%u for %s format",
(unsigned)max_channel,
mesh::bridge::espNowFormatName(_prefs->bridge_format));
}
} else if (memcmp(config, "bridge.secret ", 14) == 0) {
const char* secret = &config[14];
@@ -4166,9 +4227,29 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "Error: secret must be 1-%u characters", (unsigned)(sizeof(_prefs->bridge_secret) - 1));
} else {
StrHelper::strncpy(_prefs->bridge_secret, secret, sizeof(_prefs->bridge_secret));
_callbacks->restartBridge();
const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge();
savePrefs();
strcpy(reply, "OK");
strcpy(reply, applied ? "OK"
: "Error: setting saved; bridge failed to restart");
}
} else if (memcmp(config, "bridge.format ", 14) == 0) {
uint8_t format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
if (!mesh::bridge::parseEspNowFormat(&config[14], format)) {
strcpy(reply, "Error: format must be wrapped or raw");
} else if (!mesh::bridge::isValidEspNowBridgeChannel(
_prefs->bridge_channel, format)) {
strcpy(reply, "Error: set bridge.channel to 1-13 before changing format");
} else {
_prefs->bridge_format = format;
const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge();
savePrefs();
if (!applied) {
strcpy(reply, "Error: setting saved; bridge failed to restart");
} else {
strcpy(reply, format == mesh::bridge::ESPNOW_FORMAT_RAW
? "OK - raw MeshCore frames; bridge.secret is ignored"
: "OK - wrapped bridge frames");
}
}
#endif
} else if (memcmp(config, "adc.multiplier ", 15) == 0) {
@@ -4226,6 +4307,19 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strcmp(config, "espnow.channel") == 0) {
const uint8_t saved = mesh::wifi::loadConfiguredEspNowChannel();
const uint8_t active = mesh::wifi::activeEspNowChannel();
if (saved == active) {
snprintf(reply, 160, "> %u (saved and active)", (unsigned)saved);
} else {
snprintf(reply, 160, "> saved %u, active %u; reboot required",
(unsigned)saved, (unsigned)active);
}
return;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE
if (strcmp(config, "usb.logging") == 0) {
snprintf(reply, 160, "> %s",
@@ -4551,6 +4645,9 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel);
} else if (configKeyEquals(config, "bridge.secret")) {
sprintf(reply, "> %s", _prefs->bridge_secret);
} else if (configKeyEquals(config, "bridge.format")) {
sprintf(reply, "> %s",
mesh::bridge::espNowFormatName(_prefs->bridge_format));
#endif
} else if (configKeyEquals(config, "bootloader.ver")) {
#ifdef NRF52_PLATFORM
+11 -3
View File
@@ -9,6 +9,7 @@
#include <helpers/PrefsSaveRouting.h>
#include <helpers/RegionMap.h>
#include <helpers/ConfigSerializer.h>
#include <helpers/bridges/ESPNowBridgeFormat.h>
#ifndef DEFAULT_CAD_ENABLED
#define DEFAULT_CAD_ENABLED 0
@@ -147,7 +148,7 @@ public:
uint16_t bridge_delay = 0; // milliseconds (default 500 ms)
uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logRx)
uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200)
uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only)
uint8_t bridge_channel = 0; // 1-13 (ESP-NOW only)
char bridge_secret[16] = {}; // for XOR encryption of bridge packets (ESP-NOW only)
// Power setting
uint8_t powersaving_enabled = 0; // boolean
@@ -179,6 +180,9 @@ public:
// Runtime UART choice for merged RS-232 repeater artifacts. Appended at
// /com_prefs offset 862; single-UART builds keep their compiled port here.
uint8_t bridge_uart = 0;
// ESP-NOW bridge wire format. Appended at /com_prefs offset 863; zero keeps
// existing installations on the original wrapped/checksummed/XOR format.
uint8_t bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
uint8_t retry_preset = 0;
uint8_t direct_retry_attempts = 0;
uint16_t direct_retry_base_ms = 0;
@@ -277,6 +281,7 @@ private:
def("uart", _parent->bridge_uart);
def("ch", _parent->bridge_channel);
def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret));
def("format", _parent->bridge_format);
def("usb_log", _parent->usb_logging_enabled);
}
@@ -494,12 +499,15 @@ public:
// no op by default
}
virtual void setBridgeState(bool enable) {
virtual bool setBridgeState(bool enable) {
// no op by default
(void)enable;
return false;
};
virtual void restartBridge() {
virtual bool restartBridge() {
// no op by default
return false;
};
virtual void restartBridgeSlot(int slot) {
+48 -6
View File
@@ -22,6 +22,8 @@
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <esp_wifi.h>
#include "WiFiPowerSave.h"
#include "esp32/WiFiRadioPolicy.h"
#endif
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
@@ -45,6 +47,14 @@ static uint32_t _atoi(const char* sp) {
}
#ifdef ESP_PLATFORM
static bool observerBluetoothWiFiCoexistenceRequired() {
#if defined(BLE_PIN_CODE) && defined(WIFI_SSID)
return true;
#else
return false;
#endif
}
// Optional embedded CA bundle symbols produced by board_build.embed_files.
// Weak linkage keeps non-bundle builds linkable.
extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak));
@@ -170,6 +180,11 @@ static void formatMQTTPresetListReply(char* reply, size_t reply_size, int start)
bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* config, char* reply) {
#ifdef WITH_MQTT_BRIDGE
bool handled = true;
const auto restart_observer_bridge = [this]() {
return mesh::cli::restartBridgeIfEnabled(
_prefs->bridge_enabled != 0,
[this]() { return _callbacks->restartBridge(); });
};
if (memcmp(config, "snmp.community ", 15) == 0) {
if (valueTooLong(&config[15], sizeof(_mqtt_prefs.snmp_community), reply, "snmp.community")) return true;
StrHelper::strncpy(_mqtt_prefs.snmp_community, &config[15], sizeof(_mqtt_prefs.snmp_community));
@@ -222,8 +237,9 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
// until one is set). This keeps the pre-existing "clear IATA" capability.
_mqtt_prefs.mqtt_iata[0] = '\0';
saveObserverPrefs();
_callbacks->restartBridge();
strcpy(reply, "OK - IATA cleared");
strcpy(reply, restart_observer_bridge()
? "OK - IATA cleared"
: "Error: IATA cleared; bridge failed to restart");
} else {
// A region code goes straight into MQTT topic paths, so require exactly
// three alphanumeric characters (real IATA codes are 3 letters, e.g. DEN).
@@ -235,8 +251,9 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
_mqtt_prefs.mqtt_iata[i] = toupper(_mqtt_prefs.mqtt_iata[i]);
}
saveObserverPrefs();
_callbacks->restartBridge();
strcpy(reply, "OK");
strcpy(reply, restart_observer_bridge()
? "OK"
: "Error: IATA saved; bridge failed to restart");
}
}
} else if (memcmp(config, "mqtt.status ", 12) == 0) {
@@ -268,8 +285,17 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
if (minutes >= 1 && minutes <= 60) {
_mqtt_prefs.mqtt_status_interval = minutes * 60000;
saveObserverPrefs();
_callbacks->restartBridge();
sprintf(reply, "OK - interval set to %u minutes (%lu ms), bridge restarted", minutes, (unsigned long)_mqtt_prefs.mqtt_status_interval);
if (!restart_observer_bridge()) {
strcpy(reply, "Error: interval saved; bridge failed to restart");
} else if (_prefs->bridge_enabled) {
sprintf(reply,
"OK - interval set to %u minutes (%lu ms), bridge restarted",
minutes, (unsigned long)_mqtt_prefs.mqtt_status_interval);
} else {
sprintf(reply,
"OK - interval set to %u minutes (%lu ms), bridge disabled",
minutes, (unsigned long)_mqtt_prefs.mqtt_status_interval);
}
} else {
strcpy(reply, "Error: interval must be between 1-60 minutes");
}
@@ -358,6 +384,17 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
}
if (!valid) {
strcpy(reply, "Error: must be none, min, or max");
#ifdef ESP_PLATFORM
} else if (mesh::wifi::kPrimaryEspNowRadio
&& ps_value == mesh::wifi::kPowerSaveMax) {
strcpy(reply,
"Error: power save max is unavailable while ESP-NOW is the primary radio");
} else if (mesh::wifi::effectivePowerSave(
ps_value, observerBluetoothWiFiCoexistenceRequired(),
mesh::wifi::kPrimaryEspNowRadio) != ps_value) {
strcpy(reply,
"Error: power save none is unavailable while Bluetooth is active");
#endif
} else {
_mqtt_prefs.wifi_power_save = ps_value;
saveObserverPrefs();
@@ -999,6 +1036,11 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
#endif
} else if (memcmp(config, "wifi.powersave", 14) == 0) {
uint8_t ps = _mqtt_prefs.wifi_power_save;
#ifdef ESP_PLATFORM
ps = mesh::wifi::effectivePowerSave(
ps, observerBluetoothWiFiCoexistenceRequired(),
mesh::wifi::kPrimaryEspNowRadio);
#endif
const char* ps_name = (ps == 1) ? "none" : (ps == 2) ? "max" : "min";
sprintf(reply, "> %s", ps_name);
} else if (memcmp(config, "timezone.offset", 15) == 0) {
+385
View File
@@ -0,0 +1,385 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
namespace espnow {
// ESP-NOW v1 payloads are limited to 250 bytes while MeshCore's serialized
// packet length is an unsigned byte and can therefore reach 255 bytes. Keep
// ordinary packets byte-for-byte compatible and use this envelope only for
// the five otherwise-unrepresentable lengths, 251 through 255.
static constexpr size_t ESPNOW_RAW_MAX_FRAME_SIZE = 250;
static constexpr size_t ESPNOW_RAW_MAX_PACKET_SIZE = 255;
static constexpr size_t ESPNOW_RAW_SOURCE_MAC_SIZE = 6;
static constexpr uint8_t ESPNOW_RAW_FRAGMENT_COUNT = 2;
static constexpr uint8_t ESPNOW_RAW_FRAGMENT_VERSION = 1;
// 0xFE has MeshCore payload-version bits 0b11. Current MeshCore receivers
// reject every such packet before parsing its route or payload, so this marker
// cannot collide with a valid serialized MeshCore packet. This deliberately
// reserves every raw frame beginning with 0xFE for fragmentation until a
// future MeshCore wire-version change revises the reservation. The following
// MCF bytes and explicit version make malformed envelopes fail closed.
static constexpr uint8_t ESPNOW_RAW_FRAGMENT_MAGIC[] = {
0xFE, 0x4D, 0x43, 0x46}; // unsupported MeshCore version, "MCF"
static constexpr size_t ESPNOW_RAW_FRAGMENT_MAGIC_SIZE =
sizeof(ESPNOW_RAW_FRAGMENT_MAGIC);
// Fragment header, all multi-byte fields in network byte order:
// magic[4], version, count, index, total_len, offset, crc32[4]
// Fragment zero carries a full 237-byte body. Fragment one carries the
// remaining 14-18 bytes. Both frames therefore fit ESP-NOW's 250-byte limit.
static constexpr size_t ESPNOW_RAW_FRAGMENT_HEADER_SIZE = 13;
static constexpr size_t ESPNOW_RAW_FRAGMENT_DATA_SIZE =
ESPNOW_RAW_MAX_FRAME_SIZE - ESPNOW_RAW_FRAGMENT_HEADER_SIZE;
static_assert(ESPNOW_RAW_FRAGMENT_DATA_SIZE < ESPNOW_RAW_MAX_FRAME_SIZE,
"ESP-NOW fragment header must leave room for data");
static_assert(ESPNOW_RAW_FRAGMENT_DATA_SIZE * ESPNOW_RAW_FRAGMENT_COUNT >=
ESPNOW_RAW_MAX_PACKET_SIZE,
"two ESP-NOW fragments must hold one maximum MeshCore packet");
struct ESPNowRawFrames {
uint8_t count;
uint16_t lengths[ESPNOW_RAW_FRAGMENT_COUNT];
uint8_t data[ESPNOW_RAW_FRAGMENT_COUNT][ESPNOW_RAW_MAX_FRAME_SIZE];
ESPNowRawFrames() : count(0), lengths{0, 0}, data{} {}
};
inline uint32_t espNowRawCrc32(const uint8_t* data, size_t len) {
if (data == nullptr && len != 0) return 0;
uint32_t crc = 0xFFFFFFFFUL;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
const uint32_t mask = 0U - (crc & 1U);
crc = (crc >> 1) ^ (0xEDB88320UL & mask);
}
}
return ~crc;
}
inline void writeEspNowRawUint32(uint8_t* dest, uint32_t value) {
dest[0] = static_cast<uint8_t>(value >> 24);
dest[1] = static_cast<uint8_t>(value >> 16);
dest[2] = static_cast<uint8_t>(value >> 8);
dest[3] = static_cast<uint8_t>(value);
}
inline uint32_t readEspNowRawUint32(const uint8_t* src) {
return (static_cast<uint32_t>(src[0]) << 24)
| (static_cast<uint32_t>(src[1]) << 16)
| (static_cast<uint32_t>(src[2]) << 8)
| static_cast<uint32_t>(src[3]);
}
/**
* Encode one serialized MeshCore packet for raw ESP-NOW transport.
*
* Packets up to 250 bytes produce one exact, unwrapped frame. Only packets
* from 251 through 255 bytes produce the versioned two-frame envelope.
* `frames.count` is zero on every failure.
*/
inline bool encodeEspNowRawFrames(const uint8_t* packet, size_t packet_len,
ESPNowRawFrames& frames) {
frames.count = 0;
frames.lengths[0] = 0;
frames.lengths[1] = 0;
if (packet == nullptr || packet_len < 2
|| packet_len > ESPNOW_RAW_MAX_PACKET_SIZE) {
return false;
}
if (packet_len <= ESPNOW_RAW_MAX_FRAME_SIZE) {
memcpy(frames.data[0], packet, packet_len);
frames.lengths[0] = static_cast<uint16_t>(packet_len);
frames.count = 1;
return true;
}
const uint32_t checksum = espNowRawCrc32(packet, packet_len);
for (uint8_t index = 0; index < ESPNOW_RAW_FRAGMENT_COUNT; ++index) {
const size_t offset = index == 0 ? 0 : ESPNOW_RAW_FRAGMENT_DATA_SIZE;
const size_t remaining = packet_len - offset;
const size_t body_len = remaining < ESPNOW_RAW_FRAGMENT_DATA_SIZE
? remaining : ESPNOW_RAW_FRAGMENT_DATA_SIZE;
uint8_t* frame = frames.data[index];
memcpy(frame, ESPNOW_RAW_FRAGMENT_MAGIC,
ESPNOW_RAW_FRAGMENT_MAGIC_SIZE);
frame[4] = ESPNOW_RAW_FRAGMENT_VERSION;
frame[5] = ESPNOW_RAW_FRAGMENT_COUNT;
frame[6] = index;
frame[7] = static_cast<uint8_t>(packet_len);
frame[8] = static_cast<uint8_t>(offset);
writeEspNowRawUint32(frame + 9, checksum);
memcpy(frame + ESPNOW_RAW_FRAGMENT_HEADER_SIZE, packet + offset,
body_len);
frames.lengths[index] = static_cast<uint16_t>(
ESPNOW_RAW_FRAGMENT_HEADER_SIZE + body_len);
}
frames.count = ESPNOW_RAW_FRAGMENT_COUNT;
return true;
}
enum class ESPNowRawReassemblyResult : uint8_t {
PASSTHROUGH,
FRAGMENT_STORED,
PACKET_COMPLETE,
DUPLICATE,
REJECTED,
OUTPUT_TOO_SMALL,
};
/**
* Fixed-storage ESP-NOW raw-packet reassembler.
*
* Slots are keyed by source MAC, complete-packet CRC, and packet length.
* Fragments may arrive in either order. Identical fragments are harmless;
* conflicting duplicates and malformed envelopes are rejected without
* replacing a valid fragment. Completed identities are remembered until the
* same timeout while their bounded slot remains resident; under slot pressure,
* delivered duplicate-cache entries are evicted before in-flight assemblies.
*/
template <size_t SlotCount>
class ESPNowRawReassemblerT {
public:
static_assert(SlotCount > 0, "ESP-NOW reassembly needs at least one slot");
explicit ESPNowRawReassemblerT(uint32_t timeout_ms = 2000)
: _timeout_ms(timeout_ms) {
reset();
}
void reset() {
memset(_slots, 0, sizeof(_slots));
}
size_t expire(uint32_t now_ms) {
size_t expired = 0;
for (size_t i = 0; i < SlotCount; ++i) {
if (_slots[i].state != SLOT_EMPTY
&& elapsed(now_ms, _slots[i].updated_ms) >= _timeout_ms) {
clearSlot(_slots[i]);
++expired;
}
}
return expired;
}
ESPNowRawReassemblyResult acceptFrame(
const uint8_t source_mac[ESPNOW_RAW_SOURCE_MAC_SIZE],
const uint8_t* frame, size_t frame_len, uint32_t now_ms,
uint8_t* packet_out, size_t packet_capacity, size_t& packet_len) {
packet_len = 0;
expire(now_ms);
if (frame == nullptr || frame_len == 0
|| frame_len > ESPNOW_RAW_MAX_FRAME_SIZE) {
return ESPNowRawReassemblyResult::REJECTED;
}
// Valid current MeshCore packets can never begin with 0xFE. Reserve that
// entire prefix so truncated or corrupted envelopes cannot be handed to
// the ordinary MeshCore parser as if they were legacy raw frames.
if (frame[0] != ESPNOW_RAW_FRAGMENT_MAGIC[0]) {
if (packet_out == nullptr || packet_capacity < frame_len) {
return ESPNowRawReassemblyResult::OUTPUT_TOO_SMALL;
}
memcpy(packet_out, frame, frame_len);
packet_len = frame_len;
return ESPNowRawReassemblyResult::PASSTHROUGH;
}
FragmentInfo fragment;
if (source_mac == nullptr || !parseFragment(frame, frame_len, fragment)) {
return ESPNowRawReassemblyResult::REJECTED;
}
Slot* slot = findSlot(source_mac, fragment.total_len, fragment.checksum);
if (slot == nullptr) {
slot = allocateSlot(now_ms);
initializeSlot(*slot, source_mac, fragment.total_len,
fragment.checksum, now_ms);
}
const uint8_t bit = static_cast<uint8_t>(1U << fragment.index);
if ((slot->received_mask & bit) != 0) {
if (memcmp(slot->packet + fragment.offset, fragment.body,
fragment.body_len) != 0) {
return ESPNowRawReassemblyResult::REJECTED;
}
return ESPNowRawReassemblyResult::DUPLICATE;
}
if (slot->state == SLOT_DELIVERED) {
// A delivered slot necessarily has both bits, but fail closed if its
// metadata was ever corrupted rather than writing into it again.
return ESPNowRawReassemblyResult::REJECTED;
}
memcpy(slot->packet + fragment.offset, fragment.body, fragment.body_len);
slot->received_mask = static_cast<uint8_t>(slot->received_mask | bit);
slot->updated_ms = now_ms;
const uint8_t all_fragments =
static_cast<uint8_t>((1U << ESPNOW_RAW_FRAGMENT_COUNT) - 1U);
if (slot->received_mask != all_fragments) {
return ESPNowRawReassemblyResult::FRAGMENT_STORED;
}
if (espNowRawCrc32(slot->packet, slot->total_len) != slot->checksum) {
clearSlot(*slot);
return ESPNowRawReassemblyResult::REJECTED;
}
if (packet_out == nullptr || packet_capacity < slot->total_len) {
clearSlot(*slot);
return ESPNowRawReassemblyResult::OUTPUT_TOO_SMALL;
}
memcpy(packet_out, slot->packet, slot->total_len);
packet_len = slot->total_len;
slot->state = SLOT_DELIVERED;
slot->updated_ms = now_ms;
return ESPNowRawReassemblyResult::PACKET_COMPLETE;
}
private:
enum SlotState : uint8_t {
SLOT_EMPTY = 0,
SLOT_ASSEMBLING = 1,
SLOT_DELIVERED = 2,
};
struct FragmentInfo {
uint8_t index;
uint8_t total_len;
uint8_t offset;
uint32_t checksum;
const uint8_t* body;
size_t body_len;
};
struct Slot {
SlotState state;
uint8_t source_mac[ESPNOW_RAW_SOURCE_MAC_SIZE];
uint8_t total_len;
uint8_t received_mask;
uint32_t checksum;
uint32_t updated_ms;
uint8_t packet[ESPNOW_RAW_MAX_PACKET_SIZE];
};
static uint32_t elapsed(uint32_t now_ms, uint32_t then_ms) {
return static_cast<uint32_t>(now_ms - then_ms);
}
static void clearSlot(Slot& slot) {
slot.state = SLOT_EMPTY;
slot.received_mask = 0;
}
static bool parseFragment(const uint8_t* frame, size_t frame_len,
FragmentInfo& fragment) {
if (frame_len < ESPNOW_RAW_FRAGMENT_HEADER_SIZE
|| memcmp(frame, ESPNOW_RAW_FRAGMENT_MAGIC,
ESPNOW_RAW_FRAGMENT_MAGIC_SIZE) != 0
|| frame[4] != ESPNOW_RAW_FRAGMENT_VERSION
|| frame[5] != ESPNOW_RAW_FRAGMENT_COUNT
|| frame[6] >= ESPNOW_RAW_FRAGMENT_COUNT
|| frame[7] <= ESPNOW_RAW_MAX_FRAME_SIZE
|| frame[7] > ESPNOW_RAW_MAX_PACKET_SIZE) {
return false;
}
fragment.index = frame[6];
fragment.total_len = frame[7];
fragment.offset = frame[8];
fragment.checksum = readEspNowRawUint32(frame + 9);
fragment.body = frame + ESPNOW_RAW_FRAGMENT_HEADER_SIZE;
fragment.body_len = frame_len - ESPNOW_RAW_FRAGMENT_HEADER_SIZE;
const size_t expected_offset = fragment.index == 0
? 0 : ESPNOW_RAW_FRAGMENT_DATA_SIZE;
const size_t expected_body_len = fragment.index == 0
? ESPNOW_RAW_FRAGMENT_DATA_SIZE
: static_cast<size_t>(fragment.total_len)
- ESPNOW_RAW_FRAGMENT_DATA_SIZE;
return fragment.offset == expected_offset
&& fragment.body_len == expected_body_len
&& static_cast<size_t>(fragment.offset) + fragment.body_len
<= fragment.total_len;
}
Slot* findSlot(const uint8_t* source_mac, uint8_t total_len,
uint32_t checksum) {
for (size_t i = 0; i < SlotCount; ++i) {
Slot& slot = _slots[i];
if (slot.state != SLOT_EMPTY && slot.total_len == total_len
&& slot.checksum == checksum
&& memcmp(slot.source_mac, source_mac,
ESPNOW_RAW_SOURCE_MAC_SIZE) == 0) {
return &slot;
}
}
return nullptr;
}
Slot* allocateSlot(uint32_t now_ms) {
for (size_t i = 0; i < SlotCount; ++i) {
if (_slots[i].state == SLOT_EMPTY) return &_slots[i];
}
// Delivered identities are only a duplicate-suppression cache, so evict
// the oldest of those before sacrificing an in-flight reassembly.
Slot* candidate = nullptr;
uint32_t candidate_age = 0;
for (size_t i = 0; i < SlotCount; ++i) {
Slot& slot = _slots[i];
if (slot.state != SLOT_DELIVERED) continue;
const uint32_t age = elapsed(now_ms, slot.updated_ms);
if (candidate == nullptr || age > candidate_age) {
candidate = &slot;
candidate_age = age;
}
}
if (candidate != nullptr) return candidate;
// If every bounded slot is assembling, retain the freshest work and
// replace the oldest. No input can grow memory usage beyond SlotCount.
candidate = &_slots[0];
candidate_age = elapsed(now_ms, candidate->updated_ms);
for (size_t i = 1; i < SlotCount; ++i) {
const uint32_t age = elapsed(now_ms, _slots[i].updated_ms);
if (age > candidate_age) {
candidate = &_slots[i];
candidate_age = age;
}
}
return candidate;
}
static void initializeSlot(Slot& slot, const uint8_t* source_mac,
uint8_t total_len, uint32_t checksum,
uint32_t now_ms) {
slot.state = SLOT_ASSEMBLING;
memcpy(slot.source_mac, source_mac, ESPNOW_RAW_SOURCE_MAC_SIZE);
slot.total_len = total_len;
slot.received_mask = 0;
slot.checksum = checksum;
slot.updated_ms = now_ms;
}
uint32_t _timeout_ms;
Slot _slots[SlotCount];
};
using ESPNowRawReassembler = ESPNowRawReassemblerT<4>;
} // namespace espnow
} // namespace mesh
+27
View File
@@ -89,6 +89,33 @@ public:
bool isBorrowingAscii() const { return _restore_ascii; }
};
// Keeps the single-TTY logging transitions independent from the platform's
// stream objects. In particular, an active TCP terminal owns the role CLI and
// must not be displaced just because USB logging is enabled.
enum class UsbLoggingTerminalAction : uint8_t {
NONE,
CLAIM_USB,
RETURN_TO_BINARY,
KEEP_ASCII,
};
inline UsbLoggingTerminalAction selectUsbLoggingTerminalAction(
bool has_dedicated_logging_port, bool logging_enabled,
bool usb_terminal_active, bool usb_logging_terminal_active,
bool full_companion, bool network_terminal_active) {
if (has_dedicated_logging_port || network_terminal_active) {
return UsbLoggingTerminalAction::NONE;
}
if (logging_enabled) {
return UsbLoggingTerminalAction::CLAIM_USB;
}
if (!usb_terminal_active || !usb_logging_terminal_active) {
return UsbLoggingTerminalAction::NONE;
}
return full_companion ? UsbLoggingTerminalAction::KEEP_ASCII
: UsbLoggingTerminalAction::RETURN_TO_BINARY;
}
enum class UsbMotaEntryOrigin : uint8_t { BINARY, ASCII };
inline bool shouldRestoreAsciiAfterMotaFailure(UsbMotaEntryOrigin origin) {
+9 -4
View File
@@ -156,15 +156,20 @@ static inline uint32_t finishRebootAt(bool batch_reboot, bool batch_all_ok, uint
// A successful setup-portal save that changes station credentials is held in
// Pending while AP+STA joins the selected network. That makes the DHCP address
// available to the captive page before its setup AP disappears. Other saves
// keep the ordinary batch/reboot path.
// available to the captive page before its setup AP disappears. When the
// shared ESP-NOW channel changes, the station must remain on the old boot
// channel until reboot, so credentials are applied together on the next boot
// instead of attempting this live handoff. Other saves keep the ordinary
// batch/reboot path.
static inline bool shouldStartSetupWiFiHandoff(bool setup_mode,
bool batch_reboot,
bool batch_all_ok,
bool wifi_credentials_changed,
bool has_wifi_ssid) {
bool has_wifi_ssid,
bool espnow_channel_changed) {
return setup_mode && batch_reboot && batch_all_ok
&& wifi_credentials_changed && has_wifi_ssid;
&& wifi_credentials_changed && has_wifi_ssid
&& !espnow_channel_changed;
}
static inline uint32_t setupWiFiConnectDeadline(uint32_t now) {
+9 -1
View File
@@ -24,7 +24,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
"advert.interval", "flood.advert.interval",
"flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect",
// MQTTPrefs (WiFi / MQTT / misc observer)
"wifi.ssid", "wifi.pwd", "wifi.powersave",
"wifi.ssid", "wifi.pwd", "wifi.powersave", "espnow.channel",
"mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw",
"mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval",
"mqtt.ntp", "mqtt.owner", "mqtt.email",
@@ -57,6 +57,14 @@ static inline bool wcIsAllowedSetKey(const char* key) {
return false;
}
// Changing the primary ESP-NOW channel cannot be applied live: every peer and
// any infrastructure-WiFi association must stay on the boot channel until the
// node restarts. The server enforces this even when a crafted client omits the
// browser's reboot flag.
static inline bool wcSetKeyRequiresReboot(const char* key) {
return key != NULL && strcmp(key, "espnow.channel") == 0;
}
// The admin password maps to the top-level `password` command, not a setter, so
// it is classified apart from the `set` allowlist. It is the only key that gets
// this treatment, which is what keeps the allowlist the sole route to `set` and
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace wifi {
// ESP-NOW primary-radio targets use the 2.4 GHz channel range that can also
// carry ordinary b/g/n WiFi. Channel 14 is deliberately excluded: it is
// country-specific and restricted to 802.11b, so it cannot satisfy the shared
// b/g/n+LR coexistence policy used by Full builds.
static constexpr uint8_t kEspNowChannelMin = 1;
static constexpr uint8_t kEspNowChannelMax = 13;
inline bool isValidEspNowChannel(unsigned long channel) {
return channel >= kEspNowChannelMin && channel <= kEspNowChannelMax;
}
// Strict whole-value parser for CLI and WebConfig input. Whitespace around the
// decimal channel is accepted; signs, decimal points, and trailing text are
// rejected so a malformed request cannot silently select a different channel.
inline bool parseEspNowChannel(const char* value, uint8_t& channel) {
if (value == nullptr) return false;
while (*value == ' ' || *value == '\t') value++;
if (*value < '0' || *value > '9') return false;
unsigned long parsed = 0;
do {
parsed = parsed * 10UL + static_cast<unsigned long>(*value - '0');
if (parsed > kEspNowChannelMax) return false;
value++;
} while (*value >= '0' && *value <= '9');
while (*value == ' ' || *value == '\t') value++;
if (*value != 0 || !isValidEspNowChannel(parsed)) return false;
channel = static_cast<uint8_t>(parsed);
return true;
}
inline uint8_t validEspNowChannelOrDefault(uint8_t stored,
uint8_t fallback) {
return isValidEspNowChannel(stored) ? stored : fallback;
}
} // namespace wifi
} // namespace mesh
+8 -2
View File
@@ -22,10 +22,16 @@ static constexpr uint8_t kPowerSaveMax = 2;
static constexpr uint8_t kDefaultPowerSave = DEFAULT_WIFI_POWER_SAVE_MODE;
// ESP32 WiFi and Bluetooth coexistence requires modem sleep, so "none" must
// fall back to the minimum sleep policy whenever Bluetooth is active.
// fall back to the minimum sleep policy whenever Bluetooth is active. A
// primary ESP-NOW receiver must also avoid maximum modem sleep: infrastructure
// access points do not buffer ESP-NOW broadcasts while the station is asleep.
inline uint8_t effectivePowerSave(uint8_t configured,
bool bluetooth_active) {
bool bluetooth_active,
bool primary_espnow_active = false) {
if (configured > kPowerSaveMax) configured = kDefaultPowerSave;
if (primary_espnow_active && configured == kPowerSaveMax) {
configured = kPowerSaveMin;
}
return bluetooth_active && configured == kPowerSaveNone
? kPowerSaveMin : configured;
}
+52 -14
View File
@@ -9,6 +9,9 @@
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <strings.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/WiFiStationPolicy.h>
namespace {
@@ -28,8 +31,8 @@ button{margin-top:1.2rem}small{color:#555}</style></head><body>
<h2>MeshCore WiFi setup</h2><p>Enter the WiFi network this node should join.</p>
<form method="post" action="/save">
<label>SSID<input name="ssid" maxlength="31" required autocomplete="off"></label>
<label>Password<input name="password" type="password" maxlength="63" autocomplete="off"></label>
<small>Leave the password blank for an open network.</small>
<label>Password<input name="password" type="password" maxlength="64" autocomplete="off"></label>
<small>Leave blank for an open network. A 64-character key must be hexadecimal.</small>
<button type="submit">Save and connect</button></form></body></html>)HTML";
struct PortalImpl {
@@ -42,7 +45,7 @@ struct PortalImpl {
uint32_t close_ap_at = 0;
char ap_name[33] = "MeshCore-Setup";
char recovery_ssid[32] = "";
char recovery_password[64] = "";
char recovery_password[65] = "";
uint32_t recovery_interval_ms = 0;
uint32_t next_recovery_at = 0;
uint32_t recovery_deadline = 0;
@@ -114,7 +117,7 @@ static bool getFormField(const char* body, const char* name,
while (field && *field) {
const char* end = strchr(field, '&');
size_t field_len = end ? static_cast<size_t>(end - field) : strlen(field);
if (field_len > name_len + 1 && field[name_len] == '='
if (field_len >= name_len + 1 && field[name_len] == '='
&& strncmp(field, name, name_len) == 0) {
return decodeFormValue(field + name_len + 1, field_len - name_len - 1,
output, output_size);
@@ -125,22 +128,53 @@ static bool getFormField(const char* body, const char* name,
return false;
}
static bool hasFormField(const char* body, const char* name) {
if (!body || !name) return false;
const size_t name_len = strlen(name);
const char* field = body;
while (*field) {
const char* end = strchr(field, '&');
const size_t field_len = end
? static_cast<size_t>(end - field) : strlen(field);
if (field_len >= name_len + 1 && field[name_len] == '='
&& strncmp(field, name, name_len) == 0) {
return true;
}
field = end ? end + 1 : nullptr;
if (!field) break;
}
return false;
}
static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
char ssid[32];
char password[64];
char password[65];
if (!getFormField(body, "ssid", ssid, sizeof(ssid)) || ssid[0] == 0) {
sendResponse(client, 400, "Bad Request", "text/plain", "A valid SSID is required.");
return;
}
// Browsers omit an empty password only if the form is modified. Treat either
// an empty field or an absent one as an open network.
if (!getFormField(body, "password", password, sizeof(password))) password[0] = 0;
if (hasFormField(body, "password")) {
if (!getFormField(body, "password", password, sizeof(password))) {
sendResponse(client, 400, "Bad Request", "text/plain",
"WiFi password must be 0-63 characters or exactly 64 hexadecimal characters.");
return;
}
} else {
password[0] = 0;
}
if (!mesh::cli::standaloneWiFiPasswordValid(password)) {
sendResponse(client, 400, "Bad Request", "text/plain",
"WiFi password must be 0-63 characters or exactly 64 hexadecimal characters.");
return;
}
// A submitted configuration takes priority over a background retry of the
// previously saved network.
impl->recovery_connecting = false;
Serial.printf("WiFi setup: connecting to '%s'...\n", ssid);
WiFi.begin(ssid, password);
mesh::wifi::beginStation(ssid, password);
uint32_t deadline = millis() + CONNECT_TIMEOUT_MS;
while (impl->active && *impl->active && WiFi.status() != WL_CONNECTED
&& static_cast<int32_t>(millis() - deadline) < 0) {
@@ -250,7 +284,8 @@ static void portalTask(void* arg) {
} else if (impl->recovery_ssid[0] && impl->recovery_interval_ms
&& static_cast<int32_t>(now - impl->next_recovery_at) >= 0) {
Serial.printf("WiFi setup: retrying saved network '%s'...\n", impl->recovery_ssid);
WiFi.begin(impl->recovery_ssid, impl->recovery_password);
mesh::wifi::beginStation(
impl->recovery_ssid, impl->recovery_password);
impl->recovery_connecting = true;
impl->recovery_deadline = now + CONNECT_TIMEOUT_MS;
impl->next_recovery_at = now + impl->recovery_interval_ms;
@@ -297,14 +332,15 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
WiFi.mode(WIFI_AP_STA);
if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK)
|| !WiFi.softAP(impl->ap_name, nullptr)
|| !WiFi.softAP(impl->ap_name, nullptr,
mesh::wifi::accessPointChannel())
|| esp_wifi_set_protocol(
WIFI_IF_AP,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
mesh::wifi::kProtocolMask)
!= ESP_OK
|| esp_wifi_set_protocol(
WIFI_IF_STA,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
mesh::wifi::kProtocolMask)
!= ESP_OK) {
WiFi.softAPdisconnect(true);
return false;
@@ -366,7 +402,8 @@ bool WiFiSetupPortal::loadStoredCredentials(char* ssid, size_t ssid_size,
String stored_password = prefs.getString("password", "");
prefs.end();
if (stored_ssid.length() == 0 || stored_ssid.length() >= ssid_size
|| stored_password.length() >= password_size) return false;
|| stored_password.length() >= password_size
|| !mesh::cli::standaloneWiFiPasswordValid(stored_password.c_str())) return false;
strncpy(ssid, stored_ssid.c_str(), ssid_size - 1);
ssid[ssid_size - 1] = 0;
strncpy(password, stored_password.c_str(), password_size - 1);
@@ -375,10 +412,11 @@ bool WiFiSetupPortal::loadStoredCredentials(char* ssid, size_t ssid_size,
}
bool WiFiSetupPortal::saveStoredCredentials(const char* ssid, const char* password) {
if (!ssid || !ssid[0] || strlen(ssid) > 31 || (password && strlen(password) > 63)) return false;
const char* saved_password = password ? password : "";
if (!ssid || !ssid[0] || strlen(ssid) > 31
|| !mesh::cli::standaloneWiFiPasswordValid(saved_password)) return false;
Preferences prefs;
if (!prefs.begin("mesh-wifi", false)) return false;
const char* saved_password = password ? password : "";
bool ok = prefs.putString("ssid", ssid) == strlen(ssid);
prefs.putString("password", saved_password); // An empty String reports zero bytes even on success.
ok = ok && prefs.getString("password", "\x01") == saved_password;
+379 -53
View File
@@ -12,13 +12,13 @@ ESPNowBridge *ESPNowBridge::_instance = nullptr;
void ESPNowBridge::recv_cb(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
const uint8_t *mac = info != nullptr ? info->src_addr : nullptr;
if (_instance) {
_instance->onDataRecv(mac, data, len);
_instance->queueReceivedFrame(mac, data, len);
}
}
#else
void ESPNowBridge::recv_cb(const uint8_t *mac, const uint8_t *data, int len) {
if (_instance) {
_instance->onDataRecv(mac, data, len);
_instance->queueReceivedFrame(mac, data, len);
}
}
#endif
@@ -36,7 +36,12 @@ void ESPNowBridge::send_cb(const uint8_t *mac, esp_now_send_status_t status) {
}
ESPNowBridge::ESPNowBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc)
: BridgeBase(prefs, mgr, rtc), _rx_buffer_pos(0) {
: BridgeBase(prefs, mgr, rtc), _rx_head(0), _rx_tail(0), _rx_count(0),
_rx_dropped(0), _rx_dropped_reported(0),
_tx_head(0), _tx_tail(0), _tx_count(0), _tx_waiting(false),
_tx_callback_done(false), _tx_callback_status(ESP_NOW_SEND_SUCCESS),
_tx_dropped(0), _tx_dropped_reported(0),
_active_format(mesh::bridge::ESPNOW_FORMAT_WRAPPED) {
_instance = this;
}
@@ -45,6 +50,33 @@ void ESPNowBridge::begin() {
if (_initialized) return;
_active_format = mesh::bridge::isValidEspNowFormat(_prefs->bridge_format)
? _prefs->bridge_format : mesh::bridge::ESPNOW_FORMAT_WRAPPED;
portENTER_CRITICAL(&_rx_mux);
_rx_head = 0;
_rx_tail = 0;
_rx_count = 0;
_rx_dropped = 0;
portEXIT_CRITICAL(&_rx_mux);
_rx_dropped_reported = 0;
_raw_reassembler.reset();
portENTER_CRITICAL(&_tx_mux);
_tx_head = 0;
_tx_tail = 0;
_tx_count = 0;
_tx_waiting = false;
_tx_callback_done = false;
_tx_callback_status = ESP_NOW_SEND_SUCCESS;
_tx_dropped = 0;
portEXIT_CRITICAL(&_tx_mux);
_tx_dropped_reported = 0;
if (!mesh::bridge::isValidEspNowBridgeChannel(
_prefs->bridge_channel, _active_format)) {
BRIDGE_DEBUG_PRINTLN("ESP-NOW channel must be 1-13\n");
return;
}
// ESP-NOW only needs the ESP-IDF station interface. Avoid Arduino's WiFi
// facade here: it pulls DHCP, DNS, scanning, AP, and event plumbing that an
// ESP-NOW-only bridge never uses.
@@ -57,6 +89,21 @@ void ESPNowBridge::begin() {
esp_wifi_deinit();
return;
}
const bool raw_format =
_active_format == mesh::bridge::ESPNOW_FORMAT_RAW;
if (raw_format) {
// Primary ESPNOWRadio nodes use Espressif's LR PHY. Retain B/G/N receive
// support as well, but force this peer's outgoing frames to LR below.
const uint8_t protocols = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G
| WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR;
if (esp_wifi_set_protocol(WIFI_IF_STA, protocols) != ESP_OK) {
BRIDGE_DEBUG_PRINTLN("Error enabling ESP-NOW LR protocol\n");
esp_wifi_stop();
esp_wifi_deinit();
return;
}
}
// Set Wi-Fi channel
if (esp_wifi_set_channel(_prefs->bridge_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) {
@@ -90,6 +137,7 @@ void ESPNowBridge::begin() {
memset(&peerInfo, 0, sizeof(peerInfo));
memset(peerInfo.peer_addr, 0xFF, ESP_NOW_ETH_ALEN); // Broadcast address
peerInfo.channel = _prefs->bridge_channel;
peerInfo.ifidx = WIFI_IF_STA;
peerInfo.encrypt = false;
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
@@ -102,6 +150,31 @@ void ESPNowBridge::begin() {
return;
}
if (raw_format) {
esp_err_t rate_result;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0)
esp_now_rate_config_t rate_config = {};
rate_config.phymode = WIFI_PHY_MODE_LR;
rate_config.rate = WIFI_PHY_RATE_LORA_250K;
rate_config.ersu = false;
rate_config.dcm = false;
rate_result = esp_now_set_peer_rate_config(peerInfo.peer_addr, &rate_config);
#else
rate_result = esp_wifi_config_espnow_rate(
WIFI_IF_STA, WIFI_PHY_RATE_LORA_250K);
#endif
if (rate_result != ESP_OK) {
BRIDGE_DEBUG_PRINTLN("Error configuring ESP-NOW LR peer rate\n");
esp_now_del_peer(peerInfo.peer_addr);
esp_now_register_recv_cb(nullptr);
esp_now_register_send_cb(nullptr);
esp_now_deinit();
esp_wifi_stop();
esp_wifi_deinit();
return;
}
}
// Update bridge state
_initialized = true;
}
@@ -128,12 +201,189 @@ void ESPNowBridge::end() {
esp_wifi_stop();
esp_wifi_deinit();
portENTER_CRITICAL(&_rx_mux);
_rx_head = 0;
_rx_tail = 0;
_rx_count = 0;
portEXIT_CRITICAL(&_rx_mux);
_raw_reassembler.reset();
// queueTransmitFrames() marks packets seen at admission so the same bridge
// loop cannot enqueue duplicates. Undo that mark for entries which never
// reached esp_now_send() before an explicit bridge stop/restart.
uint8_t tx_index = _tx_tail;
for (uint8_t i = 0; i < _tx_count; ++i) {
const QueuedTransmit& queued_tx = _tx_queue[tx_index];
if (!queued_tx.started) _seen_packets.clear(&queued_tx.packet);
tx_index = static_cast<uint8_t>((tx_index + 1) % TX_QUEUE_DEPTH);
}
portENTER_CRITICAL(&_tx_mux);
_tx_head = 0;
_tx_tail = 0;
_tx_count = 0;
_tx_waiting = false;
_tx_callback_done = false;
portEXIT_CRITICAL(&_tx_mux);
// Update bridge state
_initialized = false;
}
void ESPNowBridge::loop() {
// Nothing to do here - ESP-NOW is callback based
pumpTransmitQueue();
uint8_t frame[MAX_ESPNOW_PACKET_SIZE];
uint8_t source[mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE];
for (uint8_t processed = 0; processed < RX_QUEUE_DEPTH; ++processed) {
size_t frame_len = 0;
uint32_t dropped = 0;
portENTER_CRITICAL(&_rx_mux);
dropped = _rx_dropped;
if (_rx_count > 0) {
frame_len = _rx_lengths[_rx_tail];
if (frame_len > 0 && frame_len <= sizeof(frame)) {
memcpy(frame, _rx_buffers[_rx_tail], frame_len);
memcpy(source, _rx_sources[_rx_tail], sizeof(source));
} else {
frame_len = 0;
}
_rx_tail = static_cast<uint8_t>((_rx_tail + 1) % RX_QUEUE_DEPTH);
_rx_count--;
}
portEXIT_CRITICAL(&_rx_mux);
if (dropped != _rx_dropped_reported) {
BRIDGE_DEBUG_PRINTLN("RX queue full, dropped=%lu\n",
(unsigned long)dropped);
_rx_dropped_reported = dropped;
}
if (frame_len == 0) break;
processReceivedFrame(source, frame, frame_len);
}
}
bool ESPNowBridge::queueTransmitFrames(
const mesh::espnow::ESPNowRawFrames& frames,
const mesh::Packet& packet) {
if (frames.count == 0 || frames.count > mesh::espnow::ESPNOW_RAW_FRAGMENT_COUNT) {
return false;
}
for (uint8_t i = 0; i < frames.count; ++i) {
if (frames.lengths[i] == 0
|| frames.lengths[i] > MAX_ESPNOW_PACKET_SIZE) {
return false;
}
}
bool queued = false;
portENTER_CRITICAL(&_tx_mux);
if (_tx_count < TX_QUEUE_DEPTH) {
QueuedTransmit& queued_tx = _tx_queue[_tx_head];
queued_tx.frames = frames;
queued_tx.packet = packet;
queued_tx.next_frame = 0;
queued_tx.started = false;
_tx_head = static_cast<uint8_t>((_tx_head + 1) % TX_QUEUE_DEPTH);
_tx_count++;
queued = true;
} else {
_tx_dropped++;
}
portEXIT_CRITICAL(&_tx_mux);
return queued;
}
void ESPNowBridge::pumpTransmitQueue() {
uint8_t frame[MAX_ESPNOW_PACKET_SIZE];
uint16_t frame_len = 0;
bool completed = false;
int completed_status = ESP_NOW_SEND_SUCCESS;
bool clear_unsent_seen = false;
mesh::Packet unsent_packet;
uint32_t dropped = 0;
portENTER_CRITICAL(&_tx_mux);
dropped = _tx_dropped;
if (_tx_waiting && _tx_callback_done) {
completed = true;
completed_status = _tx_callback_status;
_tx_waiting = false;
_tx_callback_done = false;
if (_tx_count > 0) {
QueuedTransmit& queued_tx = _tx_queue[_tx_tail];
if (completed_status == ESP_NOW_SEND_SUCCESS) {
queued_tx.next_frame++;
if (queued_tx.next_frame >= queued_tx.frames.count) {
_tx_tail = static_cast<uint8_t>(
(_tx_tail + 1) % TX_QUEUE_DEPTH);
_tx_count--;
}
} else {
// esp_now_send() accepted at least one frame, matching the old bridge
// definition of "seen", but never submit any remaining fragments from
// a logical packet whose preceding frame failed.
_tx_tail = static_cast<uint8_t>((_tx_tail + 1) % TX_QUEUE_DEPTH);
_tx_count--;
}
}
}
if (!_tx_waiting && _tx_count > 0) {
QueuedTransmit& queued_tx = _tx_queue[_tx_tail];
if (queued_tx.next_frame < queued_tx.frames.count) {
frame_len = queued_tx.frames.lengths[queued_tx.next_frame];
if (frame_len > 0 && frame_len <= sizeof(frame)) {
memcpy(frame, queued_tx.frames.data[queued_tx.next_frame], frame_len);
} else {
frame_len = 0;
}
}
if (frame_len > 0) {
_tx_waiting = true;
_tx_callback_done = false;
}
}
portEXIT_CRITICAL(&_tx_mux);
if (dropped != _tx_dropped_reported) {
BRIDGE_DEBUG_PRINTLN("TX queue full, dropped=%lu\n",
(unsigned long)dropped);
_tx_dropped_reported = dropped;
}
if (completed && completed_status != ESP_NOW_SEND_SUCCESS) {
BRIDGE_DEBUG_PRINTLN("TX callback failed, status=%d\n",
completed_status);
}
if (frame_len == 0) return;
static const uint8_t broadcast_address[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
const esp_err_t result = esp_now_send(
broadcast_address, frame, frame_len);
if (result != ESP_OK) {
portENTER_CRITICAL(&_tx_mux);
_tx_waiting = false;
_tx_callback_done = false;
if (_tx_count > 0) {
QueuedTransmit& queued_tx = _tx_queue[_tx_tail];
if (!queued_tx.started) {
unsent_packet = queued_tx.packet;
clear_unsent_seen = true;
}
_tx_tail = static_cast<uint8_t>((_tx_tail + 1) % TX_QUEUE_DEPTH);
_tx_count--;
}
portEXIT_CRITICAL(&_tx_mux);
// Admission marks the packet to suppress duplicate queue entries. Restore
// the prior behavior when even its first esp_now_send() cannot start.
if (clear_unsent_seen) _seen_packets.clear(&unsent_packet);
BRIDGE_DEBUG_PRINTLN("TX failed to start, error=%d\n", (int)result);
} else {
portENTER_CRITICAL(&_tx_mux);
if (_tx_count > 0) _tx_queue[_tx_tail].started = true;
portEXIT_CRITICAL(&_tx_mux);
}
}
bool ESPNowBridge::xorCrypt(uint8_t *data, size_t len) {
@@ -149,16 +399,84 @@ bool ESPNowBridge::xorCrypt(uint8_t *data, size_t len) {
return true;
}
void ESPNowBridge::onDataRecv(const uint8_t *mac, const uint8_t *data, int len) {
// Ignore packets that are too small to contain header + checksum
if (len < (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE)) {
BRIDGE_DEBUG_PRINTLN("RX packet too small, len=%d\n", len);
void ESPNowBridge::receiveMeshPacket(const uint8_t *data, size_t len) {
mesh::Packet *pkt = _mgr->allocNew();
if (!pkt) return;
if (pkt->readFrom(data, len)) {
onPacketReceived(pkt);
} else {
_mgr->free(pkt);
}
}
void ESPNowBridge::queueReceivedFrame(const uint8_t *mac, const uint8_t *data,
int len) {
if (data == nullptr || len <= 0 || len > MAX_ESPNOW_PACKET_SIZE) {
return;
}
// Validate total packet size
if (len > MAX_ESPNOW_PACKET_SIZE) {
BRIDGE_DEBUG_PRINTLN("RX packet too large, len=%d\n", len);
// Wrapped deployments share the WiFi channel with unrelated ESP-NOW users.
// Reject obvious foreign traffic before it can occupy a queue slot.
if (_active_format == mesh::bridge::ESPNOW_FORMAT_WRAPPED
&& (len < BRIDGE_MAGIC_SIZE
|| data[0] != static_cast<uint8_t>(BRIDGE_PACKET_MAGIC >> 8)
|| data[1] != static_cast<uint8_t>(BRIDGE_PACKET_MAGIC & 0xFF))) {
return;
}
portENTER_CRITICAL(&_rx_mux);
if (_rx_count < RX_QUEUE_DEPTH) {
memcpy(_rx_buffers[_rx_head], data, static_cast<size_t>(len));
_rx_lengths[_rx_head] = static_cast<uint16_t>(len);
if (mac != nullptr) {
memcpy(_rx_sources[_rx_head], mac,
mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE);
} else {
memset(_rx_sources[_rx_head], 0,
mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE);
}
_rx_head = static_cast<uint8_t>((_rx_head + 1) % RX_QUEUE_DEPTH);
_rx_count++;
} else {
_rx_dropped++;
}
portEXIT_CRITICAL(&_rx_mux);
}
void ESPNowBridge::processReceivedFrame(const uint8_t *mac,
const uint8_t *data, size_t len) {
if (data == nullptr || len == 0 || len > MAX_ESPNOW_PACKET_SIZE) {
return;
}
if (_active_format == mesh::bridge::ESPNOW_FORMAT_RAW) {
uint8_t packet[MAX_TRANS_UNIT];
size_t packet_len = 0;
const mesh::espnow::ESPNowRawReassemblyResult result =
_raw_reassembler.acceptFrame(
mac, data, len, millis(), packet, sizeof(packet), packet_len);
if (result == mesh::espnow::ESPNowRawReassemblyResult::PASSTHROUGH
|| result ==
mesh::espnow::ESPNowRawReassemblyResult::PACKET_COMPLETE) {
BRIDGE_DEBUG_PRINTLN("RX raw, payload_len=%u%s\n",
(unsigned)packet_len,
result == mesh::espnow::ESPNowRawReassemblyResult::PACKET_COMPLETE
? " (reassembled)" : "");
receiveMeshPacket(packet, packet_len);
} else if (result == mesh::espnow::ESPNowRawReassemblyResult::REJECTED
|| result ==
mesh::espnow::ESPNowRawReassemblyResult::OUTPUT_TOO_SMALL) {
BRIDGE_DEBUG_PRINTLN("RX raw fragment rejected, result=%u len=%u\n",
(unsigned)result, (unsigned)len);
}
return;
}
// Wrapped mode is strict: ignore packets too small to contain its header
// and checksum rather than trying to auto-detect raw MeshCore traffic.
if (len < (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE)) {
BRIDGE_DEBUG_PRINTLN("RX packet too small, len=%u\n", (unsigned)len);
return;
}
@@ -187,21 +505,18 @@ void ESPNowBridge::onDataRecv(const uint8_t *mac, const uint8_t *data, int len)
return;
}
BRIDGE_DEBUG_PRINTLN("RX, payload_len=%d\n", payloadLen);
// Create mesh packet
mesh::Packet *pkt = _instance->_mgr->allocNew();
if (!pkt) return;
if (pkt->readFrom(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen)) {
_instance->onPacketReceived(pkt);
} else {
_instance->_mgr->free(pkt);
}
BRIDGE_DEBUG_PRINTLN("RX, payload_len=%u\n", (unsigned)payloadLen);
receiveMeshPacket(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen);
}
void ESPNowBridge::onDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
// Could add transmission error handling here if needed
(void)mac_addr;
portENTER_CRITICAL(&_tx_mux);
if (_tx_waiting) {
_tx_callback_status = static_cast<int>(status);
_tx_callback_done = true;
}
portEXIT_CRITICAL(&_tx_mux);
}
void ESPNowBridge::sendPacket(mesh::Packet *packet) {
@@ -219,50 +534,61 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) {
if (!_seen_packets.wasSeen(packet)) {
// Check the serialized size before writing into the ESP-NOW-sized buffer.
const int expectedMeshPacketLen = packet->getRawLength();
if (expectedMeshPacketLen < 0 || (size_t)expectedMeshPacketLen > MAX_PAYLOAD_SIZE) {
BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", expectedMeshPacketLen,
MAX_PAYLOAD_SIZE);
const size_t maxPayload =
mesh::bridge::espNowMaxMeshPacketSize(_active_format);
if (expectedMeshPacketLen < 0
|| (size_t)expectedMeshPacketLen > maxPayload) {
BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%u)\n",
expectedMeshPacketLen, (unsigned)maxPayload);
return;
}
uint8_t sizingBuffer[MAX_PAYLOAD_SIZE];
uint16_t meshPacketLen = packet->writeTo(sizingBuffer);
uint8_t buffer[MAX_TRANS_UNIT];
uint16_t meshPacketLen = packet->writeTo(buffer);
if (meshPacketLen != (uint16_t)expectedMeshPacketLen) {
BRIDGE_DEBUG_PRINTLN("TX packet length mismatch (actual=%d, expected=%d)\n", meshPacketLen,
expectedMeshPacketLen);
return;
}
uint8_t buffer[MAX_ESPNOW_PACKET_SIZE];
size_t totalPacketSize = meshPacketLen;
if (_active_format != mesh::bridge::ESPNOW_FORMAT_RAW) {
const size_t packetOffset = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE;
memmove(buffer + packetOffset, buffer, meshPacketLen);
// Write magic header (2 bytes)
buffer[0] = (BRIDGE_PACKET_MAGIC >> 8) & 0xFF;
buffer[1] = BRIDGE_PACKET_MAGIC & 0xFF;
// Write the legacy magic and encrypted checksum/payload wrapper.
buffer[0] = (BRIDGE_PACKET_MAGIC >> 8) & 0xFF;
buffer[1] = BRIDGE_PACKET_MAGIC & 0xFF;
const uint16_t checksum =
fletcher16(buffer + packetOffset, meshPacketLen);
buffer[2] = (checksum >> 8) & 0xFF;
buffer[3] = checksum & 0xFF;
if (!xorCrypt(buffer + BRIDGE_MAGIC_SIZE,
meshPacketLen + BRIDGE_CHECKSUM_SIZE)) {
return;
}
totalPacketSize = packetOffset + meshPacketLen;
}
// Write packet payload starting after magic header and checksum
const size_t packetOffset = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE;
memcpy(buffer + packetOffset, sizingBuffer, meshPacketLen);
mesh::espnow::ESPNowRawFrames frames;
bool encoded = false;
if (_active_format == mesh::bridge::ESPNOW_FORMAT_RAW) {
encoded = mesh::espnow::encodeEspNowRawFrames(
buffer, totalPacketSize, frames);
} else if (totalPacketSize <= MAX_ESPNOW_PACKET_SIZE) {
frames.count = 1;
frames.lengths[0] = static_cast<uint16_t>(totalPacketSize);
memcpy(frames.data[0], buffer, totalPacketSize);
encoded = true;
}
// Calculate and add checksum (only of the payload)
uint16_t checksum = fletcher16(buffer + packetOffset, meshPacketLen);
buffer[2] = (checksum >> 8) & 0xFF; // High byte
buffer[3] = checksum & 0xFF; // Low byte
// Encrypt payload and checksum (not including magic header)
if (!xorCrypt(buffer + BRIDGE_MAGIC_SIZE, meshPacketLen + BRIDGE_CHECKSUM_SIZE)) return;
// Total packet size: magic header + checksum + payload
const size_t totalPacketSize = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE + meshPacketLen;
// Broadcast using ESP-NOW
uint8_t broadcastAddress[] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
esp_err_t result = esp_now_send(broadcastAddress, buffer, totalPacketSize);
if (result == ESP_OK) {
if (encoded && queueTransmitFrames(frames, *packet)) {
_seen_packets.markSeen(packet);
BRIDGE_DEBUG_PRINTLN("TX, len=%d\n", meshPacketLen);
BRIDGE_DEBUG_PRINTLN("TX %s, len=%d, frames=%u\n",
mesh::bridge::espNowFormatName(_active_format),
meshPacketLen, (unsigned)frames.count);
} else {
BRIDGE_DEBUG_PRINTLN("TX FAILED!\n");
BRIDGE_DEBUG_PRINTLN("TX queue/encode failed!\n");
}
}
}
+81 -22
View File
@@ -3,7 +3,10 @@
#include "MeshCore.h"
#include "esp_now.h"
#include "esp_idf_version.h"
#include <freertos/FreeRTOS.h>
#include "helpers/ESPNowRawFragmentation.h"
#include "helpers/bridges/BridgeBase.h"
#include "helpers/bridges/ESPNowBridgeFormat.h"
#ifdef WITH_ESPNOW_BRIDGE
@@ -16,27 +19,35 @@
*
* Features:
* - Broadcast-based communication (all bridges receive all packets)
* - Network isolation using XOR encryption with shared secret
* - Selectable wrapped or raw MeshCore wire format
* - Network isolation using XOR encryption with shared secret in wrapped mode
* - Duplicate packet detection using SimpleMeshTables tracking
* - Maximum packet size of 250 bytes (ESP-NOW limitation)
* - Raw MeshCore packets up to 255 bytes using two frames only when needed
*
* Packet Structure:
* Wrapped Packet Structure (the backward-compatible default):
* [2 bytes] Magic Header - Used to identify ESPNowBridge packets
* [2 bytes] Fletcher-16 checksum of encrypted payload (calculated over payload only)
* [246 bytes max] Encrypted payload containing the mesh packet
* [2 bytes] Fletcher-16 checksum of the plaintext mesh payload
* [246 bytes max] Mesh packet payload
*
* The Fletcher-16 checksum is used to validate packet integrity and detect
* corrupted or tampered packets. It's calculated over the encrypted payload
* and provides a simple but effective way to verify packets are both
* uncorrupted and from the same network (since the checksum is calculated
* after encryption).
* corrupted packets. It is calculated over the plaintext payload; the
* checksum and payload are then XORed together using bridge.secret. A receiver
* with the wrong secret will normally fail checksum validation.
*
* Raw mode sends the exact bytes produced by mesh::Packet::writeTo(). It is
* compatible with primary ESPNOWRadio nodes, but has no bridge-secret wrapper.
* Packets up to the ESP-NOW frame limit remain byte-for-byte unchanged;
* 251-255-byte packets use a versioned two-frame envelope understood by
* updated raw endpoints. Receive parsing is deliberately strict to the
* selected format so a mixed deployment cannot silently become asymmetric.
*
* Configuration:
* - Define WITH_ESPNOW_BRIDGE to enable this bridge
* - Define _prefs->bridge_secret with a string to set the network encryption key
* - Set _prefs->bridge_format to wrapped (default) or raw
*
* Network Isolation:
* Multiple independent mesh networks can coexist by using different
* Network Isolation (wrapped mode only):
* Multiple independent wrapped mesh networks can coexist by using different
* _prefs->bridge_secret values. Packets encrypted with a different key will
* fail the checksum validation and be discarded.
*/
@@ -66,18 +77,52 @@ private:
* - Checksum: 2 bytes
* - Available payload: 246 bytes
*/
static const size_t MAX_ESPNOW_PACKET_SIZE = 250;
static const size_t MAX_ESPNOW_PACKET_SIZE = mesh::bridge::ESPNOW_MAX_FRAME_SIZE;
static const uint8_t RX_QUEUE_DEPTH = 4;
static const uint8_t TX_QUEUE_DEPTH = 6;
/**
* Size constants for packet parsing
*/
static const size_t MAX_PAYLOAD_SIZE = MAX_ESPNOW_PACKET_SIZE - (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE);
/** Bounded callback-to-loop queue for receiving ESP-NOW packets. */
uint8_t _rx_buffers[RX_QUEUE_DEPTH][MAX_ESPNOW_PACKET_SIZE];
uint16_t _rx_lengths[RX_QUEUE_DEPTH];
uint8_t _rx_sources[RX_QUEUE_DEPTH]
[mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE];
/** Buffer for receiving ESP-NOW packets */
uint8_t _rx_buffer[MAX_ESPNOW_PACKET_SIZE];
// The ESP-NOW callback runs on the WiFi task. It only prefilters and copies
// bounded frames here; loop() owns PacketManager and duplicate processing.
portMUX_TYPE _rx_mux = portMUX_INITIALIZER_UNLOCKED;
volatile uint8_t _rx_head;
volatile uint8_t _rx_tail;
volatile uint8_t _rx_count;
volatile uint32_t _rx_dropped;
uint32_t _rx_dropped_reported;
/** Current position in receive buffer */
size_t _rx_buffer_pos;
// Keep logical packets intact in the queue. Espressif recommends waiting
// for a frame's send callback before submitting the next one, and retaining
// the packet boundary lets a failed first fragment discard its sibling
// instead of transmitting an orphan fragment.
struct QueuedTransmit {
mesh::espnow::ESPNowRawFrames frames;
mesh::Packet packet;
uint8_t next_frame;
bool started;
};
QueuedTransmit _tx_queue[TX_QUEUE_DEPTH];
portMUX_TYPE _tx_mux = portMUX_INITIALIZER_UNLOCKED;
volatile uint8_t _tx_head;
volatile uint8_t _tx_tail;
volatile uint8_t _tx_count;
volatile bool _tx_waiting;
volatile bool _tx_callback_done;
volatile int _tx_callback_status;
volatile uint32_t _tx_dropped;
uint32_t _tx_dropped_reported;
/** Raw-mode fragment assemblies, keyed by source MAC. */
mesh::espnow::ESPNowRawReassembler _raw_reassembler;
// Snapshotted by begin() and immutable until end(), so callbacks and sends
// never observe a partially applied preference change.
uint8_t _active_format;
/**
* Performs XOR encryption/decryption of data
@@ -92,6 +137,9 @@ private:
*/
bool xorCrypt(uint8_t *data, size_t len);
/** Parse and queue one serialized MeshCore packet. */
void receiveMeshPacket(const uint8_t *data, size_t len);
/**
* ESP-NOW receive callback
* Called by ESP-NOW when a packet is received
@@ -100,7 +148,18 @@ private:
* @param data Received data
* @param len Length of received data
*/
void onDataRecv(const uint8_t *mac, const uint8_t *data, int len);
void queueReceivedFrame(const uint8_t *mac, const uint8_t *data, int len);
/** Parse a queued frame from loop(), never from the WiFi callback. */
void processReceivedFrame(const uint8_t *mac, const uint8_t *data,
size_t len);
/** Atomically append one logical packet's one or two transport frames. */
bool queueTransmitFrames(const mesh::espnow::ESPNowRawFrames& frames,
const mesh::Packet& packet);
/** Submit at most one queued frame after the previous send callback. */
void pumpTransmitQueue();
/**
* ESP-NOW send callback
@@ -143,7 +202,7 @@ public:
/**
* Main loop handler
* ESP-NOW is callback-based, so this is currently empty
* Drains the bounded frames handed off by the WiFi receive callback.
*/
void loop() override;
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <helpers/ESPNowRawFragmentation.h>
namespace mesh {
namespace bridge {
// Keep wrapped as zero so every existing preference image and a freshly
// zero-initialized NodePrefs retains the original bridge wire format.
static constexpr uint8_t ESPNOW_FORMAT_WRAPPED = 0;
static constexpr uint8_t ESPNOW_FORMAT_RAW = 1;
static constexpr size_t ESPNOW_MAX_FRAME_SIZE = 250;
static constexpr size_t ESPNOW_WRAPPED_OVERHEAD = 4;
static inline bool isValidEspNowFormat(uint8_t format) {
return format == ESPNOW_FORMAT_WRAPPED || format == ESPNOW_FORMAT_RAW;
}
static inline bool parseEspNowFormat(const char* text, uint8_t& format) {
if (text == nullptr) return false;
if (strcmp(text, "wrapped") == 0) {
format = ESPNOW_FORMAT_WRAPPED;
return true;
}
if (strcmp(text, "raw") == 0) {
format = ESPNOW_FORMAT_RAW;
return true;
}
return false;
}
static inline const char* espNowFormatName(uint8_t format) {
return format == ESPNOW_FORMAT_RAW ? "raw" : "wrapped";
}
static inline size_t espNowMaxMeshPacketSize(uint8_t format) {
return format == ESPNOW_FORMAT_RAW
? mesh::espnow::ESPNOW_RAW_MAX_PACKET_SIZE
: ESPNOW_MAX_FRAME_SIZE - ESPNOW_WRAPPED_OVERHEAD;
}
static inline uint8_t espNowMaxChannel(uint8_t format) {
(void)format;
return 13;
}
static inline bool isValidEspNowBridgeChannel(uint8_t channel,
uint8_t format) {
return channel >= 1 && channel <= espNowMaxChannel(format);
}
// Accept surrounding spaces/tabs, but no signs, separators, suffixes, or
// decimal notation. Leave the caller's output unchanged on every failure.
static inline bool parseEspNowBridgeChannel(const char* text, uint8_t format,
uint8_t& channel) {
if (text == nullptr) return false;
while (*text == ' ' || *text == '\t') text++;
if (*text < '0' || *text > '9') return false;
uint16_t parsed = 0;
while (*text >= '0' && *text <= '9') {
parsed = (uint16_t)(parsed * 10 + (uint8_t)(*text - '0'));
if (parsed > 13) return false;
text++;
}
while (*text == ' ' || *text == '\t') text++;
if (*text != '\0' || !isValidEspNowBridgeChannel((uint8_t)parsed, format)) {
return false;
}
channel = (uint8_t)parsed;
return true;
}
} // namespace bridge
} // namespace mesh
+24 -5
View File
@@ -25,6 +25,7 @@
#include <esp_wifi.h>
#include <esp_sntp.h>
#include <esp_heap_caps.h>
#include <helpers/esp32/WiFiStationPolicy.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
@@ -1290,12 +1291,17 @@ void MQTTBridge::initializeWiFiInTask() {
// Initialize WiFi
WiFi.mode(WIFI_STA);
// Enable automatic reconnection - ESP32 will handle reconnection automatically
WiFi.setAutoReconnect(true);
// A primary ESP-NOW radio must reconnect through the fixed-channel policy;
// other ESP32 targets retain the driver's normal automatic reconnect.
mesh::wifi::setStationAutoReconnect(true);
#if !defined(ESP_ARDUINO_VERSION_MAJOR) || ESP_ARDUINO_VERSION_MAJOR < 3
// Arduino-ESP32 3.x removed setAutoConnect(); begin() plus
// setAutoReconnect() is the supported equivalent there.
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
WiFi.setAutoConnect(false);
#else
WiFi.setAutoConnect(true);
#endif
#endif
// Set up WiFi event handlers for better diagnostics and immediate disconnection
@@ -1340,7 +1346,7 @@ void MQTTBridge::initializeWiFiInTask() {
// because _ntp_synced persists across end() (only _slots_setup_done is reset).
if (WiFi.status() != WL_CONNECTED) {
if (_manage_wifi) {
WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
mesh::wifi::beginStation(_obs->wifi_ssid, _obs->wifi_password);
}
} else if (!_ntp_synced && !_ntp_sync_pending) {
_ntp_sync_pending = true; // already connected but never synced - kick NTP now
@@ -2834,6 +2840,14 @@ void MQTTBridge::checkConfigurationMismatch() {
bool MQTTBridge::handleWiFiConnection(unsigned long now) {
wl_status_t current_wifi_status = WiFi.status();
#ifdef ESP_PLATFORM
if (current_wifi_status == WL_CONNECTED
&& !mesh::wifi::enforceStationChannel()) {
current_wifi_status = WL_DISCONNECTED;
MQTT_DEBUG_PRINTLN(
"Rejected WiFi association outside the primary ESP-NOW channel");
}
#endif
bool transitioned_to_connected = false;
if (current_wifi_status == WL_CONNECTED && s_wifi_connected_at == 0) {
@@ -2869,10 +2883,11 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) {
uint8_t ps_pref = mesh::wifi::effectivePowerSave(
_obs->wifi_power_save,
#if defined(BLE_PIN_CODE) && defined(WIFI_SSID)
true
true,
#else
false
false,
#endif
mesh::wifi::kPrimaryEspNowRadio
);
if (ps_pref == 1) {
ps_mode = WIFI_PS_NONE;
@@ -2920,7 +2935,11 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) {
_wifi_reconnect_backoff_attempt =
MQTTConnectionPolicy::nextWifiBackoffAttempt(_wifi_reconnect_backoff_attempt);
WiFi.disconnect();
#ifdef ESP_PLATFORM
mesh::wifi::beginStation(_obs->wifi_ssid, _obs->wifi_password);
#else
WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
#endif
}
}
_last_wifi_status = current_wifi_status;
+233 -19
View File
@@ -3,6 +3,8 @@
#include <esp_idf_version.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <helpers/ESPNowRawFragmentation.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#if ESP_ARDUINO_VERSION_MAJOR >= 3
#include <esp_mac.h>
#endif
@@ -11,8 +13,25 @@ static uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
static esp_now_peer_info_t peerInfo;
static volatile bool is_send_complete = false;
static esp_err_t last_send_result;
static uint8_t rx_buf[256];
static uint8_t last_rx_len = 0;
static volatile esp_now_send_status_t last_send_status =
ESP_NOW_SEND_SUCCESS;
static bool tx_second_pending = false;
static uint16_t tx_second_length = 0;
static uint8_t tx_second_frame[mesh::espnow::ESPNOW_RAW_MAX_FRAME_SIZE];
static constexpr uint16_t RX_FRAME_MAX =
mesh::espnow::ESPNOW_RAW_MAX_FRAME_SIZE;
static constexpr uint8_t RX_QUEUE_DEPTH = 4;
static uint8_t rx_frames[RX_QUEUE_DEPTH][RX_FRAME_MAX];
static uint16_t rx_lengths[RX_QUEUE_DEPTH];
static uint8_t rx_sources[RX_QUEUE_DEPTH]
[mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE];
static volatile uint8_t rx_head = 0;
static volatile uint8_t rx_tail = 0;
static volatile uint8_t rx_count = 0;
static volatile uint32_t rx_dropped = 0;
static uint32_t rx_dropped_reported = 0;
static portMUX_TYPE rx_mux = portMUX_INITIALIZER_UNLOCKED;
static mesh::espnow::ESPNowRawReassembler rx_reassembler;
// callback when data is sent
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
@@ -23,6 +42,7 @@ static void OnDataSent(const esp_now_send_info_t *info,
static void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
(void)mac_addr;
#endif
last_send_status = status;
is_send_complete = true;
ESPNOW_DEBUG_PRINTLN("Send Status: %d", (int)status);
}
@@ -30,25 +50,78 @@ static void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
static void OnDataRecv(const esp_now_recv_info_t *info, const uint8_t *data,
int len) {
(void)info;
const uint8_t *mac = info != nullptr ? info->src_addr : nullptr;
#else
static void OnDataRecv(const uint8_t *mac, const uint8_t *data, int len) {
(void)mac;
#endif
ESPNOW_DEBUG_PRINTLN("Recv: len = %d", len);
memcpy(rx_buf, data, len);
last_rx_len = len;
portENTER_CRITICAL(&rx_mux);
if (data != nullptr && len > 0 && len <= RX_FRAME_MAX
&& rx_count < RX_QUEUE_DEPTH) {
memcpy(rx_frames[rx_head], data, static_cast<size_t>(len));
rx_lengths[rx_head] = static_cast<uint16_t>(len);
if (mac != nullptr) {
memcpy(rx_sources[rx_head], mac,
mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE);
} else {
memset(rx_sources[rx_head], 0,
mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE);
}
rx_head = static_cast<uint8_t>((rx_head + 1) % RX_QUEUE_DEPTH);
rx_count++;
} else {
rx_dropped++;
}
portEXIT_CRITICAL(&rx_mux);
}
void ESPNOWRadio::init() {
portENTER_CRITICAL(&rx_mux);
rx_head = 0;
rx_tail = 0;
rx_count = 0;
rx_dropped = 0;
portEXIT_CRITICAL(&rx_mux);
rx_dropped_reported = 0;
rx_reassembler.reset();
tx_second_pending = false;
tx_second_length = 0;
last_send_status = ESP_NOW_SEND_SUCCESS;
// Set device as a Wi-Fi Station
// ESP-NOW's LR protocol is local to this radio transport. Keeping WiFi
// driver writes in RAM prevents a later conventional WiFi image from
// inheriting the proprietary protocol bit through NVS.
WiFi.persistent(false);
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// Arduino defaults station auto-reconnect to enabled. Disable it before the
// driver starts so credentials left by another image cannot pull the radio
// away from the configured mesh channel behind this policy's back.
WiFi.setAutoReconnect(false);
#endif
WiFi.mode(WIFI_STA);
// Long Range mode
esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_LR);
#if defined(MESH_ESPNOW_RADIO) && MESH_ESPNOW_RADIO
// Full Companion also exposes ordinary WiFi. Preserve B/G/N for normal
// access points while retaining LR for ESP-NOW, and pin both transports to
// one channel because an ESP32 cannot associate and send ESP-NOW on two
// different channels simultaneously.
if (mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) {
ESPNOW_DEBUG_PRINTLN("Error configuring ESP-NOW/WiFi coexistence");
return;
}
#else
// Dedicated ESP-NOW targets retain the original LR-only behavior.
if (esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_LR) != ESP_OK) {
ESPNOW_DEBUG_PRINTLN("Error configuring ESP-NOW LR protocol");
return;
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (mesh::wifi::restoreEspNowChannel() != ESP_OK) {
ESPNOW_DEBUG_PRINTLN("Error setting primary ESP-NOW channel");
return;
}
#endif
// Init ESP-NOW
if (esp_now_init() != ESP_OK) {
@@ -62,14 +135,41 @@ void ESPNOWRadio::init() {
esp_now_register_recv_cb(OnDataRecv);
// Register peer
memset(&peerInfo, 0, sizeof(peerInfo));
memcpy(peerInfo.peer_addr, broadcastAddress, 6);
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// Zero follows the station/AP's current channel. The policy pins that radio
// channel and rejects off-channel infrastructure associations, avoiding a
// stale fixed peer entry after a configured-channel reboot.
#endif
peerInfo.channel = 0;
peerInfo.ifidx = WIFI_IF_STA;
peerInfo.encrypt = false;
is_send_complete = true;
// Add peer
if (esp_now_add_peer(&peerInfo) == ESP_OK) {
#if defined(MESH_ESPNOW_RADIO) && MESH_ESPNOW_RADIO
// Enabling interoperable B/G/N alongside LR changes the interface's
// default rate selection. Force ESP-NOW packets back to the legacy LR rate
// so Full images remain compatible with existing LR-only mesh nodes.
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0)
esp_now_rate_config_t rate_config = {};
rate_config.phymode = WIFI_PHY_MODE_LR;
rate_config.rate = WIFI_PHY_RATE_LORA_250K;
rate_config.ersu = false;
rate_config.dcm = false;
if (esp_now_set_peer_rate_config(broadcastAddress, &rate_config) != ESP_OK) {
ESPNOW_DEBUG_PRINTLN("Error configuring ESP-NOW LR peer rate");
}
#else
if (esp_wifi_config_espnow_rate(WIFI_IF_STA, WIFI_PHY_RATE_LORA_250K)
!= ESP_OK) {
ESPNOW_DEBUG_PRINTLN("Error configuring ESP-NOW LR interface rate");
}
#endif
#endif
ESPNOW_DEBUG_PRINTLN("init success");
} else {
// ESPNOW_DEBUG_PRINTLN("Failed to add peer");
@@ -96,44 +196,158 @@ uint32_t ESPNOWRadio::intID() {
}
bool ESPNOWRadio::startSendRaw(const uint8_t* bytes, int len) {
// Send message via ESP-NOW
mesh::espnow::ESPNowRawFrames frames;
if (len < 0
|| !mesh::espnow::encodeEspNowRawFrames(
bytes, static_cast<size_t>(len), frames)) {
ESPNOW_DEBUG_PRINTLN("Invalid TX length: %d", len);
return false;
}
tx_second_pending = frames.count == 2;
tx_second_length = 0;
if (tx_second_pending) {
tx_second_length = frames.lengths[1];
memcpy(tx_second_frame, frames.data[1], tx_second_length);
}
// Send the first (or only) message via ESP-NOW. isSendComplete() submits a
// second fragment only after this frame's callback, preserving Espressif's
// required callback ordering without blocking the mesh dispatcher.
last_send_status = ESP_NOW_SEND_SUCCESS;
is_send_complete = false;
esp_err_t result = esp_now_send(broadcastAddress, bytes, len);
esp_err_t result = esp_now_send(
broadcastAddress, frames.data[0], frames.lengths[0]);
if (result == ESP_OK) {
n_sent++;
ESPNOW_DEBUG_PRINTLN("Send success");
ESPNOW_DEBUG_PRINTLN("Send started: frames=%u raw_len=%d",
(unsigned)frames.count, len);
return true;
}
last_send_result = result;
tx_second_pending = false;
tx_second_length = 0;
is_send_complete = true;
ESPNOW_DEBUG_PRINTLN("Send failed: %d", result);
return false;
}
bool ESPNOWRadio::isSendComplete() {
if (!is_send_complete) return false;
if (last_send_status != ESP_NOW_SEND_SUCCESS) {
// A failed callback means the logical packet was not transmitted. Do not
// send an orphan second fragment; remaining incomplete lets Dispatcher use
// its bounded timeout/retry path.
tx_second_pending = false;
tx_second_length = 0;
is_send_complete = false;
ESPNOW_DEBUG_PRINTLN("Send callback failed: %d", (int)last_send_status);
return false;
}
if (tx_second_pending) {
tx_second_pending = false;
last_send_status = ESP_NOW_SEND_SUCCESS;
is_send_complete = false;
const esp_err_t result = esp_now_send(
broadcastAddress, tx_second_frame, tx_second_length);
if (result != ESP_OK) {
// Leave the operation incomplete. Dispatcher will use its bounded radio
// timeout/retry path instead of reporting a partially sent packet as a
// success.
last_send_result = result;
tx_second_length = 0;
ESPNOW_DEBUG_PRINTLN("Second fragment failed to start: %d", result);
return false;
}
tx_second_length = 0;
ESPNOW_DEBUG_PRINTLN("Second fragment started");
return false;
}
return is_send_complete;
}
void ESPNOWRadio::onSendFinished() {
tx_second_pending = false;
tx_second_length = 0;
last_send_status = ESP_NOW_SEND_SUCCESS;
is_send_complete = true;
}
bool ESPNOWRadio::isInRecvMode() const {
return is_send_complete; // if NO send in progress, then we're in Rx mode
return is_send_complete && !tx_second_pending;
}
float ESPNOWRadio::getLastRSSI() const { return 0; }
float ESPNOWRadio::getLastSNR() const { return 0; }
int ESPNOWRadio::recvRaw(uint8_t* bytes, int sz) {
int len = last_rx_len;
if (last_rx_len > 0) {
memcpy(bytes, rx_buf, last_rx_len);
last_rx_len = 0;
n_recv++;
if (bytes == nullptr || sz <= 0) return 0;
uint32_t dropped = 0;
portENTER_CRITICAL(&rx_mux);
dropped = rx_dropped;
portEXIT_CRITICAL(&rx_mux);
if (dropped != rx_dropped_reported) {
n_recv_errors += dropped - rx_dropped_reported;
ESPNOW_DEBUG_PRINTLN("RX queue/length drops: %lu",
(unsigned long)dropped);
rx_dropped_reported = dropped;
}
return len;
// Drain fragment-only and malformed frames in one dispatcher poll. This
// lets a queued two-frame packet become one ordinary MeshCore receive while
// retaining the callback's fixed, bounded handoff queue.
for (uint8_t processed = 0; processed < RX_QUEUE_DEPTH; ++processed) {
uint8_t frame[RX_FRAME_MAX];
uint8_t source[mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE];
uint16_t frame_len = 0;
portENTER_CRITICAL(&rx_mux);
if (rx_count > 0) {
frame_len = rx_lengths[rx_tail];
if (frame_len > 0 && frame_len <= sizeof(frame)) {
memcpy(frame, rx_frames[rx_tail], frame_len);
memcpy(source, rx_sources[rx_tail], sizeof(source));
} else {
frame_len = 0;
}
rx_tail = static_cast<uint8_t>((rx_tail + 1) % RX_QUEUE_DEPTH);
rx_count--;
}
portEXIT_CRITICAL(&rx_mux);
if (frame_len == 0) break;
size_t packet_len = 0;
const mesh::espnow::ESPNowRawReassemblyResult result =
rx_reassembler.acceptFrame(
source, frame, frame_len, millis(), bytes,
static_cast<size_t>(sz), packet_len);
if (result == mesh::espnow::ESPNowRawReassemblyResult::PASSTHROUGH
|| result ==
mesh::espnow::ESPNowRawReassemblyResult::PACKET_COMPLETE) {
n_recv++;
return static_cast<int>(packet_len);
}
if (result == mesh::espnow::ESPNowRawReassemblyResult::REJECTED
|| result ==
mesh::espnow::ESPNowRawReassemblyResult::OUTPUT_TOO_SMALL) {
n_recv_errors++;
ESPNOW_DEBUG_PRINTLN("RX fragment rejected: result=%u len=%u size=%d",
(unsigned)result, (unsigned)frame_len, sz);
}
}
return 0;
}
uint32_t ESPNOWRadio::getEstAirtimeFor(int len_bytes) {
return 4; // Fast AF
if (len_bytes > static_cast<int>(
mesh::espnow::ESPNOW_RAW_MAX_FRAME_SIZE)) {
// A maximum raw packet becomes one full ESP-NOW LR frame plus one short
// frame. Include callback scheduling margin for both transmissions.
return 40;
}
// At the forced 250-kbit/s LR rate, a 250-byte payload alone takes 8 ms.
// Dispatcher applies a 1.5x deadline multiplier, so 20 ms leaves room for
// ESP-NOW/MAC overhead and callback scheduling without false timeouts.
return 20;
}
File diff suppressed because it is too large. Load diff
+275 -49
View File
@@ -28,6 +28,136 @@
#include "helpers/CLICommandUtils.h"
#include "helpers/WebConfigKeys.h"
#include "helpers/WiFiPowerSave.h"
#include "helpers/esp32/WiFiRadioPolicy.h"
#include "helpers/esp32/WiFiStationPolicy.h"
// ESPAsyncWebServer closes every ordinary response, so splitting the UI into
// many HTTP requests exhausts a lossy link's TCP/WiFi resources. Its ordinary
// PROGMEM response also fills the whole TCP send window at once. Keep one
// response open and queue only 512 body bytes after the preceding bytes have
// actually been acknowledged.
class WebConfigPacedProgmemResponse final : public AsyncWebServerResponse {
static constexpr size_t kBodyChunk = 512;
const uint8_t* _content;
String _assembled_headers;
size_t _written_headers = 0;
size_t _in_flight = 0;
size_t _space_before_batch = 0;
size_t queueNext(AsyncWebServerRequest* request) {
AsyncClient* client = request ? request->client() : nullptr;
if (!client) {
_state = RESPONSE_FAILED;
return 0;
}
// This response has exactly one writer and never queues another batch
// until the current one is acknowledged. Remembering the send-buffer space
// here lets _ack() recover if AsyncTCP cannot allocate its SENT event: lwIP
// has already restored tcp_sndbuf by then, but that event is not replayed.
const size_t space_before_batch = client->space();
size_t queued = 0;
if (_state == RESPONSE_HEADERS) {
const size_t added = client->add(
_assembled_headers.c_str() + _written_headers,
_assembled_headers.length() - _written_headers);
_written_headers += added;
_writtenLength += added;
queued += added;
if (_written_headers < _assembled_headers.length()) {
if (queued && client->send()) {
_in_flight += queued;
_space_before_batch = space_before_batch;
} else if (queued) {
_state = RESPONSE_FAILED;
client->close();
}
return queued;
}
_assembled_headers = String();
_state = RESPONSE_CONTENT;
}
if (_state == RESPONSE_CONTENT && _sentLength < _contentLength) {
size_t amount = _contentLength - _sentLength;
if (amount > kBodyChunk) amount = kBodyChunk;
const size_t room = client->space();
if (amount > room) amount = room;
if (amount) {
const size_t added = client->add(
reinterpret_cast<const char*>(_content + _sentLength), amount);
_sentLength += added;
_writtenLength += added;
queued += added;
}
if (_sentLength == _contentLength) _state = RESPONSE_WAIT_ACK;
}
if (queued) {
if (client->send()) {
_in_flight += queued;
_space_before_batch = space_before_batch;
} else {
_state = RESPONSE_FAILED;
client->close();
}
}
return queued;
}
public:
WebConfigPacedProgmemResponse(const char* content_type,
const uint8_t* content, size_t length)
: _content(content) {
_code = 200;
_contentType = content_type;
_contentLength = length;
}
bool _sourceValid() const override {
return _content != nullptr && _contentLength != 0;
}
void _respond(AsyncWebServerRequest* request) override {
addHeader("Connection", "close", false);
_assembleHead(_assembled_headers, request->version());
_state = RESPONSE_HEADERS;
queueNext(request);
}
size_t _ack(AsyncWebServerRequest* request, size_t len,
uint32_t time) override {
(void)time;
AsyncClient* client = request ? request->client() : nullptr;
if (len == 0 && _in_flight != 0 && client
&& client->space() >= _space_before_batch) {
// AsyncTCP reports polls as zero-length ACKs. A restored send buffer is
// unambiguous here because this response has only one batch in flight.
// Account for an ACK whose AsyncTCP event allocation failed under low
// internal heap, otherwise the response would remain stuck forever.
len = _in_flight;
}
_ackedLength += len;
if (len >= _in_flight) _in_flight = 0;
else _in_flight -= len;
if (_in_flight != 0) return 0;
if (_state == RESPONSE_WAIT_ACK) {
// The full Content-Length has now been acknowledged. ESPAsyncWebServer
// otherwise performs a graceful active close for every response, and
// this IDF 4.4 stack retains malloc-backed TCP PCBs in TIME_WAIT for about
// two minutes. Repeated WebConfig loads consume scarce internal heap and
// can also prevent AsyncTCP from retiring close events, wedging both
// infrastructure WiFi and ESP-NOW. Abort only after the final ACK so the
// peer has the complete page, while freeing this PCB immediately. Keep
// RESPONSE_WAIT_ACK: abort synchronously dispatches disconnect and the
// request's scoped owner then destroys this response.
if (client) client->abort();
return 0;
}
return queueNext(request);
}
};
static bool bluetoothWiFiCoexistenceRequired() {
#if defined(BLE_PIN_CODE) && defined(WIFI_SSID)
@@ -39,7 +169,23 @@ static bool bluetoothWiFiCoexistenceRequired() {
static uint8_t effectiveWiFiPowerSave(uint8_t configured) {
return mesh::wifi::effectivePowerSave(
configured, bluetoothWiFiCoexistenceRequired());
configured, bluetoothWiFiCoexistenceRequired(),
mesh::wifi::kPrimaryEspNowRadio);
}
static void stopOwnedWiFiRadio() {
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// ESP-NOW is this target's mesh radio. Stop only infrastructure-WiFi
// ownership; powering the driver down would also remove the mesh transport.
WiFi.setAutoReconnect(false);
WiFi.disconnect(false, false);
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
mesh::wifi::restoreEspNowChannel();
#else
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
#endif
}
// Placeholder sent instead of stored secrets; POSTs carrying it are dropped
@@ -48,6 +194,11 @@ static const char SECRET_SENTINEL[] = "********";
static bool isAllowedSetKey(const char* key, bool has_mqtt) {
if (!key || !wcIsAllowedSetKey(key)) return false;
#if !defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW
// The shared source-level allowlist is host-tested, but only a target whose
// mesh transport is ESP-NOW has a meaningful primary-radio channel to set.
if (wcSetKeyRequiresReboot(key)) return false;
#endif
const bool mqtt_only = strncmp(key, "mqtt.", 5) == 0
|| strncmp(key, "mqtt", 4) == 0
|| strncmp(key, "timezone", 8) == 0
@@ -202,24 +353,31 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
#ifdef WITH_MQTT_BRIDGE
MQTTPrefs* obs = static_cast<MQTTPrefs*>(_mqtt_prefs);
if (obs) {
if (obs && _owns_wifi) {
strncpy(_wifi_ssid, obs->wifi_ssid, sizeof(_wifi_ssid) - 1);
strncpy(_wifi_password, obs->wifi_password, sizeof(_wifi_password) - 1);
_wifi_power_save = obs->wifi_power_save <= mesh::wifi::kPowerSaveMax
? obs->wifi_power_save : mesh::wifi::kDefaultPowerSave;
// Companion builds keep their canonical connection credentials in the
// shared mesh-wifi namespace. A fresh MQTT configuration can therefore
// have an empty MQTTPrefs WiFi field even while the companion is already
// connected; use the canonical copy until the first WebUI save syncs both.
if (_wifi_ssid[0] == 0 && !_owns_wifi) {
loadStandaloneWiFi(_wifi_ssid, sizeof(_wifi_ssid),
_wifi_password, sizeof(_wifi_password), &_wifi_power_save);
}
} else
#endif
{
loadStandaloneWiFi(_wifi_ssid, sizeof(_wifi_ssid),
_wifi_password, sizeof(_wifi_password), &_wifi_power_save);
// Companion and standalone FULL builds keep their canonical connection
// credentials in mesh-wifi NVS. Do not round-trip a 64-hex raw PSK through
// the fixed-layout MQTTPrefs wifi_password[64] field.
const bool loaded_standalone = loadStandaloneWiFi(
_wifi_ssid, sizeof(_wifi_ssid),
_wifi_password, sizeof(_wifi_password), &_wifi_power_save);
#ifdef WITH_MQTT_BRIDGE
// Preserve the upgrade path from older WiFi-MQTT Companion installs that
// have not written the canonical namespace yet. Once mesh-wifi exists it
// always wins, including when it contains a 64-hex PSK.
if (!loaded_standalone && obs) {
strncpy(_wifi_ssid, obs->wifi_ssid, sizeof(_wifi_ssid) - 1);
strncpy(_wifi_password, obs->wifi_password, sizeof(_wifi_password) - 1);
_wifi_power_save = obs->wifi_power_save <= mesh::wifi::kPowerSaveMax
? obs->wifi_power_save : mesh::wifi::kDefaultPowerSave;
}
#endif
}
_wifi_power_save = effectiveWiFiPowerSave(_wifi_power_save);
}
@@ -275,7 +433,8 @@ bool WebConfigServer::loadStandaloneWiFi(char* ssid, size_t ssid_len,
"powersave", mesh::wifi::kDefaultPowerSave);
nvs.end();
if (stored_ssid.length() >= ssid_len
|| stored_password.length() >= password_len) {
|| stored_password.length() >= password_len
|| !mesh::cli::standaloneWiFiPasswordValid(stored_password.c_str())) {
return false;
}
strncpy(ssid, stored_ssid.c_str(), ssid_len - 1);
@@ -289,14 +448,14 @@ bool WebConfigServer::loadStandaloneWiFi(char* ssid, size_t ssid_len,
bool WebConfigServer::saveStandaloneWiFi(const char* ssid, const char* password,
uint8_t power_save) {
power_save = effectiveWiFiPowerSave(power_save);
const char* pwd = password ? password : "";
if (!ssid || !ssid[0] || strlen(ssid) >= 32
|| (password && strlen(password) >= 64)
|| !mesh::cli::standaloneWiFiPasswordValid(pwd)
|| power_save > mesh::wifi::kPowerSaveMax) {
return false;
}
Preferences nvs;
if (!nvs.begin("mesh-wifi", false)) return false;
const char* pwd = password ? password : "";
bool ok = nvs.putString("ssid", ssid) == strlen(ssid);
nvs.putString("password", pwd); // empty String legitimately writes zero bytes
ok = ok && nvs.getString("password", "\x01") == pwd;
@@ -330,7 +489,7 @@ bool WebConfigServer::setStandaloneWiFiPassword(const char* value, char* reply,
if (!reply || reply_len == 0) return false;
if (!mesh::cli::standaloneWiFiPasswordValid(value)) {
snprintf(reply, reply_len,
"Error: WiFi password must be at most 63 characters");
"Error: WiFi password must be 0-63 characters or 64 hex characters");
return false;
}
@@ -355,12 +514,17 @@ bool WebConfigServer::setStandaloneWiFiPowerSave(const char* value, char* reply,
snprintf(reply, reply_len, "Error: power save must be none, min, or max");
return false;
}
if (mesh::wifi::kPrimaryEspNowRadio
&& power_save == mesh::wifi::kPowerSaveMax) {
snprintf(reply, reply_len,
"Error: power save max is unavailable while ESP-NOW is the primary radio");
return false;
}
if (effectiveWiFiPowerSave(power_save) != power_save) {
snprintf(reply, reply_len,
"Error: power save none is unavailable while Bluetooth is active");
return false;
}
Preferences nvs;
if (!nvs.begin("mesh-wifi", false)) {
snprintf(reply, reply_len, "Error: failed to open WiFi settings");
@@ -421,7 +585,7 @@ bool WebConfigServer::formatWiFiSSID(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
char ssid[32] = "";
char password[64] = "";
char password[65] = "";
uint8_t power_save = mesh::wifi::kDefaultPowerSave;
bool configured = loadStandaloneWiFi(
ssid, sizeof(ssid), password, sizeof(password), &power_save);
@@ -443,7 +607,7 @@ bool WebConfigServer::formatWiFiPowerSave(char* reply, size_t reply_len) {
power_save = _active->_wifi_power_save;
} else {
char ssid[32] = "";
char password[64] = "";
char password[65] = "";
loadStandaloneWiFi(
ssid, sizeof(ssid), password, sizeof(password), &power_save);
}
@@ -479,7 +643,7 @@ bool WebConfigServer::formatWiFiStatus(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
char ssid[32] = "";
char password[64] = "";
char password[65] = "";
uint8_t power_save = mesh::wifi::kDefaultPowerSave;
bool configured = loadStandaloneWiFi(
ssid, sizeof(ssid), password, sizeof(password), &power_save);
@@ -599,9 +763,11 @@ bool WebConfigServer::startSetupMode(char reply[]) {
bool ap_ok = false;
for (uint8_t attempt = 1; attempt <= 6 && !ap_ok; ++attempt) {
#ifdef WEBCONFIG_AP_PASSWORD
ap_ok = WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD);
ap_ok = WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD,
mesh::wifi::accessPointChannel());
#else
ap_ok = WiFi.softAP(_ap_ssid);
ap_ok = WiFi.softAP(_ap_ssid, nullptr,
mesh::wifi::accessPointChannel());
#endif
if (ap_ok) {
// ESP-NOW can leave the persistent AP protocol mask with the proprietary
@@ -609,11 +775,9 @@ bool WebConfigServer::startSetupMode(char reply[]) {
// phones and laptops cannot discover its beacon. A WiFi companion must
// advertise using the interoperable 2.4 GHz protocol set.
const esp_err_t ap_protocol_result = esp_wifi_set_protocol(
WIFI_IF_AP,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N);
WIFI_IF_AP, mesh::wifi::kProtocolMask);
const esp_err_t sta_protocol_result = esp_wifi_set_protocol(
WIFI_IF_STA,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N);
WIFI_IF_STA, mesh::wifi::kProtocolMask);
if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break;
Serial.printf("WebConfig protocol reset failed: AP=%d STA=%d\n",
(int)ap_protocol_result, (int)sta_protocol_result);
@@ -630,7 +794,7 @@ bool WebConfigServer::startSetupMode(char reply[]) {
mode_ok = WiFi.mode(WIFI_AP_STA);
}
if (!ap_ok) {
if (!promote_lan) WiFi.mode(WIFI_OFF);
if (!promote_lan) stopOwnedWiFiRadio();
strcpy(reply, "Err: failed to start AP");
return false;
}
@@ -692,20 +856,17 @@ bool WebConfigServer::startAutoMode(char reply[]) {
}
WiFi.mode(WIFI_STA);
if (esp_wifi_set_protocol(
WIFI_IF_STA,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
!= ESP_OK) {
if (mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) {
strcpy(reply, "Err: failed to reset WiFi station protocol");
return false;
}
WiFi.setAutoReconnect(true);
mesh::wifi::setStationAutoReconnect(true);
_retry_saved_wifi_in_setup = false;
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
_setup_started_at = 0;
_wifi_reconnect_tracker.noteDisconnected(millis());
WiFi.begin(_wifi_ssid, _wifi_password);
mesh::wifi::beginStation(_wifi_ssid, _wifi_password);
_wifi_power_save = effectiveWiFiPowerSave(_wifi_power_save);
wifi_ps_type_t ps_mode =
_wifi_power_save == mesh::wifi::kPowerSaveNone ? WIFI_PS_NONE
@@ -764,15 +925,14 @@ void WebConfigServer::finalizeTeardown() {
// Nothing else owns WiFi when we raised the AP: either the node is
// unconfigured, or `start webconfig ap` required the bridge stopped.
if (_wifi_ssid[0] == 0 || _owns_wifi) {
WiFi.mode(WIFI_OFF);
stopOwnedWiFiRadio();
} else {
WiFi.mode(WIFI_STA);
}
_was_setup_ap = false;
}
if (_owns_wifi && !was_setup_ap) {
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
stopOwnedWiFiRadio();
}
_initial_setup = false;
_setup_started_at = 0;
@@ -816,6 +976,13 @@ void WebConfigServer::tick(uint32_t now) {
}
if (_mode == MODE_OFF) return;
// A primary ESP-NOW radio and its WiFi station cannot remain on different
// channels. Reject a router-driven channel move (for example a CSA) and put
// the mesh radio back on its boot channel before any reconnect work runs.
if (!mesh::wifi::enforceStationChannel()) {
_wifi_reconnect_tracker.noteDisconnected(now);
}
if (_mode == MODE_CONNECTING) {
if (WiFi.status() == WL_CONNECTED) {
_wifi_reconnect_tracker.noteConnected();
@@ -857,9 +1024,9 @@ void WebConfigServer::tick(uint32_t now) {
Serial.printf("WebConfig: WiFi still unavailable; retrying '%s'\n",
_wifi_ssid);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
mesh::wifi::setStationAutoReconnect(true);
WiFi.disconnect(false, false);
WiFi.begin(_wifi_ssid, _wifi_password);
mesh::wifi::beginStation(_wifi_ssid, _wifi_password);
}
}
}
@@ -881,7 +1048,7 @@ void WebConfigServer::tick(uint32_t now) {
}
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
mesh::wifi::setStationAutoReconnect(true);
_was_setup_ap = false;
_initial_setup = false;
_setup_started_at = 0;
@@ -905,7 +1072,7 @@ void WebConfigServer::tick(uint32_t now) {
_setup_reconnect_in_progress = true;
_setup_reconnect_deadline = now + 20000UL;
Serial.printf("WebConfig: retrying saved WiFi '%s'\n", _wifi_ssid);
WiFi.begin(_wifi_ssid, _wifi_password);
mesh::wifi::beginStation(_wifi_ssid, _wifi_password);
}
}
@@ -965,7 +1132,7 @@ void WebConfigServer::tick(uint32_t now) {
}
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
mesh::wifi::setStationAutoReconnect(true);
_was_setup_ap = false;
_initial_setup = false;
_setup_started_at = 0;
@@ -1090,8 +1257,9 @@ void WebConfigServer::drainBatch(uint32_t now) {
strcpy(e.reply, "OK");
}
} else if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.pwd") == 0) {
if (strlen(value) >= sizeof(_wifi_password)) {
strcpy(e.reply, "Error: WiFi password must be at most 63 characters");
if (!mesh::cli::standaloneWiFiPasswordValid(value)) {
strcpy(e.reply,
"Error: WiFi password must be 0-63 characters or 64 hex characters");
} else {
strncpy(_wifi_password, value, sizeof(_wifi_password) - 1);
_wifi_password[sizeof(_wifi_password) - 1] = 0;
@@ -1105,7 +1273,11 @@ void WebConfigServer::drainBatch(uint32_t now) {
strcpy(e.reply,
"Error: power save none is unavailable while Bluetooth is active");
} else if (strcmp(value, "none") == 0) _wifi_power_save = 1;
else if (strcmp(value, "max") == 0) _wifi_power_save = 2;
else if (strcmp(value, "max") == 0
&& mesh::wifi::kPrimaryEspNowRadio) {
strcpy(e.reply,
"Error: power save max is unavailable while ESP-NOW is the primary radio");
} else if (strcmp(value, "max") == 0) _wifi_power_save = 2;
else strcpy(e.reply, "Error: must be none, min, or max");
if (e.reply[0] == 0) {
_standalone_wifi_dirty = true;
@@ -1169,16 +1341,19 @@ void WebConfigServer::drainBatch(uint32_t now) {
}
bool wifi_credentials_changed = false;
bool espnow_channel_changed = false;
for (int i = 0; i < _batch_count; i++) {
if (strcmp(_batch[i].key, "wifi.ssid") == 0
|| strcmp(_batch[i].key, "wifi.pwd") == 0) {
wifi_credentials_changed = true;
break;
} else if (wcSetKeyRequiresReboot(_batch[i].key)) {
espnow_channel_changed = true;
}
}
if (WebConfigBatch::shouldStartSetupWiFiHandoff(
_mode == MODE_SETUP, _batch_reboot, _batch_all_ok,
wifi_credentials_changed, _wifi_ssid[0] != 0)) {
wifi_credentials_changed, _wifi_ssid[0] != 0,
espnow_channel_changed)) {
{
WCLock lock(_mux);
_setup_wifi_handoff_pending = true;
@@ -1192,7 +1367,7 @@ void WebConfigServer::drainBatch(uint32_t now) {
WiFi.setAutoReconnect(false);
Serial.printf("WebConfig: testing saved WiFi '%s' before reboot\n",
_wifi_ssid);
WiFi.begin(_wifi_ssid, _wifi_password);
mesh::wifi::beginStation(_wifi_ssid, _wifi_password);
return;
}
finishBatch(now);
@@ -1252,6 +1427,9 @@ void WebConfigServer::dispatchRequest(AsyncWebServerRequest* req,
}
void WebConfigServer::registerRoutes() {
_server->on("/ui", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleUi);
});
_server->on("/", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleRoot);
});
@@ -1361,12 +1539,34 @@ void WebConfigServer::handleRoot(AsyncWebServerRequest* req) {
return;
}
AsyncWebServerResponse* res =
req->beginResponse(200, "text/html", WEBCONFIG_HTML_GZ, WEBCONFIG_HTML_GZ_LEN);
res->addHeader("Content-Encoding", "gzip");
req->beginResponse(200, "text/html", WEBCONFIG_HTML_LOADER,
WEBCONFIG_HTML_LOADER_LEN);
res->addHeader("ETag", WEBCONFIG_HTML_ETAG);
req->send(res);
}
void WebConfigServer::handleUi(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_last_activity = millis();
if (!req->hasParam("v") ||
req->getParam("v")->value() != WEBCONFIG_HTML_VERSION) {
// Part URLs are immutable. Never serve a new layout under an old loader's
// cache key; a 409 tells the loader to refresh itself first.
AsyncWebServerResponse* res =
req->beginResponse(409, "text/plain", "WebConfig version changed");
res->addHeader("Cache-Control", "no-store");
req->send(res);
return;
}
AsyncWebServerResponse* res =
new WebConfigPacedProgmemResponse("text/html; charset=utf-8",
WEBCONFIG_HTML_GZ,
WEBCONFIG_HTML_GZ_LEN);
res->addHeader("Content-Encoding", "gzip");
res->addHeader("Cache-Control", "public, max-age=31536000, immutable");
req->send(res);
}
void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
bool authed = checkAuth(req);
@@ -1405,6 +1605,9 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
doc["active_slots"] = 0;
#endif
doc["mqtt"] = has_mqtt;
// The fixed-layout MQTTPrefs path remains limited to 63 characters.
// Standalone mesh-wifi NVS can also hold a standards-defined 64-hex PSK.
doc["wifi_psk64"] = (_mqtt_prefs == NULL || !_owns_wifi);
doc["cli"] = _cli_enabled && _mode == MODE_LAN
&& WiFi.status() == WL_CONNECTED && _cb->supportsCliTerminal();
doc["capabilities"] = node.capabilities;
@@ -1524,6 +1727,9 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
wifi["pwd"] = _wifi_password[0] ? SECRET_SENTINEL : "";
wifi["powersave"] = _wifi_power_save == 0 ? "min"
: _wifi_power_save == 2 ? "max" : "none";
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
wifi["espnow_channel"] = mesh::wifi::loadConfiguredEspNowChannel();
#endif
#ifdef WITH_MQTT_BRIDGE
MQTTPrefs* obs = static_cast<MQTTPrefs*>(_mqtt_prefs);
@@ -1598,6 +1804,13 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
return;
}
JsonObject set = doc["set"];
for (JsonPair kv : set) {
if (wcSetKeyRequiresReboot(kv.key().c_str())) {
// The configured value is intentionally not activated until boot, so a
// client cannot leave persisted and live ESP-NOW channels disagreeing.
reboot_after = true;
}
}
WCLock lock(_mux);
const WebConfigBatch::State bstate = toSpecState(_batch_state);
@@ -1649,6 +1862,18 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
req->send(400, "application/json", out);
return;
}
if (wcSetKeyRequiresReboot(key)) {
uint8_t channel = 0;
if (!mesh::wifi::parseEspNowChannel(val, channel)) {
StaticJsonDocument<128> err;
err["error"] = "ESP-NOW channel must be 1-13";
err["key"] = key;
String out;
serializeJson(err, out);
req->send(400, "application/json", out);
return;
}
}
if (admin_pwd && !wcIsValidAdminPassword(val)) {
req->send(400, "application/json",
"{\"error\":\"admin password must be 1-15 characters with no line breaks\"}");
@@ -2120,6 +2345,7 @@ void WebConfigServer::handleScan(AsyncWebServerRequest* req) {
net["ssid"] = WiFi.SSID(i);
net["rssi"] = WiFi.RSSI(i);
net["enc"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
net["channel"] = WiFi.channel(i);
}
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
+4 -1
View File
@@ -73,6 +73,7 @@ public:
CAP_RX_POWER_SAVING = 1UL << 12,
CAP_POWER_SAVING = 1UL << 13,
CAP_BLUETOOTH_NAME = 1UL << 14,
CAP_ESPNOW_CHANNEL = 1UL << 15,
CAP_DELAYS = CAP_RX_DELAY | CAP_TX_DELAY,
};
@@ -243,7 +244,8 @@ private:
uint32_t _setup_started_at = 0;
uint32_t _connect_deadline = 0;
char _wifi_ssid[32] = {0};
char _wifi_password[64] = {0};
// 63-character passphrase + NUL, or 64-hex raw WPA/WPA2 PSK + NUL.
char _wifi_password[65] = {0};
uint8_t _wifi_power_save = mesh::wifi::kDefaultPowerSave;
bool _cli_enabled = true;
// A `password` command has succeeded this session. Lets the CLI satisfy the
@@ -319,6 +321,7 @@ private:
size_t index, size_t total);
void handleRoot(AsyncWebServerRequest* req);
void handleUi(AsyncWebServerRequest* req);
void handleStatus(AsyncWebServerRequest* req);
void handleLogin(AsyncWebServerRequest* req);
void handleLogout(AsyncWebServerRequest* req);
+130
View File
@@ -0,0 +1,130 @@
#pragma once
#include <stdint.h>
#include <esp_err.h>
#include <esp_wifi.h>
#include <helpers/WiFiChannelPolicy.h>
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
#include <Preferences.h>
#endif
namespace mesh {
namespace wifi {
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
#ifndef MESH_ESPNOW_CHANNEL
#define MESH_ESPNOW_CHANNEL 1
#endif
static_assert(MESH_ESPNOW_CHANNEL >= kEspNowChannelMin
&& MESH_ESPNOW_CHANNEL <= kEspNowChannelMax,
"MESH_ESPNOW_CHANNEL must be a 2.4 GHz WiFi channel");
static constexpr bool kPrimaryEspNowRadio = true;
static constexpr bool kKeepEspNowRadioRunning = true;
static constexpr uint8_t kDefaultEspNowChannel = MESH_ESPNOW_CHANNEL;
#else
static constexpr bool kPrimaryEspNowRadio = false;
static constexpr bool kKeepEspNowRadioRunning = false;
static constexpr uint8_t kDefaultEspNowChannel = 1;
#endif
#if defined(MESH_ESPNOW_RADIO) && MESH_ESPNOW_RADIO
static constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_11B
| WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR;
#elif defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// A primary ESP-NOW image without conventional WiFi retains its original
// LR-only protocol until a coexistence-capable recipe explicitly opts in.
static constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_LR;
#else
static constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_11B
| WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
struct EspNowBootChannelState {
bool loaded;
uint8_t active;
};
inline uint8_t loadConfiguredEspNowChannel() {
uint8_t channel = kDefaultEspNowChannel;
Preferences prefs;
if (prefs.begin("mesh-wifi", true)) {
channel = prefs.getUChar("espnow_ch", kDefaultEspNowChannel);
prefs.end();
}
return validEspNowChannelOrDefault(channel, kDefaultEspNowChannel);
}
// This cache intentionally represents the boot channel. Saving a new setting
// must not mutate it: reconnect/AP helpers continue using the old channel until
// reboot, so ESP-NOW, STA, and SoftAP cannot be moved at different times.
inline EspNowBootChannelState& espNowBootChannelState() {
static EspNowBootChannelState state = {false, kDefaultEspNowChannel};
return state;
}
inline uint8_t activeEspNowChannel() {
EspNowBootChannelState& state = espNowBootChannelState();
if (!state.loaded) {
state.active = loadConfiguredEspNowChannel();
state.loaded = true;
}
return state.active;
}
inline bool saveConfiguredEspNowChannel(uint8_t channel) {
if (!isValidEspNowChannel(channel)) return false;
Preferences prefs;
if (!prefs.begin("mesh-wifi", false)) return false;
const bool saved = prefs.putUChar("espnow_ch", channel) == sizeof(uint8_t)
&& prefs.getUChar("espnow_ch", 0) == channel;
prefs.end();
return saved;
}
#else
inline uint8_t loadConfiguredEspNowChannel() {
return kDefaultEspNowChannel;
}
inline uint8_t activeEspNowChannel() {
return kDefaultEspNowChannel;
}
inline bool saveConfiguredEspNowChannel(uint8_t) {
return false;
}
#endif
inline esp_err_t applyProtocolMask(wifi_interface_t interface_id) {
return esp_wifi_set_protocol(interface_id, kProtocolMask);
}
// An ESP-NOW radio and an associated station cannot occupy different channels.
// Full Companion therefore keeps both its setup AP and any infrastructure-WiFi
// association on the mesh channel. Passing zero on ordinary targets preserves
// Arduino's normal all-channel scan behavior.
inline int32_t stationChannelHint() {
return kPrimaryEspNowRadio
? static_cast<int32_t>(activeEspNowChannel()) : 0;
}
inline int accessPointChannel() {
return kPrimaryEspNowRadio
? static_cast<int>(activeEspNowChannel()) : 1;
}
inline uint8_t stationScanChannel() {
return kPrimaryEspNowRadio ? activeEspNowChannel() : 0;
}
inline esp_err_t restoreEspNowChannel() {
if (!kKeepEspNowRadioRunning) return ESP_OK;
// Fail closed instead of silently moving to the build default. A failure can
// be transient (scan/connect in progress), and changing the cached boot
// channel here would split this node from peers which still use the selected
// channel. Callers retry through their normal reconnect/tick paths.
return esp_wifi_set_channel(
activeEspNowChannel(), WIFI_SECOND_CHAN_NONE);
}
} // namespace wifi
} // namespace mesh
+93
View File
@@ -0,0 +1,93 @@
#pragma once
#include <limits.h>
#include <string.h>
#include <WiFi.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
namespace mesh {
namespace wifi {
// Driver-managed reconnect is intentionally disabled for a primary ESP-NOW
// radio. ESP-IDF treats wifi_sta_config_t::channel as a scan-start hint, so an
// unconstrained reconnect could associate on another channel and silently
// drag ESP-NOW (and a setup AP) with it. Existing application retry timers call
// beginStation() and therefore retain the fixed-channel/BSSID constraint.
inline void setStationAutoReconnect(bool enabled) {
WiFi.setAutoReconnect(enabled && !kPrimaryEspNowRadio);
}
inline wl_status_t beginStation(const char* ssid, const char* password) {
if (!kPrimaryEspNowRadio) {
return WiFi.begin(ssid, password);
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (!ssid || !ssid[0]) return WL_CONNECT_FAILED;
setStationAutoReconnect(false);
if (applyProtocolMask(WIFI_IF_STA) != ESP_OK
|| restoreEspNowChannel() != ESP_OK) {
return WL_CONNECT_FAILED;
}
// Do not collide with WebConfig's asynchronous SSID scan. The caller's
// normal retry path will try again after that scan completes.
const int16_t prior_scan = WiFi.scanComplete();
if (prior_scan == WIFI_SCAN_RUNNING) return WL_IDLE_STATUS;
if (prior_scan >= 0) WiFi.scanDelete();
const uint8_t channel = activeEspNowChannel();
const int16_t count = WiFi.scanNetworks(
false, true, false, 300, channel, ssid, nullptr);
int best = -1;
int32_t best_rssi = INT32_MIN;
for (int i = 0; i < count; i++) {
if (WiFi.channel(i) != channel || WiFi.SSID(i) != ssid) continue;
const int32_t rssi = WiFi.RSSI(i);
if (best < 0 || rssi > best_rssi) {
best = i;
best_rssi = rssi;
}
}
if (best < 0) {
WiFi.scanDelete();
restoreEspNowChannel();
return WL_NO_SSID_AVAIL;
}
const uint8_t* selected_bssid = WiFi.BSSID(best);
if (!selected_bssid) {
WiFi.scanDelete();
restoreEspNowChannel();
return WL_CONNECT_FAILED;
}
uint8_t bssid[6];
memcpy(bssid, selected_bssid, sizeof(bssid));
WiFi.scanDelete();
return WiFi.begin(ssid, password, channel, bssid);
#else
return WL_CONNECT_FAILED;
#endif
}
// Returns false after dropping an association which moved away from the mesh
// channel (for example after an AP channel-switch announcement). Callers then
// follow their existing disconnected/retry path.
inline bool enforceStationChannel() {
if (!kPrimaryEspNowRadio) return true;
if (WiFi.status() != WL_CONNECTED) {
// A disconnect and scan/association completion are asynchronous. Keep
// reasserting the boot channel so a first ESP_ERR_WIFI_STATE cannot leave
// the mesh parked on the router's former channel.
restoreEspNowChannel();
return true;
}
if (WiFi.channel() == activeEspNowChannel()) return true;
WiFi.disconnect(false, false);
restoreEspNowChannel();
return false;
}
} // namespace wifi
} // namespace mesh
+6 -5
View File
@@ -26,8 +26,8 @@ does not reflect the GoogleTest count -- run the built binary directly
|-------|-------------------|--------|
| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants |
| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation -- including boundaries and nulls |
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password and browser-terminal command validation, slot-index bounds, and short-input guards |
| `test_webconfig_batch` | `src/helpers/WebConfigBatch.h` | config POST/replay/busy decisions; paced command drain; setup WiFi validation/IP handoff; reboot confirmation/fallback; stop/refcount gating; exact timing and rollover boundaries |
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist and reboot classification, secret detection, admin-password and browser-terminal command validation, slot-index bounds, and short-input guards |
| `test_webconfig_batch` | `src/helpers/WebConfigBatch.h` | config POST/replay/busy decisions; paced command drain; setup WiFi validation/IP handoff, including channel-change suppression; reboot confirmation/fallback; stop/refcount gating; exact timing and rollover boundaries |
| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz |
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries |
| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover |
@@ -44,19 +44,20 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_telemetry_history` | `src/helpers/TelemetryHistory.h`, `ExternalVoltageHistory.h` | 30-minute rings; seven-day temperature/battery and four-day multi-channel I2C voltage retention; exact 1 C temperature/status and 0.02 V packed voltage encodings; zero-only channel omission; Base64 pages and raw chunks; GPS differentials, resize preservation, heap budgets, and paging bounds |
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered blacklist matching; ordered 1/2/3-byte pbyte rule prefixes; original incoming scope classes and canonical region-name identity; channel-authentication cache key comparison; priority ordering and terminal stop masks; bridge-bucket and regionless channel-target selector encoding; `require=region` and per-channel scope-gate truth tables; fast/slow timing; adding, replacing, and preserving packet scope |
| `test_logical_message_cache` | `src/helpers/LogicalMessageCache.h` | bounded logical-message mapping; stable retry timestamps; exact older retries after newer messages; stale and same-timestamp mismatch rejection |
| `test_cli_command_utils` | `src/helpers/CLICommandUtils.h`, `src/helpers/ContactListOrder.h`, `src/helpers/TerminalCommandTracker.h`, `src/helpers/TerminalDisplayFilter.h` | terminal verb/argument/path parsing; routed receive labels; quiet display defaults and independent emergency filtering; favorite-first contact ordering; single-command reply matching, round-trip timing, and rollover-safe expiration |
| `test_cli_command_utils` | `src/helpers/CLICommandUtils.h`, `src/helpers/ContactListOrder.h`, `src/helpers/TerminalCommandTracker.h`, `src/helpers/TerminalDisplayFilter.h`, `src/helpers/WiFiChannelPolicy.h`, `src/helpers/bridges/ESPNowBridgeFormat.h` | terminal verb/argument/path parsing; routed receive labels; quiet display defaults and independent emergency filtering; favorite-first contact ordering; standalone WiFi validation including 64-hex WPA/WPA2 PSKs; strict ESP-NOW channel and bridge-format parsing/range/fallback; single-command reply matching, round-trip timing, and rollover-safe expiration |
| `test_espnow_raw_fragmentation` | `src/helpers/ESPNowRawFragmentation.h` | byte-exact legacy raw frames; 251-255-byte two-frame encoding; CRC-32 integrity; source-MAC-keyed bounded reassembly; malformed, duplicate, out-of-order, timeout, rollover, and capacity handling |
| `test_gps_time_validation` | `src/helpers/sensors/GpsTimeValidation.h` | complete NMEA UTC validation; leap years and calendar bounds; rejection of receiver-default and signed-32-bit-out-of-range dates |
| `test_identity_generation` | `src/helpers/IdentityGeneration.h` | reserved-prefix rejection; bounded retries; final provisioned attempt; fail-closed exhaustion |
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h`, `src/helpers/RemoteCliTimeout.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; 300% response timeout; empty-response completion; on-air truncation and clearing |
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing |
| `test_companion_status_response` | `src/helpers/CompanionStatusResponse.h` | request-tag correlation and minimum status-response length, including rejection of the three-entry ACL payload that previously masqueraded as status |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | terminal/seeder control-sequence recognition and passthrough ownership; queued/atomic USB output under backpressure and short writes; partial-frame busy state; requester-affine replies, locked contact streams, and Bluetooth-only pairing routing |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | terminal/seeder control-sequence recognition, single-TTY logging transitions, and USB/TCP ownership; queued/atomic USB output under backpressure and short writes; partial-frame busy state; requester-affine replies, locked contact streams, and Bluetooth-only pairing routing |
| `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion |
| `test_ble_mota_control` | `src/helpers/BleMotaStream.h`, `CompanionMotaControl.h` | encrypted mOTA channel ring buffering, overflow fail-closed behavior, request gating, and strict rejection of injected or USB-ownership control commands |
| `test_atomic_file_writer` | `src/helpers/AtomicFileWriter.h` | verified temporary-file commit; short-write, readback, validation, and rename failures; preservation of the live file and stale-temp cleanup |
| `test_cad_timing` | `src/helpers/radiolib/CadTiming.h`, `LR2021SideDetectorConfig.h`, `RadioAirtime.h` | Cascade and slow-profile CAD deadlines; invalid airtime handling; bounded LR2021 side-detector parsing and LDRO recomputation |
| `test_companion_node_prefs` | `examples/companion_radio/NodePrefs.h` | independent device power saving, RXPS, Wi-Fi, and FEM preferences; one-time migration of the regressed power-saving default |
| `test_config_serializer` | `src/helpers/ConfigSerializer.cpp`, Companion `NodePrefs` | escaped config save/load, whitespace and malformed input, unknown fields, and FEM preference round trips |
| `test_config_serializer` | `src/helpers/ConfigSerializer.cpp`, Companion `NodePrefs` | escaped config save/load, whitespace and malformed input, unknown fields, and FEM/ESP-NOW bridge-format preference round trips |
| `test_deferred_cli_command` | `src/helpers/DeferredCliCommand.h` | copying authenticated command context, single-pending-command enforcement, clearing, and length rejection |
| `test_host_cli_bridge` | `src/helpers/HostCliBridge.h` | bounded request/reply parsing, Base64URL serial framing, correlation preservation, one-time service-claim proofs, request-token fields, line safety, and UTF-8-safe truncation |
| `test_kiss_modem` | `examples/kiss_modem/KissModem.cpp` | KISS escaping/framing and packet metadata under partial writes, host TX backpressure, queue saturation, and radio completion; run with `native_kiss_modem` |
+83
View File
@@ -290,6 +290,89 @@ SenseCapIndicator-ESPNow_comp_radio_usb|SenseCapIndicator-ESPNow_companion_radio
SenseCapIndicator-LoRa_comp_radio_usb_wifi|SenseCapIndicator-LoRa_companion_radio_full|SenseCapIndicator-LoRa_comp_radio_usb_wifi
FULL_COMPANION_SPECS
# The Full overlay, not the historical transport used as its build base, owns
# the capability contract. Verify every registered ESP32 Full target receives
# an ordinary WiFi Companion transport even when its base was USB- or BLE-only.
for full_env in "${SUPPORTED_PIO_ENVS[@]}"; do
is_esp32_companion_radio_full_target "$full_env" || continue
pio_env=$(get_pio_build_env "$full_env")
PLATFORMIO_BUILD_FLAGS=""
PLATFORMIO_BUILD_UNFLAGS=""
PLATFORMIO_BUILD_SRC_FILTER=""
BUILD_REDUCTIONS=()
apply_companion_radio_full_profile "$full_env" "$pio_env"
apply_esp32_full_async_tcp_profile "$full_env"
if ! pio_env_option_contains "$pio_env" build_flags WIFI_SSID \
&& [[ "$PLATFORMIO_BUILD_FLAGS" != *"WIFI_SSID"* ]]; then
fail "$full_env Full Companion omitted ordinary WiFi credentials"
fi
if ! pio_env_option_contains "$pio_env" build_src_filter "helpers/esp32/*.cpp" \
&& ! pio_env_option_contains "$pio_env" build_src_filter \
"helpers/esp32/SerialWifiInterface.cpp" \
&& [[ "$PLATFORMIO_BUILD_SRC_FILTER" \
!= *"helpers/esp32/SerialWifiInterface.cpp"* ]]; then
fail "$full_env Full Companion omitted SerialWifiInterface"
fi
[[ "$PLATFORMIO_BUILD_FLAGS" == *"WIFI_OTA_SEEDER=1"* ]] \
|| fail "$full_env Full Companion omitted its WiFi runtime overlay"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"CONFIG_ASYNC_TCP_STACK_SIZE=4096"* ]] \
|| fail "$full_env Full Companion omitted its bounded AsyncTCP stack"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"CONFIG_ASYNC_TCP_RUNNING_CORE=1"* ]] \
|| fail "$full_env Full Companion omitted its AsyncTCP core isolation"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"ASYNCWEBSERVER_USE_CHUNK_INFLIGHT=0"* ]] \
|| fail "$full_env Full Companion omitted its slow-client response profile"
case "${full_env,,}" in
generic_espnow_companion_radio_full|\
sensecapindicator-espnow_companion_radio_full)
pio_env_option_contains "$pio_env" build_flags "MESH_PRIMARY_ESPNOW=1" \
|| fail "$full_env lost its primary ESP-NOW radio marker"
[[ "$PLATFORMIO_BUILD_UNFLAGS" != *"MESH_PRIMARY_ESPNOW"* ]] \
|| fail "$full_env removed its primary ESP-NOW radio marker"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"MESH_ESPNOW_RADIO=1"* ]] \
|| fail "$full_env omitted ESP-NOW/WiFi coexistence policy"
;;
esac
done
unset PLATFORMIO_BUILD_FLAGS PLATFORMIO_BUILD_UNFLAGS \
PLATFORMIO_BUILD_SRC_FILTER
# The bounded AsyncTCP task stack belongs to every ESP32 Full profile, including
# expanded non-Companion builds, but must not leak into ordinary or nRF52
# recipes.
ESP32_FULL_BUILD=1
PLATFORMIO_BUILD_FLAGS=""
apply_esp32_full_async_tcp_profile Heltec_v3_repeater
[[ "$PLATFORMIO_BUILD_FLAGS" == *"CONFIG_ASYNC_TCP_STACK_SIZE=4096"* ]] \
|| fail "expanded ESP32 Full omitted its bounded AsyncTCP stack"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"CONFIG_ASYNC_TCP_RUNNING_CORE=1"* ]] \
|| fail "expanded ESP32 Full omitted its AsyncTCP core isolation"
[[ "$PLATFORMIO_BUILD_FLAGS" == *"ASYNCWEBSERVER_USE_CHUNK_INFLIGHT=0"* ]] \
|| fail "expanded ESP32 Full omitted its slow-client response profile"
ESP32_FULL_BUILD=0
PLATFORMIO_BUILD_FLAGS=""
apply_esp32_full_async_tcp_profile Heltec_v3_repeater
[[ "$PLATFORMIO_BUILD_FLAGS" != *"CONFIG_ASYNC_TCP_STACK_SIZE"* ]] \
|| fail "ordinary ESP32 inherited the Full AsyncTCP stack override"
[[ "$PLATFORMIO_BUILD_FLAGS" != *"CONFIG_ASYNC_TCP_RUNNING_CORE"* ]] \
|| fail "ordinary ESP32 inherited the Full AsyncTCP core override"
[[ "$PLATFORMIO_BUILD_FLAGS" != *"ASYNCWEBSERVER_USE_CHUNK_INFLIGHT"* ]] \
|| fail "ordinary ESP32 inherited the Full slow-client response override"
ESP32_FULL_BUILD=1
PLATFORMIO_BUILD_FLAGS=""
apply_esp32_full_async_tcp_profile RAK_4631_companion_radio_full
[[ "$PLATFORMIO_BUILD_FLAGS" != *"CONFIG_ASYNC_TCP_STACK_SIZE"* ]] \
|| fail "nRF52 Full inherited the ESP32 AsyncTCP stack override"
[[ "$PLATFORMIO_BUILD_FLAGS" != *"CONFIG_ASYNC_TCP_RUNNING_CORE"* ]] \
|| fail "nRF52 Full inherited the ESP32 AsyncTCP core override"
[[ "$PLATFORMIO_BUILD_FLAGS" != *"ASYNCWEBSERVER_USE_CHUNK_INFLIGHT"* ]] \
|| fail "nRF52 Full inherited the ESP32 slow-client response override"
ESP32_FULL_BUILD=0
unset PLATFORMIO_BUILD_FLAGS
while IFS='|' read -r source_env full_env; do
[ "$(get_esp32_full_companion_replacement "$source_env")" = "$full_env" ] \
|| fail "$source_env did not collapse into $full_env"
@@ -1,10 +1,14 @@
#include <gtest/gtest.h>
#include <string>
#include <helpers/CLICommandUtils.h>
#include <helpers/ContactListOrder.h>
#include <helpers/TerminalCommandTracker.h>
#include <helpers/TerminalDisplayFilter.h>
#include <helpers/WiFiChannelPolicy.h>
#include <helpers/WiFiPowerSave.h>
#include <helpers/bridges/ESPNowBridgeFormat.h>
#include <helpers/radiolib/RadioPowerLimits.h>
TEST(ContactListOrder, SortsFavoritesBeforeNewerNonFavorites) {
@@ -543,8 +547,17 @@ TEST(CLICommandUtils, ValidatesStandaloneWiFiValues) {
EXPECT_TRUE(mesh::cli::standaloneWiFiPasswordValid(""));
EXPECT_TRUE(mesh::cli::standaloneWiFiPasswordValid(
"123456789012345678901234567890123456789012345678901234567890123"));
EXPECT_TRUE(mesh::cli::standaloneWiFiPasswordValid(
"0123456789abcdefABCDEF0123456789abcdefABCDEF0123456789abcdef0123"));
std::string non_hex_64(64, 'a');
non_hex_64[31] = 'g';
EXPECT_FALSE(mesh::cli::standaloneWiFiPasswordValid(non_hex_64.c_str()));
non_hex_64[31] = ' ';
EXPECT_FALSE(mesh::cli::standaloneWiFiPasswordValid(non_hex_64.c_str()));
EXPECT_FALSE(mesh::cli::standaloneWiFiPasswordValid(
"1234567890123456789012345678901234567890123456789012345678901234"));
std::string(65, 'a').c_str()));
EXPECT_FALSE(mesh::cli::standaloneWiFiPasswordValid(nullptr));
uint8_t power_save = 99;
EXPECT_TRUE(mesh::cli::parseStandaloneWiFiPowerSave("min", power_save));
@@ -569,6 +582,18 @@ TEST(CLICommandUtils, EnforcesWiFiSleepForBluetoothCoexistence) {
mesh::wifi::effectivePowerSave(99, true));
EXPECT_EQ(mesh::wifi::kPowerSaveNone,
mesh::wifi::effectivePowerSave(99, false));
EXPECT_EQ(mesh::wifi::kPowerSaveMin,
mesh::wifi::effectivePowerSave(
mesh::wifi::kPowerSaveMax, false, true));
EXPECT_EQ(mesh::wifi::kPowerSaveMin,
mesh::wifi::effectivePowerSave(
mesh::wifi::kPowerSaveMax, true, true));
EXPECT_EQ(mesh::wifi::kPowerSaveMin,
mesh::wifi::effectivePowerSave(
mesh::wifi::kPowerSaveNone, true, true));
EXPECT_EQ(mesh::wifi::kPowerSaveNone,
mesh::wifi::effectivePowerSave(
mesh::wifi::kPowerSaveNone, false, true));
}
TEST(CLICommandUtils, DefaultsWiFiPowerSaveToNoneWithoutProfileOverride) {
@@ -691,6 +716,152 @@ TEST(CLICommandUtils, FormatsUsefulUnknownSettingErrors) {
EXPECT_EQ(nullptr, strstr(reply, "??:"));
}
TEST(WiFiChannelPolicy, AcceptsTheSupportedEspNowChannelRange) {
uint8_t channel = 0;
EXPECT_TRUE(mesh::wifi::parseEspNowChannel("1", channel));
EXPECT_EQ(1, channel);
EXPECT_TRUE(mesh::wifi::parseEspNowChannel("6", channel));
EXPECT_EQ(6, channel);
EXPECT_TRUE(mesh::wifi::parseEspNowChannel("13", channel));
EXPECT_EQ(13, channel);
EXPECT_TRUE(mesh::wifi::parseEspNowChannel(" \t11\t ", channel));
EXPECT_EQ(11, channel);
EXPECT_FALSE(mesh::wifi::isValidEspNowChannel(0));
EXPECT_TRUE(mesh::wifi::isValidEspNowChannel(1));
EXPECT_TRUE(mesh::wifi::isValidEspNowChannel(13));
EXPECT_FALSE(mesh::wifi::isValidEspNowChannel(14));
}
TEST(WiFiChannelPolicy, RejectsMalformedAndOutOfRangeValuesWithoutMutation) {
const char* invalid[] = {
nullptr, "", " \t", "0", "14", "+1", "-1", "1.0", "6GHz",
"1 2", "1\n", "999999999999999999999999999999999999",
};
for (const char* value : invalid) {
uint8_t channel = 7;
EXPECT_FALSE(mesh::wifi::parseEspNowChannel(value, channel))
<< (value == nullptr ? "<null>" : value);
EXPECT_EQ(7, channel);
}
}
TEST(WiFiChannelPolicy, FallsBackOnlyWhenTheStoredChannelIsInvalid) {
EXPECT_EQ(1, mesh::wifi::validEspNowChannelOrDefault(0, 1));
EXPECT_EQ(6, mesh::wifi::validEspNowChannelOrDefault(6, 1));
EXPECT_EQ(13, mesh::wifi::validEspNowChannelOrDefault(13, 1));
EXPECT_EQ(1, mesh::wifi::validEspNowChannelOrDefault(14, 1));
}
TEST(CLICommandUtils, ObserverSettingsNeverStartADisabledBridge) {
int calls = 0;
EXPECT_TRUE(mesh::cli::restartBridgeIfEnabled(false, [&calls]() {
++calls;
return true;
}));
EXPECT_EQ(0, calls);
EXPECT_TRUE(mesh::cli::restartBridgeIfEnabled(true, [&calls]() {
++calls;
return true;
}));
EXPECT_EQ(1, calls);
EXPECT_FALSE(mesh::cli::restartBridgeIfEnabled(true, [&calls]() {
++calls;
return false;
}));
EXPECT_EQ(2, calls);
}
TEST(ESPNowBridgeFormat, ParsesOnlyExplicitSupportedNames) {
uint8_t format = 99;
EXPECT_TRUE(mesh::bridge::parseEspNowFormat("wrapped", format));
EXPECT_EQ(mesh::bridge::ESPNOW_FORMAT_WRAPPED, format);
EXPECT_STREQ("wrapped", mesh::bridge::espNowFormatName(format));
EXPECT_TRUE(mesh::bridge::parseEspNowFormat("raw", format));
EXPECT_EQ(mesh::bridge::ESPNOW_FORMAT_RAW, format);
EXPECT_STREQ("raw", mesh::bridge::espNowFormatName(format));
const char* invalid[] = {
nullptr, "", "auto", "both", "native", "RAW", "raw ", " wrapped",
};
for (const char* value : invalid) {
format = 7;
EXPECT_FALSE(mesh::bridge::parseEspNowFormat(value, format));
EXPECT_EQ(7, format);
}
}
TEST(ESPNowBridgeFormat, PreservesLegacyDefaultAndRawPayloadCapacity) {
EXPECT_TRUE(mesh::bridge::isValidEspNowFormat(
mesh::bridge::ESPNOW_FORMAT_WRAPPED));
EXPECT_TRUE(mesh::bridge::isValidEspNowFormat(
mesh::bridge::ESPNOW_FORMAT_RAW));
EXPECT_FALSE(mesh::bridge::isValidEspNowFormat(2));
EXPECT_EQ(246U, mesh::bridge::espNowMaxMeshPacketSize(
mesh::bridge::ESPNOW_FORMAT_WRAPPED));
EXPECT_EQ(255U, mesh::bridge::espNowMaxMeshPacketSize(
mesh::bridge::ESPNOW_FORMAT_RAW));
EXPECT_EQ(246U, mesh::bridge::espNowMaxMeshPacketSize(99));
}
TEST(ESPNowBridgeFormat, BothFormatsAcceptExactlyChannelsOneThroughThirteen) {
const uint8_t formats[] = {
mesh::bridge::ESPNOW_FORMAT_WRAPPED,
mesh::bridge::ESPNOW_FORMAT_RAW,
};
for (uint8_t format : formats) {
EXPECT_EQ(13, mesh::bridge::espNowMaxChannel(format));
EXPECT_FALSE(mesh::bridge::isValidEspNowBridgeChannel(0, format));
EXPECT_FALSE(mesh::bridge::isValidEspNowBridgeChannel(14, format));
for (uint8_t expected = 1; expected <= 13; ++expected) {
char text[4];
snprintf(text, sizeof(text), "%u", (unsigned)expected);
uint8_t channel = 99;
EXPECT_TRUE(mesh::bridge::parseEspNowBridgeChannel(
text, format, channel)) << "format=" << (unsigned)format
<< " channel=" << (unsigned)expected;
EXPECT_EQ(expected, channel);
EXPECT_TRUE(mesh::bridge::isValidEspNowBridgeChannel(channel, format));
}
}
}
TEST(ESPNowBridgeFormat, BothFormatsRejectMalformedChannelsWithoutMutation) {
const uint8_t formats[] = {
mesh::bridge::ESPNOW_FORMAT_WRAPPED,
mesh::bridge::ESPNOW_FORMAT_RAW,
};
const char* invalid[] = {
nullptr, "", " ", "0", "14", "15", "+6", "-6", "6junk", "1 2",
"6.0", "6\n", "999999999999999999999999999999999",
};
for (uint8_t format : formats) {
for (const char* value : invalid) {
uint8_t channel = 7;
EXPECT_FALSE(mesh::bridge::parseEspNowBridgeChannel(
value, format, channel))
<< "format=" << (unsigned)format
<< " value=" << (value == nullptr ? "<null>" : value);
EXPECT_EQ(7, channel);
}
uint8_t channel = 0;
EXPECT_TRUE(mesh::bridge::parseEspNowBridgeChannel(
" \t13\t ", format, channel));
EXPECT_EQ(13, channel);
}
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
@@ -254,6 +254,7 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) {
saved.radio_fem_txgain = 1;
saved.usb_logging_enabled = 0;
saved.bridge_uart = 2;
saved.bridge_format = mesh::bridge::ESPNOW_FORMAT_RAW;
MockPrintStream output;
ASSERT_TRUE(saved.saveSerial(output));
@@ -263,6 +264,7 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) {
EXPECT_NE(std::string::npos, serialised.find("fem_txgain:1"));
EXPECT_NE(std::string::npos, serialised.find("usb_log:0"));
EXPECT_NE(std::string::npos, serialised.find("uart:2"));
EXPECT_NE(std::string::npos, serialised.find("format:1"));
MockInputStream input(serialised.c_str());
NodePrefs loaded;
@@ -270,12 +272,46 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) {
loaded.radio_fem_txgain = 0;
loaded.usb_logging_enabled = 1;
loaded.bridge_uart = 1;
loaded.bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
ASSERT_TRUE(loaded.loadSerial(input));
EXPECT_EQ(0, loaded.radio_fem_rxgain);
EXPECT_EQ(1, loaded.radio_fem_txgain);
EXPECT_EQ(0, loaded.usb_logging_enabled);
EXPECT_EQ(2, loaded.bridge_uart);
EXPECT_EQ(mesh::bridge::ESPNOW_FORMAT_RAW, loaded.bridge_format);
}
TEST(NodePrefs, BridgeFormatDefaultsToWrappedAndIsWrittenExplicitly) {
NodePrefs prefs;
EXPECT_EQ(mesh::bridge::ESPNOW_FORMAT_WRAPPED, prefs.bridge_format);
MockPrintStream output;
ASSERT_TRUE(prefs.saveSerial(output));
std::string serialised(reinterpret_cast<const char*>(output.getBytes()),
output.getLength());
EXPECT_NE(std::string::npos, serialised.find("format:0"));
}
TEST(NodePrefs, LegacySerialWithoutBridgeFormatKeepsWrappedDefault) {
MockInputStream legacy("{bridge:{ch:13,secret:\"legacy\"}}");
NodePrefs loaded;
ASSERT_EQ(mesh::bridge::ESPNOW_FORMAT_WRAPPED, loaded.bridge_format);
ASSERT_TRUE(loaded.loadSerial(legacy));
EXPECT_EQ(13, loaded.bridge_channel);
EXPECT_STREQ("legacy", loaded.bridge_secret);
EXPECT_EQ(mesh::bridge::ESPNOW_FORMAT_WRAPPED, loaded.bridge_format);
}
TEST(NodePrefs, ExplicitWrappedBridgeFormatReplacesRawInMemoryValue) {
MockInputStream serialised("{bridge:{format:0}}");
NodePrefs loaded;
loaded.bridge_format = mesh::bridge::ESPNOW_FORMAT_RAW;
ASSERT_TRUE(loaded.loadSerial(serialised));
EXPECT_EQ(mesh::bridge::ESPNOW_FORMAT_WRAPPED, loaded.bridge_format);
}
@@ -0,0 +1,458 @@
#include <gtest/gtest.h>
#include <MeshCore.h>
#include <Packet.h>
#include <helpers/ESPNowRawFragmentation.h>
#include <array>
#include <cstring>
namespace {
using mesh::espnow::ESPNowRawFrames;
using mesh::espnow::ESPNowRawReassemblerT;
using mesh::espnow::ESPNowRawReassemblyResult;
static std::array<uint8_t, MAX_TRANS_UNIT> makePacket(uint8_t salt = 0) {
std::array<uint8_t, MAX_TRANS_UNIT> packet{};
// Version zero is the only currently valid serialized MeshCore version.
packet[0] = static_cast<uint8_t>(0x03U | ((salt & 0x0FU) << 2));
for (size_t i = 1; i < packet.size(); ++i) {
packet[i] = static_cast<uint8_t>((i * 37U + salt * 19U) & 0xFFU);
}
return packet;
}
static const uint8_t MAC_A[mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE] =
{0x02, 0x11, 0x22, 0x33, 0x44, 0x55};
static const uint8_t MAC_B[mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE] =
{0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE};
struct ValidWirePacket {
std::array<uint8_t, MAX_TRANS_UNIT> bytes{};
size_t length = 0;
};
static ValidWirePacket makeValidWirePacket(size_t target_length) {
mesh::Packet packet;
packet.payload_len = MAX_PACKET_PAYLOAD;
for (size_t i = 0; i < packet.payload_len; ++i) {
packet.payload[i] = static_cast<uint8_t>((i * 3U + 7U) & 0xFFU);
}
if (target_length == 250) {
packet.header = static_cast<uint8_t>(
(PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD);
packet.setPathHashSizeAndCount(2, 32); // 64 path bytes
} else {
packet.header = static_cast<uint8_t>(
(PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT)
| ROUTE_TYPE_TRANSPORT_FLOOD);
packet.transport_codes[0] = 0x1122;
packet.transport_codes[1] = 0x3344;
const size_t path_bytes = target_length - 1U - 4U - 1U
- MAX_PACKET_PAYLOAD;
if (path_bytes == 64) {
packet.setPathHashSizeAndCount(2, 32);
} else {
packet.setPathHashSizeAndCount(1,
static_cast<uint8_t>(path_bytes));
}
}
for (size_t i = 0; i < packet.getPathByteLen(); ++i) {
packet.path[i] = static_cast<uint8_t>((0x80U + i) & 0xFFU);
}
ValidWirePacket wire;
wire.length = packet.writeTo(wire.bytes.data());
return wire;
}
TEST(ESPNowRawFragmentation, ConstantsMatchMeshCoreAndReserveInvalidVersion) {
EXPECT_EQ(static_cast<size_t>(MAX_TRANS_UNIT),
mesh::espnow::ESPNOW_RAW_MAX_PACKET_SIZE);
EXPECT_EQ(250U, mesh::espnow::ESPNOW_RAW_MAX_FRAME_SIZE);
EXPECT_EQ(3U, mesh::espnow::ESPNOW_RAW_FRAGMENT_MAGIC[0] >> 6);
EXPECT_LE(mesh::espnow::ESPNOW_RAW_FRAGMENT_HEADER_SIZE
+ mesh::espnow::ESPNOW_RAW_FRAGMENT_DATA_SIZE,
mesh::espnow::ESPNOW_RAW_MAX_FRAME_SIZE);
}
TEST(ESPNowRawFragmentation, Crc32UsesTheStandardIEEEVector) {
static const uint8_t input[] = {'1', '2', '3', '4', '5',
'6', '7', '8', '9'};
EXPECT_EQ(0xCBF43926UL,
mesh::espnow::espNowRawCrc32(input, sizeof(input)));
EXPECT_EQ(0U, mesh::espnow::espNowRawCrc32(nullptr, 0));
}
TEST(ESPNowRawFragmentation, OrdinaryRawFramesRemainExactlyOneUnchangedFrame) {
const auto packet = makePacket();
const size_t lengths[] = {2, 249, 250};
for (size_t len : lengths) {
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), len,
frames));
ASSERT_EQ(1U, frames.count) << "len=" << len;
EXPECT_EQ(len, frames.lengths[0]);
EXPECT_EQ(0, memcmp(packet.data(), frames.data[0], len));
EXPECT_EQ(0U, frames.lengths[1]);
}
}
TEST(ESPNowRawFragmentation, RejectsNullEmptyAndOverLimitInput) {
auto packet = makePacket();
ESPNowRawFrames frames;
EXPECT_FALSE(mesh::espnow::encodeEspNowRawFrames(nullptr, 2, frames));
EXPECT_EQ(0U, frames.count);
EXPECT_FALSE(mesh::espnow::encodeEspNowRawFrames(packet.data(), 0, frames));
EXPECT_EQ(0U, frames.count);
EXPECT_FALSE(mesh::espnow::encodeEspNowRawFrames(
packet.data(), mesh::espnow::ESPNOW_RAW_MAX_PACKET_SIZE + 1, frames));
EXPECT_EQ(0U, frames.count);
}
TEST(ESPNowRawFragmentation, SplitsOnlyOversizePacketsIntoTwoBoundedFrames) {
const auto packet = makePacket();
const size_t lengths[] = {251, 252, 253, 254, 255};
for (size_t len : lengths) {
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), len,
frames));
ASSERT_EQ(2U, frames.count) << "len=" << len;
EXPECT_EQ(mesh::espnow::ESPNOW_RAW_MAX_FRAME_SIZE, frames.lengths[0]);
EXPECT_LE(frames.lengths[1],
mesh::espnow::ESPNOW_RAW_MAX_FRAME_SIZE);
EXPECT_EQ(0, memcmp(frames.data[0],
mesh::espnow::ESPNOW_RAW_FRAGMENT_MAGIC,
mesh::espnow::ESPNOW_RAW_FRAGMENT_MAGIC_SIZE));
EXPECT_EQ(0, memcmp(frames.data[1],
mesh::espnow::ESPNOW_RAW_FRAGMENT_MAGIC,
mesh::espnow::ESPNOW_RAW_FRAGMENT_MAGIC_SIZE));
EXPECT_EQ(len, frames.data[0][7]);
EXPECT_EQ(len, frames.data[1][7]);
EXPECT_EQ(0U, frames.data[0][6]);
EXPECT_EQ(1U, frames.data[1][6]);
EXPECT_EQ(0U, frames.data[0][8]);
EXPECT_EQ(mesh::espnow::ESPNOW_RAW_FRAGMENT_DATA_SIZE,
frames.data[1][8]);
}
}
TEST(ESPNowRawFragmentation, ValidMeshCorePacketsRoundTripAcrossBoundary) {
const size_t lengths[] = {250, 251, 254};
for (size_t expected_length : lengths) {
const ValidWirePacket wire = makeValidWirePacket(expected_length);
ASSERT_EQ(expected_length, wire.length);
mesh::Packet input_check;
ASSERT_TRUE(input_check.readFrom(
wire.bytes.data(), static_cast<uint8_t>(wire.length)));
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(
wire.bytes.data(), wire.length, frames));
ASSERT_EQ(expected_length <= 250 ? 1U : 2U, frames.count);
ESPNowRawReassemblerT<2> reassembler;
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
ESPNowRawReassemblyResult result =
ESPNowRawReassemblyResult::REJECTED;
for (uint8_t i = 0; i < frames.count; ++i) {
result = reassembler.acceptFrame(
MAC_A, frames.data[i], frames.lengths[i], 100U + i,
output, sizeof(output), output_len);
}
EXPECT_EQ(expected_length <= 250
? ESPNowRawReassemblyResult::PASSTHROUGH
: ESPNowRawReassemblyResult::PACKET_COMPLETE,
result);
ASSERT_EQ(wire.length, output_len);
EXPECT_EQ(0, memcmp(wire.bytes.data(), output, output_len));
mesh::Packet decoded;
EXPECT_TRUE(decoded.readFrom(output, static_cast<uint8_t>(output_len)));
EXPECT_EQ(MAX_PACKET_PAYLOAD, decoded.payload_len);
}
}
TEST(ESPNowRawFragmentation, FragmentEnvelopeHasStableGoldenWireOrder) {
const ValidWirePacket wire = makeValidWirePacket(251);
ASSERT_EQ(251U, wire.length);
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(
wire.bytes.data(), wire.length, frames));
ASSERT_EQ(2U, frames.count);
// Header fields and CRC32 are network byte order. These bytes intentionally
// do not derive their expected checksum from the implementation under test.
static const uint8_t expected_first_header[] = {
0xFE, 0x4D, 0x43, 0x46, 0x01, 0x02, 0x00,
0xFB, 0x00, 0x51, 0x24, 0x1C, 0xDC};
static const uint8_t expected_second_header[] = {
0xFE, 0x4D, 0x43, 0x46, 0x01, 0x02, 0x01,
0xFB, 0xED, 0x51, 0x24, 0x1C, 0xDC};
EXPECT_EQ(0, memcmp(expected_first_header, frames.data[0],
sizeof(expected_first_header)));
EXPECT_EQ(0, memcmp(expected_second_header, frames.data[1],
sizeof(expected_second_header)));
}
TEST(ESPNowRawFragmentation, PassesOrdinaryFramesThroughExactly) {
const auto packet = makePacket();
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 99;
ESPNowRawReassemblerT<2> reassembler;
EXPECT_EQ(ESPNowRawReassemblyResult::PASSTHROUGH,
reassembler.acceptFrame(MAC_A, packet.data(), 250, 10,
output, sizeof(output), output_len));
EXPECT_EQ(250U, output_len);
EXPECT_EQ(0, memcmp(packet.data(), output, output_len));
}
TEST(ESPNowRawFragmentation, ReassemblesEveryOversizeLengthInOrder) {
const auto packet = makePacket();
for (size_t len = 251; len <= MAX_TRANS_UNIT; ++len) {
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), len,
frames));
ESPNowRawReassemblerT<2> reassembler;
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
100, output, sizeof(output), output_len));
EXPECT_EQ(0U, output_len);
EXPECT_EQ(ESPNowRawReassemblyResult::PACKET_COMPLETE,
reassembler.acceptFrame(MAC_A, frames.data[1], frames.lengths[1],
101, output, sizeof(output), output_len));
ASSERT_EQ(len, output_len);
EXPECT_EQ(0, memcmp(packet.data(), output, len));
}
}
TEST(ESPNowRawFragmentation, ReassemblesOutOfOrder) {
const auto packet = makePacket();
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), packet.size(),
frames));
ESPNowRawReassemblerT<2> reassembler;
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames.data[1], frames.lengths[1],
1, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::PACKET_COMPLETE,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
2, output, sizeof(output), output_len));
ASSERT_EQ(packet.size(), output_len);
EXPECT_EQ(0, memcmp(packet.data(), output, output_len));
}
TEST(ESPNowRawFragmentation, KeysConcurrentAssembliesBySourceMac) {
const auto packet = makePacket();
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), packet.size(),
frames));
ESPNowRawReassemblerT<2> reassembler;
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
10, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_B, frames.data[0], frames.lengths[0],
11, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::PACKET_COMPLETE,
reassembler.acceptFrame(MAC_A, frames.data[1], frames.lengths[1],
12, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::PACKET_COMPLETE,
reassembler.acceptFrame(MAC_B, frames.data[1], frames.lengths[1],
13, output, sizeof(output), output_len));
}
TEST(ESPNowRawFragmentation, HandlesIdenticalAndConflictingDuplicates) {
const auto packet = makePacket();
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), packet.size(),
frames));
ESPNowRawReassemblerT<2> reassembler;
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
1, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::DUPLICATE,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
2, output, sizeof(output), output_len));
ESPNowRawFrames conflicting = frames;
conflicting.data[0][mesh::espnow::ESPNOW_RAW_FRAGMENT_HEADER_SIZE + 3] ^= 1;
EXPECT_EQ(ESPNowRawReassemblyResult::REJECTED,
reassembler.acceptFrame(MAC_A, conflicting.data[0],
conflicting.lengths[0], 3, output,
sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::PACKET_COMPLETE,
reassembler.acceptFrame(MAC_A, frames.data[1], frames.lengths[1],
4, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::DUPLICATE,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
5, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::DUPLICATE,
reassembler.acceptFrame(MAC_A, frames.data[1], frames.lengths[1],
6, output, sizeof(output), output_len));
}
TEST(ESPNowRawFragmentation, RejectsCorruptPayloadAtWholePacketIntegrityCheck) {
const auto packet = makePacket();
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), packet.size(),
frames));
frames.data[1][mesh::espnow::ESPNOW_RAW_FRAGMENT_HEADER_SIZE] ^= 0x80;
ESPNowRawReassemblerT<2> reassembler;
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
1, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::REJECTED,
reassembler.acceptFrame(MAC_A, frames.data[1], frames.lengths[1],
2, output, sizeof(output), output_len));
EXPECT_EQ(0U, output_len);
}
TEST(ESPNowRawFragmentation, RejectsMalformedReservedEnvelopes) {
const auto packet = makePacket();
ESPNowRawFrames original;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), packet.size(),
original));
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
auto expectRejected = [&](ESPNowRawFrames frames, size_t frame_len) {
ESPNowRawReassemblerT<1> reassembler;
EXPECT_EQ(ESPNowRawReassemblyResult::REJECTED,
reassembler.acceptFrame(MAC_A, frames.data[0], frame_len, 1,
output, sizeof(output), output_len));
EXPECT_EQ(0U, output_len);
};
ESPNowRawFrames bad = original;
bad.data[0][1] ^= 1;
expectRejected(bad, bad.lengths[0]);
bad = original;
bad.data[0][4]++;
expectRejected(bad, bad.lengths[0]);
bad = original;
bad.data[0][5] = 3;
expectRejected(bad, bad.lengths[0]);
bad = original;
bad.data[0][6] = 2;
expectRejected(bad, bad.lengths[0]);
bad = original;
bad.data[0][7] = 250;
expectRejected(bad, bad.lengths[0]);
bad = original;
bad.data[0][8] = 1;
expectRejected(bad, bad.lengths[0]);
expectRejected(original,
mesh::espnow::ESPNOW_RAW_FRAGMENT_HEADER_SIZE - 1);
expectRejected(original, original.lengths[0] - 1);
}
TEST(ESPNowRawFragmentation, RequiresAdequateOutputWithoutOverflowingIt) {
const auto packet = makePacket();
ESPNowRawReassemblerT<1> reassembler;
uint8_t output[254] = {};
size_t output_len = 77;
EXPECT_EQ(ESPNowRawReassemblyResult::OUTPUT_TOO_SMALL,
reassembler.acceptFrame(MAC_A, packet.data(), 250, 1,
output, 249, output_len));
EXPECT_EQ(0U, output_len);
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), packet.size(),
frames));
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
2, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::OUTPUT_TOO_SMALL,
reassembler.acceptFrame(MAC_A, frames.data[1], frames.lengths[1],
3, output, sizeof(output), output_len));
EXPECT_EQ(0U, output_len);
}
TEST(ESPNowRawFragmentation, ExpiresAssembliesAcrossMillisRollover) {
const auto packet = makePacket();
ESPNowRawFrames frames;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(packet.data(), packet.size(),
frames));
ESPNowRawReassemblerT<1> reassembler(20);
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames.data[0], frames.lengths[0],
0xFFFFFFF0UL, output, sizeof(output),
output_len));
// 0xFFFFFFF0 -> 0x00000005 is 21 ms, so fragment zero has expired.
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames.data[1], frames.lengths[1],
0x00000005UL, output, sizeof(output),
output_len));
EXPECT_EQ(0U, output_len);
}
TEST(ESPNowRawFragmentation, BoundedSlotsEvictOldestIncompleteAssembly) {
const auto packet_a = makePacket(1);
const auto packet_b = makePacket(2);
const auto packet_c = makePacket(3);
ESPNowRawFrames frames_a;
ESPNowRawFrames frames_b;
ESPNowRawFrames frames_c;
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(
packet_a.data(), packet_a.size(), frames_a));
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(
packet_b.data(), packet_b.size(), frames_b));
ASSERT_TRUE(mesh::espnow::encodeEspNowRawFrames(
packet_c.data(), packet_c.size(), frames_c));
ESPNowRawReassemblerT<2> reassembler(1000);
uint8_t output[MAX_TRANS_UNIT] = {};
size_t output_len = 0;
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames_a.data[0], frames_a.lengths[0],
10, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames_b.data[0], frames_b.lengths[0],
20, output, sizeof(output), output_len));
EXPECT_EQ(ESPNowRawReassemblyResult::FRAGMENT_STORED,
reassembler.acceptFrame(MAC_A, frames_c.data[0], frames_c.lengths[0],
30, output, sizeof(output), output_len));
// A was oldest and was evicted; B remains and can complete.
EXPECT_EQ(ESPNowRawReassemblyResult::PACKET_COMPLETE,
reassembler.acceptFrame(MAC_A, frames_b.data[1], frames_b.lengths[1],
40, output, sizeof(output), output_len));
ASSERT_EQ(packet_b.size(), output_len);
EXPECT_EQ(0, memcmp(packet_b.data(), output, output_len));
}
} // namespace
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+83
View File
@@ -329,6 +329,77 @@ const heltecV4TftFemOff = picker.parseTargetProfile(
assert.strictEqual(heltecV4TftFemOff.hardware, "heltec_v4_3_tft");
assert.strictEqual(heltecV4TftFemOff.variant, "default");
const directWifiMqtt = picker.parseTargetProfile(
"Heltec_v3_companion_radio_wifi_mqtt"
);
assert.strictEqual(directWifiMqtt.mode, "wifi");
assert.strictEqual(directWifiMqtt.logging, "wifi");
assert.deepStrictEqual(directWifiMqtt.loggingModes, ["wifi"]);
assert.strictEqual(directWifiMqtt.variant, "default");
const consolidatedV3 = [
"Heltec_v3_companion_radio_full",
"Heltec_v3_companion_radio_wifi_mqtt",
].map(function (target) {
return Object.assign(picker.parseTargetProfile(target), {
installKinds: ["bin", "merged-bin"],
});
});
picker.applyFullCompanionCapabilities(consolidatedV3);
assert.deepStrictEqual(
consolidatedV3[0].loggingModes,
["none", "usb"]
);
assert(!picker.profileMatches(consolidatedV3[0], { logging: "wifi" }));
assert.deepStrictEqual(
picker.omitTransportsReplacedByFull(consolidatedV3).map(function (item) {
return item.target;
}),
["Heltec_v3_companion_radio_full"]
);
const consolidatedV4 = [
"heltec_v4_2_v4_3_companion_radio_full_femon",
"heltec_v4_companion_radio_wifi_mqtt_femon",
"heltec_v4_3_companion_radio_wifi_mqtt_femoff",
].map(function (target) {
return Object.assign(picker.parseTargetProfile(target), {
installKinds: ["bin", "merged-bin"],
});
});
picker.applyFullCompanionCapabilities(consolidatedV4);
assert.deepStrictEqual(
picker.omitTransportsReplacedByFull(consolidatedV4).map(function (item) {
return item.target;
}),
["heltec_v4_2_v4_3_companion_radio_full_femon"]
);
const consolidatedSenseCap = [
"SenseCapIndicator-LoRa_companion_radio_full",
"SenseCapIndicator-LoRa_comp_radio_usb_wifi",
].map(function (target) {
return Object.assign(picker.parseTargetProfile(target), {
installKinds: ["bin", "merged-bin"],
});
});
assert.strictEqual(consolidatedSenseCap[1].variant, "default");
picker.applyFullCompanionCapabilities(consolidatedSenseCap);
assert.deepStrictEqual(
picker.omitTransportsReplacedByFull(consolidatedSenseCap).map(
function (item) { return item.target; }
),
["SenseCapIndicator-LoRa_companion_radio_full"]
);
const nrfUf2OnlyFull = Object.assign(
picker.parseTargetProfile("RAK_4631_companion_radio_full"),
{ installKinds: ["uf2"] }
);
picker.applyFullCompanionCapabilities([nrfUf2OnlyFull]);
assert.strictEqual(nrfUf2OnlyFull.dedicatedUsbLogging, true);
assert.strictEqual(nrfUf2OnlyFull.logging, "usb-runtime");
const g2RxBoosted = picker.parseTargetProfile(
"Station_G2_logging_repeater"
);
@@ -436,6 +507,18 @@ assert.strictEqual(mqtt.mode, "mqtt");
assert.strictEqual(mqtt.ota, "lora-receiver");
assert.strictEqual(mqtt.variant, "default");
const fullCompanionSteps = picker.installSteps(v4Full, "merged-bin");
assert(fullCompanionSteps.some(function (step) {
return step.includes("usb.logging");
}));
assert(!fullCompanionSteps.some(function (step) {
return step.includes("logging.output");
}));
const observerSteps = picker.installSteps(mqtt, "merged-bin");
assert(observerSteps.some(function (step) {
return step.includes("logging.output");
}));
const lora = profile(
"Station_G2_repeater_lora_ota_no_external_sensors"
);
@@ -470,6 +470,39 @@ TEST(SerialModeSwitch, UsbBinaryActivityWinsDuringTcpBorrow) {
EXPECT_FALSE(handoff.shouldRestoreAscii(21));
}
TEST(SerialModeSwitch, SingleTtyLoggingCannotStealNetworkTerminal) {
using Action = mesh::UsbLoggingTerminalAction;
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, true, false, false, true, true),
Action::NONE);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, true, false, false, false, true),
Action::NONE);
}
TEST(SerialModeSwitch, LoggingClaimsOnlySingleTtyUsb) {
using Action = mesh::UsbLoggingTerminalAction;
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, true, false, false, true, false),
Action::CLAIM_USB);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
true, true, false, false, true, false),
Action::NONE);
}
TEST(SerialModeSwitch, LoggingOffRestoresTheBuildDefaultUsbMode) {
using Action = mesh::UsbLoggingTerminalAction;
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, false, true, true, false, false),
Action::RETURN_TO_BINARY);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, false, true, true, true, false),
Action::KEEP_ASCII);
EXPECT_EQ(mesh::selectUsbLoggingTerminalAction(
false, false, true, false, false, false),
Action::NONE);
}
TEST(SerialModeSwitch, FailedMotaRestoresOnlyItsAsciiOrigin) {
EXPECT_TRUE(mesh::shouldRestoreAsciiAfterMotaFailure(
mesh::UsbMotaEntryOrigin::ASCII));
@@ -112,12 +112,25 @@ TEST(WebConfigBatch, FinishArmsThirtySecondFallbackOnlyForAFullyOkRebootBatch) {
TEST(WebConfigBatch, SetupWiFiHandoffRequiresASuccessfulCredentialChangingReboot) {
EXPECT_TRUE(Batch::shouldStartSetupWiFiHandoff(
/*setup=*/true, /*reboot=*/true, /*all_ok=*/true,
/*wifi_changed=*/true, /*has_ssid=*/true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(false, true, true, true, true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, false, true, true, true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, false, true, true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, true, false, true));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(true, true, true, true, false));
/*wifi_changed=*/true, /*has_ssid=*/true,
/*espnow_channel_changed=*/false));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(
false, true, true, true, true, false));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(
true, false, true, true, true, false));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(
true, true, false, true, true, false));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(
true, true, true, false, true, false));
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(
true, true, true, true, false, false));
}
TEST(WebConfigBatch, EspNowChannelChangeSkipsLiveSetupWiFiHandoff) {
EXPECT_FALSE(Batch::shouldStartSetupWiFiHandoff(
/*setup=*/true, /*reboot=*/true, /*all_ok=*/true,
/*wifi_changed=*/true, /*has_ssid=*/true,
/*espnow_channel_changed=*/true));
}
TEST(WebConfigBatch, SetupWiFiHandoffUsesBoundedConnectAndBriefFlushDelays) {
@@ -14,6 +14,7 @@ TEST(WebConfigKeys, AllowsKnownScalarKeys) {
EXPECT_TRUE(wcIsAllowedSetKey("powersaving"));
EXPECT_TRUE(wcIsAllowedSetKey("repeat"));
EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid"));
EXPECT_TRUE(wcIsAllowedSetKey("espnow.channel"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt.iata"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors"));
EXPECT_TRUE(wcIsAllowedSetKey("mqtt.neighbors.interval"));
@@ -67,6 +68,15 @@ TEST(WebConfigKeys, IsCaseSensitive) {
EXPECT_FALSE(wcIsAllowedSetKey("MQTT1.preset"));
}
TEST(WebConfigKeys, EspNowChannelAlwaysRequiresAReboot) {
EXPECT_TRUE(wcSetKeyRequiresReboot("espnow.channel"));
EXPECT_FALSE(wcSetKeyRequiresReboot(NULL));
EXPECT_FALSE(wcSetKeyRequiresReboot("espnow.channel.extra"));
EXPECT_FALSE(wcSetKeyRequiresReboot("ESPNOW.channel"));
EXPECT_FALSE(wcSetKeyRequiresReboot("wifi.ssid"));
EXPECT_FALSE(wcSetKeyRequiresReboot(""));
}
// ---- short-key OOB guard --------------------------------------------------
// The slot-prefix probe indexes key[4..6]; these short strings must be rejected
// without ever reading past the terminator.
+144 -20
View File
@@ -281,8 +281,9 @@ def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases():
)
assert "COMPANION_FEATURE_DEDICATED_USB_LOGGING" in companion_features
assert "defined(COMPANION_RADIO_FULL)" in companion
assert "if (!mesh::hasDedicatedUsbLoggingPort())" in companion_main
assert "if (mesh::isUsbLoggingEnabled())" in companion_main
assert "mesh::selectUsbLoggingTerminalAction(" in companion_main
assert "mesh::hasDedicatedUsbLoggingPort()" in companion_main
assert "mesh::isUsbLoggingEnabled()" in companion_main
assert "enterUsbLoggingTerminalMode();" in companion_main
assert "usb_logging_terminal_mode" in companion_main
assert "_prefs.usb_logging_enabled = 0" in companion
@@ -306,7 +307,7 @@ def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases():
assert "is_esp32_dual_cdc_companion_radio_full_target" not in replacement
def test_single_tty_logging_off_requires_a_subsequent_mode_switch():
def test_single_tty_logging_off_restores_each_builds_default_mode():
root = Path(__file__).resolve().parents[2]
companion_main = (
root / "examples/companion_radio/main.cpp"
@@ -317,30 +318,36 @@ def test_single_tty_logging_off_requires_a_subsequent_mode_switch():
"static void expireUsbBinaryStartupProbeBeforeDispatch()", 1
)[0]
# A logging preference changed through BLE/WiFi must release logging's
# exclusive ownership without silently changing the protocol of an open
# USB ASCII session.
external_disable = service.split(
"else if (usb_logging_terminal_mode && the_mesh.isTerminalMode()) {",
1,
)[1].split("\n }\n }\n#endif", 1)[0]
assert "usb_logging_terminal_mode = false;" in external_disable
assert "clearUsbTerminalLine();" in external_disable
assert "usb_terminal_discard_line = false;" in external_disable
assert "leaveUsbTerminalMode" not in external_disable
# The shared policy distinguishes Full's startup ASCII mode from an
# ordinary USB Companion's Binary default, and an active TCP terminal keeps
# ownership even when the saved USB logging preference is on.
assert "mesh::selectUsbLoggingTerminalAction(" in service
assert "isNetworkTerminalActive()" in service
assert "case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY:" in service
assert "leaveUsbTerminalMode(true);" in service
assert "case mesh::UsbLoggingTerminalAction::KEEP_ASCII:" in service
keep_ascii = service.split(
"case mesh::UsbLoggingTerminalAction::KEEP_ASCII:", 1
)[1].split(
"case mesh::UsbLoggingTerminalAction::NONE:", 1
)[0]
assert "usb_logging_terminal_mode = false;" in keep_ascii
assert "clearUsbTerminalLine();" in keep_ascii
assert "usb_terminal_discard_line = false;" in keep_ascii
assert "leaveUsbTerminalMode" not in keep_ascii
# The same rule applies when `set usb.logging off` is entered on the USB
# logging terminal itself: keep the fresh-flash ASCII mode after replying.
# A command entered on the logging terminal follows the same split: Full
# stays in ASCII, while a non-Full Companion returns to Binary immediately.
command_disable = service.split(
"the_mesh.handleTerminalCommand(usb_terminal_line);", 1
)[1].split('Serial.print("> ");', 1)[0]
assert "!mesh::isUsbLoggingEnabled()" in command_disable
assert "usb_logging_terminal_mode = false;" in command_disable
assert "leaveUsbTerminalMode" not in command_disable
assert "#if defined(COMPANION_RADIO_FULL)" in command_disable
assert "leaveUsbTerminalMode(true);" in command_disable
# Binary Companion remains reachable, but only through the ordinary mode
# switch that follows the logging-off command (explicit token or framed
# startup probe), never as a side effect of changing the preference.
# Full Companion reaches Binary through its ordinary explicit token or
# framed startup probe after logging is disabled.
terminal_stop = service.split(
"if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0) {", 1
)[1].split("}", 1)[0]
@@ -728,6 +735,123 @@ def test_release_catalog_resolves_canonical_runtime_aliases():
assert catalog["device"][0]["firmware"] == [canonical]
def test_release_catalog_resolves_legacy_companions_to_plain_full():
root = Path(__file__).resolve().parents[2]
def load_module(name, relative_path):
spec = importlib.util.spec_from_file_location(name, root / relative_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
provider = load_module(
"meshcore_provider_plain_full_test",
"mesh-america/update-provider-release.py",
)
logging_provider = load_module(
"meshcore_logging_provider_plain_full_test",
"mesh-america/update-logging-provider-release.py",
)
release_files = {
identity: [Path(f"{index}.bin")]
for index, identity in enumerate(
(
"Station_G2_companion_radio_full",
"ThinkNode_M2_companion_radio_full",
"RAK_4631_companion_radio_full",
"Heltec_v2_companion_radio_full",
"heltec_tracker_v2_companion_radio_full_femon",
"Heltec_v3_companion_radio_full",
"heltec_v4_2_v4_3_companion_radio_full_femon",
"Generic_ESPNOW_companion_radio_full",
"Heltec_E290_companion_radio_full",
"Heltec_T190_companion_radio_full_",
"SenseCapIndicator-ESPNow_companion_radio_full",
"SenseCapIndicator-LoRa_companion_radio_full",
)
)
}
migrations = {
# Ordinary transport artifacts can carry either release qualifier.
"Station_G2_companion_radio_usb-ota": (
"Station_G2_companion_radio_full", False
),
"Station_G2_companion_radio_ble": (
"Station_G2_companion_radio_full", False
),
"Station_G2_companion_radio_wifi-logging": (
"Station_G2_companion_radio_full", False
),
"ThinkNode_M2_companion_radio_serial-logging": (
"ThinkNode_M2_companion_radio_full", False
),
"RAK_4631_companion_radio_ethernet-logging": (
"RAK_4631_companion_radio_full", False
),
"Heltec_v2_companion_radio_wifi-full-logging-ota": (
"Heltec_v2_companion_radio_full", False
),
"heltec_tracker_v2_companion_radio_usb_femoff-ota": (
"heltec_tracker_v2_companion_radio_full_femon", False
),
# Direct MQTT observers become the MQTT-capable Full image and must
# retain their observer metadata in both catalogs.
"Heltec_v3_companion_radio_wifi_mqtt-ota": (
"Heltec_v3_companion_radio_full", True
),
"heltec_v4_companion_radio_wifi_mqtt_femon-ota": (
"heltec_v4_2_v4_3_companion_radio_full_femon", True
),
"heltec_v4_3_companion_radio_wifi_mqtt_femoff-ota": (
"heltec_v4_2_v4_3_companion_radio_full_femon", True
),
# Nonstandard and combined legacy recipe names use explicit aliases.
"Generic_ESPNOW_comp_radio_usb-ota": (
"Generic_ESPNOW_companion_radio_full", False
),
"Generic_ESPNOW_comp_radio_usb-logging": (
"Generic_ESPNOW_companion_radio_full", False
),
"Heltec_E290_companion_usb-ota": (
"Heltec_E290_companion_radio_full", False
),
"Heltec_E290_companion_ble": (
"Heltec_E290_companion_radio_full", False
),
"Heltec_E290_companion_usb_ble-logging": (
"Heltec_E290_companion_radio_full", False
),
"Heltec_T190_companion_radio_usb_-ota": (
"Heltec_T190_companion_radio_full_", False
),
"Heltec_T190_companion_radio_ble_": (
"Heltec_T190_companion_radio_full_", False
),
"Heltec_T190_companion_radio_usb_ble_-logging": (
"Heltec_T190_companion_radio_full_", False
),
"SenseCapIndicator-ESPNow_comp_radio_usb-ota": (
"SenseCapIndicator-ESPNow_companion_radio_full", False
),
"SenseCapIndicator-ESPNow_comp_radio_usb-logging": (
"SenseCapIndicator-ESPNow_companion_radio_full", False
),
"SenseCapIndicator-LoRa_comp_radio_usb_wifi-logging": (
"SenseCapIndicator-LoRa_companion_radio_full", False
),
}
# The logging updater intentionally imports this resolver from the main
# updater. Exercise both loaded modules so a future local copy cannot drift.
for resolver_owner in (provider, logging_provider.common):
for old_identity, expected in migrations.items():
assert resolver_owner.resolve_release_identity(
old_identity, release_files
) == expected
def test_ota_target_generation_honors_explicit_disable():
from gen_targets import ota_envs, release_aliases
+3
View File
@@ -8,6 +8,7 @@ board = esp32-c3-devkitm-1
build_flags =
${esp32_base.build_flags}
-I variants/generic_espnow
-D MESH_PRIMARY_ESPNOW=1
; -D ESP32_CPU_FREQ=80
-D PIN_BOARD_SDA=-1
-D PIN_BOARD_SCL=-1
@@ -45,6 +46,7 @@ build_flags =
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MESH_ESPNOW_RADIO=1
-D MAX_NEIGHBOURS=50
build_src_filter = ${Generic_ESPNOW.build_src_filter}
+<../examples/simple_repeater/*.cpp>
@@ -78,6 +80,7 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D MESH_ESPNOW_RADIO=1
build_src_filter = ${Generic_ESPNOW.build_src_filter}
+<../examples/simple_room_server/*.cpp>
lib_deps =
@@ -9,6 +9,7 @@ board_upload.maximum_size = 8388608
board_build.partitions = default.csv
build_flags =
${esp32_base.build_flags}
-D MESH_PRIMARY_ESPNOW=1
-D PIN_BOARD_SDA=39
-D PIN_BOARD_SCL=40
-D BOARD_HAS_PSRAM
@@ -63,6 +64,7 @@ lib_deps=${esp32_base.lib_deps}
extends = SenseCapIndicator-ESPNow
build_flags =
${SenseCapIndicator-ESPNow.build_flags}
-UMESH_PRIMARY_ESPNOW
-D SENSECAP_INDICATOR_LORA
-D USE_SX1262
-D RADIO_CLASS=CustomSX1262
+49 -14
View File
@@ -248,15 +248,18 @@ body.tab-cli{padding-bottom:0}
<div class="card">
<h2>Step 1 &middot; WiFi</h2>
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px" id="wz-wifi-copy">Connect this node to your WiFi network so it can reach the MQTT servers.</p>
<div class="note"><b>Preparing for remote deployment?</b> This wizard tests the WiFi before reboot. If the deployment network is not available here, use USB serial at <b>config.meshcore.io</b> and run <b>set wifi.ssid ...</b> and <b>set wifi.pwd ...</b>. The node saves them without connecting and retries after deployment.</div>
<div class="note"><b>Preparing for remote deployment?</b> This wizard tests the WiFi before reboot<span data-cap="32768"> unless you also change the shared ESP-NOW channel</span>. If the deployment network is not available here, use USB serial at <b>config.meshcore.io</b> and run <b>set wifi.ssid ...</b> and <b>set wifi.pwd ...</b>. The node saves them without connecting and retries after deployment.</div>
<div class="f"><label>Network name (SSID)</label>
<div class="row" style="gap:8px">
<input type="text" data-k="wifi.ssid" id="wz-ssid" autocapitalize="off" autocorrect="off" style="flex:1">
<button class="btn sec" type="button" onmousedown="event.preventDefault()" onclick="openScan('wz-ssid')">Scan</button>
</div></div>
<div class="f"><label>Password</label>
<input type="password" data-k="wifi.pwd" autocomplete="off">
<div class="hint">Leave blank for an open network. 2.4&thinsp;GHz networks only.</div></div>
<input type="password" data-k="wifi.pwd" maxlength="64" autocomplete="off">
<div class="hint">Leave blank for an open network. <span data-wifi-pwd-hint>Passphrases may be up to 63 characters; a 64-character key must be hexadecimal.</span> 2.4&thinsp;GHz networks only.</div></div>
<div class="f" data-cap="32768"><label>ESP-NOW / WiFi channel</label>
<input type="number" data-k="espnow.channel" min="1" max="13" step="1" style="max-width:120px">
<div class="hint">Must match the 2.4&thinsp;GHz router and every ESP-NOW node. Scanning fills the router's channel. Use only a channel permitted locally. A change takes effect after reboot, so WiFi is joined after the reboot too.</div></div>
<div class="f"><label>Node name</label>
<input type="text" data-k="name" maxlength="31">
<div class="hint" id="wz-name-hint">Shown on the mesh and in MQTT status messages.</div></div>
@@ -495,7 +498,11 @@ body.tab-cli{padding-bottom:0}
<input type="text" data-k="wifi.ssid" id="app-ssid" autocapitalize="off" autocorrect="off" style="flex:1">
<button class="btn sec" type="button" onmousedown="event.preventDefault()" onclick="openScan('app-ssid')">Scan</button>
</div></div>
<div class="f"><label>Password</label><input type="password" data-k="wifi.pwd" autocomplete="off"></div>
<div class="f"><label>Password</label><input type="password" data-k="wifi.pwd" maxlength="64" autocomplete="off">
<div class="hint">Leave blank for an open network. <span data-wifi-pwd-hint>Passphrases may be up to 63 characters; a 64-character key must be hexadecimal.</span></div></div>
<div class="f" data-cap="32768"><label>ESP-NOW / WiFi channel</label>
<input type="number" data-k="espnow.channel" min="1" max="13" step="1" style="max-width:120px">
<div class="hint">Channel 1-13 where locally permitted. The router and every ESP-NOW node must match. Selecting a scanned network fills its channel; changes require a reboot.</div></div>
<div class="f" data-cap="1024"><label>Power save</label>
<select data-k="wifi.powersave"><option value="min">Minimum</option><option value="none">None (lowest latency)</option><option value="max">Maximum (battery)</option></select></div>
</div>
@@ -598,7 +605,7 @@ body.tab-cli{padding-bottom:0}
<script>
"use strict";
var SENTINEL="********";
var st={mode:"",authed:false,hasMqtt:false,hasCli:false,passwordSupported:false,needsPassword:false,caps:0,cfg:null,orig:{},dirty:{},presets:[],nslots:0,statsTimer:0,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
var st={mode:"",authed:false,hasMqtt:false,hasCli:false,wifiPsk64:false,passwordSupported:false,needsPassword:false,caps:0,cfg:null,orig:{},dirty:{},presets:[],nslots:0,statsTimer:0,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
function $(s){return document.querySelector(s)}
function $$(s){return Array.prototype.slice.call(document.querySelectorAll(s))}
@@ -662,6 +669,12 @@ function applyFeatures(){
var floodCard=$("#radio-flood-card");
if(floodCard)floodCard.classList.toggle("hide",!(st.caps&(128|256)));
$$(".password-card").forEach(function(el){el.classList.toggle("hide",!st.passwordSupported)});
$$('[data-k="wifi.pwd"]').forEach(function(el){el.maxLength=st.wifiPsk64?64:63});
$$('[data-wifi-pwd-hint]').forEach(function(el){
el.textContent=st.wifiPsk64
? "Passphrases may be up to 63 characters; a 64-character key must be hexadecimal."
: "Passphrases may be up to 63 characters.";
});
if(!st.hasCli){
var cliTab=$('#tabs button[data-t="cli"]');if(cliTab)cliTab.remove();
$("#t-cli").classList.add("hide");
@@ -690,6 +703,7 @@ function boot(){
// (active_slots: 2 without PSRAM, 5 with). Fall back to the runtime array
// size for older firmware that doesn't report it.
st.mode=s.mode;st.authed=s.auth;st.hasMqtt=!!s.mqtt;st.hasCli=!!s.cli;
st.wifiPsk64=!!s.wifi_psk64;
st.passwordSupported=!!s.password_supported;st.needsPassword=!!s.needs_password;
st.needsSetup=!!s.needs_setup;st.caps=s.capabilities||0;
st.nslots=st.hasMqtt?(s.active_slots||s.runtime_slots||0):0;
@@ -754,6 +768,7 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
case"flood.advert.interval":return String(r.flood_advert_interval);
case"password":return"";
case"wifi.ssid":return w.ssid;case"wifi.pwd":return w.pwd;case"wifi.powersave":return w.powersave;
case"espnow.channel":return String(w.espnow_channel||1);
case"mqtt.origin":return q.origin;case"mqtt.iata":return q.iata;
case"mqtt.status":return q.status?"on":"off";case"mqtt.packets":return q.packets?"on":"off";
case"mqtt.raw":return q.raw?"on":"off";case"mqtt.tx":return q.tx;case"mqtt.rx":return q.rx?"on":"off";
@@ -1049,7 +1064,7 @@ function saveChanges(extra,cb){
// Applying new station credentials necessarily drops this page. Reboot
// after a successful batch so the bridge joins with the complete SSID and
// password together instead of reconnecting between the two writes.
if((extra&&extra.reboot)||("wifi.ssid" in setmap)||("wifi.pwd" in setmap))body.reboot=true;
if((extra&&extra.reboot)||("wifi.ssid" in setmap)||("wifi.pwd" in setmap)||("espnow.channel" in setmap))body.reboot=true;
if(Object.keys(setmap).length===0&&!body.reboot){toast("Nothing to save");return}
var reqid=mkReqId();body.reqid=reqid;
$("#save-btn").disabled=true;
@@ -1085,7 +1100,7 @@ function saveChanges(extra,cb){
});
}
function appSave(){
var wifiChanged=("wifi.ssid" in st.dirty)||("wifi.pwd" in st.dirty);
var wifiChanged=("wifi.ssid" in st.dirty)||("wifi.pwd" in st.dirty)||("espnow.channel" in st.dirty);
var pwdErr=adminPwdError(wifiChanged);
if(pwdErr){
$('#tabs button[data-t="radio"]').click();
@@ -1290,8 +1305,8 @@ function pollScan(url){
nets.sort(function(a,b){return b.rssi-a.rssi});
var seen={};nets=nets.filter(function(n){if(seen[n.ssid])return false;seen[n.ssid]=1;return true});
$("#scan-list").innerHTML=nets.length?nets.map(function(n){
return'<button type="button" class="net" data-ssid="'+esc(n.ssid)+'">'+bars(n.rssi)
+'<span class="ss">'+esc(n.ssid)+"</span>"+(n.enc?"&#128274;":"")+"<small>"+n.rssi+"</small></button>";
return'<button type="button" class="net" data-ssid="'+esc(n.ssid)+'" data-channel="'+esc(n.channel||"")+'">'+bars(n.rssi)
+'<span class="ss">'+esc(n.ssid)+"</span>"+(n.enc?"&#128274;":"")+"<small>ch "+esc(n.channel||"?")+" &middot; "+n.rssi+"</small></button>";
}).join(""):'<p style="color:var(--mut);padding:12px">No networks found.</p>';
}).catch(function(){$("#scan-list").innerHTML='<p style="color:var(--mut);padding:12px">Scan failed.</p>'});
}
@@ -1300,6 +1315,14 @@ $("#scan-list").addEventListener("click",function(ev){
var el=document.getElementById(st.scanTarget);
el.value=b.dataset.ssid;
el.dispatchEvent(new Event("input",{bubbles:true}));
var channel=Number(b.dataset.channel);
if((st.caps&32768)&&channel>=1&&channel<=13&&Math.floor(channel)===channel){
// Wizard and app mode each render this setting. Keep both copies in sync
// so a scan always updates the channel field beside the SSID that opened it.
$$('[data-k="espnow.channel"]').forEach(function(ch){
ch.value=String(channel);ch.dispatchEvent(new Event("input",{bubbles:true}))
});
}
closeScan();
});
@@ -1409,6 +1432,7 @@ function buildReview(){
function row(l,v){rows.push('<div class="kv"><i>'+l+"</i><b>"+esc(v)+"</b></div>")}
row("WiFi network",d["wifi.ssid"]||st.orig["wifi.ssid"]||"(not set)");
row("WiFi password",d["wifi.pwd"]?"******":(st.orig["wifi.pwd"]?"(unchanged)":"(open network)"));
if(st.caps&32768)row("ESP-NOW / WiFi channel","Channel "+eff("espnow.channel")+(d["espnow.channel"]?" (reboot required)":""));
if(d.name||st.orig.name)row("Node name",d.name||st.orig.name);
var rc=d.radio||st.orig.radio||"",rp=null;
RADIO_PRESETS.forEach(function(p){if(comboEq(presetCombo(p),rc))rp=p});
@@ -1427,7 +1451,7 @@ function buildReview(){
if(p&&p!=="none")row("Slot "+i,p);
}
}
var extra=Object.keys(d).filter(function(k){return!/^(wifi\.|password$|name$|radio$|tx$|mqtt\.origin|mqtt\.iata|mqtt\.owner|mqtt\.email|mqtt[1-9]\.)/.test(k)}).length;
var extra=Object.keys(d).filter(function(k){return!/^(wifi\.|espnow\.channel$|password$|name$|radio$|tx$|mqtt\.origin|mqtt\.iata|mqtt\.owner|mqtt\.email|mqtt[1-9]\.)/.test(k)}).length;
if(extra)row("Other changes",extra+" setting(s)");
$("#wz-review").innerHTML=rows.join("");
}
@@ -1439,7 +1463,8 @@ function wizardSave(){
if(st.hasMqtt&&!/^[A-Za-z]{3}$/.test(st.dirty["mqtt.iata"]||st.orig["mqtt.iata"]||"")){
$("#wz-err").textContent="Please enter a 3-letter IATA region code (MQTT step).";return}
$("#wz-err").textContent="";
var b=$("#wz-save"),blabel=b.textContent;b.disabled=true;b.textContent="Testing WiFi...";
var b=$("#wz-save"),blabel=b.textContent;b.disabled=true;
b.textContent=("espnow.channel" in st.dirty)?"Saving channel...":"Testing WiFi...";
saveChanges({reboot:true},function(r,acked){
b.disabled=false;b.textContent=blabel;
if(!r){
@@ -1464,8 +1489,11 @@ function wizardSave(){
+"'. This page and address stay visible while it reboots. Reconnect your phone or laptop to that WiFi, then open the address.",
false,"WiFi connected [OK] - rebooting soon",r.wifi_ip,rebootSeconds(r));
}else{
showReboot("The settings were saved, but the node did not report a new network address. Reconnect to '"
+eff("wifi.ssid")+"' and use your router or 'get webui' to find it.",
var changedChannel="espnow.channel" in st.dirty;
showReboot((changedChannel
? "The shared ESP-NOW channel and WiFi settings were saved together. The node joins '"+eff("wifi.ssid")+"' after reboot on channel "+eff("espnow.channel")+". "
: "The settings were saved, but the node did not report a new network address. Reconnect to '"+eff("wifi.ssid")+"'. ")
+"Use your router or 'get webui' to find its address.",
false,"Settings saved [OK] - rebooting",null,rebootSeconds(r));
}
st.dirty={};
@@ -1640,6 +1668,7 @@ var CLI_KEYS=[
["bridge.baud","Bridge serial baud rate",0],
["bridge.delay","Bridge send delay",0],
["bridge.channel","Bridge channel",0],
["bridge.format","ESP-NOW bridge wire format",0,"wrapped|raw"],
["bridge.secret","Bridge shared secret",0]
];
// Per-slot keys, expanded across the slots this board actually runs.
@@ -1657,7 +1686,7 @@ var CLI_SLOT=[
];
var CLI_TYPES="req,response,txt_msg,ack,advert,grp_txt,grp_data,anon_req,path,trace,multipart,control,raw_custom".split(",");
var cli={built:0,tbl:[],hist:[],hix:-1,draft:"",sug:[],sel:-1,busy:false,shown:false,pwd:false};
var cli={built:-1,tbl:[],hist:[],hix:-1,draft:"",sug:[],sel:-1,busy:false,shown:false,pwd:false};
// Rebuilt when the slot count changes: `active_slots` decides how many
// mqttN.* keys actually exist on this board.
@@ -1669,6 +1698,10 @@ function cliTable(){
if(k[2]!==2)t.push(["get "+k[0],k[1]]);
if(k[2]!==1)t.push(["set "+k[0]+" ",k[1]+vals]);
});
if(st.caps&32768){
t.push(["get espnow.channel","Shared ESP-NOW / WiFi channel"]);
t.push(["set espnow.channel ","Shared channel {1-13; reboot required}"]);
}
for(var n=1;n<=st.nslots;n++){
CLI_SLOT.forEach(function(f){
t.push(["get mqtt"+n+"."+f[0],"Slot "+n+" "+f[1]]);
@@ -1687,6 +1720,7 @@ function cliEnum(key){
if(m[1]==="filter")return ["all","none"].concat(CLI_TYPES);
return null;
}
if(key==="espnow.channel")return(st.caps&32768)?["1","2","3","4","5","6","7","8","9","10","11","12","13"]:null;
for(var i=0;i<CLI_KEYS.length;i++){
if(CLI_KEYS[i][0]===key)return CLI_KEYS[i][3]?CLI_KEYS[i][3].split("|"):null;
}
@@ -1940,6 +1974,7 @@ var CLI_RISK=[
[/^ota update/,"downloads and flashes new firmware, then reboots"],
[/^stop webconfig$/,"stops this portal"],
[/^set wifi\.(ssid|pwd)\s/,"changes WiFi -- this page will drop"],
[/^set espnow\.channel\s/,"changes the shared ESP-NOW/WiFi channel after reboot -- every ESP-NOW peer and the router must match"],
[/^set radio\s/,"changes radio parameters -- a wrong value takes this node off the air"],
[/^set freq\s/,"changes the frequency -- a wrong value takes this node off the air"],
[/^password\s/,"changes the admin password"],