diff --git a/docs/bootloader_version_stock_audit.md b/docs/bootloader_version_stock_audit.md
index 0b3b8d96b..11c431c15 100644
--- a/docs/bootloader_version_stock_audit.md
+++ b/docs/bootloader_version_stock_audit.md
@@ -1,173 +1,5 @@
-# Stock nRF52 bootloader version audit
+# Document moved
-Checked **2026-09-29**, against the 40 nRF52 variant families resolving to 26
-board configurations in this checkout. This concerns `get bootloader.ver`, not
-bootloader installation or OTA eligibility. No devices were flashed, and no
-features, storage sizes, or partitions were changed.
+
-## Result
-
-The real firmware reader successfully reads **26 distinct manufacturer-published
-bootloader images** from 32 artifact records, including older RAK revisions,
-current and historical LilyGO images, and vendor HEX, UF2 and DFU ZIP packages.
-Repeated copies and identical normalized bootloader images are counted once.
-These are publicly supplied stock/recovery images, **not proof that every
-manufacturing batch shipped identical bytes**. No untouched live unit was read
-in this audit.
-
-One format gap was fixed: Seeed's T1000-E **LoRaWAN development-board** bootloader
-declares `UF2 Bootloader 1.00`. The reader now accepts that two-component version
-and returns `1.00` unchanged. It does not invent `1.00.0`. That development image
-is separate from Seeed's Meshtastic/MeshCore recovery image, which reports
-`0.9.1-5-g488711a` and already worked.
-
-All other tested images already expose a readable UF2 bootloader record.
-Generic bare dotted-number searches remain deliberately unsupported: TinyUSB,
-nrfx, and SoftDevice can embed their own versions inside the same bootloader.
-The bounded standalone fallback still requires the explicit OTAFIX version
-shape. Ambiguous records, invalid vectors, and corrupt canonical OTAFIX metadata
-retain the existing rejection rules.
-
-## Published images actually tested
-
-The exact source URLs, download hashes, normalized-image hashes, extraction
-layout and expected strings are pinned in
-[`stock_images.json`](../test/fixtures/nrf52_bootloader_version/stock_images.json).
-
-| Manufacturer image family | Version captured from the image |
-| --- | --- |
-| RAK4631/RAK4630 Arduino bootloader, historical through current | `0.3.2-109-gd6b28e6`, `0.4.1-13-g5e6690e-dirty`, `0.4.2`, `0.4.3`, `0.4.4` |
-| Heltec HT-n5262, SDK and T114 vendor recovery HEX | `0.9.0-2-g836c8dc-dirty` |
-| LilyGO T-Echo | `0.6.1-2-g1224915` |
-| LilyGO T-Echo Lite, Card, T-Impulse Plus (including historical builds) | `0.9.2-dirty` |
-| Seeed XIAO nRF52840 and Sense | `0.6.1` |
-| Seeed XIAO nRF52840 Plus and Sense Plus | `0.9.2-29-g6a9a6a3` |
-| Seeed Wio Tracker 1110 SDK/conversion bootloader | `0.6.2-26-g949425a-dirty` |
-| Seeed Wio Tracker L1 vendor recovery ZIP | `0.7.0-22-g277a0c8` |
-| Seeed T1000-E Meshtastic/MeshCore recovery ZIP | `0.9.1-5-g488711a` |
-| Seeed T1000-E LoRaWAN development-board HEX | `1.00` (newly supported) |
-| Seeed MeshTracker X1 recovery ZIP | `0.10.0-18-gb93789f` |
-| MTools Tec MeshTiny vendor bootloader UF2 | `0.4.5` |
-| BQ/Unit Engineering Nano G2 Ultra vendor HEX ZIP | `0.6.4-dirty` |
-| Muzi Works R1 Neo release HEX | `0.9.2-31-g990aa7f-dirty` |
-
-**Use the embedded version, not the filename.** For example, Seeed's XIAO file
-named `...bootloader-0.6.2...hex` reports `0.6.1`, and its L1 ZIP named
-`...bootloader-0.10.0...zip` reports `0.7.0-22-g277a0c8`. These are actual tested
-bytes, not renamed or inferred release numbers.
-
-All these normalized images use the Adafruit nRF52840 `F4000..FE000` bootloader
-window. HEX/UF2 address records identify the bytes. The boot-only Seeed DFU
-packages omit MBR/UICR address words; their bootloader binary is extracted using
-the manifest's `sd_size`/`bl_size` and the
-[Adafruit nRF52840 linker layout](https://github.com/adafruit/Adafruit_nRF52_Bootloader/blob/master/linker/nrf52840.ld).
-The runtime reader independently selects the installed MBR/UICR boot address;
-this audit does not hardcode that layout into the firmware getter.
-
-## Coverage of every variant family
-
-- **Image verified:** a published bootloader for that named product/module was
- tested. Factory batch identification still requires reading the actual unit.
-- **Representative only:** a related module/SDK bootloader was tested; its exact
- factory image was not obtained. Shared build configuration alone does not
- establish identical factory bootloaders.
-- **Unverified:** no accessible factory bootloader image was obtained. This is
- not a demonstrated reader failure.
-
-| Variant family | Status | Evidence or remaining gap |
-| --- | --- | --- |
-| `gat562_30s_mesh_kit` | Unverified | GAT documentation/firmware repository checked; no product-specific stock bootloader found. |
-| `gat562_mesh_evb_pro` | Unverified | Same GAT gap; RAK board configuration is not factory-image proof. |
-| `gat562_mesh_tracker_pro` | Unverified | Same GAT gap; custom vendor/OTAFIX builds also exist. |
-| `gat562_mesh_watch13` | Unverified | Same GAT gap. |
-| `heltec_mesh_solar` | Representative only | HT-n5262 SDK image passes; Solar downloads found application images, not a board-specific stock bootloader. |
-| `heltec_t096` | Representative only | HT-n5262 SDK image passes; T096 factory revision not established. |
-| `heltec_t1` | Representative only | SDK image passes; vendor F&T archive contains application-only UF2s. |
-| `heltec_t114` | Image verified | Both SDK HEX and T114-specific vendor recovery HEX pass. |
-| `heltec_tower_v2` | Representative only | SDK image passes; vendor matching-firmware archive contains applications, not the stock bootloader. |
-| `ikoka_handheld_nrf` | Representative only | XIAO board configuration; XIAO module images pass, exact installed factory image not obtained. |
-| `ikoka_nano_nrf` | Representative only | Same XIAO representative coverage. |
-| `ikoka_stick_nrf` | Representative only | Same XIAO representative coverage. |
-| `keepteen_lt1` | Unverified | Manufacturer product/download search did not yield a factory bootloader binary. |
-| `lilygo_t_impulse_plus` | Image verified | Both historical and newer vendor bootloader HEX images pass. |
-| `lilygo_techo` | Image verified | Vendor T-Echo bootloader passes. |
-| `lilygo_techo_card` | Image verified | Three distinct vendor historical/current bootloader images pass. NFC UICR repair application excluded. |
-| `lilygo_techo_lite` | Image verified | Vendor bootloader HEX passes. |
-| `mesh_pocket` | Representative only | HT-n5262 SDK image passes; exact Pocket factory revision not established. |
-| `meshtiny` | Image verified | Direct manufacturer bootloader UF2 passes; newer ADV/factory revisions require their actual image. |
-| `meshtracker_x1` | Image verified | Seeed product-specific recovery ZIP passes. |
-| `minewsemi_me25ls01` | Unverified | Manufacturer module page lists firmware as none; no stock bootloader binary found. OEM/dev-kit installations can differ. |
-| `muzi_base` | Unverified | Manufacturer publishes bootloader source, but its release assets found were for R1 Neo, not BASE. |
-| `muziworks_r1_neo` | Image verified | Manufacturer R1 Neo release HEX passes; do not substitute a generic RAK version. |
-| `nano_g2_ultra` | Image verified | Manufacturer's preinstalled-bootloader recovery HEX ZIP passes. |
-| `promicro` | Unverified | Nologo documents a Nice!Nano UF2 drive; its linked download requires Baidu access. Generic clones need separate factory images. |
-| `rak3401` | Representative only | RAK4631 Arduino images pass; RAK3401-specific untouched image not obtained. |
-| `rak4631` | Image verified | Five distinct published stock Arduino bootloader revisions pass. RAK4631-R/RUI3 is a different bootloader family. |
-| `rak_wismesh_tag` | Representative only | RAK core images pass; exact Tag factory image not obtained. |
-| `sensecap_solar` | Representative only | Seeed documentation identifies XIAO DFU; XIAO images pass, exact Solar factory image not obtained. |
-| `t1000-e` | Image verified | Both vendor recovery ZIP and separate LoRaWAN development-board HEX pass. |
-| `thinknode_m1` | Unverified | Manufacturer repository checked; no factory bootloader binary found. |
-| `thinknode_m3` | Unverified | Published factory UF2 is application-only (`26000..52BFF`), not a bootloader image. |
-| `thinknode_m4` | Unverified | Published factory UF2 is application-only (`26000..521FF`). |
-| `thinknode_m6` | Unverified | Published factory UF2 is application-only (`26000..46CFF`). |
-| `thinknode_m8` | Unverified | No accessible product-specific factory bootloader binary found. |
-| `wio-tracker-l1` | Image verified | Seeed's L1 recovery ZIP passes. |
-| `wio-tracker-l1-1w` | Representative only | L1 recovery image passes; exact 1W/ADV factory revision not established. |
-| `wio-tracker-l1-eink` | Representative only | L1 recovery image passes; exact e-ink factory revision not established. |
-| `wio_wm1110` | Representative only | Published Wio Tracker 1110 and XIAO SDK images pass; the variant's XIAO config does not prove the shipped module's image. |
-| `xiao_nrf52` | Image verified | XIAO, Sense, Plus and Sense Plus vendor images pass. |
-
-Representative/unverified products use the same shared reader and should report
-a version **if their installed bootloader exposes one of the recognized records**.
-There is no evidence yet for claiming an exact version or success on every
-untouched unit. ESP32, RP2040 and STM32 are outside this nRF52 flash reader;
-their getters were not changed and this report does not claim stock-version
-capture on those architectures.
-
-## Reproduce the artifact tests
-
-Normal tests are offline and include all observed version strings, malformed and
-conflicting versions, dependency-version false positives, region/vector bounds,
-and the existing OTAFIX metadata/CRC/fallback cases:
-
-```sh
-python3 test/test_nrf52_bootloader_version.py -v
-```
-
-To replay the real images, download each `url` in `stock_images.json` into a
-cache directory using its `file` name, then run:
-
-```sh
-MESHCORE_STOCK_BOOTLOADER_CACHE=/absolute/path/to/cache \
- python3 test/test_nrf52_bootloader_version.py -v
-```
-
-This checks the exact download SHA-256, extracts only the bootloader window,
-checks the normalized-image SHA-256, and invokes the actual C++ reader with an
-exact expected version. It never downloads automatically or flashes a device.
-The ordinary regression cases cover every captured format even without the
-optional cached artifacts. RAK4631 and T1000-E repeater firmware builds passed.
-
-## What is needed to close the remaining gaps
-
-For each untouched unit, collect its DFU drive's **`INFO_UF2.TXT`** if present.
-To prove the application's flash reader can capture that exact revision, also
-obtain a read-only bootloader dump plus the MBR word at `0xFF8`, UICR `NRFFW[0]`,
-and flash geometry (or a full vendor HEX including those address records).
-Keep the bootloader unchanged while collecting this evidence. Only the identified
-bootloader region is needed; private application keys/configuration are not.
-If no version bytes or startup register value exist, report `unknown`; a regex
-cannot recover a version that was never embedded.
-
-## Manufacturer sources
-
-- [RAK stock bootloader catalog](https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630)
-- [Heltec SDK](https://github.com/HelTecAutomation/Heltec_nRF52), [vendor downloads](https://resource.heltec.cn/download)
-- LilyGO [T-Echo](https://github.com/Xinyuan-LilyGO/T-Echo), [T-Echo Lite](https://github.com/Xinyuan-LilyGO/T-Echo-Lite), [T-Echo Card](https://github.com/Xinyuan-LilyGO/T-Echo-Card), [T-Impulse Plus](https://github.com/Xinyuan-LilyGO/T-Impulse-Plus)
-- [Seeed Arduino bootloader catalog](https://github.com/Seeed-Studio/Adafruit_nRF52_Arduino/tree/master/bootloader), [T1000-E recovery](https://wiki.seeedstudio.com/sensecap_t1000_e/), [T1000-E development board](https://github.com/Seeed-Studio/Seeed-Tracker-T1000-E-for-LoRaWAN-dev-board), [MeshTracker X1 recovery](https://wiki.seeedstudio.com/sensecap_meshtracker_x1_meshcore/), [Wio L1 bootloader guidance](https://wiki.seeedstudio.com/get_started_with_other_mesh_firmware/)
-- [MeshTiny manufacturer bootloader download](https://shop.mtoolstec.com/bootloader-update-on-meshtiny.html)
-- [Nano G2 Ultra manufacturer bootloader documentation](https://wiki.bqvoy.com/en/meshtastic/nano-g2-ultra)
-- [Muzi bootloader source/releases](https://github.com/muzi-works/Adafruit_nRF52_Bootloader), [BASE source guidance](https://muzi.works/pages/base-system-downloads)
-- [GAT manufacturer documentation](https://github.com/gat-iot/GAT562-family), [Elecrow manufacturer repositories](https://github.com/Elecrow-RD), [Keepteen](https://www.keepteen.com/)
-- [Nologo ProMicro documentation](https://www.nologo.tech/product/otherboard/NRF52840.html)
-- [Minewsemi ME25LS01 module specifications](https://en.minewsemi.com/lora-module/lr1110-nrf52840-me25LS01)
+This document is now in [Test Results](test-results/bootloader_version_stock_audit.md).
diff --git a/docs/cad_scan_validation.md b/docs/cad_scan_validation.md
index 99532b645..ab3dd92ad 100644
--- a/docs/cad_scan_validation.md
+++ b/docs/cad_scan_validation.md
@@ -1,63 +1,5 @@
-# SX1262 channel-scanning hardware results
+# Document moved
-Hardware test on September 12, 2026 (America/Los_Angeles). Heltec V4 transmitter; XIAO ESP32-S3 with Wio SX1262 receiver. Both used standalone diagnostic firmware.
+
-The best tested scan setting was **four channels with four-symbol RX-preamble windows**. Three-symbol windows were unreliable even in the single-channel re-arm control. In the longer four-symbol runs, four channels received 80/80 packets, five received 79/80, and six received 52/60. These are measured limits of this implementation and test setup, not universal SX1262 channel-count limits.
-
-## Configuration and counting
-
-SF5, 125 kHz, coding rate 4/5, explicit header, payload CRC, and 32 preamble symbols. The programmed preamble is 8.192 ms. Channels start at 909.5 MHz and increase in 1 MHz steps; tests use the first N channels, up to 920.5 MHz. Test packets are transmitted individually with varied timing relative to the scanner. There are no overlapping transmitters in this experiment.
-
-A success requires a CRC-valid packet with the expected sequence, channel, length, and test payload. Every included transmission has its own successful transmitter acknowledgement; delayed serial acknowledgements are recovered by sequence, without retransmitting. The one exploratory RX scan trial with shifted acknowledgements is excluded.
-
-## Completed trials
-
-| Trial | Method/window | Channels | Payload bytes | Received / sent | Mean complete scan |
-| --- | --- | ---: | ---: | ---: | ---: |
-| baseline-fixed | Fixed-channel RX | 4 | 64 | 16 / 16 | — |
-| cad-standard | CAD + software RX | 4 | 64 | 7 / 32 | 14.469 ms |
-| cad-auto | CAD + automatic RX | 4 | 64 | 16 / 32 | 14.468 ms |
-| rxfast-4 | rxfast 4 | 4 | 64 | 32 / 32 | 6.100 ms |
-| rxfast-4-maxpayload | rxfast 4 | 4 | 255 | 35 / 35 | 6.098 ms |
-| matrix-cad1-1 | cad 1 | 1 | 64 | 7 / 8 | 2.643 ms |
-| matrix-cad1-10 | cad 1 | 10 | 64 | 2 / 20 | 28.389 ms |
-| matrix-cad1-11 | cad 1 | 11 | 64 | 5 / 22 | 31.297 ms |
-| matrix-cad1-12 | cad 1 | 12 | 64 | 3 / 24 | 34.145 ms |
-| matrix-cad2-10 | cad 2 | 10 | 64 | 3 / 40 | 31.040 ms |
-| matrix-fixed-12 | Fixed-channel RX | 12 | 64 | 24 / 24 | — |
-| matrix-rx1-10 | rxfast 1 | 10 | 64 | 0 / 20 | 7.555 ms |
-| matrix-rx1-11 | rxfast 1 | 11 | 64 | 0 / 22 | 8.316 ms |
-| matrix-rx1-12 | rxfast 1 | 12 | 64 | 0 / 24 | 9.072 ms |
-| matrix-rx2-10 | rxfast 2 | 10 | 64 | 0 / 40 | 10.133 ms |
-| matrix-rx3-7 | rxfast 3 | 7 | 64 | 2 / 35 | 8.892 ms |
-| matrix-rx3-8 | rxfast 3 | 8 | 64 | 1 / 40 | 10.160 ms |
-| matrix-rx4-5 | rxfast 4 | 5 | 64 | 39 / 40 | 7.630 ms |
-| boundary-rx3-1 | rxfast 3 | 1 | 64 | 4 / 24 | 1.268 ms |
-| boundary-rx3-4 | rxfast 3 | 4 | 64 | 2 / 24 | 5.080 ms |
-| boundary-rx3-5 | rxfast 3 | 5 | 64 | 0 / 25 | 6.350 ms |
-| boundary-rx3-6 | rxfast 3 | 6 | 64 | 2 / 30 | 7.621 ms |
-| boundary-rx4-4 | rxfast 4 | 4 | 64 | 80 / 80 | 6.104 ms |
-| boundary-rx4-5 | rxfast 4 | 5 | 64 | 79 / 80 | 7.630 ms |
-| boundary-rx4-6 | rxfast 4 | 6 | 64 | 52 / 60 | 9.158 ms |
-
-`rxfast N` means a continuous-RX preamble observation window of N symbols, with minimal reconfiguration between frequencies. It is not CAD. `cad N` uses the SX1262 CAD detector with N symbols and automatic handoff to RX. CAD has no three-symbol mode.
-
-Scan times describe uninterrupted sweeps. When reception starts, the radio stays on that frequency through the packet and cannot hear packets on the other frequencies. Serial status requests can add occasional timing outliers.
-
-The maximum-payload trial ended after 35 confirmed transmissions when a serial acknowledgement was delayed. Its last TX acknowledgement and matching RX were subsequently recovered from the serial logs; all 35 were received. It is not a completed 64-packet trial.
-
-## Interpretation and limits
-
-The initial four-channel fast-RX test received 32/32 64-byte packets and 35/35 255-byte packets. That is a successful strong-signal bench result, not a guarantee of zero loss. Ordinary four-symbol CAD took approximately 14.47 ms to cover four frequencies and was unreliable with an 8.192 ms preamble.
-
-Reducing the observation window also reduces the time available for the modem to recognize a preamble. Dividing preamble duration by scan duration alone overestimates the number of usable channels. One- and two-symbol RX windows produced no preamble detections in the tested high-channel-count cases.
-
-The three-symbol single-channel control repeatedly re-arms RX, just as a scan would, and received only 4/24 packets. It is distinct from continuous fixed-channel reception, which passed its baseline. This isolates a detection-window limitation even without a long multi-channel sweep.
-
-CAD detection peak 18 and minimum 10 were held constant. These are the datasheet four-symbol SF5 thresholds; the one- and two-symbol CAD tests do not establish optimized thresholds or weak-signal sensitivity. CAD activity followed by no decoded packet can be a late packet detection, not necessarily a noise false positive.
-
-Received signals were strong. These tests do not establish weak-signal range, adjacent-channel rejection, simultaneous-packet capacity, production firmware scheduling performance, power consumption, FCC compliance, or months of uptime.
-
-## Reproduction and evidence
-
-See [the test harness](../tools/hil/cad_scan/README.md). Compact validated results, timing counters, and final firmware/source hashes are in [cad_scan_results.json](../tools/hil/cad_scan/cad_scan_results.json). Detailed sequence-level logs and private lab transport helpers are retained locally under `out/cad-scan/`.
+This document is now in [Test Results](test-results/cad_scan_validation.md).
diff --git a/docs/carrier_wave.md b/docs/carrier_wave.md
index 7adbce688..84762b4d7 100644
--- a/docs/carrier_wave.md
+++ b/docs/carrier_wave.md
@@ -56,5 +56,5 @@ ordinary LoRa packets to verify both RX and TX recovery.
The reproducible bench receiver that provides this live reading is
`tools/hil/cw_observer.cpp`, built with `tools/hil/cw_observer.ini` on a
separate RAK4631. It never transmits on its own.
-See the [hardware validation](carrier_wave_validation.md) for measured results,
+See the [hardware validation](test-results/carrier_wave_validation.md) for measured results,
reproduction steps, and the tested preamble settings.
diff --git a/docs/carrier_wave_validation.md b/docs/carrier_wave_validation.md
index b53a33ab0..33de2a462 100644
--- a/docs/carrier_wave_validation.md
+++ b/docs/carrier_wave_validation.md
@@ -1,112 +1,5 @@
-# CW and CW2 validation
+# Document moved
-Tested on the MercerWoodMesh lab Pi on September 14, 2026 (Pacific), using
-base commit `9241ccc253ad2f8ac2c9a2fe756f59ba2f52505d` plus the CW changes.
-Test images are marked `v1.17.1.6-cw-test-9241ccc2`; they are development images.
+
-A separate RAK4631 sampled live received signal strength (RSSI) about every
-10 ms, without needing to decode a packet.
-The transmitting boards ran the Full V4 repeater and Full T1000-E Companion
-builds. Both were configured for −9 dBm chip TX power, with profiles at
-909.5 and 919.5 MHz, BW125, SF7, CR5, and 32-symbol preambles.
-
-| Check | V4 / SX1262 | T1000-E / LR1110 |
-| --- | --- | --- |
-| `cw on 0.25` measured duration | 0.25 s | 0.25 s |
-| `cw on 1.25` measured duration | 1.25 s | 1.25 s |
-| Manual stop approximately 1 s into a 5 s request | 1.01 s | 1.01 s |
-| `cw on 1.25` after enabling RXPS | 1.26 s | 1.25 s |
-| `cw2 on 1.25` on the second frequency | 1.25 s | 1.25 s |
-| Primary-frequency RSSI change while CW2 transmits | 0 dB | 0 dB |
-| Paired RX and TX recovery checks | 5/5 passed | 5/5 passed |
-
-The observer saw roughly −56 to −57 dBm during V4 carriers and −67 to
-−71 dBm during T1000-E carriers, against a −112 to −113 dBm baseline.
-Readings returned to baseline after timeout and manual stop. These are
-received signal readings, **not calibrated transmitter output measurements**.
-
-RX recovery used CRC-valid LoRa test payloads and the DUT's receive counter.
-TX recovery used each DUT's MeshCore advertisement, received with valid CRC
-by the observer. Both DUTs finished in RX, with CW and radio2 disabled and
-no outbound packet pending. The observer has no autonomous transmission.
-
-## Software checks
-
-- 1,436 native tests passed, including new checks that CW holds queued packets,
- services its deadline, and prevents transmission in the same loop where a
- received command starts CW.
-- Compiled production-method tests cover fractional input, malformed durations,
- bounds, timer rollover, busy/IRQ rejection, RXPS cleanup, board TX/RX callbacks,
- profile selection and replacement, failed TX entry, stop retries, and failed
- RX restart. An ESP32 sleep test covers a carrier with USB disconnected.
-- Full V4 repeater, Full T1000-E Companion, and ordinary STM32
- `RAK_3x72_companion_radio_usb` builds passed. The STM32 build is a compilation
- check, not an RF test of that board.
-- An additional existing OTA sleep test still expects the old
- `ota::ota_ctx().apply_pending` spelling. It fails against unchanged HEAD
- `src/Mesh.cpp` and test-file contents; the actual implementation uses
- `ota_context_if_active()`. This is separate from the CW changes.
-
-## SX1276 follow-up
-
-The SX1276 carrier feature from [PR #11](https://github.com/mikecarper/MeshCore/pull/11)
-is included alongside SX1262 and LR1110 support. It switches to FSK with zero
-deviation, applies the board's calibrated TX power, and rebuilds the runtime
-LoRa configuration when stopped. External-PA settings pass through the same
-DAC/drive-power mapping used for packets, including RFO selection.
-
-The normal TX power command can adjust a running carrier for meter sweeps.
-RadioLib's SX1276 power setter enters standby, so the carrier is restarted
-afterward without extending the deadline. Failed power changes stop the
-carrier and restore the previous cached power.
-
-Follow-up checks use base `9dd9881d520b4b791600a00fff1f8e1436921054` plus the
-SX1276 changes. Production-method regression tests cover both profiles,
-zero deviation, external-PA calibration, RFO, power sweeps, timeout, failed
-FSK entry, failed restoration, retry guards, and return to LoRa. The native
-CW paths and existing radio/profile/sleep/PA checks pass as well.
-Both `GEPRC_Linkflow_900_repeater` (external PA) and
-`Heltec_v2_companion_radio_usb` firmware builds pass, including size checks.
-
-SX1276 RF output has not been measured in this follow-up. The physical RF
-results above apply to SX1262 and LR1110.
-
-## Bench qualifications
-
-The first CW2 request arrived before radio2's existing delayed activation.
-The harness now polls `get radio2.status` for the active mode before testing.
-
-An initial V4 dual-profile packet probe used a 120-symbol preamble against
-32-symbol profiles. A comparison around another CW2 burst received **0/8
-before and 1/8 afterward** with that mismatched probe, versus **8/8 before
-and 8/8 afterward** with a matching 32-symbol probe. The configured preamble
-also sets the driver's preamble-detection hold timeout, so a much longer
-incoming preamble can outlast that hold and be interrupted by scanning.
-The final dual-profile recovery checks use matched preambles. This finding
-does not establish general packet-loss performance for every scan combination.
-
-These checks establish RF activity, selected-frequency behavior, and recovery
-on the tested boards. They do not measure spectral purity, frequency error,
-SWR, calibrated power, or months of uptime. Carrier timeouts are serviced by
-the main loop and depend on that loop remaining responsive.
-
-## Reproduction
-
-Append `tools/hil/cw_observer.ini` to `extra_configs` in `platformio.local.ini`,
-preserving existing entries, and build `pio run -e cw_observer_rak4631`.
-Flash that application onto a separate lab RAK4631. Do not run PlatformIO
-concurrently with another build or upload in the checkout.
-
-Configure the lab DUT's primary profile as above, disable radio2, set TX power
-to −9 dBm, and reboot to apply saved primary settings. Then run:
-
-```sh
-python3 tools/hil/carrier_wave.py --dut /dev/serial/by-id/DUT \
- --observer /dev/serial/by-id/OBSERVER --role repeater --output cw-results.json
-```
-
-Use `--role companion` for the T1000-E. The harness changes lab radio2/RXPS
-settings and emits short RF bursts and packet probes. It is for test boards.
-
-Compact evidence: [carrier_wave_results.json](../tools/hil/carrier_wave_results.json).
-Command guide: [carrier-wave RF checks](carrier_wave.md).
+This document is now in [Test Results](test-results/carrier_wave_validation.md).
diff --git a/docs/cli_commands.md b/docs/cli_commands.md
index fd881b889..663ccf2a7 100644
--- a/docs/cli_commands.md
+++ b/docs/cli_commands.md
@@ -1044,7 +1044,7 @@ set usb.logging on
Verify with `get powersaving` (expect `off`) and `get usb.logging` (expect `on`).
Both settings are saved; disabling logging later does not restore power
-saving. This works around the [released USB sleep bug](releases/1.17.1.5.md#g3-usb-disappearance-with-power-saving-enabled).
+saving. This works around the [released USB sleep bug](old-releases/1.17.1.5.md#g3-usb-disappearance-with-power-saving-enabled).
nRF52 does not need this ESP32 workaround.
These commands are compiled into ordinary USB-loggable artifacts and every
@@ -1443,7 +1443,7 @@ Station G2/G3 targets default to `off`.
**Notes:**
- `get rxps.wd` reports the radio watchdogs' soft and hard recovery counts.
- See [receive calibration and recovery](radio_receive_calibration.md) for
+ See [receive calibration and recovery](test-results/radio_receive_calibration.md) for
noise sampling, CAD handling, and the continuous-RX mode check.
- `get radio.rxps.config` adds the persisted level and preamble assumption to
the on/off and timing values. Deployment tools use it to restore a
@@ -2154,7 +2154,7 @@ minutes when the terminal is closed, even with a computer attached. The
source fix blocks ESP32 light sleep while a native USB host is attached or
live USB logging is enabled; ordinary CPU idle/yield remains available.
The G3 button also wakes the device for at least two minutes. See the
-[G3 release note](releases/1.17.1.5.md#g3-usb-disappearance-with-power-saving-enabled)
+[G3 release note](old-releases/1.17.1.5.md#g3-usb-disappearance-with-power-saving-enabled)
for verification steps and the distinction between the fix and the published
binaries.
@@ -2257,7 +2257,7 @@ The pin number is the Arduino pin number used by that target (the normal GPIO nu
**1.17.1.5 infrastructure bug:** affected Repeater, Room Server, and Sensor
images accept `get path.hash.mode` but report `unknown config` for the setter.
The command was not renamed. The source correction restores the setter;
-installed images need an updated build. See the [setting-dispatch audit](cli_settings_audit.md).
+installed images need an updated build. See the [setting-dispatch audit](test-results/cli_settings_audit.md).
**Usage:**
- `get path.hash.mode`
@@ -4770,7 +4770,7 @@ displaying a version neither grants OTA capability nor bypasses install checks.
The fix is in MeshCore application firmware; installing another bootloader alone
does not update an older application's version reader.
-See the [stock bootloader audit](bootloader_version_stock_audit.md) for tested
+See the [stock bootloader audit](test-results/bootloader_version_stock_audit.md) for tested
manufacturer images and the boards still requiring untouched factory dumps.
---
diff --git a/docs/cli_settings_audit.md b/docs/cli_settings_audit.md
index c4dc0a3da..113dbba66 100644
--- a/docs/cli_settings_audit.md
+++ b/docs/cli_settings_audit.md
@@ -1,74 +1,5 @@
-# CLI setting dispatch audit
+# Document moved
-The 1.17.1.5 source retained `get path.hash.mode` in `CommonCLI` but lost its
-setter when radio preferences moved into `CommonRadioPrefs`. Infrastructure
-roles call `CommonCLI` directly, so the shared parser's setter was unreachable.
-The correction restores `set path.hash.mode 0|1|2` there, validates the value,
-and saves accepted changes. Missing/invalid values report a value error and
-leave the setting unchanged.
+
-The other radio setters removed by the original refactor had already been
-restored. A feature-guarded check found a second mismatch: `get extra.sf` exposed
-stored data on non-LR2021 radios while its setter was compiled out. Both
-commands now return `Error: extra.sf requires an LR2021 radio` there. Supported
-LR2021 set/get behavior is preserved. These are source corrections; existing
-release binaries need rebuilding/updating to receive them.
-
-## Coverage
-
-| Surface | Check |
-| --- | --- |
-| Common infrastructure CLI | 72 literal setter keys and 84 query keys; every query has a setter or an explicitly reviewed read-only/alternate-command exception |
-| MQTT/observer CLI | 33 literal setter keys and 39 query keys, including Wi-Fi, timezone, alerts, display and watchdog settings |
-| MQTT slots | All nine writable slot subkeys have query/set coverage; `diag` is intentionally read-only |
-| Repeater, Room Server, Sensor | Shared CLI delegation and role-specific query/set coverage |
-| Companion | Shared-radio allowlist and parser agreement, persistence wiring, terminal-specific setters, and role query exceptions |
-| Radio settings moved by the refactor | All 13 shared setters remain present in infrastructure dispatch |
-| Literal command comparisons | Checks key lengths so a setter cannot accidentally compare its value against the key's terminating NUL |
-| Feature guards | Preprocesses common and observer handlers for minimal, nRF52 GPS/SD, ESP32 WebConfig, ESP32 MQTT, RS232/GPS, ESP-NOW, and LR2021 profiles before comparing query/set coverage |
-
-Read-only exceptions include runtime connection status, diagnostics, firmware
-role, bootloader identity and reset information. Alternate-command exceptions
-include the password command and `set battery.alert on `. These
-exceptions are explicit in the test, rather than silently allowing all missing
-setters. Dynamic/delegated command families and hardware-specific features
-continue to use their dedicated tests; the inventory is not a claim that every
-build has every feature enabled.
-
-## Executed behavior tests
-
-The native C++ fixture compiles the production get/set dispatch branches and
-the real setting bodies for 14 radio-related settings: `radio`, `freq`, `af`,
-`dutycycle`, `int.thresh`, `cad`, `radio.rxgain`, `tx`, `rxdelay`,
-`agc.reset.interval`, `path.hash.mode`, `multi.acks`, `txdelay`, and
-`direct.txdelay`. It runs local and authenticated on-air caller timestamps,
-checks round-trip values and save calls, preserves AGC interval rounding, and
-checks that rejected hardware applies do not save or change preferences.
-Hardware callbacks are mocked; these are not live-radio or flash-durability
-tests. The fixture also runs with and without `USE_LR2021`, checking supported
-`extra.sf` set/query/clear behavior, invalid values, and the unsupported-radio
-error.
-
-The focused hash-mode test checks all valid modes, missing values, negative and
-out-of-range values, overflow, malformed numbers, whitespace, and similarly
-named unknown keys. Existing transport tests exercise Companion local/framed
-and on-air dispatch, local-only secret reads, and browser/stream routing under
-AddressSanitizer and UndefinedBehaviorSanitizer on Linux.
-
-Run the CI checks locally with a host C++ compiler (Linux is required for the
-existing sanitizer-enabled transport suite):
-
-```text
-python3 -B test/test_path_hash_cli.py
-python3 -B test/test_cli_settings_contract.py
-python3 -B test/test_local_cli_access.py
-```
-
-All 14 tests passed on the Linux VM. The workflow runs these commands on future
-pushes and pull requests. The new inventory and native radio tests also passed
-with the Windows host compiler.
-
-`pio run -e heltec_v4_repeater` passed after both corrections, including the
-firmware's flash-size and runtime-RAM checks. The initial audit used host tests
-and build checks; subsequent hardware checks are recorded in the
-[dual-profile validation](radio_profiles_validation.md).
+This document is now in [Test Results](test-results/cli_settings_audit.md).
diff --git a/docs/companion_contact_cache.md b/docs/companion_contact_cache.md
index 54b7aadc9..41623d2c8 100644
--- a/docs/companion_contact_cache.md
+++ b/docs/companion_contact_cache.md
@@ -6,7 +6,7 @@ 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](companion_contact_cache_results.md) for the
+See the [qualification results](test-results/companion_contact_cache_results.md) for the
six-board RAM comparison, physical tests, and shared-secret timings.
PSRAM boards retain their existing complete contact table in external RAM.
@@ -124,7 +124,7 @@ 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](nimble_companion_trial.md), then build one environment at
+[NimBLE trial guide](test-results/nimble_companion_trial.md), then build one environment at
a time. For example:
```sh
diff --git a/docs/companion_contact_cache_results.md b/docs/companion_contact_cache_results.md
index fa93f3599..231d58b7c 100644
--- a/docs/companion_contact_cache_results.md
+++ b/docs/companion_contact_cache_results.md
@@ -1,146 +1,5 @@
-# Contact-cache and NimBLE RAM qualification
+# Document moved
-Test date: 2026-09-08. Firmware source: `b68aa1c6`.
+
-All six constrained ESP32 Full Companion trials pass the linked RAM budget
-at **350 contacts, 40 channels and 256 normal offline frames**. Their final
-capability manifests confirm compiled Bluetooth/mOTA support and valid WiFi
-OTA layouts.
-These are the optional environments in the
-[contact-cache guide](companion_contact_cache.md). Ordinary release recipes
-retain their established contact limits pending qualification on those boards.
-
-## Linked internal RAM
-
-Every column below uses 350 contacts, 40 channels and 256 normal offline
-frames. Values are bytes available for runtime allocations after linking.
-The final margin subtracts the existing conservative runtime reservation;
-it is not a measurement of live free heap.
-
-| Hardware | Bluedroid | NimBLE | NimBLE + mOTA queue loan | Plus contact caches | Final margin |
-| --- | ---: | ---: | ---: | ---: | ---: |
-| Heltec V3 | 143,776 | 158,896 | 170,152 | 196,168 | 23,112 |
-| Wireless Tracker | 148,432 | 163,536 | 174,792 | 200,824 | 30,838 |
-| Tracker V2, FEM on | 133,064 | 148,168 | 159,416 | 185,456 | 15,470 |
-| Heltec CT62 | 99,040 | 114,096 | 125,312 | 151,344 | 15,152 |
-| XIAO ESP32-C3 | 98,816 | 113,888 | 125,120 | 151,152 | 14,960 |
-| Generic ESP-NOW | 99,776 | 114,800 | 126,032 | 152,064 | 15,872 |
-
-NimBLE recovers about 14.7 KiB. Contact caches add about 25.4 KiB on top of
-NimBLE and the shared mOTA workspace. NimBLE alone fails the unchanged budget
-at this capacity on all six; adding the queue loan passes only on the original
-Wireless Tracker. Adding the contact caches passes all six, with 14.6-30.1 KiB
-of margin. The same Bluetooth runtime allowance is used in every comparison.
-
-The shared mOTA queue stays at 256 frames normally and retains 128 while
-mOTA borrows its workspace, then returns to 256. A queue above 128 must be
-synchronized before that workspace can be borrowed; unread frames are not
-discarded to start mOTA. PSRAM-backed queues retain their existing policy.
-
-The baseline builds use `b041fc7f`; the final builds use `b68aa1c6`, with the
-same ESP32 Arduino 2.0.17 toolchain and NimBLE-Arduino 2.5.1 where applicable.
-
-The CT62, XIAO C3 and Generic ESP-NOW applications fit two 1.5 MiB OTA slots
-on 4 MiB flash. Their explicit trial layouts retain 896 KiB SPIFFS and the
-existing SPIFFS/coredump addresses. Install the merged image over USB when
-changing from the ordinary single-slot Full layout. The other three trials
-also retain verified dual WiFi OTA slots. All six remain mOTA senders.
-
-Additional builds check the platform and default-policy branches:
-
-| Build | Available bytes | Reserved runtime bytes | Margin bytes | Policy |
-| --- | ---: | ---: | ---: | --- |
-| T096 Full, FEM on | 100,076 | 73,728 | 26,348 | nRF52 path/secret caches |
-| RAK3401 Full | 99,900 | 50,976 | 48,924 | nRF52 path/secret caches |
-| Wireless Paper Full | 187,360 | 152,576 | 34,784 | ESP32 caches, existing Bluedroid |
-| V4 Full NimBLE | 263,944 | 173,056 | 90,888 | Complete contacts in PSRAM |
-| XIAO nRF52 legacy BLE Companion | 84,368 | 38,688 | 45,680 | Existing inline contact storage |
-
-The V4 test-only forced-cache build also passes its RAM budget. The V4 normally
-keeps complete contacts in PSRAM; forcing caches adds internal RAM overhead.
-The six-board savings above apply to boards without PSRAM.
-
-## Physical checks
-
-The VM V4 used the test-only cache override. USB tests verified all 350
-contacts and all 64 saved path bytes per contact, cold individual lookups,
-replacement of the complete path set, and persistence after reboot. A secret
-workload contacted 21 distinct synthetic peers, then the first peer twice:
-22 calculations, one RAM hit, and no secret-file access under the ESP32 policy.
-
-A sustained send test exposed a false receive watchdog recovery. RX could
-occur inside the main loop between two successful transmissions, while the
-watchdog observed TX at both loop boundaries. It counted from the start of
-the whole burst and could reset a working radio during a later CAD pause.
-`468cc3cf` restarts the receive recovery allowance after each successful TX.
-The native regression fails before that change and passes afterward; it also
-checks that a stuck receiver still gets recovered.
-
-The corrected V4 passed the same 23-datagram workload with zero radio error
-flags. It also passed a complete 350-path replacement, another send workload
-and a post-reboot path check with zero radio error flags. The replacement
-took about 127 seconds: dirty-cache eviction still performs synchronous
-SPIFFS contact-file saves, so bulk path updates remain a latency workload
-to qualify on the other ESP32 boards. After that run it reported 133,828 bytes free internal heap, a minimum
-of 130,976 bytes, and a largest free block of 122,868 bytes. These readings
-belong to the forced-cache V4 workload, not the six other boards.
-
-After qualification, the VM V4 was erased to remove synthetic contacts and
-installed with the normal PSRAM-backed V4 NimBLE trial from `b68aa1c6`. It is
-named `NimBLE-V4-VM`, uses the PIN shown on its display, and has powersaving
-off for USB use. That clean setup reported 143,612 bytes of free internal heap
-and passed 201 local USB requests with zero radio error flags. The Mercer V4 and
-XIAO S3 were left on their previously qualified NimBLE builds.
-
-The Mercer RAK3401 Full Companion verified 350 paths, eviction and persistence.
-Its first secret workload produced 21 calculations, one flash hit and one RAM
-hit with no skipped saves. After reboot, the same workload produced 22 flash
-hits, one RAM hit and **zero calculations**. Both completed with zero radio
-error flags. The final `b68aa1c6` RAK image repeated the 350-path and saved-secret
-checks with zero radio errors and zero calculations. The RAK was returned to
-the published `26303793` repeater
-application with its original repeater name and USA Cascade settings; the
-Pi's MQTT logger was restarted after the exclusive USB tests.
-
-## Shared-secret timing
-
-| Hardware/filesystem | Successful saved-secret lookup | Key exchange | Selected miss policy |
-| --- | ---: | ---: | --- |
-| V4 / SPIFFS | 132,427 us | 21,932 us | Recalculate |
-| RAK3401 / LittleFS | 7,812-8,789 us | 31,250 us | Read saved entry, calculate on miss |
-
-The SPIFFS timing comes from the earlier forced flash-cache experiment. The
-final ESP32 policy does no secret-file I/O. Both platforms reuse the 16 most
-recent secrets from RAM. These are elapsed-time measurements, not energy
-measurements; timing on other ESP32/nRF52 boards remains to be measured.
-
-## Regression checks and evidence
-
-The contact-cache suite exercises the production cache and persistence code
-under address/undefined-behavior sanitizers, with the real Ed25519 key-exchange
-implementation. Its cases include 350 paths, identity changes, snapshots,
-legacy migration, storage faults, handle exhaustion and packet release when
-a cold path cannot be read. The related contract suites passed 62 checks.
-The native queue/store suites passed 27 cases, the radio-liveness suite passed
-eight, and the partition-selection suite passed 11.
-
-Hardware reproduction commands are in the
-[contact-cache guide](companion_contact_cache.md#regression-checks). The tracked
-harness is `tools/hil/contact_cache_serial_stress.py`. It requires exclusive
-access to the specified Companion data port.
-
-Local evidence in the qualification workspace:
-
-- `.releases/nimble-six-capacity/control/results.json`: 18 baseline builds.
-- `.releases/contact-cache/qualified/control/results.json`: final build results.
-- `.releases/contact-cache/qualified/built/`: firmware, ELFs, maps, RAM reports,
- partition tables and capability manifests.
-- `.releases/contact-cache/qualified/control/`: final V4 USB and flash checks.
-- `.releases/contact-cache/control/`: earlier experiments, RAK results and
- regression logs, including the watchdog's failing/passing test runs.
-
-Each final RAM report is checked against its ELF hash. All 12 final builds pass
-their RAM and OTA capability checks. Physical testing here covers the V4 and
-RAK3401; the six constrained ESP32 boards still need pairing, WiFi/BLE/LoRa
-coexistence and sustained-load testing on their own hardware. No GitHub release
-assets or firmware-picker entries were replaced by these experimental builds.
+This document is now in [Test Results](test-results/companion_contact_cache_results.md).
diff --git a/docs/companion_offline_queue.md b/docs/companion_offline_queue.md
index 2a480d7e9..a898addc6 100644
--- a/docs/companion_offline_queue.md
+++ b/docs/companion_offline_queue.md
@@ -28,7 +28,7 @@ Meshadventurer SX1262 and SX1268 Full Companion use 16 frames together with 100
contacts and 30 group channels; their ordinary transport-specific images keep
128 frames and 40 channels.
-The [nRF52 Full memory correction](releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report)
+The [nRF52 Full memory correction](old-releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report)
applies queue sharing to every nRF52 Full Companion. It keeps 256 frames
normally, retaining each board's contacts, channels, and all
Full transports. The upper 128 slots temporarily hold the mOTA context when
diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md
index 2ea4729b1..958f240d4 100644
--- a/docs/companion_radio_full.md
+++ b/docs/companion_radio_full.md
@@ -398,7 +398,7 @@ defaults and full-queue behavior.
Every corrected nRF52 Full profile keeps **256 offline frames normally** and
temporarily lends 128 slots to mOTA to leave room for
Bluetooth tasks, displays and UI allocations. Queue sharing preserves the
-board's contacts, channels, and USB/Bluetooth mOTA sending. See the [memory correction](releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report).
+board's contacts, channels, and USB/Bluetooth mOTA sending. See the [memory correction](old-releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report).
If an explicit OTA source, discovery, download, install, or temporary-radio
request needs that workspace while more than 128 unread frames are queued, the
oldest volatile frames are consumed until 128 remain. The command reports the
diff --git a/docs/companion_usb_ascii_validation.md b/docs/companion_usb_ascii_validation.md
index a9ca454a6..27067a01f 100644
--- a/docs/companion_usb_ascii_validation.md
+++ b/docs/companion_usb_ascii_validation.md
@@ -1,68 +1,5 @@
-# USB Companion ASCII default validation
+# Document moved
-The updated [1.17.1.6 Companion release](https://github.com/mikecarper/MeshCore/releases/tag/v1.17.1.6-halo-keymind-cascade-dev-306feebe)
-covers 103 USB-enabled profiles: 52 ESP32 Full, 43 nRF52 Full, four RP2040 USB,
-and four STM32 USB. The BLE-only WM1110 image has no USB interface and is outside
-this change.
+
-## Behavior and cause
-
-Ordinary USB Companions previously started in Binary mode. Full Companions
-started in ASCII, but host-session cleanup could select or retain Binary mode
-for the next connection.
-
-All USB Companions now select ASCII before framed dispatch begins. An
-observable USB session boundary cancels old USB operations and clears partial
-input, queued protocol output, frame ownership, and mOTA attachment before restoring
-ASCII. A network terminal can defer that restoration; a new USB binary client
-that connects during the deferral keeps its chosen mode. BLE and network
-Companion transports keep their existing binary protocol.
-
-A complete framed app command still selects Binary mode automatically. An
-incomplete initial probe returns to ASCII after one second. Explicit terminal
-start/stop controls remain available.
-
-Session detection depends on the hardware. Native USB with DTR can detect a
-terminal closing. ESP32 hardware USB Serial/JTAG observes bus resets and host
-loss, but cannot reliably observe a program closing its handle. UART bridges
-usually cannot observe that close either. Idle time never forces an active
-binary app back into ASCII; use the start token on a port that remains Binary.
-See the [USB mode guide](full_companion_usb_switcher.md).
-
-## Automated validation
-
-- The real firmware startup and reset functions are compiled and executed for
- nRF52, ESP32 TinyUSB, ESP32 hardware USB, and RP2040, with and without the
- Full macro. Cases include reconnecting from Binary or ASCII, delayed cleanup,
- mOTA cancellation, network ownership, and a new binary client during deferral.
-- 55 native serial-mode/flow-control tests and 11 terminal-session/diagnostic
- tests passed locally, along with 49 USB Python tests and the picker tests.
-- STM32 number formatting is tested against an Arduino API without `ltoa`.
- CI also compiles an ordinary RAK 3x72 USB Companion with the real core and
- RadioLib, so missing packages and GPIO API incompatibilities fail the build.
-
-The 99 ESP32/nRF52/RP2040 profiles use
-[`d4a641ff`](https://github.com/mikecarper/MeshCore/commit/d4a641ff8af1b413c8fb96a670282b799be1f6e8).
-The four STM32 profiles additionally use the compatible Arduino 2.12 platform
-pin and build test in
-[`d2c52e4d`](https://github.com/mikecarper/MeshCore/commit/d2c52e4d047c4b1068a1053364d9acaddefa64a1).
-Both source revisions passed GitHub checks:
-[main repair](https://github.com/mikecarper/MeshCore/actions/runs/34792631229) and
-[STM32 repair](https://github.com/mikecarper/MeshCore/actions/runs/34793043594).
-
-All 103 builds passed capability and RAM qualification. Packaging checks bind
-each application to its capability and memory manifests, retain the original
-profile capabilities and download formats, and verify ESP32 application/merged
-image consistency. Replacement publication verifies public SHA-256 digests
-before deleting old images and checks that unrelated firmware assets retain
-their original hashes. Release manifests, checksum lists, picker links, and
-capacity-note source references follow the new binaries.
-
-Publication completed with all 430 uploaded files verified through their public
-download URLs. The 412 superseded firmware/manifest files were removed. The
-remaining 1,837 assets kept their original hashes, and all five release checksum
-lists matched the final GitHub asset digests.
-
-No USB Companion was attached to the local computer for this repair. These
-results are compilation and automated protocol tests, not physical USB tests
-across the board matrix.
+This document is now in [Test Results](test-results/companion_usb_ascii_validation.md).
diff --git a/docs/companion_usb_client_validation.md b/docs/companion_usb_client_validation.md
index d6035e987..db109c856 100644
--- a/docs/companion_usb_client_validation.md
+++ b/docs/companion_usb_client_validation.md
@@ -1,117 +1,5 @@
-# USB Companion client compatibility
+# Document moved
-The RAK4631 report was reproduced on the MercerWoodMesh Pi with the published
-`RAK_4631_companion_radio_full-v1.17.1.6-halo-keymind-cascade-dev-d4a641ff`
-image. The fixes described here are in the source and test firmware;
-the existing GitHub release assets have not yet been replaced with this fix.
-Hardware validation was completed on 2026-09-14 with test builds labeled
-`v1.17.1.6-usb-client-test-0e5955f8` (base commit plus these USB fixes).
+
-## Port selection
-
-| Hardware | Companion/ASCII port | USB logging |
-| --- | --- | --- |
-| RAK4631 and other nRF52 Full Companions | Primary CDC, Linux `*-if00` | Optional second CDC, `*-if02` |
-| ESP32 Full Companions | Primary USB serial port | Shares that port; must be off for Binary Companion |
-
-`+++MESHCORE-TERM-STOP` changes the primary port to Binary Companion. Its
-`OK - Binary mode` reply does not move the client to a second port. Close the
-terminal before opening that same port with `meshcli -s PORT infos`. Do not use
-MeshCLI's repeater-mode `-r` option for a binary Companion connection.
-
-## Two reproduced failures
-
-1. Stock `meshcore-cli` 1.6.3 was tested with `meshcore` 2.3.10, whose serial client
- deliberately deasserts DTR. The nRF52 primary USB gate and response route
- both required DTR. A raw battery query returned a valid 14-byte response
- with DTR high and no bytes with DTR low.
-2. The automatic ASCII banner ended in `> `. MeshCLI recognized that `>` as a
- binary frame marker and consumed the real response's leading `>` as a
- length byte. Even forcing DTR high in a diagnostic client did not recover
- the first `APP_START` response. Stock MeshCLI exited without node information.
-
-The nRF52 transport now accepts real USB input as evidence of a client, tagged
-with the current session generation. Close, line-coding, and device-reset
-events invalidate that evidence; an old reader racing cleanup cannot grant
-access to a newer session. The write path remains bounded and does not wait
-for an unread host. CDC1 logging retains its own DTR requirement.
-
-The default ASCII terminal now suppresses its banner and unsolicited terminal
-events until ASCII input arrives. Enter or a command reveals the terminal.
-A binary probe receives a clean first response. Explicit START/STOP remains
-available, and an incomplete binary probe still times out back to ASCII.
-
-On a shared logging port, STOP now reports
-`ERROR: set usb.logging off before Binary mode` while logging is enabled.
-It no longer acknowledges Binary mode and immediately reclaims the port for
-ASCII logging.
-
-## Hardware validation
-
-The RAK4631 Full test firmware passed with an unmodified installed MeshCLI and
-its normal DTR-low serial library:
-
-- ASCII `ver` from the default mode, without START;
-- explicit STOP acknowledgement followed by 10 binary battery queries;
-- 10 immediate close/reopen queries each with DTR low and high;
-- 10 stock-library reconnects and three separate `meshcli ... infos` runs;
-- all of the above while the second CDC streamed logs: 598 bytes received,
- no logging disconnections.
-
-The Heltec V4 Full Companion also passed using its ESP32-S3 USB Serial/JTAG
-interface, with USB logging disabled:
-
-- default ASCII `ver`, then STOP and 10 clean binary battery replies;
-- 10 stock-library reconnects and three unmodified `meshcli ... infos` runs;
-- a stock-client connection directly after reboot, without either mode token.
-
-The shared-port logging policy was exercised on the V4 as well. With logging
-on, STOP returned the explicit error, a binary probe could not claim the port,
-and stock MeshCLI could not connect. After `set usb.logging off`, STOP worked,
-10 binary replies were clean, and three stock-client reconnects succeeded.
-
-After testing, the V4's complete original flash snapshot was restored and
-verified by the flasher. Its original `v1.17.1-soak-v4-B` firmware answered
-again, and the previously active memory-soak collector was restarted. This
-test interrupted that board's uptime; the restored firmware starts a new run.
-Fresh successful V4 telemetry was verified at `2026-09-14T19:46:01Z`.
-The RAK4631 remains on the patched Full Companion test build with its separate
-logging interface enabled. The Pi's previously active services were restored.
-
-The nRF52 DTR stress sequence is deliberately not used on ESP32. Changing those
-control lines during immediate close/reopen triggered the V4's hardware
-bootloader and produced an `ESP` boot message. Preselecting RTS before opening
-did not prevent that transition on this host. ESP32 reconnect results above
-use the unmodified client's normal connection sequence instead.
-
-The test issues local information queries only. It does not transmit test chat
-messages over LoRa. Firmware installation and logging configuration are
-separate from the reusable test runner:
-
-```sh
-python3 tools/hil/companion_usb_client.py \
- --port /dev/serial/by-id/PRIMARY-if00 \
- --logging-port /dev/serial/by-id/LOGGING-if02 \
- --test-dtr --cycles 10 --output usb-client-results.json
-```
-
-Replace the example paths with the device's actual stable paths. Install
-`meshcore-cli` in the Python environment used for the test. Start after a fresh
-reboot so the default-ASCII check has a new session. On ESP32, omit both
-`--logging-port` and `--test-dtr`, and disable USB logging first.
-
-## Automated checks
-
-- 53 Python USB checks pass, including compiled execution of the real nRF52
- callbacks and transport gate with DTR-low clients, cleanup races, early
- input, line-coding changes, physical disconnects, full TX FIFO, CDC1 isolation,
- and the 1200-baud bootloader entry.
-- The real terminal methods are compiled for ordinary and Full modes to verify
- that unsolicited text is suppressed before the first binary response.
-- The actual STOP branch is exercised for logging off, shared-port logging on,
- and dedicated-port logging on.
-- 63 native serial-switching, flow-control, and terminal-session tests pass.
-- RAK4631 Full and Heltec V4 Full firmware compile and pass their packaged
- capability checks.
-
-The USB workflow runs the new compiled regression tests on subsequent changes.
+This document is now in [Test Results](test-results/companion_usb_client_validation.md).
diff --git a/docs/double_frequency_v4_validation.md b/docs/double_frequency_v4_validation.md
index 355129d5d..d1d796ac5 100644
--- a/docs/double_frequency_v4_validation.md
+++ b/docs/double_frequency_v4_validation.md
@@ -1,114 +1,5 @@
-# Double center-frequency write: V4 transmitter / XIAO receiver
+# Document moved
-2026-09-14. **Completed bounded comparison: neither single nor duplicate
-frequency writes reached 400/400. The duplicate adds about 86 us per hop.**
-No production default was changed. Both radios are idle with HIL installed;
-the duplicate-write toggle is off. The earlier descending-SF sweep and its
-follow-up remain paused; no new exhaustive settling sweep was launched.
+
-## Hardware and control
-
-- RX: XIAO ESP32-S3/WIO SX1262, COM31, MAC 28:84:85:b4:09:80. Normal gain
- register 0x94, unchanged-modulation cache enabled, buffered 8 MHz SPI.
-- TX: newly connected Heltec V4, COM7, MAC 44:1b:f6:6a:e8:44. Hardware probe
- found ESP32-S3, 2 MB embedded PSRAM and 16 MB flash. HIL detected KCT8103L
- FEM and reported the amplified TX path; chip RX gain 0x96.
-- User explicitly authorized erasing the V4 without backup. Its full flash
- was erased and a verified HIL upload completed. XIAO's previous HIL image
- was replaced with a verified same-image A/B-capable build. No private
- backup contents were read; RAK and other boards were not touched.
-- SF10/125 kHz, CR4/5, four centers 909.500/909.750/910.000/910.250 MHz,
- 5.1-symbol visits (41,780 us), 32-symbol TX preamble, zero added settling,
- synthetic 16-byte packets, -9 dBm **chip request**. No extra IRQ polling.
-
-The V4 FEM uses the repository's production detection and TX/RX control.
-HIL restores RX after success or error on each direct transmit path. The
-GC1109 branch (not detected here) uses bypass according to
-[GC1109 table 4](https://resource.heltec.cn/download/WiFi_LoRa_32_V4/datasheet/GC1109_EN_V0.9.2.pdf).
-The detected KCT8103L uses its amplified path; antenna-port TX power was not
-measured. This is not a power-matched Indicator/V4 comparison.
-
-## Exact experiment
-
-`scanfreqrepeat 1` makes the HIL `ExperimentalSX1262` repeat the same
-SetRfFrequency (0x86) immediately after the first successful frequency call,
-before modulation, packet parameters and RX restart. The second call skips
-the image-calibration check, but keeps normal BUSY/status handling. Only an
-identified RX-to-RX hop may repeat; ordinary setup/TX does not. Either write's
-failure propagates through the existing failed profile-apply path. The toggle
-defaults off at reboot and cannot change during an active scan.
-
-Both modes use the same XIAO binary. Actual SPI command counts, not only
-software policy labels, are checked against one or two 0x86 writes per hop.
-The modulation cache remains on in both modes.
-
-## RX-only timing
-
-Six-second windows in single/double/double/single order, with reboot/clean
-setup between windows. No reference packets transmitted during this phase.
-
-| Frequency writes | Hops | Mean switch | Actual 0x86 | Actual 0x8B |
-| --- | ---: | ---: | ---: | ---: |
-| Single, first | 142 | 451.965 us | 142 | 0 |
-| Double, first | 141 | 538.213 us | 282 | 0 |
-| Double, second | 141 | 538.170 us | 282 | 0 |
-| Single, second | 142 | 451.930 us | 142 | 0 |
-
-Weighted means: **451.948 us single, 538.192 us double**: +86.244 us / +19.1%.
-All 566 hops had zero retune, RX-mode and cache errors. Maximum observed
-switch costs were 594 us single and 674 us double; these are samples, not
-hard latency guarantees. Unheld full-cycle means were about 168.942 ms versus
-169.285 ms.
-
-## Packet checks
-
-Single first, then double, each up to 100 packets/channel and stopping on its
-first miss; a 400/400 result would have stopped all RF testing. Neither passed.
-
-| Frequency writes | Valid before first miss | Failed packet | RF-run mean switch | Hops / 0x86 writes |
-| --- | ---: | ---: | ---: | ---: |
-| Single | 1 | 2, channel 2 | 452.719 us | 235 / 235 |
-| Double | 4 | 5, channel 2 | 542.063 us | 255 / 510 |
-
-Both failed packets were receiver timeouts, with TX success acknowledged,
-zero device-error flags and RX mode still active. Both settings reported zero
-retune, RX-mode, cache and RX errors. Ordinary processing after received
-packets caused one/four modulation rewrites respectively; pure RX-only timing
-above is the cleaner comparison of the duplicate cost.
-
-All five good V4 packets reported **-19 dBm RSSI**. SNR was +9.0 dB in the
-single-write sample and +8.2..+9.2 dB (mean +8.675 dB) for four double-write
-samples. The previous Indicator run had 547 good packets at average -40.547
-dBm RSSI, +8.311 dB SNR. Thus this V4 setup was about 21.5 dB stronger as seen
-by the receiver. Hardware/antenna/placement/power differences remain
-confounded; neither the Indicator nor close-range effects are proven causes.
-
-One versus four successes before a miss is too small and stop-conditioned to
-establish a reliability improvement or packet-error rate. Repeating center
-frequency did not remove the observed failure at this setting. No results for
-lower SFs or added settling delays are claimed for the V4. A weaker received
-signal is needed before treating a transmitter swap as a clean comparison.
-
-## Artifacts and checks
-
-Base checkout `0e5955f889788a4317863e731b38ffe09e3d5441`, branch
-`keymindCascade`, plus local uncommitted changes. RadioLib 7.7.1 pinned at
-`187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32 6.11.0 / Arduino
-ESP32 2.0.17. Both builds/uploads succeeded and uploader verified written
-data. The 59 selected host/native tests passed (39 collectors, ten profile,
-four SX1262, five radio and one receive-mode), including FEM restore on error
-and frequency-repeat scope/error propagation. Temporary build overlay removed.
-
-Actually loaded firmware SHA-256:
-
-- XIAO: `9f82d86ea78cfa6bc03fef4925f10c918c8b9fc7a96a7d2b565a04bd2f281288`
-- V4: `2ea2ce37d9d66fb4f8ec9cf3cedfbe7d4eb6d08bfe9abe6c817e5afb04885636`
-
-Raw evidence and byte-preserving SHA-256:
-
-- [RX-only ABBA](../tools/hil/profile_switch_frequency_sf10_rx_only_results.json):
- `38ded968a60ff19f45618ec35d75ce46354d81f163879185e9d6ff0e68da3ae0`
-- [Single-write packets](../tools/hil/profile_switch_frequency_v4_sf10_off_results.json):
- `4cadeba0d22b3ec6158ff295f1becfb57fde7ef8bf7fabbdbc6295f274619557`
-- [Double-write packets](../tools/hil/profile_switch_frequency_v4_sf10_on_results.json):
- `46bea8d9c095847ef8ccf4a4ed51ab5404eb5bb87d9cf62b832c3298159d1525`
+This document is now in [Test Results](test-results/double_frequency_v4_validation.md).
diff --git a/docs/firmware_memory_budget.md b/docs/firmware_memory_budget.md
index 348136f1b..80671b423 100644
--- a/docs/firmware_memory_budget.md
+++ b/docs/firmware_memory_budget.md
@@ -33,7 +33,7 @@ Headless and OLED devices use their own smaller totals. The JSON lists each
component and checks the largest available region against the largest planned
single allocation.
-The [small-screen message layout](v4_pixel5_font_trial.md) retains complete
+The [small-screen message layout](test-results/v4_pixel5_font_trial.md) retains complete
160-byte messages. Its expanded preview records add 2,816 bytes to the
startup allowance and increase the contiguous history allocation budget.
The V4 can allocate that history in PSRAM; the guard conservatively reserves
diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md
index 6a1eceb03..665db59a1 100644
--- a/docs/firmware_picker.md
+++ b/docs/firmware_picker.md
@@ -534,5 +534,5 @@ release version. Its installation directions identify the nRF52 queue-sharing
behavior, Wireless Paper Full's 350 contacts with a shared 256/128-slot queue,
and the 150-contact limit on the other six affected ESP32 Full profiles.
Those notices apply only to the corrected files. See the
-[memory correction details](releases/1.17.1.5.md#memory-corrections-in-aa20e927)
+[memory correction details](old-releases/1.17.1.5.md#memory-corrections-in-aa20e927)
and each replacement's `.memory.json` report before updating.
diff --git a/docs/four_fixed_tx_dwell_validation.md b/docs/four_fixed_tx_dwell_validation.md
index 013a61df4..0bed63cbb 100644
--- a/docs/four_fixed_tx_dwell_validation.md
+++ b/docs/four_fixed_tx_dwell_validation.md
@@ -1,143 +1,5 @@
-# Four fixed transmitters: longer dwell repeats
+# Document moved
-Same physical MercerMesh fixture and installed firmware as
-[the completed 5.1-symbol run](four_fixed_tx_single_pass_validation.md):
-RAK4631, Heltec T096, MeshTower V2 and T1000-E fixed transmitters; Heltec V4 RX.
-SF10 / BW125 kHz / CR4/5, four channels spaced 1 MHz apart, 32-symbol preamble,
-normal SX1262 RX gain, -9 dBm chip TX setting, zero added settling and one
-frequency write per hop. No firmware was rebuilt or reflashed for these repeats.
+
-## 6.1-symbol run: user-stopped, incomplete sample
-
-Started 2026-09-14 22:44:47 UTC. Dwell was 49,972 us per channel.
-All four stationary controls passed 100/100 again.
-
-The user requested stopping this run and proceeding to 7.7 symbols. The
-collector was interrupted only after verifying its PID, command line and owner.
-At the last saved checkpoint, **342/348 scored scan packets were received**
-(98.28%), with six timeouts:
-
-| TX / channel | Scored attempts | Received | Timeouts |
-| --- | ---: | ---: | ---: |
-| RAK4631 / 0 | 87 | 87 | 0 |
-| Heltec T096 / 1 | 87 | 86 | 1 |
-| MeshTower V2 / 2 | 87 | 85 | 2 |
-| T1000-E / 3 | 87 | 84 | 3 |
-
-This is not a completed 400-packet run or a validated full-run timing capture.
-An interrupted in-flight packet can exist beyond the last saved checkpoint;
-it is not scored as another success or failure. No final switch timing was
-collected before reboot. The raw capture remains incomplete, and the separate
-user-stop record documents why. All five radios were then independently
-verified idle, and ModemManager was restored. Gateway services stayed active;
-the V4 memory-soak service stayed inactive.
-
-- [Partial raw capture](../tools/hil/profile_four_tx_sf10_6p1_results.json)
-- [User stop record](../tools/hil/profile_four_tx_sf10_6p1_user-stop.json)
-- [Verified cleanup](../tools/hil/profile_four_tx_sf10_6p1_cleanup.json)
-- [Run provenance](../tools/hil/profile_four_tx_sf10_6p1_launch.json)
-
-Partial raw capture SHA-256:
-`ad199124930da19fe56c78a4ea8280a6d94b95ef408589b19a9e165155dbf7fd`.
-
-## 7.7-symbol run
-
-Launched 2026-09-14 22:58:14 UTC; collector ran from 22:58:15 to 23:11:45 UTC
-with 63,079 us visits. All four fixed-channel controls passed 100/100.
-The full scan completed with **395/400 received (98.75%)**, five timeouts and
-zero wrong-channel or invalid payloads. Thus **7.7 symbols did not achieve a
-400/400 pass**.
-
-| TX / channel | Scanning result | Timeouts | Mean received RSSI (dBm) | Mean received SNR (dB) |
-| --- | ---: | ---: | ---: | ---: |
-| RAK4631 / 0 | 100/100 | 0 | -21.05 | +8.14 |
-| Heltec T096 / 1 | 99/100 | 1 | -4.35 | +8.98 |
-| MeshTower V2 / 2 | 97/100 | 3 | -44.86 | +8.63 |
-| T1000-E / 3 | 99/100 | 1 | -18.56 | +8.26 |
-
-All 800 baseline/scan TX sequences were unique, all transmit calls succeeded,
-and every TX packet reported zero frequency and modulation configuration writes.
-There were zero packet-response transport replays. All four final TX lifetime
-checks reported 200 packets and a prepared, non-failed transmitter.
-
-The five timeouts occurred at scan attempts 158 (T1000-E), 184 (T096), and
-200, 293, 314 (MeshTower). Each reported RX mode 1 and zero device errors.
-Final RX failure, RX-mode-error, cache-error and receive-error counters were zero.
-
-### Measured switch and sweep timing
-
-| Hop population | Count | Minimum (us) | Mean (us) | Maximum (us) |
-| --- | ---: | ---: | ---: | ---: |
-| All single-pass switches | 5,046 | 438 | 524.146 | 804 |
-| Cached modulation; no 0x8B | 4,651 | 438 | 502.147 | 686 |
-| Post-reception modulation refresh | 395 | 760 | 783.180 | 804 |
-
-Exactly 5,046 RF-frequency commands and 5,046 optimized RX resumes were counted,
-with one retune pass and zero second passes/detours. The 395 modulation writes
-match the 395 post-reception cache refreshes. This is the same cache behavior
-as the earlier run, not double-setting the radio. The slightly higher overall
-mean than the 5.1 run partly reflects a larger fraction of refresh hops.
-
-Across 860 measured idle cycles, the four-channel sweep averaged **254.351 ms**,
-minimum 254.208 ms and maximum **255.482 ms**. Relative to the 262.144 ms
-programmed preamble, that leaves 7.793 ms at the mean and 6.662 ms at the maximum.
-Nominal dwell was 63.079 ms; measured idle dwell averaged 63.084 ms. Packet-held
-visits include payload/guard time and are not comparable to idle cycles.
-
-The 7.7 run received three more packets than the earlier 5.1 run (395 versus
-392 out of 400). This small, sequential comparison does not establish a reliable
-improvement or a lossless dwell threshold. The 6.1 result is partial and must
-not be treated as an equal-size completed comparison.
-
-### Evidence and cleanup
-
-Unlike the original 5.1 run's overly strict host assertion, this collector
-completed normally with `complete: true` and exit code 0. Independent offline
-validation also passed. The same collector SHA-256 was used for both repeats:
-`cceb8a570f4aedeaf45d28b4b4590b23df3c1efd490c8e8e9cd74a23ba00024f`.
-The copied deployment/manifest files are provenance for reused firmware, not
-records of a new flash operation. Host options and acknowledged dwell match.
-
-- [Completed raw capture](../tools/hil/profile_four_tx_sf10_7p7_results.json)
-- [Validated summary](../tools/hil/profile_four_tx_sf10_7p7_validated.json)
-- [Run provenance](../tools/hil/profile_four_tx_sf10_7p7_launch.json)
-- [Cleanup readbacks and warnings](../tools/hil/profile_four_tx_sf10_7p7_cleanup.json)
-
-Raw capture SHA-256:
-`5e3f9a80af1384c3072303428b75c8550ac42d2ac95bd1234bf92cb0aea625e8`.
-
-The cleanup wrapper retained three errors from its reboot phase: the T096
-primary USB interface was temporarily missing/ambiguous, and attempts to open
-the MeshTower/T1000-E ports returned permission denied. Its subsequent final
-readbacks nevertheless succeeded for all five radios: each TX was unprepared,
-non-failed, packet count zero and autonomous TX disabled; RX scanning was
-inactive with zero channels. No further radio access or permission changes
-were attempted after those results returned. The wrapper's `verified_idle`
-flag remains **false** because it retains any earlier cleanup error, and the
-outer operation returned nonzero for that reason. These warnings happened
-after the complete RF sample; they are not counted as RF packet losses or
-silently removed from the record.
-
-ModemManager was restored active; gateway services remained active. The V4
-memory-soak service remains inactive with test firmware installed. No firmware
-restore, commit, push or pull was performed. Previous raw captures are unchanged.
-
-## Dwell budget
-
-One chirp at SF10/125 lasts 8.192 ms. For the experiment's nominal budget of
-one complete four-channel idle sweep inside 32 programmed preamble symbols:
-
-`maximum dwell symbols = 32 / 4 - switching_ms / 8.192`.
-
-The 5.1-symbol run's mean switch (0.513980 ms) gives about 7.937 symbols;
-its maximum switch (0.801 ms) gives about 7.902 symbols, before other loop
-overhead. These are timing ceilings, not proven lossless dwell limits or a
-complete acquisition model. Near the ceiling there is little timing margin.
-At 7.7 symbols, four nominal dwell-plus-average-switch intervals total about
-254.4 ms, versus a 262.144 ms programmed preamble.
-
-The host keeps the same seed and arrival-randomization rule, spanning about
-two nominal scan cycles; absolute arrival pauses therefore scale with dwell.
-The fixed-channel controls, sample counts, switching policy and firmware stay
-unchanged. As before, external PA/LNA paths and unrelated RF traffic mean this
-is a finite bench comparison, not a calibrated sensitivity or interference test.
+This document is now in [Test Results](test-results/four_fixed_tx_dwell_validation.md).
diff --git a/docs/four_fixed_tx_single_pass_validation.md b/docs/four_fixed_tx_single_pass_validation.md
index a1b440f28..d47bb04a6 100644
--- a/docs/four_fixed_tx_single_pass_validation.md
+++ b/docs/four_fixed_tx_single_pass_validation.md
@@ -1,185 +1,5 @@
-# Four fixed transmitters / fast single-pass receiver
+# Document moved
-## Result
+
-MercerMesh Pi bench run, started **2026-09-14 22:18:49 UTC** (15:18:49 PDT):
-four physically separate, fixed-channel transmitters and the Heltec V4 receiver.
-The full stationary controls passed **400/400**. The subsequent four-channel
-scan received **392/400 correctly (98%)**, with **8 timeouts (2%)** and **zero
-wrong-channel payloads**. No RF retries were used.
-
-| TX / assigned channel | Frequency (MHz) | Fixed-RX control | Scanning RX | Scan mean RSSI (dBm) | Scan mean SNR (dB) |
-| --- | ---: | ---: | ---: | ---: | ---: |
-| RAK4631 / 0 | 909.5 | 100/100 | 96/100 | -20.69 | +8.63 |
-| Heltec T096 / 1 | 910.5 | 100/100 | 98/100 | -3.84 | +8.45 |
-| Heltec MeshTower V2 / 2 | 911.5 | 100/100 | 100/100 | -45.48 | +8.41 |
-| Seeed T1000-E (LR1110) / 3 | 912.5 | 100/100 | 98/100 | -18.27 | +8.51 |
-
-RSSI/SNR describe received packets only. Each transmitter sent 200 unique
-packets across its baseline and scan. All 800 transmit calls returned success,
-with zero frequency or modulation commands during those packet calls. There
-were zero recovered/replayed USB responses in the packet records.
-
-## Switching timing and command evidence
-
-| Hop population | Count | Minimum (us) | Mean (us) | Maximum (us) |
-| --- | ---: | ---: | ---: | ---: |
-| All single-pass switches | 7,206 | 429 | 513.980 | 801 |
-| Cached modulation; no 0x8B | 6,814 | 429 | 498.602 | 686 |
-| Modulation cache refresh | 392 | 763 | 781.291 | 801 |
-
-The receiver recorded exactly **7,206 frequency commands and 7,206 optimized
-RX resumes**: one frequency write and one resume per hop. Second-pass count
-was zero. Duplicate frequency writes, frequency/CR detours and added settling
-were disabled. Warm XOSC, bulk 8 MHz SPI and cached modulation were enabled.
-
-The average switch was **0.514 ms**, but not every switch was below 0.6 ms.
-After each received packet, ordinary RX staging invalidates the modulation
-cache; the next hop refreshes it. Subsequent unchanged hops omit 0x8B.
-The 392 refreshes match the 392 received packets. The source path is
-`RadioLibWrapper::recvRaw()` -> `startRecv()` / `startReceiveMode()` ->
-`PhysicalLayer::startReceive()` -> `CustomSX1262::stageMode()` ->
-`SX1262ProfileSwitchState::invalidate()`. No production cache-invalidation
-behavior was changed for this test.
-
-Mean switch-plus-settling-instrumentation time was 515.698 us, maximum 803 us.
-The settling measurement averaged 1.718 us of timer/measurement overhead;
-the configured settling delay was zero. The idle four-channel sweep averaged
-169.135 ms (maximum 170.470 ms), versus 262.144 ms for the 32-symbol preamble.
-Packet-held visits include reception/guard time and are not idle sweep timings.
-The `deferred` counter counts guard-loop deferrals, not lost packets.
-
-## Method and fixture
-
-- SF10 / 125 kHz / CR4/5; 32-symbol preamble; synthetic 16-byte CHS1 packets.
-- Four channels spaced 1 MHz apart; 5.1-symbol RX visits (41,780 us).
-- Each physical transmitter was configured once and remained on its assigned
- frequency throughout both the controls and scanning.
-- Receiver was reinitialized for each fixed-channel control and once before
- the scan. All 100 control packets per transmitter were retained.
-- Scan used 100 packets per channel, randomized channel order and arrival
- pauses (seed 606125). Transmissions were staggered, not concurrent.
-- Each expected packet had a 10-second scan timeout. Misses did not stop the
- run, restart the scanner, force it onto the TX channel or trigger a retry.
-- SX1262 normal RX gain was verified as register 0x94. The V4's external
- KCT8103L front end remains distinct from the chip's boosted-gain setting.
-- Requested TX power was -9 dBm at each radio chip. The Heltec TX boards have
- external PA paths, held in TX mode by this HIL firmware; these are **not
- calibrated equal antenna-port powers**.
-- High-rate channel trace was disabled; command counters and timing remained
- enabled. Gateway services remained running, so unrelated RF was not excluded.
-
-| Role | Hardware serial / MAC | Pi USB topology |
-| --- | --- | --- |
-| TX0 RAK4631 | 9AB3B64C641BA927 | 1-1.3.1 |
-| TX1 Heltec T096 | 651F8E496197F882 | 1-1.2.3 |
-| TX2 Heltec MeshTower V2 | 9352162A72082314 | 1-1.2.2 |
-| TX3 Seeed T1000-E LR1110 | 34A9141999729D5D | 1-1.2.1 |
-| RX Heltec V4 | 44:1B:F6:69:CF:98 | 1-1.3.3 |
-
-Board selection checked serial plus topology, not transient tty names or CDC
-interface count. The RAK3401 gateway, serial 0B81C9C68D8D01B4, was excluded.
-The earlier local XIAO radios were not modified during this run.
-
-## Remaining losses and interpretation
-
-Timeouts occurred at overall scan attempts 10, 14, 55, 65, 145, 299, 304 and
-384. Thus they were not confined to startup. Each timeout reported RX mode 1
-and zero device errors; TX calls returned success and still reported no
-configuration writes. Final RX failure, RX-mode, cache and receive-error
-counters were all zero.
-
-The wrong-channel behavior did **not** reproduce in this configuration.
-The remaining misses do not require a transmitter that retunes between packets.
-However, this is not a controlled A/B against the earlier XIAO setup: the RX
-board, TX boards/chips, antennas and geometry changed. These results do not
-prove that the earlier transmitter was faulty or establish the cause of the
-eight remaining losses.
-
-Several received signals were very strong, particularly the T096 at about
--4 dBm. The weakest link, MeshTower V2 at about -45 dBm, passed 100/100 while
-scanning. That makes a controlled spacing/attenuation comparison worth
-considering, but this sample does not prove receiver overload. The observed
-98% is a finite bench result, not a reliability guarantee or sensitivity test.
-
-## Capture integrity and post-run validation
-
-All 400 scan probes finished and final radio telemetry was returned. The
-original host checker then rejected that telemetry because it incorrectly
-required zero modulation writes over the entire run, including post-packet
-RX staging. Its raw capture therefore remains `complete: false`, with final
-status preserved verbatim inside its error string. The normal `sender_final`
-collector step did not run. **The raw failure record was not rewritten.**
-
-Source review established the cache-refresh behavior above. The host checker
-now validates modulation writes against the measured refresh-hop count, checks
-that cached plus refresh hops equal total hops, and still requires single
-frequency writes, single fast resumes, zero added delay and the other policies.
-It also saves final telemetry before checking it, so future failures retain
-that structured record.
-
-Separate offline validation parsed the saved telemetry, checked the full
-400-probe scan and all four 100-probe controls, verified 800 unique sequences,
-and checked every transmitter's packet counter from 1 through 200 and its
-zero-retune acknowledgments. A capture regression additionally checks that
-each TX/RX response ID matches its probe and every accepted payload/channel
-matches the assigned channel. No radio was rerun to replace the failed host
-assertion. The derived summary explicitly reports
-`original_collector_complete: false` and `post_run_validation_passed: true`.
-
-Evidence files:
-
-- [Immutable raw capture](../tools/hil/profile_four_tx_sf10_single_results.json)
-- [Independently validated summary](../tools/hil/profile_four_tx_sf10_single_validated.json)
-- [Deployment record](../tools/hil/profile_four_tx_sf10_single_deployment.json)
-- [Build manifest](../tools/hil/profile_four_tx_sf10_single_manifest.json)
-- [Verified idle/service state](../tools/hil/profile_four_tx_sf10_single_cleanup.json)
-
-Raw capture SHA-256:
-`42de3ac0c335b2c59f35c1cab217a01ef242c9f2e7b0010c8000a908a59232e6`.
-Derived summary SHA-256:
-`316bd551eb7306dfc576f4b88ad23e82cb1d701481d1e022067c961806d31bfe`.
-
-## Build, deployment and final state
-
-Built from the dirty `keymindCascade` worktree based on
-`2b5a6f209835f0f67ed73fee78d36447a4566ce6`. This is not a claim that HEAD
-alone reproduces the images. The manifest records image and HIL-source hashes.
-All five PlatformIO builds succeeded and ran sequentially.
-
-After the host-checker correction, 25 focused unit/regression tests passed,
-including the preserved-capture check. `git diff --check` passed; its only
-warning concerned a pre-existing CRLF conversion in `MyMesh.cpp`.
-
-RadioLib was pinned to `187ef24791c3d844939b2be13a68bd890bd04e4c` (7.7.1).
-The nRF framework was `d5413016` (1.10701.0); the ESP build used platform
-6.11.0 / Arduino 2.0.17. The first three nRF targets retained S140 6.1.1
-(FWID 0xB6, app base 0x26000); T1000-E retained S140 7.3.0 (FWID 0x123,
-app base 0x27000). nRF DFU packages were application-only, with their matching
-SoftDevice requirements intact. No nRF bootloader/SoftDevice replacement or
-cross-board override was used. Runtime FWID and application base were verified.
-
-V4 received the normal ESP bootloader, partition table, boot_app0 and HIL app
-at 0x0, 0x8000, 0xE000 and 0x10000, respectively, with esptool hash checks.
-No whole-flash bulk erase was issued. No backup was requested for these
-authorized bench deployments.
-
-The initial bundle's collector/deployer hashes are not the final scripts used:
-the actual run used `profile_four_tx_v2.py` with SHA-256
-`b762e412636076902174c76b71961cc8ab3059c2b6de3dd3b4123bafcb06545c`
-(startup-greeting fix, original strict final assertion), and deployment used
-`profile_four_tx_deploy_v3.py` with SHA-256
-`8a660788e0215e6ac1aece0a5d033521f9f376c9e51c00e760e918ebfd72cca1`.
-The current local collector includes the later validation correction; firmware
-images were not changed after the run. Remote evidence remains under
-`/home/mikec/hwtest/runs/four-tx-sf10-20260914`.
-
-All five radios were subsequently rebooted and independently verified idle.
-All TXs reported unprepared, packet count zero, no failure and autonomous TX
-disabled; V4 reported scanning inactive and zero hops after reboot. HIL
-firmware remains installed. ModemManager was restored active, gateway services
-were left active, and the V4 memory-soak service remains inactive because its
-firmware is now the bench receiver. No Pi USB hub power cycle was performed.
-
-No commit, push or pull was performed for this test. Unrelated worktree changes
-were preserved.
+This document is now in [Test Results](test-results/four_fixed_tx_single_pass_validation.md).
diff --git a/docs/full_companion_features.md b/docs/full_companion_features.md
index 007bdc001..95071317e 100644
--- a/docs/full_companion_features.md
+++ b/docs/full_companion_features.md
@@ -15,7 +15,7 @@ queued messages normally. During an mOTA session, 128 message slots are
available while the other half holds its workspace. If more than 128 unread
messages are queued, sync them with an app before starting mOTA. Disconnecting
the source or turning it off returns all 256 slots. Idle WiFi mOTA listening
-does not borrow the queue. See the [1.17.1.5 release guide](releases/1.17.1.5.md)
+does not borrow the queue. See the [1.17.1.5 release guide](old-releases/1.17.1.5.md)
for the corrected download; the earlier `aa20e927` Wireless Paper image has
150 contacts.
diff --git a/docs/full_companion_usb_switcher.md b/docs/full_companion_usb_switcher.md
index f036155cf..f76123a76 100644
--- a/docs/full_companion_usb_switcher.md
+++ b/docs/full_companion_usb_switcher.md
@@ -14,7 +14,7 @@ frame automatically hands the interface to the binary parser.
Current source keeps the default terminal quiet until the host sends ASCII
input. Press Enter or type a command to see the banner. This prevents the ASCII
`>` prompt from being mistaken for the beginning of an app's first binary
-response. See [USB client compatibility validation](companion_usb_client_validation.md)
+response. See [USB client compatibility validation](test-results/companion_usb_client_validation.md)
for the tested firmware and release status.
These mode controls apply to Full and ordinary USB Companion builds. The
diff --git a/docs/full_usb_logging_validation.md b/docs/full_usb_logging_validation.md
index fa69d176c..44cca260e 100644
--- a/docs/full_usb_logging_validation.md
+++ b/docs/full_usb_logging_validation.md
@@ -1,78 +1,5 @@
-# Full USB logging repair — 1.17.1.6
+# Document moved
-Audit date: 2026-09-13. Release firmware source: `306feebe6d648a247f925894f876be9d747fc8d2`.
+
-The original Station G2 Full Repeater and Full Room Server images omitted USB
-packet logging. Both inherited `-UMESH_PACKET_LOGGING` from their ordinary MQTT
-observer recipes. Full added `-DMESH_PACKET_LOGGING=1`, but PlatformIO's
-`ProcessFlags` moves undefines after defines in the compiler command. The
-resulting image compiled successfully with its packet logger removed.
-
-The previous release gate checked OTA and other features without requiring
-USB logging in Full infrastructure. An old passing manifest could also be
-reused by `--resume` without the new logging evidence.
-
-## Published-image audit
-
-Each downloaded/cached artifact was matched to GitHub's published SHA-256.
-The check inspected application bytes, including the application inside nRF52
-DFU ZIPs. It required the packet-log formatter and USB logging controls.
-
-| Release group | Application images | Missing USB packet logging |
-| --- | ---: | ---: |
-| ESP32 Full infrastructure/utility | 157 | 2 |
-| ESP32 Full Companion | 52 | 0 |
-| nRF52 Full Companion | 43 | 0 |
-| Total | 252 | 2 |
-
-Affected targets:
-
-- `Station_G2_repeater_observer_mqtt`
-- `Station_G2_room_server_observer_mqtt`
-
-V4/R8 Full builds and Station G2's Full ESP-NOW bridge passed. This audit does
-not count ordinary MQTT profiles that intentionally omit USB logging as
-broken. Artifact inspection proves compiled support, not hardware operation
-on every board.
-
-## Fix and regression coverage
-
-- Full builds that require logging remove inherited disables of
- `MESH_PACKET_LOGGING` before PlatformIO parses flags. Other macros and
- explicit logging-off profiles retain their policy.
-- Full capability checks require the packet logger and USB controls in the
- packaged application. Combined USB/WiFi profiles additionally require
- the `logging.output` setter. ELF symbols or debug text cannot satisfy these
- checks.
-- Resumed builds must have passing application evidence for the current
- logging contract, along with the existing artifact hashes and RAM checks.
-- CI runs the flag, real PlatformIO/compiler, artifact, resume, and build-profile
- regression suites.
-
-Focused verification (after installing PlatformIO and its tool-scons package):
-
-```sh
-python3 -B test/test_full_build_flag_dedup.py
-python3 -B test/test_full_logging_compile.py
-python3 -B test/test_firmware_capabilities.py
-python3 -B test/test_full_logging_resume.py
-bash test/test_build_profiles.sh
-python3 -B test/test_cascade_release_package.py
-```
-
-Both Station G2 profiles were rebuilt through `build.sh --full` with the
-original observer recipes, including their inherited logging undefines. They
-passed the new application checks, runtime RAM budgets, and both OTA slot
-size checks. The new checker rejects both original published images.
-
-On the Bellevue Station G2, the corrected Full repeater implementation also
-passed serial CLI and passive traffic checks: four RX and two TX packet log
-lines were captured. Its active temporary profile retained preamble 120 and
-the original schedule end time; the Pi relay services were restored.
-
-Corrected application SHA-256:
-
-```text
-Repeater: 81a0a2926d4e567c069d0b10f316047881bcedce062afaa42119dddb2baadf23
-Room Server: 7ee81f1cb65fc274bd363397c3c80ce9006d575efae50fe952c8ca246613a18c
-```
+This document is now in [Test Results](test-results/full_usb_logging_validation.md).
diff --git a/docs/hardware_validation_checklist.md b/docs/hardware_validation_checklist.md
index 3ff9599a0..6cb784202 100644
--- a/docs/hardware_validation_checklist.md
+++ b/docs/hardware_validation_checklist.md
@@ -1,1409 +1,5 @@
-# Hardware validation checklist
+# Document moved
-This file records dated hardware observations and open validation tasks.
-Historical defaults, versions, and test frequencies below describe the tested
-revision, not necessarily the current release. For current ASCII USB defaults
-and runtime switches, use [feature switches by role](role_feature_switches.md).
+
-Use this checklist for release-candidate hardware runs. A check is complete only
-when its log identifies the physical device, firmware artifact, artifact hash,
-command result, and cold/warm boot outcome. Do not infer success from a tool's
-exit code when the tool has a documented false-success mode.
-
-## Bluetooth stealth qualification - 2026-09-07
-
-The [XIAO stealth report](hardware_validation_bluetooth_stealth_2026-09-07.md)
-records exact artifacts for the initial implementation and independent-flag
-follow-up. The flag run covers custom/saved-random pairing and bonded reboot
-reconnect, idempotent `on`, `off` preserving the MAC, directed advertising,
-and both rotating policies with stealth still enabled. It also gives the
-repeatable hardware regression procedure. True battery-disconnected cold boot,
-phone address-privacy behavior, a second unbonded central, and ESP32 hardware
-remain separate uncompleted gates; do not infer them from the XIAO results.
-
-## Current marathon ledger (through 2026-09-04)
-
-| Hardware | Stable identity | Current state | Next blocking check |
-| --- | --- | --- | --- |
-| Seeed XIAO nRF52840 | `B35E71C1C3726CE7` | OTAFIX candidate `0x02040405` passed exact serial install and corrected Legacy DIS model gates; the patched Bluetooth repeater-updater application was restored over BLE and returned as exact `2886:8044` USB | Use the updater for an identity-gated Bluetooth transfer to another repeater, then prove current-build cold persistence |
-| Seeed Tracker T1000-E | `34A9141999729D5D` | OTAFIX candidate `0x02040405` and the `uf2reset`-fixed Full Companion are installed. The real text command returned exact `2886:0057` boot USB, the same fixed app was restored, and refreshed bonded BLE services pass as `MeshCore-09848C15` | Restore the protected Repeater identity when Companion qualification is complete; cold boot, then force LR1110 reset |
-| RAK3401 | `0B81C9C68D8D01B4`; FICR `8D8D01B4 0B81C9C6` | OTAFIX test version `0x02040403` passes bidirectional application UF2, exact SWD application/bootloader readback, unchanged UICR, and a 74-block direct V4 LoRa apply from exact `3caf9dcf` to current HEAD `9fd580c8`. Post-apply USB and LoRa identity/hash checks pass and the temporary 0 dBm bench setting is restored to 22 dBm | Add exact RF packet counters, then repeat the supported-bandwidth and controlled/passive/mixed routed-hop matrix |
-| Heltec T096 | `651F8E496197F882` | OTAFIX candidate `0x02040405` and the `uf2reset`-fixed no-footer Full Companion are installed. The real text command returned exact boot USB, and final USB terminal/Binary mode, dual CDC, version, help, and BLE identity pass | Perform a privileged or physical host-driven USB bus reset without resetting the MCU; complete the deferred multi-click/long-press physical matrix |
-| Heltec MeshTower V2 with SD | `9352162A72082314` | OTAFIX candidate `0x02040405` passed serial combined install, the historical cached DIS handles, BLE application restore, and exact application USB return. Card format/cooldown/forced-format/raw-erase/remount and the live SD apply contract passed earlier | Signed interrupted-download resume, corruption/signature rejection, then full/delta apply |
-| Heltec V4 | USB MAC `44:1B:F6:6A:E8:44`; BLE `44:1B:F6:6A:E8:45` | USB/BLE/Wi-Fi and all three TCP services pass on hardware. The final fresh-NTP-gated Full Companion image builds with 4.31 MB app space free | Flash the exact final NTP build, prove NTP-before-TLS success/failure ordering, then OTA seeding |
-| SenseCAP Indicator LoRa | CH340 plus USB MAC `D8:3B:DA:75:23:AC`; BLE `D8:3B:DA:75:23:AD` | The current dark-layout application is SHA-256 `81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b`; identity-gated flash, USB ASCII/Binary switching, runtime logging, and separate configured-Wi-Fi, BLE, and TCP checks pass. Fresh-NTP HTTPS recovery, strict Range resume, STAGEV2 install, and RP USB readback passed on earlier exact artifacts; the final coordinator source still needs its blocked-UDP/123 hardware gate | Physical display-wrap and four-mode render/heap checks, exact-final blocked-NTP recovery gate, LoRa/TempRadio, and non-empty OTA seeding |
-
-The current source checkpoint passes 1,132/1,132 native cases, 68/68 host
-broker/endpoint cases, all 318 LoRa-OTA chain/route/bundle cases, and 41/41
-MOTA format/signature/delta cases under the documented pipx `detools`
-interpreter. Indicator geometry, dark-theme, and font-recovery contracts pass
-4/4, 6/6, and 25/25. A serialized real Indicator ESP32-S3 Wi-Fi/USB profile
-also compiles at 95,944 bytes RAM and 1,589,433 bytes flash. The direct
-system-Python MOTA invocation predictably lacks `detools`; that environment
-boundary is not a firmware failure and must not be worked around by installing
-the package globally.
-
-The RAK failure log is
-`/home/mikec/hwtest/runs/rak3401-syncidle-clean-tSp7PLgA/result.txt` on
-the Mercerwood Pi. The tested UF2 SHA-256 was
-`650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7`.
-`cp` returned success in 95 ms, but `sync -f` failed with `EIO` after
-157,188 ms. Post-failure SWD readback proved that all seven ordinary sectors
-whose bytes differed between the old and intended builds still contained the
-old values, and the settings page was still erased. The only available
-pre-copy SWD dump was taken several hours earlier, however, so it cannot prove
-that zero unchanged blocks were written during this attempt or establish when
-the stale S140-v7 tail in the first application page appeared. The next run
-must capture an immediate pre-copy dump as well as the post-copy dump. Serial
-DFU recovery then completed in 35,698 ms with the exact `Device programmed.`
-marker. All 1,830 UF2 payload blocks matched SWD readback after recovery, and
-the application passed a subsequent real power-removal boot.
-
-The corrected 2.4.4-preview.2 candidate was installed through identity-gated
-SWD from combined-image SHA-256
-`9b1654baccd2a920f68651893e31d79170400d1a7982a3f854ae640f1d3513a8`.
-An immediate pre-copy dump closed the earlier evidence gap. A deliberately old
-UF2 then copied and flushed successfully in 16,568 ms, and the exact formerly
-failing UF2 above copied and flushed successfully in 16,601 ms. After each
-direction, all 1,830 payload blocks matched a full SWD readback with zero header
-or payload mismatches. The return update changed 123 bytes across five expected
-application pages, UICR remained byte-identical, and a subsequent downstream
-USB power removal plus Pi reboot returned the RAK as the same stable identity.
-It reported the intended `3caf9dcf` application, board, node name, USA radio
-tuple, and 22 dBm setting after that cold boot. The new bootloader's CLI source
-string is truncated before the injected preview number, so the independently
-hashed SWD artifact is the authoritative candidate identity for this run.
-
-The later TinyUSB lifecycle/generation candidate identifies itself as test
-version `0x02040403`. Its exact 40,960-byte bootloader readback SHA-256 is
-`f609eb18754070c54f127325b1cb48547baf5c6b1c695d36fd0c8e0874713c13`.
-With that candidate installed, a full `CURRENT.UF2` read completed before each
-write. The 936,960-byte `bf092b24` application UF2 (SHA-256
-`12477a9734cf12eb920361bd21024e39bf303953fcf79d0bf720e00847e2ae92`)
-copied and flushed in 28,203 ms and returned to application mode four seconds
-later. The exact `3caf9dcf` application UF2 (SHA-256
-`650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7`)
-then copied and flushed in 26,479 ms and also returned in four seconds. Both
-boots retained `RAK3401-OTA-BENCH` and `910.525/62.5/SF7/CR5`.
-
-Identity-gated SWD then read the entire 468,480-byte padded UF2 application
-range. Its SHA-256 exactly matched the converted source on both sides at
-`b2e37a7681027ca91d1b2bd8e39b55395d9e91d4863aed17131e50c1ba1d2227`.
-The bootloader still matched the installed `0x02040403` image byte-for-byte,
-and UICR still matched the pre-test image at
-`d029ea630c2f632a1b690cb52b2a96a28b6f870c656c3e1bd74ba3a571c53b9c`.
-The authoritative logs are under
-`/home/mikec/hwtest/runs/rak3401-uf2-02040403-20260830-170829` and
-`/home/mikec/hwtest/runs/rak3401-uf2-return-02040403-sfHiGEqA` on Mercerwood.
-
-The subsequent direct LoRa qualification used the V4 as controller/seeder and
-the RAK's exact `bf092b24` body `91CF4EC2882F23D6` as the base. Its mandatory
-fixed three-minute rehearsal passed on `909.95/250/SF5/CR5`, including natural
-expiry and exact normal-radio recovery. The 2,132-byte, two-block in-place
-delta reached `2/2` and ready in six seconds; install was accepted, and the new
-body appeared at the 60-second reboot probe. The automation took 514 seconds
-including rehearsal, clock/radio safety checks, and post-install verification.
-The final body was `27A223DDFC3A8F47`, version `1.17.1.5` at `3caf9dcf`, and
-the bootloader reported `blrc:B8`, which is OTAFIX's successful application
-apply result rather than an error. Exact target TX power was wrapped from
-22 dBm to 0 dBm for the co-located transfer and restored to 22 dBm afterward;
-the source RXPS configuration and both normal radio tuples were also restored.
-
-Identity-gated SWD then read the live application at `0x26000`. All 468,384
-bytes matched the intended release image exactly (SHA-256
-`b496e3f4419c9f771564ed3c74b3407822f45acdf60cbd6c4625a54b181ca084`),
-and the independently recomputed EndF hash was `27A223DDFC3A8F47`. The target
-ID was `2FA509C1`, hardware ID was `RAK_3401`, and version word decoded to
-`1.17.1.5`. The 40,024-byte bootloader readback also matched the exact installed
-`ffb1580`/test-version-`0x02040402` artifact byte-for-byte; UICR was unchanged.
-After SWD reset-halt/resume, USB again reported the expected application,
-identity, name, radio, 22 dBm, no TempRadio lease, and `blrc:00` (the retained
-success diagnostic is intentionally cleared by the next normal boot).
-
-The current `0x02040403` bootloader candidate then passed a second direct V4
-LoRa qualification using a materially larger application change. The unsigned
-in-place package was 75,614 bytes with 75,108 payload bytes and 74 transfer
-blocks. Its mandatory fixed three-minute rehearsal passed, including natural
-TempRadio expiry and exact normal-channel recovery; one lost read-only
-`ota status` reply was reconciled by its bounded exact retry. The transfer
-reached 74/74 and READY in 122 seconds, the explicit unsigned install was
-accepted, and reboot produced exact HEAD `9fd580c8` with body
-`744BC4443891ED10`. The complete safety-wrapped run took 589.487 seconds,
-including rehearsal, recovery, and final verification. The first postboot
-`ver` reply was lost and its exact read-only retry passed. Independent USB
-checks then confirmed target `2FA509C1`, hardware `RAK_3401`, unchanged
-identity/name/radio/RXPS/watchdog state, bootloader CRC `6683C066`, ABI 3,
-apply result `blrc:B8`, and no active TempRadio/download/seeder/key state.
-After the separate host-recovery exercise, the near-field TX setting was
-restored from 0 dBm to its exact original 22 dBm and read back over an
-authenticated LoRa session. The sanitized authoritative log is
-`/home/mikec/hwtest/runs/rak3401-02040403-head-vzWOXVxT/qualification-redacted.log`
-with SHA-256
-`3e53dcaa5922c7c18a7890963b8fc092311d2db01caa5603640f43f1482802f6`.
-
-A serialized VM build of the six connected-board qualification profiles then
-completed 6/6 in 136.122 seconds: XIAO nRF52 BLE Companion, T1000-E BLE
-Companion, RAK3401 LoRa-OTA Repeater, SenseCAP Indicator LoRa USB/Wi-Fi
-Companion, MeshTower V2 SD LoRa-OTA Repeater, and the consolidated Heltec V4
-V4.2/V4.3 Full Companion. The earlier V4 selector typo was rejected before any
-compile began and is not a firmware build failure.
-
-After the fresh-NTP download gate, shared SNTP coordinator, MQTT-to-MeshCore
-RTC handoff, and daily refresh policy were added, the complete native matrix
-passed 1,102/1,102 cases in 93.953 seconds. The scheduler is host-tested at
-24-hours-minus-one-millisecond, the exact 24-hour boundary, normal and wrapped
-`millis()` values, a short retry that overrides the daily cadence, the zero
-deadline sentinel, and a Wi-Fi event latch. A reconnect after a successful
-sample preserves the existing deadline, so successful samples are requested
-after boot and then only once per day. All current
-Python and shell contract suites passed, and the expanded LoRa-OTA automation
-suite passed 317/317 cases.
-
-Serialized real builds also passed after that change. The final event-safe
-implementation uses an atomic `GOT_IP` latch so even a short initial connection
-before the 10-second MQTT status sample cannot skip the required boot NTP
-request. The final Heltec V4 MQTT Observer application is 1,853,304 bytes
-with SHA-256
-`1938ae88fcdbe601cf8698df44b548656079562d899801886b808af913e5f9ac`.
-The consolidated Full V4 image used 2,250,801/6,553,600 flash bytes and left
-4,302,799 bytes of app-image space. Its 2,251,272-byte application image has
-SHA-256
-`05131eac29164be46d1bcceeb4f03455f5ea8d61c8d4f529b77d8adfe25fdb6a`;
-the 2,316,808-byte merged image has SHA-256
-`c4c1fc639b6d0087c795a81aca79c07eba56dc4e071eb374dc70da0bec81815e`.
-The focused daily-NTP policy suites passed all 42 cases after the latch was
-added, and the Full capability manifest verified all ten expected features.
-The reduced RAK3401 LoRa-OTA Repeater also compiled with the verified WisBlock
-I2C aliases. At that source state its link used 471,344/815,104 flash bytes; the
-reduced external-sensor contract retains INA219/INA226/INA260/INA3221 as the
-voltage/current entries in the optional environmental-telemetry table. These
-are not the image's only I2C consumers. The image separately retains its
-SSD1306 display, auto-discovered I2C RTCs,
-RAK12500 I2C GPS, and RAK12501/L76K UART GPS paths. The configured INA3221 and
-RAK12500 both default to I2C address `0x42`; those two devices cannot coexist
-at those addresses. The supported combined arrangement leaves RAK12500 at
-`0x42`, straps INA3221 A0 to SCL for `0x43`, and uses a build with
-`-DTELEM_INA3221_ADDRESS=0x43`.
-The nRF52840 die-temperature and ADC battery-voltage paths are also retained.
-The 472,227-byte DFU ZIP SHA-256 is
-`fdd369005a61635756efa9e38b217376a952f334b0864f927a46799b65264fa9`,
-and the matching 943,104-byte UF2 SHA-256 is
-`2155632fde730e0ae2e4a72ce64f89910861f6c5105a4bd7ac96aaa538a33dca`.
-These VM results do not replace the remaining exact-final hardware test with
-UDP/123 blocked.
-
-The later serialized RAK3401 contract build, after the non-blocking nRF52 USB
-logging change, used 471,576 bytes before its 56-byte EndF trailer. Its EndF
-body hash is `026749684bf6ee8f`, target ID is `2FA509C1`, hardware ID is
-`RAK_3401`, and version word is `0x01110105`. The 472,435-byte DFU ZIP has
-SHA-256
-`cf0880b26f2d6bd8157f8af76a6718fe1f998de8ea7df8cd952e734448201fb9`;
-the 943,616-byte UF2 has SHA-256
-`f893a8cebf309af12a4a6f5483acb28f94158e18632f660e011658c846e8ca7f`.
-The linked ELF contains GPS, SSD1306, RTC, and the four INA families, while an
-exact symbol audit finds none of AHT10/20, BME280, BMP280, SHTC3, SHT4X,
-LPS22HB, MLX90614, VL53L0X, BME680/BSEC, BMP085, or RAK12035. The complete
-native matrix passed 1,112/1,112 cases at that intermediate source state. These
-are VM gates only; flash the exact artifacts and repeat the physical peripheral
-and blocked-clock checks before marking any later source state
-hardware-qualified.
-
-Subsequent source hardening changed RAK peripheral discovery: the shared 3V3_S
-rail remains enabled, UART GPS requires a complete checksum-valid NMEA sentence,
-an exact INA3221 identity blocks a conflicting u-blox probe, and a successful
-I2C GPS claim suppresses a sensor only on the same bus and address. The hashes
-and sizes above describe their recorded intermediate artifacts; they are not
-current-HEAD evidence and must not be reused for the hardened source.
-
-The immutable font endpoint was re-probed only after the VM reported
-`NTPSynchronized=yes`. GitHub returned HTTP 206 for bytes `0-65535`, an exact
-65,536-byte match to the checked-in font (range SHA-256
-`8b0dd308eb01469d1f9e732fba260720a80cc86f6cb5385c28e8a16c76dcfbf3`),
-with total length 1,302,608 and strong ETag
-`"39ff0cfbe37e36905507697d042869c05374c3202dd96d5a9f98812153f324c1"`.
-This validates the current strict Range assumptions against the live service;
-the exact-final Indicator still needs the negative hardware run proving that
-blocked UDP/123 prevents every TLS request and RP2040 staging write.
-
-Mercerwood subsequently recovered without a target write. Both its Tailscale
-and LAN addresses again accepted SSH, Bluetooth was idle, and the exact T1000-E
-stable serial `34A9141999729D5D` was present as application USB `239a:8029`.
-The RAK3401 simultaneously uses the same VID/PID, which confirms that every
-continuation must resolve the stable serial/by-id identity rather than VID/PID.
-The T1000-E remained in the exact bonded Full Companion application; no stale
-DFU, scanner, `meshcli`, or HCI-capture process was running.
-
-The post-reboot seven-TTY inventory was also resolved without assuming that
-one board owned adjacent device names. `ttyUSB0` is the SenseCAP Indicator's
-ESP32-S3 CH340 UART and `ttyACM5` is its RP2040; their sibling physical USB
-paths and the Indicator's live TCP board reply agree. The separately attached
-`ttyACM4` is the MeshTower V2 native `HT-n5262` endpoint and returned the
-qualified MeshTower build over its own console. The remaining ACM endpoints
-independently identify the RAK3401, XIAO nRF52840, Heltec V4, and T1000-E.
-
-ModemManager had restarted after the Pi reboot and could probe otherwise idle
-radio consoles. Six serial-bearing boards now use stable-serial-only
-`ID_MM_DEVICE_IGNORE` rules. Because the Indicator CH340 has no stable serial,
-its separate rule requires the exact current physical port chain *and* exact
-`1a86:7523` identity; there is no broad CH340, product, or VID/PID-only rule.
-A controlled ModemManager restart reported zero modems, displaced no TTY owner,
-caused no USB disconnect/re-enumeration, and left all seven endpoints ignored.
-The same mctomqtt process retained sole ownership of the RAK, while a subsequent
-Indicator TCP query still returned the expected board and build. Reversible
-root-only backups were retained on the Pi before each rule update.
-
-## Run-wide gates
-
-- [ ] Record commit, version string, role/profile, artifact filename, size, and
- SHA-256 before flashing.
-- [ ] Archive the exact ELF and symbol/objdump map beside each qualification
- firmware. A rebuilt ELF is acceptable for diagnosis only after its
- firmware binary matches byte-for-byte; compiler `__DATE__`/`__TIME__`
- strings can otherwise change both bytes and the EndF body hash.
-- [ ] Bind every operation to a stable USB serial number or physical USB path;
- never rely on a changing `ttyACM`/`ttyUSB` number.
-- [ ] After erasing/reflashing a BLE board that reuses its Bluetooth address,
- remove any stale host bond and pair again with the board's current PIN
- before diagnosing service-discovery disconnects. On Linux, verify the
- BlueZ agent actually accepted the passkey; a scanner seeing the device is
- not proof that Companion GATT is usable.
-- [ ] Do not use BlueZ's advertisement `service_uuids` as a same-address warm
- handoff identity gate. After application/bootloader transitions BlueZ can
- merge the bootloader's cached GATT UUIDs into the live application's
- advertisement. Gate the application advertisement by exact address and
- name, then require the live connected BLEDfu service, control/revision
- characteristics, retained bond/encryption, notification subscription,
- and observed target disconnect before accepting the handoff.
-- [ ] Do not treat `meshcli -P` as an infallible pairing probe. Immediately
- after one successful post-DFU session, a second invocation with the exact
- paired XIAO address hit the meshcore-cli `NoneType ... pair/disconnect`
- path before it created a Bleak client. BlueZ still reported the exact
- bond and the same command without `-P` connected and synchronized the
- clock successfully. Capture this as a host-tool failure, then verify the
- device independently before blaming firmware.
-- [ ] After a successful `BleakClient.connect()`, start a fresh disconnect
- generation before beginning DFU. BlueZ can fail one internal LE attempt,
- retry successfully, and still deliver the failed attempt's callback. A
- stale event caused the host to stop notifications and issue a local HCI
- disconnect after a valid image CRC but before ACTIVATE; the target and
- bootloader had not dropped the link. Preserve an HCI trace through
- activation so local-host and remote-target disconnects remain
- distinguishable.
-- [ ] Before a BLE pairing/throughput test on a Wi-Fi-managed Pi, record both
- management addresses and a same-LAN recovery vantage point. The XIAO
- pairing run completed, but Mercerwood then stopped answering on both its
- Wi-Fi and Tailscale addresses; BellevueBBS confirmed the old LAN address
- was absent at ARP and no replacement LAN host exposed SSH. This is an
- infrastructure outage until Pi logs prove a cause, not a firmware or BLE
- failure. The T1000-E pairing run reproduced the management outage: its
- exact application paired successfully and exposed the complete DFU
- service, then Mercerwood stopped accepting SSH and ultimately stopped
- answering Tailscale pings. No buttonless DFU write had been sent, and the
- T1000 remained in its valid application. Capture Pi Wi-Fi/firmware logs
- after recovery and avoid leaving discovery enabled between operations.
-- [ ] Confirm the selected board and role before every erase or flash.
-- [ ] Before every SWD write, read the target FICR device ID and match it to the
- intended board's stable USB serial (and physical wiring record). A USB
- product name or remembered cable position is not sufficient.
-- [ ] Confirm no other PlatformIO process is active; run only one PlatformIO
- build, test, clean, or upload at a time.
-- [ ] Record the installed bootloader, SoftDevice, partition table, or flash
- layout independently of the application banner where applicable.
-- [ ] Test both a software reboot and a real power removal/cold boot.
-- [ ] After an update, read back identity, version, radio tuple, node name, and
- persistent settings instead of assuming they survived.
-- [ ] Save command output and elapsed milliseconds. Mark a test `FAIL`, not
- `PASS`, if the transport reports an I/O error even when firmware later
- boots.
-- [ ] Before calling a node hung, validate that the probe uses that profile's
- command terminator, check a second transport, and capture PC/LR/watchdog
- state over identity-gated SWD before reset. Resume once and retest before
- using reset as recovery so the diagnostic state is not destroyed first.
-- [ ] Treat a remote login acknowledgement and the following command reply as
- separate LoRa packets. A lost login acknowledgement is not proof of
- failed authentication when a command-matched private reply from the
- selected contact key is present; an explicit negative login result still
- fails closed.
-- [ ] Restore the intended USA radio preset and normal-radio mode after tests.
-- [ ] Compare both semantic version and exact EndF/body hash. A transition
- between two commits carrying the same version string must stop at the
- normal reinstall guard unless the run plan explicitly records and passes
- `--allow-non-upgrade`; a different commit name alone is not authorization.
-- [ ] Do not allow a non-interactive transmission-failure prompt to continue
- retry cycles forever. Optional capability probes must fail/fall back after
- a bounded interval, while mutations and cleanup retain their stronger
- bounded-lease recovery rules.
-- [ ] Before the first remote command after any controller/source reboot, read
- its RTC and compare it with a trusted host clock. Advance a clock which is
- behind and prove readback; never move a radio clock backward. Fail closed
- when it is more than 10 minutes ahead. During the RAK rehearsal the V4
- rebooted to `2026-03-01` while the RAK remained at `2026-08-30`; the RAK
- received the direct packets but correctly rejected their old timestamps
- as replays. Setting the V4 to host epoch immediately restored login and
- the exact public-key reply at SNR -1.5 dB.
-- [ ] Before every external HTTPS download, require a fresh SNTP response for
- that bounded operation, a plausible signed wall clock, connected Wi-Fi,
- and a non-expired proof immediately before opening TLS. Re-check the proof
- before every resumed/Range TLS connection. A retained plausible RTC or
- mesh time is not a substitute for observing NTP, and failure must occur
- before any HTTP request body or flash writer begins.
-- [ ] Treat an unacknowledged immediate `tempradio` mutation as potentially
- active, not merely lost. On the tested repeater, scheduled-radio expiry
- deliberately waits for `hasOutbound()` to clear; a CLI reply stranded on
- the temporary tuple can therefore hold the node there beyond its nominal
- lease. Prefer one fixed absolute `tempradioat` window per node, so duplicate
- delivery cannot extend it, and prove the return on both tuples before a
- transfer. If recovering an older immediate handoff, join the exact
- temporary tuple to drain the reply before issuing `normalradio`.
-- [ ] Do not diagnose a co-located high-power LoRa bench failure as weak RF
- without an overdrive check. On the RAK3401/V4 bench, the RAK at 22 dBm
- produced only 3/6 status replies around -1.5 dB SNR, while a reversible
- 0 dBm setting produced 6/6 replies in 5--8 seconds at +12 to +12.5 dB
- SNR. Read the original power, lower it only for the near-field test, and
- require exact restoration afterward.
-
-### Compute-placement gate
-
-- [ ] Run firmware builds, PlatformIO, mOTA/delta generation, compression,
- full-image hashing sweeps, symbol generation, and other sustained CPU or
- memory work on the VM. Do not offload these jobs to a Pi merely because
- the hardware and source artifacts are attached there.
-- [ ] Use the Mercerwood Pi as the identity-gated hardware gateway, SWD/USB/BLE
- test host, and LoRa/Wi-Fi seeder. Copy only the completed artifact plus
- its manifest/hash to the Pi, then verify the copied SHA-256 before use.
-- [ ] Run CPU work on a Pi only when Pi performance or deployment behavior is
- itself the test subject, and label that exception in the run log.
-- [ ] Do not assume a non-interactive SSH session has the same `PATH` as an
- interactive shell. Resolve pipx-installed tools such as esptool and
- detools to their explicit venv path, record the version, and fail before
- erase if the pinned executable is unavailable.
-- [ ] Treat short serial/BLE/SWD control scripts on the Pi as hardware I/O, not
- as permission to generate artifacts there. Use the explicit Python from
- the relevant pipx environment when a probe needs one of its dependencies;
- the Pi system Python did not contain `pyserial` during the RAK run.
-
-### LoRa-controlled host recovery gate
-
-- [x] The privileged recovery boundary is a root-owned, group-restricted
- AF_UNIX broker with a fixed command grammar and fixed `systemctl` argv.
- The unprivileged MQTT endpoint cannot gain privileges under
- `NoNewPrivileges`, cannot select an executable or unit, and commits an
- action only after its signed challenge/reply has been published and
- confirmed. The installed broker passed its cold activation, peer
- credential, socket owner/mode, injection, duplicate, timeout, durable
- reservation, and ambiguous-outcome checks.
-- [x] A signed `host network restart` was accepted exactly once in 4.088
- seconds. The broker retained one `network-restart` record at sequence 1,
- and the fixed unit journal had exactly one start/finish pair. Independent
- management probing measured 7.184 seconds from last successful probe to
- first recovered success and 6.314 seconds from first failure to recovery.
- Pi-local loopback MQTT delivered 107/107 samples; mosquitto, mctomqtt,
- and the endpoint retained their exact PIDs, and mctomqtt remained the sole
- owner of the RAK USB port. One read-only LoRa status reply was lost at the
- temporary 0 dBm bench setting, then authenticated status returned
- `scheduled`; no mutation was replayed. One monitor parsed two
- `systemctl --value` columns in reverse order, so its boolean labels are
- discarded; its PID/MQTT trace and the independent postchecks remain
- valid.
-- [x] A signed `host reboot` was accepted exactly once in 3.312 seconds and
- armed the fixed 10-second timer. A changed boot ID and one observed
- outage prove one reboot; management recovery took 35.763 seconds from
- the last pre-outage success and 31.276 seconds from the first failed
- probe. Postboot signed LoRa uptime replied in 3.651 seconds. All seven
- exact USB identities returned, only the RAK port was owned, the deployed
- configuration hashes were unchanged, and NetworkManager, Tailscale,
- mosquitto, mctomqtt, the endpoint, and both privileged sockets were
- active. Broker action state is intentionally under `/run` and persistent
- journaling was unavailable, so exactly-once evidence is the accepted
- reply, one boot-ID transition, one outage, an inactive timer, and no
- second outage; do not claim a durable postboot operation record.
-- [x] Reboot auto-started the still-enabled ModemManager service, but all seven
- exact devices retained `ID_MM_DEVICE_IGNORE=1`, zero modems appeared,
- and no TTY was probed or claimed. The service was returned to its
- pretest inactive state without disabling or masking it. The RAK then
- passed exact identity/body/radio gates; an explicit radio-busy rejection
- left TX at 0 dBm, one bounded idempotent retry after idle returned OK,
- and authenticated readback proved exact restoration to 22 dBm. V4
- identity, power-saving, RXPS, TempRadio, and seeder state also matched
- their pretest values.
-
-The sanitized host-recovery evidence bundle is under
-`out/host-actions-live-20260831/`. Its `SHA256SUMS` manifest has SHA-256
-`5acff71ed98f483bb769e8c99c577bf358d0491355ac72855ee7093cd658bf21`,
-and `sha256sum -c` verifies every deployed-hash, pre/post-state, radio, OTA,
-network, and reboot record in that manifest.
-
-### Mercerwood Pi USB power-cycle gate
-
-- [ ] Confirm the Pi is reachable through Wi-Fi/Tailscale and that `eth0` is not
- the active management path before cycling its downstream USB tree.
-- [ ] Stop all flashes/transfers, run `sync`, and release SWD GPIO 8 and GPIO 11
- to inputs with no pulls before removing USB power. This prevents an SWD
- signal from partially back-powering an otherwise unpowered nRF52.
-- [ ] Never issue a standalone `uhubctl ... -a off` to Pi root hub location `1`.
- Once its only port is off, the hub disappears from `uhubctl` discovery and
- a separate `-a on` command fails with `No compatible devices detected at
- location 1!`. This has interrupted two hardware runs.
-- [ ] A downstream hub may be cycled only after resolving the board's stable
- USB serial to an exact sysfs path and proving that hub advertises
- per-port power switching. Record the hub location and port, cycle only
- that port, and gate the re-enumerated VID/PID/serial before continuing.
- The T1000-E qualification safely used child hub `1-1.2`, port 3; this is
- not permission to cycle the ganged root hub at location `1`.
-- [ ] Do not use `uhubctl` `off`, `on`, **or** atomic `cycle` on Mercerwood root
- hub location `1`. Physical testing showed that even one atomic `cycle`
- powers the ganged root hub down and then cannot rediscover it to restore
- power. Use a planned Wi-Fi-issued `sudo reboot`, which is the verified
- recovery, or arrange a true upstream VBUS removal when that distinction
- is part of the test.
-- [ ] Treat root hub `1` as a ganged operation: every attached test board and USB
- Ethernet adapter loses power. After recovery, enumerate every stable
- `/dev/serial/by-id` identity again before resuming any board-specific test.
-- [ ] On this heavily populated root hub, inspect kernel logs for real transfer
- faults before and after a flash, and stop ModemManager while it could open
- a test TTY. A CH340/ESP32-S3 write which lost the chip at 460800 baud
- completed and verified at 115200 after a planned Pi reboot; start at the
- board-qualified conservative rate rather than treating a fast partial
- transfer as firmware failure.
-- [ ] Under non-interactive SSH, do not let `udisksctl` fall through to an
- interactive polkit password prompt. After resolving the exact UF2 block
- device by board serial, either use an already-mounted volume or an
- explicit `sudo -n mount` at a board-specific mount point; re-check
- `INFO_UF2.TXT` model/version before copying.
-
-## Seeed XIAO nRF52840
-
-- [ ] Stable USB identity distinguishes application (`2886:8044`) from UF2
- bootloader (`2886:0044`).
-- [ ] Do not repeat the known-failing full-application UF2 copy under the
- installed initial 2.4.4 candidate. The identity-gated 897,024-byte copy
- returned exit 0, then `sync -f` failed with `EIO` in 850 ms; the kernel
- logged an offline device, lost queued writes, and FAT errors before USB
- disappeared. Recover through a separately hash-gated BLE/serial path and
- qualify a corrected bootloader candidate before retrying UF2.
-- [ ] SWD readback exactly matches the intended bootloader-region SHA-256.
-- [ ] Actual SoftDevice FWID and runtime application base match the artifact
- (`S140 7.3.0`: FWID `0x0123`, application base `0x27000`).
-- [ ] Exercise and independently verify the exact-board combined
- bootloader+SoftDevice recovery path before relying on it in the field.
- A wrong-layout nRF52840 application can overwrite part of S140 while the
- UF2 bootloader still accepts files and the application still reads flash;
- internal writes or `InternalFS.format()` may then hang or reboot. An
- application-only UF2 and a filesystem erase cannot repair that state.
- The known XIAO incident in upstream issue #3284 recovered only after the
- matching Sense/non-Sense OTAFIX bootloader+S140 ZIP was installed. Keep
- board identity, SoftDevice FWID, application base, and package contents
- as four separate pre-write gates.
-- [ ] Invalid settings enter persistent UF2 recovery after a cold boot.
-- [ ] UF2 application copy returns success; `sync -f` returns success; the host
- has no offline-sector, lost-write, or FAT I/O errors.
-- [ ] Application enumerates after update and reports the expected board,
- version, role, and radio tuple.
-- [ ] Legacy serial DFU crosses all application pages, emits the exact
- `Device programmed.` success marker, and rejects false-success output.
- Two application serial-DFU attempts on this board timed out waiting for
- the start ACK while `adafruit-nrfutil` still returned exit 0, so exit
- status alone is explicitly insufficient.
-- [ ] Legacy BLE DFU verifies each cumulative PRN receipt exactly and fails on
- a missing, short, regressive, or overreported offset; it may adapt
- only among the packet-size-specific safe receipt windows after timely
- exact receipts and must step back down on slow exact receipts. Final
- application/SoftDevice/bootloader CRC and a peer disconnect are mandatory
- before declaring success.
-- [ ] Every upward PRN move is a bounded adjacent-level probe. A failed probe
- or a later real demotion blocks further increases for that transfer, while
- additional slow exact receipts may still negotiate down one level at a
- time. This prevents `4 -> 8 -> 4 -> 8` churn without disabling a safe
- initial probe or a `32 -> 16 -> 8` fallback.
-- [x] The exact Full Companion ZIP
- `Xiao_nrf52_companion_radio_full-v1.17.1.5-halo-keymind-cascade-3caf9dcf.zip`
- (SHA-256 `9a916c1a3ea83d8f6dd704465fe9c044a363478f852a4369cf6b13424a7a9430`)
- installed over BLE in 237,297 ms at about 2,110 B/s using ATT MTU 247,
- 244-byte writes, PRN 4, and exact cumulative receipts. Validate and the
- target-initiated disconnect passed. USB then reported the exact expected
- version/hash, USA radio tuple, key, RXPS policy, and reversible
- Terminal/Binary mode; the new application advertised NUS as
- `MeshCore-4610BBD2`.
-- [x] The post-update application was paired through BlueZ with the expected
- PIN. Companion GATT then returned the same public key, USA radio tuple,
- 22 dBm limit, non-repeater role, board, and build over BLE. Its RTC was
- 18,038 seconds behind the trusted host; `clock sync` advanced it and the
- immediate readback was within one second of the host interval. No
- backward clock write was performed.
-- [x] A second exact same-image update exercised application buttonless DFU and
- retained-peer bootloader entry. The bootloader deliberately reused the
- bonded application address `E5:C3:A8:B0:60:66` rather than the cold-entry
- address ending in `:67`; exact name, Legacy DFU service, and DIS model
- gates still passed. The first adaptive threshold remained at PRN 4 and
- completed all 448,416 bytes at about 877 B/s in 527,832 ms; validate,
- target disconnect, USB identity, application key, role, and USA radio
- readback all passed. The application clock was then about 10 minutes
- behind because its RTC did not advance during the bootloader transfer;
- it was advanced to the host clock and proved by immediate readback.
-- [x] A third same-image A/B run used bounded neutral-window exploration. At
- 244-byte writes, two exact neutral PRN-4 receipts triggered one adjacent
- PRN-8 probe. PRN 8 measured about 845 B/s against an approximately
- 879 B/s PRN-4 baseline, so the helper rolled back to PRN 4 after two
- receipts and never re-probed. All 448,416 bytes completed at about
- 870 B/s in 531,804 ms; CRC validation, target disconnect, USB return,
- exact Companion key/role/radio readback, and post-DFU forward clock sync
- passed. This proves up/down sender-window adaptation and no churn, while also showing
- that PRN receipt overhead is not the bonded-path bottleneck on this run.
-- [x] A bootloader candidate built from exact ZIP SHA-256
- `9c70200fc15a315a50ec68d2a905b5bcf2fc07a796a21fbd439535ec19a608be`
- disabled inherited slave latency locally during DFU DATA. The controller
- trace still reported the peer-negotiated 30 ms interval and latency 4,
- with no connection-parameter update after START; the change is therefore
- a local event-attendance override, not a GAP speed renegotiation. A cold
- address-generation control completed 448,416 bytes at 2,231 B/s and
- passed validation/activation in 226,103 ms.
-- [x] Two subsequent bonded application-to-bootloader runs completed the exact
- same 448,416-byte image at 3,524 and 3,534 B/s. Their complete elapsed
- times, including exact application advertisement proof, buttonless
- handoff, bootloader scan, erase, DATA, CRC validation, activation, and
- reboot, were 169,414 and 169,221 ms. Both targets initiated the final
- disconnect; USB returned as stable serial `B35E71C1C3726CE7`; the public
- key, 22 dBm setting, USA `910.525/62.5/SF7/CR5` tuple, board, and build
- read back exactly. Compared with the prior roughly 877 B/s bonded run,
- the DATA rate improved by about 4.0x without increasing the PRN window
- beyond 8.
-- [x] An exact bonded B-A-B link-profile sequence used the same Full Companion
- ZIP SHA-256 `9a916c1a3ea83d8f6dd704465fe9c044a363478f852a4369cf6b13424a7a9430`,
- lab helper SHA-256 `4909c617de37b5666cc622b7190568f002280f55bf26a05843ea283f2be8f566`,
- handoff helper SHA-256
- `1bad570173079b623bbe2bbb9e5ba7c4f8ac379bf966bfd4382c5266fb9fd214`,
- stable USB serial, bonded address, and 448,416-byte application throughout.
- First B, `xiao-ble-event-length-prestart-20260830-072641-W7COxm`, used one
- exact pre-START 15 ms/latency-0 update with `0/0` no-preference event-length
- hints and completed DATA in 72.030 seconds at 6,225 B/s; DFU through target
- disconnect was 102.792 seconds and the full workflow was 117,064 ms.
- Control A, `xiao-ble-control-prestart-20260830-073149-yKf0lO`, requested no
- update, retained the bootloader connection's 30 ms/latency-4 link, and
- completed DATA in 134.131 seconds at 3,343 B/s; DFU was 165.137 seconds and
- the full workflow was 182,624 ms. Final B,
- `xiao-ble-exact15-prestart-20260830-073554-FuGPfH`, repeated exact 15 ms and
- completed DATA in 71.970 seconds at 6,231 B/s; DFU was 102.799 seconds and
- the full workflow was 117,179 ms. Both B rates were about 1.86x control and
- differed by only 0.096%.
-- [x] The B-A-B protocol and link gates passed independently. Both B runs probed
- PRN 4 -> 8 after an exact 0.135-second four-packet receipt, rolled back
- once to PRN 4 after the exact 0.345-second eight-packet receipt, and never
- re-probed. Control A likewise probed after 0.269 seconds and rolled back
- after 0.600 seconds. Each B bootloader connection had exactly one matching
- HCI Update Complete before START; control A had no bootloader-connection
- update. All three captures contained zero connection updates from START
- through DATA, CRC validation, activation, and the target-initiated
- disconnect. Each run ended `RESULT=PASS`, `RC=0`, `STAGE=complete`; final B
- returned the exact Full Companion application as USB `2886:8044`, stable
- serial `B35E71C1C3726CE7`, with current application name
- `MeshCore-4610BBD2`. A fresh post-B binary Companion query independently
- re-gated the unchanged public identity, name, Full Companion role, 22 dBm
- power, and USA `910.525/62.5/SF7/CR5` radio tuple. A separate bonded BLE
- application query then returned the same board, build, and Full Companion
- role, so the USB return did not mask a broken Companion BLE path or stale
- bootloader alias.
-- [x] The current host-controller ceiling is independently characterized. The
- Pi Zero 2 W Broadcom UART controller reports HCI/LMP 4.2 but supports only
- LE 1M; LE Read Maximum Data Length and Read Suggested Default Data Length
- both return Unknown HCI Command. The final B capture contains 1,837 full
- 244-byte firmware writes,
- each split into ten 27-byte HCI ACL fragments; all 18,425 transmitted ACL
- fragments received completed-packet accounting. The median full-write gap
- was 29.947 ms at 15 ms versus 59.936 ms in control A. A BLE 5 controller
- with working Data Length Extension is therefore the next host-side speed
- experiment; this built-in controller cannot exercise DLE or LE 2M.
-- [x] The host helper now clears a disconnect callback left by an unsuccessful
- internal BlueZ connection attempt only after `connect()` has returned a
- live link, and labels PRN changes as adaptation rather than link
- negotiation. All 81 strict host tests pass. The first physical run before
- this fix still proved the 3,520 B/s DATA rate and returned a successful
- validation response, but is correctly recorded as FAIL because the stale
- host event prevented ACTIVATE.
-- [ ] Warm app-to-bootloader handoff is tested with the application watchdog
- active; cold recovery is tested separately.
-- [ ] BLE Companion/recovery behavior and radio-reset recovery are exercised.
-- [x] The post-Pi-recovery application still enumerated as exact
- `2886:8044` / `B35E71C1C3726CE7`, but one identity-gated, bounded USB
- Binary Companion attempt received no protocol response. `meshcore-cli`
- 1.6.0 printed both of its no-response error lines and nevertheless exited
- zero; the supervisor treated the attempt as a failure and independently
- proved that no process retained the serial port. The exact terminal-stop
- token and an identity/path-gated USB bus reset also failed, which excludes
- an ordinary startup Terminal-mode hold and host USB endpoint state. The
- external tool defect remains in upstream `meshcore-cli` 1.6.3: its serial
- creation failure returns normally, so callers must validate command output
- instead of trusting exit zero. The LoRa-OTA controller has an exact
- three-diagnostic regression and continues to fail closed.
-- [x] An exact hash-recorded Full Companion application DFU erased/replaced only
- the application and completed in 36 seconds. The transferred ZIP was
- 440,083 bytes with SHA-256
- `9946b8417c389784ac92c20e4ce5cf0b6a0123a58136627c0689815637f96992`.
- The same stable USB serial returned as interface `00`; USB and the existing
- bonded BLE identity both reported `Seeed Xiao-nrf52`, the 31-Aug-2026
- build, and Companion role. Preserved settings included a roughly six-month
- stale clock, which Binary Companion `clock sync` advanced to one second of
- the Pi without changing the role.
-- [x] The recovered application passed explicit Terminal start/stop, exact board
- query, saved `usb.logging off`, source-only OTA status, and a real serial
- `motatool serve` attach/detach with an empty folder (`COUNT -> 0`). Binary
- Companion returned after both terminal and mOTA ownership. Enabling USB
- logging and rebooting produced exact interfaces `00` and `02`; interface
- `02` emitted 123 bytes of entirely printable log data while Binary
- Companion stayed usable on `00`. Disabling and rebooting removed `02`,
- restored the single-interface descriptor, and read back logging off.
-- [ ] A physical power-removal cold test is still needed to prove persistence of
- the current application. It cannot retrospectively determine whether a
- power cycle alone would have cleared the original stall. Answer that only
- after reproducing the stall under a bounded stress loop, capturing state,
- and power-cycling before any DFU. The application DFU proves recovery but
- intentionally does not claim the underlying stall trigger is fixed.
-
-## Seeed Tracker T1000-E
-
-- [x] The cross-role `uf2reset` repair was hardware-qualified with Full
- Companion ZIP SHA-256
- `16e470c0d66bec14e1e9f2d4ebe04d83ffb30efa606465f885cbe9fc3d004a14`.
- It was built from `f2c15225` with GCC 14.2.1, the USA Cascadia/Cascade
- profile, and embedded version
- `v1.17.1-uf2reset-f2c15225-f2c15225`. Serial DFU took 32.196 seconds and
- stable application USB returned in 35.888 seconds. Terminal entry,
- version, help, the exact no-argument command, application disconnect, and
- stable `2886:0057` boot USB all passed in one 8.898-second script. The same
- image was restored in 32.204 seconds and stable `239a:8029` USB returned
- in 36.313 seconds; a final terminal check returned Binary mode cleanly.
-- [x] The fixed application advertises live as `MeshCore-09848C15` at
- `D1:A0:86:CD:BB:C6`. The obsolete Pi bond for the earlier temporary
- identity was removed, and the exact address was paired again with the
- qualification PIN. Paired, bonded, and trusted state, an encrypted BLEDfu
- revision read, and the Nordic UART service all passed. The pre-run old
- application enumerated but produced neither a terminal response nor a
- live advertisement, so this run does not claim continuity of that
- temporary identity or of application-owned settings across a combined
- SoftDevice/bootloader recovery install.
-- [x] Stable application identity `34A9141999729D5D` is recorded and is not
- confused with the RAK3401 even though both currently enumerate with USB
- product ID `239a:8029`. Application is `239a:8029`; the exercised
- bootloader is `2886:0057`, with the same stable serial and exact
- `T1000-E`/`T1KE_DFU` model/name gates.
-- [x] A post-Pi-recovery, identity-gated, read-only USB Companion query returned
- the exact T1000-E model, non-repeater Full Companion role, 22 dBm maximum,
- and `910.525/62.5/SF7/CR5`. Its clock readback was within the query's
- few-second host interval. No clock, radio, role, key, or settings write
- was issued.
-- [ ] Board, version, repeater role, bootloader status, and USA radio tuple are
- correct after erase/install and after reboot. The live baseline and a
- 2,184 ms software reboot passed: `3caf9dcf`, `Seeed Tracker T1000-E`, 22 dBm,
- `910.525/62.5/SF7/CR5`, OTA target `7B071FA0`, bootloader
- `0.11.0-OTAFIX2.4.3`, and full OTA base hash
- `3E92B157128B6963`. A real power-removal boot is still required.
-- [ ] UF2 and serial DFU installation paths complete without host I/O errors.
- On OTAFIX 2.4.3, an identity-gated root copy of the exact old UF2 returned
- success in 112 ms, but the device disconnected before host writeback was
- durable: `sync -f` failed with `EIO` after 17,695 ms and the kernel logged
- lost asynchronous writes/FAT read errors. The old application happened to
- boot and retained its settings, but this transport is a **FAIL**. Retest
- with the corrected 2.4.4 candidate before qualifying T1000-E UF2.
-- [x] The installed `t1000e_repeater_lora_ota_no_external_sensors` profile does
- not compile the Companion BLE transport and did not advertise during a
- post-reboot BlueZ scan; that is expected for this repeater image. BLE
- Companion qualification belongs to the separate T1000-E Companion build.
-- [x] The exact latency-disable bootloader candidate ZIP (SHA-256
- `ebe9739ce40c2c7cc1f52076f9614194959fcf6df93d37666b69b7bd415fab68`)
- installed through identity-gated serial DFU. The final output contained
- exactly one terminal `Device programmed.` success line, the bootloader
- returned as `2886:0057`, and its embedded manifest identifies
- `T1KE_DFU`, S140 7.3/FWID `0x0123`, application base `0x27000`, and test
- version `0x02040403`.
-- [ ] The later connection-policy-reapply combined ZIP (SHA-256
- `c2de1fb2704154b4abc371b6b4827c32ad09c4d15f3f6d97d0f4182bb51fff58`)
- was identity-gated, transferred, validated, activated, and returned as
- exact `2886:0057` recovery USB. `INFO_UF2.TXT` reported
- `0.11.0-OTAFIX2.4.3-3-gffb1580-dirty-test-version-0x02040403`, S140
- 7.3.0, and the T1000-E board. The earlier and later candidates share the
- same visible version string, however, so package sequencing and INFO_UF2
- do not independently prove the installed bootloader bytes. Require an
- identity-gated live bootloader-region readback or a uniquely versioned
- rebuild before checking this item.
-- [x] The combined-image BLE transport itself passed: 193,688 bytes at
- 3,331 B/s, target CRC validation, and target-initiated activation. The
- package contains SoftDevice plus bootloader and `APP=0`, so remaining in
- recovery USB was the correct postcondition. Its first supervisor record
- is not accepted as run-level evidence: TERM inherited status zero and
- wrote a false `PASS` at `STAGE=post_copy_usb`. A separate audit records
- the transport result, and all later wrappers map INT/TERM to nonzero and
- refuse `PASS` unless `STAGE=complete`.
-- [x] A separate exact application recovery after that combined transfer
- completed 404,468 bytes at 2,095 B/s, validated, activated, and returned
- stable application USB in 225,899 ms. The lower rate is the expected
- cold/unbonded class, not the bonded warm baseline.
-- [x] A cold exact Full Companion application ZIP (SHA-256
- `b3be000ab2391527a325cc868e2706dec291c167b6e6cd3495908addb8af6c65`)
- completed all 404,468 DATA bytes over BLE at 1,572 B/s. The complete
- workflow took 281,236 ms (about 1,438 B/s end to end, including setup,
- validation, activation, and reboot). ATT MTU reached 247, but BlueZ's
- independently cached maximum write size was still 20, so the
- identity-gated helper correctly used 20-byte packets, adapted PRN
- 8 -> 16 -> 32, validated CRC, activated, observed the target-initiated
- disconnect, and gated the exact USB application return.
-- [x] The Full Companion application advertised as `MeshCore-F9AD7082` at the
- expected address generation, paired with the qualification PIN, and
- exposed its bonded, MITM-protected Legacy DFU service after connection.
- That DFU service is intentionally absent from the advertisement, so an
- advertisement-only check is insufficient. A 1200-baud USB touch instead
- selects serial-only DFU and correctly produces no BLE advertisement;
- use the bonded application control characteristic for a warm BLE handoff.
-- [x] Exact same-address bonded warm buttonless DFU passed. The application
- advertisement, Paired/Bonded state, connected BLEDfu service/control/
- revision layout, encrypted notification/write gate, observed target
- disconnect, `T1KE_DFU` advertisement, DIS model `T1000-E`, ATT MTU 247,
- and 244-byte write capability were all checked. All 404,468 bytes were
- confirmed in 121.081 seconds at 3,340 B/s; DFU through target disconnect
- took 144.076 seconds and the full supervisor took 176.958 seconds. CRC,
- activation, and exact `239a:8029`/`34A9141999729D5D` USB return passed.
-- [x] Two accepted in-DATA 30 ms -> 15 ms -> 30 ms controller experiments
- completed without corrupting either image, but were decisive negative
- performance results. The first finished DATA in 425.398 seconds at
- 951 B/s; the later candidate finished DATA in 449.063 seconds at 901 B/s.
- HCI capture found the cause: `hcitool lecup` silently encoded both
- connection-event-length hints as `0x0001` (0.625 ms). The Pi's Broadcom
- 4.2 controller consequently scheduled about one ACL fragment per event:
- roughly 64 fragments/s at 15 ms and 32 fragments/s after the 30 ms
- "rollback", versus roughly 120--144 fragments/s on the untouched link.
- The later pre-START controls prove that 15 ms itself is not slow; the
- restrictive event-length hint and in-DATA update/rollback were unsafe.
-- [x] Two exact 15 ms/latency-0, zero-event-length lab controls passed:
- `t1000-ble-event-length-prestart-20260830-071549-QOeVuw` completed all
- 404,468 DATA bytes in 64.905 seconds at 6,232 B/s (95.635 seconds through
- target disconnect; 112,639 ms full workflow), and
- `t1000-ble-event-length-prestart-20260830-071825-VE9Re3` completed DATA in
- 62.070 seconds at 6,516 B/s (92.794 seconds through target disconnect;
- 106,807 ms full workflow). Both used exact `0/0` no-preference minimum and
- maximum connection-event lengths. On each bootloader connection the HCI
- trace contains exactly one successful update before START and none from
- START through DATA, validation, activation, and the target disconnect;
- Update Complete preceded START by 7.535 and 7.536 seconds. Completed-packet
- clusters followed the 15 ms connection anchors, while the median full-size
- DFU write gap was 29.997 ms in both traces. Exact package hash, CRC,
- activation, and stable application USB return passed twice.
-- [x] `t1000-ble-event-length-prestart-20260830-071358-ogzmRy` is the bounded
- negative control. HCI accepted exact 15 ms/latency 0 with both event-length
- hints set to 10 ms, then reported `Connection Timeout (0x08)` 6.810 seconds
- after Update Complete during the 20-second pre-START pause. The run failed
- closed at `STAGE=pre_start_update` in 44,227 ms; START and application DATA
- were never sent. A 10 ms event-length hint is therefore rejected for this
- adapter/target pair.
-- [ ] Production BLE DFU keeps the controller-negotiated link and issues no raw
- host connection update unless that exact adapter/target profile has been
- separately graduated. The opt-in lab path may issue exactly one request on
- the bootloader connection before START while sent/confirmed are zero: exact
- interval and latency, `0/0` no-preference event-length hints, bounded pause,
- and a matching HCI Update Complete are mandatory. Failure or mismatch
- disconnects before START. Never update or roll back the link during DATA;
- only the protocol PRN/window may adapt there. Compare complete repeated
- control/candidate DFUs offline before graduating a production profile.
-- [ ] Repeat the cold transfer with the host's bounded BlueZ readiness wait.
- When ATT MTU is greater than 23 while the cached write capability remains
- 20, it polls the same connected DFU characteristic for at most three
- seconds, aborts on disconnect, disappearance, or characteristic-handle
- change, and otherwise falls back safely to 20. The four readiness unit
- cases and all 81 strict helper tests pass; a physical 20 -> 244-byte
- promotion is still required.
-- [ ] Restore the protected
- `t1000e_repeater_lora_ota_no_external_sensors` application (target
- `0x7B071FA0`, exact ZIP SHA-256
- `9b9be2bee9ebb6644d22806d404fe02c415d037707713d206fa43a8c689cf632`)
- through identity-gated application DFU while retaining the candidate
- bootloader. Do not use the generic repeater image or the unqualified UF2
- path. Verify the protected private/public identity without printing the
- private key, plus exact name, latitude/longitude, build/body/OTA target,
- USA radio, 22 dBm, GPS off, power saving on, RX gain on, RXPS on,
- watchdog on, telemetry access `all`, no TempRadio/download, and a valid
- admin login.
-- [ ] LoRa radio reset/recovery is forced and normal receive/transmit resumes.
- Normal post-reboot bidirectional LoRa is already proven: the V4 learned a
- zero-hop path, received the exact message-specific ACK in 558 ms, and
- received the T1000-E's exact `3caf9dcf` version reply. A deliberately
- forced LR1110 recovery event is still required.
-- [x] Direct V4 LoRa OTA receive, apply, reboot, and post-update verification
- passed from exact `bf092b24` body `9443FB5E6F23FC1B` to exact `3caf9dcf`
- body `3E92B157128B6963` on OTAFIX 2.4.3. The mandatory fixed three-minute
- rehearsal passed at `909.950/250/SF5/CR5`, including natural expiry and
- return to `910.525/62.5/SF7/CR5`. The 2,036-byte/two-block delta reached
- ready in six seconds, the new body replied at the 50-second probe, and the
- complete safety-wrapped runner took 519 seconds. V4 source RXPS and both
- normal radio tuples were restored. The co-located T1000 was temporarily
- reduced to 0 dBm and restored to 22 dBm using bounded retries across its
- RXPS busy windows.
-- [x] Independent post-LoRa-OTA USB verification matched the protected pre-run
- private key, public key, name, latitude, and longitude without printing
- private material. GPS remained off; device power saving, system watchdog,
- RX gain, and RXPS remained on; telemetry access remained `all`; no
- TempRadio lease or download remained. `blrc:B8` is the expected OTAFIX
- application-apply success diagnostic. A subsequent software reboot reset
- uptime and preserved the exact build/body, name, radio, 22 dBm, GPS,
- power-saving, RXPS, watchdog, and no-TempRadio state. The CDC symlink did
- not disappear during this warm reset, so uptime/readback—not USB removal—
- is the required reboot proof on this board.
-- [x] GPS was initially off, enabled successfully under device power saving,
- reported active/no-fix/zero-satellites with its duty-cycle deadline, and
- was restored to off. Device power saving reported on, RXPS reported
- `on,18205,20423`, GPS advert policy `prefs`, and telemetry access `all`.
-
-## RAK3401
-
-- [x] Stable USB identity and SWD FICR identity are independently matched.
-- [x] Source-level WisBlock peripheral pins are complete and non-conflicting:
- I2C SDA/SCL are P0.13/P0.14 through both `PIN_WIRE_*` and
- `PIN_BOARD_*`; GPS-module RX crosses to MCU UART TX P0.16, GPS-module TX
- crosses to MCU UART RX P0.15, and PPS is P0.17. A static contract test
- prevents these aliases from silently disappearing or being uncrossed.
-- [x] Ordinary/full-sensor RAK3401 profiles use the source-built Adafruit BME680
- provider, not Bosch's precompiled BSEC archive, so upstream issue #3292's
- soft-float/hard-float link mismatch is not reachable there. The reduced
- LoRa-OTA profile deliberately omits BME680/BSEC and the other declared
- optional environmental/ranging drivers. A static contract test requires
- the hard-float path fix and BSEC dependency if BSEC is enabled in this
- board or an inherited full-sensor recipe later.
-- [ ] With the RAK13302 radio installed, detect a RAK12501/L76K UART GPS from a
- complete checksum-valid NMEA sentence in sensor slot A. Confirm random
- UART bytes, an incomplete sentence, and a bad checksum do not claim GPS.
-- [ ] Detect RAK12500 I2C GPS alone at `0x42`; exercise GPS power saving and
- confirm the shared 3V3_S rail, OLED, RTC, and other slots remain powered.
-- [ ] Detect INA3221 alone at the firmware-configured `0x42` and prove the
- discovery path does not send u-blox configuration traffic to it.
-- [ ] Exercise RAK12500 at `0x42` and INA3221 at `0x43` together after strapping
- INA3221 A0 to SCL and building with
- `-DTELEM_INA3221_ADDRESS=0x43`; verify both retain independent telemetry.
-- [ ] With a representative omitted environmental sensor attached, confirm the
- reduced image leaves the generic I2C bus healthy but does not advertise a
- driver that was intentionally removed.
-- [x] Local USB terminal probes are terminated with carriage return (`\r`).
- This CLI echoes an LF-only command without executing it; an echoed
- `ver` with no reply is therefore a bad probe, not evidence of a hung
- application. Confirm any suspected hang with a CR-terminated command,
- LoRa status, and SWD state before resetting the board.
-- [x] Assert CDC DTR when probing the RAK terminal. With DTR deasserted, writes
- reached the port but no CLI replies were returned; the same exact
- stable-identity port returned the expected version immediately after DTR
- was asserted. Keep 1200-baud bootloader touch separate from 115200-baud
- application probes, and allow a full reply window so delayed responses
- are not attributed to the following command.
-- [x] Installed S140 6.1.1 FWID (`0x00B6`) and runtime application base
- (`0x26000`) are independently verified.
-- [x] Bootloader version and apply capabilities are independently reported.
-- [x] UF2 application copy returns success; `sync -f` returns success; no USB
- reset/timeout or offline-sector errors occur; SWD readback matches every
- UF2 payload byte. Current 2.4.4-preview.2 result is **PASS** in both
- current→old and old→current directions.
-- [x] Board, repeater role, version, name, and USA radio tuple are verified
- after serial recovery and again after a real cold boot.
-- [x] Six consecutive authenticated `ota self` attempts leave the application
- healthy. In the qualification run, two login/reply pairs were lost, one
- login succeeded while its command reply was lost, and the next three
- commands replied in about 2.5 seconds each. A CR-terminated local `ver`
- probe passed after every attempt; packet loss did not become a node hang.
-- [ ] An unchanged admin login does not rewrite the ACL flash file, while an
- actually changed/newly learned persistent route schedules exactly one
- delayed save and survives reboot. Cover delayed flood-path replies,
- already-unknown paths, changed permissions/secrets, new/evicted clients,
- and the intentionally non-persistent guest case. Do not rely on an
- unrelated login write to capture a later PATH response.
-- [ ] Location and private-key persistence are verified without exposing the
- private key in logs.
-- [x] Serial recovery rejects nrfutil's false exit-0 failure, requires the exact
- success marker, and produces a byte-exact SWD application readback.
-- [ ] UF2/serial recovery works with both the deployed older bootloader family
- and the current backward-compatible application.
-- [ ] Bluetooth advertisement and connection work when enabled. The tested
- repeater profile does not advertise BLE during normal operation.
-- [x] Direct LoRa OTA from the V4 passes with elapsed time, bounded retry
- behavior, final hash/version verification, exact radio restoration, and
- independent SWD readback. The qualified `bf092b24` base
- `91CF4EC2882F23D6` reached `3caf9dcf`/`27A223DDFC3A8F47` using a
- two-block, 2,132-byte delta: ready in six seconds, new body at the
- 60-second reboot probe, and 514 seconds for the complete safety-wrapped
- run. Log:
- `/home/mikec/hwtest/runs/rak3401-0dbm-final-9KWjey61/run.log`.
-- [x] The current OTAFIX test version `0x02040403` independently passes a
- 74-block direct LoRa update from exact `3caf9dcf` body
- `27A223DDFC3A8F47` to HEAD `9fd580c8` body `744BC4443891ED10`.
- READY took 122 seconds and the complete three-minute-rehearsal,
- transfer, install, reboot, recovery, and restore wrapper took 589.487
- seconds. Lost read-only preflight and postboot replies were reconciled by
- bounded exact retries. USB confirmed the target/hardware/body and
- bootloader success result, and authenticated LoRa readback confirmed the
- final 22 dBm restoration. Sanitized log:
- `/home/mikec/hwtest/runs/rak3401-02040403-head-vzWOXVxT/qualification-redacted.log`.
-- [ ] Add exact RF packet sent/confirmed counters to the device/host telemetry.
- The qualified run records two payload blocks, all seeder reads, command
- retries, and final success, but those are not equivalent to LoRa packet
- counts and must not be presented as such.
-- [ ] Repeat at each supported bandwidth and record 0-hop, 1-hop, and 2-hop
- estimates from measured airtime rather than extrapolating only by file size.
-
-## RAK4631 reduced profiles
-
-- [x] Serialized VM contracts distinguish the three application identities:
- the plain and Serial2 reduced profiles retain the combined WisBlock GPS
- provider and all four INA voltage/current drivers; the explicit legacy
- Serial1 profile retains the INA drivers and omits GPS. This is build
- evidence only, not physical peripheral qualification.
-- [ ] On the plain reduced profile, detect and read RAK12500 I2C GPS, a
- RAK12501/L76K UART GPS in a separate run, the SSD1306 OLED, a supported
- autodiscovered RTC, and representative INA telemetry. Verify GPS sleep and
- repeated discovery never lower the shared WB_IO2/3V3_S rail.
-- [ ] With a UART RAK12501 installed and detected, confirm the Serial2 bridge
- and GPS remain usable together. Select Serial1 and try to enable the
- bridge with the GPS preference both on and off; verify each attempt is
- refused before the bridge starts and does not change GPS transport,
- acquisition, hold, or cached-fix state. The shared WB_IO2/3V3_S rail must
- remain high and GPS must remain usable after each refusal.
-- [ ] With only an I2C RAK12500 installed, and again with neither GPS installed,
- select Serial1 on the merged image and confirm bridge enable is refused
- without changing saved UART/runtime state. Silence or an I2C-only detection
- must not defeat the fail-closed RAK12501 reservation. Confirm Serial2 starts
- and I2C GPS telemetry remains usable.
-- [ ] Exercise RAK12500 at `0x42` and INA3221 at `0x43` together with the
- address-matched build. Separately place INA3221 alone at the configured
- `0x42` and prove the identity guard prevents u-blox configuration writes.
-- [ ] With a logic analyzer, hold SDA low and then SCL low before sensor
- discovery. Confirm the validated board pins receive at most nine
- open-drain recovery clocks and a STOP only after SCL rises; the firmware
- must skip discovery if either line remains low. Repeat on ProMicro's
- remapped SDA 8/SCL 7 and verify the user-button pin is never driven.
-- [ ] Record the remaining core limitation: the pinned Adafruit nRF52 Wire
- implementation has no transaction timeout. This preflight cannot protect
- display/RTC calls made earlier in boot or interrupt a peripheral that
- wedges after a transaction begins. Qualify those cases with a hard power
- cut/watchdog fixture before claiming bus-hang recovery.
-- [ ] Confirm the explicit legacy Serial1 image exposes neither UART RAK12501
- nor I2C RAK12500. Record this as a combined-provider build limitation, not
- an assertion that RAK12500 electrically conflicts with the UART.
-
-## Heltec T096
-
-- [x] The cross-role `uf2reset` repair was hardware-qualified on the exact
- no-footer Full Companion image. ZIP SHA-256 is
- `0585bee1c0b278a68788f78af384bcbdb5633d57f9ea301fc14f1c80c4275489`;
- embedded version is `v1.17.1-uf2reset-f2c15225-f2c15225`. Terminal entry
- and help passed, the exact command disconnected application USB in
- 1.031 seconds, and stable boot USB returned in 2.026 seconds. The final
- Full image installed over serial in 38.900 seconds and stable application
- USB returned in 41.672 seconds. Final primary terminal/Binary switching,
- dedicated logging interface, version, help entry, and live
- `MeshCore-8E229D0A` advertisement at `CF:A8:18:36:DE:8B` all passed.
-- [x] Stable USB serial `651F8E496197F882` distinguishes the application
- (`239A:8029`) from the OTAFIX serial-DFU interface. Every flash followed
- that identity across the host-assigned `COM9 -> COM6 -> COM9` transition;
- the simultaneously connected MeshPocket on `COM20` was never selected.
-- [x] Baseline bootloader `0.11.0-OTAFIX2.4.3` was installed at this checkpoint. The restored Repeater
- reports application body hash `3B6D1A729C087243`, target `98411AC6`, apply
- ABI 3, codec mask `0x5`, and stage address `0xED000`. The application ZIP
- is 708,216 bytes with SHA-256
- `abb00560cc5618ae155bf10252e9261127416a7a91b5f6565837384f0dec2390`.
-- [x] A 48,229-byte in-place delta with SHA-256
- `988bd2385e9c995d0ae04effb6bb5cd614227c7c764590d82bd1943da2f18e4d`
- transferred over LoRa in 47/47 blocks from the MeshPocket seeder and
- applied successfully. The first application boot reported bootloader
- result `B8`; the running body hash matched the candidate exactly. No chat
- message was sent for this transfer.
-- [x] The generic delayed AGC/watchdog arming fix passes warm-reboot hardware
- checks on both the T096 and MeshPocket. The restored T096 retained AGC
- interval 8 and, after 215 seconds, was in RX with 100 packets received,
- 28 sent, zero core errors, and `err_flags=0`. The Pocket's quiet-channel
- liveness recovery set only the expected radio-watchdog bit; temporarily
- matching the USA channel then received 14 packets with zero receive
- errors before its saved `869.618/62.5/SF8/CR5` profile was restored. At
- 498 seconds the T096 had one CAD-busy timeout after 37 busy-channel
- samples, then returned to RX with no queue, watchdog, or start-RX fault.
-- [x] Exact Full Companion ZIP SHA-256
- `ee92856e5bfb7877b0d8bff6d6a5873d8037fc178ddb151202a9e6a6d9d59e73`
- installed by serial DFU and passed the reversible dual-CDC test. Logging
- off exposed only interface `00`; `set usb.logging on reboot` added
- interface `02` as `COM21`. Opening `02` emitted both portable identity
- lines, 980 captured bytes were entirely printable, and host input produced
- no echo or command reply while Binary Companion on `00` returned the exact
- model, key, and radio tuple.
-- [x] With interface `02` deliberately unread, 11/11 text-terminal probes stayed
- between 212 and 231 ms for 44.4 seconds. A longer 100-second hold buffered
- 8,636 entirely printable bytes while 60/60 hard-bounded Binary Companion
- reconnects passed with no timeout; the final full identity query completed
- in 739 ms. Neither USB interface disconnected. Logging off plus reboot
- removed `02`, read back off on `00`, and the qualified Repeater was then
- restored with its original name, public key, and USA radio tuple.
-- [x] The descriptor/reconnect follow-up built a 482,271-byte Full Companion DFU
- ZIP with SHA-256
- `2a5a6bd0fd26031a2c2e047af6ead8f85f0d617da238d0cd6d816f7d5c41ec6f`.
- On the physical T096, Windows read interface `02`'s bus-reported
- description as `MeshCore Logging`. Two immediate open/close cycles each
- delivered the complete 80-byte two-line identity marker with no NUL or
- other invalid control byte, while Binary Companion on interface `00`
- retained the exact key, model, and `910.525/62.5/SF7/CR5` tuple.
-- [x] The final short-write/reconnect guard built a 482,271-byte Full Companion
- DFU ZIP with SHA-256
- `bbbc3459b2c032af23c6da05e41984cb0250398b57ba56ea99166456700ace6f`
- and EndF body hash `41365E4DB0FF4AE5`. On the physical T096, interface
- `02` again reported `MeshCore Logging`; 3/3 explicit DTR sessions each
- delivered one complete 80-byte marker with no NUL or invalid control byte.
- With that logging endpoint held open and unread, interface `00` completed
- 60/60 Binary Companion reconnect queries and retained public key
- `8e229d0a9ace9bd6f768fc1237d9e3ebca340d1ff1c6087349983a07f305903a`
- plus the exact `910.525/62.5/SF7/CR5` tuple.
-- [x] The corrected Windows flasher identifies Binary-mode Full Companion and
- links the same-radio `MI_02` inventory row to `MI_00`. An intentional
- selection of `COM21/MI_02` was canonicalized to `COM9/MI_00` before any
- 1200-baud action; the exact USB serial then followed
- `COM9 -> COM6 -> COM9`, never touched the logging endpoint or MeshPocket,
- and produced both `Device programmed.` and `Flash completed.`. The VM was
- suspended only across each touch/transfer and resumed immediately. After
- restoring the qualified Repeater and performing a final warm reboot, the
- 58-second snapshot was in RX with 28 packets received, 9 sent, zero core
- errors, an empty queue, and `err_flags=0`.
-- [x] Final flasher review now revalidates the selected USB identity and exact
- interface immediately before both the direct 1200-baud open and its Python
- fallback, and balances every successfully written terminal START with a
- STOP even when the banner is lost. Windows PowerShell 5.1 and PowerShell 7
- regression suites pass. A second physical restore deliberately selected
- `COM21/MI_02`; it was canonicalized to `COM9/MI_00`, followed the same
- `COM9 -> COM6 -> COM9` transition, and completed without touching the
- MeshPocket. At 112 seconds the restored Repeater retained its name, OTA
- body hash `3B6D1A729C087243`, USA radio tuple, and AGC interval 8; it was in
- RX with 20 packets received, 6 sent, zero core errors, an empty queue, and
- `err_flags=0`. The VM was running again immediately after the flash.
-- [x] The T096-only message profile defines `UI_MESSAGE_CHANNEL_FOOTER=0`;
- every other profile retains the default footer. The exact incoming
- `#testing` message `T096 footer test: wrapped text should reach the screen
- bottom.` traversed the live channel receive/display path, and the temporary
- empty `#testing` channel slots on both endpoints were restored byte-for-byte.
- Static profile coverage passes 2/2. No framebuffer or camera capture was
- available, so the evidence is the exact live UI path plus the compiled
- board-specific flag rather than a pixel photograph.
-- [x] The then-final Full Companion artifact at this checkpoint,
- `v1.17.1-t096-head-fcd1f8cc-usbtask8-fcd1f8cc`, was installed. Its DFU ZIP
- is 487,231 bytes with SHA-256
- `CD267952F420294DF21CA96E3AA6D924C6EF7467C31C8D69B46F31E6719A1D67`;
- the application is 486,428 bytes with a 486,372-byte EndF body and hash
- `2E03B1C1166D4CC0`. The ELF exposes strong `T` definitions for both CDC
- callbacks, mount/unmount, SOF, and the CDC0-only framework flush override.
-- [x] Identity-gated BLE application DFU passed through the bonded address
- `CF:A8:18:36:DE:8B`: encrypted application handoff from
- `MeshCore-8E229D0A`, same-address `T096_DFU` Legacy service, and DIS model
- `HT-n5262G` all matched. The target confirmed 486,428/486,428 bytes in
- 565.986 seconds at 859 payload B/s, validated, activated, and initiated
- the expected disconnect; end-to-end time was 603.967 seconds. USB serial,
- version, board, public key, radio tuple, EndF hash, and bootloader apply
- ABI 3/codecs `0x5` returned exactly, and a fresh active scan observed the
- application name again at the same address.
-- [x] The real host `motatool` opened `COM9`, survived the intentional CDC
- session-settle purge, attached an empty folder with `COUNT -> 0` and
- `OK folder attached`, then detached it and restored Binary mode. This
- test sent no mesh messages. Three primary-port close/reopen cycles after
- deliberately abandoned terminal output exposed zero stale bytes and each
- returned a clean banner. Five logging-port reopen sessions each began
- with the exact 80-byte identity marker and contained no NUL or invalid
- control byte.
-- [x] With both USB outputs deliberately unread for 65 seconds after a 10,000-byte
- terminal input burst, the primary accumulated 16,886 printable bytes and
- CDC1 accumulated 3,735 printable bytes beginning with its exact marker.
- `ver` and Binary Companion remained responsive. A subsequent warm reboot
- removed USB for 2.209 seconds, returned both interfaces in 2.409 seconds,
- and CDC1 again emitted the exact marker. At 65 seconds the fresh boot had
- `err_flags=0`, an empty outbound queue, 9 received floods, and zero CRC
- errors. A Windows host-side composite
- restart was attempted but denied by the non-elevated PnP API, so the
- distinct host-driven BUS_RESET case remains a physical/privileged check;
- it is not inferred from the warm-reboot result.
-- [x] After the operator physically removed and restored T096 power (the off
- interval was not measured), stable serial `651F8E496197F882` returned as
- primary `MI_00`/`COM9` and logging `MI_02`/`COM21`. The cold terminal
- reported exact version
- `v1.17.1-t096-head-fcd1f8cc-usbtask8-fcd1f8cc`, Heltec T096 identity,
- public key, USA radio tuple, BLE name, dedicated-CDC logging state, EndF
- body hash `2E03B1C1166D4CC0`, and bootloader apply ABI 3/codecs `0x5` with
- stage ceiling `0xD4000`; mOTA remained source-only with no folder attached,
- and STOP restored Binary mode. CDC1's first read was the exact 80-byte
- identity marker with no NUL. At 150 seconds, Binary stats reported 4,224
- mV, `err_flags=0`, an empty outbound queue, 47 packets received, zero
- packets sent, and zero TX airtime. A second core checkpoint advanced
- monotonically to 315 seconds with 4,220 mV, `err_flags=0`, and an empty
- queue, providing no evidence of a watchdog reboot. A fresh active BLE
- scan observed `MeshCore-8E229D0A` at the same `CF:A8:18:36:DE:8B`
- address; BlueZ still
- reported paired, bonded, and trusted. No mesh message was sent. This proves
- cold persistence, but not the distinct host-only USB `BUS_RESET` path.
-- [x] The display auto-off wake failure was traced to the shared ST7735 power
- lifecycle: `turnOff()` released SCK/MOSI as inputs, but the nRF52
- `SPIClass::begin()` wake path was a no-op while the SPI object still
- considered itself initialized. The shared driver now restores the bus-pin
- directions and nRF52 high-drive configuration explicitly, waits through
- reset release, and primes the retained frame after `DISPON`. Static wake
- ordering and the T096 footer profile pass, and the exact Full Companion artifact
- `v1.17.1-t096-head-fcd1f8cc-wake1-fcd1f8cc` compiled at 486,348 bytes.
- Its 487,231-byte serial-DFU ZIP has SHA-256
- `A091F6DC458177BB26B8384B2AAAC0A9FD550322179F96062CCD3F0F5BB0E585`;
- the 486,428-byte OTA image reports a 486,372-byte EndF body with hash
- `E9AB2A36E9F7091F`. Identity-gated `meshfirmware` selected `COM21/MI_02`,
- canonicalized it to `COM9/MI_00`, followed `COM9 -> COM6 -> COM9`, and
- emitted both `Device programmed.` and `Flash completed.` without touching
- the MeshPocket. The VM was suspended only across touch/transfer and was
- running again as soon as exact serial `651F8E496197F882` returned. Live
- readback then matched the public key, Heltec T096 board, USA
- `910.525/62.5/SF7/CR5` tuple, ABI 3/codecs `0x5`/stage `0xD4000`, and exact
- 80-byte CDC1 marker; core checkpoints advanced through 369 seconds with
- `err_flags=0`, an empty queue, and zero TX. Follow-up physical testing
- confirmed auto-off wake and single-click page navigation. Double-click,
- triple-click, long-press, and simultaneous-button gestures remain deferred.
-- [x] A signed bootloader mOTA package for OTAFIX HIL candidate `0x02040401`
- transferred over LoRa in 40/40 blocks, passed the privileged-install gate,
- installed with the watchdog enabled, and rebooted with unchanged node and
- radio settings. The installed bootloader CRC changed to the package's
- expected `381BAE6E`; the temporary HIL signer was removed afterwards.
-- [x] A 486,796-byte Full Companion application completed bonded Legacy BLE DFU
- at approximately 5.7-6.2 kbit/s payload throughput, then returned with the
- same identity and USA radio tuple. The later CDC0 first-open fix preserved
- immediate `APP_START` input across the eight-millisecond session gate:
- first client after reboot passed with CDC1 logging active, followed by
- 10/10 immediate COM9 close/reopen queries. CDC1 continued to emit its exact
- identity marker and printable packet/debug logs.
-
-## Heltec MeshTower V2 with SD
-
-- [x] Stable serial `9352162A72082314` and physical downstream-hub path
- `1-1.2` port 4 identify this board across application and bootloader
- descriptors; no other attached nRF52 endpoint was selected. The older
- 2.4.3 bootloader's UF2/serial failure was rejected rather than treated as
- a successful flash.
-- [x] Exact `1.17.1.5-halo-keymind-cascade-marathon-hwtest-e26d48e4` SD
- Repeater boots with hardware ID `Heltec_tower_v2`, target `0A9DBBF0`,
- body hash `E9282D470AAC348B`, and USA radio
- `910.525/62.5/SF7/CR5`. `ota self` reports SD apply ABI 3 with codec mask
- `0x5`; `ota bootloader` reports board `239A0071`, target `1150F50E`, name
- `TOWER_V2_OTA`, and capability byte `09`.
-- [x] A post-Pi-recovery identity-gated serial audit reconfirmed the exact
- Tower V2 model, Repeater role, build, `910.525/62.5/SF7/CR5`, target
- `0A9DBBF0`, body `E9282D47`, SD ABI 3/codecs `0x5`, and installed
- bootloader identity/capability `239A0071`/`09`. The card reported
- 959.6 MiB free and no active download. This profile exposes the local
- text console but deliberately has no runtime `usb.logging` setting; the
- unknown-setting reply is a capability result, not a transport failure.
-- [x] Card detection and destructive maintenance pass. Normal format took
- 3,927 ms; an immediate repeat was rejected by the five-minute cooldown;
- forced format took 3,933 ms; and raw erase plus format took 3,947 ms.
- The card remounted empty with 16.0 KiB used and 959.6 MiB free, and both
- completion ages were recorded. Automatic archive capture was then
- disabled deterministically, leaving only `/mota/cache.off`.
-- [ ] SD card detection, staging, hash validation, apply, cleanup, and recovery
- from missing/corrupt media pass.
-- [ ] Multi-page application and bootloader update paths pass across a cold boot.
-- [ ] LoRa OTA receive/apply reports the expected target ID and installed version.
-
-## Heltec V4
-
-- [x] Exact V4 hardware variant is reported (`Heltec V4.3 OLED`); OLED/TFT
- variants are not merged.
-- [x] Full Companion boots with USB logging off and Binary Companion selected.
- The text terminal independently reports the complete build string;
- protocol `ver` has a shorter fixed-width version field.
-- [ ] USB logging changes take effect immediately on this single-TTY ESP32
- profile, require no reboot, and return to Binary Companion only through
- the explicit terminal-stop mode change.
-- [x] Bluetooth pairing and Companion protocol operations pass. Qualification
- required removing the Pi's stale pre-flash bond, pairing through a BlueZ
- `KeyboardOnly` agent with the current displayed/reported PIN, then
- reconnecting; BLE `infos` and `ver` returned the exact V4.3 OLED identity.
-- [x] Saved Wi-Fi connects to `SlowFi`, returns at `192.168.1.51` after the test
- host reboot, reports Wi-Fi power save `min`, and remains governed by the
- configured-network behavior rather than the unconfigured 30-minute
- shutdown policy.
-- [x] TCP Binary Companion (`5000`), Wi-Fi OTA (`5001`), and text CLI (`5002`)
- are each tested and cannot be confused with one another. Port 5000
- returned the exact Companion identity; 5002 returned the full terminal,
- version, Wi-Fi, logging, RXPS, and memory state; and a real `motatool
- serve --tcp 192.168.1.51:5001 -v` session received `COUNT -> 0` from an
- intentionally empty folder. Disconnect then restored `folder:not
- connected` while the 5001 listener stayed active.
-- [ ] For a shared Full-Companion controller/seeder, bind the source identity
- to the exact public key already read from Binary Companion port 5000.
- Do not require `get public.key` from text port 5002: the hardware-tested
- V4 Full CLI rejects that command. Port 5002 must still prove the local
- TempRadio state/tuple, while keyed on-air destination replies prove the
- physical LoRa handoff.
-- [ ] V4 seeds direct and routed LoRa OTA; TempRadio preflight, apply, and
- automatic normal-radio restoration all pass.
-
-## Seeed SenseCAP Indicator LoRa
-
-- [x] Flashing selects the ESP32-S3 CH340 endpoint, never the Indicator RP2040
- ACM endpoint; esptool MAC probing succeeds before erase. The qualifying
- merged write used 115200 baud and completed with flash hash verification.
-- [x] A fresh merged install completes SPIFFS initialization and reports the
- intended Full Companion version/role and `Seeed SenseCAP Indicator`
- hardware identity. The exact qualifying artifact was
- `SenseCapIndicator-LoRa_companion_radio_full-1.17.1.5-halo-keymind-cascade-marathon-e26d48e4-merged.bin`,
- 2,070,152 bytes, SHA-256
- `df08b770313ca8776436456525681cf286cd9b5b40e05e7cf55f21ef097f338a`;
- its ELF, map, manifest, and partition signature are archived beside it on
- the Mercerwood Pi.
-- [x] The current dark-layout Full Companion application was flashed app-only
- with exact CH340/MAC/partition gates, preserving identity, Wi-Fi, and
- settings. The 2,147,800-byte application has SHA-256
- `81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b`;
- esptool verified its flash hash in 134.2 seconds. USB Binary/terminal,
- saved logging-off state, configured Wi-Fi, BLE, TCP ports 5000/5001/5002,
- and an empty-folder Wi-Fi mOTA `COUNT -> 0` exchange all pass afterward.
-- [x] Keep one identity-gated CH340 descriptor open with DTR/RTS deasserted
- through startup. On the valid-current-font path, the Indicator streams a
- 1,302,608-byte font from its RP2040 at 1 Mbps before registering the USB
- terminal, so allow at least 30 seconds before the first command. A
- missing/corrupt-font recovery instead runs in the background after the
- interfaces start. Reopening short probes can reset the ESP32 repeatedly
- and create a false UART-hang diagnosis; the controlled run returned
- `ver` and `board` after the same apparent-silence condition.
-- [x] Automatic font recovery starts from a physically corrupt RP asset. The
- setup deliberately installed 64 all-zero bytes, CRC32 `758d6336`, in
- place of `/meshcore/ui-font.vlw`; the ESP32 rejected that asset and kept
- its built-in fallback font while recovery ran.
-- [x] Every new HTTPS recovery attempt waits for a fresh SNTP callback and a
- signed epoch at or after the compiled asset publication time before it
- opens TLS. The forced-resume run observed fresh syncs in 3,700, 400, and
- 699 ms across three outer attempts, including two failed TLS handshakes;
- the final normal run synced in 1,999 ms and only then logged the first
- `api.github.com` connection. A retained merely plausible clock is not
- accepted. CA validation and the compiled asset SHA-256 remain mandatory.
-- [x] The release-source normal path downloaded all 1,302,608 bytes into PSRAM
- in 13,205 ms, closed TLS, verified SHA-256
- `61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421`,
- staged receiver-paced 512-byte `MCFONT STAGEV2` chunks in 36,676 ms,
- committed, and verified RP CRC32 `19f80d64` at 76.7 seconds from reset.
- An independent `MCFONT GET` over RP USB then returned exactly 1,302,608
- bytes with the same CRC and SHA in 15.273 seconds. The exact installed
- application is
- `SenseCapIndicator-LoRa_companion_radio_full-1.17.1.5-halo-keymind-cascade-marathon-font-recovery-e26d48e4.bin`;
- its log is
- `/home/mikec/hwtest/runs/indicator-release-recovery-lUZEmBHL/esp-serial.log`
- on Mercerwood. The current RP service reports protocol 2 and completed
- STAGEV2 accounting; its installed flash was not independently read back,
- so no RP artifact hash is inferred from the archived candidates.
-- [x] Strict Range recovery passed a forced hardware interruption. Exact test
- application SHA-256
- `cfd5aec497cb9919f87c0f82f5deaf8614d411582a709cea1c36275529c0bb68`
- closed the initial response at verified offset 1,048,576. Its first
- same-offset resume handshake failed; bounded retry 2/2 then accepted only
- the remaining 254,032-byte `206` response under the original strong
- ETag/`If-Range`, retained the same PSRAM buffer and SHA stream, completed
- the download in 49,422 ms, staged in 36,702 ms, committed, and passed an
- independent exact USB readback. The full log is
- `/home/mikec/hwtest/runs/indicator-range-v2-xbKr09wI/esp-serial.log`.
- An earlier forced 512 KiB run proved one correct resume at 524,288 but
- later exhausted its second reconnect on a TLS failure; it is retained as
- regression-trigger evidence rather than an end-to-end pass.
-- [x] Rebooting the exact release-source application with the current valid
- font performed no NTP query, TLS connection, or GitHub request. The
- valid-font boot log is
- `/home/mikec/hwtest/runs/indicator-release-valid-a84vd7Du/esp-serial.log`.
-- [x] The post-qualification shared SNTP-coordinator source compiles in the
- real Arduino-ESP32 2.x LoRa USB/WiFi profile (1,587,965 bytes, 60.6% of
- its OTA slot), the dynamically generated ESP-NOW Full profile (2,093,465
- bytes, 79.9%), and the matching RP2040 transactional service. After the
- VM independently reported `NTPSynchronized=yes`, an
- end-to-end request to the immutable GitHub asset returned HTTP 200,
- exactly 1,302,608 bytes, and SHA-256
- `61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421`.
- A live `524288-1302607` continuation then returned HTTP 206, the same
- strong ETag, an exact 778,320-byte `Content-Length`, and a byte-for-byte
- hash match against the checked-in font tail; the pinned Sectigo E46 root
- independently verified the current `api.github.com` chain.
- The same coordinator also compiles in the real
- `heltec_v4_repeater_observer_mqtt` profile: its 1,852,392-byte EndF image
- has SHA-256
- `fe8352654437f67488b412e1c1a690f2874e836abb5a5d209654bd030c713d7e`.
- MQTT's three process-global `configTime()` paths are lease-protected,
- and a busy asynchronous refresh uses a wrap-safe five-second retry
- deadline instead of overwriting another service's SNTP callback or
- recording a false successful refresh.
- The exact final LoRa Full artifact gate then passed 11/11 linked-image
- markers, including both `requesting fresh NTP time before download` and
- the subsequent TLS-open marker. Its 2,146,312-byte application has
- SHA-256
- `f557088a5ef3f49f74ab01ca95fefc76911cca4073f01b77cee4882284fc5110`
- and 475,128 bytes remain in its app slot; the matching 2,211,848-byte DIO
- merged image has SHA-256
- `52a4559774384b54dd7fb81da82c791e2c9564991fcf016bcad3b6ad756c1fb2`.
- The exact ELF, map, capability manifest, and partition signature are
- archived together under `/tmp/indicator-full-daily-ntp.WQ6ozM` on
- the VM pending transfer to the hardware-run archive.
- The ESP-NOW Full sibling passed the same 11/11 gate with a 2,093,928-byte
- application (527,512 bytes free), SHA-256
- `fcd9e0e8a37551a845a234fc10eaa4a9f420d54172039b9241f4696a12d4c65f`,
- and matching DIO merged-image SHA-256
- `f3637c23f58204f5b752b68a1bd494601cb68bee9c27b664832d40abd03cc186`;
- its complete archive is `/tmp/indicator-espnow-full-ntp-gate.YN9T8l`.
- Repeat the negative UDP/123-blocked hardware gate on this exact final
- source before treating the coordinator refinement as hardware-qualified.
-- [ ] Full Wi-Fi SSID, setup address, and related status text wrap without
- clipping on the physical display. The new dark palette and exclusive
- lower pairing block are installed; a fresh bond removal held the random
- PIN request on-screen for 30 seconds and then an immediate second attempt
- paired, trusted, and returned the exact Indicator identity over BLE. A
- human physical-readability confirmation is still required; the automated
- geometry tests prove the PIN block is centered at four-fifths height and
- cannot overlap IP, inbox, or status text.
-- [ ] Boot the exact current LoRa and ESP-NOW Full artifacts in both saved
- secondary-transport modes and capture the `Indicator render` line. Require
- LoRa + WiFi, LoRa + BLE, and ESP-NOW + infrastructure WiFi to retain
- 480x480, and ESP-NOW + BLE to select 320x320 scaled to the panel. A logged
- emergency 320x320 fallback keeps the unit recoverable but does not pass a
- native-480 gate.
-- [ ] For all four boots, record free heap and largest internal block before
- interfaces and after the selected interface is usable. In WiFi mode prove
- BLE never starts; in BLE mode prove infrastructure WiFi, WebConfig, MQTT,
- and TCP never start. Keep the primary LoRa or ESP-NOW radio exchanging
- traffic throughout. For both BLE modes pair/encrypt, negotiate MTU,
- subscribe, exchange repeated Companion requests, disconnect/reconnect,
- and repeat after bond removal; exercise network/TCP traffic in both WiFi
- modes and record the lowest heap/largest-block values.
-- [x] With no saved SSID, setup Wi-Fi powers down after the absolute 30-minute
- window even if queried or used.
-- [x] A software reboot starts a fresh unconfigured setup window.
-- [x] With a saved SSID, Wi-Fi reconnects to `SlowFi`, reports minimum modem
- power save, and remains governed by normal configured-network behavior.
-- [x] Bluetooth Companion pairing and protocol operations pass after saving a
- deterministic qualification PIN, removing any stale host bond, pairing,
- disconnecting the `bluetoothctl` client, and reconnecting with meshcli.
- Binary `infos`/`ver` returned the exact Indicator identity and USA tuple.
- A second connection after the USB-open reboot and logging-mode exercise
- also returned the exact build; one initial scanner miss was resolved by
- an explicit BlueZ discovery pass and was not a firmware advertisement
- failure.
-- [x] Wi-Fi/TCP Companion, CLI, and OTA endpoints pass at `192.168.1.54`:
- port 5000 returned Binary Companion identity, port 5002 returned the full
- text version/status, and motatool completed a port-5001 `COUNT -> 0`
- protocol exchange against an empty served folder.
-- [x] Fresh Full starts with USB logging off. On this single-TTY ESP32 profile,
- logging on/off applies and persists without a descriptor-changing reboot;
- the same held-open ASCII terminal reported off -> on -> off correctly,
- emitted live Wi-Fi/radio diagnostics while on, then accepted the explicit
- terminal-stop token and returned a valid 85-byte Binary Companion device
- info frame for `Seeed SenseCAP Indicator` without rebooting. Companion
- remained independently usable over Bluetooth and Wi-Fi.
-- [x] Normal-radio LoRa transmit and receive pass. At the installed 22 dBm,
- a co-located discovery request overloaded the nearby receivers and found
- zero nodes; temporarily reducing only the Indicator to 0 dBm produced
- three repeater replies at RSSI -29 to -59 dBm, proving both directions.
- The saved 22 dBm value was restored and independently read back after the
- test; this near-field result is a lab-power gate, not a firmware failure.
-- [ ] TempRadio sanity check, OTA seeding, and normal-radio restoration pass.
-
-## Cross-radio LoRa OTA acceptance
-
-- [ ] Run a three-minute reachability preflight before staging or changing any
- repeater radio settings.
-- [ ] Test direct, fully controlled multi-hop, passive TempRadio hop, and mixed
- controlled/passive paths independently.
-- [ ] Record bytes, LoRa packets sent, packets confirmed, retries, useful
- throughput, total elapsed time, and per-phase elapsed time.
-- [ ] Confirm every controlled intermediate returns to its saved radio tuple.
-- [ ] Verify target application and bootloader hashes after apply, not merely the
- OTA tool's final status line.
-- [ ] Exercise interruption/retry and confirm incomplete data cannot be approved
- or booted.
+This document is now in [Test Results](test-results/hardware_validation_checklist.md).
diff --git a/docs/home_text_spacing.md b/docs/home_text_spacing.md
index aaf43699a..f115a38ea 100644
--- a/docs/home_text_spacing.md
+++ b/docs/home_text_spacing.md
@@ -1,23 +1,5 @@
-# Home-screen text spacing
+# Document moved
-On non-Indicator companions, a visible Bluetooth PIN or connection status owns
-a separate bottom-aligned block. It replaces the ordinary inbox instruction,
-clock and WiFi rows while displayed. The PIN stays below the inbox title; the
-label and value use measured font heights, including the MeshPocket's larger
-e-paper font. An unusually short viewport may omit the label, never overlap
-the title or clip the PIN. Indicator's dedicated pairing layout is unchanged.
+
-The repeater home page also stacks measured font-height rows instead of fixed
-ten-pixel offsets. Long names and status lines are ellipsized horizontally.
-
-Run `python3 -m unittest discover -s test -p test_home_text_spacing.py -v` on a
-host with a C++ compiler. This renders the actual companion and repeater home
-branches, recording every text rectangle and rejecting overlaps or clipping.
-It covers 128x64 OLED, 160x80 T096, 250x122 MeshPocket and larger viewports,
-PIN/connected/disabled/prompt states, WiFi variants, long strings and a sweep
-of font heights. It also checks the static reader hint. The separate native
-display and T096 tests exercise the driver drawing/measurement methods.
-
-Hardware acceptance: check that the full six-digit PIN is near the bottom,
-with no text over it; connect/disconnect Bluetooth and verify no stale digits;
-then check the repeater's five rows and the V4's non-blinking reader footer.
+This document is now in [Test Results](test-results/home_text_spacing.md).
diff --git a/docs/index.md b/docs/index.md
index fa32d754e..785a1c97f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,8 +5,8 @@ Welcome to the MeshCore documentation.
Below are a few quick start guides.
- [1.17.1.8 USA Cascade release](./releases/1.17.1.8.md)
-- [1.17.1.5 Markdown audit](./releases/1.17.1.5-doc-audit.md)
-- [1.17.1.5 USA Cascade release](./releases/1.17.1.5.md)
+- [Test Results](./test-results/index.md)
+- [Old Releases](./old-releases/index.md)
- [Feature switches by role: Companion, Repeater, Room Server, Sensor](./role_feature_switches.md)
- [USB web console](https://flasher.meshcore.io/console)
- [Full Companion feature switches](./full_companion_features.md)
diff --git a/docs/mbedtls-tls-footprint.md b/docs/mbedtls-tls-footprint.md
index b9a426b37..3aa8f0fb4 100644
--- a/docs/mbedtls-tls-footprint.md
+++ b/docs/mbedtls-tls-footprint.md
@@ -1,183 +1,5 @@
-# Shrinking the per-connection TLS footprint on non-PSRAM observers
+# Document moved
-## Why
+
-On a non-PSRAM Heltec V3 running two WSS/JWT broker slots, the largest allocatable block in
-internal DRAM walks down in ~16 KiB steps at every TLS reconnect while total free heap stays
-flat. Measured on hardware over 50 reconnect cycles: 62,452 -> 16,372 bytes, permanently.
-
-The step size is not a coincidence. `framework-arduinoespressif32 3.20017` (Arduino 2.0.17,
-IDF 4.4) builds mbedTLS with the **symmetric** buffer configuration:
-
-```
-CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN 16384 # sizes BOTH the in and out record buffers
-CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE 1
-# CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN -- not defined
-# CONFIG_MBEDTLS_DYNAMIC_BUFFER -- not defined
-# CONFIG_MBEDTLS_SSL_VARIABLE_BUFFER_LENGTH -- not defined
-```
-
-Read from `packages/framework-arduinoespressif32/tools/sdk/esp32s3/qio_qspi/include/sdkconfig.h`.
-Note the separate `framework-arduinoespressif32-libs` package in `~/.platformio` belongs to
-the esp32c6 env (pioarduino, IDF 5.3) and is **not** what this env links against -- though it
-happens to carry identical mbedTLS values.
-
-So each broker slot costs **2 x 16 KiB = 32 KiB** of contiguous internal DRAM in record
-buffers alone, and two slots cost 64 KiB on a board with roughly 80 KiB free. Every reconnect
-frees and re-allocates those buffers, and anything that lands in the vacated hole in between
-prevents them from going back, which is the ratchet.
-
-Confirmed by two independent observations: losing a whole TLS session returned exactly 16,384
-bytes of contiguity on one device and exactly 32,768 on another, and per-connection teardown
-frees ~41.6-44.7 KB total.
-
-## What the firmware could already do, and its limit
-
-`softDisconnect()` (branch `perf/mqtt-renewal-no-stop`, commit `6c51e468`) stops the JWT
-renewal bounce from destroying and recreating the esp-mqtt task, keeping its 6 KiB stack out
-of the hole. Measured: the staircase arrests after 2 steps at 36,852 through cycle 16, where
-the baseline took 4 steps and settled at 16,372 by cycle 11 -- about 20 KB better.
-
-That is as far as the application layer reaches. MQTT 3.1.1 has no re-authentication packet,
-so presenting a fresh JWT *requires* a transport reconnect; mbedTLS's internal allocation
-order during the handshake is not controllable from the application. The remaining cost is
-the record buffers themselves.
-
-## The changes
-
-All three are compile-time in mbedTLS, and the Arduino framework ships precompiled `.a`
-archives (`tools/sdk/esp32s3/lib/libmbedtls.a`), so a project-level `-D` cannot change them.
-A custom framework build is required.
-
-| Setting | From | To | Saving per connection |
-|---|---|---|---|
-| `CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN` | unset | `y` | enables the two below |
-| `CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN` | 16384 (implied) | 16384 | none -- keep it |
-| `CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN` | 16384 (implied) | 4096 | **~12 KiB** |
-| `CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE` | `1` | `n` | ~4 KiB |
-
-Roughly 16 KiB per connection, 32 KiB across two slots.
-
-### Why inbound stays at 16 KiB
-
-A peer may legitimately send a 16 KiB TLS record. Lowering the inbound limit only works if
-both brokers negotiate the max-fragment-length extension or provably never send larger, and
-getting it wrong produces invalid-record and handshake failures rather than a clean error.
-Outbound is ours to choose: this firmware's MQTT and WebSocket frames are far below 4 KiB
-(`MAX_TRANS_UNIT`-bounded packets plus small JSON), so 4 KiB is comfortable.
-
-### Risk on the peer certificate
-
-Dropping `KEEP_PEER_CERTIFICATE` means `mbedtls_ssl_get_peer_cert()` returns NULL after the
-handshake. Chain validation still happens -- only retention of the parsed leaf changes. This
-firmware verifies against a CA (`GTS_ROOT_R4` / the bundle) and never inspects the peer
-certificate or a fingerprint, so it should be safe. It does change `mbedtls_ssl_session`
-layout, which is exactly why the whole framework must be rebuilt together rather than
-swapping a single archive in.
-
-## Build procedure
-
-### Do not use esp32-arduino-lib-builder for this
-
-`release/v4.4` is the branch matching Arduino 2.0.x, but its `update-components.sh` clones
-every dependency at **master**, so it no longer resolves. Five successive failures, each a
-different repo: the arduino branch name it passes to `-A` does not exist; `jq` is absent
-from the IDF image and its absence makes `build.sh`'s target loop a **silent no-op that
-still exits 0**; `esp_littlefs` and `esp32-camera` master require IDF >=5.0/>=5.1;
-`esp32-camera` later needs an `esp_jpeg` version the 4.4 registry cannot satisfy; and
-tinyusb's source layout no longer matches `arduino_tinyusb/CMakeLists.txt`. Pinning each
-one in turn just surfaces the next.
-
-### Rebuild only the mbedTLS archives
-
-More rigorous anyway, because it reuses the shipped `sdkconfig` verbatim -- so the archives
-differ from stock *only* by the intended change, with no arduino-version or
-`DYNAMIC_BUFFER` drift.
-
-This is ABI-safe for the content-length change specifically: `ssl.h` declares `in_buf` and
-`out_buf` as `unsigned char *`, allocated in `ssl_setup()`, and no public struct embeds a
-CONTENT_LEN-sized array. The other precompiled archives (esp-tls, esp_http_client,
-esp-mqtt) therefore remain compatible. **It is not safe for
-`CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE`**, which changes `mbedtls_ssl_session` layout --
-that one needs everything rebuilt together, so it is excluded here.
-
-1. Minimal IDF project whose only component requirement is `mbedtls`.
-2. `sdkconfig.defaults` = the shipped
- `packages/framework-arduinoespressif32/tools/sdk/esp32s3/sdkconfig`, with
- `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384` replaced by the three asymmetric lines.
- Verify the diff is exactly 5 lines before building.
-3. `docker run --rm -v $PWD:/project -w /project espressif/idf:v4.4.7 idf.py -DIDF_TARGET=esp32s3 build`
-4. Confirm `build/config/sdkconfig.h` carries `OUT_CONTENT_LEN 4096`.
-5. Stage the four archives under the framework's names -- note the rename:
-
- | built | framework name | stock size | rebuilt |
- |---|---|---:|---:|
- | `esp-idf/mbedtls/libmbedtls.a` | `libmbedtls.a` | 113,914 | 113,338 |
- | `esp-idf/mbedtls/mbedtls/library/libmbedtls.a` | **`libmbedtls_2.a`** | 1,252,150 | 1,245,718 |
- | `.../libmbedcrypto.a` | `libmbedcrypto.a` | 4,302,698 | 4,259,458 |
- | `.../libmbedx509.a` | `libmbedx509.a` | 676,590 | 673,318 |
-
- All within ~1% of stock, which is a good check that only the config differs.
-
-### Wire it in with -L, never platform_packages
-
-```
-PLATFORMIO_BUILD_FLAGS="-L/path/to/staged/archives" pio run -e Heltec_v3_repeater_observer_mqtt
-```
-
-Verify with `grep -oE "[^ ]*libmbed[a-z0-9_]*\.a" .pio/build//firmware.map | sort -u` --
-every path must be the staged directory.
-
-**Do not** point `platform_packages` at a `file://` copy of the framework. PlatformIO
-installs it *over* the shared `~/.platformio/packages/framework-arduinoespressif32`,
-silently changing mbedTLS for every other ESP32 env and project on the machine. It does this
-even when the copy's `package.json` version differs -- verified twice here, and both times the
-fix was `rm -rf` the package and `pio pkg install` to re-download stock. A prepended library
-search path keeps the change scoped to one env, because the linker takes each archive member
-from the first archive that satisfies an undefined symbol.
-
-### How the archives are distributed
-
-They are not committed: ~6 MB per architecture, and they have to be rebuilt for every
-`platformio/espressif32` bump, so committing them would grow history permanently and go
-stale silently. Instead they are published as a release asset and fetched on demand:
-
-```
-scripts/fetch_mbedtls_4k.sh esp32s3 # download + verify against the manifest
-MESHCORE_REDUCED_TLS=1 pio run -e Heltec_v3_repeater_observer_mqtt
-```
-
-- `scripts/mbedtls_4k_manifest.txt` — per-arch sha256 of each archive. **Update it on every
- platform bump**, together with the published asset.
-- `scripts/fetch_mbedtls_4k.sh` — downloads into `.mbedtls-4k//` (gitignored) and
- verifies. `MBEDTLS_4K_LOCAL=` copies from a local build tree instead of downloading.
-- `scripts/mbedtls_4k.py` — wired into `esp32_base.extra_scripts`, but a **no-op unless
- `MESHCORE_REDUCED_TLS=1`**, so ordinary builds need no artifact and behave as before.
-
-The opt-in path is deliberately loud, because both ways this can go wrong produce a
-firmware that looks correct and silently lacks the change:
-
-| failure | what happens without a guard | guard |
-|---|---|---|
-| directory missing or partial | linker ignores an unusable `-L` and resolves mbedTLS from the framework | pre-build: hard error |
-| archives stale after a platform bump | links the wrong build | pre-build: sha256 vs manifest |
-| `-L` present but outranked | framework archives win, flag is inert | post-link: `firmware.map` must resolve every `libmbed*.a` to `.mbedtls-4k/` |
-
-That last one is the reason the post-link check exists rather than trusting the flag: a
-build flag reaching the compiler proves nothing about what got linked.
-
-## How to verify it worked
-
-1. `strings`/`grep` the new `sdkconfig.h` for the four settings.
-2. Build and check the RAM figure; static usage should be unchanged (these are heap buffers).
-3. On hardware, `get mqtt.stats` at boot with two slots connected: the largest free block
- should start roughly 24-32 KiB higher than the current 62-67 KiB.
-4. Soak across reconnect cycles and compare the floor against the two recorded runs:
- baseline settled 16,372 (cycle 11); `softDisconnect` holds 36,852 (cycle 16).
-
-## Prior art in this investigation
-
-`.scratch/mqtt-non-psram-heap-staircase-analysis-2026-08-05.md` (untracked -- `.scratch/` is
-globally gitignored) holds the full allocation inventory. `~/mqtt-soak/STATE.md` holds the
-soak evidence, including two retracted hypotheses worth not repeating: the perf commits were
-not the cause, and waev does not cap connections per IP.
+This document is now in [Test Results](test-results/mbedtls-tls-footprint.md).
diff --git a/docs/mixed_scan_validation.md b/docs/mixed_scan_validation.md
index 26a9119f9..eaf5642f3 100644
--- a/docs/mixed_scan_validation.md
+++ b/docs/mixed_scan_validation.md
@@ -1,27 +1,5 @@
-# Mixed-bandwidth two-channel reception test
+# Document moved
-V4 transmitter on MercerWoodMesh; XIAO ESP32-S3/Wio SX1262 receiver. Channel 0: 909.5 MHz, SF7/62.5 kHz, preamble 32. Channel 1: 910.5 MHz, 500 kHz, SF and preamble below. Both use CR4/5, explicit header, CRC and 64-byte test payloads. Fast RX-preamble scanning uses four symbols of the active profile per visit, not four simultaneous channels or hardware CAD.
+
-Each setting starts with a five-packet-per-channel continuous-RX baseline. Scanning sends 50 packets per channel in shuffled order with random host delays. Only CRC-valid, payload-validated, sequence-matched packets count. Any scan loss escalates the 500 kHz preamble by 16, capped at 128. SF9 and SF8 start at 32; SF7 starts at 48. An additional SF8/40 test was requested after SF8/48; its firmware adds acceptance of preamble 40 without changing radio timing or scanning behavior.
-
-| 500 kHz SF | Preamble | Mode | SF7/62.5 received | 500 kHz received | Mean scan cycle |
-|---|---:|---|---:|---:|---:|
-| SF9 | 32 | Fixed baseline | 5/5 | 5/5 | — |
-| SF9 | 32 | Scan | 50/50 | 50/50 | 14.676 ms |
-| SF8 | 32 | Fixed baseline | 5/5 | 5/5 | — |
-| SF8 | 32 | Scan | 50/50 | 49/50 | 12.628 ms |
-| SF8 | 48 | Fixed baseline | 5/5 | 5/5 | — |
-| SF8 | 48 | Scan | 50/50 | 50/50 | 12.629 ms |
-| SF8 | 40 | Fixed baseline | 5/5 | 5/5 | — |
-| SF8 | 40 | Scan | 50/50 | 49/50 | 12.630 ms |
-| SF7 | 48 | Fixed baseline | 5/5 | 5/5 | — |
-| SF7 | 48 | Scan | 50/50 | 50/50 | 11.603 ms |
-
-These are strong-signal, sequential-transmission lab results. Successful reception of 50 packets is not proof of zero loss over months. The receiver remains on one channel through an entire detected packet, so overlapping traffic on the other channel can be lost. Scan-cycle measurements exclude time occupied by packet reception; USB command handling can add timing outliers.
-
-Machine-readable counts, timings and firmware/source hashes: [mixed_scan_results.json](../tools/hil/cad_scan/mixed_scan_results.json).
-
-After testing, both boards were restored to their original B soak firmware using
-app-only flashes. Version and normal radio settings were verified on both boards;
-Wi-Fi was connected and MQTT reported `custom (ok)`. Both persistent soak loggers
-were restarted in phase `B-post-CAD-soak`, marking the intentional uptime reset.
+This document is now in [Test Results](test-results/mixed_scan_validation.md).
diff --git a/docs/mixed_sf_bw_validation.md b/docs/mixed_sf_bw_validation.md
index 2c1a526dc..b3b682e7b 100644
--- a/docs/mixed_sf_bw_validation.md
+++ b/docs/mixed_sf_bw_validation.md
@@ -1,136 +1,5 @@
-# Equal-symbol-time mixed SF/BW, four-channel test
+# Document moved
-2026-09-14. **Promising, but no strict 400/400 pass.** With no added settling,
-the mixed scanner received 61 consecutive packets on the correct channels,
-then timed out on packet 62. The near-ceiling +22.4 ms setting received four
-packets and timed out on the fifth. There were no wrong-channel payloads in
-either run. Both stopped on the first miss without retries.
+
-## Configuration
-
-| Channel | Center (MHz) | SF | BW (kHz) | Symbol time (ms) |
-| --- | ---: | ---: | ---: | ---: |
-| 0 | 909.5 | 9 | 62.5 | 8.192 |
-| 1 | 910.5 | 10 | 125 | 8.192 |
-| 2 | 911.5 | 11 | 250 | 8.192 |
-| 3 | 912.5 | 12 | 500 | 8.192 |
-
-These preserve the preceding SF10/125 symbol duration exactly, rather than
-approximately: `symbol_us = 2^SF * 1000 / BW_kHz`. The supported SF/BW values
-and symbol-rate equation are specified in Semtech's
-[SX1261/2 datasheet, sections 6.1.1.1–2](https://cdn.sparkfun.com/assets/6/b/5/1/4/SX1262_datasheet.pdf).
-Equal symbol durations do not imply equal payload airtime or sensitivity.
-
-- RX: XIAO ESP32-S3 / Wio SX1262, COM31, MAC 28:84:85:B4:09:80.
-- TX: XIAO nRF52840 / Wio SX1262, COM15, serial B35E71C1C3726CE7.
-- Normal RX gain 0x94, -9 dBm TX chip request, CR4/5, 32-symbol preamble,
- 16-byte CHS1 packets, 1 MHz channel spacing. No extra IRQ tracing/polling.
-- 5.1-symbol visits = 41,780 us; programmed preamble = 262,144 us.
-- Double frequency write retained, warm XOSC, bulk SPI and fast RX restart.
- SF/BW changes on every hop, so **one 0x8B command is required on every hop**.
-- Goal per setting: 100 packets/channel, 400 total, strict channel identity.
- No relaxed off-channel acceptance and no automatic channel-count expansion.
-
-The lab scanner stages four channels through the existing two production
-profile slots; this does not change the public two-profile protocol.
-
-## Positive controls
-
-Before scanning, each profile received ten fixed-frequency probes after full
-normal initialization. **All four passed 10/10, forty total**. Zero frequency
-commands occurred after each setup; no stationary receiver hardware failure.
-These are fresh ten-packet controls, not claims of 100/100 on each new profile.
-
-Both devices acknowledge the same exact modulation table through `mixinfo`.
-Transmitter results verify SF, fractional BW, center, sequence and packet size.
-RX records verify center-command word and SF/BW at packet read. Full expected
-payload identity and CRC remain required. Mode/cache errors are treated as
-fixture failures rather than ordinary RF misses.
-
-## Timing controls and settling budget
-
-Six-second RX-only window at zero added delay: 141 hops, mean 637.922 us,
-maximum 828 us. The largest 100 us delay step with a 500 us per-hop reserve is
-`floor((262144/4 - 41780 - 828 - 500)/100)*100 = 22400 us`.
-
-The +22,400 us RX-only verification window measured 87 hops, mean 638.241 us,
-maximum 833 us. Nineteen complete idle cycles measured mean 259,292.789 us,
-maximum 259,310 us, below the 262,144 us preamble. Every hop had two observed
-frequency commands and one modulation command, with no mode/cache/RX errors.
-
-This is an empirical near-ceiling budget, not a guarantee of acquisition
-for every arrival phase. Longer packet holds and software jitter can defeat
-the idle-cycle calculation. RX is active throughout added settling.
-
-## Four-channel results
-
-| Added settling | Strict received / attempted before first miss | Failed TX profile | Mean switch (us) | Max switch (us) | Max idle cycle (us) |
-| --- | ---: | --- | ---: | ---: | ---: |
-| 22,400 us | 4 / 5 | Channel 2, SF11/250 | 660.951 | 892 | 259391 |
-| 0 us | 61 / 62 | Channel 0, SF9/62.5 | 674.819 | 899 | 169798 |
-
-Both failures were timeouts, with TX rc=0, RX mode=1, device errors=0.
-Both runs had zero mode/cache/receive errors and zero off-channel payloads.
-The +22.4 ms run exercised 163 hops, 326 frequency commands and 163 modulation
-commands. Zero-delay exercised 718 hops, 1,436 frequency commands and 718
-modulation commands. Optimized RX resume counts equaled hop counts.
-
-No-delay per-channel counts at stop:
-
-| Channel | Received | Attempted | Good-packet mean RSSI (dBm) | Good-packet mean SNR (dB) |
-| --- | ---: | ---: | ---: | ---: |
-| 0 | 15 | 16 | -34.00 | 11.15 |
-| 1 | 16 | 16 | -33.69 | 8.24 |
-| 2 | 15 | 15 | -34.33 | 5.78 |
-| 3 | 15 | 15 | -33.27 | 3.47 |
-
-The receiver stayed longer on some visits: maximum held visit 582,993 us
-with settling, 580,577 us without. Held-visit statistics include successful
-packet reception, so these maxima alone do **not** prove false off-channel
-preamble holds in this experiment. No IRQ trace was collected for the two
-failures; their precise mechanism is not established.
-
-The longer success sequence and absence of wrong-channel payloads are
-encouraging relative to the earlier uniform-SF/BW tests, but do not establish
-a statistically measured improvement or zero-loss reliability. Each setting
-was censored at first failure; do not present 61/62 as an unbiased measured
-packet-delivery rate or either run as a completed 400-packet test. The random
-arrival method is seeded, not RF-phase synchronized across configurations.
-
-## Implementation, verification and final state
-
-HIL-only `ProfileMixedChannels.h`, `basemixed`, `mixtx`, `scanmixed` and
-`profile_switch_mixed.py` add the bounded experiment. Uniform modes preserve
-their defaults. Post-hop checks use the actual per-channel modulation;
-the uniform collector and settling evidence checks reject mixed-mode results.
-Receive ownership, packet guards, BUSY and command-error handling remain intact.
-
-17 native profile tests and 48 HIL host tests passed. Tests cover fractional
-62.5 kHz, equal symbol duration, table identity, normal uniform mapping,
-required modulation writes, TX replay, and wrong-policy rejection. Both
-firmware builds and normal uploads succeeded. The nRF52 ZIP is application-
-only, SoftDevice ID 0x0123; no bootloader or SoftDevice replacement. RAK COM29
-was not opened. Production settings/source were not changed this turn.
-
-Both boards rebooted idle after the second failed setting, with test firmware
-retained. No autonomous TX, background collectors or restoration. Temporary
-PlatformIO overlay removed. No commit or push was requested. Existing unrelated
-working-tree changes remain intact. `git diff --check` passed apart from
-unrelated pre-existing CRLF warnings.
-
-## Provenance
-
-Raw capture: `tools/hil/profile_switch_mixed_equal_symbols_results.json`.
-It includes the four baselines, two timing controls and two strict scans,
-with `complete:true`, `stopped:both_delay_controls_failed`, and no fixture or
-cleanup errors. Earlier captures were not rewritten.
-
-- Raw SHA-256: `49de5c6415b1becdca02bc6e3c72d5e55127e92e758bbe3ac6c342c89e3723dd`
-- RX BIN SHA-256: `20d3008ef3d92610510748f64ac90c327f37c91d96cd3ac55272a918867fd5d0`
-- TX ZIP SHA-256: `915d507d0feec1712fad2bd4be62c2fa8d769d11400711de0da1cf98a50a4b36`
-- TX HEX SHA-256: `1225bcc70854a224f2862443d816b0e9216a253da87c42e96569a933204af941`
-
-RX build reported 94,844 bytes RAM / 367,537 bytes flash. TX reported
-18,220 / 95,908 bytes. Source is keymindCascade commit
-`0e5955f889788a4317863e731b38ffe09e3d5441` plus existing uncommitted work and
-these HIL changes; this is not a clean-release comparison.
+This document is now in [Test Results](test-results/mixed_sf_bw_validation.md).
diff --git a/docs/nimble_companion_trial.md b/docs/nimble_companion_trial.md
index ced29182e..d08410b78 100644
--- a/docs/nimble_companion_trial.md
+++ b/docs/nimble_companion_trial.md
@@ -1,133 +1,5 @@
-# ESP32-S3 NimBLE Full Companion trial
+# Document moved
-These optional builds use NimBLE-Arduino 2.5.1 with the existing ESP32 Arduino
-2.0.17 toolchain. They are hardware qualification builds, outside the normal
-release matrix and firmware picker.
+
-See the [2026-09-08 hardware results](nimble_companion_trial_results.md) for
-measured RAM, MAC-policy checks, WiFi updates, and remaining qualification.
-
-The [contact-cache guide](companion_contact_cache.md) adds six optional
-350-contact trials for ESP32 boards without PSRAM, including Heltec V3.
-Their [qualification results](companion_contact_cache_results.md) include
-linked RAM margins, V4/RAK hardware tests and the mOTA queue policy.
-
-| Hardware | Trial environment | Contacts | Channels | Offline queue |
-| --- | --- | ---: | ---: | ---: |
-| Heltec V4.2/V4.3 OLED, FEM on | `heltec_v4_2_v4_3_companion_radio_full_femon_nimble` | 350 | 40 | 512 |
-| XIAO ESP32-S3 with WIO SX1262 | `Xiao_S3_WIO_companion_radio_full_nimble` | 350 | 40 | 256 |
-
-The XIAO profile uses WIO radio pins CS 41, DIO1 39, BUSY 40 and RESET 42.
-It is not the generic XIAO profile for separately wired radio modules. Both
-boards retain their parent Full Companion features, USB mOTA sender, WiFi
-update slots, and PSRAM-backed offline queue. The V4 also retains direct MQTT.
-
-The [small-screen message layout](v4_pixel5_font_trial.md) uses 6-pixel capitals
-on 128x64 panels and 5-pixel capitals on very tiny panels. It includes a compact
-channel/sender line and full message previews. Font selection is automatic.
-
-## Build
-
-Enable the optional configuration in the ignored `platformio.local.ini`:
-
-```ini
-[platformio]
-extra_configs =
- variants/*/platformio.ini
- platformio.nimble.ini
-```
-
-If that file already contains local settings, merge this list with them. Run
-one PlatformIO/build.sh command at a time. Both commands below use USA Cascade:
-
-```sh
-OUTPUT_DIR=.releases/nimble-v4 bash build.sh build-firmware \
- heltec_v4_2_v4_3_companion_radio_full_femon_nimble \
- --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \
- --radio-preset usa-cascadia --profile cascade --standard --require-ota
-
-OUTPUT_DIR=.releases/nimble-xiao bash build.sh build-firmware \
- Xiao_S3_WIO_companion_radio_full_nimble \
- --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \
- --radio-preset usa-cascadia --profile cascade --standard --require-ota
-```
-
-Use the merged image at address 0 for a USB installation. The V4 uses 16 MB
-flash; the XIAO uses 8 MB. Both use DIO flash mode. Use the application-only
-`.bin` for an existing, matching WiFi OTA layout. Remove the optional config
-entry when finished to return to the ordinary release matrix.
-
-## Bluetooth identity and pairing
-
-All existing MAC settings remain available from the ASCII USB terminal and
-the app's authenticated local CLI. A setting takes effect after reboot.
-
-| Command | Behavior |
-| --- | --- |
-| `get bluetooth.mac` | Show the saved MAC policy. |
-| `set bluetooth.mac C2:17:15:04:00:01` | Use this custom random-static address. |
-| `set bluetooth.mac random` | Generate one address and retain it across boots. |
-| `set bluetooth.mac random-every-boot` | Generate a new address on each boot. |
-| `set bluetooth.mac random-after-connect` | Rotate on the next boot after an authenticated connection; otherwise retain the address. |
-| `set bluetooth.mac default` | Restore the factory Bluetooth address. |
-| `set bluetooth.stealth on` | Pair once, then accept the saved bonded peer. |
-| `set bluetooth.stealth off` | Restore ordinary discovery without changing the MAC policy. |
-| `get bluetooth.stealth` | Show pairing or bonded-peer-only state. |
-
-The `ble.mac` and `ble.stealth` aliases also work. A literal custom address must
-be a valid BLE random-static address (first byte C0-FF). Forget the old entry
-in the phone's Bluetooth settings and pair again after an address change or
-switching Bluetooth libraries. Existing Bluedroid bonds are not migrated.
-
-Stealth does not disable the selected rotation policy. If rotation changes
-the address on reboot, first pairing opens again for the new identity.
-
-Pairing requires encryption, PIN authentication and a stored bond. NimBLE's
-controller byte order is converted explicitly, so a custom address appears
-over the air in the order entered. Failed identity setup stops advertising
-and retries initialization instead of advertising the wrong address.
-
-## V4 WiFi setup QR
-
-The active WiFi setup page displays a compact QR code with a one-pixel white
-border. The normal open setup network fits a 44 x 44 pixel code, with 2 x 2
-pixel modules. The SSID, portal address and `HOLD STOP` hint remain beside it.
-Short-click to leave the page; hold the button on this page to start or stop
-the setup AP. From USB, `start webconfig ap` starts the same setup session.
-
-When the setup AP is inactive and the node has joined WiFi, this page shows
-`WIFI CONNECTED`, `IP `, `SETUP AP INACTIVE`, then `HOLD START AP` from
-top to bottom. The address appears once assigned and clears when the station
-disconnects. While the setup AP is active, the QR page shows the setup portal's
-address instead.
-
-## Regression checks
-
-```sh
-python3 -B test/test_nimble_companion.py
-python3 -B test/test_bluetooth_mac_contract.py
-python3 -B test/test_bluetooth_pairing_ui.py
-python3 -B test/test_heltec_v4_wifi_setup_page.py
-python3 -B test/test_firmware_ram.py
-python3 -B test/test_esp32_dram.py
-python3 -B test/test_shared_mota_queue.py
-python3 -B test/test_companion_mqtt_stack.py
-pio test -e native -f test_companion_node_prefs \
- -f test_ble_tx_stall_watchdog -f test_companion_frame_queue \
- -f test_display_driver
-```
-
-The adapter test compiles the actual transport under address/undefined-behavior
-sanitizers with API doubles. It injects allocation, identity and bond failures;
-checks PIN authentication and stealth; and exercises 176-byte frames, MTU and
-notification subscription gating, retry and duplicate prevention. Embedded
-builds compile against the pinned real library. RAM reports retain the existing
-wireless budget; linked headroom is not a measurement of live free heap.
-The MQTT save callback also has a compiler-enforced stack-frame regression
-check: nested preference copies previously overflowed the V4 loop task when
-saving MQTT settings through WebConfig, despite sufficient free heap.
-
-Hardware qualification must additionally verify pairing and reconnection,
-advertised addresses for every MAC policy, contact synchronization, WiFi/BLE
-coexistence, and uptime under load. BlueZ results do not establish iOS or
-Android camera/pairing compatibility.
+This document is now in [Test Results](test-results/nimble_companion_trial.md).
diff --git a/docs/nimble_companion_trial_results.md b/docs/nimble_companion_trial_results.md
index 4df36eb92..49c58cf74 100644
--- a/docs/nimble_companion_trial_results.md
+++ b/docs/nimble_companion_trial_results.md
@@ -1,117 +1,5 @@
-# ESP32-S3 NimBLE trial results, 2026-09-08
+# Document moved
-These are local hardware trial results for USA Cascade Full Companions on a
-Heltec V4.3 OLED and a Seeed XIAO ESP32-S3 with WIO SX1262. The V4.2 shares the
-profile but was not physically tested. Build and command instructions are in
-[the trial guide](nimble_companion_trial.md).
+
-The final source revision is `9dbab431`, using NimBLE-Arduino 2.5.1 and the
-existing Arduino-ESP32 2.0.17 toolchain. These optional profiles are outside the
-ordinary release matrix. This trial does not change GitHub release assets or
-the firmware picker.
-
-## Capacity
-
-| Measurement | Heltec V4 | XIAO S3 WIO |
-| --- | ---: | ---: |
-| Contacts / channels | 350 / 40 | 350 / 40 |
-| PSRAM-backed offline queue | 512 | 256 |
-| Application image, bytes | 1,774,600 | 1,510,536 |
-| OTA application slot, bytes | 6,553,600 | 3,342,336 |
-| Linked internal capacity before runtime allocation, bytes | 263,944 | 265,576 |
-| Required runtime RAM budget, bytes | 173,056 | 148,480 |
-| Headroom beyond that budget, bytes | 90,888 | 117,096 |
-| Live free heap with Bluetooth and 350 test contacts, bytes | 142,644 | 147,444 |
-| Minimum free heap during that contact test, bytes | 138,024 | 142,788 |
-
-The live contact-test readings are from revision `7f1bd8b1`. Revision
-`9dbab431` retains the same Bluetooth adapter, contact allocation and queue
-sizes, and fixes the MQTT save stack use described below. Linked capacity is
-not live free heap; the firmware memory reports label that distinction.
-
-A matching V4 build using Bluedroid had 248,840 bytes of linked internal
-capacity. NimBLE recovers 15,104 bytes at link time. The matching Bluedroid
-application was 2,169,848 bytes. There was no matching physical Bluedroid heap
-measurement, so these figures do not establish a live heap saving.
-
-## Bluetooth hardware checks
-
-Both boards passed the following cases with BlueZ on the Mercerwood Pi:
-
-- PIN authentication, encryption, stored bonding, and bonded reconnection.
-- MTU 179; accepted 176-byte writes and complete 148-byte contact notifications.
-- Insert and synchronize all 350 synthetic contacts without missing or
- duplicate entries.
-- Custom MAC address appears over the air in the entered byte order and
- persists across reboots.
-- Saved random MAC persists; random-every-boot changes on each boot.
-- Random-after-connect retains its address through an unauthenticated boot
- and rotates on the boot following an authenticated connection.
-- Stealth first pairing, bonded-peer reconnection, and reconnection after
- reboot with a stable identity.
-- Restore factory MAC and ordinary discovery.
-- Three reconnect samples with no observed downward free-heap trend.
-
-The complete MAC suite ran on `7f1bd8b1`. Final-image connection and update
-checks also verify the unchanged adapter after the MQTT stack fix. Synthetic
-contacts were removed after testing; factory MAC mode and stealth off were
-restored.
-
-## MQTT save stack overflow
-
-The V4 test reproduced an actual reset while saving MQTT settings through
-WebConfig. Serial output identified `Stack canary watchpoint triggered
-(loopTask)`. `onConfigBatchEnd()` kept a second 2.8 KB preference copy while
-the loader and NVS code also used stack space. Free heap was not the limiting
-resource.
-
-The loader already updates its destination only after validation, so the
-callback now passes the live preference object directly. The actual Xtensa
-callback frame shrank from 2,928 bytes to 32 bytes. The regression test
-compiles the real callback with a 512-byte frame ceiling and demonstrates that
-the previous nested-copy implementation fails the check.
-
-On the corrected `9dbab431` image, the 11-setting WebConfig batch completed
-with all entries accepted and no reboot. The V4 connected to a local
-Mosquitto broker, published MQTT traffic, and kept its authenticated Bluetooth
-session alive through a 30-second load interval with continuous uptime and
-zero reported error flags. Free heap measured 120,684 then 122,140 bytes;
-the minimum was 118,332 bytes and the largest free block was 110,580 bytes.
-Disabling the test MQTT slot also completed successfully without reboot.
-
-## WiFi updates and QR
-
-Both boards completed real application-image uploads over WiFi with Bluetooth
-connected, followed by reboot and reconnection using the saved bond. Repeated
-HTTP page requests and Bluetooth status requests ran together before upload.
-The image hash was checked before transmission, and the running firmware
-version was checked after reboot.
-
-The V4 WiFi setup page again contains a QR code. The default open setup SSID
-fits a 44 x 44 pixel code: 2 x 2 pixel modules and an exact one-pixel white
-border. The native test rendered the actual display/QRCode code and decoded
-the result with ZXing. SSID, portal address and the hold-to-stop hint fit
-beside it. Physical phone-camera scanning has not been verified.
-
-## Observed limits
-
-One XIAO warm reboot left its USB endpoint unresponsive while Bluetooth still
-answered. The user's physical unplug/replug restored USB; the later full MAC
-suite completed its commanded reboots successfully. The cause of that one
-USB failure is unconfirmed.
-
-The XIAO initially failed to associate with the saved WiFi network. Reapplying
-the SSID scheduled a fresh connection, after which it joined at -53 dBm and
-completed WiFi OTA. No antenna change was needed for that recovery.
-
-This was functional hardware testing, not a long-duration soak. iOS/Android
-app pairing, phone camera scanning, multiple TLS MQTT brokers, and an
-end-to-end LoRa mOTA transfer were not qualified in this trial. The XIAO parent
-profile does not include direct MQTT; the V4 does.
-
-## Final application hashes
-
-| Hardware | SHA-256 |
-| --- | --- |
-| V4 | `e6a1522ced4ed38ddd00e7cdf8393857224adbb622888a86370c0f7a3ee28d88` |
-| XIAO S3 WIO | `ecf7afe6222e3897556a04e022515f9dcfcb825172dae9eaaa72eb92396dd004` |
+This document is now in [Test Results](test-results/nimble_companion_trial_results.md).
diff --git a/docs/nrf52-usb-ready-fix.md b/docs/nrf52-usb-ready-fix.md
index 63e7cd6aa..7532aada4 100644
--- a/docs/nrf52-usb-ready-fix.md
+++ b/docs/nrf52-usb-ready-fix.md
@@ -1,46 +1,5 @@
-# nRF52 USB READY hang
+# Document moved
-The RAK3401 on Mercer was found stuck in the framework's TinyUSB power handler:
-USBD was enabled and attached, but its write-one-to-clear READY event was
-already consumed. The duplicate-event guard also required HFCLK to be running.
-If that guard missed the completed initialization, the following unbounded
-READY wait could stop application startup. A bootloader update alone cannot
-replace this USB driver in an already installed MeshCore application.
+
-The wait and guard were inherited through the pinned Adafruit nRF52 Arduino
-framework, not introduced by the recent OTAFIX upstream merge. TinyUSB's wait
-dates back at least to 2019; the guard was added in
-[upstream commit 7d9efd0697](https://github.com/hathach/tinyusb/commit/7d9efd06979f6cdde2a4093f0c26e8100312c92c).
-OTAFIX's August 24 HFCLK backport addressed a later clock wait, leaving this
-earlier READY wait uncovered. The exact live interrupt ordering is not known;
-the stalled program counter and consumed event were read directly over SWD.
-
-All nRF52 environments inherit `pre:scripts/nrf52_usb_power_fix.py`. It compiles
-a build-local replacement for the matching framework driver, without changing
-PlatformIO's shared SDK or affecting ESP32 builds. A changed/unrecognized
-framework implementation stops the build for review. The backport:
-
-- requests HFCLK before checking the consumed READY event;
-- rechecks attachment while waiting, including completion by another callback;
-- bounds both waits with one 100,000-poll budget, independent of OS ticks;
-- ignores stale READY on a disabled peripheral and exits if it is removed.
-- replays an already asserted regulator READY state when the application USB
- task starts with VBUS present, as the bootloader does. The framework's
- original startup replayed DETECTED only, so a missing later power edge could
- leave the new application's USB transport unattached after a UF2 handoff.
-
-The poll budget is an iteration limit, not a promised elapsed-time timeout.
-If the clock/peripheral never becomes ready, the caller returns instead of
-freezing the application. A later power event can retry; permanent electrical
-failure can still require reconnecting USB. This does not establish or fix the
-separate Pi hub/controller fault.
-
-Run `python3 -B test/test_nrf52_usb_power.py -v`. The tests compile the actual
-patched handler from the pinned-framework fixture, model W1C event semantics,
-and cover duplicate/nested callbacks, delayed/missing clocks and READY, removal,
-retry, and detached USB. Restoring the inherited READY prefix must reproduce
-the infinite wait under a subprocess deadline. Additional tests check SDK
-isolation, idempotence, fail-closed patching, the startup DETECTED/READY order,
-and all nRF52 environment hooks.
-The harness is shared with OTAFIX's TinyUSB fork at
-`test/otafix/nrf5x_power_test.py`; keep the two copies in sync when extending it.
+This document is now in [Test Results](test-results/nrf52-usb-ready-fix.md).
diff --git a/docs/nrf52_companion_storage_recovery.md b/docs/nrf52_companion_storage_recovery.md
index 8f302956f..07ed4bed1 100644
--- a/docs/nrf52_companion_storage_recovery.md
+++ b/docs/nrf52_companion_storage_recovery.md
@@ -1,68 +1,5 @@
-# nRF52 Companion automatic ExtraFS recovery
+# Document moved
-Companion builds using internal ExtraFS reserve 100 KiB at
-`0xD4000..0xED000`. Identity and preferences remain in the separate 28 KiB
-primary store at `0xED000..0xF4000`. This applies to internal-ExtraFS Companion
-builds, including Full, not repeaters or external QSPI storage.
+
-At startup the firmware:
-
-1. Mounts primary storage without erasing nonblank media. Only proven-blank
- primary storage may be initialized automatically.
-2. Checks the exact ExtraFS address, size, block geometry, and application
- linker reservation, then attempts a non-formatting secondary mount.
-3. Validates primary filesystem metadata before any destructive secondary
- recovery. An unavailable primary store blocks recovery and identity replacement.
-4. Keeps an already mounted, metadata-valid ExtraFS unchanged. If its initial
- mount or validation failed, unmounts and retries once without formatting.
-5. If the retry remains unusable, automatically formats only the reserved
- 100 KiB region, then remounts and validates it. There is one format attempt
- per boot; failure keeps the contact/channel write quarantine active.
-6. Runs the existing verified migration from primary into ExtraFS. Primary
- contact/channel sources are retired only after every destination is copied,
- read back, and the migration transaction is committed. Identity and
- preferences stay on primary.
-
-No terminal repair command, phone intervention, or bootloader update is needed
-for this recovery path when a compatible application/layout is already used.
-After successful recovery, the Companion storage API reports 100 KiB total
-rather than the 28 KiB primary fallback.
-
-## Data-loss boundary
-
-Automatic rebuilding prioritizes a usable contact/channel store over retaining
-an unreadable secondary filesystem. Data stored only in that damaged region
-can be lost. Surviving primary files are migrated, but may not contain the
-latest secondary-only changes. An API backup remains advisable before flashing.
-Primary storage is never formatted by this recovery path.
-
-This is mount/metadata recovery, not forensic file recovery. An invalid
-migration journal, unreadable individual contact page, primary-source error,
-full filesystem, or failed migration cleanup does **not** on its own trigger a
-format of a mountable, metadata-valid secondary store. Those cases retain the
-existing validation, retry, and incomplete-load protections.
-
-## Regression coverage
-
-`test/test_internal_secondary_fs_repair` exercises healthy/no-format startup,
-successful remount, failed remount/traversal followed by one repair, and
-format/remount/validation failure. `test/test_nrf52_extrafs_contract.py` checks
-primary-first ordering, internal-only geometry guards, quarantine handling,
-and the separation between migration failures and destructive recovery.
-
-## T1000-E hardware qualification (2026-09-03)
-
-A device reporting 25/28 KiB primary fallback and contact enumeration error 5
-was flashed with the recovery build. With no explicit repair command, startup
-activated 100 KiB ExtraFS and loaded all 125 contacts and 40 channel slots.
-Identity, preferences, radio settings, and channels matched the pre-update API
-snapshot. Add, edit, and delete of an owned temporary contact each survived a
-separate normal reboot; the device ended with the original 125 contacts and
-27/100 KiB usage. The 161 offline/native regression tests and target build passed.
-
-This was not lossless recovery of every historical RAM value: 21 contacts had
-older advertisement/local-modification timestamps, including one older advertised
-location. No contact was missing, and no computer-backup restoration was used.
-A subsequent read-only scan found no readable cached advert for any of the 125
-contacts. Without a surviving newer copy, firmware cannot infer those earlier
-values; receiving new verified advertisements can refresh them normally.
+This document is now in [Test Results](test-results/nrf52_companion_storage_recovery.md).
diff --git a/docs/old-releases/1.17.1.5-doc-audit.md b/docs/old-releases/1.17.1.5-doc-audit.md
new file mode 100644
index 000000000..c539e0ae0
--- /dev/null
+++ b/docs/old-releases/1.17.1.5-doc-audit.md
@@ -0,0 +1,123 @@
+# Markdown audit — 1.17.1.5 USA Cascade
+
+Audited on 2026-09-05 against firmware source `26303793`, the resolved build
+configuration, and the qualified 1.17.1.5 staging manifests. The scope was all
+**71 tracked Markdown files** present at the start, including root documents,
+docs, examples, tools, tests, variants, and vendored library documentation.
+Generated/ignored build directories and external repositories were outside that scope.
+
+## Corrections
+
+- Added [role-specific feature switches](../role_feature_switches.md), exact
+ Companion/infrastructure MQTT and logging controls, board exceptions, SNMP,
+ and the [USB web console](https://flasher.meshcore.io/console).
+- Added an [exact-name variant map](1.17.1.5-variant-map.tsv); every one of its
+ 1,325 covered entries resolves to a staged firmware file. The 36 excluded
+ entries are identified rather than linked to substitute hardware.
+- Corrected universal 254-neighbor claims, Full Companion MQTT filename/CLI
+ assumptions, TLora Full WebConfig availability, and partition/NVS guidance.
+- Documented the power-saving guards present at audit time; current instructions
+ now use the shared `get powersaving` / `set powersaving on|off` command set.
+ Corrected the RS232 baud range and SNMP environment list.
+- Linked OTAFIX 2.4.6 and its board/storage requirements from operator guides;
+ aligned the two manual USA OTA quickstarts with 910.525 MHz.
+- Marked old provider catalogs and dated hardware results as historical;
+ documented the legacy tag workflow that also matches `repeater-room-*`.
+- Fixed the vendored MQTT documentation link and documentation-site links
+ that incorrectly treated repository source files as generated site pages.
+
+## Verification and limits
+
+All original Markdown files were scanned for relative links, section anchors,
+source-path references, version/default claims, and role/OTA terminology.
+Flagged operator instructions were checked against CommonCLI, its observer
+extension, Companion command handlers, board configuration, and release manifests.
+Brace-expanded source references and SDK-relative paths were retained as such.
+Historical measurements, changelogs, upstream findings, contribution/security
+policies, and licenses were not rewritten as new validation results.
+
+The documentation site passed a strict MkDocs Material build. Relative repository
+links and section anchors were checked separately, including documents outside
+the MkDocs source directory. Console and current bootloader links were checked
+on their primary sites. This was not a crawl of every third-party URL or a
+new hardware/firmware qualification; no claim is made that every board was
+physically tested or every protocol statement independently re-proved.
+
+## Original file coverage
+
+“Retained” means no correction was identified by this documentation audit;
+it does not certify all historical statements as current hardware behavior.
+
+| File | Disposition |
+| --- | --- |
+| [AGENTS.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/AGENTS.md) | Scanned; retained |
+| [ALERTS.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md) | Scanned; retained |
+| [CHANGELOG.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/CHANGELOG.md) | Historical record retained |
+| [CONTRIBUTING.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/CONTRIBUTING.md) | Scanned; retained |
+| [MEMORY_MONITORING.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MEMORY_MONITORING.md) | Scanned; retained |
+| [MQTT_IMPLEMENTATION.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md) | Corrected or clarified |
+| [MQTT_INTERNALS.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_INTERNALS.md) | Corrected or clarified |
+| [MQTT_OWNERSHIP.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_OWNERSHIP.md) | Historical record retained |
+| [MQTT_SNMP.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md) | Corrected or clarified |
+| [README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/README.md) | Corrected or clarified |
+| [RELEASE.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/RELEASE.md) | Corrected or clarified |
+| [RESTORE_UPSTREAM_NOTES.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/RESTORE_UPSTREAM_NOTES.md) | Historical record retained |
+| [SECURITY.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/SECURITY.md) | Scanned; retained |
+| [STABILITY_TESTABILITY_HANDOFF.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/STABILITY_TESTABILITY_HANDOFF.md) | Historical record retained |
+| [UPSTREAM_BUGS.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/UPSTREAM_BUGS.md) | Scanned; retained |
+| [WEB_CONFIG_REVIEW.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/WEB_CONFIG_REVIEW.md) | Corrected or clarified |
+| [arch/stm32/Adafruit_LittleFS_stm32/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/arch/stm32/Adafruit_LittleFS_stm32/README.md) | Vendored documentation/license retained |
+| [arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/LICENSE.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/LICENSE.md) | Vendored documentation/license retained |
+| [arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/README.md) | Vendored documentation/license retained |
+| [docs/WiFi.md](../WiFi.md) | Corrected or clarified |
+| [docs/cli_build_matrix.md](../cli_build_matrix.md) | Corrected or clarified |
+| [docs/cli_command_availability.md](../cli_command_availability.md) | Corrected or clarified |
+| [docs/cli_commands.md](../cli_commands.md) | Corrected or clarified |
+| [docs/companion_offline_queue.md](../companion_offline_queue.md) | Scanned; retained |
+| [docs/companion_protocol.md](../companion_protocol.md) | Scanned; retained |
+| [docs/companion_radio_full.md](../companion_radio_full.md) | Corrected or clarified |
+| [docs/docs.md](../docs.md) | Scanned; retained |
+| [docs/esp32_memory_budget.md](../esp32_memory_budget.md) | Scanned; retained |
+| [docs/faq.md](../faq.md) | Corrected or clarified |
+| [docs/filter_tool.md](../filter_tool.md) | Scanned; retained |
+| [docs/firmware_picker.md](../firmware_picker.md) | Corrected or clarified |
+| [docs/flood_filtering.md](../flood_filtering.md) | Scanned; retained |
+| [docs/full_companion_features.md](../full_companion_features.md) | Corrected or clarified |
+| [docs/full_companion_usb_switcher.md](../full_companion_usb_switcher.md) | Corrected or clarified |
+| [docs/gps_tracking.md](../gps_tracking.md) | Scanned; retained |
+| [docs/halo_keymind_settings.md](../halo_keymind_settings.md) | Corrected or clarified |
+| [docs/hardware_validation_checklist.md](../test-results/hardware_validation_checklist.md) | Corrected or clarified |
+| [docs/host_cli_service.md](../host_cli_service.md) | Scanned; retained |
+| [docs/index.md](../index.md) | Corrected or clarified |
+| [docs/kiss_modem_protocol.md](../kiss_modem_protocol.md) | Scanned; retained |
+| [docs/lora_ota_automation.md](../lora_ota_automation.md) | Corrected or clarified |
+| [docs/mbedtls-tls-footprint.md](../test-results/mbedtls-tls-footprint.md) | Scanned; retained |
+| [docs/nrf52_companion_storage_recovery.md](../test-results/nrf52_companion_storage_recovery.md) | Scanned; retained |
+| [docs/nrf52_power_management.md](../nrf52_power_management.md) | Scanned; retained |
+| [docs/number_allocations.md](../number_allocations.md) | Scanned; retained |
+| [docs/ota_easy.md](../ota_easy.md) | Corrected or clarified |
+| [docs/ota_meshtower_v2_sdcard.md](../ota_meshtower_v2_sdcard.md) | Corrected or clarified |
+| [docs/ota_nrf52_bootloader_update.md](../ota_nrf52_bootloader_update.md) | Corrected or clarified |
+| [docs/ota_nrf52_qspi.md](../ota_nrf52_qspi.md) | Corrected or clarified |
+| [docs/ota_protocol.md](../ota_protocol.md) | Corrected or clarified |
+| [docs/ota_user_guide.md](../ota_user_guide.md) | Corrected or clarified |
+| [docs/packet_format.md](../packet_format.md) | Scanned; retained |
+| [docs/payloads.md](../payloads.md) | Scanned; retained |
+| [docs/qr_codes.md](../qr_codes.md) | Scanned; retained |
+| [docs/rak3401_mota_chain.md](../rak3401_mota_chain.md) | Historical record retained |
+| [docs/releases/1.17.1.5.md](1.17.1.5.md) | Corrected or clarified |
+| [docs/spiffs_regular_file_reads.md](../test-results/spiffs_regular_file_reads.md) | Scanned; retained |
+| [docs/stats_binary_frames.md](../stats_binary_frames.md) | Scanned; retained |
+| [docs/telemetry_decoder.md](../telemetry_decoder.md) | Scanned; retained |
+| [docs/terminal_chat_cli.md](../terminal_chat_cli.md) | Scanned; retained |
+| [docs/usb_serial_backpressure.md](../test-results/usb_serial_backpressure.md) | Scanned; retained |
+| [examples/host_cli_service/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/examples/host_cli_service/README.md) | Scanned; retained |
+| [lib/PsychicMqttClient/CHANGELOG.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/lib/PsychicMqttClient/CHANGELOG.md) | Vendored documentation/license retained |
+| [lib/PsychicMqttClient/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/lib/PsychicMqttClient/README.md) | Corrected or clarified |
+| [mesh-america/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/mesh-america/README.md) | Corrected or clarified |
+| [test/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/test/README.md) | Scanned; retained |
+| [test/test_mqtt_prefs_codec/FIXTURES.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/test/test_mqtt_prefs_codec/FIXTURES.md) | Historical record retained |
+| [tools/mota/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/mota/README.md) | Scanned; retained |
+| [tools/sensecap_indicator_font/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/sensecap_indicator_font/README.md) | Scanned; retained |
+| [tools/sensecap_indicator_rp2040/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/sensecap_indicator_rp2040/README.md) | Scanned; retained |
+| [variants/sensecap_indicator-espnow/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/variants/sensecap_indicator-espnow/README.md) | Scanned; retained |
diff --git a/docs/releases/1.17.1.5-variant-map.tsv b/docs/old-releases/1.17.1.5-variant-map.tsv
similarity index 100%
rename from docs/releases/1.17.1.5-variant-map.tsv
rename to docs/old-releases/1.17.1.5-variant-map.tsv
diff --git a/docs/old-releases/1.17.1.5.md b/docs/old-releases/1.17.1.5.md
new file mode 100644
index 000000000..c36859bff
--- /dev/null
+++ b/docs/old-releases/1.17.1.5.md
@@ -0,0 +1,333 @@
+# MeshCore 1.17.1.5 Dev - USA Cascade
+
+Use [feature switches by role](../role_feature_switches.md) for exact on/off
+commands for Companion, Repeater, Room Server, and Sensor, including MQTT,
+logging, power saving, GPS, and board exceptions. Open the
+[USB web console](https://flasher.meshcore.io/console) for the default ASCII
+terminal on Full Companion and infrastructure images.
+
+The firmware identifier and main release tag follow
+`v1.17.1.5-halo-keymind-cascade-dev-`, matching the 1.17.1.4 naming
+convention. Supplemental pages retain the `repeater-room-`, `utility-`,
+`logging-`, `lora-ota-`, and `full-profiles-` prefixes where those outputs
+exist. The Companion page is marked Latest; the supplemental pages remain
+development prereleases.
+
+This release uses the USA/Canada radio preset and Cascade runtime defaults.
+The release build records the resolved frequency, bandwidth, spreading factor,
+and coding rate. The offline USA Cascadia fallback is 910.525 MHz, BW62.5,
+SF7, CR5. Saved node settings survive ordinary firmware updates.
+
+## Memory corrections in `aa20e927`
+
+All 583 published profiles (the 540-profile matrix plus 43 supplemental Full
+repeaters) were checked against the new
+[runtime RAM policy](../firmware_memory_budget.md). **All 11 failing profiles
+have replacements**, and the same queue sharing has been applied to the other
+41 nRF52 Full profiles: **52 corrected profiles in total**, initially built from
+[`aa20e927`](https://github.com/mikecarper/MeshCore/tree/aa20e9278ebdd574c4769fdb58d46fd9d4face68).
+The existing release tags stay in place; corrected filenames end in `aa20e927`,
+except Wireless Paper Full's capacity-restoring follow-up, `1e4d1e16`.
+Companion remains Latest. Both Mesh America catalogs and the firmware picker
+point to the replacement files.
+
+All 43 nRF52 Full Companions keep 256 offline frames normally and lend 128
+slots to mOTA while needed. Wireless Paper Full now also shares its queue and
+keeps **350 contacts and 40 channels**. The other six corrected ESP32 Full
+profiles below use **150 contacts**, preserving their 256-frame queue and Full transports.
+**Export contacts before upgrading if you have more than 150**; entries beyond
+that limit may be unavailable and a later save may omit them. This warning
+applies to those six profiles and the superseded `aa20e927` Wireless Paper image.
+
+Wireless Paper Full's
+[`1e4d1e16` update](https://github.com/mikecarper/MeshCore/tree/1e4d1e167a984abee34e92288b44d55f3d0e15a6)
+has 256 message slots normally and 128 while an mOTA session borrows its
+workspace. Idle WiFi listening leaves all 256 slots available. If more than
+128 unread messages are queued, sync them with an app before starting mOTA.
+Source stop/disconnect restores all 256 slots and frees ESP32 scratch buffers.
+USB, Bluetooth, WiFi, the display and the partition layout are retained.
+The application is 1,869,976 bytes; use its app-only `.bin` for a compatible
+WiFi update, or its merged image for USB installation.
+
+The T096 and T1 LoRa OTA repeater corrections allocate the diagnostic manual
+staging buffer only when requested, recovering about 4 KiB at startup.
+Ordinary mOTA uses its existing staging store. `ota dev clear` releases a manual
+stage; allocation failure reports an error before writing. These same-version
+repairs require an explicit exact-target manual LoRa installation or USB;
+forward-only trusted autoinstall does not select an equal-version repair.
+
+Available RAM below means capacity **before startup allocations**, not free
+heap after boot. The Wireless Paper row includes its latest `1e4d1e16` update:
+
+| Failing original profile | Original bytes | Replacement bytes | Required bytes |
+| --- | ---: | ---: | ---: |
+| `Generic_ESPNOW_companion_radio_full` | 101,920 | 139,456 | 136,192 |
+| `Heltec_Wireless_Paper_companion_radio_full` | 150,144 | 161,336 | 152,576 |
+| `Heltec_Wireless_Tracker_companion_radio_full` | 148,496 | 186,032 | 169,986 |
+| `Heltec_ct62_companion_radio_full` | 99,104 | 136,640 | 136,192 |
+| `Heltec_t096_companion_radio_full_femon` | 54,724 | 74,060 | 73,728 |
+| `Heltec_t1_companion_radio_full` | 55,044 | 74,380 | 73,728 |
+| `Heltec_v3_companion_radio_full` | 143,840 | 181,376 | 173,056 |
+| `Xiao_C3_companion_radio_full` | 98,880 | 136,416 | 136,192 |
+| `heltec_tracker_v2_companion_radio_full_femon` | 133,136 | 170,672 | 169,986 |
+| `Heltec_t096_repeater_lora_ota_no_external_sensors` | 61,588 | 65,676 | 65,538 |
+| `Heltec_t1_repeater_lora_ota_no_external_sensors` | 61,964 | 66,052 | 65,538 |
+
+Each replacement has a matching `.memory.json` with its ELF hash, RAM budget
+and firmware-file hashes. The
+[complete audit](https://github.com/mikecarper/MeshCore/releases/download/v1.17.1.5-halo-keymind-cascade-dev-26303793/MEMORY-AUDIT-1.17.1.5.json)
+distinguishes original binary/log calculations from new ELF checks.
+All 52 exact firmware builds, their native file pairs, and 150 native mOTA
+tests passed, alongside memory, allocation-failure, shared-queue and picker
+regressions. Wireless Paper's follow-up also passed complete TCP mOTA transfers
+with address/leak sanitizers, repeated disconnects, queue-full refusal, idle
+listening and USB/TCP ownership changes. Physical boot/load/soak validation
+remains pending.
+
+## G3 USB disappearance with power saving enabled
+
+The published `26303793` firmware has an ESP32 native-USB sleep bug that
+matches a Station G3 report: USB disappears from the connected Pi after a
+few minutes, the display button stops responding, but the repeater still
+answers over LoRa. Hardware confirmation of this report is pending.
+
+Cascade enables device power saving by default on fresh settings. After
+two minutes, the repeater can enter light sleep when no serial terminal is
+open, even though the Pi has enumerated the USB device. Light sleep stops
+USB service; the radio GPIO can still wake the CPU, while the G3 display
+button is only polled. Closing a terminal must not be treated as unplugging
+the USB host. Espressif documents the underlying
+[native USB sleep limitation](https://docs.espressif.com/projects/esp-idf/en/v4.4.7/esp32s3/api-guides/usb-otg-console.html#limitations).
+
+To enable **USB logging on an ESP32 1.17.1.5 image with logging support**,
+disable power saving first. Use the role's text terminal, or the repeater's
+remote admin CLI if USB has already disappeared:
+
+```text
+set powersaving off
+set usb.logging on
+get powersaving
+get usb.logging
+```
+
+The read-back commands should report power saving `off` and USB logging `on`.
+For an image without USB logging, use just `set powersaving off` and `get powersaving`
+to apply/check the sleep workaround. Disabling power saving saves the setting and takes
+effect immediately; it does not erase the node or change LoRa RX power
+saving (`radio.rxps`). If USB does not reappear after disabling sleep
+remotely, power-cycle once and check that the port and display button remain
+usable beyond the two-minute mark. Ordinary reflashing can retain the
+saved power-saving setting, so check it during comparisons with another
+firmware build too.
+
+On unified Full infrastructure, also run `set powersaving off` before enabling
+USB through `set logging.output usb` or `set logging.output both`. These are
+separate commands. Turning logging off does not automatically restore the
+saved power-saving setting. nRF52 logging does not need this ESP32 workaround.
+
+**WiFi/MQTT-only logging does not need `set powersaving off` while the
+Repeater/Room Server MQTT bridge is running.** The original `26303793` code
+already prevents device sleep in that state. Select `set logging.output wifi`
+where supported and check `get mqtt.running`; `get mqtt.enabled` alone
+only checks the saved preference. MQTT-capable Full Companion now uses the
+same `mqtt.enabled` and `logging.output` commands as infrastructure. Disabling
+MQTT preserves broker settings; the MQTT tab exposes the same master switch.
+
+The source correction keeps native USB serviced while a host is enumerated,
+including when its terminal is closed. Enabled live USB logging also blocks
+ESP32 light sleep, even without a host. FreeRTOS can still idle/yield; this
+does not disable the saved power-saving setting or Companion CPU scaling.
+With logging off, USB power from a charger still permits sleep.
+
+The corrected G3 uses its user button as a light-sleep wake source alongside
+LoRa. Button activity keeps the CPU and display awake for at least two minutes;
+holding or pressing the button again extends the interval. The usual display
+timeout can keep the screen on longer. An intentional long-press power-off
+retains its existing action. After the wake interval, sleep is allowed when
+power saving is enabled, logging is off, no native USB host is attached, and
+no running bridge or pending work needs service.
+
+Host simulations cover both native USB modes, terminal close/reconnect,
+logging toggles, OTA inhibition, simultaneous button/LoRa wake configuration,
+and the two-minute interval across timer rollover. The Station G3 repeater
+download remains the original `26303793` build; continue to use the logging
+workaround above on that image.
+
+## T096 Full Companion Bluetooth and menu freeze report
+
+A report against `Heltec_t096_companion_radio_full_femon` describes no usable
+Bluetooth despite a displayed PIN, followed by a freeze while stepping through
+the device menu. Reflashing both with and without a full wipe gave the same
+result. The exact freeze and Bluetooth behavior still need confirmation on a
+physical T096 with the source correction below.
+
+The published `26303793` Full image leaves only **54,724 bytes (53.4 KiB)**
+between the linked heap boundaries. Its static RAM check did not account for
+the allocations made at startup: the 160x80 color framebuffer alone needs
+25,602 bytes, followed by task stacks, the packet pool, filesystem buffers,
+and message previews. This leaves inadequate runtime headroom. The pinned
+Bluefruit library also ignores failure to create its BLE/SOC worker tasks,
+and the sensor menu could write through a failed CayenneLPP allocation. Those
+are code paths consistent with this report; a PIN is not proof of a working
+Bluetooth radio.
+
+The correction applies to **every nRF52 Full Companion**, keeping its offline
+queue at **256 frames
+during normal use**. mOTA borrows the upper 128 slots for its roughly 19 KiB
+session state only when needed, leaving 128 message slots during mOTA use.
+Starting a source or a TempRadio discovery session acquires this workspace.
+Stopping or disconnecting the USB/Bluetooth source restores all 256 slots,
+even if the temporary radio window is still open. A discovery-only session
+returns the slots when its TempRadio window ends. No heap resizing is involved.
+
+Unread messages keep their order across both transitions. If more than 128
+frames are pending, mOTA refuses to start and asks you to **sync unread
+messages with a Companion app first**; it does not discard them to make room.
+OTA configuration and signer keys survive reuse of the workspace. While mOTA
+owns it, the normal queue overflow policy applies to the remaining 128 slots.
+
+The T096 profile requires at least 72 KiB of heap space at link time. It retains
+350 contacts, 40 channels, the color display, sensors, USB, Bluetooth,
+Bluetooth DFU, and USB/Bluetooth mOTA sending. This reserve is space for
+runtime allocations, not a measurement of free heap after boot.
+Bluetooth startup now checks both worker tasks and all registered services;
+a failed partial initialization does not repeat every five seconds. The
+sensor page reports low memory if its buffer could not be allocated.
+
+Regression tests transfer complete mOTA containers through the actual shared
+context, check queue wraparound and message order, and cover source stop,
+disconnect, conflicting USB/Bluetooth ownership and repeated sessions. They
+also inject worker-task and GATT-service failures, verify that retries do not
+initialize the stack again, open the sensor path with a failed allocation,
+and use the real linker assertion to reject the old heap budget.
+The original T096 Full download has been replaced by the corrected `aa20e927`
+image on the same release page. Another wipe is not a fix for the old compiled
+memory budget.
+
+For the corrected T096 build, use its matching application
+UF2 through the existing USB bootloader. Preserve the bootloader and node data.
+Check Bluetooth discovery, pairing and message exchange on battery power and
+with USB attached; then step through every menu page, return from screen-off,
+and check USB and Bluetooth mOTA sending. Hardware validation is pending.
+
+## Firmware roles and update support
+
+Use the exact board's Full Companion image when available. Every Full
+Companion must pass linked-firmware checks for MOTA sending, its text terminal,
+and the platform's source transports. ESP32 supplies USB and WiFi sources;
+nRF52 supplies USB and encrypted Bluetooth sources. Full Companions remain
+USB-updateable and do not require LoRa self-installation. Optional ESP32
+WiFi self-update uses port 8080 where the existing partition layout has two
+application slots; nRF52 retains Bluetooth DFU with a compatible bootloader.
+
+Read [Full Companion feature switches](../full_companion_features.md) for
+on/off commands, USB logging, power saving, WiFi/Bluetooth selection, GPS,
+MQTT, MOTA sending, and update directions.
+
+Infrastructure artifacts must have a verified wireless self-update path.
+Option 3 reports and omits infrastructure on platforms without a wireless
+updater. The report is `ota-excluded-targets.txt`. Source/seeder capability
+alone does not count as self-update support. ESP32 qualification checks both
+application slots and OTA metadata; nRF52 Bluetooth qualification checks the
+linked DFU service and its application ZIP. LoRa installation on nRF52 still
+requires the exact matching OTAFIX bootloader and storage arrangement.
+
+Classic ESP32 capacity limits are recorded in each artifact's capability
+manifest. Generic E22, Heltec V2, Meshadventurer, and T-Beam OTA repeaters,
+plus the TLora MQTT repeater, use 50 neighbors where needed to preserve the
+required internal RAM reserve.
+T-Beam MQTT repeater profiles retain the full rule engine with 31 flood rules;
+constrained classic ESP32 ESP-NOW bridges with logging use 47.
+Meshadventurer Full uses 100 contacts, 30 group channels, and a 16-frame
+offline queue while retaining its MOTA and transport features.
+
+Since 1.17.1.4, this branch also includes USB session and contact-persistence
+fixes, automatic nRF52 ExtraFS recovery, improved Indicator setup/display
+handling, compressed MOTA transport, and measured ESP32 memory limits.
+Qualified internal-flash nRF52840 receivers now reserve 64 KiB of retained
+RAM to extend application MOTA staging beyond the available flash prefix.
+This handoff requires the exact board's
+[OTAFIX 2.4.6 bootloader](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.11.0-OTAFIX2.4.6).
+Install that bootloader before relying on LoRa updates from these new hybrid
+receiver images: they reject application containers larger than one flash
+page when the installed bootloader lacks the retained-RAM capability.
+QSPI/microSD roles and Full Companions retain their existing storage layouts.
+An interrupted hybrid download must be fetched again after an application
+restart or power cycle. See the [protocol](../ota_protocol.md) for the exact
+admission and reset checks.
+
+## Build using option 3
+
+The equivalent reproducible command is:
+
+```bash
+OUTPUT_DIR=.releases/1.17.1.5-halo-keymind-cascade-dev \
+bash build.sh build-firmwares-logging-matrix \
+ --firmware-version v1.17.1.5-halo-keymind-cascade-dev \
+ --radio-preset usa-cascadia --profile cascade \
+ --require-ota --skip-kiss --resume --background
+```
+
+Or run `bash build.sh`, select option 3, set version
+`v1.17.1.5-halo-keymind-cascade-dev`, and retain USA Cascadia/Cascade.
+Option 3 enables the infrastructure OTA requirement by
+default. It merges supported runtime USB logging into canonical artifacts and
+builds the expanded ESP32 profiles. It runs one PlatformIO process at a time.
+
+The background launcher prints its persistent log and status paths. Each
+target also gets a log in `OUTPUT_DIR/build-logs/`. Resume accepts only
+artifacts with the current capability schema and a successful qualification;
+infrastructure also needs a verified update method. Any failed target leaves
+the matrix unsuccessful and must be resolved before publication.
+
+`--allow-no-ota` is an explicit development override for cable-only
+infrastructure. Those outputs do not belong in this OTA-required release.
+Use a separate output directory when changing the radio or settings profile.
+Use a fresh release output directory after changing the source revision or
+firmware version so packaging cannot mix old and new binaries.
+
+## Local release staging
+
+After the matrix completes successfully, `scripts/package_cascade_release.py`
+groups the qualified images using the previous GitHub release naming. Each
+group includes the firmware files, capability manifests, TSV/JSON inventory,
+checksums, build notes, and Full Companion directions. The staging directory's
+root `FIRMWARE-PICKER-1.17.1.5.html` opens the local downloads; the copies inside
+each release group use the planned GitHub download URLs. Staging does not
+push commits, create remote tags, or publish a GitHub release.
+
+## Published release set and small-slot Heltec V4 updates
+
+The capability JSON and target inventories now report both Bluetooth DFU and
+LoRa mOTA for all 60 nRF52 receiver profiles. The original report omitted the
+nRF52 LoRa method. Each affected manifest includes `ota_update_requirements.lora`
+with its storage type, accepted package types, exact-base requirement for deltas,
+and the matching OTAFIX bootloader link. Internal-flash receivers accept in-place
+deltas; supported QSPI/SD receivers also accept full application packages. The
+20 retained-RAM receivers require OTAFIX 2.4.6's matching handoff support.
+These reports verify the released application bytes and storage layout; they
+do not certify the bootloader installed on a physical device. Firmware binaries
+and tags are unchanged. Full Companion mOTA sending remains separate from
+self-update capability.
+
+Published firmware tags remain at source commit `26303793`. Documentation can
+advance independently. The original matrix qualified 540 profiles (1,080
+firmware files) from 551 attempts; 11 oversized portable attempts have expanded
+Full replacements. The [variant map](1.17.1.5-variant-map.tsv) maps all 1,361
+previous-release choices to 1,325 covered choices and 36 excluded entries.
+Multiple old choices map to the same current artifact.
+
+For Heltec V4 with an existing compatible **1.25 MiB OTA application slot**, the
+standard `heltec_v4_repeater` application is **1,179,200 bytes** and the lean
+`heltec_v4_repeater_lora_ota_no_external_sensors` application is **1,232,024 bytes**.
+Both fit that slot. Start the WiFi uploader with `start ota ap`, join
+`MeshCore-OTA`, and open `http://192.168.4.1/update`. Upload the application
+`.bin`; use `stop ota` to close an unused uploader. Their merged USB installers
+write two 6.25 MiB application slots. Expanded Full V4 applications require
+the larger layout and do not fit a 1.25 MiB slot.
+
+- [Companion firmware](https://github.com/mikecarper/MeshCore/releases/tag/v1.17.1.5-halo-keymind-cascade-dev-26303793)
+- [Repeater and Room Server firmware](https://github.com/mikecarper/MeshCore/releases/tag/repeater-room-v1.17.1.5-halo-keymind-cascade-dev-26303793)
+- [Sensor firmware](https://github.com/mikecarper/MeshCore/releases/tag/utility-v1.17.1.5-halo-keymind-cascade-dev-26303793)
+- [LoRa OTA repeater firmware](https://github.com/mikecarper/MeshCore/releases/tag/lora-ota-v1.17.1.5-halo-keymind-cascade-dev-26303793)
+- [Expanded Full infrastructure](https://github.com/mikecarper/MeshCore/releases/tag/full-profiles-v1.17.1.5-halo-keymind-cascade-dev-26303793)
diff --git a/docs/old-releases/1.17.1.6.md b/docs/old-releases/1.17.1.6.md
new file mode 100644
index 000000000..180aab76d
--- /dev/null
+++ b/docs/old-releases/1.17.1.6.md
@@ -0,0 +1,157 @@
+# MeshCore 1.17.1.6 Dev - USA Cascade
+
+This release builds the current `keymindCascade` source after repairing the
+unit-test regressions. Its firmware identifier is
+`v1.17.1.6-halo-keymind-cascade-dev-`. The recorded commit identifies
+the source for every firmware file; binaries from earlier releases are not reused.
+
+Use the [firmware picker](https://mikecarper.github.io/MeshCore/firmware_picker/)
+to match the exact board, radio, display and flash variant. The Companion release
+is marked Latest. Supporting release pages retain the `repeater-room-`,
+`utility-`, `logging-`, `lora-ota-` and `full-profiles-` prefixes where those
+outputs exist, and remain development prereleases.
+
+The build uses the USA/Canada radio preset and Cascade runtime defaults. Build
+records include the resolved radio settings; the offline USA Cascadia fallback
+is **910.525 MHz, BW62.5, SF7, CR5**. Ordinary updates retain saved node settings.
+
+## Published matrix
+
+The [GitHub release](https://github.com/mikecarper/MeshCore/releases/tag/v1.17.1.6-halo-keymind-cascade-dev-306feebe)
+contains **553 qualified firmware profiles across five release pages**. The
+initial matrix completed all 565 attempts: 537 passed, 16 failed, and 12
+standard ESP32 profiles exceeded the 1.25 MiB portable slot. All 16 failures
+have passing repair builds; all 12 portable exclusions have qualified expanded
+Full alternatives. Smaller profiles that fit remain available.
+
+The initial firmware uses `306feebe`, the 14 nRF52 repairs use `283acd27`, and
+the two Tracker MQTT repairs use `1274956f`. Each firmware filename and target
+manifest records its source. Existing firmware assets and release tags were
+preserved when the repairs were added.
+
+See the [failure and repair report](https://github.com/mikecarper/MeshCore/releases/download/v1.17.1.6-halo-keymind-cascade-dev-306feebe/BUILD-FAILURES.md)
+and [portable-profile exclusions](https://github.com/mikecarper/MeshCore/releases/download/v1.17.1.6-halo-keymind-cascade-dev-306feebe/PORTABLE-PROFILE-EXCLUSIONS.md).
+Full alternatives require their matching expanded partition layout; they do
+not fit a deployed 1.25 MiB slot.
+
+## Two LoRa profiles
+
+`radio2`, `tempradio2`, `radioat2` and `tempradioat2` add a second profile to
+LoRa-equipped Companion, Repeater, Room Server, Sensor and terminal firmware.
+The second profile starts disabled. It supports receive-only or receive/transmit
+operation, separate retry ownership and backoff, and explicit off commands.
+
+One transceiver alternates between at most two active profiles. Reception and
+transmission occupy that transceiver, so a packet on one profile interrupts
+listening on the other. This does not provide two simultaneous receivers.
+
+By default, traffic can cross between two permanent profiles or two temporary
+profiles. A permanent/temporary pair stays isolated. `radio2.cross auto|on|off`
+controls this policy; receive-only still forbids transmission on profile 2.
+`tempradio2` can carry LoRa OTA traffic while the primary profile remains
+available between packets. Use a firmware profile that supports the required
+OTA role.
+
+Radio commands accept an optional trailing preamble, and their getters include
+the effective preamble. Automatic preambles round up in steps of eight. Paired
+with SF7/BW62.5/preamble 32, the automatic BW500 values are 48 symbols at SF9,
+88 at SF8 and 120 at SF7. Configure transmitters with matching preambles too.
+
+Dual-profile receive uses normal RX with the slower profile visited first.
+RX power saving and device sleep are suspended while scanning, then the saved
+settings apply again in single-profile mode. See the
+[setup guide and command matrix](../radio_profiles.md) and
+[hardware measurements](../test-results/radio_profiles_validation.md).
+
+## Other changes since 1.17.1.5
+
+- Restores `set path.hash.mode 0|1|2` on infrastructure firmware, with CLI
+ dispatch and get/set regression coverage. Unsupported `extra.sf` commands
+ report an explicit error on radios without that capability.
+- Uses received MeshCore packets to feed the repeater RX watchdog. Fresh
+ settings default to 24 hours; temporary primary-radio sessions lasting at
+ least 12 hours use 12 hours, while shorter sessions disable that watchdog.
+ Temporary-session adverts and watchdog settings revert when the session ends
+ or the node reboots. See [CLI commands](../cli_commands.md).
+- Adds separate battery and USB display modes, with timeouts in seconds.
+ Companion inbox settings distinguish retained history, pending delivery and
+ the local unread count. Read previews remain browseable in Unread mode.
+- Includes native-USB sleep/wake corrections, nRF52 USB startup fixes,
+ receive recovery and noise-calibration improvements, and the reviewed
+ ExpressLRS board/power changes.
+- Consolidates redundant Full infrastructure builds while retaining useful
+ smaller-slot variants. Full profiles and smaller portable images continue
+ to require their matching partition and update method.
+
+Feature switches and update commands are documented in
+[feature switches by role](../role_feature_switches.md),
+[Full Companion features](../full_companion_features.md) and the
+[ESP-NOW bridge guide](../espnow_bridge_setup.md).
+
+## Validation and installation
+
+The release is gated on the [unit-test workflow](https://github.com/mikecarper/MeshCore/actions/workflows/run-unit-tests.yml)
+for its source commit. CI repairs replace stale display assertions with compiled
+tests of bookmark saves across battery/USB transitions and message summaries in
+each inbox mode. The sleep harness checks both MQTT and dual-profile scanning.
+
+The release packager checks each published profile's capability manifest,
+firmware-file hashes, native file pairs and
+[runtime RAM budget](../firmware_memory_budget.md). Infrastructure must have a
+verified wireless update path. The target manifests and exclusion report describe
+the actual published matrix; a build check is not a physical-board test.
+
+Dual-profile timing was measured on a Heltec V4 with a XIAO ESP32-S3/Wio LoRa
+transmitter. Finite strong-signal tests and an OTA catalogue exchange passed;
+these tests do not establish weak-signal performance, simultaneous traffic
+reliability, a complete hardware OTA installation, or months of uptime.
+
+ESP32 WiFi updates use the application `.bin`. The `-merged.bin` image includes
+boot and partition data for USB installation. nRF52 uses the board's `.uf2` or
+native application DFU `.zip`; LoRa OTA also needs the exact destination package
+and a compatible OTAFIX bootloader. Check the per-profile capability manifest
+before choosing an update method.
+
+## Reproduce the release
+
+Follow-up builds repair the constrained profiles without relaxing qualification:
+
+- nRF52 repeaters use `-Os` instead of `-Ofast`, retaining their sensor drivers
+ and protocol features while reducing flash use.
+- T096 and T1 LoRa OTA repeaters retain their board-defined 50 neighbors and
+ use 16 flood-rule slots. The complete rule engine, color display, GPS and OTA
+ remain enabled. The T096's available startup heap rises from 72,492 to 81,468
+ bytes, and the T1's from 72,868 to 81,844 bytes. Required startup allocations
+ fall from 88,410 to 79,010 bytes. These are linked capacity checks, not soak
+ measurements. Full Companion capacities are unchanged.
+- Tracker v1.1 and v2 Full MQTT repeaters retain their board-defined 50
+ neighbors so the color display, MQTT/TLS and OTA fit in internal RAM.
+ Their radio, display, sensor and update features remain enabled; ordinary
+ Tracker repeater and Companion capacities are unchanged. Tracker v2's
+ available startup heap rises from 184,640 to 222,992 bytes against the
+ unchanged 190,386-byte requirement. Both corrected Tracker profiles pass.
+- Size-failure detection keeps its temporary output log outside PlatformIO's
+ build tree, so SCons cleanup cannot erase the evidence needed by the measured
+ size fallback. CI exercises cleanup, ordinary compiler failures and log errors.
+
+Use a clean checkout of the release commit and a fresh output directory:
+
+```bash
+OUTPUT_DIR=.releases/1.17.1.6-halo-keymind-cascade-dev \
+bash build.sh build-firmwares-logging-matrix \
+ --firmware-version v1.17.1.6-halo-keymind-cascade-dev \
+ --radio-preset usa-cascadia --profile cascade \
+ --require-ota --skip-kiss --resume --background
+```
+
+Run only one PlatformIO process in that checkout. Let the matrix finish even
+when individual targets fail. The packager normally requires a successful full
+matrix. For an initial release with gaps, `scripts/package_cascade_release.py`
+accepts `--allow-partial` with `--version 1.17.1.6`, the original completed build
+status and source commit. It lists every failed attempt in `BUILD-FAILURES.md`
+and `BUILD-FAILURES.json`; each published file must still pass all qualification
+and memory checks. Repair the failures and add the corrected profiles afterward.
+`PORTABLE-PROFILE-EXCLUSIONS.md` and `.json` separately list standard ESP32
+profiles that exceed the 1.25 MiB slot and are redirected to the expanded Full
+pass. Those size deferrals are not compiler failures. Qualified smaller images
+remain available; Full alternatives require their matching partition layout.
diff --git a/docs/old-releases/1.17.1.7.md b/docs/old-releases/1.17.1.7.md
new file mode 100644
index 000000000..36715bec6
--- /dev/null
+++ b/docs/old-releases/1.17.1.7.md
@@ -0,0 +1,18 @@
+# MeshCore 1.17.1.7 USA Cascade
+
+This development release uses 910.525 MHz, 62.5 kHz bandwidth, SF7, and CR5 with Cascade defaults. Saved radio settings take precedence. The firmware source is `2d03e098cf75d58adcf35fe3d4dbbeaf0af114e1`.
+
+The release family contains 536 qualified firmware profiles on five GitHub release pages. The companion page is the main release; the repeater and room server, sensor and utility, LoRa OTA, and expanded ESP32 Full pages have the same version suffix. The [firmware picker](../firmware_picker.md) selects the exact board, role, storage and update file.
+
+## Installation
+
+- Match the exact board and radio variant. Use the application image only with a compatible bootloader and partition layout.
+- For an ESP32 that needs the expanded Full layout, flash the exact merged image over USB, or use a supported exact board and role migration ZIP from the utility release for a staged Wi-Fi or LoRa route. The 94 migration packages and complete bundle are included there. Read the package README before installation; the bridge writes the partition table and a power loss can require cable recovery. Run `get storage.layout` on the installed firmware if supported to see its live partitions; version alone cannot prove the installed layout. Do not send a loose app-only Full image directly to an older layout.
+- nRF52 application UF2 and DFU packages do not install a bootloader. Use the matching [OTAFIX 2.4.10](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/v0.11.0-OTAFIX2.4.10) bootloader where required. Standard RAK `3401_DFU` / `4631_DFU` bootloaders can receive the matching signed bootloader `.mota` over LoRa with a compatible OTA application. Devices already running the older `*_AUTO_DFU` identity require a local recovery bridge before installing the normal 2.4.10 image.
+- The RAK 3x72, Tiny Relay, Wio-E5 Mini, and Wio-E5 USB Companion images retain their framed USB transport and 32 KiB LittleFS area. Packet diagnostics are omitted on these four 256 KiB STM32WL targets to keep the application within flash.
+
+## Build verification
+
+The original matrix produced 532 qualified images and four STM32WL USB Companion flash overflows. Those four were rebuilt individually with packet diagnostics omitted and passed capability, flash size and memory checks. The [repair record](https://github.com/mikecarper/MeshCore/releases/download/v1.17.1.7-halo-keymind-cascade-dev-2d03e098/REPAIR-PROVENANCE.json) identifies them. The local release package also verified all 94 ESP32 migration archives and generated SHA-256 sums.
+
+Automated qualification does not establish physical update compatibility on every board. Verify critical deployed devices with the matching image and recovery path before a fleet upgrade.
diff --git a/docs/old-releases/index.md b/docs/old-releases/index.md
new file mode 100644
index 000000000..eebff3dcc
--- /dev/null
+++ b/docs/old-releases/index.md
@@ -0,0 +1,9 @@
+# Old Releases
+
+Historical release notes and their supporting files are kept here for reference. Use the [current 1.17.1.8 release notes](../releases/1.17.1.8.md) for current firmware.
+
+- [1.17.1.7 release](1.17.1.7.md)
+- [1.17.1.6 release](1.17.1.6.md)
+- [1.17.1.5 release](1.17.1.5.md)
+- [1.17.1.5 documentation audit](1.17.1.5-doc-audit.md)
+- [1.17.1.5 variant map](1.17.1.5-variant-map.tsv)
diff --git a/docs/pair_4p6_300us_validation.md b/docs/pair_4p6_300us_validation.md
index 4a8b486bb..25da30a41 100644
--- a/docs/pair_4p6_300us_validation.md
+++ b/docs/pair_4p6_300us_validation.md
@@ -1,71 +1,5 @@
-# SF7/62.5 + SF8/500: 4.6 slow chirps, 0.3 ms reserve
+# Document moved
-User-requested follow-up to the [original pair test](pair_preamble32_validation.md).
-Completed 2026-09-15 00:21:27–00:23:52 UTC (September 14 local time).
+
-## Result
-
-| Profile | Requested dwell | Stationary control | Scanning received |
-| --- | ---: | ---: | ---: |
-| Slow: SF7 / 62.5 kHz | 9.421 ms, approximately 4.6 chirps | 100/100 | 97/100 |
-| Fast: SF8 / 500 kHz | 21.847 ms, approximately 42.67 chirps | 100/100 | 100/100 |
-
-Full scan: **197/200 (98.5%)**, not lossless. All three misses were slow-profile
-timeouts with no device error and the chip still in RX. The collector completed
-every scheduled probe, with no RF retry, retune/reset on a miss, or early stop.
-The two controls plus the scan contain 400 unique probes; each fixed transmitter
-acknowledged exactly 200 packets. There were no USB result replays.
-
-This is one finite sample. It does not establish that 4.6 is inherently worse
-than 4.1 or better than 5.1, nor identify the cause of the three losses.
-
-## Timing and unchanged conditions
-
-- Reserve: 300 us; nominal switching allowance: 600 us per hop. Neither is an
- added settling delay. Nominal cycle: 9.421 + 21.847 + 1.200 = 32.468 ms.
-- Actual switching across 3,957 hops: **0.582 ms mean, 0.827 ms maximum**.
- Direction means: 0.586 ms toward slow, 0.578 ms toward fast.
-- Five-second RX-only switching window: 0.559 ms mean, 0.779 ms maximum.
-- Non-packet-held cycle: 32.428 ms mean, 34.242 ms maximum. These samples can
- still include USB/loop scheduling overruns; the reserve is not a hard bound.
-- Observed non-held dwell means: slow 9.426 ms, fast 21.855 ms; maxima 10.480
- and 23.474 ms respectively. No per-loss timing trace was collected.
-- Successful-packet mean SNR: slow +12.27 dB, fast +13.47 dB; mean RSSI:
- -20.87 and -18.99 dBm. These remain strong-signal lab conditions.
-
-Same V4 RX, RAK4631 slow TX and T1000-E/LR1110 fast TX; 909.5/910.5 MHz,
-CR4/5, explicit header, CRC, 32-symbol preambles, 16-byte synthetic payloads,
--9 dBm chip TX power, normal RX gain 0x94, randomized interleaving and 10–180 ms
-arrival delays. Only one transmitter sends at a time. Each TX configures once;
-every packet has zero frequency/modulation writes.
-
-The single-pass production guarded switching path uses warm XOSC and buffered
-8 MHz SPI. Each hop issued exactly one frequency and one modulation command,
-with one optimized RX restart. No double writes, detours, rollback, added
-settling, off-channel acceptance, cache errors, RX errors or retune failures.
-As before, this is a small HIL application, not full production UI/network load.
-
-## Verification and artifacts
-
-Only the V4 application was updated. Exact USB identity/MAC, idle ownership and
-existing partition metadata were checked before writing. The nRF52 transmitter
-images, bootloaders, SoftDevices, gateway and unrelated radios were not flashed.
-All three test radios were rebooted and verified idle afterward; HIL firmware
-was left installed. ModemManager was restored active, gateway and host CLI
-services are active, and the previously paused memory soak remains inactive.
-
-- [Raw capture](../tools/hil/profile_pair_sf8_32_4p6_300us_results.json), SHA-256
- `1c2e0eae3f304fcaeb6addfeef4f2f09a3c2bec8d78c60d3f151c952d0f6d5f5`.
-- [Independent offline validation](../tools/hil/profile_pair_sf8_32_4p6_300us_validated.json)
- checks complete samples, unique sequences, sender lifetime counts, actual
- RX frequency/SF/BW/payload, command counts, policies and idle cleanup.
-- [Manifest](../tools/hil/profile_pair_sf8_32_4p6_300us_manifest.json) records the
- receiver image/source hashes and prior transmitter-manifest hash. Bundle
- SHA-256 `fb7a08536ed06e36cf7d5c29babbe2affb30183de05918c4db271b98b796e579`.
-- Remote records remain under
- `/home/mikec/hwtest/runs/pair-sf8-32-4p6-300us-20260914`.
-- Receiver build passed. HIL host regression suite: **64/64 passed**; new plan
- guards and the exact dwell/budget arithmetic are covered. Python compile and
- scoped whitespace checks passed. Collector/wrapper exit zero.
-
-No production timing defaults changed, and no commit or push was made.
+This document is now in [Test Results](test-results/pair_4p6_300us_validation.md).
diff --git a/docs/pair_preamble32_validation.md b/docs/pair_preamble32_validation.md
index 376839192..965120c0d 100644
--- a/docs/pair_preamble32_validation.md
+++ b/docs/pair_preamble32_validation.md
@@ -1,138 +1,5 @@
-# SF7/62.5 + SF8/500, fast switching and preamble 32
+# Document moved
-User-requested hardware validation, 2026-09-14. This is a finite HIL experiment,
-not a production reliability guarantee. No production timing defaults or
-automatic-preamble overrides are changed by this test.
+
-## Fixture and method
-
-- Heltec V4 receiver, USB serial `44:1B:F6:69:CF:98`, normal chip RX gain (0x94).
-- RAK4631 `9AB3B64C641BA927`: stationary transmitter at 909.5 MHz, SF7/62.5 kHz.
-- T1000-E/LR1110 `34A9141999729D5D`: stationary transmitter at 910.5 MHz, SF8/500 kHz.
-- CR4/5, explicit header, CRC, 32-symbol preamble, synthetic 16-byte sequence-tagged
- payloads. -9 dBm chip output. Only one transmitter sends at a time on host request.
-- Each transmitter initializes its assigned settings once. RF-frequency and
- modulation-command counters must remain unchanged for every TX call.
-- 100 fixed-channel controls per profile before scanning. Require 100/100 on
- both before making a scan-timing inference.
-- Three scanner settings, 100 packets per profile per setting, randomized
- interleaving and 10–180 ms arrival delays. A miss does not cause a retry,
- retune, reset, or early end of the finite sample. The scan expectation expires
- after 1000 ms, far longer than the test frame and randomized host delay.
-
-| Case | Loop reserve (not a delay) | Slow visit | Fast visit |
-| --- | ---: | ---: | ---: |
-| 4.1-chirp floor | 0 ms | 8.397 ms | 23.171 ms |
-| Spare time used for 5.1 slow chirps | 0 ms | 10.445 ms | 21.123 ms |
-| Previous reserve comparison | 4 ms | 8.397 ms | 19.171 ms |
-
-The 5.1 case reallocates 2.048 ms from the fast visit to the slow visit; it does
-not increase the nominal cycle. The 4 ms reserve is omitted from the first two
-cases. The theoretical switch allowance is 0.6 ms per hop; actual switching is
-measured, never forced to equal that allowance. Changed SF/BW requires one
-modulation command per hop; frequency-only cache-hit timings do not apply.
-
-The scheduler uses the same asymmetric two-profile visit budget and the actual
-production guarded `tuneProfile()` / fast RX implementation, but runs in a small
-RAM-only HIL application, without the full production networking/UI load. All
-retunes are single-pass, warm-XOSC, buffered 8 MHz SPI, zero added settling;
-double frequency writes, detours and rollback experiments are off. Packet/busy
-ownership guards are retained. Explicit 32 on both sides bypasses the historical
-automatic SF8/500 88-symbol rule without removing it from production.
-
-The selected radios already had disposable HIL firmware. Updates are
-application-only: matching nRF52 SoftDevice IDs/address ranges and exact USB
-serial/topology are checked. V4 partition metadata is read back and compared
-before its application-only write. No bootloader, SoftDevice, settings backup,
-gateway firmware or unrelated radio is changed. Prior images/captures remain.
-
-## Results
-
-Finished all 800 unique probes, 2026-09-15 00:04:54–00:11:19 UTC (September 14
-local time). Both stationary controls passed 100/100. All three scans completed
-their full 100-packet sample per profile. No scan achieved 200/200.
-
-| Case | SF7/62.5 received | SF8/500 received | Total | Switch mean / max | Idle cycle mean / max |
-| --- | ---: | ---: | ---: | ---: | ---: |
-| 4.1 chirps, no loop reserve | 99/100 | 100/100 | 199/200 | 0.581 / 0.829 ms | 32.717 / 34.733 ms |
-| 5.1 chirps, no loop reserve | 100/100 | 97/100 | 197/200 | 0.572 / 0.834 ms | 32.706 / 34.978 ms |
-| 4.1 chirps, 4 ms loop reserve | 97/100 | 99/100 | 196/200 | 0.567 / 0.836 ms | 28.701 / 31.195 ms |
-
-Switch timing includes every successful hop during each stage, including
-post-reception hops. The RX-only five-second windows averaged 0.554 ms per hop.
-The largest observed switch was 0.836 ms: 0.6 ms is a nominal allowance, **not a
-measured worst-case bound**. Direction-specific timing is in the validated
-capture. Idle-cycle samples exclude packet-held visits but still include USB
-command/loop scheduling; their occasional roughly 2 ms overshoots are real.
-
-Every scan hop issued exactly one RF-frequency and one modulation command,
-with one optimized RX restart. There were zero added settling delays, double
-passes, frequency repeats, RF retries, USB result replays, device errors,
-retune failures, RX mode errors, cache errors or off-channel acceptances.
-All eight missed packets were sequence-matched RX timeouts with the receiver
-still in RX mode and no device error. Both transmitters acknowledged exactly
-400 transmissions and zero frequency/modulation writes during every packet.
-
-Successful scan packets had mean SNR approximately +12.0 to +12.1 dB on the
-slow profile and +12.8 dB on the fast profile. Mean RSSI was approximately
--20.4 to -20.7 dBm and -18.5 to -18.9 dBm, respectively. These are strong-signal
-bench results; the controls passed, but weak-signal performance and possible
-strong-signal/front-end effects are not established by this test.
-
-## What the extra chirp changes
-
-An extra SF7/62.5 symbol is 2.048 ms, equivalent to **four SF8/500 symbols**.
-Increasing the slow dwell from 4.1 to 5.1 preserves the full-sweep budget by
-shortening the fast dwell, but increases the fast profile's uninterrupted
-blind interval. With the nominal 0.6 ms hops, the 32-symbol fast preamble's
-remaining time after the slow visit plus two hops falls from 6.787 to 4.739 ms
-(13.26 to 9.26 fast symbols), before acquisition and scheduling overruns.
-This is a timing-budget calculation, not an experimentally proven acquisition
-threshold or a per-miss trace.
-
-The observed 5.1 sample improved the slow result from 99 to 100, while the fast
-result fell from 100 to 97. This is consistent with a dwell/acquisition tradeoff;
-one finite sample per setting does not isolate the cause or establish a
-statistically reliable ranking. Simply fitting a sweep inside a preamble does
-not establish lossless acquisition. The 4 ms reserve is an allowance, not a
-sleep; retaining it also did not produce a lossless result here.
-
-Using genuine spare time above the 4.1 floor remains a reasonable policy to
-investigate, but allocation must protect **both** profiles' acquisition/return
-budgets and measured switching/scheduling overruns. Do not automatically spend
-all of the reserve on longer slow visits, or treat the historic 88-symbol rule
-as disproven by this nearly-lossless sample. No production defaults or warning
-formula were changed by this hardware-test turn.
-
-## Capture integrity, deployment and cleanup
-
-- Raw capture: [profile_pair_sf8_32_results.json](../tools/hil/profile_pair_sf8_32_results.json),
- SHA-256 `3472883ade89494b64657486efd1645a04b0a3bebfd0a2e31bbf914341ad792f`.
-- Independent offline checks: [profile_pair_sf8_32_validated.json](../tools/hil/profile_pair_sf8_32_validated.json).
- Checks all 800 unique sequences, 100/profile/phase, sender lifetime counts,
- actual RX frequency/SF/BW/payload identity, command counts, policies and cleanup.
-- [Manifest](../tools/hil/profile_pair_sf8_32_manifest.json) records source and image hashes.
- Bundle SHA-256 `d6cb9ff36be109af8906eddbbe8a4657ca793d1351ec4bf50ebbd4909eed2bba`.
- Local bundle is `out/pair-sf8-32-20260914`; remote capture remains under
- `/home/mikec/hwtest/runs/pair-sf8-32-20260914`.
-- Initial RAK USB reset returned EPROTO before any firmware write. The preserved
- [pre-write record](../tools/hil/profile_pair_sf8_32_deployment-prewrite-usb.json)
- has an empty writes list. Inspection confirmed the exact RAK serial/topology
- in its documented CDC-only bootloader PID 0x002A. Deployment was revised to
- accept that identity (and reset-time disconnect only after exact bootloader
- re-enumeration); no permission check or board-identity check was bypassed.
- The revision hash and all three successful application-only writes are saved
- in the deployment records. No bootloader or SoftDevice was replaced.
-- All three radios were rebooted and live-verified idle, with test firmware left
- installed. ModemManager was restored active; gateway and host CLI services
- remained active. The preexisting memory soak remains inactive. No unrelated
- radio was flashed. Wrapper exit was zero.
-- All three HIL firmware builds passed, run sequentially. The HIL host regression
- suite passed **63/63 tests**, including the new plan and packet-policy tests.
- Python compile checks and scoped `git diff --check` passed. No commit or push.
-
-## Follow-up
-
-The requested [4.6-chirp / 0.3 ms reserve follow-up](pair_4p6_300us_validation.md)
-completed 197/200 scanning packets: slow 97/100, fast 100/100, after fresh
-100/100 controls on both profiles. Its captures are separate from this run.
+This document is now in [Test Results](test-results/pair_preamble32_validation.md).
diff --git a/docs/pr7_review_validation.md b/docs/pr7_review_validation.md
index 5470df8fe..be828c283 100644
--- a/docs/pr7_review_validation.md
+++ b/docs/pr7_review_validation.md
@@ -1,78 +1,5 @@
-# PR #7 review and validation
+# Document moved
-Validated on 2026-09-10 against integration commit
-`fa010196b130dee9a8a6fc6325d794e2f4fb9ede`, which combines base `01cc7b8a`
-with PR head `c002966c` and the review fixes. This includes the stacked ESP32
-memory changes already present in PR #7.
+
-## Review fixes
-
-- Use the [published ExpressLRS Linkflow calibration](https://github.com/ExpressLRS/Targets/blob/504178dcfa469ee32f4290d6ae9ba02e2f2f365e/TX/GEPRC%20900%20Linkflow.json),
- expose 17–30 dBm, and retain the fixed +2 dBm radio drive and correct RF output
- after radio recovery. Propagate startup and radio-setting failures.
-- Keep Linkflow LoRa OTA enabled, as intended by the added repeater target.
-- Preserve stored ACLs and flood filters when allocation fails. Release owned
- buffers and prevent accidental shallow copies of their owners.
-- Release heap OTA workspaces after self-serving windows; retain them for active
- transfers, manual staging, folder operations, and pending application. Service
- cleanup on Companions and reset staged-resume flags when a context disappears.
-- Count dynamically allocated clients, filters, and OTA workspaces in the RAM
- budget. Respect explicit OTA storage ownership in PlatformIO flags.
-
-## Automated validation
-
-- `pio test -e native -e native_kiss_modem`: **1,393/1,393 cases passed**.
-- Real C++ radio-power, heap-context, ACL persistence/CLI, and shared OTA queue
- tests passed. The applicable radio-power, heap-context, and shared-queue tests
- also passed on Linux with AddressSanitizer and UndefinedBehaviorSanitizer.
-- Display/inbox, radio receive, static DRAM, and runtime RAM regression tests
- passed. ACL allocation-failure coverage verifies that stored clients survive.
-- Sequential firmware builds and their memory gates passed for:
- `GEPRC_Linkflow_900_repeater`, `Heltec_v2_companion_radio_ble`,
- `Tbeam_SX1262_repeater_observer_mqtt`,
- `heltec_v4_r8_repeater_observer_mqtt`, `RAK_4631_repeater`, and
- `SenseCapIndicator-LoRa_companion_radio_full`.
-
-## Indicator hardware validation
-
-The original SenseCAP Indicator LoRa with 8 MiB flash and 8 MiB octal PSRAM
-was reached through the MercerWoodMesh Pi over Wi-Fi. Its MAC, public identity,
-board reply, and existing partition layout were checked before updating.
-USB interfaces were absent, so this run does not qualify USB behavior.
-
-The Full profile was applied to `SenseCapIndicator-LoRa_comp_radio_usb_wifi`.
-The exact application is 2,149,208 bytes:
-
-- Application SHA-256:
- `7a2b11e731f946af79d1104818c7993d3897e6d8ec1f0121d27284c0f0c8c665`
-- ELF SHA-256, also verified in the application descriptor:
- `24e60c7785a7aefe3fb0536b0b595bf19e1264e4d5f100d201290dc95eb1761a`
-
-This direct profile build uses the repository's default `v1.17.1` version text
-and `14 Aug 2026` build-date text; the hashes above identify the tested image.
-
-The Wi-Fi updater returned HTTP 200 and booted the application in `app1` at
-`0x400000`. The previous application remains in `app0` at `0x10000`; the
-partition table, NVS, and SPIFFS were not rewritten. Public identity, radio
-parameters, and USB logging preference matched their pre-update values.
-
-The existing Companion stress harness, using a TCP socket adapter, completed
-100 persistent cycles and 100 reconnects in 109.9 seconds: **501 requests and
-501 validated responses**, with no discarded bytes, malformed frames, or device
-error flags. Device info, time, storage, and core statistics were exercised.
-The text terminal exposed all four display-power controls and `display.inbox`.
-The OTA folder service issued a valid `COUNT` request, received an empty-folder
-reply, and returned to its disconnected state after the client closed.
-
-After these checks, four memory snapshots showed 69,548–69,996 bytes of free
-internal heap and a 61,428-byte largest block. PSRAM availability stayed at
-6,408,731 bytes. Final uptime was 262 seconds, with zero error flags and an
-empty outbound queue. This is a short regression check, not a long-term soak.
-Raw baseline, upload, stress, and final-state records are retained on the Pi
-under `/home/mikec/hwtest/runs/pr7-review/`.
-
-No Linkflow module or RF power meter was available in this run. Linkflow power
-calibration is supported by the pinned upstream table and executable driver
-tests, not a physical RF output measurement. The Indicator's ESP32-S3 does not
-exercise the classic ESP32 heap-OTA default; host tests and classic ESP32 builds
-cover that path. Physical display readability was not inspected.
+This document is now in [Test Results](test-results/pr7_review_validation.md).
diff --git a/docs/preamble_detection_investigation.md b/docs/preamble_detection_investigation.md
index fda94c92c..bf16498df 100644
--- a/docs/preamble_detection_investigation.md
+++ b/docs/preamble_detection_investigation.md
@@ -1,170 +1,5 @@
-# Off-channel preamble investigation
+# Document moved
-Measured 2026-09-14. **The SF8 off-channel preamble indication reproduces with
-a stationary, fully initialized receiver, without profile hopping or fast RX
-reuse.** Both boards reproduce it when their transmitter/receiver roles are
-reversed. This is evidence of transmitter-associated off-channel detection in
-this close-range bench setup, not evidence that the channel jump needs more
-settling time.
+
-The earlier SF8 scan's long software guard explains its missed listening
-opportunity. The earlier SF6 scan miss remains unresolved: its trace had no
-preamble indication or guard hold. No production guard or acquisition policy
-was changed during this investigation.
-
-## Controlled experiment
-
-- XIAO ESP32-S3 + WIO SX1262 (COM31) and SenseCAP Indicator ESP32-S3 LoRa
- (COM28). The Indicator RP2040 and RAK4631 were not modified.
-- Receiver fixed at 910.000 MHz. Transmitter silent, on frequency, or offset
- by +/-250, +/-500 or +/-1,000 kHz. These are offsets, not bandwidths.
-- 125 kHz bandwidth, SF8 or SF6, CR4/5, 32 programmed preamble symbols,
- -9 dBm, one 16-byte sequence-tagged synthetic payload per transmitting case.
-- Each case fully initializes both radios. RX uses ordinary `startReceive()`;
- profile-switch optimization and experimental omission masks are disabled.
- Buffered 8 MHz SPI remains enabled. No hops, warm-retune reuse, CAD, or
- `isReceiving()` software guard runs during these controls.
-- TX is prepared before RX is armed. Each receive window lasts 450 ms;
- all eight cases are shuffled within each balanced round, seed 814910.
- The full RX window completes even when an off-frequency packet is not
- received, as expected. An on-frequency positive-control failure stops the
- experiment. No RF retransmissions are used to repair a result.
-- The HAL records actual outgoing `SetRfFrequency` bytes on both boards,
- and the frequency word present at `SetTx`. The host verifies these words
- against the requested frequencies. This checks commands sent to the chip,
- not an independent measurement of the emitted RF spectrum.
-- Existing IRQ replies are observed and IRQs additionally polled at a nominal
- 64 us interval. Events are buffered in RAM; timestamps are software
- observations, not synchronized RF arrival times. Unknown payload contents
- are not exported.
-
-The three completed matrices contain **128 windows: 112 transmitted probes
-and 16 silent controls**. A separate initial 450 ms receiver-only smoke check
-was also performed. These are bounded diagnostic controls, not another
-100-packets-per-channel capacity sweep.
-
-## Results
-
-Entries below are windows with a preamble indication / tested windows. Each
-positive window reported one indication; latched IRQs do not count continuous
-detector activity or prove a valid packet.
-
-| Transmitter offset | SF8, XIAO RX | SF8, Indicator RX | SF6, XIAO RX |
-| --- | ---: | ---: | ---: |
-| Silent | 0/8 | 0/4 | 0/4 |
-| On frequency | 8/8 | 4/4 | 4/4 |
-| -250 kHz | 8/8 | 4/4 | 1/4 |
-| +250 kHz | 8/8 | 4/4 | 0/4 |
-| -500 kHz | 5/8 | 3/4 | 0/4 |
-| +500 kHz | 1/8 | 3/4 | 0/4 |
-| -1,000 kHz | 5/8 | 4/4 | 0/4 |
-| +1,000 kHz | 5/8 | 3/4 | 0/4 |
-
-All **16 on-frequency probes delivered valid payloads**. None of the 96
-off-frequency probes delivered a valid payload. No silent control detected a
-preamble. Completed windows ended in RX mode with zero device errors; there
-were no foreign valid payloads or trace overflows.
-
-Most off-frequency detections stopped at `PreambleDetected` (0x0004). Four
-progressed further but failed CRC (`seen_irq=0x0056`: preamble, header, RxDone,
-CRC error): two at +250 kHz with XIAO RX, and one each at +250 and +1,000 kHz
-with Indicator RX. Therefore **HeaderValid alone would not prove that these
-off-frequency indications are legitimate traffic**.
-
-In forward SF8 on-frequency controls, receiver signal strength (RSSI) sampled
-at the first preamble was -42 to -41 dBm; at +/-250 kHz it was -91 to -88 dBm.
-These are single-point receiver readings, not calibrated measurements of
-transmitted leakage or a sensitivity test. Board positions and attenuation
-were not systematically varied. Reversing roles reproduces the behavior but
-does not isolate its physical path.
-
-## What this explains
-
-The [earlier SF8 four-channel scan](sf8_5p1_trace_validation.md) missed probe
-13 on 910.250 MHz after detecting a bare preamble while tuned to 910.000 MHz.
-The existing `CustomSX1262::isReceiving()` guard allowed 91 ms for preamble
-progression, consistent with the observed 92.257 ms from first preamble
-observation to the stale-bit clear. That produced approximately 102 ms total
-occupancy on the wrong channel and a 135.145 ms target-channel revisit gap.
-The programmed 32-symbol preamble lasted only 65.536 ms.
-
-The delayed retune itself took 550 us. These controls reproduce the suspect
-off-frequency indication with no retunes at all. Together the evidence
-supports **off-channel acquisition plus a long guard hold** as the SF8
-failure mechanism, rather than slow SPI or a demonstrated need for a longer
-post-jump delay. It does not independently prove the RF source of that one
-historical scan event.
-
-The controlled-transmitter association and quiet silent controls make random
-ambient RF alone an inadequate explanation of the repeated bench result.
-Exact RF cause remains unmeasured: receiver selectivity/false acquisition,
-transmitter spectral leakage, and coupling between the nearby boards are
-not separated by these tests. No spectrum-analyzer capture or calibrated
-attenuation sweep was performed. Increasing channel spacing is not established
-as a fix: SF8 indications also occurred at +/-1 MHz.
-
-The [earlier SF6 miss](sf6_5p1_trace_validation.md) had no observed nonzero IRQ
-or guard hold. The four successful stationary SF6 positive controls show the
-fixed-channel link works, but do not establish the acquisition time needed
-after a retune. These small, differently sized samples are not packet-error
-rate estimates or proof of a zero-loss channel count.
-
-## Follow-up candidates, not implemented fixes
-
-Semtech documents that false LoRa detections can occur and describes
-`SetLoRaSymbNumTimeout` as validating reception over multiple symbols before
-continuing when the modem locks. This is a candidate to investigate, not a
-drop-in verified fix. Its behavior in continuous RX and through each retune
-must be measured. See section 13.4.9, page 99 of the
-[Semtech SX1261/2 datasheet, revision 2.2](https://resource.heltec.cn/download/WiFi_LoRa_32_V4/datasheet/SX1261_2%20V2-2.pdf).
-
-Useful next experiments would qualify/revalidate preambles before granting a
-long scan hold, and repeat the controls with lower signal levels or increased
-physical isolation. Any candidate must preserve legitimate weak signals and
-long preambles. Blindly shortening the current guard, or requiring only a
-header indication, is not justified by these results. No production radio
-change was made in this diagnostic turn.
-
-## Reproduction, provenance, and checks
-
-See the [stationary diagnostic instructions](../tools/hil/profile_switch_README.md#stationary-off-channel-preamble-controls).
-The same diagnostic firmware binaries were used for all three matrices.
-Base commit `c329cf1fc1e7f2e8c8733ab0d000094730458fe3` plus local changes;
-RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32
-6.11.0 / Arduino ESP32 2.0.17. Both HIL targets built and uploaded successfully.
-All **29 selected host/native tests passed** after testing.
-
-Raw results and SHA-256:
-
-- [Forward SF8, eight rounds](../tools/hil/preamble_stationary_sf8_results.json):
- `e876a8b1727c5584aa67cb4fcdcae5ca30701e90f7ed998b5df36c37d245f7b0`
-- [Reverse SF8, four rounds](../tools/hil/preamble_stationary_sf8_reverse_results.json):
- `83b576544f807c6b81ee83a7db487137ef2584cb4981e475c7e7ac50e4fa2363`
-- [Forward SF6, four rounds](../tools/hil/preamble_stationary_sf6_run2_results.json):
- `bdb65b4e23d8b05c8523cec092533509021f878518a38523b02135b810da991b`
-- [Aborted initial SF6 launch](../tools/hil/preamble_stationary_sf6_results.json)
- is retained: no trials and no RF probes. A host reboot-command quoting
- failure left the diagnostic image unready after the preceding run. After
- correcting the command and rebooting both boards, the complete SF6 matrix
- was recorded to the separate `run2` path. A subsequent host cleanup change
- ensures an already-opened receiver port is closed if opening TX fails.
-
-Tested firmware SHA-256:
-
-- XIAO: `f89da2a2e542df481ff6c26d2492dcc46db8794b5549d3b4f73217767130a21d`
-- Indicator: `e58207c7e63de625bee2d4e4400eb934b2737f51e8babc7c1fddd85c48efdda6`
-
-## Device handoff
-
-Fresh full 8 MiB backups were taken and separately verified before flashing,
-in the private current-user MeshCore backup directory. No backup contents
-or identity/channel secrets are in the diagnostic results.
-
-- XIAO backup: `5e5825b625a0e6ded65aafa72658eb3b15b466316a9efe8239b2f09de9418e63`
-- Indicator backup: `6f834296d4e29e4fe17297aad0a9e2bf2adcf0a1391cf645de1bc51aebfd70de`
-
-Both original images were restored after the controls, separately verified
-against the complete 8 MiB flash with matching digests, and hardware-reset.
-The RAK4631 and Indicator RP2040 were untouched; the XIAO soak logger was not
-restarted. The temporary HIL build overlay was removed. Investigation source,
-tests, and results were initially handed off locally before publication.
+This document is now in [Test Results](test-results/preamble_detection_investigation.md).
diff --git a/docs/radio_chirp_math_validation.md b/docs/radio_chirp_math_validation.md
index 19b8613cb..e73251ab3 100644
--- a/docs/radio_chirp_math_validation.md
+++ b/docs/radio_chirp_math_validation.md
@@ -1,135 +1,5 @@
-# Shared radio/radio2 chirp timing and preamble warnings
+# Document moved
-Historical validation record: the nominal 600 us policy described below has
-been replaced by the activation self-test and 10% guarded measured budget.
-The later automatic policy also removed the pair-specific 88-symbol SF8/500
-floor and the fixed 300 us loop allowance; the older calculations below remain
-as test history.
-See [radio profiles](radio_profiles.md#preamble) for
-current behavior.
+
-Implemented in the `keymindCascade` working tree, 2026-09-14. The initial
-4.1/4000-us implementation record below is retained as history; the
-[4.6/300-us policy update](#automatic-46-chirp--300-us-policy-update) was a prior stage.
-No radios were flashed or RF experiments started for either function-only change.
-
-## Initial implementation scope
-
-- One portable timing function in `src/RadioProfiles.h` calculates both profiles
- from their own SF/BW symbol times, optional dwell times and explicit budgets.
-- The production dwell minimum is now 4.1 chirps on both profiles, rounded up to
- a whole microsecond. The slow visit changed from 4.8 to 4.1; the old four-chirp
- fast minimum changed to 4.1. The existing asymmetric scheduler may allocate
- a longer fast-channel visit from the slow preamble's return deadline.
-- Automatic preambles and diagnostics share the same requirement calculation.
- The hot retune path computes only the requested profile, not a full diagnostic
- structure or extra hardware reads.
-- Round recommendations upward to multiples of eight, minimum 32. Preserve
- the prior measured 88-symbol SF8/500-versus-SF7/62.5 floor.
-- Warn by profile name with the recommended preamble when the requirement is
- above 32 or above an explicitly selected short value. Successful radio-setting
- replies include `WARN recommended preamble: radio=...` and/or `radio2=...`.
- Existing explicit values are not overwritten.
- Existing unsafe-airtime and infeasible-return-deadline rejection stays intact.
-- `get radio2.timing` / `get radio.timing` is read-only. It reports both dwell
- values and requirements in `radio,radio2` order. Settings/getters and scheduling
- acknowledgments include warnings; successful primary-setting responses use a
- preview of the requested SF/BW, not stale live settings.
-
-## Evidence and limits
-
-The [five-radio single-pass bench](four_fixed_tx_single_pass_validation.md)
-received 392/400 at 5.1 chirps. The [longer-dwell repeats](four_fixed_tx_dwell_validation.md)
-scored 342/348 before the user stopped 6.1, and 395/400 at 7.7. They do not
-establish any lossless four-channel dwell. They also do not justify treating
-one full sweep fitting inside the preamble as proof of acquisition.
-
-The function retains the earlier production two-profile policy: two return
-opportunities on the slower channel, blind time plus sixteen acquisition
-symbols on the faster channel. Its 4.1 floor is the requested policy boundary,
-not an experimentally established detection minimum. This change requires RF
-qualification before describing it as a reliability improvement.
-
-Single-pass V4 switching peaked at 804 us including post-reception cache refresh.
-Indicator reached 8428 us in the prior receive-only study. The pure function can
-model either allowance. Per the follow-up request, runtime scheduling now uses
-a nominal 600 us switch / 4000 us loop budget; advisory calculations raise the
-switch allowance if the observed maximum exceeds 600 us. The 0.6 ms nominal
-value does not cover all measured switches. They do not dynamically retune the production
-scheduler or silently lengthen already configured preambles.
-
-See [the formula and CLI examples](radio_profiles.md#chirp-calculator-and-warnings).
-
-## Verification
-
-- Portable C++ property sweep: 1,600 SF/BW pairs (SF5 through SF12, five bandwidths
- on each side), minimum dwell, rounding, automatic/diagnostic consistency and
- the two-return deadline for accepted tuples.
-- Exact SF10/125 symbol duration and 4.1 floor; SF6/125 comparison using 1000 us
- versus 9000 us switching; reversed profile order; invalid/non-finite inputs;
- explicit long/short preambles; advisory-only overrun handling.
-- Production CLI tests cover warnings before delayed publication, named profiles,
- both timing aliases, single-profile off, explicit short overrides, saved versus
- temporary primary settings, read-only enforcement and bounded reply buffers.
-- Production scan/transition harness retains packet ownership, busy deferral,
- failed-retune rollback, receive restoration and oscillator-policy checks.
-- Native `test_trace_retry`: 63/63 tests passed, including the updated automatic
- preamble expectations and profile routing/retry/scheduling tests.
-
-- Host regression suites: 22/22 tests passed across chirp math, profile CLI,
- scan transitions, setting contracts, temporary-reply delivery, STM32 float
- formatting, local/LoRa CLI access and repeater timing integration. The CLI
- fixtures include the new warning method. Windows MinGW runs the local-access
- assertions without unavailable ASan/UBSan runtimes; Linux retains those flags.
-- Full `heltec_v4_repeater` build after the 0.6 ms follow-up: passed;
- RAM 125,344 bytes, flash 1,597,789 bytes, runtime-RAM checks passed.
-- Full `heltec_v4_companion_radio_usb` build after the follow-up: passed;
- RAM 62,460 bytes, flash 828,589 bytes, runtime-RAM checks passed.
-- The rerun passed all 22 host tests and 63 native cases, including actual
- primary-setting reply dispatch (local and LoRa), requested-tuple previews,
- a recommended 32-symbol preamble for an explicit 16-symbol override, and
- the nominal 600 us allowance. The 4.1-symbol floor and 4000 us loop margin
- remain unchanged.
-- `git diff --check`: passed. No firmware was uploaded and no commit or push
- was made for this change.
-
-## Automatic 4.6-chirp / 300-us policy update
-
-After the separate [4.6/0.3 ms hardware test](pair_4p6_300us_validation.md), the
-user requested this policy in the shared production function. That sample was
-197/200 (slow 97/100, fast 100/100), not evidence of lossless operation.
-
-- Slow-profile dwell is now `ceil(4.6 * symbol_us)`; the fast floor remains
- `ceil(4.1 * symbol_us)`. Slow/fast is selected by symbol duration, not profile
- index (primary wins equal-symbol ties). Single-profile mode is unchanged.
-- Keep 600 us per switch and use 300 us of loop reserve. Allocate the fast
- visit from half the configured slow preamble duration minus the slow visit,
- two switches and the reserve. The reserve is not an added delay.
-- The pure calculator, automatic preamble calculation, real scheduler and
- acceptance checks all use the same role-specific rounded minima. Advisory
- warnings still account for measured switch overruns without rewriting
- explicit preambles or persisted configuration.
-- SF7/62.5 + SF8/500 with a slow preamble of 32 yields **9,421 us slow and
- 21,847 us fast**, matching the tested plan, in either profile order.
-- Preserve unsafe-airtime/short-slow-preamble rejection, eight-symbol rounding,
- the 32-symbol recommendation minimum, and the historical SF8/500 88-symbol
- floor. For SF7/500 paired with SF7/62.5, the automatic fast preamble changes
- from 72 to 64 because the reserve is smaller. Explicit 32 remains explicit,
- with the existing short-override warning.
-
-Verification covers 1,600 SF/BW pairs with exact role-dependent rounding and
-fast-dwell arithmetic, profile reversal, equal-symbol ties, larger explicit
-slow preambles, infeasible short slow preambles, single-profile off, long
-explicit visits, and advisory-only switch overruns. Production CLI checks
-assert the actual `4.60,42.67` chirp readout, `loop=300us`, warning values and
-unchanged explicit 32 on the tested SF8/500 pair. The frozen HIL plan now
-checks equality against the production 4.6/300-us allocation.
-
-- Host production regression suites: 15/15 passed.
-- Native production radio/routing/retry suite: 63/63 passed.
-- HIL host regression suites: 64/64 passed.
-- Full V4 repeater build: passed; RAM 125,344 bytes, flash 1,597,881 bytes.
-- Full V4 USB companion build: passed; RAM 62,460 bytes, flash 828,657 bytes.
- Both passed runtime-RAM checks; builds ran sequentially in one process.
-- Scoped `git diff --check`: passed.
-- No hardware upload, commit or push for this policy update.
+This document is now in [Test Results](test-results/radio_chirp_math_validation.md).
diff --git a/docs/radio_dwell_policy_validation.md b/docs/radio_dwell_policy_validation.md
index b05158939..06dc21158 100644
--- a/docs/radio_dwell_policy_validation.md
+++ b/docs/radio_dwell_policy_validation.md
@@ -1,69 +1,5 @@
-# Automatic dwell policy: 4.6 chirps on both profiles
+# Document moved
-Historical validation record: the nominal 600 us switch allowance below has
-been replaced by the activation self-test and a 10% guarded measured budget.
-The current automatic policy also removed the fixed 300 us loop allowance and
-the pair-specific SF8/500 88-symbol floor. Values below are retained as bench
-history, not current automatic defaults.
-See [radio profiles](radio_profiles.md#preamble) for current behavior.
+
-The shared `RadioProfiles` function uses a 4.6-symbol minimum for both logical
-profiles of one physical transceiver, rounded upward to a whole microsecond.
-The slower profile is selected by symbol duration, with primary winning ties.
-The fast visit receives the remaining half-slow-preamble budget, subject to
-the same 4.6-symbol minimum. The nominal allowances are 600 us per switch and
-300 us per cycle, not added delays.
-
-The scheduler, automatic preambles, configuration acceptance and diagnostic
-calculator use the same minima. Preserve explicit preambles, safe-airtime
-validation, too-short slow-preamble rejection, recommendation rounding to
-multiples of eight (minimum 32), and the existing SF8/500 versus SF7/62.5
-88-symbol recommendation floor. Measured switching overruns raise advisory
-warnings without rewriting configured values. Single-profile RX still does
-not perform a scan.
-
-For SF7/62.5 plus SF8/500 with slow preamble 32, the allocation remains
-9,421 us slow and 21,847 us fast, in either profile order. The fast visit
-already exceeds 4.6 fast symbols. Raising the fast minimum matters when
-less budget is available: equal SF7/500 profiles with slow preamble 30 no
-longer fit, because only 1,162 us would remain versus the new 1,178 us floor.
-Slow preamble 32 fits with a 1,418 us fast visit.
-
-## Hardware context
-
-The preceding V4 RX / fixed RAK4631 and T1000-E TX bench used explicit
-32-symbol preambles on both profiles, 100 stationary controls per profile
-and a complete 100/profile scan. Controls passed 100/100 each; scanning
-received slow 97/100 and fast 100/100 (197/200 total). Switching averaged
-582 us and peaked at 827 us. Successful-packet mean SNR was +12.27/+13.47 dB.
-No RF retry or USB result replay was used. Radios were left idle.
-
-That test used the same 9,421/21,847 us windows, so the higher fast floor
-does not change this particular allocation. It does not establish lossless
-performance or qualify other SF/BW combinations. Raw capture SHA-256:
-`1c2e0eae3f304fcaeb6addfeef4f2f09a3c2bec8d78c60d3f151c952d0f6d5f5`.
-No additional RF run is required to apply the user-requested policy change.
-
-## Software verification
-
-The portable C++ test sweeps 1,600 SF/BW pairs and checks both 4.6-symbol
-minima, exact fast-window allocation, preamble/diagnostic consistency and
-accepted return deadlines. It also covers reversed/equal symbol times,
-explicit preambles, newly infeasible short settings, long requested visits,
-invalid parameters and advisory-only switching overruns. CLI tests exercise
-warnings, both timing aliases, saved/temporary configuration, persistence
-and bounded replies. Native integration covers routing, retries and packet
-ownership alongside the timing model.
-
-Verification completed on 2026-09-14 (local date):
-
-- Exact commit snapshot: 24/24 host production tests passed, including primary
- CLI settings, shared timing, command dispatch, persistence and reply checks.
-- Native radio/routing/retry integration: 63/63 passed.
-- Existing HIL host regressions: 64/64 passed in the working checkout.
-- Both firmware builds passed from the isolated commit snapshot, without the
- unrelated local lab/Wi-Fi/extra modulation-cache edits: V4 repeater RAM
- 125,336 bytes / flash 1,597,497 bytes; USB companion RAM 62,452 bytes /
- flash 828,257 bytes. Both passed runtime-RAM checks.
-- Staged whitespace and documentation-link checks passed. No radios were
- flashed for this function update.
+This document is now in [Test Results](test-results/radio_dwell_policy_validation.md).
diff --git a/docs/radio_profile_switch_validation.md b/docs/radio_profile_switch_validation.md
index 875844b2c..672953838 100644
--- a/docs/radio_profile_switch_validation.md
+++ b/docs/radio_profile_switch_validation.md
@@ -1,524 +1,5 @@
-# SX1262 profile-switch validation
+# Document moved
-Measured locally on 2026-09-13 using an XIAO ESP32-S3 with WIO SX1262.
-Later tests also use the SenseCAP Indicator ESP32-S3 LoRa processor. The
-[V8 integration](#production-integration-and-usb-recovery-v8) is the current
-implementation; V1-V7 sections/captures document the development history.
+
-## Initial result (V1)
-
-The production retune path averaged **1.434 ms with XOSC held**, versus
-**3.166 ms with RC standby**. Holding the oscillator saved **1.732 ms per
-retune (54.7%)**. Warm-mode maximum was **1.467 ms** across 6,000 measured
-retunes. The 6 ms switch allowance and 4 ms loop allowance are unchanged.
-
-Each row combines both directions and two reversed-order trials, with 2,000
-measured retunes per standby policy. Times run from production `tuneProfile`
-entry until BUSY is observed low. GetStatus confirmed RX after every timed hop.
-
-| Secondary profile | RC mean / maximum | XOSC mean / maximum |
-| --- | --- | --- |
-| SF7 / 500 kHz | 3.161 / 3.199 ms | 1.434 / 1.467 ms |
-| SF8 / 500 kHz | 3.166 / 3.209 ms | 1.435 / 1.464 ms |
-| SF9 / 500 kHz | 3.171 / 3.193 ms | 1.433 / 1.437 ms |
-
-- Primary: 909.5 MHz, SF7 / 62.5 kHz, CR 4/5.
-- Secondary: 910.5 MHz, CR 4/5, automatic production preamble calculation.
-- TCXO configuration remained 1.8 V and 1,600 us startup delay.
-- 12,000 measured retunes; zero retune failures, BUSY timeouts, or RX-mode errors.
-- 109 packet/busy deferrals were excluded from hop timings (57 RC, 52 XOSC).
- Deferral preserves the existing packet and radio ownership checks.
-- The first eight successful hops of every trial were excluded from
- steady-state statistics, including the initial oscillator transition.
-- All 12 trials returned to RC standby after disabling the second profile.
-
-## Scope and reproducibility
-
-The [timing harness](../tools/hil/profile_switch_README.md) compiles the actual
-production wrapper, not the earlier fast/minimal RX benchmark. RC and XOSC
-use the same executable: the baseline changes only the standby policy after
-entering dual-profile mode. No TCXO supply or initial startup delay is disabled.
-The normal guard checks and frequency/SF/BW/CR/preamble setters remain active.
-
-This isolates radio switching from UI, Wi-Fi, BLE, mesh dispatch, and scan-loop
-scheduling. BUSY polling/timer overhead is included; the RX status query is
-outside the timing. This is not a packet-delivery test, first-startup timing,
-power measurement, wide-frequency image-recalibration test, or proof of analog
-RF settling beyond BUSY/RX status. Indicator I/O-expander latency and nRF52
-timing still need their own measurements before reducing common budgets.
-
-Raw results: [profile_switch_results.json](../tools/hil/profile_switch_results.json).
-The collector requires all requested samples, zero failure/timeout/mode-error
-counts, and successful RC restoration before accepting a trial.
-
-Build provenance:
-
-- Base commit: `c329cf1fc1e7f2e8c8733ab0d000094730458fe3`, with the local warm-standby patch.
-- Environment: `profile_switch_xiao`; Espressif32 6.11.0 / Arduino 2.0.17.
-- RadioLib: pinned `187ef24791c3d844939b2be13a68bd890bd04e4c` (7.7.1).
-- Test `firmware.bin` SHA-256:
- `629d39bba80ad97c434fbaab61bc7c3c302b5ac8180cb2cb12806508b2d111e0`.
-- Existing unrelated Companion/repeater/Wi-Fi soak changes were not included
- in this minimal timing executable.
-
-Host regression checks also passed: profile scan/CLI (3 tests), receive contract
-(2 tests), and SX126x RX-status decoding (1 test). A normal
-`heltec_v4_companion_radio_usb` build and the XIAO timing build succeeded.
-
-The XIAO's original 8 MiB flash snapshot was verified against the device while
-held in the bootloader before the timing firmware was uploaded. Raw flash
-backups stay in the current user's private backup directory, outside Git.
-After testing, the entire original snapshot was restored and verified again
-against the device before reboot. The live USB CLI then confirmed `Xiao S3 WIO`
-running `v1.17.1-soak-xiao-B` (11-Sep-2026). Its soak logger was left stopped;
-existing logs were preserved. The Indicator and RAK Tag were not flashed.
-
-## Follow-up: batched modulation
-
-On the same date, a second 12,000-retune experiment held XOSC warm in both
-cases and compared the old three-setter sequence with one combined LoRa
-modulation update. Both paths ran in the same V2 executable, with the second
-round reversing their order. Each mode completed 6,000 timed hops.
-
-| Secondary profile | Separate setters: mean / maximum | Batched: mean / maximum |
-| --- | --- | --- |
-| SF7 / 500 kHz | 1.442 / 1.473 ms | 1.159 / 1.198 ms |
-| SF8 / 500 kHz | 1.444 / 1.480 ms | 1.158 / 1.192 ms |
-| SF9 / 500 kHz | 1.446 / 1.478 ms | 1.156 / 1.163 ms |
-
-Overall: **1.444 ms to 1.158 ms**, saving **0.286 ms (19.8%)** per retune.
-Use this matched V2 baseline for the batching comparison; the earlier V1
-warm-standby numbers came from a different executable layout.
-
-There were zero retune failures, BUSY timeouts, RX-mode errors, or SF/BW/CR
-cache mismatches. All 12 trials restored RC standby after disabling profile 2.
-84 packet/busy deferrals were excluded (54 separate-setter, 30 batched).
-First eight successful hops per trial were excluded as before.
-
-The batching removes two repeated modem checks and two modulation writes.
-The helper validates a complete MeshCore bandwidth/SF/CR tuple, updates
-RadioLib's cached SF/BW/CR before its automatic LDRO calculation, and restores
-the old cache if the command fails. The wrapper's existing physical rollback
-and safe-transition paths are retained. **Frequency changes, preamble updates,
-standby, IRQ/buffer setup, packet parameters, and RX restart remain unchanged.**
-This does not qualify eliminating those other repeated operations.
-
-Raw results: [profile_switch_batch_results.json](../tools/hil/profile_switch_batch_results.json).
-V2 test firmware SHA-256:
-`b4403f6f5eb0a2b0a31c2d79859122225f247296699a578ad2596ef17eaed0fd`.
-Toolchain and RadioLib pin match the first experiment. The normal
-`heltec_v4_companion_radio_usb` build also succeeded with batching enabled.
-
-The two new host tests execute the production method over 1,200 valid
-bandwidth/SF/CR/LDRO combinations, invalid inputs and wrong modem, command
-failures with cache rollback/retry, and the wrapper's failure short-circuit.
-All eight relevant host tests passed. RF packet delivery and application/UI
-scheduling remain outside this timing experiment; shared budgets are unchanged.
-
-## Expanded screening: redundant RX setup and SPI (V5)
-
-Corrected V5 measurements on 2026-09-13 cover **28,800 timed retunes in 300
-trials** across XIAO S3/WIO SX1262 and the Indicator ESP32/SX1262. Each sweep
-uses SF7/8/9 at 500 kHz paired with SF7/62.5 kHz, both directions, reversed
-case order in round two, and eight excluded initial hops per trial.
-All 300 trials completed their sample count and restored RC standby on exit.
-There were **zero timed switch failures, BUSY timeouts, RX-mode errors or
-SF/BW/CR cache mismatches**. The normal packet guards deferred 175 attempted
-hops; these were not counted as timed retunes.
-
-Twenty-one distinct cases were screened: seven individual redundant-operation
-changes, a full manual RX control, combinations, 2/4/8 MHz SPI and whole-buffer
-ESP32 SPI transfers. Shared controls appear in both groups. All new shortcuts
-are **HIL-only**, not included in normal targets.
-
-### Individual and combined RX changes
-
-Means in milliseconds, with warm oscillator and batched modulation throughout.
-Each XIAO row has 768 timed hops; each Indicator row has 384.
-
-| RX/SPI case | XIAO mean | Indicator mean |
-| --- | ---: | ---: |
-| Production batching, 2 MHz SPI | 1.182 | 15.190 |
-| Manual RX sequence, no omissions | 1.181 | 15.135 |
-| Skip second standby | 1.124 | 14.195 |
-| Skip repeated IRQ setup | 1.096 | 14.399 |
-| Skip repeated buffer-base setup | 1.146 | 14.437 |
-| Skip repeated packet-parameter setup | 0.991 | 12.364 |
-| Skip RX modem query | 1.145 | 14.427 |
-| Defer preamble to the one RX packet-parameter write | 0.956 | 11.659 |
-| Skip wake NOP when already awake | 1.146 | 14.576 |
-| Combined omissions, early preamble write | 0.771 | 9.096 |
-| Combined omissions, deferred preamble | 0.732 | 8.384 |
-| 4 MHz SPI only | 0.985 | 14.996 |
-| 8 MHz SPI only | 0.889 | 14.889 |
-| Combined/deferred, 4 MHz SPI | 0.627 | 8.280 |
-| Combined/deferred, 8 MHz SPI | 0.579 | 8.224 |
-
-The largest individual saving is programming preamble/packet parameters once.
-Compare individual omissions with the manual full control when attributing
-their isolated costs: replacing RadioLib's staging dispatch also has overhead.
-Individual savings are not additive; some remove overlapping work.
-
-### Whole-buffer SPI follow-up (same V5 binaries)
-
-| Case | XIAO mean / maximum | Indicator mean / maximum |
-| --- | --- | --- |
-| Production batching, byte loop, 2 MHz | 1.184 / 1.221 ms | 15.185 / 15.785 ms |
-| Bulk transfer only, 2 MHz | 1.070 / 1.073 ms | 15.092 / 15.134 ms |
-| Bulk transfer only, 4 MHz | 0.890 / 0.903 ms | 14.924 / 14.983 ms |
-| Bulk transfer only, 8 MHz | 0.797 / 0.806 ms | 14.836 / 14.908 ms |
-| Combined/deferred, byte loop, 8 MHz | 0.579 / 0.588 ms | 8.222 / 8.843 ms |
-| Combined/deferred + bulk, 2 MHz | 0.671 / 0.700 ms | 8.369 / 9.027 ms |
-| Combined/deferred + bulk, 4 MHz | 0.582 / 0.595 ms | 8.252 / 8.315 ms |
-| Combined/deferred + bulk, 8 MHz | **0.535 / 0.545 ms** | **8.203 / 8.233 ms** |
-
-The fastest XIAO candidate saves **0.649 ms (54.8%)** versus that sweep's
-already-warm, already-batched production control. At 8 MHz, bulk transfers
-alone add about 0.043 ms of benefit to the combined RX shortcut. The Indicator
-remains dominated by I2C-mediated chip-select/BUSY/DIO access; the final bulk
-step improves its combined mean by only 0.019 ms. Small HAL effects varied
-between executable layouts in preliminary screens; do not generalize them.
-
-**The Indicator exceeds the generic 6 ms switch allowance even in its fastest
-experimental path.** These are board measurements, not a justification for
-reducing the shared 6 ms switch / 4 ms loop constants. Indicator scheduling
-and effective preambles need board-specific treatment before a production
-rollout. Application/display/network scheduling is still excluded here.
-
-Raw accepted sweeps:
-
-- [XIAO RX cases](../tools/hil/profile_switch_v5_rx_xiao_complete.json)
-- [Indicator RX cases](../tools/hil/profile_switch_v5_rx_indicator.json)
-- [XIAO bulk SPI](../tools/hil/profile_switch_v5_bulk_xiao.json)
-- [Indicator bulk SPI](../tools/hil/profile_switch_v5_bulk_indicator.json)
-
-### Test corrections and provenance
-
-V3/V4 broad screening accidentally called `SX1262::startReceive()` instead of
-`CustomSX1262::startReceive()` in the fallback, omitting MeshCore's extra
-preamble-detection IRQ flag. Their raw files are marked superseded and are
-not included in the counts or conclusions above. The V4 packet sweep was
-stopped on discovery. V5 preserves that flag in both normal and manual RX
-paths; a host regression test checks the Custom fallback and mapped flags.
-The earlier V1/V2 warm/batching experiments did not use that experimental
-subclass and are unaffected.
-
-An initial V5 XIAO capture stopped on a partial serial JSON line during guarded
-mode exit. It is retained as incomplete, not counted above. The host now
-accumulates reads until newline, with regression coverage. Initial setup
-rejections are retried only for the explicit pre-measurement packet-guard
-reply, bounded to three seconds and counted; packet losses and timed failures
-are never retried away.
-
-Final V5 firmware SHA-256:
-
-- XIAO: `5506a40ffa8240201aca519e8e24db3647f897e7a1290bba22be6e4986d8f02f`
-- Indicator: `dd595b2e7fa8c1ccb14fac9223f1a57277cd6a602864ee9bf090672f494fa2f6`
-
-Base commit/toolchain/RadioLib pin match the earlier experiments. Thirteen unique
-host tests passed (eight production regressions, two HIL boundary/buffer tests,
-three serial-reader/setup-guard tests). The HIL tests cover all 255 nonzero omission masks,
-normal-path fallbacks, preserved IRQ clear/SetRx, error short-circuit and
-full-duplex buffer lengths 1..260. They are not full driver lifecycle tests.
-An additional normal `t1000e_companion_radio_usb` nRF52 build passed with the
-production warm/batching changes: 591,484-byte app, runtime RAM guard passed.
-It was not flashed to the RAK or any nRF52 board.
-
-### Qualification boundary
-
-No BUSY wait, command status validation, stale-IRQ clear, RX command or wrapper
-packet-ownership guard is removed. The fastest path relies on a known
-RX-to-RX window and established RX setup. Correct invalidation after TX, CAD,
-sleep, reset, failed commands, and recovery must be designed and tested before
-promoting these shortcuts. The per-byte/bulk HAL experiment is ESP32-specific.
-Sensitivity/PER, oscillator power draw, large-frequency image recalibration,
-and application-driven continuous scanning remain unqualified.
-
-### Actual packet checks and limitations
-
-Final forward matrix: Indicator transmitter to XIAO receiver, -9 dBm,
-21 cases x SF7/8/9 x both profiles x 16/64/255-byte payloads = **378 probes**.
-**359 sequence/length/payload/profile checks succeeded**, 13 receiver-declared
-wait timeouts occurred, and six receiver USB replies did not arrive before
-the host deadline. Transmitter replies returned success. Successfully read
-packets had RSSI -42 to -39 dBm and SNR 10.2 to 14.2 dB. Timeout diagnostics
-reported RX mode and no device errors; some retained a preamble IRQ. These
-observations do not establish why packets were missed.
-
-The production-batched baseline delivered 17/18 in that matrix; the fastest
-combined/bulk/8 MHz candidate delivered 18/18. An earlier reversed-order
-smoke test of the fastest candidate had one receiver timeout, so the clean
-18-packet batch is **not** a reliability qualification. Earlier 0 dBm checks
-also had baseline and candidate misses. Reducing power did not eliminate them.
-
-Reverse direction: XIAO transmitter to Indicator receiver, -9 dBm, baseline,
-combined/8 MHz and combined/bulk/8 MHz, with the same SF/profile/length matrix.
-**All 54 payloads arrived intact**, 18/18 per case. However, one XIAO USB TX
-acknowledgement was delayed past the host deadline, after which 32 later
-records picked up the previous command's acknowledgement. Thus the raw file
-has only 21 fully matched transactions despite 54 correctly received payloads.
-This is sequence-correlated evidence of a host acknowledgement problem, not
-33 RF losses. Subsequent collector code rejects/stores stale sequence replies
-instead of associating them with a new command, and now asserts DTR on Windows
-to match the existing `s3_memory_soak.py` HWCDC session policy. The captures
-above used DTR deasserted; a causal DTR A/B test was not performed. Sequence
-handling has host regression coverage, but these last host changes were not
-rerun on HIL firmware after the boards were restored.
-
-USB acknowledgements and actual receiver payload validity are recorded
-separately. Missing acknowledgements never trigger an automatic retransmit.
-The host drains the receiver concurrently with the transmitter's command and
-records timeouts without hiding them. These tests establish some real packet
-functionality, not a controlled packet-error-rate comparison. Fix/qualify the
-test transport, repeat with a stable RF reference, then test TX/CAD/reset/RXPS
-and continuous application scanning before promoting the shortcuts.
-
-- [Forward matrix, all 21 cases](../tools/hil/profile_switch_v5_packets_complete.json)
-- [Reverse matrix, three cases](../tools/hil/profile_switch_v5_packets_reverse_complete.json)
-- [0 dBm smoke](../tools/hil/profile_switch_v5_packet_smoke.json)
-- [-9 dBm reversed-order smoke](../tools/hil/profile_switch_v5_packet_minus9_smoke.json)
-
-Earlier incomplete packet captures and superseded screens are retained for
-audit, not counted in the 378/54 final matrices. No identity or channel secrets
-were transmitted: probes used synthetic sequence-tagged bytes.
-
-## Production integration and USB recovery (V8)
-
-Normal XIAO S3 WIO and Indicator LoRa variants now opt in to buffered 8 MHz
-SPI and a guarded fast RX-to-RX path. Other SX1262 variants retain default
-SPI/full RX setup; modulation batching and warm standby remain shared.
-
-The fast path keeps the TCXO warm, applies one SF/BW/CR tuple and one packet
-tuple, clears stale IRQs and starts RX with the original BUSY handling. It
-reuses an established LoRa IRQ map and buffer bases only within an owned
-continuous-RX retune. Ordinary standby, staging/TX, CAD, sleep, reset/init,
-RX duty cycling and command failures invalidate that reuse. RX startup failure
-now fails the whole profile transaction and restores the old profile/caches
-with full setup; failed recovery keeps the profile marked for refresh.
-
-### Sleep recovery discovered during integration
-
-Initial lifecycle captures found `XOSC_START_ERR` (0x20) after retained sleep,
-despite successful RX status. RC-policy controls reproduced the same flag:
-this was not established as a regression caused by the fast path. Simply
-waking in RC and entering standby before sleep did not clear it. Reapplying
-the already configured TCXO voltage and 1,600 us delay resolved it.
-
-The production sleep override now enters standby first, wakes in RC, and
-restores TCXO configuration on wake. It refuses to clear unrelated device
-faults. The warm policy resumes after full RX setup. This follows the standby
-entry requirement in section 13.1.1 of the
-[Semtech SX1261/2 datasheet](https://files.waveshare.com/wiki/SX1262-XXXM-LoRaWAN-GNSS-HAT/DS_SX1261-2_V1.2.pdf).
-Section 13.3.6 describes TCXO control and the XOSC error flag; it does not by
-itself prove the cause of this retained-sleep observation.
-
-Diagnostic files `profile_switch_v6_recovery_xiao.json`,
-`profile_switch_v7_recovery_xiao.json`,
-`profile_switch_v7_recovery_xiao_complete.json` (despite its filename, its
-`complete` field is false), and `profile_switch_v7_sleep_diagnostic_xiao.json`
-retain these failures. They are not counted as passing lifecycle tests.
-
-### USB fix and scope
-
-The HIL host distinguishes Espressif native CDC (DTR asserted) from CH340
-bridges (DTR/RTS deasserted). Replies are newline-assembled and sequence-matched.
-Firmware caches completed packet **and timing** reports before emitting and
-flushing them. A timeout or malformed report triggers a sequence-scoped result
-query, never a repeated TX or timing run. Randomized session sequence starts
-avoid a cached result from an earlier host session.
-
-V7 had successfully fixed/retested packet result replay but a long native-USB
-timing sweep still lost one response. V8 adds whole-report timing buffering and
-replay. The incomplete V7 XIAO timing capture is retained, not silently completed.
-The public MeshCore Companion USB protocol is unchanged; these USB fixes are
-for the radio-lab transport. The previous broad Windows-DTR policy described
-in the V5 history above has been replaced by native-device identification.
-
-Both boards passed **20 reopen cycles**, deliberate lost timing replies,
-deliberate lost TX replies, stale TX reply isolation, and **18 lifecycle cases**
-each. The cases cover TX, CAD, sleep, reset/init, RC-policy sleep and explicit
-TCXO-restoration controls, repeated three times. Fast counters prove one full
-RX fallback after invalidation and reuse only on the following retune.
-All command results, RX-mode checks and device-error checks passed.
-
-- [XIAO recovery](../tools/hil/profile_switch_v8_recovery_xiao.json)
-- [Indicator recovery](../tools/hil/profile_switch_v8_recovery_indicator.json)
-
-V8 test firmware SHA-256 (same pinned RadioLib/toolchain as above):
-
-- XIAO: `e1806bbf4af5a88b31423131806a682713221082235507ff95e5beb9ef3ac067`
-- Indicator: `bab4009078a4fff68dfb7aa73a5afc83e84f5c47589354d8aeb8e5b8d8798442`
-
-Normal USB Companion builds passed for `Xiao_S3_WIO_companion_radio_usb`
-(813,901 app bytes / 63,012 static RAM),
-`SenseCapIndicator-LoRa_comp_radio_usb` (879,809 / 62,960), and
-`t1000e_companion_radio_usb` (591,980 / 179,868). Runtime RAM budgets passed
-on all three. These normal application images were compiled, not substituted
-for the user's configured soak firmware during the HIL tests.
-
-Host/native regression coverage includes production modulation caches/LDRO,
-standby failure short-circuit, RX-start rollback failure, ownership guards,
-fast-state invalidation, TCXO wake fault preservation, buffered SPI semantics,
-USB result overflow/replay, partial reads and stale-sequence handling.
-
-### Final steady-state timing
-
-V8 ran 256 timed retunes per trial, four production configurations, SF7/8/9
-at 500 kHz paired with SF7/62.5 kHz, and two reversed-order rounds on each
-board: **12,288 measured retunes / 48 trials**. The first eight hops per trial
-are excluded. There were zero retune failures, BUSY timeouts, RX-mode errors
-or modulation-cache errors. All 48 trials restored RC policy on exit. The
-normal packet guards deferred 21 XIAO and 59 Indicator hops; no setup retries
-or naturally missing/replayed USB reports occurred in these final sweeps.
-
-| Production configuration | XIAO mean / maximum | Indicator mean / maximum |
-| --- | --- | --- |
-| Warm + batching, per-byte SPI 2 MHz | 1.193 / 1.227 ms | 15.174 / 15.836 ms |
-| Fast RX, per-byte SPI 2 MHz | 0.744 / 0.770 ms | 8.426 / 8.471 ms |
-| Full RX, buffered SPI 8 MHz | 0.828 / 0.840 ms | 14.816 / 15.386 ms |
-| Fast RX + buffered SPI 8 MHz | **0.549 / 0.556 ms** | **8.263 / 8.879 ms** |
-
-The combined production path saves **54.0% on XIAO** and **45.5% on Indicator**
-relative to each same-image warm/batched control. Small differences from V5-V7
-reflect different binaries/code layout; only within-image A/Bs are compared.
-Both fast configurations must increment the production success counter; full
-RX controls must not. Mode/cache verification happens after the timed region.
-
-- [XIAO V8 timing](../tools/hil/profile_switch_v8_production_xiao.json)
-- [Indicator V8 timing](../tools/hil/profile_switch_v8_production_indicator.json)
-
-The common 6 ms switch and 4 ms loop allowances are unchanged, not delays
-inserted by the driver. Indicator still exceeds 6 ms. These measurements do
-not qualify continuous application scheduling, scan-overlap reception,
-sensitivity/PER, power use, temperature extremes, or image recalibration on
-frequency changes larger than this 1 MHz pair. Do not shorten common transmit
-preambles or advertise the XIAO number for other boards.
-
-### Final over-air packet matrix
-
-**288/288 payloads and command transactions passed**: 144 Indicator-to-XIAO
-and 144 XIAO-to-Indicator. Each direction covers all four production cases,
-SF7/8/9 secondary profiles, both target profiles, 16/64/255-byte synthetic
-payloads and two reversed-order rounds. TX used buffered 8 MHz SPI at -9 dBm.
-Fast receiver readiness reports had to show actual production fast resumes.
-
-There were **zero RX errors, receiver timeouts, missing replies, stale replies,
-or natural transport-recovery queries** in these matrices. Deliberately lost
-and stale replies were separately exercised by the recovery tests above.
-RSSI ranged -43 to -39 dBm and SNR 10.2 to 14.5 dB. This clean short-range
-matrix addresses the failures seen in the earlier V5 harness; it does not
-identify which single change caused each historical miss or establish a
-field packet-error rate.
-
-- [Indicator TX / XIAO RX](../tools/hil/profile_switch_v8_packets_forward.json)
-- [XIAO TX / Indicator RX](../tools/hil/profile_switch_v8_packets_reverse.json)
-
-All **18 host/native regression tests** passed. The HIL builds do not include
-the unrelated pre-existing Companion/repeater/Wi-Fi soak edits; the normal
-application compile checks include the current working tree and preserve them.
-
-A subsequent requested [SF6/125 kHz multi-channel test](sf6_channel_scan_validation.md)
-used 32-symbol preambles and 4.8-symbol visits. It stopped on the third packet
-at the initial four channels (two received, one timeout); no higher count was
-tried. That continuous scan experiment is separate from the 288/288
-stationary-after-retune matrix above.
-
-## Unchanged modulation tuple follow-up
-
-The initial software-only checks below were followed by
-[same-image hardware timing and separated-radio tests](separated_radio_modulation_cache_validation.md).
-Frequency-only means improved from 548.880 to 451.897 us on XIAO and from
-8,263.438 to 7,601.193 us on Indicator. Four-channel SF6 reception was not
-loss-free. The following paragraph records the initial implementation stage,
-not the current hardware-test status.
-
-Implemented 2026-09-14 after commit `0e5955f8`, without restarting hardware
-tests. On opted-in fast RX-to-RX hops, `setLoRaModulationParams()` now compares
-the requested SF, encoded bandwidth, encoded CR and effective LDRO against
-the last successfully acknowledged modulation write. An exact match within
-a valid owned standby window omits only `SetModulationParams` (0x8B).
-Frequency programming, the modem query, preamble/packet parameters, IRQ clears,
-RF-switch control and BUSY waits remain unchanged.
-
-The acknowledgement is separate from RadioLib's mutable software fields.
-Auto/manual LDRO compares the resulting command byte, with the pinned
-RadioLib >=16 ms symbol rule. A failed write revokes the whole fast context
-before rollback. Ordinary SF/BW/CR/forced-LDRO setters invalidate the saved
-tuple even on failure. Existing full-RX staging, ordinary standby, TX/CAD
-staging, reset/init, sleep, duty cycling and failed RX/standby operations also
-invalidate it. The first hop after lost context therefore writes again;
-frequency-only hops that retain the context can skip subsequent writes.
-
-All **30 selected host/native tests passed**, including repeat-write counts
-for all supported bandwidth/SF/CR combinations and auto/forced LDRO modes,
-each independently changed field, LDRO policy transitions, failed-write retry,
-modem-query failure, ownership gates, ordinary setters and lifecycle invalidation.
-Both `profile_switch_xiao` and `profile_switch_indicator` compiled successfully
-against the pinned RadioLib. No firmware was uploaded, no radio command was
-issued, and no new RF result was collected. The temporary build overlay was
-removed. The earlier ~0.549 ms XIAO and ~8.263 ms Indicator timings do **not**
-measure this added optimization; its time saving and RF behavior await testing.
-
-## MercerMesh Heltec T096 SX1262 experiment (2026-09-21)
-
-The Pi has **one** Heltec T096 (USB serial `651F8E496197F882`); the other
-Heltec-named nRF52 is a MeshTower V2, not another T096. An application-only
-T096 HIL image tested two further RX-to-RX shortcuts with a buffered 8 MHz SPI
-bus: use SX1262 `SetFs` instead of warm XOSC standby, and skip
-`SetPacketParams` only when the LoRa RX packet tuple is unchanged. Both switches
-defaulted off in this HIL image. The image explicitly enabled the existing
-production fast-RX path. Normal T096 firmware does not currently enable that
-path or use the buffered SPI HAL, so these numbers are **not** stock T096
-switch timings.
-
-The same image ran 256 measured hops per configuration, after eight warm-up
-hops, on 909.5/909.75 MHz. `freq` held SF7/BW125/CR5; `both` alternated
-SF7/SF8 at BW125/CR5. Preamble was 32 symbols. The DWT cycle counter measured
-through RX re-entry and BUSY release. Every 8 MHz configuration had zero
-switch failures, mode/cache/RSSI errors, or BUSY timeouts.
-
-| 8 MHz HIL configuration | Frequency-only mean | Frequency + SF mean |
-| --- | ---: | ---: |
-| Fast RX baseline | 467.3 us | 553.6 us |
-| `SetFs` only | 422.5 us | 517.4 us |
-| Unchanged packet tuple skip only | 357.7 us | 444.0 us |
-| Both | **313.0 us** | **407.8 us** |
-
-Combined savings were 154.3 us (33.0%) for frequency-only and 145.8 us
-(26.3%) for frequency-plus-SF relative to the same-image fast-RX baseline.
-Command counts confirmed one frequency command, IRQ clear and RX command per
-hop; the optimized cases eliminated the corresponding standby and packet
-commands. These are controlled short-range, same-band results, not a field
-packet-error rate or a qualification of large frequency jumps, sleep/wake,
-TX/CAD interruption, BLE scheduling, or all SX1262 boards.
-
-A separate RAK4631 fixed transmitter sent 16-byte synthetic packets at
-909.5 MHz/SF10/BW125/CR5/preamble32, -9 dBm chip power. The T096 hopped to
-909.75 MHz and back before each transmission. Baseline and combined shortcuts
-each delivered **60/60** packets after **120** fast hops, with payload sequence
-and content checked. The RAK bootloader on this exact fixture enumerated as
-`239A:002A` (the fixture's earlier `0029` expectation was corrected).
-
-Doubling buffered SPI to 16 MHz gave only 12–21 us (about 3–4%) savings in
-comparable successful cases, while five of eight setup attempts failed. The
-combined shortcut had no clean 16 MHz result. Keep this T096 at 8 MHz pending
-a separately diagnosed and repeatable 16 MHz bring-up; no 16 MHz over-air
-qualification was attempted.
-
-Both radios were restored via app-only DFU after the experiment: the T096 to
-`Heltec_t096_repeater` and the RAK to `RAK_4631_companion_radio_full`, each
-built at `v1.17.1.7-hil-restore-e7160dfd`. The T096 answered normal `version`
-and `board` commands; the RAK answered the Full Companion terminal's version
-command. The V4 observer's memory-soak service remained active. Reproducible
-bench code is in `tools/hil/profile_switch_t096_sx1262.cpp` and
-`tools/hil/t096_sx1262_packet_probe.py`.
-
-Follow-up: owned fast RX, `SetFs`, and acknowledged packet-tuple reuse are
-now defaults for `CustomSX1262` targets, including the original XIAO S3 WIO,
-Station G3 KISS, and Heltec V4 KISS paths. The Seeed Indicator retains its
-original owned-RX path but explicitly opts out of the two new shortcuts because
-its radio control and BUSY/IRQ pins run through an I2C expander. The owned-RX
-state now remembers only an acknowledged packet tuple and revokes it on
-ordinary packet setters, TX/CAD staging, standby outside the hop, sleep,
-reset, RX duty cycling, or command failure. The T096 numbers remain lab
-measurements, not on-board qualification of every `CustomSX1262` target.
+This document is now in [Test Results](test-results/radio_profile_switch_validation.md).
diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md
index 071a2c0ac..183c4160b 100644
--- a/docs/radio_profiles.md
+++ b/docs/radio_profiles.md
@@ -320,12 +320,12 @@ channel until reception finishes.
The SF8 / 500 kHz value is calculated from the same formula as every other
profile; the older pair-specific 88-symbol floor has been removed. The explicit
-values come from [historical production validation](radio_profiles_validation.md):
+values come from [historical production validation](test-results/radio_profiles_validation.md):
the final fast-channel attempts each received 50/50 packets at 4.8 slow symbols.
Automatic values use the measured switch allowance below.
Those explicit-preamble tests used the older 4.8-symbol policy; they do not
validate the newer 4.6-symbol slow dwell. The
-[4.6/0.3 ms two-profile bench](radio_dwell_policy_validation.md#hardware-context) received 197/200
+[4.6/0.3 ms two-profile bench](test-results/radio_dwell_policy_validation.md#hardware-context) received 197/200
at SF7/62.5 + SF8/500 with explicit 32-symbol preambles. The newer four-channel
5.1/6.1/7.7 tests also had misses, so this is a timing policy, not a claim of zero packet loss.
The automatic fast preamble covers the blind interval during the slow visit,
@@ -438,7 +438,7 @@ The model preserves two slow-profile return opportunities and the existing
not a fitted success curve, and neither packet overlap nor arbitrary application
stalls can be made safe by a finite preamble recommendation.
-See the [implementation and validation notes](radio_dwell_policy_validation.md).
+See the [implementation and validation notes](test-results/radio_dwell_policy_validation.md).
## Schedule the second profile
@@ -491,7 +491,7 @@ profile. Packets/retries bound to an expired or changed session are discarded.
Dual mode suspends RX power saving and MCU idle sleep so receive visits can run
promptly. The saved RX power-saving setting returns when profile 2 is off.
The scan uses **normal receive**, following the
-[CAD/RX measurements](cad_scan_validation.md), with the updated 4.6-symbol dwell on the slower
+[CAD/RX measurements](test-results/cad_scan_validation.md), with the updated 4.6-symbol dwell on the slower
channel, followed by the remaining fast-channel visit. It does not use CAD for scanning.
The separate CAD check before an initial transmission still follows `cad`.
@@ -506,13 +506,13 @@ power during the short standby intervals.
The switch-time self-test is not a forced settling delay or a proof of RF
acquisition. The earlier standalone
-[keep-warm scan experiment](mixed_scan_validation.md) averaged about 1.19 ms
+[keep-warm scan experiment](test-results/mixed_scan_validation.md) averaged about 1.19 ms
of overhead per hop (scan-cycle time minus both receive windows, divided by
two). That experiment used a leaner receive path; it is not a measurement or
guarantee of the production implementation. Measure the target board before
shortening its timing margins or transmit preambles.
-The [production-path XIAO timing test](radio_profile_switch_validation.md)
+The [production-path XIAO timing test](test-results/radio_profile_switch_validation.md)
measured **1.434 ms average / 1.467 ms maximum** with warm standby, compared
with **3.166 ms average** using RC standby: about **1.732 ms saved per hop**.
It covered 12,000 retunes across SF7/8/9 at 500 kHz paired with SF7/62.5 kHz.
@@ -524,7 +524,7 @@ A same-image follow-up batching SF/BW/CR into one modulation command reduced
warm switching from **1.444 ms to 1.158 ms average** (batched maximum **1.198 ms**),
saving another **0.286 ms / 19.8%**. RadioLib's modulation caches and LDRO
calculation remain synchronized; frequency, preamble, packet guards, and RX
-restart are unchanged. A later [HIL-only screening](radio_profile_switch_validation.md#expanded-screening-redundant-rx-setup-and-spi-v5)
+restart are unchanged. A later [HIL-only screening](test-results/radio_profile_switch_validation.md#expanded-screening-redundant-rx-setup-and-spi-v5)
tested redundant standby/IRQ/buffer/packet setup and SPI transfer changes.
Its fastest candidate measured 0.535 ms mean on XIAO but 8.203 ms on the
Indicator, whose radio GPIOs go through an I2C expander. The validated production
@@ -549,11 +549,11 @@ including when automatic LDRO policy changes. Ordinary setters and lost RX
context invalidate that acknowledgement; initial setup, reset, sleep, failed
commands and full RX setup conservatively reapply the tuple. Frequency,
preamble/packet setup, IRQ clearing and BUSY checks are unchanged. This follow-up
-has [same-image hardware measurements](separated_radio_modulation_cache_validation.md):
+has [same-image hardware measurements](test-results/separated_radio_modulation_cache_validation.md):
frequency-only hops averaged 0.452 ms on XIAO and 7.601 ms on Indicator.
The mixed-bandwidth timing figures above predate it. Four-channel SF6 scans
still missed packets; faster switching is not proof of loss-free acquisition.
-See [production and USB validation](radio_profile_switch_validation.md#production-integration-and-usb-recovery-v8)
+See [production and USB validation](test-results/radio_profile_switch_validation.md#production-integration-and-usb-recovery-v8)
for current measurements, lifecycle tests and limitations.
The Indicator result shows why each board now measures its own switch time.
That measurement adjusts automatic preambles and dwell, but it cannot verify
diff --git a/docs/radio_profiles_validation.md b/docs/radio_profiles_validation.md
index cdb7cb105..1cf5d94b8 100644
--- a/docs/radio_profiles_validation.md
+++ b/docs/radio_profiles_validation.md
@@ -1,204 +1,5 @@
-# Dual-profile receive validation
+# Document moved
-These measurements exercise the production two-profile implementation on one
-SX1262 transceiver, using a Heltec V4 repeater as receiver and a XIAO ESP32-S3
-with a Wio LoRa module as transmitter. They follow the dedicated
-[mixed-bandwidth scan experiment](mixed_scan_validation.md).
+
-Counts, diagnostic replies and firmware/source hashes are in
-[radio_profiles_results.json](../tools/hil/radio_profiles_results.json).
-
-The primary profile is 909.5 MHz, SF7, 62.5 kHz, CR 4/5, preamble 32.
-The secondary is 910.5 MHz, 500 kHz, CR 4/5, with SF9, SF8 or SF7.
-Scanning uses normal RX, starts with the slower profile, and reserves two slow
-visits per slow preamble. Transmitters must use the corresponding preamble too.
-
-## Method and limits
-
-The transmitter sends MeshCore adverts at varied intervals. The receiver's
-per-profile MeshCore RX counters are compared with the transmitter's completed
-TX counters before and after each run. Initial OTA announcements can add packets
-beyond the manually requested adverts; the table includes those transmissions.
-These are aggregate counters, not sequence-matched payload captures.
-
-Runs use one transmitting profile at a time and a strong signal. They do not
-measure simultaneous traffic, weak-signal reception, interference or months of
-uptime. A single transceiver remains unavailable to the other profile while
-receiving or transmitting a packet. A clean finite run is evidence for a timing
-choice, not a guarantee that it cannot drop packets.
-
-## Five-symbol baseline
-
-Slow visits were 10.240 ms and fast visits 6.528 ms, plus actual switching time.
-The longest observed profile switch was 4.416 ms; no switch failures were reported.
-
-| Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
-| --- | ---: | ---: | ---: |
-| SF9 / 500 | 48 | 54 / 54 | 30 / 30 |
-| SF8 / 500 | 72 | 49 / 50 | — |
-| SF8 / 500 | 80 | 49 / 50 | — |
-| SF8 / 500 | 88 | 50 / 50 | 30 / 30 |
-| SF7 / 500 | 120 | 50 / 50 | 30 / 30 |
-
-The SF8 automatic preamble retains a measured floor of 88 symbols. Automatically
-selected preambles always round up in steps of eight.
-
-## Fractional-symbol tuning
-
-The initial sweep started at 4.2 slow symbols, with an increase of 0.1 planned
-for any slow-channel loss. That first sweep passed all three fast settings.
-Each completed run used 50 requested adverts per profile. The SF8 fast run was
-interrupted to check SF9 preamble 32, then restarted; only its completed run is
-included below.
-
-| Slow symbols | Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
-| ---: | --- | ---: | ---: | ---: |
-| 4.2 | SF9 / 500 | 48 | 50 / 50 | 50 / 50 |
-| 4.2 | SF9 / 500 | 32 | 50 / 50 | 50 / 50 |
-| 4.2 | SF8 / 500 | 88 | 50 / 50 | 50 / 50 |
-| 4.2 | SF7 / 500 | 120 | 50 / 50 | 50 / 50 |
-
-The four completed pairings received 200/200 packets on the slower channel and
-200/200 on the faster channel. No switch failures were reported; the longest
-measured switch during the 4.2-symbol sweep was 4.407 ms.
-
-The explicit SF9 preamble-32 check uses the same 8.602 ms slow and 8.166 ms fast
-windows as the preamble-48 check. Only the fast transmitter's preamble changes.
-The automatic SF9 calculation still reserves a 48-symbol preamble for the
-configured switching and loop-jitter budgets.
-
-## Shorter fast preambles
-
-The follow-up starts with preamble 48 on both fast settings. Each miss advances
-the next candidate by eight symbols. The receive-window budgets stay at 4.2
-slow symbols and the same remaining fast-channel time.
-
-| Fast profile | Preamble | Fast RX / TX |
-| --- | ---: | ---: |
-| SF8 / 500 | 48 | 49 / 50 |
-| SF8 / 500 | 56 | 50 / 50 |
-| SF7 / 500 | 48 | 42 / 50 |
-| SF7 / 500 | 56 | 41 / 50 |
-| SF7 / 500 | 64 | 45 / 50 |
-| SF7 / 500 | 72 | 50 / 50 |
-
-The slower channel passed 50/50 paired with SF8/56, but then received **49/50**
-paired with SF7/72. Across the completed 4.2-symbol slow-channel runs, that is
-299/300. This later miss invalidated the provisional 4.2-symbol choice and
-triggered the next headroom test at 4.3 symbols. Fast-preamble baselines carry
-forward as 32 for SF9, 56 for SF8 and 72 for SF7, advancing by eight on a miss.
-
-At 4.3 symbols, the SF9/32 pairing received 100/100 slow packets and 50/50 fast
-packets. The next SF8/56 pairing received only 42/50 slow packets, so that
-candidate also failed. A control run with scanning disabled received 50/50 on
-the slow channel. At 4.4, SF8/56 passed 100/100 slow and 50/50 fast, but the
-SF7/72 pairing then received 49/50 slow packets.
-
-At 4.5, the SF7 pairing passed 100/100 slow packets. Fast preamble 72 received
-47/50, so the next baseline increased to 80 and passed 50/50. The following
-SF8/56 slow run received 49/50, triggering 4.6 symbols. Baselines now carry
-forward as SF9/32, SF8/56 and SF7/80.
-
-At 4.6, both SF8/56 and SF7/80 passed 100/100 slow packets and 50/50 fast
-packets. The following SF9/32 slow run received 49/50, triggering 4.7 symbols.
-There were no switch failures; the longest observed switch was 4.414 ms.
-
-At 4.7, the SF9/32 slow run passed its first 50 packets but received 99/100
-over the full run. This triggered 4.8 symbols, with the same fast preamble
-baselines. No fast-channel run was started for the 4.7 candidate.
-
-## Selected bench baseline: 4.8 slow symbols
-
-The 4.8-symbol sweep passed all three slow-channel checks, totaling 300/300.
-Slow visits are 9.831 ms and fast visits 6.937 ms, plus actual switching time.
-The longest measured switch was 4.491 ms, with no switch failures.
-
-| Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
-| --- | ---: | ---: | ---: |
-| SF9 / 500 | 32 | 50 / 50 | 100 / 100 |
-| SF8 / 500 | 56 | 49 / 50 | 100 / 100 |
-| SF8 / 500 | 64 | 50 / 50 | — |
-| SF7 / 500 | 80 | 50 / 50 | 100 / 100 |
-
-The SF8 miss advanced its baseline from 56 to 64, exactly eight symbols. Its
-slow-channel check preceded that increase; changing the fast preamble leaves
-the idle receive-window calculation unchanged. The final fast-channel attempts
-received 150/150, using explicit preambles **32 / 64 / 80** for SF9 / SF8 / SF7.
-These become the starting values for further tests, increasing by eight on a
-fast-channel miss. A slow-channel miss instead advances the listen window by
-0.1 symbol.
-
-The production automatic preambles remain **48 / 88 / 120**, retaining the
-switching, loop-jitter and acquisition budgets. The shorter explicit values are
-bench results under the conditions above, not a guarantee for other boards or
-traffic patterns.
-
-## Transmit policy
-
-Hardware checks send three zero-hop adverts from the V4 with the XIAO listening
-on profile 2. OTA startup traffic is allowed to settle before taking the counter
-snapshots. The completed cases are:
-
-| V4 profiles and policy | TX on primary / secondary | Received by XIAO |
-| --- | ---: | ---: |
-| Permanent / permanent, `auto` | 3 / 3 | 3 |
-| Permanent / permanent, `off` | 3 / 0 | 0 |
-| Permanent / temporary, `auto` | 3 / 0 | 0 |
-| Permanent / temporary, `on` | 3 / 3 | 3 |
-| Secondary RX only, `on` | 3 / 0 | 0 |
-| Temporary / temporary, `auto` | 3 / 3 | 3 |
-
-A separate isolation check issued three explicit OTA announce commands with
-normal `radio`, RX-only `tempradio2` and `radio2.cross auto`. The completed TX
-counters stayed unchanged on both profiles (0 / 0), confirming that locally
-generated OTA traffic does not fall back to the normal channel in that mode.
-
-Turning `radio2` off stopped scanning. A one-minute `tempradio2` session also
-returned to one profile on expiry. Rebooting with a saved SF8 RX profile and an
-active temporary SF9 RX/TX profile cleared the temporary session and restored
-the saved SF8 profile, including its automatic 88-symbol preamble.
-
-## OTA discovery while retaining the main channel
-
-Both boards ran normal SF7 / 62.5 kHz on the primary and temporary SF8 / 500 kHz
-RX/TX on the secondary, using `radio2.cross auto` and automatic preambles 32/88.
-The XIAO discovered the V4's firmware catalogue over profile 2, and both boards
-recorded received and transmitted OTA packets there. Ten V4 zero-hop adverts
-then arrived on the XIAO's primary profile (10/10), with both temporary second
-profiles still active. The XIAO reported no switch failures and a longest switch
-of 4.547 ms during this check.
-
-This hardware check covers discovery, bidirectional OTA traffic and concurrent
-availability of the main channel between packets. It does not perform a complete
-firmware transfer or install. Byte-exact transfer validation is provided by the
-shared OTA software tests described below.
-
-## Software coverage
-
-Native tests cover identical-packet fanout, separate retry ownership and coding
-rates, channel-busy backoff, generation changes, temporary-session isolation,
-deferred replies and stale-packet removal. Tests using the production CLI cover
-parsing, persistence failures, schedules, expiry and clock rollover. A physical
-radio mock exercises the production scan transitions, pending RX interrupts,
-RX power-saving restoration and failed profile switches.
-
-The full native suite passed 1,422 cases after the RX-only OTA isolation fix;
-all eight KISS modem tests also passed. Subsequent timing changes through 4.8
-symbols passed the 63-case retry/profile suite and production CLI and scan
-harnesses. Shared OTA transfer tests passed byte-exact checks with
-AddressSanitizer and UndefinedBehaviorSanitizer for ESP32 and nRF52 variants.
-
-Build coverage includes the Heltec V4 repeater, room server, sensor and terminal
-chat, the Full XIAO Companion, and the nRF52 T1000-E repeater. The nRF52 result
-is a build check; these receive measurements use the ESP32/SX1262 boards above.
-
-## Hardware restored after validation
-
-Both boards were returned to their original B soak images after the checks:
-`v1.17.1-soak-v4-B` and `v1.17.1-soak-xiao-B`. The V4 retained 909.5 MHz,
-SF7 / 62.5 kHz, CR 4/5 and TX 0; the XIAO retained 910.525 MHz with the same
-modulation and TX 22. Both reconnected to Wi-Fi and their custom MQTT service.
-The soak logs resumed under `B-post-radio2-soak`. These restored images are the
-earlier memory-soak firmware; the new dual-profile code is validated by the
-finite tests above, not by a months-long uptime run.
-The Pi's soak service and Indicator telemetry were also healthy after restoration.
+This document is now in [Test Results](test-results/radio_profiles_validation.md).
diff --git a/docs/radio_receive_calibration.md b/docs/radio_receive_calibration.md
index 44402b4c2..d4ff931c3 100644
--- a/docs/radio_receive_calibration.md
+++ b/docs/radio_receive_calibration.md
@@ -1,88 +1,5 @@
-# Radio receive calibration and recovery
+# Document moved
-Noise calibration collects 64 idle RSSI samples at least 50 ms apart. A quiet
-block takes about 3.2 seconds. Both continuous RX and temporary RXPS calibration
-windows allow up to 30 seconds; a partial block times out without publishing.
-Periodic requests coalesce while a block is active. RXPS resumes after a complete
-block, including a held block, or at timeout. Receive and transmit activity are
-never interrupted just to finish calibration.
+
-The block estimate is the 12.5th percentile: the average of the eighth and
-ninth sorted samples, retaining fractional dBm. A published update uses 25% of
-the previous floor and 75% of the new estimate, with a -120 dBm lower bound. Initial calibration and explicit gain/tuning changes seed a new baseline.
-The old published value remains available while collecting. An AGC reset drops
-partial samples while retaining a valid baseline for channel checks.
-
-An estimate more than 15 dB above the established floor is held twice. The third
-consecutive complete high block is accepted, allowing recovery after a permanent
-ambient rise. A normal or quieter complete block clears the hold. Partial
-blocks do not advance it. There is no admission threshold based on the old
-floor, so an old low baseline cannot reject every sample from a higher floor.
-
-Hardware captures with sustained different-SF/BW traffic showed the median and
-lower quartile following the interferer. The 12.5th percentile retained the
-quiet background in those captures. It needs at least nine background samples
-out of 64 to avoid higher-valued traffic entirely. Nearly continuous
-interference or enough low outliers can still mislead it. The rise hold delays
-large changes but cannot identify their source.
-
-RSSI sampling waits 20 ms after every receive re-arm, including CAD and TX
-completion, to avoid unsettled frontend readings. The ten-second trial window
-often failed to complete during heavy traffic with that settling guard; the
-30-second cap allows more idle opportunities. Partial blocks still time out
-without publishing. Longer collection spends more time in continuous RX when
-RXPS is enabled. A quiet minimum is about 3.2 seconds; real completion time
-also depends on packet activity and main-loop scheduling.
-
-On radios with a mode probe, each eligible RSSI sample first checks that the
-chip has not silently left RX. Confirmed standby/non-RX discards the partial
-block without changing the published floor. Rejected attempts are also spaced
-50 ms apart. BUSY and packet ownership block sampling; families without a mode
-probe retain their existing RSSI path.
-
-CAD has a bounded wait, stops RX duty cycling before scanning, and re-arms the
-configured receive mode on success, busy detection, or error. A completed or
-currently receiving packet owns the radio and blocks CAD. A packet interrupt
-arriving during re-arm is retained. Failed re-arm and a newly arrived packet
-both defer transmission.
-
-SX126x wrappers additionally run a recovery watchdog at most once per
-10 seconds while continuous RX is expected. RXPS sleep, busy SPI, pending packets,
-and invalid status reads do not count as non-RX evidence. Two confirmed non-RX
-observations request soft recovery; a third uses the existing hard recovery path
-where supported. An observed healthy RX state clears the sequence. Recovery
-preserves tuning, TX power, receive gain, and the operator's power-saving intent.
-The existing radio liveness and RXPS watchdogs remain active for other failures.
-
-The radio liveness watchdog measures time without a successfully received
-packet. Only a positive `recvRaw()` result restarts its observation window;
-TX completion, CAD interrupts, and failed packet reads do not. Reception counts
-even if application parsing rejects the packet or the packet pool is full.
-The check runs after pending RX is drained and TX completes, before the next
-queued transmission, so sustained outbound traffic cannot postpone recovery.
-
-The default silence thresholds are 30 minutes for a soft RX/AGC re-arm and
-12 hours for a radio-only hard recovery where supported. MQTT observer
-`radio.watchdog` settings can override the soft interval. A successful hard
-recovery starts a fresh observation window to limit repeated resets on a quiet
-mesh; a failed hard recovery remains escalated and retries after 30 seconds.
-Targets with `RADIO_LIVENESS_SOFT_ONLY` repeat soft recovery at the soft interval
-because their radio cannot be reset independently. These recoveries do not
-reboot the MCU. The repeater's `rx.watchdog` is a separate whole-board reboot
-watchdog, enabled by default with a 24-hour sliding deadline since the last
-received packet passed MeshCore parsing. Its timer is updated before receive
-delays and routing filters; raw LoRa reads rejected by the parser do not count.
-MeshCore parsing checks the packet structure, not sender authentication or
-decryption of traffic addressed to other nodes. Temporary
-radio windows of at least 12 hours use a 12-hour deadline; shorter windows
-disable that reboot watchdog until the window ends. The temporary timing
-overrides are held in RAM and do not replace saved normal-mode preferences.
-
-The status read uses the two-byte GetStatus transaction. The pinned RadioLib
-getStatus() helper requests zero copied data bytes, so its return value is not
-usable for this mode check. The dedicated read restores the normal SPI command
-layout afterward and does not wake a duty-cycling radio to inspect its mode.
-
-Validation lives in `test_noise_floor_estimator`, `test_rx_power_saving`,
-`test_radio_liveness`, `test_radio_receive_contract.py`, and
-`test_sx126x_receive_mode.py`.
+This document is now in [Test Results](test-results/radio_receive_calibration.md).
diff --git a/docs/releases/1.17.1.5-doc-audit.md b/docs/releases/1.17.1.5-doc-audit.md
index 96183e688..8c997470a 100644
--- a/docs/releases/1.17.1.5-doc-audit.md
+++ b/docs/releases/1.17.1.5-doc-audit.md
@@ -1,123 +1,5 @@
-# Markdown audit — 1.17.1.5 USA Cascade
+# Release notes moved
-Audited on 2026-09-05 against firmware source `26303793`, the resolved build
-configuration, and the qualified 1.17.1.5 staging manifests. The scope was all
-**71 tracked Markdown files** present at the start, including root documents,
-docs, examples, tools, tests, variants, and vendored library documentation.
-Generated/ignored build directories and external repositories were outside that scope.
+
-## Corrections
-
-- Added [role-specific feature switches](../role_feature_switches.md), exact
- Companion/infrastructure MQTT and logging controls, board exceptions, SNMP,
- and the [USB web console](https://flasher.meshcore.io/console).
-- Added an [exact-name variant map](1.17.1.5-variant-map.tsv); every one of its
- 1,325 covered entries resolves to a staged firmware file. The 36 excluded
- entries are identified rather than linked to substitute hardware.
-- Corrected universal 254-neighbor claims, Full Companion MQTT filename/CLI
- assumptions, TLora Full WebConfig availability, and partition/NVS guidance.
-- Documented the power-saving guards present at audit time; current instructions
- now use the shared `get powersaving` / `set powersaving on|off` command set.
- Corrected the RS232 baud range and SNMP environment list.
-- Linked OTAFIX 2.4.6 and its board/storage requirements from operator guides;
- aligned the two manual USA OTA quickstarts with 910.525 MHz.
-- Marked old provider catalogs and dated hardware results as historical;
- documented the legacy tag workflow that also matches `repeater-room-*`.
-- Fixed the vendored MQTT documentation link and documentation-site links
- that incorrectly treated repository source files as generated site pages.
-
-## Verification and limits
-
-All original Markdown files were scanned for relative links, section anchors,
-source-path references, version/default claims, and role/OTA terminology.
-Flagged operator instructions were checked against CommonCLI, its observer
-extension, Companion command handlers, board configuration, and release manifests.
-Brace-expanded source references and SDK-relative paths were retained as such.
-Historical measurements, changelogs, upstream findings, contribution/security
-policies, and licenses were not rewritten as new validation results.
-
-The documentation site passed a strict MkDocs Material build. Relative repository
-links and section anchors were checked separately, including documents outside
-the MkDocs source directory. Console and current bootloader links were checked
-on their primary sites. This was not a crawl of every third-party URL or a
-new hardware/firmware qualification; no claim is made that every board was
-physically tested or every protocol statement independently re-proved.
-
-## Original file coverage
-
-“Retained” means no correction was identified by this documentation audit;
-it does not certify all historical statements as current hardware behavior.
-
-| File | Disposition |
-| --- | --- |
-| [AGENTS.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/AGENTS.md) | Scanned; retained |
-| [ALERTS.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md) | Scanned; retained |
-| [CHANGELOG.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/CHANGELOG.md) | Historical record retained |
-| [CONTRIBUTING.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/CONTRIBUTING.md) | Scanned; retained |
-| [MEMORY_MONITORING.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MEMORY_MONITORING.md) | Scanned; retained |
-| [MQTT_IMPLEMENTATION.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md) | Corrected or clarified |
-| [MQTT_INTERNALS.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_INTERNALS.md) | Corrected or clarified |
-| [MQTT_OWNERSHIP.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_OWNERSHIP.md) | Historical record retained |
-| [MQTT_SNMP.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md) | Corrected or clarified |
-| [README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/README.md) | Corrected or clarified |
-| [RELEASE.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/RELEASE.md) | Corrected or clarified |
-| [RESTORE_UPSTREAM_NOTES.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/RESTORE_UPSTREAM_NOTES.md) | Historical record retained |
-| [SECURITY.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/SECURITY.md) | Scanned; retained |
-| [STABILITY_TESTABILITY_HANDOFF.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/STABILITY_TESTABILITY_HANDOFF.md) | Historical record retained |
-| [UPSTREAM_BUGS.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/UPSTREAM_BUGS.md) | Scanned; retained |
-| [WEB_CONFIG_REVIEW.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/WEB_CONFIG_REVIEW.md) | Corrected or clarified |
-| [arch/stm32/Adafruit_LittleFS_stm32/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/arch/stm32/Adafruit_LittleFS_stm32/README.md) | Vendored documentation/license retained |
-| [arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/LICENSE.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/LICENSE.md) | Vendored documentation/license retained |
-| [arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/arch/stm32/Adafruit_LittleFS_stm32/src/littlefs/README.md) | Vendored documentation/license retained |
-| [docs/WiFi.md](../WiFi.md) | Corrected or clarified |
-| [docs/cli_build_matrix.md](../cli_build_matrix.md) | Corrected or clarified |
-| [docs/cli_command_availability.md](../cli_command_availability.md) | Corrected or clarified |
-| [docs/cli_commands.md](../cli_commands.md) | Corrected or clarified |
-| [docs/companion_offline_queue.md](../companion_offline_queue.md) | Scanned; retained |
-| [docs/companion_protocol.md](../companion_protocol.md) | Scanned; retained |
-| [docs/companion_radio_full.md](../companion_radio_full.md) | Corrected or clarified |
-| [docs/docs.md](../docs.md) | Scanned; retained |
-| [docs/esp32_memory_budget.md](../esp32_memory_budget.md) | Scanned; retained |
-| [docs/faq.md](../faq.md) | Corrected or clarified |
-| [docs/filter_tool.md](../filter_tool.md) | Scanned; retained |
-| [docs/firmware_picker.md](../firmware_picker.md) | Corrected or clarified |
-| [docs/flood_filtering.md](../flood_filtering.md) | Scanned; retained |
-| [docs/full_companion_features.md](../full_companion_features.md) | Corrected or clarified |
-| [docs/full_companion_usb_switcher.md](../full_companion_usb_switcher.md) | Corrected or clarified |
-| [docs/gps_tracking.md](../gps_tracking.md) | Scanned; retained |
-| [docs/halo_keymind_settings.md](../halo_keymind_settings.md) | Corrected or clarified |
-| [docs/hardware_validation_checklist.md](../hardware_validation_checklist.md) | Corrected or clarified |
-| [docs/host_cli_service.md](../host_cli_service.md) | Scanned; retained |
-| [docs/index.md](../index.md) | Corrected or clarified |
-| [docs/kiss_modem_protocol.md](../kiss_modem_protocol.md) | Scanned; retained |
-| [docs/lora_ota_automation.md](../lora_ota_automation.md) | Corrected or clarified |
-| [docs/mbedtls-tls-footprint.md](../mbedtls-tls-footprint.md) | Scanned; retained |
-| [docs/nrf52_companion_storage_recovery.md](../nrf52_companion_storage_recovery.md) | Scanned; retained |
-| [docs/nrf52_power_management.md](../nrf52_power_management.md) | Scanned; retained |
-| [docs/number_allocations.md](../number_allocations.md) | Scanned; retained |
-| [docs/ota_easy.md](../ota_easy.md) | Corrected or clarified |
-| [docs/ota_meshtower_v2_sdcard.md](../ota_meshtower_v2_sdcard.md) | Corrected or clarified |
-| [docs/ota_nrf52_bootloader_update.md](../ota_nrf52_bootloader_update.md) | Corrected or clarified |
-| [docs/ota_nrf52_qspi.md](../ota_nrf52_qspi.md) | Corrected or clarified |
-| [docs/ota_protocol.md](../ota_protocol.md) | Corrected or clarified |
-| [docs/ota_user_guide.md](../ota_user_guide.md) | Corrected or clarified |
-| [docs/packet_format.md](../packet_format.md) | Scanned; retained |
-| [docs/payloads.md](../payloads.md) | Scanned; retained |
-| [docs/qr_codes.md](../qr_codes.md) | Scanned; retained |
-| [docs/rak3401_mota_chain.md](../rak3401_mota_chain.md) | Historical record retained |
-| [docs/releases/1.17.1.5.md](../releases/1.17.1.5.md) | Corrected or clarified |
-| [docs/spiffs_regular_file_reads.md](../spiffs_regular_file_reads.md) | Scanned; retained |
-| [docs/stats_binary_frames.md](../stats_binary_frames.md) | Scanned; retained |
-| [docs/telemetry_decoder.md](../telemetry_decoder.md) | Scanned; retained |
-| [docs/terminal_chat_cli.md](../terminal_chat_cli.md) | Scanned; retained |
-| [docs/usb_serial_backpressure.md](../usb_serial_backpressure.md) | Scanned; retained |
-| [examples/host_cli_service/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/examples/host_cli_service/README.md) | Scanned; retained |
-| [lib/PsychicMqttClient/CHANGELOG.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/lib/PsychicMqttClient/CHANGELOG.md) | Vendored documentation/license retained |
-| [lib/PsychicMqttClient/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/lib/PsychicMqttClient/README.md) | Corrected or clarified |
-| [mesh-america/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/mesh-america/README.md) | Corrected or clarified |
-| [test/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/test/README.md) | Scanned; retained |
-| [test/test_mqtt_prefs_codec/FIXTURES.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/test/test_mqtt_prefs_codec/FIXTURES.md) | Historical record retained |
-| [tools/mota/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/mota/README.md) | Scanned; retained |
-| [tools/sensecap_indicator_font/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/sensecap_indicator_font/README.md) | Scanned; retained |
-| [tools/sensecap_indicator_rp2040/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/sensecap_indicator_rp2040/README.md) | Scanned; retained |
-| [variants/sensecap_indicator-espnow/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/variants/sensecap_indicator-espnow/README.md) | Scanned; retained |
+These notes are now in [Old Releases](../old-releases/1.17.1.5-doc-audit.md).
diff --git a/docs/releases/1.17.1.5.md b/docs/releases/1.17.1.5.md
index c36859bff..28d7a4a19 100644
--- a/docs/releases/1.17.1.5.md
+++ b/docs/releases/1.17.1.5.md
@@ -1,333 +1,5 @@
-# MeshCore 1.17.1.5 Dev - USA Cascade
+# Release notes moved
-Use [feature switches by role](../role_feature_switches.md) for exact on/off
-commands for Companion, Repeater, Room Server, and Sensor, including MQTT,
-logging, power saving, GPS, and board exceptions. Open the
-[USB web console](https://flasher.meshcore.io/console) for the default ASCII
-terminal on Full Companion and infrastructure images.
+
-The firmware identifier and main release tag follow
-`v1.17.1.5-halo-keymind-cascade-dev-`, matching the 1.17.1.4 naming
-convention. Supplemental pages retain the `repeater-room-`, `utility-`,
-`logging-`, `lora-ota-`, and `full-profiles-` prefixes where those outputs
-exist. The Companion page is marked Latest; the supplemental pages remain
-development prereleases.
-
-This release uses the USA/Canada radio preset and Cascade runtime defaults.
-The release build records the resolved frequency, bandwidth, spreading factor,
-and coding rate. The offline USA Cascadia fallback is 910.525 MHz, BW62.5,
-SF7, CR5. Saved node settings survive ordinary firmware updates.
-
-## Memory corrections in `aa20e927`
-
-All 583 published profiles (the 540-profile matrix plus 43 supplemental Full
-repeaters) were checked against the new
-[runtime RAM policy](../firmware_memory_budget.md). **All 11 failing profiles
-have replacements**, and the same queue sharing has been applied to the other
-41 nRF52 Full profiles: **52 corrected profiles in total**, initially built from
-[`aa20e927`](https://github.com/mikecarper/MeshCore/tree/aa20e9278ebdd574c4769fdb58d46fd9d4face68).
-The existing release tags stay in place; corrected filenames end in `aa20e927`,
-except Wireless Paper Full's capacity-restoring follow-up, `1e4d1e16`.
-Companion remains Latest. Both Mesh America catalogs and the firmware picker
-point to the replacement files.
-
-All 43 nRF52 Full Companions keep 256 offline frames normally and lend 128
-slots to mOTA while needed. Wireless Paper Full now also shares its queue and
-keeps **350 contacts and 40 channels**. The other six corrected ESP32 Full
-profiles below use **150 contacts**, preserving their 256-frame queue and Full transports.
-**Export contacts before upgrading if you have more than 150**; entries beyond
-that limit may be unavailable and a later save may omit them. This warning
-applies to those six profiles and the superseded `aa20e927` Wireless Paper image.
-
-Wireless Paper Full's
-[`1e4d1e16` update](https://github.com/mikecarper/MeshCore/tree/1e4d1e167a984abee34e92288b44d55f3d0e15a6)
-has 256 message slots normally and 128 while an mOTA session borrows its
-workspace. Idle WiFi listening leaves all 256 slots available. If more than
-128 unread messages are queued, sync them with an app before starting mOTA.
-Source stop/disconnect restores all 256 slots and frees ESP32 scratch buffers.
-USB, Bluetooth, WiFi, the display and the partition layout are retained.
-The application is 1,869,976 bytes; use its app-only `.bin` for a compatible
-WiFi update, or its merged image for USB installation.
-
-The T096 and T1 LoRa OTA repeater corrections allocate the diagnostic manual
-staging buffer only when requested, recovering about 4 KiB at startup.
-Ordinary mOTA uses its existing staging store. `ota dev clear` releases a manual
-stage; allocation failure reports an error before writing. These same-version
-repairs require an explicit exact-target manual LoRa installation or USB;
-forward-only trusted autoinstall does not select an equal-version repair.
-
-Available RAM below means capacity **before startup allocations**, not free
-heap after boot. The Wireless Paper row includes its latest `1e4d1e16` update:
-
-| Failing original profile | Original bytes | Replacement bytes | Required bytes |
-| --- | ---: | ---: | ---: |
-| `Generic_ESPNOW_companion_radio_full` | 101,920 | 139,456 | 136,192 |
-| `Heltec_Wireless_Paper_companion_radio_full` | 150,144 | 161,336 | 152,576 |
-| `Heltec_Wireless_Tracker_companion_radio_full` | 148,496 | 186,032 | 169,986 |
-| `Heltec_ct62_companion_radio_full` | 99,104 | 136,640 | 136,192 |
-| `Heltec_t096_companion_radio_full_femon` | 54,724 | 74,060 | 73,728 |
-| `Heltec_t1_companion_radio_full` | 55,044 | 74,380 | 73,728 |
-| `Heltec_v3_companion_radio_full` | 143,840 | 181,376 | 173,056 |
-| `Xiao_C3_companion_radio_full` | 98,880 | 136,416 | 136,192 |
-| `heltec_tracker_v2_companion_radio_full_femon` | 133,136 | 170,672 | 169,986 |
-| `Heltec_t096_repeater_lora_ota_no_external_sensors` | 61,588 | 65,676 | 65,538 |
-| `Heltec_t1_repeater_lora_ota_no_external_sensors` | 61,964 | 66,052 | 65,538 |
-
-Each replacement has a matching `.memory.json` with its ELF hash, RAM budget
-and firmware-file hashes. The
-[complete audit](https://github.com/mikecarper/MeshCore/releases/download/v1.17.1.5-halo-keymind-cascade-dev-26303793/MEMORY-AUDIT-1.17.1.5.json)
-distinguishes original binary/log calculations from new ELF checks.
-All 52 exact firmware builds, their native file pairs, and 150 native mOTA
-tests passed, alongside memory, allocation-failure, shared-queue and picker
-regressions. Wireless Paper's follow-up also passed complete TCP mOTA transfers
-with address/leak sanitizers, repeated disconnects, queue-full refusal, idle
-listening and USB/TCP ownership changes. Physical boot/load/soak validation
-remains pending.
-
-## G3 USB disappearance with power saving enabled
-
-The published `26303793` firmware has an ESP32 native-USB sleep bug that
-matches a Station G3 report: USB disappears from the connected Pi after a
-few minutes, the display button stops responding, but the repeater still
-answers over LoRa. Hardware confirmation of this report is pending.
-
-Cascade enables device power saving by default on fresh settings. After
-two minutes, the repeater can enter light sleep when no serial terminal is
-open, even though the Pi has enumerated the USB device. Light sleep stops
-USB service; the radio GPIO can still wake the CPU, while the G3 display
-button is only polled. Closing a terminal must not be treated as unplugging
-the USB host. Espressif documents the underlying
-[native USB sleep limitation](https://docs.espressif.com/projects/esp-idf/en/v4.4.7/esp32s3/api-guides/usb-otg-console.html#limitations).
-
-To enable **USB logging on an ESP32 1.17.1.5 image with logging support**,
-disable power saving first. Use the role's text terminal, or the repeater's
-remote admin CLI if USB has already disappeared:
-
-```text
-set powersaving off
-set usb.logging on
-get powersaving
-get usb.logging
-```
-
-The read-back commands should report power saving `off` and USB logging `on`.
-For an image without USB logging, use just `set powersaving off` and `get powersaving`
-to apply/check the sleep workaround. Disabling power saving saves the setting and takes
-effect immediately; it does not erase the node or change LoRa RX power
-saving (`radio.rxps`). If USB does not reappear after disabling sleep
-remotely, power-cycle once and check that the port and display button remain
-usable beyond the two-minute mark. Ordinary reflashing can retain the
-saved power-saving setting, so check it during comparisons with another
-firmware build too.
-
-On unified Full infrastructure, also run `set powersaving off` before enabling
-USB through `set logging.output usb` or `set logging.output both`. These are
-separate commands. Turning logging off does not automatically restore the
-saved power-saving setting. nRF52 logging does not need this ESP32 workaround.
-
-**WiFi/MQTT-only logging does not need `set powersaving off` while the
-Repeater/Room Server MQTT bridge is running.** The original `26303793` code
-already prevents device sleep in that state. Select `set logging.output wifi`
-where supported and check `get mqtt.running`; `get mqtt.enabled` alone
-only checks the saved preference. MQTT-capable Full Companion now uses the
-same `mqtt.enabled` and `logging.output` commands as infrastructure. Disabling
-MQTT preserves broker settings; the MQTT tab exposes the same master switch.
-
-The source correction keeps native USB serviced while a host is enumerated,
-including when its terminal is closed. Enabled live USB logging also blocks
-ESP32 light sleep, even without a host. FreeRTOS can still idle/yield; this
-does not disable the saved power-saving setting or Companion CPU scaling.
-With logging off, USB power from a charger still permits sleep.
-
-The corrected G3 uses its user button as a light-sleep wake source alongside
-LoRa. Button activity keeps the CPU and display awake for at least two minutes;
-holding or pressing the button again extends the interval. The usual display
-timeout can keep the screen on longer. An intentional long-press power-off
-retains its existing action. After the wake interval, sleep is allowed when
-power saving is enabled, logging is off, no native USB host is attached, and
-no running bridge or pending work needs service.
-
-Host simulations cover both native USB modes, terminal close/reconnect,
-logging toggles, OTA inhibition, simultaneous button/LoRa wake configuration,
-and the two-minute interval across timer rollover. The Station G3 repeater
-download remains the original `26303793` build; continue to use the logging
-workaround above on that image.
-
-## T096 Full Companion Bluetooth and menu freeze report
-
-A report against `Heltec_t096_companion_radio_full_femon` describes no usable
-Bluetooth despite a displayed PIN, followed by a freeze while stepping through
-the device menu. Reflashing both with and without a full wipe gave the same
-result. The exact freeze and Bluetooth behavior still need confirmation on a
-physical T096 with the source correction below.
-
-The published `26303793` Full image leaves only **54,724 bytes (53.4 KiB)**
-between the linked heap boundaries. Its static RAM check did not account for
-the allocations made at startup: the 160x80 color framebuffer alone needs
-25,602 bytes, followed by task stacks, the packet pool, filesystem buffers,
-and message previews. This leaves inadequate runtime headroom. The pinned
-Bluefruit library also ignores failure to create its BLE/SOC worker tasks,
-and the sensor menu could write through a failed CayenneLPP allocation. Those
-are code paths consistent with this report; a PIN is not proof of a working
-Bluetooth radio.
-
-The correction applies to **every nRF52 Full Companion**, keeping its offline
-queue at **256 frames
-during normal use**. mOTA borrows the upper 128 slots for its roughly 19 KiB
-session state only when needed, leaving 128 message slots during mOTA use.
-Starting a source or a TempRadio discovery session acquires this workspace.
-Stopping or disconnecting the USB/Bluetooth source restores all 256 slots,
-even if the temporary radio window is still open. A discovery-only session
-returns the slots when its TempRadio window ends. No heap resizing is involved.
-
-Unread messages keep their order across both transitions. If more than 128
-frames are pending, mOTA refuses to start and asks you to **sync unread
-messages with a Companion app first**; it does not discard them to make room.
-OTA configuration and signer keys survive reuse of the workspace. While mOTA
-owns it, the normal queue overflow policy applies to the remaining 128 slots.
-
-The T096 profile requires at least 72 KiB of heap space at link time. It retains
-350 contacts, 40 channels, the color display, sensors, USB, Bluetooth,
-Bluetooth DFU, and USB/Bluetooth mOTA sending. This reserve is space for
-runtime allocations, not a measurement of free heap after boot.
-Bluetooth startup now checks both worker tasks and all registered services;
-a failed partial initialization does not repeat every five seconds. The
-sensor page reports low memory if its buffer could not be allocated.
-
-Regression tests transfer complete mOTA containers through the actual shared
-context, check queue wraparound and message order, and cover source stop,
-disconnect, conflicting USB/Bluetooth ownership and repeated sessions. They
-also inject worker-task and GATT-service failures, verify that retries do not
-initialize the stack again, open the sensor path with a failed allocation,
-and use the real linker assertion to reject the old heap budget.
-The original T096 Full download has been replaced by the corrected `aa20e927`
-image on the same release page. Another wipe is not a fix for the old compiled
-memory budget.
-
-For the corrected T096 build, use its matching application
-UF2 through the existing USB bootloader. Preserve the bootloader and node data.
-Check Bluetooth discovery, pairing and message exchange on battery power and
-with USB attached; then step through every menu page, return from screen-off,
-and check USB and Bluetooth mOTA sending. Hardware validation is pending.
-
-## Firmware roles and update support
-
-Use the exact board's Full Companion image when available. Every Full
-Companion must pass linked-firmware checks for MOTA sending, its text terminal,
-and the platform's source transports. ESP32 supplies USB and WiFi sources;
-nRF52 supplies USB and encrypted Bluetooth sources. Full Companions remain
-USB-updateable and do not require LoRa self-installation. Optional ESP32
-WiFi self-update uses port 8080 where the existing partition layout has two
-application slots; nRF52 retains Bluetooth DFU with a compatible bootloader.
-
-Read [Full Companion feature switches](../full_companion_features.md) for
-on/off commands, USB logging, power saving, WiFi/Bluetooth selection, GPS,
-MQTT, MOTA sending, and update directions.
-
-Infrastructure artifacts must have a verified wireless self-update path.
-Option 3 reports and omits infrastructure on platforms without a wireless
-updater. The report is `ota-excluded-targets.txt`. Source/seeder capability
-alone does not count as self-update support. ESP32 qualification checks both
-application slots and OTA metadata; nRF52 Bluetooth qualification checks the
-linked DFU service and its application ZIP. LoRa installation on nRF52 still
-requires the exact matching OTAFIX bootloader and storage arrangement.
-
-Classic ESP32 capacity limits are recorded in each artifact's capability
-manifest. Generic E22, Heltec V2, Meshadventurer, and T-Beam OTA repeaters,
-plus the TLora MQTT repeater, use 50 neighbors where needed to preserve the
-required internal RAM reserve.
-T-Beam MQTT repeater profiles retain the full rule engine with 31 flood rules;
-constrained classic ESP32 ESP-NOW bridges with logging use 47.
-Meshadventurer Full uses 100 contacts, 30 group channels, and a 16-frame
-offline queue while retaining its MOTA and transport features.
-
-Since 1.17.1.4, this branch also includes USB session and contact-persistence
-fixes, automatic nRF52 ExtraFS recovery, improved Indicator setup/display
-handling, compressed MOTA transport, and measured ESP32 memory limits.
-Qualified internal-flash nRF52840 receivers now reserve 64 KiB of retained
-RAM to extend application MOTA staging beyond the available flash prefix.
-This handoff requires the exact board's
-[OTAFIX 2.4.6 bootloader](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.11.0-OTAFIX2.4.6).
-Install that bootloader before relying on LoRa updates from these new hybrid
-receiver images: they reject application containers larger than one flash
-page when the installed bootloader lacks the retained-RAM capability.
-QSPI/microSD roles and Full Companions retain their existing storage layouts.
-An interrupted hybrid download must be fetched again after an application
-restart or power cycle. See the [protocol](../ota_protocol.md) for the exact
-admission and reset checks.
-
-## Build using option 3
-
-The equivalent reproducible command is:
-
-```bash
-OUTPUT_DIR=.releases/1.17.1.5-halo-keymind-cascade-dev \
-bash build.sh build-firmwares-logging-matrix \
- --firmware-version v1.17.1.5-halo-keymind-cascade-dev \
- --radio-preset usa-cascadia --profile cascade \
- --require-ota --skip-kiss --resume --background
-```
-
-Or run `bash build.sh`, select option 3, set version
-`v1.17.1.5-halo-keymind-cascade-dev`, and retain USA Cascadia/Cascade.
-Option 3 enables the infrastructure OTA requirement by
-default. It merges supported runtime USB logging into canonical artifacts and
-builds the expanded ESP32 profiles. It runs one PlatformIO process at a time.
-
-The background launcher prints its persistent log and status paths. Each
-target also gets a log in `OUTPUT_DIR/build-logs/`. Resume accepts only
-artifacts with the current capability schema and a successful qualification;
-infrastructure also needs a verified update method. Any failed target leaves
-the matrix unsuccessful and must be resolved before publication.
-
-`--allow-no-ota` is an explicit development override for cable-only
-infrastructure. Those outputs do not belong in this OTA-required release.
-Use a separate output directory when changing the radio or settings profile.
-Use a fresh release output directory after changing the source revision or
-firmware version so packaging cannot mix old and new binaries.
-
-## Local release staging
-
-After the matrix completes successfully, `scripts/package_cascade_release.py`
-groups the qualified images using the previous GitHub release naming. Each
-group includes the firmware files, capability manifests, TSV/JSON inventory,
-checksums, build notes, and Full Companion directions. The staging directory's
-root `FIRMWARE-PICKER-1.17.1.5.html` opens the local downloads; the copies inside
-each release group use the planned GitHub download URLs. Staging does not
-push commits, create remote tags, or publish a GitHub release.
-
-## Published release set and small-slot Heltec V4 updates
-
-The capability JSON and target inventories now report both Bluetooth DFU and
-LoRa mOTA for all 60 nRF52 receiver profiles. The original report omitted the
-nRF52 LoRa method. Each affected manifest includes `ota_update_requirements.lora`
-with its storage type, accepted package types, exact-base requirement for deltas,
-and the matching OTAFIX bootloader link. Internal-flash receivers accept in-place
-deltas; supported QSPI/SD receivers also accept full application packages. The
-20 retained-RAM receivers require OTAFIX 2.4.6's matching handoff support.
-These reports verify the released application bytes and storage layout; they
-do not certify the bootloader installed on a physical device. Firmware binaries
-and tags are unchanged. Full Companion mOTA sending remains separate from
-self-update capability.
-
-Published firmware tags remain at source commit `26303793`. Documentation can
-advance independently. The original matrix qualified 540 profiles (1,080
-firmware files) from 551 attempts; 11 oversized portable attempts have expanded
-Full replacements. The [variant map](1.17.1.5-variant-map.tsv) maps all 1,361
-previous-release choices to 1,325 covered choices and 36 excluded entries.
-Multiple old choices map to the same current artifact.
-
-For Heltec V4 with an existing compatible **1.25 MiB OTA application slot**, the
-standard `heltec_v4_repeater` application is **1,179,200 bytes** and the lean
-`heltec_v4_repeater_lora_ota_no_external_sensors` application is **1,232,024 bytes**.
-Both fit that slot. Start the WiFi uploader with `start ota ap`, join
-`MeshCore-OTA`, and open `http://192.168.4.1/update`. Upload the application
-`.bin`; use `stop ota` to close an unused uploader. Their merged USB installers
-write two 6.25 MiB application slots. Expanded Full V4 applications require
-the larger layout and do not fit a 1.25 MiB slot.
-
-- [Companion firmware](https://github.com/mikecarper/MeshCore/releases/tag/v1.17.1.5-halo-keymind-cascade-dev-26303793)
-- [Repeater and Room Server firmware](https://github.com/mikecarper/MeshCore/releases/tag/repeater-room-v1.17.1.5-halo-keymind-cascade-dev-26303793)
-- [Sensor firmware](https://github.com/mikecarper/MeshCore/releases/tag/utility-v1.17.1.5-halo-keymind-cascade-dev-26303793)
-- [LoRa OTA repeater firmware](https://github.com/mikecarper/MeshCore/releases/tag/lora-ota-v1.17.1.5-halo-keymind-cascade-dev-26303793)
-- [Expanded Full infrastructure](https://github.com/mikecarper/MeshCore/releases/tag/full-profiles-v1.17.1.5-halo-keymind-cascade-dev-26303793)
+These notes are now in [Old Releases](../old-releases/1.17.1.5.md).
diff --git a/docs/releases/1.17.1.6.md b/docs/releases/1.17.1.6.md
index 35321d6a4..d11627eaf 100644
--- a/docs/releases/1.17.1.6.md
+++ b/docs/releases/1.17.1.6.md
@@ -1,157 +1,5 @@
-# MeshCore 1.17.1.6 Dev - USA Cascade
+# Release notes moved
-This release builds the current `keymindCascade` source after repairing the
-unit-test regressions. Its firmware identifier is
-`v1.17.1.6-halo-keymind-cascade-dev-`. The recorded commit identifies
-the source for every firmware file; binaries from earlier releases are not reused.
+
-Use the [firmware picker](https://mikecarper.github.io/MeshCore/firmware_picker/)
-to match the exact board, radio, display and flash variant. The Companion release
-is marked Latest. Supporting release pages retain the `repeater-room-`,
-`utility-`, `logging-`, `lora-ota-` and `full-profiles-` prefixes where those
-outputs exist, and remain development prereleases.
-
-The build uses the USA/Canada radio preset and Cascade runtime defaults. Build
-records include the resolved radio settings; the offline USA Cascadia fallback
-is **910.525 MHz, BW62.5, SF7, CR5**. Ordinary updates retain saved node settings.
-
-## Published matrix
-
-The [GitHub release](https://github.com/mikecarper/MeshCore/releases/tag/v1.17.1.6-halo-keymind-cascade-dev-306feebe)
-contains **553 qualified firmware profiles across five release pages**. The
-initial matrix completed all 565 attempts: 537 passed, 16 failed, and 12
-standard ESP32 profiles exceeded the 1.25 MiB portable slot. All 16 failures
-have passing repair builds; all 12 portable exclusions have qualified expanded
-Full alternatives. Smaller profiles that fit remain available.
-
-The initial firmware uses `306feebe`, the 14 nRF52 repairs use `283acd27`, and
-the two Tracker MQTT repairs use `1274956f`. Each firmware filename and target
-manifest records its source. Existing firmware assets and release tags were
-preserved when the repairs were added.
-
-See the [failure and repair report](https://github.com/mikecarper/MeshCore/releases/download/v1.17.1.6-halo-keymind-cascade-dev-306feebe/BUILD-FAILURES.md)
-and [portable-profile exclusions](https://github.com/mikecarper/MeshCore/releases/download/v1.17.1.6-halo-keymind-cascade-dev-306feebe/PORTABLE-PROFILE-EXCLUSIONS.md).
-Full alternatives require their matching expanded partition layout; they do
-not fit a deployed 1.25 MiB slot.
-
-## Two LoRa profiles
-
-`radio2`, `tempradio2`, `radioat2` and `tempradioat2` add a second profile to
-LoRa-equipped Companion, Repeater, Room Server, Sensor and terminal firmware.
-The second profile starts disabled. It supports receive-only or receive/transmit
-operation, separate retry ownership and backoff, and explicit off commands.
-
-One transceiver alternates between at most two active profiles. Reception and
-transmission occupy that transceiver, so a packet on one profile interrupts
-listening on the other. This does not provide two simultaneous receivers.
-
-By default, traffic can cross between two permanent profiles or two temporary
-profiles. A permanent/temporary pair stays isolated. `radio2.cross auto|on|off`
-controls this policy; receive-only still forbids transmission on profile 2.
-`tempradio2` can carry LoRa OTA traffic while the primary profile remains
-available between packets. Use a firmware profile that supports the required
-OTA role.
-
-Radio commands accept an optional trailing preamble, and their getters include
-the effective preamble. Automatic preambles round up in steps of eight. Paired
-with SF7/BW62.5/preamble 32, the automatic BW500 values are 48 symbols at SF9,
-88 at SF8 and 120 at SF7. Configure transmitters with matching preambles too.
-
-Dual-profile receive uses normal RX with the slower profile visited first.
-RX power saving and device sleep are suspended while scanning, then the saved
-settings apply again in single-profile mode. See the
-[setup guide and command matrix](../radio_profiles.md) and
-[hardware measurements](../radio_profiles_validation.md).
-
-## Other changes since 1.17.1.5
-
-- Restores `set path.hash.mode 0|1|2` on infrastructure firmware, with CLI
- dispatch and get/set regression coverage. Unsupported `extra.sf` commands
- report an explicit error on radios without that capability.
-- Uses received MeshCore packets to feed the repeater RX watchdog. Fresh
- settings default to 24 hours; temporary primary-radio sessions lasting at
- least 12 hours use 12 hours, while shorter sessions disable that watchdog.
- Temporary-session adverts and watchdog settings revert when the session ends
- or the node reboots. See [CLI commands](../cli_commands.md).
-- Adds separate battery and USB display modes, with timeouts in seconds.
- Companion inbox settings distinguish retained history, pending delivery and
- the local unread count. Read previews remain browseable in Unread mode.
-- Includes native-USB sleep/wake corrections, nRF52 USB startup fixes,
- receive recovery and noise-calibration improvements, and the reviewed
- ExpressLRS board/power changes.
-- Consolidates redundant Full infrastructure builds while retaining useful
- smaller-slot variants. Full profiles and smaller portable images continue
- to require their matching partition and update method.
-
-Feature switches and update commands are documented in
-[feature switches by role](../role_feature_switches.md),
-[Full Companion features](../full_companion_features.md) and the
-[ESP-NOW bridge guide](../espnow_bridge_setup.md).
-
-## Validation and installation
-
-The release is gated on the [unit-test workflow](https://github.com/mikecarper/MeshCore/actions/workflows/run-unit-tests.yml)
-for its source commit. CI repairs replace stale display assertions with compiled
-tests of bookmark saves across battery/USB transitions and message summaries in
-each inbox mode. The sleep harness checks both MQTT and dual-profile scanning.
-
-The release packager checks each published profile's capability manifest,
-firmware-file hashes, native file pairs and
-[runtime RAM budget](../firmware_memory_budget.md). Infrastructure must have a
-verified wireless update path. The target manifests and exclusion report describe
-the actual published matrix; a build check is not a physical-board test.
-
-Dual-profile timing was measured on a Heltec V4 with a XIAO ESP32-S3/Wio LoRa
-transmitter. Finite strong-signal tests and an OTA catalogue exchange passed;
-these tests do not establish weak-signal performance, simultaneous traffic
-reliability, a complete hardware OTA installation, or months of uptime.
-
-ESP32 WiFi updates use the application `.bin`. The `-merged.bin` image includes
-boot and partition data for USB installation. nRF52 uses the board's `.uf2` or
-native application DFU `.zip`; LoRa OTA also needs the exact destination package
-and a compatible OTAFIX bootloader. Check the per-profile capability manifest
-before choosing an update method.
-
-## Reproduce the release
-
-Follow-up builds repair the constrained profiles without relaxing qualification:
-
-- nRF52 repeaters use `-Os` instead of `-Ofast`, retaining their sensor drivers
- and protocol features while reducing flash use.
-- T096 and T1 LoRa OTA repeaters retain their board-defined 50 neighbors and
- use 16 flood-rule slots. The complete rule engine, color display, GPS and OTA
- remain enabled. The T096's available startup heap rises from 72,492 to 81,468
- bytes, and the T1's from 72,868 to 81,844 bytes. Required startup allocations
- fall from 88,410 to 79,010 bytes. These are linked capacity checks, not soak
- measurements. Full Companion capacities are unchanged.
-- Tracker v1.1 and v2 Full MQTT repeaters retain their board-defined 50
- neighbors so the color display, MQTT/TLS and OTA fit in internal RAM.
- Their radio, display, sensor and update features remain enabled; ordinary
- Tracker repeater and Companion capacities are unchanged. Tracker v2's
- available startup heap rises from 184,640 to 222,992 bytes against the
- unchanged 190,386-byte requirement. Both corrected Tracker profiles pass.
-- Size-failure detection keeps its temporary output log outside PlatformIO's
- build tree, so SCons cleanup cannot erase the evidence needed by the measured
- size fallback. CI exercises cleanup, ordinary compiler failures and log errors.
-
-Use a clean checkout of the release commit and a fresh output directory:
-
-```bash
-OUTPUT_DIR=.releases/1.17.1.6-halo-keymind-cascade-dev \
-bash build.sh build-firmwares-logging-matrix \
- --firmware-version v1.17.1.6-halo-keymind-cascade-dev \
- --radio-preset usa-cascadia --profile cascade \
- --require-ota --skip-kiss --resume --background
-```
-
-Run only one PlatformIO process in that checkout. Let the matrix finish even
-when individual targets fail. The packager normally requires a successful full
-matrix. For an initial release with gaps, `scripts/package_cascade_release.py`
-accepts `--allow-partial` with `--version 1.17.1.6`, the original completed build
-status and source commit. It lists every failed attempt in `BUILD-FAILURES.md`
-and `BUILD-FAILURES.json`; each published file must still pass all qualification
-and memory checks. Repair the failures and add the corrected profiles afterward.
-`PORTABLE-PROFILE-EXCLUSIONS.md` and `.json` separately list standard ESP32
-profiles that exceed the 1.25 MiB slot and are redirected to the expanded Full
-pass. Those size deferrals are not compiler failures. Qualified smaller images
-remain available; Full alternatives require their matching partition layout.
+These notes are now in [Old Releases](../old-releases/1.17.1.6.md).
diff --git a/docs/releases/1.17.1.7.md b/docs/releases/1.17.1.7.md
index 36715bec6..60c3422dd 100644
--- a/docs/releases/1.17.1.7.md
+++ b/docs/releases/1.17.1.7.md
@@ -1,18 +1,5 @@
-# MeshCore 1.17.1.7 USA Cascade
+# Release notes moved
-This development release uses 910.525 MHz, 62.5 kHz bandwidth, SF7, and CR5 with Cascade defaults. Saved radio settings take precedence. The firmware source is `2d03e098cf75d58adcf35fe3d4dbbeaf0af114e1`.
+
-The release family contains 536 qualified firmware profiles on five GitHub release pages. The companion page is the main release; the repeater and room server, sensor and utility, LoRa OTA, and expanded ESP32 Full pages have the same version suffix. The [firmware picker](../firmware_picker.md) selects the exact board, role, storage and update file.
-
-## Installation
-
-- Match the exact board and radio variant. Use the application image only with a compatible bootloader and partition layout.
-- For an ESP32 that needs the expanded Full layout, flash the exact merged image over USB, or use a supported exact board and role migration ZIP from the utility release for a staged Wi-Fi or LoRa route. The 94 migration packages and complete bundle are included there. Read the package README before installation; the bridge writes the partition table and a power loss can require cable recovery. Run `get storage.layout` on the installed firmware if supported to see its live partitions; version alone cannot prove the installed layout. Do not send a loose app-only Full image directly to an older layout.
-- nRF52 application UF2 and DFU packages do not install a bootloader. Use the matching [OTAFIX 2.4.10](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/v0.11.0-OTAFIX2.4.10) bootloader where required. Standard RAK `3401_DFU` / `4631_DFU` bootloaders can receive the matching signed bootloader `.mota` over LoRa with a compatible OTA application. Devices already running the older `*_AUTO_DFU` identity require a local recovery bridge before installing the normal 2.4.10 image.
-- The RAK 3x72, Tiny Relay, Wio-E5 Mini, and Wio-E5 USB Companion images retain their framed USB transport and 32 KiB LittleFS area. Packet diagnostics are omitted on these four 256 KiB STM32WL targets to keep the application within flash.
-
-## Build verification
-
-The original matrix produced 532 qualified images and four STM32WL USB Companion flash overflows. Those four were rebuilt individually with packet diagnostics omitted and passed capability, flash size and memory checks. The [repair record](https://github.com/mikecarper/MeshCore/releases/download/v1.17.1.7-halo-keymind-cascade-dev-2d03e098/REPAIR-PROVENANCE.json) identifies them. The local release package also verified all 94 ESP32 migration archives and generated SHA-256 sums.
-
-Automated qualification does not establish physical update compatibility on every board. Verify critical deployed devices with the matching image and recovery path before a fleet upgrade.
+These notes are now in [Old Releases](../old-releases/1.17.1.7.md).
diff --git a/docs/role_feature_switches.md b/docs/role_feature_switches.md
index 58f7b6387..558a86663 100644
--- a/docs/role_feature_switches.md
+++ b/docs/role_feature_switches.md
@@ -6,7 +6,7 @@ choices where the hardware supports them. A setting cannot add missing radio
hardware, MQTT code, storage, or an OTA partition. Check the download's
`.capabilities.json` and the [firmware picker](firmware_picker.md).
For exact old device/variant names, search the
-[1.17.1.5 variant map](releases/1.17.1.5-variant-map.tsv): it maps all 1,361
+[1.17.1.5 variant map](old-releases/1.17.1.5-variant-map.tsv): it maps all 1,361
previous release entries to 1,325 covered choices or 36 excluded entries.
Many old choices share one current image; the command tables below explain
how to select their former behavior. Excluded entries have no download.
@@ -204,7 +204,7 @@ ESP32 USB sleep bug, including the G3 report. Both settings are saved; turning
logging off later does not automatically restore power saving. LoRa RXPS is
independent. nRF52 logging does not need this ESP32 workaround.
-With the [G3 sleep correction](releases/1.17.1.5.md#g3-usb-disappearance-with-power-saving-enabled),
+With the [G3 sleep correction](old-releases/1.17.1.5.md#g3-usb-disappearance-with-power-saving-enabled),
enabled live USB logging keeps ESP32 USB serviced and blocks light sleep,
including when a host closes the port or disconnects. CPU idle/yield remains
available. `set usb.logging off` removes that blocker; an attached native USB
diff --git a/docs/s3_memory_soak_validation.md b/docs/s3_memory_soak_validation.md
index e0c76cdbe..f69ad1bc5 100644
--- a/docs/s3_memory_soak_validation.md
+++ b/docs/s3_memory_soak_validation.md
@@ -1,233 +1,5 @@
-# ESP32-S3 OTA memory experiment
+# Document moved
-This experiment compares the current static S3 OTA workspace (A) with
-`OTA_HEAP_CONTEXT=1` (B), with WiFi and a real TLS MQTT connection enabled.
-It is isolated on `experiment/s3-ota-memory-soak`; published recipes retain
-their existing defaults. A short stress test cannot establish months of uptime.
+
-## Hardware and scope
-
-| Board | Role | Flash | PSRAM | Lab identity |
-|---|---|---:|---:|---|
-| Heltec V4.3 OLED, standard V4 | Observer/MQTT repeater | 16 MiB | 2 MiB QSPI | USB MAC `44:1B:F6:69:CF:98` |
-| Original SenseCAP Indicator LoRa | Full companion plus experimental MQTT | 8 MiB | 8 MiB octal | WiFi MAC `D8:3B:DA:75:23:AC` |
-| Seeed XIAO ESP32-S3 with WIO LoRa | Observer/MQTT repeater | 8 MiB | 8 MiB octal | MAC `28:84:85:B4:09:80`, Windows COM31 |
-
-This is not an R8 test. Both Indicator variants use the same 320x320 canvas;
-the native 480x480 Full-plus-MQTT configuration is not qualified by this run.
-One custom TLS MQTT slot per board publishes only to the isolated Pi broker.
-These results do not qualify five concurrent slots, JWT renewal, heavy RF
-traffic, every OTA receive/apply path, or months of operation.
-
-## Reproducible builds
-
-Base commit: `bc0fb2c335938c97220de51098cea6162f2f0fe0`, following PR #7's merge.
-Copy `tools/hil/s3_memory_soak.ini` to ignored `platformio.local.ini` and build
-the six named environments sequentially, with `MESHCORE_ESP32_FULL_BUILD=1`.
-Never run multiple PlatformIO processes in this checkout. Archive each image,
-ELF, map, partitions, bootloader, and memory report before the next build.
-
-The private broker's public CA must be in `ssl_certs` while generating these
-experimental images. Remove it and `platformio.local.ini` afterward. Private
-keys stay on the Pi. WiFi credentials and the Windows logger token are in
-private local files; do not commit them or the generated certificate bundle.
-Broker certificate verification remains enabled.
-
-The V4 and XIAO lab recipes also enable `MESH_SOAK_WIFI_KEY_OVERRIDE` and
-require the ignored local header `tools/hil/S3SoakWiFiKey.h`. To reproduce
-diagnostics using ordinary WiFi credentials, remove that define from the lab
-recipe; the private header is then unnecessary. Published recipes do not
-enable either lab flag.
-
-| Image | Application bytes | Link-time available internal bytes | SHA-256 |
-|---|---:|---:|---|
-| V4 A | 1,910,392 | 237,360 | `68c2749e0ea40db91d953f6355010bc602f85424526ca6acbe1cad7fd11c8630` |
-| V4 B | 1,911,256 | 252,760 | `6847ad4ad6bac2e558b8aed67de37417a497972391f13a71e0a42b5c9704dcff` |
-| Indicator A | 2,313,256 | 244,248 | `3d0ac27f95ddc54a32b59a3892d157713dcded285dbeb98d1bdfdb1ce0b951ab` |
-| Indicator B | 2,314,136 | 255,496 | `83045d7ec48befc43f3460c08c68b71da037a5476e14dd59def5706fc5a1b07e` |
-| XIAO A | 1,845,000 | 238,448 | `6fcc657c5ccbc5dd3d53a0cf1ceebde54479d59d329f8625e7cb7141a27d9ed8` |
-| XIAO B | 1,845,912 | 253,864 | `2b233e42e0c515b152ce83ca8fe38b1f35780970bb6350dbadf2591b6a2699a1` |
-
-All six firmware builds passed their RAM/flash gates. The linked internal
-capacity gain is 15,400 bytes on V4, 11,248 bytes on Indicator, and 15,416 bytes
-on XIAO. This is
-capacity returned while idle, not a reduction in peak OTA allocation needs.
-Both A/B variants keep the existing MQTT client reuse policy and internal
-MQTT task stack; this experiment does not move that stack into PSRAM.
-
-## Instrumentation
-
-`MESH_SOAK_DIAGNOSTICS` exposes local-only `get soak`, `soak ota-cycle`, and
-`soak wifi-drop` commands. They report free/largest/minimum internal heap,
-free PSRAM, OTA context size/presence, WiFi state, and uptime. No published
-recipe enables these controls.
-
-The lab network has a valid 64-digit raw WiFi key. Production MQTT preferences
-currently accept only 63 characters. The V4 experiment uses a separate,
-test-only NVS key through `MESH_SOAK_WIFI_KEY_OVERRIDE`, without changing the
-production binary preference layout. This does not fix released raw-key input.
-The network's original nine-character passphrase was subsequently recovered
-from the connected Windows profile; XIAO uses it through ordinary settings.
-The override is compiled into both XIAO images but no override key is stored.
-
-The Pi logger owns one persistent V4 USB handle: opening this particular USB
-device resets it, even with DTR/RTS disabled. Firmware installations and logger
-restarts therefore begin new uptime runs. Indicator telemetry uses TCP 5002.
-The logger distinguishes the 49.7-day `millis()` rollover from a reboot.
-
-Passive samples omit `get mqtt.stats`: its outbox query can block behind the
-MQTT client lock during a TLS handshake and stall the CLI. The logger reads
-`get mqtt.status` and separately counts messages received through a verified
-TLS subscription. It stores topic counts, not MQTT payloads or credentials.
-
-Two exploratory rapid-flap runs used an overly short 90-second recovery cutoff.
-The first also used the blocking outbox probe. Preserve those pilot results;
-do not count them as successful tests. Production MQTT backoff deliberately
-grows to five minutes and clears after two stable connected minutes, so rapid
-flapping is not equivalent to repeated independent connection tests.
-
-## Results and continuing soak
-
-V4 and Indicator both passed the matched A/B workload: 200 explicit OTA context
-acquire/release attempts, four MQTT bridge restarts, one WiFi reconnection and
-one five-second private-broker outage per image. An initial bridge restart
-preconditions each run. No unexpected uptime resets occurred in these runs.
-A was paused after its four bridge restarts to investigate the display report,
-then resumed for the network faults; its elapsed duration includes that pause.
-
-After the workload, with WiFi and one TLS MQTT slot connected:
-
-| Board | A free internal bytes | B free internal bytes | Gain | A largest block | B largest block |
-|---|---:|---:|---:|---:|---:|
-| Standard V4 | 95,396 | 111,040 | 15,644 (15.28 KiB) | 81,908 | 98,292 |
-| Indicator | 107,168 | 118,520 | 11,352 (11.09 KiB) | 94,196 | 106,484 |
-| XIAO (same saved settings, cold boot) | 151,028 | 166,444 | 15,416 (15.05 KiB) | 139,252 | 147,444 |
-
-Free heap varies with network activity; link-time capacity is the cleaner
-measurement of storage moved out of static RAM. PSRAM remained about 2.01 MB
-on V4 and 6.33 MB on Indicator. In each B run the OTA context returned to absent
-after the allocation workload. Neither board's largest block shrank through
-those 200 allocation cycles or the reconnect workload. WiFi/MQTT recovery took
-about 4.3 seconds on A and 3.8 seconds on B; broker recovery took about 19.5 and
-21.8 seconds respectively. These are single observed recovery times.
-
-Workspace recreation has an observed latency cost. The 200 command cycles
-took approximately 189 seconds on V4 B and 379 seconds on Indicator B, versus
-32 and 15 seconds on A. This includes command transport, OTA initialization,
-and ordinary firmware service, so it is not an allocator microbenchmark.
-The static comparator already owns an initialized workspace; the dynamic
-variant recreates it. This experiment does not claim faster OTA startup, and
-this cost should be checked against real session use before a default change.
-Code inspection shows that `OtaContext::begin()` calls `ota_self_firmware()`,
-which scans the running ESP32 application for its EndF identity on each call.
-All six experimental images contain valid EndF trailers. Caching immutable
-boot identity is a candidate for a separate change, not part of these results.
-
-The real WiFi mOTA folder endpoint was tested separately on Indicator: 20
-empty-folder sessions on A and 40 on B. All completed and B released its context.
-During B's first batch, free internal RAM went from 118,552 to 116,316 bytes,
-and its largest block from 106,484 to 102,388. A second batch ended at 116,308
-bytes with the same 102,388-byte largest block. This retained allocation/layout
-change is not explained by the now-absent OTA context; record it separately and
-watch it during the soak. It did not keep growing in the second batch.
-
-The original A folder script also tried V4, whose observer recipe has no TCP
-5001 folder source. Its overall result therefore says `passed: false`, despite
-Indicator completing all 20 sessions. That endpoint mismatch is preserved in
-the evidence; it is not an OTA failure on V4. The corrected script targets only
-the Indicator. These empty-folder checks do not qualify a full firmware transfer.
-
-XIAO passed 200 context cycles, four bridge restarts and one WiFi reconnect
-per image, with no unexpected reset. Its largest block stayed unchanged within
-each workload. WiFi/TLS recovered in about six seconds for both. This local
-test did not inject a separate broker outage or exercise a TCP folder endpoint.
-Verified subscriptions on the Pi confirmed actual TLS publications for both.
-
-The first XIAO A run followed initial device configuration and finished with
-126,140 internal bytes free, which would have overstated the OTA saving. It is
-preserved as `xiao-live/A-configuration-stress.json`. Reflashing A without an
-erase, retaining exactly the saved settings used by B, and rerunning the whole
-workload produced the 151,028-byte baseline above. Its resulting gain exactly
-matches the 15,416-byte linked-capacity change. The earlier larger gap is not
-credited to OTA storage; the precise retained allocation was not isolated.
-
-The Pi stores evidence under `/home/mikec/hwtest/runs/s3-memory-soak`:
-
-- `telemetry.jsonl`: append-only memory, WiFi/MQTT state, uptime, transport errors.
-- `latest.json`: latest samples plus broker-side received-message counts.
-- `A-stress.json` / `B-stress.json`: matched stress results and phase boundaries.
-- `A-folder.json` / `B-folder.json`: real WiFi mOTA empty-folder session checks.
-- `B-repeat-folder.json`: the extra batch checking the initial allocation change.
-
-`meshcore-memory-soak.service` holds the USB connection and samples each minute.
-`meshcore-soak-broker.service` supplies the private TLS broker. Both are enabled
-on the Pi. The logger never reboots a board to hide a firmware failure, but
-reopening the V4 USB transport can itself reset it; inspect transport counts,
-service restarts, and uptime together when diagnosing any interruption.
-The final passive phase started at `2026-09-11T07:18:07Z`. Installing the final
-logger revision (which serializes concurrent samples) deliberately restarted
-the service and therefore reset V4 once. That begins its long-soak uptime run;
-Indicator retained its uptime. Both then reported WiFi/TLS connected, absent
-OTA contexts, and actual broker-side publications.
-
-The local XIAO logger owns COM31 and writes under
-`C:\git\MeshCore\out\s3-memory-soak\xiao-live`. Its control endpoint is
-loopback-only with a private token. It runs while this Windows session remains
-alive; it is not a Pi service and will not survive a computer shutdown. A
-separate verified TLS subscription on the Pi confirmed XIAO publications for
-both images. The Pi logger's per-device counts cover V4 and Indicator only.
-The final XIAO B passive phase was verified at `2026-09-11T07:21:21Z`, with
-166,508 free internal bytes, a 155,636-byte largest block, WiFi/TLS connected,
-and no active OTA context. The largest block differs from the post-stress
-sample because this is a fresh boot, which illustrates why comparisons must
-use the same workload history. A Pi subscription received its publication two
-seconds later.
-
-Read-only checks on the Pi:
-
-```sh
-systemctl is-active meshcore-memory-soak meshcore-soak-broker
-cat /home/mikec/hwtest/runs/s3-memory-soak/latest.json
-```
-
-On Windows, read `out/s3-memory-soak/xiao-live/latest.json`. Keep the computer
-awake for continuous local samples. Neither logger automatically reboots a
-radio in response to a failed sample.
-
-The public lab CA was removed from `ssl_certs`, and the temporary local PIO
-configuration and generated test trust bundle were removed after building.
-Archived experimental firmware and public evidence remain in ignored
-`out/s3-memory-soak`; no test CA enters the published build configuration.
-
-No published default has changed. Leave the experimental B images running to
-collect longer evidence before deciding whether to expand S3 heap allocation.
-More idle internal RAM gives WiFi/TLS useful headroom; it does not itself prove
-stability or eliminate the need to allocate the OTA workspace during use.
-
-## Physical display checks
-
-The user reported unresponsive screens during MQTT restart testing. The V4
-lit after temporarily choosing `on`; after restoring battery and USB profiles
-to `button`, 15 seconds, the user confirmed that pressing the button wakes it.
-The synchronous reconnect/diagnostic work may have delayed UI service, but the
-exact cause of the original visual report was not established.
-
-The T096 is a separate nRF52840 board, USB identity `651F8E496197F882`. It was
-reflashed with current Full firmware and briefly instrumented. The trace showed
-the radio, sensor and UI loop advancing; the display had initialized but was
-marked off. Its functional CDC0 was in Binary mode after a host-session close.
-Sending a newline-delimited `+++MESHCORE-TERM-START` restored ASCII control.
-With display mode set to `on`, the user confirmed the normal screen appeared.
-Temporary tracing was removed by reinstalling the non-instrumented image, and
-battery/USB profiles were restored to `button-pairing`, 15 seconds. The user
-then confirmed that its button wakes the normal screen after timeout.
-
-T096 recovery firmware: `v1.17.1-t096-check`, Full/shared OTA queue, `-Os` size
-profile, available linked internal RAM 99,892 bytes (73,728 required). ZIP SHA-256
-`f5cfcb001efda559ae9b4e7b105ac9766a604808f5fd708b7c533f8a11c64578`.
-No device-wide filesystem erase was needed for this recovery. T096 does not
-count as an S3 A/B test.
-
-Host validation: `python -m unittest discover -s tools/hil -p test_s3_memory_soak.py`.
-The five checks cover diagnostic filtering, missing data, resets, and counter
-rollover. They are not substitutes for the hardware evidence.
+This document is now in [Test Results](test-results/s3_memory_soak_validation.md).
diff --git a/docs/separated_radio_modulation_cache_validation.md b/docs/separated_radio_modulation_cache_validation.md
index a069f22bf..d261b98fe 100644
--- a/docs/separated_radio_modulation_cache_validation.md
+++ b/docs/separated_radio_modulation_cache_validation.md
@@ -1,140 +1,5 @@
-# Separated radios, normal XIAO RX gain, and unchanged-modulation timing
+# Document moved
-Measured 2026-09-14 on `keymindCascade`, base commit
-`0e5955f889788a4317863e731b38ffe09e3d5441` plus local changes.
+
-The unchanged-modulation optimization reduces XIAO frequency-only retunes
-from **548.880 to 451.897 us (17.7%)** in a same-image control. The separated
-four-channel SF6 test still misses packets. Separation and disabling XIAO
-boosted gain were changed together; their RF effects are not isolated.
-
-## Settings and controls
-
-- XIAO ESP32-S3/WIO SX1262 COM31 receiver; SenseCAP Indicator ESP32-S3 COM28
- reference transmitter. The user moved the radios apart; distance and
- attenuation were not measured. RAK4631 and Indicator RP2040 untouched.
-- XIAO HIL build has `SX126X_RX_BOOSTED_GAIN=0`; actual RX gain register
- readback was **0x94** in initial info, every stationary arm, and every scan
- status. Indicator remains 0x96. Normal XIAO production default is unchanged.
-- 32-symbol preamble, CR4/5, 16-byte synthetic packets, -9 dBm, buffered 8 MHz
- SPI. Four scan centers 909.500/909.750/910.000/910.250 MHz.
-- No guard shortening, forced retunes through packets, RF retransmissions,
- new public N-channel protocol, or additional oscillator-delay bypass.
-
-## Same-image, receive-only timing A/B
-
-Each board ran four three-second windows in off/on/on/off order, rebooting
-between windows. SF6/125 kHz, four channels, 2,612 us nominal dwell, no extra
-IRQ tracing, no TX. A HIL-only hook invalidates just the acknowledged
-modulation tuple before each baseline hop. Optimized windows use production
-cache behavior. The binary and all other settings remain identical per board.
-
-Time is entry to the production retune through BUSY-low observation. It does
-not include the listening dwell or full application scheduling, and is not an
-independent RF settling measurement. Unlike the older mixed-bandwidth tests,
-this frequency-only workload can skip 0x8B.
-
-| Board / policy | Hops | Mean | Min–max | Actual 0x86 / 0x8B writes |
-| --- | ---: | ---: | ---: | ---: |
-| XIAO, always write | 1,898 | 548.880 us | 535–663 us | 1,898 / 1,898 |
-| XIAO, reuse unchanged tuple | 1,957 | 451.897 us | 441–580 us | 1,957 / 0 |
-| Indicator, always write | 564 | 8,263.438 us | 8,228–8,428 us | 564 / 564 |
-| Indicator, reuse unchanged tuple | 602 | 7,601.193 us | 7,583–7,762 us | 602 / 0 |
-
-Pooled means are sample-weighted from reported per-window means. Savings are
-96.983 us (17.7%) for XIAO and 662.246 us (8.0%) for Indicator. All 5,021 hops
-passed RX-mode/software-cache checks with zero retune errors or deferrals.
-Counters observe outgoing SPI opcodes, not merely which software branch ran.
-Initial setup before counter snapshots is excluded. The Indicator remains
-above the common 6 ms switching allowance; no common timing budget changed.
-
-## Stationary RF controls
-
-Full initialization before every case, fixed RX at 910 MHz, ordinary RX with
-fast reuse disabled, shuffled offsets, 450 ms capture windows. Method matches
-the [earlier investigation](preamble_detection_investigation.md).
-
-| TX offset | SF6 preamble windows | SF8 preamble windows |
-| --- | ---: | ---: |
-| Silent | 0/4 | 0/8 |
-| On frequency | 4/4 | 8/8 |
-| -250 kHz | 0/4 | 1/8 |
-| +250 kHz | 0/4 | 1/8 |
-| -500 kHz | 0/4 | 2/8 |
-| +500 kHz | 0/4 | 0/8 |
-| -1,000 kHz | 0/4 | 0/8 |
-| +1,000 kHz | 0/4 | 0/8 |
-
-All 12 on-frequency probes delivered valid payloads. No off-frequency header
-or valid payload, silent preamble, device/read error, foreign valid payload,
-or trace overflow occurred. SF6 off-channel detections changed from 1/24 to
-0/24; SF8 from 32/48 to 4/48. These are small bounded samples, not sensitivity
-or packet-error-rate estimates. Signal strength (RSSI) sampled on-frequency
-was -41 to -40 dBm at SF6 and -42 dBm at SF8: the signal remains strong, but
-these are not calibrated attenuation measurements. Improvement cannot be
-attributed solely to separation or solely to normal gain.
-
-## Four-channel SF6/125, 5.1-symbol visits
-
-Same image, baseline then optimized, reboot both boards between runs. Each
-requested 100 packets/channel with varied unsynchronized arrival timing and
-stopped at its first miss. Extra nominal 64 us IRQ polling was enabled here,
-unlike the receive-only timing windows. No higher channel count was tested.
-
-| Policy | Received before first miss | Failed channel | Timed hops | Mean retune |
-| --- | ---: | --- | ---: | ---: |
-| Always write 0x8B | 24, then probe 25 missed | 1 / 909.750 MHz | 4,746 | 551.253 us |
-| Reuse unchanged tuple | 51, then probe 52 missed | 1 / 909.750 MHz | 7,748 | 455.157 us |
-
-Optimized RF run: 7,697 hops omitted 0x8B, averaging 453.081 us; 51 hops
-reapplied it after received packets, averaging 768.529 us. These are different
-RX lifecycle paths, not a pure 315 us command-cost comparison. Both runs had
-one frequency write per hop and no RX-mode/cache/retune errors. Successful
-packets read -42 to -40 dBm with SNR 10.0–11.5 dB. Neither met 100/100 on every
-channel. More packets before the first miss does not establish a reliable
-improvement in packet-error rate.
-
-The baseline miss captured a target-channel preamble at 176,194 us after
-arming, 1,830 us after a hop onto that channel. A guard held it there; no
-header or packet followed. An IRQ clear was observed at 195,025 us and the
-next hop completed at 195,240 us. This is **not** the earlier SF8 wrong-channel
-hold: the preamble was observed on the intended channel. Its RF source and
-reason for failing to progress remain unproven.
-
-The optimized miss captured 3,256 hops over ten seconds with **no observed
-nonzero IRQ and no guard hold**, reproducing the pre-acquisition symptom of
-the earlier SF6 miss. Target-channel revisit intervals were 12.249–12.324 ms
-(mean 12.286 ms), shorter than the 16.384 ms programmed preamble, yet no packet
-was acquired during those short individual visits. This does not establish
-that additional post-retune settling is needed. Trace buffers did not overflow.
-TX returned success for both failed probes; the MCU clocks are not synchronized
-and there was no independent RF capture.
-
-## Provenance
-
-RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32
-6.11.0 / Arduino ESP32 2.0.17. Both targets built and uploads were verified.
-These captures precede the subsequent SF5/250 bandwidth-selector addition.
-
-Tested firmware SHA-256:
-
-- XIAO: `8aa79da891409c9e600ce14c1d83a8d73c3198225a2aaf799fc884247e882fab`
-- Indicator: `868946d0beca882d8f215c1556c0c19bf41908baac6620dc5632c3fca85b5feb`
-
-Raw files in `tools/hil`, preserved byte-for-byte:
-
-| File | SHA-256 |
-| --- | --- |
-| [XIAO timing](../tools/hil/profile_switch_same_modulation_xiao_results.json) | `45e7a640323a3c9028b6c92a5128e51307db1bd94a4846e8744d3c3ec098adfd` |
-| [Indicator timing](../tools/hil/profile_switch_same_modulation_indicator_results.json) | `f0a85826f500b3a9173bf22c6207fbfba3b6cb17f3d70b849a9fe7621ae3dfc5` |
-| [Stationary SF6](../tools/hil/preamble_stationary_sf6_separated_normal_results.json) | `1d981e5af41eabbc43f92641813bf8f7c5e7015439f5ba53ce0467fe6477d3f3` |
-| [Stationary SF8](../tools/hil/preamble_stationary_sf8_separated_normal_results.json) | `36e63da7435d0a16f61db7db7730893de392d028feca7a96960fd17ae1fa624b` |
-| [SF6 baseline scan](../tools/hil/sf6_125_5p1_separated_normal_baseline_results.json) | `cbfd200170891b334ef3958479694c6e318146993b94073eb9aee981f97fde97` |
-| [SF6 optimized scan](../tools/hil/sf6_125_5p1_separated_normal_optimized_results.json) | `d88a621ef9aa358ce52bb3efb6aa9c4bcb77c681af20b2c66e4aebf3721e17ca` |
-
-Fresh complete 8 MiB backups were saved and separately verified in the private
-current-user backup directory before testing. Restores had already started
-when the user requested leaving HIL firmware installed; those in-flight writes
-and verification finished safely. Both boards were subsequently loaded with
-the newer SF5/250 HIL build for the user's next sweep. Do not restore them
-again unless requested. No private backup contents were added to Git.
+This document is now in [Test Results](test-results/separated_radio_modulation_cache_validation.md).
diff --git a/docs/sf10_10_vs_100hz_full_sample_validation.md b/docs/sf10_10_vs_100hz_full_sample_validation.md
index 264f2cd33..c5f9faf80 100644
--- a/docs/sf10_10_vs_100hz_full_sample_validation.md
+++ b/docs/sf10_10_vs_100hz_full_sample_validation.md
@@ -1,159 +1,5 @@
-# SF10/125 full samples: 10 Hz versus 100 Hz first-pass detour
+# Document moved
-2026-09-14. **Both 400-packet samples completed: 100 attempts on each of four
-channels, with no RF retries or early stop on misses. Payload loss was
-64/400 (16.0%) at 10 Hz and 81/400 (20.25%) at 100 Hz. Strict same-channel
-reception was almost identical: 230/400 versus 231/400. The larger offset
-showed no improvement in this comparison.**
+
-These are two sequential finite bench samples, not proof that the offset
-caused the difference in payload loss. The earlier stop-first tests could
-not provide this error-rate estimate and are not combined with these counts.
-
-## Settings and method
-
-- RX: XIAO ESP32-S3 / Wio SX1262, COM31, 28:84:85:B4:09:80.
-- TX: XIAO nRF52840 / Wio SX1262, COM15, B35E71C1C3726CE7.
-- Uniform SF10, BW125 kHz, nominal CR4/5; 909.5, 910.5, 911.5, 912.5 MHz.
-- 32-symbol preamble; 5.1-symbol visits (41,780 us); zero added settling.
-- Normal RX gain 0x94, -9 dBm chip TX request, 16-byte CHS1 payloads.
-- Warm XOSC, bulk SPI, fast RX; TCXO setting 1600 us. No added IRQ tracing.
-- Two full retunes per hop. First: selected positive RF offset, CR4/6.
- Second: exact original RF command, CR4/5. Duplicate frequency writes
- remain inside each pass: four RF commands, two modulation writes and
- two optimized RX starts per completed hop.
-- Requested 10 Hz = +10 integer RF-word steps, nominal 9.5367431640625 Hz.
- Requested 100 Hz = +105 steps, nominal 100.13580322265625 Hz. Neither
- depends on a float-MHz addition. This is programmed, not independently
- spectrum-measured, frequency.
-- Same receiver image for both runs; only the requested offset changed.
- Order: 10 Hz, reboot, 100 Hz. TX firmware was unchanged.
-- Seed 606125: shuffled channel order each round and varied arrival pauses.
- Each run has 400 unique probe sequences and exactly 100 TXs per channel.
-- Existing 10-second expectation timeout retained. A miss does not reset
- the receiver, tune it toward TX, reset the visit clock or clear counters.
- Hardware/guard/command failures still abort rather than become RF losses.
-
-The complete comparison ran from 21:09:11 to 21:42:39 UTC (14:09–14:42 PDT),
-about 33 minutes. The 10 Hz sample took about 15.4 minutes; 100 Hz about 18.0.
-The extra elapsed time is largely the additional ten-second timeouts.
-
-## Overall results
-
-"Payload loss" means no intact expected payload was delivered within the
-expectation window, regardless of reported RX channel. "Strict error" also
-counts intact payloads received while the scanner reports another channel.
-
-| Requested offset | Attempts | Correct-channel delivery | Intact wrong-channel delivery | Timeouts / payload loss | Strict errors |
-| --- | ---: | ---: | ---: | ---: | ---: |
-| 10 Hz | 400 | 230 (57.5%) | 106 (26.5%) | 64 (16.0%) | 170 (42.5%) |
-| 100 Hz | 400 | 231 (57.75%) | 88 (22.0%) | 81 (20.25%) | 169 (42.25%) |
-
-All scored delivery failures were timeouts. The receiver also counted six
-packet-read errors at 10 Hz and eleven at 100 Hz. The error codes were not
-captured, so these cannot be identified specifically as CRC failures or
-mapped one-to-one to timeout probes. "Zero invalid payloads" in the summary
-does not mean there were no packet-read/CRC errors before timeout.
-
-## Per-channel results
-
-Each cell uses exactly 100 attempts. Loss counts therefore also equal percent.
-
-| Center (MHz) | 10 Hz payload loss | 100 Hz payload loss | 10 Hz strict errors | 100 Hz strict errors |
-| --- | ---: | ---: | ---: | ---: |
-| 909.5 | 10% | 11% | 26% | 25% |
-| 910.5 | 23% | 23% | 51% | 45% |
-| 911.5 | 19% | 26% | 48% | 55% |
-| 912.5 | 12% | 21% | 45% | 44% |
-
-The extra 17 payload losses at 100 Hz occurred on channels 0 (+1), 2 (+7)
-and 3 (+9). Correct-channel deliveries differed by only one packet overall.
-Neither offset eliminated wrong-channel acquisition/delivery or timeouts.
-This comparison does not isolate the underlying RF failure mechanism.
-
-## Switching and signal measurements
-
-| Metric | 10 Hz | 100 Hz |
-| --- | ---: | ---: |
-| Completed hops | 16,992 | 20,676 |
-| Mean complete two-pass switch | 1263.765 us | 1262.551 us |
-| Maximum complete switch | 1533 us | 1532 us |
-| Mean first pass | 660.712 us | 659.458 us |
-| Mean second pass | 592.344 us | 592.406 us |
-| RF commands | 67,968 | 82,704 |
-| Modulation commands / RX starts | 33,984 / 33,984 | 41,352 / 41,352 |
-| Maximum unheld four-channel cycle | 174,139 us | 173,722 us |
-| Maximum held channel visit | 590,757 us | 640,492 us |
-| Packet-read errors | 6 | 11 |
-
-Unheld cycles fit within the 262,144 us programmed preamble; packet-related
-held visits can exceed it. Aggregate timing without IRQ traces does not prove
-which hold caused any particular timeout. The approximately 1.2 us difference
-in mean switching time is negligible here and does not explain the losses.
-
-All completed hops passed the internal first-offset/final-correction and CR
-command checks: 16,992/16,992 and 20,676/20,676, `detour.bad=0`. Second-pass
-blocks, retune failures, mode errors and cache errors were zero. Counts match
-four RF writes, two modulation writes and two RX starts per completed hop.
-
-Telemetry limitation: the diagnostic `detour.first_rf/first_mod` fields are
-updated on a guard-deferred first attempt as well as an applied attempt, while
-`final_rf/final_mod` retain the last completed second pass. Thus the final
-snapshot's displayed pair can be from different attempts and appears equal
-in these captures. Those fields must not be interpreted as the last verified
-pair. Verification itself runs only after two applied passes and increments
-`detour.n` only then; its per-hop comparisons, zero failures and matching
-counts establish the commanded sequence. Original capture bytes are retained.
-
-| Packet group | 10 Hz mean RSSI / SNR | 100 Hz mean RSSI / SNR |
-| --- | --- | --- |
-| Correct-channel delivery | -34.05 dBm / +8.56 dB | -34.15 dBm / +8.62 dB |
-| Intact wrong-channel delivery | -105.18 dBm / -13.75 dB | -105.22 dBm / -13.62 dB |
-
-The markedly weaker wrong-channel readings are observations, not proof of a
-specific filter/image/overload mechanism.
-
-## Implementation and verification
-
-The HIL scanner gained `scancontinue 1`, default off, so its ordinary completion
-and timeout accounting can leave scanning active. The host gained bounded
-`--continue-on-miss --max-channels 4` with separate delivery/strict summaries.
-`scandetouroffset 10|100` and `--first-pass-offset-hz 10|100` select the integer
-offset while stopped. Production radio defaults and receive guards were not
-changed. The controller is `tools/hil/profile_switch_offset_compare.py`.
-
-23 native profile tests and 51 host tests passed. Coverage includes continuing
-through all 400 attempts without restart/retry, balanced channels, separated
-error definitions, default first-miss behavior, aborting fixture failures,
-both integer offsets and exact restoration. Build/upload succeeded with hash
-verification: RAM 94,948 bytes, reported flash 370,213 bytes.
-
-There was one cached USB RX-result replay in each sample, with no RF retransmit.
-All TX commands returned success. Host/device strict receive and miss counters
-matched; both captures are complete under `fixed_sample_complete`. The master
-manifest is complete under `both_400_packet_samples_complete`, with no fixture
-or cleanup error.
-
-Both boards rebooted idle afterward. RX confirmed scanner inactive, zero
-channels, one-pass default, detour/continue disabled and normal gain 0x94.
-TX confirmed no autonomous TX. HIL remains installed; RAK COM29 was not opened.
-The temporary PlatformIO overlay was removed. No background collector remains.
-This work made no commits or pushes. Existing unrelated changes were preserved.
-
-## Provenance
-
-- Manifest: `tools/hil/profile_switch_sf10_10_vs_100hz_full_results.json`
- SHA-256 `40ef9d70991b585abe6b99b3dc4878829412d55e0e8b5a984576b00db0326981`
-- 10 Hz capture: `tools/hil/profile_switch_sf10_10_vs_100hz_full_results_10hz.json`
- SHA-256 `889a7afeb980ec8459fccb8da5a7c496feebd433da8715f84181f02c1da613aa`
-- 100 Hz capture: `tools/hil/profile_switch_sf10_10_vs_100hz_full_results_100hz.json`
- SHA-256 `0b54a057c07f9da0b03f585aeffee960ffcfa2ecb5383914554697ca250aa071`
-- RX BIN SHA-256:
- `bddf779208cbfd24478d39d3075f3f7537b7c9275453492748a349f3ec15b630`
-- Unchanged TX ZIP SHA-256 from the preceding validated upload:
- `915d507d0feec1712fad2bd4be62c2fa8d769d11400711de0da1cf98a50a4b36`
-
-Source: live keymindCascade working tree at HEAD
-`2b5a6f209835f0f67ed73fee78d36447a4566ce6`, plus existing dirty changes and
-these HIL additions. The RX image hash identifies the exact tested build.
-Earlier captures were not overwritten.
+This document is now in [Test Results](test-results/sf10_10_vs_100hz_full_sample_validation.md).
diff --git a/docs/sf10_10hz_cr_detour_validation.md b/docs/sf10_10hz_cr_detour_validation.md
index 5f1975342..e85d9a6af 100644
--- a/docs/sf10_10hz_cr_detour_validation.md
+++ b/docs/sf10_10hz_cr_detour_validation.md
@@ -1,143 +1,5 @@
-# SF10/125: tiny first-pass frequency offset and coding-rate detour
+# Document moved
-2026-09-14. **The requested offset no longer rounds to zero. Actual outgoing
-commands verified a +10-step RF-word change (nominal +9.536743 Hz), then exact
-restoration of the original word, with CR4/6 then CR4/5. Two passes averaged
-1.259 ms in RX-only timing. The first packet still timed out.** This is a
-bounded one-probe failure, not a 400-packet loss-rate estimate.
+
-## Scope and precision
-
-The request was to apply a slightly different center frequency and coding
-rate on the first full retune, then the correct settings on the second,
-with about 0.01 kHz difference. At these centers a float in MHz advances in
-roughly 61 Hz increments: adding 0.00001 MHz can produce the same float.
-
-The HIL implementation does not make that float addition. A scoped integer
-offset changes the four-byte argument of the actual 0x86 frequency command
-by ten steps. The nominal SX1262 step is 32 MHz / 2^25; ten steps are
-9.5367431640625 Hz, the nearest achievable nominal command offset to 10 Hz.
-See the frequency-step specifications in the [Semtech SX1261/2 datasheet](https://cdn.sparkfun.com/assets/6/b/5/1/4/SX1262_datasheet.pdf).
-Pinned RadioLib 7.7.1 also specifies this step and sets the standard sensitivity
-configuration bit for bandwidths other than 500 kHz. This establishes the
-programmed offset, not a spectrum-analyzer measurement of physical frequency.
-
-First pass: destination RF word + 10 and CR4/6. Second pass: original RF word
-and CR4/5. The offset is applied to both existing duplicate frequency writes
-inside the first pass. Caller buffers remain unchanged; a scoped restore
-prevents the offset leaking into the corrected pass or an unrelated command.
-Rollback applies the offset only when restoring the temporary CR4/6 tuple.
-
-Both passes retain ordinary packet/ownership/BUSY guards, command-result
-checks and RX startup. A blocked second pass fails the diagnostic rather than
-overriding a packet guard. Each completed hop verifies both outgoing frequency
-words and modulation words (0x0a040200 then 0x0a040100). The host requires a
-verification count equal to completed hops and zero mismatches.
-
-This feature is HIL-only, off on reboot. Production defaults were not changed.
-The earlier +31.25 kHz draft was superseded before flashing or RF testing.
-
-## Fixture and method
-
-- RX: XIAO ESP32-S3 / Wio SX1262, COM31, 28:84:85:B4:09:80.
-- TX: XIAO nRF52840 / Wio SX1262, COM15, B35E71C1C3726CE7; unchanged firmware.
-- Centers 909.5, 910.5, 911.5, 912.5 MHz; uniform SF10/BW125.
-- 32-symbol preamble, 5.1-symbol visits (41,780 us), no added settling.
-- Normal RX gain 0x94; warm XOSC, bulk SPI and fast RX; TCXO setting 1600 us.
-- 16-byte CHS1 probes, -9 dBm chip TX request, varied arrival phase.
-- Goal 100 packets/channel; stop at the first strict miss or 400/400.
-- Four frequency commands, two forced modulation writes, two optimized RX
- starts per completed hop. No extra IRQ tracing/polling.
-
-Same-image RX-only ABBA controls compare two identical full passes (A) with
-the first-pass detour followed by correction (B), six seconds each, rebooting
-between windows. This is not a comparison against the original single-pass
-approximately 0.53 ms cached-modulation path.
-
-## Timing
-
-| Window | First-pass detour | Hops | Mean total (us) | Maximum (us) | First pass mean (us) | Second pass mean (us) |
-| --- | --- | ---: | ---: | ---: | ---: | ---: |
-| A1 | Off | 139 | 1230.094 | 1464 | 631.727 | 594.281 |
-| B1 | On | 139 | 1259.770 | 1490 | 647.165 | 603.245 |
-| B2 | On | 139 | 1258.806 | 1481 | 646.547 | 602.957 |
-| A2 | Off | 139 | 1230.597 | 1467 | 633.209 | 593.252 |
-
-Mean two-pass control 1230.346 us; detour/correction 1259.288 us, about
-28.943 us additional total including configuration/verification bookkeeping.
-The detour passes themselves averaged 646.856 + 603.101 us. Both detour
-windows verified 139/139 completed hops with zero mismatches; all four
-windows had exactly 556 RF commands, 278 modulation commands and 278 RX
-starts, with zero guard blocks or RX/mode/cache errors.
-
-## Packet result
-
-Probe 1021099889, TX channel 3 at 912.5 MHz, timed out: **0 received / 1
-attempted**, then stopped. TX rc=0, setup 86,914 us, transmit call 529,297 us.
-RX was in receive mode, hardware device-errors=0. No payload-only diagnostic
-ran because this was a timeout, not an intact payload attributed to another
-channel. There were no RF retries.
-
-During that packet window:
-
-- 220 completed hops; 220 verified detour/correction pairs, zero mismatches.
-- 880 RF commands, 440 modulation commands, 440 optimized RX starts.
-- Mean switch 1262.095 us, maximum 1507 us; first pass 652.082 us, second
- 601.295 us. Second-pass-blocked=0, retune failures=0, mode/cache errors=0.
-- Maximum unheld four-channel cycle 172,222 us, below the 262,144 us preamble.
-- Maximum held visit 542,732 us; the aggregate receive-error count was 1.
- That counter means a packet read returned an error; this capture does not
- retain its error code or IRQ trace, so it cannot establish CRC as the cause
- or assign the hold to a particular channel. Device-errors=0 is a separate
- hardware status and does not mean there were no receive errors.
-
-Across both detour timing windows and the packet window, **498/498 completed
-hops** verified the nonzero first-pass RF offset, original final RF word and
-CR4/6-to-CR4/5 sequence. This proves the change was exercised; it did not
-resolve reception in this bounded test. The underlying RF failure remains
-unidentified.
-
-## USB setup issue and verification
-
-The first controller attempt stopped before transmitting because the new
-126-byte capability JSON plus CRLF formed an exact 128-byte native-USB reply.
-It remained buffered until another command was sent. This was preserved as
-a fixture failure, not counted as packet loss. A single LF-terminated write
-(127 bytes) fixed this specific reply; three independent reads then passed
-without a follow-up command, and the complete controller ran successfully.
-
-22 native profile tests and 49 host tests passed. Added tests cover integer
-carry, unchanged caller bytes, both SPI paths, observation of shifted wire
-bytes, exact restoration, scope exit/nesting, non-frequency commands,
-overflow, detour verification failures and the USB reply length. Build/upload
-passed with flash hash verification: RAM 94,948 bytes, reported flash 369,593
-bytes. `git diff --check` passed (unrelated existing CRLF warning only).
-
-Both boards rebooted idle after completion. RX confirmed scanner inactive,
-zero channels, one-pass default, detour disabled, normal gain 0x94. TX reports
-no autonomous transmission. HIL firmware remains installed as requested.
-RAK COM29 was not opened. Temporary PlatformIO overlay removed; no collector
-left running. This experiment made no commits, pushes or production-default
-changes.
-
-## Provenance
-
-- Successful manifest: `tools/hil/profile_switch_sf10_10hz_cr_detour_verified_results.json`
- SHA-256 `84a14c3ca3169b8afdaffbef4405a5ea25140560c799091e5f22257814713bcb`
-- Strict capture: `tools/hil/profile_switch_sf10_10hz_cr_detour_verified_results_strict.json`
- SHA-256 `e7995b327b5a84d8fc1830ece1f4cfccd70cae9f367eefd91b7e97ade4a9fb68`
-- Setup-only failed attempt: `tools/hil/profile_switch_sf10_10hz_cr_detour_results.json`
- SHA-256 `486dd52a4ce5f4b20fadec4e6af2e1ede9669acd3e6a09b3490ae5c8b52b82dd`
-- Installed RX BIN SHA-256:
- `aae7fd3ffcb5226759884e4d1b41d9da67bb97e95ef89d29dd54f204412d6b48`
-- Unchanged TX ZIP SHA-256 from the preceding validated upload:
- `915d507d0feec1712fad2bd4be62c2fa8d769d11400711de0da1cf98a50a4b36`
-
-Successful manifest complete, stopped `bounded_tests_complete`; strict capture
-complete under first-miss stopping. No fixture or cleanup error in that run.
-The build used the live keymindCascade working tree with existing dirty
-changes and these HIL additions, not a clean-release image. HEAD observed at
-completion was `2b5a6f209835f0f67ed73fee78d36447a4566ce6`; the unrelated
-Companion USB commit advanced HEAD from the earlier experiment's base during
-this ongoing work. Its files were not reverted. The RX image hash above is
-the exact tested-image identifier. Prior captures were preserved.
+This document is now in [Test Results](test-results/sf10_10hz_cr_detour_validation.md).
diff --git a/docs/sf10_full_retune_twice_validation.md b/docs/sf10_full_retune_twice_validation.md
index d192d4208..7949f42f4 100644
--- a/docs/sf10_full_retune_twice_validation.md
+++ b/docs/sf10_full_retune_twice_validation.md
@@ -1,116 +1,5 @@
-# SF10/125: apply the full radio settings twice per hop
+# Document moved
-2026-09-14. **Two full retune passes were verified, averaging 1.276 ms per
-channel switch. This did not resolve reception in the bounded packet test:
-the first packet timed out.** This is one failed probe, not a 400-packet loss
-measurement. No production defaults or receive guards were changed.
+
-## Requested interpretation and fixture
-
-"The 125 run" was interpreted as uniform SF10/125 on four channels, not the
-preceding mixed-SF/BW experiment. Apply the complete retune twice to each
-destination: frequency, modulation, preamble/packet configuration and RX
-restart, retaining normal guards and command-result checks for both passes.
-
-- Receiver: XIAO ESP32-S3 / Wio SX1262, COM31, 28:84:85:B4:09:80.
-- Transmitter: XIAO nRF52840 / Wio SX1262, COM15, B35E71C1C3726CE7.
-- Centers: 909.5, 910.5, 911.5, 912.5 MHz. SF10/125, CR4/5, 32-symbol
- preamble, 5.1-symbol visits, 16-byte CHS1 probes, -9 dBm chip request.
-- Normal RX gain 0x94; warm XOSC and fast/bulk RX path; TCXO timing 1600 us.
-- No added settling and no extra IRQ tracing/polling. Goal: 100 packets per
- channel, stop on the first strict miss or a complete 400/400 pass.
-- The previous immediate double frequency write remains inside **each**
- pass: four frequency commands per hop. Modulation is forced in both passes,
- giving two 0x8B commands and two optimized RX starts per completed hop.
-
-The earlier approximately 0.53 ms path could skip unchanged modulation.
-This test deliberately writes those settings again, so it measures more than
-0.53 + 0.53 ms. No artificial time padding was added to meet an expected value.
-
-## Same-image RX-only timing
-
-Six-second windows in 1/2/2/1-pass order, with a reboot before each. Both one-
-and two-pass controls force modulation writes, so the comparison isolates
-complete repetition rather than also changing cache policy.
-
-| Window | Passes per hop | Hops | Mean total (us) | Max total (us) | Mean first pass (us) | Mean second pass (us) |
-| --- | ---: | ---: | ---: | ---: | ---: | ---: |
-| A1 | 1 | 141 | 647.284 | 847 | 644.220 | — |
-| B1 | 2 | 139 | 1275.410 | 1491 | 648.899 | 622.835 |
-| B2 | 2 | 139 | 1276.086 | 1488 | 649.000 | 623.288 |
-| A2 | 1 | 141 | 647.858 | 836 | 644.872 | — |
-
-Equal-weight/hop-count means: one full pass 647.571 us; two full passes
-1275.748 us. The first and second passes average 648.950 + 623.062 us;
-the total includes approximately 3.7 us additional wrapper/BUSY bookkeeping.
-Both two-pass windows had 556 frequency writes, 278 modulation writes and
-278 optimized RX starts. No second-pass guard block or hardware/cache error.
-
-## Packet test
-
-First probe: TX channel 3, 912.5 MHz, sequence 600920621. TX returned rc=0,
-setup 86,914 us, transmit call 529,297 us. RX timed out with mode=1 and device
-errors=0. The controller stopped without retrying or testing further packets.
-No separate payload-only diagnostic ran because this was a real timeout,
-not an intact wrong-channel delivery.
-
-During the packet window:
-
-- 223 channel hops; 892 frequency writes; 446 modulation writes; 446 optimized
- RX starts. Both pass counters = 223, second-pass-blocked = 0.
-- Mean full switch 1275.058 us, maximum 1510 us.
-- First pass mean 648.812 us, second pass mean 622.601 us.
-- Maximum unheld full four-channel cycle 172,262 us, below the 262,144 us
- programmed preamble. Mean listening visit 41,785.719 us.
-- Maximum held visit 405,102 us. No IRQ trace was taken, so the exact cause
- and channel of this hold cannot be assigned from these aggregate numbers.
-- Received 0, missed 1. Mode errors, cache errors, RX errors and device errors
- were all zero. Normal gain remained 0x94.
-
-This shows that completing two real settings applications does not by itself
-guarantee successful reception in this fixture. It does not prove every packet
-would fail or independently identify the cause of this particular timeout.
-
-## Implementation and verification
-
-HIL `BenchWrapper::hop` performs the first ordinary guarded `tuneProfile`,
-then waits boundedly for BUSY-low and requests a refresh of the same profile
-before the second ordinary guarded transaction. This bypasses only the
-same-generation no-op, not receive ownership or packet-in-progress checks.
-The modulation cache is invalidated for each requested full pass.
-
-If the second pass is blocked by newly acquired reception, the diagnostic
-fails closed. Returning ordinary BUSY would wrongly imply that no channel
-change occurred even though pass one had completed; it is therefore treated
-as a fixture failure, not silently skipped or counted as an RF loss.
-
-`scanretunepasses 1` is the reboot default. `scanretunepasses 2` and
-`--retune-passes 2 --modulation-cache off` select this bounded test.
-The standalone controller is `tools/hil/profile_switch_retune_repeat.py`.
-19 native profile tests and 49 host tests passed, including real refresh,
-first/second failure handling, BUSY timeout, no guard override, and rejection
-of missing physical write/resume counts. `git diff --check` passed with only
-unrelated existing CRLF warnings.
-
-Receiver build/upload succeeded with hash verification: 94,908 bytes RAM,
-368,389 bytes reported flash. Transmitter firmware was not changed or
-reflashed. Unrelated RAK COM29 was not opened. Both boards rebooted idle after
-completion, retaining HIL firmware; no autonomous TX or background collector.
-Temporary PlatformIO overlay removed. No commit/push requested or performed.
-
-## Provenance
-
-- Manifest: `tools/hil/profile_switch_sf10_full_retune_twice_results.json`
- SHA-256 `2928955ce4b02828f3de292cdf8a18606cb56d8a5d6b65c7b50c3de1dd15f764`
-- Strict capture: `tools/hil/profile_switch_sf10_full_retune_twice_results_strict.json`
- SHA-256 `322d1859716fbed0e42224f7bfbf71815dcc986df3ccccea37c1680a63723340`
-- Installed RX BIN SHA-256:
- `aa7e3e96729bdb855593385a7a5ab9a22a5e186fda551db863a52a81b7cca2bb`
-- Unchanged TX ZIP SHA-256:
- `915d507d0feec1712fad2bd4be62c2fa8d769d11400711de0da1cf98a50a4b36`
-
-Manifest complete, stopped `bounded_tests_complete`; strict capture complete
-under first-miss stopping. No fixture or cleanup error. Previous captures were
-not rewritten. Source remains keymindCascade commit
-`0e5955f889788a4317863e731b38ffe09e3d5441` plus existing dirty changes and
-these HIL-only additions, not a clean-release comparison.
+This document is now in [Test Results](test-results/sf10_full_retune_twice_validation.md).
diff --git a/docs/sf10_nrf52_double_write_settling_validation.md b/docs/sf10_nrf52_double_write_settling_validation.md
index 4d407c49a..ef5e17155 100644
--- a/docs/sf10_nrf52_double_write_settling_validation.md
+++ b/docs/sf10_nrf52_double_write_settling_validation.md
@@ -1,136 +1,5 @@
-# SF10 / 125 kHz, XIAO nRF52 TX, double write, 1 ms delay grid
+# Document moved
-2026-09-14. **Fixed-channel positive control passed 100/100. The subsequent
-four-channel, double-write sweep failed at every 1 ms step from +0 to +23 ms.**
+
-New sender serial B35E71C1C3726CE7 first enumerated as Zephyr CDC COM33
-(2FE3:0004). A 1200-baud touch exposed Seeed XIAO bootloader COM25
-(2886:0044, XIAO-BOOT). The application-only DFU ZIP was accepted with its
-normal compatibility checks, reported programmed successfully and reappeared
-as COM15 (2886:8044), with the same serial and ready XIAO nRF52840/Wio SX1262
-HIL identity. No bootloader/SoftDevice replacement or full erase was performed.
-The unrelated RAK COM29 (4D6F77FD74011872) was not opened.
-
-## Requested test
-
-- Receiver: existing XIAO ESP32-S3/Wio SX1262 on COM31, normal RX gain 0x94.
- MAC 28:84:85:b4:09:80. The host reasserts double-write mode after every reboot.
-- Sender: XIAO nRF52840/Wio SX1262 COM15, serial B35E71C1C3726CE7.
-- SF10/125 kHz, CR4/5, four channels, 5.1-symbol listening visits, 32-symbol
- preamble, 100 packets/channel, fixed 16-byte probes at -9 dBm chip request.
-- Added post-BUSY settling 0, 1, 2, ... 23 ms. Each failed setting stops at
- its first miss; the entire sweep stops at its first 400/400 pass. No added
- delay beyond the nominal full-sweep timing ceiling and no lower SF runs.
-- Double frequency writes retained; modulation cache on; no extra IRQ trace.
- No prior-transmitter or single-write measurements will be reused.
-
-Budget uses 539 us nominal double-write retune (ceil of the measured 538.192
-us RX-only mean), 41,780 us post-settle visit and 262,144 us preamble:
-`262144 / 4 - 41780 - 539 = 23217 us`. Last 1 ms grid point is 23,000 us.
-This is a nominal ceiling, not a worst-case jitter or zero-loss guarantee.
-
-## Preparation
-
-The settling controller now accepts `--step-us 1000 --frequency-repeat on`;
-its existing defaults are unchanged. Capture policy checks reject mixed
-single/double evidence. The 42 host collector tests and 14 selected profile
-native tests passed, including grid/first-pass stop, nRF USB DTR behavior,
-transmitter replay, setup/TX failure, stationary payload integrity, zero-retune
-and host/device-count checks. `git diff --check` passed (unrelated CRLF warnings).
-
-Minimal `profile_switch_xiao_nrf52_tx` firmware compiled successfully: 95,340
-bytes reported flash usage, 18,212 bytes static RAM. It uses the production
-XIAO pin map and CustomSX1262 initialization, exposes only reference TX and
-cached USB results, and has no Mesh identity or application settings writes.
-Includes the existing build-local bounded nRF USB READY/HFCLK fix.
-
-Platform: Nordic nRF52 10.8.0; Adafruit nRF52 framework
-1.10701.0+sha.d5413016; ARM GCC 14.2.1; RadioLib 7.7.1 at
-`187ef24791c3d844939b2be13a68bd890bd04e4c`.
-
-Generated DFU ZIP contains **application only**, requiring SoftDevice ID
-0x0123. Upload used normal DFU validation without compatibility overrides.
-The V4's earlier no-backup erase authorization is not treated as authority to
-erase this new board's bootloader or unrelated storage.
-
-Flashed nRF52 transmitter SHA-256:
-
-- HEX: `aaee693900dedc3816a6baeca576c7449502d30c05ac48e2663b02acec9aec9a`
-- DFU ZIP: `7466625ef5e7bcc8f7dd16db2b843db29b4312a33b0d32bc0b1e1193d6aee03a`
-
-## Baseline requested before continuing the sweep
-
-The initial four-channel run was interrupted at the user's request after
-13 completed settings, added delay 0..12,000 us. Each missed its first packet:
-nine matched the test sequence but were reported on channel 2 when channel 3
-was expected; four timed out. These are not 13 full 400-packet measurements
-and must not be presented as an unbiased loss rate. The incomplete original
-top manifest and all raw captures under
-`tools/hil/profile_switch_sf10_nrf52_double_1ms_results*` remain unchanged.
-
-Added an independent HIL-only stationary receiver: one full normal radio
-initialization, fixed 910.25 MHz (channel 3), normal RX rearm, no profile
-scheduler/fast-switch/cache shortcut. All 100 packets use the same transmitter,
-SF10/125, CR4/5, 32 preamble symbols, 16-byte CHS1 body and -9 dBm request.
-No RF retries and no early stop on an RF miss; hardware/fixture faults abort.
-
-Result: **100/100**, CRC/read errors 0, foreign packets 0, hardware errors 0,
-observed frequency writes after setup 0. Normal gain register 0x94 and RX mode
-1 verified. RSSI mean -33.610 dBm, range -34..-33; SNR mean +8.736 dB,
-range +7.8..+10.0. Baseline cleanly stopped and both devices rebooted idle.
-This confirms the fixed-channel link in this finite sample, not all-channel
-or interference immunity.
-
-Capture: `tools/hil/profile_stationary_nrf52_sf10_100_results.json`.
-SHA-256: `547c30efc89c29e3f60cb975f7fc34de3e9f14bed3ed4cc9f1ba01f63402bf15`.
-Receiver image with stationary capability compiled/flashed and hash-verified:
-SHA-256 `0daab2e3293359280714042492639ab8c176609143cf01996ad4ba7026704768`.
-Reported flash 364,657 bytes; static RAM 94,796 bytes. Existing double-write
-and four-channel switching implementation is unchanged; baseline is a
-separate inactive command mode during the scan.
-
-## Post-baseline four-channel run
-
-Fresh prefix: `tools/hil/profile_switch_sf10_nrf52_postbaseline_double_1ms_results`.
-Command: `profile_switch_settling.py --receiver COM31 --sender COM15 --sf 10
---frequency-repeat on --step-us 1000 --output .json`.
-No old captures are reused. Stop at the first 400/400 or after +23 ms.
-
-Completed all 24 settings. **No 400/400 pass.** At zero added delay, the first
-packet passed and the second timed out. Each +1..+23 ms setting failed its
-first packet. Total 25 probes: one valid, 17 timeouts and seven test-sequence
-packets reported on channel 2 while expecting channel 3. Each setting stops
-on its first miss; this is deliberately censored evidence, not a measured
-unbiased 4% packet-delivery rate. The lone valid packet was -33 dBm / +8.2 dB
-SNR. The wrong-channel records do not alone distinguish real off-channel
-acquisition from channel bookkeeping; no root cause is claimed.
-
-Measured 3,086 retunes, weighted mean **526.783 us (0.527 ms)**, maximum
-876 us (0.876 ms), excluding the separately applied settling delay. Exactly
-6,172 SetRfFrequency commands confirm two per hop. One modulation command
-was issued across the measured scan windows (after the sole received packet);
-no retune failures, RX-mode errors, cache errors or CRC/read errors.
-All gain checks remained 0x94. This measurement is from the receiver image
-with the new independent baseline mode, not a controlled performance A/B
-against the previous image's 538.192 us RX-only mean.
-
-At +23 ms the measured settling interval was 23,000..23,001 us and mean
-switch-plus-settle 23,567.2 us. That short failed window did not contain a
-complete unheld scan cycle, so it provides no measured final-step full-sweep
-latency. The ceiling remains the nominal 23,217 us calculation above.
-
-Post-baseline top manifest SHA-256:
-`badda7f64a7bdd445942da19dc76dea0d1aace2ae451772edf4744d4670ac28c`.
-Interrupted pre-baseline top manifest SHA-256:
-`90f7b213bc44ae73714c5d9d32216ff3f1760fafd657feb5ea9299ed1e10ddf3`.
-
-Conclusion: the fixed-channel link passes its 100-packet control, but extra
-post-switch settling through the nominal budget does not cure this scanner
-failure. Investigate acquisition/hold behavior and channel attribution next;
-do not claim more waiting, a second frequency write, or a transmitter swap
-has fixed the issue. No guard/default production timing was changed here.
-
-Collector exited successfully at the nominal timing limit, with no cleanup
-error; both radios rebooted idle. The previous follow-up automation remains
-paused. No commit or push requested; HIL firmware remains installed. Temporary
-build overlay removed. No background test collector remains running.
+This document is now in [Test Results](test-results/sf10_nrf52_double_write_settling_validation.md).
diff --git a/docs/sf10_rollback_failure_validation.md b/docs/sf10_rollback_failure_validation.md
index b93de7684..a5011ffbf 100644
--- a/docs/sf10_rollback_failure_validation.md
+++ b/docs/sf10_rollback_failure_validation.md
@@ -1,232 +1,5 @@
-# SF10/125 rollback and wider-spacing failure isolation
+# Document moved
-2026-09-14. **No strict four-channel 400/400 pass. Reverting the switching
-optimizations did not remove failures. Off-channel reception also occurs with
-a fixed, fully initialized receiver, without any channel switching.** A traced
-timeout shows an off-channel preamble holding the scanner away from the
-intended channel until after its transmit preamble. Wider spacing did not
-eliminate either wrong-channel delivery or actual missing packets.
+
-This isolates a concrete scanner failure mechanism, not the underlying
-physical RF cause. Commanded frequency words are observed, but actual emitted
-spectrum, antenna-port power, image responses and overload have not been
-independently measured. Do not claim front-end saturation or a specific spur
-as proven. Do not treat these first-failure-censored samples as unbiased PER.
-
-## Fixture and requested controls
-
-- RX: XIAO ESP32-S3 / Wio SX1262, COM31, MAC 28:84:85:B4:09:80; normal RX gain
- register 0x94 throughout (boosted gain off).
-- TX: XIAO nRF52840 / Wio SX1262, COM15, serial B35E71C1C3726CE7.
-- SF10, 125 kHz BW, CR4/5, 32-symbol preamble, 16-byte sequenced CHS1 probes,
- -9 dBm chip request, four channels, 5.1-symbol post-settle listening visits.
-- Original centers: 909.5, 909.75, 910.0, 910.25 MHz. User-requested wider
- centers: 909.5, 910.5, 911.5, 912.5 MHz (1 MHz spacing).
-- Existing fixed-channel positive control was 100/100; see
- `sf10_nrf52_double_write_settling_validation.md`. No failed packets retried.
-- Unrelated RAK COM29 was not opened. New nRF52 upload was application-only,
- using normal SoftDevice ID 0x0123 validation; no bootloader/SoftDevice erase.
-
-Rollback modes are runtime HIL controls on the same current source, not
-historical firmware checkouts. No production default or receive safety guard
-was changed during this investigation. Tests preserve ownership, packet-in-
-progress, BUSY and command-result checks. Existing unrelated working-tree
-changes were left intact. No commit or push was requested this turn.
-
-## Maximum practical settling control
-
-At SF10/125, a symbol is 8,192 us, the programmed preamble is 262,144 us,
-and each 5.1-symbol post-settle visit is 41,780 us. For each rollback mode:
-
-1. Measure six seconds of RX-only switching without added delay.
-2. Choose the largest 100 us delay step satisfying
- `4 * (41780 + observed_max_switch_us + added_delay_us + 500) <= 262144`.
-3. Measure another six seconds; require at least eight full idle cycles and
- maximum observed idle cycle below the preamble before allowing TX.
-
-The 500 us per-hop reserve covers some observed scheduling variation, not
-all possible jitter. RX is active during added settling. Near-ceiling cycle
-timing is not itself a guarantee of acquisition for every arrival phase,
-especially when an IRQ causes an extended packet hold. Idle-cycle statistics
-exclude guard/packet-held visits; those are measured separately.
-
-## 250 kHz spacing: eleven rollback cases
-
-Each case stopped on its **first probe**, TX channel 3, with a real timeout.
-No strict 400/400 pass, TX fault, RX mode/cache failure, or hardware error.
-The first nine cases used the checkout's existing 1,600 us TCXO setting;
-the final two explicitly restored 6,000 us. Production timing was not edited.
-
-| Case | Added delay (us) | Zero-delay mean switch (us) | Max settled idle cycle (us) |
-| --- | ---: | ---: | ---: |
-| Fast, cached modulation, double frequency write | 22500 | 535.88 | 259297 |
-| Always write modulation | 22500 | 625.50 | 259682 |
-| Individual SF/BW/CR writes | 22200 | 840.97 | 259346 |
-| Full normal RX restart | 22200 | 896.89 | 259577 |
-| Full RX + individual modulation | 22000 | 1113.95 | 259623 |
-| Above + byte-wise SPI, warm XOSC | 21800 | 1215.79 | 259220 |
-| Above + RC standby, 1.6 ms TCXO | 20100 | 2942.50 | 259339 |
-| Fast, cached modulation, single frequency write | 22600 | 450.25 | 259341 |
-| RC/full RX/individual/byte-wise, single write, 1.6 ms TCXO | 20200 | 2837.50 | 259329 |
-| RC/full RX/individual/byte-wise, double write, 6 ms TCXO | 15800 | 7293.25 | 259550 |
-| RC/full RX/individual/byte-wise, single write, 6 ms TCXO | 15800 | 7190.31 | 259139 |
-
-Actual frequency/modulation command counts and fast-RX counts verified each
-policy. Slower modes received less added delay to preserve the total sweep
-budget. These are eleven probes with eleven timeouts, **not eleven complete
-400-packet runs**. Packet arrival pauses use the same seeded scheduling
-method but not hardware-synchronized identical RF phases across modes.
-
-## Fixed-frequency controls: off-channel responses without switching
-
-Each pair received three independent probes after full normal initialization.
-Positive controls were interleaved. The receiver remained at one frequency:
-zero observed frequency commands after setup, no hopping or cache shortcut.
-Payload success requires the exact expected sequence/channel/body, length 16
-and clean CRC. It does not require RX center to equal TX center in this
-explicit offset diagnostic. Offset below means programmed RX minus TX.
-
-| Spacing | RX-TX offset (kHz) | Probes | Intact expected payloads | Preamble IRQ seen |
-| --- | ---: | ---: | ---: | ---: |
-| 250 kHz | 0 | 6 | 6 | 6 |
-| 250 kHz | -250 | 3 | 2 | 3 |
-| 250 kHz | -500 | 3 | 0 | 3 |
-| 250 kHz | -750 | 3 | 0 | 3 |
-| 250 kHz | +250 | 3 | 0 | 2 |
-| 1 MHz | 0 | 6 | 6 | 6 |
-| 1 MHz | -1000 | 3 | 2 | 3 |
-| 1 MHz | -2000 | 3 | 0 | 0 |
-| 1 MHz | -3000 | 3 | 3 | 3 |
-| 1 MHz | +1000 | 3 | 3 | 3 |
-
-At 250 kHz spacing, the two valid -250 kHz-offset packets reported RSSI
--91/-90 dBm and SNR -12.8/-10.8 dB. At 1 MHz spacing, valid off-channel
-packets reported RSSI -102..-109 dBm and SNR -11.5..-15.8 dB. Wider on-channel
-controls were all -33 dBm, SNR +8.0..+8.8 dB. These are radio-reported values,
-not calibrated measurements of transmitted spurs or receiver rejection.
-
-The non-monotonic offset pattern means that increasing spacing alone cannot
-be assumed to remove this response. These fixed-frequency tests rule out a
-channel-hop shortcut as a necessary condition for the observed off-channel
-packets; they do not establish which physical component produces them.
-
-## A traced real timeout at 250 kHz spacing
-
-Fast/double-write RX, +22,500 us settling. One channel-3 probe timed out with
-TX rc=0, RX mode=1 and device errors=0. The trace contains 454 events, overflow
-0. Trace adds 64 us IRQ polling; use it for ordering, not an unperturbed timing
-A/B comparison. Times below are relative to receiver expectation arming.
-
-| RX time (ms) | Evidence |
-| ---: | --- |
-| 169.190 | Frequency command for channel 3, 910.25 MHz |
-| 234.032 | Leaves channel 3 for channel 0 |
-| 298.869 | Frequency command for channel 1, 909.75 MHz |
-| 330.030 | PREAMBLE_DETECTED (0x0004) while on channel 1 |
-| 363.632 | Requested next hop is held by receive-in-progress guard |
-| 726.734 | Guard clears the lingering preamble IRQ |
-| 726.826 | Frequency command for channel 2 |
-| 791.654 | Finally commands intended channel 3 again |
-| 814.586 | Channel-3 hop/settling completes |
-
-TX setup was 86,914 us and its transmit call 529,297 us. Host/firmware timing
-places TX RF start approximately 287..289 ms after RX arming and the end of
-its programmed preamble around 550 ms. This estimate is not a synchronized
-RF measurement, but the receiver's several-hundred-millisecond absence from
-the intended channel is much larger than USB timestamp uncertainty.
-
-The held channel-1 visit measured **427,395 us**, versus normal idle four-
-channel cycle maximum **259,358 us**. The guard timeout is consistent with
-`RadioLibWrapper::calcMaxPacketMillis`: `(32 + 8 + 4.25) * 8192 = 362496 us`,
-rounded to 363 ms. `CustomSX1262::isReceiving()` starts that timer when the
-guard first observes the preamble, here later than the diagnostic IRQ poll.
-
-Thus an off-channel preamble can monopolize the scanner long enough to miss
-the real channel. Do not simply remove or shorten this safety guard as a
-production fix: it also protects legitimate weak or long-preamble reception.
-
-## 1 MHz spacing: strict results versus payload delivery
-
-Three strict rollback cases (fast double, fast single, fully rolled back
-single/6 ms) each stopped at their first probe. All three actually decoded
-the intact channel-3 payload while programmed for channel 2: RX 911.5 MHz,
-TX 912.5 MHz, RF word at read 955777024. They are **channel mismatches, not
-lost payloads**. RSSI was -102/-101/-101 dBm; SNR -11.5/-11.5/-11.2 dB.
-
-An explicitly separate payload-delivery diagnostic then continued through
-such packets while retaining strict validity in raw evidence:
-
-| Mode | Added delay | Delivered before stop | Strict same-channel | Stop | Mean switch | Max idle cycle |
-| --- | ---: | ---: | ---: | --- | ---: | ---: |
-| Fast, cached, double write | 22.5 ms | 3 of 4 probes | 0 | TX channel 0 timed out | 557.721 us | 259381 us |
-| RC/full RX/individual/byte-wise, single write, 6 ms TCXO | 15.8 ms | 2 of 3 probes | 0 | TX channel 1 timed out | 7310.510 us | 259643 us |
-
-Every delivered packet in these two runs arrived one programmed channel below
-its transmitter. Both runs had zero mode/cache/RX/device errors and actual
-switch-policy counts matched. Neither is a 400/400 pass, even under the relaxed
-delivery-only criterion. The true timeout in the fully rolled-back path also
-shows the problem is not limited to the accelerated RX restart or 0x8B cache.
-No traces were enabled for these two runs, so do not assign their individual
-timeouts the exact IRQ timeline from the earlier traced probe.
-
-## Changes and verification
-
-- HIL-only rollback, explicit TCXO timing, selectable 250/1000 kHz spacing,
- frequency-command trace events and fixed-frequency offset controls.
-- Separate opt-in payload-delivery test; strict wire validity remains intact.
- Host/device counters distinguish strict from off-channel deliveries;
- settling sweeps reject delivery-only captures, including reused evidence.
-- 16 native profile tests and 46 HIL host tests passed. Receiver and nRF52
- builds/uploads succeeded with normal verification. Receiver reported
- 94,844 bytes RAM / 366,777 bytes flash; nRF52 18,220 / 95,572 bytes.
-- `git diff --check` passed; unrelated pre-existing CRLF warnings only.
-- Temporary PlatformIO overlay removed. Controllers stopped, both boards
- rebooted idle with HIL firmware retained. No autonomous TX or restoration.
-
-Next useful isolation is the physical off-channel response (controlled RF
-attenuation or reversed radio roles), followed by safe acquisition/hold-policy
-experiments. More settling delay alone does not address the demonstrated
-false-hold mechanism. No claim of an identified silicon defect or verified
-production fix is made here.
-
-## Evidence provenance (SHA-256)
-
-Raw JSON files below are under `tools/hil/`; prior captures were not rewritten.
-Matrix manifests include per-case paths and timing controls. All listed new
-experiments completed under their stated stopping policies without fixture or
-cleanup errors. Completed first-failure runs still have incomplete 400-packet
-levels by design.
-
-| Capture | SHA-256 |
-| --- | --- |
-| profile_switch_sf10_rollback_results.json | `e87c504b3c89fbafe90c18fcd0ab37df34a6aeb51d95b5c69f58d60f70de185a` |
-| profile_stationary_sf10_offset_results.json | `d3d87315513a70fc3f00cc7f17369043328f53ee3028d499087b21e767370ab7` |
-| profile_switch_sf10_rollback_fast_trace_results.json | `8ac9db50f0f06eb7511d7d233957119618df62afdc7112dd83eb68d292fcd725` |
-| profile_switch_sf10_rollback_6ms_results.json | `74e7bfeb23bc569a5786e2322b5e3b1753cba9f628e2969bf4f5c4296ca5817a` |
-| profile_switch_sf10_wide_rollback_results.json | `d79096fbe3e15e0375457b487fd40be3418789e798a978efb79d8298c880eea2` |
-| profile_switch_sf10_wide_payload_results.json | `1316a2c2f32a8c0773fc56587c4220ee8f6f6cdf4ab9b87e00fd361fc9395257` |
-| profile_switch_sf10_wide_legacy_payload_results.json | `fcf2123270634e2f27800787e20b9b8b7ae63bcfb03e17d26a4a4f6f4e23d32f` |
-| profile_stationary_sf10_wide_offset_results.json | `4c494a59fa793d274b0366185cfe3230b6c9fce7a57dfaa61f9f29c36465bbd3` |
-
-RX firmware images used, in chronological order:
-
-| Stage | firmware.bin SHA-256 |
-| --- | --- |
-| Initial nine-mode rollback | `5f8dbf9c4584e0de94a7aebda5a20e961b644a880ecb6a34ab5871621958696f` |
-| Fixed 250 kHz offsets and traced timeout | `cd839caa33c869dd448cc55e9c8b959e39d105f80a2ac4a8029393698b993fd3` |
-| Explicit 6 ms TCXO rollback | `0ba044035c4986749c9ad4f50c702dc8c57e8e0fecf9d46fc11515cad5678166` |
-| Wider strict rollback | `5fc919c80438635b4a96021b39fae2ada9e767508d7bb73854e4e108f0d4920f` |
-| Wider delivery diagnostics and fixed offsets; final installed | `7b313a0f8f1eb04f6fc12197af5e9481ad43154b981ab7627e50167d2a97d8c2` |
-
-The 250 kHz tests used the prior nRF52 TX ZIP hash
-`7466625ef5e7bcc8f7dd16db2b843db29b4312a33b0d32bc0b1e1193d6aee03a`.
-All wider tests used the new selectable-spacing application:
-
-- TX ZIP: `c474b7598a2c0b6c529ead897f3fc7aab8caf4b7581c73d7acac6ffae081377d`
-- TX HEX: `b400fc53c496f4a956f88ccee5cda83b5f6679b8e2b59bfcb4b8155ea9e1e5e9`
-
-Build artifacts are generated and may be replaced by a subsequent build;
-hashes identify the images used, not a promise that every historical binary
-remains in `.pio/build`. Source starts from keymindCascade commit
-`0e5955f889788a4317863e731b38ffe09e3d5441` with the existing dirty changes and
-these uncommitted HIL additions; this is not a clean-release comparison.
+This document is now in [Test Results](test-results/sf10_rollback_failure_validation.md).
diff --git a/docs/sf125_settling_limits_validation.md b/docs/sf125_settling_limits_validation.md
index 88986590f..dd7eb9a45 100644
--- a/docs/sf125_settling_limits_validation.md
+++ b/docs/sf125_settling_limits_validation.md
@@ -1,127 +1,5 @@
-# Descending SF10..SF5 / 125 kHz settling limits
+# Document moved
-Experiment started 2026-09-14. **Stopped at the user's request to replace the
-Indicator transmitter with a V4. No 400/400 pass was found.** The saved
-[top-level manifest](../tools/hil/profile_switch_sf125_first_pass_results.json)
-remains incomplete; the [SF10 manifest](../tools/hil/profile_switch_sf125_first_pass_results_sf10.json)
-contains all completed settings. Original evidence is not rewritten to make an
-interrupted experiment look complete.
+
-## Results at user-requested stop
-
-94 SF10/125 settings completed: every added delay from 0 through 9,300 us in
-100 us increments. Each stopped on its first RF miss; none reached 400/400.
-The best individual setting was +1,000 us: 25 valid packets, then packet 26
-failed. No lower SF was reached in this descending sweep. No result is claimed
-for SF10 delays above 9,300 us or for SF9..SF5 in this run.
-
-Across these completed settings: 641 probes, 547 valid and 94 failed. Because
-each setting ends at its first miss, these totals are not an unbiased packet
-error-rate measurement and do not establish a reliable lower/upper delay limit.
-
-For the 547 valid receptions, reported SNR averaged **+8.311 dB**, minimum
-+7.5 dB and maximum +9.5 dB. RSSI averaged **-40.547 dBm**, range -42 to
--39 dBm. These are conditional on successful reception; no SNR is inferred
-for missing packets.
-
-24,055 retunes averaged **456.620 us**, weighted by hop count and excluding the
-intentional settling delay. Reported retune failures, RX-mode errors and cache
-errors were all zero. Four RX errors were reported, one each at +1,100,
-+5,900, +6,700 and +6,900 us. These counters do not identify the transmitter as
-the cause. At +9,300 us, the unheld four-channel cycle averaged 206.134 ms
-(maximum 206.157 ms), versus a 262.144 ms programmed preamble; this nominal
-time margin still did not produce an all-pass setting.
-
-Controllers and collector were stopped, and both boards were rebooted to idle
-HIL. Follow-up polling is paused. Subsequent `info` checks returned ready on
-both boards, with XIAO gain register 0x94 and Indicator 0x96. Test firmware was
-retained; no original firmware was restored. Replacement-board testing has
-not started.
-
-Saved manifest SHA-256 at stop:
-
-- Top level: `9cbf315704816be4d3cf7977e954e64a2033155e654cd7108919e6a17a5f8f8a`
-- SF10: `509f632de1a463d4f5f84c055a1570468c5752685d596f4b445aa7d7c339189d`
-
-## Requested method
-
-XIAO ESP32-S3/WIO SX1262 COM31 RX, SenseCAP Indicator ESP32-S3 COM28 TX,
-user-separated positions, XIAO boosted gain off (actual register 0x94),
-Indicator gain unchanged at 0x96. Four centers 909.500/909.750/910.000/910.250
-MHz; bandwidth 125 kHz, CR4/5, 5.1-symbol visits, 32 programmed preamble
-symbols, 16-byte synthetic packets at -9 dBm. Warm fast RX, buffered 8 MHz
-SPI and unchanged-modulation cache enabled. No extra IRQ polling/tracing.
-
-Start SF10 and descend one SF at a time through SF5. For each SF, test added
-post-BUSY delay in 100 us increments starting at zero. Every failed setting
-stops on its first invalid reception/timeout; a pass requires 100/100 on
-each of four channels. **Latest user instruction: stop the entire sweep at
-the first 400/400 pass**, without continuing to find an upper limit or a lower
-SF. Without any pass, continue to the ceiling, then lower SF. Fixture errors abort the entire
-experiment. No failed RF probe is retransmitted.
-
-This searches for a sampled all-pass setting, not proof of monotonic delay
-behavior, an upper delay limit, or zero PER. The RX modem remains active during the
-extra delay; the independent 5.1-symbol visit starts afterward. Thus extra
-settling is also extra RX occupancy, not a pre-SetRx blanking interval.
-
-| SF | Visit | Last 100 us delay step | Programmed preamble |
-| --- | ---: | ---: | ---: |
-| 10 | 41,780 us | 23,300 us | 262,144 us |
-| 9 | 20,890 us | 11,400 us | 131,072 us |
-| 8 | 10,445 us | 5,400 us | 65,536 us |
-| 7 | 5,223 us | 2,500 us | 32,768 us |
-| 6 | 2,612 us | 1,000 us | 16,384 us |
-| 5 | 1,306 us | 200 us | 8,192 us |
-
-Budget: `preamble_us / 4 - dwell_us - 453`, using the nominal retune cost.
-Actual cycle means/maxima are recorded and may exceed that nominal budget.
-The randomized arrival pause now includes added settling when estimating
-two scan cycles; earlier saved captures retain their original distributions.
-
-The stop policy changed after SF10 delays 0..5,600 us had all failed. The old
-supervisors were stopped while the active 5,600 us collector finished normally.
-Original captures are preserved. A fresh first-pass manifest reuses completed,
-configuration-validated raw captures with their SHA-256 hashes, then resumes at
-5,700 us; no completed setting is repeated. The earlier top-level manifest is
-incomplete because its upper-limit policy was superseded, not due to an RF or
-fixture fault.
-
-## Build and initial checks
-
-Both HIL targets compiled and verified uploads succeeded. The selected
-**53 host/native tests passed**: 36 host collectors, seven profile native,
-four SX1262, five radio and one receive-mode test.
-After the stop-rule change, all 39 host collector tests passed, including new
-whole-sweep first-pass stopping and matching, hash-preserving capture reuse.
-
-Actually loaded firmware SHA-256:
-
-- XIAO: `89faa3bbc5eade5e632e0c720c75be0ba9105e595b1384de74a00e09c0b60a24`
-- Indicator: `fb06c52e138e5cd65fff1393e3f1208230b06977e5f824852086d4c80c89845c`
-
-A one-second RX-only SF10/+23,300 us smoke check recorded 15 hops with
-minimum delay 23,300 us, minimum post-delay dwell 41,782 us, and zero
-retune/RX-mode/cache errors. Its two clean cycles averaged 262,125 us, maximum
-262,127 us: only a tiny margin against the 262,144 us preamble, not a robust
-worst-case bound. No RF probe was transmitted during that check.
-
-Base `0e5955f889788a4317863e731b38ffe09e3d5441` on `keymindCascade`, plus local
-changes. RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`,
-Espressif32 6.11.0 / Arduino ESP32 2.0.17.
-
-```text
-python tools/hil/profile_switch_sf_limits.py --receiver COM31 --sender COM28 --output tools/hil/profile_switch_sf125_limits_results.json
-```
-
-Updated run:
-
-```text
-python tools/hil/profile_switch_sf_limits.py --receiver COM31 --sender COM28 --output tools/hil/profile_switch_sf125_first_pass_results.json --stop-on-first-pass --reuse-prefix tools/hil/profile_switch_sf125_limits_results.json
-```
-
-The controller reboots to idle HIL after each per-SF child completes or fails;
-test firmware is to remain installed, per user request. Original private flash
-backups are retained. RAK4631, Indicator RP2040 and unrelated S3 soak work are
-not part of this experiment. No commit/push requested. Temporary build overlay
-has been removed.
+This document is now in [Test Results](test-results/sf125_settling_limits_validation.md).
diff --git a/docs/sf5_250_dwell_validation.md b/docs/sf5_250_dwell_validation.md
index a0120cc45..bd2b1a1b2 100644
--- a/docs/sf5_250_dwell_validation.md
+++ b/docs/sf5_250_dwell_validation.md
@@ -1,125 +1,5 @@
-# SF5 / 250 kHz: four-channel dwell sweep
+# Document moved
-Measured 2026-09-14. **No tested dwell from 4.1 through 8.1 chirps passed.**
-Each setting stopped at its first invalid reception or timeout; the sweep
-stopped at the requested 8.1 limit. None reached 100 packets per channel.
+
-## Setup
-
-- XIAO ESP32-S3/WIO SX1262, COM31, receiver; SenseCAP Indicator ESP32-S3,
- COM28, reference TX. Radios remain in the user's separated positions.
-- XIAO boosted RX **off**, verified actual gain register 0x94 before tests
- and in every final scan status. Indicator 0x96, unchanged as transmitter.
-- Four centers: 909.500, 909.750, 910.000, 910.250 MHz. SF5, bandwidth 250 kHz,
- CR4/5, 32 programmed preamble symbols, 16 synthetic bytes, -9 dBm.
-- Production warm fast RX and unchanged-modulation cache enabled, buffered
- 8 MHz SPI. Existing packet guards and initialization unchanged.
-- Requested grid 4.1 to 8.1 in 0.5-symbol steps. Each setting starts fresh,
- shuffles channel order with seed 606125 and varies arrival timing without
- synchronization to RX visits. Both boards reboot between settings.
-- 100 packets/channel maximum; stop that setting on first failure, then move
- to next dwell. No retransmission of a failed probe. Stop the entire sweep
- on first complete 400/400 result or after 8.1; abort on fixture errors.
-- Extra IRQ polling and packet traces disabled to avoid adding diagnostic
- overhead to sub-millisecond visits. RX mode, modem software cache, gain,
- SPI command counts, timing and packet identity checks remain enabled.
-
-```text
-python tools/hil/profile_switch_dwell.py --receiver COM31 --sender COM28 --output tools/hil/sf5_250_dwell_normal_results.json
-```
-
-## Results
-
-"Received" means correct sequence, payload and reported receive channel.
-Each row is a first-failure sample, not a 100-packet/channel PER measurement.
-
-| Chirps | Requested dwell | Received / attempted | First failure | Mean retune |
-| --- | ---: | ---: | --- | ---: |
-| 4.1 | 525 us | 1 / 2 | channel mismatch | 458.122 us |
-| 4.6 | 589 us | 0 / 1 | timeout | 450.787 us |
-| 5.1 | 653 us | 0 / 1 | timeout | 450.667 us |
-| 5.6 | 717 us | 0 / 1 | timeout | 452.270 us |
-| 6.1 | 781 us | 2 / 3 | timeout | 452.991 us |
-| 6.6 | 845 us | 0 / 1 | timeout | 451.082 us |
-| 7.1 | 909 us | 0 / 1 | timeout | 453.796 us |
-| 7.6 | 973 us | 0 / 1 | timeout | 453.568 us |
-| 8.1 | 1,037 us | 4 / 5 | timeout | 457.886 us |
-
-All 16 requested transmissions returned success. Seven met the full receive
-checks. Of nine failures, one returned a matching synthetic sequence on a
-different reported receive channel and eight timed out. Every timeout run
-also recorded one `rx_errors` increment. These are receive/read/decode errors,
-not retune hardware faults; without an IRQ trace their exact cause and
-association with the expected probe cannot be reconstructed.
-
-All **64,507 timed hops** passed RX-mode/software-cache checks with zero
-retune failures. Weighted mean switch time was **452.739 us**, excluding
-listening dwell and full application scheduling. Each hop programmed a
-frequency. Modulation rewrites occurred only when the prior context needed
-re-establishing, rather than on every frequency-only hop.
-
-The 4.1 second probe was transmitted for channel 2 (910.000 MHz), but its
-sequence was returned while the receiver reported channel 3 (910.250 MHz),
-16 bytes, RSSI -66 dBm, SNR 0.5 dB. It therefore failed the channel check;
-the saved `valid=false` also does not independently certify all payload bytes.
-No synchronized RF/frequency-register capture was taken, so this is evidence
-of wrong-channel reporting/reception, not proof of its physical mechanism.
-Successful correct-channel probes read -42 to -41 dBm, SNR 7.8–8.8 dB.
-
-## Interpretation
-
-SF5/250 did **not** establish a solution to the close-range problem, and
-increasing dwell alone did not produce a clean four-channel result. The
-wrong-channel sequence and receive errors prevent interpreting this as a
-pure minimum-dwell/acquisition test. It is not evidence that 8.1 is a reliable
-setting merely because four probes passed before its miss.
-
-There is also a timing tradeoff: a symbol lasts 128 us, so 32 programmed
-preamble symbols occupy only **4.096 ms**. A ~453 us retune costs about
-**3.54 symbols**. At 4.1, four nominal dwells plus four such retunes consume
-about 3.91 ms before other loop costs; at 8.1 they consume about 5.96 ms.
-Longer visits give acquisition more uninterrupted time but increase the time
-spent away from each channel. This arithmetic motivates testing a longer
-preamble or fewer channels separately; it does not prove the cause of any
-particular failure. No such follow-up was transmitted after the 8.1 limit.
-
-Unlike the [earlier separated SF6 run](separated_radio_modulation_cache_validation.md),
-this changes SF, bandwidth, absolute preamble duration and diagnostic polling.
-The RF settings also leave channel spacing unchanged at 250 kHz. It is not a
-matched comparison isolating receiver sensitivity or the modulation cache.
-
-## Provenance and handoff
-
-Base `0e5955f889788a4317863e731b38ffe09e3d5441` on `keymindCascade`, plus
-uncommitted production cache and HIL changes. RadioLib 7.7.1 at
-`187ef24791c3d844939b2be13a68bd890bd04e4c`; Espressif32 6.11.0 / Arduino
-ESP32 2.0.17. Both HIL builds and verified uploads succeeded. All **39 selected
-host/native tests** passed, including bandwidth/dwell calculation and bounded
-sweep stopping. No commit or push was requested for this work.
-
-Tested firmware SHA-256:
-
-- XIAO: `6ba7b4c6785ac11eb8a4ecd2f1148f4f55ce866c1cb172776757fe0a95047da9`
-- Indicator: `3672ca330d4b2d3e8efa39edf2dbc3b27b8804dbb9a98f29267c85c41972a028`
-
-Raw [manifest](../tools/hil/sf5_250_dwell_normal_results.json) references all
-nine per-dwell captures. SHA-256 values (all files under `tools/hil`):
-
-| File suffix after `sf5_250_dwell_normal_results` | SHA-256 |
-| --- | --- |
-| `.json` | `aaf00ef8531cb2c5c7e4dfb78d3b8b03ce61d9afe8323fb33e70be09656adf54` |
-| `_4p1.json` | `a9c6baf0bece79c9b9c96bfa97e3cd9932e0c04fc76b43f61ed350512306341a` |
-| `_4p6.json` | `63863b55108374011afbe555bfeb97034b010e194cdad83a212d5d286d1992fe` |
-| `_5p1.json` | `ff1d2927365e3634984bc0b843a09f2934c5dcac49b60b862ff4db030e0f7e74` |
-| `_5p6.json` | `33c91687d0b61dd83e2a08d8358b43f21fa27f9e80f9503feedeb657453a7efd` |
-| `_6p1.json` | `5baa8d1bfcf0ccc947c92d563dc555802ac599ce1e8d260a623964936a99da4e` |
-| `_6p6.json` | `90f6b4d7785add771e30da7dfa78672fd75ad13d8a5965c21ec1a37e15d8dabb` |
-| `_7p1.json` | `eb99afd932a5579c765f8a6c0a18db4bacc317829b0654d11fbf2f569c3a8618` |
-| `_7p6.json` | `bdb88da82437fa6e01ad90ab927d364b4d67aaadbac7597c3aacb9799b053211` |
-| `_8p1.json` | `404ee179e4c10333bd6fddae9f0faa9da81f6193dcb5fff6d9bd37a466c319a3` |
-
-Both boards were rebooted after the sweep and answered ready with correct
-HIL capabilities; XIAO gain remained 0x94. Per user request, **HIL firmware is
-left installed**, idle with no autonomous test transmissions. Private original
-flash backups remain available. The RAK4631, Indicator RP2040 and unrelated
-S3 soak work were not modified. The temporary build overlay was removed.
+This document is now in [Test Results](test-results/sf5_250_dwell_validation.md).
diff --git a/docs/sf6_125_settling_validation.md b/docs/sf6_125_settling_validation.md
index 70c5188af..d0a811ae2 100644
--- a/docs/sf6_125_settling_validation.md
+++ b/docs/sf6_125_settling_validation.md
@@ -1,148 +1,5 @@
-# SF6/125 four-channel post-switch settling sweep
+# Document moved
-Measured 2026-09-14. **No setting from +0.0 through +1.0 ms passed 400/400.**
-The requested delay was applied after BUSY went low without consuming the
-subsequent 5.1-symbol listening visit. Each run stopped on its first miss; the
-sweep would have stopped at the first 400/400 pass, but reached its nominal
-timing limit instead. No RF probes were sent beyond that limit.
+
-## Method and timing budget
-
-- XIAO ESP32-S3/WIO SX1262 COM31 receiver, SenseCAP Indicator ESP32-S3 COM28
- transmitter, unchanged user-separated positions. XIAO boosted gain off,
- actual register 0x94 verified; Indicator 0x96. Original backups retained.
-- SF6 / 125 kHz / CR4/5, four centers 909.500/909.750/910.000/910.250 MHz,
- 32-symbol preamble, 16-byte synthetic packets, -9 dBm.
-- Warm fast RX, buffered 8 MHz SPI, unchanged-modulation reuse enabled.
- Same XIAO image for every delay; unchanged Indicator reference image.
-- 100 packets/channel requested, balanced shuffled channel order (seed 606125),
- varied unsynchronized arrival timing. Both HIL boards reboot between delays.
-- No extra IRQ polling or packet traces; ordinary ownership/preamble guards
- remain enabled. No RF retries, shorter channel count or altered preamble.
-
-At SF6/125, a symbol is 512 us. The requested listening visit rounds up to
-ceil(5.1 * 512) = 2,612 us; 32 programmed preamble symbols last 16,384 us.
-Using the user's nominal 453 us retune, the extra per-hop budget is:
-
-`16,384 / 4 - 2,612 - 453 = 1,031 us`
-
-Thus the 100 us grid runs from 0 through 1,000 us; 1,100 us would exceed the
-nominal full-sweep calculation. Sync/header time is not spent as extra scan
-budget. This is not a worst-case scheduling or acquisition guarantee.
-
-The delay is **HIL-only**: after a successful guarded production retune and
-BUSY-low observation, the CPU waits without SPI/IRQ operations, then starts a
-separate 2,612 us listening clock. The SX1262 is already receiving throughout
-that delay and may acquire a packet. This tests extra RX occupancy/settling,
-not a pre-SetRx delay or a period with RX disabled. Ordinary RX restarts after
-processing packets reset the visit clock without another channel delay.
-Zero delay preserves the previous production RX-start clock.
-
-An initial receive-only, 300 ms smoke check at +1,000 us measured 74 hops,
-minimum applied delay 1,000 us and minimum subsequent dwell 2,612 us. Its
-17 clean full cycles averaged 16,235.941 us, maximum 16,260 us. No RF probe
-was transmitted in this check. The longer packet experiments below capture
-more scheduling variability, including host command handling.
-
-## Results
-
-All times in the last two columns are measured means, not target values.
-"Received" is successes before the next probe timed out; every row ended in
-one timeout. These first-failure samples do not estimate comparable PERs.
-
-| Added delay | Received before miss | Switch + delay | Clean full cycle |
-| ---: | ---: | ---: | ---: |
-| 0.0 ms | 44 | 0.452381 ms | 12.256886 ms |
-| 0.1 ms | 4 | 0.548387 ms | 12.669976 ms |
-| 0.2 ms | 34 | 0.650424 ms | 13.074105 ms |
-| 0.3 ms | 53 | 0.753410 ms | 13.485859 ms |
-| 0.4 ms | 47 | 0.850830 ms | 13.868454 ms |
-| 0.5 ms | 66 | 0.952289 ms | 14.273736 ms |
-| 0.6 ms | 14 | 1.050333 ms | 14.671156 ms |
-| 0.7 ms | 21 | 1.151351 ms | 15.076094 ms |
-| 0.8 ms | 63 | 1.255113 ms | 15.482272 ms |
-| 0.9 ms | 19 | 1.352843 ms | 15.880361 ms |
-| 1.0 ms | 25 | 1.452750 ms | 16.283488 ms |
-
-All **401 transmissions** returned success: **390 valid receptions and 11
-timeouts**, spread across the eleven independently stopped settings. None
-completed 100/100 on each channel. All **52,609 timed hops** passed RX-mode
-and software-cache checks, with one observed frequency write per hop and zero
-retune failures. Each positive-delay hop waited at least the requested time;
-every run's minimum subsequent idle dwell was 2,612 us. No receive-error
-counter increment or device error was reported. Successful RSSI was -42 to
--40 dBm and SNR 9.5–11.8 dB.
-
-`switch` measures production retune entry through BUSY-low observation;
-`settling` measures the following wait, including timer overhead;
-`switch_and_settle` combines them. Even zero requested delay records roughly
-1–2 us of timer/bookkeeping overhead in the combined boundary. Listening
-dwell and full application scheduling are not included in retune time.
-
-`idle_cycle` measures consecutive channel-0 return times, excluding initial
-cycles and cycles containing packet/guard-held visits. It still includes loop
-and USB work encountered during otherwise unheld visits. At +1.0 ms its mean
-is 100.512 us inside the preamble budget, but its **maximum was 17.475 ms**,
-exceeding 16.384 ms. At +0.9 ms the observed maximum was 16.153 ms. Therefore
-the last grid point fits only the nominal/mean calculation, not every observed
-full cycle. Legitimate packet holds can extend scan timing further.
-
-## Conclusion and limits
-
-Extra post-switch settling through the available timing budget did not
-establish loss-free four-channel reception. The 66 successes at +0.5 ms are
-not evidence that it is an optimum; the results are small, first-failure
-samples with different uncontrolled RF arrival phases. The added delay also
-extends total RX occupancy, so any improvement would not isolate analog
-settling from longer acquisition time.
-
-Every failure here was a timeout without a reported receive/decode error.
-Without an IRQ trace or synchronized independent RF capture, that does not
-prove absence of a preamble/header indication or identify the precise cause.
-It does not justify changing production packet guards or adding a permanent
-settling penalty. No production default was changed by this experiment.
-
-## Reproduction, provenance and handoff
-
-```text
-python tools/hil/profile_switch_settling.py --receiver COM31 --sender COM28 --output tools/hil/sf6_125_settling_normal_results.json
-```
-
-See [harness instructions](../tools/hil/profile_switch_README.md#post-switch-settling-delay-sweep-sf6125).
-Base `0e5955f889788a4317863e731b38ffe09e3d5441` on `keymindCascade` plus local
-changes. RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`,
-Espressif32 6.11.0 / Arduino ESP32 2.0.17. Both HIL targets compiled; only
-XIAO needed an upload for the new receiver-side delay. All **45 selected
-host/native tests** passed, including delay/visit separation, packet restart,
-timer wraparound, clean-cycle accounting, bounded grid and first-pass stop.
-
-Actually loaded firmware SHA-256:
-
-- XIAO: `a0dbc113dcb297be34e22c0bc058db71c4108659e1e289679ecdbfc5c0009f8e`
-- Indicator, unchanged from SF5/250 sweep:
- `3672ca330d4b2d3e8efa39edf2dbc3b27b8804dbb9a98f29267c85c41972a028`
-
-Raw [manifest](../tools/hil/sf6_125_settling_normal_results.json) and per-delay
-captures are preserved byte-for-byte. SHA-256, file suffixes after
-`tools/hil/sf6_125_settling_normal_results`:
-
-| Suffix | SHA-256 |
-| --- | --- |
-| `.json` | `41a20c3d5b809cd225772980fbe0d305c14391659f10d685bf78f8bdf326e61c` |
-| `_0us.json` | `47a73a35ed72ebc61b88bfe315e86bf7e15ba1d2d93e68ce92ce681e1ef73cef` |
-| `_100us.json` | `b585ea01f605bc876da86ba2a35fbe6e7cc6543eb5e827ba190c51dca10dcb85` |
-| `_200us.json` | `6ce378cd079eefae8450c7f5672281095b9b99e28e0c7920f024a4396606bb40` |
-| `_300us.json` | `d93e7a258dddb6731c030e395e09c3f06b4d5988638cc85dc5cd0745256c6202` |
-| `_400us.json` | `56bfe4cb7cbab2a660a232a2bf85b9bb87221ffade7c9bbce6d1116e6c30fcbf` |
-| `_500us.json` | `fffb6c33a506f537913084fc4f5cc9f8009a4f0f61e4f290dd81a7409f53d1bd` |
-| `_600us.json` | `7bc6dd41f0079f37bdbf77fc48c4113e26589bcce704850b106c33d057e34659` |
-| `_700us.json` | `54170f5b63bbe8ffa231852bf892ea55d8579c33631775f29fb7c127197afb2a` |
-| `_800us.json` | `f7c53375b29764b4369b84e8ac3244aa37e385c8738a9dfd7f3257d85a64a705` |
-| `_900us.json` | `56bc3691f2046cafcf2115b2f6b7bd164e9c9039733ea51028db292deea10230` |
-| `_1000us.json` | `f708af37caee8f689f2436567382458d63aba1269e8306ac6e4d0079677b9115` |
-
-Both boards rebooted to idle HIL after testing and answered ready. XIAO gain
-remained 0x94 and settling policy was returned to zero. **Test firmware stays
-installed**, per user request; no restore, commit or push was performed this
-turn. RAK4631, Indicator RP2040 and unrelated S3 soak work were untouched.
-The temporary build overlay was removed; no autonomous test TX remains.
+This document is now in [Test Results](test-results/sf6_125_settling_validation.md).
diff --git a/docs/sf6_5p1_trace_validation.md b/docs/sf6_5p1_trace_validation.md
index 0d18e72ae..a249f4f5c 100644
--- a/docs/sf6_5p1_trace_validation.md
+++ b/docs/sf6_5p1_trace_validation.md
@@ -1,138 +1,5 @@
-# SF6 / 125 kHz: four channels at 5.1 chirps
+# Document moved
-Measured 2026-09-14. **Seven packets received, then packet 8 missed.** The run
-stopped at that first miss. No RF retry, other dwell, or higher/lower channel
-count was tested. The target of 100 packets per channel was not reached.
+
-Follow-up: [SF8/125 at the same 5.1-chirp dwell](sf8_5p1_trace_validation.md)
-missed on packet 13 and captured an off-channel preamble hold. That differs
-from the absence of observed preamble in this SF6 failure.
-
-## Setup
-
-- XIAO ESP32-S3 + WIO SX1262 scanner, COM31.
-- SenseCAP Indicator ESP32-S3 reference transmitter, COM28.
-- Four centers: 909.500, 909.750, 910.000, 910.250 MHz.
-- SF6 / 125 kHz / CR4/5, 32-symbol programmed TX preamble.
-- User-corrected dwell: **5.1 symbols**, ceil(5.1 * 512) = **2,612 us**.
-- Same warm production fast retune, buffered 8 MHz SPI, packet guards.
-- 16-byte synthetic payloads at -9 dBm; balanced randomized channel order,
- seed 606125; varied arrival timing, not synchronized to scan phase.
-- IRQ changes, IRQ clears, hops and first guard deferrals buffered in RAM.
- Additional read-only IRQ polling at nominal 64 us intervals while armed.
- No per-event serial printing during capture. Trace read after each result.
-
-```text
-python tools/hil/profile_switch_channels.py --receiver COM31 --sender COM28 --output tools/hil/sf6_125_5p1_trace_results.json --samples 100 --preamble 32 --max-channels 4 --dwell-symbols 5.1 --trace
-```
-
-## Results and what the trace establishes
-
-Probes 1–7 succeeded on channels **3, 2, 1, 0, 2, 3, 1**. Probe 8, sequence
-`944962150`, missed on channel **0 (909.500 MHz)**. Thus channel 0 had already
-received a valid probe; this was not a channel that never worked at all.
-Successful packets had RSSI -42 to -40 dBm and SNR 9.5 to 10.8 dB.
-
-The failed probe has a complete, non-overflowed 3,160-event trace:
-
-- Expectation armed on channel 3 without selecting the expected channel.
-- 3,158 completed hops during the bounded 10-second confirmation interval.
-- **No observed nonzero IRQ bits**: no preamble, header, RxDone or CRC event.
-- **No guard-held visit** on any channel during that expectation.
-- Receiver timeout at 10,000,369 us, with RX mode and zero device errors.
-- Reference TX returned success. All USB sequence acknowledgements matched;
- no stale replies, missing acknowledgements or cached transport recovery.
-
-The scanner was not stuck on the wrong channel by a false preamble. It also
-did not acquire a header and then fail a CRC check. The evidence locates the
-failure before **observed preamble acquisition**, not at payload decoding.
-It does not prove why acquisition failed: a short-visit/arrival-phase issue
-remains a candidate, as does an unobserved RF/reference issue. There was no
-independent RF capture, and the two MCU clocks were not synchronized.
-
-### Actual timing during the failed expectation
-
-| Measurement | Minimum | Mean | Maximum |
-| --- | --- | --- | --- |
-| Receive dwell, 3,158 hops | 2,613 us | 2,628.706 us | 2,646 us |
-| Retune, trace hop rows | 526 us | 538.700 us | 630 us |
-| Channel 0 revisit interval, 789 cycles | 12,643 us | 12,666.829 us | 12,705 us |
-
-The repeatable scan timing rules out a large software scheduling stall in the
-failed capture. The 32 programmed symbols occupy 16.384 ms, longer than that
-idle scan cycle; nevertheless, revisiting within the preamble did not ensure
-successful acquisition during these short individual visits.
-
-### Successful acquisition traces
-
-Times below are relative to the logged hop completion onto the eventual
-receive channel, not a precisely captured RF edge or hardware RX-start edge.
-The trace observed the following progression for every successful probe:
-PreambleDetected (0x0004), HeaderValid (0x0014), RxDone (0x0016), consumption.
-
-| Probe | Channel | First observed preamble after hop | RxDone after hop |
-| --- | --- | --- | --- |
-| 1 | 3 | 1,838 us | 32,982 us |
-| 2 | 2 | 1,836 us | 35,470 us |
-| 3 | 1 | 1,844 us | 35,151 us |
-| 4 | 0 | 2,332 us | 37,329 us |
-| 5 | 2 | 1,847 us | 35,532 us |
-| 6 | 3 | 1,831 us | 30,731 us |
-| 7 | 1 | 1,329 us | 32,567 us |
-
-After detecting preamble, the production guard kept those visits on-channel
-until reception completed. The failed probe never reached that observed
-state. This is not evidence that 5.1 symbols always suffice, nor does 7/8
-estimate a stable packet-error rate or establish a channel-capacity limit.
-
-## Instrumentation and reporting caveats
-
-The failed capture made 115,783 diagnostic IRQ polls, costing a total
-2,821,617 us (about 24.4 us each; maximum 61 us). These reads are not free:
-they alter loop load, although actual dwell and revisit timing were measured.
-This is **not a matched A/B** against the earlier untraced 4.8-symbol run, so
-seven successes versus two cannot be attributed solely to the extra dwell.
-
-The run used `channel_trace: 1`. During analysis, a reporting bug was found in
-the newly added `held_dwell` aggregate: production `startRecv()` resets the
-visit timestamp after reading a packet. That aggregate therefore omitted the
-preceding held reception time. **Do not use its 2.709 ms mean as total held
-occupancy.** The timestamped IRQ/hop trace above is the evidence for the actual
-30.7–37.3 ms hop-to-RxDone intervals. The failed capture had no RX restart, so
-its dwell rows are unaffected.
-
-The source now keeps a separate channel-occupancy timestamp (`channel_trace:
-2`, `trace_format: 2`). Both HIL targets compiled with this reporting-only
-correction; it was **not flashed or RF-tested**, and the saved raw result was
-not rewritten. Scheduler, packet guard and radio commands were not changed by
-that correction. Regression coverage includes passive SPI observation,
-bounded/frozen capture, occupancy accounting, page identity, requested dwell,
-and stopping at the first miss.
-
-## Provenance and device handoff
-
-- [Unmodified raw result and all eight traces](../tools/hil/sf6_125_5p1_trace_results.json)
-- [Harness and trace format](../tools/hil/profile_switch_README.md#traced-four-channel-diagnostic)
-- [Earlier 4.8-symbol result](sf6_channel_scan_validation.md)
-
-Raw result SHA-256:
-`7caa16483b7b0000b388a1906395fe3d381bd0e40de73f48cd5c876d96deab3b`.
-
-**Actually tested** firmware SHA-256 (trace version 1, before reporting fix):
-
-- XIAO: `35c201f367b0aaa693be03542a4c115024d1a70a2be2fbdbef2c94da5a6053ae`
-- Indicator: `8a25d84fc99f6aeaf8318bf4533767e16a9afc0de51394fce27d3a610c63b352`
-
-Base commit `c329cf1fc1e7f2e8c8733ab0d000094730458fe3` plus local changes;
-RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32
-6.11.0 / Arduino ESP32 2.0.17. No normal production firmware change this turn.
-
-Fresh full 8 MiB snapshots were saved and digest-verified before flashing:
-
-- XIAO: `35aa2deeb8a8145bc54435c9f87b3db1c65f320f2568ee0b489305db1a6330c2`
-- Indicator: `dba59cde1df3c69dd2e5995950315951f75cd43886b21be8b14e261ec4b57309`
-
-Both snapshots were restored afterward, separately verified against the full
-flash, and hardware-reset. Snapshots remain in the private current-user backup
-directory. The RAK4631 and Indicator RP2040 were not modified; the XIAO soak
-logger was not restarted. Changes were initially handed off locally before publication.
+This document is now in [Test Results](test-results/sf6_5p1_trace_validation.md).
diff --git a/docs/sf6_channel_scan_validation.md b/docs/sf6_channel_scan_validation.md
index ee0c136af..069831e4d 100644
--- a/docs/sf6_channel_scan_validation.md
+++ b/docs/sf6_channel_scan_validation.md
@@ -1,106 +1,5 @@
-# SF6 / 125 kHz channel-count test
+# Document moved
-Measured 2026-09-13. The run stopped on the **third packet at the initial four
-channels**: two arrived intact, then the receiver timed out. No five-channel
-test, lower-channel test, retry or retransmission was performed. Consequently
-this run does **not** establish a maximum channel count with 100% reception.
+
-Follow-up: [four channels at 5.1 symbols with an IRQ trace](sf6_5p1_trace_validation.md)
-stopped at packet 8 and observed no preamble acquisition during the miss.
-
-## Requested setup
-
-- Receiver: XIAO ESP32-S3 + WIO SX1262, COM31.
-- Reference transmitter: SenseCAP Indicator ESP32-S3 LoRa processor, COM28.
-- SF6 / 125 kHz / CR4/5, fixed **32-symbol programmed preamble**.
-- **4.8-symbol receive visits**: 2,457.6 us, rounded up to 2,458 us.
-- Buffered 8 MHz SPI and the production fast retune/packet-ownership guards.
-- Start at four channels and add one only after 100/100 packets on every channel.
-- Stop at the first missing/corrupt packet; USB/reference/scanner faults are
- reported separately as inconclusive fixture errors.
-- 16-byte synthetic payloads at -9 dBm. No identity/channel secrets transmitted.
-- Four channel centers: 909.500, 909.750, 910.000 and 910.250 MHz.
-- Balanced randomized channel order, seed 606125; varied arrival timing across
- approximately two idle scan cycles. Arming an expectation does not select
- the receiver's channel or reset its visit clock.
-
-The 100-packet-per-channel target was **not reached**: the explicit first-miss
-stop rule ended the run during its first shuffled pass.
-
-## Observations
-
-| Probe | Channel center | Outcome |
-| --- | --- | --- |
-| 1 | 910.250 MHz | Valid 16 bytes, RSSI -40 dBm, SNR 10.8 dB |
-| 2 | 910.000 MHz | Valid 16 bytes, RSSI -40 dBm, SNR 11.0 dB |
-| 3 | 909.750 MHz | Receiver timeout; transmitter returned success |
-
-All three transmit acknowledgements and receiver reports matched their
-sequence IDs, with no missing/replayed/stale USB replies. On the miss, the
-receiver reported RX mode and zero device errors. The scanner recorded zero
-retune failures, RX-mode errors, modulation-cache errors or CRC/read errors.
-No probe was sent on 909.500 MHz before the stop condition.
-
-| Timing | Minimum | Mean | Maximum |
-| --- | --- | --- | --- |
-| Production retune to BUSY low, 3,467 hops | 504 us | **514.652 us** | 726 us |
-| Idle receive dwell, 3,463 visits | 2,458 us | **2,462.480 us** | 2,468 us |
-
-These samples include scanning during the bounded 10-second wait that
-confirmed the missing packet. There were 2,451 deferred *attempts* while
-production preamble/header/packet ownership held a visit; this is not a count
-of packets or failed retunes. Held visits are excluded from idle-dwell timing.
-All 3,467 successful retunes used the production fast-RX path.
-
-The means imply about **11.91 ms per four-channel idle cycle**, excluding
-packet-held visits and small loop bookkeeping. Fast retuning alone did not
-produce 100% reception in this setup. This short stop-on-first-miss experiment
-does not identify the exact acquisition/phase cause, estimate a stable PER,
-prove that three channels would pass, or establish a universal board limit.
-
-## Implementation and reproduction
-
-This is a lab-only N-channel scheduler over the real `tuneProfile()` path.
-It reuses the two production profile slots as staging slots for the next
-frequency. Normal MeshCore `radio2` remains two-profile. The explicit test
-schedule intentionally does not use the normal two-profile automatic-preamble
-fit calculation or generic 6 ms switch / 4 ms loop allowances. It keeps the
-existing packet-protection guards and measures real dwell/retune overhead.
-
-```text
-python tools/hil/profile_switch_channels.py --receiver COM31 --sender COM28 --output results.json --samples 100 --preamble 32
-```
-
-The controller has regression tests for balanced samples, starting at four,
-incrementing only after a perfect level, stopping without another TX, and
-distinguishing fixture failure from an RF miss. A separate RX-only 0.5-second
-setup check verified the scan timing before any capacity-test packets were
-sent. The capacity run itself was not repeated.
-
-- [Raw result, including the failing probe](../tools/hil/sf6_125_channel_capacity_results.json)
-- [Host controller](../tools/hil/profile_switch_channels.py)
-- [Firmware scheduler](../tools/hil/profile_switch_channels.h)
-- [Harness instructions](../tools/hil/profile_switch_README.md#sf6125-khz-channel-count-limit)
-- [Earlier USB and fast-switch qualification](radio_profile_switch_validation.md#production-integration-and-usb-recovery-v8)
-
-Build provenance: base commit `c329cf1fc1e7f2e8c8733ab0d000094730458fe3`
-plus the local fast-switch and HIL changes; Arduino ESP32 2.0.17,
-Espressif32 6.11.0, RadioLib 7.7.1 pinned at
-`187ef24791c3d844939b2be13a68bd890bd04e4c`. This is the extended V8 harness
-with `channel_sweep: 1`; its binaries differ from the earlier two-profile
-V8 measurements, so timings should not be treated as a same-image A/B.
-
-Firmware SHA-256:
-
-- XIAO: `b0a6dd216be8a638fe088219801290d4632afcf63048f8597037650d68cd2301`
-- Indicator: `f0aa43a46e5e59ebf4768a255bd8c31d7a72c0038e11173314f78908b725786d`
-
-## Device handoff
-
-After the first-miss stop, both original 8 MiB flash snapshots were restored.
-Separate full-flash verification matched each backup's digest, followed by
-hardware reset. The private backups remain in the current-user backup
-directory. The Indicator RP2040 and RAK4631 were not modified. The interrupted
-XIAO soak logger remains stopped, and the temporary PlatformIO HIL overlay was
-removed. Implementation, tests and reports were initially handed off locally
-before publication.
+This document is now in [Test Results](test-results/sf6_channel_scan_validation.md).
diff --git a/docs/sf8_5p1_trace_validation.md b/docs/sf8_5p1_trace_validation.md
index 3202602c1..8f5f98fa5 100644
--- a/docs/sf8_5p1_trace_validation.md
+++ b/docs/sf8_5p1_trace_validation.md
@@ -1,137 +1,5 @@
-# SF8 / 125 kHz: four-channel comparison at 5.1 chirps
+# Document moved
-Measured 2026-09-14. **12 packets received, then packet 13 missed.** The first
-three balanced rounds passed (3/3 on every channel); the next probe on channel
-3 failed. The run stopped at that miss. The target of 100 packets per channel
-was not reached. No RF retry or other channel count was tested.
+
-## Setup and comparison
-
-Same XIAO ESP32-S3 + WIO SX1262 scanner (COM31) and SenseCAP Indicator ESP32-S3
-reference transmitter (COM28). Four centers: 909.500, 909.750, 910.000 and
-910.250 MHz. CR4/5, 32-symbol programmed preamble, 16-byte synthetic packets,
--9 dBm, warm production fast RX and buffered 8 MHz SPI. Same production packet
-guards and read-only 64 us nominal IRQ polling; events buffered until each
-result. Randomized balanced channel order, seed 606125, varied arrival timing.
-
-| Setting | Earlier SF6/125 | This SF8/125 |
-| --- | --- | --- |
-| One symbol | 512 us | 2,048 us |
-| Requested 5.1-symbol dwell, rounded up | 2,612 us | 10,445 us |
-| 32 programmed preamble symbols | 16.384 ms | 65.536 ms |
-| Whole-run average measured retune | 539.427 us | 524.124 us |
-| Outcome before first miss | 7 received, packet 8 missed | 12 received, packet 13 missed |
-| Miss trace | No observed preamble or hold | Off-channel preamble and prolonged hold |
-
-Changing SF increases real listening and preamble durations together. This is
-not a matched same-image A/B or an isolated post-retune settling experiment.
-The small retune-time difference must not be treated as a new optimization;
-no extra settling delay was inserted. The earlier reporting-only held-visit
-timestamp correction is included in this trace-version-2 run.
-
-## Failed probe: an off-channel hold
-
-Failed sequence `388715032` targeted **channel 3 / 910.250 MHz**. The scanner
-instead observed PreambleDetected on **channel 2 / 910.000 MHz**, then its
-normal packet guard deferred the intended jump to channel 3. It never observed
-a valid header, header error, CRC event, or RxDone for that preamble.
-
-Times are software observations relative to expectation arming:
-
-| Time | Event |
-| --- | --- |
-| 207.649 ms | Last hop onto target channel 3 before the long gap |
-| 240.542 ms | Hop onto channel 2 |
-| 250.030 ms | PreambleDetected (0x0004) observed on channel 2 |
-| 251.013 ms | Guard defers channel 2 → 3 at the 5.1-symbol deadline |
-| 342.287 ms | Guard issues a clear of the still-latched preamble bit |
-| 342.794 ms | Hop onto channel 3 finally completes |
-| 342.851 ms | IRQ read confirms zero bits |
-| 9,999.270 ms | Expectation timeout, RX mode, zero device errors |
-
-Channel-2 occupancy was about **102 ms**. The target channel's revisit gap
-grew to **135.145 ms**, compared with its ordinary ~43.875 ms cycle and the
-65.536 ms programmed preamble. The delayed retune itself took only **550 us**.
-
-The preamble-to-header budget from `calcMaxPacketMillis()` is
-ceil((32 + 8 + 4.25) * 2.048) = **91 ms**. The strict millisecond timeout in
-`CustomSX1262::isReceiving()` clears a preamble that does not advance to a
-header. The observed **92.257 ms** from the first IRQ observation to that
-clear is consistent with this existing protection mechanism.
-
-This identifies a prolonged off-channel hold that removed the next listening
-opportunity. It does **not** show that the ~0.52 ms jump needs more settling
-time. The RF source of that preamble is not proven: adjacent-channel pickup/
-false detection or unrelated RF are possibilities. There was no independent
-RF capture or synchronized MCU clock. The earlier SF6 miss had no observed
-preamble or hold; this result does not establish the same cause for that miss.
-
-Simply shortening the guard risks cutting off valid long-preamble packets.
-Successful traces show that reception legitimately keeps a channel occupied
-well past 10.445 ms. No production guard change was made in this diagnostic.
-
-Follow-up: the [stationary preamble investigation](preamble_detection_investigation.md)
-reproduced transmitter-associated off-frequency indications with full RX
-initialization and no hopping, on both boards. It strengthens the off-channel
-detection explanation without independently identifying the RF source of this
-historical missed probe. The SF6 miss remains a separate unresolved case.
-
-## Measurements and checks
-
-- All 12 successful payloads were valid; RSSI -42 to -41 dBm, SNR 12.0 to
- 14.2 dB. First observed preamble after logged hop completion ranged
- **7.433–9.485 ms**; hop-to-RxDone ranged **105.056–146.836 ms**.
-- Entire run: 1,167 fast hops, mean retune 524.124 us, maximum 774 us. Idle
- dwell mean 10,452.444 us; held visits separately accounted for.
-- Failed expectation: 910 trace events, zero overflow, 903 completed hops.
- The 902 unheld dwells were 10,445–10,480 us, mean 10,450.424 us.
-- Excluding the one held cycle, target-channel revisit intervals were
- **43,847–43,928 us**, mean **43,874.580 us** (224 intervals).
-- Failed-expectation retunes: 511–617 us, mean 520.443 us. The held occupancy
- accounts for the anomalous target-channel revisit gap.
-- Zero retune failures, RX-mode errors, modulation-cache errors, CRC/read
- errors, USB stale replies or cached transport recovery. Reference TX rc=0.
-- 133,217 extra IRQ polls during the failed capture; total cost 3,241,509 us,
- maximum 55 us. Polls add load; timestamps are observations, not exact IRQ
- edges. No per-event serial output occurs during capture.
-
-This first-miss sample neither estimates a stable packet-error rate nor
-establishes a channel count with 100% reception.
-
-## Reproduction and provenance
-
-```text
-python tools/hil/profile_switch_channels.py --receiver COM31 --sender COM28 --output tools/hil/sf8_125_5p1_trace_results.json --samples 100 --preamble 32 --max-channels 4 --dwell-symbols 5.1 --trace --sf 8
-```
-
-- [Raw result and all 13 traces](../tools/hil/sf8_125_5p1_trace_results.json)
-- [Earlier SF6/125 at 5.1 chirps](sf6_5p1_trace_validation.md)
-- [Harness instructions](../tools/hil/profile_switch_README.md#sf8-comparison-at-the-same-chirp-count)
-
-Raw result SHA-256:
-`0d7e97173937f42708febf8276cdadc36578740735058d5da39b1b576e9ca49e`.
-
-Tested firmware SHA-256:
-
-- XIAO: `a68ab9834f62d0a5fb3f10c7a0b312c17d7d62795885cac4ea0dce483d6bf1ea`
-- Indicator: `bca1514bc6e09af6b1a9daed5bf4d3dbeeab988bcb856acbc73f59334395280d`
-
-Base commit `c329cf1fc1e7f2e8c8733ab0d000094730458fe3` plus local changes;
-RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32
-6.11.0 / Arduino ESP32 2.0.17. Both HIL targets built successfully. All 27
-selected host/native tests passed, including SF scaling, propagation to both
-RX staging slots and TX, cache checks, trace handling and first-miss stop.
-
-## Device handoff
-
-Fresh full 8 MiB backups were saved and digest-verified before flashing:
-
-- XIAO: `e9533742103c9d7af11a3dc0c40496ca412b072e11880c2be785c92ef80e4310`
-- Indicator: `d4f39a0be1339f142ed329b68606347adeb514d26d6cc8bad43fb3d343ac1e2a`
-
-Both were restored after the test, separately full-flash digest-verified, and
-hardware-reset. Backups remain in the private current-user backup directory.
-No identity or channel secrets were used in the synthetic RF probes. The
-RAK4631 and Indicator RP2040 were not modified; the XIAO soak logger was not
-restarted. The temporary HIL build overlay was removed. Source, tests and
-results were initially handed off locally before publication.
+This document is now in [Test Results](test-results/sf8_5p1_trace_validation.md).
diff --git a/docs/spiffs_regular_file_reads.md b/docs/spiffs_regular_file_reads.md
index 6965efc49..f653800d7 100644
--- a/docs/spiffs_regular_file_reads.md
+++ b/docs/spiffs_regular_file_reads.md
@@ -1,63 +1,5 @@
-# SPIFFS regular-file reads and login replay state
+# Document moved
-Arduino-ESP32 SPIFFS can return a truthy directory handle from a read-open of a
-nonexistent filename. `File::operator bool()` alone does not prove that a
-regular file exists. A directory's `size()` and `read()` are zero.
+
-The first privileged login on an upgraded G2 encountered this in
-`ClientACL::writeClientLoginReplayCeiling()`: opening the not-yet-created
-`/s_login_replay` appeared successful, then subtracting the eight-byte trailer
-from size zero underflowed. Copying the supposed records failed and login was
-rejected. Waiting or changing the repeater clock cannot fix that file-open bug.
-
-## Fix
-
-Use `mesh::openFileRead()` for regular-file reads. It checks existence and
-rejects directory handles, while preserving real empty files. Replay record
-counts also validate the minimum trailer size, record alignment, and maximum
-count before subtraction. An existing replay file that becomes unreadable is
-not treated as a new store.
-
-No replay records are cleared and authentication is not weakened. Invalid or
-unwritable replay state still fails closed. The first successful privileged
-login creates a 44-byte file: one 36-byte identity/ceiling record plus its
-eight-byte integrity trailer.
-
-Setting the repeater clock must not clear this file. Admission compares the
-sender's timestamp with that sender's saved boundary, not with the repeater's
-current time. Clearing the boundary could make captured requests reusable. A
-sender clock rollback is a separate condition and remains subject to the saved
-boundary after this fix.
-
-Companion directory enumeration uses the separate `openDirectory()` API,
-which deliberately permits SPIFFS virtual directories. Regular-file reads are
-also enforced for companion data, repeater/room logs, flood-rule verification,
-and HTTP packet-log downloads (missing logs return 404; real empty logs 200).
-
-## Audit boundary
-
-The audit covered file opens and size arithmetic in `src` and `examples`.
-Identity, region, clock, and common preference loaders already gate reads with
-filesystem existence checks; SPIFFS's `exists()` explicitly excludes directory
-handles. Relevant MQTT length subtraction follows validated headers and exact
-reads. ESP32 OTA staging uses partition APIs, not these SPIFFS file handles.
-Intentional directory enumeration must not be changed to a regular-file read.
-
-## Regression checks
-
-- `test/test_client_acl_spiffs.py` compiles the actual `ClientACL.cpp` against
- a filesystem that reproduces the misleading missing-file directory handle.
- It covers first creation, fresh/stale login, reboot ceilings, corrupt and
- unreadable state, short writes, failed publication, and truncated sources.
-- `test/test_regular_file_reads.py` executes production companion readers,
- flood-file verification, and HTTP log delivery with the same filesystem
- behavior, including platform-specific file API differences.
-- `test/test_esp32_tinyusb_cooperative_output.py` exercises both real role log
- pumps, including missing logs and bounded output.
-- Native ACL transaction, login persistence, and client-path persistence suites
- retain the existing security and recovery coverage.
-
-The pre-fix actual ACL code was reproduced as `first_login=rejected`, one
-missing read-open, and no replay file. The fixed code accepted that identical
-scenario without a missing read-open and created the valid 44-byte record.
-Host simulations do not replace a post-flash LoRa login test on the G2.
+This document is now in [Test Results](test-results/spiffs_regular_file_reads.md).
diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md
index 0c3af9455..2e45e01da 100644
--- a/docs/terminal_chat_cli.md
+++ b/docs/terminal_chat_cli.md
@@ -117,7 +117,7 @@ picocom --baud 115200 /dev/ttyACM0
Current source waits for input before printing the USB banner. Press Enter or
type a command. This preserves the clean first response needed by binary apps;
-see [USB client validation](companion_usb_client_validation.md) for release status.
+see [USB client validation](test-results/companion_usb_client_validation.md) for release status.
For older firmware or a port already in Binary mode, use:
diff --git a/docs/test-results/bootloader_version_stock_audit.md b/docs/test-results/bootloader_version_stock_audit.md
new file mode 100644
index 000000000..a3beb2af1
--- /dev/null
+++ b/docs/test-results/bootloader_version_stock_audit.md
@@ -0,0 +1,173 @@
+# Stock nRF52 bootloader version audit
+
+Checked **2026-09-29**, against the 40 nRF52 variant families resolving to 26
+board configurations in this checkout. This concerns `get bootloader.ver`, not
+bootloader installation or OTA eligibility. No devices were flashed, and no
+features, storage sizes, or partitions were changed.
+
+## Result
+
+The real firmware reader successfully reads **26 distinct manufacturer-published
+bootloader images** from 32 artifact records, including older RAK revisions,
+current and historical LilyGO images, and vendor HEX, UF2 and DFU ZIP packages.
+Repeated copies and identical normalized bootloader images are counted once.
+These are publicly supplied stock/recovery images, **not proof that every
+manufacturing batch shipped identical bytes**. No untouched live unit was read
+in this audit.
+
+One format gap was fixed: Seeed's T1000-E **LoRaWAN development-board** bootloader
+declares `UF2 Bootloader 1.00`. The reader now accepts that two-component version
+and returns `1.00` unchanged. It does not invent `1.00.0`. That development image
+is separate from Seeed's Meshtastic/MeshCore recovery image, which reports
+`0.9.1-5-g488711a` and already worked.
+
+All other tested images already expose a readable UF2 bootloader record.
+Generic bare dotted-number searches remain deliberately unsupported: TinyUSB,
+nrfx, and SoftDevice can embed their own versions inside the same bootloader.
+The bounded standalone fallback still requires the explicit OTAFIX version
+shape. Ambiguous records, invalid vectors, and corrupt canonical OTAFIX metadata
+retain the existing rejection rules.
+
+## Published images actually tested
+
+The exact source URLs, download hashes, normalized-image hashes, extraction
+layout and expected strings are pinned in
+[`stock_images.json`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/test/fixtures/nrf52_bootloader_version/stock_images.json).
+
+| Manufacturer image family | Version captured from the image |
+| --- | --- |
+| RAK4631/RAK4630 Arduino bootloader, historical through current | `0.3.2-109-gd6b28e6`, `0.4.1-13-g5e6690e-dirty`, `0.4.2`, `0.4.3`, `0.4.4` |
+| Heltec HT-n5262, SDK and T114 vendor recovery HEX | `0.9.0-2-g836c8dc-dirty` |
+| LilyGO T-Echo | `0.6.1-2-g1224915` |
+| LilyGO T-Echo Lite, Card, T-Impulse Plus (including historical builds) | `0.9.2-dirty` |
+| Seeed XIAO nRF52840 and Sense | `0.6.1` |
+| Seeed XIAO nRF52840 Plus and Sense Plus | `0.9.2-29-g6a9a6a3` |
+| Seeed Wio Tracker 1110 SDK/conversion bootloader | `0.6.2-26-g949425a-dirty` |
+| Seeed Wio Tracker L1 vendor recovery ZIP | `0.7.0-22-g277a0c8` |
+| Seeed T1000-E Meshtastic/MeshCore recovery ZIP | `0.9.1-5-g488711a` |
+| Seeed T1000-E LoRaWAN development-board HEX | `1.00` (newly supported) |
+| Seeed MeshTracker X1 recovery ZIP | `0.10.0-18-gb93789f` |
+| MTools Tec MeshTiny vendor bootloader UF2 | `0.4.5` |
+| BQ/Unit Engineering Nano G2 Ultra vendor HEX ZIP | `0.6.4-dirty` |
+| Muzi Works R1 Neo release HEX | `0.9.2-31-g990aa7f-dirty` |
+
+**Use the embedded version, not the filename.** For example, Seeed's XIAO file
+named `...bootloader-0.6.2...hex` reports `0.6.1`, and its L1 ZIP named
+`...bootloader-0.10.0...zip` reports `0.7.0-22-g277a0c8`. These are actual tested
+bytes, not renamed or inferred release numbers.
+
+All these normalized images use the Adafruit nRF52840 `F4000..FE000` bootloader
+window. HEX/UF2 address records identify the bytes. The boot-only Seeed DFU
+packages omit MBR/UICR address words; their bootloader binary is extracted using
+the manifest's `sd_size`/`bl_size` and the
+[Adafruit nRF52840 linker layout](https://github.com/adafruit/Adafruit_nRF52_Bootloader/blob/master/linker/nrf52840.ld).
+The runtime reader independently selects the installed MBR/UICR boot address;
+this audit does not hardcode that layout into the firmware getter.
+
+## Coverage of every variant family
+
+- **Image verified:** a published bootloader for that named product/module was
+ tested. Factory batch identification still requires reading the actual unit.
+- **Representative only:** a related module/SDK bootloader was tested; its exact
+ factory image was not obtained. Shared build configuration alone does not
+ establish identical factory bootloaders.
+- **Unverified:** no accessible factory bootloader image was obtained. This is
+ not a demonstrated reader failure.
+
+| Variant family | Status | Evidence or remaining gap |
+| --- | --- | --- |
+| `gat562_30s_mesh_kit` | Unverified | GAT documentation/firmware repository checked; no product-specific stock bootloader found. |
+| `gat562_mesh_evb_pro` | Unverified | Same GAT gap; RAK board configuration is not factory-image proof. |
+| `gat562_mesh_tracker_pro` | Unverified | Same GAT gap; custom vendor/OTAFIX builds also exist. |
+| `gat562_mesh_watch13` | Unverified | Same GAT gap. |
+| `heltec_mesh_solar` | Representative only | HT-n5262 SDK image passes; Solar downloads found application images, not a board-specific stock bootloader. |
+| `heltec_t096` | Representative only | HT-n5262 SDK image passes; T096 factory revision not established. |
+| `heltec_t1` | Representative only | SDK image passes; vendor F&T archive contains application-only UF2s. |
+| `heltec_t114` | Image verified | Both SDK HEX and T114-specific vendor recovery HEX pass. |
+| `heltec_tower_v2` | Representative only | SDK image passes; vendor matching-firmware archive contains applications, not the stock bootloader. |
+| `ikoka_handheld_nrf` | Representative only | XIAO board configuration; XIAO module images pass, exact installed factory image not obtained. |
+| `ikoka_nano_nrf` | Representative only | Same XIAO representative coverage. |
+| `ikoka_stick_nrf` | Representative only | Same XIAO representative coverage. |
+| `keepteen_lt1` | Unverified | Manufacturer product/download search did not yield a factory bootloader binary. |
+| `lilygo_t_impulse_plus` | Image verified | Both historical and newer vendor bootloader HEX images pass. |
+| `lilygo_techo` | Image verified | Vendor T-Echo bootloader passes. |
+| `lilygo_techo_card` | Image verified | Three distinct vendor historical/current bootloader images pass. NFC UICR repair application excluded. |
+| `lilygo_techo_lite` | Image verified | Vendor bootloader HEX passes. |
+| `mesh_pocket` | Representative only | HT-n5262 SDK image passes; exact Pocket factory revision not established. |
+| `meshtiny` | Image verified | Direct manufacturer bootloader UF2 passes; newer ADV/factory revisions require their actual image. |
+| `meshtracker_x1` | Image verified | Seeed product-specific recovery ZIP passes. |
+| `minewsemi_me25ls01` | Unverified | Manufacturer module page lists firmware as none; no stock bootloader binary found. OEM/dev-kit installations can differ. |
+| `muzi_base` | Unverified | Manufacturer publishes bootloader source, but its release assets found were for R1 Neo, not BASE. |
+| `muziworks_r1_neo` | Image verified | Manufacturer R1 Neo release HEX passes; do not substitute a generic RAK version. |
+| `nano_g2_ultra` | Image verified | Manufacturer's preinstalled-bootloader recovery HEX ZIP passes. |
+| `promicro` | Unverified | Nologo documents a Nice!Nano UF2 drive; its linked download requires Baidu access. Generic clones need separate factory images. |
+| `rak3401` | Representative only | RAK4631 Arduino images pass; RAK3401-specific untouched image not obtained. |
+| `rak4631` | Image verified | Five distinct published stock Arduino bootloader revisions pass. RAK4631-R/RUI3 is a different bootloader family. |
+| `rak_wismesh_tag` | Representative only | RAK core images pass; exact Tag factory image not obtained. |
+| `sensecap_solar` | Representative only | Seeed documentation identifies XIAO DFU; XIAO images pass, exact Solar factory image not obtained. |
+| `t1000-e` | Image verified | Both vendor recovery ZIP and separate LoRaWAN development-board HEX pass. |
+| `thinknode_m1` | Unverified | Manufacturer repository checked; no factory bootloader binary found. |
+| `thinknode_m3` | Unverified | Published factory UF2 is application-only (`26000..52BFF`), not a bootloader image. |
+| `thinknode_m4` | Unverified | Published factory UF2 is application-only (`26000..521FF`). |
+| `thinknode_m6` | Unverified | Published factory UF2 is application-only (`26000..46CFF`). |
+| `thinknode_m8` | Unverified | No accessible product-specific factory bootloader binary found. |
+| `wio-tracker-l1` | Image verified | Seeed's L1 recovery ZIP passes. |
+| `wio-tracker-l1-1w` | Representative only | L1 recovery image passes; exact 1W/ADV factory revision not established. |
+| `wio-tracker-l1-eink` | Representative only | L1 recovery image passes; exact e-ink factory revision not established. |
+| `wio_wm1110` | Representative only | Published Wio Tracker 1110 and XIAO SDK images pass; the variant's XIAO config does not prove the shipped module's image. |
+| `xiao_nrf52` | Image verified | XIAO, Sense, Plus and Sense Plus vendor images pass. |
+
+Representative/unverified products use the same shared reader and should report
+a version **if their installed bootloader exposes one of the recognized records**.
+There is no evidence yet for claiming an exact version or success on every
+untouched unit. ESP32, RP2040 and STM32 are outside this nRF52 flash reader;
+their getters were not changed and this report does not claim stock-version
+capture on those architectures.
+
+## Reproduce the artifact tests
+
+Normal tests are offline and include all observed version strings, malformed and
+conflicting versions, dependency-version false positives, region/vector bounds,
+and the existing OTAFIX metadata/CRC/fallback cases:
+
+```sh
+python3 test/test_nrf52_bootloader_version.py -v
+```
+
+To replay the real images, download each `url` in `stock_images.json` into a
+cache directory using its `file` name, then run:
+
+```sh
+MESHCORE_STOCK_BOOTLOADER_CACHE=/absolute/path/to/cache \
+ python3 test/test_nrf52_bootloader_version.py -v
+```
+
+This checks the exact download SHA-256, extracts only the bootloader window,
+checks the normalized-image SHA-256, and invokes the actual C++ reader with an
+exact expected version. It never downloads automatically or flashes a device.
+The ordinary regression cases cover every captured format even without the
+optional cached artifacts. RAK4631 and T1000-E repeater firmware builds passed.
+
+## What is needed to close the remaining gaps
+
+For each untouched unit, collect its DFU drive's **`INFO_UF2.TXT`** if present.
+To prove the application's flash reader can capture that exact revision, also
+obtain a read-only bootloader dump plus the MBR word at `0xFF8`, UICR `NRFFW[0]`,
+and flash geometry (or a full vendor HEX including those address records).
+Keep the bootloader unchanged while collecting this evidence. Only the identified
+bootloader region is needed; private application keys/configuration are not.
+If no version bytes or startup register value exist, report `unknown`; a regex
+cannot recover a version that was never embedded.
+
+## Manufacturer sources
+
+- [RAK stock bootloader catalog](https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630)
+- [Heltec SDK](https://github.com/HelTecAutomation/Heltec_nRF52), [vendor downloads](https://resource.heltec.cn/download)
+- LilyGO [T-Echo](https://github.com/Xinyuan-LilyGO/T-Echo), [T-Echo Lite](https://github.com/Xinyuan-LilyGO/T-Echo-Lite), [T-Echo Card](https://github.com/Xinyuan-LilyGO/T-Echo-Card), [T-Impulse Plus](https://github.com/Xinyuan-LilyGO/T-Impulse-Plus)
+- [Seeed Arduino bootloader catalog](https://github.com/Seeed-Studio/Adafruit_nRF52_Arduino/tree/master/bootloader), [T1000-E recovery](https://wiki.seeedstudio.com/sensecap_t1000_e/), [T1000-E development board](https://github.com/Seeed-Studio/Seeed-Tracker-T1000-E-for-LoRaWAN-dev-board), [MeshTracker X1 recovery](https://wiki.seeedstudio.com/sensecap_meshtracker_x1_meshcore/), [Wio L1 bootloader guidance](https://wiki.seeedstudio.com/get_started_with_other_mesh_firmware/)
+- [MeshTiny manufacturer bootloader download](https://shop.mtoolstec.com/bootloader-update-on-meshtiny.html)
+- [Nano G2 Ultra manufacturer bootloader documentation](https://wiki.bqvoy.com/en/meshtastic/nano-g2-ultra)
+- [Muzi bootloader source/releases](https://github.com/muzi-works/Adafruit_nRF52_Bootloader), [BASE source guidance](https://muzi.works/pages/base-system-downloads)
+- [GAT manufacturer documentation](https://github.com/gat-iot/GAT562-family), [Elecrow manufacturer repositories](https://github.com/Elecrow-RD), [Keepteen](https://www.keepteen.com/)
+- [Nologo ProMicro documentation](https://www.nologo.tech/product/otherboard/NRF52840.html)
+- [Minewsemi ME25LS01 module specifications](https://en.minewsemi.com/lora-module/lr1110-nrf52840-me25LS01)
diff --git a/docs/test-results/cad_scan_validation.md b/docs/test-results/cad_scan_validation.md
new file mode 100644
index 000000000..bf97a15b0
--- /dev/null
+++ b/docs/test-results/cad_scan_validation.md
@@ -0,0 +1,63 @@
+# SX1262 channel-scanning hardware results
+
+Hardware test on September 12, 2026 (America/Los_Angeles). Heltec V4 transmitter; XIAO ESP32-S3 with Wio SX1262 receiver. Both used standalone diagnostic firmware.
+
+The best tested scan setting was **four channels with four-symbol RX-preamble windows**. Three-symbol windows were unreliable even in the single-channel re-arm control. In the longer four-symbol runs, four channels received 80/80 packets, five received 79/80, and six received 52/60. These are measured limits of this implementation and test setup, not universal SX1262 channel-count limits.
+
+## Configuration and counting
+
+SF5, 125 kHz, coding rate 4/5, explicit header, payload CRC, and 32 preamble symbols. The programmed preamble is 8.192 ms. Channels start at 909.5 MHz and increase in 1 MHz steps; tests use the first N channels, up to 920.5 MHz. Test packets are transmitted individually with varied timing relative to the scanner. There are no overlapping transmitters in this experiment.
+
+A success requires a CRC-valid packet with the expected sequence, channel, length, and test payload. Every included transmission has its own successful transmitter acknowledgement; delayed serial acknowledgements are recovered by sequence, without retransmitting. The one exploratory RX scan trial with shifted acknowledgements is excluded.
+
+## Completed trials
+
+| Trial | Method/window | Channels | Payload bytes | Received / sent | Mean complete scan |
+| --- | --- | ---: | ---: | ---: | ---: |
+| baseline-fixed | Fixed-channel RX | 4 | 64 | 16 / 16 | — |
+| cad-standard | CAD + software RX | 4 | 64 | 7 / 32 | 14.469 ms |
+| cad-auto | CAD + automatic RX | 4 | 64 | 16 / 32 | 14.468 ms |
+| rxfast-4 | rxfast 4 | 4 | 64 | 32 / 32 | 6.100 ms |
+| rxfast-4-maxpayload | rxfast 4 | 4 | 255 | 35 / 35 | 6.098 ms |
+| matrix-cad1-1 | cad 1 | 1 | 64 | 7 / 8 | 2.643 ms |
+| matrix-cad1-10 | cad 1 | 10 | 64 | 2 / 20 | 28.389 ms |
+| matrix-cad1-11 | cad 1 | 11 | 64 | 5 / 22 | 31.297 ms |
+| matrix-cad1-12 | cad 1 | 12 | 64 | 3 / 24 | 34.145 ms |
+| matrix-cad2-10 | cad 2 | 10 | 64 | 3 / 40 | 31.040 ms |
+| matrix-fixed-12 | Fixed-channel RX | 12 | 64 | 24 / 24 | — |
+| matrix-rx1-10 | rxfast 1 | 10 | 64 | 0 / 20 | 7.555 ms |
+| matrix-rx1-11 | rxfast 1 | 11 | 64 | 0 / 22 | 8.316 ms |
+| matrix-rx1-12 | rxfast 1 | 12 | 64 | 0 / 24 | 9.072 ms |
+| matrix-rx2-10 | rxfast 2 | 10 | 64 | 0 / 40 | 10.133 ms |
+| matrix-rx3-7 | rxfast 3 | 7 | 64 | 2 / 35 | 8.892 ms |
+| matrix-rx3-8 | rxfast 3 | 8 | 64 | 1 / 40 | 10.160 ms |
+| matrix-rx4-5 | rxfast 4 | 5 | 64 | 39 / 40 | 7.630 ms |
+| boundary-rx3-1 | rxfast 3 | 1 | 64 | 4 / 24 | 1.268 ms |
+| boundary-rx3-4 | rxfast 3 | 4 | 64 | 2 / 24 | 5.080 ms |
+| boundary-rx3-5 | rxfast 3 | 5 | 64 | 0 / 25 | 6.350 ms |
+| boundary-rx3-6 | rxfast 3 | 6 | 64 | 2 / 30 | 7.621 ms |
+| boundary-rx4-4 | rxfast 4 | 4 | 64 | 80 / 80 | 6.104 ms |
+| boundary-rx4-5 | rxfast 4 | 5 | 64 | 79 / 80 | 7.630 ms |
+| boundary-rx4-6 | rxfast 4 | 6 | 64 | 52 / 60 | 9.158 ms |
+
+`rxfast N` means a continuous-RX preamble observation window of N symbols, with minimal reconfiguration between frequencies. It is not CAD. `cad N` uses the SX1262 CAD detector with N symbols and automatic handoff to RX. CAD has no three-symbol mode.
+
+Scan times describe uninterrupted sweeps. When reception starts, the radio stays on that frequency through the packet and cannot hear packets on the other frequencies. Serial status requests can add occasional timing outliers.
+
+The maximum-payload trial ended after 35 confirmed transmissions when a serial acknowledgement was delayed. Its last TX acknowledgement and matching RX were subsequently recovered from the serial logs; all 35 were received. It is not a completed 64-packet trial.
+
+## Interpretation and limits
+
+The initial four-channel fast-RX test received 32/32 64-byte packets and 35/35 255-byte packets. That is a successful strong-signal bench result, not a guarantee of zero loss. Ordinary four-symbol CAD took approximately 14.47 ms to cover four frequencies and was unreliable with an 8.192 ms preamble.
+
+Reducing the observation window also reduces the time available for the modem to recognize a preamble. Dividing preamble duration by scan duration alone overestimates the number of usable channels. One- and two-symbol RX windows produced no preamble detections in the tested high-channel-count cases.
+
+The three-symbol single-channel control repeatedly re-arms RX, just as a scan would, and received only 4/24 packets. It is distinct from continuous fixed-channel reception, which passed its baseline. This isolates a detection-window limitation even without a long multi-channel sweep.
+
+CAD detection peak 18 and minimum 10 were held constant. These are the datasheet four-symbol SF5 thresholds; the one- and two-symbol CAD tests do not establish optimized thresholds or weak-signal sensitivity. CAD activity followed by no decoded packet can be a late packet detection, not necessarily a noise false positive.
+
+Received signals were strong. These tests do not establish weak-signal range, adjacent-channel rejection, simultaneous-packet capacity, production firmware scheduling performance, power consumption, FCC compliance, or months of uptime.
+
+## Reproduction and evidence
+
+See [the test harness](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/cad_scan/README.md). Compact validated results, timing counters, and final firmware/source hashes are in [cad_scan_results.json](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/cad_scan/cad_scan_results.json). Detailed sequence-level logs and private lab transport helpers are retained locally under `out/cad-scan/`.
diff --git a/docs/test-results/carrier_wave_validation.md b/docs/test-results/carrier_wave_validation.md
new file mode 100644
index 000000000..3f1f9f22a
--- /dev/null
+++ b/docs/test-results/carrier_wave_validation.md
@@ -0,0 +1,112 @@
+# CW and CW2 validation
+
+Tested on the MercerWoodMesh lab Pi on September 14, 2026 (Pacific), using
+base commit `9241ccc253ad2f8ac2c9a2fe756f59ba2f52505d` plus the CW changes.
+Test images are marked `v1.17.1.6-cw-test-9241ccc2`; they are development images.
+
+A separate RAK4631 sampled live received signal strength (RSSI) about every
+10 ms, without needing to decode a packet.
+The transmitting boards ran the Full V4 repeater and Full T1000-E Companion
+builds. Both were configured for −9 dBm chip TX power, with profiles at
+909.5 and 919.5 MHz, BW125, SF7, CR5, and 32-symbol preambles.
+
+| Check | V4 / SX1262 | T1000-E / LR1110 |
+| --- | --- | --- |
+| `cw on 0.25` measured duration | 0.25 s | 0.25 s |
+| `cw on 1.25` measured duration | 1.25 s | 1.25 s |
+| Manual stop approximately 1 s into a 5 s request | 1.01 s | 1.01 s |
+| `cw on 1.25` after enabling RXPS | 1.26 s | 1.25 s |
+| `cw2 on 1.25` on the second frequency | 1.25 s | 1.25 s |
+| Primary-frequency RSSI change while CW2 transmits | 0 dB | 0 dB |
+| Paired RX and TX recovery checks | 5/5 passed | 5/5 passed |
+
+The observer saw roughly −56 to −57 dBm during V4 carriers and −67 to
+−71 dBm during T1000-E carriers, against a −112 to −113 dBm baseline.
+Readings returned to baseline after timeout and manual stop. These are
+received signal readings, **not calibrated transmitter output measurements**.
+
+RX recovery used CRC-valid LoRa test payloads and the DUT's receive counter.
+TX recovery used each DUT's MeshCore advertisement, received with valid CRC
+by the observer. Both DUTs finished in RX, with CW and radio2 disabled and
+no outbound packet pending. The observer has no autonomous transmission.
+
+## Software checks
+
+- 1,436 native tests passed, including new checks that CW holds queued packets,
+ services its deadline, and prevents transmission in the same loop where a
+ received command starts CW.
+- Compiled production-method tests cover fractional input, malformed durations,
+ bounds, timer rollover, busy/IRQ rejection, RXPS cleanup, board TX/RX callbacks,
+ profile selection and replacement, failed TX entry, stop retries, and failed
+ RX restart. An ESP32 sleep test covers a carrier with USB disconnected.
+- Full V4 repeater, Full T1000-E Companion, and ordinary STM32
+ `RAK_3x72_companion_radio_usb` builds passed. The STM32 build is a compilation
+ check, not an RF test of that board.
+- An additional existing OTA sleep test still expects the old
+ `ota::ota_ctx().apply_pending` spelling. It fails against unchanged HEAD
+ `src/Mesh.cpp` and test-file contents; the actual implementation uses
+ `ota_context_if_active()`. This is separate from the CW changes.
+
+## SX1276 follow-up
+
+The SX1276 carrier feature from [PR #11](https://github.com/mikecarper/MeshCore/pull/11)
+is included alongside SX1262 and LR1110 support. It switches to FSK with zero
+deviation, applies the board's calibrated TX power, and rebuilds the runtime
+LoRa configuration when stopped. External-PA settings pass through the same
+DAC/drive-power mapping used for packets, including RFO selection.
+
+The normal TX power command can adjust a running carrier for meter sweeps.
+RadioLib's SX1276 power setter enters standby, so the carrier is restarted
+afterward without extending the deadline. Failed power changes stop the
+carrier and restore the previous cached power.
+
+Follow-up checks use base `9dd9881d520b4b791600a00fff1f8e1436921054` plus the
+SX1276 changes. Production-method regression tests cover both profiles,
+zero deviation, external-PA calibration, RFO, power sweeps, timeout, failed
+FSK entry, failed restoration, retry guards, and return to LoRa. The native
+CW paths and existing radio/profile/sleep/PA checks pass as well.
+Both `GEPRC_Linkflow_900_repeater` (external PA) and
+`Heltec_v2_companion_radio_usb` firmware builds pass, including size checks.
+
+SX1276 RF output has not been measured in this follow-up. The physical RF
+results above apply to SX1262 and LR1110.
+
+## Bench qualifications
+
+The first CW2 request arrived before radio2's existing delayed activation.
+The harness now polls `get radio2.status` for the active mode before testing.
+
+An initial V4 dual-profile packet probe used a 120-symbol preamble against
+32-symbol profiles. A comparison around another CW2 burst received **0/8
+before and 1/8 afterward** with that mismatched probe, versus **8/8 before
+and 8/8 afterward** with a matching 32-symbol probe. The configured preamble
+also sets the driver's preamble-detection hold timeout, so a much longer
+incoming preamble can outlast that hold and be interrupted by scanning.
+The final dual-profile recovery checks use matched preambles. This finding
+does not establish general packet-loss performance for every scan combination.
+
+These checks establish RF activity, selected-frequency behavior, and recovery
+on the tested boards. They do not measure spectral purity, frequency error,
+SWR, calibrated power, or months of uptime. Carrier timeouts are serviced by
+the main loop and depend on that loop remaining responsive.
+
+## Reproduction
+
+Append `tools/hil/cw_observer.ini` to `extra_configs` in `platformio.local.ini`,
+preserving existing entries, and build `pio run -e cw_observer_rak4631`.
+Flash that application onto a separate lab RAK4631. Do not run PlatformIO
+concurrently with another build or upload in the checkout.
+
+Configure the lab DUT's primary profile as above, disable radio2, set TX power
+to −9 dBm, and reboot to apply saved primary settings. Then run:
+
+```sh
+python3 tools/hil/carrier_wave.py --dut /dev/serial/by-id/DUT \
+ --observer /dev/serial/by-id/OBSERVER --role repeater --output cw-results.json
+```
+
+Use `--role companion` for the T1000-E. The harness changes lab radio2/RXPS
+settings and emits short RF bursts and packet probes. It is for test boards.
+
+Compact evidence: [carrier_wave_results.json](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/carrier_wave_results.json).
+Command guide: [carrier-wave RF checks](../carrier_wave.md).
diff --git a/docs/test-results/cli_settings_audit.md b/docs/test-results/cli_settings_audit.md
new file mode 100644
index 000000000..c4dc0a3da
--- /dev/null
+++ b/docs/test-results/cli_settings_audit.md
@@ -0,0 +1,74 @@
+# CLI setting dispatch audit
+
+The 1.17.1.5 source retained `get path.hash.mode` in `CommonCLI` but lost its
+setter when radio preferences moved into `CommonRadioPrefs`. Infrastructure
+roles call `CommonCLI` directly, so the shared parser's setter was unreachable.
+The correction restores `set path.hash.mode 0|1|2` there, validates the value,
+and saves accepted changes. Missing/invalid values report a value error and
+leave the setting unchanged.
+
+The other radio setters removed by the original refactor had already been
+restored. A feature-guarded check found a second mismatch: `get extra.sf` exposed
+stored data on non-LR2021 radios while its setter was compiled out. Both
+commands now return `Error: extra.sf requires an LR2021 radio` there. Supported
+LR2021 set/get behavior is preserved. These are source corrections; existing
+release binaries need rebuilding/updating to receive them.
+
+## Coverage
+
+| Surface | Check |
+| --- | --- |
+| Common infrastructure CLI | 72 literal setter keys and 84 query keys; every query has a setter or an explicitly reviewed read-only/alternate-command exception |
+| MQTT/observer CLI | 33 literal setter keys and 39 query keys, including Wi-Fi, timezone, alerts, display and watchdog settings |
+| MQTT slots | All nine writable slot subkeys have query/set coverage; `diag` is intentionally read-only |
+| Repeater, Room Server, Sensor | Shared CLI delegation and role-specific query/set coverage |
+| Companion | Shared-radio allowlist and parser agreement, persistence wiring, terminal-specific setters, and role query exceptions |
+| Radio settings moved by the refactor | All 13 shared setters remain present in infrastructure dispatch |
+| Literal command comparisons | Checks key lengths so a setter cannot accidentally compare its value against the key's terminating NUL |
+| Feature guards | Preprocesses common and observer handlers for minimal, nRF52 GPS/SD, ESP32 WebConfig, ESP32 MQTT, RS232/GPS, ESP-NOW, and LR2021 profiles before comparing query/set coverage |
+
+Read-only exceptions include runtime connection status, diagnostics, firmware
+role, bootloader identity and reset information. Alternate-command exceptions
+include the password command and `set battery.alert on `. These
+exceptions are explicit in the test, rather than silently allowing all missing
+setters. Dynamic/delegated command families and hardware-specific features
+continue to use their dedicated tests; the inventory is not a claim that every
+build has every feature enabled.
+
+## Executed behavior tests
+
+The native C++ fixture compiles the production get/set dispatch branches and
+the real setting bodies for 14 radio-related settings: `radio`, `freq`, `af`,
+`dutycycle`, `int.thresh`, `cad`, `radio.rxgain`, `tx`, `rxdelay`,
+`agc.reset.interval`, `path.hash.mode`, `multi.acks`, `txdelay`, and
+`direct.txdelay`. It runs local and authenticated on-air caller timestamps,
+checks round-trip values and save calls, preserves AGC interval rounding, and
+checks that rejected hardware applies do not save or change preferences.
+Hardware callbacks are mocked; these are not live-radio or flash-durability
+tests. The fixture also runs with and without `USE_LR2021`, checking supported
+`extra.sf` set/query/clear behavior, invalid values, and the unsupported-radio
+error.
+
+The focused hash-mode test checks all valid modes, missing values, negative and
+out-of-range values, overflow, malformed numbers, whitespace, and similarly
+named unknown keys. Existing transport tests exercise Companion local/framed
+and on-air dispatch, local-only secret reads, and browser/stream routing under
+AddressSanitizer and UndefinedBehaviorSanitizer on Linux.
+
+Run the CI checks locally with a host C++ compiler (Linux is required for the
+existing sanitizer-enabled transport suite):
+
+```text
+python3 -B test/test_path_hash_cli.py
+python3 -B test/test_cli_settings_contract.py
+python3 -B test/test_local_cli_access.py
+```
+
+All 14 tests passed on the Linux VM. The workflow runs these commands on future
+pushes and pull requests. The new inventory and native radio tests also passed
+with the Windows host compiler.
+
+`pio run -e heltec_v4_repeater` passed after both corrections, including the
+firmware's flash-size and runtime-RAM checks. The initial audit used host tests
+and build checks; subsequent hardware checks are recorded in the
+[dual-profile validation](radio_profiles_validation.md).
diff --git a/docs/test-results/companion_contact_cache_results.md b/docs/test-results/companion_contact_cache_results.md
new file mode 100644
index 000000000..8348bf4ef
--- /dev/null
+++ b/docs/test-results/companion_contact_cache_results.md
@@ -0,0 +1,146 @@
+# Contact-cache and NimBLE RAM qualification
+
+Test date: 2026-09-08. Firmware source: `b68aa1c6`.
+
+All six constrained ESP32 Full Companion trials pass the linked RAM budget
+at **350 contacts, 40 channels and 256 normal offline frames**. Their final
+capability manifests confirm compiled Bluetooth/mOTA support and valid WiFi
+OTA layouts.
+These are the optional environments in the
+[contact-cache guide](../companion_contact_cache.md). Ordinary release recipes
+retain their established contact limits pending qualification on those boards.
+
+## Linked internal RAM
+
+Every column below uses 350 contacts, 40 channels and 256 normal offline
+frames. Values are bytes available for runtime allocations after linking.
+The final margin subtracts the existing conservative runtime reservation;
+it is not a measurement of live free heap.
+
+| Hardware | Bluedroid | NimBLE | NimBLE + mOTA queue loan | Plus contact caches | Final margin |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| Heltec V3 | 143,776 | 158,896 | 170,152 | 196,168 | 23,112 |
+| Wireless Tracker | 148,432 | 163,536 | 174,792 | 200,824 | 30,838 |
+| Tracker V2, FEM on | 133,064 | 148,168 | 159,416 | 185,456 | 15,470 |
+| Heltec CT62 | 99,040 | 114,096 | 125,312 | 151,344 | 15,152 |
+| XIAO ESP32-C3 | 98,816 | 113,888 | 125,120 | 151,152 | 14,960 |
+| Generic ESP-NOW | 99,776 | 114,800 | 126,032 | 152,064 | 15,872 |
+
+NimBLE recovers about 14.7 KiB. Contact caches add about 25.4 KiB on top of
+NimBLE and the shared mOTA workspace. NimBLE alone fails the unchanged budget
+at this capacity on all six; adding the queue loan passes only on the original
+Wireless Tracker. Adding the contact caches passes all six, with 14.6-30.1 KiB
+of margin. The same Bluetooth runtime allowance is used in every comparison.
+
+The shared mOTA queue stays at 256 frames normally and retains 128 while
+mOTA borrows its workspace, then returns to 256. A queue above 128 must be
+synchronized before that workspace can be borrowed; unread frames are not
+discarded to start mOTA. PSRAM-backed queues retain their existing policy.
+
+The baseline builds use `b041fc7f`; the final builds use `b68aa1c6`, with the
+same ESP32 Arduino 2.0.17 toolchain and NimBLE-Arduino 2.5.1 where applicable.
+
+The CT62, XIAO C3 and Generic ESP-NOW applications fit two 1.5 MiB OTA slots
+on 4 MiB flash. Their explicit trial layouts retain 896 KiB SPIFFS and the
+existing SPIFFS/coredump addresses. Install the merged image over USB when
+changing from the ordinary single-slot Full layout. The other three trials
+also retain verified dual WiFi OTA slots. All six remain mOTA senders.
+
+Additional builds check the platform and default-policy branches:
+
+| Build | Available bytes | Reserved runtime bytes | Margin bytes | Policy |
+| --- | ---: | ---: | ---: | --- |
+| T096 Full, FEM on | 100,076 | 73,728 | 26,348 | nRF52 path/secret caches |
+| RAK3401 Full | 99,900 | 50,976 | 48,924 | nRF52 path/secret caches |
+| Wireless Paper Full | 187,360 | 152,576 | 34,784 | ESP32 caches, existing Bluedroid |
+| V4 Full NimBLE | 263,944 | 173,056 | 90,888 | Complete contacts in PSRAM |
+| XIAO nRF52 legacy BLE Companion | 84,368 | 38,688 | 45,680 | Existing inline contact storage |
+
+The V4 test-only forced-cache build also passes its RAM budget. The V4 normally
+keeps complete contacts in PSRAM; forcing caches adds internal RAM overhead.
+The six-board savings above apply to boards without PSRAM.
+
+## Physical checks
+
+The VM V4 used the test-only cache override. USB tests verified all 350
+contacts and all 64 saved path bytes per contact, cold individual lookups,
+replacement of the complete path set, and persistence after reboot. A secret
+workload contacted 21 distinct synthetic peers, then the first peer twice:
+22 calculations, one RAM hit, and no secret-file access under the ESP32 policy.
+
+A sustained send test exposed a false receive watchdog recovery. RX could
+occur inside the main loop between two successful transmissions, while the
+watchdog observed TX at both loop boundaries. It counted from the start of
+the whole burst and could reset a working radio during a later CAD pause.
+`468cc3cf` restarts the receive recovery allowance after each successful TX.
+The native regression fails before that change and passes afterward; it also
+checks that a stuck receiver still gets recovered.
+
+The corrected V4 passed the same 23-datagram workload with zero radio error
+flags. It also passed a complete 350-path replacement, another send workload
+and a post-reboot path check with zero radio error flags. The replacement
+took about 127 seconds: dirty-cache eviction still performs synchronous
+SPIFFS contact-file saves, so bulk path updates remain a latency workload
+to qualify on the other ESP32 boards. After that run it reported 133,828 bytes free internal heap, a minimum
+of 130,976 bytes, and a largest free block of 122,868 bytes. These readings
+belong to the forced-cache V4 workload, not the six other boards.
+
+After qualification, the VM V4 was erased to remove synthetic contacts and
+installed with the normal PSRAM-backed V4 NimBLE trial from `b68aa1c6`. It is
+named `NimBLE-V4-VM`, uses the PIN shown on its display, and has powersaving
+off for USB use. That clean setup reported 143,612 bytes of free internal heap
+and passed 201 local USB requests with zero radio error flags. The Mercer V4 and
+XIAO S3 were left on their previously qualified NimBLE builds.
+
+The Mercer RAK3401 Full Companion verified 350 paths, eviction and persistence.
+Its first secret workload produced 21 calculations, one flash hit and one RAM
+hit with no skipped saves. After reboot, the same workload produced 22 flash
+hits, one RAM hit and **zero calculations**. Both completed with zero radio
+error flags. The final `b68aa1c6` RAK image repeated the 350-path and saved-secret
+checks with zero radio errors and zero calculations. The RAK was returned to
+the published `26303793` repeater
+application with its original repeater name and USA Cascade settings; the
+Pi's MQTT logger was restarted after the exclusive USB tests.
+
+## Shared-secret timing
+
+| Hardware/filesystem | Successful saved-secret lookup | Key exchange | Selected miss policy |
+| --- | ---: | ---: | --- |
+| V4 / SPIFFS | 132,427 us | 21,932 us | Recalculate |
+| RAK3401 / LittleFS | 7,812-8,789 us | 31,250 us | Read saved entry, calculate on miss |
+
+The SPIFFS timing comes from the earlier forced flash-cache experiment. The
+final ESP32 policy does no secret-file I/O. Both platforms reuse the 16 most
+recent secrets from RAM. These are elapsed-time measurements, not energy
+measurements; timing on other ESP32/nRF52 boards remains to be measured.
+
+## Regression checks and evidence
+
+The contact-cache suite exercises the production cache and persistence code
+under address/undefined-behavior sanitizers, with the real Ed25519 key-exchange
+implementation. Its cases include 350 paths, identity changes, snapshots,
+legacy migration, storage faults, handle exhaustion and packet release when
+a cold path cannot be read. The related contract suites passed 62 checks.
+The native queue/store suites passed 27 cases, the radio-liveness suite passed
+eight, and the partition-selection suite passed 11.
+
+Hardware reproduction commands are in the
+[contact-cache guide](../companion_contact_cache.md#regression-checks). The tracked
+harness is `tools/hil/contact_cache_serial_stress.py`. It requires exclusive
+access to the specified Companion data port.
+
+Local evidence in the qualification workspace:
+
+- `.releases/nimble-six-capacity/control/results.json`: 18 baseline builds.
+- `.releases/contact-cache/qualified/control/results.json`: final build results.
+- `.releases/contact-cache/qualified/built/`: firmware, ELFs, maps, RAM reports,
+ partition tables and capability manifests.
+- `.releases/contact-cache/qualified/control/`: final V4 USB and flash checks.
+- `.releases/contact-cache/control/`: earlier experiments, RAK results and
+ regression logs, including the watchdog's failing/passing test runs.
+
+Each final RAM report is checked against its ELF hash. All 12 final builds pass
+their RAM and OTA capability checks. Physical testing here covers the V4 and
+RAK3401; the six constrained ESP32 boards still need pairing, WiFi/BLE/LoRa
+coexistence and sustained-load testing on their own hardware. No GitHub release
+assets or firmware-picker entries were replaced by these experimental builds.
diff --git a/docs/test-results/companion_usb_ascii_validation.md b/docs/test-results/companion_usb_ascii_validation.md
new file mode 100644
index 000000000..2ed3b9691
--- /dev/null
+++ b/docs/test-results/companion_usb_ascii_validation.md
@@ -0,0 +1,68 @@
+# USB Companion ASCII default validation
+
+The updated [1.17.1.6 Companion release](https://github.com/mikecarper/MeshCore/releases/tag/v1.17.1.6-halo-keymind-cascade-dev-306feebe)
+covers 103 USB-enabled profiles: 52 ESP32 Full, 43 nRF52 Full, four RP2040 USB,
+and four STM32 USB. The BLE-only WM1110 image has no USB interface and is outside
+this change.
+
+## Behavior and cause
+
+Ordinary USB Companions previously started in Binary mode. Full Companions
+started in ASCII, but host-session cleanup could select or retain Binary mode
+for the next connection.
+
+All USB Companions now select ASCII before framed dispatch begins. An
+observable USB session boundary cancels old USB operations and clears partial
+input, queued protocol output, frame ownership, and mOTA attachment before restoring
+ASCII. A network terminal can defer that restoration; a new USB binary client
+that connects during the deferral keeps its chosen mode. BLE and network
+Companion transports keep their existing binary protocol.
+
+A complete framed app command still selects Binary mode automatically. An
+incomplete initial probe returns to ASCII after one second. Explicit terminal
+start/stop controls remain available.
+
+Session detection depends on the hardware. Native USB with DTR can detect a
+terminal closing. ESP32 hardware USB Serial/JTAG observes bus resets and host
+loss, but cannot reliably observe a program closing its handle. UART bridges
+usually cannot observe that close either. Idle time never forces an active
+binary app back into ASCII; use the start token on a port that remains Binary.
+See the [USB mode guide](../full_companion_usb_switcher.md).
+
+## Automated validation
+
+- The real firmware startup and reset functions are compiled and executed for
+ nRF52, ESP32 TinyUSB, ESP32 hardware USB, and RP2040, with and without the
+ Full macro. Cases include reconnecting from Binary or ASCII, delayed cleanup,
+ mOTA cancellation, network ownership, and a new binary client during deferral.
+- 55 native serial-mode/flow-control tests and 11 terminal-session/diagnostic
+ tests passed locally, along with 49 USB Python tests and the picker tests.
+- STM32 number formatting is tested against an Arduino API without `ltoa`.
+ CI also compiles an ordinary RAK 3x72 USB Companion with the real core and
+ RadioLib, so missing packages and GPIO API incompatibilities fail the build.
+
+The 99 ESP32/nRF52/RP2040 profiles use
+[`d4a641ff`](https://github.com/mikecarper/MeshCore/commit/d4a641ff8af1b413c8fb96a670282b799be1f6e8).
+The four STM32 profiles additionally use the compatible Arduino 2.12 platform
+pin and build test in
+[`d2c52e4d`](https://github.com/mikecarper/MeshCore/commit/d2c52e4d047c4b1068a1053364d9acaddefa64a1).
+Both source revisions passed GitHub checks:
+[main repair](https://github.com/mikecarper/MeshCore/actions/runs/34792631229) and
+[STM32 repair](https://github.com/mikecarper/MeshCore/actions/runs/34793043594).
+
+All 103 builds passed capability and RAM qualification. Packaging checks bind
+each application to its capability and memory manifests, retain the original
+profile capabilities and download formats, and verify ESP32 application/merged
+image consistency. Replacement publication verifies public SHA-256 digests
+before deleting old images and checks that unrelated firmware assets retain
+their original hashes. Release manifests, checksum lists, picker links, and
+capacity-note source references follow the new binaries.
+
+Publication completed with all 430 uploaded files verified through their public
+download URLs. The 412 superseded firmware/manifest files were removed. The
+remaining 1,837 assets kept their original hashes, and all five release checksum
+lists matched the final GitHub asset digests.
+
+No USB Companion was attached to the local computer for this repair. These
+results are compilation and automated protocol tests, not physical USB tests
+across the board matrix.
diff --git a/docs/test-results/companion_usb_client_validation.md b/docs/test-results/companion_usb_client_validation.md
new file mode 100644
index 000000000..d6035e987
--- /dev/null
+++ b/docs/test-results/companion_usb_client_validation.md
@@ -0,0 +1,117 @@
+# USB Companion client compatibility
+
+The RAK4631 report was reproduced on the MercerWoodMesh Pi with the published
+`RAK_4631_companion_radio_full-v1.17.1.6-halo-keymind-cascade-dev-d4a641ff`
+image. The fixes described here are in the source and test firmware;
+the existing GitHub release assets have not yet been replaced with this fix.
+Hardware validation was completed on 2026-09-14 with test builds labeled
+`v1.17.1.6-usb-client-test-0e5955f8` (base commit plus these USB fixes).
+
+## Port selection
+
+| Hardware | Companion/ASCII port | USB logging |
+| --- | --- | --- |
+| RAK4631 and other nRF52 Full Companions | Primary CDC, Linux `*-if00` | Optional second CDC, `*-if02` |
+| ESP32 Full Companions | Primary USB serial port | Shares that port; must be off for Binary Companion |
+
+`+++MESHCORE-TERM-STOP` changes the primary port to Binary Companion. Its
+`OK - Binary mode` reply does not move the client to a second port. Close the
+terminal before opening that same port with `meshcli -s PORT infos`. Do not use
+MeshCLI's repeater-mode `-r` option for a binary Companion connection.
+
+## Two reproduced failures
+
+1. Stock `meshcore-cli` 1.6.3 was tested with `meshcore` 2.3.10, whose serial client
+ deliberately deasserts DTR. The nRF52 primary USB gate and response route
+ both required DTR. A raw battery query returned a valid 14-byte response
+ with DTR high and no bytes with DTR low.
+2. The automatic ASCII banner ended in `> `. MeshCLI recognized that `>` as a
+ binary frame marker and consumed the real response's leading `>` as a
+ length byte. Even forcing DTR high in a diagnostic client did not recover
+ the first `APP_START` response. Stock MeshCLI exited without node information.
+
+The nRF52 transport now accepts real USB input as evidence of a client, tagged
+with the current session generation. Close, line-coding, and device-reset
+events invalidate that evidence; an old reader racing cleanup cannot grant
+access to a newer session. The write path remains bounded and does not wait
+for an unread host. CDC1 logging retains its own DTR requirement.
+
+The default ASCII terminal now suppresses its banner and unsolicited terminal
+events until ASCII input arrives. Enter or a command reveals the terminal.
+A binary probe receives a clean first response. Explicit START/STOP remains
+available, and an incomplete binary probe still times out back to ASCII.
+
+On a shared logging port, STOP now reports
+`ERROR: set usb.logging off before Binary mode` while logging is enabled.
+It no longer acknowledges Binary mode and immediately reclaims the port for
+ASCII logging.
+
+## Hardware validation
+
+The RAK4631 Full test firmware passed with an unmodified installed MeshCLI and
+its normal DTR-low serial library:
+
+- ASCII `ver` from the default mode, without START;
+- explicit STOP acknowledgement followed by 10 binary battery queries;
+- 10 immediate close/reopen queries each with DTR low and high;
+- 10 stock-library reconnects and three separate `meshcli ... infos` runs;
+- all of the above while the second CDC streamed logs: 598 bytes received,
+ no logging disconnections.
+
+The Heltec V4 Full Companion also passed using its ESP32-S3 USB Serial/JTAG
+interface, with USB logging disabled:
+
+- default ASCII `ver`, then STOP and 10 clean binary battery replies;
+- 10 stock-library reconnects and three unmodified `meshcli ... infos` runs;
+- a stock-client connection directly after reboot, without either mode token.
+
+The shared-port logging policy was exercised on the V4 as well. With logging
+on, STOP returned the explicit error, a binary probe could not claim the port,
+and stock MeshCLI could not connect. After `set usb.logging off`, STOP worked,
+10 binary replies were clean, and three stock-client reconnects succeeded.
+
+After testing, the V4's complete original flash snapshot was restored and
+verified by the flasher. Its original `v1.17.1-soak-v4-B` firmware answered
+again, and the previously active memory-soak collector was restarted. This
+test interrupted that board's uptime; the restored firmware starts a new run.
+Fresh successful V4 telemetry was verified at `2026-09-14T19:46:01Z`.
+The RAK4631 remains on the patched Full Companion test build with its separate
+logging interface enabled. The Pi's previously active services were restored.
+
+The nRF52 DTR stress sequence is deliberately not used on ESP32. Changing those
+control lines during immediate close/reopen triggered the V4's hardware
+bootloader and produced an `ESP` boot message. Preselecting RTS before opening
+did not prevent that transition on this host. ESP32 reconnect results above
+use the unmodified client's normal connection sequence instead.
+
+The test issues local information queries only. It does not transmit test chat
+messages over LoRa. Firmware installation and logging configuration are
+separate from the reusable test runner:
+
+```sh
+python3 tools/hil/companion_usb_client.py \
+ --port /dev/serial/by-id/PRIMARY-if00 \
+ --logging-port /dev/serial/by-id/LOGGING-if02 \
+ --test-dtr --cycles 10 --output usb-client-results.json
+```
+
+Replace the example paths with the device's actual stable paths. Install
+`meshcore-cli` in the Python environment used for the test. Start after a fresh
+reboot so the default-ASCII check has a new session. On ESP32, omit both
+`--logging-port` and `--test-dtr`, and disable USB logging first.
+
+## Automated checks
+
+- 53 Python USB checks pass, including compiled execution of the real nRF52
+ callbacks and transport gate with DTR-low clients, cleanup races, early
+ input, line-coding changes, physical disconnects, full TX FIFO, CDC1 isolation,
+ and the 1200-baud bootloader entry.
+- The real terminal methods are compiled for ordinary and Full modes to verify
+ that unsolicited text is suppressed before the first binary response.
+- The actual STOP branch is exercised for logging off, shared-port logging on,
+ and dedicated-port logging on.
+- 63 native serial-switching, flow-control, and terminal-session tests pass.
+- RAK4631 Full and Heltec V4 Full firmware compile and pass their packaged
+ capability checks.
+
+The USB workflow runs the new compiled regression tests on subsequent changes.
diff --git a/docs/test-results/double_frequency_v4_validation.md b/docs/test-results/double_frequency_v4_validation.md
new file mode 100644
index 000000000..1388b31a6
--- /dev/null
+++ b/docs/test-results/double_frequency_v4_validation.md
@@ -0,0 +1,114 @@
+# Double center-frequency write: V4 transmitter / XIAO receiver
+
+2026-09-14. **Completed bounded comparison: neither single nor duplicate
+frequency writes reached 400/400. The duplicate adds about 86 us per hop.**
+No production default was changed. Both radios are idle with HIL installed;
+the duplicate-write toggle is off. The earlier descending-SF sweep and its
+follow-up remain paused; no new exhaustive settling sweep was launched.
+
+## Hardware and control
+
+- RX: XIAO ESP32-S3/WIO SX1262, COM31, MAC 28:84:85:b4:09:80. Normal gain
+ register 0x94, unchanged-modulation cache enabled, buffered 8 MHz SPI.
+- TX: newly connected Heltec V4, COM7, MAC 44:1b:f6:6a:e8:44. Hardware probe
+ found ESP32-S3, 2 MB embedded PSRAM and 16 MB flash. HIL detected KCT8103L
+ FEM and reported the amplified TX path; chip RX gain 0x96.
+- User explicitly authorized erasing the V4 without backup. Its full flash
+ was erased and a verified HIL upload completed. XIAO's previous HIL image
+ was replaced with a verified same-image A/B-capable build. No private
+ backup contents were read; RAK and other boards were not touched.
+- SF10/125 kHz, CR4/5, four centers 909.500/909.750/910.000/910.250 MHz,
+ 5.1-symbol visits (41,780 us), 32-symbol TX preamble, zero added settling,
+ synthetic 16-byte packets, -9 dBm **chip request**. No extra IRQ polling.
+
+The V4 FEM uses the repository's production detection and TX/RX control.
+HIL restores RX after success or error on each direct transmit path. The
+GC1109 branch (not detected here) uses bypass according to
+[GC1109 table 4](https://resource.heltec.cn/download/WiFi_LoRa_32_V4/datasheet/GC1109_EN_V0.9.2.pdf).
+The detected KCT8103L uses its amplified path; antenna-port TX power was not
+measured. This is not a power-matched Indicator/V4 comparison.
+
+## Exact experiment
+
+`scanfreqrepeat 1` makes the HIL `ExperimentalSX1262` repeat the same
+SetRfFrequency (0x86) immediately after the first successful frequency call,
+before modulation, packet parameters and RX restart. The second call skips
+the image-calibration check, but keeps normal BUSY/status handling. Only an
+identified RX-to-RX hop may repeat; ordinary setup/TX does not. Either write's
+failure propagates through the existing failed profile-apply path. The toggle
+defaults off at reboot and cannot change during an active scan.
+
+Both modes use the same XIAO binary. Actual SPI command counts, not only
+software policy labels, are checked against one or two 0x86 writes per hop.
+The modulation cache remains on in both modes.
+
+## RX-only timing
+
+Six-second windows in single/double/double/single order, with reboot/clean
+setup between windows. No reference packets transmitted during this phase.
+
+| Frequency writes | Hops | Mean switch | Actual 0x86 | Actual 0x8B |
+| --- | ---: | ---: | ---: | ---: |
+| Single, first | 142 | 451.965 us | 142 | 0 |
+| Double, first | 141 | 538.213 us | 282 | 0 |
+| Double, second | 141 | 538.170 us | 282 | 0 |
+| Single, second | 142 | 451.930 us | 142 | 0 |
+
+Weighted means: **451.948 us single, 538.192 us double**: +86.244 us / +19.1%.
+All 566 hops had zero retune, RX-mode and cache errors. Maximum observed
+switch costs were 594 us single and 674 us double; these are samples, not
+hard latency guarantees. Unheld full-cycle means were about 168.942 ms versus
+169.285 ms.
+
+## Packet checks
+
+Single first, then double, each up to 100 packets/channel and stopping on its
+first miss; a 400/400 result would have stopped all RF testing. Neither passed.
+
+| Frequency writes | Valid before first miss | Failed packet | RF-run mean switch | Hops / 0x86 writes |
+| --- | ---: | ---: | ---: | ---: |
+| Single | 1 | 2, channel 2 | 452.719 us | 235 / 235 |
+| Double | 4 | 5, channel 2 | 542.063 us | 255 / 510 |
+
+Both failed packets were receiver timeouts, with TX success acknowledged,
+zero device-error flags and RX mode still active. Both settings reported zero
+retune, RX-mode, cache and RX errors. Ordinary processing after received
+packets caused one/four modulation rewrites respectively; pure RX-only timing
+above is the cleaner comparison of the duplicate cost.
+
+All five good V4 packets reported **-19 dBm RSSI**. SNR was +9.0 dB in the
+single-write sample and +8.2..+9.2 dB (mean +8.675 dB) for four double-write
+samples. The previous Indicator run had 547 good packets at average -40.547
+dBm RSSI, +8.311 dB SNR. Thus this V4 setup was about 21.5 dB stronger as seen
+by the receiver. Hardware/antenna/placement/power differences remain
+confounded; neither the Indicator nor close-range effects are proven causes.
+
+One versus four successes before a miss is too small and stop-conditioned to
+establish a reliability improvement or packet-error rate. Repeating center
+frequency did not remove the observed failure at this setting. No results for
+lower SFs or added settling delays are claimed for the V4. A weaker received
+signal is needed before treating a transmitter swap as a clean comparison.
+
+## Artifacts and checks
+
+Base checkout `0e5955f889788a4317863e731b38ffe09e3d5441`, branch
+`keymindCascade`, plus local uncommitted changes. RadioLib 7.7.1 pinned at
+`187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32 6.11.0 / Arduino
+ESP32 2.0.17. Both builds/uploads succeeded and uploader verified written
+data. The 59 selected host/native tests passed (39 collectors, ten profile,
+four SX1262, five radio and one receive-mode), including FEM restore on error
+and frequency-repeat scope/error propagation. Temporary build overlay removed.
+
+Actually loaded firmware SHA-256:
+
+- XIAO: `9f82d86ea78cfa6bc03fef4925f10c918c8b9fc7a96a7d2b565a04bd2f281288`
+- V4: `2ea2ce37d9d66fb4f8ec9cf3cedfbe7d4eb6d08bfe9abe6c817e5afb04885636`
+
+Raw evidence and byte-preserving SHA-256:
+
+- [RX-only ABBA](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_frequency_sf10_rx_only_results.json):
+ `38ded968a60ff19f45618ec35d75ce46354d81f163879185e9d6ff0e68da3ae0`
+- [Single-write packets](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_frequency_v4_sf10_off_results.json):
+ `4cadeba0d22b3ec6158ff295f1becfb57fde7ef8bf7fabbdbc6295f274619557`
+- [Double-write packets](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_frequency_v4_sf10_on_results.json):
+ `46bea8d9c095847ef8ccf4a4ed51ab5404eb5bb87d9cf62b832c3298159d1525`
diff --git a/docs/test-results/four_fixed_tx_dwell_validation.md b/docs/test-results/four_fixed_tx_dwell_validation.md
new file mode 100644
index 000000000..e41beb6e6
--- /dev/null
+++ b/docs/test-results/four_fixed_tx_dwell_validation.md
@@ -0,0 +1,143 @@
+# Four fixed transmitters: longer dwell repeats
+
+Same physical MercerMesh fixture and installed firmware as
+[the completed 5.1-symbol run](four_fixed_tx_single_pass_validation.md):
+RAK4631, Heltec T096, MeshTower V2 and T1000-E fixed transmitters; Heltec V4 RX.
+SF10 / BW125 kHz / CR4/5, four channels spaced 1 MHz apart, 32-symbol preamble,
+normal SX1262 RX gain, -9 dBm chip TX setting, zero added settling and one
+frequency write per hop. No firmware was rebuilt or reflashed for these repeats.
+
+## 6.1-symbol run: user-stopped, incomplete sample
+
+Started 2026-09-14 22:44:47 UTC. Dwell was 49,972 us per channel.
+All four stationary controls passed 100/100 again.
+
+The user requested stopping this run and proceeding to 7.7 symbols. The
+collector was interrupted only after verifying its PID, command line and owner.
+At the last saved checkpoint, **342/348 scored scan packets were received**
+(98.28%), with six timeouts:
+
+| TX / channel | Scored attempts | Received | Timeouts |
+| --- | ---: | ---: | ---: |
+| RAK4631 / 0 | 87 | 87 | 0 |
+| Heltec T096 / 1 | 87 | 86 | 1 |
+| MeshTower V2 / 2 | 87 | 85 | 2 |
+| T1000-E / 3 | 87 | 84 | 3 |
+
+This is not a completed 400-packet run or a validated full-run timing capture.
+An interrupted in-flight packet can exist beyond the last saved checkpoint;
+it is not scored as another success or failure. No final switch timing was
+collected before reboot. The raw capture remains incomplete, and the separate
+user-stop record documents why. All five radios were then independently
+verified idle, and ModemManager was restored. Gateway services stayed active;
+the V4 memory-soak service stayed inactive.
+
+- [Partial raw capture](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_6p1_results.json)
+- [User stop record](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_6p1_user-stop.json)
+- [Verified cleanup](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_6p1_cleanup.json)
+- [Run provenance](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_6p1_launch.json)
+
+Partial raw capture SHA-256:
+`ad199124930da19fe56c78a4ea8280a6d94b95ef408589b19a9e165155dbf7fd`.
+
+## 7.7-symbol run
+
+Launched 2026-09-14 22:58:14 UTC; collector ran from 22:58:15 to 23:11:45 UTC
+with 63,079 us visits. All four fixed-channel controls passed 100/100.
+The full scan completed with **395/400 received (98.75%)**, five timeouts and
+zero wrong-channel or invalid payloads. Thus **7.7 symbols did not achieve a
+400/400 pass**.
+
+| TX / channel | Scanning result | Timeouts | Mean received RSSI (dBm) | Mean received SNR (dB) |
+| --- | ---: | ---: | ---: | ---: |
+| RAK4631 / 0 | 100/100 | 0 | -21.05 | +8.14 |
+| Heltec T096 / 1 | 99/100 | 1 | -4.35 | +8.98 |
+| MeshTower V2 / 2 | 97/100 | 3 | -44.86 | +8.63 |
+| T1000-E / 3 | 99/100 | 1 | -18.56 | +8.26 |
+
+All 800 baseline/scan TX sequences were unique, all transmit calls succeeded,
+and every TX packet reported zero frequency and modulation configuration writes.
+There were zero packet-response transport replays. All four final TX lifetime
+checks reported 200 packets and a prepared, non-failed transmitter.
+
+The five timeouts occurred at scan attempts 158 (T1000-E), 184 (T096), and
+200, 293, 314 (MeshTower). Each reported RX mode 1 and zero device errors.
+Final RX failure, RX-mode-error, cache-error and receive-error counters were zero.
+
+### Measured switch and sweep timing
+
+| Hop population | Count | Minimum (us) | Mean (us) | Maximum (us) |
+| --- | ---: | ---: | ---: | ---: |
+| All single-pass switches | 5,046 | 438 | 524.146 | 804 |
+| Cached modulation; no 0x8B | 4,651 | 438 | 502.147 | 686 |
+| Post-reception modulation refresh | 395 | 760 | 783.180 | 804 |
+
+Exactly 5,046 RF-frequency commands and 5,046 optimized RX resumes were counted,
+with one retune pass and zero second passes/detours. The 395 modulation writes
+match the 395 post-reception cache refreshes. This is the same cache behavior
+as the earlier run, not double-setting the radio. The slightly higher overall
+mean than the 5.1 run partly reflects a larger fraction of refresh hops.
+
+Across 860 measured idle cycles, the four-channel sweep averaged **254.351 ms**,
+minimum 254.208 ms and maximum **255.482 ms**. Relative to the 262.144 ms
+programmed preamble, that leaves 7.793 ms at the mean and 6.662 ms at the maximum.
+Nominal dwell was 63.079 ms; measured idle dwell averaged 63.084 ms. Packet-held
+visits include payload/guard time and are not comparable to idle cycles.
+
+The 7.7 run received three more packets than the earlier 5.1 run (395 versus
+392 out of 400). This small, sequential comparison does not establish a reliable
+improvement or a lossless dwell threshold. The 6.1 result is partial and must
+not be treated as an equal-size completed comparison.
+
+### Evidence and cleanup
+
+Unlike the original 5.1 run's overly strict host assertion, this collector
+completed normally with `complete: true` and exit code 0. Independent offline
+validation also passed. The same collector SHA-256 was used for both repeats:
+`cceb8a570f4aedeaf45d28b4b4590b23df3c1efd490c8e8e9cd74a23ba00024f`.
+The copied deployment/manifest files are provenance for reused firmware, not
+records of a new flash operation. Host options and acknowledged dwell match.
+
+- [Completed raw capture](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_7p7_results.json)
+- [Validated summary](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_7p7_validated.json)
+- [Run provenance](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_7p7_launch.json)
+- [Cleanup readbacks and warnings](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_7p7_cleanup.json)
+
+Raw capture SHA-256:
+`5e3f9a80af1384c3072303428b75c8550ac42d2ac95bd1234bf92cb0aea625e8`.
+
+The cleanup wrapper retained three errors from its reboot phase: the T096
+primary USB interface was temporarily missing/ambiguous, and attempts to open
+the MeshTower/T1000-E ports returned permission denied. Its subsequent final
+readbacks nevertheless succeeded for all five radios: each TX was unprepared,
+non-failed, packet count zero and autonomous TX disabled; RX scanning was
+inactive with zero channels. No further radio access or permission changes
+were attempted after those results returned. The wrapper's `verified_idle`
+flag remains **false** because it retains any earlier cleanup error, and the
+outer operation returned nonzero for that reason. These warnings happened
+after the complete RF sample; they are not counted as RF packet losses or
+silently removed from the record.
+
+ModemManager was restored active; gateway services remained active. The V4
+memory-soak service remains inactive with test firmware installed. No firmware
+restore, commit, push or pull was performed. Previous raw captures are unchanged.
+
+## Dwell budget
+
+One chirp at SF10/125 lasts 8.192 ms. For the experiment's nominal budget of
+one complete four-channel idle sweep inside 32 programmed preamble symbols:
+
+`maximum dwell symbols = 32 / 4 - switching_ms / 8.192`.
+
+The 5.1-symbol run's mean switch (0.513980 ms) gives about 7.937 symbols;
+its maximum switch (0.801 ms) gives about 7.902 symbols, before other loop
+overhead. These are timing ceilings, not proven lossless dwell limits or a
+complete acquisition model. Near the ceiling there is little timing margin.
+At 7.7 symbols, four nominal dwell-plus-average-switch intervals total about
+254.4 ms, versus a 262.144 ms programmed preamble.
+
+The host keeps the same seed and arrival-randomization rule, spanning about
+two nominal scan cycles; absolute arrival pauses therefore scale with dwell.
+The fixed-channel controls, sample counts, switching policy and firmware stay
+unchanged. As before, external PA/LNA paths and unrelated RF traffic mean this
+is a finite bench comparison, not a calibrated sensitivity or interference test.
diff --git a/docs/test-results/four_fixed_tx_single_pass_validation.md b/docs/test-results/four_fixed_tx_single_pass_validation.md
new file mode 100644
index 000000000..6870ec4d4
--- /dev/null
+++ b/docs/test-results/four_fixed_tx_single_pass_validation.md
@@ -0,0 +1,185 @@
+# Four fixed transmitters / fast single-pass receiver
+
+## Result
+
+MercerMesh Pi bench run, started **2026-09-14 22:18:49 UTC** (15:18:49 PDT):
+four physically separate, fixed-channel transmitters and the Heltec V4 receiver.
+The full stationary controls passed **400/400**. The subsequent four-channel
+scan received **392/400 correctly (98%)**, with **8 timeouts (2%)** and **zero
+wrong-channel payloads**. No RF retries were used.
+
+| TX / assigned channel | Frequency (MHz) | Fixed-RX control | Scanning RX | Scan mean RSSI (dBm) | Scan mean SNR (dB) |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| RAK4631 / 0 | 909.5 | 100/100 | 96/100 | -20.69 | +8.63 |
+| Heltec T096 / 1 | 910.5 | 100/100 | 98/100 | -3.84 | +8.45 |
+| Heltec MeshTower V2 / 2 | 911.5 | 100/100 | 100/100 | -45.48 | +8.41 |
+| Seeed T1000-E (LR1110) / 3 | 912.5 | 100/100 | 98/100 | -18.27 | +8.51 |
+
+RSSI/SNR describe received packets only. Each transmitter sent 200 unique
+packets across its baseline and scan. All 800 transmit calls returned success,
+with zero frequency or modulation commands during those packet calls. There
+were zero recovered/replayed USB responses in the packet records.
+
+## Switching timing and command evidence
+
+| Hop population | Count | Minimum (us) | Mean (us) | Maximum (us) |
+| --- | ---: | ---: | ---: | ---: |
+| All single-pass switches | 7,206 | 429 | 513.980 | 801 |
+| Cached modulation; no 0x8B | 6,814 | 429 | 498.602 | 686 |
+| Modulation cache refresh | 392 | 763 | 781.291 | 801 |
+
+The receiver recorded exactly **7,206 frequency commands and 7,206 optimized
+RX resumes**: one frequency write and one resume per hop. Second-pass count
+was zero. Duplicate frequency writes, frequency/CR detours and added settling
+were disabled. Warm XOSC, bulk 8 MHz SPI and cached modulation were enabled.
+
+The average switch was **0.514 ms**, but not every switch was below 0.6 ms.
+After each received packet, ordinary RX staging invalidates the modulation
+cache; the next hop refreshes it. Subsequent unchanged hops omit 0x8B.
+The 392 refreshes match the 392 received packets. The source path is
+`RadioLibWrapper::recvRaw()` -> `startRecv()` / `startReceiveMode()` ->
+`PhysicalLayer::startReceive()` -> `CustomSX1262::stageMode()` ->
+`SX1262ProfileSwitchState::invalidate()`. No production cache-invalidation
+behavior was changed for this test.
+
+Mean switch-plus-settling-instrumentation time was 515.698 us, maximum 803 us.
+The settling measurement averaged 1.718 us of timer/measurement overhead;
+the configured settling delay was zero. The idle four-channel sweep averaged
+169.135 ms (maximum 170.470 ms), versus 262.144 ms for the 32-symbol preamble.
+Packet-held visits include reception/guard time and are not idle sweep timings.
+The `deferred` counter counts guard-loop deferrals, not lost packets.
+
+## Method and fixture
+
+- SF10 / 125 kHz / CR4/5; 32-symbol preamble; synthetic 16-byte CHS1 packets.
+- Four channels spaced 1 MHz apart; 5.1-symbol RX visits (41,780 us).
+- Each physical transmitter was configured once and remained on its assigned
+ frequency throughout both the controls and scanning.
+- Receiver was reinitialized for each fixed-channel control and once before
+ the scan. All 100 control packets per transmitter were retained.
+- Scan used 100 packets per channel, randomized channel order and arrival
+ pauses (seed 606125). Transmissions were staggered, not concurrent.
+- Each expected packet had a 10-second scan timeout. Misses did not stop the
+ run, restart the scanner, force it onto the TX channel or trigger a retry.
+- SX1262 normal RX gain was verified as register 0x94. The V4's external
+ KCT8103L front end remains distinct from the chip's boosted-gain setting.
+- Requested TX power was -9 dBm at each radio chip. The Heltec TX boards have
+ external PA paths, held in TX mode by this HIL firmware; these are **not
+ calibrated equal antenna-port powers**.
+- High-rate channel trace was disabled; command counters and timing remained
+ enabled. Gateway services remained running, so unrelated RF was not excluded.
+
+| Role | Hardware serial / MAC | Pi USB topology |
+| --- | --- | --- |
+| TX0 RAK4631 | 9AB3B64C641BA927 | 1-1.3.1 |
+| TX1 Heltec T096 | 651F8E496197F882 | 1-1.2.3 |
+| TX2 Heltec MeshTower V2 | 9352162A72082314 | 1-1.2.2 |
+| TX3 Seeed T1000-E LR1110 | 34A9141999729D5D | 1-1.2.1 |
+| RX Heltec V4 | 44:1B:F6:69:CF:98 | 1-1.3.3 |
+
+Board selection checked serial plus topology, not transient tty names or CDC
+interface count. The RAK3401 gateway, serial 0B81C9C68D8D01B4, was excluded.
+The earlier local XIAO radios were not modified during this run.
+
+## Remaining losses and interpretation
+
+Timeouts occurred at overall scan attempts 10, 14, 55, 65, 145, 299, 304 and
+384. Thus they were not confined to startup. Each timeout reported RX mode 1
+and zero device errors; TX calls returned success and still reported no
+configuration writes. Final RX failure, RX-mode, cache and receive-error
+counters were all zero.
+
+The wrong-channel behavior did **not** reproduce in this configuration.
+The remaining misses do not require a transmitter that retunes between packets.
+However, this is not a controlled A/B against the earlier XIAO setup: the RX
+board, TX boards/chips, antennas and geometry changed. These results do not
+prove that the earlier transmitter was faulty or establish the cause of the
+eight remaining losses.
+
+Several received signals were very strong, particularly the T096 at about
+-4 dBm. The weakest link, MeshTower V2 at about -45 dBm, passed 100/100 while
+scanning. That makes a controlled spacing/attenuation comparison worth
+considering, but this sample does not prove receiver overload. The observed
+98% is a finite bench result, not a reliability guarantee or sensitivity test.
+
+## Capture integrity and post-run validation
+
+All 400 scan probes finished and final radio telemetry was returned. The
+original host checker then rejected that telemetry because it incorrectly
+required zero modulation writes over the entire run, including post-packet
+RX staging. Its raw capture therefore remains `complete: false`, with final
+status preserved verbatim inside its error string. The normal `sender_final`
+collector step did not run. **The raw failure record was not rewritten.**
+
+Source review established the cache-refresh behavior above. The host checker
+now validates modulation writes against the measured refresh-hop count, checks
+that cached plus refresh hops equal total hops, and still requires single
+frequency writes, single fast resumes, zero added delay and the other policies.
+It also saves final telemetry before checking it, so future failures retain
+that structured record.
+
+Separate offline validation parsed the saved telemetry, checked the full
+400-probe scan and all four 100-probe controls, verified 800 unique sequences,
+and checked every transmitter's packet counter from 1 through 200 and its
+zero-retune acknowledgments. A capture regression additionally checks that
+each TX/RX response ID matches its probe and every accepted payload/channel
+matches the assigned channel. No radio was rerun to replace the failed host
+assertion. The derived summary explicitly reports
+`original_collector_complete: false` and `post_run_validation_passed: true`.
+
+Evidence files:
+
+- [Immutable raw capture](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_single_results.json)
+- [Independently validated summary](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_single_validated.json)
+- [Deployment record](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_single_deployment.json)
+- [Build manifest](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_single_manifest.json)
+- [Verified idle/service state](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_four_tx_sf10_single_cleanup.json)
+
+Raw capture SHA-256:
+`42de3ac0c335b2c59f35c1cab217a01ef242c9f2e7b0010c8000a908a59232e6`.
+Derived summary SHA-256:
+`316bd551eb7306dfc576f4b88ad23e82cb1d701481d1e022067c961806d31bfe`.
+
+## Build, deployment and final state
+
+Built from the dirty `keymindCascade` worktree based on
+`2b5a6f209835f0f67ed73fee78d36447a4566ce6`. This is not a claim that HEAD
+alone reproduces the images. The manifest records image and HIL-source hashes.
+All five PlatformIO builds succeeded and ran sequentially.
+
+After the host-checker correction, 25 focused unit/regression tests passed,
+including the preserved-capture check. `git diff --check` passed; its only
+warning concerned a pre-existing CRLF conversion in `MyMesh.cpp`.
+
+RadioLib was pinned to `187ef24791c3d844939b2be13a68bd890bd04e4c` (7.7.1).
+The nRF framework was `d5413016` (1.10701.0); the ESP build used platform
+6.11.0 / Arduino 2.0.17. The first three nRF targets retained S140 6.1.1
+(FWID 0xB6, app base 0x26000); T1000-E retained S140 7.3.0 (FWID 0x123,
+app base 0x27000). nRF DFU packages were application-only, with their matching
+SoftDevice requirements intact. No nRF bootloader/SoftDevice replacement or
+cross-board override was used. Runtime FWID and application base were verified.
+
+V4 received the normal ESP bootloader, partition table, boot_app0 and HIL app
+at 0x0, 0x8000, 0xE000 and 0x10000, respectively, with esptool hash checks.
+No whole-flash bulk erase was issued. No backup was requested for these
+authorized bench deployments.
+
+The initial bundle's collector/deployer hashes are not the final scripts used:
+the actual run used `profile_four_tx_v2.py` with SHA-256
+`b762e412636076902174c76b71961cc8ab3059c2b6de3dd3b4123bafcb06545c`
+(startup-greeting fix, original strict final assertion), and deployment used
+`profile_four_tx_deploy_v3.py` with SHA-256
+`8a660788e0215e6ac1aece0a5d033521f9f376c9e51c00e760e918ebfd72cca1`.
+The current local collector includes the later validation correction; firmware
+images were not changed after the run. Remote evidence remains under
+`/home/mikec/hwtest/runs/four-tx-sf10-20260914`.
+
+All five radios were subsequently rebooted and independently verified idle.
+All TXs reported unprepared, packet count zero, no failure and autonomous TX
+disabled; V4 reported scanning inactive and zero hops after reboot. HIL
+firmware remains installed. ModemManager was restored active, gateway services
+were left active, and the V4 memory-soak service remains inactive because its
+firmware is now the bench receiver. No Pi USB hub power cycle was performed.
+
+No commit, push or pull was performed for this test. Unrelated worktree changes
+were preserved.
diff --git a/docs/test-results/full_usb_logging_validation.md b/docs/test-results/full_usb_logging_validation.md
new file mode 100644
index 000000000..fa69d176c
--- /dev/null
+++ b/docs/test-results/full_usb_logging_validation.md
@@ -0,0 +1,78 @@
+# Full USB logging repair — 1.17.1.6
+
+Audit date: 2026-09-13. Release firmware source: `306feebe6d648a247f925894f876be9d747fc8d2`.
+
+The original Station G2 Full Repeater and Full Room Server images omitted USB
+packet logging. Both inherited `-UMESH_PACKET_LOGGING` from their ordinary MQTT
+observer recipes. Full added `-DMESH_PACKET_LOGGING=1`, but PlatformIO's
+`ProcessFlags` moves undefines after defines in the compiler command. The
+resulting image compiled successfully with its packet logger removed.
+
+The previous release gate checked OTA and other features without requiring
+USB logging in Full infrastructure. An old passing manifest could also be
+reused by `--resume` without the new logging evidence.
+
+## Published-image audit
+
+Each downloaded/cached artifact was matched to GitHub's published SHA-256.
+The check inspected application bytes, including the application inside nRF52
+DFU ZIPs. It required the packet-log formatter and USB logging controls.
+
+| Release group | Application images | Missing USB packet logging |
+| --- | ---: | ---: |
+| ESP32 Full infrastructure/utility | 157 | 2 |
+| ESP32 Full Companion | 52 | 0 |
+| nRF52 Full Companion | 43 | 0 |
+| Total | 252 | 2 |
+
+Affected targets:
+
+- `Station_G2_repeater_observer_mqtt`
+- `Station_G2_room_server_observer_mqtt`
+
+V4/R8 Full builds and Station G2's Full ESP-NOW bridge passed. This audit does
+not count ordinary MQTT profiles that intentionally omit USB logging as
+broken. Artifact inspection proves compiled support, not hardware operation
+on every board.
+
+## Fix and regression coverage
+
+- Full builds that require logging remove inherited disables of
+ `MESH_PACKET_LOGGING` before PlatformIO parses flags. Other macros and
+ explicit logging-off profiles retain their policy.
+- Full capability checks require the packet logger and USB controls in the
+ packaged application. Combined USB/WiFi profiles additionally require
+ the `logging.output` setter. ELF symbols or debug text cannot satisfy these
+ checks.
+- Resumed builds must have passing application evidence for the current
+ logging contract, along with the existing artifact hashes and RAM checks.
+- CI runs the flag, real PlatformIO/compiler, artifact, resume, and build-profile
+ regression suites.
+
+Focused verification (after installing PlatformIO and its tool-scons package):
+
+```sh
+python3 -B test/test_full_build_flag_dedup.py
+python3 -B test/test_full_logging_compile.py
+python3 -B test/test_firmware_capabilities.py
+python3 -B test/test_full_logging_resume.py
+bash test/test_build_profiles.sh
+python3 -B test/test_cascade_release_package.py
+```
+
+Both Station G2 profiles were rebuilt through `build.sh --full` with the
+original observer recipes, including their inherited logging undefines. They
+passed the new application checks, runtime RAM budgets, and both OTA slot
+size checks. The new checker rejects both original published images.
+
+On the Bellevue Station G2, the corrected Full repeater implementation also
+passed serial CLI and passive traffic checks: four RX and two TX packet log
+lines were captured. Its active temporary profile retained preamble 120 and
+the original schedule end time; the Pi relay services were restored.
+
+Corrected application SHA-256:
+
+```text
+Repeater: 81a0a2926d4e567c069d0b10f316047881bcedce062afaa42119dddb2baadf23
+Room Server: 7ee81f1cb65fc274bd363397c3c80ce9006d575efae50fe952c8ca246613a18c
+```
diff --git a/docs/test-results/hardware_validation_checklist.md b/docs/test-results/hardware_validation_checklist.md
new file mode 100644
index 000000000..1420d4353
--- /dev/null
+++ b/docs/test-results/hardware_validation_checklist.md
@@ -0,0 +1,1409 @@
+# Hardware validation checklist
+
+This file records dated hardware observations and open validation tasks.
+Historical defaults, versions, and test frequencies below describe the tested
+revision, not necessarily the current release. For current ASCII USB defaults
+and runtime switches, use [feature switches by role](../role_feature_switches.md).
+
+Use this checklist for release-candidate hardware runs. A check is complete only
+when its log identifies the physical device, firmware artifact, artifact hash,
+command result, and cold/warm boot outcome. Do not infer success from a tool's
+exit code when the tool has a documented false-success mode.
+
+## Bluetooth stealth qualification - 2026-09-07
+
+The [XIAO stealth report](../hardware_validation_bluetooth_stealth_2026-09-07.md)
+records exact artifacts for the initial implementation and independent-flag
+follow-up. The flag run covers custom/saved-random pairing and bonded reboot
+reconnect, idempotent `on`, `off` preserving the MAC, directed advertising,
+and both rotating policies with stealth still enabled. It also gives the
+repeatable hardware regression procedure. True battery-disconnected cold boot,
+phone address-privacy behavior, a second unbonded central, and ESP32 hardware
+remain separate uncompleted gates; do not infer them from the XIAO results.
+
+## Current marathon ledger (through 2026-09-04)
+
+| Hardware | Stable identity | Current state | Next blocking check |
+| --- | --- | --- | --- |
+| Seeed XIAO nRF52840 | `B35E71C1C3726CE7` | OTAFIX candidate `0x02040405` passed exact serial install and corrected Legacy DIS model gates; the patched Bluetooth repeater-updater application was restored over BLE and returned as exact `2886:8044` USB | Use the updater for an identity-gated Bluetooth transfer to another repeater, then prove current-build cold persistence |
+| Seeed Tracker T1000-E | `34A9141999729D5D` | OTAFIX candidate `0x02040405` and the `uf2reset`-fixed Full Companion are installed. The real text command returned exact `2886:0057` boot USB, the same fixed app was restored, and refreshed bonded BLE services pass as `MeshCore-09848C15` | Restore the protected Repeater identity when Companion qualification is complete; cold boot, then force LR1110 reset |
+| RAK3401 | `0B81C9C68D8D01B4`; FICR `8D8D01B4 0B81C9C6` | OTAFIX test version `0x02040403` passes bidirectional application UF2, exact SWD application/bootloader readback, unchanged UICR, and a 74-block direct V4 LoRa apply from exact `3caf9dcf` to current HEAD `9fd580c8`. Post-apply USB and LoRa identity/hash checks pass and the temporary 0 dBm bench setting is restored to 22 dBm | Add exact RF packet counters, then repeat the supported-bandwidth and controlled/passive/mixed routed-hop matrix |
+| Heltec T096 | `651F8E496197F882` | OTAFIX candidate `0x02040405` and the `uf2reset`-fixed no-footer Full Companion are installed. The real text command returned exact boot USB, and final USB terminal/Binary mode, dual CDC, version, help, and BLE identity pass | Perform a privileged or physical host-driven USB bus reset without resetting the MCU; complete the deferred multi-click/long-press physical matrix |
+| Heltec MeshTower V2 with SD | `9352162A72082314` | OTAFIX candidate `0x02040405` passed serial combined install, the historical cached DIS handles, BLE application restore, and exact application USB return. Card format/cooldown/forced-format/raw-erase/remount and the live SD apply contract passed earlier | Signed interrupted-download resume, corruption/signature rejection, then full/delta apply |
+| Heltec V4 | USB MAC `44:1B:F6:6A:E8:44`; BLE `44:1B:F6:6A:E8:45` | USB/BLE/Wi-Fi and all three TCP services pass on hardware. The final fresh-NTP-gated Full Companion image builds with 4.31 MB app space free | Flash the exact final NTP build, prove NTP-before-TLS success/failure ordering, then OTA seeding |
+| SenseCAP Indicator LoRa | CH340 plus USB MAC `D8:3B:DA:75:23:AC`; BLE `D8:3B:DA:75:23:AD` | The current dark-layout application is SHA-256 `81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b`; identity-gated flash, USB ASCII/Binary switching, runtime logging, and separate configured-Wi-Fi, BLE, and TCP checks pass. Fresh-NTP HTTPS recovery, strict Range resume, STAGEV2 install, and RP USB readback passed on earlier exact artifacts; the final coordinator source still needs its blocked-UDP/123 hardware gate | Physical display-wrap and four-mode render/heap checks, exact-final blocked-NTP recovery gate, LoRa/TempRadio, and non-empty OTA seeding |
+
+The current source checkpoint passes 1,132/1,132 native cases, 68/68 host
+broker/endpoint cases, all 318 LoRa-OTA chain/route/bundle cases, and 41/41
+MOTA format/signature/delta cases under the documented pipx `detools`
+interpreter. Indicator geometry, dark-theme, and font-recovery contracts pass
+4/4, 6/6, and 25/25. A serialized real Indicator ESP32-S3 Wi-Fi/USB profile
+also compiles at 95,944 bytes RAM and 1,589,433 bytes flash. The direct
+system-Python MOTA invocation predictably lacks `detools`; that environment
+boundary is not a firmware failure and must not be worked around by installing
+the package globally.
+
+The RAK failure log is
+`/home/mikec/hwtest/runs/rak3401-syncidle-clean-tSp7PLgA/result.txt` on
+the Mercerwood Pi. The tested UF2 SHA-256 was
+`650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7`.
+`cp` returned success in 95 ms, but `sync -f` failed with `EIO` after
+157,188 ms. Post-failure SWD readback proved that all seven ordinary sectors
+whose bytes differed between the old and intended builds still contained the
+old values, and the settings page was still erased. The only available
+pre-copy SWD dump was taken several hours earlier, however, so it cannot prove
+that zero unchanged blocks were written during this attempt or establish when
+the stale S140-v7 tail in the first application page appeared. The next run
+must capture an immediate pre-copy dump as well as the post-copy dump. Serial
+DFU recovery then completed in 35,698 ms with the exact `Device programmed.`
+marker. All 1,830 UF2 payload blocks matched SWD readback after recovery, and
+the application passed a subsequent real power-removal boot.
+
+The corrected 2.4.4-preview.2 candidate was installed through identity-gated
+SWD from combined-image SHA-256
+`9b1654baccd2a920f68651893e31d79170400d1a7982a3f854ae640f1d3513a8`.
+An immediate pre-copy dump closed the earlier evidence gap. A deliberately old
+UF2 then copied and flushed successfully in 16,568 ms, and the exact formerly
+failing UF2 above copied and flushed successfully in 16,601 ms. After each
+direction, all 1,830 payload blocks matched a full SWD readback with zero header
+or payload mismatches. The return update changed 123 bytes across five expected
+application pages, UICR remained byte-identical, and a subsequent downstream
+USB power removal plus Pi reboot returned the RAK as the same stable identity.
+It reported the intended `3caf9dcf` application, board, node name, USA radio
+tuple, and 22 dBm setting after that cold boot. The new bootloader's CLI source
+string is truncated before the injected preview number, so the independently
+hashed SWD artifact is the authoritative candidate identity for this run.
+
+The later TinyUSB lifecycle/generation candidate identifies itself as test
+version `0x02040403`. Its exact 40,960-byte bootloader readback SHA-256 is
+`f609eb18754070c54f127325b1cb48547baf5c6b1c695d36fd0c8e0874713c13`.
+With that candidate installed, a full `CURRENT.UF2` read completed before each
+write. The 936,960-byte `bf092b24` application UF2 (SHA-256
+`12477a9734cf12eb920361bd21024e39bf303953fcf79d0bf720e00847e2ae92`)
+copied and flushed in 28,203 ms and returned to application mode four seconds
+later. The exact `3caf9dcf` application UF2 (SHA-256
+`650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7`)
+then copied and flushed in 26,479 ms and also returned in four seconds. Both
+boots retained `RAK3401-OTA-BENCH` and `910.525/62.5/SF7/CR5`.
+
+Identity-gated SWD then read the entire 468,480-byte padded UF2 application
+range. Its SHA-256 exactly matched the converted source on both sides at
+`b2e37a7681027ca91d1b2bd8e39b55395d9e91d4863aed17131e50c1ba1d2227`.
+The bootloader still matched the installed `0x02040403` image byte-for-byte,
+and UICR still matched the pre-test image at
+`d029ea630c2f632a1b690cb52b2a96a28b6f870c656c3e1bd74ba3a571c53b9c`.
+The authoritative logs are under
+`/home/mikec/hwtest/runs/rak3401-uf2-02040403-20260830-170829` and
+`/home/mikec/hwtest/runs/rak3401-uf2-return-02040403-sfHiGEqA` on Mercerwood.
+
+The subsequent direct LoRa qualification used the V4 as controller/seeder and
+the RAK's exact `bf092b24` body `91CF4EC2882F23D6` as the base. Its mandatory
+fixed three-minute rehearsal passed on `909.95/250/SF5/CR5`, including natural
+expiry and exact normal-radio recovery. The 2,132-byte, two-block in-place
+delta reached `2/2` and ready in six seconds; install was accepted, and the new
+body appeared at the 60-second reboot probe. The automation took 514 seconds
+including rehearsal, clock/radio safety checks, and post-install verification.
+The final body was `27A223DDFC3A8F47`, version `1.17.1.5` at `3caf9dcf`, and
+the bootloader reported `blrc:B8`, which is OTAFIX's successful application
+apply result rather than an error. Exact target TX power was wrapped from
+22 dBm to 0 dBm for the co-located transfer and restored to 22 dBm afterward;
+the source RXPS configuration and both normal radio tuples were also restored.
+
+Identity-gated SWD then read the live application at `0x26000`. All 468,384
+bytes matched the intended release image exactly (SHA-256
+`b496e3f4419c9f771564ed3c74b3407822f45acdf60cbd6c4625a54b181ca084`),
+and the independently recomputed EndF hash was `27A223DDFC3A8F47`. The target
+ID was `2FA509C1`, hardware ID was `RAK_3401`, and version word decoded to
+`1.17.1.5`. The 40,024-byte bootloader readback also matched the exact installed
+`ffb1580`/test-version-`0x02040402` artifact byte-for-byte; UICR was unchanged.
+After SWD reset-halt/resume, USB again reported the expected application,
+identity, name, radio, 22 dBm, no TempRadio lease, and `blrc:00` (the retained
+success diagnostic is intentionally cleared by the next normal boot).
+
+The current `0x02040403` bootloader candidate then passed a second direct V4
+LoRa qualification using a materially larger application change. The unsigned
+in-place package was 75,614 bytes with 75,108 payload bytes and 74 transfer
+blocks. Its mandatory fixed three-minute rehearsal passed, including natural
+TempRadio expiry and exact normal-channel recovery; one lost read-only
+`ota status` reply was reconciled by its bounded exact retry. The transfer
+reached 74/74 and READY in 122 seconds, the explicit unsigned install was
+accepted, and reboot produced exact HEAD `9fd580c8` with body
+`744BC4443891ED10`. The complete safety-wrapped run took 589.487 seconds,
+including rehearsal, recovery, and final verification. The first postboot
+`ver` reply was lost and its exact read-only retry passed. Independent USB
+checks then confirmed target `2FA509C1`, hardware `RAK_3401`, unchanged
+identity/name/radio/RXPS/watchdog state, bootloader CRC `6683C066`, ABI 3,
+apply result `blrc:B8`, and no active TempRadio/download/seeder/key state.
+After the separate host-recovery exercise, the near-field TX setting was
+restored from 0 dBm to its exact original 22 dBm and read back over an
+authenticated LoRa session. The sanitized authoritative log is
+`/home/mikec/hwtest/runs/rak3401-02040403-head-vzWOXVxT/qualification-redacted.log`
+with SHA-256
+`3e53dcaa5922c7c18a7890963b8fc092311d2db01caa5603640f43f1482802f6`.
+
+A serialized VM build of the six connected-board qualification profiles then
+completed 6/6 in 136.122 seconds: XIAO nRF52 BLE Companion, T1000-E BLE
+Companion, RAK3401 LoRa-OTA Repeater, SenseCAP Indicator LoRa USB/Wi-Fi
+Companion, MeshTower V2 SD LoRa-OTA Repeater, and the consolidated Heltec V4
+V4.2/V4.3 Full Companion. The earlier V4 selector typo was rejected before any
+compile began and is not a firmware build failure.
+
+After the fresh-NTP download gate, shared SNTP coordinator, MQTT-to-MeshCore
+RTC handoff, and daily refresh policy were added, the complete native matrix
+passed 1,102/1,102 cases in 93.953 seconds. The scheduler is host-tested at
+24-hours-minus-one-millisecond, the exact 24-hour boundary, normal and wrapped
+`millis()` values, a short retry that overrides the daily cadence, the zero
+deadline sentinel, and a Wi-Fi event latch. A reconnect after a successful
+sample preserves the existing deadline, so successful samples are requested
+after boot and then only once per day. All current
+Python and shell contract suites passed, and the expanded LoRa-OTA automation
+suite passed 317/317 cases.
+
+Serialized real builds also passed after that change. The final event-safe
+implementation uses an atomic `GOT_IP` latch so even a short initial connection
+before the 10-second MQTT status sample cannot skip the required boot NTP
+request. The final Heltec V4 MQTT Observer application is 1,853,304 bytes
+with SHA-256
+`1938ae88fcdbe601cf8698df44b548656079562d899801886b808af913e5f9ac`.
+The consolidated Full V4 image used 2,250,801/6,553,600 flash bytes and left
+4,302,799 bytes of app-image space. Its 2,251,272-byte application image has
+SHA-256
+`05131eac29164be46d1bcceeb4f03455f5ea8d61c8d4f529b77d8adfe25fdb6a`;
+the 2,316,808-byte merged image has SHA-256
+`c4c1fc639b6d0087c795a81aca79c07eba56dc4e071eb374dc70da0bec81815e`.
+The focused daily-NTP policy suites passed all 42 cases after the latch was
+added, and the Full capability manifest verified all ten expected features.
+The reduced RAK3401 LoRa-OTA Repeater also compiled with the verified WisBlock
+I2C aliases. At that source state its link used 471,344/815,104 flash bytes; the
+reduced external-sensor contract retains INA219/INA226/INA260/INA3221 as the
+voltage/current entries in the optional environmental-telemetry table. These
+are not the image's only I2C consumers. The image separately retains its
+SSD1306 display, auto-discovered I2C RTCs,
+RAK12500 I2C GPS, and RAK12501/L76K UART GPS paths. The configured INA3221 and
+RAK12500 both default to I2C address `0x42`; those two devices cannot coexist
+at those addresses. The supported combined arrangement leaves RAK12500 at
+`0x42`, straps INA3221 A0 to SCL for `0x43`, and uses a build with
+`-DTELEM_INA3221_ADDRESS=0x43`.
+The nRF52840 die-temperature and ADC battery-voltage paths are also retained.
+The 472,227-byte DFU ZIP SHA-256 is
+`fdd369005a61635756efa9e38b217376a952f334b0864f927a46799b65264fa9`,
+and the matching 943,104-byte UF2 SHA-256 is
+`2155632fde730e0ae2e4a72ce64f89910861f6c5105a4bd7ac96aaa538a33dca`.
+These VM results do not replace the remaining exact-final hardware test with
+UDP/123 blocked.
+
+The later serialized RAK3401 contract build, after the non-blocking nRF52 USB
+logging change, used 471,576 bytes before its 56-byte EndF trailer. Its EndF
+body hash is `026749684bf6ee8f`, target ID is `2FA509C1`, hardware ID is
+`RAK_3401`, and version word is `0x01110105`. The 472,435-byte DFU ZIP has
+SHA-256
+`cf0880b26f2d6bd8157f8af76a6718fe1f998de8ea7df8cd952e734448201fb9`;
+the 943,616-byte UF2 has SHA-256
+`f893a8cebf309af12a4a6f5483acb28f94158e18632f660e011658c846e8ca7f`.
+The linked ELF contains GPS, SSD1306, RTC, and the four INA families, while an
+exact symbol audit finds none of AHT10/20, BME280, BMP280, SHTC3, SHT4X,
+LPS22HB, MLX90614, VL53L0X, BME680/BSEC, BMP085, or RAK12035. The complete
+native matrix passed 1,112/1,112 cases at that intermediate source state. These
+are VM gates only; flash the exact artifacts and repeat the physical peripheral
+and blocked-clock checks before marking any later source state
+hardware-qualified.
+
+Subsequent source hardening changed RAK peripheral discovery: the shared 3V3_S
+rail remains enabled, UART GPS requires a complete checksum-valid NMEA sentence,
+an exact INA3221 identity blocks a conflicting u-blox probe, and a successful
+I2C GPS claim suppresses a sensor only on the same bus and address. The hashes
+and sizes above describe their recorded intermediate artifacts; they are not
+current-HEAD evidence and must not be reused for the hardened source.
+
+The immutable font endpoint was re-probed only after the VM reported
+`NTPSynchronized=yes`. GitHub returned HTTP 206 for bytes `0-65535`, an exact
+65,536-byte match to the checked-in font (range SHA-256
+`8b0dd308eb01469d1f9e732fba260720a80cc86f6cb5385c28e8a16c76dcfbf3`),
+with total length 1,302,608 and strong ETag
+`"39ff0cfbe37e36905507697d042869c05374c3202dd96d5a9f98812153f324c1"`.
+This validates the current strict Range assumptions against the live service;
+the exact-final Indicator still needs the negative hardware run proving that
+blocked UDP/123 prevents every TLS request and RP2040 staging write.
+
+Mercerwood subsequently recovered without a target write. Both its Tailscale
+and LAN addresses again accepted SSH, Bluetooth was idle, and the exact T1000-E
+stable serial `34A9141999729D5D` was present as application USB `239a:8029`.
+The RAK3401 simultaneously uses the same VID/PID, which confirms that every
+continuation must resolve the stable serial/by-id identity rather than VID/PID.
+The T1000-E remained in the exact bonded Full Companion application; no stale
+DFU, scanner, `meshcli`, or HCI-capture process was running.
+
+The post-reboot seven-TTY inventory was also resolved without assuming that
+one board owned adjacent device names. `ttyUSB0` is the SenseCAP Indicator's
+ESP32-S3 CH340 UART and `ttyACM5` is its RP2040; their sibling physical USB
+paths and the Indicator's live TCP board reply agree. The separately attached
+`ttyACM4` is the MeshTower V2 native `HT-n5262` endpoint and returned the
+qualified MeshTower build over its own console. The remaining ACM endpoints
+independently identify the RAK3401, XIAO nRF52840, Heltec V4, and T1000-E.
+
+ModemManager had restarted after the Pi reboot and could probe otherwise idle
+radio consoles. Six serial-bearing boards now use stable-serial-only
+`ID_MM_DEVICE_IGNORE` rules. Because the Indicator CH340 has no stable serial,
+its separate rule requires the exact current physical port chain *and* exact
+`1a86:7523` identity; there is no broad CH340, product, or VID/PID-only rule.
+A controlled ModemManager restart reported zero modems, displaced no TTY owner,
+caused no USB disconnect/re-enumeration, and left all seven endpoints ignored.
+The same mctomqtt process retained sole ownership of the RAK, while a subsequent
+Indicator TCP query still returned the expected board and build. Reversible
+root-only backups were retained on the Pi before each rule update.
+
+## Run-wide gates
+
+- [ ] Record commit, version string, role/profile, artifact filename, size, and
+ SHA-256 before flashing.
+- [ ] Archive the exact ELF and symbol/objdump map beside each qualification
+ firmware. A rebuilt ELF is acceptable for diagnosis only after its
+ firmware binary matches byte-for-byte; compiler `__DATE__`/`__TIME__`
+ strings can otherwise change both bytes and the EndF body hash.
+- [ ] Bind every operation to a stable USB serial number or physical USB path;
+ never rely on a changing `ttyACM`/`ttyUSB` number.
+- [ ] After erasing/reflashing a BLE board that reuses its Bluetooth address,
+ remove any stale host bond and pair again with the board's current PIN
+ before diagnosing service-discovery disconnects. On Linux, verify the
+ BlueZ agent actually accepted the passkey; a scanner seeing the device is
+ not proof that Companion GATT is usable.
+- [ ] Do not use BlueZ's advertisement `service_uuids` as a same-address warm
+ handoff identity gate. After application/bootloader transitions BlueZ can
+ merge the bootloader's cached GATT UUIDs into the live application's
+ advertisement. Gate the application advertisement by exact address and
+ name, then require the live connected BLEDfu service, control/revision
+ characteristics, retained bond/encryption, notification subscription,
+ and observed target disconnect before accepting the handoff.
+- [ ] Do not treat `meshcli -P` as an infallible pairing probe. Immediately
+ after one successful post-DFU session, a second invocation with the exact
+ paired XIAO address hit the meshcore-cli `NoneType ... pair/disconnect`
+ path before it created a Bleak client. BlueZ still reported the exact
+ bond and the same command without `-P` connected and synchronized the
+ clock successfully. Capture this as a host-tool failure, then verify the
+ device independently before blaming firmware.
+- [ ] After a successful `BleakClient.connect()`, start a fresh disconnect
+ generation before beginning DFU. BlueZ can fail one internal LE attempt,
+ retry successfully, and still deliver the failed attempt's callback. A
+ stale event caused the host to stop notifications and issue a local HCI
+ disconnect after a valid image CRC but before ACTIVATE; the target and
+ bootloader had not dropped the link. Preserve an HCI trace through
+ activation so local-host and remote-target disconnects remain
+ distinguishable.
+- [ ] Before a BLE pairing/throughput test on a Wi-Fi-managed Pi, record both
+ management addresses and a same-LAN recovery vantage point. The XIAO
+ pairing run completed, but Mercerwood then stopped answering on both its
+ Wi-Fi and Tailscale addresses; BellevueBBS confirmed the old LAN address
+ was absent at ARP and no replacement LAN host exposed SSH. This is an
+ infrastructure outage until Pi logs prove a cause, not a firmware or BLE
+ failure. The T1000-E pairing run reproduced the management outage: its
+ exact application paired successfully and exposed the complete DFU
+ service, then Mercerwood stopped accepting SSH and ultimately stopped
+ answering Tailscale pings. No buttonless DFU write had been sent, and the
+ T1000 remained in its valid application. Capture Pi Wi-Fi/firmware logs
+ after recovery and avoid leaving discovery enabled between operations.
+- [ ] Confirm the selected board and role before every erase or flash.
+- [ ] Before every SWD write, read the target FICR device ID and match it to the
+ intended board's stable USB serial (and physical wiring record). A USB
+ product name or remembered cable position is not sufficient.
+- [ ] Confirm no other PlatformIO process is active; run only one PlatformIO
+ build, test, clean, or upload at a time.
+- [ ] Record the installed bootloader, SoftDevice, partition table, or flash
+ layout independently of the application banner where applicable.
+- [ ] Test both a software reboot and a real power removal/cold boot.
+- [ ] After an update, read back identity, version, radio tuple, node name, and
+ persistent settings instead of assuming they survived.
+- [ ] Save command output and elapsed milliseconds. Mark a test `FAIL`, not
+ `PASS`, if the transport reports an I/O error even when firmware later
+ boots.
+- [ ] Before calling a node hung, validate that the probe uses that profile's
+ command terminator, check a second transport, and capture PC/LR/watchdog
+ state over identity-gated SWD before reset. Resume once and retest before
+ using reset as recovery so the diagnostic state is not destroyed first.
+- [ ] Treat a remote login acknowledgement and the following command reply as
+ separate LoRa packets. A lost login acknowledgement is not proof of
+ failed authentication when a command-matched private reply from the
+ selected contact key is present; an explicit negative login result still
+ fails closed.
+- [ ] Restore the intended USA radio preset and normal-radio mode after tests.
+- [ ] Compare both semantic version and exact EndF/body hash. A transition
+ between two commits carrying the same version string must stop at the
+ normal reinstall guard unless the run plan explicitly records and passes
+ `--allow-non-upgrade`; a different commit name alone is not authorization.
+- [ ] Do not allow a non-interactive transmission-failure prompt to continue
+ retry cycles forever. Optional capability probes must fail/fall back after
+ a bounded interval, while mutations and cleanup retain their stronger
+ bounded-lease recovery rules.
+- [ ] Before the first remote command after any controller/source reboot, read
+ its RTC and compare it with a trusted host clock. Advance a clock which is
+ behind and prove readback; never move a radio clock backward. Fail closed
+ when it is more than 10 minutes ahead. During the RAK rehearsal the V4
+ rebooted to `2026-03-01` while the RAK remained at `2026-08-30`; the RAK
+ received the direct packets but correctly rejected their old timestamps
+ as replays. Setting the V4 to host epoch immediately restored login and
+ the exact public-key reply at SNR -1.5 dB.
+- [ ] Before every external HTTPS download, require a fresh SNTP response for
+ that bounded operation, a plausible signed wall clock, connected Wi-Fi,
+ and a non-expired proof immediately before opening TLS. Re-check the proof
+ before every resumed/Range TLS connection. A retained plausible RTC or
+ mesh time is not a substitute for observing NTP, and failure must occur
+ before any HTTP request body or flash writer begins.
+- [ ] Treat an unacknowledged immediate `tempradio` mutation as potentially
+ active, not merely lost. On the tested repeater, scheduled-radio expiry
+ deliberately waits for `hasOutbound()` to clear; a CLI reply stranded on
+ the temporary tuple can therefore hold the node there beyond its nominal
+ lease. Prefer one fixed absolute `tempradioat` window per node, so duplicate
+ delivery cannot extend it, and prove the return on both tuples before a
+ transfer. If recovering an older immediate handoff, join the exact
+ temporary tuple to drain the reply before issuing `normalradio`.
+- [ ] Do not diagnose a co-located high-power LoRa bench failure as weak RF
+ without an overdrive check. On the RAK3401/V4 bench, the RAK at 22 dBm
+ produced only 3/6 status replies around -1.5 dB SNR, while a reversible
+ 0 dBm setting produced 6/6 replies in 5--8 seconds at +12 to +12.5 dB
+ SNR. Read the original power, lower it only for the near-field test, and
+ require exact restoration afterward.
+
+### Compute-placement gate
+
+- [ ] Run firmware builds, PlatformIO, mOTA/delta generation, compression,
+ full-image hashing sweeps, symbol generation, and other sustained CPU or
+ memory work on the VM. Do not offload these jobs to a Pi merely because
+ the hardware and source artifacts are attached there.
+- [ ] Use the Mercerwood Pi as the identity-gated hardware gateway, SWD/USB/BLE
+ test host, and LoRa/Wi-Fi seeder. Copy only the completed artifact plus
+ its manifest/hash to the Pi, then verify the copied SHA-256 before use.
+- [ ] Run CPU work on a Pi only when Pi performance or deployment behavior is
+ itself the test subject, and label that exception in the run log.
+- [ ] Do not assume a non-interactive SSH session has the same `PATH` as an
+ interactive shell. Resolve pipx-installed tools such as esptool and
+ detools to their explicit venv path, record the version, and fail before
+ erase if the pinned executable is unavailable.
+- [ ] Treat short serial/BLE/SWD control scripts on the Pi as hardware I/O, not
+ as permission to generate artifacts there. Use the explicit Python from
+ the relevant pipx environment when a probe needs one of its dependencies;
+ the Pi system Python did not contain `pyserial` during the RAK run.
+
+### LoRa-controlled host recovery gate
+
+- [x] The privileged recovery boundary is a root-owned, group-restricted
+ AF_UNIX broker with a fixed command grammar and fixed `systemctl` argv.
+ The unprivileged MQTT endpoint cannot gain privileges under
+ `NoNewPrivileges`, cannot select an executable or unit, and commits an
+ action only after its signed challenge/reply has been published and
+ confirmed. The installed broker passed its cold activation, peer
+ credential, socket owner/mode, injection, duplicate, timeout, durable
+ reservation, and ambiguous-outcome checks.
+- [x] A signed `host network restart` was accepted exactly once in 4.088
+ seconds. The broker retained one `network-restart` record at sequence 1,
+ and the fixed unit journal had exactly one start/finish pair. Independent
+ management probing measured 7.184 seconds from last successful probe to
+ first recovered success and 6.314 seconds from first failure to recovery.
+ Pi-local loopback MQTT delivered 107/107 samples; mosquitto, mctomqtt,
+ and the endpoint retained their exact PIDs, and mctomqtt remained the sole
+ owner of the RAK USB port. One read-only LoRa status reply was lost at the
+ temporary 0 dBm bench setting, then authenticated status returned
+ `scheduled`; no mutation was replayed. One monitor parsed two
+ `systemctl --value` columns in reverse order, so its boolean labels are
+ discarded; its PID/MQTT trace and the independent postchecks remain
+ valid.
+- [x] A signed `host reboot` was accepted exactly once in 3.312 seconds and
+ armed the fixed 10-second timer. A changed boot ID and one observed
+ outage prove one reboot; management recovery took 35.763 seconds from
+ the last pre-outage success and 31.276 seconds from the first failed
+ probe. Postboot signed LoRa uptime replied in 3.651 seconds. All seven
+ exact USB identities returned, only the RAK port was owned, the deployed
+ configuration hashes were unchanged, and NetworkManager, Tailscale,
+ mosquitto, mctomqtt, the endpoint, and both privileged sockets were
+ active. Broker action state is intentionally under `/run` and persistent
+ journaling was unavailable, so exactly-once evidence is the accepted
+ reply, one boot-ID transition, one outage, an inactive timer, and no
+ second outage; do not claim a durable postboot operation record.
+- [x] Reboot auto-started the still-enabled ModemManager service, but all seven
+ exact devices retained `ID_MM_DEVICE_IGNORE=1`, zero modems appeared,
+ and no TTY was probed or claimed. The service was returned to its
+ pretest inactive state without disabling or masking it. The RAK then
+ passed exact identity/body/radio gates; an explicit radio-busy rejection
+ left TX at 0 dBm, one bounded idempotent retry after idle returned OK,
+ and authenticated readback proved exact restoration to 22 dBm. V4
+ identity, power-saving, RXPS, TempRadio, and seeder state also matched
+ their pretest values.
+
+The sanitized host-recovery evidence bundle is under
+`out/host-actions-live-20260831/`. Its `SHA256SUMS` manifest has SHA-256
+`5acff71ed98f483bb769e8c99c577bf358d0491355ac72855ee7093cd658bf21`,
+and `sha256sum -c` verifies every deployed-hash, pre/post-state, radio, OTA,
+network, and reboot record in that manifest.
+
+### Mercerwood Pi USB power-cycle gate
+
+- [ ] Confirm the Pi is reachable through Wi-Fi/Tailscale and that `eth0` is not
+ the active management path before cycling its downstream USB tree.
+- [ ] Stop all flashes/transfers, run `sync`, and release SWD GPIO 8 and GPIO 11
+ to inputs with no pulls before removing USB power. This prevents an SWD
+ signal from partially back-powering an otherwise unpowered nRF52.
+- [ ] Never issue a standalone `uhubctl ... -a off` to Pi root hub location `1`.
+ Once its only port is off, the hub disappears from `uhubctl` discovery and
+ a separate `-a on` command fails with `No compatible devices detected at
+ location 1!`. This has interrupted two hardware runs.
+- [ ] A downstream hub may be cycled only after resolving the board's stable
+ USB serial to an exact sysfs path and proving that hub advertises
+ per-port power switching. Record the hub location and port, cycle only
+ that port, and gate the re-enumerated VID/PID/serial before continuing.
+ The T1000-E qualification safely used child hub `1-1.2`, port 3; this is
+ not permission to cycle the ganged root hub at location `1`.
+- [ ] Do not use `uhubctl` `off`, `on`, **or** atomic `cycle` on Mercerwood root
+ hub location `1`. Physical testing showed that even one atomic `cycle`
+ powers the ganged root hub down and then cannot rediscover it to restore
+ power. Use a planned Wi-Fi-issued `sudo reboot`, which is the verified
+ recovery, or arrange a true upstream VBUS removal when that distinction
+ is part of the test.
+- [ ] Treat root hub `1` as a ganged operation: every attached test board and USB
+ Ethernet adapter loses power. After recovery, enumerate every stable
+ `/dev/serial/by-id` identity again before resuming any board-specific test.
+- [ ] On this heavily populated root hub, inspect kernel logs for real transfer
+ faults before and after a flash, and stop ModemManager while it could open
+ a test TTY. A CH340/ESP32-S3 write which lost the chip at 460800 baud
+ completed and verified at 115200 after a planned Pi reboot; start at the
+ board-qualified conservative rate rather than treating a fast partial
+ transfer as firmware failure.
+- [ ] Under non-interactive SSH, do not let `udisksctl` fall through to an
+ interactive polkit password prompt. After resolving the exact UF2 block
+ device by board serial, either use an already-mounted volume or an
+ explicit `sudo -n mount` at a board-specific mount point; re-check
+ `INFO_UF2.TXT` model/version before copying.
+
+## Seeed XIAO nRF52840
+
+- [ ] Stable USB identity distinguishes application (`2886:8044`) from UF2
+ bootloader (`2886:0044`).
+- [ ] Do not repeat the known-failing full-application UF2 copy under the
+ installed initial 2.4.4 candidate. The identity-gated 897,024-byte copy
+ returned exit 0, then `sync -f` failed with `EIO` in 850 ms; the kernel
+ logged an offline device, lost queued writes, and FAT errors before USB
+ disappeared. Recover through a separately hash-gated BLE/serial path and
+ qualify a corrected bootloader candidate before retrying UF2.
+- [ ] SWD readback exactly matches the intended bootloader-region SHA-256.
+- [ ] Actual SoftDevice FWID and runtime application base match the artifact
+ (`S140 7.3.0`: FWID `0x0123`, application base `0x27000`).
+- [ ] Exercise and independently verify the exact-board combined
+ bootloader+SoftDevice recovery path before relying on it in the field.
+ A wrong-layout nRF52840 application can overwrite part of S140 while the
+ UF2 bootloader still accepts files and the application still reads flash;
+ internal writes or `InternalFS.format()` may then hang or reboot. An
+ application-only UF2 and a filesystem erase cannot repair that state.
+ The known XIAO incident in upstream issue #3284 recovered only after the
+ matching Sense/non-Sense OTAFIX bootloader+S140 ZIP was installed. Keep
+ board identity, SoftDevice FWID, application base, and package contents
+ as four separate pre-write gates.
+- [ ] Invalid settings enter persistent UF2 recovery after a cold boot.
+- [ ] UF2 application copy returns success; `sync -f` returns success; the host
+ has no offline-sector, lost-write, or FAT I/O errors.
+- [ ] Application enumerates after update and reports the expected board,
+ version, role, and radio tuple.
+- [ ] Legacy serial DFU crosses all application pages, emits the exact
+ `Device programmed.` success marker, and rejects false-success output.
+ Two application serial-DFU attempts on this board timed out waiting for
+ the start ACK while `adafruit-nrfutil` still returned exit 0, so exit
+ status alone is explicitly insufficient.
+- [ ] Legacy BLE DFU verifies each cumulative PRN receipt exactly and fails on
+ a missing, short, regressive, or overreported offset; it may adapt
+ only among the packet-size-specific safe receipt windows after timely
+ exact receipts and must step back down on slow exact receipts. Final
+ application/SoftDevice/bootloader CRC and a peer disconnect are mandatory
+ before declaring success.
+- [ ] Every upward PRN move is a bounded adjacent-level probe. A failed probe
+ or a later real demotion blocks further increases for that transfer, while
+ additional slow exact receipts may still negotiate down one level at a
+ time. This prevents `4 -> 8 -> 4 -> 8` churn without disabling a safe
+ initial probe or a `32 -> 16 -> 8` fallback.
+- [x] The exact Full Companion ZIP
+ `Xiao_nrf52_companion_radio_full-v1.17.1.5-halo-keymind-cascade-3caf9dcf.zip`
+ (SHA-256 `9a916c1a3ea83d8f6dd704465fe9c044a363478f852a4369cf6b13424a7a9430`)
+ installed over BLE in 237,297 ms at about 2,110 B/s using ATT MTU 247,
+ 244-byte writes, PRN 4, and exact cumulative receipts. Validate and the
+ target-initiated disconnect passed. USB then reported the exact expected
+ version/hash, USA radio tuple, key, RXPS policy, and reversible
+ Terminal/Binary mode; the new application advertised NUS as
+ `MeshCore-4610BBD2`.
+- [x] The post-update application was paired through BlueZ with the expected
+ PIN. Companion GATT then returned the same public key, USA radio tuple,
+ 22 dBm limit, non-repeater role, board, and build over BLE. Its RTC was
+ 18,038 seconds behind the trusted host; `clock sync` advanced it and the
+ immediate readback was within one second of the host interval. No
+ backward clock write was performed.
+- [x] A second exact same-image update exercised application buttonless DFU and
+ retained-peer bootloader entry. The bootloader deliberately reused the
+ bonded application address `E5:C3:A8:B0:60:66` rather than the cold-entry
+ address ending in `:67`; exact name, Legacy DFU service, and DIS model
+ gates still passed. The first adaptive threshold remained at PRN 4 and
+ completed all 448,416 bytes at about 877 B/s in 527,832 ms; validate,
+ target disconnect, USB identity, application key, role, and USA radio
+ readback all passed. The application clock was then about 10 minutes
+ behind because its RTC did not advance during the bootloader transfer;
+ it was advanced to the host clock and proved by immediate readback.
+- [x] A third same-image A/B run used bounded neutral-window exploration. At
+ 244-byte writes, two exact neutral PRN-4 receipts triggered one adjacent
+ PRN-8 probe. PRN 8 measured about 845 B/s against an approximately
+ 879 B/s PRN-4 baseline, so the helper rolled back to PRN 4 after two
+ receipts and never re-probed. All 448,416 bytes completed at about
+ 870 B/s in 531,804 ms; CRC validation, target disconnect, USB return,
+ exact Companion key/role/radio readback, and post-DFU forward clock sync
+ passed. This proves up/down sender-window adaptation and no churn, while also showing
+ that PRN receipt overhead is not the bonded-path bottleneck on this run.
+- [x] A bootloader candidate built from exact ZIP SHA-256
+ `9c70200fc15a315a50ec68d2a905b5bcf2fc07a796a21fbd439535ec19a608be`
+ disabled inherited slave latency locally during DFU DATA. The controller
+ trace still reported the peer-negotiated 30 ms interval and latency 4,
+ with no connection-parameter update after START; the change is therefore
+ a local event-attendance override, not a GAP speed renegotiation. A cold
+ address-generation control completed 448,416 bytes at 2,231 B/s and
+ passed validation/activation in 226,103 ms.
+- [x] Two subsequent bonded application-to-bootloader runs completed the exact
+ same 448,416-byte image at 3,524 and 3,534 B/s. Their complete elapsed
+ times, including exact application advertisement proof, buttonless
+ handoff, bootloader scan, erase, DATA, CRC validation, activation, and
+ reboot, were 169,414 and 169,221 ms. Both targets initiated the final
+ disconnect; USB returned as stable serial `B35E71C1C3726CE7`; the public
+ key, 22 dBm setting, USA `910.525/62.5/SF7/CR5` tuple, board, and build
+ read back exactly. Compared with the prior roughly 877 B/s bonded run,
+ the DATA rate improved by about 4.0x without increasing the PRN window
+ beyond 8.
+- [x] An exact bonded B-A-B link-profile sequence used the same Full Companion
+ ZIP SHA-256 `9a916c1a3ea83d8f6dd704465fe9c044a363478f852a4369cf6b13424a7a9430`,
+ lab helper SHA-256 `4909c617de37b5666cc622b7190568f002280f55bf26a05843ea283f2be8f566`,
+ handoff helper SHA-256
+ `1bad570173079b623bbe2bbb9e5ba7c4f8ac379bf966bfd4382c5266fb9fd214`,
+ stable USB serial, bonded address, and 448,416-byte application throughout.
+ First B, `xiao-ble-event-length-prestart-20260830-072641-W7COxm`, used one
+ exact pre-START 15 ms/latency-0 update with `0/0` no-preference event-length
+ hints and completed DATA in 72.030 seconds at 6,225 B/s; DFU through target
+ disconnect was 102.792 seconds and the full workflow was 117,064 ms.
+ Control A, `xiao-ble-control-prestart-20260830-073149-yKf0lO`, requested no
+ update, retained the bootloader connection's 30 ms/latency-4 link, and
+ completed DATA in 134.131 seconds at 3,343 B/s; DFU was 165.137 seconds and
+ the full workflow was 182,624 ms. Final B,
+ `xiao-ble-exact15-prestart-20260830-073554-FuGPfH`, repeated exact 15 ms and
+ completed DATA in 71.970 seconds at 6,231 B/s; DFU was 102.799 seconds and
+ the full workflow was 117,179 ms. Both B rates were about 1.86x control and
+ differed by only 0.096%.
+- [x] The B-A-B protocol and link gates passed independently. Both B runs probed
+ PRN 4 -> 8 after an exact 0.135-second four-packet receipt, rolled back
+ once to PRN 4 after the exact 0.345-second eight-packet receipt, and never
+ re-probed. Control A likewise probed after 0.269 seconds and rolled back
+ after 0.600 seconds. Each B bootloader connection had exactly one matching
+ HCI Update Complete before START; control A had no bootloader-connection
+ update. All three captures contained zero connection updates from START
+ through DATA, CRC validation, activation, and the target-initiated
+ disconnect. Each run ended `RESULT=PASS`, `RC=0`, `STAGE=complete`; final B
+ returned the exact Full Companion application as USB `2886:8044`, stable
+ serial `B35E71C1C3726CE7`, with current application name
+ `MeshCore-4610BBD2`. A fresh post-B binary Companion query independently
+ re-gated the unchanged public identity, name, Full Companion role, 22 dBm
+ power, and USA `910.525/62.5/SF7/CR5` radio tuple. A separate bonded BLE
+ application query then returned the same board, build, and Full Companion
+ role, so the USB return did not mask a broken Companion BLE path or stale
+ bootloader alias.
+- [x] The current host-controller ceiling is independently characterized. The
+ Pi Zero 2 W Broadcom UART controller reports HCI/LMP 4.2 but supports only
+ LE 1M; LE Read Maximum Data Length and Read Suggested Default Data Length
+ both return Unknown HCI Command. The final B capture contains 1,837 full
+ 244-byte firmware writes,
+ each split into ten 27-byte HCI ACL fragments; all 18,425 transmitted ACL
+ fragments received completed-packet accounting. The median full-write gap
+ was 29.947 ms at 15 ms versus 59.936 ms in control A. A BLE 5 controller
+ with working Data Length Extension is therefore the next host-side speed
+ experiment; this built-in controller cannot exercise DLE or LE 2M.
+- [x] The host helper now clears a disconnect callback left by an unsuccessful
+ internal BlueZ connection attempt only after `connect()` has returned a
+ live link, and labels PRN changes as adaptation rather than link
+ negotiation. All 81 strict host tests pass. The first physical run before
+ this fix still proved the 3,520 B/s DATA rate and returned a successful
+ validation response, but is correctly recorded as FAIL because the stale
+ host event prevented ACTIVATE.
+- [ ] Warm app-to-bootloader handoff is tested with the application watchdog
+ active; cold recovery is tested separately.
+- [ ] BLE Companion/recovery behavior and radio-reset recovery are exercised.
+- [x] The post-Pi-recovery application still enumerated as exact
+ `2886:8044` / `B35E71C1C3726CE7`, but one identity-gated, bounded USB
+ Binary Companion attempt received no protocol response. `meshcore-cli`
+ 1.6.0 printed both of its no-response error lines and nevertheless exited
+ zero; the supervisor treated the attempt as a failure and independently
+ proved that no process retained the serial port. The exact terminal-stop
+ token and an identity/path-gated USB bus reset also failed, which excludes
+ an ordinary startup Terminal-mode hold and host USB endpoint state. The
+ external tool defect remains in upstream `meshcore-cli` 1.6.3: its serial
+ creation failure returns normally, so callers must validate command output
+ instead of trusting exit zero. The LoRa-OTA controller has an exact
+ three-diagnostic regression and continues to fail closed.
+- [x] An exact hash-recorded Full Companion application DFU erased/replaced only
+ the application and completed in 36 seconds. The transferred ZIP was
+ 440,083 bytes with SHA-256
+ `9946b8417c389784ac92c20e4ce5cf0b6a0123a58136627c0689815637f96992`.
+ The same stable USB serial returned as interface `00`; USB and the existing
+ bonded BLE identity both reported `Seeed Xiao-nrf52`, the 31-Aug-2026
+ build, and Companion role. Preserved settings included a roughly six-month
+ stale clock, which Binary Companion `clock sync` advanced to one second of
+ the Pi without changing the role.
+- [x] The recovered application passed explicit Terminal start/stop, exact board
+ query, saved `usb.logging off`, source-only OTA status, and a real serial
+ `motatool serve` attach/detach with an empty folder (`COUNT -> 0`). Binary
+ Companion returned after both terminal and mOTA ownership. Enabling USB
+ logging and rebooting produced exact interfaces `00` and `02`; interface
+ `02` emitted 123 bytes of entirely printable log data while Binary
+ Companion stayed usable on `00`. Disabling and rebooting removed `02`,
+ restored the single-interface descriptor, and read back logging off.
+- [ ] A physical power-removal cold test is still needed to prove persistence of
+ the current application. It cannot retrospectively determine whether a
+ power cycle alone would have cleared the original stall. Answer that only
+ after reproducing the stall under a bounded stress loop, capturing state,
+ and power-cycling before any DFU. The application DFU proves recovery but
+ intentionally does not claim the underlying stall trigger is fixed.
+
+## Seeed Tracker T1000-E
+
+- [x] The cross-role `uf2reset` repair was hardware-qualified with Full
+ Companion ZIP SHA-256
+ `16e470c0d66bec14e1e9f2d4ebe04d83ffb30efa606465f885cbe9fc3d004a14`.
+ It was built from `f2c15225` with GCC 14.2.1, the USA Cascadia/Cascade
+ profile, and embedded version
+ `v1.17.1-uf2reset-f2c15225-f2c15225`. Serial DFU took 32.196 seconds and
+ stable application USB returned in 35.888 seconds. Terminal entry,
+ version, help, the exact no-argument command, application disconnect, and
+ stable `2886:0057` boot USB all passed in one 8.898-second script. The same
+ image was restored in 32.204 seconds and stable `239a:8029` USB returned
+ in 36.313 seconds; a final terminal check returned Binary mode cleanly.
+- [x] The fixed application advertises live as `MeshCore-09848C15` at
+ `D1:A0:86:CD:BB:C6`. The obsolete Pi bond for the earlier temporary
+ identity was removed, and the exact address was paired again with the
+ qualification PIN. Paired, bonded, and trusted state, an encrypted BLEDfu
+ revision read, and the Nordic UART service all passed. The pre-run old
+ application enumerated but produced neither a terminal response nor a
+ live advertisement, so this run does not claim continuity of that
+ temporary identity or of application-owned settings across a combined
+ SoftDevice/bootloader recovery install.
+- [x] Stable application identity `34A9141999729D5D` is recorded and is not
+ confused with the RAK3401 even though both currently enumerate with USB
+ product ID `239a:8029`. Application is `239a:8029`; the exercised
+ bootloader is `2886:0057`, with the same stable serial and exact
+ `T1000-E`/`T1KE_DFU` model/name gates.
+- [x] A post-Pi-recovery, identity-gated, read-only USB Companion query returned
+ the exact T1000-E model, non-repeater Full Companion role, 22 dBm maximum,
+ and `910.525/62.5/SF7/CR5`. Its clock readback was within the query's
+ few-second host interval. No clock, radio, role, key, or settings write
+ was issued.
+- [ ] Board, version, repeater role, bootloader status, and USA radio tuple are
+ correct after erase/install and after reboot. The live baseline and a
+ 2,184 ms software reboot passed: `3caf9dcf`, `Seeed Tracker T1000-E`, 22 dBm,
+ `910.525/62.5/SF7/CR5`, OTA target `7B071FA0`, bootloader
+ `0.11.0-OTAFIX2.4.3`, and full OTA base hash
+ `3E92B157128B6963`. A real power-removal boot is still required.
+- [ ] UF2 and serial DFU installation paths complete without host I/O errors.
+ On OTAFIX 2.4.3, an identity-gated root copy of the exact old UF2 returned
+ success in 112 ms, but the device disconnected before host writeback was
+ durable: `sync -f` failed with `EIO` after 17,695 ms and the kernel logged
+ lost asynchronous writes/FAT read errors. The old application happened to
+ boot and retained its settings, but this transport is a **FAIL**. Retest
+ with the corrected 2.4.4 candidate before qualifying T1000-E UF2.
+- [x] The installed `t1000e_repeater_lora_ota_no_external_sensors` profile does
+ not compile the Companion BLE transport and did not advertise during a
+ post-reboot BlueZ scan; that is expected for this repeater image. BLE
+ Companion qualification belongs to the separate T1000-E Companion build.
+- [x] The exact latency-disable bootloader candidate ZIP (SHA-256
+ `ebe9739ce40c2c7cc1f52076f9614194959fcf6df93d37666b69b7bd415fab68`)
+ installed through identity-gated serial DFU. The final output contained
+ exactly one terminal `Device programmed.` success line, the bootloader
+ returned as `2886:0057`, and its embedded manifest identifies
+ `T1KE_DFU`, S140 7.3/FWID `0x0123`, application base `0x27000`, and test
+ version `0x02040403`.
+- [ ] The later connection-policy-reapply combined ZIP (SHA-256
+ `c2de1fb2704154b4abc371b6b4827c32ad09c4d15f3f6d97d0f4182bb51fff58`)
+ was identity-gated, transferred, validated, activated, and returned as
+ exact `2886:0057` recovery USB. `INFO_UF2.TXT` reported
+ `0.11.0-OTAFIX2.4.3-3-gffb1580-dirty-test-version-0x02040403`, S140
+ 7.3.0, and the T1000-E board. The earlier and later candidates share the
+ same visible version string, however, so package sequencing and INFO_UF2
+ do not independently prove the installed bootloader bytes. Require an
+ identity-gated live bootloader-region readback or a uniquely versioned
+ rebuild before checking this item.
+- [x] The combined-image BLE transport itself passed: 193,688 bytes at
+ 3,331 B/s, target CRC validation, and target-initiated activation. The
+ package contains SoftDevice plus bootloader and `APP=0`, so remaining in
+ recovery USB was the correct postcondition. Its first supervisor record
+ is not accepted as run-level evidence: TERM inherited status zero and
+ wrote a false `PASS` at `STAGE=post_copy_usb`. A separate audit records
+ the transport result, and all later wrappers map INT/TERM to nonzero and
+ refuse `PASS` unless `STAGE=complete`.
+- [x] A separate exact application recovery after that combined transfer
+ completed 404,468 bytes at 2,095 B/s, validated, activated, and returned
+ stable application USB in 225,899 ms. The lower rate is the expected
+ cold/unbonded class, not the bonded warm baseline.
+- [x] A cold exact Full Companion application ZIP (SHA-256
+ `b3be000ab2391527a325cc868e2706dec291c167b6e6cd3495908addb8af6c65`)
+ completed all 404,468 DATA bytes over BLE at 1,572 B/s. The complete
+ workflow took 281,236 ms (about 1,438 B/s end to end, including setup,
+ validation, activation, and reboot). ATT MTU reached 247, but BlueZ's
+ independently cached maximum write size was still 20, so the
+ identity-gated helper correctly used 20-byte packets, adapted PRN
+ 8 -> 16 -> 32, validated CRC, activated, observed the target-initiated
+ disconnect, and gated the exact USB application return.
+- [x] The Full Companion application advertised as `MeshCore-F9AD7082` at the
+ expected address generation, paired with the qualification PIN, and
+ exposed its bonded, MITM-protected Legacy DFU service after connection.
+ That DFU service is intentionally absent from the advertisement, so an
+ advertisement-only check is insufficient. A 1200-baud USB touch instead
+ selects serial-only DFU and correctly produces no BLE advertisement;
+ use the bonded application control characteristic for a warm BLE handoff.
+- [x] Exact same-address bonded warm buttonless DFU passed. The application
+ advertisement, Paired/Bonded state, connected BLEDfu service/control/
+ revision layout, encrypted notification/write gate, observed target
+ disconnect, `T1KE_DFU` advertisement, DIS model `T1000-E`, ATT MTU 247,
+ and 244-byte write capability were all checked. All 404,468 bytes were
+ confirmed in 121.081 seconds at 3,340 B/s; DFU through target disconnect
+ took 144.076 seconds and the full supervisor took 176.958 seconds. CRC,
+ activation, and exact `239a:8029`/`34A9141999729D5D` USB return passed.
+- [x] Two accepted in-DATA 30 ms -> 15 ms -> 30 ms controller experiments
+ completed without corrupting either image, but were decisive negative
+ performance results. The first finished DATA in 425.398 seconds at
+ 951 B/s; the later candidate finished DATA in 449.063 seconds at 901 B/s.
+ HCI capture found the cause: `hcitool lecup` silently encoded both
+ connection-event-length hints as `0x0001` (0.625 ms). The Pi's Broadcom
+ 4.2 controller consequently scheduled about one ACL fragment per event:
+ roughly 64 fragments/s at 15 ms and 32 fragments/s after the 30 ms
+ "rollback", versus roughly 120--144 fragments/s on the untouched link.
+ The later pre-START controls prove that 15 ms itself is not slow; the
+ restrictive event-length hint and in-DATA update/rollback were unsafe.
+- [x] Two exact 15 ms/latency-0, zero-event-length lab controls passed:
+ `t1000-ble-event-length-prestart-20260830-071549-QOeVuw` completed all
+ 404,468 DATA bytes in 64.905 seconds at 6,232 B/s (95.635 seconds through
+ target disconnect; 112,639 ms full workflow), and
+ `t1000-ble-event-length-prestart-20260830-071825-VE9Re3` completed DATA in
+ 62.070 seconds at 6,516 B/s (92.794 seconds through target disconnect;
+ 106,807 ms full workflow). Both used exact `0/0` no-preference minimum and
+ maximum connection-event lengths. On each bootloader connection the HCI
+ trace contains exactly one successful update before START and none from
+ START through DATA, validation, activation, and the target disconnect;
+ Update Complete preceded START by 7.535 and 7.536 seconds. Completed-packet
+ clusters followed the 15 ms connection anchors, while the median full-size
+ DFU write gap was 29.997 ms in both traces. Exact package hash, CRC,
+ activation, and stable application USB return passed twice.
+- [x] `t1000-ble-event-length-prestart-20260830-071358-ogzmRy` is the bounded
+ negative control. HCI accepted exact 15 ms/latency 0 with both event-length
+ hints set to 10 ms, then reported `Connection Timeout (0x08)` 6.810 seconds
+ after Update Complete during the 20-second pre-START pause. The run failed
+ closed at `STAGE=pre_start_update` in 44,227 ms; START and application DATA
+ were never sent. A 10 ms event-length hint is therefore rejected for this
+ adapter/target pair.
+- [ ] Production BLE DFU keeps the controller-negotiated link and issues no raw
+ host connection update unless that exact adapter/target profile has been
+ separately graduated. The opt-in lab path may issue exactly one request on
+ the bootloader connection before START while sent/confirmed are zero: exact
+ interval and latency, `0/0` no-preference event-length hints, bounded pause,
+ and a matching HCI Update Complete are mandatory. Failure or mismatch
+ disconnects before START. Never update or roll back the link during DATA;
+ only the protocol PRN/window may adapt there. Compare complete repeated
+ control/candidate DFUs offline before graduating a production profile.
+- [ ] Repeat the cold transfer with the host's bounded BlueZ readiness wait.
+ When ATT MTU is greater than 23 while the cached write capability remains
+ 20, it polls the same connected DFU characteristic for at most three
+ seconds, aborts on disconnect, disappearance, or characteristic-handle
+ change, and otherwise falls back safely to 20. The four readiness unit
+ cases and all 81 strict helper tests pass; a physical 20 -> 244-byte
+ promotion is still required.
+- [ ] Restore the protected
+ `t1000e_repeater_lora_ota_no_external_sensors` application (target
+ `0x7B071FA0`, exact ZIP SHA-256
+ `9b9be2bee9ebb6644d22806d404fe02c415d037707713d206fa43a8c689cf632`)
+ through identity-gated application DFU while retaining the candidate
+ bootloader. Do not use the generic repeater image or the unqualified UF2
+ path. Verify the protected private/public identity without printing the
+ private key, plus exact name, latitude/longitude, build/body/OTA target,
+ USA radio, 22 dBm, GPS off, power saving on, RX gain on, RXPS on,
+ watchdog on, telemetry access `all`, no TempRadio/download, and a valid
+ admin login.
+- [ ] LoRa radio reset/recovery is forced and normal receive/transmit resumes.
+ Normal post-reboot bidirectional LoRa is already proven: the V4 learned a
+ zero-hop path, received the exact message-specific ACK in 558 ms, and
+ received the T1000-E's exact `3caf9dcf` version reply. A deliberately
+ forced LR1110 recovery event is still required.
+- [x] Direct V4 LoRa OTA receive, apply, reboot, and post-update verification
+ passed from exact `bf092b24` body `9443FB5E6F23FC1B` to exact `3caf9dcf`
+ body `3E92B157128B6963` on OTAFIX 2.4.3. The mandatory fixed three-minute
+ rehearsal passed at `909.950/250/SF5/CR5`, including natural expiry and
+ return to `910.525/62.5/SF7/CR5`. The 2,036-byte/two-block delta reached
+ ready in six seconds, the new body replied at the 50-second probe, and the
+ complete safety-wrapped runner took 519 seconds. V4 source RXPS and both
+ normal radio tuples were restored. The co-located T1000 was temporarily
+ reduced to 0 dBm and restored to 22 dBm using bounded retries across its
+ RXPS busy windows.
+- [x] Independent post-LoRa-OTA USB verification matched the protected pre-run
+ private key, public key, name, latitude, and longitude without printing
+ private material. GPS remained off; device power saving, system watchdog,
+ RX gain, and RXPS remained on; telemetry access remained `all`; no
+ TempRadio lease or download remained. `blrc:B8` is the expected OTAFIX
+ application-apply success diagnostic. A subsequent software reboot reset
+ uptime and preserved the exact build/body, name, radio, 22 dBm, GPS,
+ power-saving, RXPS, watchdog, and no-TempRadio state. The CDC symlink did
+ not disappear during this warm reset, so uptime/readback—not USB removal—
+ is the required reboot proof on this board.
+- [x] GPS was initially off, enabled successfully under device power saving,
+ reported active/no-fix/zero-satellites with its duty-cycle deadline, and
+ was restored to off. Device power saving reported on, RXPS reported
+ `on,18205,20423`, GPS advert policy `prefs`, and telemetry access `all`.
+
+## RAK3401
+
+- [x] Stable USB identity and SWD FICR identity are independently matched.
+- [x] Source-level WisBlock peripheral pins are complete and non-conflicting:
+ I2C SDA/SCL are P0.13/P0.14 through both `PIN_WIRE_*` and
+ `PIN_BOARD_*`; GPS-module RX crosses to MCU UART TX P0.16, GPS-module TX
+ crosses to MCU UART RX P0.15, and PPS is P0.17. A static contract test
+ prevents these aliases from silently disappearing or being uncrossed.
+- [x] Ordinary/full-sensor RAK3401 profiles use the source-built Adafruit BME680
+ provider, not Bosch's precompiled BSEC archive, so upstream issue #3292's
+ soft-float/hard-float link mismatch is not reachable there. The reduced
+ LoRa-OTA profile deliberately omits BME680/BSEC and the other declared
+ optional environmental/ranging drivers. A static contract test requires
+ the hard-float path fix and BSEC dependency if BSEC is enabled in this
+ board or an inherited full-sensor recipe later.
+- [ ] With the RAK13302 radio installed, detect a RAK12501/L76K UART GPS from a
+ complete checksum-valid NMEA sentence in sensor slot A. Confirm random
+ UART bytes, an incomplete sentence, and a bad checksum do not claim GPS.
+- [ ] Detect RAK12500 I2C GPS alone at `0x42`; exercise GPS power saving and
+ confirm the shared 3V3_S rail, OLED, RTC, and other slots remain powered.
+- [ ] Detect INA3221 alone at the firmware-configured `0x42` and prove the
+ discovery path does not send u-blox configuration traffic to it.
+- [ ] Exercise RAK12500 at `0x42` and INA3221 at `0x43` together after strapping
+ INA3221 A0 to SCL and building with
+ `-DTELEM_INA3221_ADDRESS=0x43`; verify both retain independent telemetry.
+- [ ] With a representative omitted environmental sensor attached, confirm the
+ reduced image leaves the generic I2C bus healthy but does not advertise a
+ driver that was intentionally removed.
+- [x] Local USB terminal probes are terminated with carriage return (`\r`).
+ This CLI echoes an LF-only command without executing it; an echoed
+ `ver` with no reply is therefore a bad probe, not evidence of a hung
+ application. Confirm any suspected hang with a CR-terminated command,
+ LoRa status, and SWD state before resetting the board.
+- [x] Assert CDC DTR when probing the RAK terminal. With DTR deasserted, writes
+ reached the port but no CLI replies were returned; the same exact
+ stable-identity port returned the expected version immediately after DTR
+ was asserted. Keep 1200-baud bootloader touch separate from 115200-baud
+ application probes, and allow a full reply window so delayed responses
+ are not attributed to the following command.
+- [x] Installed S140 6.1.1 FWID (`0x00B6`) and runtime application base
+ (`0x26000`) are independently verified.
+- [x] Bootloader version and apply capabilities are independently reported.
+- [x] UF2 application copy returns success; `sync -f` returns success; no USB
+ reset/timeout or offline-sector errors occur; SWD readback matches every
+ UF2 payload byte. Current 2.4.4-preview.2 result is **PASS** in both
+ current→old and old→current directions.
+- [x] Board, repeater role, version, name, and USA radio tuple are verified
+ after serial recovery and again after a real cold boot.
+- [x] Six consecutive authenticated `ota self` attempts leave the application
+ healthy. In the qualification run, two login/reply pairs were lost, one
+ login succeeded while its command reply was lost, and the next three
+ commands replied in about 2.5 seconds each. A CR-terminated local `ver`
+ probe passed after every attempt; packet loss did not become a node hang.
+- [ ] An unchanged admin login does not rewrite the ACL flash file, while an
+ actually changed/newly learned persistent route schedules exactly one
+ delayed save and survives reboot. Cover delayed flood-path replies,
+ already-unknown paths, changed permissions/secrets, new/evicted clients,
+ and the intentionally non-persistent guest case. Do not rely on an
+ unrelated login write to capture a later PATH response.
+- [ ] Location and private-key persistence are verified without exposing the
+ private key in logs.
+- [x] Serial recovery rejects nrfutil's false exit-0 failure, requires the exact
+ success marker, and produces a byte-exact SWD application readback.
+- [ ] UF2/serial recovery works with both the deployed older bootloader family
+ and the current backward-compatible application.
+- [ ] Bluetooth advertisement and connection work when enabled. The tested
+ repeater profile does not advertise BLE during normal operation.
+- [x] Direct LoRa OTA from the V4 passes with elapsed time, bounded retry
+ behavior, final hash/version verification, exact radio restoration, and
+ independent SWD readback. The qualified `bf092b24` base
+ `91CF4EC2882F23D6` reached `3caf9dcf`/`27A223DDFC3A8F47` using a
+ two-block, 2,132-byte delta: ready in six seconds, new body at the
+ 60-second reboot probe, and 514 seconds for the complete safety-wrapped
+ run. Log:
+ `/home/mikec/hwtest/runs/rak3401-0dbm-final-9KWjey61/run.log`.
+- [x] The current OTAFIX test version `0x02040403` independently passes a
+ 74-block direct LoRa update from exact `3caf9dcf` body
+ `27A223DDFC3A8F47` to HEAD `9fd580c8` body `744BC4443891ED10`.
+ READY took 122 seconds and the complete three-minute-rehearsal,
+ transfer, install, reboot, recovery, and restore wrapper took 589.487
+ seconds. Lost read-only preflight and postboot replies were reconciled by
+ bounded exact retries. USB confirmed the target/hardware/body and
+ bootloader success result, and authenticated LoRa readback confirmed the
+ final 22 dBm restoration. Sanitized log:
+ `/home/mikec/hwtest/runs/rak3401-02040403-head-vzWOXVxT/qualification-redacted.log`.
+- [ ] Add exact RF packet sent/confirmed counters to the device/host telemetry.
+ The qualified run records two payload blocks, all seeder reads, command
+ retries, and final success, but those are not equivalent to LoRa packet
+ counts and must not be presented as such.
+- [ ] Repeat at each supported bandwidth and record 0-hop, 1-hop, and 2-hop
+ estimates from measured airtime rather than extrapolating only by file size.
+
+## RAK4631 reduced profiles
+
+- [x] Serialized VM contracts distinguish the three application identities:
+ the plain and Serial2 reduced profiles retain the combined WisBlock GPS
+ provider and all four INA voltage/current drivers; the explicit legacy
+ Serial1 profile retains the INA drivers and omits GPS. This is build
+ evidence only, not physical peripheral qualification.
+- [ ] On the plain reduced profile, detect and read RAK12500 I2C GPS, a
+ RAK12501/L76K UART GPS in a separate run, the SSD1306 OLED, a supported
+ autodiscovered RTC, and representative INA telemetry. Verify GPS sleep and
+ repeated discovery never lower the shared WB_IO2/3V3_S rail.
+- [ ] With a UART RAK12501 installed and detected, confirm the Serial2 bridge
+ and GPS remain usable together. Select Serial1 and try to enable the
+ bridge with the GPS preference both on and off; verify each attempt is
+ refused before the bridge starts and does not change GPS transport,
+ acquisition, hold, or cached-fix state. The shared WB_IO2/3V3_S rail must
+ remain high and GPS must remain usable after each refusal.
+- [ ] With only an I2C RAK12500 installed, and again with neither GPS installed,
+ select Serial1 on the merged image and confirm bridge enable is refused
+ without changing saved UART/runtime state. Silence or an I2C-only detection
+ must not defeat the fail-closed RAK12501 reservation. Confirm Serial2 starts
+ and I2C GPS telemetry remains usable.
+- [ ] Exercise RAK12500 at `0x42` and INA3221 at `0x43` together with the
+ address-matched build. Separately place INA3221 alone at the configured
+ `0x42` and prove the identity guard prevents u-blox configuration writes.
+- [ ] With a logic analyzer, hold SDA low and then SCL low before sensor
+ discovery. Confirm the validated board pins receive at most nine
+ open-drain recovery clocks and a STOP only after SCL rises; the firmware
+ must skip discovery if either line remains low. Repeat on ProMicro's
+ remapped SDA 8/SCL 7 and verify the user-button pin is never driven.
+- [ ] Record the remaining core limitation: the pinned Adafruit nRF52 Wire
+ implementation has no transaction timeout. This preflight cannot protect
+ display/RTC calls made earlier in boot or interrupt a peripheral that
+ wedges after a transaction begins. Qualify those cases with a hard power
+ cut/watchdog fixture before claiming bus-hang recovery.
+- [ ] Confirm the explicit legacy Serial1 image exposes neither UART RAK12501
+ nor I2C RAK12500. Record this as a combined-provider build limitation, not
+ an assertion that RAK12500 electrically conflicts with the UART.
+
+## Heltec T096
+
+- [x] The cross-role `uf2reset` repair was hardware-qualified on the exact
+ no-footer Full Companion image. ZIP SHA-256 is
+ `0585bee1c0b278a68788f78af384bcbdb5633d57f9ea301fc14f1c80c4275489`;
+ embedded version is `v1.17.1-uf2reset-f2c15225-f2c15225`. Terminal entry
+ and help passed, the exact command disconnected application USB in
+ 1.031 seconds, and stable boot USB returned in 2.026 seconds. The final
+ Full image installed over serial in 38.900 seconds and stable application
+ USB returned in 41.672 seconds. Final primary terminal/Binary switching,
+ dedicated logging interface, version, help entry, and live
+ `MeshCore-8E229D0A` advertisement at `CF:A8:18:36:DE:8B` all passed.
+- [x] Stable USB serial `651F8E496197F882` distinguishes the application
+ (`239A:8029`) from the OTAFIX serial-DFU interface. Every flash followed
+ that identity across the host-assigned `COM9 -> COM6 -> COM9` transition;
+ the simultaneously connected MeshPocket on `COM20` was never selected.
+- [x] Baseline bootloader `0.11.0-OTAFIX2.4.3` was installed at this checkpoint. The restored Repeater
+ reports application body hash `3B6D1A729C087243`, target `98411AC6`, apply
+ ABI 3, codec mask `0x5`, and stage address `0xED000`. The application ZIP
+ is 708,216 bytes with SHA-256
+ `abb00560cc5618ae155bf10252e9261127416a7a91b5f6565837384f0dec2390`.
+- [x] A 48,229-byte in-place delta with SHA-256
+ `988bd2385e9c995d0ae04effb6bb5cd614227c7c764590d82bd1943da2f18e4d`
+ transferred over LoRa in 47/47 blocks from the MeshPocket seeder and
+ applied successfully. The first application boot reported bootloader
+ result `B8`; the running body hash matched the candidate exactly. No chat
+ message was sent for this transfer.
+- [x] The generic delayed AGC/watchdog arming fix passes warm-reboot hardware
+ checks on both the T096 and MeshPocket. The restored T096 retained AGC
+ interval 8 and, after 215 seconds, was in RX with 100 packets received,
+ 28 sent, zero core errors, and `err_flags=0`. The Pocket's quiet-channel
+ liveness recovery set only the expected radio-watchdog bit; temporarily
+ matching the USA channel then received 14 packets with zero receive
+ errors before its saved `869.618/62.5/SF8/CR5` profile was restored. At
+ 498 seconds the T096 had one CAD-busy timeout after 37 busy-channel
+ samples, then returned to RX with no queue, watchdog, or start-RX fault.
+- [x] Exact Full Companion ZIP SHA-256
+ `ee92856e5bfb7877b0d8bff6d6a5873d8037fc178ddb151202a9e6a6d9d59e73`
+ installed by serial DFU and passed the reversible dual-CDC test. Logging
+ off exposed only interface `00`; `set usb.logging on reboot` added
+ interface `02` as `COM21`. Opening `02` emitted both portable identity
+ lines, 980 captured bytes were entirely printable, and host input produced
+ no echo or command reply while Binary Companion on `00` returned the exact
+ model, key, and radio tuple.
+- [x] With interface `02` deliberately unread, 11/11 text-terminal probes stayed
+ between 212 and 231 ms for 44.4 seconds. A longer 100-second hold buffered
+ 8,636 entirely printable bytes while 60/60 hard-bounded Binary Companion
+ reconnects passed with no timeout; the final full identity query completed
+ in 739 ms. Neither USB interface disconnected. Logging off plus reboot
+ removed `02`, read back off on `00`, and the qualified Repeater was then
+ restored with its original name, public key, and USA radio tuple.
+- [x] The descriptor/reconnect follow-up built a 482,271-byte Full Companion DFU
+ ZIP with SHA-256
+ `2a5a6bd0fd26031a2c2e047af6ead8f85f0d617da238d0cd6d816f7d5c41ec6f`.
+ On the physical T096, Windows read interface `02`'s bus-reported
+ description as `MeshCore Logging`. Two immediate open/close cycles each
+ delivered the complete 80-byte two-line identity marker with no NUL or
+ other invalid control byte, while Binary Companion on interface `00`
+ retained the exact key, model, and `910.525/62.5/SF7/CR5` tuple.
+- [x] The final short-write/reconnect guard built a 482,271-byte Full Companion
+ DFU ZIP with SHA-256
+ `bbbc3459b2c032af23c6da05e41984cb0250398b57ba56ea99166456700ace6f`
+ and EndF body hash `41365E4DB0FF4AE5`. On the physical T096, interface
+ `02` again reported `MeshCore Logging`; 3/3 explicit DTR sessions each
+ delivered one complete 80-byte marker with no NUL or invalid control byte.
+ With that logging endpoint held open and unread, interface `00` completed
+ 60/60 Binary Companion reconnect queries and retained public key
+ `8e229d0a9ace9bd6f768fc1237d9e3ebca340d1ff1c6087349983a07f305903a`
+ plus the exact `910.525/62.5/SF7/CR5` tuple.
+- [x] The corrected Windows flasher identifies Binary-mode Full Companion and
+ links the same-radio `MI_02` inventory row to `MI_00`. An intentional
+ selection of `COM21/MI_02` was canonicalized to `COM9/MI_00` before any
+ 1200-baud action; the exact USB serial then followed
+ `COM9 -> COM6 -> COM9`, never touched the logging endpoint or MeshPocket,
+ and produced both `Device programmed.` and `Flash completed.`. The VM was
+ suspended only across each touch/transfer and resumed immediately. After
+ restoring the qualified Repeater and performing a final warm reboot, the
+ 58-second snapshot was in RX with 28 packets received, 9 sent, zero core
+ errors, an empty queue, and `err_flags=0`.
+- [x] Final flasher review now revalidates the selected USB identity and exact
+ interface immediately before both the direct 1200-baud open and its Python
+ fallback, and balances every successfully written terminal START with a
+ STOP even when the banner is lost. Windows PowerShell 5.1 and PowerShell 7
+ regression suites pass. A second physical restore deliberately selected
+ `COM21/MI_02`; it was canonicalized to `COM9/MI_00`, followed the same
+ `COM9 -> COM6 -> COM9` transition, and completed without touching the
+ MeshPocket. At 112 seconds the restored Repeater retained its name, OTA
+ body hash `3B6D1A729C087243`, USA radio tuple, and AGC interval 8; it was in
+ RX with 20 packets received, 6 sent, zero core errors, an empty queue, and
+ `err_flags=0`. The VM was running again immediately after the flash.
+- [x] The T096-only message profile defines `UI_MESSAGE_CHANNEL_FOOTER=0`;
+ every other profile retains the default footer. The exact incoming
+ `#testing` message `T096 footer test: wrapped text should reach the screen
+ bottom.` traversed the live channel receive/display path, and the temporary
+ empty `#testing` channel slots on both endpoints were restored byte-for-byte.
+ Static profile coverage passes 2/2. No framebuffer or camera capture was
+ available, so the evidence is the exact live UI path plus the compiled
+ board-specific flag rather than a pixel photograph.
+- [x] The then-final Full Companion artifact at this checkpoint,
+ `v1.17.1-t096-head-fcd1f8cc-usbtask8-fcd1f8cc`, was installed. Its DFU ZIP
+ is 487,231 bytes with SHA-256
+ `CD267952F420294DF21CA96E3AA6D924C6EF7467C31C8D69B46F31E6719A1D67`;
+ the application is 486,428 bytes with a 486,372-byte EndF body and hash
+ `2E03B1C1166D4CC0`. The ELF exposes strong `T` definitions for both CDC
+ callbacks, mount/unmount, SOF, and the CDC0-only framework flush override.
+- [x] Identity-gated BLE application DFU passed through the bonded address
+ `CF:A8:18:36:DE:8B`: encrypted application handoff from
+ `MeshCore-8E229D0A`, same-address `T096_DFU` Legacy service, and DIS model
+ `HT-n5262G` all matched. The target confirmed 486,428/486,428 bytes in
+ 565.986 seconds at 859 payload B/s, validated, activated, and initiated
+ the expected disconnect; end-to-end time was 603.967 seconds. USB serial,
+ version, board, public key, radio tuple, EndF hash, and bootloader apply
+ ABI 3/codecs `0x5` returned exactly, and a fresh active scan observed the
+ application name again at the same address.
+- [x] The real host `motatool` opened `COM9`, survived the intentional CDC
+ session-settle purge, attached an empty folder with `COUNT -> 0` and
+ `OK folder attached`, then detached it and restored Binary mode. This
+ test sent no mesh messages. Three primary-port close/reopen cycles after
+ deliberately abandoned terminal output exposed zero stale bytes and each
+ returned a clean banner. Five logging-port reopen sessions each began
+ with the exact 80-byte identity marker and contained no NUL or invalid
+ control byte.
+- [x] With both USB outputs deliberately unread for 65 seconds after a 10,000-byte
+ terminal input burst, the primary accumulated 16,886 printable bytes and
+ CDC1 accumulated 3,735 printable bytes beginning with its exact marker.
+ `ver` and Binary Companion remained responsive. A subsequent warm reboot
+ removed USB for 2.209 seconds, returned both interfaces in 2.409 seconds,
+ and CDC1 again emitted the exact marker. At 65 seconds the fresh boot had
+ `err_flags=0`, an empty outbound queue, 9 received floods, and zero CRC
+ errors. A Windows host-side composite
+ restart was attempted but denied by the non-elevated PnP API, so the
+ distinct host-driven BUS_RESET case remains a physical/privileged check;
+ it is not inferred from the warm-reboot result.
+- [x] After the operator physically removed and restored T096 power (the off
+ interval was not measured), stable serial `651F8E496197F882` returned as
+ primary `MI_00`/`COM9` and logging `MI_02`/`COM21`. The cold terminal
+ reported exact version
+ `v1.17.1-t096-head-fcd1f8cc-usbtask8-fcd1f8cc`, Heltec T096 identity,
+ public key, USA radio tuple, BLE name, dedicated-CDC logging state, EndF
+ body hash `2E03B1C1166D4CC0`, and bootloader apply ABI 3/codecs `0x5` with
+ stage ceiling `0xD4000`; mOTA remained source-only with no folder attached,
+ and STOP restored Binary mode. CDC1's first read was the exact 80-byte
+ identity marker with no NUL. At 150 seconds, Binary stats reported 4,224
+ mV, `err_flags=0`, an empty outbound queue, 47 packets received, zero
+ packets sent, and zero TX airtime. A second core checkpoint advanced
+ monotonically to 315 seconds with 4,220 mV, `err_flags=0`, and an empty
+ queue, providing no evidence of a watchdog reboot. A fresh active BLE
+ scan observed `MeshCore-8E229D0A` at the same `CF:A8:18:36:DE:8B`
+ address; BlueZ still
+ reported paired, bonded, and trusted. No mesh message was sent. This proves
+ cold persistence, but not the distinct host-only USB `BUS_RESET` path.
+- [x] The display auto-off wake failure was traced to the shared ST7735 power
+ lifecycle: `turnOff()` released SCK/MOSI as inputs, but the nRF52
+ `SPIClass::begin()` wake path was a no-op while the SPI object still
+ considered itself initialized. The shared driver now restores the bus-pin
+ directions and nRF52 high-drive configuration explicitly, waits through
+ reset release, and primes the retained frame after `DISPON`. Static wake
+ ordering and the T096 footer profile pass, and the exact Full Companion artifact
+ `v1.17.1-t096-head-fcd1f8cc-wake1-fcd1f8cc` compiled at 486,348 bytes.
+ Its 487,231-byte serial-DFU ZIP has SHA-256
+ `A091F6DC458177BB26B8384B2AAAC0A9FD550322179F96062CCD3F0F5BB0E585`;
+ the 486,428-byte OTA image reports a 486,372-byte EndF body with hash
+ `E9AB2A36E9F7091F`. Identity-gated `meshfirmware` selected `COM21/MI_02`,
+ canonicalized it to `COM9/MI_00`, followed `COM9 -> COM6 -> COM9`, and
+ emitted both `Device programmed.` and `Flash completed.` without touching
+ the MeshPocket. The VM was suspended only across touch/transfer and was
+ running again as soon as exact serial `651F8E496197F882` returned. Live
+ readback then matched the public key, Heltec T096 board, USA
+ `910.525/62.5/SF7/CR5` tuple, ABI 3/codecs `0x5`/stage `0xD4000`, and exact
+ 80-byte CDC1 marker; core checkpoints advanced through 369 seconds with
+ `err_flags=0`, an empty queue, and zero TX. Follow-up physical testing
+ confirmed auto-off wake and single-click page navigation. Double-click,
+ triple-click, long-press, and simultaneous-button gestures remain deferred.
+- [x] A signed bootloader mOTA package for OTAFIX HIL candidate `0x02040401`
+ transferred over LoRa in 40/40 blocks, passed the privileged-install gate,
+ installed with the watchdog enabled, and rebooted with unchanged node and
+ radio settings. The installed bootloader CRC changed to the package's
+ expected `381BAE6E`; the temporary HIL signer was removed afterwards.
+- [x] A 486,796-byte Full Companion application completed bonded Legacy BLE DFU
+ at approximately 5.7-6.2 kbit/s payload throughput, then returned with the
+ same identity and USA radio tuple. The later CDC0 first-open fix preserved
+ immediate `APP_START` input across the eight-millisecond session gate:
+ first client after reboot passed with CDC1 logging active, followed by
+ 10/10 immediate COM9 close/reopen queries. CDC1 continued to emit its exact
+ identity marker and printable packet/debug logs.
+
+## Heltec MeshTower V2 with SD
+
+- [x] Stable serial `9352162A72082314` and physical downstream-hub path
+ `1-1.2` port 4 identify this board across application and bootloader
+ descriptors; no other attached nRF52 endpoint was selected. The older
+ 2.4.3 bootloader's UF2/serial failure was rejected rather than treated as
+ a successful flash.
+- [x] Exact `1.17.1.5-halo-keymind-cascade-marathon-hwtest-e26d48e4` SD
+ Repeater boots with hardware ID `Heltec_tower_v2`, target `0A9DBBF0`,
+ body hash `E9282D470AAC348B`, and USA radio
+ `910.525/62.5/SF7/CR5`. `ota self` reports SD apply ABI 3 with codec mask
+ `0x5`; `ota bootloader` reports board `239A0071`, target `1150F50E`, name
+ `TOWER_V2_OTA`, and capability byte `09`.
+- [x] A post-Pi-recovery identity-gated serial audit reconfirmed the exact
+ Tower V2 model, Repeater role, build, `910.525/62.5/SF7/CR5`, target
+ `0A9DBBF0`, body `E9282D47`, SD ABI 3/codecs `0x5`, and installed
+ bootloader identity/capability `239A0071`/`09`. The card reported
+ 959.6 MiB free and no active download. This profile exposes the local
+ text console but deliberately has no runtime `usb.logging` setting; the
+ unknown-setting reply is a capability result, not a transport failure.
+- [x] Card detection and destructive maintenance pass. Normal format took
+ 3,927 ms; an immediate repeat was rejected by the five-minute cooldown;
+ forced format took 3,933 ms; and raw erase plus format took 3,947 ms.
+ The card remounted empty with 16.0 KiB used and 959.6 MiB free, and both
+ completion ages were recorded. Automatic archive capture was then
+ disabled deterministically, leaving only `/mota/cache.off`.
+- [ ] SD card detection, staging, hash validation, apply, cleanup, and recovery
+ from missing/corrupt media pass.
+- [ ] Multi-page application and bootloader update paths pass across a cold boot.
+- [ ] LoRa OTA receive/apply reports the expected target ID and installed version.
+
+## Heltec V4
+
+- [x] Exact V4 hardware variant is reported (`Heltec V4.3 OLED`); OLED/TFT
+ variants are not merged.
+- [x] Full Companion boots with USB logging off and Binary Companion selected.
+ The text terminal independently reports the complete build string;
+ protocol `ver` has a shorter fixed-width version field.
+- [ ] USB logging changes take effect immediately on this single-TTY ESP32
+ profile, require no reboot, and return to Binary Companion only through
+ the explicit terminal-stop mode change.
+- [x] Bluetooth pairing and Companion protocol operations pass. Qualification
+ required removing the Pi's stale pre-flash bond, pairing through a BlueZ
+ `KeyboardOnly` agent with the current displayed/reported PIN, then
+ reconnecting; BLE `infos` and `ver` returned the exact V4.3 OLED identity.
+- [x] Saved Wi-Fi connects to `SlowFi`, returns at `192.168.1.51` after the test
+ host reboot, reports Wi-Fi power save `min`, and remains governed by the
+ configured-network behavior rather than the unconfigured 30-minute
+ shutdown policy.
+- [x] TCP Binary Companion (`5000`), Wi-Fi OTA (`5001`), and text CLI (`5002`)
+ are each tested and cannot be confused with one another. Port 5000
+ returned the exact Companion identity; 5002 returned the full terminal,
+ version, Wi-Fi, logging, RXPS, and memory state; and a real `motatool
+ serve --tcp 192.168.1.51:5001 -v` session received `COUNT -> 0` from an
+ intentionally empty folder. Disconnect then restored `folder:not
+ connected` while the 5001 listener stayed active.
+- [ ] For a shared Full-Companion controller/seeder, bind the source identity
+ to the exact public key already read from Binary Companion port 5000.
+ Do not require `get public.key` from text port 5002: the hardware-tested
+ V4 Full CLI rejects that command. Port 5002 must still prove the local
+ TempRadio state/tuple, while keyed on-air destination replies prove the
+ physical LoRa handoff.
+- [ ] V4 seeds direct and routed LoRa OTA; TempRadio preflight, apply, and
+ automatic normal-radio restoration all pass.
+
+## Seeed SenseCAP Indicator LoRa
+
+- [x] Flashing selects the ESP32-S3 CH340 endpoint, never the Indicator RP2040
+ ACM endpoint; esptool MAC probing succeeds before erase. The qualifying
+ merged write used 115200 baud and completed with flash hash verification.
+- [x] A fresh merged install completes SPIFFS initialization and reports the
+ intended Full Companion version/role and `Seeed SenseCAP Indicator`
+ hardware identity. The exact qualifying artifact was
+ `SenseCapIndicator-LoRa_companion_radio_full-1.17.1.5-halo-keymind-cascade-marathon-e26d48e4-merged.bin`,
+ 2,070,152 bytes, SHA-256
+ `df08b770313ca8776436456525681cf286cd9b5b40e05e7cf55f21ef097f338a`;
+ its ELF, map, manifest, and partition signature are archived beside it on
+ the Mercerwood Pi.
+- [x] The current dark-layout Full Companion application was flashed app-only
+ with exact CH340/MAC/partition gates, preserving identity, Wi-Fi, and
+ settings. The 2,147,800-byte application has SHA-256
+ `81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b`;
+ esptool verified its flash hash in 134.2 seconds. USB Binary/terminal,
+ saved logging-off state, configured Wi-Fi, BLE, TCP ports 5000/5001/5002,
+ and an empty-folder Wi-Fi mOTA `COUNT -> 0` exchange all pass afterward.
+- [x] Keep one identity-gated CH340 descriptor open with DTR/RTS deasserted
+ through startup. On the valid-current-font path, the Indicator streams a
+ 1,302,608-byte font from its RP2040 at 1 Mbps before registering the USB
+ terminal, so allow at least 30 seconds before the first command. A
+ missing/corrupt-font recovery instead runs in the background after the
+ interfaces start. Reopening short probes can reset the ESP32 repeatedly
+ and create a false UART-hang diagnosis; the controlled run returned
+ `ver` and `board` after the same apparent-silence condition.
+- [x] Automatic font recovery starts from a physically corrupt RP asset. The
+ setup deliberately installed 64 all-zero bytes, CRC32 `758d6336`, in
+ place of `/meshcore/ui-font.vlw`; the ESP32 rejected that asset and kept
+ its built-in fallback font while recovery ran.
+- [x] Every new HTTPS recovery attempt waits for a fresh SNTP callback and a
+ signed epoch at or after the compiled asset publication time before it
+ opens TLS. The forced-resume run observed fresh syncs in 3,700, 400, and
+ 699 ms across three outer attempts, including two failed TLS handshakes;
+ the final normal run synced in 1,999 ms and only then logged the first
+ `api.github.com` connection. A retained merely plausible clock is not
+ accepted. CA validation and the compiled asset SHA-256 remain mandatory.
+- [x] The release-source normal path downloaded all 1,302,608 bytes into PSRAM
+ in 13,205 ms, closed TLS, verified SHA-256
+ `61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421`,
+ staged receiver-paced 512-byte `MCFONT STAGEV2` chunks in 36,676 ms,
+ committed, and verified RP CRC32 `19f80d64` at 76.7 seconds from reset.
+ An independent `MCFONT GET` over RP USB then returned exactly 1,302,608
+ bytes with the same CRC and SHA in 15.273 seconds. The exact installed
+ application is
+ `SenseCapIndicator-LoRa_companion_radio_full-1.17.1.5-halo-keymind-cascade-marathon-font-recovery-e26d48e4.bin`;
+ its log is
+ `/home/mikec/hwtest/runs/indicator-release-recovery-lUZEmBHL/esp-serial.log`
+ on Mercerwood. The current RP service reports protocol 2 and completed
+ STAGEV2 accounting; its installed flash was not independently read back,
+ so no RP artifact hash is inferred from the archived candidates.
+- [x] Strict Range recovery passed a forced hardware interruption. Exact test
+ application SHA-256
+ `cfd5aec497cb9919f87c0f82f5deaf8614d411582a709cea1c36275529c0bb68`
+ closed the initial response at verified offset 1,048,576. Its first
+ same-offset resume handshake failed; bounded retry 2/2 then accepted only
+ the remaining 254,032-byte `206` response under the original strong
+ ETag/`If-Range`, retained the same PSRAM buffer and SHA stream, completed
+ the download in 49,422 ms, staged in 36,702 ms, committed, and passed an
+ independent exact USB readback. The full log is
+ `/home/mikec/hwtest/runs/indicator-range-v2-xbKr09wI/esp-serial.log`.
+ An earlier forced 512 KiB run proved one correct resume at 524,288 but
+ later exhausted its second reconnect on a TLS failure; it is retained as
+ regression-trigger evidence rather than an end-to-end pass.
+- [x] Rebooting the exact release-source application with the current valid
+ font performed no NTP query, TLS connection, or GitHub request. The
+ valid-font boot log is
+ `/home/mikec/hwtest/runs/indicator-release-valid-a84vd7Du/esp-serial.log`.
+- [x] The post-qualification shared SNTP-coordinator source compiles in the
+ real Arduino-ESP32 2.x LoRa USB/WiFi profile (1,587,965 bytes, 60.6% of
+ its OTA slot), the dynamically generated ESP-NOW Full profile (2,093,465
+ bytes, 79.9%), and the matching RP2040 transactional service. After the
+ VM independently reported `NTPSynchronized=yes`, an
+ end-to-end request to the immutable GitHub asset returned HTTP 200,
+ exactly 1,302,608 bytes, and SHA-256
+ `61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421`.
+ A live `524288-1302607` continuation then returned HTTP 206, the same
+ strong ETag, an exact 778,320-byte `Content-Length`, and a byte-for-byte
+ hash match against the checked-in font tail; the pinned Sectigo E46 root
+ independently verified the current `api.github.com` chain.
+ The same coordinator also compiles in the real
+ `heltec_v4_repeater_observer_mqtt` profile: its 1,852,392-byte EndF image
+ has SHA-256
+ `fe8352654437f67488b412e1c1a690f2874e836abb5a5d209654bd030c713d7e`.
+ MQTT's three process-global `configTime()` paths are lease-protected,
+ and a busy asynchronous refresh uses a wrap-safe five-second retry
+ deadline instead of overwriting another service's SNTP callback or
+ recording a false successful refresh.
+ The exact final LoRa Full artifact gate then passed 11/11 linked-image
+ markers, including both `requesting fresh NTP time before download` and
+ the subsequent TLS-open marker. Its 2,146,312-byte application has
+ SHA-256
+ `f557088a5ef3f49f74ab01ca95fefc76911cca4073f01b77cee4882284fc5110`
+ and 475,128 bytes remain in its app slot; the matching 2,211,848-byte DIO
+ merged image has SHA-256
+ `52a4559774384b54dd7fb81da82c791e2c9564991fcf016bcad3b6ad756c1fb2`.
+ The exact ELF, map, capability manifest, and partition signature are
+ archived together under `/tmp/indicator-full-daily-ntp.WQ6ozM` on
+ the VM pending transfer to the hardware-run archive.
+ The ESP-NOW Full sibling passed the same 11/11 gate with a 2,093,928-byte
+ application (527,512 bytes free), SHA-256
+ `fcd9e0e8a37551a845a234fc10eaa4a9f420d54172039b9241f4696a12d4c65f`,
+ and matching DIO merged-image SHA-256
+ `f3637c23f58204f5b752b68a1bd494601cb68bee9c27b664832d40abd03cc186`;
+ its complete archive is `/tmp/indicator-espnow-full-ntp-gate.YN9T8l`.
+ Repeat the negative UDP/123-blocked hardware gate on this exact final
+ source before treating the coordinator refinement as hardware-qualified.
+- [ ] Full Wi-Fi SSID, setup address, and related status text wrap without
+ clipping on the physical display. The new dark palette and exclusive
+ lower pairing block are installed; a fresh bond removal held the random
+ PIN request on-screen for 30 seconds and then an immediate second attempt
+ paired, trusted, and returned the exact Indicator identity over BLE. A
+ human physical-readability confirmation is still required; the automated
+ geometry tests prove the PIN block is centered at four-fifths height and
+ cannot overlap IP, inbox, or status text.
+- [ ] Boot the exact current LoRa and ESP-NOW Full artifacts in both saved
+ secondary-transport modes and capture the `Indicator render` line. Require
+ LoRa + WiFi, LoRa + BLE, and ESP-NOW + infrastructure WiFi to retain
+ 480x480, and ESP-NOW + BLE to select 320x320 scaled to the panel. A logged
+ emergency 320x320 fallback keeps the unit recoverable but does not pass a
+ native-480 gate.
+- [ ] For all four boots, record free heap and largest internal block before
+ interfaces and after the selected interface is usable. In WiFi mode prove
+ BLE never starts; in BLE mode prove infrastructure WiFi, WebConfig, MQTT,
+ and TCP never start. Keep the primary LoRa or ESP-NOW radio exchanging
+ traffic throughout. For both BLE modes pair/encrypt, negotiate MTU,
+ subscribe, exchange repeated Companion requests, disconnect/reconnect,
+ and repeat after bond removal; exercise network/TCP traffic in both WiFi
+ modes and record the lowest heap/largest-block values.
+- [x] With no saved SSID, setup Wi-Fi powers down after the absolute 30-minute
+ window even if queried or used.
+- [x] A software reboot starts a fresh unconfigured setup window.
+- [x] With a saved SSID, Wi-Fi reconnects to `SlowFi`, reports minimum modem
+ power save, and remains governed by normal configured-network behavior.
+- [x] Bluetooth Companion pairing and protocol operations pass after saving a
+ deterministic qualification PIN, removing any stale host bond, pairing,
+ disconnecting the `bluetoothctl` client, and reconnecting with meshcli.
+ Binary `infos`/`ver` returned the exact Indicator identity and USA tuple.
+ A second connection after the USB-open reboot and logging-mode exercise
+ also returned the exact build; one initial scanner miss was resolved by
+ an explicit BlueZ discovery pass and was not a firmware advertisement
+ failure.
+- [x] Wi-Fi/TCP Companion, CLI, and OTA endpoints pass at `192.168.1.54`:
+ port 5000 returned Binary Companion identity, port 5002 returned the full
+ text version/status, and motatool completed a port-5001 `COUNT -> 0`
+ protocol exchange against an empty served folder.
+- [x] Fresh Full starts with USB logging off. On this single-TTY ESP32 profile,
+ logging on/off applies and persists without a descriptor-changing reboot;
+ the same held-open ASCII terminal reported off -> on -> off correctly,
+ emitted live Wi-Fi/radio diagnostics while on, then accepted the explicit
+ terminal-stop token and returned a valid 85-byte Binary Companion device
+ info frame for `Seeed SenseCAP Indicator` without rebooting. Companion
+ remained independently usable over Bluetooth and Wi-Fi.
+- [x] Normal-radio LoRa transmit and receive pass. At the installed 22 dBm,
+ a co-located discovery request overloaded the nearby receivers and found
+ zero nodes; temporarily reducing only the Indicator to 0 dBm produced
+ three repeater replies at RSSI -29 to -59 dBm, proving both directions.
+ The saved 22 dBm value was restored and independently read back after the
+ test; this near-field result is a lab-power gate, not a firmware failure.
+- [ ] TempRadio sanity check, OTA seeding, and normal-radio restoration pass.
+
+## Cross-radio LoRa OTA acceptance
+
+- [ ] Run a three-minute reachability preflight before staging or changing any
+ repeater radio settings.
+- [ ] Test direct, fully controlled multi-hop, passive TempRadio hop, and mixed
+ controlled/passive paths independently.
+- [ ] Record bytes, LoRa packets sent, packets confirmed, retries, useful
+ throughput, total elapsed time, and per-phase elapsed time.
+- [ ] Confirm every controlled intermediate returns to its saved radio tuple.
+- [ ] Verify target application and bootloader hashes after apply, not merely the
+ OTA tool's final status line.
+- [ ] Exercise interruption/retry and confirm incomplete data cannot be approved
+ or booted.
diff --git a/docs/test-results/home_text_spacing.md b/docs/test-results/home_text_spacing.md
new file mode 100644
index 000000000..aaf43699a
--- /dev/null
+++ b/docs/test-results/home_text_spacing.md
@@ -0,0 +1,23 @@
+# Home-screen text spacing
+
+On non-Indicator companions, a visible Bluetooth PIN or connection status owns
+a separate bottom-aligned block. It replaces the ordinary inbox instruction,
+clock and WiFi rows while displayed. The PIN stays below the inbox title; the
+label and value use measured font heights, including the MeshPocket's larger
+e-paper font. An unusually short viewport may omit the label, never overlap
+the title or clip the PIN. Indicator's dedicated pairing layout is unchanged.
+
+The repeater home page also stacks measured font-height rows instead of fixed
+ten-pixel offsets. Long names and status lines are ellipsized horizontally.
+
+Run `python3 -m unittest discover -s test -p test_home_text_spacing.py -v` on a
+host with a C++ compiler. This renders the actual companion and repeater home
+branches, recording every text rectangle and rejecting overlaps or clipping.
+It covers 128x64 OLED, 160x80 T096, 250x122 MeshPocket and larger viewports,
+PIN/connected/disabled/prompt states, WiFi variants, long strings and a sweep
+of font heights. It also checks the static reader hint. The separate native
+display and T096 tests exercise the driver drawing/measurement methods.
+
+Hardware acceptance: check that the full six-digit PIN is near the bottom,
+with no text over it; connect/disconnect Bluetooth and verify no stale digits;
+then check the repeater's five rows and the V4's non-blinking reader footer.
diff --git a/docs/test-results/index.md b/docs/test-results/index.md
new file mode 100644
index 000000000..3e3f37ba0
--- /dev/null
+++ b/docs/test-results/index.md
@@ -0,0 +1,59 @@
+# Test Results
+
+Completed validation reports, experiments, and test guides are collected here. Older page addresses redirect to these copies. Results are tied to the hardware and firmware versions named in each report; they do not automatically qualify a newer build.
+
+- [1.17.1.8 Preview 1 qualification and raw reports](preview-1.17.1.8/index.md)
+
+## Radio and RF
+
+- [SX1262 channel-scanning hardware results](cad_scan_validation.md)
+- [CW and CW2 validation](carrier_wave_validation.md)
+- [Double center-frequency write: V4 transmitter / XIAO receiver](double_frequency_v4_validation.md)
+- [Four fixed transmitters: longer dwell repeats](four_fixed_tx_dwell_validation.md)
+- [Four fixed transmitters / fast single-pass receiver](four_fixed_tx_single_pass_validation.md)
+- [Mixed-bandwidth two-channel reception test](mixed_scan_validation.md)
+- [Equal-symbol-time mixed SF/BW, four-channel test](mixed_sf_bw_validation.md)
+- [SF7/62.5 + SF8/500: 4.6 slow chirps, 0.3 ms reserve](pair_4p6_300us_validation.md)
+- [SF7/62.5 + SF8/500, fast switching and preamble 32](pair_preamble32_validation.md)
+- [Off-channel preamble investigation](preamble_detection_investigation.md)
+- [Shared radio/radio2 chirp timing and preamble warnings](radio_chirp_math_validation.md)
+- [Automatic dwell policy: 4.6 chirps on both profiles](radio_dwell_policy_validation.md)
+- [SX1262 profile-switch validation](radio_profile_switch_validation.md)
+- [Dual-profile receive validation](radio_profiles_validation.md)
+- [Radio receive calibration and recovery](radio_receive_calibration.md)
+- [Separated radios, normal XIAO RX gain, and unchanged-modulation timing](separated_radio_modulation_cache_validation.md)
+- [SF10/125 full samples: 10 Hz versus 100 Hz first-pass detour](sf10_10_vs_100hz_full_sample_validation.md)
+- [SF10/125: tiny first-pass frequency offset and coding-rate detour](sf10_10hz_cr_detour_validation.md)
+- [SF10/125: apply the full radio settings twice per hop](sf10_full_retune_twice_validation.md)
+- [SF10 / 125 kHz, XIAO nRF52 TX, double write, 1 ms delay grid](sf10_nrf52_double_write_settling_validation.md)
+- [SF10/125 rollback and wider-spacing failure isolation](sf10_rollback_failure_validation.md)
+- [Descending SF10..SF5 / 125 kHz settling limits](sf125_settling_limits_validation.md)
+- [SF5 / 250 kHz: four-channel dwell sweep](sf5_250_dwell_validation.md)
+- [SF6/125 four-channel post-switch settling sweep](sf6_125_settling_validation.md)
+- [SF6 / 125 kHz: four channels at 5.1 chirps](sf6_5p1_trace_validation.md)
+- [SF6 / 125 kHz channel-count test](sf6_channel_scan_validation.md)
+- [SF8 / 125 kHz: four-channel comparison at 5.1 chirps](sf8_5p1_trace_validation.md)
+
+## Companion, USB, BLE, and display
+
+- [Contact-cache and NimBLE RAM qualification](companion_contact_cache_results.md)
+- [USB Companion ASCII default validation](companion_usb_ascii_validation.md)
+- [USB Companion client compatibility](companion_usb_client_validation.md)
+- [Full USB logging repair — 1.17.1.6](full_usb_logging_validation.md)
+- [Home-screen text spacing](home_text_spacing.md)
+- [ESP32-S3 NimBLE trial results, 2026-09-08](nimble_companion_trial_results.md)
+- [ESP32-S3 NimBLE Full Companion trial](nimble_companion_trial.md)
+- [nRF52 USB READY hang](nrf52-usb-ready-fix.md)
+- [Native USB backpressure and radio liveness](usb_serial_backpressure.md)
+- [Small-screen message fonts](v4_pixel5_font_trial.md)
+
+## Build, storage, memory, and checklists
+
+- [Stock nRF52 bootloader version audit](bootloader_version_stock_audit.md)
+- [CLI setting dispatch audit](cli_settings_audit.md)
+- [Hardware validation checklist](hardware_validation_checklist.md)
+- [Shrinking the per-connection TLS footprint on non-PSRAM observers](mbedtls-tls-footprint.md)
+- [nRF52 Companion automatic ExtraFS recovery](nrf52_companion_storage_recovery.md)
+- [PR #7 review and validation](pr7_review_validation.md)
+- [ESP32-S3 OTA memory experiment](s3_memory_soak_validation.md)
+- [SPIFFS regular-file reads and login replay state](spiffs_regular_file_reads.md)
diff --git a/docs/test-results/mbedtls-tls-footprint.md b/docs/test-results/mbedtls-tls-footprint.md
new file mode 100644
index 000000000..b9a426b37
--- /dev/null
+++ b/docs/test-results/mbedtls-tls-footprint.md
@@ -0,0 +1,183 @@
+# Shrinking the per-connection TLS footprint on non-PSRAM observers
+
+## Why
+
+On a non-PSRAM Heltec V3 running two WSS/JWT broker slots, the largest allocatable block in
+internal DRAM walks down in ~16 KiB steps at every TLS reconnect while total free heap stays
+flat. Measured on hardware over 50 reconnect cycles: 62,452 -> 16,372 bytes, permanently.
+
+The step size is not a coincidence. `framework-arduinoespressif32 3.20017` (Arduino 2.0.17,
+IDF 4.4) builds mbedTLS with the **symmetric** buffer configuration:
+
+```
+CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN 16384 # sizes BOTH the in and out record buffers
+CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE 1
+# CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN -- not defined
+# CONFIG_MBEDTLS_DYNAMIC_BUFFER -- not defined
+# CONFIG_MBEDTLS_SSL_VARIABLE_BUFFER_LENGTH -- not defined
+```
+
+Read from `packages/framework-arduinoespressif32/tools/sdk/esp32s3/qio_qspi/include/sdkconfig.h`.
+Note the separate `framework-arduinoespressif32-libs` package in `~/.platformio` belongs to
+the esp32c6 env (pioarduino, IDF 5.3) and is **not** what this env links against -- though it
+happens to carry identical mbedTLS values.
+
+So each broker slot costs **2 x 16 KiB = 32 KiB** of contiguous internal DRAM in record
+buffers alone, and two slots cost 64 KiB on a board with roughly 80 KiB free. Every reconnect
+frees and re-allocates those buffers, and anything that lands in the vacated hole in between
+prevents them from going back, which is the ratchet.
+
+Confirmed by two independent observations: losing a whole TLS session returned exactly 16,384
+bytes of contiguity on one device and exactly 32,768 on another, and per-connection teardown
+frees ~41.6-44.7 KB total.
+
+## What the firmware could already do, and its limit
+
+`softDisconnect()` (branch `perf/mqtt-renewal-no-stop`, commit `6c51e468`) stops the JWT
+renewal bounce from destroying and recreating the esp-mqtt task, keeping its 6 KiB stack out
+of the hole. Measured: the staircase arrests after 2 steps at 36,852 through cycle 16, where
+the baseline took 4 steps and settled at 16,372 by cycle 11 -- about 20 KB better.
+
+That is as far as the application layer reaches. MQTT 3.1.1 has no re-authentication packet,
+so presenting a fresh JWT *requires* a transport reconnect; mbedTLS's internal allocation
+order during the handshake is not controllable from the application. The remaining cost is
+the record buffers themselves.
+
+## The changes
+
+All three are compile-time in mbedTLS, and the Arduino framework ships precompiled `.a`
+archives (`tools/sdk/esp32s3/lib/libmbedtls.a`), so a project-level `-D` cannot change them.
+A custom framework build is required.
+
+| Setting | From | To | Saving per connection |
+|---|---|---|---|
+| `CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN` | unset | `y` | enables the two below |
+| `CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN` | 16384 (implied) | 16384 | none -- keep it |
+| `CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN` | 16384 (implied) | 4096 | **~12 KiB** |
+| `CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE` | `1` | `n` | ~4 KiB |
+
+Roughly 16 KiB per connection, 32 KiB across two slots.
+
+### Why inbound stays at 16 KiB
+
+A peer may legitimately send a 16 KiB TLS record. Lowering the inbound limit only works if
+both brokers negotiate the max-fragment-length extension or provably never send larger, and
+getting it wrong produces invalid-record and handshake failures rather than a clean error.
+Outbound is ours to choose: this firmware's MQTT and WebSocket frames are far below 4 KiB
+(`MAX_TRANS_UNIT`-bounded packets plus small JSON), so 4 KiB is comfortable.
+
+### Risk on the peer certificate
+
+Dropping `KEEP_PEER_CERTIFICATE` means `mbedtls_ssl_get_peer_cert()` returns NULL after the
+handshake. Chain validation still happens -- only retention of the parsed leaf changes. This
+firmware verifies against a CA (`GTS_ROOT_R4` / the bundle) and never inspects the peer
+certificate or a fingerprint, so it should be safe. It does change `mbedtls_ssl_session`
+layout, which is exactly why the whole framework must be rebuilt together rather than
+swapping a single archive in.
+
+## Build procedure
+
+### Do not use esp32-arduino-lib-builder for this
+
+`release/v4.4` is the branch matching Arduino 2.0.x, but its `update-components.sh` clones
+every dependency at **master**, so it no longer resolves. Five successive failures, each a
+different repo: the arduino branch name it passes to `-A` does not exist; `jq` is absent
+from the IDF image and its absence makes `build.sh`'s target loop a **silent no-op that
+still exits 0**; `esp_littlefs` and `esp32-camera` master require IDF >=5.0/>=5.1;
+`esp32-camera` later needs an `esp_jpeg` version the 4.4 registry cannot satisfy; and
+tinyusb's source layout no longer matches `arduino_tinyusb/CMakeLists.txt`. Pinning each
+one in turn just surfaces the next.
+
+### Rebuild only the mbedTLS archives
+
+More rigorous anyway, because it reuses the shipped `sdkconfig` verbatim -- so the archives
+differ from stock *only* by the intended change, with no arduino-version or
+`DYNAMIC_BUFFER` drift.
+
+This is ABI-safe for the content-length change specifically: `ssl.h` declares `in_buf` and
+`out_buf` as `unsigned char *`, allocated in `ssl_setup()`, and no public struct embeds a
+CONTENT_LEN-sized array. The other precompiled archives (esp-tls, esp_http_client,
+esp-mqtt) therefore remain compatible. **It is not safe for
+`CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE`**, which changes `mbedtls_ssl_session` layout --
+that one needs everything rebuilt together, so it is excluded here.
+
+1. Minimal IDF project whose only component requirement is `mbedtls`.
+2. `sdkconfig.defaults` = the shipped
+ `packages/framework-arduinoespressif32/tools/sdk/esp32s3/sdkconfig`, with
+ `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384` replaced by the three asymmetric lines.
+ Verify the diff is exactly 5 lines before building.
+3. `docker run --rm -v $PWD:/project -w /project espressif/idf:v4.4.7 idf.py -DIDF_TARGET=esp32s3 build`
+4. Confirm `build/config/sdkconfig.h` carries `OUT_CONTENT_LEN 4096`.
+5. Stage the four archives under the framework's names -- note the rename:
+
+ | built | framework name | stock size | rebuilt |
+ |---|---|---:|---:|
+ | `esp-idf/mbedtls/libmbedtls.a` | `libmbedtls.a` | 113,914 | 113,338 |
+ | `esp-idf/mbedtls/mbedtls/library/libmbedtls.a` | **`libmbedtls_2.a`** | 1,252,150 | 1,245,718 |
+ | `.../libmbedcrypto.a` | `libmbedcrypto.a` | 4,302,698 | 4,259,458 |
+ | `.../libmbedx509.a` | `libmbedx509.a` | 676,590 | 673,318 |
+
+ All within ~1% of stock, which is a good check that only the config differs.
+
+### Wire it in with -L, never platform_packages
+
+```
+PLATFORMIO_BUILD_FLAGS="-L/path/to/staged/archives" pio run -e Heltec_v3_repeater_observer_mqtt
+```
+
+Verify with `grep -oE "[^ ]*libmbed[a-z0-9_]*\.a" .pio/build//firmware.map | sort -u` --
+every path must be the staged directory.
+
+**Do not** point `platform_packages` at a `file://` copy of the framework. PlatformIO
+installs it *over* the shared `~/.platformio/packages/framework-arduinoespressif32`,
+silently changing mbedTLS for every other ESP32 env and project on the machine. It does this
+even when the copy's `package.json` version differs -- verified twice here, and both times the
+fix was `rm -rf` the package and `pio pkg install` to re-download stock. A prepended library
+search path keeps the change scoped to one env, because the linker takes each archive member
+from the first archive that satisfies an undefined symbol.
+
+### How the archives are distributed
+
+They are not committed: ~6 MB per architecture, and they have to be rebuilt for every
+`platformio/espressif32` bump, so committing them would grow history permanently and go
+stale silently. Instead they are published as a release asset and fetched on demand:
+
+```
+scripts/fetch_mbedtls_4k.sh esp32s3 # download + verify against the manifest
+MESHCORE_REDUCED_TLS=1 pio run -e Heltec_v3_repeater_observer_mqtt
+```
+
+- `scripts/mbedtls_4k_manifest.txt` — per-arch sha256 of each archive. **Update it on every
+ platform bump**, together with the published asset.
+- `scripts/fetch_mbedtls_4k.sh` — downloads into `.mbedtls-4k//` (gitignored) and
+ verifies. `MBEDTLS_4K_LOCAL=` copies from a local build tree instead of downloading.
+- `scripts/mbedtls_4k.py` — wired into `esp32_base.extra_scripts`, but a **no-op unless
+ `MESHCORE_REDUCED_TLS=1`**, so ordinary builds need no artifact and behave as before.
+
+The opt-in path is deliberately loud, because both ways this can go wrong produce a
+firmware that looks correct and silently lacks the change:
+
+| failure | what happens without a guard | guard |
+|---|---|---|
+| directory missing or partial | linker ignores an unusable `-L` and resolves mbedTLS from the framework | pre-build: hard error |
+| archives stale after a platform bump | links the wrong build | pre-build: sha256 vs manifest |
+| `-L` present but outranked | framework archives win, flag is inert | post-link: `firmware.map` must resolve every `libmbed*.a` to `.mbedtls-4k/` |
+
+That last one is the reason the post-link check exists rather than trusting the flag: a
+build flag reaching the compiler proves nothing about what got linked.
+
+## How to verify it worked
+
+1. `strings`/`grep` the new `sdkconfig.h` for the four settings.
+2. Build and check the RAM figure; static usage should be unchanged (these are heap buffers).
+3. On hardware, `get mqtt.stats` at boot with two slots connected: the largest free block
+ should start roughly 24-32 KiB higher than the current 62-67 KiB.
+4. Soak across reconnect cycles and compare the floor against the two recorded runs:
+ baseline settled 16,372 (cycle 11); `softDisconnect` holds 36,852 (cycle 16).
+
+## Prior art in this investigation
+
+`.scratch/mqtt-non-psram-heap-staircase-analysis-2026-08-05.md` (untracked -- `.scratch/` is
+globally gitignored) holds the full allocation inventory. `~/mqtt-soak/STATE.md` holds the
+soak evidence, including two retracted hypotheses worth not repeating: the perf commits were
+not the cause, and waev does not cap connections per IP.
diff --git a/docs/test-results/mixed_scan_validation.md b/docs/test-results/mixed_scan_validation.md
new file mode 100644
index 000000000..34e6fdd56
--- /dev/null
+++ b/docs/test-results/mixed_scan_validation.md
@@ -0,0 +1,27 @@
+# Mixed-bandwidth two-channel reception test
+
+V4 transmitter on MercerWoodMesh; XIAO ESP32-S3/Wio SX1262 receiver. Channel 0: 909.5 MHz, SF7/62.5 kHz, preamble 32. Channel 1: 910.5 MHz, 500 kHz, SF and preamble below. Both use CR4/5, explicit header, CRC and 64-byte test payloads. Fast RX-preamble scanning uses four symbols of the active profile per visit, not four simultaneous channels or hardware CAD.
+
+Each setting starts with a five-packet-per-channel continuous-RX baseline. Scanning sends 50 packets per channel in shuffled order with random host delays. Only CRC-valid, payload-validated, sequence-matched packets count. Any scan loss escalates the 500 kHz preamble by 16, capped at 128. SF9 and SF8 start at 32; SF7 starts at 48. An additional SF8/40 test was requested after SF8/48; its firmware adds acceptance of preamble 40 without changing radio timing or scanning behavior.
+
+| 500 kHz SF | Preamble | Mode | SF7/62.5 received | 500 kHz received | Mean scan cycle |
+|---|---:|---|---:|---:|---:|
+| SF9 | 32 | Fixed baseline | 5/5 | 5/5 | — |
+| SF9 | 32 | Scan | 50/50 | 50/50 | 14.676 ms |
+| SF8 | 32 | Fixed baseline | 5/5 | 5/5 | — |
+| SF8 | 32 | Scan | 50/50 | 49/50 | 12.628 ms |
+| SF8 | 48 | Fixed baseline | 5/5 | 5/5 | — |
+| SF8 | 48 | Scan | 50/50 | 50/50 | 12.629 ms |
+| SF8 | 40 | Fixed baseline | 5/5 | 5/5 | — |
+| SF8 | 40 | Scan | 50/50 | 49/50 | 12.630 ms |
+| SF7 | 48 | Fixed baseline | 5/5 | 5/5 | — |
+| SF7 | 48 | Scan | 50/50 | 50/50 | 11.603 ms |
+
+These are strong-signal, sequential-transmission lab results. Successful reception of 50 packets is not proof of zero loss over months. The receiver remains on one channel through an entire detected packet, so overlapping traffic on the other channel can be lost. Scan-cycle measurements exclude time occupied by packet reception; USB command handling can add timing outliers.
+
+Machine-readable counts, timings and firmware/source hashes: [mixed_scan_results.json](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/cad_scan/mixed_scan_results.json).
+
+After testing, both boards were restored to their original B soak firmware using
+app-only flashes. Version and normal radio settings were verified on both boards;
+Wi-Fi was connected and MQTT reported `custom (ok)`. Both persistent soak loggers
+were restarted in phase `B-post-CAD-soak`, marking the intentional uptime reset.
diff --git a/docs/test-results/mixed_sf_bw_validation.md b/docs/test-results/mixed_sf_bw_validation.md
new file mode 100644
index 000000000..2c1a526dc
--- /dev/null
+++ b/docs/test-results/mixed_sf_bw_validation.md
@@ -0,0 +1,136 @@
+# Equal-symbol-time mixed SF/BW, four-channel test
+
+2026-09-14. **Promising, but no strict 400/400 pass.** With no added settling,
+the mixed scanner received 61 consecutive packets on the correct channels,
+then timed out on packet 62. The near-ceiling +22.4 ms setting received four
+packets and timed out on the fifth. There were no wrong-channel payloads in
+either run. Both stopped on the first miss without retries.
+
+## Configuration
+
+| Channel | Center (MHz) | SF | BW (kHz) | Symbol time (ms) |
+| --- | ---: | ---: | ---: | ---: |
+| 0 | 909.5 | 9 | 62.5 | 8.192 |
+| 1 | 910.5 | 10 | 125 | 8.192 |
+| 2 | 911.5 | 11 | 250 | 8.192 |
+| 3 | 912.5 | 12 | 500 | 8.192 |
+
+These preserve the preceding SF10/125 symbol duration exactly, rather than
+approximately: `symbol_us = 2^SF * 1000 / BW_kHz`. The supported SF/BW values
+and symbol-rate equation are specified in Semtech's
+[SX1261/2 datasheet, sections 6.1.1.1–2](https://cdn.sparkfun.com/assets/6/b/5/1/4/SX1262_datasheet.pdf).
+Equal symbol durations do not imply equal payload airtime or sensitivity.
+
+- RX: XIAO ESP32-S3 / Wio SX1262, COM31, MAC 28:84:85:B4:09:80.
+- TX: XIAO nRF52840 / Wio SX1262, COM15, serial B35E71C1C3726CE7.
+- Normal RX gain 0x94, -9 dBm TX chip request, CR4/5, 32-symbol preamble,
+ 16-byte CHS1 packets, 1 MHz channel spacing. No extra IRQ tracing/polling.
+- 5.1-symbol visits = 41,780 us; programmed preamble = 262,144 us.
+- Double frequency write retained, warm XOSC, bulk SPI and fast RX restart.
+ SF/BW changes on every hop, so **one 0x8B command is required on every hop**.
+- Goal per setting: 100 packets/channel, 400 total, strict channel identity.
+ No relaxed off-channel acceptance and no automatic channel-count expansion.
+
+The lab scanner stages four channels through the existing two production
+profile slots; this does not change the public two-profile protocol.
+
+## Positive controls
+
+Before scanning, each profile received ten fixed-frequency probes after full
+normal initialization. **All four passed 10/10, forty total**. Zero frequency
+commands occurred after each setup; no stationary receiver hardware failure.
+These are fresh ten-packet controls, not claims of 100/100 on each new profile.
+
+Both devices acknowledge the same exact modulation table through `mixinfo`.
+Transmitter results verify SF, fractional BW, center, sequence and packet size.
+RX records verify center-command word and SF/BW at packet read. Full expected
+payload identity and CRC remain required. Mode/cache errors are treated as
+fixture failures rather than ordinary RF misses.
+
+## Timing controls and settling budget
+
+Six-second RX-only window at zero added delay: 141 hops, mean 637.922 us,
+maximum 828 us. The largest 100 us delay step with a 500 us per-hop reserve is
+`floor((262144/4 - 41780 - 828 - 500)/100)*100 = 22400 us`.
+
+The +22,400 us RX-only verification window measured 87 hops, mean 638.241 us,
+maximum 833 us. Nineteen complete idle cycles measured mean 259,292.789 us,
+maximum 259,310 us, below the 262,144 us preamble. Every hop had two observed
+frequency commands and one modulation command, with no mode/cache/RX errors.
+
+This is an empirical near-ceiling budget, not a guarantee of acquisition
+for every arrival phase. Longer packet holds and software jitter can defeat
+the idle-cycle calculation. RX is active throughout added settling.
+
+## Four-channel results
+
+| Added settling | Strict received / attempted before first miss | Failed TX profile | Mean switch (us) | Max switch (us) | Max idle cycle (us) |
+| --- | ---: | --- | ---: | ---: | ---: |
+| 22,400 us | 4 / 5 | Channel 2, SF11/250 | 660.951 | 892 | 259391 |
+| 0 us | 61 / 62 | Channel 0, SF9/62.5 | 674.819 | 899 | 169798 |
+
+Both failures were timeouts, with TX rc=0, RX mode=1, device errors=0.
+Both runs had zero mode/cache/receive errors and zero off-channel payloads.
+The +22.4 ms run exercised 163 hops, 326 frequency commands and 163 modulation
+commands. Zero-delay exercised 718 hops, 1,436 frequency commands and 718
+modulation commands. Optimized RX resume counts equaled hop counts.
+
+No-delay per-channel counts at stop:
+
+| Channel | Received | Attempted | Good-packet mean RSSI (dBm) | Good-packet mean SNR (dB) |
+| --- | ---: | ---: | ---: | ---: |
+| 0 | 15 | 16 | -34.00 | 11.15 |
+| 1 | 16 | 16 | -33.69 | 8.24 |
+| 2 | 15 | 15 | -34.33 | 5.78 |
+| 3 | 15 | 15 | -33.27 | 3.47 |
+
+The receiver stayed longer on some visits: maximum held visit 582,993 us
+with settling, 580,577 us without. Held-visit statistics include successful
+packet reception, so these maxima alone do **not** prove false off-channel
+preamble holds in this experiment. No IRQ trace was collected for the two
+failures; their precise mechanism is not established.
+
+The longer success sequence and absence of wrong-channel payloads are
+encouraging relative to the earlier uniform-SF/BW tests, but do not establish
+a statistically measured improvement or zero-loss reliability. Each setting
+was censored at first failure; do not present 61/62 as an unbiased measured
+packet-delivery rate or either run as a completed 400-packet test. The random
+arrival method is seeded, not RF-phase synchronized across configurations.
+
+## Implementation, verification and final state
+
+HIL-only `ProfileMixedChannels.h`, `basemixed`, `mixtx`, `scanmixed` and
+`profile_switch_mixed.py` add the bounded experiment. Uniform modes preserve
+their defaults. Post-hop checks use the actual per-channel modulation;
+the uniform collector and settling evidence checks reject mixed-mode results.
+Receive ownership, packet guards, BUSY and command-error handling remain intact.
+
+17 native profile tests and 48 HIL host tests passed. Tests cover fractional
+62.5 kHz, equal symbol duration, table identity, normal uniform mapping,
+required modulation writes, TX replay, and wrong-policy rejection. Both
+firmware builds and normal uploads succeeded. The nRF52 ZIP is application-
+only, SoftDevice ID 0x0123; no bootloader or SoftDevice replacement. RAK COM29
+was not opened. Production settings/source were not changed this turn.
+
+Both boards rebooted idle after the second failed setting, with test firmware
+retained. No autonomous TX, background collectors or restoration. Temporary
+PlatformIO overlay removed. No commit or push was requested. Existing unrelated
+working-tree changes remain intact. `git diff --check` passed apart from
+unrelated pre-existing CRLF warnings.
+
+## Provenance
+
+Raw capture: `tools/hil/profile_switch_mixed_equal_symbols_results.json`.
+It includes the four baselines, two timing controls and two strict scans,
+with `complete:true`, `stopped:both_delay_controls_failed`, and no fixture or
+cleanup errors. Earlier captures were not rewritten.
+
+- Raw SHA-256: `49de5c6415b1becdca02bc6e3c72d5e55127e92e758bbe3ac6c342c89e3723dd`
+- RX BIN SHA-256: `20d3008ef3d92610510748f64ac90c327f37c91d96cd3ac55272a918867fd5d0`
+- TX ZIP SHA-256: `915d507d0feec1712fad2bd4be62c2fa8d769d11400711de0da1cf98a50a4b36`
+- TX HEX SHA-256: `1225bcc70854a224f2862443d816b0e9216a253da87c42e96569a933204af941`
+
+RX build reported 94,844 bytes RAM / 367,537 bytes flash. TX reported
+18,220 / 95,908 bytes. Source is keymindCascade commit
+`0e5955f889788a4317863e731b38ffe09e3d5441` plus existing uncommitted work and
+these HIL changes; this is not a clean-release comparison.
diff --git a/docs/test-results/nimble_companion_trial.md b/docs/test-results/nimble_companion_trial.md
new file mode 100644
index 000000000..19d626851
--- /dev/null
+++ b/docs/test-results/nimble_companion_trial.md
@@ -0,0 +1,133 @@
+# ESP32-S3 NimBLE Full Companion trial
+
+These optional builds use NimBLE-Arduino 2.5.1 with the existing ESP32 Arduino
+2.0.17 toolchain. They are hardware qualification builds, outside the normal
+release matrix and firmware picker.
+
+See the [2026-09-08 hardware results](nimble_companion_trial_results.md) for
+measured RAM, MAC-policy checks, WiFi updates, and remaining qualification.
+
+The [contact-cache guide](../companion_contact_cache.md) adds six optional
+350-contact trials for ESP32 boards without PSRAM, including Heltec V3.
+Their [qualification results](companion_contact_cache_results.md) include
+linked RAM margins, V4/RAK hardware tests and the mOTA queue policy.
+
+| Hardware | Trial environment | Contacts | Channels | Offline queue |
+| --- | --- | ---: | ---: | ---: |
+| Heltec V4.2/V4.3 OLED, FEM on | `heltec_v4_2_v4_3_companion_radio_full_femon_nimble` | 350 | 40 | 512 |
+| XIAO ESP32-S3 with WIO SX1262 | `Xiao_S3_WIO_companion_radio_full_nimble` | 350 | 40 | 256 |
+
+The XIAO profile uses WIO radio pins CS 41, DIO1 39, BUSY 40 and RESET 42.
+It is not the generic XIAO profile for separately wired radio modules. Both
+boards retain their parent Full Companion features, USB mOTA sender, WiFi
+update slots, and PSRAM-backed offline queue. The V4 also retains direct MQTT.
+
+The [small-screen message layout](v4_pixel5_font_trial.md) uses 6-pixel capitals
+on 128x64 panels and 5-pixel capitals on very tiny panels. It includes a compact
+channel/sender line and full message previews. Font selection is automatic.
+
+## Build
+
+Enable the optional configuration in the ignored `platformio.local.ini`:
+
+```ini
+[platformio]
+extra_configs =
+ variants/*/platformio.ini
+ platformio.nimble.ini
+```
+
+If that file already contains local settings, merge this list with them. Run
+one PlatformIO/build.sh command at a time. Both commands below use USA Cascade:
+
+```sh
+OUTPUT_DIR=.releases/nimble-v4 bash build.sh build-firmware \
+ heltec_v4_2_v4_3_companion_radio_full_femon_nimble \
+ --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \
+ --radio-preset usa-cascadia --profile cascade --standard --require-ota
+
+OUTPUT_DIR=.releases/nimble-xiao bash build.sh build-firmware \
+ Xiao_S3_WIO_companion_radio_full_nimble \
+ --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \
+ --radio-preset usa-cascadia --profile cascade --standard --require-ota
+```
+
+Use the merged image at address 0 for a USB installation. The V4 uses 16 MB
+flash; the XIAO uses 8 MB. Both use DIO flash mode. Use the application-only
+`.bin` for an existing, matching WiFi OTA layout. Remove the optional config
+entry when finished to return to the ordinary release matrix.
+
+## Bluetooth identity and pairing
+
+All existing MAC settings remain available from the ASCII USB terminal and
+the app's authenticated local CLI. A setting takes effect after reboot.
+
+| Command | Behavior |
+| --- | --- |
+| `get bluetooth.mac` | Show the saved MAC policy. |
+| `set bluetooth.mac C2:17:15:04:00:01` | Use this custom random-static address. |
+| `set bluetooth.mac random` | Generate one address and retain it across boots. |
+| `set bluetooth.mac random-every-boot` | Generate a new address on each boot. |
+| `set bluetooth.mac random-after-connect` | Rotate on the next boot after an authenticated connection; otherwise retain the address. |
+| `set bluetooth.mac default` | Restore the factory Bluetooth address. |
+| `set bluetooth.stealth on` | Pair once, then accept the saved bonded peer. |
+| `set bluetooth.stealth off` | Restore ordinary discovery without changing the MAC policy. |
+| `get bluetooth.stealth` | Show pairing or bonded-peer-only state. |
+
+The `ble.mac` and `ble.stealth` aliases also work. A literal custom address must
+be a valid BLE random-static address (first byte C0-FF). Forget the old entry
+in the phone's Bluetooth settings and pair again after an address change or
+switching Bluetooth libraries. Existing Bluedroid bonds are not migrated.
+
+Stealth does not disable the selected rotation policy. If rotation changes
+the address on reboot, first pairing opens again for the new identity.
+
+Pairing requires encryption, PIN authentication and a stored bond. NimBLE's
+controller byte order is converted explicitly, so a custom address appears
+over the air in the order entered. Failed identity setup stops advertising
+and retries initialization instead of advertising the wrong address.
+
+## V4 WiFi setup QR
+
+The active WiFi setup page displays a compact QR code with a one-pixel white
+border. The normal open setup network fits a 44 x 44 pixel code, with 2 x 2
+pixel modules. The SSID, portal address and `HOLD STOP` hint remain beside it.
+Short-click to leave the page; hold the button on this page to start or stop
+the setup AP. From USB, `start webconfig ap` starts the same setup session.
+
+When the setup AP is inactive and the node has joined WiFi, this page shows
+`WIFI CONNECTED`, `IP `, `SETUP AP INACTIVE`, then `HOLD START AP` from
+top to bottom. The address appears once assigned and clears when the station
+disconnects. While the setup AP is active, the QR page shows the setup portal's
+address instead.
+
+## Regression checks
+
+```sh
+python3 -B test/test_nimble_companion.py
+python3 -B test/test_bluetooth_mac_contract.py
+python3 -B test/test_bluetooth_pairing_ui.py
+python3 -B test/test_heltec_v4_wifi_setup_page.py
+python3 -B test/test_firmware_ram.py
+python3 -B test/test_esp32_dram.py
+python3 -B test/test_shared_mota_queue.py
+python3 -B test/test_companion_mqtt_stack.py
+pio test -e native -f test_companion_node_prefs \
+ -f test_ble_tx_stall_watchdog -f test_companion_frame_queue \
+ -f test_display_driver
+```
+
+The adapter test compiles the actual transport under address/undefined-behavior
+sanitizers with API doubles. It injects allocation, identity and bond failures;
+checks PIN authentication and stealth; and exercises 176-byte frames, MTU and
+notification subscription gating, retry and duplicate prevention. Embedded
+builds compile against the pinned real library. RAM reports retain the existing
+wireless budget; linked headroom is not a measurement of live free heap.
+The MQTT save callback also has a compiler-enforced stack-frame regression
+check: nested preference copies previously overflowed the V4 loop task when
+saving MQTT settings through WebConfig, despite sufficient free heap.
+
+Hardware qualification must additionally verify pairing and reconnection,
+advertised addresses for every MAC policy, contact synchronization, WiFi/BLE
+coexistence, and uptime under load. BlueZ results do not establish iOS or
+Android camera/pairing compatibility.
diff --git a/docs/test-results/nimble_companion_trial_results.md b/docs/test-results/nimble_companion_trial_results.md
new file mode 100644
index 000000000..4df36eb92
--- /dev/null
+++ b/docs/test-results/nimble_companion_trial_results.md
@@ -0,0 +1,117 @@
+# ESP32-S3 NimBLE trial results, 2026-09-08
+
+These are local hardware trial results for USA Cascade Full Companions on a
+Heltec V4.3 OLED and a Seeed XIAO ESP32-S3 with WIO SX1262. The V4.2 shares the
+profile but was not physically tested. Build and command instructions are in
+[the trial guide](nimble_companion_trial.md).
+
+The final source revision is `9dbab431`, using NimBLE-Arduino 2.5.1 and the
+existing Arduino-ESP32 2.0.17 toolchain. These optional profiles are outside the
+ordinary release matrix. This trial does not change GitHub release assets or
+the firmware picker.
+
+## Capacity
+
+| Measurement | Heltec V4 | XIAO S3 WIO |
+| --- | ---: | ---: |
+| Contacts / channels | 350 / 40 | 350 / 40 |
+| PSRAM-backed offline queue | 512 | 256 |
+| Application image, bytes | 1,774,600 | 1,510,536 |
+| OTA application slot, bytes | 6,553,600 | 3,342,336 |
+| Linked internal capacity before runtime allocation, bytes | 263,944 | 265,576 |
+| Required runtime RAM budget, bytes | 173,056 | 148,480 |
+| Headroom beyond that budget, bytes | 90,888 | 117,096 |
+| Live free heap with Bluetooth and 350 test contacts, bytes | 142,644 | 147,444 |
+| Minimum free heap during that contact test, bytes | 138,024 | 142,788 |
+
+The live contact-test readings are from revision `7f1bd8b1`. Revision
+`9dbab431` retains the same Bluetooth adapter, contact allocation and queue
+sizes, and fixes the MQTT save stack use described below. Linked capacity is
+not live free heap; the firmware memory reports label that distinction.
+
+A matching V4 build using Bluedroid had 248,840 bytes of linked internal
+capacity. NimBLE recovers 15,104 bytes at link time. The matching Bluedroid
+application was 2,169,848 bytes. There was no matching physical Bluedroid heap
+measurement, so these figures do not establish a live heap saving.
+
+## Bluetooth hardware checks
+
+Both boards passed the following cases with BlueZ on the Mercerwood Pi:
+
+- PIN authentication, encryption, stored bonding, and bonded reconnection.
+- MTU 179; accepted 176-byte writes and complete 148-byte contact notifications.
+- Insert and synchronize all 350 synthetic contacts without missing or
+ duplicate entries.
+- Custom MAC address appears over the air in the entered byte order and
+ persists across reboots.
+- Saved random MAC persists; random-every-boot changes on each boot.
+- Random-after-connect retains its address through an unauthenticated boot
+ and rotates on the boot following an authenticated connection.
+- Stealth first pairing, bonded-peer reconnection, and reconnection after
+ reboot with a stable identity.
+- Restore factory MAC and ordinary discovery.
+- Three reconnect samples with no observed downward free-heap trend.
+
+The complete MAC suite ran on `7f1bd8b1`. Final-image connection and update
+checks also verify the unchanged adapter after the MQTT stack fix. Synthetic
+contacts were removed after testing; factory MAC mode and stealth off were
+restored.
+
+## MQTT save stack overflow
+
+The V4 test reproduced an actual reset while saving MQTT settings through
+WebConfig. Serial output identified `Stack canary watchpoint triggered
+(loopTask)`. `onConfigBatchEnd()` kept a second 2.8 KB preference copy while
+the loader and NVS code also used stack space. Free heap was not the limiting
+resource.
+
+The loader already updates its destination only after validation, so the
+callback now passes the live preference object directly. The actual Xtensa
+callback frame shrank from 2,928 bytes to 32 bytes. The regression test
+compiles the real callback with a 512-byte frame ceiling and demonstrates that
+the previous nested-copy implementation fails the check.
+
+On the corrected `9dbab431` image, the 11-setting WebConfig batch completed
+with all entries accepted and no reboot. The V4 connected to a local
+Mosquitto broker, published MQTT traffic, and kept its authenticated Bluetooth
+session alive through a 30-second load interval with continuous uptime and
+zero reported error flags. Free heap measured 120,684 then 122,140 bytes;
+the minimum was 118,332 bytes and the largest free block was 110,580 bytes.
+Disabling the test MQTT slot also completed successfully without reboot.
+
+## WiFi updates and QR
+
+Both boards completed real application-image uploads over WiFi with Bluetooth
+connected, followed by reboot and reconnection using the saved bond. Repeated
+HTTP page requests and Bluetooth status requests ran together before upload.
+The image hash was checked before transmission, and the running firmware
+version was checked after reboot.
+
+The V4 WiFi setup page again contains a QR code. The default open setup SSID
+fits a 44 x 44 pixel code: 2 x 2 pixel modules and an exact one-pixel white
+border. The native test rendered the actual display/QRCode code and decoded
+the result with ZXing. SSID, portal address and the hold-to-stop hint fit
+beside it. Physical phone-camera scanning has not been verified.
+
+## Observed limits
+
+One XIAO warm reboot left its USB endpoint unresponsive while Bluetooth still
+answered. The user's physical unplug/replug restored USB; the later full MAC
+suite completed its commanded reboots successfully. The cause of that one
+USB failure is unconfirmed.
+
+The XIAO initially failed to associate with the saved WiFi network. Reapplying
+the SSID scheduled a fresh connection, after which it joined at -53 dBm and
+completed WiFi OTA. No antenna change was needed for that recovery.
+
+This was functional hardware testing, not a long-duration soak. iOS/Android
+app pairing, phone camera scanning, multiple TLS MQTT brokers, and an
+end-to-end LoRa mOTA transfer were not qualified in this trial. The XIAO parent
+profile does not include direct MQTT; the V4 does.
+
+## Final application hashes
+
+| Hardware | SHA-256 |
+| --- | --- |
+| V4 | `e6a1522ced4ed38ddd00e7cdf8393857224adbb622888a86370c0f7a3ee28d88` |
+| XIAO S3 WIO | `ecf7afe6222e3897556a04e022515f9dcfcb825172dae9eaaa72eb92396dd004` |
diff --git a/docs/test-results/nrf52-usb-ready-fix.md b/docs/test-results/nrf52-usb-ready-fix.md
new file mode 100644
index 000000000..63e7cd6aa
--- /dev/null
+++ b/docs/test-results/nrf52-usb-ready-fix.md
@@ -0,0 +1,46 @@
+# nRF52 USB READY hang
+
+The RAK3401 on Mercer was found stuck in the framework's TinyUSB power handler:
+USBD was enabled and attached, but its write-one-to-clear READY event was
+already consumed. The duplicate-event guard also required HFCLK to be running.
+If that guard missed the completed initialization, the following unbounded
+READY wait could stop application startup. A bootloader update alone cannot
+replace this USB driver in an already installed MeshCore application.
+
+The wait and guard were inherited through the pinned Adafruit nRF52 Arduino
+framework, not introduced by the recent OTAFIX upstream merge. TinyUSB's wait
+dates back at least to 2019; the guard was added in
+[upstream commit 7d9efd0697](https://github.com/hathach/tinyusb/commit/7d9efd06979f6cdde2a4093f0c26e8100312c92c).
+OTAFIX's August 24 HFCLK backport addressed a later clock wait, leaving this
+earlier READY wait uncovered. The exact live interrupt ordering is not known;
+the stalled program counter and consumed event were read directly over SWD.
+
+All nRF52 environments inherit `pre:scripts/nrf52_usb_power_fix.py`. It compiles
+a build-local replacement for the matching framework driver, without changing
+PlatformIO's shared SDK or affecting ESP32 builds. A changed/unrecognized
+framework implementation stops the build for review. The backport:
+
+- requests HFCLK before checking the consumed READY event;
+- rechecks attachment while waiting, including completion by another callback;
+- bounds both waits with one 100,000-poll budget, independent of OS ticks;
+- ignores stale READY on a disabled peripheral and exits if it is removed.
+- replays an already asserted regulator READY state when the application USB
+ task starts with VBUS present, as the bootloader does. The framework's
+ original startup replayed DETECTED only, so a missing later power edge could
+ leave the new application's USB transport unattached after a UF2 handoff.
+
+The poll budget is an iteration limit, not a promised elapsed-time timeout.
+If the clock/peripheral never becomes ready, the caller returns instead of
+freezing the application. A later power event can retry; permanent electrical
+failure can still require reconnecting USB. This does not establish or fix the
+separate Pi hub/controller fault.
+
+Run `python3 -B test/test_nrf52_usb_power.py -v`. The tests compile the actual
+patched handler from the pinned-framework fixture, model W1C event semantics,
+and cover duplicate/nested callbacks, delayed/missing clocks and READY, removal,
+retry, and detached USB. Restoring the inherited READY prefix must reproduce
+the infinite wait under a subprocess deadline. Additional tests check SDK
+isolation, idempotence, fail-closed patching, the startup DETECTED/READY order,
+and all nRF52 environment hooks.
+The harness is shared with OTAFIX's TinyUSB fork at
+`test/otafix/nrf5x_power_test.py`; keep the two copies in sync when extending it.
diff --git a/docs/test-results/nrf52_companion_storage_recovery.md b/docs/test-results/nrf52_companion_storage_recovery.md
new file mode 100644
index 000000000..8f302956f
--- /dev/null
+++ b/docs/test-results/nrf52_companion_storage_recovery.md
@@ -0,0 +1,68 @@
+# nRF52 Companion automatic ExtraFS recovery
+
+Companion builds using internal ExtraFS reserve 100 KiB at
+`0xD4000..0xED000`. Identity and preferences remain in the separate 28 KiB
+primary store at `0xED000..0xF4000`. This applies to internal-ExtraFS Companion
+builds, including Full, not repeaters or external QSPI storage.
+
+At startup the firmware:
+
+1. Mounts primary storage without erasing nonblank media. Only proven-blank
+ primary storage may be initialized automatically.
+2. Checks the exact ExtraFS address, size, block geometry, and application
+ linker reservation, then attempts a non-formatting secondary mount.
+3. Validates primary filesystem metadata before any destructive secondary
+ recovery. An unavailable primary store blocks recovery and identity replacement.
+4. Keeps an already mounted, metadata-valid ExtraFS unchanged. If its initial
+ mount or validation failed, unmounts and retries once without formatting.
+5. If the retry remains unusable, automatically formats only the reserved
+ 100 KiB region, then remounts and validates it. There is one format attempt
+ per boot; failure keeps the contact/channel write quarantine active.
+6. Runs the existing verified migration from primary into ExtraFS. Primary
+ contact/channel sources are retired only after every destination is copied,
+ read back, and the migration transaction is committed. Identity and
+ preferences stay on primary.
+
+No terminal repair command, phone intervention, or bootloader update is needed
+for this recovery path when a compatible application/layout is already used.
+After successful recovery, the Companion storage API reports 100 KiB total
+rather than the 28 KiB primary fallback.
+
+## Data-loss boundary
+
+Automatic rebuilding prioritizes a usable contact/channel store over retaining
+an unreadable secondary filesystem. Data stored only in that damaged region
+can be lost. Surviving primary files are migrated, but may not contain the
+latest secondary-only changes. An API backup remains advisable before flashing.
+Primary storage is never formatted by this recovery path.
+
+This is mount/metadata recovery, not forensic file recovery. An invalid
+migration journal, unreadable individual contact page, primary-source error,
+full filesystem, or failed migration cleanup does **not** on its own trigger a
+format of a mountable, metadata-valid secondary store. Those cases retain the
+existing validation, retry, and incomplete-load protections.
+
+## Regression coverage
+
+`test/test_internal_secondary_fs_repair` exercises healthy/no-format startup,
+successful remount, failed remount/traversal followed by one repair, and
+format/remount/validation failure. `test/test_nrf52_extrafs_contract.py` checks
+primary-first ordering, internal-only geometry guards, quarantine handling,
+and the separation between migration failures and destructive recovery.
+
+## T1000-E hardware qualification (2026-09-03)
+
+A device reporting 25/28 KiB primary fallback and contact enumeration error 5
+was flashed with the recovery build. With no explicit repair command, startup
+activated 100 KiB ExtraFS and loaded all 125 contacts and 40 channel slots.
+Identity, preferences, radio settings, and channels matched the pre-update API
+snapshot. Add, edit, and delete of an owned temporary contact each survived a
+separate normal reboot; the device ended with the original 125 contacts and
+27/100 KiB usage. The 161 offline/native regression tests and target build passed.
+
+This was not lossless recovery of every historical RAM value: 21 contacts had
+older advertisement/local-modification timestamps, including one older advertised
+location. No contact was missing, and no computer-backup restoration was used.
+A subsequent read-only scan found no readable cached advert for any of the 125
+contacts. Without a surviving newer copy, firmware cannot infer those earlier
+values; receiving new verified advertisements can refresh them normally.
diff --git a/docs/test-results/pair_4p6_300us_validation.md b/docs/test-results/pair_4p6_300us_validation.md
new file mode 100644
index 000000000..a63ad0fbc
--- /dev/null
+++ b/docs/test-results/pair_4p6_300us_validation.md
@@ -0,0 +1,71 @@
+# SF7/62.5 + SF8/500: 4.6 slow chirps, 0.3 ms reserve
+
+User-requested follow-up to the [original pair test](pair_preamble32_validation.md).
+Completed 2026-09-15 00:21:27–00:23:52 UTC (September 14 local time).
+
+## Result
+
+| Profile | Requested dwell | Stationary control | Scanning received |
+| --- | ---: | ---: | ---: |
+| Slow: SF7 / 62.5 kHz | 9.421 ms, approximately 4.6 chirps | 100/100 | 97/100 |
+| Fast: SF8 / 500 kHz | 21.847 ms, approximately 42.67 chirps | 100/100 | 100/100 |
+
+Full scan: **197/200 (98.5%)**, not lossless. All three misses were slow-profile
+timeouts with no device error and the chip still in RX. The collector completed
+every scheduled probe, with no RF retry, retune/reset on a miss, or early stop.
+The two controls plus the scan contain 400 unique probes; each fixed transmitter
+acknowledged exactly 200 packets. There were no USB result replays.
+
+This is one finite sample. It does not establish that 4.6 is inherently worse
+than 4.1 or better than 5.1, nor identify the cause of the three losses.
+
+## Timing and unchanged conditions
+
+- Reserve: 300 us; nominal switching allowance: 600 us per hop. Neither is an
+ added settling delay. Nominal cycle: 9.421 + 21.847 + 1.200 = 32.468 ms.
+- Actual switching across 3,957 hops: **0.582 ms mean, 0.827 ms maximum**.
+ Direction means: 0.586 ms toward slow, 0.578 ms toward fast.
+- Five-second RX-only switching window: 0.559 ms mean, 0.779 ms maximum.
+- Non-packet-held cycle: 32.428 ms mean, 34.242 ms maximum. These samples can
+ still include USB/loop scheduling overruns; the reserve is not a hard bound.
+- Observed non-held dwell means: slow 9.426 ms, fast 21.855 ms; maxima 10.480
+ and 23.474 ms respectively. No per-loss timing trace was collected.
+- Successful-packet mean SNR: slow +12.27 dB, fast +13.47 dB; mean RSSI:
+ -20.87 and -18.99 dBm. These remain strong-signal lab conditions.
+
+Same V4 RX, RAK4631 slow TX and T1000-E/LR1110 fast TX; 909.5/910.5 MHz,
+CR4/5, explicit header, CRC, 32-symbol preambles, 16-byte synthetic payloads,
+-9 dBm chip TX power, normal RX gain 0x94, randomized interleaving and 10–180 ms
+arrival delays. Only one transmitter sends at a time. Each TX configures once;
+every packet has zero frequency/modulation writes.
+
+The single-pass production guarded switching path uses warm XOSC and buffered
+8 MHz SPI. Each hop issued exactly one frequency and one modulation command,
+with one optimized RX restart. No double writes, detours, rollback, added
+settling, off-channel acceptance, cache errors, RX errors or retune failures.
+As before, this is a small HIL application, not full production UI/network load.
+
+## Verification and artifacts
+
+Only the V4 application was updated. Exact USB identity/MAC, idle ownership and
+existing partition metadata were checked before writing. The nRF52 transmitter
+images, bootloaders, SoftDevices, gateway and unrelated radios were not flashed.
+All three test radios were rebooted and verified idle afterward; HIL firmware
+was left installed. ModemManager was restored active, gateway and host CLI
+services are active, and the previously paused memory soak remains inactive.
+
+- [Raw capture](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_pair_sf8_32_4p6_300us_results.json), SHA-256
+ `1c2e0eae3f304fcaeb6addfeef4f2f09a3c2bec8d78c60d3f151c952d0f6d5f5`.
+- [Independent offline validation](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_pair_sf8_32_4p6_300us_validated.json)
+ checks complete samples, unique sequences, sender lifetime counts, actual
+ RX frequency/SF/BW/payload, command counts, policies and idle cleanup.
+- [Manifest](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_pair_sf8_32_4p6_300us_manifest.json) records the
+ receiver image/source hashes and prior transmitter-manifest hash. Bundle
+ SHA-256 `fb7a08536ed06e36cf7d5c29babbe2affb30183de05918c4db271b98b796e579`.
+- Remote records remain under
+ `/home/mikec/hwtest/runs/pair-sf8-32-4p6-300us-20260914`.
+- Receiver build passed. HIL host regression suite: **64/64 passed**; new plan
+ guards and the exact dwell/budget arithmetic are covered. Python compile and
+ scoped whitespace checks passed. Collector/wrapper exit zero.
+
+No production timing defaults changed, and no commit or push was made.
diff --git a/docs/test-results/pair_preamble32_validation.md b/docs/test-results/pair_preamble32_validation.md
new file mode 100644
index 000000000..84a12e81f
--- /dev/null
+++ b/docs/test-results/pair_preamble32_validation.md
@@ -0,0 +1,138 @@
+# SF7/62.5 + SF8/500, fast switching and preamble 32
+
+User-requested hardware validation, 2026-09-14. This is a finite HIL experiment,
+not a production reliability guarantee. No production timing defaults or
+automatic-preamble overrides are changed by this test.
+
+## Fixture and method
+
+- Heltec V4 receiver, USB serial `44:1B:F6:69:CF:98`, normal chip RX gain (0x94).
+- RAK4631 `9AB3B64C641BA927`: stationary transmitter at 909.5 MHz, SF7/62.5 kHz.
+- T1000-E/LR1110 `34A9141999729D5D`: stationary transmitter at 910.5 MHz, SF8/500 kHz.
+- CR4/5, explicit header, CRC, 32-symbol preamble, synthetic 16-byte sequence-tagged
+ payloads. -9 dBm chip output. Only one transmitter sends at a time on host request.
+- Each transmitter initializes its assigned settings once. RF-frequency and
+ modulation-command counters must remain unchanged for every TX call.
+- 100 fixed-channel controls per profile before scanning. Require 100/100 on
+ both before making a scan-timing inference.
+- Three scanner settings, 100 packets per profile per setting, randomized
+ interleaving and 10–180 ms arrival delays. A miss does not cause a retry,
+ retune, reset, or early end of the finite sample. The scan expectation expires
+ after 1000 ms, far longer than the test frame and randomized host delay.
+
+| Case | Loop reserve (not a delay) | Slow visit | Fast visit |
+| --- | ---: | ---: | ---: |
+| 4.1-chirp floor | 0 ms | 8.397 ms | 23.171 ms |
+| Spare time used for 5.1 slow chirps | 0 ms | 10.445 ms | 21.123 ms |
+| Previous reserve comparison | 4 ms | 8.397 ms | 19.171 ms |
+
+The 5.1 case reallocates 2.048 ms from the fast visit to the slow visit; it does
+not increase the nominal cycle. The 4 ms reserve is omitted from the first two
+cases. The theoretical switch allowance is 0.6 ms per hop; actual switching is
+measured, never forced to equal that allowance. Changed SF/BW requires one
+modulation command per hop; frequency-only cache-hit timings do not apply.
+
+The scheduler uses the same asymmetric two-profile visit budget and the actual
+production guarded `tuneProfile()` / fast RX implementation, but runs in a small
+RAM-only HIL application, without the full production networking/UI load. All
+retunes are single-pass, warm-XOSC, buffered 8 MHz SPI, zero added settling;
+double frequency writes, detours and rollback experiments are off. Packet/busy
+ownership guards are retained. Explicit 32 on both sides bypasses the historical
+automatic SF8/500 88-symbol rule without removing it from production.
+
+The selected radios already had disposable HIL firmware. Updates are
+application-only: matching nRF52 SoftDevice IDs/address ranges and exact USB
+serial/topology are checked. V4 partition metadata is read back and compared
+before its application-only write. No bootloader, SoftDevice, settings backup,
+gateway firmware or unrelated radio is changed. Prior images/captures remain.
+
+## Results
+
+Finished all 800 unique probes, 2026-09-15 00:04:54–00:11:19 UTC (September 14
+local time). Both stationary controls passed 100/100. All three scans completed
+their full 100-packet sample per profile. No scan achieved 200/200.
+
+| Case | SF7/62.5 received | SF8/500 received | Total | Switch mean / max | Idle cycle mean / max |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| 4.1 chirps, no loop reserve | 99/100 | 100/100 | 199/200 | 0.581 / 0.829 ms | 32.717 / 34.733 ms |
+| 5.1 chirps, no loop reserve | 100/100 | 97/100 | 197/200 | 0.572 / 0.834 ms | 32.706 / 34.978 ms |
+| 4.1 chirps, 4 ms loop reserve | 97/100 | 99/100 | 196/200 | 0.567 / 0.836 ms | 28.701 / 31.195 ms |
+
+Switch timing includes every successful hop during each stage, including
+post-reception hops. The RX-only five-second windows averaged 0.554 ms per hop.
+The largest observed switch was 0.836 ms: 0.6 ms is a nominal allowance, **not a
+measured worst-case bound**. Direction-specific timing is in the validated
+capture. Idle-cycle samples exclude packet-held visits but still include USB
+command/loop scheduling; their occasional roughly 2 ms overshoots are real.
+
+Every scan hop issued exactly one RF-frequency and one modulation command,
+with one optimized RX restart. There were zero added settling delays, double
+passes, frequency repeats, RF retries, USB result replays, device errors,
+retune failures, RX mode errors, cache errors or off-channel acceptances.
+All eight missed packets were sequence-matched RX timeouts with the receiver
+still in RX mode and no device error. Both transmitters acknowledged exactly
+400 transmissions and zero frequency/modulation writes during every packet.
+
+Successful scan packets had mean SNR approximately +12.0 to +12.1 dB on the
+slow profile and +12.8 dB on the fast profile. Mean RSSI was approximately
+-20.4 to -20.7 dBm and -18.5 to -18.9 dBm, respectively. These are strong-signal
+bench results; the controls passed, but weak-signal performance and possible
+strong-signal/front-end effects are not established by this test.
+
+## What the extra chirp changes
+
+An extra SF7/62.5 symbol is 2.048 ms, equivalent to **four SF8/500 symbols**.
+Increasing the slow dwell from 4.1 to 5.1 preserves the full-sweep budget by
+shortening the fast dwell, but increases the fast profile's uninterrupted
+blind interval. With the nominal 0.6 ms hops, the 32-symbol fast preamble's
+remaining time after the slow visit plus two hops falls from 6.787 to 4.739 ms
+(13.26 to 9.26 fast symbols), before acquisition and scheduling overruns.
+This is a timing-budget calculation, not an experimentally proven acquisition
+threshold or a per-miss trace.
+
+The observed 5.1 sample improved the slow result from 99 to 100, while the fast
+result fell from 100 to 97. This is consistent with a dwell/acquisition tradeoff;
+one finite sample per setting does not isolate the cause or establish a
+statistically reliable ranking. Simply fitting a sweep inside a preamble does
+not establish lossless acquisition. The 4 ms reserve is an allowance, not a
+sleep; retaining it also did not produce a lossless result here.
+
+Using genuine spare time above the 4.1 floor remains a reasonable policy to
+investigate, but allocation must protect **both** profiles' acquisition/return
+budgets and measured switching/scheduling overruns. Do not automatically spend
+all of the reserve on longer slow visits, or treat the historic 88-symbol rule
+as disproven by this nearly-lossless sample. No production defaults or warning
+formula were changed by this hardware-test turn.
+
+## Capture integrity, deployment and cleanup
+
+- Raw capture: [profile_pair_sf8_32_results.json](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_pair_sf8_32_results.json),
+ SHA-256 `3472883ade89494b64657486efd1645a04b0a3bebfd0a2e31bbf914341ad792f`.
+- Independent offline checks: [profile_pair_sf8_32_validated.json](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_pair_sf8_32_validated.json).
+ Checks all 800 unique sequences, 100/profile/phase, sender lifetime counts,
+ actual RX frequency/SF/BW/payload identity, command counts, policies and cleanup.
+- [Manifest](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_pair_sf8_32_manifest.json) records source and image hashes.
+ Bundle SHA-256 `d6cb9ff36be109af8906eddbbe8a4657ca793d1351ec4bf50ebbd4909eed2bba`.
+ Local bundle is `out/pair-sf8-32-20260914`; remote capture remains under
+ `/home/mikec/hwtest/runs/pair-sf8-32-20260914`.
+- Initial RAK USB reset returned EPROTO before any firmware write. The preserved
+ [pre-write record](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_pair_sf8_32_deployment-prewrite-usb.json)
+ has an empty writes list. Inspection confirmed the exact RAK serial/topology
+ in its documented CDC-only bootloader PID 0x002A. Deployment was revised to
+ accept that identity (and reset-time disconnect only after exact bootloader
+ re-enumeration); no permission check or board-identity check was bypassed.
+ The revision hash and all three successful application-only writes are saved
+ in the deployment records. No bootloader or SoftDevice was replaced.
+- All three radios were rebooted and live-verified idle, with test firmware left
+ installed. ModemManager was restored active; gateway and host CLI services
+ remained active. The preexisting memory soak remains inactive. No unrelated
+ radio was flashed. Wrapper exit was zero.
+- All three HIL firmware builds passed, run sequentially. The HIL host regression
+ suite passed **63/63 tests**, including the new plan and packet-policy tests.
+ Python compile checks and scoped `git diff --check` passed. No commit or push.
+
+## Follow-up
+
+The requested [4.6-chirp / 0.3 ms reserve follow-up](pair_4p6_300us_validation.md)
+completed 197/200 scanning packets: slow 97/100, fast 100/100, after fresh
+100/100 controls on both profiles. Its captures are separate from this run.
diff --git a/docs/test-results/pr7_review_validation.md b/docs/test-results/pr7_review_validation.md
new file mode 100644
index 000000000..5470df8fe
--- /dev/null
+++ b/docs/test-results/pr7_review_validation.md
@@ -0,0 +1,78 @@
+# PR #7 review and validation
+
+Validated on 2026-09-10 against integration commit
+`fa010196b130dee9a8a6fc6325d794e2f4fb9ede`, which combines base `01cc7b8a`
+with PR head `c002966c` and the review fixes. This includes the stacked ESP32
+memory changes already present in PR #7.
+
+## Review fixes
+
+- Use the [published ExpressLRS Linkflow calibration](https://github.com/ExpressLRS/Targets/blob/504178dcfa469ee32f4290d6ae9ba02e2f2f365e/TX/GEPRC%20900%20Linkflow.json),
+ expose 17–30 dBm, and retain the fixed +2 dBm radio drive and correct RF output
+ after radio recovery. Propagate startup and radio-setting failures.
+- Keep Linkflow LoRa OTA enabled, as intended by the added repeater target.
+- Preserve stored ACLs and flood filters when allocation fails. Release owned
+ buffers and prevent accidental shallow copies of their owners.
+- Release heap OTA workspaces after self-serving windows; retain them for active
+ transfers, manual staging, folder operations, and pending application. Service
+ cleanup on Companions and reset staged-resume flags when a context disappears.
+- Count dynamically allocated clients, filters, and OTA workspaces in the RAM
+ budget. Respect explicit OTA storage ownership in PlatformIO flags.
+
+## Automated validation
+
+- `pio test -e native -e native_kiss_modem`: **1,393/1,393 cases passed**.
+- Real C++ radio-power, heap-context, ACL persistence/CLI, and shared OTA queue
+ tests passed. The applicable radio-power, heap-context, and shared-queue tests
+ also passed on Linux with AddressSanitizer and UndefinedBehaviorSanitizer.
+- Display/inbox, radio receive, static DRAM, and runtime RAM regression tests
+ passed. ACL allocation-failure coverage verifies that stored clients survive.
+- Sequential firmware builds and their memory gates passed for:
+ `GEPRC_Linkflow_900_repeater`, `Heltec_v2_companion_radio_ble`,
+ `Tbeam_SX1262_repeater_observer_mqtt`,
+ `heltec_v4_r8_repeater_observer_mqtt`, `RAK_4631_repeater`, and
+ `SenseCapIndicator-LoRa_companion_radio_full`.
+
+## Indicator hardware validation
+
+The original SenseCAP Indicator LoRa with 8 MiB flash and 8 MiB octal PSRAM
+was reached through the MercerWoodMesh Pi over Wi-Fi. Its MAC, public identity,
+board reply, and existing partition layout were checked before updating.
+USB interfaces were absent, so this run does not qualify USB behavior.
+
+The Full profile was applied to `SenseCapIndicator-LoRa_comp_radio_usb_wifi`.
+The exact application is 2,149,208 bytes:
+
+- Application SHA-256:
+ `7a2b11e731f946af79d1104818c7993d3897e6d8ec1f0121d27284c0f0c8c665`
+- ELF SHA-256, also verified in the application descriptor:
+ `24e60c7785a7aefe3fb0536b0b595bf19e1264e4d5f100d201290dc95eb1761a`
+
+This direct profile build uses the repository's default `v1.17.1` version text
+and `14 Aug 2026` build-date text; the hashes above identify the tested image.
+
+The Wi-Fi updater returned HTTP 200 and booted the application in `app1` at
+`0x400000`. The previous application remains in `app0` at `0x10000`; the
+partition table, NVS, and SPIFFS were not rewritten. Public identity, radio
+parameters, and USB logging preference matched their pre-update values.
+
+The existing Companion stress harness, using a TCP socket adapter, completed
+100 persistent cycles and 100 reconnects in 109.9 seconds: **501 requests and
+501 validated responses**, with no discarded bytes, malformed frames, or device
+error flags. Device info, time, storage, and core statistics were exercised.
+The text terminal exposed all four display-power controls and `display.inbox`.
+The OTA folder service issued a valid `COUNT` request, received an empty-folder
+reply, and returned to its disconnected state after the client closed.
+
+After these checks, four memory snapshots showed 69,548–69,996 bytes of free
+internal heap and a 61,428-byte largest block. PSRAM availability stayed at
+6,408,731 bytes. Final uptime was 262 seconds, with zero error flags and an
+empty outbound queue. This is a short regression check, not a long-term soak.
+Raw baseline, upload, stress, and final-state records are retained on the Pi
+under `/home/mikec/hwtest/runs/pr7-review/`.
+
+No Linkflow module or RF power meter was available in this run. Linkflow power
+calibration is supported by the pinned upstream table and executable driver
+tests, not a physical RF output measurement. The Indicator's ESP32-S3 does not
+exercise the classic ESP32 heap-OTA default; host tests and classic ESP32 builds
+cover that path. Physical display readability was not inspected.
diff --git a/docs/test-results/preamble_detection_investigation.md b/docs/test-results/preamble_detection_investigation.md
new file mode 100644
index 000000000..2bef84837
--- /dev/null
+++ b/docs/test-results/preamble_detection_investigation.md
@@ -0,0 +1,170 @@
+# Off-channel preamble investigation
+
+Measured 2026-09-14. **The SF8 off-channel preamble indication reproduces with
+a stationary, fully initialized receiver, without profile hopping or fast RX
+reuse.** Both boards reproduce it when their transmitter/receiver roles are
+reversed. This is evidence of transmitter-associated off-channel detection in
+this close-range bench setup, not evidence that the channel jump needs more
+settling time.
+
+The earlier SF8 scan's long software guard explains its missed listening
+opportunity. The earlier SF6 scan miss remains unresolved: its trace had no
+preamble indication or guard hold. No production guard or acquisition policy
+was changed during this investigation.
+
+## Controlled experiment
+
+- XIAO ESP32-S3 + WIO SX1262 (COM31) and SenseCAP Indicator ESP32-S3 LoRa
+ (COM28). The Indicator RP2040 and RAK4631 were not modified.
+- Receiver fixed at 910.000 MHz. Transmitter silent, on frequency, or offset
+ by +/-250, +/-500 or +/-1,000 kHz. These are offsets, not bandwidths.
+- 125 kHz bandwidth, SF8 or SF6, CR4/5, 32 programmed preamble symbols,
+ -9 dBm, one 16-byte sequence-tagged synthetic payload per transmitting case.
+- Each case fully initializes both radios. RX uses ordinary `startReceive()`;
+ profile-switch optimization and experimental omission masks are disabled.
+ Buffered 8 MHz SPI remains enabled. No hops, warm-retune reuse, CAD, or
+ `isReceiving()` software guard runs during these controls.
+- TX is prepared before RX is armed. Each receive window lasts 450 ms;
+ all eight cases are shuffled within each balanced round, seed 814910.
+ The full RX window completes even when an off-frequency packet is not
+ received, as expected. An on-frequency positive-control failure stops the
+ experiment. No RF retransmissions are used to repair a result.
+- The HAL records actual outgoing `SetRfFrequency` bytes on both boards,
+ and the frequency word present at `SetTx`. The host verifies these words
+ against the requested frequencies. This checks commands sent to the chip,
+ not an independent measurement of the emitted RF spectrum.
+- Existing IRQ replies are observed and IRQs additionally polled at a nominal
+ 64 us interval. Events are buffered in RAM; timestamps are software
+ observations, not synchronized RF arrival times. Unknown payload contents
+ are not exported.
+
+The three completed matrices contain **128 windows: 112 transmitted probes
+and 16 silent controls**. A separate initial 450 ms receiver-only smoke check
+was also performed. These are bounded diagnostic controls, not another
+100-packets-per-channel capacity sweep.
+
+## Results
+
+Entries below are windows with a preamble indication / tested windows. Each
+positive window reported one indication; latched IRQs do not count continuous
+detector activity or prove a valid packet.
+
+| Transmitter offset | SF8, XIAO RX | SF8, Indicator RX | SF6, XIAO RX |
+| --- | ---: | ---: | ---: |
+| Silent | 0/8 | 0/4 | 0/4 |
+| On frequency | 8/8 | 4/4 | 4/4 |
+| -250 kHz | 8/8 | 4/4 | 1/4 |
+| +250 kHz | 8/8 | 4/4 | 0/4 |
+| -500 kHz | 5/8 | 3/4 | 0/4 |
+| +500 kHz | 1/8 | 3/4 | 0/4 |
+| -1,000 kHz | 5/8 | 4/4 | 0/4 |
+| +1,000 kHz | 5/8 | 3/4 | 0/4 |
+
+All **16 on-frequency probes delivered valid payloads**. None of the 96
+off-frequency probes delivered a valid payload. No silent control detected a
+preamble. Completed windows ended in RX mode with zero device errors; there
+were no foreign valid payloads or trace overflows.
+
+Most off-frequency detections stopped at `PreambleDetected` (0x0004). Four
+progressed further but failed CRC (`seen_irq=0x0056`: preamble, header, RxDone,
+CRC error): two at +250 kHz with XIAO RX, and one each at +250 and +1,000 kHz
+with Indicator RX. Therefore **HeaderValid alone would not prove that these
+off-frequency indications are legitimate traffic**.
+
+In forward SF8 on-frequency controls, receiver signal strength (RSSI) sampled
+at the first preamble was -42 to -41 dBm; at +/-250 kHz it was -91 to -88 dBm.
+These are single-point receiver readings, not calibrated measurements of
+transmitted leakage or a sensitivity test. Board positions and attenuation
+were not systematically varied. Reversing roles reproduces the behavior but
+does not isolate its physical path.
+
+## What this explains
+
+The [earlier SF8 four-channel scan](sf8_5p1_trace_validation.md) missed probe
+13 on 910.250 MHz after detecting a bare preamble while tuned to 910.000 MHz.
+The existing `CustomSX1262::isReceiving()` guard allowed 91 ms for preamble
+progression, consistent with the observed 92.257 ms from first preamble
+observation to the stale-bit clear. That produced approximately 102 ms total
+occupancy on the wrong channel and a 135.145 ms target-channel revisit gap.
+The programmed 32-symbol preamble lasted only 65.536 ms.
+
+The delayed retune itself took 550 us. These controls reproduce the suspect
+off-frequency indication with no retunes at all. Together the evidence
+supports **off-channel acquisition plus a long guard hold** as the SF8
+failure mechanism, rather than slow SPI or a demonstrated need for a longer
+post-jump delay. It does not independently prove the RF source of that one
+historical scan event.
+
+The controlled-transmitter association and quiet silent controls make random
+ambient RF alone an inadequate explanation of the repeated bench result.
+Exact RF cause remains unmeasured: receiver selectivity/false acquisition,
+transmitter spectral leakage, and coupling between the nearby boards are
+not separated by these tests. No spectrum-analyzer capture or calibrated
+attenuation sweep was performed. Increasing channel spacing is not established
+as a fix: SF8 indications also occurred at +/-1 MHz.
+
+The [earlier SF6 miss](sf6_5p1_trace_validation.md) had no observed nonzero IRQ
+or guard hold. The four successful stationary SF6 positive controls show the
+fixed-channel link works, but do not establish the acquisition time needed
+after a retune. These small, differently sized samples are not packet-error
+rate estimates or proof of a zero-loss channel count.
+
+## Follow-up candidates, not implemented fixes
+
+Semtech documents that false LoRa detections can occur and describes
+`SetLoRaSymbNumTimeout` as validating reception over multiple symbols before
+continuing when the modem locks. This is a candidate to investigate, not a
+drop-in verified fix. Its behavior in continuous RX and through each retune
+must be measured. See section 13.4.9, page 99 of the
+[Semtech SX1261/2 datasheet, revision 2.2](https://resource.heltec.cn/download/WiFi_LoRa_32_V4/datasheet/SX1261_2%20V2-2.pdf).
+
+Useful next experiments would qualify/revalidate preambles before granting a
+long scan hold, and repeat the controls with lower signal levels or increased
+physical isolation. Any candidate must preserve legitimate weak signals and
+long preambles. Blindly shortening the current guard, or requiring only a
+header indication, is not justified by these results. No production radio
+change was made in this diagnostic turn.
+
+## Reproduction, provenance, and checks
+
+See the [stationary diagnostic instructions](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_README.md#stationary-off-channel-preamble-controls).
+The same diagnostic firmware binaries were used for all three matrices.
+Base commit `c329cf1fc1e7f2e8c8733ab0d000094730458fe3` plus local changes;
+RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32
+6.11.0 / Arduino ESP32 2.0.17. Both HIL targets built and uploaded successfully.
+All **29 selected host/native tests passed** after testing.
+
+Raw results and SHA-256:
+
+- [Forward SF8, eight rounds](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/preamble_stationary_sf8_results.json):
+ `e876a8b1727c5584aa67cb4fcdcae5ca30701e90f7ed998b5df36c37d245f7b0`
+- [Reverse SF8, four rounds](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/preamble_stationary_sf8_reverse_results.json):
+ `83b576544f807c6b81ee83a7db487137ef2584cb4981e475c7e7ac50e4fa2363`
+- [Forward SF6, four rounds](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/preamble_stationary_sf6_run2_results.json):
+ `bdb65b4e23d8b05c8523cec092533509021f878518a38523b02135b810da991b`
+- [Aborted initial SF6 launch](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/preamble_stationary_sf6_results.json)
+ is retained: no trials and no RF probes. A host reboot-command quoting
+ failure left the diagnostic image unready after the preceding run. After
+ correcting the command and rebooting both boards, the complete SF6 matrix
+ was recorded to the separate `run2` path. A subsequent host cleanup change
+ ensures an already-opened receiver port is closed if opening TX fails.
+
+Tested firmware SHA-256:
+
+- XIAO: `f89da2a2e542df481ff6c26d2492dcc46db8794b5549d3b4f73217767130a21d`
+- Indicator: `e58207c7e63de625bee2d4e4400eb934b2737f51e8babc7c1fddd85c48efdda6`
+
+## Device handoff
+
+Fresh full 8 MiB backups were taken and separately verified before flashing,
+in the private current-user MeshCore backup directory. No backup contents
+or identity/channel secrets are in the diagnostic results.
+
+- XIAO backup: `5e5825b625a0e6ded65aafa72658eb3b15b466316a9efe8239b2f09de9418e63`
+- Indicator backup: `6f834296d4e29e4fe17297aad0a9e2bf2adcf0a1391cf645de1bc51aebfd70de`
+
+Both original images were restored after the controls, separately verified
+against the complete 8 MiB flash with matching digests, and hardware-reset.
+The RAK4631 and Indicator RP2040 were untouched; the XIAO soak logger was not
+restarted. The temporary HIL build overlay was removed. Investigation source,
+tests, and results were initially handed off locally before publication.
diff --git a/docs/test-results/preview-1.17.1.8/BUILD-INFO.json b/docs/test-results/preview-1.17.1.8/BUILD-INFO.json
new file mode 100644
index 000000000..f4f942568
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/BUILD-INFO.json
@@ -0,0 +1,21 @@
+{
+ "schema_version": 1,
+ "version": "v1.17.1.8-halo-keymind-cascade-dev-d5853b9c",
+ "source_commit": "d5853b9ca2f8172622918088fe980d97095ba734",
+ "source_branch": "keymindCascade",
+ "source_worktree_clean": true,
+ "radio": {
+ "preset": "USA Cascade",
+ "frequency_mhz": 910.525,
+ "bandwidth_khz": 62.5,
+ "spreading_factor": 7,
+ "coding_rate": 5
+ },
+ "feature_profile": "cascade",
+ "preview": true,
+ "physical_hardware_testing_performed": false,
+ "build_commands": [
+ "OUTPUT_DIR=out/rak3401-preview-1.17.1.8/no-lora-ota bash build.sh build-firmware RAK_3401_repeater --build-profile standard --firmware-version v1.17.1.8-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade",
+ "OUTPUT_DIR=out/rak3401-preview-1.17.1.8/lora-ota bash build.sh build-firmware RAK_3401_repeater_lora_ota_no_external_sensors --build-profile standard --firmware-version v1.17.1.8-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade"
+ ]
+}
diff --git a/docs/test-results/preview-1.17.1.8/PACKAGE-CHECKS.json b/docs/test-results/preview-1.17.1.8/PACKAGE-CHECKS.json
new file mode 100644
index 000000000..7a592b681
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/PACKAGE-CHECKS.json
@@ -0,0 +1,28 @@
+[
+ {
+ "variant": "no-lora-ota",
+ "application_bytes": 542056,
+ "uf2_start": "0x26000",
+ "uf2_end": "0xaa600",
+ "dfu_crc16_verified": true,
+ "uf2_dfu_application_identical": true,
+ "capabilities_verified": true,
+ "ram_available": 162740,
+ "ram_required": 66904,
+ "uf2": "RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.uf2",
+ "dfu_zip": "RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.zip"
+ },
+ {
+ "variant": "lora-ota",
+ "application_bytes": 531348,
+ "uf2_start": "0x26000",
+ "uf2_end": "0xa7c00",
+ "dfu_crc16_verified": true,
+ "uf2_dfu_application_identical": true,
+ "capabilities_verified": true,
+ "ram_available": 67660,
+ "ram_required": 66904,
+ "uf2": "RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.uf2",
+ "dfu_zip": "RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.zip"
+ }
+]
diff --git a/docs/test-results/preview-1.17.1.8/RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json b/docs/test-results/preview-1.17.1.8/RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json
new file mode 100644
index 000000000..eaabd770c
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json
@@ -0,0 +1,41 @@
+{
+ "artifact_target": "RAK_3401_repeater",
+ "build_profile": "standard",
+ "capabilities": [
+ "profile.standard",
+ "ota.update.bluetooth",
+ "cli.retry_preset",
+ "telemetry.history"
+ ],
+ "ota_update_evidence": "application DFU ZIP verified; matching BLE DFU bootloader required",
+ "ota_update_methods": [
+ "bluetooth"
+ ],
+ "ota_update_verified": true,
+ "platform": "NRF52_PLATFORM",
+ "platformio_env": "RAK_3401_repeater",
+ "reductions": [],
+ "schema_version": 2,
+ "target": "RAK_3401_repeater",
+ "verification": [
+ {
+ "capability": "ota.update.bluetooth",
+ "evidence": "_ZN6BLEDfu5beginEv",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "cli.retry_preset",
+ "evidence": "retry.preset",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "telemetry.history",
+ "evidence": "telemetry.volt.i2c",
+ "present": true,
+ "source": "linked image"
+ }
+ ],
+ "verified": true
+}
diff --git a/docs/test-results/preview-1.17.1.8/RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json b/docs/test-results/preview-1.17.1.8/RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json
new file mode 100644
index 000000000..79f92f5a3
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json
@@ -0,0 +1,38 @@
+{
+ "schema_version": 1,
+ "target": "RAK_3401_repeater",
+ "platform": "NRF52_PLATFORM",
+ "mcu": "nrf52840",
+ "elf_sha256": "e535ff473122d5c8bfb8398114232de4a0ebec85b3afa97bd661e8fb904b2b98",
+ "passed": true,
+ "available_internal_bytes": 162740,
+ "largest_internal_region_bytes": 162740,
+ "required_heap_bytes": 66904,
+ "required_contiguous_bytes": 12616,
+ "components": {
+ "loop_and_callback_stacks": 11264,
+ "core_usb_filesystems_sensors": 12288,
+ "radio_packet_pool": 10240,
+ "client_table": 10256,
+ "flood_filter_table": 12616,
+ "display_pixels_and_driver": 4096,
+ "screen_objects_and_history": 2048,
+ "allocation_and_transient_margin": 4096
+ },
+ "display": "SSD1306Display",
+ "full_companion": false,
+ "regions": [
+ {
+ "start": 536968268,
+ "end": 537131008
+ }
+ ],
+ "scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required.",
+ "artifact_target": "RAK_3401_repeater",
+ "pio_environment": "RAK_3401_repeater",
+ "files": {
+ "RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.uf2": "ee5ace87301281715a0eaa47820750e6bae79f1286e6811766de0052f4b88f63",
+ "RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.zip": "db2a14d93cca17a079040a2ab8b9c9561f2b9e59fd13bed09e8d1a41a6a75dcb",
+ "RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json": "f401a87dd80f32386989126b471fe7ae3facecbb4c23ddbe64d56138bd58b796"
+ }
+}
diff --git a/docs/test-results/preview-1.17.1.8/RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json b/docs/test-results/preview-1.17.1.8/RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json
new file mode 100644
index 000000000..a33838538
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json
@@ -0,0 +1,110 @@
+{
+ "artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors",
+ "build_profile": "standard",
+ "capabilities": [
+ "profile.standard",
+ "ota.update.bluetooth",
+ "ota.update.lora",
+ "cli.retry_preset",
+ "telemetry.history",
+ "ota.cli",
+ "sensor.gps",
+ "sensor.ina219",
+ "sensor.ina226",
+ "sensor.ina260",
+ "sensor.ina3221"
+ ],
+ "ota_update_evidence": "application DFU ZIP verified; matching BLE DFU bootloader required; LoRa mOTA receiver/apply code and EndF storage layout verified: internal_flash_and_retained_ram; matching board/storage OTAFIX bootloader and compatible destination package required",
+ "ota_update_methods": [
+ "bluetooth",
+ "lora"
+ ],
+ "ota_update_requirements": {
+ "lora": {
+ "bootloader": "Matching board/storage OTAFIX bootloader",
+ "bootloader_release": "https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.11.0-OTAFIX2.4.6",
+ "notes": [
+ "Use a destination-specific .mota package matching the board, target, and storage layout.",
+ "An in-place delta requires the exact firmware currently running as its base.",
+ "The package must fit the receiver's staging and apply workspace.",
+ "Artifact inspection verifies compiled support; it does not verify the bootloader installed on a physical device.",
+ "Requires OTAFIX 2.4.6 retained-RAM handoff support; a transfer cannot resume after the receiver restarts."
+ ],
+ "package_types": [
+ "in_place_delta"
+ ],
+ "storage": "internal_flash_and_retained_ram"
+ }
+ },
+ "ota_update_verified": true,
+ "platform": "NRF52_PLATFORM",
+ "platformio_env": "RAK_3401_repeater_lora_ota_no_external_sensors",
+ "reductions": [
+ "selected optional environmental/ranging drivers omitted; INA219/INA226/INA260/INA3221 and board display/RTC/GPS retained; RAK12500 and INA3221 require distinct configured I2C addresses"
+ ],
+ "schema_version": 2,
+ "target": "RAK_3401_repeater_lora_ota_no_external_sensors",
+ "verification": [
+ {
+ "capability": "ota.update.bluetooth",
+ "evidence": "_ZN6BLEDfu5beginEv",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.update.lora",
+ "evidence": "invalid in-place patch geometry",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "cli.retry_preset",
+ "evidence": "retry.preset",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "telemetry.history",
+ "evidence": "telemetry.volt.i2c",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.cli",
+ "evidence": "OTA: status",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "sensor.gps",
+ "evidence": "meshcore.capability.rak_wisblock_gps.v1",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "sensor.ina219",
+ "evidence": "INA219",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "sensor.ina226",
+ "evidence": "INA226",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "sensor.ina260",
+ "evidence": "INA260",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "sensor.ina3221",
+ "evidence": "INA3221",
+ "present": true,
+ "source": "linked image"
+ }
+ ],
+ "verified": true
+}
diff --git a/docs/test-results/preview-1.17.1.8/RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json b/docs/test-results/preview-1.17.1.8/RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json
new file mode 100644
index 000000000..e6d2d6e05
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json
@@ -0,0 +1,38 @@
+{
+ "schema_version": 1,
+ "target": "RAK_3401_repeater_lora_ota_no_external_sensors",
+ "platform": "NRF52_PLATFORM",
+ "mcu": "nrf52840",
+ "elf_sha256": "98297b740f52e278c9533400de7434e33487adae943c1c58434c833248285450",
+ "passed": true,
+ "available_internal_bytes": 67660,
+ "largest_internal_region_bytes": 67660,
+ "required_heap_bytes": 66904,
+ "required_contiguous_bytes": 12616,
+ "components": {
+ "loop_and_callback_stacks": 11264,
+ "core_usb_filesystems_sensors": 12288,
+ "radio_packet_pool": 10240,
+ "client_table": 10256,
+ "flood_filter_table": 12616,
+ "display_pixels_and_driver": 4096,
+ "screen_objects_and_history": 2048,
+ "allocation_and_transient_margin": 4096
+ },
+ "display": "SSD1306Display",
+ "full_companion": false,
+ "regions": [
+ {
+ "start": 536997812,
+ "end": 537065472
+ }
+ ],
+ "scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required.",
+ "artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors",
+ "pio_environment": "RAK_3401_repeater_lora_ota_no_external_sensors",
+ "files": {
+ "RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.uf2": "4725fb48c382d8da834f26a51b8b574167fd8da23ec78352d1dfb35f2265d8b5",
+ "RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.zip": "824d9601cefdbf66f948a38eb89e1a29fc6e23ec8ce5025a24293d0c036bea29",
+ "RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json": "4c8fa6183af5ff9ad1fe49cbb9cd0289f708bd9c97fe0acd2b764c0a6cb26e4f"
+ }
+}
diff --git a/docs/test-results/preview-1.17.1.8/heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json b/docs/test-results/preview-1.17.1.8/heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json
new file mode 100644
index 000000000..84c7e4e30
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json
@@ -0,0 +1,181 @@
+{
+ "artifact_target": "heltec_v4_2_v4_3_companion_radio_full_femon",
+ "build_profile": "full",
+ "capabilities": [
+ "profile.full",
+ "companion.temp_radio",
+ "companion.ota_cli",
+ "companion.usb",
+ "companion.bluetooth",
+ "companion.usb_logging",
+ "companion.usb_mota_source",
+ "companion.mota_sender",
+ "companion.network_terminal",
+ "companion.wifi_ota_seeder",
+ "web.webconfig",
+ "web.unconfigured_setup_cutoff",
+ "companion.direct_mqtt",
+ "logging.usb.packets",
+ "logging.usb.control",
+ "companion.app_wifi.get_enabled",
+ "companion.app_wifi.set_enabled",
+ "companion.app_wifi.get_ssid",
+ "companion.app_wifi.set_ssid",
+ "companion.app_wifi.get_password",
+ "companion.app_wifi.set_password",
+ "companion.app_wifi.get_status",
+ "companion.app_wifi.get_ip",
+ "ota.update.wifi"
+ ],
+ "ota_update_evidence": "firmware fits both OTA application slots; otadata present",
+ "ota_update_methods": [
+ "wifi"
+ ],
+ "ota_update_verified": true,
+ "platform": "ESP32_PLATFORM",
+ "platformio_env": "heltec_v4_2_v4_3_companion_radio_full_femon",
+ "reductions": [],
+ "schema_version": 2,
+ "target": "heltec_v4_2_v4_3_companion_radio_full_femon",
+ "verification": [
+ {
+ "capability": "companion.temp_radio",
+ "evidence": "tempradio",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.ota_cli",
+ "evidence": "ota folder",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.usb",
+ "evidence": "+++MESHCORE-TERM-START",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.bluetooth",
+ "evidence": "Companion: starting Bluetooth",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.usb_logging",
+ "evidence": "get usb.logging",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.usb_mota_source",
+ "evidence": "ota folder on",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.mota_sender",
+ "evidence": "_ZN4mesh3ota16SerialMotaSource4read",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.network_terminal",
+ "evidence": "USB currently owns the Full Companion terminal",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.wifi_ota_seeder",
+ "evidence": "OTA seeder listening on :",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "web.webconfig",
+ "evidence": "start webconfig",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "web.unconfigured_setup_cutoff",
+ "evidence": "WiFi still unconfigured after",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "companion.direct_mqtt",
+ "evidence": "mqtt.status",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "logging.usb.packets",
+ "evidence": "%s: %s, len=%d (type=%d, route=%s, payload_len=%d)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.control",
+ "evidence": "get usb.logging",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "companion.app_wifi.get_enabled",
+ "evidence": "get wifi.enabled",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "companion.app_wifi.set_enabled",
+ "evidence": "set wifi.enabled ",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "companion.app_wifi.get_ssid",
+ "evidence": "> (not set)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "companion.app_wifi.set_ssid",
+ "evidence": "OK - WiFi SSID saved",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "companion.app_wifi.get_password",
+ "evidence": "get wifi.pwd",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "companion.app_wifi.set_password",
+ "evidence": "OK - WiFi password saved",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "companion.app_wifi.get_status",
+ "evidence": "wifi.status",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "companion.app_wifi.get_ip",
+ "evidence": "get wifi.ip",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "ota.update.wifi",
+ "evidence": "MeshCore firmware update",
+ "present": true,
+ "source": "linked gzip asset"
+ }
+ ],
+ "verified": true
+}
diff --git a/docs/test-results/preview-1.17.1.8/heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json b/docs/test-results/preview-1.17.1.8/heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json
new file mode 100644
index 000000000..92eb772d9
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json
@@ -0,0 +1,44 @@
+{
+ "schema_version": 1,
+ "target": "heltec_v4_2_v4_3_companion_radio_full_femon",
+ "platform": "ESP32_PLATFORM",
+ "mcu": "esp32s3",
+ "elf_sha256": "225a3d11b2be308a3a8730f6d5e2599f8a31d9f653b6fca866a3b7e2c79719c7",
+ "passed": true,
+ "available_internal_bytes": 237048,
+ "largest_internal_region_bytes": 204280,
+ "required_heap_bytes": 177920,
+ "required_contiguous_bytes": 11008,
+ "components": {
+ "core_tasks_usb_filesystems": 24576,
+ "wireless_stacks": 81920,
+ "mqtt_connections_buffers": 24576,
+ "ota_source_scratch": 8192,
+ "browser_terminal_session": 2048,
+ "radio_packet_pool": 5120,
+ "display_pixels_and_driver": 4096,
+ "screen_objects_and_history": 8192,
+ "expanded_message_previews": 2816,
+ "allocation_and_transient_margin": 16384
+ },
+ "display": "SSD1306Display",
+ "full_companion": true,
+ "regions": [
+ {
+ "start": 1070299416,
+ "end": 1070503696
+ },
+ {
+ "start": 1070530560,
+ "end": 1070563328
+ }
+ ],
+ "scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required.",
+ "artifact_target": "heltec_v4_2_v4_3_companion_radio_full_femon",
+ "pio_environment": "heltec_v4_2_v4_3_companion_radio_full_femon",
+ "files": {
+ "heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.bin": "dcbdbeec7b369c8eabaaadb6072851d04cf30417ffe1ead5f4bea5e57c1ba950",
+ "heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a-merged.bin": "877884505324e32f5ac732c1cc346c5a4139204962560b9b413963e5402ee85c",
+ "heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json": "d3cfdb5c8a98364e874354401ecfc3ec0a3900566c78051a24ab1e38e74d4cba"
+ }
+}
diff --git a/docs/test-results/preview-1.17.1.8/heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json b/docs/test-results/preview-1.17.1.8/heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json
new file mode 100644
index 000000000..551ad8288
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json
@@ -0,0 +1,86 @@
+{
+ "artifact_target": "heltec_v4_repeater",
+ "build_profile": "full",
+ "capabilities": [
+ "bridge.espnow",
+ "profile.full",
+ "release.full.same_partition_successor",
+ "ota.update.wifi",
+ "ota.update.lora",
+ "cli.retry_preset",
+ "telemetry.history",
+ "ota.cli",
+ "web.webconfig",
+ "logging.usb.packets",
+ "logging.usb.control",
+ "logging.usb.output"
+ ],
+ "ota_update_evidence": "firmware fits both OTA application slots; otadata present",
+ "ota_update_methods": [
+ "wifi",
+ "lora"
+ ],
+ "ota_update_verified": true,
+ "platform": "ESP32_PLATFORM",
+ "platformio_env": "heltec_v4_repeater_observer_mqtt",
+ "reductions": [],
+ "schema_version": 2,
+ "target": "heltec_v4_repeater",
+ "verification": [
+ {
+ "capability": "ota.update.wifi",
+ "evidence": "Started: http://%s/update",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.update.lora",
+ "evidence": "image_hash MISMATCH after decode",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "cli.retry_preset",
+ "evidence": "retry.preset",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "telemetry.history",
+ "evidence": "telemetry.volt.i2c",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.cli",
+ "evidence": "OTA: status",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "web.webconfig",
+ "evidence": "start webconfig",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "logging.usb.packets",
+ "evidence": "%s: %s, len=%d (type=%d, route=%s, payload_len=%d)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.control",
+ "evidence": "OK - USB logging %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.output",
+ "evidence": "OK - logging.output %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ }
+ ],
+ "verified": true
+}
diff --git a/docs/test-results/preview-1.17.1.8/heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json b/docs/test-results/preview-1.17.1.8/heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json
new file mode 100644
index 000000000..f34082f25
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json
@@ -0,0 +1,46 @@
+{
+ "schema_version": 1,
+ "target": "heltec_v4_repeater",
+ "platform": "ESP32_PLATFORM",
+ "mcu": "esp32s3",
+ "elf_sha256": "1c1b461e95b447d2b00287825618743cc33dd1e44ee332d2d343f9c14fd51697",
+ "passed": true,
+ "available_internal_bytes": 187728,
+ "largest_internal_region_bytes": 154960,
+ "required_heap_bytes": 164784,
+ "required_contiguous_bytes": 12616,
+ "components": {
+ "core_tasks_usb_filesystems": 24576,
+ "wireless_stacks": 49152,
+ "mqtt_connections_buffers": 24576,
+ "ota_source_scratch": 8192,
+ "browser_terminal_session": 2048,
+ "neighbor_history": 600,
+ "radio_packet_pool": 10240,
+ "client_table": 10256,
+ "flood_filter_table": 12616,
+ "display_pixels_and_driver": 4096,
+ "screen_objects_and_history": 2048,
+ "allocation_and_transient_margin": 16384
+ },
+ "display": "SSD1306Display",
+ "full_companion": false,
+ "regions": [
+ {
+ "start": 1070348736,
+ "end": 1070503696
+ },
+ {
+ "start": 1070530560,
+ "end": 1070563328
+ }
+ ],
+ "scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required.",
+ "artifact_target": "heltec_v4_repeater",
+ "pio_environment": "heltec_v4_repeater_observer_mqtt",
+ "files": {
+ "heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.bin": "11ed65664aceaac41086f76b4086ca78d97be96634be3fcdfe37f11ce8d60534",
+ "heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a-merged.bin": "f9b1d9096ba637d06f9dda11dd586934e609b600a85913234e0dcfce406d9993",
+ "heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json": "113223333763da5a9fef4070680dd1f5ec369776e8559fcedd3ff7802f515740"
+ }
+}
diff --git a/docs/test-results/preview-1.17.1.8/heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json b/docs/test-results/preview-1.17.1.8/heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json
new file mode 100644
index 000000000..6f83512e1
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json
@@ -0,0 +1,93 @@
+{
+ "artifact_target": "heltec_v4_room_server",
+ "build_profile": "full",
+ "capabilities": [
+ "bridge.espnow",
+ "profile.full",
+ "release.full.same_partition_successor",
+ "ota.update.wifi",
+ "ota.update.lora",
+ "cli.retry_preset",
+ "telemetry.history",
+ "ota.cli",
+ "web.webconfig",
+ "room.flood_rule_engine",
+ "logging.usb.packets",
+ "logging.usb.control",
+ "logging.usb.output"
+ ],
+ "ota_update_evidence": "firmware fits both OTA application slots; otadata present",
+ "ota_update_methods": [
+ "wifi",
+ "lora"
+ ],
+ "ota_update_verified": true,
+ "platform": "ESP32_PLATFORM",
+ "platformio_env": "heltec_v4_room_server_observer_mqtt",
+ "reductions": [],
+ "schema_version": 2,
+ "target": "heltec_v4_room_server",
+ "verification": [
+ {
+ "capability": "ota.update.wifi",
+ "evidence": "Started: http://%s/update",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.update.lora",
+ "evidence": "image_hash MISMATCH after decode",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "cli.retry_preset",
+ "evidence": "retry.preset",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "telemetry.history",
+ "evidence": "telemetry.volt.i2c",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.cli",
+ "evidence": "OTA: status",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "web.webconfig",
+ "evidence": "start webconfig",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "room.flood_rule_engine",
+ "evidence": "flood.rule",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "logging.usb.packets",
+ "evidence": "%s: %s, len=%d (type=%d, route=%s, payload_len=%d)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.control",
+ "evidence": "OK - USB logging %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.output",
+ "evidence": "OK - logging.output %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ }
+ ],
+ "verified": true
+}
diff --git a/docs/test-results/preview-1.17.1.8/heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json b/docs/test-results/preview-1.17.1.8/heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json
new file mode 100644
index 000000000..4db2bd424
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json
@@ -0,0 +1,46 @@
+{
+ "schema_version": 1,
+ "target": "heltec_v4_room_server",
+ "platform": "ESP32_PLATFORM",
+ "mcu": "esp32s3",
+ "elf_sha256": "3eb37eb59c76023d08b6b4240390adda686272d90acce81679f9a7f8188e0d94",
+ "passed": true,
+ "available_internal_bytes": 203800,
+ "largest_internal_region_bytes": 171032,
+ "required_heap_bytes": 158384,
+ "required_contiguous_bytes": 10256,
+ "components": {
+ "core_tasks_usb_filesystems": 24576,
+ "wireless_stacks": 49152,
+ "mqtt_connections_buffers": 24576,
+ "ota_source_scratch": 8192,
+ "browser_terminal_session": 2048,
+ "neighbor_history": 600,
+ "radio_packet_pool": 10240,
+ "client_table": 10256,
+ "room_flood_rule_table": 6216,
+ "display_pixels_and_driver": 4096,
+ "screen_objects_and_history": 2048,
+ "allocation_and_transient_margin": 16384
+ },
+ "display": "SSD1306Display",
+ "full_companion": false,
+ "regions": [
+ {
+ "start": 1070332664,
+ "end": 1070503696
+ },
+ {
+ "start": 1070530560,
+ "end": 1070563328
+ }
+ ],
+ "scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required.",
+ "artifact_target": "heltec_v4_room_server",
+ "pio_environment": "heltec_v4_room_server_observer_mqtt",
+ "files": {
+ "heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.bin": "8e3b1ba0233101446d30c716cba368cccebd7ae431ab97e51de4b8813fcd7b3b",
+ "heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a-merged.bin": "070c7eeaa58f9fa8cbceea96fa4a2439d8e87fb20113a1c9409dfadba092d1f3",
+ "heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json": "98cc0e73739c8fd45fd0be2a4f744d029aebaa0bc33068f864bd63d711005ad3"
+ }
+}
diff --git a/docs/test-results/preview-1.17.1.8/heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json b/docs/test-results/preview-1.17.1.8/heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json
new file mode 100644
index 000000000..772dee1f1
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json
@@ -0,0 +1,64 @@
+{
+ "artifact_target": "heltec_v4_sensor",
+ "build_profile": "full",
+ "capabilities": [
+ "profile.full",
+ "release.full.same_partition_successor",
+ "ota.update.wifi",
+ "ota.update.lora",
+ "ota.cli",
+ "web.webconfig",
+ "logging.usb.packets",
+ "logging.usb.control"
+ ],
+ "ota_update_evidence": "firmware fits both OTA application slots; otadata present",
+ "ota_update_methods": [
+ "wifi",
+ "lora"
+ ],
+ "ota_update_verified": true,
+ "platform": "ESP32_PLATFORM",
+ "platformio_env": "heltec_v4_sensor",
+ "reductions": [],
+ "schema_version": 2,
+ "target": "heltec_v4_sensor",
+ "verification": [
+ {
+ "capability": "ota.update.wifi",
+ "evidence": "Started: http://%s/update",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.update.lora",
+ "evidence": "image_hash MISMATCH after decode",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.cli",
+ "evidence": "OTA: status",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "web.webconfig",
+ "evidence": "start webconfig",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "logging.usb.packets",
+ "evidence": "%s: %s, len=%d (type=%d, route=%s, payload_len=%d)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.control",
+ "evidence": "OK - USB logging %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ }
+ ],
+ "verified": true
+}
diff --git a/docs/test-results/preview-1.17.1.8/heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json b/docs/test-results/preview-1.17.1.8/heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json
new file mode 100644
index 000000000..44c7434dc
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json
@@ -0,0 +1,44 @@
+{
+ "schema_version": 1,
+ "target": "heltec_v4_sensor",
+ "platform": "ESP32_PLATFORM",
+ "mcu": "esp32s3",
+ "elf_sha256": "bb9f260e5d757f43ac1d71192999b5d24cac674fc23f17b29a49f53a3036aa0d",
+ "passed": true,
+ "available_internal_bytes": 277256,
+ "largest_internal_region_bytes": 244488,
+ "required_heap_bytes": 127592,
+ "required_contiguous_bytes": 10256,
+ "components": {
+ "core_tasks_usb_filesystems": 24576,
+ "wireless_stacks": 49152,
+ "ota_source_scratch": 8192,
+ "browser_terminal_session": 2048,
+ "neighbor_history": 600,
+ "radio_packet_pool": 10240,
+ "client_table": 10256,
+ "display_pixels_and_driver": 4096,
+ "screen_objects_and_history": 2048,
+ "allocation_and_transient_margin": 16384
+ },
+ "display": "SSD1306Display",
+ "full_companion": false,
+ "regions": [
+ {
+ "start": 1070259208,
+ "end": 1070503696
+ },
+ {
+ "start": 1070530560,
+ "end": 1070563328
+ }
+ ],
+ "scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required.",
+ "artifact_target": "heltec_v4_sensor",
+ "pio_environment": "heltec_v4_sensor",
+ "files": {
+ "heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.bin": "af430ddb17974966e6c3f10e67515e0e29526b0c1ea339f017923b17362f4b68",
+ "heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a-merged.bin": "82bc1735d9aeb2a5d660db982ab9822056e55419654e819f226460de8127b976",
+ "heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json": "6ebb9ee0401cb7c011a6aa1ff4c41b2e569c6fbabcd307ee344de3c5e3a0fa60"
+ }
+}
diff --git a/docs/test-results/preview-1.17.1.8/index.md b/docs/test-results/preview-1.17.1.8/index.md
new file mode 100644
index 000000000..79529d616
--- /dev/null
+++ b/docs/test-results/preview-1.17.1.8/index.md
@@ -0,0 +1,25 @@
+# 1.17.1.8 Preview 1 test results
+
+This preview contains USA Cascade builds for RAK3401 Repeater and Heltec V4 OLED Full Companion, Full Repeater, Full Room Server, and Full Sensor. The [GitHub prerelease](https://github.com/mikecarper/MeshCore/releases/tag/rak3401-preview-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c) has the matching firmware assets.
+
+The [RAK3401 source test run](https://github.com/mikecarper/MeshCore/actions/runs/36810565194) and [Heltec V4 source test run](https://github.com/mikecarper/MeshCore/actions/runs/36826971285) passed. The RAK3401 UF2 and DFU application payloads match, with verified DFU CRCs. Both RAK3401 packages passed capability and memory checks. The four Heltec V4 OLED variants passed capability and memory qualification. These are build/package checks, not physical on-device tests of every variant.
+
+## RAK3401 package results
+
+- [Build information](BUILD-INFO.json)
+- [UF2, DFU, capability, and memory checks](PACKAGE-CHECKS.json)
+- [RAK3401 LoRa OTA capabilities](RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json)
+- [RAK3401 LoRa OTA memory](RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json)
+- [RAK3401 standard capabilities](RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.capabilities.json)
+- [RAK3401 standard memory](RAK_3401_repeater-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json)
+
+## Heltec V4 OLED reports
+
+- [Full Companion capabilities](heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json)
+- [Full Companion memory](heltec_v4_2_v4_3_companion_radio_full_femon-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json)
+- [Full Repeater capabilities](heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json)
+- [Full Repeater memory](heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json)
+- [Full Room Server capabilities](heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json)
+- [Full Room Server memory](heltec_v4_room_server-full-usb-wifi-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json)
+- [Full Sensor capabilities](heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.capabilities.json)
+- [Full Sensor memory](heltec_v4_sensor-full-logging-ota-v1.17.1.8-halo-keymind-cascade-preview1-cab781f9a.memory.json)
diff --git a/docs/test-results/radio_chirp_math_validation.md b/docs/test-results/radio_chirp_math_validation.md
new file mode 100644
index 000000000..3b5cdead5
--- /dev/null
+++ b/docs/test-results/radio_chirp_math_validation.md
@@ -0,0 +1,135 @@
+# Shared radio/radio2 chirp timing and preamble warnings
+
+Historical validation record: the nominal 600 us policy described below has
+been replaced by the activation self-test and 10% guarded measured budget.
+The later automatic policy also removed the pair-specific 88-symbol SF8/500
+floor and the fixed 300 us loop allowance; the older calculations below remain
+as test history.
+See [radio profiles](../radio_profiles.md#preamble) for
+current behavior.
+
+Implemented in the `keymindCascade` working tree, 2026-09-14. The initial
+4.1/4000-us implementation record below is retained as history; the
+[4.6/300-us policy update](#automatic-46-chirp-300-us-policy-update) was a prior stage.
+No radios were flashed or RF experiments started for either function-only change.
+
+## Initial implementation scope
+
+- One portable timing function in `src/RadioProfiles.h` calculates both profiles
+ from their own SF/BW symbol times, optional dwell times and explicit budgets.
+- The production dwell minimum is now 4.1 chirps on both profiles, rounded up to
+ a whole microsecond. The slow visit changed from 4.8 to 4.1; the old four-chirp
+ fast minimum changed to 4.1. The existing asymmetric scheduler may allocate
+ a longer fast-channel visit from the slow preamble's return deadline.
+- Automatic preambles and diagnostics share the same requirement calculation.
+ The hot retune path computes only the requested profile, not a full diagnostic
+ structure or extra hardware reads.
+- Round recommendations upward to multiples of eight, minimum 32. Preserve
+ the prior measured 88-symbol SF8/500-versus-SF7/62.5 floor.
+- Warn by profile name with the recommended preamble when the requirement is
+ above 32 or above an explicitly selected short value. Successful radio-setting
+ replies include `WARN recommended preamble: radio=...` and/or `radio2=...`.
+ Existing explicit values are not overwritten.
+ Existing unsafe-airtime and infeasible-return-deadline rejection stays intact.
+- `get radio2.timing` / `get radio.timing` is read-only. It reports both dwell
+ values and requirements in `radio,radio2` order. Settings/getters and scheduling
+ acknowledgments include warnings; successful primary-setting responses use a
+ preview of the requested SF/BW, not stale live settings.
+
+## Evidence and limits
+
+The [five-radio single-pass bench](four_fixed_tx_single_pass_validation.md)
+received 392/400 at 5.1 chirps. The [longer-dwell repeats](four_fixed_tx_dwell_validation.md)
+scored 342/348 before the user stopped 6.1, and 395/400 at 7.7. They do not
+establish any lossless four-channel dwell. They also do not justify treating
+one full sweep fitting inside the preamble as proof of acquisition.
+
+The function retains the earlier production two-profile policy: two return
+opportunities on the slower channel, blind time plus sixteen acquisition
+symbols on the faster channel. Its 4.1 floor is the requested policy boundary,
+not an experimentally established detection minimum. This change requires RF
+qualification before describing it as a reliability improvement.
+
+Single-pass V4 switching peaked at 804 us including post-reception cache refresh.
+Indicator reached 8428 us in the prior receive-only study. The pure function can
+model either allowance. Per the follow-up request, runtime scheduling now uses
+a nominal 600 us switch / 4000 us loop budget; advisory calculations raise the
+switch allowance if the observed maximum exceeds 600 us. The 0.6 ms nominal
+value does not cover all measured switches. They do not dynamically retune the production
+scheduler or silently lengthen already configured preambles.
+
+See [the formula and CLI examples](../radio_profiles.md#chirp-calculator-and-warnings).
+
+## Verification
+
+- Portable C++ property sweep: 1,600 SF/BW pairs (SF5 through SF12, five bandwidths
+ on each side), minimum dwell, rounding, automatic/diagnostic consistency and
+ the two-return deadline for accepted tuples.
+- Exact SF10/125 symbol duration and 4.1 floor; SF6/125 comparison using 1000 us
+ versus 9000 us switching; reversed profile order; invalid/non-finite inputs;
+ explicit long/short preambles; advisory-only overrun handling.
+- Production CLI tests cover warnings before delayed publication, named profiles,
+ both timing aliases, single-profile off, explicit short overrides, saved versus
+ temporary primary settings, read-only enforcement and bounded reply buffers.
+- Production scan/transition harness retains packet ownership, busy deferral,
+ failed-retune rollback, receive restoration and oscillator-policy checks.
+- Native `test_trace_retry`: 63/63 tests passed, including the updated automatic
+ preamble expectations and profile routing/retry/scheduling tests.
+
+- Host regression suites: 22/22 tests passed across chirp math, profile CLI,
+ scan transitions, setting contracts, temporary-reply delivery, STM32 float
+ formatting, local/LoRa CLI access and repeater timing integration. The CLI
+ fixtures include the new warning method. Windows MinGW runs the local-access
+ assertions without unavailable ASan/UBSan runtimes; Linux retains those flags.
+- Full `heltec_v4_repeater` build after the 0.6 ms follow-up: passed;
+ RAM 125,344 bytes, flash 1,597,789 bytes, runtime-RAM checks passed.
+- Full `heltec_v4_companion_radio_usb` build after the follow-up: passed;
+ RAM 62,460 bytes, flash 828,589 bytes, runtime-RAM checks passed.
+- The rerun passed all 22 host tests and 63 native cases, including actual
+ primary-setting reply dispatch (local and LoRa), requested-tuple previews,
+ a recommended 32-symbol preamble for an explicit 16-symbol override, and
+ the nominal 600 us allowance. The 4.1-symbol floor and 4000 us loop margin
+ remain unchanged.
+- `git diff --check`: passed. No firmware was uploaded and no commit or push
+ was made for this change.
+
+## Automatic 4.6-chirp / 300-us policy update
+
+After the separate [4.6/0.3 ms hardware test](pair_4p6_300us_validation.md), the
+user requested this policy in the shared production function. That sample was
+197/200 (slow 97/100, fast 100/100), not evidence of lossless operation.
+
+- Slow-profile dwell is now `ceil(4.6 * symbol_us)`; the fast floor remains
+ `ceil(4.1 * symbol_us)`. Slow/fast is selected by symbol duration, not profile
+ index (primary wins equal-symbol ties). Single-profile mode is unchanged.
+- Keep 600 us per switch and use 300 us of loop reserve. Allocate the fast
+ visit from half the configured slow preamble duration minus the slow visit,
+ two switches and the reserve. The reserve is not an added delay.
+- The pure calculator, automatic preamble calculation, real scheduler and
+ acceptance checks all use the same role-specific rounded minima. Advisory
+ warnings still account for measured switch overruns without rewriting
+ explicit preambles or persisted configuration.
+- SF7/62.5 + SF8/500 with a slow preamble of 32 yields **9,421 us slow and
+ 21,847 us fast**, matching the tested plan, in either profile order.
+- Preserve unsafe-airtime/short-slow-preamble rejection, eight-symbol rounding,
+ the 32-symbol recommendation minimum, and the historical SF8/500 88-symbol
+ floor. For SF7/500 paired with SF7/62.5, the automatic fast preamble changes
+ from 72 to 64 because the reserve is smaller. Explicit 32 remains explicit,
+ with the existing short-override warning.
+
+Verification covers 1,600 SF/BW pairs with exact role-dependent rounding and
+fast-dwell arithmetic, profile reversal, equal-symbol ties, larger explicit
+slow preambles, infeasible short slow preambles, single-profile off, long
+explicit visits, and advisory-only switch overruns. Production CLI checks
+assert the actual `4.60,42.67` chirp readout, `loop=300us`, warning values and
+unchanged explicit 32 on the tested SF8/500 pair. The frozen HIL plan now
+checks equality against the production 4.6/300-us allocation.
+
+- Host production regression suites: 15/15 passed.
+- Native production radio/routing/retry suite: 63/63 passed.
+- HIL host regression suites: 64/64 passed.
+- Full V4 repeater build: passed; RAM 125,344 bytes, flash 1,597,881 bytes.
+- Full V4 USB companion build: passed; RAM 62,460 bytes, flash 828,657 bytes.
+ Both passed runtime-RAM checks; builds ran sequentially in one process.
+- Scoped `git diff --check`: passed.
+- No hardware upload, commit or push for this policy update.
diff --git a/docs/test-results/radio_dwell_policy_validation.md b/docs/test-results/radio_dwell_policy_validation.md
new file mode 100644
index 000000000..4d88dff99
--- /dev/null
+++ b/docs/test-results/radio_dwell_policy_validation.md
@@ -0,0 +1,69 @@
+# Automatic dwell policy: 4.6 chirps on both profiles
+
+Historical validation record: the nominal 600 us switch allowance below has
+been replaced by the activation self-test and a 10% guarded measured budget.
+The current automatic policy also removed the fixed 300 us loop allowance and
+the pair-specific SF8/500 88-symbol floor. Values below are retained as bench
+history, not current automatic defaults.
+See [radio profiles](../radio_profiles.md#preamble) for current behavior.
+
+The shared `RadioProfiles` function uses a 4.6-symbol minimum for both logical
+profiles of one physical transceiver, rounded upward to a whole microsecond.
+The slower profile is selected by symbol duration, with primary winning ties.
+The fast visit receives the remaining half-slow-preamble budget, subject to
+the same 4.6-symbol minimum. The nominal allowances are 600 us per switch and
+300 us per cycle, not added delays.
+
+The scheduler, automatic preambles, configuration acceptance and diagnostic
+calculator use the same minima. Preserve explicit preambles, safe-airtime
+validation, too-short slow-preamble rejection, recommendation rounding to
+multiples of eight (minimum 32), and the existing SF8/500 versus SF7/62.5
+88-symbol recommendation floor. Measured switching overruns raise advisory
+warnings without rewriting configured values. Single-profile RX still does
+not perform a scan.
+
+For SF7/62.5 plus SF8/500 with slow preamble 32, the allocation remains
+9,421 us slow and 21,847 us fast, in either profile order. The fast visit
+already exceeds 4.6 fast symbols. Raising the fast minimum matters when
+less budget is available: equal SF7/500 profiles with slow preamble 30 no
+longer fit, because only 1,162 us would remain versus the new 1,178 us floor.
+Slow preamble 32 fits with a 1,418 us fast visit.
+
+## Hardware context
+
+The preceding V4 RX / fixed RAK4631 and T1000-E TX bench used explicit
+32-symbol preambles on both profiles, 100 stationary controls per profile
+and a complete 100/profile scan. Controls passed 100/100 each; scanning
+received slow 97/100 and fast 100/100 (197/200 total). Switching averaged
+582 us and peaked at 827 us. Successful-packet mean SNR was +12.27/+13.47 dB.
+No RF retry or USB result replay was used. Radios were left idle.
+
+That test used the same 9,421/21,847 us windows, so the higher fast floor
+does not change this particular allocation. It does not establish lossless
+performance or qualify other SF/BW combinations. Raw capture SHA-256:
+`1c2e0eae3f304fcaeb6addfeef4f2f09a3c2bec8d78c60d3f151c952d0f6d5f5`.
+No additional RF run is required to apply the user-requested policy change.
+
+## Software verification
+
+The portable C++ test sweeps 1,600 SF/BW pairs and checks both 4.6-symbol
+minima, exact fast-window allocation, preamble/diagnostic consistency and
+accepted return deadlines. It also covers reversed/equal symbol times,
+explicit preambles, newly infeasible short settings, long requested visits,
+invalid parameters and advisory-only switching overruns. CLI tests exercise
+warnings, both timing aliases, saved/temporary configuration, persistence
+and bounded replies. Native integration covers routing, retries and packet
+ownership alongside the timing model.
+
+Verification completed on 2026-09-14 (local date):
+
+- Exact commit snapshot: 24/24 host production tests passed, including primary
+ CLI settings, shared timing, command dispatch, persistence and reply checks.
+- Native radio/routing/retry integration: 63/63 passed.
+- Existing HIL host regressions: 64/64 passed in the working checkout.
+- Both firmware builds passed from the isolated commit snapshot, without the
+ unrelated local lab/Wi-Fi/extra modulation-cache edits: V4 repeater RAM
+ 125,336 bytes / flash 1,597,497 bytes; USB companion RAM 62,452 bytes /
+ flash 828,257 bytes. Both passed runtime-RAM checks.
+- Staged whitespace and documentation-link checks passed. No radios were
+ flashed for this function update.
diff --git a/docs/test-results/radio_profile_switch_validation.md b/docs/test-results/radio_profile_switch_validation.md
new file mode 100644
index 000000000..7e7b291bb
--- /dev/null
+++ b/docs/test-results/radio_profile_switch_validation.md
@@ -0,0 +1,524 @@
+# SX1262 profile-switch validation
+
+Measured locally on 2026-09-13 using an XIAO ESP32-S3 with WIO SX1262.
+Later tests also use the SenseCAP Indicator ESP32-S3 LoRa processor. The
+[V8 integration](#production-integration-and-usb-recovery-v8) is the current
+implementation; V1-V7 sections/captures document the development history.
+
+## Initial result (V1)
+
+The production retune path averaged **1.434 ms with XOSC held**, versus
+**3.166 ms with RC standby**. Holding the oscillator saved **1.732 ms per
+retune (54.7%)**. Warm-mode maximum was **1.467 ms** across 6,000 measured
+retunes. The 6 ms switch allowance and 4 ms loop allowance are unchanged.
+
+Each row combines both directions and two reversed-order trials, with 2,000
+measured retunes per standby policy. Times run from production `tuneProfile`
+entry until BUSY is observed low. GetStatus confirmed RX after every timed hop.
+
+| Secondary profile | RC mean / maximum | XOSC mean / maximum |
+| --- | --- | --- |
+| SF7 / 500 kHz | 3.161 / 3.199 ms | 1.434 / 1.467 ms |
+| SF8 / 500 kHz | 3.166 / 3.209 ms | 1.435 / 1.464 ms |
+| SF9 / 500 kHz | 3.171 / 3.193 ms | 1.433 / 1.437 ms |
+
+- Primary: 909.5 MHz, SF7 / 62.5 kHz, CR 4/5.
+- Secondary: 910.5 MHz, CR 4/5, automatic production preamble calculation.
+- TCXO configuration remained 1.8 V and 1,600 us startup delay.
+- 12,000 measured retunes; zero retune failures, BUSY timeouts, or RX-mode errors.
+- 109 packet/busy deferrals were excluded from hop timings (57 RC, 52 XOSC).
+ Deferral preserves the existing packet and radio ownership checks.
+- The first eight successful hops of every trial were excluded from
+ steady-state statistics, including the initial oscillator transition.
+- All 12 trials returned to RC standby after disabling the second profile.
+
+## Scope and reproducibility
+
+The [timing harness](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_README.md) compiles the actual
+production wrapper, not the earlier fast/minimal RX benchmark. RC and XOSC
+use the same executable: the baseline changes only the standby policy after
+entering dual-profile mode. No TCXO supply or initial startup delay is disabled.
+The normal guard checks and frequency/SF/BW/CR/preamble setters remain active.
+
+This isolates radio switching from UI, Wi-Fi, BLE, mesh dispatch, and scan-loop
+scheduling. BUSY polling/timer overhead is included; the RX status query is
+outside the timing. This is not a packet-delivery test, first-startup timing,
+power measurement, wide-frequency image-recalibration test, or proof of analog
+RF settling beyond BUSY/RX status. Indicator I/O-expander latency and nRF52
+timing still need their own measurements before reducing common budgets.
+
+Raw results: [profile_switch_results.json](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_results.json).
+The collector requires all requested samples, zero failure/timeout/mode-error
+counts, and successful RC restoration before accepting a trial.
+
+Build provenance:
+
+- Base commit: `c329cf1fc1e7f2e8c8733ab0d000094730458fe3`, with the local warm-standby patch.
+- Environment: `profile_switch_xiao`; Espressif32 6.11.0 / Arduino 2.0.17.
+- RadioLib: pinned `187ef24791c3d844939b2be13a68bd890bd04e4c` (7.7.1).
+- Test `firmware.bin` SHA-256:
+ `629d39bba80ad97c434fbaab61bc7c3c302b5ac8180cb2cb12806508b2d111e0`.
+- Existing unrelated Companion/repeater/Wi-Fi soak changes were not included
+ in this minimal timing executable.
+
+Host regression checks also passed: profile scan/CLI (3 tests), receive contract
+(2 tests), and SX126x RX-status decoding (1 test). A normal
+`heltec_v4_companion_radio_usb` build and the XIAO timing build succeeded.
+
+The XIAO's original 8 MiB flash snapshot was verified against the device while
+held in the bootloader before the timing firmware was uploaded. Raw flash
+backups stay in the current user's private backup directory, outside Git.
+After testing, the entire original snapshot was restored and verified again
+against the device before reboot. The live USB CLI then confirmed `Xiao S3 WIO`
+running `v1.17.1-soak-xiao-B` (11-Sep-2026). Its soak logger was left stopped;
+existing logs were preserved. The Indicator and RAK Tag were not flashed.
+
+## Follow-up: batched modulation
+
+On the same date, a second 12,000-retune experiment held XOSC warm in both
+cases and compared the old three-setter sequence with one combined LoRa
+modulation update. Both paths ran in the same V2 executable, with the second
+round reversing their order. Each mode completed 6,000 timed hops.
+
+| Secondary profile | Separate setters: mean / maximum | Batched: mean / maximum |
+| --- | --- | --- |
+| SF7 / 500 kHz | 1.442 / 1.473 ms | 1.159 / 1.198 ms |
+| SF8 / 500 kHz | 1.444 / 1.480 ms | 1.158 / 1.192 ms |
+| SF9 / 500 kHz | 1.446 / 1.478 ms | 1.156 / 1.163 ms |
+
+Overall: **1.444 ms to 1.158 ms**, saving **0.286 ms (19.8%)** per retune.
+Use this matched V2 baseline for the batching comparison; the earlier V1
+warm-standby numbers came from a different executable layout.
+
+There were zero retune failures, BUSY timeouts, RX-mode errors, or SF/BW/CR
+cache mismatches. All 12 trials restored RC standby after disabling profile 2.
+84 packet/busy deferrals were excluded (54 separate-setter, 30 batched).
+First eight successful hops per trial were excluded as before.
+
+The batching removes two repeated modem checks and two modulation writes.
+The helper validates a complete MeshCore bandwidth/SF/CR tuple, updates
+RadioLib's cached SF/BW/CR before its automatic LDRO calculation, and restores
+the old cache if the command fails. The wrapper's existing physical rollback
+and safe-transition paths are retained. **Frequency changes, preamble updates,
+standby, IRQ/buffer setup, packet parameters, and RX restart remain unchanged.**
+This does not qualify eliminating those other repeated operations.
+
+Raw results: [profile_switch_batch_results.json](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_batch_results.json).
+V2 test firmware SHA-256:
+`b4403f6f5eb0a2b0a31c2d79859122225f247296699a578ad2596ef17eaed0fd`.
+Toolchain and RadioLib pin match the first experiment. The normal
+`heltec_v4_companion_radio_usb` build also succeeded with batching enabled.
+
+The two new host tests execute the production method over 1,200 valid
+bandwidth/SF/CR/LDRO combinations, invalid inputs and wrong modem, command
+failures with cache rollback/retry, and the wrapper's failure short-circuit.
+All eight relevant host tests passed. RF packet delivery and application/UI
+scheduling remain outside this timing experiment; shared budgets are unchanged.
+
+## Expanded screening: redundant RX setup and SPI (V5)
+
+Corrected V5 measurements on 2026-09-13 cover **28,800 timed retunes in 300
+trials** across XIAO S3/WIO SX1262 and the Indicator ESP32/SX1262. Each sweep
+uses SF7/8/9 at 500 kHz paired with SF7/62.5 kHz, both directions, reversed
+case order in round two, and eight excluded initial hops per trial.
+All 300 trials completed their sample count and restored RC standby on exit.
+There were **zero timed switch failures, BUSY timeouts, RX-mode errors or
+SF/BW/CR cache mismatches**. The normal packet guards deferred 175 attempted
+hops; these were not counted as timed retunes.
+
+Twenty-one distinct cases were screened: seven individual redundant-operation
+changes, a full manual RX control, combinations, 2/4/8 MHz SPI and whole-buffer
+ESP32 SPI transfers. Shared controls appear in both groups. All new shortcuts
+are **HIL-only**, not included in normal targets.
+
+### Individual and combined RX changes
+
+Means in milliseconds, with warm oscillator and batched modulation throughout.
+Each XIAO row has 768 timed hops; each Indicator row has 384.
+
+| RX/SPI case | XIAO mean | Indicator mean |
+| --- | ---: | ---: |
+| Production batching, 2 MHz SPI | 1.182 | 15.190 |
+| Manual RX sequence, no omissions | 1.181 | 15.135 |
+| Skip second standby | 1.124 | 14.195 |
+| Skip repeated IRQ setup | 1.096 | 14.399 |
+| Skip repeated buffer-base setup | 1.146 | 14.437 |
+| Skip repeated packet-parameter setup | 0.991 | 12.364 |
+| Skip RX modem query | 1.145 | 14.427 |
+| Defer preamble to the one RX packet-parameter write | 0.956 | 11.659 |
+| Skip wake NOP when already awake | 1.146 | 14.576 |
+| Combined omissions, early preamble write | 0.771 | 9.096 |
+| Combined omissions, deferred preamble | 0.732 | 8.384 |
+| 4 MHz SPI only | 0.985 | 14.996 |
+| 8 MHz SPI only | 0.889 | 14.889 |
+| Combined/deferred, 4 MHz SPI | 0.627 | 8.280 |
+| Combined/deferred, 8 MHz SPI | 0.579 | 8.224 |
+
+The largest individual saving is programming preamble/packet parameters once.
+Compare individual omissions with the manual full control when attributing
+their isolated costs: replacing RadioLib's staging dispatch also has overhead.
+Individual savings are not additive; some remove overlapping work.
+
+### Whole-buffer SPI follow-up (same V5 binaries)
+
+| Case | XIAO mean / maximum | Indicator mean / maximum |
+| --- | --- | --- |
+| Production batching, byte loop, 2 MHz | 1.184 / 1.221 ms | 15.185 / 15.785 ms |
+| Bulk transfer only, 2 MHz | 1.070 / 1.073 ms | 15.092 / 15.134 ms |
+| Bulk transfer only, 4 MHz | 0.890 / 0.903 ms | 14.924 / 14.983 ms |
+| Bulk transfer only, 8 MHz | 0.797 / 0.806 ms | 14.836 / 14.908 ms |
+| Combined/deferred, byte loop, 8 MHz | 0.579 / 0.588 ms | 8.222 / 8.843 ms |
+| Combined/deferred + bulk, 2 MHz | 0.671 / 0.700 ms | 8.369 / 9.027 ms |
+| Combined/deferred + bulk, 4 MHz | 0.582 / 0.595 ms | 8.252 / 8.315 ms |
+| Combined/deferred + bulk, 8 MHz | **0.535 / 0.545 ms** | **8.203 / 8.233 ms** |
+
+The fastest XIAO candidate saves **0.649 ms (54.8%)** versus that sweep's
+already-warm, already-batched production control. At 8 MHz, bulk transfers
+alone add about 0.043 ms of benefit to the combined RX shortcut. The Indicator
+remains dominated by I2C-mediated chip-select/BUSY/DIO access; the final bulk
+step improves its combined mean by only 0.019 ms. Small HAL effects varied
+between executable layouts in preliminary screens; do not generalize them.
+
+**The Indicator exceeds the generic 6 ms switch allowance even in its fastest
+experimental path.** These are board measurements, not a justification for
+reducing the shared 6 ms switch / 4 ms loop constants. Indicator scheduling
+and effective preambles need board-specific treatment before a production
+rollout. Application/display/network scheduling is still excluded here.
+
+Raw accepted sweeps:
+
+- [XIAO RX cases](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v5_rx_xiao_complete.json)
+- [Indicator RX cases](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v5_rx_indicator.json)
+- [XIAO bulk SPI](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v5_bulk_xiao.json)
+- [Indicator bulk SPI](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v5_bulk_indicator.json)
+
+### Test corrections and provenance
+
+V3/V4 broad screening accidentally called `SX1262::startReceive()` instead of
+`CustomSX1262::startReceive()` in the fallback, omitting MeshCore's extra
+preamble-detection IRQ flag. Their raw files are marked superseded and are
+not included in the counts or conclusions above. The V4 packet sweep was
+stopped on discovery. V5 preserves that flag in both normal and manual RX
+paths; a host regression test checks the Custom fallback and mapped flags.
+The earlier V1/V2 warm/batching experiments did not use that experimental
+subclass and are unaffected.
+
+An initial V5 XIAO capture stopped on a partial serial JSON line during guarded
+mode exit. It is retained as incomplete, not counted above. The host now
+accumulates reads until newline, with regression coverage. Initial setup
+rejections are retried only for the explicit pre-measurement packet-guard
+reply, bounded to three seconds and counted; packet losses and timed failures
+are never retried away.
+
+Final V5 firmware SHA-256:
+
+- XIAO: `5506a40ffa8240201aca519e8e24db3647f897e7a1290bba22be6e4986d8f02f`
+- Indicator: `dd595b2e7fa8c1ccb14fac9223f1a57277cd6a602864ee9bf090672f494fa2f6`
+
+Base commit/toolchain/RadioLib pin match the earlier experiments. Thirteen unique
+host tests passed (eight production regressions, two HIL boundary/buffer tests,
+three serial-reader/setup-guard tests). The HIL tests cover all 255 nonzero omission masks,
+normal-path fallbacks, preserved IRQ clear/SetRx, error short-circuit and
+full-duplex buffer lengths 1..260. They are not full driver lifecycle tests.
+An additional normal `t1000e_companion_radio_usb` nRF52 build passed with the
+production warm/batching changes: 591,484-byte app, runtime RAM guard passed.
+It was not flashed to the RAK or any nRF52 board.
+
+### Qualification boundary
+
+No BUSY wait, command status validation, stale-IRQ clear, RX command or wrapper
+packet-ownership guard is removed. The fastest path relies on a known
+RX-to-RX window and established RX setup. Correct invalidation after TX, CAD,
+sleep, reset, failed commands, and recovery must be designed and tested before
+promoting these shortcuts. The per-byte/bulk HAL experiment is ESP32-specific.
+Sensitivity/PER, oscillator power draw, large-frequency image recalibration,
+and application-driven continuous scanning remain unqualified.
+
+### Actual packet checks and limitations
+
+Final forward matrix: Indicator transmitter to XIAO receiver, -9 dBm,
+21 cases x SF7/8/9 x both profiles x 16/64/255-byte payloads = **378 probes**.
+**359 sequence/length/payload/profile checks succeeded**, 13 receiver-declared
+wait timeouts occurred, and six receiver USB replies did not arrive before
+the host deadline. Transmitter replies returned success. Successfully read
+packets had RSSI -42 to -39 dBm and SNR 10.2 to 14.2 dB. Timeout diagnostics
+reported RX mode and no device errors; some retained a preamble IRQ. These
+observations do not establish why packets were missed.
+
+The production-batched baseline delivered 17/18 in that matrix; the fastest
+combined/bulk/8 MHz candidate delivered 18/18. An earlier reversed-order
+smoke test of the fastest candidate had one receiver timeout, so the clean
+18-packet batch is **not** a reliability qualification. Earlier 0 dBm checks
+also had baseline and candidate misses. Reducing power did not eliminate them.
+
+Reverse direction: XIAO transmitter to Indicator receiver, -9 dBm, baseline,
+combined/8 MHz and combined/bulk/8 MHz, with the same SF/profile/length matrix.
+**All 54 payloads arrived intact**, 18/18 per case. However, one XIAO USB TX
+acknowledgement was delayed past the host deadline, after which 32 later
+records picked up the previous command's acknowledgement. Thus the raw file
+has only 21 fully matched transactions despite 54 correctly received payloads.
+This is sequence-correlated evidence of a host acknowledgement problem, not
+33 RF losses. Subsequent collector code rejects/stores stale sequence replies
+instead of associating them with a new command, and now asserts DTR on Windows
+to match the existing `s3_memory_soak.py` HWCDC session policy. The captures
+above used DTR deasserted; a causal DTR A/B test was not performed. Sequence
+handling has host regression coverage, but these last host changes were not
+rerun on HIL firmware after the boards were restored.
+
+USB acknowledgements and actual receiver payload validity are recorded
+separately. Missing acknowledgements never trigger an automatic retransmit.
+The host drains the receiver concurrently with the transmitter's command and
+records timeouts without hiding them. These tests establish some real packet
+functionality, not a controlled packet-error-rate comparison. Fix/qualify the
+test transport, repeat with a stable RF reference, then test TX/CAD/reset/RXPS
+and continuous application scanning before promoting the shortcuts.
+
+- [Forward matrix, all 21 cases](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v5_packets_complete.json)
+- [Reverse matrix, three cases](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v5_packets_reverse_complete.json)
+- [0 dBm smoke](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v5_packet_smoke.json)
+- [-9 dBm reversed-order smoke](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v5_packet_minus9_smoke.json)
+
+Earlier incomplete packet captures and superseded screens are retained for
+audit, not counted in the 378/54 final matrices. No identity or channel secrets
+were transmitted: probes used synthetic sequence-tagged bytes.
+
+## Production integration and USB recovery (V8)
+
+Normal XIAO S3 WIO and Indicator LoRa variants now opt in to buffered 8 MHz
+SPI and a guarded fast RX-to-RX path. Other SX1262 variants retain default
+SPI/full RX setup; modulation batching and warm standby remain shared.
+
+The fast path keeps the TCXO warm, applies one SF/BW/CR tuple and one packet
+tuple, clears stale IRQs and starts RX with the original BUSY handling. It
+reuses an established LoRa IRQ map and buffer bases only within an owned
+continuous-RX retune. Ordinary standby, staging/TX, CAD, sleep, reset/init,
+RX duty cycling and command failures invalidate that reuse. RX startup failure
+now fails the whole profile transaction and restores the old profile/caches
+with full setup; failed recovery keeps the profile marked for refresh.
+
+### Sleep recovery discovered during integration
+
+Initial lifecycle captures found `XOSC_START_ERR` (0x20) after retained sleep,
+despite successful RX status. RC-policy controls reproduced the same flag:
+this was not established as a regression caused by the fast path. Simply
+waking in RC and entering standby before sleep did not clear it. Reapplying
+the already configured TCXO voltage and 1,600 us delay resolved it.
+
+The production sleep override now enters standby first, wakes in RC, and
+restores TCXO configuration on wake. It refuses to clear unrelated device
+faults. The warm policy resumes after full RX setup. This follows the standby
+entry requirement in section 13.1.1 of the
+[Semtech SX1261/2 datasheet](https://files.waveshare.com/wiki/SX1262-XXXM-LoRaWAN-GNSS-HAT/DS_SX1261-2_V1.2.pdf).
+Section 13.3.6 describes TCXO control and the XOSC error flag; it does not by
+itself prove the cause of this retained-sleep observation.
+
+Diagnostic files `profile_switch_v6_recovery_xiao.json`,
+`profile_switch_v7_recovery_xiao.json`,
+`profile_switch_v7_recovery_xiao_complete.json` (despite its filename, its
+`complete` field is false), and `profile_switch_v7_sleep_diagnostic_xiao.json`
+retain these failures. They are not counted as passing lifecycle tests.
+
+### USB fix and scope
+
+The HIL host distinguishes Espressif native CDC (DTR asserted) from CH340
+bridges (DTR/RTS deasserted). Replies are newline-assembled and sequence-matched.
+Firmware caches completed packet **and timing** reports before emitting and
+flushing them. A timeout or malformed report triggers a sequence-scoped result
+query, never a repeated TX or timing run. Randomized session sequence starts
+avoid a cached result from an earlier host session.
+
+V7 had successfully fixed/retested packet result replay but a long native-USB
+timing sweep still lost one response. V8 adds whole-report timing buffering and
+replay. The incomplete V7 XIAO timing capture is retained, not silently completed.
+The public MeshCore Companion USB protocol is unchanged; these USB fixes are
+for the radio-lab transport. The previous broad Windows-DTR policy described
+in the V5 history above has been replaced by native-device identification.
+
+Both boards passed **20 reopen cycles**, deliberate lost timing replies,
+deliberate lost TX replies, stale TX reply isolation, and **18 lifecycle cases**
+each. The cases cover TX, CAD, sleep, reset/init, RC-policy sleep and explicit
+TCXO-restoration controls, repeated three times. Fast counters prove one full
+RX fallback after invalidation and reuse only on the following retune.
+All command results, RX-mode checks and device-error checks passed.
+
+- [XIAO recovery](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v8_recovery_xiao.json)
+- [Indicator recovery](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v8_recovery_indicator.json)
+
+V8 test firmware SHA-256 (same pinned RadioLib/toolchain as above):
+
+- XIAO: `e1806bbf4af5a88b31423131806a682713221082235507ff95e5beb9ef3ac067`
+- Indicator: `bab4009078a4fff68dfb7aa73a5afc83e84f5c47589354d8aeb8e5b8d8798442`
+
+Normal USB Companion builds passed for `Xiao_S3_WIO_companion_radio_usb`
+(813,901 app bytes / 63,012 static RAM),
+`SenseCapIndicator-LoRa_comp_radio_usb` (879,809 / 62,960), and
+`t1000e_companion_radio_usb` (591,980 / 179,868). Runtime RAM budgets passed
+on all three. These normal application images were compiled, not substituted
+for the user's configured soak firmware during the HIL tests.
+
+Host/native regression coverage includes production modulation caches/LDRO,
+standby failure short-circuit, RX-start rollback failure, ownership guards,
+fast-state invalidation, TCXO wake fault preservation, buffered SPI semantics,
+USB result overflow/replay, partial reads and stale-sequence handling.
+
+### Final steady-state timing
+
+V8 ran 256 timed retunes per trial, four production configurations, SF7/8/9
+at 500 kHz paired with SF7/62.5 kHz, and two reversed-order rounds on each
+board: **12,288 measured retunes / 48 trials**. The first eight hops per trial
+are excluded. There were zero retune failures, BUSY timeouts, RX-mode errors
+or modulation-cache errors. All 48 trials restored RC policy on exit. The
+normal packet guards deferred 21 XIAO and 59 Indicator hops; no setup retries
+or naturally missing/replayed USB reports occurred in these final sweeps.
+
+| Production configuration | XIAO mean / maximum | Indicator mean / maximum |
+| --- | --- | --- |
+| Warm + batching, per-byte SPI 2 MHz | 1.193 / 1.227 ms | 15.174 / 15.836 ms |
+| Fast RX, per-byte SPI 2 MHz | 0.744 / 0.770 ms | 8.426 / 8.471 ms |
+| Full RX, buffered SPI 8 MHz | 0.828 / 0.840 ms | 14.816 / 15.386 ms |
+| Fast RX + buffered SPI 8 MHz | **0.549 / 0.556 ms** | **8.263 / 8.879 ms** |
+
+The combined production path saves **54.0% on XIAO** and **45.5% on Indicator**
+relative to each same-image warm/batched control. Small differences from V5-V7
+reflect different binaries/code layout; only within-image A/Bs are compared.
+Both fast configurations must increment the production success counter; full
+RX controls must not. Mode/cache verification happens after the timed region.
+
+- [XIAO V8 timing](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v8_production_xiao.json)
+- [Indicator V8 timing](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v8_production_indicator.json)
+
+The common 6 ms switch and 4 ms loop allowances are unchanged, not delays
+inserted by the driver. Indicator still exceeds 6 ms. These measurements do
+not qualify continuous application scheduling, scan-overlap reception,
+sensitivity/PER, power use, temperature extremes, or image recalibration on
+frequency changes larger than this 1 MHz pair. Do not shorten common transmit
+preambles or advertise the XIAO number for other boards.
+
+### Final over-air packet matrix
+
+**288/288 payloads and command transactions passed**: 144 Indicator-to-XIAO
+and 144 XIAO-to-Indicator. Each direction covers all four production cases,
+SF7/8/9 secondary profiles, both target profiles, 16/64/255-byte synthetic
+payloads and two reversed-order rounds. TX used buffered 8 MHz SPI at -9 dBm.
+Fast receiver readiness reports had to show actual production fast resumes.
+
+There were **zero RX errors, receiver timeouts, missing replies, stale replies,
+or natural transport-recovery queries** in these matrices. Deliberately lost
+and stale replies were separately exercised by the recovery tests above.
+RSSI ranged -43 to -39 dBm and SNR 10.2 to 14.5 dB. This clean short-range
+matrix addresses the failures seen in the earlier V5 harness; it does not
+identify which single change caused each historical miss or establish a
+field packet-error rate.
+
+- [Indicator TX / XIAO RX](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v8_packets_forward.json)
+- [XIAO TX / Indicator RX](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_v8_packets_reverse.json)
+
+All **18 host/native regression tests** passed. The HIL builds do not include
+the unrelated pre-existing Companion/repeater/Wi-Fi soak edits; the normal
+application compile checks include the current working tree and preserve them.
+
+A subsequent requested [SF6/125 kHz multi-channel test](sf6_channel_scan_validation.md)
+used 32-symbol preambles and 4.8-symbol visits. It stopped on the third packet
+at the initial four channels (two received, one timeout); no higher count was
+tried. That continuous scan experiment is separate from the 288/288
+stationary-after-retune matrix above.
+
+## Unchanged modulation tuple follow-up
+
+The initial software-only checks below were followed by
+[same-image hardware timing and separated-radio tests](separated_radio_modulation_cache_validation.md).
+Frequency-only means improved from 548.880 to 451.897 us on XIAO and from
+8,263.438 to 7,601.193 us on Indicator. Four-channel SF6 reception was not
+loss-free. The following paragraph records the initial implementation stage,
+not the current hardware-test status.
+
+Implemented 2026-09-14 after commit `0e5955f8`, without restarting hardware
+tests. On opted-in fast RX-to-RX hops, `setLoRaModulationParams()` now compares
+the requested SF, encoded bandwidth, encoded CR and effective LDRO against
+the last successfully acknowledged modulation write. An exact match within
+a valid owned standby window omits only `SetModulationParams` (0x8B).
+Frequency programming, the modem query, preamble/packet parameters, IRQ clears,
+RF-switch control and BUSY waits remain unchanged.
+
+The acknowledgement is separate from RadioLib's mutable software fields.
+Auto/manual LDRO compares the resulting command byte, with the pinned
+RadioLib >=16 ms symbol rule. A failed write revokes the whole fast context
+before rollback. Ordinary SF/BW/CR/forced-LDRO setters invalidate the saved
+tuple even on failure. Existing full-RX staging, ordinary standby, TX/CAD
+staging, reset/init, sleep, duty cycling and failed RX/standby operations also
+invalidate it. The first hop after lost context therefore writes again;
+frequency-only hops that retain the context can skip subsequent writes.
+
+All **30 selected host/native tests passed**, including repeat-write counts
+for all supported bandwidth/SF/CR combinations and auto/forced LDRO modes,
+each independently changed field, LDRO policy transitions, failed-write retry,
+modem-query failure, ownership gates, ordinary setters and lifecycle invalidation.
+Both `profile_switch_xiao` and `profile_switch_indicator` compiled successfully
+against the pinned RadioLib. No firmware was uploaded, no radio command was
+issued, and no new RF result was collected. The temporary build overlay was
+removed. The earlier ~0.549 ms XIAO and ~8.263 ms Indicator timings do **not**
+measure this added optimization; its time saving and RF behavior await testing.
+
+## MercerMesh Heltec T096 SX1262 experiment (2026-09-21)
+
+The Pi has **one** Heltec T096 (USB serial `651F8E496197F882`); the other
+Heltec-named nRF52 is a MeshTower V2, not another T096. An application-only
+T096 HIL image tested two further RX-to-RX shortcuts with a buffered 8 MHz SPI
+bus: use SX1262 `SetFs` instead of warm XOSC standby, and skip
+`SetPacketParams` only when the LoRa RX packet tuple is unchanged. Both switches
+defaulted off in this HIL image. The image explicitly enabled the existing
+production fast-RX path. Normal T096 firmware does not currently enable that
+path or use the buffered SPI HAL, so these numbers are **not** stock T096
+switch timings.
+
+The same image ran 256 measured hops per configuration, after eight warm-up
+hops, on 909.5/909.75 MHz. `freq` held SF7/BW125/CR5; `both` alternated
+SF7/SF8 at BW125/CR5. Preamble was 32 symbols. The DWT cycle counter measured
+through RX re-entry and BUSY release. Every 8 MHz configuration had zero
+switch failures, mode/cache/RSSI errors, or BUSY timeouts.
+
+| 8 MHz HIL configuration | Frequency-only mean | Frequency + SF mean |
+| --- | ---: | ---: |
+| Fast RX baseline | 467.3 us | 553.6 us |
+| `SetFs` only | 422.5 us | 517.4 us |
+| Unchanged packet tuple skip only | 357.7 us | 444.0 us |
+| Both | **313.0 us** | **407.8 us** |
+
+Combined savings were 154.3 us (33.0%) for frequency-only and 145.8 us
+(26.3%) for frequency-plus-SF relative to the same-image fast-RX baseline.
+Command counts confirmed one frequency command, IRQ clear and RX command per
+hop; the optimized cases eliminated the corresponding standby and packet
+commands. These are controlled short-range, same-band results, not a field
+packet-error rate or a qualification of large frequency jumps, sleep/wake,
+TX/CAD interruption, BLE scheduling, or all SX1262 boards.
+
+A separate RAK4631 fixed transmitter sent 16-byte synthetic packets at
+909.5 MHz/SF10/BW125/CR5/preamble32, -9 dBm chip power. The T096 hopped to
+909.75 MHz and back before each transmission. Baseline and combined shortcuts
+each delivered **60/60** packets after **120** fast hops, with payload sequence
+and content checked. The RAK bootloader on this exact fixture enumerated as
+`239A:002A` (the fixture's earlier `0029` expectation was corrected).
+
+Doubling buffered SPI to 16 MHz gave only 12–21 us (about 3–4%) savings in
+comparable successful cases, while five of eight setup attempts failed. The
+combined shortcut had no clean 16 MHz result. Keep this T096 at 8 MHz pending
+a separately diagnosed and repeatable 16 MHz bring-up; no 16 MHz over-air
+qualification was attempted.
+
+Both radios were restored via app-only DFU after the experiment: the T096 to
+`Heltec_t096_repeater` and the RAK to `RAK_4631_companion_radio_full`, each
+built at `v1.17.1.7-hil-restore-e7160dfd`. The T096 answered normal `version`
+and `board` commands; the RAK answered the Full Companion terminal's version
+command. The V4 observer's memory-soak service remained active. Reproducible
+bench code is in `tools/hil/profile_switch_t096_sx1262.cpp` and
+`tools/hil/t096_sx1262_packet_probe.py`.
+
+Follow-up: owned fast RX, `SetFs`, and acknowledged packet-tuple reuse are
+now defaults for `CustomSX1262` targets, including the original XIAO S3 WIO,
+Station G3 KISS, and Heltec V4 KISS paths. The Seeed Indicator retains its
+original owned-RX path but explicitly opts out of the two new shortcuts because
+its radio control and BUSY/IRQ pins run through an I2C expander. The owned-RX
+state now remembers only an acknowledged packet tuple and revokes it on
+ordinary packet setters, TX/CAD staging, standby outside the hop, sleep,
+reset, RX duty cycling, or command failure. The T096 numbers remain lab
+measurements, not on-board qualification of every `CustomSX1262` target.
diff --git a/docs/test-results/radio_profiles_validation.md b/docs/test-results/radio_profiles_validation.md
new file mode 100644
index 000000000..a9e67b2cb
--- /dev/null
+++ b/docs/test-results/radio_profiles_validation.md
@@ -0,0 +1,204 @@
+# Dual-profile receive validation
+
+These measurements exercise the production two-profile implementation on one
+SX1262 transceiver, using a Heltec V4 repeater as receiver and a XIAO ESP32-S3
+with a Wio LoRa module as transmitter. They follow the dedicated
+[mixed-bandwidth scan experiment](mixed_scan_validation.md).
+
+Counts, diagnostic replies and firmware/source hashes are in
+[radio_profiles_results.json](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/radio_profiles_results.json).
+
+The primary profile is 909.5 MHz, SF7, 62.5 kHz, CR 4/5, preamble 32.
+The secondary is 910.5 MHz, 500 kHz, CR 4/5, with SF9, SF8 or SF7.
+Scanning uses normal RX, starts with the slower profile, and reserves two slow
+visits per slow preamble. Transmitters must use the corresponding preamble too.
+
+## Method and limits
+
+The transmitter sends MeshCore adverts at varied intervals. The receiver's
+per-profile MeshCore RX counters are compared with the transmitter's completed
+TX counters before and after each run. Initial OTA announcements can add packets
+beyond the manually requested adverts; the table includes those transmissions.
+These are aggregate counters, not sequence-matched payload captures.
+
+Runs use one transmitting profile at a time and a strong signal. They do not
+measure simultaneous traffic, weak-signal reception, interference or months of
+uptime. A single transceiver remains unavailable to the other profile while
+receiving or transmitting a packet. A clean finite run is evidence for a timing
+choice, not a guarantee that it cannot drop packets.
+
+## Five-symbol baseline
+
+Slow visits were 10.240 ms and fast visits 6.528 ms, plus actual switching time.
+The longest observed profile switch was 4.416 ms; no switch failures were reported.
+
+| Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
+| --- | ---: | ---: | ---: |
+| SF9 / 500 | 48 | 54 / 54 | 30 / 30 |
+| SF8 / 500 | 72 | 49 / 50 | — |
+| SF8 / 500 | 80 | 49 / 50 | — |
+| SF8 / 500 | 88 | 50 / 50 | 30 / 30 |
+| SF7 / 500 | 120 | 50 / 50 | 30 / 30 |
+
+The SF8 automatic preamble retains a measured floor of 88 symbols. Automatically
+selected preambles always round up in steps of eight.
+
+## Fractional-symbol tuning
+
+The initial sweep started at 4.2 slow symbols, with an increase of 0.1 planned
+for any slow-channel loss. That first sweep passed all three fast settings.
+Each completed run used 50 requested adverts per profile. The SF8 fast run was
+interrupted to check SF9 preamble 32, then restarted; only its completed run is
+included below.
+
+| Slow symbols | Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
+| ---: | --- | ---: | ---: | ---: |
+| 4.2 | SF9 / 500 | 48 | 50 / 50 | 50 / 50 |
+| 4.2 | SF9 / 500 | 32 | 50 / 50 | 50 / 50 |
+| 4.2 | SF8 / 500 | 88 | 50 / 50 | 50 / 50 |
+| 4.2 | SF7 / 500 | 120 | 50 / 50 | 50 / 50 |
+
+The four completed pairings received 200/200 packets on the slower channel and
+200/200 on the faster channel. No switch failures were reported; the longest
+measured switch during the 4.2-symbol sweep was 4.407 ms.
+
+The explicit SF9 preamble-32 check uses the same 8.602 ms slow and 8.166 ms fast
+windows as the preamble-48 check. Only the fast transmitter's preamble changes.
+The automatic SF9 calculation still reserves a 48-symbol preamble for the
+configured switching and loop-jitter budgets.
+
+## Shorter fast preambles
+
+The follow-up starts with preamble 48 on both fast settings. Each miss advances
+the next candidate by eight symbols. The receive-window budgets stay at 4.2
+slow symbols and the same remaining fast-channel time.
+
+| Fast profile | Preamble | Fast RX / TX |
+| --- | ---: | ---: |
+| SF8 / 500 | 48 | 49 / 50 |
+| SF8 / 500 | 56 | 50 / 50 |
+| SF7 / 500 | 48 | 42 / 50 |
+| SF7 / 500 | 56 | 41 / 50 |
+| SF7 / 500 | 64 | 45 / 50 |
+| SF7 / 500 | 72 | 50 / 50 |
+
+The slower channel passed 50/50 paired with SF8/56, but then received **49/50**
+paired with SF7/72. Across the completed 4.2-symbol slow-channel runs, that is
+299/300. This later miss invalidated the provisional 4.2-symbol choice and
+triggered the next headroom test at 4.3 symbols. Fast-preamble baselines carry
+forward as 32 for SF9, 56 for SF8 and 72 for SF7, advancing by eight on a miss.
+
+At 4.3 symbols, the SF9/32 pairing received 100/100 slow packets and 50/50 fast
+packets. The next SF8/56 pairing received only 42/50 slow packets, so that
+candidate also failed. A control run with scanning disabled received 50/50 on
+the slow channel. At 4.4, SF8/56 passed 100/100 slow and 50/50 fast, but the
+SF7/72 pairing then received 49/50 slow packets.
+
+At 4.5, the SF7 pairing passed 100/100 slow packets. Fast preamble 72 received
+47/50, so the next baseline increased to 80 and passed 50/50. The following
+SF8/56 slow run received 49/50, triggering 4.6 symbols. Baselines now carry
+forward as SF9/32, SF8/56 and SF7/80.
+
+At 4.6, both SF8/56 and SF7/80 passed 100/100 slow packets and 50/50 fast
+packets. The following SF9/32 slow run received 49/50, triggering 4.7 symbols.
+There were no switch failures; the longest observed switch was 4.414 ms.
+
+At 4.7, the SF9/32 slow run passed its first 50 packets but received 99/100
+over the full run. This triggered 4.8 symbols, with the same fast preamble
+baselines. No fast-channel run was started for the 4.7 candidate.
+
+## Selected bench baseline: 4.8 slow symbols
+
+The 4.8-symbol sweep passed all three slow-channel checks, totaling 300/300.
+Slow visits are 9.831 ms and fast visits 6.937 ms, plus actual switching time.
+The longest measured switch was 4.491 ms, with no switch failures.
+
+| Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
+| --- | ---: | ---: | ---: |
+| SF9 / 500 | 32 | 50 / 50 | 100 / 100 |
+| SF8 / 500 | 56 | 49 / 50 | 100 / 100 |
+| SF8 / 500 | 64 | 50 / 50 | — |
+| SF7 / 500 | 80 | 50 / 50 | 100 / 100 |
+
+The SF8 miss advanced its baseline from 56 to 64, exactly eight symbols. Its
+slow-channel check preceded that increase; changing the fast preamble leaves
+the idle receive-window calculation unchanged. The final fast-channel attempts
+received 150/150, using explicit preambles **32 / 64 / 80** for SF9 / SF8 / SF7.
+These become the starting values for further tests, increasing by eight on a
+fast-channel miss. A slow-channel miss instead advances the listen window by
+0.1 symbol.
+
+The production automatic preambles remain **48 / 88 / 120**, retaining the
+switching, loop-jitter and acquisition budgets. The shorter explicit values are
+bench results under the conditions above, not a guarantee for other boards or
+traffic patterns.
+
+## Transmit policy
+
+Hardware checks send three zero-hop adverts from the V4 with the XIAO listening
+on profile 2. OTA startup traffic is allowed to settle before taking the counter
+snapshots. The completed cases are:
+
+| V4 profiles and policy | TX on primary / secondary | Received by XIAO |
+| --- | ---: | ---: |
+| Permanent / permanent, `auto` | 3 / 3 | 3 |
+| Permanent / permanent, `off` | 3 / 0 | 0 |
+| Permanent / temporary, `auto` | 3 / 0 | 0 |
+| Permanent / temporary, `on` | 3 / 3 | 3 |
+| Secondary RX only, `on` | 3 / 0 | 0 |
+| Temporary / temporary, `auto` | 3 / 3 | 3 |
+
+A separate isolation check issued three explicit OTA announce commands with
+normal `radio`, RX-only `tempradio2` and `radio2.cross auto`. The completed TX
+counters stayed unchanged on both profiles (0 / 0), confirming that locally
+generated OTA traffic does not fall back to the normal channel in that mode.
+
+Turning `radio2` off stopped scanning. A one-minute `tempradio2` session also
+returned to one profile on expiry. Rebooting with a saved SF8 RX profile and an
+active temporary SF9 RX/TX profile cleared the temporary session and restored
+the saved SF8 profile, including its automatic 88-symbol preamble.
+
+## OTA discovery while retaining the main channel
+
+Both boards ran normal SF7 / 62.5 kHz on the primary and temporary SF8 / 500 kHz
+RX/TX on the secondary, using `radio2.cross auto` and automatic preambles 32/88.
+The XIAO discovered the V4's firmware catalogue over profile 2, and both boards
+recorded received and transmitted OTA packets there. Ten V4 zero-hop adverts
+then arrived on the XIAO's primary profile (10/10), with both temporary second
+profiles still active. The XIAO reported no switch failures and a longest switch
+of 4.547 ms during this check.
+
+This hardware check covers discovery, bidirectional OTA traffic and concurrent
+availability of the main channel between packets. It does not perform a complete
+firmware transfer or install. Byte-exact transfer validation is provided by the
+shared OTA software tests described below.
+
+## Software coverage
+
+Native tests cover identical-packet fanout, separate retry ownership and coding
+rates, channel-busy backoff, generation changes, temporary-session isolation,
+deferred replies and stale-packet removal. Tests using the production CLI cover
+parsing, persistence failures, schedules, expiry and clock rollover. A physical
+radio mock exercises the production scan transitions, pending RX interrupts,
+RX power-saving restoration and failed profile switches.
+
+The full native suite passed 1,422 cases after the RX-only OTA isolation fix;
+all eight KISS modem tests also passed. Subsequent timing changes through 4.8
+symbols passed the 63-case retry/profile suite and production CLI and scan
+harnesses. Shared OTA transfer tests passed byte-exact checks with
+AddressSanitizer and UndefinedBehaviorSanitizer for ESP32 and nRF52 variants.
+
+Build coverage includes the Heltec V4 repeater, room server, sensor and terminal
+chat, the Full XIAO Companion, and the nRF52 T1000-E repeater. The nRF52 result
+is a build check; these receive measurements use the ESP32/SX1262 boards above.
+
+## Hardware restored after validation
+
+Both boards were returned to their original B soak images after the checks:
+`v1.17.1-soak-v4-B` and `v1.17.1-soak-xiao-B`. The V4 retained 909.5 MHz,
+SF7 / 62.5 kHz, CR 4/5 and TX 0; the XIAO retained 910.525 MHz with the same
+modulation and TX 22. Both reconnected to Wi-Fi and their custom MQTT service.
+The soak logs resumed under `B-post-radio2-soak`. These restored images are the
+earlier memory-soak firmware; the new dual-profile code is validated by the
+finite tests above, not by a months-long uptime run.
+The Pi's soak service and Indicator telemetry were also healthy after restoration.
diff --git a/docs/test-results/radio_receive_calibration.md b/docs/test-results/radio_receive_calibration.md
new file mode 100644
index 000000000..44402b4c2
--- /dev/null
+++ b/docs/test-results/radio_receive_calibration.md
@@ -0,0 +1,88 @@
+# Radio receive calibration and recovery
+
+Noise calibration collects 64 idle RSSI samples at least 50 ms apart. A quiet
+block takes about 3.2 seconds. Both continuous RX and temporary RXPS calibration
+windows allow up to 30 seconds; a partial block times out without publishing.
+Periodic requests coalesce while a block is active. RXPS resumes after a complete
+block, including a held block, or at timeout. Receive and transmit activity are
+never interrupted just to finish calibration.
+
+The block estimate is the 12.5th percentile: the average of the eighth and
+ninth sorted samples, retaining fractional dBm. A published update uses 25% of
+the previous floor and 75% of the new estimate, with a -120 dBm lower bound. Initial calibration and explicit gain/tuning changes seed a new baseline.
+The old published value remains available while collecting. An AGC reset drops
+partial samples while retaining a valid baseline for channel checks.
+
+An estimate more than 15 dB above the established floor is held twice. The third
+consecutive complete high block is accepted, allowing recovery after a permanent
+ambient rise. A normal or quieter complete block clears the hold. Partial
+blocks do not advance it. There is no admission threshold based on the old
+floor, so an old low baseline cannot reject every sample from a higher floor.
+
+Hardware captures with sustained different-SF/BW traffic showed the median and
+lower quartile following the interferer. The 12.5th percentile retained the
+quiet background in those captures. It needs at least nine background samples
+out of 64 to avoid higher-valued traffic entirely. Nearly continuous
+interference or enough low outliers can still mislead it. The rise hold delays
+large changes but cannot identify their source.
+
+RSSI sampling waits 20 ms after every receive re-arm, including CAD and TX
+completion, to avoid unsettled frontend readings. The ten-second trial window
+often failed to complete during heavy traffic with that settling guard; the
+30-second cap allows more idle opportunities. Partial blocks still time out
+without publishing. Longer collection spends more time in continuous RX when
+RXPS is enabled. A quiet minimum is about 3.2 seconds; real completion time
+also depends on packet activity and main-loop scheduling.
+
+On radios with a mode probe, each eligible RSSI sample first checks that the
+chip has not silently left RX. Confirmed standby/non-RX discards the partial
+block without changing the published floor. Rejected attempts are also spaced
+50 ms apart. BUSY and packet ownership block sampling; families without a mode
+probe retain their existing RSSI path.
+
+CAD has a bounded wait, stops RX duty cycling before scanning, and re-arms the
+configured receive mode on success, busy detection, or error. A completed or
+currently receiving packet owns the radio and blocks CAD. A packet interrupt
+arriving during re-arm is retained. Failed re-arm and a newly arrived packet
+both defer transmission.
+
+SX126x wrappers additionally run a recovery watchdog at most once per
+10 seconds while continuous RX is expected. RXPS sleep, busy SPI, pending packets,
+and invalid status reads do not count as non-RX evidence. Two confirmed non-RX
+observations request soft recovery; a third uses the existing hard recovery path
+where supported. An observed healthy RX state clears the sequence. Recovery
+preserves tuning, TX power, receive gain, and the operator's power-saving intent.
+The existing radio liveness and RXPS watchdogs remain active for other failures.
+
+The radio liveness watchdog measures time without a successfully received
+packet. Only a positive `recvRaw()` result restarts its observation window;
+TX completion, CAD interrupts, and failed packet reads do not. Reception counts
+even if application parsing rejects the packet or the packet pool is full.
+The check runs after pending RX is drained and TX completes, before the next
+queued transmission, so sustained outbound traffic cannot postpone recovery.
+
+The default silence thresholds are 30 minutes for a soft RX/AGC re-arm and
+12 hours for a radio-only hard recovery where supported. MQTT observer
+`radio.watchdog` settings can override the soft interval. A successful hard
+recovery starts a fresh observation window to limit repeated resets on a quiet
+mesh; a failed hard recovery remains escalated and retries after 30 seconds.
+Targets with `RADIO_LIVENESS_SOFT_ONLY` repeat soft recovery at the soft interval
+because their radio cannot be reset independently. These recoveries do not
+reboot the MCU. The repeater's `rx.watchdog` is a separate whole-board reboot
+watchdog, enabled by default with a 24-hour sliding deadline since the last
+received packet passed MeshCore parsing. Its timer is updated before receive
+delays and routing filters; raw LoRa reads rejected by the parser do not count.
+MeshCore parsing checks the packet structure, not sender authentication or
+decryption of traffic addressed to other nodes. Temporary
+radio windows of at least 12 hours use a 12-hour deadline; shorter windows
+disable that reboot watchdog until the window ends. The temporary timing
+overrides are held in RAM and do not replace saved normal-mode preferences.
+
+The status read uses the two-byte GetStatus transaction. The pinned RadioLib
+getStatus() helper requests zero copied data bytes, so its return value is not
+usable for this mode check. The dedicated read restores the normal SPI command
+layout afterward and does not wake a duty-cycling radio to inspect its mode.
+
+Validation lives in `test_noise_floor_estimator`, `test_rx_power_saving`,
+`test_radio_liveness`, `test_radio_receive_contract.py`, and
+`test_sx126x_receive_mode.py`.
diff --git a/docs/test-results/s3_memory_soak_validation.md b/docs/test-results/s3_memory_soak_validation.md
new file mode 100644
index 000000000..e0c76cdbe
--- /dev/null
+++ b/docs/test-results/s3_memory_soak_validation.md
@@ -0,0 +1,233 @@
+# ESP32-S3 OTA memory experiment
+
+This experiment compares the current static S3 OTA workspace (A) with
+`OTA_HEAP_CONTEXT=1` (B), with WiFi and a real TLS MQTT connection enabled.
+It is isolated on `experiment/s3-ota-memory-soak`; published recipes retain
+their existing defaults. A short stress test cannot establish months of uptime.
+
+## Hardware and scope
+
+| Board | Role | Flash | PSRAM | Lab identity |
+|---|---|---:|---:|---|
+| Heltec V4.3 OLED, standard V4 | Observer/MQTT repeater | 16 MiB | 2 MiB QSPI | USB MAC `44:1B:F6:69:CF:98` |
+| Original SenseCAP Indicator LoRa | Full companion plus experimental MQTT | 8 MiB | 8 MiB octal | WiFi MAC `D8:3B:DA:75:23:AC` |
+| Seeed XIAO ESP32-S3 with WIO LoRa | Observer/MQTT repeater | 8 MiB | 8 MiB octal | MAC `28:84:85:B4:09:80`, Windows COM31 |
+
+This is not an R8 test. Both Indicator variants use the same 320x320 canvas;
+the native 480x480 Full-plus-MQTT configuration is not qualified by this run.
+One custom TLS MQTT slot per board publishes only to the isolated Pi broker.
+These results do not qualify five concurrent slots, JWT renewal, heavy RF
+traffic, every OTA receive/apply path, or months of operation.
+
+## Reproducible builds
+
+Base commit: `bc0fb2c335938c97220de51098cea6162f2f0fe0`, following PR #7's merge.
+Copy `tools/hil/s3_memory_soak.ini` to ignored `platformio.local.ini` and build
+the six named environments sequentially, with `MESHCORE_ESP32_FULL_BUILD=1`.
+Never run multiple PlatformIO processes in this checkout. Archive each image,
+ELF, map, partitions, bootloader, and memory report before the next build.
+
+The private broker's public CA must be in `ssl_certs` while generating these
+experimental images. Remove it and `platformio.local.ini` afterward. Private
+keys stay on the Pi. WiFi credentials and the Windows logger token are in
+private local files; do not commit them or the generated certificate bundle.
+Broker certificate verification remains enabled.
+
+The V4 and XIAO lab recipes also enable `MESH_SOAK_WIFI_KEY_OVERRIDE` and
+require the ignored local header `tools/hil/S3SoakWiFiKey.h`. To reproduce
+diagnostics using ordinary WiFi credentials, remove that define from the lab
+recipe; the private header is then unnecessary. Published recipes do not
+enable either lab flag.
+
+| Image | Application bytes | Link-time available internal bytes | SHA-256 |
+|---|---:|---:|---|
+| V4 A | 1,910,392 | 237,360 | `68c2749e0ea40db91d953f6355010bc602f85424526ca6acbe1cad7fd11c8630` |
+| V4 B | 1,911,256 | 252,760 | `6847ad4ad6bac2e558b8aed67de37417a497972391f13a71e0a42b5c9704dcff` |
+| Indicator A | 2,313,256 | 244,248 | `3d0ac27f95ddc54a32b59a3892d157713dcded285dbeb98d1bdfdb1ce0b951ab` |
+| Indicator B | 2,314,136 | 255,496 | `83045d7ec48befc43f3460c08c68b71da037a5476e14dd59def5706fc5a1b07e` |
+| XIAO A | 1,845,000 | 238,448 | `6fcc657c5ccbc5dd3d53a0cf1ceebde54479d59d329f8625e7cb7141a27d9ed8` |
+| XIAO B | 1,845,912 | 253,864 | `2b233e42e0c515b152ce83ca8fe38b1f35780970bb6350dbadf2591b6a2699a1` |
+
+All six firmware builds passed their RAM/flash gates. The linked internal
+capacity gain is 15,400 bytes on V4, 11,248 bytes on Indicator, and 15,416 bytes
+on XIAO. This is
+capacity returned while idle, not a reduction in peak OTA allocation needs.
+Both A/B variants keep the existing MQTT client reuse policy and internal
+MQTT task stack; this experiment does not move that stack into PSRAM.
+
+## Instrumentation
+
+`MESH_SOAK_DIAGNOSTICS` exposes local-only `get soak`, `soak ota-cycle`, and
+`soak wifi-drop` commands. They report free/largest/minimum internal heap,
+free PSRAM, OTA context size/presence, WiFi state, and uptime. No published
+recipe enables these controls.
+
+The lab network has a valid 64-digit raw WiFi key. Production MQTT preferences
+currently accept only 63 characters. The V4 experiment uses a separate,
+test-only NVS key through `MESH_SOAK_WIFI_KEY_OVERRIDE`, without changing the
+production binary preference layout. This does not fix released raw-key input.
+The network's original nine-character passphrase was subsequently recovered
+from the connected Windows profile; XIAO uses it through ordinary settings.
+The override is compiled into both XIAO images but no override key is stored.
+
+The Pi logger owns one persistent V4 USB handle: opening this particular USB
+device resets it, even with DTR/RTS disabled. Firmware installations and logger
+restarts therefore begin new uptime runs. Indicator telemetry uses TCP 5002.
+The logger distinguishes the 49.7-day `millis()` rollover from a reboot.
+
+Passive samples omit `get mqtt.stats`: its outbox query can block behind the
+MQTT client lock during a TLS handshake and stall the CLI. The logger reads
+`get mqtt.status` and separately counts messages received through a verified
+TLS subscription. It stores topic counts, not MQTT payloads or credentials.
+
+Two exploratory rapid-flap runs used an overly short 90-second recovery cutoff.
+The first also used the blocking outbox probe. Preserve those pilot results;
+do not count them as successful tests. Production MQTT backoff deliberately
+grows to five minutes and clears after two stable connected minutes, so rapid
+flapping is not equivalent to repeated independent connection tests.
+
+## Results and continuing soak
+
+V4 and Indicator both passed the matched A/B workload: 200 explicit OTA context
+acquire/release attempts, four MQTT bridge restarts, one WiFi reconnection and
+one five-second private-broker outage per image. An initial bridge restart
+preconditions each run. No unexpected uptime resets occurred in these runs.
+A was paused after its four bridge restarts to investigate the display report,
+then resumed for the network faults; its elapsed duration includes that pause.
+
+After the workload, with WiFi and one TLS MQTT slot connected:
+
+| Board | A free internal bytes | B free internal bytes | Gain | A largest block | B largest block |
+|---|---:|---:|---:|---:|---:|
+| Standard V4 | 95,396 | 111,040 | 15,644 (15.28 KiB) | 81,908 | 98,292 |
+| Indicator | 107,168 | 118,520 | 11,352 (11.09 KiB) | 94,196 | 106,484 |
+| XIAO (same saved settings, cold boot) | 151,028 | 166,444 | 15,416 (15.05 KiB) | 139,252 | 147,444 |
+
+Free heap varies with network activity; link-time capacity is the cleaner
+measurement of storage moved out of static RAM. PSRAM remained about 2.01 MB
+on V4 and 6.33 MB on Indicator. In each B run the OTA context returned to absent
+after the allocation workload. Neither board's largest block shrank through
+those 200 allocation cycles or the reconnect workload. WiFi/MQTT recovery took
+about 4.3 seconds on A and 3.8 seconds on B; broker recovery took about 19.5 and
+21.8 seconds respectively. These are single observed recovery times.
+
+Workspace recreation has an observed latency cost. The 200 command cycles
+took approximately 189 seconds on V4 B and 379 seconds on Indicator B, versus
+32 and 15 seconds on A. This includes command transport, OTA initialization,
+and ordinary firmware service, so it is not an allocator microbenchmark.
+The static comparator already owns an initialized workspace; the dynamic
+variant recreates it. This experiment does not claim faster OTA startup, and
+this cost should be checked against real session use before a default change.
+Code inspection shows that `OtaContext::begin()` calls `ota_self_firmware()`,
+which scans the running ESP32 application for its EndF identity on each call.
+All six experimental images contain valid EndF trailers. Caching immutable
+boot identity is a candidate for a separate change, not part of these results.
+
+The real WiFi mOTA folder endpoint was tested separately on Indicator: 20
+empty-folder sessions on A and 40 on B. All completed and B released its context.
+During B's first batch, free internal RAM went from 118,552 to 116,316 bytes,
+and its largest block from 106,484 to 102,388. A second batch ended at 116,308
+bytes with the same 102,388-byte largest block. This retained allocation/layout
+change is not explained by the now-absent OTA context; record it separately and
+watch it during the soak. It did not keep growing in the second batch.
+
+The original A folder script also tried V4, whose observer recipe has no TCP
+5001 folder source. Its overall result therefore says `passed: false`, despite
+Indicator completing all 20 sessions. That endpoint mismatch is preserved in
+the evidence; it is not an OTA failure on V4. The corrected script targets only
+the Indicator. These empty-folder checks do not qualify a full firmware transfer.
+
+XIAO passed 200 context cycles, four bridge restarts and one WiFi reconnect
+per image, with no unexpected reset. Its largest block stayed unchanged within
+each workload. WiFi/TLS recovered in about six seconds for both. This local
+test did not inject a separate broker outage or exercise a TCP folder endpoint.
+Verified subscriptions on the Pi confirmed actual TLS publications for both.
+
+The first XIAO A run followed initial device configuration and finished with
+126,140 internal bytes free, which would have overstated the OTA saving. It is
+preserved as `xiao-live/A-configuration-stress.json`. Reflashing A without an
+erase, retaining exactly the saved settings used by B, and rerunning the whole
+workload produced the 151,028-byte baseline above. Its resulting gain exactly
+matches the 15,416-byte linked-capacity change. The earlier larger gap is not
+credited to OTA storage; the precise retained allocation was not isolated.
+
+The Pi stores evidence under `/home/mikec/hwtest/runs/s3-memory-soak`:
+
+- `telemetry.jsonl`: append-only memory, WiFi/MQTT state, uptime, transport errors.
+- `latest.json`: latest samples plus broker-side received-message counts.
+- `A-stress.json` / `B-stress.json`: matched stress results and phase boundaries.
+- `A-folder.json` / `B-folder.json`: real WiFi mOTA empty-folder session checks.
+- `B-repeat-folder.json`: the extra batch checking the initial allocation change.
+
+`meshcore-memory-soak.service` holds the USB connection and samples each minute.
+`meshcore-soak-broker.service` supplies the private TLS broker. Both are enabled
+on the Pi. The logger never reboots a board to hide a firmware failure, but
+reopening the V4 USB transport can itself reset it; inspect transport counts,
+service restarts, and uptime together when diagnosing any interruption.
+The final passive phase started at `2026-09-11T07:18:07Z`. Installing the final
+logger revision (which serializes concurrent samples) deliberately restarted
+the service and therefore reset V4 once. That begins its long-soak uptime run;
+Indicator retained its uptime. Both then reported WiFi/TLS connected, absent
+OTA contexts, and actual broker-side publications.
+
+The local XIAO logger owns COM31 and writes under
+`C:\git\MeshCore\out\s3-memory-soak\xiao-live`. Its control endpoint is
+loopback-only with a private token. It runs while this Windows session remains
+alive; it is not a Pi service and will not survive a computer shutdown. A
+separate verified TLS subscription on the Pi confirmed XIAO publications for
+both images. The Pi logger's per-device counts cover V4 and Indicator only.
+The final XIAO B passive phase was verified at `2026-09-11T07:21:21Z`, with
+166,508 free internal bytes, a 155,636-byte largest block, WiFi/TLS connected,
+and no active OTA context. The largest block differs from the post-stress
+sample because this is a fresh boot, which illustrates why comparisons must
+use the same workload history. A Pi subscription received its publication two
+seconds later.
+
+Read-only checks on the Pi:
+
+```sh
+systemctl is-active meshcore-memory-soak meshcore-soak-broker
+cat /home/mikec/hwtest/runs/s3-memory-soak/latest.json
+```
+
+On Windows, read `out/s3-memory-soak/xiao-live/latest.json`. Keep the computer
+awake for continuous local samples. Neither logger automatically reboots a
+radio in response to a failed sample.
+
+The public lab CA was removed from `ssl_certs`, and the temporary local PIO
+configuration and generated test trust bundle were removed after building.
+Archived experimental firmware and public evidence remain in ignored
+`out/s3-memory-soak`; no test CA enters the published build configuration.
+
+No published default has changed. Leave the experimental B images running to
+collect longer evidence before deciding whether to expand S3 heap allocation.
+More idle internal RAM gives WiFi/TLS useful headroom; it does not itself prove
+stability or eliminate the need to allocate the OTA workspace during use.
+
+## Physical display checks
+
+The user reported unresponsive screens during MQTT restart testing. The V4
+lit after temporarily choosing `on`; after restoring battery and USB profiles
+to `button`, 15 seconds, the user confirmed that pressing the button wakes it.
+The synchronous reconnect/diagnostic work may have delayed UI service, but the
+exact cause of the original visual report was not established.
+
+The T096 is a separate nRF52840 board, USB identity `651F8E496197F882`. It was
+reflashed with current Full firmware and briefly instrumented. The trace showed
+the radio, sensor and UI loop advancing; the display had initialized but was
+marked off. Its functional CDC0 was in Binary mode after a host-session close.
+Sending a newline-delimited `+++MESHCORE-TERM-START` restored ASCII control.
+With display mode set to `on`, the user confirmed the normal screen appeared.
+Temporary tracing was removed by reinstalling the non-instrumented image, and
+battery/USB profiles were restored to `button-pairing`, 15 seconds. The user
+then confirmed that its button wakes the normal screen after timeout.
+
+T096 recovery firmware: `v1.17.1-t096-check`, Full/shared OTA queue, `-Os` size
+profile, available linked internal RAM 99,892 bytes (73,728 required). ZIP SHA-256
+`f5cfcb001efda559ae9b4e7b105ac9766a604808f5fd708b7c533f8a11c64578`.
+No device-wide filesystem erase was needed for this recovery. T096 does not
+count as an S3 A/B test.
+
+Host validation: `python -m unittest discover -s tools/hil -p test_s3_memory_soak.py`.
+The five checks cover diagnostic filtering, missing data, resets, and counter
+rollover. They are not substitutes for the hardware evidence.
diff --git a/docs/test-results/separated_radio_modulation_cache_validation.md b/docs/test-results/separated_radio_modulation_cache_validation.md
new file mode 100644
index 000000000..5533550f8
--- /dev/null
+++ b/docs/test-results/separated_radio_modulation_cache_validation.md
@@ -0,0 +1,140 @@
+# Separated radios, normal XIAO RX gain, and unchanged-modulation timing
+
+Measured 2026-09-14 on `keymindCascade`, base commit
+`0e5955f889788a4317863e731b38ffe09e3d5441` plus local changes.
+
+The unchanged-modulation optimization reduces XIAO frequency-only retunes
+from **548.880 to 451.897 us (17.7%)** in a same-image control. The separated
+four-channel SF6 test still misses packets. Separation and disabling XIAO
+boosted gain were changed together; their RF effects are not isolated.
+
+## Settings and controls
+
+- XIAO ESP32-S3/WIO SX1262 COM31 receiver; SenseCAP Indicator ESP32-S3 COM28
+ reference transmitter. The user moved the radios apart; distance and
+ attenuation were not measured. RAK4631 and Indicator RP2040 untouched.
+- XIAO HIL build has `SX126X_RX_BOOSTED_GAIN=0`; actual RX gain register
+ readback was **0x94** in initial info, every stationary arm, and every scan
+ status. Indicator remains 0x96. Normal XIAO production default is unchanged.
+- 32-symbol preamble, CR4/5, 16-byte synthetic packets, -9 dBm, buffered 8 MHz
+ SPI. Four scan centers 909.500/909.750/910.000/910.250 MHz.
+- No guard shortening, forced retunes through packets, RF retransmissions,
+ new public N-channel protocol, or additional oscillator-delay bypass.
+
+## Same-image, receive-only timing A/B
+
+Each board ran four three-second windows in off/on/on/off order, rebooting
+between windows. SF6/125 kHz, four channels, 2,612 us nominal dwell, no extra
+IRQ tracing, no TX. A HIL-only hook invalidates just the acknowledged
+modulation tuple before each baseline hop. Optimized windows use production
+cache behavior. The binary and all other settings remain identical per board.
+
+Time is entry to the production retune through BUSY-low observation. It does
+not include the listening dwell or full application scheduling, and is not an
+independent RF settling measurement. Unlike the older mixed-bandwidth tests,
+this frequency-only workload can skip 0x8B.
+
+| Board / policy | Hops | Mean | Min–max | Actual 0x86 / 0x8B writes |
+| --- | ---: | ---: | ---: | ---: |
+| XIAO, always write | 1,898 | 548.880 us | 535–663 us | 1,898 / 1,898 |
+| XIAO, reuse unchanged tuple | 1,957 | 451.897 us | 441–580 us | 1,957 / 0 |
+| Indicator, always write | 564 | 8,263.438 us | 8,228–8,428 us | 564 / 564 |
+| Indicator, reuse unchanged tuple | 602 | 7,601.193 us | 7,583–7,762 us | 602 / 0 |
+
+Pooled means are sample-weighted from reported per-window means. Savings are
+96.983 us (17.7%) for XIAO and 662.246 us (8.0%) for Indicator. All 5,021 hops
+passed RX-mode/software-cache checks with zero retune errors or deferrals.
+Counters observe outgoing SPI opcodes, not merely which software branch ran.
+Initial setup before counter snapshots is excluded. The Indicator remains
+above the common 6 ms switching allowance; no common timing budget changed.
+
+## Stationary RF controls
+
+Full initialization before every case, fixed RX at 910 MHz, ordinary RX with
+fast reuse disabled, shuffled offsets, 450 ms capture windows. Method matches
+the [earlier investigation](preamble_detection_investigation.md).
+
+| TX offset | SF6 preamble windows | SF8 preamble windows |
+| --- | ---: | ---: |
+| Silent | 0/4 | 0/8 |
+| On frequency | 4/4 | 8/8 |
+| -250 kHz | 0/4 | 1/8 |
+| +250 kHz | 0/4 | 1/8 |
+| -500 kHz | 0/4 | 2/8 |
+| +500 kHz | 0/4 | 0/8 |
+| -1,000 kHz | 0/4 | 0/8 |
+| +1,000 kHz | 0/4 | 0/8 |
+
+All 12 on-frequency probes delivered valid payloads. No off-frequency header
+or valid payload, silent preamble, device/read error, foreign valid payload,
+or trace overflow occurred. SF6 off-channel detections changed from 1/24 to
+0/24; SF8 from 32/48 to 4/48. These are small bounded samples, not sensitivity
+or packet-error-rate estimates. Signal strength (RSSI) sampled on-frequency
+was -41 to -40 dBm at SF6 and -42 dBm at SF8: the signal remains strong, but
+these are not calibrated attenuation measurements. Improvement cannot be
+attributed solely to separation or solely to normal gain.
+
+## Four-channel SF6/125, 5.1-symbol visits
+
+Same image, baseline then optimized, reboot both boards between runs. Each
+requested 100 packets/channel with varied unsynchronized arrival timing and
+stopped at its first miss. Extra nominal 64 us IRQ polling was enabled here,
+unlike the receive-only timing windows. No higher channel count was tested.
+
+| Policy | Received before first miss | Failed channel | Timed hops | Mean retune |
+| --- | ---: | --- | ---: | ---: |
+| Always write 0x8B | 24, then probe 25 missed | 1 / 909.750 MHz | 4,746 | 551.253 us |
+| Reuse unchanged tuple | 51, then probe 52 missed | 1 / 909.750 MHz | 7,748 | 455.157 us |
+
+Optimized RF run: 7,697 hops omitted 0x8B, averaging 453.081 us; 51 hops
+reapplied it after received packets, averaging 768.529 us. These are different
+RX lifecycle paths, not a pure 315 us command-cost comparison. Both runs had
+one frequency write per hop and no RX-mode/cache/retune errors. Successful
+packets read -42 to -40 dBm with SNR 10.0–11.5 dB. Neither met 100/100 on every
+channel. More packets before the first miss does not establish a reliable
+improvement in packet-error rate.
+
+The baseline miss captured a target-channel preamble at 176,194 us after
+arming, 1,830 us after a hop onto that channel. A guard held it there; no
+header or packet followed. An IRQ clear was observed at 195,025 us and the
+next hop completed at 195,240 us. This is **not** the earlier SF8 wrong-channel
+hold: the preamble was observed on the intended channel. Its RF source and
+reason for failing to progress remain unproven.
+
+The optimized miss captured 3,256 hops over ten seconds with **no observed
+nonzero IRQ and no guard hold**, reproducing the pre-acquisition symptom of
+the earlier SF6 miss. Target-channel revisit intervals were 12.249–12.324 ms
+(mean 12.286 ms), shorter than the 16.384 ms programmed preamble, yet no packet
+was acquired during those short individual visits. This does not establish
+that additional post-retune settling is needed. Trace buffers did not overflow.
+TX returned success for both failed probes; the MCU clocks are not synchronized
+and there was no independent RF capture.
+
+## Provenance
+
+RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32
+6.11.0 / Arduino ESP32 2.0.17. Both targets built and uploads were verified.
+These captures precede the subsequent SF5/250 bandwidth-selector addition.
+
+Tested firmware SHA-256:
+
+- XIAO: `8aa79da891409c9e600ce14c1d83a8d73c3198225a2aaf799fc884247e882fab`
+- Indicator: `868946d0beca882d8f215c1556c0c19bf41908baac6620dc5632c3fca85b5feb`
+
+Raw files in `tools/hil`, preserved byte-for-byte:
+
+| File | SHA-256 |
+| --- | --- |
+| [XIAO timing](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_same_modulation_xiao_results.json) | `45e7a640323a3c9028b6c92a5128e51307db1bd94a4846e8744d3c3ec098adfd` |
+| [Indicator timing](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_same_modulation_indicator_results.json) | `f0a85826f500b3a9173bf22c6207fbfba3b6cb17f3d70b849a9fe7621ae3dfc5` |
+| [Stationary SF6](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/preamble_stationary_sf6_separated_normal_results.json) | `1d981e5af41eabbc43f92641813bf8f7c5e7015439f5ba53ce0467fe6477d3f3` |
+| [Stationary SF8](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/preamble_stationary_sf8_separated_normal_results.json) | `36e63da7435d0a16f61db7db7730893de392d028feca7a96960fd17ae1fa624b` |
+| [SF6 baseline scan](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/sf6_125_5p1_separated_normal_baseline_results.json) | `cbfd200170891b334ef3958479694c6e318146993b94073eb9aee981f97fde97` |
+| [SF6 optimized scan](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/sf6_125_5p1_separated_normal_optimized_results.json) | `d88a621ef9aa358ce52bb3efb6aa9c4bcb77c681af20b2c66e4aebf3721e17ca` |
+
+Fresh complete 8 MiB backups were saved and separately verified in the private
+current-user backup directory before testing. Restores had already started
+when the user requested leaving HIL firmware installed; those in-flight writes
+and verification finished safely. Both boards were subsequently loaded with
+the newer SF5/250 HIL build for the user's next sweep. Do not restore them
+again unless requested. No private backup contents were added to Git.
diff --git a/docs/test-results/sf10_10_vs_100hz_full_sample_validation.md b/docs/test-results/sf10_10_vs_100hz_full_sample_validation.md
new file mode 100644
index 000000000..264f2cd33
--- /dev/null
+++ b/docs/test-results/sf10_10_vs_100hz_full_sample_validation.md
@@ -0,0 +1,159 @@
+# SF10/125 full samples: 10 Hz versus 100 Hz first-pass detour
+
+2026-09-14. **Both 400-packet samples completed: 100 attempts on each of four
+channels, with no RF retries or early stop on misses. Payload loss was
+64/400 (16.0%) at 10 Hz and 81/400 (20.25%) at 100 Hz. Strict same-channel
+reception was almost identical: 230/400 versus 231/400. The larger offset
+showed no improvement in this comparison.**
+
+These are two sequential finite bench samples, not proof that the offset
+caused the difference in payload loss. The earlier stop-first tests could
+not provide this error-rate estimate and are not combined with these counts.
+
+## Settings and method
+
+- RX: XIAO ESP32-S3 / Wio SX1262, COM31, 28:84:85:B4:09:80.
+- TX: XIAO nRF52840 / Wio SX1262, COM15, B35E71C1C3726CE7.
+- Uniform SF10, BW125 kHz, nominal CR4/5; 909.5, 910.5, 911.5, 912.5 MHz.
+- 32-symbol preamble; 5.1-symbol visits (41,780 us); zero added settling.
+- Normal RX gain 0x94, -9 dBm chip TX request, 16-byte CHS1 payloads.
+- Warm XOSC, bulk SPI, fast RX; TCXO setting 1600 us. No added IRQ tracing.
+- Two full retunes per hop. First: selected positive RF offset, CR4/6.
+ Second: exact original RF command, CR4/5. Duplicate frequency writes
+ remain inside each pass: four RF commands, two modulation writes and
+ two optimized RX starts per completed hop.
+- Requested 10 Hz = +10 integer RF-word steps, nominal 9.5367431640625 Hz.
+ Requested 100 Hz = +105 steps, nominal 100.13580322265625 Hz. Neither
+ depends on a float-MHz addition. This is programmed, not independently
+ spectrum-measured, frequency.
+- Same receiver image for both runs; only the requested offset changed.
+ Order: 10 Hz, reboot, 100 Hz. TX firmware was unchanged.
+- Seed 606125: shuffled channel order each round and varied arrival pauses.
+ Each run has 400 unique probe sequences and exactly 100 TXs per channel.
+- Existing 10-second expectation timeout retained. A miss does not reset
+ the receiver, tune it toward TX, reset the visit clock or clear counters.
+ Hardware/guard/command failures still abort rather than become RF losses.
+
+The complete comparison ran from 21:09:11 to 21:42:39 UTC (14:09–14:42 PDT),
+about 33 minutes. The 10 Hz sample took about 15.4 minutes; 100 Hz about 18.0.
+The extra elapsed time is largely the additional ten-second timeouts.
+
+## Overall results
+
+"Payload loss" means no intact expected payload was delivered within the
+expectation window, regardless of reported RX channel. "Strict error" also
+counts intact payloads received while the scanner reports another channel.
+
+| Requested offset | Attempts | Correct-channel delivery | Intact wrong-channel delivery | Timeouts / payload loss | Strict errors |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| 10 Hz | 400 | 230 (57.5%) | 106 (26.5%) | 64 (16.0%) | 170 (42.5%) |
+| 100 Hz | 400 | 231 (57.75%) | 88 (22.0%) | 81 (20.25%) | 169 (42.25%) |
+
+All scored delivery failures were timeouts. The receiver also counted six
+packet-read errors at 10 Hz and eleven at 100 Hz. The error codes were not
+captured, so these cannot be identified specifically as CRC failures or
+mapped one-to-one to timeout probes. "Zero invalid payloads" in the summary
+does not mean there were no packet-read/CRC errors before timeout.
+
+## Per-channel results
+
+Each cell uses exactly 100 attempts. Loss counts therefore also equal percent.
+
+| Center (MHz) | 10 Hz payload loss | 100 Hz payload loss | 10 Hz strict errors | 100 Hz strict errors |
+| --- | ---: | ---: | ---: | ---: |
+| 909.5 | 10% | 11% | 26% | 25% |
+| 910.5 | 23% | 23% | 51% | 45% |
+| 911.5 | 19% | 26% | 48% | 55% |
+| 912.5 | 12% | 21% | 45% | 44% |
+
+The extra 17 payload losses at 100 Hz occurred on channels 0 (+1), 2 (+7)
+and 3 (+9). Correct-channel deliveries differed by only one packet overall.
+Neither offset eliminated wrong-channel acquisition/delivery or timeouts.
+This comparison does not isolate the underlying RF failure mechanism.
+
+## Switching and signal measurements
+
+| Metric | 10 Hz | 100 Hz |
+| --- | ---: | ---: |
+| Completed hops | 16,992 | 20,676 |
+| Mean complete two-pass switch | 1263.765 us | 1262.551 us |
+| Maximum complete switch | 1533 us | 1532 us |
+| Mean first pass | 660.712 us | 659.458 us |
+| Mean second pass | 592.344 us | 592.406 us |
+| RF commands | 67,968 | 82,704 |
+| Modulation commands / RX starts | 33,984 / 33,984 | 41,352 / 41,352 |
+| Maximum unheld four-channel cycle | 174,139 us | 173,722 us |
+| Maximum held channel visit | 590,757 us | 640,492 us |
+| Packet-read errors | 6 | 11 |
+
+Unheld cycles fit within the 262,144 us programmed preamble; packet-related
+held visits can exceed it. Aggregate timing without IRQ traces does not prove
+which hold caused any particular timeout. The approximately 1.2 us difference
+in mean switching time is negligible here and does not explain the losses.
+
+All completed hops passed the internal first-offset/final-correction and CR
+command checks: 16,992/16,992 and 20,676/20,676, `detour.bad=0`. Second-pass
+blocks, retune failures, mode errors and cache errors were zero. Counts match
+four RF writes, two modulation writes and two RX starts per completed hop.
+
+Telemetry limitation: the diagnostic `detour.first_rf/first_mod` fields are
+updated on a guard-deferred first attempt as well as an applied attempt, while
+`final_rf/final_mod` retain the last completed second pass. Thus the final
+snapshot's displayed pair can be from different attempts and appears equal
+in these captures. Those fields must not be interpreted as the last verified
+pair. Verification itself runs only after two applied passes and increments
+`detour.n` only then; its per-hop comparisons, zero failures and matching
+counts establish the commanded sequence. Original capture bytes are retained.
+
+| Packet group | 10 Hz mean RSSI / SNR | 100 Hz mean RSSI / SNR |
+| --- | --- | --- |
+| Correct-channel delivery | -34.05 dBm / +8.56 dB | -34.15 dBm / +8.62 dB |
+| Intact wrong-channel delivery | -105.18 dBm / -13.75 dB | -105.22 dBm / -13.62 dB |
+
+The markedly weaker wrong-channel readings are observations, not proof of a
+specific filter/image/overload mechanism.
+
+## Implementation and verification
+
+The HIL scanner gained `scancontinue 1`, default off, so its ordinary completion
+and timeout accounting can leave scanning active. The host gained bounded
+`--continue-on-miss --max-channels 4` with separate delivery/strict summaries.
+`scandetouroffset 10|100` and `--first-pass-offset-hz 10|100` select the integer
+offset while stopped. Production radio defaults and receive guards were not
+changed. The controller is `tools/hil/profile_switch_offset_compare.py`.
+
+23 native profile tests and 51 host tests passed. Coverage includes continuing
+through all 400 attempts without restart/retry, balanced channels, separated
+error definitions, default first-miss behavior, aborting fixture failures,
+both integer offsets and exact restoration. Build/upload succeeded with hash
+verification: RAM 94,948 bytes, reported flash 370,213 bytes.
+
+There was one cached USB RX-result replay in each sample, with no RF retransmit.
+All TX commands returned success. Host/device strict receive and miss counters
+matched; both captures are complete under `fixed_sample_complete`. The master
+manifest is complete under `both_400_packet_samples_complete`, with no fixture
+or cleanup error.
+
+Both boards rebooted idle afterward. RX confirmed scanner inactive, zero
+channels, one-pass default, detour/continue disabled and normal gain 0x94.
+TX confirmed no autonomous TX. HIL remains installed; RAK COM29 was not opened.
+The temporary PlatformIO overlay was removed. No background collector remains.
+This work made no commits or pushes. Existing unrelated changes were preserved.
+
+## Provenance
+
+- Manifest: `tools/hil/profile_switch_sf10_10_vs_100hz_full_results.json`
+ SHA-256 `40ef9d70991b585abe6b99b3dc4878829412d55e0e8b5a984576b00db0326981`
+- 10 Hz capture: `tools/hil/profile_switch_sf10_10_vs_100hz_full_results_10hz.json`
+ SHA-256 `889a7afeb980ec8459fccb8da5a7c496feebd433da8715f84181f02c1da613aa`
+- 100 Hz capture: `tools/hil/profile_switch_sf10_10_vs_100hz_full_results_100hz.json`
+ SHA-256 `0b54a057c07f9da0b03f585aeffee960ffcfa2ecb5383914554697ca250aa071`
+- RX BIN SHA-256:
+ `bddf779208cbfd24478d39d3075f3f7537b7c9275453492748a349f3ec15b630`
+- Unchanged TX ZIP SHA-256 from the preceding validated upload:
+ `915d507d0feec1712fad2bd4be62c2fa8d769d11400711de0da1cf98a50a4b36`
+
+Source: live keymindCascade working tree at HEAD
+`2b5a6f209835f0f67ed73fee78d36447a4566ce6`, plus existing dirty changes and
+these HIL additions. The RX image hash identifies the exact tested build.
+Earlier captures were not overwritten.
diff --git a/docs/test-results/sf10_10hz_cr_detour_validation.md b/docs/test-results/sf10_10hz_cr_detour_validation.md
new file mode 100644
index 000000000..5f1975342
--- /dev/null
+++ b/docs/test-results/sf10_10hz_cr_detour_validation.md
@@ -0,0 +1,143 @@
+# SF10/125: tiny first-pass frequency offset and coding-rate detour
+
+2026-09-14. **The requested offset no longer rounds to zero. Actual outgoing
+commands verified a +10-step RF-word change (nominal +9.536743 Hz), then exact
+restoration of the original word, with CR4/6 then CR4/5. Two passes averaged
+1.259 ms in RX-only timing. The first packet still timed out.** This is a
+bounded one-probe failure, not a 400-packet loss-rate estimate.
+
+## Scope and precision
+
+The request was to apply a slightly different center frequency and coding
+rate on the first full retune, then the correct settings on the second,
+with about 0.01 kHz difference. At these centers a float in MHz advances in
+roughly 61 Hz increments: adding 0.00001 MHz can produce the same float.
+
+The HIL implementation does not make that float addition. A scoped integer
+offset changes the four-byte argument of the actual 0x86 frequency command
+by ten steps. The nominal SX1262 step is 32 MHz / 2^25; ten steps are
+9.5367431640625 Hz, the nearest achievable nominal command offset to 10 Hz.
+See the frequency-step specifications in the [Semtech SX1261/2 datasheet](https://cdn.sparkfun.com/assets/6/b/5/1/4/SX1262_datasheet.pdf).
+Pinned RadioLib 7.7.1 also specifies this step and sets the standard sensitivity
+configuration bit for bandwidths other than 500 kHz. This establishes the
+programmed offset, not a spectrum-analyzer measurement of physical frequency.
+
+First pass: destination RF word + 10 and CR4/6. Second pass: original RF word
+and CR4/5. The offset is applied to both existing duplicate frequency writes
+inside the first pass. Caller buffers remain unchanged; a scoped restore
+prevents the offset leaking into the corrected pass or an unrelated command.
+Rollback applies the offset only when restoring the temporary CR4/6 tuple.
+
+Both passes retain ordinary packet/ownership/BUSY guards, command-result
+checks and RX startup. A blocked second pass fails the diagnostic rather than
+overriding a packet guard. Each completed hop verifies both outgoing frequency
+words and modulation words (0x0a040200 then 0x0a040100). The host requires a
+verification count equal to completed hops and zero mismatches.
+
+This feature is HIL-only, off on reboot. Production defaults were not changed.
+The earlier +31.25 kHz draft was superseded before flashing or RF testing.
+
+## Fixture and method
+
+- RX: XIAO ESP32-S3 / Wio SX1262, COM31, 28:84:85:B4:09:80.
+- TX: XIAO nRF52840 / Wio SX1262, COM15, B35E71C1C3726CE7; unchanged firmware.
+- Centers 909.5, 910.5, 911.5, 912.5 MHz; uniform SF10/BW125.
+- 32-symbol preamble, 5.1-symbol visits (41,780 us), no added settling.
+- Normal RX gain 0x94; warm XOSC, bulk SPI and fast RX; TCXO setting 1600 us.
+- 16-byte CHS1 probes, -9 dBm chip TX request, varied arrival phase.
+- Goal 100 packets/channel; stop at the first strict miss or 400/400.
+- Four frequency commands, two forced modulation writes, two optimized RX
+ starts per completed hop. No extra IRQ tracing/polling.
+
+Same-image RX-only ABBA controls compare two identical full passes (A) with
+the first-pass detour followed by correction (B), six seconds each, rebooting
+between windows. This is not a comparison against the original single-pass
+approximately 0.53 ms cached-modulation path.
+
+## Timing
+
+| Window | First-pass detour | Hops | Mean total (us) | Maximum (us) | First pass mean (us) | Second pass mean (us) |
+| --- | --- | ---: | ---: | ---: | ---: | ---: |
+| A1 | Off | 139 | 1230.094 | 1464 | 631.727 | 594.281 |
+| B1 | On | 139 | 1259.770 | 1490 | 647.165 | 603.245 |
+| B2 | On | 139 | 1258.806 | 1481 | 646.547 | 602.957 |
+| A2 | Off | 139 | 1230.597 | 1467 | 633.209 | 593.252 |
+
+Mean two-pass control 1230.346 us; detour/correction 1259.288 us, about
+28.943 us additional total including configuration/verification bookkeeping.
+The detour passes themselves averaged 646.856 + 603.101 us. Both detour
+windows verified 139/139 completed hops with zero mismatches; all four
+windows had exactly 556 RF commands, 278 modulation commands and 278 RX
+starts, with zero guard blocks or RX/mode/cache errors.
+
+## Packet result
+
+Probe 1021099889, TX channel 3 at 912.5 MHz, timed out: **0 received / 1
+attempted**, then stopped. TX rc=0, setup 86,914 us, transmit call 529,297 us.
+RX was in receive mode, hardware device-errors=0. No payload-only diagnostic
+ran because this was a timeout, not an intact payload attributed to another
+channel. There were no RF retries.
+
+During that packet window:
+
+- 220 completed hops; 220 verified detour/correction pairs, zero mismatches.
+- 880 RF commands, 440 modulation commands, 440 optimized RX starts.
+- Mean switch 1262.095 us, maximum 1507 us; first pass 652.082 us, second
+ 601.295 us. Second-pass-blocked=0, retune failures=0, mode/cache errors=0.
+- Maximum unheld four-channel cycle 172,222 us, below the 262,144 us preamble.
+- Maximum held visit 542,732 us; the aggregate receive-error count was 1.
+ That counter means a packet read returned an error; this capture does not
+ retain its error code or IRQ trace, so it cannot establish CRC as the cause
+ or assign the hold to a particular channel. Device-errors=0 is a separate
+ hardware status and does not mean there were no receive errors.
+
+Across both detour timing windows and the packet window, **498/498 completed
+hops** verified the nonzero first-pass RF offset, original final RF word and
+CR4/6-to-CR4/5 sequence. This proves the change was exercised; it did not
+resolve reception in this bounded test. The underlying RF failure remains
+unidentified.
+
+## USB setup issue and verification
+
+The first controller attempt stopped before transmitting because the new
+126-byte capability JSON plus CRLF formed an exact 128-byte native-USB reply.
+It remained buffered until another command was sent. This was preserved as
+a fixture failure, not counted as packet loss. A single LF-terminated write
+(127 bytes) fixed this specific reply; three independent reads then passed
+without a follow-up command, and the complete controller ran successfully.
+
+22 native profile tests and 49 host tests passed. Added tests cover integer
+carry, unchanged caller bytes, both SPI paths, observation of shifted wire
+bytes, exact restoration, scope exit/nesting, non-frequency commands,
+overflow, detour verification failures and the USB reply length. Build/upload
+passed with flash hash verification: RAM 94,948 bytes, reported flash 369,593
+bytes. `git diff --check` passed (unrelated existing CRLF warning only).
+
+Both boards rebooted idle after completion. RX confirmed scanner inactive,
+zero channels, one-pass default, detour disabled, normal gain 0x94. TX reports
+no autonomous transmission. HIL firmware remains installed as requested.
+RAK COM29 was not opened. Temporary PlatformIO overlay removed; no collector
+left running. This experiment made no commits, pushes or production-default
+changes.
+
+## Provenance
+
+- Successful manifest: `tools/hil/profile_switch_sf10_10hz_cr_detour_verified_results.json`
+ SHA-256 `84a14c3ca3169b8afdaffbef4405a5ea25140560c799091e5f22257814713bcb`
+- Strict capture: `tools/hil/profile_switch_sf10_10hz_cr_detour_verified_results_strict.json`
+ SHA-256 `e7995b327b5a84d8fc1830ece1f4cfccd70cae9f367eefd91b7e97ade4a9fb68`
+- Setup-only failed attempt: `tools/hil/profile_switch_sf10_10hz_cr_detour_results.json`
+ SHA-256 `486dd52a4ce5f4b20fadec4e6af2e1ede9669acd3e6a09b3490ae5c8b52b82dd`
+- Installed RX BIN SHA-256:
+ `aae7fd3ffcb5226759884e4d1b41d9da67bb97e95ef89d29dd54f204412d6b48`
+- Unchanged TX ZIP SHA-256 from the preceding validated upload:
+ `915d507d0feec1712fad2bd4be62c2fa8d769d11400711de0da1cf98a50a4b36`
+
+Successful manifest complete, stopped `bounded_tests_complete`; strict capture
+complete under first-miss stopping. No fixture or cleanup error in that run.
+The build used the live keymindCascade working tree with existing dirty
+changes and these HIL additions, not a clean-release image. HEAD observed at
+completion was `2b5a6f209835f0f67ed73fee78d36447a4566ce6`; the unrelated
+Companion USB commit advanced HEAD from the earlier experiment's base during
+this ongoing work. Its files were not reverted. The RX image hash above is
+the exact tested-image identifier. Prior captures were preserved.
diff --git a/docs/test-results/sf10_full_retune_twice_validation.md b/docs/test-results/sf10_full_retune_twice_validation.md
new file mode 100644
index 000000000..d192d4208
--- /dev/null
+++ b/docs/test-results/sf10_full_retune_twice_validation.md
@@ -0,0 +1,116 @@
+# SF10/125: apply the full radio settings twice per hop
+
+2026-09-14. **Two full retune passes were verified, averaging 1.276 ms per
+channel switch. This did not resolve reception in the bounded packet test:
+the first packet timed out.** This is one failed probe, not a 400-packet loss
+measurement. No production defaults or receive guards were changed.
+
+## Requested interpretation and fixture
+
+"The 125 run" was interpreted as uniform SF10/125 on four channels, not the
+preceding mixed-SF/BW experiment. Apply the complete retune twice to each
+destination: frequency, modulation, preamble/packet configuration and RX
+restart, retaining normal guards and command-result checks for both passes.
+
+- Receiver: XIAO ESP32-S3 / Wio SX1262, COM31, 28:84:85:B4:09:80.
+- Transmitter: XIAO nRF52840 / Wio SX1262, COM15, B35E71C1C3726CE7.
+- Centers: 909.5, 910.5, 911.5, 912.5 MHz. SF10/125, CR4/5, 32-symbol
+ preamble, 5.1-symbol visits, 16-byte CHS1 probes, -9 dBm chip request.
+- Normal RX gain 0x94; warm XOSC and fast/bulk RX path; TCXO timing 1600 us.
+- No added settling and no extra IRQ tracing/polling. Goal: 100 packets per
+ channel, stop on the first strict miss or a complete 400/400 pass.
+- The previous immediate double frequency write remains inside **each**
+ pass: four frequency commands per hop. Modulation is forced in both passes,
+ giving two 0x8B commands and two optimized RX starts per completed hop.
+
+The earlier approximately 0.53 ms path could skip unchanged modulation.
+This test deliberately writes those settings again, so it measures more than
+0.53 + 0.53 ms. No artificial time padding was added to meet an expected value.
+
+## Same-image RX-only timing
+
+Six-second windows in 1/2/2/1-pass order, with a reboot before each. Both one-
+and two-pass controls force modulation writes, so the comparison isolates
+complete repetition rather than also changing cache policy.
+
+| Window | Passes per hop | Hops | Mean total (us) | Max total (us) | Mean first pass (us) | Mean second pass (us) |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| A1 | 1 | 141 | 647.284 | 847 | 644.220 | — |
+| B1 | 2 | 139 | 1275.410 | 1491 | 648.899 | 622.835 |
+| B2 | 2 | 139 | 1276.086 | 1488 | 649.000 | 623.288 |
+| A2 | 1 | 141 | 647.858 | 836 | 644.872 | — |
+
+Equal-weight/hop-count means: one full pass 647.571 us; two full passes
+1275.748 us. The first and second passes average 648.950 + 623.062 us;
+the total includes approximately 3.7 us additional wrapper/BUSY bookkeeping.
+Both two-pass windows had 556 frequency writes, 278 modulation writes and
+278 optimized RX starts. No second-pass guard block or hardware/cache error.
+
+## Packet test
+
+First probe: TX channel 3, 912.5 MHz, sequence 600920621. TX returned rc=0,
+setup 86,914 us, transmit call 529,297 us. RX timed out with mode=1 and device
+errors=0. The controller stopped without retrying or testing further packets.
+No separate payload-only diagnostic ran because this was a real timeout,
+not an intact wrong-channel delivery.
+
+During the packet window:
+
+- 223 channel hops; 892 frequency writes; 446 modulation writes; 446 optimized
+ RX starts. Both pass counters = 223, second-pass-blocked = 0.
+- Mean full switch 1275.058 us, maximum 1510 us.
+- First pass mean 648.812 us, second pass mean 622.601 us.
+- Maximum unheld full four-channel cycle 172,262 us, below the 262,144 us
+ programmed preamble. Mean listening visit 41,785.719 us.
+- Maximum held visit 405,102 us. No IRQ trace was taken, so the exact cause
+ and channel of this hold cannot be assigned from these aggregate numbers.
+- Received 0, missed 1. Mode errors, cache errors, RX errors and device errors
+ were all zero. Normal gain remained 0x94.
+
+This shows that completing two real settings applications does not by itself
+guarantee successful reception in this fixture. It does not prove every packet
+would fail or independently identify the cause of this particular timeout.
+
+## Implementation and verification
+
+HIL `BenchWrapper::hop` performs the first ordinary guarded `tuneProfile`,
+then waits boundedly for BUSY-low and requests a refresh of the same profile
+before the second ordinary guarded transaction. This bypasses only the
+same-generation no-op, not receive ownership or packet-in-progress checks.
+The modulation cache is invalidated for each requested full pass.
+
+If the second pass is blocked by newly acquired reception, the diagnostic
+fails closed. Returning ordinary BUSY would wrongly imply that no channel
+change occurred even though pass one had completed; it is therefore treated
+as a fixture failure, not silently skipped or counted as an RF loss.
+
+`scanretunepasses 1` is the reboot default. `scanretunepasses 2` and
+`--retune-passes 2 --modulation-cache off` select this bounded test.
+The standalone controller is `tools/hil/profile_switch_retune_repeat.py`.
+19 native profile tests and 49 host tests passed, including real refresh,
+first/second failure handling, BUSY timeout, no guard override, and rejection
+of missing physical write/resume counts. `git diff --check` passed with only
+unrelated existing CRLF warnings.
+
+Receiver build/upload succeeded with hash verification: 94,908 bytes RAM,
+368,389 bytes reported flash. Transmitter firmware was not changed or
+reflashed. Unrelated RAK COM29 was not opened. Both boards rebooted idle after
+completion, retaining HIL firmware; no autonomous TX or background collector.
+Temporary PlatformIO overlay removed. No commit/push requested or performed.
+
+## Provenance
+
+- Manifest: `tools/hil/profile_switch_sf10_full_retune_twice_results.json`
+ SHA-256 `2928955ce4b02828f3de292cdf8a18606cb56d8a5d6b65c7b50c3de1dd15f764`
+- Strict capture: `tools/hil/profile_switch_sf10_full_retune_twice_results_strict.json`
+ SHA-256 `322d1859716fbed0e42224f7bfbf71815dcc986df3ccccea37c1680a63723340`
+- Installed RX BIN SHA-256:
+ `aa7e3e96729bdb855593385a7a5ab9a22a5e186fda551db863a52a81b7cca2bb`
+- Unchanged TX ZIP SHA-256:
+ `915d507d0feec1712fad2bd4be62c2fa8d769d11400711de0da1cf98a50a4b36`
+
+Manifest complete, stopped `bounded_tests_complete`; strict capture complete
+under first-miss stopping. No fixture or cleanup error. Previous captures were
+not rewritten. Source remains keymindCascade commit
+`0e5955f889788a4317863e731b38ffe09e3d5441` plus existing dirty changes and
+these HIL-only additions, not a clean-release comparison.
diff --git a/docs/test-results/sf10_nrf52_double_write_settling_validation.md b/docs/test-results/sf10_nrf52_double_write_settling_validation.md
new file mode 100644
index 000000000..4d407c49a
--- /dev/null
+++ b/docs/test-results/sf10_nrf52_double_write_settling_validation.md
@@ -0,0 +1,136 @@
+# SF10 / 125 kHz, XIAO nRF52 TX, double write, 1 ms delay grid
+
+2026-09-14. **Fixed-channel positive control passed 100/100. The subsequent
+four-channel, double-write sweep failed at every 1 ms step from +0 to +23 ms.**
+
+New sender serial B35E71C1C3726CE7 first enumerated as Zephyr CDC COM33
+(2FE3:0004). A 1200-baud touch exposed Seeed XIAO bootloader COM25
+(2886:0044, XIAO-BOOT). The application-only DFU ZIP was accepted with its
+normal compatibility checks, reported programmed successfully and reappeared
+as COM15 (2886:8044), with the same serial and ready XIAO nRF52840/Wio SX1262
+HIL identity. No bootloader/SoftDevice replacement or full erase was performed.
+The unrelated RAK COM29 (4D6F77FD74011872) was not opened.
+
+## Requested test
+
+- Receiver: existing XIAO ESP32-S3/Wio SX1262 on COM31, normal RX gain 0x94.
+ MAC 28:84:85:b4:09:80. The host reasserts double-write mode after every reboot.
+- Sender: XIAO nRF52840/Wio SX1262 COM15, serial B35E71C1C3726CE7.
+- SF10/125 kHz, CR4/5, four channels, 5.1-symbol listening visits, 32-symbol
+ preamble, 100 packets/channel, fixed 16-byte probes at -9 dBm chip request.
+- Added post-BUSY settling 0, 1, 2, ... 23 ms. Each failed setting stops at
+ its first miss; the entire sweep stops at its first 400/400 pass. No added
+ delay beyond the nominal full-sweep timing ceiling and no lower SF runs.
+- Double frequency writes retained; modulation cache on; no extra IRQ trace.
+ No prior-transmitter or single-write measurements will be reused.
+
+Budget uses 539 us nominal double-write retune (ceil of the measured 538.192
+us RX-only mean), 41,780 us post-settle visit and 262,144 us preamble:
+`262144 / 4 - 41780 - 539 = 23217 us`. Last 1 ms grid point is 23,000 us.
+This is a nominal ceiling, not a worst-case jitter or zero-loss guarantee.
+
+## Preparation
+
+The settling controller now accepts `--step-us 1000 --frequency-repeat on`;
+its existing defaults are unchanged. Capture policy checks reject mixed
+single/double evidence. The 42 host collector tests and 14 selected profile
+native tests passed, including grid/first-pass stop, nRF USB DTR behavior,
+transmitter replay, setup/TX failure, stationary payload integrity, zero-retune
+and host/device-count checks. `git diff --check` passed (unrelated CRLF warnings).
+
+Minimal `profile_switch_xiao_nrf52_tx` firmware compiled successfully: 95,340
+bytes reported flash usage, 18,212 bytes static RAM. It uses the production
+XIAO pin map and CustomSX1262 initialization, exposes only reference TX and
+cached USB results, and has no Mesh identity or application settings writes.
+Includes the existing build-local bounded nRF USB READY/HFCLK fix.
+
+Platform: Nordic nRF52 10.8.0; Adafruit nRF52 framework
+1.10701.0+sha.d5413016; ARM GCC 14.2.1; RadioLib 7.7.1 at
+`187ef24791c3d844939b2be13a68bd890bd04e4c`.
+
+Generated DFU ZIP contains **application only**, requiring SoftDevice ID
+0x0123. Upload used normal DFU validation without compatibility overrides.
+The V4's earlier no-backup erase authorization is not treated as authority to
+erase this new board's bootloader or unrelated storage.
+
+Flashed nRF52 transmitter SHA-256:
+
+- HEX: `aaee693900dedc3816a6baeca576c7449502d30c05ac48e2663b02acec9aec9a`
+- DFU ZIP: `7466625ef5e7bcc8f7dd16db2b843db29b4312a33b0d32bc0b1e1193d6aee03a`
+
+## Baseline requested before continuing the sweep
+
+The initial four-channel run was interrupted at the user's request after
+13 completed settings, added delay 0..12,000 us. Each missed its first packet:
+nine matched the test sequence but were reported on channel 2 when channel 3
+was expected; four timed out. These are not 13 full 400-packet measurements
+and must not be presented as an unbiased loss rate. The incomplete original
+top manifest and all raw captures under
+`tools/hil/profile_switch_sf10_nrf52_double_1ms_results*` remain unchanged.
+
+Added an independent HIL-only stationary receiver: one full normal radio
+initialization, fixed 910.25 MHz (channel 3), normal RX rearm, no profile
+scheduler/fast-switch/cache shortcut. All 100 packets use the same transmitter,
+SF10/125, CR4/5, 32 preamble symbols, 16-byte CHS1 body and -9 dBm request.
+No RF retries and no early stop on an RF miss; hardware/fixture faults abort.
+
+Result: **100/100**, CRC/read errors 0, foreign packets 0, hardware errors 0,
+observed frequency writes after setup 0. Normal gain register 0x94 and RX mode
+1 verified. RSSI mean -33.610 dBm, range -34..-33; SNR mean +8.736 dB,
+range +7.8..+10.0. Baseline cleanly stopped and both devices rebooted idle.
+This confirms the fixed-channel link in this finite sample, not all-channel
+or interference immunity.
+
+Capture: `tools/hil/profile_stationary_nrf52_sf10_100_results.json`.
+SHA-256: `547c30efc89c29e3f60cb975f7fc34de3e9f14bed3ed4cc9f1ba01f63402bf15`.
+Receiver image with stationary capability compiled/flashed and hash-verified:
+SHA-256 `0daab2e3293359280714042492639ab8c176609143cf01996ad4ba7026704768`.
+Reported flash 364,657 bytes; static RAM 94,796 bytes. Existing double-write
+and four-channel switching implementation is unchanged; baseline is a
+separate inactive command mode during the scan.
+
+## Post-baseline four-channel run
+
+Fresh prefix: `tools/hil/profile_switch_sf10_nrf52_postbaseline_double_1ms_results`.
+Command: `profile_switch_settling.py --receiver COM31 --sender COM15 --sf 10
+--frequency-repeat on --step-us 1000 --output .json`.
+No old captures are reused. Stop at the first 400/400 or after +23 ms.
+
+Completed all 24 settings. **No 400/400 pass.** At zero added delay, the first
+packet passed and the second timed out. Each +1..+23 ms setting failed its
+first packet. Total 25 probes: one valid, 17 timeouts and seven test-sequence
+packets reported on channel 2 while expecting channel 3. Each setting stops
+on its first miss; this is deliberately censored evidence, not a measured
+unbiased 4% packet-delivery rate. The lone valid packet was -33 dBm / +8.2 dB
+SNR. The wrong-channel records do not alone distinguish real off-channel
+acquisition from channel bookkeeping; no root cause is claimed.
+
+Measured 3,086 retunes, weighted mean **526.783 us (0.527 ms)**, maximum
+876 us (0.876 ms), excluding the separately applied settling delay. Exactly
+6,172 SetRfFrequency commands confirm two per hop. One modulation command
+was issued across the measured scan windows (after the sole received packet);
+no retune failures, RX-mode errors, cache errors or CRC/read errors.
+All gain checks remained 0x94. This measurement is from the receiver image
+with the new independent baseline mode, not a controlled performance A/B
+against the previous image's 538.192 us RX-only mean.
+
+At +23 ms the measured settling interval was 23,000..23,001 us and mean
+switch-plus-settle 23,567.2 us. That short failed window did not contain a
+complete unheld scan cycle, so it provides no measured final-step full-sweep
+latency. The ceiling remains the nominal 23,217 us calculation above.
+
+Post-baseline top manifest SHA-256:
+`badda7f64a7bdd445942da19dc76dea0d1aace2ae451772edf4744d4670ac28c`.
+Interrupted pre-baseline top manifest SHA-256:
+`90f7b213bc44ae73714c5d9d32216ff3f1760fafd657feb5ea9299ed1e10ddf3`.
+
+Conclusion: the fixed-channel link passes its 100-packet control, but extra
+post-switch settling through the nominal budget does not cure this scanner
+failure. Investigate acquisition/hold behavior and channel attribution next;
+do not claim more waiting, a second frequency write, or a transmitter swap
+has fixed the issue. No guard/default production timing was changed here.
+
+Collector exited successfully at the nominal timing limit, with no cleanup
+error; both radios rebooted idle. The previous follow-up automation remains
+paused. No commit or push requested; HIL firmware remains installed. Temporary
+build overlay removed. No background test collector remains running.
diff --git a/docs/test-results/sf10_rollback_failure_validation.md b/docs/test-results/sf10_rollback_failure_validation.md
new file mode 100644
index 000000000..b93de7684
--- /dev/null
+++ b/docs/test-results/sf10_rollback_failure_validation.md
@@ -0,0 +1,232 @@
+# SF10/125 rollback and wider-spacing failure isolation
+
+2026-09-14. **No strict four-channel 400/400 pass. Reverting the switching
+optimizations did not remove failures. Off-channel reception also occurs with
+a fixed, fully initialized receiver, without any channel switching.** A traced
+timeout shows an off-channel preamble holding the scanner away from the
+intended channel until after its transmit preamble. Wider spacing did not
+eliminate either wrong-channel delivery or actual missing packets.
+
+This isolates a concrete scanner failure mechanism, not the underlying
+physical RF cause. Commanded frequency words are observed, but actual emitted
+spectrum, antenna-port power, image responses and overload have not been
+independently measured. Do not claim front-end saturation or a specific spur
+as proven. Do not treat these first-failure-censored samples as unbiased PER.
+
+## Fixture and requested controls
+
+- RX: XIAO ESP32-S3 / Wio SX1262, COM31, MAC 28:84:85:B4:09:80; normal RX gain
+ register 0x94 throughout (boosted gain off).
+- TX: XIAO nRF52840 / Wio SX1262, COM15, serial B35E71C1C3726CE7.
+- SF10, 125 kHz BW, CR4/5, 32-symbol preamble, 16-byte sequenced CHS1 probes,
+ -9 dBm chip request, four channels, 5.1-symbol post-settle listening visits.
+- Original centers: 909.5, 909.75, 910.0, 910.25 MHz. User-requested wider
+ centers: 909.5, 910.5, 911.5, 912.5 MHz (1 MHz spacing).
+- Existing fixed-channel positive control was 100/100; see
+ `sf10_nrf52_double_write_settling_validation.md`. No failed packets retried.
+- Unrelated RAK COM29 was not opened. New nRF52 upload was application-only,
+ using normal SoftDevice ID 0x0123 validation; no bootloader/SoftDevice erase.
+
+Rollback modes are runtime HIL controls on the same current source, not
+historical firmware checkouts. No production default or receive safety guard
+was changed during this investigation. Tests preserve ownership, packet-in-
+progress, BUSY and command-result checks. Existing unrelated working-tree
+changes were left intact. No commit or push was requested this turn.
+
+## Maximum practical settling control
+
+At SF10/125, a symbol is 8,192 us, the programmed preamble is 262,144 us,
+and each 5.1-symbol post-settle visit is 41,780 us. For each rollback mode:
+
+1. Measure six seconds of RX-only switching without added delay.
+2. Choose the largest 100 us delay step satisfying
+ `4 * (41780 + observed_max_switch_us + added_delay_us + 500) <= 262144`.
+3. Measure another six seconds; require at least eight full idle cycles and
+ maximum observed idle cycle below the preamble before allowing TX.
+
+The 500 us per-hop reserve covers some observed scheduling variation, not
+all possible jitter. RX is active during added settling. Near-ceiling cycle
+timing is not itself a guarantee of acquisition for every arrival phase,
+especially when an IRQ causes an extended packet hold. Idle-cycle statistics
+exclude guard/packet-held visits; those are measured separately.
+
+## 250 kHz spacing: eleven rollback cases
+
+Each case stopped on its **first probe**, TX channel 3, with a real timeout.
+No strict 400/400 pass, TX fault, RX mode/cache failure, or hardware error.
+The first nine cases used the checkout's existing 1,600 us TCXO setting;
+the final two explicitly restored 6,000 us. Production timing was not edited.
+
+| Case | Added delay (us) | Zero-delay mean switch (us) | Max settled idle cycle (us) |
+| --- | ---: | ---: | ---: |
+| Fast, cached modulation, double frequency write | 22500 | 535.88 | 259297 |
+| Always write modulation | 22500 | 625.50 | 259682 |
+| Individual SF/BW/CR writes | 22200 | 840.97 | 259346 |
+| Full normal RX restart | 22200 | 896.89 | 259577 |
+| Full RX + individual modulation | 22000 | 1113.95 | 259623 |
+| Above + byte-wise SPI, warm XOSC | 21800 | 1215.79 | 259220 |
+| Above + RC standby, 1.6 ms TCXO | 20100 | 2942.50 | 259339 |
+| Fast, cached modulation, single frequency write | 22600 | 450.25 | 259341 |
+| RC/full RX/individual/byte-wise, single write, 1.6 ms TCXO | 20200 | 2837.50 | 259329 |
+| RC/full RX/individual/byte-wise, double write, 6 ms TCXO | 15800 | 7293.25 | 259550 |
+| RC/full RX/individual/byte-wise, single write, 6 ms TCXO | 15800 | 7190.31 | 259139 |
+
+Actual frequency/modulation command counts and fast-RX counts verified each
+policy. Slower modes received less added delay to preserve the total sweep
+budget. These are eleven probes with eleven timeouts, **not eleven complete
+400-packet runs**. Packet arrival pauses use the same seeded scheduling
+method but not hardware-synchronized identical RF phases across modes.
+
+## Fixed-frequency controls: off-channel responses without switching
+
+Each pair received three independent probes after full normal initialization.
+Positive controls were interleaved. The receiver remained at one frequency:
+zero observed frequency commands after setup, no hopping or cache shortcut.
+Payload success requires the exact expected sequence/channel/body, length 16
+and clean CRC. It does not require RX center to equal TX center in this
+explicit offset diagnostic. Offset below means programmed RX minus TX.
+
+| Spacing | RX-TX offset (kHz) | Probes | Intact expected payloads | Preamble IRQ seen |
+| --- | ---: | ---: | ---: | ---: |
+| 250 kHz | 0 | 6 | 6 | 6 |
+| 250 kHz | -250 | 3 | 2 | 3 |
+| 250 kHz | -500 | 3 | 0 | 3 |
+| 250 kHz | -750 | 3 | 0 | 3 |
+| 250 kHz | +250 | 3 | 0 | 2 |
+| 1 MHz | 0 | 6 | 6 | 6 |
+| 1 MHz | -1000 | 3 | 2 | 3 |
+| 1 MHz | -2000 | 3 | 0 | 0 |
+| 1 MHz | -3000 | 3 | 3 | 3 |
+| 1 MHz | +1000 | 3 | 3 | 3 |
+
+At 250 kHz spacing, the two valid -250 kHz-offset packets reported RSSI
+-91/-90 dBm and SNR -12.8/-10.8 dB. At 1 MHz spacing, valid off-channel
+packets reported RSSI -102..-109 dBm and SNR -11.5..-15.8 dB. Wider on-channel
+controls were all -33 dBm, SNR +8.0..+8.8 dB. These are radio-reported values,
+not calibrated measurements of transmitted spurs or receiver rejection.
+
+The non-monotonic offset pattern means that increasing spacing alone cannot
+be assumed to remove this response. These fixed-frequency tests rule out a
+channel-hop shortcut as a necessary condition for the observed off-channel
+packets; they do not establish which physical component produces them.
+
+## A traced real timeout at 250 kHz spacing
+
+Fast/double-write RX, +22,500 us settling. One channel-3 probe timed out with
+TX rc=0, RX mode=1 and device errors=0. The trace contains 454 events, overflow
+0. Trace adds 64 us IRQ polling; use it for ordering, not an unperturbed timing
+A/B comparison. Times below are relative to receiver expectation arming.
+
+| RX time (ms) | Evidence |
+| ---: | --- |
+| 169.190 | Frequency command for channel 3, 910.25 MHz |
+| 234.032 | Leaves channel 3 for channel 0 |
+| 298.869 | Frequency command for channel 1, 909.75 MHz |
+| 330.030 | PREAMBLE_DETECTED (0x0004) while on channel 1 |
+| 363.632 | Requested next hop is held by receive-in-progress guard |
+| 726.734 | Guard clears the lingering preamble IRQ |
+| 726.826 | Frequency command for channel 2 |
+| 791.654 | Finally commands intended channel 3 again |
+| 814.586 | Channel-3 hop/settling completes |
+
+TX setup was 86,914 us and its transmit call 529,297 us. Host/firmware timing
+places TX RF start approximately 287..289 ms after RX arming and the end of
+its programmed preamble around 550 ms. This estimate is not a synchronized
+RF measurement, but the receiver's several-hundred-millisecond absence from
+the intended channel is much larger than USB timestamp uncertainty.
+
+The held channel-1 visit measured **427,395 us**, versus normal idle four-
+channel cycle maximum **259,358 us**. The guard timeout is consistent with
+`RadioLibWrapper::calcMaxPacketMillis`: `(32 + 8 + 4.25) * 8192 = 362496 us`,
+rounded to 363 ms. `CustomSX1262::isReceiving()` starts that timer when the
+guard first observes the preamble, here later than the diagnostic IRQ poll.
+
+Thus an off-channel preamble can monopolize the scanner long enough to miss
+the real channel. Do not simply remove or shorten this safety guard as a
+production fix: it also protects legitimate weak or long-preamble reception.
+
+## 1 MHz spacing: strict results versus payload delivery
+
+Three strict rollback cases (fast double, fast single, fully rolled back
+single/6 ms) each stopped at their first probe. All three actually decoded
+the intact channel-3 payload while programmed for channel 2: RX 911.5 MHz,
+TX 912.5 MHz, RF word at read 955777024. They are **channel mismatches, not
+lost payloads**. RSSI was -102/-101/-101 dBm; SNR -11.5/-11.5/-11.2 dB.
+
+An explicitly separate payload-delivery diagnostic then continued through
+such packets while retaining strict validity in raw evidence:
+
+| Mode | Added delay | Delivered before stop | Strict same-channel | Stop | Mean switch | Max idle cycle |
+| --- | ---: | ---: | ---: | --- | ---: | ---: |
+| Fast, cached, double write | 22.5 ms | 3 of 4 probes | 0 | TX channel 0 timed out | 557.721 us | 259381 us |
+| RC/full RX/individual/byte-wise, single write, 6 ms TCXO | 15.8 ms | 2 of 3 probes | 0 | TX channel 1 timed out | 7310.510 us | 259643 us |
+
+Every delivered packet in these two runs arrived one programmed channel below
+its transmitter. Both runs had zero mode/cache/RX/device errors and actual
+switch-policy counts matched. Neither is a 400/400 pass, even under the relaxed
+delivery-only criterion. The true timeout in the fully rolled-back path also
+shows the problem is not limited to the accelerated RX restart or 0x8B cache.
+No traces were enabled for these two runs, so do not assign their individual
+timeouts the exact IRQ timeline from the earlier traced probe.
+
+## Changes and verification
+
+- HIL-only rollback, explicit TCXO timing, selectable 250/1000 kHz spacing,
+ frequency-command trace events and fixed-frequency offset controls.
+- Separate opt-in payload-delivery test; strict wire validity remains intact.
+ Host/device counters distinguish strict from off-channel deliveries;
+ settling sweeps reject delivery-only captures, including reused evidence.
+- 16 native profile tests and 46 HIL host tests passed. Receiver and nRF52
+ builds/uploads succeeded with normal verification. Receiver reported
+ 94,844 bytes RAM / 366,777 bytes flash; nRF52 18,220 / 95,572 bytes.
+- `git diff --check` passed; unrelated pre-existing CRLF warnings only.
+- Temporary PlatformIO overlay removed. Controllers stopped, both boards
+ rebooted idle with HIL firmware retained. No autonomous TX or restoration.
+
+Next useful isolation is the physical off-channel response (controlled RF
+attenuation or reversed radio roles), followed by safe acquisition/hold-policy
+experiments. More settling delay alone does not address the demonstrated
+false-hold mechanism. No claim of an identified silicon defect or verified
+production fix is made here.
+
+## Evidence provenance (SHA-256)
+
+Raw JSON files below are under `tools/hil/`; prior captures were not rewritten.
+Matrix manifests include per-case paths and timing controls. All listed new
+experiments completed under their stated stopping policies without fixture or
+cleanup errors. Completed first-failure runs still have incomplete 400-packet
+levels by design.
+
+| Capture | SHA-256 |
+| --- | --- |
+| profile_switch_sf10_rollback_results.json | `e87c504b3c89fbafe90c18fcd0ab37df34a6aeb51d95b5c69f58d60f70de185a` |
+| profile_stationary_sf10_offset_results.json | `d3d87315513a70fc3f00cc7f17369043328f53ee3028d499087b21e767370ab7` |
+| profile_switch_sf10_rollback_fast_trace_results.json | `8ac9db50f0f06eb7511d7d233957119618df62afdc7112dd83eb68d292fcd725` |
+| profile_switch_sf10_rollback_6ms_results.json | `74e7bfeb23bc569a5786e2322b5e3b1753cba9f628e2969bf4f5c4296ca5817a` |
+| profile_switch_sf10_wide_rollback_results.json | `d79096fbe3e15e0375457b487fd40be3418789e798a978efb79d8298c880eea2` |
+| profile_switch_sf10_wide_payload_results.json | `1316a2c2f32a8c0773fc56587c4220ee8f6f6cdf4ab9b87e00fd361fc9395257` |
+| profile_switch_sf10_wide_legacy_payload_results.json | `fcf2123270634e2f27800787e20b9b8b7ae63bcfb03e17d26a4a4f6f4e23d32f` |
+| profile_stationary_sf10_wide_offset_results.json | `4c494a59fa793d274b0366185cfe3230b6c9fce7a57dfaa61f9f29c36465bbd3` |
+
+RX firmware images used, in chronological order:
+
+| Stage | firmware.bin SHA-256 |
+| --- | --- |
+| Initial nine-mode rollback | `5f8dbf9c4584e0de94a7aebda5a20e961b644a880ecb6a34ab5871621958696f` |
+| Fixed 250 kHz offsets and traced timeout | `cd839caa33c869dd448cc55e9c8b959e39d105f80a2ac4a8029393698b993fd3` |
+| Explicit 6 ms TCXO rollback | `0ba044035c4986749c9ad4f50c702dc8c57e8e0fecf9d46fc11515cad5678166` |
+| Wider strict rollback | `5fc919c80438635b4a96021b39fae2ada9e767508d7bb73854e4e108f0d4920f` |
+| Wider delivery diagnostics and fixed offsets; final installed | `7b313a0f8f1eb04f6fc12197af5e9481ad43154b981ab7627e50167d2a97d8c2` |
+
+The 250 kHz tests used the prior nRF52 TX ZIP hash
+`7466625ef5e7bcc8f7dd16db2b843db29b4312a33b0d32bc0b1e1193d6aee03a`.
+All wider tests used the new selectable-spacing application:
+
+- TX ZIP: `c474b7598a2c0b6c529ead897f3fc7aab8caf4b7581c73d7acac6ffae081377d`
+- TX HEX: `b400fc53c496f4a956f88ccee5cda83b5f6679b8e2b59bfcb4b8155ea9e1e5e9`
+
+Build artifacts are generated and may be replaced by a subsequent build;
+hashes identify the images used, not a promise that every historical binary
+remains in `.pio/build`. Source starts from keymindCascade commit
+`0e5955f889788a4317863e731b38ffe09e3d5441` with the existing dirty changes and
+these uncommitted HIL additions; this is not a clean-release comparison.
diff --git a/docs/test-results/sf125_settling_limits_validation.md b/docs/test-results/sf125_settling_limits_validation.md
new file mode 100644
index 000000000..f8a4ff1fc
--- /dev/null
+++ b/docs/test-results/sf125_settling_limits_validation.md
@@ -0,0 +1,127 @@
+# Descending SF10..SF5 / 125 kHz settling limits
+
+Experiment started 2026-09-14. **Stopped at the user's request to replace the
+Indicator transmitter with a V4. No 400/400 pass was found.** The saved
+[top-level manifest](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_sf125_first_pass_results.json)
+remains incomplete; the [SF10 manifest](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_sf125_first_pass_results_sf10.json)
+contains all completed settings. Original evidence is not rewritten to make an
+interrupted experiment look complete.
+
+## Results at user-requested stop
+
+94 SF10/125 settings completed: every added delay from 0 through 9,300 us in
+100 us increments. Each stopped on its first RF miss; none reached 400/400.
+The best individual setting was +1,000 us: 25 valid packets, then packet 26
+failed. No lower SF was reached in this descending sweep. No result is claimed
+for SF10 delays above 9,300 us or for SF9..SF5 in this run.
+
+Across these completed settings: 641 probes, 547 valid and 94 failed. Because
+each setting ends at its first miss, these totals are not an unbiased packet
+error-rate measurement and do not establish a reliable lower/upper delay limit.
+
+For the 547 valid receptions, reported SNR averaged **+8.311 dB**, minimum
++7.5 dB and maximum +9.5 dB. RSSI averaged **-40.547 dBm**, range -42 to
+-39 dBm. These are conditional on successful reception; no SNR is inferred
+for missing packets.
+
+24,055 retunes averaged **456.620 us**, weighted by hop count and excluding the
+intentional settling delay. Reported retune failures, RX-mode errors and cache
+errors were all zero. Four RX errors were reported, one each at +1,100,
++5,900, +6,700 and +6,900 us. These counters do not identify the transmitter as
+the cause. At +9,300 us, the unheld four-channel cycle averaged 206.134 ms
+(maximum 206.157 ms), versus a 262.144 ms programmed preamble; this nominal
+time margin still did not produce an all-pass setting.
+
+Controllers and collector were stopped, and both boards were rebooted to idle
+HIL. Follow-up polling is paused. Subsequent `info` checks returned ready on
+both boards, with XIAO gain register 0x94 and Indicator 0x96. Test firmware was
+retained; no original firmware was restored. Replacement-board testing has
+not started.
+
+Saved manifest SHA-256 at stop:
+
+- Top level: `9cbf315704816be4d3cf7977e954e64a2033155e654cd7108919e6a17a5f8f8a`
+- SF10: `509f632de1a463d4f5f84c055a1570468c5752685d596f4b445aa7d7c339189d`
+
+## Requested method
+
+XIAO ESP32-S3/WIO SX1262 COM31 RX, SenseCAP Indicator ESP32-S3 COM28 TX,
+user-separated positions, XIAO boosted gain off (actual register 0x94),
+Indicator gain unchanged at 0x96. Four centers 909.500/909.750/910.000/910.250
+MHz; bandwidth 125 kHz, CR4/5, 5.1-symbol visits, 32 programmed preamble
+symbols, 16-byte synthetic packets at -9 dBm. Warm fast RX, buffered 8 MHz
+SPI and unchanged-modulation cache enabled. No extra IRQ polling/tracing.
+
+Start SF10 and descend one SF at a time through SF5. For each SF, test added
+post-BUSY delay in 100 us increments starting at zero. Every failed setting
+stops on its first invalid reception/timeout; a pass requires 100/100 on
+each of four channels. **Latest user instruction: stop the entire sweep at
+the first 400/400 pass**, without continuing to find an upper limit or a lower
+SF. Without any pass, continue to the ceiling, then lower SF. Fixture errors abort the entire
+experiment. No failed RF probe is retransmitted.
+
+This searches for a sampled all-pass setting, not proof of monotonic delay
+behavior, an upper delay limit, or zero PER. The RX modem remains active during the
+extra delay; the independent 5.1-symbol visit starts afterward. Thus extra
+settling is also extra RX occupancy, not a pre-SetRx blanking interval.
+
+| SF | Visit | Last 100 us delay step | Programmed preamble |
+| --- | ---: | ---: | ---: |
+| 10 | 41,780 us | 23,300 us | 262,144 us |
+| 9 | 20,890 us | 11,400 us | 131,072 us |
+| 8 | 10,445 us | 5,400 us | 65,536 us |
+| 7 | 5,223 us | 2,500 us | 32,768 us |
+| 6 | 2,612 us | 1,000 us | 16,384 us |
+| 5 | 1,306 us | 200 us | 8,192 us |
+
+Budget: `preamble_us / 4 - dwell_us - 453`, using the nominal retune cost.
+Actual cycle means/maxima are recorded and may exceed that nominal budget.
+The randomized arrival pause now includes added settling when estimating
+two scan cycles; earlier saved captures retain their original distributions.
+
+The stop policy changed after SF10 delays 0..5,600 us had all failed. The old
+supervisors were stopped while the active 5,600 us collector finished normally.
+Original captures are preserved. A fresh first-pass manifest reuses completed,
+configuration-validated raw captures with their SHA-256 hashes, then resumes at
+5,700 us; no completed setting is repeated. The earlier top-level manifest is
+incomplete because its upper-limit policy was superseded, not due to an RF or
+fixture fault.
+
+## Build and initial checks
+
+Both HIL targets compiled and verified uploads succeeded. The selected
+**53 host/native tests passed**: 36 host collectors, seven profile native,
+four SX1262, five radio and one receive-mode test.
+After the stop-rule change, all 39 host collector tests passed, including new
+whole-sweep first-pass stopping and matching, hash-preserving capture reuse.
+
+Actually loaded firmware SHA-256:
+
+- XIAO: `89faa3bbc5eade5e632e0c720c75be0ba9105e595b1384de74a00e09c0b60a24`
+- Indicator: `fb06c52e138e5cd65fff1393e3f1208230b06977e5f824852086d4c80c89845c`
+
+A one-second RX-only SF10/+23,300 us smoke check recorded 15 hops with
+minimum delay 23,300 us, minimum post-delay dwell 41,782 us, and zero
+retune/RX-mode/cache errors. Its two clean cycles averaged 262,125 us, maximum
+262,127 us: only a tiny margin against the 262,144 us preamble, not a robust
+worst-case bound. No RF probe was transmitted during that check.
+
+Base `0e5955f889788a4317863e731b38ffe09e3d5441` on `keymindCascade`, plus local
+changes. RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`,
+Espressif32 6.11.0 / Arduino ESP32 2.0.17.
+
+```text
+python tools/hil/profile_switch_sf_limits.py --receiver COM31 --sender COM28 --output tools/hil/profile_switch_sf125_limits_results.json
+```
+
+Updated run:
+
+```text
+python tools/hil/profile_switch_sf_limits.py --receiver COM31 --sender COM28 --output tools/hil/profile_switch_sf125_first_pass_results.json --stop-on-first-pass --reuse-prefix tools/hil/profile_switch_sf125_limits_results.json
+```
+
+The controller reboots to idle HIL after each per-SF child completes or fails;
+test firmware is to remain installed, per user request. Original private flash
+backups are retained. RAK4631, Indicator RP2040 and unrelated S3 soak work are
+not part of this experiment. No commit/push requested. Temporary build overlay
+has been removed.
diff --git a/docs/test-results/sf5_250_dwell_validation.md b/docs/test-results/sf5_250_dwell_validation.md
new file mode 100644
index 000000000..cc4f047c5
--- /dev/null
+++ b/docs/test-results/sf5_250_dwell_validation.md
@@ -0,0 +1,125 @@
+# SF5 / 250 kHz: four-channel dwell sweep
+
+Measured 2026-09-14. **No tested dwell from 4.1 through 8.1 chirps passed.**
+Each setting stopped at its first invalid reception or timeout; the sweep
+stopped at the requested 8.1 limit. None reached 100 packets per channel.
+
+## Setup
+
+- XIAO ESP32-S3/WIO SX1262, COM31, receiver; SenseCAP Indicator ESP32-S3,
+ COM28, reference TX. Radios remain in the user's separated positions.
+- XIAO boosted RX **off**, verified actual gain register 0x94 before tests
+ and in every final scan status. Indicator 0x96, unchanged as transmitter.
+- Four centers: 909.500, 909.750, 910.000, 910.250 MHz. SF5, bandwidth 250 kHz,
+ CR4/5, 32 programmed preamble symbols, 16 synthetic bytes, -9 dBm.
+- Production warm fast RX and unchanged-modulation cache enabled, buffered
+ 8 MHz SPI. Existing packet guards and initialization unchanged.
+- Requested grid 4.1 to 8.1 in 0.5-symbol steps. Each setting starts fresh,
+ shuffles channel order with seed 606125 and varies arrival timing without
+ synchronization to RX visits. Both boards reboot between settings.
+- 100 packets/channel maximum; stop that setting on first failure, then move
+ to next dwell. No retransmission of a failed probe. Stop the entire sweep
+ on first complete 400/400 result or after 8.1; abort on fixture errors.
+- Extra IRQ polling and packet traces disabled to avoid adding diagnostic
+ overhead to sub-millisecond visits. RX mode, modem software cache, gain,
+ SPI command counts, timing and packet identity checks remain enabled.
+
+```text
+python tools/hil/profile_switch_dwell.py --receiver COM31 --sender COM28 --output tools/hil/sf5_250_dwell_normal_results.json
+```
+
+## Results
+
+"Received" means correct sequence, payload and reported receive channel.
+Each row is a first-failure sample, not a 100-packet/channel PER measurement.
+
+| Chirps | Requested dwell | Received / attempted | First failure | Mean retune |
+| --- | ---: | ---: | --- | ---: |
+| 4.1 | 525 us | 1 / 2 | channel mismatch | 458.122 us |
+| 4.6 | 589 us | 0 / 1 | timeout | 450.787 us |
+| 5.1 | 653 us | 0 / 1 | timeout | 450.667 us |
+| 5.6 | 717 us | 0 / 1 | timeout | 452.270 us |
+| 6.1 | 781 us | 2 / 3 | timeout | 452.991 us |
+| 6.6 | 845 us | 0 / 1 | timeout | 451.082 us |
+| 7.1 | 909 us | 0 / 1 | timeout | 453.796 us |
+| 7.6 | 973 us | 0 / 1 | timeout | 453.568 us |
+| 8.1 | 1,037 us | 4 / 5 | timeout | 457.886 us |
+
+All 16 requested transmissions returned success. Seven met the full receive
+checks. Of nine failures, one returned a matching synthetic sequence on a
+different reported receive channel and eight timed out. Every timeout run
+also recorded one `rx_errors` increment. These are receive/read/decode errors,
+not retune hardware faults; without an IRQ trace their exact cause and
+association with the expected probe cannot be reconstructed.
+
+All **64,507 timed hops** passed RX-mode/software-cache checks with zero
+retune failures. Weighted mean switch time was **452.739 us**, excluding
+listening dwell and full application scheduling. Each hop programmed a
+frequency. Modulation rewrites occurred only when the prior context needed
+re-establishing, rather than on every frequency-only hop.
+
+The 4.1 second probe was transmitted for channel 2 (910.000 MHz), but its
+sequence was returned while the receiver reported channel 3 (910.250 MHz),
+16 bytes, RSSI -66 dBm, SNR 0.5 dB. It therefore failed the channel check;
+the saved `valid=false` also does not independently certify all payload bytes.
+No synchronized RF/frequency-register capture was taken, so this is evidence
+of wrong-channel reporting/reception, not proof of its physical mechanism.
+Successful correct-channel probes read -42 to -41 dBm, SNR 7.8–8.8 dB.
+
+## Interpretation
+
+SF5/250 did **not** establish a solution to the close-range problem, and
+increasing dwell alone did not produce a clean four-channel result. The
+wrong-channel sequence and receive errors prevent interpreting this as a
+pure minimum-dwell/acquisition test. It is not evidence that 8.1 is a reliable
+setting merely because four probes passed before its miss.
+
+There is also a timing tradeoff: a symbol lasts 128 us, so 32 programmed
+preamble symbols occupy only **4.096 ms**. A ~453 us retune costs about
+**3.54 symbols**. At 4.1, four nominal dwells plus four such retunes consume
+about 3.91 ms before other loop costs; at 8.1 they consume about 5.96 ms.
+Longer visits give acquisition more uninterrupted time but increase the time
+spent away from each channel. This arithmetic motivates testing a longer
+preamble or fewer channels separately; it does not prove the cause of any
+particular failure. No such follow-up was transmitted after the 8.1 limit.
+
+Unlike the [earlier separated SF6 run](separated_radio_modulation_cache_validation.md),
+this changes SF, bandwidth, absolute preamble duration and diagnostic polling.
+The RF settings also leave channel spacing unchanged at 250 kHz. It is not a
+matched comparison isolating receiver sensitivity or the modulation cache.
+
+## Provenance and handoff
+
+Base `0e5955f889788a4317863e731b38ffe09e3d5441` on `keymindCascade`, plus
+uncommitted production cache and HIL changes. RadioLib 7.7.1 at
+`187ef24791c3d844939b2be13a68bd890bd04e4c`; Espressif32 6.11.0 / Arduino
+ESP32 2.0.17. Both HIL builds and verified uploads succeeded. All **39 selected
+host/native tests** passed, including bandwidth/dwell calculation and bounded
+sweep stopping. No commit or push was requested for this work.
+
+Tested firmware SHA-256:
+
+- XIAO: `6ba7b4c6785ac11eb8a4ecd2f1148f4f55ce866c1cb172776757fe0a95047da9`
+- Indicator: `3672ca330d4b2d3e8efa39edf2dbc3b27b8804dbb9a98f29267c85c41972a028`
+
+Raw [manifest](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/sf5_250_dwell_normal_results.json) references all
+nine per-dwell captures. SHA-256 values (all files under `tools/hil`):
+
+| File suffix after `sf5_250_dwell_normal_results` | SHA-256 |
+| --- | --- |
+| `.json` | `aaf00ef8531cb2c5c7e4dfb78d3b8b03ce61d9afe8323fb33e70be09656adf54` |
+| `_4p1.json` | `a9c6baf0bece79c9b9c96bfa97e3cd9932e0c04fc76b43f61ed350512306341a` |
+| `_4p6.json` | `63863b55108374011afbe555bfeb97034b010e194cdad83a212d5d286d1992fe` |
+| `_5p1.json` | `ff1d2927365e3634984bc0b843a09f2934c5dcac49b60b862ff4db030e0f7e74` |
+| `_5p6.json` | `33c91687d0b61dd83e2a08d8358b43f21fa27f9e80f9503feedeb657453a7efd` |
+| `_6p1.json` | `5baa8d1bfcf0ccc947c92d563dc555802ac599ce1e8d260a623964936a99da4e` |
+| `_6p6.json` | `90f6b4d7785add771e30da7dfa78672fd75ad13d8a5965c21ec1a37e15d8dabb` |
+| `_7p1.json` | `eb99afd932a5579c765f8a6c0a18db4bacc317829b0654d11fbf2f569c3a8618` |
+| `_7p6.json` | `bdb88da82437fa6e01ad90ab927d364b4d67aaadbac7597c3aacb9799b053211` |
+| `_8p1.json` | `404ee179e4c10333bd6fddae9f0faa9da81f6193dcb5fff6d9bd37a466c319a3` |
+
+Both boards were rebooted after the sweep and answered ready with correct
+HIL capabilities; XIAO gain remained 0x94. Per user request, **HIL firmware is
+left installed**, idle with no autonomous test transmissions. Private original
+flash backups remain available. The RAK4631, Indicator RP2040 and unrelated
+S3 soak work were not modified. The temporary build overlay was removed.
diff --git a/docs/test-results/sf6_125_settling_validation.md b/docs/test-results/sf6_125_settling_validation.md
new file mode 100644
index 000000000..e880c7f84
--- /dev/null
+++ b/docs/test-results/sf6_125_settling_validation.md
@@ -0,0 +1,148 @@
+# SF6/125 four-channel post-switch settling sweep
+
+Measured 2026-09-14. **No setting from +0.0 through +1.0 ms passed 400/400.**
+The requested delay was applied after BUSY went low without consuming the
+subsequent 5.1-symbol listening visit. Each run stopped on its first miss; the
+sweep would have stopped at the first 400/400 pass, but reached its nominal
+timing limit instead. No RF probes were sent beyond that limit.
+
+## Method and timing budget
+
+- XIAO ESP32-S3/WIO SX1262 COM31 receiver, SenseCAP Indicator ESP32-S3 COM28
+ transmitter, unchanged user-separated positions. XIAO boosted gain off,
+ actual register 0x94 verified; Indicator 0x96. Original backups retained.
+- SF6 / 125 kHz / CR4/5, four centers 909.500/909.750/910.000/910.250 MHz,
+ 32-symbol preamble, 16-byte synthetic packets, -9 dBm.
+- Warm fast RX, buffered 8 MHz SPI, unchanged-modulation reuse enabled.
+ Same XIAO image for every delay; unchanged Indicator reference image.
+- 100 packets/channel requested, balanced shuffled channel order (seed 606125),
+ varied unsynchronized arrival timing. Both HIL boards reboot between delays.
+- No extra IRQ polling or packet traces; ordinary ownership/preamble guards
+ remain enabled. No RF retries, shorter channel count or altered preamble.
+
+At SF6/125, a symbol is 512 us. The requested listening visit rounds up to
+ceil(5.1 * 512) = 2,612 us; 32 programmed preamble symbols last 16,384 us.
+Using the user's nominal 453 us retune, the extra per-hop budget is:
+
+`16,384 / 4 - 2,612 - 453 = 1,031 us`
+
+Thus the 100 us grid runs from 0 through 1,000 us; 1,100 us would exceed the
+nominal full-sweep calculation. Sync/header time is not spent as extra scan
+budget. This is not a worst-case scheduling or acquisition guarantee.
+
+The delay is **HIL-only**: after a successful guarded production retune and
+BUSY-low observation, the CPU waits without SPI/IRQ operations, then starts a
+separate 2,612 us listening clock. The SX1262 is already receiving throughout
+that delay and may acquire a packet. This tests extra RX occupancy/settling,
+not a pre-SetRx delay or a period with RX disabled. Ordinary RX restarts after
+processing packets reset the visit clock without another channel delay.
+Zero delay preserves the previous production RX-start clock.
+
+An initial receive-only, 300 ms smoke check at +1,000 us measured 74 hops,
+minimum applied delay 1,000 us and minimum subsequent dwell 2,612 us. Its
+17 clean full cycles averaged 16,235.941 us, maximum 16,260 us. No RF probe
+was transmitted in this check. The longer packet experiments below capture
+more scheduling variability, including host command handling.
+
+## Results
+
+All times in the last two columns are measured means, not target values.
+"Received" is successes before the next probe timed out; every row ended in
+one timeout. These first-failure samples do not estimate comparable PERs.
+
+| Added delay | Received before miss | Switch + delay | Clean full cycle |
+| ---: | ---: | ---: | ---: |
+| 0.0 ms | 44 | 0.452381 ms | 12.256886 ms |
+| 0.1 ms | 4 | 0.548387 ms | 12.669976 ms |
+| 0.2 ms | 34 | 0.650424 ms | 13.074105 ms |
+| 0.3 ms | 53 | 0.753410 ms | 13.485859 ms |
+| 0.4 ms | 47 | 0.850830 ms | 13.868454 ms |
+| 0.5 ms | 66 | 0.952289 ms | 14.273736 ms |
+| 0.6 ms | 14 | 1.050333 ms | 14.671156 ms |
+| 0.7 ms | 21 | 1.151351 ms | 15.076094 ms |
+| 0.8 ms | 63 | 1.255113 ms | 15.482272 ms |
+| 0.9 ms | 19 | 1.352843 ms | 15.880361 ms |
+| 1.0 ms | 25 | 1.452750 ms | 16.283488 ms |
+
+All **401 transmissions** returned success: **390 valid receptions and 11
+timeouts**, spread across the eleven independently stopped settings. None
+completed 100/100 on each channel. All **52,609 timed hops** passed RX-mode
+and software-cache checks, with one observed frequency write per hop and zero
+retune failures. Each positive-delay hop waited at least the requested time;
+every run's minimum subsequent idle dwell was 2,612 us. No receive-error
+counter increment or device error was reported. Successful RSSI was -42 to
+-40 dBm and SNR 9.5–11.8 dB.
+
+`switch` measures production retune entry through BUSY-low observation;
+`settling` measures the following wait, including timer overhead;
+`switch_and_settle` combines them. Even zero requested delay records roughly
+1–2 us of timer/bookkeeping overhead in the combined boundary. Listening
+dwell and full application scheduling are not included in retune time.
+
+`idle_cycle` measures consecutive channel-0 return times, excluding initial
+cycles and cycles containing packet/guard-held visits. It still includes loop
+and USB work encountered during otherwise unheld visits. At +1.0 ms its mean
+is 100.512 us inside the preamble budget, but its **maximum was 17.475 ms**,
+exceeding 16.384 ms. At +0.9 ms the observed maximum was 16.153 ms. Therefore
+the last grid point fits only the nominal/mean calculation, not every observed
+full cycle. Legitimate packet holds can extend scan timing further.
+
+## Conclusion and limits
+
+Extra post-switch settling through the available timing budget did not
+establish loss-free four-channel reception. The 66 successes at +0.5 ms are
+not evidence that it is an optimum; the results are small, first-failure
+samples with different uncontrolled RF arrival phases. The added delay also
+extends total RX occupancy, so any improvement would not isolate analog
+settling from longer acquisition time.
+
+Every failure here was a timeout without a reported receive/decode error.
+Without an IRQ trace or synchronized independent RF capture, that does not
+prove absence of a preamble/header indication or identify the precise cause.
+It does not justify changing production packet guards or adding a permanent
+settling penalty. No production default was changed by this experiment.
+
+## Reproduction, provenance and handoff
+
+```text
+python tools/hil/profile_switch_settling.py --receiver COM31 --sender COM28 --output tools/hil/sf6_125_settling_normal_results.json
+```
+
+See [harness instructions](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_README.md#post-switch-settling-delay-sweep-sf6125).
+Base `0e5955f889788a4317863e731b38ffe09e3d5441` on `keymindCascade` plus local
+changes. RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`,
+Espressif32 6.11.0 / Arduino ESP32 2.0.17. Both HIL targets compiled; only
+XIAO needed an upload for the new receiver-side delay. All **45 selected
+host/native tests** passed, including delay/visit separation, packet restart,
+timer wraparound, clean-cycle accounting, bounded grid and first-pass stop.
+
+Actually loaded firmware SHA-256:
+
+- XIAO: `a0dbc113dcb297be34e22c0bc058db71c4108659e1e289679ecdbfc5c0009f8e`
+- Indicator, unchanged from SF5/250 sweep:
+ `3672ca330d4b2d3e8efa39edf2dbc3b27b8804dbb9a98f29267c85c41972a028`
+
+Raw [manifest](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/sf6_125_settling_normal_results.json) and per-delay
+captures are preserved byte-for-byte. SHA-256, file suffixes after
+`tools/hil/sf6_125_settling_normal_results`:
+
+| Suffix | SHA-256 |
+| --- | --- |
+| `.json` | `41a20c3d5b809cd225772980fbe0d305c14391659f10d685bf78f8bdf326e61c` |
+| `_0us.json` | `47a73a35ed72ebc61b88bfe315e86bf7e15ba1d2d93e68ce92ce681e1ef73cef` |
+| `_100us.json` | `b585ea01f605bc876da86ba2a35fbe6e7cc6543eb5e827ba190c51dca10dcb85` |
+| `_200us.json` | `6ce378cd079eefae8450c7f5672281095b9b99e28e0c7920f024a4396606bb40` |
+| `_300us.json` | `d93e7a258dddb6731c030e395e09c3f06b4d5988638cc85dc5cd0745256c6202` |
+| `_400us.json` | `56bfe4cb7cbab2a660a232a2bf85b9bb87221ffade7c9bbce6d1116e6c30fcbf` |
+| `_500us.json` | `fffb6c33a506f537913084fc4f5cc9f8009a4f0f61e4f290dd81a7409f53d1bd` |
+| `_600us.json` | `7bc6dd41f0079f37bdbf77fc48c4113e26589bcce704850b106c33d057e34659` |
+| `_700us.json` | `54170f5b63bbe8ffa231852bf892ea55d8579c33631775f29fb7c127197afb2a` |
+| `_800us.json` | `f7c53375b29764b4369b84e8ac3244aa37e385c8738a9dfd7f3257d85a64a705` |
+| `_900us.json` | `56bc3691f2046cafcf2115b2f6b7bd164e9c9039733ea51028db292deea10230` |
+| `_1000us.json` | `f708af37caee8f689f2436567382458d63aba1269e8306ac6e4d0079677b9115` |
+
+Both boards rebooted to idle HIL after testing and answered ready. XIAO gain
+remained 0x94 and settling policy was returned to zero. **Test firmware stays
+installed**, per user request; no restore, commit or push was performed this
+turn. RAK4631, Indicator RP2040 and unrelated S3 soak work were untouched.
+The temporary build overlay was removed; no autonomous test TX remains.
diff --git a/docs/test-results/sf6_5p1_trace_validation.md b/docs/test-results/sf6_5p1_trace_validation.md
new file mode 100644
index 000000000..29b293701
--- /dev/null
+++ b/docs/test-results/sf6_5p1_trace_validation.md
@@ -0,0 +1,138 @@
+# SF6 / 125 kHz: four channels at 5.1 chirps
+
+Measured 2026-09-14. **Seven packets received, then packet 8 missed.** The run
+stopped at that first miss. No RF retry, other dwell, or higher/lower channel
+count was tested. The target of 100 packets per channel was not reached.
+
+Follow-up: [SF8/125 at the same 5.1-chirp dwell](sf8_5p1_trace_validation.md)
+missed on packet 13 and captured an off-channel preamble hold. That differs
+from the absence of observed preamble in this SF6 failure.
+
+## Setup
+
+- XIAO ESP32-S3 + WIO SX1262 scanner, COM31.
+- SenseCAP Indicator ESP32-S3 reference transmitter, COM28.
+- Four centers: 909.500, 909.750, 910.000, 910.250 MHz.
+- SF6 / 125 kHz / CR4/5, 32-symbol programmed TX preamble.
+- User-corrected dwell: **5.1 symbols**, ceil(5.1 * 512) = **2,612 us**.
+- Same warm production fast retune, buffered 8 MHz SPI, packet guards.
+- 16-byte synthetic payloads at -9 dBm; balanced randomized channel order,
+ seed 606125; varied arrival timing, not synchronized to scan phase.
+- IRQ changes, IRQ clears, hops and first guard deferrals buffered in RAM.
+ Additional read-only IRQ polling at nominal 64 us intervals while armed.
+ No per-event serial printing during capture. Trace read after each result.
+
+```text
+python tools/hil/profile_switch_channels.py --receiver COM31 --sender COM28 --output tools/hil/sf6_125_5p1_trace_results.json --samples 100 --preamble 32 --max-channels 4 --dwell-symbols 5.1 --trace
+```
+
+## Results and what the trace establishes
+
+Probes 1–7 succeeded on channels **3, 2, 1, 0, 2, 3, 1**. Probe 8, sequence
+`944962150`, missed on channel **0 (909.500 MHz)**. Thus channel 0 had already
+received a valid probe; this was not a channel that never worked at all.
+Successful packets had RSSI -42 to -40 dBm and SNR 9.5 to 10.8 dB.
+
+The failed probe has a complete, non-overflowed 3,160-event trace:
+
+- Expectation armed on channel 3 without selecting the expected channel.
+- 3,158 completed hops during the bounded 10-second confirmation interval.
+- **No observed nonzero IRQ bits**: no preamble, header, RxDone or CRC event.
+- **No guard-held visit** on any channel during that expectation.
+- Receiver timeout at 10,000,369 us, with RX mode and zero device errors.
+- Reference TX returned success. All USB sequence acknowledgements matched;
+ no stale replies, missing acknowledgements or cached transport recovery.
+
+The scanner was not stuck on the wrong channel by a false preamble. It also
+did not acquire a header and then fail a CRC check. The evidence locates the
+failure before **observed preamble acquisition**, not at payload decoding.
+It does not prove why acquisition failed: a short-visit/arrival-phase issue
+remains a candidate, as does an unobserved RF/reference issue. There was no
+independent RF capture, and the two MCU clocks were not synchronized.
+
+### Actual timing during the failed expectation
+
+| Measurement | Minimum | Mean | Maximum |
+| --- | --- | --- | --- |
+| Receive dwell, 3,158 hops | 2,613 us | 2,628.706 us | 2,646 us |
+| Retune, trace hop rows | 526 us | 538.700 us | 630 us |
+| Channel 0 revisit interval, 789 cycles | 12,643 us | 12,666.829 us | 12,705 us |
+
+The repeatable scan timing rules out a large software scheduling stall in the
+failed capture. The 32 programmed symbols occupy 16.384 ms, longer than that
+idle scan cycle; nevertheless, revisiting within the preamble did not ensure
+successful acquisition during these short individual visits.
+
+### Successful acquisition traces
+
+Times below are relative to the logged hop completion onto the eventual
+receive channel, not a precisely captured RF edge or hardware RX-start edge.
+The trace observed the following progression for every successful probe:
+PreambleDetected (0x0004), HeaderValid (0x0014), RxDone (0x0016), consumption.
+
+| Probe | Channel | First observed preamble after hop | RxDone after hop |
+| --- | --- | --- | --- |
+| 1 | 3 | 1,838 us | 32,982 us |
+| 2 | 2 | 1,836 us | 35,470 us |
+| 3 | 1 | 1,844 us | 35,151 us |
+| 4 | 0 | 2,332 us | 37,329 us |
+| 5 | 2 | 1,847 us | 35,532 us |
+| 6 | 3 | 1,831 us | 30,731 us |
+| 7 | 1 | 1,329 us | 32,567 us |
+
+After detecting preamble, the production guard kept those visits on-channel
+until reception completed. The failed probe never reached that observed
+state. This is not evidence that 5.1 symbols always suffice, nor does 7/8
+estimate a stable packet-error rate or establish a channel-capacity limit.
+
+## Instrumentation and reporting caveats
+
+The failed capture made 115,783 diagnostic IRQ polls, costing a total
+2,821,617 us (about 24.4 us each; maximum 61 us). These reads are not free:
+they alter loop load, although actual dwell and revisit timing were measured.
+This is **not a matched A/B** against the earlier untraced 4.8-symbol run, so
+seven successes versus two cannot be attributed solely to the extra dwell.
+
+The run used `channel_trace: 1`. During analysis, a reporting bug was found in
+the newly added `held_dwell` aggregate: production `startRecv()` resets the
+visit timestamp after reading a packet. That aggregate therefore omitted the
+preceding held reception time. **Do not use its 2.709 ms mean as total held
+occupancy.** The timestamped IRQ/hop trace above is the evidence for the actual
+30.7–37.3 ms hop-to-RxDone intervals. The failed capture had no RX restart, so
+its dwell rows are unaffected.
+
+The source now keeps a separate channel-occupancy timestamp (`channel_trace:
+2`, `trace_format: 2`). Both HIL targets compiled with this reporting-only
+correction; it was **not flashed or RF-tested**, and the saved raw result was
+not rewritten. Scheduler, packet guard and radio commands were not changed by
+that correction. Regression coverage includes passive SPI observation,
+bounded/frozen capture, occupancy accounting, page identity, requested dwell,
+and stopping at the first miss.
+
+## Provenance and device handoff
+
+- [Unmodified raw result and all eight traces](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/sf6_125_5p1_trace_results.json)
+- [Harness and trace format](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_README.md#traced-four-channel-diagnostic)
+- [Earlier 4.8-symbol result](sf6_channel_scan_validation.md)
+
+Raw result SHA-256:
+`7caa16483b7b0000b388a1906395fe3d381bd0e40de73f48cd5c876d96deab3b`.
+
+**Actually tested** firmware SHA-256 (trace version 1, before reporting fix):
+
+- XIAO: `35c201f367b0aaa693be03542a4c115024d1a70a2be2fbdbef2c94da5a6053ae`
+- Indicator: `8a25d84fc99f6aeaf8318bf4533767e16a9afc0de51394fce27d3a610c63b352`
+
+Base commit `c329cf1fc1e7f2e8c8733ab0d000094730458fe3` plus local changes;
+RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32
+6.11.0 / Arduino ESP32 2.0.17. No normal production firmware change this turn.
+
+Fresh full 8 MiB snapshots were saved and digest-verified before flashing:
+
+- XIAO: `35aa2deeb8a8145bc54435c9f87b3db1c65f320f2568ee0b489305db1a6330c2`
+- Indicator: `dba59cde1df3c69dd2e5995950315951f75cd43886b21be8b14e261ec4b57309`
+
+Both snapshots were restored afterward, separately verified against the full
+flash, and hardware-reset. Snapshots remain in the private current-user backup
+directory. The RAK4631 and Indicator RP2040 were not modified; the XIAO soak
+logger was not restarted. Changes were initially handed off locally before publication.
diff --git a/docs/test-results/sf6_channel_scan_validation.md b/docs/test-results/sf6_channel_scan_validation.md
new file mode 100644
index 000000000..6c12cefb6
--- /dev/null
+++ b/docs/test-results/sf6_channel_scan_validation.md
@@ -0,0 +1,106 @@
+# SF6 / 125 kHz channel-count test
+
+Measured 2026-09-13. The run stopped on the **third packet at the initial four
+channels**: two arrived intact, then the receiver timed out. No five-channel
+test, lower-channel test, retry or retransmission was performed. Consequently
+this run does **not** establish a maximum channel count with 100% reception.
+
+Follow-up: [four channels at 5.1 symbols with an IRQ trace](sf6_5p1_trace_validation.md)
+stopped at packet 8 and observed no preamble acquisition during the miss.
+
+## Requested setup
+
+- Receiver: XIAO ESP32-S3 + WIO SX1262, COM31.
+- Reference transmitter: SenseCAP Indicator ESP32-S3 LoRa processor, COM28.
+- SF6 / 125 kHz / CR4/5, fixed **32-symbol programmed preamble**.
+- **4.8-symbol receive visits**: 2,457.6 us, rounded up to 2,458 us.
+- Buffered 8 MHz SPI and the production fast retune/packet-ownership guards.
+- Start at four channels and add one only after 100/100 packets on every channel.
+- Stop at the first missing/corrupt packet; USB/reference/scanner faults are
+ reported separately as inconclusive fixture errors.
+- 16-byte synthetic payloads at -9 dBm. No identity/channel secrets transmitted.
+- Four channel centers: 909.500, 909.750, 910.000 and 910.250 MHz.
+- Balanced randomized channel order, seed 606125; varied arrival timing across
+ approximately two idle scan cycles. Arming an expectation does not select
+ the receiver's channel or reset its visit clock.
+
+The 100-packet-per-channel target was **not reached**: the explicit first-miss
+stop rule ended the run during its first shuffled pass.
+
+## Observations
+
+| Probe | Channel center | Outcome |
+| --- | --- | --- |
+| 1 | 910.250 MHz | Valid 16 bytes, RSSI -40 dBm, SNR 10.8 dB |
+| 2 | 910.000 MHz | Valid 16 bytes, RSSI -40 dBm, SNR 11.0 dB |
+| 3 | 909.750 MHz | Receiver timeout; transmitter returned success |
+
+All three transmit acknowledgements and receiver reports matched their
+sequence IDs, with no missing/replayed/stale USB replies. On the miss, the
+receiver reported RX mode and zero device errors. The scanner recorded zero
+retune failures, RX-mode errors, modulation-cache errors or CRC/read errors.
+No probe was sent on 909.500 MHz before the stop condition.
+
+| Timing | Minimum | Mean | Maximum |
+| --- | --- | --- | --- |
+| Production retune to BUSY low, 3,467 hops | 504 us | **514.652 us** | 726 us |
+| Idle receive dwell, 3,463 visits | 2,458 us | **2,462.480 us** | 2,468 us |
+
+These samples include scanning during the bounded 10-second wait that
+confirmed the missing packet. There were 2,451 deferred *attempts* while
+production preamble/header/packet ownership held a visit; this is not a count
+of packets or failed retunes. Held visits are excluded from idle-dwell timing.
+All 3,467 successful retunes used the production fast-RX path.
+
+The means imply about **11.91 ms per four-channel idle cycle**, excluding
+packet-held visits and small loop bookkeeping. Fast retuning alone did not
+produce 100% reception in this setup. This short stop-on-first-miss experiment
+does not identify the exact acquisition/phase cause, estimate a stable PER,
+prove that three channels would pass, or establish a universal board limit.
+
+## Implementation and reproduction
+
+This is a lab-only N-channel scheduler over the real `tuneProfile()` path.
+It reuses the two production profile slots as staging slots for the next
+frequency. Normal MeshCore `radio2` remains two-profile. The explicit test
+schedule intentionally does not use the normal two-profile automatic-preamble
+fit calculation or generic 6 ms switch / 4 ms loop allowances. It keeps the
+existing packet-protection guards and measures real dwell/retune overhead.
+
+```text
+python tools/hil/profile_switch_channels.py --receiver COM31 --sender COM28 --output results.json --samples 100 --preamble 32
+```
+
+The controller has regression tests for balanced samples, starting at four,
+incrementing only after a perfect level, stopping without another TX, and
+distinguishing fixture failure from an RF miss. A separate RX-only 0.5-second
+setup check verified the scan timing before any capacity-test packets were
+sent. The capacity run itself was not repeated.
+
+- [Raw result, including the failing probe](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/sf6_125_channel_capacity_results.json)
+- [Host controller](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_channels.py)
+- [Firmware scheduler](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_channels.h)
+- [Harness instructions](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_README.md#sf6125-khz-channel-count-limit)
+- [Earlier USB and fast-switch qualification](radio_profile_switch_validation.md#production-integration-and-usb-recovery-v8)
+
+Build provenance: base commit `c329cf1fc1e7f2e8c8733ab0d000094730458fe3`
+plus the local fast-switch and HIL changes; Arduino ESP32 2.0.17,
+Espressif32 6.11.0, RadioLib 7.7.1 pinned at
+`187ef24791c3d844939b2be13a68bd890bd04e4c`. This is the extended V8 harness
+with `channel_sweep: 1`; its binaries differ from the earlier two-profile
+V8 measurements, so timings should not be treated as a same-image A/B.
+
+Firmware SHA-256:
+
+- XIAO: `b0a6dd216be8a638fe088219801290d4632afcf63048f8597037650d68cd2301`
+- Indicator: `f0aa43a46e5e59ebf4768a255bd8c31d7a72c0038e11173314f78908b725786d`
+
+## Device handoff
+
+After the first-miss stop, both original 8 MiB flash snapshots were restored.
+Separate full-flash verification matched each backup's digest, followed by
+hardware reset. The private backups remain in the current-user backup
+directory. The Indicator RP2040 and RAK4631 were not modified. The interrupted
+XIAO soak logger remains stopped, and the temporary PlatformIO HIL overlay was
+removed. Implementation, tests and reports were initially handed off locally
+before publication.
diff --git a/docs/test-results/sf8_5p1_trace_validation.md b/docs/test-results/sf8_5p1_trace_validation.md
new file mode 100644
index 000000000..d44827420
--- /dev/null
+++ b/docs/test-results/sf8_5p1_trace_validation.md
@@ -0,0 +1,137 @@
+# SF8 / 125 kHz: four-channel comparison at 5.1 chirps
+
+Measured 2026-09-14. **12 packets received, then packet 13 missed.** The first
+three balanced rounds passed (3/3 on every channel); the next probe on channel
+3 failed. The run stopped at that miss. The target of 100 packets per channel
+was not reached. No RF retry or other channel count was tested.
+
+## Setup and comparison
+
+Same XIAO ESP32-S3 + WIO SX1262 scanner (COM31) and SenseCAP Indicator ESP32-S3
+reference transmitter (COM28). Four centers: 909.500, 909.750, 910.000 and
+910.250 MHz. CR4/5, 32-symbol programmed preamble, 16-byte synthetic packets,
+-9 dBm, warm production fast RX and buffered 8 MHz SPI. Same production packet
+guards and read-only 64 us nominal IRQ polling; events buffered until each
+result. Randomized balanced channel order, seed 606125, varied arrival timing.
+
+| Setting | Earlier SF6/125 | This SF8/125 |
+| --- | --- | --- |
+| One symbol | 512 us | 2,048 us |
+| Requested 5.1-symbol dwell, rounded up | 2,612 us | 10,445 us |
+| 32 programmed preamble symbols | 16.384 ms | 65.536 ms |
+| Whole-run average measured retune | 539.427 us | 524.124 us |
+| Outcome before first miss | 7 received, packet 8 missed | 12 received, packet 13 missed |
+| Miss trace | No observed preamble or hold | Off-channel preamble and prolonged hold |
+
+Changing SF increases real listening and preamble durations together. This is
+not a matched same-image A/B or an isolated post-retune settling experiment.
+The small retune-time difference must not be treated as a new optimization;
+no extra settling delay was inserted. The earlier reporting-only held-visit
+timestamp correction is included in this trace-version-2 run.
+
+## Failed probe: an off-channel hold
+
+Failed sequence `388715032` targeted **channel 3 / 910.250 MHz**. The scanner
+instead observed PreambleDetected on **channel 2 / 910.000 MHz**, then its
+normal packet guard deferred the intended jump to channel 3. It never observed
+a valid header, header error, CRC event, or RxDone for that preamble.
+
+Times are software observations relative to expectation arming:
+
+| Time | Event |
+| --- | --- |
+| 207.649 ms | Last hop onto target channel 3 before the long gap |
+| 240.542 ms | Hop onto channel 2 |
+| 250.030 ms | PreambleDetected (0x0004) observed on channel 2 |
+| 251.013 ms | Guard defers channel 2 → 3 at the 5.1-symbol deadline |
+| 342.287 ms | Guard issues a clear of the still-latched preamble bit |
+| 342.794 ms | Hop onto channel 3 finally completes |
+| 342.851 ms | IRQ read confirms zero bits |
+| 9,999.270 ms | Expectation timeout, RX mode, zero device errors |
+
+Channel-2 occupancy was about **102 ms**. The target channel's revisit gap
+grew to **135.145 ms**, compared with its ordinary ~43.875 ms cycle and the
+65.536 ms programmed preamble. The delayed retune itself took only **550 us**.
+
+The preamble-to-header budget from `calcMaxPacketMillis()` is
+ceil((32 + 8 + 4.25) * 2.048) = **91 ms**. The strict millisecond timeout in
+`CustomSX1262::isReceiving()` clears a preamble that does not advance to a
+header. The observed **92.257 ms** from the first IRQ observation to that
+clear is consistent with this existing protection mechanism.
+
+This identifies a prolonged off-channel hold that removed the next listening
+opportunity. It does **not** show that the ~0.52 ms jump needs more settling
+time. The RF source of that preamble is not proven: adjacent-channel pickup/
+false detection or unrelated RF are possibilities. There was no independent
+RF capture or synchronized MCU clock. The earlier SF6 miss had no observed
+preamble or hold; this result does not establish the same cause for that miss.
+
+Simply shortening the guard risks cutting off valid long-preamble packets.
+Successful traces show that reception legitimately keeps a channel occupied
+well past 10.445 ms. No production guard change was made in this diagnostic.
+
+Follow-up: the [stationary preamble investigation](preamble_detection_investigation.md)
+reproduced transmitter-associated off-frequency indications with full RX
+initialization and no hopping, on both boards. It strengthens the off-channel
+detection explanation without independently identifying the RF source of this
+historical missed probe. The SF6 miss remains a separate unresolved case.
+
+## Measurements and checks
+
+- All 12 successful payloads were valid; RSSI -42 to -41 dBm, SNR 12.0 to
+ 14.2 dB. First observed preamble after logged hop completion ranged
+ **7.433–9.485 ms**; hop-to-RxDone ranged **105.056–146.836 ms**.
+- Entire run: 1,167 fast hops, mean retune 524.124 us, maximum 774 us. Idle
+ dwell mean 10,452.444 us; held visits separately accounted for.
+- Failed expectation: 910 trace events, zero overflow, 903 completed hops.
+ The 902 unheld dwells were 10,445–10,480 us, mean 10,450.424 us.
+- Excluding the one held cycle, target-channel revisit intervals were
+ **43,847–43,928 us**, mean **43,874.580 us** (224 intervals).
+- Failed-expectation retunes: 511–617 us, mean 520.443 us. The held occupancy
+ accounts for the anomalous target-channel revisit gap.
+- Zero retune failures, RX-mode errors, modulation-cache errors, CRC/read
+ errors, USB stale replies or cached transport recovery. Reference TX rc=0.
+- 133,217 extra IRQ polls during the failed capture; total cost 3,241,509 us,
+ maximum 55 us. Polls add load; timestamps are observations, not exact IRQ
+ edges. No per-event serial output occurs during capture.
+
+This first-miss sample neither estimates a stable packet-error rate nor
+establishes a channel count with 100% reception.
+
+## Reproduction and provenance
+
+```text
+python tools/hil/profile_switch_channels.py --receiver COM31 --sender COM28 --output tools/hil/sf8_125_5p1_trace_results.json --samples 100 --preamble 32 --max-channels 4 --dwell-symbols 5.1 --trace --sf 8
+```
+
+- [Raw result and all 13 traces](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/sf8_125_5p1_trace_results.json)
+- [Earlier SF6/125 at 5.1 chirps](sf6_5p1_trace_validation.md)
+- [Harness instructions](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/hil/profile_switch_README.md#sf8-comparison-at-the-same-chirp-count)
+
+Raw result SHA-256:
+`0d7e97173937f42708febf8276cdadc36578740735058d5da39b1b576e9ca49e`.
+
+Tested firmware SHA-256:
+
+- XIAO: `a68ab9834f62d0a5fb3f10c7a0b312c17d7d62795885cac4ea0dce483d6bf1ea`
+- Indicator: `bca1514bc6e09af6b1a9daed5bf4d3dbeeab988bcb856acbc73f59334395280d`
+
+Base commit `c329cf1fc1e7f2e8c8733ab0d000094730458fe3` plus local changes;
+RadioLib 7.7.1 at `187ef24791c3d844939b2be13a68bd890bd04e4c`, Espressif32
+6.11.0 / Arduino ESP32 2.0.17. Both HIL targets built successfully. All 27
+selected host/native tests passed, including SF scaling, propagation to both
+RX staging slots and TX, cache checks, trace handling and first-miss stop.
+
+## Device handoff
+
+Fresh full 8 MiB backups were saved and digest-verified before flashing:
+
+- XIAO: `e9533742103c9d7af11a3dc0c40496ca412b072e11880c2be785c92ef80e4310`
+- Indicator: `d4f39a0be1339f142ed329b68606347adeb514d26d6cc8bad43fb3d343ac1e2a`
+
+Both were restored after the test, separately full-flash digest-verified, and
+hardware-reset. Backups remain in the private current-user backup directory.
+No identity or channel secrets were used in the synthetic RF probes. The
+RAK4631 and Indicator RP2040 were not modified; the XIAO soak logger was not
+restarted. The temporary HIL build overlay was removed. Source, tests and
+results were initially handed off locally before publication.
diff --git a/docs/test-results/spiffs_regular_file_reads.md b/docs/test-results/spiffs_regular_file_reads.md
new file mode 100644
index 000000000..6965efc49
--- /dev/null
+++ b/docs/test-results/spiffs_regular_file_reads.md
@@ -0,0 +1,63 @@
+# SPIFFS regular-file reads and login replay state
+
+Arduino-ESP32 SPIFFS can return a truthy directory handle from a read-open of a
+nonexistent filename. `File::operator bool()` alone does not prove that a
+regular file exists. A directory's `size()` and `read()` are zero.
+
+The first privileged login on an upgraded G2 encountered this in
+`ClientACL::writeClientLoginReplayCeiling()`: opening the not-yet-created
+`/s_login_replay` appeared successful, then subtracting the eight-byte trailer
+from size zero underflowed. Copying the supposed records failed and login was
+rejected. Waiting or changing the repeater clock cannot fix that file-open bug.
+
+## Fix
+
+Use `mesh::openFileRead()` for regular-file reads. It checks existence and
+rejects directory handles, while preserving real empty files. Replay record
+counts also validate the minimum trailer size, record alignment, and maximum
+count before subtraction. An existing replay file that becomes unreadable is
+not treated as a new store.
+
+No replay records are cleared and authentication is not weakened. Invalid or
+unwritable replay state still fails closed. The first successful privileged
+login creates a 44-byte file: one 36-byte identity/ceiling record plus its
+eight-byte integrity trailer.
+
+Setting the repeater clock must not clear this file. Admission compares the
+sender's timestamp with that sender's saved boundary, not with the repeater's
+current time. Clearing the boundary could make captured requests reusable. A
+sender clock rollback is a separate condition and remains subject to the saved
+boundary after this fix.
+
+Companion directory enumeration uses the separate `openDirectory()` API,
+which deliberately permits SPIFFS virtual directories. Regular-file reads are
+also enforced for companion data, repeater/room logs, flood-rule verification,
+and HTTP packet-log downloads (missing logs return 404; real empty logs 200).
+
+## Audit boundary
+
+The audit covered file opens and size arithmetic in `src` and `examples`.
+Identity, region, clock, and common preference loaders already gate reads with
+filesystem existence checks; SPIFFS's `exists()` explicitly excludes directory
+handles. Relevant MQTT length subtraction follows validated headers and exact
+reads. ESP32 OTA staging uses partition APIs, not these SPIFFS file handles.
+Intentional directory enumeration must not be changed to a regular-file read.
+
+## Regression checks
+
+- `test/test_client_acl_spiffs.py` compiles the actual `ClientACL.cpp` against
+ a filesystem that reproduces the misleading missing-file directory handle.
+ It covers first creation, fresh/stale login, reboot ceilings, corrupt and
+ unreadable state, short writes, failed publication, and truncated sources.
+- `test/test_regular_file_reads.py` executes production companion readers,
+ flood-file verification, and HTTP log delivery with the same filesystem
+ behavior, including platform-specific file API differences.
+- `test/test_esp32_tinyusb_cooperative_output.py` exercises both real role log
+ pumps, including missing logs and bounded output.
+- Native ACL transaction, login persistence, and client-path persistence suites
+ retain the existing security and recovery coverage.
+
+The pre-fix actual ACL code was reproduced as `first_login=rejected`, one
+missing read-open, and no replay file. The fixed code accepted that identical
+scenario without a missing read-open and created the valid 44-byte record.
+Host simulations do not replace a post-flash LoRa login test on the G2.
diff --git a/docs/test-results/usb_serial_backpressure.md b/docs/test-results/usb_serial_backpressure.md
new file mode 100644
index 000000000..f25626445
--- /dev/null
+++ b/docs/test-results/usb_serial_backpressure.md
@@ -0,0 +1,74 @@
+# Native USB backpressure and radio liveness
+
+ESP32-S2/S3 builds using native TinyUSB CDC (`ARDUINO_USB_MODE=0` and
+CDC-on-boot) must not wait for a computer to read USB output. In the bundled
+Arduino-ESP32 2.0.17 core, `USBCDC::write()` can wait indefinitely for transmit
+space; its configured timeout bounds a mutex, not that wait. This can stop the
+same loop that services LoRa, even while the USB connection still appears open.
+
+## Firmware behavior
+
+- Native CDC output uses a single FIFO attempt, not Arduino's wait-for-space
+ write or flush. TinyUSB's internal short mutex operations still apply; this
+ is a no-host-progress-wait guarantee, not a lock-free driver replacement.
+- Functional text and diagnostics share one ordered 4 KiB queue. Diagnostics
+ leave 3 KiB reserved for commands. A diagnostic backlog alone does not prevent
+ command input. A stalled host may lose diagnostic records; radio work continues.
+- A connected terminal that exceeds its bounded output capacity gets an explicit
+ dropped-byte notice when transmission resumes. Large local file dumps and
+ recent-repeater listings advance between radio service passes instead.
+- File dumps stop at their initial file size and emit `-> EOF` only after the
+ final accepted record. Corrupt stored lines exceeding 640 bytes are omitted
+ with a visible notice. Recent-repeater output is a live, bounded-cursor view;
+ incoming packets can change its ordering while it is being printed.
+- Disconnects discard pending old-session text and reset the role's partial
+ command/listing state. Binary/mOTA transitions suppress pending text notices.
+ Bytes already transmitted cannot be recalled.
+- Companion frames retain and retry short writes. An mOTA request is admitted
+ only when its complete, at-most-11-byte record fits.
+- nRF52, UART, and USB-Serial-JTAG behavior is not changed by this native-CDC path.
+
+Host software must also keep reading independently of command writes. A serial
+relay should start its reader before the first command, use finite write and
+response deadlines, avoid discarding received packet logs, and cancel I/O during
+shutdown. A response timeout must not allow a late reply to satisfy another
+command: the CLI has no transaction identifiers. Updating firmware alone does
+not correct an indefinite host-side serial write.
+
+## Regression checks
+
+Run only one PlatformIO process in the checkout at a time.
+
+```sh
+python test/test_esp32_tinyusb_nonblocking.py
+python test/test_esp32_tinyusb_role_hygiene.py
+python test/test_esp32_tinyusb_cooperative_output.py
+python test/test_esp32_usb_serial_hygiene.py
+python test/test_nrf52_usb_logging_contract.py
+pio test -e native -f test_nrf52_debug_output -f test_serial_packet_log -f test_serial_mode_switch -f test_mesh_tables
+```
+
+The first test compiles the real USB facade against a simulated 64-byte FIFO,
+including stopped readers, reconnects, protocol transitions, and other-platform
+fallbacks. Host simulations cannot establish that every real USB driver or
+endpoint failure has recovered.
+
+A Full Station G2 validation build with USA Cascadia radio settings and the Cascade
+profile can be made using the normal build entry point. The portable `standard`
+recipe preserves the deployed partition layout but omits LoRa OTA; it still has
+the browser firmware uploader. The `auto`/`full` recipe instead enables the
+expanded feature set and partition layout: its merged image is **not** an
+app-only update for a device with the legacy layout.
+
+```sh
+MESHDEBUG_OVERRIDE=on PACKET_LOGGING_OVERRIDE=on \
+ bash build.sh build-firmware Station_G2_repeater --build-profile full \
+ --radio-preset usa-cascadia --profile cascade
+```
+
+Before installing on hardware, preserve the device identity, preferences, and
+existing partition layout. Then verify USB command responses and repeated LoRa
+logins with the relay running, paused, and stopped, including a host that leaves
+USB open without reading. Check LoRa recovery separately from USB OUT recovery;
+fixing transmit backpressure does not prove an unrelated OUT endpoint fault is
+resolved. Do not erase or repartition the radio as part of this test.
diff --git a/docs/test-results/v4_pixel5_font_trial.md b/docs/test-results/v4_pixel5_font_trial.md
new file mode 100644
index 000000000..c2f6919e0
--- /dev/null
+++ b/docs/test-results/v4_pixel5_font_trial.md
@@ -0,0 +1,206 @@
+# Small-screen message fonts
+
+Small-screen Companion builds automatically select a compact font for received
+message text and the channel/sender line, including `Ch 0 Public`. SSD1306 and
+SH1106 OLEDs and the U8g2 T-Echo Card display use this on panels smaller than
+160 pixels on both axes. ST7735 TFTs now use their native 160x80 dimensions
+and normal font. The shared renderer selects by panel dimensions, including
+rotation:
+
+| Panel size | Font | Capital height | Line spacing |
+| --- | --- | ---: | ---: |
+| At least 128 x 64, or 64 x 128 rotated; both axes below 160 | Squeezed Regular 6 | 6 pixels | 8 pixels |
+| Smaller panels, including 72 x 40, 128 x 32 and 64 x 48 | Picopixel | 5 pixels | 7 pixels |
+| Either axis at least 160 | Normal display font | Depends on panel | Depends on panel |
+
+Both fonts allow one pixel for descenders and one blank pixel row between
+lines. Character widths vary: most letters advance by 4 pixels, with narrow
+letters taking less space and `M` and `W` taking 6 pixels. Squeezed Regular 6
+is a [public-domain font by Oliver Kraus](https://github.com/olikraus/u8g2/wiki/fntgrpu8g#squeezed_r6).
+Its bitmap data is stored as constants without heap allocation.
+
+On a 128 x 64 OLED with the single-button message reader, the compact
+channel/sender line starts at y=8 and message text at y=16. The header uses
+the same small font. Five complete 6px-font rows fit above the navigation
+hint at y=56. The software comparison fits three representative 160-character
+messages in those five rows. Capacity
+depends on the characters; messages with many wide letters can still overflow.
+Text wraps at character boundaries, with `...` on the last line if necessary.
+Unsupported characters appear as `?`. Long channel/sender names are ellipsized
+to stay on their own line. An enabled channel footer reserves its own space.
+Readers without the button hint retain the y=14 origin and y=22 message text.
+
+On V4 and other single-button builds using this reader, the home screen says
+`hold button: inbox`. Hold the user button for about 1.2 seconds to open it.
+The bottom continuously shows `4 <<- 2 <- tap -> 1 ->> 3 hold: Exit` without alternating or
+blinking. Double tap goes to the previous
+message, one tap advances, three taps select the next channel, and four taps
+select the previous channel. Channel selection cycles through All, configured
+channels, and direct messages, starting at the newest message in each filter.
+The header shows `All`, `Ch N`, or `DM`, including when a channel is empty.
+Hold to return home; advancing past the last message also returns home.
+The complete hint fits in the V4's existing 8-pixel footer at y=56, leaving
+five message rows. The footer keeps the same 6px font, using 108 pixels with
+10-pixel margins on the V4. Narrower screens first use the compact `hold:X`
+hint without the extra spaces or arrow dashes, then split the
+same controls across fixed lines. The hint appears even when the inbox is
+empty. These are button taps. Touchscreen and joystick builds retain
+instructions appropriate to their controls. During the first eight
+seconds after startup, holding the button on an ordinary home page enters
+CLI rescue instead; wait for that startup window to finish before opening
+the inbox. Exiting a message preview and the WiFi setup page's hold action
+remain available immediately.
+
+The hidden [Reader](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/bible/README.md#on-device-reader), opened
+by a long press on the radio page, uses the same button hints. Three taps jump
+to the first verse of the next chapter; four taps jump to the first verse of
+the previous chapter. Chapter navigation stops at the beginning and end of
+the book. It reserves the hint before pagination and resumes saved bookmarks
+at the page containing the same text, even after the available page size changes.
+
+Larger display classes keep their existing font. Menus, Bluetooth PINs and WiFi
+setup QR codes keep their normal layout. TFT drivers use native panel
+coordinates; the SenseCAP Indicator retains its existing scaling. The normal OLED font uses 7-pixel letters,
+8-pixel line spacing and 6-pixel character advances.
+
+The preview buffer holds a complete 160-byte MeshCore message plus its
+terminator. Previously the main message UI allocated 78 bytes, leaving room
+for only 77 bytes of text. It still retains 32 previews; larger records add
+about 2.8 KB of RAM. The firmware RAM guard includes that increase. This
+history is separate from the offline queue and its mOTA policy.
+
+The tiny 72 x 40 T-Echo Card interface now previews the latest received
+message below its status bar, with three small-font message rows. A button
+press dismisses it. It stores one full message, and shows `...` when the
+screen fills. Incoming text does not replace an active Bluetooth pairing PIN.
+
+## V4 hardware trial
+
+Enable `platformio.nimble.ini` in the ignored `platformio.local.ini` as shown
+in the [NimBLE trial guide](nimble_companion_trial.md#build), then run:
+
+```sh
+OUTPUT_DIR=.releases/v4-smallfont bash build.sh build-firmware \
+ heltec_v4_2_v4_3_companion_radio_full_femon_nimble \
+ --firmware-version v1.17.1.5-halo-keymind-cascade-squeezed6-trial \
+ --radio-preset usa-cascadia --profile cascade --standard --require-ota
+```
+
+This build keeps NimBLE, 350 contacts, 40 channels, the V4's 512-frame
+PSRAM queue, USB mOTA sending and WiFi OTA support. The smaller font is also
+the default in ordinary small-screen Companion builds from this source.
+The older `_nimble_pixel5` environment name remains available, but also uses
+the automatic 5px/6px selection.
+
+Use the application `.bin` for WiFi OTA. A clean USB install uses the merged
+image at address 0. When manually writing the application at `0x10000`, an
+existing OTA selector may still boot app1. Check the running `ver` afterward.
+On the matching V4 16 MB layout only, clearing the 8 KB `otadata` partition at
+`0xe000` selects the newly written app0 without clearing NVS/settings. Confirm
+the partition table and verify the app write before changing that selector.
+
+A custom build can set `-D UI_SMALL_MESSAGE_FONT=0` to restore the old font
+and spacing. Remove any explicit `UI_MSG_PREVIEW_SIZE` flag too if the old
+preview capacity is desired. These are compile-time options, not CLI commands.
+No settings erase is necessary when changing between matching V4 layouts.
+
+## Verification
+
+```sh
+python3 -B test/test_ssd1306_picopixel.py
+python3 -B test/test_firmware_ram.py
+pio test -e native -f test_display_driver -f test_companion_message_history
+```
+
+The native tests exercise both capital heights, automatic font selection,
+160-character messages, complete rows at display edges, long sender lines,
+navigation hints, reserved footers, overflow markers and tiny/rotated screen
+geometry. The additional rendering comparison checks all 95 printable ASCII
+glyphs in each
+font, under address/undefined-behavior sanitizers. Picopixel is compared pixel
+for pixel with Adafruit GFX; Squeezed Regular 6 is compared with the upstream
+BDF fixture independently of the converted C++ tables. This needs a cached
+PlatformIO Adafruit GFX library; set `MESHCORE_GFX_LIBRARY` to its directory
+if needed. It reports a skip when the library is absent. The native tests do
+not require that dependency. Run only one PlatformIO command at a time.
+
+## Automatic 5px/6px results, 2026-09-08
+
+Source revision `469b47d1` passed five representative firmware builds. All
+five retained the same static RAM usage and startup heap margin as their
+earlier 5px builds. The four Full profiles also passed required OTA packaging.
+
+| Hardware/profile | Display | Selected capital height | RAM beyond the required startup budget, bytes |
+| --- | --- | ---: | ---: |
+| V4.2/V4.3 Full NimBLE | SSD1306 | 6 pixels | 88,072 |
+| T096 Full, FEM on | ST7735 | 6 pixels | 24,778 |
+| Station G3 ESP32 Full | SH1106 | 6 pixels | 82,080 |
+| RAK3401 Full | SSD1306 | 6 pixels | 46,108 |
+| T-Echo Card BLE Companion | U8g2, 72 x 40 | 5 pixels | 30,992 |
+
+The native display/history suites passed 36 tests. The Python font, RAM,
+pairing, display-profile, queue and QR checks passed 38 tests, including
+pixel comparisons for all 95 ASCII glyphs in each font. The actual SSD1306
+software rendering matches the approved 6px comparison image pixel for pixel.
+
+Both `NimBLE-V4-VM` and `NimBLE-V4-Trial` were flashed with
+`v1.17.1.5-halo-keymind-cascade-squeezed6-trial-469b47d1`. Their partition tables
+and application hashes were verified, their settings retained, and running
+versions confirmed. Each passed 201 USB protocol requests without error flags.
+The Mercerwood V4 reconnected over bonded Bluetooth at MTU 179 and retained
+the factory-address policy.
+
+The XIAO sent a private 160-byte LoRa message to the Mercerwood V4. All bytes
+arrived, and the V4 stayed responsive through 20 seconds of display refreshes
+with no uptime reset or error flags. Both temporary channel settings were
+restored. After that interval the V4 had 147,196 bytes of free internal heap,
+a minimum of 146,328 bytes, and a largest free block of 139,252 bytes.
+
+These are short functional checks. Physical readability remains a user
+judgment. A message with many wide characters can still need truncation.
+
+V4 application size: 1,778,936 bytes (1,152 bytes larger than the earlier
+5px trial). SHA-256:
+`8e6797b90741bf013b3757668e5a23378ac8c0b0ed9c75830939f13305c56a68`.
+
+## Earlier 5px hardware and build results, 2026-09-08
+
+Source revision `eeea15ef` passed seven representative firmware builds:
+
+| Hardware/profile | Display | RAM beyond the required startup budget, bytes |
+| --- | --- | ---: |
+| V4.2/V4.3 Full NimBLE Picopixel | SSD1306 | 88,072 |
+| T096 Full, FEM on | ST7735 | 24,778 |
+| Station G3 ESP32 Full | SH1106 | 82,080 |
+| RAK3401 Full | SSD1306 | 46,108 |
+| Wireless Tracker Full NimBLE capacity trial | ST7735 | 28,022 |
+| Wio Tracker L1 Full | SH1106 | 45,744 |
+| T-Echo Card BLE Companion | U8g2 | 30,992 |
+
+These are linked-capacity checks before runtime allocation, not live free
+heap measurements. The six Full profiles also passed their required OTA
+packaging checks. The T-Echo Card row is a BLE Companion build.
+
+Both physical V4.3 nodes, `NimBLE-V4-VM` and `NimBLE-V4-Trial`, were flashed,
+their image hashes verified, and their running versions confirmed. Each
+passed 201 USB protocol requests with zero reported error flags. The
+Mercerwood V4 also reconnected over authenticated, bonded Bluetooth with
+MTU 179 and the existing factory-address policy.
+
+A private XIAO-to-V4 LoRa test delivered all 160 message bytes. The V4
+continued responding during 20 seconds of message redraws, with zero error
+flags. Both temporary channel configurations were restored. Free internal
+heap after that interval was 147,200 bytes, minimum 146,340 bytes, and the
+largest free block was 139,252 bytes. These are short functional checks;
+physical readability remains a user judgment.
+
+A matching pre/post-boot Mercerwood measurement showed the longer preview
+using 2,816 additional PSRAM bytes, with unchanged internal free heap. Boards
+without PSRAM use their ordinary RAM for the larger preview records.
+
+The native display/history suites passed 31 tests. The Python font, RAM,
+pairing, display-profile, queue and QR checks passed 38 tests. The Adafruit
+comparison covers all 95 printable ASCII glyphs.
+
+V4 application SHA-256:
+`d914d80124b499d7b719f8427f3794ab1b6d15fd0112e3ef89ba2fac26b5a974`.
diff --git a/docs/usb_serial_backpressure.md b/docs/usb_serial_backpressure.md
index f25626445..e4855ef8f 100644
--- a/docs/usb_serial_backpressure.md
+++ b/docs/usb_serial_backpressure.md
@@ -1,74 +1,5 @@
-# Native USB backpressure and radio liveness
+# Document moved
-ESP32-S2/S3 builds using native TinyUSB CDC (`ARDUINO_USB_MODE=0` and
-CDC-on-boot) must not wait for a computer to read USB output. In the bundled
-Arduino-ESP32 2.0.17 core, `USBCDC::write()` can wait indefinitely for transmit
-space; its configured timeout bounds a mutex, not that wait. This can stop the
-same loop that services LoRa, even while the USB connection still appears open.
+
-## Firmware behavior
-
-- Native CDC output uses a single FIFO attempt, not Arduino's wait-for-space
- write or flush. TinyUSB's internal short mutex operations still apply; this
- is a no-host-progress-wait guarantee, not a lock-free driver replacement.
-- Functional text and diagnostics share one ordered 4 KiB queue. Diagnostics
- leave 3 KiB reserved for commands. A diagnostic backlog alone does not prevent
- command input. A stalled host may lose diagnostic records; radio work continues.
-- A connected terminal that exceeds its bounded output capacity gets an explicit
- dropped-byte notice when transmission resumes. Large local file dumps and
- recent-repeater listings advance between radio service passes instead.
-- File dumps stop at their initial file size and emit `-> EOF` only after the
- final accepted record. Corrupt stored lines exceeding 640 bytes are omitted
- with a visible notice. Recent-repeater output is a live, bounded-cursor view;
- incoming packets can change its ordering while it is being printed.
-- Disconnects discard pending old-session text and reset the role's partial
- command/listing state. Binary/mOTA transitions suppress pending text notices.
- Bytes already transmitted cannot be recalled.
-- Companion frames retain and retry short writes. An mOTA request is admitted
- only when its complete, at-most-11-byte record fits.
-- nRF52, UART, and USB-Serial-JTAG behavior is not changed by this native-CDC path.
-
-Host software must also keep reading independently of command writes. A serial
-relay should start its reader before the first command, use finite write and
-response deadlines, avoid discarding received packet logs, and cancel I/O during
-shutdown. A response timeout must not allow a late reply to satisfy another
-command: the CLI has no transaction identifiers. Updating firmware alone does
-not correct an indefinite host-side serial write.
-
-## Regression checks
-
-Run only one PlatformIO process in the checkout at a time.
-
-```sh
-python test/test_esp32_tinyusb_nonblocking.py
-python test/test_esp32_tinyusb_role_hygiene.py
-python test/test_esp32_tinyusb_cooperative_output.py
-python test/test_esp32_usb_serial_hygiene.py
-python test/test_nrf52_usb_logging_contract.py
-pio test -e native -f test_nrf52_debug_output -f test_serial_packet_log -f test_serial_mode_switch -f test_mesh_tables
-```
-
-The first test compiles the real USB facade against a simulated 64-byte FIFO,
-including stopped readers, reconnects, protocol transitions, and other-platform
-fallbacks. Host simulations cannot establish that every real USB driver or
-endpoint failure has recovered.
-
-A Full Station G2 validation build with USA Cascadia radio settings and the Cascade
-profile can be made using the normal build entry point. The portable `standard`
-recipe preserves the deployed partition layout but omits LoRa OTA; it still has
-the browser firmware uploader. The `auto`/`full` recipe instead enables the
-expanded feature set and partition layout: its merged image is **not** an
-app-only update for a device with the legacy layout.
-
-```sh
-MESHDEBUG_OVERRIDE=on PACKET_LOGGING_OVERRIDE=on \
- bash build.sh build-firmware Station_G2_repeater --build-profile full \
- --radio-preset usa-cascadia --profile cascade
-```
-
-Before installing on hardware, preserve the device identity, preferences, and
-existing partition layout. Then verify USB command responses and repeated LoRa
-logins with the relay running, paused, and stopped, including a host that leaves
-USB open without reading. Check LoRa recovery separately from USB OUT recovery;
-fixing transmit backpressure does not prove an unrelated OUT endpoint fault is
-resolved. Do not erase or repartition the radio as part of this test.
+This document is now in [Test Results](test-results/usb_serial_backpressure.md).
diff --git a/docs/v4_pixel5_font_trial.md b/docs/v4_pixel5_font_trial.md
index c2f6919e0..6c11a4de1 100644
--- a/docs/v4_pixel5_font_trial.md
+++ b/docs/v4_pixel5_font_trial.md
@@ -1,206 +1,5 @@
-# Small-screen message fonts
+# Document moved
-Small-screen Companion builds automatically select a compact font for received
-message text and the channel/sender line, including `Ch 0 Public`. SSD1306 and
-SH1106 OLEDs and the U8g2 T-Echo Card display use this on panels smaller than
-160 pixels on both axes. ST7735 TFTs now use their native 160x80 dimensions
-and normal font. The shared renderer selects by panel dimensions, including
-rotation:
+
-| Panel size | Font | Capital height | Line spacing |
-| --- | --- | ---: | ---: |
-| At least 128 x 64, or 64 x 128 rotated; both axes below 160 | Squeezed Regular 6 | 6 pixels | 8 pixels |
-| Smaller panels, including 72 x 40, 128 x 32 and 64 x 48 | Picopixel | 5 pixels | 7 pixels |
-| Either axis at least 160 | Normal display font | Depends on panel | Depends on panel |
-
-Both fonts allow one pixel for descenders and one blank pixel row between
-lines. Character widths vary: most letters advance by 4 pixels, with narrow
-letters taking less space and `M` and `W` taking 6 pixels. Squeezed Regular 6
-is a [public-domain font by Oliver Kraus](https://github.com/olikraus/u8g2/wiki/fntgrpu8g#squeezed_r6).
-Its bitmap data is stored as constants without heap allocation.
-
-On a 128 x 64 OLED with the single-button message reader, the compact
-channel/sender line starts at y=8 and message text at y=16. The header uses
-the same small font. Five complete 6px-font rows fit above the navigation
-hint at y=56. The software comparison fits three representative 160-character
-messages in those five rows. Capacity
-depends on the characters; messages with many wide letters can still overflow.
-Text wraps at character boundaries, with `...` on the last line if necessary.
-Unsupported characters appear as `?`. Long channel/sender names are ellipsized
-to stay on their own line. An enabled channel footer reserves its own space.
-Readers without the button hint retain the y=14 origin and y=22 message text.
-
-On V4 and other single-button builds using this reader, the home screen says
-`hold button: inbox`. Hold the user button for about 1.2 seconds to open it.
-The bottom continuously shows `4 <<- 2 <- tap -> 1 ->> 3 hold: Exit` without alternating or
-blinking. Double tap goes to the previous
-message, one tap advances, three taps select the next channel, and four taps
-select the previous channel. Channel selection cycles through All, configured
-channels, and direct messages, starting at the newest message in each filter.
-The header shows `All`, `Ch N`, or `DM`, including when a channel is empty.
-Hold to return home; advancing past the last message also returns home.
-The complete hint fits in the V4's existing 8-pixel footer at y=56, leaving
-five message rows. The footer keeps the same 6px font, using 108 pixels with
-10-pixel margins on the V4. Narrower screens first use the compact `hold:X`
-hint without the extra spaces or arrow dashes, then split the
-same controls across fixed lines. The hint appears even when the inbox is
-empty. These are button taps. Touchscreen and joystick builds retain
-instructions appropriate to their controls. During the first eight
-seconds after startup, holding the button on an ordinary home page enters
-CLI rescue instead; wait for that startup window to finish before opening
-the inbox. Exiting a message preview and the WiFi setup page's hold action
-remain available immediately.
-
-The hidden [Reader](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/bible/README.md#on-device-reader), opened
-by a long press on the radio page, uses the same button hints. Three taps jump
-to the first verse of the next chapter; four taps jump to the first verse of
-the previous chapter. Chapter navigation stops at the beginning and end of
-the book. It reserves the hint before pagination and resumes saved bookmarks
-at the page containing the same text, even after the available page size changes.
-
-Larger display classes keep their existing font. Menus, Bluetooth PINs and WiFi
-setup QR codes keep their normal layout. TFT drivers use native panel
-coordinates; the SenseCAP Indicator retains its existing scaling. The normal OLED font uses 7-pixel letters,
-8-pixel line spacing and 6-pixel character advances.
-
-The preview buffer holds a complete 160-byte MeshCore message plus its
-terminator. Previously the main message UI allocated 78 bytes, leaving room
-for only 77 bytes of text. It still retains 32 previews; larger records add
-about 2.8 KB of RAM. The firmware RAM guard includes that increase. This
-history is separate from the offline queue and its mOTA policy.
-
-The tiny 72 x 40 T-Echo Card interface now previews the latest received
-message below its status bar, with three small-font message rows. A button
-press dismisses it. It stores one full message, and shows `...` when the
-screen fills. Incoming text does not replace an active Bluetooth pairing PIN.
-
-## V4 hardware trial
-
-Enable `platformio.nimble.ini` in the ignored `platformio.local.ini` as shown
-in the [NimBLE trial guide](nimble_companion_trial.md#build), then run:
-
-```sh
-OUTPUT_DIR=.releases/v4-smallfont bash build.sh build-firmware \
- heltec_v4_2_v4_3_companion_radio_full_femon_nimble \
- --firmware-version v1.17.1.5-halo-keymind-cascade-squeezed6-trial \
- --radio-preset usa-cascadia --profile cascade --standard --require-ota
-```
-
-This build keeps NimBLE, 350 contacts, 40 channels, the V4's 512-frame
-PSRAM queue, USB mOTA sending and WiFi OTA support. The smaller font is also
-the default in ordinary small-screen Companion builds from this source.
-The older `_nimble_pixel5` environment name remains available, but also uses
-the automatic 5px/6px selection.
-
-Use the application `.bin` for WiFi OTA. A clean USB install uses the merged
-image at address 0. When manually writing the application at `0x10000`, an
-existing OTA selector may still boot app1. Check the running `ver` afterward.
-On the matching V4 16 MB layout only, clearing the 8 KB `otadata` partition at
-`0xe000` selects the newly written app0 without clearing NVS/settings. Confirm
-the partition table and verify the app write before changing that selector.
-
-A custom build can set `-D UI_SMALL_MESSAGE_FONT=0` to restore the old font
-and spacing. Remove any explicit `UI_MSG_PREVIEW_SIZE` flag too if the old
-preview capacity is desired. These are compile-time options, not CLI commands.
-No settings erase is necessary when changing between matching V4 layouts.
-
-## Verification
-
-```sh
-python3 -B test/test_ssd1306_picopixel.py
-python3 -B test/test_firmware_ram.py
-pio test -e native -f test_display_driver -f test_companion_message_history
-```
-
-The native tests exercise both capital heights, automatic font selection,
-160-character messages, complete rows at display edges, long sender lines,
-navigation hints, reserved footers, overflow markers and tiny/rotated screen
-geometry. The additional rendering comparison checks all 95 printable ASCII
-glyphs in each
-font, under address/undefined-behavior sanitizers. Picopixel is compared pixel
-for pixel with Adafruit GFX; Squeezed Regular 6 is compared with the upstream
-BDF fixture independently of the converted C++ tables. This needs a cached
-PlatformIO Adafruit GFX library; set `MESHCORE_GFX_LIBRARY` to its directory
-if needed. It reports a skip when the library is absent. The native tests do
-not require that dependency. Run only one PlatformIO command at a time.
-
-## Automatic 5px/6px results, 2026-09-08
-
-Source revision `469b47d1` passed five representative firmware builds. All
-five retained the same static RAM usage and startup heap margin as their
-earlier 5px builds. The four Full profiles also passed required OTA packaging.
-
-| Hardware/profile | Display | Selected capital height | RAM beyond the required startup budget, bytes |
-| --- | --- | ---: | ---: |
-| V4.2/V4.3 Full NimBLE | SSD1306 | 6 pixels | 88,072 |
-| T096 Full, FEM on | ST7735 | 6 pixels | 24,778 |
-| Station G3 ESP32 Full | SH1106 | 6 pixels | 82,080 |
-| RAK3401 Full | SSD1306 | 6 pixels | 46,108 |
-| T-Echo Card BLE Companion | U8g2, 72 x 40 | 5 pixels | 30,992 |
-
-The native display/history suites passed 36 tests. The Python font, RAM,
-pairing, display-profile, queue and QR checks passed 38 tests, including
-pixel comparisons for all 95 ASCII glyphs in each font. The actual SSD1306
-software rendering matches the approved 6px comparison image pixel for pixel.
-
-Both `NimBLE-V4-VM` and `NimBLE-V4-Trial` were flashed with
-`v1.17.1.5-halo-keymind-cascade-squeezed6-trial-469b47d1`. Their partition tables
-and application hashes were verified, their settings retained, and running
-versions confirmed. Each passed 201 USB protocol requests without error flags.
-The Mercerwood V4 reconnected over bonded Bluetooth at MTU 179 and retained
-the factory-address policy.
-
-The XIAO sent a private 160-byte LoRa message to the Mercerwood V4. All bytes
-arrived, and the V4 stayed responsive through 20 seconds of display refreshes
-with no uptime reset or error flags. Both temporary channel settings were
-restored. After that interval the V4 had 147,196 bytes of free internal heap,
-a minimum of 146,328 bytes, and a largest free block of 139,252 bytes.
-
-These are short functional checks. Physical readability remains a user
-judgment. A message with many wide characters can still need truncation.
-
-V4 application size: 1,778,936 bytes (1,152 bytes larger than the earlier
-5px trial). SHA-256:
-`8e6797b90741bf013b3757668e5a23378ac8c0b0ed9c75830939f13305c56a68`.
-
-## Earlier 5px hardware and build results, 2026-09-08
-
-Source revision `eeea15ef` passed seven representative firmware builds:
-
-| Hardware/profile | Display | RAM beyond the required startup budget, bytes |
-| --- | --- | ---: |
-| V4.2/V4.3 Full NimBLE Picopixel | SSD1306 | 88,072 |
-| T096 Full, FEM on | ST7735 | 24,778 |
-| Station G3 ESP32 Full | SH1106 | 82,080 |
-| RAK3401 Full | SSD1306 | 46,108 |
-| Wireless Tracker Full NimBLE capacity trial | ST7735 | 28,022 |
-| Wio Tracker L1 Full | SH1106 | 45,744 |
-| T-Echo Card BLE Companion | U8g2 | 30,992 |
-
-These are linked-capacity checks before runtime allocation, not live free
-heap measurements. The six Full profiles also passed their required OTA
-packaging checks. The T-Echo Card row is a BLE Companion build.
-
-Both physical V4.3 nodes, `NimBLE-V4-VM` and `NimBLE-V4-Trial`, were flashed,
-their image hashes verified, and their running versions confirmed. Each
-passed 201 USB protocol requests with zero reported error flags. The
-Mercerwood V4 also reconnected over authenticated, bonded Bluetooth with
-MTU 179 and the existing factory-address policy.
-
-A private XIAO-to-V4 LoRa test delivered all 160 message bytes. The V4
-continued responding during 20 seconds of message redraws, with zero error
-flags. Both temporary channel configurations were restored. Free internal
-heap after that interval was 147,200 bytes, minimum 146,340 bytes, and the
-largest free block was 139,252 bytes. These are short functional checks;
-physical readability remains a user judgment.
-
-A matching pre/post-boot Mercerwood measurement showed the longer preview
-using 2,816 additional PSRAM bytes, with unchanged internal free heap. Boards
-without PSRAM use their ordinary RAM for the larger preview records.
-
-The native display/history suites passed 31 tests. The Python font, RAM,
-pairing, display-profile, queue and QR checks passed 38 tests. The Adafruit
-comparison covers all 95 printable ASCII glyphs.
-
-V4 application SHA-256:
-`d914d80124b499d7b719f8427f3794ab1b6d15fd0112e3ef89ba2fac26b5a974`.
+This document is now in [Test Results](test-results/v4_pixel5_font_trial.md).
diff --git a/mkdocs.yml b/mkdocs.yml
index 6c38fe820..26138cc79 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -6,6 +6,27 @@ repo_name: !ENV [MKDOCS_REPO_NAME, 'meshcore-dev/meshcore']
repo_url: !ENV [MKDOCS_REPO_URL, 'https://github.com/meshcore-dev/meshcore/']
edit_uri: !ENV [MKDOCS_EDIT_URI, 'edit/main/docs/']
+# Keep historical URLs for bookmarks while showing only the organized copies.
+not_in_nav: |
+ /*_validation.md
+ /*_results.md
+ /*_audit.md
+ /*_investigation.md
+ /*_trial.md
+ /hardware_validation_checklist.md
+ /home_text_spacing.md
+ /mbedtls-tls-footprint.md
+ /nrf52_companion_storage_recovery.md
+ /nrf52-usb-ready-fix.md
+ /radio_receive_calibration.md
+ /spiffs_regular_file_reads.md
+ /usb_serial_backpressure.md
+ /v4_pixel5_font_trial.md
+ /releases/1.17.1.5-doc-audit.md
+ /releases/1.17.1.5.md
+ /releases/1.17.1.6.md
+ /releases/1.17.1.7.md
+
theme:
name: material
logo: _assets/meshcore.svg