diff --git a/docs/research/companion_contact_cache.md b/docs/research/companion_contact_cache.md index 90998486d..2e58693a6 100644 --- a/docs/research/companion_contact_cache.md +++ b/docs/research/companion_contact_cache.md @@ -4,8 +4,8 @@ # Companion contact caches -Full Companions on ESP32 and nRF52 without PSRAM, plus the high-contact RAK4631 -USB/BLE profiles, keep 16 outgoing paths +Full Companions on ESP32 and nRF52 without PSRAM, plus nRF52 USB/BLE +profiles with more than 300 contacts, keep 16 outgoing paths and 16 shared secrets in RAM. Every contact remains in the contact table; selecting a different contact loads its saved path as needed. This recovers about 25.4 KiB of internal RAM in the 350-contact ESP32 qualification builds. @@ -14,12 +14,20 @@ See the [qualification results](../test-results/companion_contact_cache_results. six-board RAM comparison, physical tests, and shared-secret timings. PSRAM boards retain their existing complete contact table in external RAM. -Most legacy USB/BLE/WiFi Companion profiles retain inline paths and secrets. -The high-contact RAK4631 USB/BLE profiles also enable the same cache, +Lower-contact legacy profiles and ordinary ESP32 USB/BLE/WiFi Companion +profiles retain inline paths and secrets. High-contact nRF52 USB/BLE profiles +(including Wio Tracker L1) enable the same cache, keeping all 350 contacts while making room for the shared 256-frame offline/DM pool. The build flag `MESH_CONTACT_CACHE=0` or `1` overrides this policy for qualification; it is not a runtime feature switch. +The ordinary Wio Tracker L1 BLE build reproduced a runtime RAM failure with +48,964 bytes available against 59,968 required. Enabling the existing cache +for high-contact nRF52 profiles recovered 30,200 bytes: the linked build has +79,164 bytes available and a 19,196-byte margin against the unchanged budget. +Its 350 contacts, 40 channels and configured 256-frame offline/held-DM pool +are retained. This is a linked-capacity result, not a physical load/soak test. + ## Paths and persistence The 16 resident paths use least-recently-used replacement. Contact records diff --git a/src/helpers/ContactCachePolicy.h b/src/helpers/ContactCachePolicy.h index 7225bba74..71e8fa6fa 100644 --- a/src/helpers/ContactCachePolicy.h +++ b/src/helpers/ContactCachePolicy.h @@ -1,10 +1,14 @@ #pragma once // PSRAM boards already keep the complete contact table outside internal RAM. +// High-contact nRF52 USB/BLE recipes need the same cache as Full Companion: +// inline paths/secrets can otherwise leave too little heap for startup. // An explicit 0/1 override is useful for matched qualification builds. #ifndef MESH_CONTACT_CACHE -#if defined(COMPANION_RADIO_FULL) && !defined(BOARD_HAS_PSRAM) && \ - (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) +#if !defined(BOARD_HAS_PSRAM) && \ + ((defined(COMPANION_RADIO_FULL) && \ + (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM))) || \ + (defined(NRF52_PLATFORM) && defined(MAX_CONTACTS) && MAX_CONTACTS > 300)) #define MESH_CONTACT_CACHE 1 #else #define MESH_CONTACT_CACHE 0 diff --git a/test/test_contact_cache.py b/test/test_contact_cache.py index 7a29b1142..a89f39ee7 100644 --- a/test/test_contact_cache.py +++ b/test/test_contact_cache.py @@ -22,6 +22,30 @@ def crypto_objects(temp): class ContactCacheTest(unittest.TestCase): + def test_default_policy_covers_high_contact_nrf52_without_full(self): + cases = ( + ({"NRF52_PLATFORM": 1, "MAX_CONTACTS": 350}, 1), + ({"NRF52_PLATFORM": 1, "MAX_CONTACTS": 301}, 1), + ({"NRF52_PLATFORM": 1, "MAX_CONTACTS": 300}, 0), + ({"NRF52_PLATFORM": 1}, 0), + ({"ESP32_PLATFORM": 1, "MAX_CONTACTS": 350}, 0), + ({"NRF52_PLATFORM": 1, "COMPANION_RADIO_FULL": 1}, 1), + ({"ESP32_PLATFORM": 1, "COMPANION_RADIO_FULL": 1}, 1), + ({"NRF52_PLATFORM": 1, "MAX_CONTACTS": 350, "BOARD_HAS_PSRAM": 1}, 0), + ({"ESP32_PLATFORM": 1, "COMPANION_RADIO_FULL": 1, "BOARD_HAS_PSRAM": 1}, 0), + ({"NRF52_PLATFORM": 1, "MAX_CONTACTS": 350, "MESH_CONTACT_CACHE": 0}, 0), + ({"NRF52_PLATFORM": 1, "MAX_CONTACTS": 100, "MESH_CONTACT_CACHE": 1}, 1), + ) + for defines, expected in cases: + with self.subTest(defines=defines): + result = subprocess.run([ + "c++", "-E", "-P", "-x", "c++", "-", + "-I", str(ROOT / "src"), + *("-D%s=%s" % item for item in defines.items()), + ], input='#include \nMESH_CONTACT_CACHE\n', + text=True, capture_output=True, check=True) + self.assertEqual(result.stdout.strip(), str(expected)) + def test_esp32_misses_recalculate_without_flash_io(self): with tempfile.TemporaryDirectory(prefix="mesh-secret-power-") as temp: temp = Path(temp) @@ -44,6 +68,11 @@ class ContactCacheTest(unittest.TestCase): def test_nrf52_paged_store_migration_and_snapshots(self): self.run_cache_test("NRF52_PLATFORM") + def test_standard_nrf52_policy_preserves_migration_and_snapshots(self): + # No Full or explicit cache flag: exercise the ordinary 350-contact + # recipe's default through the same real cache/persistence harness. + self.run_cache_test("NRF52_PLATFORM", standard_nrf52=True) + def test_esp32_default_crc_cadence_is_detected(self): self.run_cache_test("ESP32_PLATFORM", disable_crc_batch=True) @@ -66,7 +95,8 @@ class ContactCacheTest(unittest.TestCase): disable_raw_recovery=False, unsafe_legacy_discovery=False, unsafe_page_discovery=False, fixture_transform=None, expected_failure=None, - expected_marker='PASS: 114 shared request/login payload and routing traces'): + expected_marker='PASS: 114 shared request/login payload and routing traces', + standard_nrf52=False): with tempfile.TemporaryDirectory(prefix="mesh-contact-cache-") as temp: temp = Path(temp) store = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() @@ -188,7 +218,7 @@ class ContactCacheTest(unittest.TestCase): result = subprocess.run([ "c++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-fsanitize=address,undefined", "-fno-omit-frame-pointer", - "-DMESH_CONTACT_CACHE=1", "-DCOMPANION_RADIO_FULL=1", + *([] if standard_nrf52 else ["-DMESH_CONTACT_CACHE=1", "-DCOMPANION_RADIO_FULL=1"]), "-DMESH_CONTACT_SECRET_FLASH_CACHE=1", "-D" + platform + "=1", "-DMAX_CONTACTS=350", "-I", str(ROOT / "test/fixtures/contact_cache/mocks"), diff --git a/test/test_one_key_dm_shared_queue.py b/test/test_one_key_dm_shared_queue.py index 2204b8f63..464ec1527 100644 --- a/test/test_one_key_dm_shared_queue.py +++ b/test/test_one_key_dm_shared_queue.py @@ -22,6 +22,25 @@ def method(source: str, signature: str) -> str: class OneKeyDMSharedQueueTest(unittest.TestCase): + def test_standard_high_contact_nrf52_selects_shared_pool_by_default(self): + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + start = header.index("#if defined(NRF52_PLATFORM) && MAX_CONTACTS > 300") + end = header.index("#ifndef ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE", start) + policy = '#include \n' + header[start:end] + policy += '\nstatic_assert(MESH_CONTACT_CACHE == 1);\n' + policy += 'static_assert(ONE_KEY_DM_SHARED_OFFLINE_QUEUE == 1);\n' + policy += 'static_assert(OFFLINE_QUEUE_SIZE == 256);\nint main() {}\n' + with tempfile.TemporaryDirectory(prefix="meshcore-dm-policy-") as temp: + binary = Path(temp) / "policy.exe" + for capacity in (301, 350): + with self.subTest(capacity=capacity): + result = subprocess.run([ + "c++", "-std=c++17", "-x", "c++", "-", + "-DNRF52_PLATFORM=1", f"-DMAX_CONTACTS={capacity}", + "-I", str(ROOT / "src"), "-o", str(binary), + ], input=policy, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + def compile_and_run(self, source: str): with tempfile.TemporaryDirectory(prefix="meshcore-shared-dm-") as temp: path = Path(temp) / "shared_dm.cpp"