Add configurable Companion Bluetooth addresses
This commit is contained in:
1 parent
b25005f52f
commit
4b400ea56b
26 files changed
+2723
-1966
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1960
-1944
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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-<node name>">
|
||||
<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)"],
|
||||
|
||||
Reference in new issue
Block a user