9.0 KiB
Full Companion contact caches
Full Companions on ESP32 and nRF52 without PSRAM now 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.
See the qualification results for the six-board RAM comparison, physical tests, and shared-secret timings.
PSRAM boards retain their existing complete contact table in external RAM.
Legacy USB/BLE/WiFi Companion profiles also retain their inline paths and
secrets. The build flag MESH_CONTACT_CACHE=0 or 1 overrides this policy for
qualification; it is not a runtime feature switch.
Paths and persistence
The 16 resident paths use least-recently-used replacement. Contact records
keep small handles to their saved paths, including 1-, 2-, and 3-byte hop
hashes. The existing 152-byte contact record and Companion app frame formats
are unchanged. ESP32 uses /contacts3; nRF52 also supports its paged contact
store and migration from /contacts3. An erase is not required.
A hot path needs no flash read. A cold path is read from its existing contact record. ESP32 reuses one open contact-file reader to avoid repeated SPIFFS metadata scans during synchronization and saving. It closes that reader before replacing the file or recovering a saved transaction.
Changing a copied contact preserves the old path for a pending app response or rollback. Dirty entries are saved before eviction. If storage cannot be read, written, or represented completely, the operation fails instead of silently losing a contact or sending along a different route. Reboot retries an incomplete load from the preserved files. The path checksum also rejects a cold path changed underneath a live handle; it does not add a new checksum to the legacy ESP32 on-disk format.
Shared secrets and power
Both platforms reuse the 16 most recent secrets from RAM. Cache misses use a platform-specific policy:
| Platform | Miss behavior |
|---|---|
| ESP32 | Recalculate the secret without reading or writing a secret file. |
| nRF52 | Try a saved LittleFS entry, then recalculate if it is unavailable. |
On the V4, a measured SPIFFS secret lookup took 132,427 microseconds while key exchange took 21,932 microseconds. ESP32 therefore uses recalculation on a RAM-cache miss. It avoids secret-file writes and reduces time awake for this measured workload. These are elapsed times, not measurements of energy in joules; other ESP32 hardware has not been physically benchmarked.
On a RAK3401, a LittleFS lookup took 7,812 microseconds versus 31,250 microseconds for key exchange. The nRF52 backend therefore uses saved flash entries to avoid repeat key exchange when a usable entry exists, including after reboot. Saved entries match the full peer public key and a fingerprint of the local key pair, and have a record checksum. Importing a different private or public key invalidates old results. A corrupt or missing saved entry is recalculated. The native tests use the firmware's actual Ed25519 key-exchange library and verify agreement with the peer's independently calculated secret.
Derived secrets are expendable. Their storage keeps a reserve for contacts, preferences and filesystem metadata; a full filesystem leaves the calculated secret usable in RAM. nRF52 packs 56 entries into a 3,844-byte page to use its 4 KiB flash blocks efficiently. Small filesystems may not have space to persist secrets for all 350 contacts. A miss without a usable saved entry still performs key exchange.
MESH_CONTACT_SECRET_FLASH_CACHE=0 or 1 overrides the miss policy for
qualification builds. The optional ESP32 flash backend groups eight entries
per file. The normal ESP32 policy does not create or use those files.
Inspect the cache
From the Full Companion's ASCII USB terminal or authenticated local CLI:
get contact.cache
Cached builds report paths=16 secrets=16, miss=calculate or miss=flash,
plus these boot-session counters:
| Field | Meaning |
|---|---|
ram_hits |
Shared secret reused from RAM. |
flash_hits |
Shared secret loaded from flash without recalculation. |
calculations |
Shared-secret key exchanges performed. |
save_skips |
Calculated secrets that could not be persisted, including storage reserve/backoff. |
Uncached builds report paths=inline secrets=inline. The normal memory
command on supported ESP32 Full Companions shows live heap and queue capacity.
get contact.cache.timing reports the last successful flash lookup and last
key calculation in microseconds. Zero means no such operation has completed
since boot. These timings exclude secret writes and are useful for comparing
the work avoided on a valid flash hit.
Offline messages during mOTA
Contact caches are independent of the offline message queue. Builds using the shared mOTA queue retain 256 frames normally and 128 while mOTA owns its workspace, then restore 256. No unread frames are discarded to start mOTA: synchronize the queue and retry if it exceeds the retained capacity. PSRAM-backed queues keep their existing capacity and allocation policy.
Each queue frame occupies 177 bytes. Retaining 32 instead of 128 would make another 16,992 bytes (16.6 KiB) available inside the shared storage. It would not increase free heap by itself: the normal 256-frame storage is statically reserved, and the current mOTA context already fits in the loaned half. More mOTA scratch data would have to share that space to obtain an additional RAM saving. These changes keep the existing 128-frame mOTA limit.
Build the six capacity trials
The optional environments in platformio.nimble.ini combine NimBLE, these
contact caches, and the shared mOTA queue at 350 contacts, 40 channels and
256 normal offline frames. They remain outside the ordinary release matrix.
The ordinary six constrained release recipes still use their established
150-contact limits pending wider hardware qualification.
Enable platformio.nimble.ini as described in the
NimBLE trial guide, then build one environment at
a time. For example:
OUTPUT_DIR=.releases/contact-cache-v3 bash build.sh build-firmware \
Heltec_v3_ram_trial_companion_radio_full_nimble \
--firmware-version v1.17.1.5-halo-keymind-cascade-cache-trial \
--radio-preset usa-cascadia --profile cascade --standard --require-ota
| Hardware | Trial environment |
|---|---|
| Heltec V3 | Heltec_v3_ram_trial_companion_radio_full_nimble |
| Wireless Tracker | Heltec_Wireless_Tracker_ram_trial_companion_radio_full_nimble |
| Tracker V2, FEM on | heltec_tracker_v2_ram_trial_companion_radio_full_femon_nimble |
| Heltec CT62 | Heltec_ct62_ram_trial_companion_radio_full_nimble |
| XIAO ESP32-C3 | Xiao_C3_ram_trial_companion_radio_full_nimble |
| Generic ESP-NOW | Generic_ESPNOW_ram_trial_companion_radio_full_nimble |
The firmware RAM guard remains enabled. A successful linked budget is distinct from live free heap and physical Bluetooth/LoRa qualification on each board.
The smaller NimBLE applications let the CT62, XIAO C3 and Generic ESP-NOW
trials use two 1.5 MiB WiFi OTA slots on 4 MiB flash. Their 896 KiB SPIFFS and
coredump stay at the same addresses as the ordinary huge_app.csv Full
layout. Install the trial's merged image over USB first to install its
partition table. Subsequent WiFi updates must fit the new slots. An
application-only update from the ordinary single-slot Full layout cannot
change that layout or enable WiFi OTA.
Regression checks
python3 -B test/test_contact_cache.py
python3 -B test/test_companion_contact_persistence_contract.py
python3 -B test/test_companion_contact_stream_contract.py
python3 -B test/test_nrf52_extrafs_contract.py
python3 -B test/test_shared_mota_queue.py
python3 -B test/test_t096_full_memory.py
python3 -B test/test_firmware_ram.py
The cache tests compile the production path/secret cache code and extracted production persistence, routing and request functions with filesystem/radio adapters. They exercise 350 routes, eviction, snapshot rollback, page migration, identity changes, read/write/rename faults, handle exhaustion, packet release on a failed path read, and recovery after a simulated reset between file renames. They do not simulate a physical flash power cut.
tools/hil/contact_cache_serial_stress.py checks all 350 paths over USB,
replaces them with a different generation, and optionally exercises secret
eviction. Use an otherwise empty test node and exclusive access to its
Companion data port. --populate writes test contacts; --secrets transmits
23 directed LoRa datagrams to synthetic peers. For an ESP32 cache trial:
python3 tools/hil/contact_cache_serial_stress.py --port /dev/ttyACM0 \
--populate --generation 1 --secrets --expect-calculate --reboot
python3 tools/hil/contact_cache_serial_stress.py --port /dev/ttyACM0 \
--generation 1 --secrets --expect-calculate
Use the actual device path. Omit --expect-calculate for the nRF52 flash
policy. The second invocation verifies the persisted paths after reboot.