Add configurable Companion Bluetooth addresses

This commit is contained in:
mikecarper committed 2026-09-07 18:29:00 -07:00
1 parent b25005f52f
commit 4b400ea56b
26 files changed
+2723 -1966

No files matched your search

+1
View File
@@ -70,6 +70,7 @@ over the normal binary USB, BLE, or TCP connection:
| [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Companion on a board with controllable LoRa FEM LNA |
| [`get/set wifi.powersave`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | ESP32 WiFi Companion; active transport constraints still apply |
| [`get/set bluetooth.name`](cli_commands.md#view-or-change-the-independent-bluetooth-name-companion) | Companion firmware |
| [`get/set bluetooth.mac`](cli_commands.md#view-or-change-the-bluetooth-address-ble-companion) | Every Companion build with Bluetooth |
| [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | Every nRF52 Companion text terminal and local command `0x42`; local only |
Deprecated binary aliases remain receive-only for older clients; new clients
+33
View File
@@ -1180,6 +1180,39 @@ the frame and reply format.
---
#### View or change the Bluetooth address (BLE Companion)
**Usage:**
- `get bluetooth.mac`
- `set bluetooth.mac <address|random|random-every-boot|default>`
`get ble.mac` and `set ble.mac ...` are accepted aliases. The setting is
available on every Companion build that includes Bluetooth.
| Value | Result after reboot |
|---|---|
| `C2:11:22:33:44:55` | Save and use that custom address. |
| `random` | Generate one random address now, save it, and reuse it on later boots. |
| `random-every-boot` | Generate a new address once at each boot. `random everyboot` is also accepted. |
| `default` or `clear` | Remove the override and use the chipset's factory Bluetooth address. |
A custom value must be a valid BLE random-static address in
`XX:XX:XX:XX:XX:XX` form. Its first byte must be from `C0` through `FF`, and
you are responsible for keeping addresses unique. Hyphens are also accepted
on input; status output uses uppercase colons.
Every change requires a reboot and invalidates the phone's previous view of
the device. Forget the old device entry and pair again. `random-every-boot`
also clears the Companion's saved peer bonds during each startup, so pairing
and PIN entry are expected again after every reboot.
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.
---
#### View or change this node's latitude
**Usage:**
- `get lat`
+10
View File
@@ -289,6 +289,7 @@ 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>` |
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
@@ -324,6 +325,15 @@ The Bluetooth name can be configured over USB, BLE, or TCP. Use
value is rejected. (`clear` is also accepted as an alias for `default`.) A
custom name is limited to 31 valid UTF-8 bytes and takes effect after reboot.
Bluetooth Companion builds also accept `bluetooth.mac` (`ble.mac` is an
alias). A literal address must be a BLE random-static address; `random`
generates and saves one, `random-every-boot` rotates it at each startup, and
`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.
### Bluetooth LoRa mOTA source
Protocol v14 lets a phone control an install-capable Companion's own LoRa OTA
+9
View File
@@ -383,6 +383,15 @@ Custom names replace the complete Bluetooth label rather than inheriting the
prefix, accept up to 31 valid UTF-8 bytes, and take effect after reboot. This
does not change the node's mesh advertisement name.
Every Bluetooth Companion also supports `get bluetooth.mac` and
`set bluetooth.mac <address|random|random-every-boot|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.
ESP32 ports 5000, 5001, 5002, and WebConfig have no independent login layer.
Expose them only on a trusted LAN or temporary setup network. See
[WiFi setup](./WiFi.md) for credential setup and reconnect behavior.
+12
View File
@@ -85,6 +85,18 @@ advertisement identity. A custom name may contain spaces and is limited to 31
valid UTF-8 bytes. `default` restores `MeshCore-<node name>`. The saved change
takes effect after reboot. `get ble.name` and `set ble.name` are short aliases.
```
get bluetooth.mac
set bluetooth.mac {address|random|random-every-boot|default}
```
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.
```
set lat {latitude}
```
+12
View File
@@ -602,6 +602,9 @@ bool DataStore::loadPrefsInt(const char *filename,
159 + sizeof(loaded_prefs.bluetooth_name),
161 + sizeof(loaded_prefs.bluetooth_name),
168 + sizeof(loaded_prefs.bluetooth_name),
168 + sizeof(loaded_prefs.bluetooth_name)
+ sizeof(loaded_prefs.bluetooth_mac_mode)
+ sizeof(loaded_prefs.bluetooth_mac),
};
const uint32_t prefs_size = file.size();
bool known_size = false;
@@ -706,6 +709,10 @@ bool DataStore::loadPrefsInt(const char *filename,
sizeof(loaded_prefs.cad_retry_delay_ms)); // 196
readOptionalField(&loaded_prefs.cad_max_duration_ms,
sizeof(loaded_prefs.cad_max_duration_ms)); // 198
readOptionalField(&loaded_prefs.bluetooth_mac_mode,
sizeof(loaded_prefs.bluetooth_mac_mode)); // 200
readOptionalField(loaded_prefs.bluetooth_mac,
sizeof(loaded_prefs.bluetooth_mac)); // 201
// Any bytes left over form only part of a historically appended field.
// Preserve the file and defaults rather than treating that tail as EOF.
@@ -805,6 +812,11 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
success = success && file.write((uint8_t *)&_prefs.cad_max_duration_ms,
sizeof(_prefs.cad_max_duration_ms))
== sizeof(_prefs.cad_max_duration_ms);
success = success && file.write((uint8_t *)&_prefs.bluetooth_mac_mode,
sizeof(_prefs.bluetooth_mac_mode))
== sizeof(_prefs.bluetooth_mac_mode);
success = success && file.write((uint8_t *)_prefs.bluetooth_mac,
sizeof(_prefs.bluetooth_mac)) == sizeof(_prefs.bluetooth_mac);
#if defined(NRF52_PLATFORM)
success = file.commit(success);
+174 -1
View File
@@ -2426,6 +2426,37 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
return true;
}
#if defined(BLE_PIN_CODE)
if (strcmp(command, "get bluetooth.mac") == 0
|| strcmp(command, "get ble.mac") == 0) {
formatBluetoothMacStatus(reply, reply_size);
return true;
}
const char* bluetooth_mac_value = NULL;
if (strncmp(command, "set bluetooth.mac", 17) == 0
&& (command[17] == 0 || command[17] == ' '
|| command[17] == '\t')) {
bluetooth_mac_value = command + 17;
} else if (strncmp(command, "set ble.mac", 11) == 0
&& (command[11] == 0 || command[11] == ' '
|| command[11] == '\t')) {
bluetooth_mac_value = command + 11;
}
if (bluetooth_mac_value != NULL) {
while (*bluetooth_mac_value == ' ' || *bluetooth_mac_value == '\t') {
bluetooth_mac_value++;
}
if (bluetooth_mac_value[0] == 0) {
snprintf(reply, reply_size,
"Error: use set bluetooth.mac <address|random|random-every-boot|default>");
} else {
applyAndSaveBluetoothMac(bluetooth_mac_value, reply, reply_size);
}
return true;
}
#endif
if (strncmp(command, "set pin", 7) == 0
&& (command[7] == 0 || command[7] == ' '
|| command[7] == '\t')) {
@@ -2907,7 +2938,19 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
s.capabilities = WebConfigServer::CAP_LOCATION | WebConfigServer::CAP_AIRTIME
| WebConfigServer::CAP_RX_DELAY | WebConfigServer::CAP_POWER_SAVING;
#ifdef BLE_PIN_CODE
s.capabilities |= WebConfigServer::CAP_BLUETOOTH_NAME;
s.capabilities |= WebConfigServer::CAP_BLUETOOTH_NAME
| WebConfigServer::CAP_BLUETOOTH_MAC;
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::bluetoothMacModeUsesSavedAddress(
_prefs.bluetooth_mac_mode)
&& mesh::companion::isValidBluetoothMac(
_prefs.bluetooth_mac)) {
mesh::companion::formatBluetoothMac(
_prefs.bluetooth_mac, s.bluetooth_mac, sizeof(s.bluetooth_mac));
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
s.capabilities |= WebConfigServer::CAP_WIFI_POWER_SAVE;
@@ -3030,6 +3073,13 @@ void MyMesh::execCommand(char* cmd, char* reply) {
formatBluetoothNameStatus(reply, 160);
return;
}
#if defined(BLE_PIN_CODE)
if (cmd && (strcmp(cmd, "get bluetooth.mac") == 0
|| strcmp(cmd, "get ble.mac") == 0)) {
formatBluetoothMacStatus(reply, 160);
return;
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (cmd && strcmp(cmd, "get espnow.channel") == 0) {
formatEspNowChannel(reply, 160);
@@ -3068,6 +3118,12 @@ void MyMesh::execCommand(char* cmd, char* reply) {
applyAndSaveBluetoothName(value, reply, 160);
return;
}
#if defined(BLE_PIN_CODE)
if (strcmp(key, "bluetooth.mac") == 0) {
applyAndSaveBluetoothMac(value, reply, 160);
return;
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strcmp(key, "espnow.channel") == 0) {
applyAndSaveEspNowChannel(value, reply, 160);
@@ -5735,6 +5791,118 @@ void MyMesh::formatBluetoothNameStatus(char* reply, size_t reply_size) const {
? "custom" : "default from node name");
}
#if defined(BLE_PIN_CODE)
bool MyMesh::saveBluetoothMac(uint8_t mode, const uint8_t* address) {
if (!mesh::companion::isValidBluetoothMacMode(mode)) return false;
if (mesh::companion::bluetoothMacModeUsesSavedAddress(mode)
&& !mesh::companion::isValidBluetoothMac(address)) {
return false;
}
const uint8_t previous_mode = _prefs.bluetooth_mac_mode;
uint8_t previous_address[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_address, _prefs.bluetooth_mac, sizeof(previous_address));
_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));
}
if (savePrefs()) return true;
_prefs.bluetooth_mac_mode = previous_mode;
memcpy(_prefs.bluetooth_mac, previous_address, sizeof(_prefs.bluetooth_mac));
return false;
}
bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply,
size_t reply_size) {
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>");
return false;
}
uint8_t mode = mesh::companion::BLUETOOTH_MAC_DEFAULT;
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
if (value[0] == 0 || strcmp(value, "default") == 0
|| strcmp(value, "clear") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_DEFAULT;
} else if (strcmp(value, "random") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_SAVED;
getRNG()->random(address, sizeof(address));
mesh::companion::makeRandomStaticBluetoothMac(address);
} else if (strcmp(value, "random-every-boot") == 0
|| strcmp(value, "random-everyboot") == 0
|| strcmp(value, "random_every_boot") == 0
|| strcmp(value, "random everyboot") == 0
|| strcmp(value, "random every boot") == 0
|| strcmp(value, "everyboot") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT;
} else {
const char* custom_value = value;
if (strncmp(custom_value, "custom", 6) == 0
&& (custom_value[6] == ' ' || custom_value[6] == '\t')) {
custom_value += 7;
while (*custom_value == ' ' || *custom_value == '\t') custom_value++;
}
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");
return false;
}
mode = mesh::companion::BLUETOOTH_MAC_CUSTOM;
}
if (!saveBluetoothMac(mode, address)) {
snprintf(reply, reply_size, "Error: Bluetooth MAC save failed");
return false;
}
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
if (mode == mesh::companion::BLUETOOTH_MAC_DEFAULT) {
snprintf(reply, reply_size,
"OK - factory Bluetooth address restored; reboot and re-pair");
} 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 {
mesh::companion::formatBluetoothMac(address, formatted,
sizeof(formatted));
snprintf(reply, reply_size,
"OK - Bluetooth address saved as %s; reboot and re-pair",
formatted);
}
return true;
}
void MyMesh::formatBluetoothMacStatus(char* reply, size_t reply_size) const {
if (reply == NULL || reply_size == 0) return;
const uint8_t mode = _prefs.bluetooth_mac_mode;
if (!mesh::companion::isValidBluetoothMacMode(mode)) {
snprintf(reply, reply_size,
"> factory address (invalid saved mode ignored)");
} else if (mode == mesh::companion::BLUETOOTH_MAC_DEFAULT) {
snprintf(reply, reply_size, "> factory address (default)");
} else if (mode == mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
snprintf(reply, reply_size, "> random-every-boot (pair after each reboot)");
} else if (!mesh::companion::isValidBluetoothMac(
_prefs.bluetooth_mac)) {
snprintf(reply, reply_size,
"> factory address (invalid saved address ignored)");
} else {
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
mesh::companion::formatBluetoothMac(
_prefs.bluetooth_mac, formatted, sizeof(formatted));
snprintf(reply, reply_size, "> %s (%s)", formatted,
mode == mesh::companion::BLUETOOTH_MAC_CUSTOM
? "custom" : "saved random");
}
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
void MyMesh::formatEspNowChannel(char* reply, size_t reply_size) const {
if (reply == NULL || reply_size == 0) return;
@@ -7175,6 +7343,11 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n");
terminalOutput().print(" get bluetooth.name\r\n");
terminalOutput().print(" set bluetooth.name <name|default>\r\n");
#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");
#endif
terminalOutput().print(" set pin <0-999999>\r\n");
terminalOutput().print(" powersaving [on|off]\r\n");
#if MESH_USB_LOGGING_AVAILABLE
+6
View File
@@ -349,6 +349,12 @@ private:
bool applyAndSaveBluetoothName(const char* value, char* reply,
size_t reply_size);
void formatBluetoothNameStatus(char* reply, size_t reply_size) const;
#if defined(BLE_PIN_CODE)
bool saveBluetoothMac(uint8_t mode, const uint8_t* address);
bool applyAndSaveBluetoothMac(const char* value, char* reply,
size_t reply_size);
void formatBluetoothMacStatus(char* reply, size_t reply_size) const;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
bool applyAndSaveEspNowChannel(const char* value, char* reply,
size_t reply_size);
+3
View File
@@ -3,6 +3,7 @@
#include <Arduino.h>
#include <cstdint>
#include <helpers/BluetoothMac.h>
#include <helpers/CommonRadioPrefs.h>
#include <helpers/DynamicConfigSerializer.h>
@@ -74,6 +75,8 @@ public:
uint16_t cad_scan_timeout_ms = 0;
uint16_t cad_retry_delay_ms = 0;
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] = {};
private:
class RadioPrefs : public CommonRadioPrefs {
+46 -1
View File
@@ -1,5 +1,6 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <helpers/BluetoothMac.h>
#include "MyMesh.h"
#include "CompanionWiFi.h"
#if MESH_PACKET_LOGGING
@@ -1918,6 +1919,46 @@ void halt() {
static bool companion_bluetooth_initialized = false;
static uint32_t companion_bluetooth_start_at = 0;
static constexpr uint32_t COMPANION_BLUETOOTH_RETRY_MS = 5000UL;
static uint8_t companion_bluetooth_session_address[
mesh::companion::BLUETOOTH_MAC_BYTES] = {};
static bool companion_bluetooth_session_address_ready = false;
static const uint8_t* companionBluetoothAddress(
const CompanionNodePrefs* prefs, bool& clear_bonds) {
clear_bonds = false;
if (prefs == nullptr
|| !mesh::companion::isValidBluetoothMacMode(
prefs->bluetooth_mac_mode)) {
mesh::usbLoggingPort().println(
"Companion: invalid Bluetooth MAC mode; using factory address");
return nullptr;
}
if (prefs->bluetooth_mac_mode
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
if (!companion_bluetooth_session_address_ready) {
fast_rng.random(companion_bluetooth_session_address,
sizeof(companion_bluetooth_session_address));
mesh::companion::makeRandomStaticBluetoothMac(
companion_bluetooth_session_address);
companion_bluetooth_session_address_ready = true;
}
// A new local identity cannot safely reuse bonds created for the
// previous boot's identity.
clear_bonds = true;
return companion_bluetooth_session_address;
}
if (mesh::companion::bluetoothMacModeUsesSavedAddress(
prefs->bluetooth_mac_mode)) {
if (mesh::companion::isValidBluetoothMac(prefs->bluetooth_mac)) {
return prefs->bluetooth_mac;
}
mesh::usbLoggingPort().println(
"Companion: invalid saved Bluetooth MAC; using factory address");
}
return nullptr;
}
static void scheduleCompanionBluetoothRetry() {
companion_bluetooth_start_at = millis() + COMPANION_BLUETOOTH_RETRY_MS;
@@ -1939,11 +1980,15 @@ void halt() {
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);
if (!bluetooth_interface.begin(custom_bluetooth_name ? "" : BLE_NAME_PREFIX,
custom_bluetooth_name
? prefs->bluetooth_name
: prefs->node_name,
the_mesh.getBLEPin())) {
the_mesh.getBLEPin(), bluetooth_address,
clear_bonds)) {
interface_manager.removeInterface(&bluetooth_interface);
mesh::usbLoggingPort().println(
"Companion: Bluetooth initialization failed; retrying in 5 seconds");
+115
View File
@@ -0,0 +1,115 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
namespace companion {
static const size_t BLUETOOTH_MAC_BYTES = 6;
static const size_t BLUETOOTH_MAC_TEXT_SIZE = 18;
enum BluetoothMacMode : uint8_t {
BLUETOOTH_MAC_DEFAULT = 0,
BLUETOOTH_MAC_CUSTOM = 1,
BLUETOOTH_MAC_RANDOM_SAVED = 2,
BLUETOOTH_MAC_RANDOM_EVERY_BOOT = 3,
};
inline bool isValidBluetoothMacMode(uint8_t mode) {
return mode <= BLUETOOTH_MAC_RANDOM_EVERY_BOOT;
}
inline bool bluetoothMacModeUsesSavedAddress(uint8_t mode) {
return mode == BLUETOOTH_MAC_CUSTOM
|| mode == BLUETOOTH_MAC_RANDOM_SAVED;
}
inline bool hasCustomBluetoothMac(
const uint8_t address[BLUETOOTH_MAC_BYTES]) {
if (address == NULL) return false;
for (size_t i = 0; i < BLUETOOTH_MAC_BYTES; i++) {
if (address[i] != 0) return true;
}
return false;
}
// User-selected BLE identities are random-static addresses, not IEEE-assigned
// public addresses. The two most-significant bits must be one, while the
// remaining 46 bits may not be all zero or all one.
inline bool isValidBluetoothMac(
const uint8_t address[BLUETOOTH_MAC_BYTES]) {
if (address == NULL || (address[0] & 0xC0u) != 0xC0u) return false;
bool payload_all_zero = (address[0] & 0x3Fu) == 0;
bool payload_all_one = (address[0] & 0x3Fu) == 0x3Fu;
for (size_t i = 1; i < BLUETOOTH_MAC_BYTES; i++) {
payload_all_zero = payload_all_zero && address[i] == 0;
payload_all_one = payload_all_one && address[i] == 0xFFu;
}
return !payload_all_zero && !payload_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.
inline void makeRandomStaticBluetoothMac(
uint8_t address[BLUETOOTH_MAC_BYTES]) {
if (address == NULL) return;
address[0] |= 0xC0u;
if (!isValidBluetoothMac(address)) address[5] ^= 0x01u;
}
inline int bluetoothMacHexDigit(char value) {
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
return -1;
}
inline bool parseBluetoothMac(
const char* text, uint8_t output[BLUETOOTH_MAC_BYTES]) {
if (text == NULL || output == NULL || strlen(text) != 17) return false;
const char separator = text[2];
if (separator != ':' && separator != '-') return false;
uint8_t parsed[BLUETOOTH_MAC_BYTES];
for (size_t i = 0; i < BLUETOOTH_MAC_BYTES; i++) {
const size_t offset = i * 3;
const int high = bluetoothMacHexDigit(text[offset]);
const int low = bluetoothMacHexDigit(text[offset + 1]);
if (high < 0 || low < 0
|| (i + 1 < BLUETOOTH_MAC_BYTES
&& text[offset + 2] != separator)) {
return false;
}
parsed[i] = static_cast<uint8_t>((high << 4) | low);
}
if (!isValidBluetoothMac(parsed)) return false;
memcpy(output, parsed, sizeof(parsed));
return true;
}
inline bool formatBluetoothMac(
const uint8_t address[BLUETOOTH_MAC_BYTES], char* output,
size_t output_size) {
if (address == NULL || output == NULL
|| output_size < BLUETOOTH_MAC_TEXT_SIZE) {
if (output != NULL && output_size != 0) output[0] = 0;
return false;
}
static const char HEX_DIGITS[] = "0123456789ABCDEF";
size_t cursor = 0;
for (size_t i = 0; i < BLUETOOTH_MAC_BYTES; i++) {
output[cursor++] = HEX_DIGITS[address[i] >> 4];
output[cursor++] = HEX_DIGITS[address[i] & 0x0Fu];
if (i + 1 < BLUETOOTH_MAC_BYTES) output[cursor++] = ':';
}
output[cursor] = 0;
return true;
}
} // namespace companion
} // namespace mesh
+10 -6
View File
@@ -18,7 +18,7 @@
// separately, see wcIsAdminPasswordKey below.
static const char* const WC_ALLOWED_SET_KEYS[] = {
// NodePrefs (radio / node)
"name", "bluetooth.name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"name", "bluetooth.name", "bluetooth.mac", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "radio.fem.rxgain", "radio.rxps", "powersaving",
"repeat",
"advert.interval", "flood.advert.interval",
@@ -57,14 +57,18 @@ static inline bool wcIsAllowedSetKey(const char* key) {
return false;
}
// Changing the primary ESP-NOW channel cannot be applied live: every peer and
// any infrastructure-WiFi association must stay on the boot channel until the
// node restarts. The server enforces this even when a crafted client omits the
// browser's reboot flag.
static inline bool wcSetKeyRequiresReboot(const char* key) {
static inline bool wcIsEspNowChannelKey(const char* key) {
return key != NULL && strcmp(key, "espnow.channel") == 0;
}
// These settings cannot be applied to a running transport. The server enforces
// a reboot even when a crafted client omits the browser's reboot flag.
static inline bool wcSetKeyRequiresReboot(const char* key) {
return key != NULL
&& (wcIsEspNowChannelKey(key)
|| strcmp(key, "bluetooth.mac") == 0);
}
// The admin password maps to the top-level `password` command, not a setter, so
// it is classified apart from the `set` allowlist. It is the only key that gets
// this treatment, which is what keeps the allowlist the sole route to `set` and
+73 -1
View File
@@ -1,6 +1,8 @@
#include "SerialBLEInterface.h"
#include "../BluetoothMac.h"
#include "../CompanionFrameQueue.h"
#include "esp_mac.h"
#include <stdlib.h>
#if defined(CONFIG_BLUEDROID_ENABLED)
#include "esp_gap_ble_api.h"
#endif
@@ -26,9 +28,48 @@ extern "C" bool bleInUse(void) {
#define ADVERT_RESTART_DELAY 1000 // millis
bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pin_code) {
static void clearStoredBluetoothBonds() {
#if defined(CONFIG_NIMBLE_ENABLED)
const int result = ble_store_clear();
if (result != 0) {
BLE_DEBUG_PRINTLN("Could not clear NimBLE bonds: %d", result);
}
#else
int count = esp_ble_get_bond_device_num();
if (count <= 0) return;
esp_ble_bond_dev_t* devices = static_cast<esp_ble_bond_dev_t*>(
malloc(sizeof(esp_ble_bond_dev_t) * count));
if (devices == NULL) {
BLE_DEBUG_PRINTLN("Could not allocate Bluetooth bond list");
return;
}
if (esp_ble_get_bond_device_list(&count, devices) != ESP_OK) {
BLE_DEBUG_PRINTLN("Could not read Bluetooth bond list");
free(devices);
return;
}
for (int i = 0; i < count; i++) {
if (esp_ble_remove_bond_device(devices[i].bd_addr) != ESP_OK) {
BLE_DEBUG_PRINTLN("Could not remove Bluetooth bond %d", i);
}
}
free(devices);
#endif
}
bool SerialBLEInterface::begin(const char* prefix, const char* name,
uint32_t pin_code,
const uint8_t* custom_address,
bool clear_bonds) {
_pin_code = pin_code;
if (custom_address != nullptr
&& !mesh::companion::isValidBluetoothMac(custom_address)) {
BLE_DEBUG_PRINTLN("Custom Bluetooth MAC is invalid");
return false;
}
char resolved_name[32];
const char* suffix = name;
if (strcmp(name, "@@MAC") == 0) {
@@ -59,6 +100,25 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pi
// failure is a null server below, which is handled without dereferencing it.
BLEDevice::init(dev_name);
#endif
if (clear_bonds) clearStoredBluetoothBonds();
#if defined(CONFIG_NIMBLE_ENABLED)
if (custom_address != nullptr) {
uint8_t native_address[mesh::companion::BLUETOOTH_MAC_BYTES];
for (size_t i = 0; i < mesh::companion::BLUETOOTH_MAC_BYTES; i++) {
native_address[i] = custom_address[
mesh::companion::BLUETOOTH_MAC_BYTES - 1 - i];
}
if (!BLEDevice::setOwnAddr(native_address)
|| !BLEDevice::setOwnAddrType(BLE_OWN_ADDR_RANDOM)) {
BLE_DEBUG_PRINTLN("Custom Bluetooth MAC setup failed");
BLEDevice::deinit(false);
return false;
}
}
#endif
BLEDevice::setSecurityCallbacks(this);
// ATT notifications consume three bytes of the negotiated MTU. Reserve
// that overhead so a MAX_FRAME_SIZE protocol frame fits without truncation.
@@ -91,6 +151,18 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pi
}
pServer->setCallbacks(this);
#if !defined(CONFIG_NIMBLE_ENABLED)
if (custom_address != nullptr) {
esp_bd_addr_t native_address;
memcpy(native_address, custom_address, sizeof(native_address));
// Arduino-ESP32 2.x returns void here, while newer Bluedroid wrappers
// return bool. Both configure the advertising address type and report any
// controller failure through the BLE library log.
pServer->getAdvertising()->setDeviceAddress(
native_address, BLE_ADDR_TYPE_RANDOM);
}
#endif
// Create the BLE Service
pService = pServer->createService(SERVICE_UUID);
if (pService == NULL) {
+5 -1
View File
@@ -108,8 +108,12 @@ public:
* @param prefix a prefix for the device name
* @param name a name for the device (combined with prefix); "@@MAC" uses the hardware address
* @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
*/
bool begin(const char* prefix, const char* name, uint32_t pin_code);
bool begin(const char* prefix, const char* name, uint32_t pin_code,
const uint8_t* custom_address = nullptr,
bool clear_bonds = false);
// BaseSerialInterface methods
void enable() override;
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -205,7 +205,7 @@ static bool isAllowedSetKey(const char* key, bool has_mqtt) {
#if !defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW
// The shared source-level allowlist is host-tested, but only a target whose
// mesh transport is ESP-NOW has a meaningful primary-radio channel to set.
if (wcSetKeyRequiresReboot(key)) return false;
if (wcIsEspNowChannelKey(key)) return false;
#endif
const bool mqtt_only = strncmp(key, "mqtt.", 5) == 0
|| strncmp(key, "mqtt", 4) == 0
@@ -1418,7 +1418,7 @@ void WebConfigServer::drainBatch(uint32_t now) {
if (strcmp(_batch[i].key, "wifi.ssid") == 0
|| strcmp(_batch[i].key, "wifi.pwd") == 0) {
wifi_credentials_changed = true;
} else if (wcSetKeyRequiresReboot(_batch[i].key)) {
} else if (wcIsEspNowChannelKey(_batch[i].key)) {
espnow_channel_changed = true;
}
}
@@ -1796,6 +1796,7 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
radio["loop_detect"] = LOOP_MODES[node.loop_detect <= 3 ? node.loop_detect : 0];
radio["name"] = (const char*)node.name;
radio["bluetooth_name"] = (const char*)node.bluetooth_name;
radio["bluetooth_mac"] = (const char*)node.bluetooth_mac;
radio["lat"] = node.lat;
radio["lon"] = node.lon;
radio["advert_interval"] = node.advert_interval;
@@ -1942,7 +1943,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
req->send(400, "application/json", out);
return;
}
if (wcSetKeyRequiresReboot(key)) {
if (wcIsEspNowChannelKey(key)) {
uint8_t channel = 0;
if (!mesh::wifi::parseEspNowChannel(val, channel)) {
StaticJsonDocument<128> err;
+2
View File
@@ -84,6 +84,7 @@ public:
CAP_POWER_SAVING = 1UL << 13,
CAP_BLUETOOTH_NAME = 1UL << 14,
CAP_ESPNOW_CHANNEL = 1UL << 15,
CAP_BLUETOOTH_MAC = 1UL << 16,
CAP_DELAYS = CAP_RX_DELAY | CAP_TX_DELAY,
};
@@ -93,6 +94,7 @@ public:
struct NodeSnapshot {
char name[32];
char bluetooth_name[32]; // empty = transport's default device name
char bluetooth_mac[24]; // empty = chipset factory address
char admin_password[32]; // empty = trusted LAN/no login prompt
double lat;
double lon;
+28 -1
View File
@@ -1,4 +1,5 @@
#include "SerialBLEInterface.h"
#include "../BluetoothMac.h"
#include "../CompanionFrameQueue.h"
#include <stdio.h>
#include <string.h>
@@ -238,7 +239,10 @@ bool SerialBLEInterface::removeStoredBondForPeer(const char* cause) {
return true;
}
bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pin_code) {
bool SerialBLEInterface::begin(const char* prefix, const char* name,
uint32_t pin_code,
const uint8_t* custom_address,
bool clear_bonds) {
instance = this;
char charpin[20];
@@ -252,6 +256,29 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, uint32_t pi
BLE_DEBUG_PRINTLN("Bluefruit.begin failed");
return false;
}
if (clear_bonds) Bluefruit.Periph.clearBonds();
if (custom_address != nullptr) {
if (!mesh::companion::isValidBluetoothMac(custom_address)) {
instance = nullptr;
BLE_DEBUG_PRINTLN("Custom Bluetooth MAC is invalid");
return false;
}
ble_gap_addr_t address = {};
address.addr_type = BLE_GAP_ADDR_TYPE_RANDOM_STATIC;
for (size_t i = 0; i < mesh::companion::BLUETOOTH_MAC_BYTES; i++) {
address.addr[i] = custom_address[
mesh::companion::BLUETOOTH_MAC_BYTES - 1 - i];
}
const uint32_t address_error = sd_ble_gap_addr_set(&address);
if (address_error != NRF_SUCCESS) {
instance = nullptr;
BLE_DEBUG_PRINTLN("Custom Bluetooth MAC failed: %lu", address_error);
return false;
}
}
char resolved_name[32];
const char* suffix = name;
+5 -1
View File
@@ -94,8 +94,12 @@ public:
* @param prefix a prefix for the device name
* @param name a name for the device (combined with prefix); "@@MAC" uses the hardware address
* @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
*/
bool begin(const char* prefix, const char* name, uint32_t pin_code);
bool begin(const char* prefix, const char* name, uint32_t pin_code,
const uint8_t* custom_address = nullptr,
bool clear_bonds = false);
void disconnect();
void enable() override;
+1 -1
View File
@@ -96,7 +96,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, and FEM preferences; 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, and Bluetooth address preferences; random-static address validation/generation; 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 |
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Static contracts for Companion Bluetooth identity configuration."""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
def source(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
class BluetoothMacContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.main = source("examples/companion_radio/main.cpp")
cls.mesh = source("examples/companion_radio/MyMesh.cpp")
cls.nrf_header = source("src/helpers/nrf52/SerialBLEInterface.h")
cls.nrf_source = source("src/helpers/nrf52/SerialBLEInterface.cpp")
cls.esp_header = source("src/helpers/esp32/SerialBLEInterface.h")
cls.esp_source = source("src/helpers/esp32/SerialBLEInterface.cpp")
def test_both_ble_backends_accept_the_same_configuration(self):
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("the_mesh.getBLEPin(), bluetooth_address,", self.main)
self.assertIn("clear_bonds))", self.main)
def test_nrf52_uses_random_static_softdevice_address(self):
self.assertIn("BLE_GAP_ADDR_TYPE_RANDOM_STATIC", self.nrf_source)
self.assertIn("sd_ble_gap_addr_set(&address)", self.nrf_source)
self.assertIn(
"mesh::companion::BLUETOOTH_MAC_BYTES - 1 - i",
self.nrf_source,
)
def test_esp32_supports_nimble_and_bluedroid(self):
self.assertIn("BLEDevice::setOwnAddr(native_address)", self.esp_source)
self.assertIn("BLEDevice::setOwnAddrType(BLE_OWN_ADDR_RANDOM)", self.esp_source)
self.assertIn("setDeviceAddress(", self.esp_source)
self.assertIn("BLE_ADDR_TYPE_RANDOM", self.esp_source)
def test_every_boot_mode_is_stable_across_start_retries(self):
self.assertIn(
"companion_bluetooth_session_address_ready", self.main
)
self.assertIn(
"BLUETOOTH_MAC_RANDOM_EVERY_BOOT", self.main
)
self.assertIn("clear_bonds = true;", self.main)
def test_cli_exposes_custom_random_every_boot_and_default(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, "default")', self.mesh)
self.assertIn("parseBluetoothMac(custom_value, address)", self.mesh)
if __name__ == "__main__":
unittest.main()
@@ -159,6 +159,84 @@ TEST(CompanionNodePrefs, BluetoothNameRequiresBoundedValidUtf8) {
EXPECT_FALSE(mesh::companion::isValidBluetoothName(malformed));
}
TEST(CompanionNodePrefs, BluetoothMacDefaultsToFactoryIdentity) {
CompanionNodePrefs prefs = {};
EXPECT_EQ(mesh::companion::BLUETOOTH_MAC_DEFAULT,
prefs.bluetooth_mac_mode);
EXPECT_FALSE(mesh::companion::hasCustomBluetoothMac(
prefs.bluetooth_mac));
}
TEST(CompanionNodePrefs, BluetoothMacParsesAndFormatsRandomStaticAddress) {
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
ASSERT_TRUE(mesh::companion::parseBluetoothMac(
"C2:11:22:33:44:55", address));
EXPECT_EQ(0xC2, address[0]);
EXPECT_EQ(0x55, address[5]);
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
ASSERT_TRUE(mesh::companion::formatBluetoothMac(
address, formatted, sizeof(formatted)));
EXPECT_STREQ("C2:11:22:33:44:55", formatted);
ASSERT_TRUE(mesh::companion::parseBluetoothMac(
"d6-aa-bb-cc-dd-ee", address));
ASSERT_TRUE(mesh::companion::formatBluetoothMac(
address, formatted, sizeof(formatted)));
EXPECT_STREQ("D6:AA:BB:CC:DD:EE", formatted);
}
TEST(CompanionNodePrefs, BluetoothMacRejectsInvalidOrMalformedAddress) {
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES];
memset(address, 0xA5, sizeof(address));
EXPECT_FALSE(mesh::companion::parseBluetoothMac(
"02:11:22:33:44:55", address));
EXPECT_FALSE(mesh::companion::parseBluetoothMac(
"C0:00:00:00:00:00", address));
EXPECT_FALSE(mesh::companion::parseBluetoothMac(
"FF:FF:FF:FF:FF:FF", address));
EXPECT_FALSE(mesh::companion::parseBluetoothMac(
"C2:11-22:33:44:55", address));
EXPECT_FALSE(mesh::companion::parseBluetoothMac(
"C2:11:22:33:44", address));
for (size_t i = 0; i < sizeof(address); i++) {
EXPECT_EQ(0xA5, address[i]);
}
}
TEST(CompanionNodePrefs, BluetoothMacRandomBytesAreNormalized) {
uint8_t all_zero[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
mesh::companion::makeRandomStaticBluetoothMac(all_zero);
EXPECT_TRUE(mesh::companion::isValidBluetoothMac(all_zero));
EXPECT_EQ(0xC0, all_zero[0]);
uint8_t all_one[mesh::companion::BLUETOOTH_MAC_BYTES];
memset(all_one, 0xFF, sizeof(all_one));
mesh::companion::makeRandomStaticBluetoothMac(all_one);
EXPECT_TRUE(mesh::companion::isValidBluetoothMac(all_one));
EXPECT_EQ(0xFE, all_one[5]);
}
TEST(CompanionNodePrefs, BluetoothMacModesDescribeSavedAndPerBootUse) {
EXPECT_TRUE(mesh::companion::isValidBluetoothMacMode(
mesh::companion::BLUETOOTH_MAC_DEFAULT));
EXPECT_TRUE(mesh::companion::isValidBluetoothMacMode(
mesh::companion::BLUETOOTH_MAC_CUSTOM));
EXPECT_TRUE(mesh::companion::isValidBluetoothMacMode(
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::bluetoothMacModeUsesSavedAddress(
mesh::companion::BLUETOOTH_MAC_CUSTOM));
EXPECT_TRUE(mesh::companion::bluetoothMacModeUsesSavedAddress(
mesh::companion::BLUETOOTH_MAC_RANDOM_SAVED));
EXPECT_FALSE(mesh::companion::bluetoothMacModeUsesSavedAddress(
mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT));
}
TEST(CompanionNodePrefs, MigratesRegressedPowerSavingDefaultOnce) {
CompanionNodePrefs prefs = {};
prefs.powersaving_enabled = 0;
@@ -32,6 +32,7 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase):
cls.store = source("examples/companion_radio/DataStore.cpp")
cls.store_header = source("examples/companion_radio/DataStore.h")
cls.mesh = source("examples/companion_radio/MyMesh.cpp")
cls.prefs = source("examples/companion_radio/NodePrefs.h")
def test_preferences_load_has_an_observable_fail_closed_result(self):
self.assertIn("bool loadPrefs(CompanionNodePrefs& prefs", self.store_header)
@@ -110,6 +111,37 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase):
r"if\s*\(prefs_ready\s*&&\s*\(",
)
def test_bluetooth_mac_config_is_an_append_only_preference_tail(self):
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)
self.assertLess(cad_at, mode_at)
self.assertLess(mode_at, address_at)
load = function_body(
self.store,
"bool DataStore::loadPrefsInt(const char *filename",
)
self.assertIn("sizeof(loaded_prefs.bluetooth_mac_mode)", load)
self.assertIn("sizeof(loaded_prefs.bluetooth_mac)", load)
read_mode_at = load.index(
"readOptionalField(&loaded_prefs.bluetooth_mac_mode"
)
read_address_at = load.index(
"readOptionalField(loaded_prefs.bluetooth_mac", read_mode_at
)
self.assertLess(read_mode_at, read_address_at)
save = function_body(
self.store,
"bool DataStore::savePrefs(const CompanionNodePrefs& _prefs",
)
write_mode_at = save.index("&_prefs.bluetooth_mac_mode")
write_address_at = save.index(
"(uint8_t *)_prefs.bluetooth_mac,", write_mode_at
)
self.assertLess(write_mode_at, write_address_at)
def test_channel_source_discovery_and_open_failures_latch_quarantine(self):
load = function_body(self.store, "void DataStore::loadChannels(")
presence_at = load.index(
@@ -9,6 +9,7 @@
TEST(WebConfigKeys, AllowsKnownScalarKeys) {
EXPECT_TRUE(wcIsAllowedSetKey("name"));
EXPECT_TRUE(wcIsAllowedSetKey("bluetooth.name"));
EXPECT_TRUE(wcIsAllowedSetKey("bluetooth.mac"));
EXPECT_TRUE(wcIsAllowedSetKey("radio"));
EXPECT_TRUE(wcIsAllowedSetKey("radio.rxps"));
EXPECT_TRUE(wcIsAllowedSetKey("powersaving"));
@@ -68,8 +69,9 @@ TEST(WebConfigKeys, IsCaseSensitive) {
EXPECT_FALSE(wcIsAllowedSetKey("MQTT1.preset"));
}
TEST(WebConfigKeys, EspNowChannelAlwaysRequiresAReboot) {
TEST(WebConfigKeys, AddressChangingKeysAlwaysRequireAReboot) {
EXPECT_TRUE(wcSetKeyRequiresReboot("espnow.channel"));
EXPECT_TRUE(wcSetKeyRequiresReboot("bluetooth.mac"));
EXPECT_FALSE(wcSetKeyRequiresReboot(NULL));
EXPECT_FALSE(wcSetKeyRequiresReboot("espnow.channel.extra"));
EXPECT_FALSE(wcSetKeyRequiresReboot("ESPNOW.channel"));
@@ -77,6 +79,13 @@ TEST(WebConfigKeys, EspNowChannelAlwaysRequiresAReboot) {
EXPECT_FALSE(wcSetKeyRequiresReboot(""));
}
TEST(WebConfigKeys, EspNowChannelClassificationIsNarrow) {
EXPECT_TRUE(wcIsEspNowChannelKey("espnow.channel"));
EXPECT_FALSE(wcIsEspNowChannelKey("bluetooth.mac"));
EXPECT_FALSE(wcIsEspNowChannelKey("espnow.channel.extra"));
EXPECT_FALSE(wcIsEspNowChannelKey(NULL));
}
// ---- short-key OOB guard --------------------------------------------------
// The slot-prefix probe indexes key[4..6]; these short strings must be rejected
// without ever reading past the terminator.
+13
View File
@@ -188,6 +188,15 @@ class WebConfigUiTest(unittest.TestCase):
'pwdChanged?(d["wifi.pwd"]?"******":"(open network)")', review
)
def test_bluetooth_address_field_is_capability_gated_and_bound(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertIn('data-cap="65536"><label>Bluetooth address</label>', page)
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('("bluetooth.mac" in setmap)', page)
self.assertIn("Bluetooth address saved [OK] - rebooting", page)
def test_scan_panel_exposes_automatic_updates_accessibly(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertIn('id="wz-scan-btn"', page)
@@ -221,6 +230,10 @@ class WebConfigUiTest(unittest.TestCase):
self.assertIn("function restoreConfigLoadEdits(capture)", page)
self.assertIn("function pollScan(url,epoch)", page)
self.assertIn("function clearInheritedWiFiPassword(target,nextSsid)", page)
self.assertIn('data-cap="65536"><label>Bluetooth address</label>', page)
self.assertIn('data-k="bluetooth.mac"', page)
self.assertIn('case"bluetooth.mac":return r.bluetooth_mac||"";', page)
self.assertIn("random-every-boot", page)
def test_skipped_mqtt_does_not_pin_display_in_setup_mode(self):
main = (ROOT / "examples" / "companion_radio" / "main.cpp").read_text(
+14 -5
View File
@@ -359,6 +359,9 @@ body.tab-cli{padding-bottom:0}
<div class="f" data-cap="16384"><label>Bluetooth name</label>
<input type="text" data-k="bluetooth.name" maxlength="31" placeholder="Use MeshCore-&lt;node name&gt;">
<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="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>
@@ -776,6 +779,7 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
if((m=k.match(/^mqtt([1-9])\.(\w+)$/))){var s=(q.slots||[])[+m[1]-1]||{};var v=s[m[2]];return v==null?"":String(v)}
switch(k){
case"name":return r.name;case"bluetooth.name":return r.bluetooth_name||"";
case"bluetooth.mac":return r.bluetooth_mac||"";
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);
@@ -1177,7 +1181,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))body.reboot=true;
if((extra&&extra.reboot)||("wifi.ssid" in setmap)||("wifi.pwd" in setmap)||("espnow.channel" in setmap)||("bluetooth.mac" 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;
@@ -1213,8 +1217,8 @@ function saveChanges(extra,cb){
});
}
function appSave(){
var wifiChanged=("wifi.ssid" in st.dirty)||("wifi.pwd" in st.dirty)||("espnow.channel" in st.dirty);
var pwdErr=adminPwdError(wifiChanged);
var rebootChanged=("wifi.ssid" in st.dirty)||("wifi.pwd" in st.dirty)||("espnow.channel" in st.dirty)||("bluetooth.mac" in st.dirty);
var pwdErr=adminPwdError(rebootChanged);
if(pwdErr){
$('#tabs button[data-t="radio"]').click();
toast(pwdErr);
@@ -1255,10 +1259,13 @@ function pollResult(cb,errs,idles,acked,reqid,submitted){
if(fails)toast(fails+" setting(s) rejected - see fields");
else if(r.reboot){
st.dirty={};
var bluetoothAddressChanged=submitted&&("bluetooth.mac" 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."
: "The node is restarting with the saved WiFi settings. Reconnect to that network and open the node at its router-assigned address.",
false,"Settings saved [OK] - rebooting",r.wifi_ip,rebootSeconds(r));
: bluetoothAddressChanged
? "The node is restarting with its new Bluetooth address. Forget its old Bluetooth entry, scan again, and pair with the new identity."
: "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));
return;
}else toast("Saved [OK]");
loadConfigSoft();
@@ -1814,6 +1821,8 @@ var CLI_VERBS=[
["powersaving","Show the power-saving mode"],
["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}"],
["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)"],