Add independent Companion Bluetooth stealth flag

Keep stealth separate from custom, saved-random and rotating MAC policies without migrating the development MAC mode. Persist authenticated peer state, guard advertising transitions and recovery, and expose the setting through the CLI and WebConfig.

Add regression and CI checks and record exact XIAO BLE pairing, reconnect, reboot and address-rotation hardware results.
This commit is contained in:
mikecarper committed 2026-09-07 23:46:55 -07:00
1 parent 8d72c3e3da
commit d28535f503
29 files changed
+3984 -2020

No files matched your search

+7
View File
@@ -41,6 +41,13 @@ jobs:
- name: Verify nRF52 UF2-reset CLI coverage
run: python3 -B test/test_nrf52_uf2reset_cli.py
- name: Verify Companion Bluetooth settings and WebConfig
run: |
python3 -B test/test_bluetooth_mac_contract.py
python3 -B test/test_companion_settings_persistence_contract.py
python3 -B test/test_webconfig_ui.py
python3 -B test/test_webconfig_ui_runtime.py
- name: Upload Test Results
# Upload test results even if the test step failed.
if: always()
+56 -1
View File
@@ -1185,7 +1185,7 @@ the frame and reply format.
**Usage:**
- `get bluetooth.mac`
- `set bluetooth.mac <address|random|random-every-boot|default>`
- `set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>`
`get ble.mac` and `set ble.mac ...` are accepted aliases. The setting is
available on every Companion build that includes Bluetooth.
@@ -1195,6 +1195,7 @@ available on every Companion build that includes Bluetooth.
| `C2:11:22:33:44:55` | Save and use that custom address. |
| `random` | Generate one random address now, save it, and reuse it on later boots. |
| `random-every-boot` | Generate a new address once at each boot. `random everyboot` is also accepted. |
| `random-after-connect` | Keep one random address through unused boots. After an authenticated connection, arm a new address for the next boot. |
| `default` or `clear` | Remove the override and use the chipset's factory Bluetooth address. |
A custom value must be a valid BLE random-static address in
@@ -1207,9 +1208,63 @@ the device. Forget the old device entry and pair again. `random-every-boot`
also clears the Companion's saved peer bonds during each startup, so pairing
and PIN entry are expected again after every reboot.
`random-after-connect` keeps the same address across any number of reboots when
nobody successfully authenticated. As soon as an authenticated BLE connection
succeeds, the next boot rotates the address and clears stale bonds. The marker
is saved while connected, so a normal reboot, power switch, or battery pull
after that connection all produce the same next-boot rotation.
The address setting does not enable or disable stealth. Use the separate
`bluetooth.stealth` flag below. `default` changes only the address policy.
#### View or change Bluetooth stealth (BLE Companion)
```text
get bluetooth.stealth
set bluetooth.stealth on
set bluetooth.stealth off
```
`get ble.stealth` and `set ble.stealth on|off` are short aliases. This flag
defaults to `off` and is independent of the Bluetooth address policy. Enabling
or disabling it keeps the configured custom/random/factory address policy
unchanged and requires a reboot. Repeating `on` keeps an existing stealth
pairing; repeating `off` does not alter address settings.
For a custom address with stealth:
```text
set bluetooth.mac C2:11:22:33:44:55
set bluetooth.stealth on
reboot
```
Use `set bluetooth.mac random` instead of the first line for one saved random
address. Both combinations keep their address and bond across later boots.
Rotating policies still rotate: `random-every-boot` reopens pairing at every
boot, while `random-after-connect` reopens it on the first boot after an
authenticated connection. Stealth stays enabled; each new address begins
with a fresh pairing window so it cannot get hidden behind an unusable bond.
With stealth on, the node is normally discoverable until the first authenticated pairing.
After that, nRF52 uses directed advertising to the saved peer. ESP32 uses a
minimal unnamed advertisement and a controller allowlist because its common
Arduino BLE API does not expose the directed-advertising peer field. Both
reject connection and scan requests from other peers. BLE cannot be completely
radio silent and still accept a connection, so a passive radio sniffer may
still observe packets. To deliberately clear the saved stealth peer, send
`set bluetooth.stealth off`, then `set bluetooth.stealth on`, then reboot over
USB or an existing authenticated connection. To leave stealth, set it `off`
and reboot. If the saved node-side bond is lost, recovery reopens pairing.
The former `set bluetooth.mac stealth` command is no longer accepted.
This changes only the running MeshCore Companion application. On nRF52, the
OTAFIX bootloader still uses its own address, which is visible only while the
board is in BLE DFU/recovery mode. USB and LoRa updates are unaffected.
Bluetooth update tools must follow that separate bootloader address after the
handoff; they must not assume that a custom application address remains the
DFU address. See the [hardware observations](hardware_validation_bluetooth_stealth_2026-09-07.md)
for the tested XIAO handoff and host-cache caveats.
---
+18 -6
View File
@@ -289,7 +289,8 @@ The equivalent framed CLI commands are:
| FEM receive gain | `get radio.fem.rxgain`; `set radio.fem.rxgain on|off` |
| WiFi power save | `get wifi.powersave`; `set wifi.powersave none|min|max` |
| Bluetooth name | `get bluetooth.name`; `set bluetooth.name <name|default>` |
| Bluetooth address | `get bluetooth.mac`; `set bluetooth.mac <address|random|random-every-boot|default>` |
| Bluetooth address | `get bluetooth.mac`; `set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>` |
| Bluetooth stealth | `get bluetooth.stealth`; `set bluetooth.stealth on|off` |
The framed form works over the normal binary USB, BLE, or TCP transport and
does not need the USB terminal-start token. Unsupported settings return the
@@ -328,11 +329,22 @@ custom name is limited to 31 valid UTF-8 bytes and takes effect after reboot.
Bluetooth Companion builds also accept `bluetooth.mac` (`ble.mac` is an
alias). A literal address must be a BLE random-static address; `random`
generates and saves one, `random-every-boot` rotates it at each startup, and
`default` or `clear` restores the factory address. The change takes effect
after reboot. The client must forget and re-pair the old identity. Per-boot
random mode clears local peer bonds each startup and therefore requires a new
pairing after every reboot. This is an application setting; an nRF52 OTAFIX
bootloader continues to advertise its separate DFU identity.
`random-after-connect` retains it through unused boots but rotates it on the
first boot after an authenticated connection. `default` or `clear` restores
the factory address. The change takes effect after reboot.
`bluetooth.stealth on|off` (`ble.stealth` is an alias) is a separate flag,
defaulting to off. It preserves the address policy and can be combined with
custom, saved random, rotating random, or factory addresses. With stealth on,
the node advertises normally until the first authenticated pairing, then
permits only that bonded peer to reconnect. Address changes clear the saved
peer and reopen pairing without disabling stealth. Rotating policies retain
their normal rotation triggers. Repeating `on` does not clear an existing bond.
Both flag changes require reboot. The old MAC value `stealth` is not accepted.
BLE still transmits directed or allowlisted advertisements; stealth is not
radio silence. These are application settings; an nRF52 OTAFIX bootloader
continues to advertise its separate DFU identity.
### Bluetooth LoRa mOTA source
+18 -7
View File
@@ -384,13 +384,24 @@ prefix, accept up to 31 valid UTF-8 bytes, and take effect after reboot. This
does not change the node's mesh advertisement name.
Every Bluetooth Companion also supports `get bluetooth.mac` and
`set bluetooth.mac <address|random|random-every-boot|default>`. A literal
address is a BLE random-static identity, `random` creates one persistent
identity, `random-every-boot` rotates it on startup, and `default` (or `clear`)
restores the chipset address. Reboot and re-pair after changing modes. The
per-boot mode intentionally clears local bonds and needs a new pairing after
every reboot. On nRF52 this affects the MeshCore application only, not the
OTAFIX bootloader's separate BLE DFU identity.
`set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>`.
A literal address is a BLE random-static identity, `random` creates one
persistent identity, `random-every-boot` rotates it on startup, and
`random-after-connect` rotates it on the first boot after an authenticated
connection while leaving unused boots unchanged. `default` (or `clear`)
restores the chipset address. None of these values changes the stealth flag.
Reboot and re-pair after changing modes or after a rotation. Address rotation
clears stale local bonds. On nRF52 this affects the MeshCore application only,
not the OTAFIX bootloader's separate BLE DFU identity.
`get bluetooth.stealth` and `set bluetooth.stealth on|off` control an independent
flag, defaulting to off. Combine it with a custom address or `random` to retain
the address and bond across boots. With stealth on, normal discovery ends
after the first authenticated pairing and only the saved peer may reconnect.
Rotating MAC policies still rotate and reopen first pairing when they do;
stealth remains enabled. Repeating `on` keeps an existing bond. Toggle `off`,
then `on`, then reboot to reset first pairing. All flag changes need reboot.
The node still sends directed or allowlisted BLE packets; it is not radio silent.
ESP32 ports 5000, 5001, 5002, and WebConfig have no independent login layer.
Expose them only on a trusted LAN or temporary setup network. See
@@ -0,0 +1,200 @@
# XIAO Bluetooth stealth hardware validation - 2026-09-07
Historical command syntax: the initial run used stealth as a MAC mode.
Current firmware uses the independent `bluetooth.stealth on|off` flag instead
and does not migrate that development mode. See [current commands](cli_commands.md)
and the independent-flag follow-up below. Each observation applies only to
the exact artifact listed in its section.
This is a hardware observation report, not an all-board or phone qualification.
The test host was the MercerWoodMesh Raspberry Pi Zero 2 W, accessed remotely
over Tailscale. Tests used its built-in Bluetooth adapter, not USB resets or
hub power switching. Local timestamps below use America/Los_Angeles.
## Exact target and firmware
- Board: Seeed XIAO nRF52840; application name `MeshCore-4610BBD2`.
- Factory/bootloader address: `E5:C3:A8:B0:60:66`.
- Fresh stealth test address: `DC:8D:5E:45:89:6F`.
- Bonded test central: Mercer Pi, public address `B8:27:EB:F9:6E:8E`.
- Environment: `Xiao_nrf52_companion_radio_ble`, supported build wrapper,
target radio preset, default profile, GCC 14.2.1.
- Firmware: `dev-stealth3-4b400ea5`, including the uncommitted Bluetooth
identity changes on top of `4b400ea5`.
- Application ZIP SHA-256:
`acdce0af2334349c6e0a5786db5525b00d19befef340411d2e99008ce4d316ae`.
- Linked RAM: 183,232 / 237,568 bytes; flash: 616,504 / 708,608 bytes.
The application was installed with Nordic Legacy BLE DFU. The tool verified
the exact ZIP hash, bootloader advertisement and live DIS model
`XIAO nRF52840` before transmitting 616,584 application bytes. Target image
validation and target-initiated activation disconnect passed. The transfer
reported about 2,223 bytes/second (about 277 seconds for payload transmission).
Authenticated MeshCore device queries subsequently returned protocol 14,
board `Seeed Xiao-nrf52`, and the expected unique test-version prefix.
## Observed results
| Check | Result and evidence |
| --- | --- |
| First-pair discovery | `set bluetooth.mac stealth` saved a new random-static address; reboot returned that exact address with the normal name and Nordic UART advertisement. |
| First authenticated pairing | The Pi received a passkey request and completed pairing. BlueZ reported both Paired and Bonded. MITM-protected Nordic UART notifications and real command/reply exchanges worked at MTU 247. |
| Pairing transition keeps the session alive | The initial connection returned `stealth; bonded-peer-only advertising`, remained usable for five more seconds, and answered another command without disconnecting or pairing again. |
| Advertising after disconnect | A public-address passive scan captured `ADV_DIRECT_IND` from the exact target, with advertising data length zero. The capture contained no undirected advertisement or scan response from that address. |
| Bonded reconnect | Subsequent direct connections by saved BlueZ device identity succeeded without a new passkey, including live version, core-statistics and Bluetooth-mode replies. |
| Warm reboot persistence | Two commanded reboots retained the address and saved bond. Directed advertising was captured after reboot, followed by authenticated reconnect. Device uptime decreased from 61 seconds before the second reboot to 35 seconds at the next query; core error flags were zero. |
| Slow advertising interval | A successful bonded reconnect occurred about 61 seconds after reboot, beyond the 30-second fast-advertising interval. |
| Reopen pairing | Setting `stealth` again from an existing bonded connection generated a new address, and the next boot allowed a fresh first pairing. |
| Restore default | `set bluetooth.mac default` plus reboot restored `E5:C3:A8:B0:60:66` and normal discovery. Fresh PIN pairing, version/core-statistics queries and repeated `factory address (default)` replies passed. The bench target was left in this discoverable mode. |
The newly completed pairing reaches Bluefruit's secured callback before its
bonded flag is necessarily ready. Recording the completed pairing in the
pair-complete callback as well, waiting for the deferred bond write, and
deferring identity-list reconfiguration until disconnect are covered by this
fresh-pair hardware run. Static source contracts also check those paths.
The binary reboot command can reset the target before BlueZ receives its ATT
write acknowledgement. One initial harness run reported `Unlikely Error`
after successful pre-reboot commands. Later checks require an observed
disconnect, a live post-reboot query and decreased device uptime; they do not
treat the write exception or tool exit code alone as a successful reboot.
## Discovery and DFU cautions found during testing
An ordinary BlueZ discovery scan used a temporary random local address and
did not show the directed target. A passive scan using the Pi's public
identity did show it. Reconnection must use the saved peer/device identity;
absence from an ordinary scan is not proof that the node stopped working.
Conversely, BlueZ can retain a cached name after pairing, so a displayed name
is not proof that the name is still being advertised. The packet-type and
zero-length-payload capture is the relevant evidence here.
The existing DFU handoff helper assumed the application and bootloader shared
one address. That assumption is false with a custom application address:
the authenticated handoff worked, but the helper timed out looking for DFU
at the application address. A fresh exact-address scan found the known
factory-address `XIAO_DFU` instead. An old factory-address host bond also
caused the first DFU service-discovery attempt to disconnect; clearing only
that old lab record allowed DFU. No bootloader code was changed for this test.
An earlier application-address host pairing record was also removed before
its saved stealth state was known. A direct, known-address connection from
the same Pi identity allowed fresh PIN pairing and protected commands again.
This does not prove that an arbitrary replacement phone can rediscover or
recover a stealth node after forgetting its bond.
## Remaining hardware gates
- True cold boot with all XIAO power sources, including battery, disconnected.
The XIAO reported a battery voltage and did not enumerate as USB on the Pi;
a Pi-only reboot or power cycle would not prove this gate.
- Unused-boot retention with `random-after-connect`, using a reboot trigger
that does not itself authenticate over BLE. The policy has native tests;
the remote BLE reboot command cannot prove this hardware gate.
- Android and iOS bonded reconnect, including a phone changing its resolvable
private address, and direct-connection behavior in the actual MeshCore app.
- Rejection of a connection from a second, unbonded physical central.
- ESP32 Bluedroid and NimBLE hardware. Those use allowlisted minimal
advertising rather than nRF52 directed advertisements.
- Node-side missing/corrupt bond and failed settings/bond-write fault injection.
## Repeatable regression procedure
1. Record the exact board, application version, ZIP hash, local/remote BLE
identities and original Bluetooth setting. Keep a working recovery path.
2. Set the desired custom or random MAC, enable `bluetooth.stealth on`, reboot,
and capture normal discovery before pairing.
3. Pair once. Require a stored bond and successful protected MeshCore commands,
then keep the original session open and issue another command.
4. Disconnect. Capture the actual advertising type and payload with an
appropriate receiving identity; do not use cached scan names as evidence.
5. Reconnect by the saved peer without another PIN and exchange commands.
6. For custom and saved-random addresses, repeat `bluetooth.stealth on`, reboot
twice, and verify unchanged identity/bond, decreased uptime and live
commands. Include a reconnect after the fast-advertising period ends.
7. Separately remove all target power and repeat the persistence checks.
8. Test `bluetooth.stealth off`, then `on`, as a pairing reset, and `off` as
the exit from stealth. Verify live discovery, pairing, commands and an
unchanged MAC policy. Changing the address to `default` is a separate step.
9. With each rotating MAC policy, verify that its rotation trigger still
works, stealth stays enabled, and the new address permits fresh pairing.
10. Repeat relevant gates for a phone with address privacy and each BLE backend.
Raw VM-side logs, the exact artifact and bounded test helper are in
`/home/mesh/mercer-stealth-validation.jUDW0v/`. Raw Bluetooth captures are
local diagnostics and should not be published without privacy review.
## Mercer crash capture
During this run the Pi used its existing `dwc_otg.speed=1` full-speed USB
configuration. Kernel and health logs were streamed to this VM. No new kernel
errors or Pi lockup were observed during these tests; that does not establish
the cause of the earlier lockup.
The live test streams and all temporary scans were stopped at approximately
22:56 PDT. Journald remained active, the restore service remained enabled,
and the test XIAO was disconnected in default mode. Disk journals occupied
about 4.9 MiB at the final check.
At 22:35 PDT, temporary persistent journaling was enabled with a 16 MiB
configured disk-use limit, 2 MiB journal files, one-day retention and a
30-second normal sync interval. Recent buffered messages can still be lost
in a sudden failure. The original `Storage=volatile` configuration is intact.
`mercer-crash-capture-restore.service` is enabled for the next Pi boot. It
checks the armed boot ID, removes only the unchanged managed override,
switches journald back to RAM and leaves the old disk journals for collection.
Its same-boot no-op guard and unit syntax passed. The next-boot restoration
has not yet been exercised; no Pi crash or reboot was deliberately induced.
## Independent-flag follow-up - dev-stflag
The same XIAO and Pi were used later on 2026-09-07 to test the separate
`bluetooth.stealth on|off` flag, with no development-mode migration. This run
used `Xiao_nrf52_companion_radio_ble`, target radio preset, default profile,
and firmware `dev-stflag-4b400ea5` with the uncommitted flag changes.
- Application ZIP SHA-256:
`48fc0ff2c716281771f8b525c62a0015c7605b07e7c513ab854c8b3ff3113f42`.
- Linked RAM: 183,232 / 237,568 bytes; flash: 617,192 / 708,608 bytes.
- Nordic Legacy BLE DFU verified the exact target, hash and model, transferred
617,272 application bytes at a reported 3,522 bytes/second, passed target
validation, and observed the target activation disconnect.
- Protected live device queries returned `dev-stflag-4b400ea5`, protocol 14,
and `Seeed Xiao-nrf52`; every queried core error-flags value was zero.
| Check | Observed result |
| --- | --- |
| Default and rejected input | Initial mode was factory address with stealth off. `set bluetooth.mac stealth` and `set bluetooth.stealth maybe` returned errors, leaving both settings unchanged. |
| Custom plus stealth | `D2:27:5A:40:06:99` advertised normally before first pairing. Fresh PIN pairing, stored bond, protected commands, and a five-second held session passed with stealth on. |
| Repeated on and warm reboot | Repeating `set bluetooth.stealth on` retained the paired state. After reboot, the same custom address accepted protected commands without another PIN. Uptime decreased from 84 to 67 seconds. |
| Actual advertising after reboot | A public-address passive capture showed `ADV_DIRECT_IND` for the custom address, data length zero, and no undirected advertisement or scan response from that address. The scan was explicitly disabled afterward. |
| Off preserves custom address | `set ble.stealth off` and reboot restored normal discovery at the same custom address. A live connection returned stealth off and the unchanged custom policy. |
| Saved random plus stealth | `F2:2E:E5:65:18:5E` passed first pairing and held commands. After reboot, it retained its address, saved-random policy and stealth bond; protected reconnect needed no PIN. Uptime decreased from 55 to 30 seconds. |
| Random-after-connect plus stealth | Selecting this policy kept stealth on and reopened pairing at `C7:FD:29:44:4F:3E`. Pairing produced both bonded-peer-only status and an armed next-boot rotation. Reboot changed the address to `F5:F3:89:B7:B5:26`, where normal discovery, fresh pairing and protected commands passed with stealth still on. |
| Random-every-boot plus stealth | The first boot used `F9:B2:03:DD:CC:43`, completed fresh pairing, and answered commands with stealth on. The next commanded reboot advertised `F8:C7:C0:96:1E:00`; fresh PIN pairing and protected commands passed again with the rotating policy and stealth on. |
| Restore the bench target | Setting the MAC to `default` kept stealth on until the separate `set bluetooth.stealth off`. After reboot, factory-address discovery, fresh PIN pairing and protected commands confirmed default MAC and stealth off. The obsolete factory-address host bond was replaced during this final pairing. |
These checks used actual encrypted BLE connections at MTU 247, not scan
sightings alone. Two first-pair harness attempts stopped before connecting
because their unpaired BlueZ discovery object no longer existed. A fresh
bounded scan immediately followed by pairing was used for retries; this was
not a firmware GATT command failure. Reboot-write/disconnect races were
accepted only with an observed disconnect and subsequent live device checks.
Local automated verification passed 39 native cases, 32 source/persistence/UI
contracts, and 11 real-browser cases. Supported-wrapper builds passed for
XIAO nRF52, XIAO C3, M5Stack Unit C6L and XIAO S3 WIO Full Companion, covering
nRF52, ESP32 NimBLE and ESP32 Bluedroid integration. The contracts and browser
suite are included in the unit-test workflow; a browser must be available for
the optional browser suite to execute instead of skip.
Exact artifacts, build logs, command logs and the private packet capture are
in `/home/mesh/bluetooth-stealth-flag.ApLB8o/` on the VM. The remaining hardware
gates above still apply; these results do not qualify every board or phone.
At 23:42 PDT the XIAO was disconnected in normal discoverable mode. The Pi
retained the same boot ID throughout, remained responsive, and had no new
kernel messages in the streamed log. Journald was active, the next-boot
volatile-log restoration service was still enabled, and disk journals used
about 5.9 MiB. No USB reset, Pi reboot, or target battery removal was used.
+11
View File
@@ -10,6 +10,17 @@ when its log identifies the physical device, firmware artifact, artifact hash,
command result, and cold/warm boot outcome. Do not infer success from a tool's
exit code when the tool has a documented false-success mode.
## Bluetooth stealth qualification - 2026-09-07
The [XIAO stealth report](hardware_validation_bluetooth_stealth_2026-09-07.md)
records exact artifacts for the initial implementation and independent-flag
follow-up. The flag run covers custom/saved-random pairing and bonded reboot
reconnect, idempotent `on`, `off` preserving the MAC, directed advertising,
and both rotating policies with stealth still enabled. It also gives the
repeatable hardware regression procedure. True battery-disconnected cold boot,
phone address-privacy behavior, a second unbonded central, and ESP32 hardware
remain separate uncompleted gates; do not infer them from the XIAO results.
## Current marathon ledger (through 2026-09-04)
| Hardware | Stable identity | Current state | Next blocking check |
+18 -5
View File
@@ -87,15 +87,28 @@ takes effect after reboot. `get ble.name` and `set ble.name` are short aliases.
```
get bluetooth.mac
set bluetooth.mac {address|random|random-every-boot|default}
set bluetooth.mac {address|random|random-every-boot|random-after-connect|default}
get bluetooth.stealth
set bluetooth.stealth {on|off}
```
Shows or changes the Bluetooth identity on Companion builds with BLE. A
literal address must be BLE random-static, such as `C2:11:22:33:44:55`.
`random` generates and saves one address; `random-every-boot` generates a new
one on each startup (`random everyboot` is also accepted); `default` or `clear`
restores the factory address. Reboot,
forget the old phone entry, and pair again. Per-boot random mode requires
pairing after every reboot. `get ble.mac` and `set ble.mac` are short aliases.
one on each startup (`random everyboot` is also accepted).
`random-after-connect` keeps its address across unused power cycles, then
rotates on the next boot after an authenticated connection. `default` or
`clear` restores the factory address without changing the stealth flag.
Reboot, forget the old phone entry, and pair again after an address change.
`get ble.mac` and `set ble.mac` are short aliases.
The independent `bluetooth.stealth` flag defaults off and preserves the chosen
address policy. With it on, the node is discoverable until its first
authenticated pairing, then accepts only that bonded peer. Custom and saved
random addresses keep the bond across boots; rotating modes reopen pairing
when the address rotates, leaving stealth enabled. It still transmits the
directed or allowlisted packets BLE requires for reconnection. Repeating `on`
keeps the bond. Send `off`, then `on`, then reboot to reopen pairing manually.
`ble.stealth` is the short alias. Flag changes require reboot.
```
set lat {latitude}
+29
View File
@@ -605,6 +605,17 @@ bool DataStore::loadPrefsInt(const char *filename,
168 + sizeof(loaded_prefs.bluetooth_name)
+ sizeof(loaded_prefs.bluetooth_mac_mode)
+ sizeof(loaded_prefs.bluetooth_mac),
168 + sizeof(loaded_prefs.bluetooth_name)
+ sizeof(loaded_prefs.bluetooth_mac_mode)
+ sizeof(loaded_prefs.bluetooth_mac)
+ sizeof(loaded_prefs.bluetooth_stealth_peer_type)
+ sizeof(loaded_prefs.bluetooth_stealth_peer),
168 + sizeof(loaded_prefs.bluetooth_name)
+ sizeof(loaded_prefs.bluetooth_mac_mode)
+ sizeof(loaded_prefs.bluetooth_mac)
+ sizeof(loaded_prefs.bluetooth_stealth_peer_type)
+ sizeof(loaded_prefs.bluetooth_stealth_peer)
+ sizeof(loaded_prefs.bluetooth_stealth_mode),
};
const uint32_t prefs_size = file.size();
bool known_size = false;
@@ -713,6 +724,12 @@ bool DataStore::loadPrefsInt(const char *filename,
sizeof(loaded_prefs.bluetooth_mac_mode)); // 200
readOptionalField(loaded_prefs.bluetooth_mac,
sizeof(loaded_prefs.bluetooth_mac)); // 201
readOptionalField(&loaded_prefs.bluetooth_stealth_peer_type,
sizeof(loaded_prefs.bluetooth_stealth_peer_type)); // 207
readOptionalField(loaded_prefs.bluetooth_stealth_peer,
sizeof(loaded_prefs.bluetooth_stealth_peer)); // 208
readOptionalField(&loaded_prefs.bluetooth_stealth_mode,
sizeof(loaded_prefs.bluetooth_stealth_mode)); // 214
// Any bytes left over form only part of a historically appended field.
// Preserve the file and defaults rather than treating that tail as EOF.
@@ -817,6 +834,18 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
== sizeof(_prefs.bluetooth_mac_mode);
success = success && file.write((uint8_t *)_prefs.bluetooth_mac,
sizeof(_prefs.bluetooth_mac)) == sizeof(_prefs.bluetooth_mac);
success = success && file.write(
(uint8_t *)&_prefs.bluetooth_stealth_peer_type,
sizeof(_prefs.bluetooth_stealth_peer_type))
== sizeof(_prefs.bluetooth_stealth_peer_type);
success = success && file.write(
(uint8_t *)_prefs.bluetooth_stealth_peer,
sizeof(_prefs.bluetooth_stealth_peer))
== sizeof(_prefs.bluetooth_stealth_peer);
success = success && file.write(
(uint8_t *)&_prefs.bluetooth_stealth_mode,
sizeof(_prefs.bluetooth_stealth_mode))
== sizeof(_prefs.bluetooth_stealth_mode);
#if defined(NRF52_PLATFORM)
success = file.commit(success);
+292 -6
View File
@@ -1728,6 +1728,60 @@ void MyMesh::begin(bool has_display, bool radio_available) {
_prefs.display_rotation_degrees = 0;
}
bool bluetooth_mac_repaired = false;
if (!mesh::companion::isValidBluetoothStealthMode(
_prefs.bluetooth_stealth_mode)) {
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_OFF;
bluetooth_mac_repaired = true;
}
if (!mesh::companion::isValidBluetoothMacMode(
_prefs.bluetooth_mac_mode)) {
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_DEFAULT;
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
clearCompanionBluetoothStealthPeer(_prefs);
bluetooth_mac_repaired = true;
} else if (mesh::companion::bluetoothMacModeUsesSavedAddress(
_prefs.bluetooth_mac_mode)
&& !mesh::companion::isValidBluetoothMac(
_prefs.bluetooth_mac)
&& !mesh::companion::bluetoothMacModeIsRandomAfterConnect(
_prefs.bluetooth_mac_mode)) {
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_DEFAULT;
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
clearCompanionBluetoothStealthPeer(_prefs);
bluetooth_mac_repaired = true;
}
mesh::companion::BluetoothPeerIdentity saved_stealth_peer;
saved_stealth_peer.type = _prefs.bluetooth_stealth_peer_type;
memcpy(saved_stealth_peer.address, _prefs.bluetooth_stealth_peer,
sizeof(saved_stealth_peer.address));
bool stealth_peer_payload_empty = true;
for (size_t i = 0; i < sizeof(_prefs.bluetooth_stealth_peer); i++) {
stealth_peer_payload_empty = stealth_peer_payload_empty
&& _prefs.bluetooth_stealth_peer[i] == 0;
}
if (_prefs.bluetooth_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRED
&& !mesh::companion::isValidBluetoothPeerIdentity(
saved_stealth_peer)) {
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRING;
bluetooth_mac_repaired = true;
}
if (_prefs.bluetooth_stealth_mode
!= mesh::companion::BLUETOOTH_STEALTH_PAIRED
&& (_prefs.bluetooth_stealth_peer_type
!= mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE
|| !stealth_peer_payload_empty)) {
_prefs.bluetooth_stealth_peer_type =
mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE;
memset(_prefs.bluetooth_stealth_peer, 0,
sizeof(_prefs.bluetooth_stealth_peer));
bluetooth_mac_repaired = true;
}
// sanitise bad pref values
_prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f);
_prefs.airtime_factor = constrain(_prefs.airtime_factor, 0, 9.0f);
@@ -1769,7 +1823,7 @@ void MyMesh::begin(bool has_display, bool radio_available) {
&_prefs.rx_ps_sleep_us);
if (prefs_ready
&& (power_saving_default_migrated || bluetooth_name_repaired
|| display_rotation_repaired)) {
|| display_rotation_repaired || bluetooth_mac_repaired)) {
_store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon);
}
#if MESH_USB_LOGGING_AVAILABLE
@@ -2427,6 +2481,30 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
}
#if defined(BLE_PIN_CODE)
if (strcmp(command, "get bluetooth.stealth") == 0
|| strcmp(command, "get ble.stealth") == 0) {
formatBluetoothStealthStatus(reply, reply_size);
return true;
}
const char* bluetooth_stealth_value = NULL;
if (strncmp(command, "set bluetooth.stealth", 21) == 0
&& (command[21] == 0 || command[21] == ' '
|| command[21] == '\t')) {
bluetooth_stealth_value = command + 21;
} else if (strncmp(command, "set ble.stealth", 15) == 0
&& (command[15] == 0 || command[15] == ' '
|| command[15] == '\t')) {
bluetooth_stealth_value = command + 15;
}
if (bluetooth_stealth_value != NULL) {
while (*bluetooth_stealth_value == ' ' || *bluetooth_stealth_value == '\t') {
bluetooth_stealth_value++;
}
applyAndSaveBluetoothStealth(bluetooth_stealth_value, reply, reply_size);
return true;
}
if (strcmp(command, "get bluetooth.mac") == 0
|| strcmp(command, "get ble.mac") == 0) {
formatBluetoothMacStatus(reply, reply_size);
@@ -2449,7 +2527,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
}
if (bluetooth_mac_value[0] == 0) {
snprintf(reply, reply_size,
"Error: use set bluetooth.mac <address|random|random-every-boot|default>");
"Error: use set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>");
} else {
applyAndSaveBluetoothMac(bluetooth_mac_value, reply, reply_size);
}
@@ -2939,11 +3017,18 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
| WebConfigServer::CAP_RX_DELAY | WebConfigServer::CAP_POWER_SAVING;
#ifdef BLE_PIN_CODE
s.capabilities |= WebConfigServer::CAP_BLUETOOTH_NAME
| WebConfigServer::CAP_BLUETOOTH_MAC;
| WebConfigServer::CAP_BLUETOOTH_MAC
| WebConfigServer::CAP_BLUETOOTH_STEALTH;
s.bluetooth_stealth = mesh::companion::bluetoothStealthEnabled(
_prefs.bluetooth_stealth_mode);
if (_prefs.bluetooth_mac_mode
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
wcCopyValue(s.bluetooth_mac, sizeof(s.bluetooth_mac),
"random-every-boot");
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(
_prefs.bluetooth_mac_mode)) {
wcCopyValue(s.bluetooth_mac, sizeof(s.bluetooth_mac),
"random-after-connect");
} else if (mesh::companion::bluetoothMacModeUsesSavedAddress(
_prefs.bluetooth_mac_mode)
&& mesh::companion::isValidBluetoothMac(
@@ -3074,6 +3159,11 @@ void MyMesh::execCommand(char* cmd, char* reply) {
return;
}
#if defined(BLE_PIN_CODE)
if (cmd && (strcmp(cmd, "get bluetooth.stealth") == 0
|| strcmp(cmd, "get ble.stealth") == 0)) {
formatBluetoothStealthStatus(reply, 160);
return;
}
if (cmd && (strcmp(cmd, "get bluetooth.mac") == 0
|| strcmp(cmd, "get ble.mac") == 0)) {
formatBluetoothMacStatus(reply, 160);
@@ -3119,6 +3209,11 @@ void MyMesh::execCommand(char* cmd, char* reply) {
return;
}
#if defined(BLE_PIN_CODE)
if (strcmp(key, "bluetooth.stealth") == 0
|| strcmp(key, "ble.stealth") == 0) {
applyAndSaveBluetoothStealth(value, reply, 160);
return;
}
if (strcmp(key, "bluetooth.mac") == 0) {
applyAndSaveBluetoothMac(value, reply, 160);
return;
@@ -5792,6 +5887,114 @@ void MyMesh::formatBluetoothNameStatus(char* reply, size_t reply_size) const {
}
#if defined(BLE_PIN_CODE)
bool MyMesh::prepareBluetoothMacForBoot(bool& address_rotated) {
address_rotated = false;
if (_prefs.bluetooth_mac_mode
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
// This policy deliberately invalidates the previous local BLE identity.
// Reopen first pairing before advertising rather than hiding behind a
// bond created for that old address. Address generation remains once per
// boot in main.cpp, including initialization retries.
if (!resetBluetoothStealthPairing()) return false;
address_rotated = true;
return true;
}
const bool rotate_after_connection =
mesh::companion::bluetoothMacShouldRotateAtBoot(
_prefs.bluetooth_mac_mode, _prefs.bluetooth_mac);
if (!rotate_after_connection) return true;
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES];
getRNG()->random(address, sizeof(address));
mesh::companion::makeRandomStaticBluetoothMacDifferentFrom(
address, _prefs.bluetooth_mac);
address_rotated = saveBluetoothMac(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT, address);
return address_rotated;
}
bool MyMesh::armBluetoothMacRotationAfterConnection() {
if (_prefs.bluetooth_mac_mode
== mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED) {
return true;
}
if (_prefs.bluetooth_mac_mode
!= mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT) {
return true;
}
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
if (savePrefs()) return true;
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT;
return false;
}
bool MyMesh::saveBluetoothStealthPeer(
const mesh::companion::BluetoothPeerIdentity& peer) {
if (!mesh::companion::isValidBluetoothPeerIdentity(peer)) return false;
if (_prefs.bluetooth_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRED
&& _prefs.bluetooth_stealth_peer_type == peer.type
&& memcmp(_prefs.bluetooth_stealth_peer, peer.address,
sizeof(_prefs.bluetooth_stealth_peer)) == 0) {
return true;
}
if (_prefs.bluetooth_stealth_mode
!= mesh::companion::BLUETOOTH_STEALTH_PAIRING) {
return false;
}
const uint8_t previous_mode = _prefs.bluetooth_mac_mode;
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
sizeof(previous_peer));
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRED;
// One durable write records both consequences of a successful pairing.
// Stealth must not prevent random-after-connect from arming for next boot.
if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(
_prefs.bluetooth_mac_mode)) {
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
}
_prefs.bluetooth_stealth_peer_type = peer.type;
memcpy(_prefs.bluetooth_stealth_peer, peer.address,
sizeof(_prefs.bluetooth_stealth_peer));
if (savePrefs()) return true;
_prefs.bluetooth_mac_mode = previous_mode;
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRING;
_prefs.bluetooth_stealth_peer_type = previous_type;
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
sizeof(_prefs.bluetooth_stealth_peer));
return false;
}
bool MyMesh::resetBluetoothStealthPairing() {
if (_prefs.bluetooth_stealth_mode !=
mesh::companion::BLUETOOTH_STEALTH_PAIRED) {
return true;
}
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
sizeof(previous_peer));
clearCompanionBluetoothStealthPeer(_prefs);
if (savePrefs()) return true;
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRED;
_prefs.bluetooth_stealth_peer_type = previous_type;
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
sizeof(_prefs.bluetooth_stealth_peer));
return false;
}
bool MyMesh::saveBluetoothMac(uint8_t mode, const uint8_t* address) {
if (!mesh::companion::isValidBluetoothMacMode(mode)) return false;
if (mesh::companion::bluetoothMacModeUsesSavedAddress(mode)
@@ -5799,19 +6002,36 @@ bool MyMesh::saveBluetoothMac(uint8_t mode, const uint8_t* address) {
return false;
}
if (_prefs.bluetooth_mac_mode == mode
&& (!mesh::companion::bluetoothMacModeUsesSavedAddress(mode)
|| memcmp(_prefs.bluetooth_mac, address,
sizeof(_prefs.bluetooth_mac)) == 0)) {
return true;
}
const uint8_t previous_mode = _prefs.bluetooth_mac_mode;
const uint8_t previous_stealth_mode = _prefs.bluetooth_stealth_mode;
uint8_t previous_address[mesh::companion::BLUETOOTH_MAC_BYTES];
const uint8_t previous_peer_type = _prefs.bluetooth_stealth_peer_type;
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_address, _prefs.bluetooth_mac, sizeof(previous_address));
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
sizeof(previous_peer));
_prefs.bluetooth_mac_mode = mode;
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
if (mesh::companion::bluetoothMacModeUsesSavedAddress(mode)) {
memcpy(_prefs.bluetooth_mac, address, sizeof(_prefs.bluetooth_mac));
}
clearCompanionBluetoothStealthPeer(_prefs);
if (savePrefs()) return true;
_prefs.bluetooth_mac_mode = previous_mode;
_prefs.bluetooth_stealth_mode = previous_stealth_mode;
memcpy(_prefs.bluetooth_mac, previous_address, sizeof(_prefs.bluetooth_mac));
_prefs.bluetooth_stealth_peer_type = previous_peer_type;
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
sizeof(_prefs.bluetooth_stealth_peer));
return false;
}
@@ -5820,7 +6040,7 @@ bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply,
if (reply == NULL || reply_size == 0) return false;
if (value == NULL) {
snprintf(reply, reply_size,
"Error: use set bluetooth.mac <address|random|random-every-boot|default>");
"Error: use set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>");
return false;
}
@@ -5840,6 +6060,14 @@ bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply,
|| strcmp(value, "random every boot") == 0
|| strcmp(value, "everyboot") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT;
} else if (strcmp(value, "random-after-connect") == 0
|| strcmp(value, "random-after-connection") == 0
|| strcmp(value, "random after connect") == 0
|| strcmp(value, "random after connection") == 0
|| strcmp(value, "random-on-reboot-after-connect") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT;
getRNG()->random(address, sizeof(address));
mesh::companion::makeRandomStaticBluetoothMac(address);
} else {
const char* custom_value = value;
if (strncmp(custom_value, "custom", 6) == 0
@@ -5849,7 +6077,7 @@ bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply,
}
if (!mesh::companion::parseBluetoothMac(custom_value, address)) {
snprintf(reply, reply_size,
"Error: use a random-static address (first byte C0-FF), random, random-every-boot, or default");
"Error: use a random-static address (first byte C0-FF), random, random-every-boot, random-after-connect, or default");
return false;
}
mode = mesh::companion::BLUETOOTH_MAC_CUSTOM;
@@ -5867,6 +6095,12 @@ bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply,
} else if (mode == mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
snprintf(reply, reply_size,
"OK - Bluetooth address will randomize every boot; reboot and pair each time");
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(mode)) {
mesh::companion::formatBluetoothMac(address, formatted,
sizeof(formatted));
snprintf(reply, reply_size,
"OK - Bluetooth address saved as %s; it will rotate on the first boot after a connection",
formatted);
} else {
mesh::companion::formatBluetoothMac(address, formatted,
sizeof(formatted));
@@ -5892,6 +6126,15 @@ void MyMesh::formatBluetoothMacStatus(char* reply, size_t reply_size) const {
_prefs.bluetooth_mac)) {
snprintf(reply, reply_size,
"> factory address (invalid saved address ignored)");
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(mode)) {
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
mesh::companion::formatBluetoothMac(
_prefs.bluetooth_mac, formatted, sizeof(formatted));
snprintf(
reply, reply_size, "> %s (random-after-connect; %s)", formatted,
mode == mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED
? "rotation armed for next boot"
: "retained until a successful connection");
} else {
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
mesh::companion::formatBluetoothMac(
@@ -5901,6 +6144,47 @@ void MyMesh::formatBluetoothMacStatus(char* reply, size_t reply_size) const {
? "custom" : "saved random");
}
}
bool MyMesh::applyAndSaveBluetoothStealth(const char* value, char* reply,
size_t reply_size) {
if (reply == NULL || reply_size == 0) return false;
if (value == NULL
|| (strcmp(value, "on") != 0 && strcmp(value, "off") != 0)) {
snprintf(reply, reply_size,
"Error: use set bluetooth.stealth on|off");
return false;
}
const uint8_t previous_mode = _prefs.bluetooth_stealth_mode;
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_peer, _prefs.bluetooth_stealth_peer, sizeof(previous_peer));
if (setCompanionBluetoothStealth(_prefs, strcmp(value, "on") == 0)
&& !savePrefs()) {
_prefs.bluetooth_stealth_mode = previous_mode;
_prefs.bluetooth_stealth_peer_type = previous_type;
memcpy(_prefs.bluetooth_stealth_peer, previous_peer, sizeof(previous_peer));
snprintf(reply, reply_size, "Error: Bluetooth stealth save failed");
return false;
}
snprintf(reply, reply_size,
"OK - Bluetooth stealth %s saved; MAC policy unchanged; reboot to apply",
value);
return true;
}
void MyMesh::formatBluetoothStealthStatus(char* reply, size_t reply_size) const {
if (reply == NULL || reply_size == 0) return;
if (!mesh::companion::bluetoothStealthEnabled(_prefs.bluetooth_stealth_mode)) {
snprintf(reply, reply_size, "> off");
} else {
snprintf(reply, reply_size, "> on (%s)",
_prefs.bluetooth_stealth_mode == mesh::companion::BLUETOOTH_STEALTH_PAIRED
? "bonded-peer-only advertising"
: "discoverable until first authenticated pairing");
}
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
@@ -7346,7 +7630,9 @@ void MyMesh::handleTerminalCommand(char* command) {
#if defined(BLE_PIN_CODE)
terminalOutput().print(" get bluetooth.mac\r\n");
terminalOutput().print(
" set bluetooth.mac <address|random|random-every-boot|default>\r\n");
" set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>\r\n");
terminalOutput().print(" get bluetooth.stealth\r\n");
terminalOutput().print(" set bluetooth.stealth on|off\r\n");
#endif
terminalOutput().print(" set pin <0-999999>\r\n");
terminalOutput().print(" powersaving [on|off]\r\n");
+10
View File
@@ -140,6 +140,13 @@ public:
const char *getNodeName();
CompanionNodePrefs *getNodePrefs();
uint32_t getBLEPin();
#if defined(BLE_PIN_CODE)
bool prepareBluetoothMacForBoot(bool& address_rotated);
bool armBluetoothMacRotationAfterConnection();
bool saveBluetoothStealthPeer(
const mesh::companion::BluetoothPeerIdentity& peer);
bool resetBluetoothStealthPairing();
#endif
int getOfflineQueueCapacity() const;
void noteInternetClockSet() {
#ifdef COMPANION_MESH_CLOCK_SYNC
@@ -354,6 +361,9 @@ private:
bool applyAndSaveBluetoothMac(const char* value, char* reply,
size_t reply_size);
void formatBluetoothMacStatus(char* reply, size_t reply_size) const;
bool applyAndSaveBluetoothStealth(const char* value, char* reply,
size_t reply_size);
void formatBluetoothStealthStatus(char* reply, size_t reply_size) const;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
bool applyAndSaveEspNowChannel(const char* value, char* reply,
+28
View File
@@ -77,6 +77,10 @@ public:
uint16_t cad_max_duration_ms = 0;
uint8_t bluetooth_mac_mode = mesh::companion::BLUETOOTH_MAC_DEFAULT;
uint8_t bluetooth_mac[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
uint8_t bluetooth_stealth_peer_type =
mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE;
uint8_t bluetooth_stealth_peer[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
uint8_t bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_OFF;
private:
class RadioPrefs : public CommonRadioPrefs {
@@ -210,3 +214,27 @@ inline bool migrateCompanionPowerSavingDefault(CompanionNodePrefs& prefs) {
prefs.powersaving_policy_version = COMPANION_POWERSAVING_POLICY_VERSION;
return true;
}
inline void clearCompanionBluetoothStealthPeer(CompanionNodePrefs& prefs) {
if (mesh::companion::bluetoothStealthEnabled(prefs.bluetooth_stealth_mode)) {
prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRING;
}
prefs.bluetooth_stealth_peer_type =
mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE;
memset(prefs.bluetooth_stealth_peer, 0, sizeof(prefs.bluetooth_stealth_peer));
}
// An on/off flag must not change the address policy or erase a valid bond
// when the same value is submitted repeatedly.
inline bool setCompanionBluetoothStealth(CompanionNodePrefs& prefs,
bool enabled) {
if (mesh::companion::bluetoothStealthEnabled(prefs.bluetooth_stealth_mode)
== enabled) {
return false;
}
prefs.bluetooth_stealth_mode = enabled
? mesh::companion::BLUETOOTH_STEALTH_PAIRING
: mesh::companion::BLUETOOTH_STEALTH_OFF;
clearCompanionBluetoothStealthPeer(prefs);
return true;
}
+190 -3
View File
@@ -1922,10 +1922,52 @@ void halt() {
static uint8_t companion_bluetooth_session_address[
mesh::companion::BLUETOOTH_MAC_BYTES] = {};
static bool companion_bluetooth_session_address_ready = false;
static bool companion_bluetooth_identity_prepared = false;
static bool companion_bluetooth_clear_bonds_this_boot = false;
static uint8_t companion_bluetooth_session_mode =
mesh::companion::BLUETOOTH_MAC_DEFAULT;
static uint8_t companion_bluetooth_session_stealth_mode =
mesh::companion::BLUETOOTH_STEALTH_OFF;
static uint8_t companion_bluetooth_saved_session_address[
mesh::companion::BLUETOOTH_MAC_BYTES] = {};
static bool companion_bluetooth_rotation_arm_pending = false;
static bool companion_bluetooth_stealth_transition_pending = false;
static mesh::companion::BluetoothPeerIdentity
companion_bluetooth_stealth_transition_peer;
static bool companion_bluetooth_stealth_recovery_pending = false;
static uint32_t companion_bluetooth_identity_retry_at = 0;
static bool prepareCompanionBluetoothIdentity(
CompanionNodePrefs* prefs) {
if (companion_bluetooth_identity_prepared) return true;
if (prefs == nullptr) return false;
bool address_rotated = false;
if (!the_mesh.prepareBluetoothMacForBoot(address_rotated)) {
mesh::usbLoggingPort().println(
"Companion: Bluetooth address preparation save failed");
return false;
}
companion_bluetooth_identity_prepared = true;
companion_bluetooth_session_mode = prefs->bluetooth_mac_mode;
companion_bluetooth_session_stealth_mode = prefs->bluetooth_stealth_mode;
companion_bluetooth_clear_bonds_this_boot = address_rotated
|| companion_bluetooth_session_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRING;
memcpy(companion_bluetooth_saved_session_address,
prefs->bluetooth_mac,
sizeof(companion_bluetooth_saved_session_address));
if (address_rotated) {
mesh::usbLoggingPort().println(
"Companion: Bluetooth address changed for this boot");
}
return true;
}
static const uint8_t* companionBluetoothAddress(
const CompanionNodePrefs* prefs, bool& clear_bonds) {
clear_bonds = false;
clear_bonds = companion_bluetooth_clear_bonds_this_boot;
if (prefs == nullptr
|| !mesh::companion::isValidBluetoothMacMode(
prefs->bluetooth_mac_mode)) {
@@ -1970,6 +2012,13 @@ void halt() {
companion_bluetooth_start_at = 0;
mesh::usbLoggingPort().println("Companion: starting Bluetooth");
CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
if (!prepareCompanionBluetoothIdentity(prefs)) {
mesh::usbLoggingPort().println(
"Companion: Bluetooth identity preparation failed; retrying");
scheduleCompanionBluetoothRetry();
return;
}
if (!interface_manager.addInterface(InterfaceType::Bluetooth,
&bluetooth_interface)) {
mesh::usbLoggingPort().println(
@@ -1977,18 +2026,33 @@ void halt() {
scheduleCompanionBluetoothRetry();
return;
}
CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
const bool custom_bluetooth_name =
mesh::companion::hasCustomBluetoothName(prefs->bluetooth_name);
bool clear_bonds = false;
const uint8_t* bluetooth_address =
companionBluetoothAddress(prefs, clear_bonds);
const bool stealth_pair_once = companion_bluetooth_session_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRING;
mesh::companion::BluetoothPeerIdentity bonded_only_peer;
const mesh::companion::BluetoothPeerIdentity* bonded_only_peer_ptr =
nullptr;
if (companion_bluetooth_session_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRED) {
bonded_only_peer.type = prefs->bluetooth_stealth_peer_type;
memcpy(bonded_only_peer.address, prefs->bluetooth_stealth_peer,
sizeof(bonded_only_peer.address));
if (mesh::companion::isValidBluetoothPeerIdentity(
bonded_only_peer)) {
bonded_only_peer_ptr = &bonded_only_peer;
}
}
if (!bluetooth_interface.begin(custom_bluetooth_name ? "" : BLE_NAME_PREFIX,
custom_bluetooth_name
? prefs->bluetooth_name
: prefs->node_name,
the_mesh.getBLEPin(), bluetooth_address,
clear_bonds)) {
clear_bonds, stealth_pair_once,
bonded_only_peer_ptr)) {
interface_manager.removeInterface(&bluetooth_interface);
mesh::usbLoggingPort().println(
"Companion: Bluetooth initialization failed; retrying in 5 seconds");
@@ -2009,6 +2073,128 @@ void halt() {
|| (int32_t)(millis() - companion_bluetooth_start_at) < 0) return;
startCompanionBluetooth();
}
static void serviceCompanionBluetoothIdentity() {
if (!companion_bluetooth_initialized) return;
if (bluetooth_interface.takeBondedOnlyRecovery()) {
companion_bluetooth_stealth_recovery_pending = true;
companion_bluetooth_identity_retry_at = 0;
}
const uint32_t now = millis();
if (companion_bluetooth_identity_retry_at != 0
&& (int32_t)(now - companion_bluetooth_identity_retry_at) < 0) {
return;
}
if (companion_bluetooth_stealth_recovery_pending) {
if (the_mesh.resetBluetoothStealthPairing()) {
mesh::usbLoggingPort().println(
"Companion: saved stealth bond unavailable; reopening pairing");
board.reboot();
} else {
companion_bluetooth_identity_retry_at =
now + COMPANION_BLUETOOTH_RETRY_MS;
if (companion_bluetooth_identity_retry_at == 0) {
companion_bluetooth_identity_retry_at = 1;
}
mesh::usbLoggingPort().println(
"Companion: stealth recovery save failed; retrying");
}
return;
}
const CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
bool successful_connection = false;
if (companion_bluetooth_session_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRING) {
if (!companion_bluetooth_stealth_transition_pending) {
mesh::companion::BluetoothPeerIdentity peer;
if (bluetooth_interface.takeSuccessfulConnection(&peer)) {
successful_connection = true;
companion_bluetooth_stealth_transition_peer = peer;
companion_bluetooth_stealth_transition_pending = true;
}
}
if (companion_bluetooth_stealth_transition_pending) {
if (prefs == nullptr
|| prefs->bluetooth_stealth_mode
!= mesh::companion::BLUETOOTH_STEALTH_PAIRING
|| !mesh::companion::bluetoothMacPoliciesMatch(
prefs->bluetooth_mac_mode, companion_bluetooth_session_mode)
|| memcmp(prefs->bluetooth_mac,
companion_bluetooth_saved_session_address,
sizeof(companion_bluetooth_saved_session_address)) != 0) {
// A command changed identity while this session was running. Keep
// it usable until reboot applies the requested settings.
bluetooth_interface.cancelStealthPairingTransition();
companion_bluetooth_stealth_transition_pending = false;
companion_bluetooth_session_stealth_mode =
mesh::companion::BLUETOOTH_STEALTH_OFF;
companion_bluetooth_identity_retry_at = 0;
} else if (the_mesh.saveBluetoothStealthPeer(
companion_bluetooth_stealth_transition_peer)) {
companion_bluetooth_stealth_transition_pending = false;
companion_bluetooth_session_stealth_mode =
mesh::companion::BLUETOOTH_STEALTH_PAIRED;
companion_bluetooth_identity_retry_at = 0;
if (bluetooth_interface.enableBondedOnlyAdvertising(
companion_bluetooth_stealth_transition_peer)) {
mesh::usbLoggingPort().println(
"Companion: stealth Bluetooth now accepts its bonded peer only");
} else {
companion_bluetooth_stealth_recovery_pending = true;
}
} else {
companion_bluetooth_identity_retry_at =
now + COMPANION_BLUETOOTH_RETRY_MS;
if (companion_bluetooth_identity_retry_at == 0) {
companion_bluetooth_identity_retry_at = 1;
}
mesh::usbLoggingPort().println(
"Companion: stealth peer save failed; retrying");
}
}
} else {
successful_connection = bluetooth_interface.takeSuccessfulConnection();
}
// Stealth and MAC rotation consume the same authenticated event; neither
// policy may swallow it before the other has persisted its state.
if (!mesh::companion::bluetoothMacModeIsRandomAfterConnect(
companion_bluetooth_session_mode)) {
return;
}
if (successful_connection) {
companion_bluetooth_rotation_arm_pending = true;
}
if (!companion_bluetooth_rotation_arm_pending) return;
if (prefs == nullptr
|| !mesh::companion::bluetoothMacModeIsRandomAfterConnect(
prefs->bluetooth_mac_mode)
|| memcmp(prefs->bluetooth_mac,
companion_bluetooth_saved_session_address,
sizeof(companion_bluetooth_saved_session_address)) != 0) {
companion_bluetooth_rotation_arm_pending = false;
return;
}
if (the_mesh.armBluetoothMacRotationAfterConnection()) {
companion_bluetooth_rotation_arm_pending = false;
companion_bluetooth_identity_retry_at = 0;
mesh::usbLoggingPort().println(
"Companion: Bluetooth rotation armed for next boot");
} else {
companion_bluetooth_identity_retry_at =
now + COMPANION_BLUETOOTH_RETRY_MS;
if (companion_bluetooth_identity_retry_at == 0) {
companion_bluetooth_identity_retry_at = 1;
}
mesh::usbLoggingPort().println(
"Companion: Bluetooth rotation marker save failed; retrying");
}
}
#endif
#if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS
@@ -2692,5 +2878,6 @@ void loop() {
#else
serviceDeferredCompanionBluetooth();
#endif
serviceCompanionBluetoothIdentity();
#endif
}
+85 -2
View File
@@ -15,15 +15,46 @@ enum BluetoothMacMode : uint8_t {
BLUETOOTH_MAC_CUSTOM = 1,
BLUETOOTH_MAC_RANDOM_SAVED = 2,
BLUETOOTH_MAC_RANDOM_EVERY_BOOT = 3,
BLUETOOTH_MAC_RANDOM_AFTER_CONNECT = 4,
// Internal persisted state: the current identity completed an authenticated
// connection and must rotate before it advertises next boot.
BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED = 5,
};
inline bool isValidBluetoothMacMode(uint8_t mode) {
return mode <= BLUETOOTH_MAC_RANDOM_EVERY_BOOT;
return mode <= BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
}
inline bool bluetoothMacModeIsRandomAfterConnect(uint8_t mode) {
return mode == BLUETOOTH_MAC_RANDOM_AFTER_CONNECT
|| mode == BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
}
enum BluetoothStealthMode : uint8_t {
BLUETOOTH_STEALTH_OFF = 0,
BLUETOOTH_STEALTH_PAIRING = 1,
BLUETOOTH_STEALTH_PAIRED = 2,
};
inline bool isValidBluetoothStealthMode(uint8_t mode) {
return mode <= BLUETOOTH_STEALTH_PAIRED;
}
inline bool bluetoothStealthEnabled(uint8_t mode) {
return mode == BLUETOOTH_STEALTH_PAIRING
|| mode == BLUETOOTH_STEALTH_PAIRED;
}
inline bool bluetoothMacPoliciesMatch(uint8_t first, uint8_t second) {
return first == second
|| (bluetoothMacModeIsRandomAfterConnect(first)
&& bluetoothMacModeIsRandomAfterConnect(second));
}
inline bool bluetoothMacModeUsesSavedAddress(uint8_t mode) {
return mode == BLUETOOTH_MAC_CUSTOM
|| mode == BLUETOOTH_MAC_RANDOM_SAVED;
|| mode == BLUETOOTH_MAC_RANDOM_SAVED
|| bluetoothMacModeIsRandomAfterConnect(mode);
}
inline bool hasCustomBluetoothMac(
@@ -51,6 +82,40 @@ inline bool isValidBluetoothMac(
return !payload_all_zero && !payload_all_one;
}
inline bool bluetoothMacShouldRotateAtBoot(
uint8_t mode, const uint8_t address[BLUETOOTH_MAC_BYTES]) {
return bluetoothMacModeIsRandomAfterConnect(mode)
&& (mode == BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED
|| !isValidBluetoothMac(address));
}
enum BluetoothPeerAddressType : uint8_t {
BLUETOOTH_PEER_ADDRESS_NONE = 0,
BLUETOOTH_PEER_ADDRESS_PUBLIC = 1,
BLUETOOTH_PEER_ADDRESS_RANDOM = 2,
};
struct BluetoothPeerIdentity {
uint8_t type = BLUETOOTH_PEER_ADDRESS_NONE;
uint8_t address[BLUETOOTH_MAC_BYTES] = {};
};
inline bool isValidBluetoothPeerIdentity(
const BluetoothPeerIdentity& peer) {
if (peer.type != BLUETOOTH_PEER_ADDRESS_PUBLIC
&& peer.type != BLUETOOTH_PEER_ADDRESS_RANDOM) {
return false;
}
bool all_zero = true;
bool all_one = true;
for (size_t i = 0; i < BLUETOOTH_MAC_BYTES; i++) {
all_zero = all_zero && peer.address[i] == 0;
all_one = all_one && peer.address[i] == 0xFFu;
}
return !all_zero && !all_one;
}
// Convert six random bytes into a valid BLE random-static identity. This is
// deterministic for a given input so both saved-random and per-boot modes can
// use the platform's existing entropy source without platform-specific rules.
@@ -61,6 +126,24 @@ inline void makeRandomStaticBluetoothMac(
if (!isValidBluetoothMac(address)) address[5] ^= 0x01u;
}
inline void makeRandomStaticBluetoothMacDifferentFrom(
uint8_t address[BLUETOOTH_MAC_BYTES],
const uint8_t previous[BLUETOOTH_MAC_BYTES]) {
makeRandomStaticBluetoothMac(address);
if (address == NULL || previous == NULL
|| memcmp(address, previous, BLUETOOTH_MAC_BYTES) != 0) {
return;
}
address[5] ^= 0x01u;
if (!isValidBluetoothMac(address)) {
// The old payload was one bit away from all-zero or all-one. Restore that
// bit and flip a second payload bit so the result is valid and different.
address[5] ^= 0x01u;
address[4] ^= 0x01u;
}
}
inline int bluetoothMacHexDigit(char value) {
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
+4 -2
View File
@@ -18,7 +18,8 @@
// separately, see wcIsAdminPasswordKey below.
static const char* const WC_ALLOWED_SET_KEYS[] = {
// NodePrefs (radio / node)
"name", "bluetooth.name", "bluetooth.mac", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"name", "bluetooth.name", "bluetooth.mac", "bluetooth.stealth",
"lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "radio.fem.rxgain", "radio.rxps", "powersaving",
"repeat",
"advert.interval", "flood.advert.interval",
@@ -66,7 +67,8 @@ static inline bool wcIsEspNowChannelKey(const char* key) {
static inline bool wcSetKeyRequiresReboot(const char* key) {
return key != NULL
&& (wcIsEspNowChannelKey(key)
|| strcmp(key, "bluetooth.mac") == 0);
|| strcmp(key, "bluetooth.mac") == 0
|| strcmp(key, "bluetooth.stealth") == 0);
}
// The admin password maps to the top-level `password` command, not a setter, so
+314 -9
View File
@@ -27,6 +27,7 @@ extern "C" bool bleInUse(void) {
#define CHARACTERISTIC_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
#define ADVERT_RESTART_DELAY 1000 // millis
#define BLE_BOND_PERSIST_TIMEOUT_MS 15000
static void clearStoredBluetoothBonds() {
#if defined(CONFIG_NIMBLE_ENABLED)
@@ -58,11 +59,245 @@ static void clearStoredBluetoothBonds() {
#endif
}
static bool bluetoothPeersEqual(
const mesh::companion::BluetoothPeerIdentity& lhs,
const mesh::companion::BluetoothPeerIdentity& rhs) {
return lhs.type == rhs.type
&& memcmp(lhs.address, rhs.address, sizeof(lhs.address)) == 0;
}
void SerialBLEInterface::noteSuccessfulConnection(
const mesh::companion::BluetoothPeerIdentity& peer) {
if (_stealth_pair_once) {
_advertisingSuppressed.store(true, std::memory_order_release);
_adv_restart_pending = false;
}
if (!_successfulConnectionPending.load(std::memory_order_acquire)) {
_successfulPeer = peer;
_successfulConnectionStarted.store(
(uint32_t)millis(), std::memory_order_relaxed);
_successfulConnectionPending.store(true, std::memory_order_release);
}
}
#if defined(CONFIG_NIMBLE_ENABLED)
static void bluetoothPeerFromNimbleAddress(
const ble_addr_t& address,
mesh::companion::BluetoothPeerIdentity& peer) {
peer.type = (address.type & 0x01u) == BLE_ADDR_PUBLIC
? mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC
: mesh::companion::BLUETOOTH_PEER_ADDRESS_RANDOM;
for (size_t i = 0; i < mesh::companion::BLUETOOTH_MAC_BYTES; i++) {
peer.address[i] = address.val[
mesh::companion::BLUETOOTH_MAC_BYTES - 1 - i];
}
}
static ble_addr_t nimbleAddressFromBluetoothPeer(
const mesh::companion::BluetoothPeerIdentity& peer) {
ble_addr_t address = {};
address.type =
peer.type == mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC
? BLE_ADDR_PUBLIC
: BLE_ADDR_RANDOM;
for (size_t i = 0; i < mesh::companion::BLUETOOTH_MAC_BYTES; i++) {
address.val[i] = peer.address[
mesh::companion::BLUETOOTH_MAC_BYTES - 1 - i];
}
return address;
}
static bool nimbleBondExistsForPeer(
const mesh::companion::BluetoothPeerIdentity& peer) {
ble_addr_t bonded_peers[8];
int count = 0;
if (ble_store_util_bonded_peers(
bonded_peers, &count,
sizeof(bonded_peers) / sizeof(bonded_peers[0])) != 0) {
return false;
}
for (int i = 0; i < count; i++) {
mesh::companion::BluetoothPeerIdentity candidate;
bluetoothPeerFromNimbleAddress(bonded_peers[i], candidate);
if (bluetoothPeersEqual(candidate, peer)) return true;
}
return false;
}
#else
static void bluetoothPeerFromBluedroidBond(
const esp_ble_bond_dev_t& bond, uint8_t fallback_type,
mesh::companion::BluetoothPeerIdentity& peer) {
const bool has_identity =
(bond.bond_key.key_mask & ESP_LE_KEY_PID) != 0;
const uint8_t* address = has_identity
? bond.bond_key.pid_key.static_addr
: bond.bd_addr;
const uint8_t address_type = has_identity
? bond.bond_key.pid_key.addr_type
: fallback_type;
peer.type = address_type == BLE_ADDR_TYPE_PUBLIC
? mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC
: mesh::companion::BLUETOOTH_PEER_ADDRESS_RANDOM;
memcpy(peer.address, address, sizeof(peer.address));
}
static bool findBluedroidBondedPeer(
const mesh::companion::BluetoothPeerIdentity& requested,
mesh::companion::BluetoothPeerIdentity& resolved) {
int count = esp_ble_get_bond_device_num();
if (count <= 0) return false;
esp_ble_bond_dev_t* devices = static_cast<esp_ble_bond_dev_t*>(
malloc(sizeof(esp_ble_bond_dev_t) * count));
if (devices == NULL) return false;
if (esp_ble_get_bond_device_list(&count, devices) != ESP_OK) {
free(devices);
return false;
}
bool found = false;
for (int i = 0; i < count; i++) {
mesh::companion::BluetoothPeerIdentity candidate;
bluetoothPeerFromBluedroidBond(
devices[i],
requested.type == mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC
? BLE_ADDR_TYPE_PUBLIC
: BLE_ADDR_TYPE_RANDOM,
candidate);
if (bluetoothPeersEqual(candidate, requested)
|| memcmp(devices[i].bd_addr, requested.address,
sizeof(requested.address)) == 0
|| count == 1) {
resolved = candidate;
found = mesh::companion::isValidBluetoothPeerIdentity(resolved);
break;
}
}
free(devices);
return found;
}
#endif
bool SerialBLEInterface::resolveSuccessfulPeer(
mesh::companion::BluetoothPeerIdentity& peer) const {
#if defined(CONFIG_NIMBLE_ENABLED)
if (!mesh::companion::isValidBluetoothPeerIdentity(_successfulPeer)
|| !nimbleBondExistsForPeer(_successfulPeer)) {
return false;
}
peer = _successfulPeer;
return true;
#else
if (!mesh::companion::isValidBluetoothPeerIdentity(_successfulPeer)) {
return false;
}
return findBluedroidBondedPeer(_successfulPeer, peer);
#endif
}
bool SerialBLEInterface::configureBondedOnlyAdvertising(
const mesh::companion::BluetoothPeerIdentity& peer,
bool require_stored_bond) {
if (!mesh::companion::isValidBluetoothPeerIdentity(peer)
|| pServer == NULL) {
return false;
}
#if defined(CONFIG_NIMBLE_ENABLED)
if (require_stored_bond && !nimbleBondExistsForPeer(peer)) {
requestBondedOnlyRecovery("saved peer bond is unavailable");
return false;
}
const ble_addr_t native_peer = nimbleAddressFromBluetoothPeer(peer);
if (ble_gap_wl_set(&native_peer, 1) != 0) {
requestBondedOnlyRecovery("could not set NimBLE allowlist");
return false;
}
#else
if (require_stored_bond) {
mesh::companion::BluetoothPeerIdentity resolved;
if (!findBluedroidBondedPeer(peer, resolved)
|| !bluetoothPeersEqual(peer, resolved)) {
requestBondedOnlyRecovery("saved peer bond is unavailable");
return false;
}
}
esp_bd_addr_t native_peer;
memcpy(native_peer, peer.address, sizeof(native_peer));
const esp_ble_wl_addr_type_t address_type =
peer.type == mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC
? BLE_WL_ADDR_TYPE_PUBLIC
: BLE_WL_ADDR_TYPE_RANDOM;
// The Bluedroid API adds one entry at a time. Replace any controller state
// left by an older mode/session so the requested peer is genuinely the only
// address allowed to scan or connect.
if (esp_ble_gap_clear_whitelist() != ESP_OK) {
requestBondedOnlyRecovery("could not clear Bluedroid allowlist");
return false;
}
if (esp_ble_gap_update_whitelist(true, native_peer, address_type)
!= ESP_OK) {
requestBondedOnlyRecovery("could not set Bluedroid allowlist");
return false;
}
#endif
BLEAdvertising* advertising = pServer->getAdvertising();
if (advertising == NULL) {
requestBondedOnlyRecovery("Bluetooth advertising is unavailable");
return false;
}
advertising->stop();
BLEAdvertisementData minimal_advertisement;
minimal_advertisement.setFlags(ESP_BLE_ADV_FLAG_BREDR_NOT_SPT);
advertising->setScanResponse(false);
advertising->setScanFilter(true, true);
advertising->setAdvertisementData(minimal_advertisement);
_bonded_only = true;
_stealth_pair_once = false;
_advertisingSuppressed.store(false, std::memory_order_release);
_adv_restart_pending = false;
if (_isEnabled && pServer->getConnectedCount() == 0) {
advertising->start();
}
return true;
}
void SerialBLEInterface::requestBondedOnlyRecovery(const char* cause) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: stealth recovery required (%s)",
cause);
_bonded_only = true;
_stealth_pair_once = false;
_advertisingSuppressed.store(true, std::memory_order_release);
_bondedOnlyRecoveryPending.store(true, std::memory_order_release);
_adv_restart_pending = false;
if (pServer != NULL && pServer->getAdvertising() != NULL) {
pServer->getAdvertising()->stop();
}
}
bool SerialBLEInterface::advertisingAllowed() const {
return _isEnabled
&& !_advertisingSuppressed.load(std::memory_order_acquire)
&& !_bondedOnlyRecoveryPending.load(std::memory_order_acquire);
}
bool SerialBLEInterface::begin(const char* prefix, const char* name,
uint32_t pin_code,
const uint8_t* custom_address,
bool clear_bonds) {
bool clear_bonds, bool stealth_pair_once,
const mesh::companion::BluetoothPeerIdentity*
bonded_only_peer) {
_pin_code = pin_code;
_successfulConnectionPending.store(false, std::memory_order_release);
_successfulConnectionStarted.store(0, std::memory_order_relaxed);
_bondedOnlyRecoveryPending.store(false, std::memory_order_release);
_advertisingSuppressed.store(false, std::memory_order_release);
_successfulPeer = mesh::companion::BluetoothPeerIdentity();
_stealth_pair_once = stealth_pair_once;
_bonded_only = false;
if (custom_address != nullptr
&& !mesh::companion::isValidBluetoothMac(custom_address)) {
@@ -220,6 +455,9 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name,
pRxCharacteristic->setCallbacks(this);
pServer->getAdvertising()->addServiceUUID(SERVICE_UUID);
if (bonded_only_peer != nullptr) {
configureBondedOnlyAdvertising(*bonded_only_peer, true);
}
return true;
}
@@ -250,10 +488,14 @@ bool SerialBLEInterface::onSecurityRequest() {
#if defined(CONFIG_NIMBLE_ENABLED)
void SerialBLEInterface::onAuthenticationComplete(ble_gap_conn_desc* desc) {
const bool success = desc != NULL && desc->sec_state.encrypted &&
desc->sec_state.authenticated;
desc->sec_state.authenticated
&& desc->sec_state.bonded;
if (success) {
BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Success");
deviceConnected = true;
mesh::companion::BluetoothPeerIdentity peer;
bluetoothPeerFromNimbleAddress(desc->peer_id_addr, peer);
noteSuccessfulConnection(peer);
} else {
BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Failure");
@@ -268,10 +510,15 @@ void SerialBLEInterface::onAuthenticationComplete(ble_gap_conn_desc* desc) {
}
deviceConnected = false;
if (_bonded_only) {
requestBondedOnlyRecovery("bond authentication failed");
}
if (desc != NULL) {
pServer->disconnect(desc->conn_handle);
}
scheduleAdvertisingRestart((uint32_t)millis());
if (advertisingAllowed()) {
scheduleAdvertisingRestart((uint32_t)millis());
}
}
}
#else
@@ -279,6 +526,12 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
if (cmpl.success) {
BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Success");
deviceConnected = true;
mesh::companion::BluetoothPeerIdentity peer;
peer.type = cmpl.addr_type == BLE_ADDR_TYPE_PUBLIC
? mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC
: mesh::companion::BLUETOOTH_PEER_ADDRESS_RANDOM;
memcpy(peer.address, cmpl.bd_addr, sizeof(peer.address));
noteSuccessfulConnection(peer);
} else {
BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Failure, reason=%u", (unsigned)cmpl.fail_reason);
@@ -294,8 +547,13 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
}
deviceConnected = false;
if (_bonded_only) {
requestBondedOnlyRecovery("bond authentication failed");
}
pServer->disconnect(pServer->getConnId());
scheduleAdvertisingRestart((uint32_t)millis());
if (advertisingAllowed()) {
scheduleAdvertisingRestart((uint32_t)millis());
}
}
}
#endif
@@ -355,7 +613,7 @@ void SerialBLEInterface::onDisconnect(BLEServer* pServer) {
xQueueReset(recv_queue);
_tx_reset_pending.store(true, std::memory_order_release);
if (pTxDescriptor != NULL) pTxDescriptor->setNotifications(false);
if (_isEnabled) {
if (advertisingAllowed()) {
scheduleAdvertisingRestart((uint32_t)millis());
}
}
@@ -416,6 +674,47 @@ void SerialBLEInterface::onStatus(BLECharacteristic* pCharacteristic, Status sta
// ---------- public methods
bool SerialBLEInterface::takeSuccessfulConnection(
mesh::companion::BluetoothPeerIdentity* peer) {
if (!_successfulConnectionPending.load(std::memory_order_acquire)) {
return false;
}
if (peer != nullptr && !resolveSuccessfulPeer(*peer)) {
// Bond storage can finish shortly after authentication. Keep the event
// pending so the main loop can retry without reopening general access. A
// failed bond write must not leave the node unavailable indefinitely.
const uint32_t started = _successfulConnectionStarted.load(
std::memory_order_relaxed);
if (_stealth_pair_once
&& (uint32_t)((uint32_t)millis() - started)
>= BLE_BOND_PERSIST_TIMEOUT_MS) {
_successfulConnectionPending.store(false, std::memory_order_release);
requestBondedOnlyRecovery("new peer bond did not persist");
}
return false;
}
const bool pending = _successfulConnectionPending.exchange(
false, std::memory_order_acq_rel);
if (pending) {
_successfulConnectionStarted.store(0, std::memory_order_relaxed);
}
return pending;
}
bool SerialBLEInterface::enableBondedOnlyAdvertising(
const mesh::companion::BluetoothPeerIdentity& peer) {
return configureBondedOnlyAdvertising(peer, false);
}
void SerialBLEInterface::cancelStealthPairingTransition() {
if (!_stealth_pair_once) return;
_stealth_pair_once = false;
_advertisingSuppressed.store(false, std::memory_order_release);
if (_isEnabled && pServer != NULL && pServer->getConnectedCount() == 0) {
scheduleAdvertisingRestart((uint32_t)millis());
}
}
void SerialBLEInterface::clearBuffers() {
xQueueReset(recv_queue);
send_queue_len = 0;
@@ -435,6 +734,10 @@ void SerialBLEInterface::servicePendingTxReset() {
}
void SerialBLEInterface::scheduleAdvertisingRestart(uint32_t now) {
if (!advertisingAllowed()) {
_adv_restart_pending = false;
return;
}
_adv_restart_started = now;
_adv_restart_pending = true;
}
@@ -446,7 +749,7 @@ void SerialBLEInterface::serviceTxRecovery(uint32_t now) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: stalled TX link is already closed");
deviceConnected = false;
clearBuffers();
if (_isEnabled) scheduleAdvertisingRestart(now);
if (advertisingAllowed()) scheduleAdvertisingRestart(now);
return;
}
@@ -488,7 +791,9 @@ void SerialBLEInterface::enable() {
//pServer->getAdvertising()->setMinInterval(500);
//pServer->getAdvertising()->setMaxInterval(1000);
pServer->getAdvertising()->start();
if (advertisingAllowed()) {
pServer->getAdvertising()->start();
}
_adv_restart_pending = false;
}
@@ -607,7 +912,7 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
//pServer->getAdvertising()->setMinInterval(500);
//pServer->getAdvertising()->setMaxInterval(1000);
scheduleAdvertisingRestart(now);
if (advertisingAllowed()) scheduleAdvertisingRestart(now);
} else {
BLE_DEBUG_PRINTLN("SerialBLEInterface -> stopping advertising");
BLE_DEBUG_PRINTLN("SerialBLEInterface -> connecting...");
@@ -619,7 +924,7 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
oldDeviceConnected = deviceConnected;
}
if (_adv_restart_pending &&
if (advertisingAllowed() && _adv_restart_pending &&
mesh::bleElapsedAtLeast(now, _adv_restart_started,
ADVERT_RESTART_DELAY)) {
if (pServer->getConnectedCount() == 0) {
+33 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "../BaseSerialInterface.h"
#include "../BluetoothMac.h"
#include "../BleTxStallWatchdog.h"
#include "../UsbLogging.h"
#include <BLEDevice.h>
@@ -30,6 +31,13 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
mesh::BleDisconnectRecovery _tx_disconnect_recovery;
std::atomic<bool> _tx_reset_pending{false};
std::atomic<bool> _pairingRequestPending{false};
std::atomic<bool> _successfulConnectionPending{false};
std::atomic<uint32_t> _successfulConnectionStarted{0};
mesh::companion::BluetoothPeerIdentity _successfulPeer;
bool _stealth_pair_once;
bool _bonded_only;
std::atomic<bool> _advertisingSuppressed{false};
std::atomic<bool> _bondedOnlyRecoveryPending{false};
struct Frame {
uint8_t len;
@@ -48,6 +56,15 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
void scheduleAdvertisingRestart(uint32_t now);
void recoverStalledTx(const char* cause);
void serviceTxRecovery(uint32_t now);
void noteSuccessfulConnection(
const mesh::companion::BluetoothPeerIdentity& peer);
bool resolveSuccessfulPeer(
mesh::companion::BluetoothPeerIdentity& peer) const;
bool configureBondedOnlyAdvertising(
const mesh::companion::BluetoothPeerIdentity& peer,
bool require_stored_bond);
void requestBondedOnlyRecovery(const char* cause);
bool advertisingAllowed() const;
protected:
// BLESecurityCallbacks methods
@@ -95,6 +112,8 @@ public:
_adv_restart_started = 0;
_adv_restart_pending = false;
_isEnabled = false;
_stealth_pair_once = false;
_bonded_only = false;
_last_write = 0;
last_conn_id = 0;
recv_queue = xQueueCreateStatic(
@@ -110,10 +129,14 @@ public:
* @param pin_code the BLE security pin
* @param custom_address optional human-order BLE random-static address
* @param clear_bonds clear saved peer bonds before accepting connections
* @param stealth_pair_once suppress general advertising after first pairing
* @param bonded_only_peer optional peer allowed to reconnect in stealth mode
*/
bool begin(const char* prefix, const char* name, uint32_t pin_code,
const uint8_t* custom_address = nullptr,
bool clear_bonds = false);
bool clear_bonds = false, bool stealth_pair_once = false,
const mesh::companion::BluetoothPeerIdentity*
bonded_only_peer = nullptr);
// BaseSerialInterface methods
void enable() override;
@@ -130,6 +153,15 @@ public:
bool takePairingRequest() override {
return _pairingRequestPending.exchange(false, std::memory_order_acq_rel);
}
bool takeSuccessfulConnection(
mesh::companion::BluetoothPeerIdentity* peer = nullptr);
bool enableBondedOnlyAdvertising(
const mesh::companion::BluetoothPeerIdentity& peer);
void cancelStealthPairingTransition();
bool takeBondedOnlyRecovery() {
return _bondedOnlyRecoveryPending.exchange(
false, std::memory_order_acq_rel);
}
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
};
File diff suppressed because it is too large. Load diff
+1
View File
@@ -1797,6 +1797,7 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
radio["name"] = (const char*)node.name;
radio["bluetooth_name"] = (const char*)node.bluetooth_name;
radio["bluetooth_mac"] = (const char*)node.bluetooth_mac;
radio["bluetooth_stealth"] = node.bluetooth_stealth;
radio["lat"] = node.lat;
radio["lon"] = node.lon;
radio["advert_interval"] = node.advert_interval;
+2
View File
@@ -85,6 +85,7 @@ public:
CAP_BLUETOOTH_NAME = 1UL << 14,
CAP_ESPNOW_CHANNEL = 1UL << 15,
CAP_BLUETOOTH_MAC = 1UL << 16,
CAP_BLUETOOTH_STEALTH = 1UL << 17,
CAP_DELAYS = CAP_RX_DELAY | CAP_TX_DELAY,
};
@@ -95,6 +96,7 @@ public:
char name[32];
char bluetooth_name[32]; // empty = transport's default device name
char bluetooth_mac[24]; // empty = chipset factory address
bool bluetooth_stealth; // independent of the address policy
char admin_password[32]; // empty = trusted LAN/no login prompt
double lat;
double lon;
+255 -10
View File
@@ -10,6 +10,7 @@
// Magic numbers came from actual testing
#define BLE_HEALTH_CHECK_INTERVAL 10000 // Advertising watchdog check every 10 seconds
#define BLE_RETRY_THROTTLE_MS 250 // Throttle retries to 250ms when queue buildup detected
#define BLE_BOND_PERSIST_TIMEOUT_MS 15000 // Bound first-pair transition recovery
// Connection parameters (units: interval=1.25ms, timeout=10ms)
#define BLE_MIN_CONN_INTERVAL 12 // 15ms
@@ -108,11 +109,20 @@ void SerialBLEInterface::onSecured(uint16_t connection_handle) {
if (conn != nullptr && conn->bonded()) {
instance->removeStoredBondForPeer("unsecured link");
}
if (instance->_bonded_only) {
instance->requestBondedOnlyRecovery("unsecured bonded-only link");
}
instance->disconnect();
return;
}
instance->_isDeviceConnected = true;
if (conn->bonded()) {
instance->noteSuccessfulConnection(conn->getPeerAddr());
} else {
BLE_DEBUG_PRINTLN(
"SerialBLEInterface: secured connection was not bonded");
}
instance->_security_timer.cancel();
// Connection interval units: 1.25ms, supervision timeout units: 10ms
@@ -155,8 +165,19 @@ void SerialBLEInterface::onPairingComplete(uint16_t connection_handle, uint8_t a
if (instance->isValidConnection(connection_handle)) {
if (auth_status == BLE_GAP_SEC_STATUS_SUCCESS) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: pairing successful");
BLEConnection* conn = Bluefruit.Connection(connection_handle);
if (conn != nullptr && conn->secured()) {
// Bluefruit may invoke onSecured before it marks a newly paired
// connection as bonded. Record the completed pairing here as well;
// resolveSuccessfulPeer() will keep the event pending until the
// deferred bond write can be loaded.
instance->noteSuccessfulConnection(conn->getPeerAddr());
}
} else {
BLE_DEBUG_PRINTLN("SerialBLEInterface: pairing failed, clearing stale bond and disconnecting");
if (instance->_bonded_only) {
instance->requestBondedOnlyRecovery("pairing failure");
}
BLEConnection* conn = Bluefruit.Connection(connection_handle);
if (conn != nullptr && conn->bonded()) {
instance->removeStoredBondForPeer("pairing failure");
@@ -189,6 +210,9 @@ void SerialBLEInterface::onBLEEvent(ble_evt_t* evt) {
if (conn->bonded()) {
instance->removeStoredBondForPeer("failed bond encryption");
}
if (instance->_bonded_only) {
instance->requestBondedOnlyRecovery("failed bond encryption");
}
instance->_isDeviceConnected = false;
instance->_security_timer.cancel();
sd_ble_gap_disconnect(conn_handle, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION);
@@ -197,6 +221,10 @@ void SerialBLEInterface::onBLEEvent(ble_evt_t* evt) {
ble_gap_evt_disconnected_t const* disconnected = &evt->evt.gap_evt.params.disconnected;
if (isBondAuthenticationFailure(disconnected->reason)) {
instance->removeStoredBondForPeer("authentication disconnect");
if (instance->_bonded_only) {
instance->requestBondedOnlyRecovery(
"bond authentication disconnect");
}
}
instance->_peer_address_valid = false;
} else if (evt->header.evt_id == BLE_GAP_EVT_CONN_PARAM_UPDATE_REQUEST) {
@@ -239,11 +267,149 @@ bool SerialBLEInterface::removeStoredBondForPeer(const char* cause) {
return true;
}
void SerialBLEInterface::noteSuccessfulConnection(
const ble_gap_addr_t& peer_address) {
if (_stealth_pair_once) {
_advertisingSuppressed.store(true, std::memory_order_release);
Bluefruit.Advertising.restartOnDisconnect(false);
}
if (!_successfulConnectionPending.load(std::memory_order_acquire)) {
_successful_peer_address = peer_address;
_successfulConnectionStarted.store(
(uint32_t)millis(), std::memory_order_relaxed);
_successfulConnectionPending.store(true, std::memory_order_release);
}
}
bool SerialBLEInterface::resolveSuccessfulPeer(
mesh::companion::BluetoothPeerIdentity& peer) const {
ble_gap_addr_t lookup = _successful_peer_address;
bond_keys_t keys;
if (!bond_load_keys(BLE_GAP_ROLE_PERIPH, &lookup, &keys)) {
return false;
}
const ble_gap_addr_t& identity = keys.peer_id.id_addr_info;
if (identity.addr_type == BLE_GAP_ADDR_TYPE_PUBLIC) {
peer.type = mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC;
} else if (identity.addr_type == BLE_GAP_ADDR_TYPE_RANDOM_STATIC) {
peer.type = mesh::companion::BLUETOOTH_PEER_ADDRESS_RANDOM;
} else {
return false;
}
for (size_t i = 0; i < mesh::companion::BLUETOOTH_MAC_BYTES; i++) {
peer.address[i] = identity.addr[
mesh::companion::BLUETOOTH_MAC_BYTES - 1 - i];
}
return mesh::companion::isValidBluetoothPeerIdentity(peer);
}
bool SerialBLEInterface::configureBondedOnlyAdvertising(
const mesh::companion::BluetoothPeerIdentity& peer,
bool require_stored_bond) {
if (!mesh::companion::isValidBluetoothPeerIdentity(peer)) return false;
ble_gap_addr_t native_peer = {};
native_peer.addr_type =
peer.type == mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC
? BLE_GAP_ADDR_TYPE_PUBLIC
: BLE_GAP_ADDR_TYPE_RANDOM_STATIC;
for (size_t i = 0; i < mesh::companion::BLUETOOTH_MAC_BYTES; i++) {
native_peer.addr[i] = peer.address[
mesh::companion::BLUETOOTH_MAC_BYTES - 1 - i];
}
ble_gap_addr_t lookup = native_peer;
bond_keys_t keys;
if (!bond_load_keys(BLE_GAP_ROLE_PERIPH, &lookup, &keys)
|| keys.peer_id.id_addr_info.addr_type != native_peer.addr_type
|| memcmp(keys.peer_id.id_addr_info.addr, native_peer.addr,
sizeof(native_peer.addr)) != 0) {
if (require_stored_bond) {
requestBondedOnlyRecovery("saved peer bond is unavailable");
}
return false;
}
if (isAdvertising() && !Bluefruit.Advertising.stop()) {
requestBondedOnlyRecovery("general advertising could not stop");
return false;
}
// A bonded phone may reconnect from a resolvable private address. Loading
// its identity key lets the SoftDevice put a current RPA in TargetA instead
// of directing advertisements only to the phone's static identity address.
const ble_gap_id_key_t* peer_identities[] = {&keys.peer_id};
const uint32_t identity_error = sd_ble_gap_device_identities_set(
peer_identities, nullptr, 1);
if (identity_error != NRF_SUCCESS) {
BLE_DEBUG_PRINTLN(
"SerialBLEInterface: peer identity-list setup failed: %lu",
(unsigned long)identity_error);
requestBondedOnlyRecovery("peer identity-list setup failed");
return false;
}
// Legacy directed advertisements do not carry an advertising or scan
// response payload. Leaving the normal name/service payload configured can
// make the SoftDevice reject the directed advertising parameters.
Bluefruit.Advertising.clearData();
Bluefruit.ScanResponse.clearData();
Bluefruit.Advertising.setType(
BLE_GAP_ADV_TYPE_CONNECTABLE_NONSCANNABLE_DIRECTED);
Bluefruit.Advertising.setPeerAddress(native_peer);
Bluefruit.Advertising.restartOnDisconnect(true);
_bonded_only = true;
_stealth_pair_once = false;
_advertisingSuppressed.store(false, std::memory_order_release);
if (_isEnabled && _conn_handle == BLE_CONN_HANDLE_INVALID
&& !startAdvertising("directed advertising failed to start")) {
return false;
}
return true;
}
void SerialBLEInterface::requestBondedOnlyRecovery(const char* cause) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: stealth recovery required (%s)",
cause);
_bonded_only = true;
_stealth_pair_once = false;
_advertisingSuppressed.store(true, std::memory_order_release);
_bondedOnlyRecoveryPending.store(true, std::memory_order_release);
Bluefruit.Advertising.restartOnDisconnect(false);
if (isAdvertising()) Bluefruit.Advertising.stop();
}
bool SerialBLEInterface::advertisingAllowed() const {
return _isEnabled
&& !_advertisingSuppressed.load(std::memory_order_acquire)
&& !_bondedOnlyRecoveryPending.load(std::memory_order_acquire);
}
bool SerialBLEInterface::startAdvertising(const char* failure_cause) {
if (!advertisingAllowed()) return false;
if (Bluefruit.Advertising.start(0)) return true;
if (_bonded_only) requestBondedOnlyRecovery(failure_cause);
return false;
}
bool SerialBLEInterface::begin(const char* prefix, const char* name,
uint32_t pin_code,
const uint8_t* custom_address,
bool clear_bonds) {
bool clear_bonds, bool stealth_pair_once,
const mesh::companion::BluetoothPeerIdentity*
bonded_only_peer) {
instance = this;
_successfulConnectionPending.store(false, std::memory_order_release);
_successfulConnectionStarted.store(0, std::memory_order_relaxed);
_bondedOnlyRecoveryPending.store(false, std::memory_order_release);
_advertisingSuppressed.store(false, std::memory_order_release);
_stealth_pair_once = stealth_pair_once;
_bonded_only = false;
_bonded_only_configure_pending = false;
_pending_bonded_peer = mesh::companion::BluetoothPeerIdentity();
char charpin[20];
snprintf(charpin, sizeof(charpin), "%lu", (unsigned long)pin_code);
@@ -377,6 +543,8 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name,
bledfu.setPermission(SECMODE_ENC_WITH_MITM, SECMODE_ENC_WITH_MITM);
bledfu.begin();
Bluefruit.Advertising.setType(
BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED);
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
Bluefruit.Advertising.addTxPower();
Bluefruit.Advertising.addService(bleuart);
@@ -388,9 +556,84 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name,
Bluefruit.Advertising.restartOnDisconnect(true);
if (bonded_only_peer != nullptr) {
configureBondedOnlyAdvertising(*bonded_only_peer, true);
}
return true;
}
bool SerialBLEInterface::takeSuccessfulConnection(
mesh::companion::BluetoothPeerIdentity* peer) {
if (!_successfulConnectionPending.load(std::memory_order_acquire)) {
return false;
}
if (peer != nullptr && !resolveSuccessfulPeer(*peer)) {
// Bond persistence is deferred by Bluefruit. Leave the event pending and
// let the main loop retry after that callback has written the keys. If the
// deferred write failed, reopen pairing instead of remaining hidden until
// somebody manually power-cycles the node.
const uint32_t started = _successfulConnectionStarted.load(
std::memory_order_relaxed);
if (_stealth_pair_once
&& (uint32_t)((uint32_t)millis() - started)
>= BLE_BOND_PERSIST_TIMEOUT_MS) {
_successfulConnectionPending.store(false, std::memory_order_release);
requestBondedOnlyRecovery("new peer bond did not persist");
}
return false;
}
const bool pending = _successfulConnectionPending.exchange(
false, std::memory_order_acq_rel);
if (pending) {
_successfulConnectionStarted.store(0, std::memory_order_relaxed);
}
return pending;
}
bool SerialBLEInterface::enableBondedOnlyAdvertising(
const mesh::companion::BluetoothPeerIdentity& peer) {
if (!mesh::companion::isValidBluetoothPeerIdentity(peer)) return false;
// The SoftDevice may reject identity-list changes while a connection is
// using that list. Preserve the newly paired session and finish switching
// to directed advertising from the main loop after it disconnects.
if (_conn_handle != BLE_CONN_HANDLE_INVALID) {
_pending_bonded_peer = peer;
_bonded_only_configure_pending = true;
_bonded_only = true;
_stealth_pair_once = false;
_advertisingSuppressed.store(true, std::memory_order_release);
Bluefruit.Advertising.restartOnDisconnect(false);
return true;
}
return configureBondedOnlyAdvertising(peer, false);
}
void SerialBLEInterface::serviceBondedOnlyTransition() {
if (!_bonded_only_configure_pending
|| _conn_handle != BLE_CONN_HANDLE_INVALID) {
return;
}
const mesh::companion::BluetoothPeerIdentity peer = _pending_bonded_peer;
_bonded_only_configure_pending = false;
configureBondedOnlyAdvertising(peer, true);
}
void SerialBLEInterface::cancelStealthPairingTransition() {
if (!_stealth_pair_once) return;
_stealth_pair_once = false;
_advertisingSuppressed.store(false, std::memory_order_release);
Bluefruit.Advertising.setType(
BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED);
Bluefruit.Advertising.restartOnDisconnect(true);
if (_isEnabled && _conn_handle == BLE_CONN_HANDLE_INVALID
&& !isAdvertising()) {
startAdvertising("advertising failed after stealth cancellation");
}
}
void SerialBLEInterface::clearBuffers() {
send_queue_len = 0;
recv_queue_len = 0;
@@ -454,8 +697,8 @@ void SerialBLEInterface::serviceTxRecovery(uint32_t now) {
_peer_address_valid = false;
_security_timer.cancel();
clearBuffers();
if (_isEnabled && !isAdvertising()) {
Bluefruit.Advertising.start(0);
if (advertisingAllowed() && !isAdvertising()) {
startAdvertising("advertising failed after TX recovery");
}
return;
}
@@ -510,9 +753,7 @@ bool SerialBLEInterface::isValidConnection(uint16_t handle, bool requireWaitingF
}
bool SerialBLEInterface::isAdvertising() const {
ble_gap_addr_t adv_addr;
uint32_t err_code = sd_ble_gap_adv_addr_get(0, &adv_addr);
return (err_code == NRF_SUCCESS);
return Bluefruit.Advertising.isRunning();
}
void SerialBLEInterface::enable() {
@@ -523,8 +764,10 @@ void SerialBLEInterface::enable() {
clearBuffers();
_last_health_check = millis();
Bluefruit.Advertising.restartOnDisconnect(true);
Bluefruit.Advertising.start(0);
if (advertisingAllowed()) {
Bluefruit.Advertising.restartOnDisconnect(true);
startAdvertising("advertising failed while enabling Bluetooth");
}
}
void SerialBLEInterface::disconnect() {
@@ -568,6 +811,7 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
const uint32_t check_now = (uint32_t)millis();
serviceBondedOnlyTransition();
if (_tx_disconnect_recovery.pending()) {
serviceTxRecovery(check_now);
return 0;
@@ -655,13 +899,14 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
_security_timer.cancel();
disconnect();
}
if (_isEnabled && !isConnected() && _conn_handle == BLE_CONN_HANDLE_INVALID) {
if (advertisingAllowed() && !isConnected()
&& _conn_handle == BLE_CONN_HANDLE_INVALID) {
if (now - _last_health_check >= BLE_HEALTH_CHECK_INTERVAL) {
_last_health_check = now;
if (!isAdvertising()) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: advertising watchdog - advertising stopped, restarting");
Bluefruit.Advertising.start(0);
startAdvertising("advertising watchdog restart failed");
}
}
}
+37 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "../BaseSerialInterface.h"
#include "../BluetoothMac.h"
#include "../BleTxStallWatchdog.h"
#include "../UsbLogging.h"
#include "SecuritySessionTimer.h"
@@ -34,7 +35,16 @@ class SerialBLEInterface : public BaseSerialInterface {
ble_gap_addr_t _peer_address = {};
bool _peer_address_valid;
bool _bond_removed_for_connection;
ble_gap_addr_t _successful_peer_address = {};
bool _stealth_pair_once;
bool _bonded_only;
bool _bonded_only_configure_pending;
mesh::companion::BluetoothPeerIdentity _pending_bonded_peer;
std::atomic<bool> _advertisingSuppressed{false};
std::atomic<bool> _bondedOnlyRecoveryPending{false};
std::atomic<bool> _pairingRequestPending{false};
std::atomic<bool> _successfulConnectionPending{false};
std::atomic<uint32_t> _successfulConnectionStarted{0};
SecuritySessionTimer _security_timer;
mesh::BleTxStallWatchdog _tx_stall_watchdog;
mesh::BleDisconnectRecovery _tx_disconnect_recovery;
@@ -59,6 +69,16 @@ class SerialBLEInterface : public BaseSerialInterface {
void recoverStalledTx(const char* cause);
void serviceTxRecovery(uint32_t now);
bool removeStoredBondForPeer(const char* cause);
void noteSuccessfulConnection(const ble_gap_addr_t& peer_address);
bool resolveSuccessfulPeer(
mesh::companion::BluetoothPeerIdentity& peer) const;
bool configureBondedOnlyAdvertising(
const mesh::companion::BluetoothPeerIdentity& peer,
bool require_stored_bond);
void serviceBondedOnlyTransition();
void requestBondedOnlyRecovery(const char* cause);
bool advertisingAllowed() const;
bool startAdvertising(const char* failure_cause);
bool isValidConnection(uint16_t handle, bool requireWaitingForSecurity = false) const;
bool isAdvertising() const;
static void onConnect(uint16_t connection_handle);
@@ -85,6 +105,9 @@ public:
_last_retry_attempt = 0;
_peer_address_valid = false;
_bond_removed_for_connection = false;
_stealth_pair_once = false;
_bonded_only = false;
_bonded_only_configure_pending = false;
send_queue_len = 0;
recv_queue_len = 0;
}
@@ -96,10 +119,14 @@ public:
* @param pin_code the BLE security pin
* @param custom_address optional human-order BLE random-static address
* @param clear_bonds clear saved peer bonds before accepting connections
* @param stealth_pair_once suppress general advertising after first pairing
* @param bonded_only_peer optional peer allowed to reconnect in stealth mode
*/
bool begin(const char* prefix, const char* name, uint32_t pin_code,
const uint8_t* custom_address = nullptr,
bool clear_bonds = false);
bool clear_bonds = false, bool stealth_pair_once = false,
const mesh::companion::BluetoothPeerIdentity*
bonded_only_peer = nullptr);
void disconnect();
void enable() override;
@@ -112,6 +139,15 @@ public:
bool takePairingRequest() override {
return _pairingRequestPending.exchange(false, std::memory_order_acq_rel);
}
bool takeSuccessfulConnection(
mesh::companion::BluetoothPeerIdentity* peer = nullptr);
bool enableBondedOnlyAdvertising(
const mesh::companion::BluetoothPeerIdentity& peer);
void cancelStealthPairingTransition();
bool takeBondedOnlyRecovery() {
return _bondedOnlyRecoveryPending.exchange(
false, std::memory_order_acq_rel);
}
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
+10 -1
View File
@@ -20,6 +20,10 @@ python3 test/test_companion_transport_selector.py # Indicator transport-selecto
python3 test/test_color_theme.py # shared color-display dark-palette contract
python3 test/test_indicator_font_recovery.py # Indicator TLS/SD font recovery contract
python3 test/test_companion_terminal_profile.py # Companion CLI capability gates
python3 test/test_bluetooth_mac_contract.py # MAC policies, separate stealth flag and BLE backend integration
python3 test/test_companion_settings_persistence_contract.py # Atomic settings and appended Bluetooth fields
python3 test/test_webconfig_ui.py # Web controls, reboot handling and generated-page consistency
python3 test/test_webconfig_ui_runtime.py # Real Chromium, including independent stealth toggle
python3 test/test_nrf52_uf2reset_cli.py # all nRF52 text-CLI reset dispatchers
python3 test/test_client_login_profile_contract.py # ACL login ordering/role contract
python3 test/test_client_acl_spiffs.py # Actual ACL: first login, replay/reboot, failed storage
@@ -40,6 +44,11 @@ python3 test/test_temp_radio_reply_delivery_contract.py # TempRadio ACK path/ba
python3 test/test_tls_download_clock_gates.py # Fresh-NTP/TLS download integration contract
```
The WebConfig browser suite skips if no Chromium-family browser is available.
With a sandboxed browser that cannot read `/tmp`, set `TMPDIR` to a writable
directory visible to that browser before running it. The Bluetooth settings
contracts and WebConfig suites also run in the unit-test GitHub workflow.
A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions
passed). PlatformIO's "0 test cases" line is just its Unity-style counter and
does not reflect the GoogleTest count -- run the built binary directly
@@ -96,7 +105,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_lazy_persistence` | `src/helpers/LazyPersistence.h` | first-write scheduling without postponement, zero-sentinel rollover preservation, capped exponential save-failure backoff that mutations cannot defeat, and reset only after success |
| `test_client_acl_file_transaction` | `src/helpers/ClientACLFileTransaction.h` | verified temp publication, preservation of the prior ACL on verification/rename failure, post-commit cleanup-failure handling, and recovery at every temp/backup/primary boundary |
| `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, FEM, Bluetooth name, and Bluetooth address preferences; random-static address validation/generation; one-time migration of the regressed power-saving default |
| `test_companion_node_prefs` | `examples/companion_radio/NodePrefs.h` | independent device power saving, RXPS, Wi-Fi, FEM, Bluetooth name, address policy and stealth flag; random-static address validation/generation; idempotent stealth toggles, pairing reset and armed rotation policy matching; 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/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 |
+133 -2
View File
@@ -26,9 +26,12 @@ class BluetoothMacContractTests(unittest.TestCase):
for header in (self.nrf_header, self.esp_header):
self.assertIn("const uint8_t* custom_address = nullptr", header)
self.assertIn("bool clear_bonds = false", header)
self.assertIn("bool stealth_pair_once = false", header)
self.assertIn("bonded_only_peer", header)
self.assertIn("the_mesh.getBLEPin(), bluetooth_address,", self.main)
self.assertIn("clear_bonds))", self.main)
self.assertIn("clear_bonds, stealth_pair_once,", self.main)
self.assertIn("bonded_only_peer_ptr))", self.main)
def test_nrf52_uses_random_static_softdevice_address(self):
self.assertIn("BLE_GAP_ADDR_TYPE_RANDOM_STATIC", self.nrf_source)
@@ -53,15 +56,143 @@ class BluetoothMacContractTests(unittest.TestCase):
)
self.assertIn("clear_bonds = true;", self.main)
def test_cli_exposes_custom_random_every_boot_and_default(self):
def test_cli_exposes_all_address_modes(self):
self.assertIn('"get bluetooth.mac"', self.mesh)
self.assertIn('"set bluetooth.mac"', self.mesh)
self.assertIn('strcmp(value, "random")', self.mesh)
self.assertIn('strcmp(value, "random-every-boot")', self.mesh)
self.assertIn('strcmp(value, "random everyboot")', self.mesh)
self.assertIn('strcmp(value, "random-after-connect")', self.mesh)
self.assertNotIn('strcmp(value, "stealth")', self.mesh)
self.assertIn('strcmp(value, "default")', self.mesh)
self.assertIn("parseBluetoothMac(custom_value, address)", self.mesh)
def test_stealth_is_a_separate_idempotent_flag(self):
self.assertIn('"get bluetooth.stealth"', self.mesh)
self.assertIn('"set bluetooth.stealth"', self.mesh)
self.assertIn('"get ble.stealth"', self.mesh)
self.assertIn('"set ble.stealth"', self.mesh)
setter = self.mesh[
self.mesh.index("bool MyMesh::applyAndSaveBluetoothStealth("):
self.mesh.index("void MyMesh::formatBluetoothStealthStatus(")
]
self.assertIn('strcmp(value, "on")', setter)
self.assertIn('strcmp(value, "off")', setter)
self.assertIn("setCompanionBluetoothStealth(_prefs,", setter)
self.assertIn("&& !savePrefs()", setter)
self.assertIn("_prefs.bluetooth_stealth_mode = previous_mode", setter)
self.assertNotIn("saveBluetoothMac(", setter)
self.assertNotIn("_prefs.bluetooth_mac_mode =", setter)
self.assertNotIn("getRNG()", setter)
self.assertNotIn("|stealth|", self.mesh)
def test_rotation_and_stealth_share_the_authenticated_event(self):
self.assertIn("companion_bluetooth_session_stealth_mode", self.main)
self.assertIn("prefs->bluetooth_stealth_mode", self.main)
self.assertIn("bluetoothMacPoliciesMatch(", self.main)
start = self.mesh.index("bool MyMesh::prepareBluetoothMacForBoot(")
end = self.mesh.index("bool MyMesh::armBluetoothMacRotationAfterConnection(")
self.assertIn("BLUETOOTH_MAC_RANDOM_EVERY_BOOT", self.mesh[start:end])
self.assertIn("resetBluetoothStealthPairing()", self.mesh[start:end])
start = self.mesh.index("bool MyMesh::saveBluetoothStealthPeer(")
end = self.mesh.index("bool MyMesh::resetBluetoothStealthPairing()")
save_peer = self.mesh[start:end]
self.assertLess(save_peer.index("BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED"),
save_peer.index("if (savePrefs())"))
self.assertIn("_prefs.bluetooth_mac_mode = previous_mode", save_peer)
start = self.mesh.index("bool MyMesh::saveBluetoothMac(")
end = self.mesh.index("bool MyMesh::applyAndSaveBluetoothMac(")
self.assertIn("clearCompanionBluetoothStealthPeer(_prefs)", self.mesh[start:end])
self.assertIn("_prefs.bluetooth_stealth_mode = previous_stealth_mode",
self.mesh[start:end])
def test_web_snapshot_and_dispatch_keep_flag_separate(self):
self.assertIn('strcmp(key, "bluetooth.stealth")', self.mesh)
self.assertIn("s.bluetooth_stealth =", self.mesh)
self.assertNotIn('sizeof(s.bluetooth_mac), "stealth"', self.mesh)
self.assertIn("CAP_BLUETOOTH_STEALTH", self.mesh)
def test_random_after_connect_arms_after_authentication(self):
self.assertIn("takeSuccessfulConnection(", self.nrf_header)
self.assertIn("takeSuccessfulConnection(", self.esp_header)
self.assertIn(
"_successfulConnectionPending.store(", self.nrf_source
)
self.assertIn(
"_successfulConnectionPending.store(", self.esp_source
)
self.assertIn("prepareBluetoothMacForBoot", self.main)
self.assertIn("armBluetoothMacRotationAfterConnection", self.main)
self.assertIn("bluetoothMacModeIsRandomAfterConnect", self.main)
self.assertIn(
"companion_bluetooth_clear_bonds_this_boot", self.main
)
def test_stealth_switches_to_bonded_peer_only_advertising(self):
for header in (self.nrf_header, self.esp_header):
self.assertIn("enableBondedOnlyAdvertising", header)
self.assertIn("takeBondedOnlyRecovery", header)
self.assertIn("cancelStealthPairingTransition", header)
self.assertIn(
"BLE_GAP_ADV_TYPE_CONNECTABLE_NONSCANNABLE_DIRECTED",
self.nrf_source,
)
self.assertIn("Bluefruit.Advertising.clearData()", self.nrf_source)
self.assertIn("Bluefruit.ScanResponse.clearData()", self.nrf_source)
self.assertIn("setPeerAddress(native_peer)", self.nrf_source)
self.assertIn("sd_ble_gap_device_identities_set(", self.nrf_source)
self.assertIn("serviceBondedOnlyTransition()", self.nrf_source)
self.assertIn("_bonded_only_configure_pending", self.nrf_source)
self.assertIn("ble_gap_wl_set(&native_peer, 1)", self.esp_source)
self.assertIn("esp_ble_gap_clear_whitelist()", self.esp_source)
self.assertIn("esp_ble_gap_update_whitelist", self.esp_source)
self.assertIn("advertising->setScanFilter(true, true)", self.esp_source)
self.assertIn("advertising->setScanResponse(false)", self.esp_source)
self.assertIn("saveBluetoothStealthPeer", self.main)
def test_stealth_holds_general_advertising_during_transition(self):
for source_text in (self.nrf_source, self.esp_source):
self.assertIn("_advertisingSuppressed.store(", source_text)
self.assertIn("advertisingAllowed()", source_text)
callback_at = source_text.index(
"void SerialBLEInterface::noteSuccessfulConnection"
)
callback_end = source_text.index("\n}", callback_at)
callback = source_text[callback_at:callback_end]
self.assertLess(
callback.index("_advertisingSuppressed.store("),
callback.index("_successfulConnectionPending.store("),
)
self.assertIn("restartOnDisconnect(false)", self.nrf_source)
self.assertIn(
"_conn_handle != BLE_CONN_HANDLE_INVALID",
self.nrf_source[
self.nrf_source.index(
"bool SerialBLEInterface::enableBondedOnlyAdvertising"
) : self.nrf_source.index(
"void SerialBLEInterface::serviceBondedOnlyTransition"
)
],
)
pairing_callback = self.nrf_source.index(
"void SerialBLEInterface::onPairingComplete"
)
pairing_success = self.nrf_source.index(
"pairing successful", pairing_callback
)
next_callback = self.nrf_source.index(
"void SerialBLEInterface::onBLEEvent", pairing_success
)
self.assertIn(
"noteSuccessfulConnection(conn->getPeerAddr())",
self.nrf_source[pairing_success:next_callback],
)
self.assertIn("resetBluetoothStealthPairing", self.main)
for source_text in (self.nrf_source, self.esp_source):
self.assertIn("BLE_BOND_PERSIST_TIMEOUT_MS", source_text)
self.assertIn("new peer bond did not persist", source_text)
if __name__ == "__main__":
unittest.main()
@@ -165,6 +165,10 @@ TEST(CompanionNodePrefs, BluetoothMacDefaultsToFactoryIdentity) {
prefs.bluetooth_mac_mode);
EXPECT_FALSE(mesh::companion::hasCustomBluetoothMac(
prefs.bluetooth_mac));
EXPECT_EQ(mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE,
prefs.bluetooth_stealth_peer_type);
EXPECT_EQ(mesh::companion::BLUETOOTH_STEALTH_OFF,
prefs.bluetooth_stealth_mode);
}
TEST(CompanionNodePrefs, BluetoothMacParsesAndFormatsRandomStaticAddress) {
@@ -219,6 +223,27 @@ TEST(CompanionNodePrefs, BluetoothMacRandomBytesAreNormalized) {
EXPECT_EQ(0xFE, all_one[5]);
}
TEST(CompanionNodePrefs,
BluetoothMacRandomAfterConnectCannotReuseTheOldAddress) {
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES] = {
0xC0, 0x00, 0x00, 0x00, 0x00, 0x01};
uint8_t previous[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous, address, sizeof(previous));
mesh::companion::makeRandomStaticBluetoothMacDifferentFrom(
address, previous);
EXPECT_TRUE(mesh::companion::isValidBluetoothMac(address));
EXPECT_NE(0, memcmp(address, previous, sizeof(address)));
memset(address, 0xFF, sizeof(address));
address[5] = 0xFE;
memcpy(previous, address, sizeof(previous));
mesh::companion::makeRandomStaticBluetoothMacDifferentFrom(
address, previous);
EXPECT_TRUE(mesh::companion::isValidBluetoothMac(address));
EXPECT_NE(0, memcmp(address, previous, sizeof(address)));
}
TEST(CompanionNodePrefs, BluetoothMacModesDescribeSavedAndPerBootUse) {
EXPECT_TRUE(mesh::companion::isValidBluetoothMacMode(
mesh::companion::BLUETOOTH_MAC_DEFAULT));
@@ -228,15 +253,131 @@ TEST(CompanionNodePrefs, BluetoothMacModesDescribeSavedAndPerBootUse) {
mesh::companion::BLUETOOTH_MAC_RANDOM_SAVED));
EXPECT_TRUE(mesh::companion::isValidBluetoothMacMode(
mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT));
EXPECT_FALSE(mesh::companion::isValidBluetoothMacMode(4));
EXPECT_TRUE(mesh::companion::isValidBluetoothMacMode(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT));
EXPECT_TRUE(mesh::companion::isValidBluetoothMacMode(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED));
EXPECT_FALSE(mesh::companion::isValidBluetoothMacMode(6));
EXPECT_FALSE(mesh::companion::isValidBluetoothMacMode(7));
EXPECT_FALSE(mesh::companion::isValidBluetoothMacMode(8));
EXPECT_TRUE(mesh::companion::bluetoothMacModeUsesSavedAddress(
mesh::companion::BLUETOOTH_MAC_CUSTOM));
EXPECT_TRUE(mesh::companion::bluetoothMacModeUsesSavedAddress(
mesh::companion::BLUETOOTH_MAC_RANDOM_SAVED));
EXPECT_TRUE(mesh::companion::bluetoothMacModeUsesSavedAddress(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT));
EXPECT_FALSE(mesh::companion::bluetoothMacModeUsesSavedAddress(
mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT));
}
TEST(CompanionNodePrefs,
BluetoothMacRandomAfterConnectRotatesOnlyWhenArmedOrInvalid) {
const uint8_t valid[mesh::companion::BLUETOOTH_MAC_BYTES] = {
0xC2, 0x11, 0x22, 0x33, 0x44, 0x55};
const uint8_t invalid[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
EXPECT_TRUE(mesh::companion::bluetoothMacModeIsRandomAfterConnect(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT));
EXPECT_TRUE(mesh::companion::bluetoothMacModeIsRandomAfterConnect(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED));
EXPECT_FALSE(mesh::companion::bluetoothMacModeIsRandomAfterConnect(
mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT));
EXPECT_FALSE(mesh::companion::bluetoothMacShouldRotateAtBoot(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT, valid));
EXPECT_TRUE(mesh::companion::bluetoothMacShouldRotateAtBoot(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED, valid));
EXPECT_TRUE(mesh::companion::bluetoothMacShouldRotateAtBoot(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT, invalid));
EXPECT_FALSE(mesh::companion::bluetoothMacShouldRotateAtBoot(
mesh::companion::BLUETOOTH_MAC_RANDOM_SAVED, invalid));
}
TEST(CompanionNodePrefs, BluetoothStealthTracksPairingIndependently) {
EXPECT_TRUE(mesh::companion::bluetoothStealthEnabled(
mesh::companion::BLUETOOTH_STEALTH_PAIRING));
EXPECT_TRUE(mesh::companion::bluetoothStealthEnabled(
mesh::companion::BLUETOOTH_STEALTH_PAIRED));
EXPECT_FALSE(mesh::companion::bluetoothStealthEnabled(
mesh::companion::BLUETOOTH_STEALTH_OFF));
EXPECT_FALSE(mesh::companion::isValidBluetoothStealthMode(3));
EXPECT_FALSE(mesh::companion::bluetoothStealthEnabled(255));
mesh::companion::BluetoothPeerIdentity peer;
EXPECT_FALSE(mesh::companion::isValidBluetoothPeerIdentity(peer));
peer.type = mesh::companion::BLUETOOTH_PEER_ADDRESS_RANDOM;
peer.address[0] = 0xC2;
peer.address[5] = 0x55;
EXPECT_TRUE(mesh::companion::isValidBluetoothPeerIdentity(peer));
peer.type = 3;
EXPECT_FALSE(mesh::companion::isValidBluetoothPeerIdentity(peer));
}
TEST(CompanionNodePrefs, StealthFlagPreservesEveryMacPolicyAndAddress) {
const uint8_t address[] = {0xC2, 0x11, 0x22, 0x33, 0x44, 0x55};
for (uint8_t mode = mesh::companion::BLUETOOTH_MAC_DEFAULT;
mode <= mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
mode++) {
CompanionNodePrefs prefs;
prefs.bluetooth_mac_mode = mode;
memcpy(prefs.bluetooth_mac, address, sizeof(address));
EXPECT_TRUE(setCompanionBluetoothStealth(prefs, true));
EXPECT_EQ(mesh::companion::BLUETOOTH_STEALTH_PAIRING,
prefs.bluetooth_stealth_mode);
EXPECT_EQ(mode, prefs.bluetooth_mac_mode);
EXPECT_EQ(0, memcmp(address, prefs.bluetooth_mac, sizeof(address)));
prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRED;
prefs.bluetooth_stealth_peer_type = mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC;
memcpy(prefs.bluetooth_stealth_peer, address, sizeof(address));
EXPECT_FALSE(setCompanionBluetoothStealth(prefs, true));
EXPECT_EQ(mesh::companion::BLUETOOTH_STEALTH_PAIRED,
prefs.bluetooth_stealth_mode);
EXPECT_EQ(0, memcmp(address, prefs.bluetooth_stealth_peer, sizeof(address)));
EXPECT_TRUE(setCompanionBluetoothStealth(prefs, false));
EXPECT_EQ(mesh::companion::BLUETOOTH_STEALTH_OFF, prefs.bluetooth_stealth_mode);
EXPECT_EQ(mode, prefs.bluetooth_mac_mode);
EXPECT_EQ(0, memcmp(address, prefs.bluetooth_mac, sizeof(address)));
EXPECT_EQ(mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE,
prefs.bluetooth_stealth_peer_type);
const uint8_t empty[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
EXPECT_EQ(0, memcmp(empty, prefs.bluetooth_stealth_peer, sizeof(empty)));
EXPECT_FALSE(setCompanionBluetoothStealth(prefs, false));
}
}
TEST(CompanionNodePrefs, AddressChangeReopensPairingWithoutDisablingStealth) {
CompanionNodePrefs prefs;
prefs.bluetooth_mac_mode = mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRED;
prefs.bluetooth_stealth_peer_type = mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC;
memset(prefs.bluetooth_stealth_peer, 0x12, sizeof(prefs.bluetooth_stealth_peer));
clearCompanionBluetoothStealthPeer(prefs);
EXPECT_EQ(mesh::companion::BLUETOOTH_STEALTH_PAIRING,
prefs.bluetooth_stealth_mode);
EXPECT_EQ(mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED,
prefs.bluetooth_mac_mode);
EXPECT_EQ(mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE,
prefs.bluetooth_stealth_peer_type);
prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_OFF;
clearCompanionBluetoothStealthPeer(prefs);
EXPECT_EQ(mesh::companion::BLUETOOTH_STEALTH_OFF, prefs.bluetooth_stealth_mode);
}
TEST(CompanionNodePrefs, ArmingRotationDoesNotChangeSessionPolicy) {
EXPECT_TRUE(mesh::companion::bluetoothMacPoliciesMatch(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT,
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED));
EXPECT_TRUE(mesh::companion::bluetoothMacPoliciesMatch(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED,
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT));
EXPECT_FALSE(mesh::companion::bluetoothMacPoliciesMatch(
mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT,
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT));
}
TEST(CompanionNodePrefs, MigratesRegressedPowerSavingDefaultOnce) {
CompanionNodePrefs prefs = {};
prefs.powersaving_enabled = 0;
@@ -115,8 +115,18 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase):
cad_at = self.prefs.index("uint16_t cad_max_duration_ms")
mode_at = self.prefs.index("uint8_t bluetooth_mac_mode", cad_at)
address_at = self.prefs.index("uint8_t bluetooth_mac[", mode_at)
peer_type_at = self.prefs.index(
"uint8_t bluetooth_stealth_peer_type", address_at
)
peer_at = self.prefs.index(
"uint8_t bluetooth_stealth_peer[", peer_type_at
)
stealth_at = self.prefs.index("uint8_t bluetooth_stealth_mode", peer_at)
self.assertLess(cad_at, mode_at)
self.assertLess(mode_at, address_at)
self.assertLess(address_at, peer_type_at)
self.assertLess(peer_type_at, peer_at)
self.assertLess(peer_at, stealth_at)
load = function_body(
self.store,
@@ -130,7 +140,22 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase):
read_address_at = load.index(
"readOptionalField(loaded_prefs.bluetooth_mac", read_mode_at
)
read_peer_type_at = load.index(
"readOptionalField(&loaded_prefs.bluetooth_stealth_peer_type",
read_address_at,
)
read_peer_at = load.index(
"readOptionalField(loaded_prefs.bluetooth_stealth_peer",
read_peer_type_at,
)
read_stealth_at = load.index(
"readOptionalField(&loaded_prefs.bluetooth_stealth_mode", read_peer_at
)
self.assertIn("sizeof(loaded_prefs.bluetooth_stealth_mode)", load)
self.assertLess(read_mode_at, read_address_at)
self.assertLess(read_address_at, read_peer_type_at)
self.assertLess(read_peer_type_at, read_peer_at)
self.assertLess(read_peer_at, read_stealth_at)
save = function_body(
self.store,
@@ -140,7 +165,17 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase):
write_address_at = save.index(
"(uint8_t *)_prefs.bluetooth_mac,", write_mode_at
)
write_peer_type_at = save.index(
"&_prefs.bluetooth_stealth_peer_type", write_address_at
)
write_peer_at = save.index(
"(uint8_t *)_prefs.bluetooth_stealth_peer,", write_peer_type_at
)
write_stealth_at = save.index("&_prefs.bluetooth_stealth_mode", write_peer_at)
self.assertLess(write_mode_at, write_address_at)
self.assertLess(write_address_at, write_peer_type_at)
self.assertLess(write_peer_type_at, write_peer_at)
self.assertLess(write_peer_at, write_stealth_at)
def test_channel_source_discovery_and_open_failures_latch_quarantine(self):
load = function_body(self.store, "void DataStore::loadChannels(")
@@ -10,6 +10,7 @@ TEST(WebConfigKeys, AllowsKnownScalarKeys) {
EXPECT_TRUE(wcIsAllowedSetKey("name"));
EXPECT_TRUE(wcIsAllowedSetKey("bluetooth.name"));
EXPECT_TRUE(wcIsAllowedSetKey("bluetooth.mac"));
EXPECT_TRUE(wcIsAllowedSetKey("bluetooth.stealth"));
EXPECT_TRUE(wcIsAllowedSetKey("radio"));
EXPECT_TRUE(wcIsAllowedSetKey("radio.rxps"));
EXPECT_TRUE(wcIsAllowedSetKey("powersaving"));
@@ -36,6 +37,7 @@ TEST(WebConfigKeys, RejectsDangerousOrUnknownKeys) {
EXPECT_FALSE(wcIsAllowedSetKey("password"));
EXPECT_FALSE(wcIsAllowedSetKey("reboot"));
EXPECT_FALSE(wcIsAllowedSetKey("bogus"));
EXPECT_FALSE(wcIsAllowedSetKey("bluetooth.stealth.extra"));
EXPECT_FALSE(wcIsAllowedSetKey("mqtt1.bogus")); // unknown slot field
EXPECT_FALSE(wcIsAllowedSetKey(""));
}
@@ -72,6 +74,8 @@ TEST(WebConfigKeys, IsCaseSensitive) {
TEST(WebConfigKeys, AddressChangingKeysAlwaysRequireAReboot) {
EXPECT_TRUE(wcSetKeyRequiresReboot("espnow.channel"));
EXPECT_TRUE(wcSetKeyRequiresReboot("bluetooth.mac"));
EXPECT_TRUE(wcSetKeyRequiresReboot("bluetooth.stealth"));
EXPECT_FALSE(wcSetKeyRequiresReboot("bluetooth.stealth.extra"));
EXPECT_FALSE(wcSetKeyRequiresReboot(NULL));
EXPECT_FALSE(wcSetKeyRequiresReboot("espnow.channel.extra"));
EXPECT_FALSE(wcSetKeyRequiresReboot("ESPNOW.channel"));
+13
View File
@@ -194,9 +194,22 @@ class WebConfigUiTest(unittest.TestCase):
self.assertIn('data-k="bluetooth.mac"', page)
self.assertIn('case"bluetooth.mac":return r.bluetooth_mac||"";', page)
self.assertIn("random-every-boot", page)
self.assertIn("random-after-connect", page)
self.assertNotIn("random-after-connect, or stealth", page)
self.assertIn('("bluetooth.mac" in setmap)', page)
self.assertIn("Bluetooth address saved [OK] - rebooting", page)
def test_stealth_flag_has_independent_control_and_reboot_flow(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertIn('data-cap="131072"><label>Bluetooth stealth</label>', page)
self.assertIn('data-k="bluetooth.stealth"', page)
self.assertIn('case"bluetooth.stealth":return r.bluetooth_stealth?"on":"off";', page)
self.assertIn('("bluetooth.stealth" in setmap)', page)
self.assertIn('("bluetooth.stealth" in st.dirty)', page)
self.assertIn('("bluetooth.stealth" in submitted)', page)
self.assertIn("Bluetooth stealth saved [OK] - rebooting", page)
self.assertNotIn("|stealth|", page)
def test_scan_panel_exposes_automatic_updates_accessibly(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertIn('id="wz-scan-btn"', page)
+49
View File
@@ -347,6 +347,55 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
self.assertIn('data-test-dirty-name="Early App Edit"', dom)
self.assertIn('data-test-capture="clear"', dom)
def test_bluetooth_stealth_toggle_preserves_custom_mac(self):
status, config = self.setup_values()
status["mode"] = "lan"
status["capabilities"] = (1 << 16) | (1 << 17)
config["radio"]["bluetooth_mac"] = "C2:11:22:33:44:55"
config["radio"]["bluetooth_stealth"] = False
prelude = """
<script>
(function(){
var status=%s,config=%s;
function response(value){
return {ok:true,status:200,json:function(){return Promise.resolve(value)}};
}
window.fetch=function(path){
if(path==="/api/status")return Promise.resolve(response(status));
if(path==="/api/config")return Promise.resolve(response(config));
return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({})}});
};
window.addEventListener("load",function(){
setTimeout(function(){
var flag=document.querySelector('#v-app [data-k="bluetooth.stealth"]');
var mac=document.querySelector('#v-app [data-k="bluetooth.mac"]');
var body=document.body;
body.setAttribute("data-test-initial-flag",flag.value);
flag.value="on";
flag.dispatchEvent(new Event("input",{bubbles:true}));
flag.dispatchEvent(new Event("change",{bubbles:true}));
body.setAttribute("data-test-dirty-flag",st.dirty["bluetooth.stealth"]||"");
body.setAttribute("data-test-dirty-keys",Object.keys(st.dirty).join(","));
body.setAttribute("data-test-mac",mac.value);
body.setAttribute("data-test-config-mac",cfgVal("bluetooth.mac"));
flag.value="off";
flag.dispatchEvent(new Event("input",{bubbles:true}));
flag.dispatchEvent(new Event("change",{bubbles:true}));
body.setAttribute("data-test-restored-dirty-count",Object.keys(st.dirty).length);
},500);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-initial-flag="off"', dom)
self.assertIn('data-test-dirty-flag="on"', dom)
self.assertIn('data-test-dirty-keys="bluetooth.stealth"', dom)
self.assertIn('data-test-mac="C2:11:22:33:44:55"', dom)
self.assertIn('data-test-config-mac="C2:11:22:33:44:55"', dom)
self.assertIn('data-test-restored-dirty-count="0"', dom)
def test_second_load_auto_password_clear_remains_restorable(self):
status, config = self.setup_values()
prelude = """
+14 -5
View File
@@ -361,7 +361,10 @@ body.tab-cli{padding-bottom:0}
<div class="hint">Optional complete Bluetooth device name. Leave blank to follow the node name.</div></div>
<div class="f" data-cap="65536"><label>Bluetooth address</label>
<input type="text" data-k="bluetooth.mac" maxlength="24" placeholder="Use factory address">
<div class="hint">Use C2:11:22:33:44:55, random, or random-every-boot. Clear to restore the factory address. Reboot and re-pair after a change.</div></div>
<div class="hint">Use C2:11:22:33:44:55, random, random-every-boot, or random-after-connect. Clear restores the factory address. This does not change the stealth flag.</div></div>
<div class="f" data-cap="131072"><label>Bluetooth stealth</label>
<select data-k="bluetooth.stealth"><option value="off">Off</option><option value="on">On</option></select>
<div class="hint">Independent of the address setting. Advertise normally until first pairing, then accept only that bonded peer. An address rotation reopens pairing. Changes require a reboot.</div></div>
<div class="row" data-cap="1">
<div class="f"><label>Latitude</label><input type="number" data-k="lat" step="any"></div>
<div class="f"><label>Longitude</label><input type="number" data-k="lon" step="any"></div>
@@ -780,6 +783,7 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
switch(k){
case"name":return r.name;case"bluetooth.name":return r.bluetooth_name||"";
case"bluetooth.mac":return r.bluetooth_mac||"";
case"bluetooth.stealth":return r.bluetooth_stealth?"on":"off";
case"lat":return String(r.lat);case"lon":return String(r.lon);
case"tx":return String(r.tx);case"af":return String(r.af);
case"rxdelay":return String(r.rxdelay);case"txdelay":return String(r.txdelay);
@@ -1181,7 +1185,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)||("espnow.channel" in setmap)||("bluetooth.mac" in setmap))body.reboot=true;
if((extra&&extra.reboot)||("wifi.ssid" in setmap)||("wifi.pwd" in setmap)||("espnow.channel" in setmap)||("bluetooth.mac" in setmap)||("bluetooth.stealth" 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;
@@ -1217,7 +1221,7 @@ function saveChanges(extra,cb){
});
}
function appSave(){
var rebootChanged=("wifi.ssid" in st.dirty)||("wifi.pwd" in st.dirty)||("espnow.channel" in st.dirty)||("bluetooth.mac" in st.dirty);
var rebootChanged=("wifi.ssid" in st.dirty)||("wifi.pwd" in st.dirty)||("espnow.channel" in st.dirty)||("bluetooth.mac" in st.dirty)||("bluetooth.stealth" in st.dirty);
var pwdErr=adminPwdError(rebootChanged);
if(pwdErr){
$('#tabs button[data-t="radio"]').click();
@@ -1260,12 +1264,15 @@ function pollResult(cb,errs,idles,acked,reqid,submitted){
else if(r.reboot){
st.dirty={};
var bluetoothAddressChanged=submitted&&("bluetooth.mac" in submitted);
var bluetoothStealthChanged=submitted&&("bluetooth.stealth" in submitted);
showReboot(r.wifi_ip
? "The node joined the selected WiFi. This page and address stay visible while it reboots. Reconnect your phone or laptop to that network, then open the address."
: bluetoothAddressChanged
? "The node is restarting with its new Bluetooth address. Forget its old Bluetooth entry, scan again, and pair with the new identity."
: bluetoothStealthChanged
? "The node is restarting with the saved stealth flag. Its address policy is unchanged. When enabling stealth, pair once; afterward reconnect using the saved device."
: "The node is restarting with the saved WiFi settings. Reconnect to that network and open the node at its router-assigned address.",
false,bluetoothAddressChanged?"Bluetooth address saved [OK] - rebooting":"Settings saved [OK] - rebooting",r.wifi_ip,rebootSeconds(r));
false,bluetoothAddressChanged?"Bluetooth address saved [OK] - rebooting":bluetoothStealthChanged?"Bluetooth stealth saved [OK] - rebooting":"Settings saved [OK] - rebooting",r.wifi_ip,rebootSeconds(r));
return;
}else toast("Saved [OK]");
loadConfigSoft();
@@ -1822,7 +1829,9 @@ var CLI_VERBS=[
["powersaving on","Enable power saving"],
["powersaving off","Disable power saving"],
["get bluetooth.mac","Show the Bluetooth address mode"],
["set bluetooth.mac ","Set address {C2:11:22:33:44:55|random|random everyboot|default}"],
["set bluetooth.mac ","Set address {address|random|random-every-boot|random-after-connect|default}"],
["get bluetooth.stealth","Show the independent Bluetooth stealth flag"],
["set bluetooth.stealth ","Set stealth {on|off}, keeping the address policy"],
["password ","Change the admin password {new-password}"],
["alert test","Send a test alert on the configured channel"],
["ota check","Check for a newer build (does not flash)"],