Report saved Bluetooth PIN settings to Companion apps

This commit is contained in:
mikecarper committed 2026-10-27 13:50:40 -07:00
1 parent 85fd38290b
commit 2a6d59474d
7 files changed
+248 -4

No files matched your search

+3 -1
View File
@@ -992,7 +992,9 @@ jobs:
run: python3 -B test/test_companion_bluetooth_control.py -v
- name: Verify Companion Bluetooth reboot persistence
run: python3 -B test/test_companion_bluetooth_persistence.py -v
run: |
python3 -B test/test_companion_bluetooth_persistence.py -v
python3 -B test/test_companion_bluetooth_pin.py -v
- name: Verify nRF52 SoftDevice LTO and persistent BLE subscriptions
env:
+6
View File
@@ -1038,6 +1038,12 @@ Byte values are authoritative; names are aliases. When reading firmware source,
| `0x1C` | `RESP_CODE_DEFAULT_FLOOD_SCOPE` | Default flood-scope data. |
| `0x1D` | `RESP_CODE_CLI_REPLY` | Text returned by `CMD_RUN_CLI_COMMAND`. |
In `RESP_CODE_DEVICE_INFO`, bytes 4–7 are the saved Bluetooth PIN setting
as a little-endian `uint32`: `0` selects the default/random policy; a nonzero
value is the saved custom PIN. After `CMD_SET_DEVICE_PIN` succeeds, querying
device info returns that saved choice immediately. The active pairing PIN
and the device's pairing screen change after reboot.
### Asynchronous push types
| Value | Firmware name | Description |
+4
View File
@@ -737,6 +737,10 @@ a fixed pairing PIN, then `reboot` to activate it. `set pin 0` restores the
default/generated PIN policy. A PIN saved through the Companion protocol also
takes effect after reboot. See the [PIN command reference](cli_commands.md#set-the-bluetooth-pairing-pin-companion).
The app's Bluetooth settings show the saved custom PIN or Random choice as
soon as it is saved. The pairing screen continues showing the active PIN
until reboot, when the saved choice takes effect.
On T1000-E, a USB data host automatically pauses Bluetooth, including an
existing phone connection. Unplug USB to resume Bluetooth after 500 ms; a
saved off setting stays off. A wall charger without a USB data host does not
+3 -1
View File
@@ -6439,7 +6439,9 @@ void MyMesh::handleCmdFrame(size_t len) {
out_frame[i++] = FIRMWARE_VER_CODE;
out_frame[i++] = MAX_CONTACTS / 2; // v3+
out_frame[i++] = MAX_GROUP_CHANNELS; // v3+
memcpy(&out_frame[i], &_active_ble_pin, 4);
// Device info reports the saved setting (0 = random/default). Pairing
// continues using _active_ble_pin until the requested reboot.
memcpy(&out_frame[i], &_prefs.ble_pin, 4);
i += 4;
memset(&out_frame[i], 0, 12);
strcpy((char *)&out_frame[i], FIRMWARE_BUILD_DATE);
+3 -1
View File
@@ -18,7 +18,8 @@ struct { const char* getManufacturerName() const { return "Station G2"; } } boar
struct ActualDeviceQuery {
uint8_t app_target_ver = 0;
uint32_t _active_ble_pin = 123456;
struct { bool isRepeatEn() const { return false; } uint8_t path_hash_mode = 2; } _prefs;
struct { bool isRepeatEn() const { return false; } uint8_t path_hash_mode = 2;
uint32_t ble_pin = 0; } _prefs;
BaseSerialInterface* _serial;
void dispatch(uint8_t* cmd_frame, size_t len) {
uint8_t out_frame[MAX_FRAME_SIZE] = {};
@@ -60,6 +61,7 @@ int main() {
if (reply_at < 0) reply_at = poll;
assert(size == 82 && payload[1] == FIRMWARE_VER_CODE);
assert(payload[2] == MAX_CONTACTS / 2 && payload[3] == MAX_GROUP_CHANNELS);
assert(payload[4] == 0 && payload[5] == 0 && payload[6] == 0 && payload[7] == 0);
assert(strcmp(reinterpret_cast<const char*>(payload + 20), "Station G2") == 0);
assert(payload[80] == 0 && payload[81] == 2);
}
+227
View File
@@ -0,0 +1,227 @@
"""Execute saved PIN queries independently of the active pairing session."""
from pathlib import Path
import os
import re
import subprocess
import sys
import tempfile
import unittest
from test_companion_preferences_transaction import HARNESS as STORE_HARNESS
from test_companion_preferences_transaction import esp_recovery_helpers
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
HARNESS = r'''
#include <helpers/TxtDataHelpers.h>
@CONSTANTS@
#define MAX_CONTACTS 350
#define MAX_GROUP_CHANNELS 40
#define FIRMWARE_BUILD_DATE "27 Oct 2026"
#define FIRMWARE_VERSION "pin-regression"
struct { const char* getManufacturerName() const { return "Companion"; } } board;
using Bytes=std::vector<uint8_t>;
uint32_t random_pin=345678;
struct StdRNG {
uint32_t nextInt(uint32_t low,uint32_t high) {
assert(low==100000&&high==999999);return random_pin;
}
};
struct BaseSerialInterface {};
struct Capture : BaseSerialInterface {
std::vector<Bytes> frames;
size_t writeFrame(const uint8_t* data,size_t size) {
frames.emplace_back(data,data+size);return size;
}
size_t writeFrameToRoute(BaseSerialInterface*,const uint8_t* data,size_t size) {
return writeFrame(data,size);
}
};
struct MyMesh {
CompanionNodePrefs _prefs;
DataStore* _store;
Capture wire;
Capture* _serial=&wire;
uint8_t app_target_ver=0,cmd_frame[180]={},out_frame[180]={};
uint32_t _active_ble_pin=0;
unsigned saves=0;
explicit MyMesh(DataStore& store):_store(&store) {}
bool savePrefs() {++saves;return _store->savePrefs(_prefs,47.1,-122.2);}
@SAVE_PREFERENCE@
uint32_t getBLEPin();
void writeOKFrame(BaseSerialInterface* route=nullptr);
void writeErrFrame(uint8_t,BaseSerialInterface* route=nullptr);
void boot(bool has_display) {
double lat=0,lon=0;assert(_store->loadPrefs(_prefs,lat,lon));
assert(lat==47.1&&lon==-122.2);
@BOOT_PIN@
}
Bytes command(const Bytes& bytes) {
assert(bytes.size()<=sizeof(cmd_frame));
wire.frames.clear();memcpy(cmd_frame,bytes.data(),bytes.size());
const size_t len=bytes.size();
@QUERY@ else @SET_PIN@
assert(wire.frames.size()==1);return wire.frames[0];
}
};
@METHODS@
static Bytes setPin(uint32_t pin) {
return {CMD_SET_DEVICE_PIN,uint8_t(pin),uint8_t(pin>>8),uint8_t(pin>>16),uint8_t(pin>>24)};
}
static Bytes query(MyMesh& mesh,uint32_t expected) {
const auto frame=mesh.command({CMD_DEVICE_QUERY,3});
assert(frame.size()==82&&frame[0]==RESP_CODE_DEVICE_INFO&&frame[1]==FIRMWARE_VER_CODE);
assert(frame[2]==MAX_CONTACTS/2&&frame[3]==MAX_GROUP_CHANNELS);
const uint32_t saved=uint32_t(frame[4])|(uint32_t(frame[5])<<8)
|(uint32_t(frame[6])<<16)|(uint32_t(frame[7])<<24);
assert(saved==expected&&mesh.app_target_ver==3);
assert(!strcmp(reinterpret_cast<const char*>(frame.data()+8),FIRMWARE_BUILD_DATE));
assert(!strcmp(reinterpret_cast<const char*>(frame.data()+20),"Companion"));
assert(!strcmp(reinterpret_cast<const char*>(frame.data()+60),FIRMWARE_VERSION));
assert(frame[80]==0&&frame[81]==mesh._prefs.path_hash_mode);
return frame;
}
static void seed(DataStore& store,uint32_t pin) {
CompanionNodePrefs prefs;prefs.ble_pin=pin;strcpy(prefs.node_name,"persistent");
prefs.freq=910.525f;prefs.bw=62.5f;prefs.sf=7;prefs.cr=5;
assert(store.savePrefs(prefs,47.1,-122.2));
}
int main() {
// A random/default preference is the wire sentinel, never this boot's PIN.
for(uint32_t pin:{0u,100000u,654321u,999999u}) {
DataStore store;seed(store,0);random_pin=345678;
MyMesh live(store);live.boot(true);assert(live.getBLEPin()==345678);
const auto before=query(live,0);
assert(live.command(setPin(pin))==Bytes{RESP_CODE_OK});
assert(live.saves==1&&live._prefs.ble_pin==pin);
assert(live.getBLEPin()==345678); // Existing pairing remains valid until reboot.
const auto after=query(live,pin);
for(size_t i=0;i<before.size();++i)if(i<4||i>=8)assert(before[i]==after[i]);
random_pin=709811;
MyMesh reboot(store);reboot.boot(true);
assert(reboot._prefs.ble_pin==pin&&reboot.getBLEPin()==(pin?pin:709811));
assert(!strcmp(reboot._prefs.node_name,"persistent"));query(reboot,pin);
}
// Invalid values never attempt storage or change either kind of PIN.
for(uint32_t pin:{1u,99999u,1000000u,UINT32_MAX}) {
DataStore store;seed(store,654321);MyMesh live(store);live.boot(true);
const auto disk=store.fs.files["/new_prefs"];
assert(live.command(setPin(pin))==(Bytes{RESP_CODE_ERR,ERR_CODE_ILLEGAL_ARG}));
assert(live.saves==0&&live._prefs.ble_pin==654321&&live.getBLEPin()==654321);
assert(store.fs.files["/new_prefs"]==disk);query(live,654321);
}
// Actual atomic preference writes roll back both RAM and disk on every fault.
for(unsigned fault:{0u,1u,2u
#if defined(ESP32_PLATFORM)
,3u
#endif
}) {
DataStore store;seed(store,654321);MyMesh live(store);live.boot(true);
const auto disk=store.fs.files["/new_prefs"];
if(fault==0)store.fs.fail_write=true;
if(fault==1)store.fs.fail_write_after=17;
if(fault==2)store.fs.fail_rename=1;
if(fault==3)store.fs.fail_rename=2;
assert(live.command(setPin(876543))==(Bytes{RESP_CODE_ERR,ERR_CODE_FILE_IO_ERROR}));
assert(live.saves==1&&live._prefs.ble_pin==654321&&live.getBLEPin()==654321);
assert(store.fs.files["/new_prefs"]==disk);query(live,654321);
store.fs.fail_write=false;store.fs.fail_write_after=-1;store.fs.fail_rename=0;
strcpy(live._prefs.node_name,"later save");assert(live.savePrefs());
MyMesh reboot(store);reboot.boot(true);
assert(reboot.getBLEPin()==654321&&!strcmp(reboot._prefs.node_name,"later save"));
query(reboot,654321); // An unrelated successful save cannot resurrect the failed PIN.
}
// Turning custom PIN off reports zero immediately, but security changes at boot.
DataStore store;seed(store,654321);MyMesh live(store);live.boot(true);
assert(live.command(setPin(0))==Bytes{RESP_CODE_OK});
query(live,0);assert(live.getBLEPin()==654321);
MyMesh headless(store);headless.boot(false);
assert(headless.getBLEPin()==BLE_PIN_CODE);query(headless,0);
std::puts("PASS: saved PIN queries, atomic faults, and active pairing until reboot");
}
'''
def production_harness():
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
store = (ROOT / "examples/companion_radio/DataStore.cpp").read_text()
methods = esp_recovery_helpers(store) + "\n".join(method(store, signature) for signature in (
"bool DataStore::loadPrefs(", "bool DataStore::loadPrefsInt(", "bool DataStore::savePrefs("))
prefix = STORE_HARNESS.split("int main(")[0].replace("@METHODS@", methods)
pin_begin = mesh.index("#ifdef BLE_PIN_CODE // 123456 by default")
pin_end = mesh.index("\n resetContacts();", pin_begin)
names = ("CMD_DEVICE_QUERY", "CMD_SET_DEVICE_PIN", "RESP_CODE_DEVICE_INFO",
"RESP_CODE_OK", "RESP_CODE_ERR", "ERR_CODE_FILE_IO_ERROR", "ERR_CODE_ILLEGAL_ARG")
constants = []
for name, source in [(name, mesh) for name in names] + [("FIRMWARE_VER_CODE", header)]:
constants.append(re.search(r"^#define\s+" + name + r"\s+\d+", source, re.M).group())
harness = HARNESS.replace("@CONSTANTS@", "\n".join(constants))
harness = harness.replace("@SAVE_PREFERENCE@", method(header, "template <typename T> bool savePreference("))
harness = harness.replace("@BOOT_PIN@", mesh[pin_begin:pin_end])
harness = harness.replace("@QUERY@", method(mesh, "if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2)"))
harness = harness.replace("@SET_PIN@", method(mesh, "if (cmd_frame[0] == CMD_SET_DEVICE_PIN && len >= 5)"))
harness = harness.replace("@METHODS@", "\n".join(method(mesh, signature) for signature in (
"uint32_t MyMesh::getBLEPin(", "void MyMesh::writeOKFrame(", "void MyMesh::writeErrFrame(")))
return prefix + harness
class BluetoothPinTests(unittest.TestCase):
def build_and_run(self, platform, legacy_query=False):
harness = production_harness()
if legacy_query:
self.assertEqual(harness.count("&_prefs.ble_pin, 4"), 1)
harness = harness.replace("&_prefs.ble_pin, 4", "&_active_ble_pin, 4", 1)
with tempfile.TemporaryDirectory(prefix="meshcore-bluetooth-pin-") as directory:
work = Path(directory)
(work / "Utils.h").write_text('''#pragma once
#include <Arduino.h>
#include <cassert>
namespace mesh { struct Utils {
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
}; }
''')
(work / "platform_shim.h").write_text('''#include <cstdlib>
#include <cstdio>
inline char* utoa(unsigned int value,char* output,int base){
if(base!=10)abort();sprintf(output,"%u",value);return output;
}
''')
transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text()
(work / "ContactFileTransaction.h").write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
source = work / "pin.cpp"
source.write_text(harness)
binary = work / "pin.exe"
built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17",
*SANITIZERS, "-D" + platform + "=1", "-DBLE_PIN_CODE=123456",
"-DDISPLAY_CLASS=FixtureDisplay", "-include", str(work / "platform_shim.h"),
"-I", str(work), "-I", str(ROOT / "test/fixtures/radio_profiles/mocks"),
"-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
"-I", str(ROOT / "src/helpers"), "-I", str(ROOT), str(source),
*[str(ROOT / "src/helpers" / name) for name in (
"ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp",
"radiolib/RXPowerSaving.cpp", "radiolib/RXPowerSavingCLI.cpp", "TxtDataHelpers.cpp")],
"-o", str(binary)], capture_output=True, text=True, timeout=90)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
return subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
def test_actual_commands_persistence_and_pairing_until_reboot(self):
for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM"):
with self.subTest(platform=platform):
ran = self.build_and_run(platform)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
self.assertIn("PASS: saved PIN queries", ran.stdout)
def test_legacy_active_pin_query_fails_the_regression(self):
ran = self.build_and_run("ESP32_PLATFORM", legacy_query=True)
self.assertNotEqual(ran.returncode, 0, "The old active-PIN query unexpectedly passed")
self.assertIn("saved==expected", ran.stderr)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -46,7 +46,8 @@ struct ActualDeviceQuery {
BaseSerialInterface* _serial = nullptr;
uint8_t app_target_ver = 0;
uint32_t _active_ble_pin = 123456;
struct { bool isRepeatEn() const { return false; } uint8_t path_hash_mode = 2; } _prefs;
struct { bool isRepeatEn() const { return false; } uint8_t path_hash_mode = 2;
uint32_t ble_pin = 0; } _prefs;
void dispatch(uint8_t* cmd_frame, size_t len) {
uint8_t out_frame[MAX_FRAME_SIZE]{};
@QUERY@