Report saved Bluetooth PIN settings to Companion apps
This commit is contained in:
1 parent
85fd38290b
commit
2a6d59474d
7 files changed
+248
-4
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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@
|
||||
|
||||
Reference in new issue
Block a user