diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 112fd5360..f04387912 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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: diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index afb102012..eb0be22c5 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -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 | diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 4b2327208..acd4100ae 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a42ce16f3..592102354 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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); diff --git a/test/fixtures/companion_wifi_listener/handshake.cpp b/test/fixtures/companion_wifi_listener/handshake.cpp index 14f6ad636..24b8d1a15 100644 --- a/test/fixtures/companion_wifi_listener/handshake.cpp +++ b/test/fixtures/companion_wifi_listener/handshake.cpp @@ -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(payload + 20), "Station G2") == 0); assert(payload[80] == 0 && payload[81] == 2); } diff --git a/test/test_companion_bluetooth_pin.py b/test/test_companion_bluetooth_pin.py new file mode 100644 index 000000000..a401f781c --- /dev/null +++ b/test/test_companion_bluetooth_pin.py @@ -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 +@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; +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 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(frame.data()+8),FIRMWARE_BUILD_DATE)); + assert(!strcmp(reinterpret_cast(frame.data()+20),"Companion")); + assert(!strcmp(reinterpret_cast(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=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 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 +#include +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 +#include +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 ')) + 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() diff --git a/test/test_pico_ble_queues.py b/test/test_pico_ble_queues.py index f1694ef94..00cfcf8e2 100644 --- a/test/test_pico_ble_queues.py +++ b/test/test_pico_ble_queues.py @@ -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@