Preserve radio lab experiments and reuse unchanged SX1262 modulation

Skip redundant modulation writes during owned fast RX retunes when the last
acknowledged SF/BW/CR/LDRO tuple matches. Invalidate that cache after ordinary
setters, failed writes, and lifecycle changes.

Include the remaining scan, preamble, settling, and memory-soak experiments,
their collectors, validation notes, and original capture records. Preserve
capture bytes across checkouts and keep private soak credentials local.

Run lab collector and compiled contract tests in CI. Update the expectation,
profile mapping, and result-buffer tests for the extended lab tools, and make
the private WiFi override header optional for ordinary soak diagnostics.

Validation: 145 host tests passed from the staged source snapshot. Clean
heltec_v4_repeater and Xiao_S3_WIO_companion_radio_usb builds passed their
RAM/flash gates. All 229 staged capture files retain their original bytes;
all 75 local documentation links resolve in the clean snapshot.
This commit is contained in:
mikecarper committed 2026-09-14 22:34:17 -07:00
1 parent 55b4d8848d
commit 170c2c44b1
374 files changed
+412356 -100

No files matched your search

+4
View File
@@ -7,6 +7,10 @@ docs/releases/1.17.1.5-variant-map.tsv whitespace=-blank-at-eol
# Preserve byte-exact radio-lab captures so published SHA-256 values remain
# valid across Windows and Linux checkouts. CRLF in these captures is data.
tools/hil/profile_switch*.json -text whitespace=cr-at-eol
tools/hil/profile_four_tx*.json -text whitespace=cr-at-eol
tools/hil/profile_pair*.json -text whitespace=cr-at-eol
tools/hil/profile_stationary*.json -text whitespace=cr-at-eol
tools/hil/preamble_stationary*.json -text whitespace=cr-at-eol
tools/hil/sf6_125*.json -text whitespace=cr-at-eol
tools/hil/sf8_125*.json -text whitespace=cr-at-eol
tools/hil/sf5_250*.json -text whitespace=cr-at-eol
+5
View File
@@ -100,6 +100,11 @@ jobs:
test_sx1262_profile_switch test_sx1262_batched_modulation
test_radio_chirp_math
- name: Verify radio lab collectors and compiled experiment contracts
run: |
python3 -B -m unittest discover -s test -p 'test_profile_*.py' -v
python3 -B -m unittest discover -s tools/hil -p 'test_*.py' -v
- name: Verify ESP32 static DRAM budget
run: |
python3 -B test/test_esp32_dram.py
+114
View File
@@ -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](../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`
+143
View File
@@ -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](../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.
@@ -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](../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.
+136
View File
@@ -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.
+71
View File
@@ -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](../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.
+138
View File
@@ -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](../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.
+127
View File
@@ -0,0 +1,127 @@
# Shared radio/radio2 chirp timing and preamble warnings
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) is now current.
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.
+36
View File
@@ -422,3 +422,39 @@ 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.
+11
View File
@@ -325,6 +325,17 @@ clearing, RX commands and BUSY waits remain. TX, CAD, sleep, reset, RXPS and
failed commands revoke reuse; failed RX startup rolls back the profile.
Sleep entry uses standby and wake restores the existing TCXO voltage/delay.
Other variants do not opt in to faster SPI or the fast-RX state machine.
Within a valid owned fast-RX retune, identical acknowledged SF/BW/CR/LDRO
settings now reuse the previous modulation command: frequency-only hops omit
`SetModulationParams` (0x8B). A changed effective LDRO value also forces a write,
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):
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)
for current measurements, lifecycle tests and limitations.
The Indicator result also shows that the
+233
View File
@@ -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.
@@ -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. On-frequency instantaneous RSSI was -41 to
-40 dBm at SF6 and -42 dBm at SF8: the signal remains strong and readings 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.
@@ -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.
+143
View File
@@ -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.
+116
View File
@@ -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.
@@ -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 <fresh-prefix>.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.
+232
View File
@@ -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.
+127
View File
@@ -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](../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.
+125
View File
@@ -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](../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.
+148
View File
@@ -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](../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.
+6
View File
@@ -1,5 +1,8 @@
#include <helpers/ui/DisplayPowerSettings.h>
#include "MyMesh.h"
#if defined(MESH_SOAK_DIAGNOSTICS)
#include "../../tools/hil/S3SoakDiagnostics.h"
#endif
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
@@ -8110,6 +8113,9 @@ static bool isCompanionRadioPrefsCommand(const char* command) {
// Called only for directly attached USB/BLE/TCP/browser clients. Commands
// received over LoRa use handleCommand() with a nonzero sender timestamp.
bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_size) {
#if defined(MESH_SOAK_DIAGNOSTICS)
if (mesh::hil::handleSoakCommand(command, reply, reply_size)) return true;
#endif
if (strcmp(command, "get password") == 0) {
snprintf(reply, reply_size, "> (no admin password on Companion)");
return true;
+11
View File
@@ -6,6 +6,9 @@
#endif
#include "MyMesh.h"
#if defined(MESH_SOAK_DIAGNOSTICS)
#include "../../tools/hil/S3SoakDiagnostics.h"
#endif
#if defined(ESP32_PLATFORM)
#include <helpers/ESP32TrueRandom.h>
#endif
@@ -295,6 +298,14 @@ static void __attribute__((noinline)) serviceCommandInterfaces() {
console.print('\n');
char reply[160];
reply[0] = 0;
#if defined(MESH_SOAK_DIAGNOSTICS)
if (mesh::hil::handleSoakCommand(command, reply, sizeof(reply))) {
console.print(" -> ");
console.println(reply);
command[0] = 0;
return;
}
#endif
#ifdef ETHERNET_ENABLED
if (!ethernet_handle_command(command, reply)) {
#if MESH_ENABLE_HOST_CLI
+6
View File
@@ -4,6 +4,9 @@
#include <string.h>
#include <WiFi.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#if defined(MESH_SOAK_WIFI_KEY_OVERRIDE)
#include "../../../tools/hil/S3SoakWiFiKey.h"
#endif
namespace mesh {
namespace wifi {
@@ -18,6 +21,9 @@ inline void setStationAutoReconnect(bool enabled) {
}
inline wl_status_t beginStation(const char* ssid, const char* password) {
#if defined(MESH_SOAK_WIFI_KEY_OVERRIDE)
password = mesh::hil::soakStationPassword(password);
#endif
if (!kPrimaryEspNowRadio) {
return WiFi.begin(ssid, password);
}
+37
View File
@@ -36,6 +36,10 @@ class CustomSX1262 : public SX1262 {
void endProfileSwitch(bool success) { _profileSwitch.end(success); }
bool profileSwitchFailed() const { return _profileSwitch.failed(); }
uint32_t getOptimizedProfileSwitches() const { return _profileFastResumes; }
#ifdef MC_SX1262_HIL
// Same-image lab baseline: force only 0x8B, preserving fast RX ownership.
void hilInvalidateModulation() { _profileSwitch.invalidateModulation(); }
#endif
int16_t restoreTcxoAfterSleep() {
if (!_tcxoWakePending) return RADIOLIB_ERR_NONE;
@@ -96,6 +100,25 @@ class CustomSX1262 : public SX1262 {
return SX1262::setPreambleLength(symbols);
}
// Ordinary setters can change hardware or leave RadioLib's fields updated
// after a failed write. They cannot preserve our acknowledged hop tuple.
int16_t setBandwidth(float bw) override {
_profileSwitch.invalidateModulation();
return SX1262::setBandwidth(bw);
}
int16_t setSpreadingFactor(uint8_t sf) override {
_profileSwitch.invalidateModulation();
return SX1262::setSpreadingFactor(sf);
}
int16_t setCodingRate(uint8_t cr, bool longInterleave = false) {
_profileSwitch.invalidateModulation();
return SX1262::setCodingRate(cr, longInterleave);
}
int16_t forceLDRO(bool enabled) {
_profileSwitch.invalidateModulation();
return SX1262::forceLDRO(enabled);
}
// Apply one complete LoRa modulation tuple instead of three separate
// SetModulationParams commands. Keep RadioLib's caches and automatic LDRO
// calculation consistent with its ordinary SF/BW/CR setters. Caller must
@@ -119,9 +142,18 @@ class CustomSX1262 : public SX1262 {
return RADIOLIB_ERR_INVALID_BANDWIDTH;
}
if (getPacketType() != RADIOLIB_SX126X_PACKET_TYPE_LORA) {
_profileSwitch.invalidateModulation();
return RADIOLIB_ERR_WRONG_MODEM;
}
// Match the effective on-wire LDRO value, not merely the auto/manual
// policy. autoLDRO() changes policy without writing the chip; SF/BW can
// change its computed value. Keep the pinned RadioLib >=16 ms rule.
const uint8_t effectiveLdro = ldroAuto
? ((float(uint32_t(1) << sf) / bw) >= 16.0f ? 1 : 0)
: ldrOptimize;
const bool unchanged = _profileSwitch.matchesModulation(
sf, codes[index], cr - 4, effectiveLdro);
const auto oldSf = spreadingFactor;
const auto oldBw = bandwidth;
const auto oldBwKhz = bandwidthKhz;
@@ -131,7 +163,12 @@ class CustomSX1262 : public SX1262 {
bandwidth = codes[index];
bandwidthKhz = bw;
codingRate = cr - 4; // ordinary LoRa CR, not long-interleaving encoding
if (unchanged) {
ldrOptimize = effectiveLdro;
return RADIOLIB_ERR_NONE; // no SetModulationParams (0x8B) needed
}
const int16_t state = setModulationParams(sf, bandwidth, codingRate, ldrOptimize);
_profileSwitch.modulationResult(state, sf, bandwidth, codingRate, ldrOptimize);
if (state != RADIOLIB_ERR_NONE) {
// The wrapper restores the physical tuple after a failed command.
// Do not publish a new software tuple when hardware success is unknown.
@@ -10,9 +10,15 @@ struct SX1262ProfileSwitchState {
bool active = false;
bool standbyReady = false;
int16_t error = 0;
bool modulationValid = false;
uint8_t modulationSf = 0, modulationBw = 0, modulationCr = 0, modulationLdro = 0;
explicit SX1262ProfileSwitchState(bool enable) : enabled(enable) {}
void invalidate() { rxValid = active = standbyReady = false; }
void invalidateModulation() { modulationValid = false; }
void invalidate() {
rxValid = active = standbyReady = false;
invalidateModulation();
}
void begin(bool continuousRx, bool warm) {
open = true;
error = 0;
@@ -28,6 +34,24 @@ struct SX1262ProfileSwitchState {
}
}
bool canResumeFast() const { return open && active && rxValid && standbyReady && error == 0; }
// Only trust an acknowledged tuple inside the same owned RX context. The
// normal RadioLib software fields alone do not prove a write succeeded.
bool matchesModulation(uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) const {
return canResumeFast() && modulationValid && modulationSf == sf
&& modulationBw == bw && modulationCr == cr && modulationLdro == ldro;
}
void modulationResult(int16_t result, uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) {
if (result != 0) {
if (open) error = result;
invalidate();
return;
}
modulationSf = sf;
modulationBw = bw;
modulationCr = cr;
modulationLdro = ldro;
modulationValid = true;
}
bool failed() const { return open && error != 0; }
void rxResult(int16_t result, bool reusable) {
if (result != 0) {
+32 -4
View File
@@ -7,6 +7,28 @@ import test_sx1262_batched_modulation as compiler
class ChannelTraceTests(unittest.TestCase):
compile_run = compiler.BatchedModulationTests.compile_run
def test_settle_is_additional_and_cycles_exclude_held_visits(self):
source = (Path(__file__).resolve().parents[1] / "tools/hil/ProfileChannelVisitClock.h").read_text().replace("#pragma once", "")
self.compile_run(r'''
#include <cassert>
@SOURCE@
int main() {
ChannelVisitClock v;
v.begin(100,200,false);assert(v.dwell(500,100)==400); // zero-delay legacy clock
v.begin(100,1100,true);assert(v.dwell(3711,100)==2611);
assert(v.dwell(3712,100)==2612); // settling did not consume the 5.1-symbol visit
assert(v.dwell(4100,4000)==100); // packet restart has no channel-settle delay
v.begin(0xfffffff0u,0xfffffffau,true);assert(v.dwell(10,0xfffffff0u)==16);
ChannelCycleClock c;uint32_t elapsed=0;
assert(!c.hop(0,100,false,elapsed));
for(unsigned ch=1;ch<4;++ch) assert(!c.hop(ch,100+ch*3000,false,elapsed));
assert(c.hop(0,12100,false,elapsed) && elapsed==12000);
assert(!c.hop(2,15000,true,elapsed));
assert(!c.hop(0,25000,false,elapsed)); // a held visit invalidates this full cycle
assert(c.hop(0,37000,false,elapsed) && elapsed==12000);
}
'''.replace("@SOURCE@", source))
def test_held_occupancy_survives_receive_restart(self):
source = (Path(__file__).resolve().parents[1] / "tools/hil/profile_switch_channels.h").read_text()
self.assertIn("visitAge=now-channelSweep.visitStartedUs", source)
@@ -17,11 +39,13 @@ class ChannelTraceTests(unittest.TestCase):
def test_selected_sf_reaches_initial_hops_tx_and_cache_check(self):
source = (Path(__file__).resolve().parents[1] / "tools/hil/profile_switch_channels.h").read_text()
self.assertIn("p.sf=sf", source)
for expected in ("sweepParams(0,preamble,sf)", "sweepParams(1,preamble,sf)",
"setLoRaModulationParams(125,sf,5)", "channelSweep.sf=sf;",
"chip.spreadingFactor!=channelSweep.sf"):
for expected in ("sweepParams(0,preamble,sf,bwKhz)", "sweepParams(1,preamble,sf,bwKhz)",
"setLoRaModulationParams(bwKhz,sf,5)", "channelSweep.sf=sf;",
"channelSweep.bwKhz=bwKhz;", "p.bw=bwKhz",
"chip.bandwidthKhz!=expectedParams.bw",
"chip.spreadingFactor!=expectedParams.sf"):
self.assertIn(expected, source)
self.assertEqual(source.count("sweepParams(next,channelSweep.preamble,channelSweep.sf)"), 2)
self.assertEqual(source.count("sweepParams(next,channelSweep.preamble,channelSweep.sf,channelSweep.bwKhz)"), 3)
def test_passive_irq_decode_bounds_and_freeze(self):
source = (Path(__file__).resolve().parents[1] / "tools/hil/ProfileChannelTrace.h").read_text().replace("#pragma once", "")
@@ -52,6 +76,10 @@ int main() {
t.active=false;
uint8_t rf[5]={0x86,0x38,0xe0,0,0};t.observe(rf,5,in);
assert(t.rfWord==0x38e00000);
assert(t.rfWrites==1 && t.modulationWrites==0);
uint8_t modulation[5]={0x8b,6,4,1,0};t.observe(modulation,5,in);
assert(t.modulationWrites==1 && t.size==1);
assert(t.modulationWord==0x06040100);
uint8_t tx[4]={0x83,0,0,0};t.observe(tx,4,in);
assert(t.txCommandUs==clockUs && t.txWord==t.rfWord && t.size==1);
assert(out[0]==0x12 && clear[0]==2 && in[3]==0);
+79
View File
@@ -0,0 +1,79 @@
"""Fixed-sample HIL continuation never erases misses or resets the scanner."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT=Path(__file__).resolve().parents[1]
class ContinueTests(unittest.TestCase):
compile_run=compiler.BatchedModulationTests.compile_run
def test_completion_changes_only_expectation_counts_and_stop_policy(self):
source=(ROOT/'tools/hil/profile_switch_channels.h').read_text()
body=method(source,'void completeChannelExpectation(')
self.compile_run(r'''
#include <cassert>
bool channelContinueOnMiss=false;
struct {bool active=true,expecting=true;unsigned received=0,missed=0;} channelSweep;
@METHOD@
int main(){
completeChannelExpectation(false);
assert(!channelSweep.active&&!channelSweep.expecting&&channelSweep.missed==1);
channelSweep={};channelContinueOnMiss=true;
for(unsigned i=0;i<400;++i){
channelSweep.expecting=true;completeChannelExpectation(i%4==0);
assert(channelSweep.active&&!channelSweep.expecting);
}
assert(channelSweep.received==100&&channelSweep.missed==300);
}
'''.replace('@METHOD@',body))
service=method(source,'void serviceChannelSweep(')
self.assertIn('completeChannelExpectation(accepted)',service)
self.assertIn('completeChannelExpectation(false)',service)
self.assertIn('++channelSweep.failures;channelSweep.active=false',service)
def test_expectations_preserve_scanner_phase_and_use_mode_timeout(self):
source=(ROOT/'tools/hil/profile_switch_channels.h').read_text()
self.compile_run(r'''
#include <cassert>
#include <cstdint>
bool channelPair=false;
uint32_t nowMs=0xfffffff0u;
uint32_t millis(){return nowMs;}
uint32_t micros(){return 900;}
struct {
bool active=true,expecting=false;
unsigned channels=2,current=1,expectedChannel=0;
uint32_t sequence=0,deadline=0,visitStartedUs=100,received=7,missed=3;
} channelSweep;
struct {
unsigned calls=0;
void arm(unsigned,unsigned current,unsigned,uint32_t age){
++calls;assert(current==1&&age==800);
}
} channelTrace;
struct {unsigned errors=0;void println(const char*){++errors;}} Serial;
int lastListenResult=0;
unsigned recorded=0;
void recordBenchResult(int&,unsigned sequence,const char*,...){recorded=sequence;}
@METHOD@
int main(){
for(unsigned pair=0;pair<2;++pair){
channelPair=pair;channelSweep.expecting=false;
expectChannelPacket(0,42+pair);
assert(channelSweep.expecting&&channelSweep.sequence==42+pair);
assert(channelSweep.deadline==uint32_t(nowMs+(pair?1000:10000)));
assert(recorded==42+pair&&channelTrace.calls==pair+1);
assert(channelSweep.current==1&&channelSweep.visitStartedUs==100);
assert(channelSweep.received==7&&channelSweep.missed==3);
expectChannelPacket(1,99); // overlapping requests cannot restart a deadline
assert(channelSweep.sequence==42+pair&&recorded==42+pair);
}
channelSweep.expecting=false;
expectChannelPacket(2,99);
assert(!channelSweep.expecting&&channelTrace.calls==2&&Serial.errors==3);
}
'''.replace('@METHOD@',method(source,'void expectChannelPacket(')))
if __name__=='__main__': unittest.main()
+79
View File
@@ -0,0 +1,79 @@
"""Sub-float HIL detour changes the actual RF command, not the MHz float."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT = Path(__file__).resolve().parents[1]
class FrequencyOffsetTests(unittest.TestCase):
compile_run = compiler.BatchedModulationTests.compile_run
def test_integer_word_offset_and_scoped_restore(self):
source = (ROOT / 'tools/hil/ProfileFrequencyOffset.h').read_text().replace('#pragma once', '')
self.compile_run(r'''
#include <cassert>
#include <cstring>
@SOURCE@
void earlyReturn() { HilFrequencyOffsetScope offset(10); }
int main() {
uint8_t original[5]={0x86,0x38,0xd8,0xff,0xfc}, shifted[5]={};
uint8_t saved[5];memcpy(saved,original,5);
assert(hilFrequencyCommand(original,5,shifted)==original);
{
HilFrequencyOffsetScope offset(10);
assert(hilFrequencyCommand(original,5,shifted)==shifted);
const uint8_t expected[5]={0x86,0x38,0xd9,0,6};
assert(memcmp(shifted,expected,5)==0 && memcmp(original,saved,5)==0);
assert(hilFrequencyCommand(original,4,shifted)==original);
assert(hilFrequencyCommand(nullptr,5,shifted)==nullptr);
uint8_t other[5]={0x8b,10,4,2,0};
assert(hilFrequencyCommand(other,5,shifted)==other);
uint8_t overflow[5]={0x86,0xff,0xff,0xff,0xfc};
assert(hilFrequencyCommand(overflow,5,shifted)==overflow);
{ HilFrequencyOffsetScope zero(0);assert(hilFrequencyCommand(original,5,shifted)==original); }
assert(hilFrequencyOffsetSteps==10);
}
assert(hilFrequencyOffsetSteps==0);
earlyReturn();assert(hilFrequencyOffsetSteps==0);
assert(hilFrequencyCommand(original,5,shifted)==original);
const float mhz=909.5f;
assert(float(mhz+0.00001f)==mhz); // float API cannot encode requested 10 Hz
assert(10.0*32000000.0/33554432.0==9.5367431640625);
{ HilFrequencyOffsetScope offset(105);
hilFrequencyCommand(original,5,shifted);
const uint8_t expected[5]={0x86,0x38,0xd9,0,101};
assert(memcmp(shifted,expected,5)==0 && memcmp(original,saved,5)==0);
}
assert(105.0*32000000.0/33554432.0==100.13580322265625);
}
'''.replace('@SOURCE@', source))
def test_wire_and_observer_use_shifted_bytes(self):
source = (ROOT / 'tools/hil/profile_switch.cpp').read_text()
transfer = method(source, 'void spiTransfer(')
self.assertIn('hilFrequencyCommand(out,len,shifted)', transfer)
self.assertIn('ESP32BufferedRadioHal::spiTransfer(const_cast<uint8_t*>(wire)', transfer)
self.assertIn('ArduinoHal::spiTransfer(const_cast<uint8_t*>(wire)', transfer)
self.assertIn('channelTrace.observe(wire,len,in)', transfer)
apply = method(source, 'bool applyParams(')
self.assertIn('HilFrequencyOffsetScope offset(firstPassDetour && hopPasses==2 && cr==6 ? firstPassOffsetSteps : 0)', apply)
def test_capability_reply_avoids_exact_usb_packet_boundary(self):
import json
import re
source = (ROOT / 'tools/hil/profile_switch.cpp').read_text()
branch = source.split('!strcmp(line,"detourinfo")', 1)[1].split('} else', 1)[0]
literal = re.search(r'Serial\.printf\(("(?:\\.|[^"\\])*")', branch).group(1)
template = json.loads(literal)
for hz, steps in ((10,10),(100,105)):
reply = template % (hz/1000,steps,steps*32000000/33554432)
self.assertNotEqual(len(reply.encode('ascii'))%64, 0)
self.assertTrue(reply.endswith('\n'))
self.assertEqual(json.loads(reply)['rf_offset_steps'], steps)
self.assertEqual(json.loads(reply)['offset_hz'],steps*32000000/33554432)
if __name__ == '__main__':
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
"""Check the actual HIL frequency override's scope, order and error propagation."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT=Path(__file__).resolve().parents[1]
class FrequencyRepeatTests(unittest.TestCase):
compile_run=compiler.BatchedModulationTests.compile_run
def test_only_owned_rx_hops_repeat_and_failures_short_circuit(self):
source=method((ROOT/'tools/hil/profile_switch_experiments.h').read_text(),
'int16_t setFrequency(')
harness=r'''
#include <cassert>
#include <cstdint>
#include <vector>
#define RADIOLIB_ERR_NONE 0
struct SX1262 {
std::vector<bool> calibrationSkipped; int failAt=0;
virtual int16_t setFrequency(float f) { return setFrequency(f,false); }
int16_t setFrequency(float f,bool skip) {
assert(f==909.5f); calibrationSkipped.push_back(skip);
return int(calibrationSkipped.size())==failAt ? -3:0;
}
};
struct Radio : SX1262 {
bool repeatFrequency=false, inHop=false, resumeRx=false;
@METHOD@
};
int main() {
for(bool enabled : {false,true}) for(bool hop : {false,true}) for(bool rx : {false,true}) {
Radio r;r.repeatFrequency=enabled;r.inHop=hop;r.resumeRx=rx;
assert(r.setFrequency(909.5f)==0);
assert(r.calibrationSkipped.size()==(enabled&&hop&&rx?2u:1u));
assert(!r.calibrationSkipped.front());
if(r.calibrationSkipped.size()==2) assert(r.calibrationSkipped.back());
}
for(int fail : {1,2}) {
Radio r;r.repeatFrequency=r.inHop=r.resumeRx=true;r.failAt=fail;
assert(r.setFrequency(909.5f)==-3);
assert(int(r.calibrationSkipped.size())==fail);
}
}
'''
self.compile_run(harness.replace('@METHOD@',source))
if __name__=='__main__':
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""HIL V4 front-end selection and restoration around every direct TX path."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT = Path(__file__).resolve().parents[1]
class HeltecHilTests(unittest.TestCase):
compile_run = compiler.BatchedModulationTests.compile_run
def test_gc_bypass_kct_tx_and_restore_even_on_error(self):
source = (ROOT / "tools/hil/ProfileHeltecV4.h").read_text().replace(
'#include "LoRaFEMControl.h"', '').replace('#pragma once', '')
tx = method((ROOT / "tools/hil/profile_switch.cpp").read_text(), 'int16_t benchTransmit(')
harness = r'''
#include <cassert>
#include <cstdint>
#include <cstddef>
#include <cstring>
#include <string>
enum { GC1109_PA, KCT8103L_PA };
struct LoRaFEMControl {
int type=GC1109_PA; std::string calls;
void init() { calls+='I'; }
void setRxModeEnable() { calls+='R'; }
void setTxModeEnable() { calls+='T'; }
int getFEMType() const { return type; }
};
@SOURCE@
ProfileHeltecV4 v4FrontEnd;
struct {
int16_t rc=0;
int16_t transmit(uint8_t*,size_t n) {
assert(n==16);
assert(v4FrontEnd.fem.calls.back()==(v4FrontEnd.fem.type==GC1109_PA?'R':'T'));
v4FrontEnd.fem.calls+='X'; return rc;
}
} chip;
#define HIL_HELTEC_V4
@TX@
int main() {
uint8_t data[16]={};
for(int fem : {GC1109_PA,KCT8103L_PA}) {
v4FrontEnd.fem.type=fem; v4FrontEnd.fem.calls.clear();
v4FrontEnd.begin(); assert(v4FrontEnd.fem.calls=="IR");
assert(!strcmp(v4FrontEnd.type(),fem==GC1109_PA?"GC1109":"KCT8103L"));
assert(!strcmp(v4FrontEnd.txMode(),fem==GC1109_PA?"bypass":"amplified"));
for(int error : {0,-3}) {
chip.rc=error; v4FrontEnd.fem.calls.clear();
assert(benchTransmit(data,16)==error);
assert(v4FrontEnd.fem.calls==(fem==GC1109_PA?"RXR":"TXR"));
}
}
}
'''
self.compile_run(harness.replace('@SOURCE@',source).replace('@TX@',tx))
def test_all_direct_probe_transmits_use_frontend_adapter(self):
for name in ('profile_switch_channels.h','profile_switch_packets.h','profile_preamble_diagnostic.h'):
source=(ROOT/'tools/hil'/name).read_text()
self.assertNotIn('chip.transmit(',source)
self.assertIn('benchTransmit(',source)
source=(ROOT/'tools/hil/profile_switch.cpp').read_text()
setup=method(source,'void setup()')
self.assertLess(setup.index('v4FrontEnd.begin()'),setup.index('chip.std_init('))
if __name__ == '__main__':
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
"""Mixed HIL channels preserve fractional BW and normal uniform defaults."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT=Path(__file__).resolve().parents[1]
class MixedTests(unittest.TestCase):
compile_run=compiler.BatchedModulationTests.compile_run
def test_profile_mapping_and_equal_symbols(self):
source=(ROOT/'tools/hil/profile_switch_channels.h').read_text()
header=(ROOT/'tools/hil/ProfileMixedChannels.h').read_text().replace('#pragma once','')
pair_header=(ROOT/'tools/hil/ProfilePairPlan.h').read_text().replace('#pragma once','')
self.compile_run(r'''
#include <cassert>
@HEADER@
@PAIR_HEADER@
namespace mesh { struct RadioProfileParams { float freq,bw; unsigned sf,cr,preamble; }; }
bool channelMixed=false,channelPair=false;
float sweepFrequency(unsigned channel){return 909.5f+channel;}
@METHOD@
int main(){
for(unsigned ch=0;ch<4;++ch){
auto uniform=sweepParams(ch,32,6,125);
assert(uniform.sf==6&&uniform.bw==125);
channelMixed=true;auto p=sweepParams(ch,32,10,125);channelMixed=false;
assert(p.sf==9+ch&&p.bw==62.5f*(1u<<ch)&&p.cr==5&&p.preamble==32);
assert((1u<<p.sf)*1000/p.bw==8192);
}
channelPair=true;
auto slow=sweepParams(0,32,10,125), fast=sweepParams(1,32,10,125);
assert(slow.sf==7&&slow.bw==62.5f&&slow.freq==909.5f);
assert(fast.sf==8&&fast.bw==500&&fast.freq==910.5f);
assert(slow.cr==5&&fast.cr==5&&slow.preamble==32&&fast.preamble==32);
}
'''.replace('@HEADER@',header).replace('@PAIR_HEADER@',pair_header)
.replace('@METHOD@',method(source,'mesh::RadioProfileParams sweepParams(')))
if __name__=='__main__': unittest.main()
+70
View File
@@ -0,0 +1,70 @@
"""Reference transmitter: cache replay never retransmits and failures stay failures."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT=Path(__file__).resolve().parents[1]
class NrfReferenceTxTests(unittest.TestCase):
compile_run=compiler.BatchedModulationTests.compile_run
def test_duplicate_result_replay_and_failed_setup_do_not_transmit(self):
source=method((ROOT/'tools/hil/profile_switch_nrf52_tx.cpp').read_text(),'void referenceTransmit(')
harness=r'''
#include <cassert>
#include <cstdint>
#include <cstring>
#include <cstddef>
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_CHIP_NOT_FOUND -2
unsigned ticks=0;uint32_t micros(){ return ++ticks; }
int SPI;
unsigned channelStepKhz=250;
float expectedFrequency=910.25f;
float expectedBw=125;
unsigned expectedSf=10;
struct Result { unsigned sequence=0; } lastTxResult;
unsigned replayed=0;
void emitBenchResult(const Result&) { ++replayed; }
template<class... T> void recordBenchResult(Result& r,unsigned seq,const char*,T...) { r.sequence=seq; }
struct {
bool init=true; unsigned sent=0; int txResult=0;
bool std_init(int*) { return init; }
int setFrequency(float f) { assert(f==expectedFrequency);return 0; }
int setLoRaModulationParams(float bw,unsigned sf,unsigned cr) { assert(bw==expectedBw&&sf==expectedSf&&cr==5);return 0; }
int setPreambleLength(unsigned p) { assert(p==32);return 0; }
int setOutputPower(int p) { assert(p==-9);return 0; }
int transmit(uint8_t* p,unsigned len) { assert(len==16&&!memcmp(p,"CHS1",4)&&p[8]==3);++sent;return txResult; }
} chip;
@METHOD@
int main() {
referenceTransmit(3,42,16,32,10,125);assert(chip.sent==1&&lastTxResult.sequence==42);
referenceTransmit(3,42,16,32,10,125);assert(chip.sent==1&&replayed==1);
chip.init=false;referenceTransmit(3,43,16,32,10,125);assert(chip.sent==1&&lastTxResult.sequence==43);
chip.init=true;chip.txResult=-3;referenceTransmit(3,44,16,32,10,125);assert(chip.sent==2);
referenceTransmit(3,44,16,32,10,125);assert(chip.sent==2&&replayed==2);
channelStepKhz=1000;expectedFrequency=912.5f;chip.txResult=0;
referenceTransmit(3,45,16,32,10,125);assert(chip.sent==3);
channelStepKhz=250;referenceTransmit(3,45,16,32,10,125);assert(chip.sent==3&&replayed==3);
expectedFrequency=910.25f;
for(unsigned i=0;i<4;++i) {
expectedSf=9+i;expectedBw=62.5f*(1u<<i);
referenceTransmit(3,46+i,16,32,expectedSf,expectedBw);assert(chip.sent==4+i);
}
}
'''
self.compile_run(harness.replace('@METHOD@',source))
def test_scope_and_host_command_bounds(self):
source=(ROOT/'tools/hil/profile_switch_nrf52_tx.cpp').read_text()
self.assertNotIn('transmit',method(source,'void setup()'))
loop=method(source,'void loop()')
for guard in ('channel<4','len==16','preamble==32','sf>=5','sf<=10','bw==125','bw==250'):
self.assertIn(guard,loop)
self.assertIn('static bool overflow=false',loop)
if __name__=='__main__':
unittest.main()
+94
View File
@@ -0,0 +1,94 @@
"""Full HIL retune repeat is real, bounded, and never overrides RX guards."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT=Path(__file__).resolve().parents[1]
class RetuneRepeatTests(unittest.TestCase):
compile_run=compiler.BatchedModulationTests.compile_run
def test_second_pass_refresh_and_fail_closed(self):
body=method((ROOT/'tools/hil/profile_switch.cpp').read_text(),'mesh::RadioParamApplyResult hop(')
self.compile_run(r'''
#include <cassert>
#include <cstdint>
namespace mesh {
enum class RadioParamApplyResult {APPLIED,BUSY,FAILED};
struct RadioProfileParams { float freq=910.5f;uint8_t cr=5; };
}
using Result=mesh::RadioParamApplyResult;
struct { uint32_t rfWord=0,modulationWord=0; } channelTrace;
uint32_t micros(){static uint32_t t=0;return ++t;}
struct { unsigned begins=0,ends=0;bool busyLine=false;bool isChipBusy(){return busyLine;}void beginHop(bool rx){assert(rx);++begins;}void endHop(){++ends;} } chip;
struct Bench {
unsigned hopPasses=1,calls=0;
uint32_t firstPassOffsetSteps=10;
bool firstPassDetour=false,corruptFirst=false,corruptFinal=false;
uint32_t detourChecks=0,detourErrors=0,detourFirstRf=0,detourFinalRf=0,detourFirstMod=0,detourFinalMod=0;
struct Profiles {
mesh::RadioProfileParams primary;
struct Secondary { mesh::RadioProfileParams params; } secondary;
const mesh::RadioProfileParams& params(uint8_t target){return target?secondary.params:primary;}
void setPrimary(const mesh::RadioProfileParams& p,bool){primary=p;}
void setSecondary(const Secondary& p,bool){secondary=p;}
} _profiles;
uint32_t lastFirstPassUs=0,lastSecondPassUs=0,secondPassBlocked=0;
bool _profile_refresh_required=false;
Result first=Result::APPLIED,second=Result::APPLIED;
bool isInRecvMode(){return true;}
Result tuneProfile(uint8_t target){
assert(target<=1);++calls;if(calls==2)assert(_profile_refresh_required);
const auto& p=_profiles.params(target);
assert(p.freq==910.5f);
assert(p.cr==(firstPassDetour&&calls==1?6:5));
Result r=calls==1?first:second;
if(r==Result::APPLIED){
channelTrace.rfWord=uint32_t(double(p.freq)*1048576.0)+(firstPassDetour&&calls==1?firstPassOffsetSteps:0);
channelTrace.modulationWord=p.cr==6?0x0a040200:0x0a040100;
if((calls==1&&corruptFirst)||(calls==2&&corruptFinal))channelTrace.modulationWord^=0x100;
}
return r;
}
@METHOD@
};
int main(){
Bench one;assert(one.hop(1)==Result::APPLIED&&one.calls==1&&one.lastSecondPassUs==0);
Bench two;two.hopPasses=2;assert(two.hop(1)==Result::APPLIED&&two.calls==2&&two.lastSecondPassUs>0);
Bench busy;busy.hopPasses=2;busy.first=Result::BUSY;assert(busy.hop(1)==Result::BUSY&&busy.calls==1);
Bench failed;failed.hopPasses=2;failed.first=Result::FAILED;assert(failed.hop(1)==Result::FAILED&&failed.calls==1);
Bench mid;mid.hopPasses=2;mid.second=Result::BUSY;assert(mid.hop(1)==Result::FAILED&&mid.calls==2&&mid.secondPassBlocked==1);
Bench end;end.hopPasses=2;end.second=Result::FAILED;assert(end.hop(1)==Result::FAILED&&end.calls==2);
Bench stalled;stalled.hopPasses=2;chip.busyLine=true;assert(stalled.hop(1)==Result::FAILED&&stalled.calls==1);
chip.busyLine=false;
for(unsigned target=0;target<2;++target){
Bench detour;detour.hopPasses=2;detour.firstPassDetour=true;
assert(detour.hop(target)==Result::APPLIED&&detour.calls==2&&detour.detourChecks==1&&detour.detourErrors==0);
assert(detour._profiles.params(target).freq==910.5f&&detour._profiles.params(target).cr==5);
Bench hundred;hundred.hopPasses=2;hundred.firstPassDetour=true;hundred.firstPassOffsetSteps=105;
assert(hundred.hop(target)==Result::APPLIED&&hundred.detourChecks==1&&hundred.detourErrors==0);
assert(hundred.detourFirstRf-hundred.detourFinalRf==105);
Bench deferred;deferred.hopPasses=2;deferred.firstPassDetour=true;deferred.first=Result::BUSY;
assert(deferred.hop(target)==Result::BUSY&&deferred.calls==1);
assert(deferred._profiles.params(target).freq==910.5f&&deferred._profiles.params(target).cr==5);
Bench between;between.hopPasses=2;between.firstPassDetour=true;between.second=Result::BUSY;
assert(between.hop(target)==Result::FAILED&&between.secondPassBlocked==1);
assert(between._profiles.params(target).freq==910.5f&&between._profiles.params(target).cr==5);
for(unsigned pass=0;pass<2;++pass){
Bench bad;bad.hopPasses=2;bad.firstPassDetour=true;bad.corruptFirst=pass==0;bad.corruptFinal=pass==1;
assert(bad.hop(target)==Result::FAILED&&bad.detourErrors==1);
}
}
assert(chip.begins==chip.ends);
}
'''.replace('@METHOD@',body))
def test_complete_settings_are_not_skipped_by_modulation_cache(self):
source=(ROOT/'tools/hil/profile_switch.cpp').read_text()
self.assertIn('forceModulationWrite || hopPasses==2',method(source,'void beginProfileRetune('))
self.assertIn('chip.hilInvalidateModulation()',method(source,'void beginProfileRetune('))
if __name__=='__main__': unittest.main()
+40
View File
@@ -0,0 +1,40 @@
"""HIL rollback preserves production ownership and ordinary standby semantics."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT=Path(__file__).resolve().parents[1]
class RollbackTests(unittest.TestCase):
compile_run=compiler.BatchedModulationTests.compile_run
def test_warm_rollback_only_suppresses_request(self):
source=(ROOT/'tools/hil/profile_switch.cpp').read_text()
body=method(source,'void setProfileStandbyWarm(')
self.compile_run(r'''
#include <cassert>
struct CustomSX1262Wrapper { bool requested=false;virtual void setProfileStandbyWarm(bool v){requested=v;} };
struct Bench:CustomSX1262Wrapper { bool warmStandby=true; @METHOD@ };
int main(){for(bool policy:{false,true})for(bool request:{false,true}){
Bench b;b.warmStandby=policy;b.setProfileStandbyWarm(request);assert(b.requested==(policy&&request));
}}
'''.replace('#include <cassert>','#include <cassert>\n#include <initializer_list>').replace('@METHOD@',body))
def test_rollback_never_bypasses_hop_ownership_or_busy_checks(self):
source=(ROOT/'tools/hil/profile_switch_channels.h').read_text()
start=method(source,'void startChannelSweep(')
for control in ('(channelRollback & 9)==0','(channelRollback & 4)==0',
'(channelRollback & 2)==0','(channelRollback & 8)==0'):
self.assertIn(control,start)
service=method(source,'void serviceChannelSweep(')
self.assertIn('driver.hop(slot)',service)
self.assertIn('rc==mesh::RadioParamApplyResult::BUSY',service)
self.assertIn('!waitBusy()',service)
self.assertIn('rfWordAtRead=channelTrace.rfWord',service)
self.assertIn('payloadValid && bytes[8]==channelSweep.current',service)
if __name__=='__main__':
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
"""Stationary baseline payload integrity and separation from the hop scheduler."""
from pathlib import Path
import unittest
from test_radio_receive_contract import method
import test_sx1262_batched_modulation as compiler
ROOT = Path(__file__).resolve().parents[1]
class StationaryBaselineTests(unittest.TestCase):
compile_run = compiler.BatchedModulationTests.compile_run
def test_complete_payload_identity(self):
source = (ROOT / 'tools/hil/profile_stationary_baseline.h').read_text()
self.compile_run(r'''
#include <cassert>
#include <cstdint>
#include <cstring>
@METHOD@
int main() {
uint8_t bytes[16]; uint32_t seq=123;
memcpy(bytes,"CHS1",4);memcpy(bytes+4,&seq,4);bytes[8]=3;
for(unsigned i=9;i<16;++i) bytes[i]=uint8_t(i^seq);
assert(stationaryPayloadMatches(bytes,16,123,3));
assert(!stationaryPayloadMatches(bytes,15,123,3));
assert(!stationaryPayloadMatches(bytes,16,124,3));
assert(!stationaryPayloadMatches(bytes,16,123,2));
for(unsigned i=0;i<16;++i) {
bytes[i]^=1;assert(!stationaryPayloadMatches(bytes,16,123,3));bytes[i]^=1;
}
}
'''.replace('@METHOD@', method(source, 'bool stationaryPayloadMatches(')))
def test_no_retuning_or_rf_transmission_after_setup(self):
source = (ROOT / 'tools/hil/profile_stationary_baseline.h').read_text()
self.assertNotIn('transmit(', source)
for signature in ('void expectStationaryPacket(', 'void serviceStationaryBaseline('):
body = method(source, signature)
for forbidden in ('setFrequency(', 'std_init(', 'prepareDiagnosticRadio(', 'driver.hop('):
self.assertNotIn(forbidden, body)
self.assertIn('stationaryBaseline.rfStart=channelTrace.rfWrites', source)
self.assertIn('millis()+600000', source)
if __name__ == '__main__':
unittest.main()
+13 -1
View File
@@ -41,6 +41,8 @@ struct SX1262 {
virtual int16_t standby() { return standby(1,true); }
int16_t standby(int mode,bool wake) { assert(mode==0 || mode==1);return call(wake?'S':'s'); }
virtual int16_t startReceive() { return call('X'); }
virtual int16_t setFrequency(float) { return call('F'); }
int16_t setFrequency(float,bool) { return call('F'); }
virtual int16_t setPreambleLength(size_t p) { preambleLengthLoRa=p;return call('P'); }
int getIrqMapped(int x) { return x; }
int setDioIrqParams(int flags,int mask) { assert(flags==9 && mask==2);return call('I'); }
@@ -104,10 +106,12 @@ int main() {
def test_bulk_hal_preserves_full_duplex_buffer(self):
source = (ROOT / "tools/hil/profile_switch.cpp").read_text()
operation = method(source, "void spiTransfer(").replace(" override", "")
offset = (ROOT / "tools/hil/ProfileFrequencyOffset.h").read_text().replace("#pragma once", "")
harness = r'''
#include <cassert>
#include <cstdint>
#include <cstddef>
@OFFSET@
struct Spi {
unsigned bulk=0;
void transferBytes(uint8_t* out,uint8_t* in,size_t n) {
@@ -144,9 +148,17 @@ int main() {
assert(h.fallback==unsigned(!bulk) && h.device.bulk==unsigned(bulk));
}
assert(channelTrace.calls==520);
for(int bulk=0;bulk<2;++bulk) {
Hal h;h.bulkTransfer=bulk;
uint8_t out[5]={0x86,0x38,0xd8,0,0},in[5]={};
{ HilFrequencyOffsetScope offset(10);h.spiTransfer(out,5,in); }
assert(out[4]==0 && in[4]==(10^0xa5)); // actual transmitted and observed byte
h.spiTransfer(out,5,in);
assert(in[4]==0xa5); // second/corrected pass has the original RF word
}
}
'''
self.compile_run(harness.replace("@OP@", operation))
self.compile_run(harness.replace("@OP@", operation).replace("@OFFSET@", offset))
if __name__ == "__main__":
+2 -1
View File
@@ -32,7 +32,8 @@ int main() {
assert(Serial.output.find("unavailable")!=std::string::npos);
Serial.output.clear();replayBenchResult("listening",42);
assert(Serial.output.find("unavailable")!=std::string::npos);
Serial.output.clear();recordBenchResult(lastTxResult,43,"%03000d",1);
Serial.output.clear();
recordBenchResult(lastTxResult,43,"%0*d",int(sizeof(lastTxResult.text)),1);
assert(lastTxResult.sequence==0 && Serial.output.find("overflow")!=std::string::npos);
Serial.output.clear();replayBenchResult("sent",42);
assert(Serial.output.find("unavailable")!=std::string::npos);
+101 -1
View File
@@ -31,6 +31,7 @@ class BatchedModulationTests(unittest.TestCase):
#include <cstdint>
#include <cstddef>
#include <initializer_list>
@STATE@
using std::isfinite;
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_INVALID_SPREADING_FACTOR -1
@@ -49,6 +50,7 @@ using std::isfinite;
#define RADIOLIB_SX126X_LORA_BW_250_0 0x05
#define RADIOLIB_SX126X_LORA_BW_500_0 0x06
struct Radio {
SX1262ProfileSwitchState _profileSwitch{true};
uint8_t spreadingFactor=9, bandwidth=4, codingRate=3, ldrOptimize=0;
float bandwidthKhz=125;
bool ldroAuto=true;
@@ -72,6 +74,12 @@ bool sameCache(const Radio& a,const Radio& b) {
&& a.bandwidthKhz==b.bandwidthKhz && a.codingRate==b.codingRate
&& a.ldrOptimize==b.ldrOptimize && a.ldroAuto==b.ldroAuto;
}
void beginHop(Radio& r) {
r._profileSwitch.end(true);
r._profileSwitch.rxResult(0,true);
r._profileSwitch.begin(true,true);
r._profileSwitch.standbyResult(0);
}
int main() {
const float bandwidths[]={7.8f,10.4f,15.6f,20.8f,31.25f,41.7f,62.5f,125,250,500};
const uint8_t codes[]={0,8,1,9,2,10,3,4,5,6};
@@ -85,7 +93,55 @@ int main() {
const uint8_t ldro=mode==0 ? ((1U<<sf)/bandwidths[i]>=16.0f) : mode==2;
assert(r.ldrOptimize==ldro && r.payload[0]==sf && r.payload[1]==codes[i]
&& r.payload[2]==cr-4 && r.payload[3]==ldro);
// Only a subsequent owned RX hop may reuse that acknowledged tuple.
beginHop(r);
assert(r.setLoRaModulationParams(bandwidths[i],sf,cr)==0);
assert(r.queries==2 && r.writes==1);
assert(r._profileSwitch.matchesModulation(sf,codes[i],cr-4,ldro));
}
{
Radio r;beginHop(r);
// Initially matching software fields are not proof of programmed hardware.
assert(r.setLoRaModulationParams(125,9,7)==0 && r.writes==1);
beginHop(r);assert(r.setLoRaModulationParams(125,9,7)==0 && r.writes==1);
// Change each effective field independently, then verify repeat is skipped.
assert(r.setLoRaModulationParams(250,9,7)==0 && r.writes==2);
assert(r.setLoRaModulationParams(250,10,7)==0 && r.writes==3);
assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==4);
assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==4);
r.ldroAuto=false;r.ldrOptimize=1;
assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==5);
assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==5);
// autoLDRO() itself does not write: the next hop must apply its new value.
r.ldroAuto=true;
assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==6);
r.ldroAuto=false;
assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==6);
r.ldroAuto=true;
assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==7 && r.ldrOptimize==1);
assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==7);
// A failed changed tuple revokes reuse before physical rollback.
r.result=-707;assert(r.setLoRaModulationParams(500,8,5)==-707);
assert(!r._profileSwitch.modulationValid && r._profileSwitch.failed());
r.result=0;beginHop(r);
assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==9);
assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==9);
r.packetType=255;assert(r.setLoRaModulationParams(125,11,5)==-4);
assert(!r._profileSwitch.modulationValid);
r.packetType=1;assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==10);
}
for(int gate=0;gate<6;++gate) {
Radio r;beginHop(r);assert(r.setLoRaModulationParams(125,9,7)==0);
switch(gate) {
case 0:r._profileSwitch.end(true);break; // no owned window
case 1:r._profileSwitch.begin(true,true);break; // no standby yet
case 2:r._profileSwitch.begin(false,true);break; // not continuous RX
case 3:r._profileSwitch.begin(true,false);break; // cold oscillator
case 4:r._profileSwitch.enabled=false;r._profileSwitch.begin(true,true);break;
case 5:r._profileSwitch.invalidate();beginHop(r);break; // state lost
}
assert(r.setLoRaModulationParams(125,9,7)==0 && r.writes==2);
}
// Invalid values cannot issue SPI, mutate caches, or enter float->int UB.
for(float bw: {NAN,INFINITY,-INFINITY,-1.0f,0.0f,50.0f,62.6f,1000.0f}) {
Radio r,old=r;assert(r.setLoRaModulationParams(bw,7,5)==-3);
@@ -114,7 +170,51 @@ int main() {
}
}
'''
self.compile_run(harness.replace("@PRODUCTION@", production))
state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text().replace("#pragma once", "")
self.compile_run(harness.replace("@PRODUCTION@", production).replace("@STATE@", state))
def test_ordinary_setters_invalidate_acknowledged_tuple_even_on_failure(self):
source = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text()
signatures = ("int16_t setBandwidth(", "int16_t setSpreadingFactor(",
"int16_t setCodingRate(", "int16_t forceLDRO(")
methods = "\n".join(method(source, signature) for signature in signatures)
state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text().replace("#pragma once", "")
harness = r'''
#include <cassert>
#include <cstdint>
@STATE@
struct SX1262 {
int16_t result=0;unsigned calls=0;bool interleave=false;
virtual ~SX1262()=default;
virtual int16_t setBandwidth(float bw) { assert(bw==125);++calls;return result; }
virtual int16_t setSpreadingFactor(uint8_t sf) { assert(sf==9);++calls;return result; }
int16_t setCodingRate(uint8_t cr,bool li) { assert(cr==7);interleave=li;++calls;return result; }
int16_t forceLDRO(bool enabled) { assert(enabled);++calls;return result; }
};
struct Radio: SX1262 {
SX1262ProfileSwitchState _profileSwitch{true};
@METHODS@
};
int main() {
for(int setter=0;setter<5;++setter) for(int failure=0;failure<2;++failure) {
Radio r;r.result=failure ? -707:0;
r._profileSwitch.rxResult(0,true);r._profileSwitch.begin(true,true);
r._profileSwitch.standbyResult(0);r._profileSwitch.modulationResult(0,9,4,3,0);
assert(r._profileSwitch.matchesModulation(9,4,3,0));
SX1262& base=r;int16_t rc=0;
switch(setter) {
case 0:rc=base.setBandwidth(125);break;
case 1:rc=base.setSpreadingFactor(9);break;
case 2:rc=r.setCodingRate(7);assert(!r.interleave);break;
case 3:rc=r.setCodingRate(7,true);assert(r.interleave);break;
case 4:rc=r.forceLDRO(true);break;
}
assert(rc==r.result && r.calls==1 && !r._profileSwitch.modulationValid);
assert(!r._profileSwitch.matchesModulation(9,4,3,0));
}
}
'''
self.compile_run(harness.replace("@STATE@", state).replace("@METHODS@", methods))
def test_wrapper_batches_once_and_preserves_failure_short_circuit(self):
source = (ROOT / "src/helpers/radiolib/CustomSX1262Wrapper.h").read_text()
+7 -1
View File
@@ -89,13 +89,17 @@ void prime(Radio& r) {
const bool wake=r._tcxoWakePending;
r.trace.clear();assert(r.startReceive()==0);assert(r.trace==(wake ? "GUEVFQ":cold ? "GUFQ":"GSFQ"));
assert(!r._coldStandby);
assert(!r._profileSwitch.modulationValid); // full RX setup does not prime a tuple
assert(r._profileSwitch.rxValid);r.trace.clear();
}
void fast(Radio& r) {
r.beginProfileSwitch(true);assert(r.standby()==0);assert(r.trace=="s");r.trace.clear();
r._profileSwitch.modulationResult(0,8,4,1,0);
assert(r._profileSwitch.matchesModulation(8,4,1,0));
assert(r.setPreambleLength(120)==0 && r.trace.empty());
assert(r.startReceive()==0 && r.trace=="CPWR");
assert(r.stagedMode==0 && r._profileSwitch.rxValid);
assert(r._profileSwitch.modulationValid); // successful fast resume preserves it
r.endProfileSwitch(true);assert(!r._profileSwitch.open);r.trace.clear();
}
int main() {
@@ -108,6 +112,7 @@ int main() {
// Ordinary standby, mode staging (TX/CAD), sleep, reset and RXPS revoke RX.
for(int event=0;event<7;++event) {
Radio x;prime(x);
x._profileSwitch.modulationResult(0,8,4,1,0);
switch(event) {
case 0:x.standby();break;
case 1:x.standby(uint8_t(0));break;
@@ -117,7 +122,8 @@ int main() {
case 5:x.reset();break;
case 6:x.startReceiveDutyCycle(1000,1000);break;
}
assert(!x._profileSwitch.rxValid);x.trace.clear();x.beginProfileSwitch(true);
assert(!x._profileSwitch.rxValid && !x._profileSwitch.modulationValid);
x.trace.clear();x.beginProfileSwitch(true);
const bool cold=x._coldStandby;
const bool wake=x._tcxoWakePending;
assert(x.standby()==0 && x.trace==(wake ? "UEV":cold ? "U":"S"));x.endProfileSwitch(true);prime(x);fast(x);
+11 -2
View File
@@ -7,7 +7,7 @@ struct ChannelTraceEvent {
uint8_t channel, kind;
};
struct ChannelTrace {
enum : uint8_t { Arm=1, Irq=2, Clear=3, Hop=4, Hold=5, Packet=6, Timeout=7 };
enum : uint8_t { Arm=1, Irq=2, Clear=3, Hop=4, Hold=5, Packet=6, Timeout=7, Frequency=8 };
static constexpr unsigned Capacity=4096;
ChannelTraceEvent events[Capacity];
bool active=false, enabled=false;
@@ -15,6 +15,8 @@ struct ChannelTrace {
uint32_t origin=0, sequence=0, pollAt=0, pollCount=0, pollTotalUs=0, pollMaxUs=0;
uint16_t irq=0;
uint32_t rfWord=0, txWord=0, txCommandUs=0;
uint32_t rfWrites=0, modulationWrites=0;
uint32_t modulationWord=0;
uint8_t channel=0;
void add(uint8_t kind,uint32_t a=0,uint32_t b=0) {
if(!active) return;
@@ -29,8 +31,15 @@ struct ChannelTrace {
void observe(const uint8_t* out,size_t len,const uint8_t* in) {
if(!out || !in) return;
// Track actual command bytes even outside a capture, not just SW labels.
if(len==5 && out[0]==0x86)
if(len==5 && out[0]==0x86) {
++rfWrites;
rfWord=uint32_t(out[1])<<24 | uint32_t(out[2])<<16 | uint32_t(out[3])<<8 | out[4];
add(Frequency,rfWord);
}
if(len==5 && out[0]==0x8b) {
++modulationWrites;
modulationWord=uint32_t(out[1])<<24 | uint32_t(out[2])<<16 | uint32_t(out[3])<<8 | out[4];
}
if(len==4 && out[0]==0x83) { txCommandUs=micros();txWord=rfWord; }
if(!active) return;
// Pinned SX126x Module: opcode, status dummy, two big-endian IRQ bytes.
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <stdint.h>
// Lab scheduling only: RX is already active during post-BUSY settling.
struct ChannelVisitClock {
uint32_t rxStamp=0, listenAt=0;
void begin(uint32_t rxStarted,uint32_t settledAt,bool delayEnabled) {
rxStamp=rxStarted;listenAt=delayEnabled?settledAt:rxStarted;
}
uint32_t dwell(uint32_t now,uint32_t rxStarted) {
// Receiving/processing a packet restarts ordinary RX, not a channel hop.
if(rxStarted!=rxStamp) { rxStamp=rxStarted;listenAt=rxStarted; }
return uint32_t(now-listenAt);
}
};
struct ChannelCycleClock {
bool started=false, clean=false;
uint32_t origin=0;
bool hop(unsigned next,uint32_t now,bool held,uint32_t& elapsed) {
if(held) clean=false;
if(next!=0) return false;
const bool sample=started && clean;
elapsed=uint32_t(now-origin);
started=true;clean=true;origin=now;
return sample;
}
};
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
// HIL-only sub-float-resolution frequency detour. Never alters caller bytes.
static uint32_t hilFrequencyOffsetSteps=0;
struct HilFrequencyOffsetScope {
uint32_t previous;
explicit HilFrequencyOffsetScope(uint32_t steps):previous(hilFrequencyOffsetSteps) { hilFrequencyOffsetSteps=steps; }
~HilFrequencyOffsetScope() { hilFrequencyOffsetSteps=previous; }
HilFrequencyOffsetScope(const HilFrequencyOffsetScope&)=delete;
HilFrequencyOffsetScope& operator=(const HilFrequencyOffsetScope&)=delete;
};
inline const uint8_t* hilFrequencyCommand(const uint8_t* original,size_t len,uint8_t (&shifted)[5]) {
if(!original || len!=5 || original[0]!=0x86 || !hilFrequencyOffsetSteps) return original;
uint32_t word=uint32_t(original[1])<<24 | uint32_t(original[2])<<16 | uint32_t(original[3])<<8 | original[4];
if(word>UINT32_MAX-hilFrequencyOffsetSteps) return original; // per-hop verification fails closed
word+=hilFrequencyOffsetSteps;
shifted[0]=0x86;shifted[1]=word>>24;shifted[2]=word>>16;shifted[3]=word>>8;shifted[4]=word;
return shifted;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "LoRaFEMControl.h"
// HIL-only RF front-end adapter. The chip's requested TX power is not a
// calibrated antenna-port power, especially on a KCT8103L amplified path.
class ProfileHeltecV4 {
public:
LoRaFEMControl fem;
void begin() { fem.init(); fem.setRxModeEnable(); }
void beforeTransmit() {
if (fem.getFEMType() == GC1109_PA) {
// CSD=1, CPS=0; SX1262 DIO2 supplies CTX=1 during TX: PA bypass.
// GC1109 datasheet Rev0.9.2, table 4. Keep the low-power experiment
// from silently enabling the V4's external TX amplifier.
fem.setRxModeEnable();
} else {
fem.setTxModeEnable();
}
}
void afterTransmit() { fem.setRxModeEnable(); }
const char* type() const { return fem.getFEMType() == GC1109_PA ? "GC1109" : "KCT8103L"; }
const char* txMode() const { return fem.getFEMType() == GC1109_PA ? "bypass" : "amplified"; }
};
+8
View File
@@ -0,0 +1,8 @@
#pragma once
// Bounded HIL-only four-channel experiment. All symbols are exactly 8192 us.
struct MixedChannelModulation { unsigned sf; float bwKhz; };
static constexpr MixedChannelModulation mixedChannelProfiles[4] = {
{9,62.5f}, {10,125.0f}, {11,250.0f}, {12,500.0f}
};
static constexpr const char* mixedChannelInfo =
"{\"mixed_profiles\":1,\"symbol_us\":8192,\"profiles\":[{\"sf\":9,\"bw_khz\":62.5},{\"sf\":10,\"bw_khz\":125},{\"sf\":11,\"bw_khz\":250},{\"sf\":12,\"bw_khz\":500}]}";
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <stdint.h>
// HIL-only SF7/62.5 + SF8/500 plan. Explicit 32 on both transmitters and RX;
// no inherited 88-symbol override. Same asymmetric visits as production with
// a nominal 600 us/hop and bounded loop allowances (not added delays).
struct PairProfile { unsigned sf; float bw; };
static constexpr PairProfile pairProfiles[2]={{7,62.5f},{8,500.0f}};
static constexpr bool pairPlanSupported(unsigned loopUs,unsigned slowExtraUs) {
return (loopUs==0 && (slowExtraUs==0 || slowExtraUs==2048))
|| (loopUs==4000 && slowExtraUs==0)
|| (loopUs==300 && slowExtraUs==1024);
}
static constexpr uint32_t pairListenUs(unsigned channel,unsigned loopUs,unsigned slowExtraUs=0) {
return channel==0 ? 8397+slowExtraUs : 32768-8397-1200-loopUs-slowExtraUs;
}
static constexpr const char* pairInfo=
"{\"pair_plan\":1,\"case_4p6_300us\":true,\"preamble\":32,\"switch_budget_us\":600,\"floor_chirps\":4.1,\"profiles\":[{\"sf\":7,\"bw_khz\":62.5,\"freq_khz\":909500},{\"sf\":8,\"bw_khz\":500,\"freq_khz\":910500}]}";
+1 -1
View File
@@ -3,7 +3,7 @@
struct BenchResult {
uint32_t sequence = 0;
char text[2048] = {};
char text[4096] = {}; // Pair diagnostics add per-profile dwell and hop timings.
};
BenchResult lastListenResult, lastTxResult, lastRxResult, lastRunResult;
+54
View File
@@ -0,0 +1,54 @@
#pragma once
// Test-build-only controls. Never enabled by a published board recipe.
#if defined(MESH_SOAK_DIAGNOSTICS) && defined(ESP32_PLATFORM)
#include <Arduino.h>
#include <WiFi.h>
#include <esp_heap_caps.h>
#include <helpers/ota/OtaContext.h>
#if defined(MESH_SOAK_WIFI_KEY_OVERRIDE)
#include "S3SoakWiFiKey.h"
#endif
namespace mesh { namespace hil {
inline bool handleSoakCommand(const char* command, char* reply, size_t capacity) {
#if defined(MESH_SOAK_WIFI_KEY_OVERRIDE)
if (strncmp(command, "soak wifi-key ", 14) == 0) {
snprintf(reply, capacity, "%s", setSoakWiFiKey(command + 14)
? "OK - lab key stored" : "Error - invalid lab key or NVS write failed");
return true;
}
#endif
if (strcmp(command, "soak wifi-drop") == 0) {
WiFi.disconnect(false, false);
WiFi.reconnect();
snprintf(reply, capacity, "OK - WiFi reconnect requested");
return true;
}
if (strcmp(command, "soak ota-cycle") == 0) {
char error[96] = {};
if (!ota::ota_acquire_context(error, sizeof(error))) {
snprintf(reply, capacity, "%s", error);
return true;
}
#if OTA_DYNAMIC_CONTEXT
ota::ota_release_context_if_idle(false);
#endif
snprintf(reply, capacity, "OK - OTA context cycle");
return true;
}
if (strcmp(command, "get soak") != 0) return false;
snprintf(reply, capacity,
"{\"up\":%lu,\"int\":%u,\"max\":%u,\"min\":%u,\"ps\":%u,\"ctx\":%u,\"active\":%u,\"dyn\":%u,\"wifi\":%d}",
millis() / 1000UL,
unsigned(heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)),
unsigned(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)),
unsigned(heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)),
unsigned(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)),
unsigned(sizeof(ota::OtaContext)),
ota::ota_context_if_active() != nullptr, unsigned(OTA_DYNAMIC_CONTEXT),
int(WiFi.status()));
return true;
}
} }
#endif
+1
View File
@@ -0,0 +1 @@
#include "../../../variants/rak4631/variant.cpp"
+1
View File
@@ -0,0 +1 @@
#include "../../../variants/rak4631/variant.h"
+1
View File
@@ -0,0 +1 @@
#include "../../../variants/heltec_t096/variant.cpp"
+1
View File
@@ -0,0 +1 @@
#include "../../../variants/heltec_t096/variant.h"
@@ -0,0 +1 @@
#include "../../../variants/t1000-e/variant.cpp"
+1
View File
@@ -0,0 +1 @@
#include "../../../variants/t1000-e/variant.h"
@@ -0,0 +1 @@
#include "../../../variants/heltec_tower_v2/variant.cpp"
+1
View File
@@ -0,0 +1 @@
#include "../../../variants/heltec_tower_v2/variant.h"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+146
View File
@@ -0,0 +1,146 @@
// Four-source bench: configure one channel once, then transmit only on request.
// RAM-only: no Mesh secrets, autonomous transmissions, SD card or flash writes.
#include <Arduino.h>
#include <Adafruit_TinyUSB.h>
#include <SPI.h>
#include <RadioLib.h>
#include "ProfileSwitchUsb.h"
#include "ProfilePairPlan.h"
class FixedTxHal : public ArduinoHal {
public:
using ArduinoHal::ArduinoHal;
uint32_t rfWrites=0, modWrites=0;
void spiTransfer(uint8_t* out, size_t len, uint8_t* in) override {
#ifdef HIL_FIXED_LR1110
if (len>=2 && out[0]==2 && out[1]==0x0B) ++rfWrites;
if (len>=2 && out[0]==2 && out[1]==0x0F) ++modWrites;
#else
if (len==5 && out[0]==0x86) ++rfWrites;
if (len==5 && out[0]==0x8B) ++modWrites;
#endif
ArduinoHal::spiTransfer(out,len,in);
}
} radioHal(SPI, SPISettings(8000000, MSBFIRST, SPI_MODE0));
#ifdef HIL_FIXED_LR1110
LR1110 chip = new Module(&radioHal,P_LORA_NSS,P_LORA_DIO_1,P_LORA_RESET,P_LORA_BUSY);
static const uint32_t switchPins[Module::RFSWITCH_MAX_PINS]={
RADIOLIB_LR11X0_DIO5,RADIOLIB_LR11X0_DIO6,RADIOLIB_LR11X0_DIO7,RADIOLIB_LR11X0_DIO8,RADIOLIB_NC};
static const Module::RfSwitchMode_t switchModes[]={
{LR11x0::MODE_STBY,{LOW,LOW,LOW,LOW}},
{LR11x0::MODE_RX,{HIGH,LOW,LOW,HIGH}},
{LR11x0::MODE_TX,{HIGH,HIGH,LOW,HIGH}},
{LR11x0::MODE_TX_HP,{LOW,HIGH,LOW,HIGH}},
{LR11x0::MODE_TX_HF,{LOW,LOW,LOW,LOW}},
{LR11x0::MODE_GNSS,{LOW,LOW,HIGH,LOW}},
{LR11x0::MODE_WIFI,{LOW,LOW,LOW,LOW}}, END_OF_MODE_TABLE};
#else
SX1262 chip = new Module(&radioHal,P_LORA_NSS,P_LORA_DIO_1,P_LORA_RESET,P_LORA_BUSY);
#endif
bool prepared=false, failed=false;
unsigned fixedChannel=99, packetCount=0;
bool fixedPair=false;
unsigned fixedSf=10;
float fixedBw=125;
uint32_t lastSequence=0;
void prepare(unsigned channel,bool pair=false) {
if (prepared || failed) { Serial.println("{\"error\":\"configuration locked until reboot\"}");return; }
const float freq=(909500+1000*channel)/1000.0f;
fixedPair=pair;
fixedSf=pair ? pairProfiles[channel].sf : 10;
fixedBw=pair ? pairProfiles[channel].bw : 125;
#ifdef HIL_FIXED_LR1110
int rc=chip.begin(freq,fixedBw,fixedSf,5,RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE,-9,32,1.6);
if (!rc) rc=chip.setCRC(2);
if (!rc) rc=chip.explicitHeader();
if (!rc) chip.setRfSwitchTable(switchPins,switchModes);
#else
int rc=chip.begin(freq,fixedBw,fixedSf,5,RADIOLIB_SX126X_SYNC_WORD_PRIVATE,-9,32,1.8);
if (!rc) rc=chip.setCRC(true);
if (!rc) rc=chip.explicitHeader();
if (!rc) rc=chip.setDio2AsRfSwitch(true);
if (!rc) rc=chip.setCurrentLimit(140);
#endif
prepared=rc==0;failed=rc!=0;fixedChannel=channel;
Serial.printf("{\"prepared\":%s,\"channel\":%u,\"freq_khz\":%u,\"sf\":%u,\"bw_khz\":%.3f,\"cr\":5,\"preamble\":32,\"chip_power_dbm\":-9,\"rc\":%d,\"rf_writes\":%u,\"mod_writes\":%u}\n",
prepared?"true":"false",channel,909500+1000*channel,fixedSf,double(fixedBw),rc,unsigned(radioHal.rfWrites),unsigned(radioHal.modWrites));
}
void transmit(unsigned channel, uint32_t seq) {
if (!prepared || failed || channel!=fixedChannel) { Serial.println("{\"error\":\"fixed TX configuration mismatch\"}");return; }
if (seq==lastTxResult.sequence) { emitBenchResult(lastTxResult);return; }
if (seq<=lastSequence) { Serial.println("{\"error\":\"sequence already consumed\"}");return; }
lastSequence=seq; // A failed call cannot be retried as another RF transmission.
uint8_t bytes[16];memcpy(bytes,"CHS1",4);memcpy(bytes+4,&seq,4);bytes[8]=channel;
for (unsigned i=9;i<sizeof(bytes);++i) bytes[i]=uint8_t(i^seq);
const uint32_t rfBefore=radioHal.rfWrites,modBefore=radioHal.modWrites;
const uint32_t started=micros();
const int rc=chip.transmit(bytes,sizeof(bytes)); // No init, setFrequency or modulation setters here.
const uint32_t elapsed=micros()-started;
const unsigned rfDelta=radioHal.rfWrites-rfBefore,modDelta=radioHal.modWrites-modBefore;
++packetCount;
if (rc || rfDelta || modDelta) failed=true;
recordBenchResult(lastTxResult,seq,
"{\"sent\":%u,\"channel\":%u,\"len\":16,\"preamble\":32,\"rc\":%d,\"power_dbm\":-9,\"sf\":%u,\"bw_khz\":%.3f,\"freq_khz\":%u,\"channel_step_khz\":1000,\"transmit_call_us\":%u,\"rf_commands\":%u,\"modulation_commands\":%u,\"packet_count\":%u,\"fixed_tx\":true,\"failed\":%s}\n",
unsigned(seq),channel,rc,fixedSf,double(fixedBw),909500+1000*channel,unsigned(elapsed),rfDelta,modDelta,packetCount,failed?"true":"false");
}
void setup() {
Serial.begin(115200);delay(1500);
NRF_POWER->DCDCEN=1;
#if defined(HIL_FIXED_RAK)
pinMode(37,OUTPUT);digitalWrite(37,HIGH);
#elif defined(HIL_FIXED_T096)
// Production KCT8103L path, held in TX mode. -9 dBm is chip output,
// not calibrated antenna-port power; the external PA remains in this path.
pinMode(30,OUTPUT);digitalWrite(30,HIGH);
pinMode(12,OUTPUT);digitalWrite(12,HIGH);
pinMode(41,OUTPUT);digitalWrite(41,HIGH);
#elif defined(HIL_FIXED_TOWER)
pinMode(LORA_KCT8103L_EN,OUTPUT);digitalWrite(LORA_KCT8103L_EN,HIGH);
pinMode(LORA_KCT8103L_TX_RX,OUTPUT);digitalWrite(LORA_KCT8103L_TX_RX,HIGH);
pinMode(EXTERNAL_WATCHDOG_DONE_PIN,OUTPUT);digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN,LOW);
pinMode(PIN_GPS_EN,OUTPUT);digitalWrite(PIN_GPS_EN,HIGH);
pinMode(32,OUTPUT);digitalWrite(32,HIGH); // SD CS deasserted; no card access.
#endif
delay(10);SPI.setPins(P_LORA_MISO,P_LORA_SCLK,P_LORA_MOSI);SPI.begin();
}
void loop() {
// A watchdog started by the previous application survives a soft DFU reset.
if (NRF_WDT->RUNSTATUS) for (unsigned i=0;i<8;++i) NRF_WDT->RR[i]=0x6E524635;
#ifdef HIL_FIXED_TOWER
static uint32_t fed=0;
if (millis()-fed>=10000) {
digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN,HIGH);delay(1);
digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN,LOW);fed=millis();
}
#endif
static char line[96];static unsigned used=0;static bool overflow=false;
while (Serial.available()) {
const char c=Serial.read();
if (c=='\r' || c=='\n') {
if (overflow) { overflow=false;used=0;Serial.println("{\"error\":\"command too long\"}");continue; }
if (!used) continue;
line[used]=0;used=0;
unsigned ch,seq,len,pre,sf,bw;char tail,kind[12];
if (!strcmp(line,"info")) {
Serial.printf("{\"ready\":true,\"bench\":\"production-profile-switch-v8\",\"fixed_tx\":1,\"board\":\"%s\",\"prepared\":%s,\"failed\":%s,\"channel\":%u,\"packet_count\":%u,\"sd_fwid\":%u,\"app_base\":%u,\"autonomous_tx\":false}\n",
HIL_FIXED_BOARD,prepared?"true":"false",failed?"true":"false",fixedChannel,packetCount,
unsigned(*reinterpret_cast<volatile uint16_t*>(0x300c)),unsigned(*reinterpret_cast<volatile uint32_t*>(0x3008)));
} else if (!strcmp(line,"pairinfo")) Serial.println(pairInfo);
else if (sscanf(line,"pairprepare %u %c",&ch,&tail)==1 && ch<2) prepare(ch,true);
else if (sscanf(line,"pairtx %u %u %c",&ch,&seq,&tail)==2 && ch<2 && seq && fixedPair) transmit(ch,seq);
else if (sscanf(line,"txprepare %u %c",&ch,&tail)==1 && ch<4) prepare(ch);
else if (sscanf(line,"scantx %u %u %u %u %u %u %c",&ch,&seq,&len,&pre,&sf,&bw,&tail)==6
&& ch<4 && seq && len==16 && pre==32 && sf==10 && bw==125 && !fixedPair) transmit(ch,seq);
else if (sscanf(line,"result %11s %u %c",kind,&seq,&tail)==2) replayBenchResult(kind,seq);
else if (!strcmp(line,"reboot")) { Serial.flush();delay(20);NVIC_SystemReset(); }
else Serial.println("{\"error\":\"unsupported fixed TX command\"}");
} else if (!overflow) {
if (used<sizeof(line)-1) line[used++]=c;else overflow=true;
}
}
delay(1);
}
+83
View File
@@ -0,0 +1,83 @@
; Explicit application-only HIL images. Never update the SoftDevice/bootloader.
[fixed_tx_base]
extends = arduino_base
platform = nordicnrf52
platform_packages = ${nrf52_base.platform_packages}
board_build.ldscript = boards/nrf52840_s140_v6.ld
board_build.variants_dir = tools/hil
upload_protocol = nrfutil
extra_scripts =
create-uf2.py
pre:scripts/nrf52_usb_power_fix.py
lib_deps = ${env:profile_switch_xiao.lib_deps}
lib_ignore = PsychicMqttClient
build_flags =
${arduino_base.build_flags}
-D NRF52_PLATFORM
-I lib/nrf52/s140_nrf52_6.1.1_API/include
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
build_src_filter = +<../tools/hil/profile_fixed_tx.cpp>
[env:profile_fixed_rak_tx]
extends = fixed_tx_base
board = rak4631
board_build.variant = fixed_rak_variant
build_flags = ${fixed_tx_base.build_flags}
-D HIL_FIXED_RAK
-D HIL_FIXED_BOARD='"RAK4631"'
-D P_LORA_DIO_1=47
-D P_LORA_NSS=42
-D P_LORA_RESET=38
-D P_LORA_BUSY=46
-D P_LORA_SCLK=43
-D P_LORA_MISO=45
-D P_LORA_MOSI=44
[env:profile_fixed_t096_tx]
extends = fixed_tx_base
board = heltec_t096
board_build.variant = fixed_t096_variant
build_flags = ${fixed_tx_base.build_flags}
-D HIL_FIXED_T096
-D HIL_FIXED_BOARD='"Heltec T096"'
-D P_LORA_DIO_1=21
-D P_LORA_NSS=5
-D P_LORA_RESET=16
-D P_LORA_BUSY=19
-D P_LORA_SCLK=40
-D P_LORA_MISO=14
-D P_LORA_MOSI=11
[env:profile_fixed_tower_tx]
extends = fixed_tx_base
board = heltec_tower_v2
board_build.variant = fixed_tower_variant
build_flags = ${fixed_tx_base.build_flags}
-D HIL_FIXED_TOWER
-D HIL_FIXED_BOARD='"Heltec MeshTower V2"'
[env:profile_fixed_t1000_tx]
extends = fixed_tx_base
board = tracker-t1000-e
board_build.variant = fixed_t1000_variant
board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld
board_upload.maximum_size = 708608
build_flags = ${arduino_base.build_flags}
-D NRF52_PLATFORM
-I lib/nrf52/s140_nrf52_7.3.0_API/include
-I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52
-D HIL_FIXED_LR1110
-D HIL_FIXED_BOARD='"Seeed T1000-E LR1110"'
-D P_LORA_DIO_1=33
-D P_LORA_NSS=12
-D P_LORA_RESET=42
-D P_LORA_BUSY=7
-D P_LORA_SCLK=11
-D P_LORA_MISO=40
-D P_LORA_MOSI=41
[env:profile_four_tx_v4_rx]
extends = env:profile_switch_heltec_v4
build_unflags = -DSX126X_RX_BOOSTED_GAIN=1
build_flags = ${env:profile_switch_heltec_v4.build_flags}
-D SX126X_RX_BOOSTED_GAIN=0
+200
View File
@@ -0,0 +1,200 @@
"""Four fixed physical transmitters / V4 fast single-pass RX, full finite sample.
First 100 stationary controls per transmitter, then 100 per channel scanning.
RF misses never trigger retries, reinitialization or a shortened sample.
Hardware/USB/identity failures remain fatal and are not labeled RF losses.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
import json
from pathlib import Path
import random
import secrets
import subprocess
import time
import serial
from profile_four_tx_fixture import TXS,RX,resolve
from profile_switch import exchange,exchange_ready,read_packet_response,configure_session
from profile_switch_channels import capacity_levels,channel_dwell_us,reception_validity
ROOT=Path(__file__).resolve().parent
def open_radio(target):
name=resolve(target)
p=serial.Serial();p.port=name;p.baudrate=115200;p.timeout=.2;p.write_timeout=2
configure_session(p);p.open();p.reset_input_buffer()
try:
# The asynchronous boot greeting has a bench name but no board key.
# Wait for the actual info reply instead of accepting that greeting.
info=exchange(p,'info','board',5)
if info.get('bench')!='production-profile-switch-v8' or not info.get('ready'):
raise RuntimeError('Wrong/unready bench image')
return p,info
except Exception:p.close();raise
def check_sent(sent,t,expected_count):
expected=dict(channel=t['channel'],len=16,preamble=32,rc=0,power_dbm=-9,sf=10,bw_khz=125,
freq_khz=909500+1000*t['channel'],channel_step_khz=1000,rf_commands=0,
modulation_commands=0,fixed_tx=True,failed=False,packet_count=expected_count)
if any(sent.get(k)!=v for k,v in expected.items()):
raise RuntimeError('Fixed TX failed, retuned, reset or mismatched: '+str(sent))
def check_scan(status,attempted,received):
n=status['switch']['n']
with_mod=status['with_modulation']['n'];without_mod=status['without_modulation']['n']
expected=dict(channels=4,received=received,missed=attempted-received,failures=0,rx_mode_errors=0,
cache_errors=0,rx_gain_reg=0x94,retune_passes=1,first_pass_detour=False,
frequency_repeat=False,second_pass_blocked=0,rollback=0,settle_us=0,
modulation_cache=True,modulation_commands=with_mod,rf_commands=n,optimized_rx_resumes=n,
accept_offchannel=False,continue_on_miss=True,mixed_profiles=False)
# Normal packet RX staging revokes the owned-hop cache. The next hop must
# acknowledge modulation again; unchanged subsequent hops can omit 0x8B.
# Count actual cache-hit/miss hops rather than assuming zero writes for
# an entire run that includes packet processing.
if not n or not without_mod or with_mod+without_mod!=n or any(status.get(k)!=v for k,v in expected.items()):
raise RuntimeError('Actual RX command counts/policies/counts disagree: '+str(status))
def parse_args(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--dwell-symbols',type=float,default=5.1)
args=parser.parse_args(argv)
try:args.dwell_us=channel_dwell_us(10,args.dwell_symbols)
except ValueError as error:parser.error(str(error))
return args
def main():
args=parse_args()
path=ROOT/'results.json'
if path.exists():raise RuntimeError('Existing capture; no implicit rerun')
if not json.loads((ROOT/'deployment.json').read_text()).get('complete'):
raise RuntimeError('Deployment not verified')
result=dict(schema=1,experiment='four_fixed_tx_single_pass_sf10_125',complete=False,
started_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),
sf=10,bw_khz=125,cr=5,preamble_symbols=32,dwell_symbols=args.dwell_symbols,
dwell_us=args.dwell_us,settle_us=0,channel_step_khz=1000,
retune_passes=1,frequency_repeat=False,modulation_cache=True,normal_rx_gain=True,
samples_per_channel=100,stationary_samples_per_tx=100,seed=606125,
transmitters=TXS,receiver=RX,baseline=[],sender_info=[],sender_setup=[],
rf_power_note='-9 dBm at chip; Heltec external PA paths are not calibrated antenna-port powers')
rx=None;senders=[];counts=[0]*4;sequence=secrets.randbelow(0x1fffffff)+1
rng=random.Random(606125)
def seq():
nonlocal sequence
sequence+=1;return sequence
def save(progress=None):
if progress:result.update(progress)
path.write_text(json.dumps(result,indent=2)+'\n')
def rx_reboot():
nonlocal rx
if rx:
rx.write(b'reboot\n');rx.flush();rx.close();rx=None
time.sleep(2)
rx,info=open_radio(RX)
if info.get('board')!='Heltec V4' or info.get('rx_gain_reg')!=0x94:
raise RuntimeError('Wrong RX hardware/gain')
result['receiver_info']=info
if exchange(rx,'scanstep 1000','channel_step_khz',2)['channel_step_khz']!=1000:
raise RuntimeError('RX spacing mismatch')
def send_receive(ch,s,timeout):
with ThreadPoolExecutor(max_workers=1) as pool:
incoming=pool.submit(read_packet_response,rx,'received',s,timeout)
sent=exchange(senders[ch],f'scantx {ch} {s} 16 32 10 125','sent',4,s,cached=True)
counts[ch]+=1;check_sent(sent,TXS[ch],counts[ch])
received=incoming.result()
if received.get('device_errors') or (received.get('timeout') and received.get('rx_mode')!=1):
raise RuntimeError('Receiver hardware failure: '+str(received))
return sent,received
try:
save()
for t in TXS:
p,info=open_radio(t);senders.append(p)
if any(info.get(k)!=v for k,v in dict(board=t['board'],sd_fwid=t['fwid'],app_base=t['app_base'],
fixed_tx=1,prepared=False,failed=False,packet_count=0).items()):
raise RuntimeError('TX identity/layout/non-fresh state mismatch')
result['sender_info'].append(info)
prep=exchange(p,f"txprepare {t['channel']}",'prepared',5)
if any(prep.get(k)!=v for k,v in dict(prepared=True,rc=0,channel=t['channel'],
freq_khz=909500+1000*t['channel'],sf=10,bw_khz=125,cr=5,preamble=32).items()):
raise RuntimeError('TX preparation mismatch: '+str(prep))
result['sender_setup'].append(prep);save()
rx_reboot()
for ch in range(4):
if ch:rx_reboot()
s=seq();setup=exchange(rx,f'basestart {ch} 10 {s}','listening',5,s,cached=True)
if any(setup.get(k)!=v for k,v in dict(channel=ch,freq_khz=909500+1000*ch,
sf=10,bw_khz=125,preamble=32,rx_gain_reg=0x94,full_init=True,stationary=True).items()):
raise RuntimeError('Stationary control setup mismatch')
baseline=dict(channel=ch,setup=setup,trials=[],complete=False)
result['baseline'].append(baseline);result['phase']=f'baseline_channel_{ch}';save()
for sample in range(100):
s=seq();armed=exchange(rx,f'baseexpect {s}','listening',2,s,cached=True)
if armed.get('channel')!=ch or not armed.get('stationary'):raise RuntimeError('Baseline arm mismatch')
time.sleep(rng.uniform(0,.2))
sent,received=send_receive(ch,s,5)
valid=received.get('valid') is True and received.get('len')==16 and received.get('channel')==ch
baseline['trials'].append(dict(sequence=s,channel=ch,sent=sent,received=received,valid=valid))
save()
if (sample+1)%10==0:print(json.dumps({'phase':'baseline','channel':ch,'attempted':sample+1,
'received':sum(t['valid'] for t in baseline['trials'])}),flush=True)
s=seq();status=exchange(rx,f'basestatus {s}','result',3,s,cached=True)
successes=sum(t['valid'] for t in baseline['trials'])
if any(status.get(k)!=v for k,v in dict(received=successes,missed=100-successes,rf_commands=0,
rx_gain_reg=0x94,rx_mode=1,failures=0,device_errors=0).items()):
raise RuntimeError('Baseline hardware/counter check failed')
baseline.update(complete=True,received=successes,status=status);save()
# Full controls are retained even when RF losses occur. A completely
# missing link is a fixture problem, not a useful channel-scan capacity test.
if any(b['received']==0 for b in result['baseline']):raise RuntimeError('A transmitter failed all 100 fixed-channel controls')
rx_reboot()
policies=(('scanfirstdetour 0','first_pass_detour',False),('scanretunepasses 1','retune_passes',1),
('scanfreqrepeat 0','frequency_repeat',False),('scanmixed 0','mixed_profiles',False),
('scanrollback 0','rollback',0),('scanmodcache 1','modulation_cache',True),
('scansettle 0','settle_us',0),('scanacceptoffchannel 0','accept_offchannel',False),
('scancontinue 1','continue_on_miss',True))
result['policies']={}
for command,key,value in policies:
ack=exchange(rx,command,key,3)
if ack.get(key)!=value:raise RuntimeError('RX policy mismatch: '+command)
result['policies'][key]=value
result['phase']='four_channel_scan';save()
def start(channels):
s=seq();ack=exchange_ready(rx,f'scanstart 4 32 {s} {result["dwell_us"]} 0 10 125','listening',3,s,cached=True)
if any(ack.get(k)!=v for k,v in dict(channels=4,sf=10,bw_khz=125,preamble=32,
dwell_us=result['dwell_us'],settle_us=0,step_mhz=1,spi_mhz=8,bulk=True,rollback=0,trace=False).items()):
raise RuntimeError('Scan setup mismatch')
return ack
def probe(ch,pause_ms):
s=seq();armed=exchange(rx,f'scanexpect {ch} {s}','listening',2,s,cached=True)
if armed.get('channel')!=ch or not armed.get('scanning'):raise RuntimeError('Scan arm mismatch')
time.sleep(pause_ms/1000)
sent,received=send_receive(ch,s,12)
strict,valid=reception_validity(received,ch,False)
trial=dict(sequence=s,channel=ch,armed=armed,sent=sent,received=received,valid=valid,strict_valid=strict)
attempted=sum(counts)-400
if attempted%10==0:print(json.dumps({'phase':'scan','attempted':attempted,'last_channel':ch,'last_valid':valid}),flush=True)
return trial
def finish():
s=seq();status=exchange(rx,f'scanstatus {s}','result',3,s,cached=True)
level=result['levels'][0]
level['status']=status;save() # Preserve raw telemetry even if validation fails.
check_scan(status,level['attempted'],level['received'])
return status
capacity_levels(start,probe,finish,save,samples=100,maximum=4,seed=606125,
dwell_us=result['dwell_us'],settle_us=0,continue_on_miss=True)
result['sender_final']=[exchange(p,'info','board',3) for p in senders]
if any(info.get('packet_count')!=200 or not info.get('prepared') or info.get('failed')
for info in result['sender_final']):raise RuntimeError('TX counts/lifetime mismatch')
result.update(complete=True,phase='complete',finished_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()))
print(json.dumps({'complete':True,'rates':result['levels'][0]['error_rates'],
'switch':result['levels'][0]['status']['switch']}),flush=True)
except Exception as error:
result.update(complete=False,phase='fixture_error',error=str(error));raise
finally:
for p in ([rx] if rx else [])+senders:
try:p.write(b'reboot\n');p.flush()
except Exception as error:result.setdefault('cleanup_errors',[]).append(str(error))
finally:p.close()
save()
if __name__=='__main__':main()
+9
View File
@@ -0,0 +1,9 @@
"""Export only the user-stopped 6.1-symbol experiment, without radio I/O."""
import base64
import json
from pathlib import Path
import zlib
root=Path.home()/'hwtest/runs/four-tx-sf10-6p1-20260914'
names=('results.json','deployment.json','manifest.json','launch.json','cleanup.json','user-stop.json')
records={name:(root/name).read_text() for name in names}
print('FOUR_TX_6P1_CAPTURE '+base64.b64encode(zlib.compress(json.dumps(records).encode(),9)).decode(),flush=True)
+24
View File
@@ -0,0 +1,24 @@
"""Retrieve 6.1-symbol experiment records without changing earlier captures."""
import base64
import hashlib
import json
from pathlib import Path
import subprocess
import zlib
root=Path(__file__).resolve().parents[2]
p=subprocess.run(['pwsh','-NoProfile','-File',r'C:\git\Adafruit_nRF52_Bootloader_OTAFIX\.tmp_mercermesh_bridge.ps1',
'-ScriptPath',str(Path(__file__).with_name('profile_four_tx_6p1_export.py')),'-DeadlineSeconds','60'],
capture_output=True,text=True,cwd=root,timeout=65,check=True)
prefix='FOUR_TX_6P1_CAPTURE '
lines=[line.removeprefix(prefix) for line in p.stdout.splitlines() if line.startswith(prefix)]
if len(lines)!=1 or 'MERCERMESH_OPERATION_EXIT 0' not in p.stdout:raise RuntimeError('Incomplete capture export')
records=json.loads(zlib.decompress(base64.b64decode(lines[0],validate=True)))
names={'results.json','deployment.json','manifest.json','launch.json','cleanup.json','user-stop.json'}
if set(records)!=names:raise RuntimeError('Unexpected record names')
targets={name:root/'tools/hil'/('profile_four_tx_sf10_6p1_'+name) for name in names}
if any(path.exists() for path in targets.values()):raise RuntimeError('Preserve existing local captures')
for name,record in records.items():
json.loads(record) # Validate before saving exact UTF-8 bytes from the Pi.
data=record.encode('utf8')
with targets[name].open('xb') as f:f.write(data)
print(json.dumps({'file':str(targets[name]),'sha256':hashlib.sha256(data).hexdigest()}))
+15
View File
@@ -0,0 +1,15 @@
"""Read-only progress for the 6.1-symbol repeat; never open a radio."""
import json
from pathlib import Path
root=Path.home()/'hwtest/runs/four-tx-sf10-6p1-20260914'
p=root/'results.json'
if not p.exists():
print(json.dumps({'capture_exists':False}))
else:
r=json.loads(p.read_text())
print(json.dumps({'phase':r.get('phase'),'complete':r['complete'],'error':r.get('error'),
'dwell_symbols':r['dwell_symbols'],'dwell_us':r['dwell_us'],
'baseline':[{'channel':b['channel'],'attempted':len(b['trials']),
'received':sum(t['valid'] for t in b['trials'])} for b in r['baseline']],
'scan':[{'attempted':l['attempted'],'received':l['received'],'per_channel':l['per_channel'],
'status':l.get('status'),'rates':l.get('error_rates')} for l in r.get('levels',[])]}),flush=True)
+20
View File
@@ -0,0 +1,20 @@
"""User-requested stop of only the identified 6.1-symbol collector."""
import json
import os
from pathlib import Path
import signal
root=Path.home()/'hwtest/runs/four-tx-sf10-6p1-20260914'
control=json.loads((root/'run-control.json').read_text())
pid=control['pid']
proc=Path('/proc')/str(pid)
if not proc.exists():
print(json.dumps({'already_exited':True,'pid':pid}),flush=True)
else:
argv=(proc/'cmdline').read_bytes().decode().strip('\0').split('\0')
expected=['python3','-u',str(root/'profile_four_tx.py'),'--dwell-symbols','6.1']
if argv!=expected or proc.stat().st_uid!=os.getuid():
raise RuntimeError('PID is not the exact owned 6.1-symbol collector; not stopping it')
with (root/'user-stop.json').open('x') as f:
json.dump({'reason':'User requested stop and next test at 7.7 symbols','pid':pid},f)
os.kill(pid,signal.SIGINT)
print(json.dumps({'stop_requested':True,'pid':pid,'signal':'SIGINT'}),flush=True)
+15
View File
@@ -0,0 +1,15 @@
"""Read-only progress for the 7.7-symbol repeat; never open a radio."""
import json
from pathlib import Path
root=Path.home()/'hwtest/runs/four-tx-sf10-7p7-20260914'
p=root/'results.json'
if not p.exists():
print(json.dumps({'capture_exists':False}))
else:
r=json.loads(p.read_text())
print(json.dumps({'phase':r.get('phase'),'complete':r['complete'],'error':r.get('error'),
'dwell_symbols':r['dwell_symbols'],'dwell_us':r['dwell_us'],
'baseline':[{'channel':b['channel'],'attempted':len(b['trials']),
'received':sum(t['valid'] for t in b['trials'])} for b in r['baseline']],
'scan':[{'attempted':l['attempted'],'received':l['received'],'per_channel':l['per_channel'],
'status':l.get('status'),'rates':l.get('error_rates')} for l in r.get('levels',[])]}),flush=True)
+78
View File
@@ -0,0 +1,78 @@
"""Summarize the immutable full-sample capture; never connect to radios."""
import argparse
import json
import ast
import hashlib
from pathlib import Path
import statistics
from profile_four_tx import check_scan,check_sent
from profile_four_tx_fixture import TXS
from profile_switch_channels import packet_error_summary,channel_dwell_us
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--input',type=Path,default=Path(__file__).with_name('profile_four_tx_sf10_single_results.json'))
parser.add_argument('--output',type=Path)
args=parser.parse_args()
p=args.input
output=args.output or p.with_name(p.stem.removesuffix('_results')+'_validated.json')
if output.exists():parser.error('Output already exists; preserve previous validation')
r=json.loads(p.read_text())
level=r['levels'][0]
trials=level['trials']
if (r['sf']!=10 or r['bw_khz']!=125 or r['preamble_symbols']!=32
or r['dwell_us']!=channel_dwell_us(10,r['dwell_symbols'])
or level['config']['dwell_us']!=r['dwell_us']):
raise RuntimeError('Requested and acknowledged radio/dwell settings disagree')
if len(trials)!=400 or any(c['attempted']!=100 for c in level['per_channel']):
raise RuntimeError('Not a complete 400-packet capture')
if len({t['sequence'] for t in trials})!=400:raise RuntimeError('Duplicate probe sequence')
recovery=None
status=level.get('status')
if status is None:
prefix='Actual RX command counts/policies/counts disagree: '
if not r.get('error','').startswith(prefix):raise RuntimeError('No complete final telemetry')
status=ast.literal_eval(r['error'][len(prefix):])
recovery='Final status preserved verbatim in original collector error; zero-modulation assertion was overly strict for post-packet RX staging.'
check_scan(status,400,sum(t['valid'] for t in trials))
all_trials=[t for b in r['baseline'] for t in b['trials']]+trials
if len(all_trials)!=800 or len({t['sequence'] for t in all_trials})!=800:raise RuntimeError('Incomplete/duplicate baseline or scan probes')
for ch,target in enumerate(TXS):
selected=[t for t in all_trials if t['channel']==ch]
if len(selected)!=200:raise RuntimeError('Missing transmitter probes')
for count,trial in enumerate(selected,1):
check_sent(trial['sent'],target,count)
if trial['sent']['sent']!=trial['sequence'] or trial['received']['received']!=trial['sequence']:
raise RuntimeError('Response sequence mismatch')
if trial['valid'] and (trial['received']['channel']!=ch or trial['received']['payload_channel']!=ch
or trial['received']['len']!=16):
raise RuntimeError('Accepted payload mismatch')
for b in r['baseline']:
if len(b['trials'])!=100 or not b['complete'] or b['received']!=100:
raise RuntimeError('Stationary control did not fully pass')
if r['complete']:
if (r.get('phase')!='complete' or len(r.get('sender_final',[]))!=4
or any(i.get('packet_count')!=200 or not i.get('prepared') or i.get('failed')
for i in r['sender_final'])):
raise RuntimeError('Final transmitter lifetime checks missing or mismatched')
def signals(ts):
rows=[t['received'] for t in ts if t['received'].get('valid') or t['received'].get('payload_valid')]
return {k:dict(n=len(v),mean=statistics.mean(v),min=min(v),max=max(v))
for k in ('rssi','snr') if (v:=[x[k] for x in rows if k in x])}
summary=dict(sampling_complete=True,post_run_validation_passed=True,original_collector_complete=r['complete'],
raw_capture_sha256=hashlib.sha256(p.read_bytes()).hexdigest(),validation_note=recovery,
started_utc=r['started_utc'],finished_utc=r.get('finished_utc'),
dwell_symbols=r['dwell_symbols'],dwell_us=r['dwell_us'],rates=packet_error_summary(trials,4),
status=status,baseline=[dict(channel=b['channel'],received=b['received'],
signals=signals(b['trials']),status=b['status']) for b in r['baseline']],
per_tx=[dict(channel=ch,signals=signals([t for t in trials if t['channel']==ch])) for ch in range(4)],
missing=[dict(attempt=i+1,round=t['sample_round'],channel=t['channel'],received=t['received'],
pause_ms=t['pause_ms'],tx=t['sent']) for i,t in enumerate(trials) if not t['valid']],
unique_tx_sequences=len({t['sequence'] for b in r['baseline'] for t in b['trials']}|{t['sequence'] for t in trials}),
tx_configuration_commands_during_packets=sum(t['sent']['rf_commands']+t['sent']['modulation_commands']
for t in trials+[t for b in r['baseline'] for t in b['trials']]),
transport_replays=sum(bool(t['received'].get('transport_recovered'))+bool(t['sent'].get('transport_recovered'))
for t in trials+[t for b in r['baseline'] for t in b['trials']]))
with output.open('x') as f:json.dump(summary,f,indent=2);f.write('\n')
print(json.dumps(summary,indent=2))
+103
View File
@@ -0,0 +1,103 @@
"""Enter USB DFU on four explicitly selected test TXs; read boot metadata only.
No erase, DFU payload, hub reset or bootloader update. Mounts are read-only.
The RAK3401 gateway and V4 receiver are deliberately excluded.
"""
import json
import subprocess
import tempfile
import time
import struct
from pathlib import Path
import serial
from serial.tools import list_ports
TARGETS = (
('RAK4631', '9AB3B64C641BA927', '1-1.3.1'),
('HT-n5262G', '651F8E496197F882', '1-1.2.3'),
('HT-n5262', '9352162A72082314', '1-1.2.2'),
('T1000-E', '34A9141999729D5D', '1-1.2.1'),
)
def run(*args):
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=20).stdout
def usb_parent(path):
for p in path.resolve().parents:
if (p/'idVendor').exists() and (p/'serial').exists():
return p
return None
def matches(path, serial_number, topology):
p = usb_parent(path)
return p is not None and p.name == topology and (p/'serial').read_text().strip() == serial_number
run('sudo', '-n', 'true')
mm_active = subprocess.run(['systemctl', 'is-active', '--quiet', 'ModemManager.service']).returncode == 0
try:
if mm_active: run('sudo', '-n', 'systemctl', 'stop', 'ModemManager.service')
for model, sn, topo in TARGETS:
ports = [p for p in list_ports.comports() if p.serial_number == sn
and matches(Path('/sys/class/tty')/Path(p.device).name/'device', sn, topo)]
ports.sort(key=lambda p: p.location or '')
if not ports: raise RuntimeError(f'{model}: exact serial/topology missing')
port = ports[0]
already_msc = any(matches(p/'device',sn,topo) for p in Path('/sys/class/block').iterdir())
if not already_msc and port.pid not in (0x0029, 0x002A, 0x4404, 0x0071, 0x0057):
if model not in (port.product or ''): raise RuntimeError(f'Unexpected product {port.product}')
busy = subprocess.run(['fuser', port.device], capture_output=True, text=True)
if busy.returncode != 1: raise RuntimeError(f'{port.device} is owned or ownership check failed')
print(json.dumps({'enter_dfu': model, 'serial': sn, 'port': port.device, 'topology': topo}), flush=True)
try:
with serial.Serial(port.device, 115200, timeout=.2, write_timeout=2) as s:
s.dtr = True
if model in ('HT-n5262G', 'T1000-E'):
s.write(b'+++MESHCORE-TERM-STOP\r\n+++MESHCORE-TERM-START\r\n')
s.flush();time.sleep(.7);s.reset_input_buffer()
s.write(b'uf2reset\r\n');s.flush()
time.sleep(.5)
except (OSError, serial.SerialException):
pass # Disconnect is expected; exact boot enumeration below is mandatory.
deadline = time.monotonic()+25
blocks = []
while time.monotonic() < deadline:
blocks = [p for p in Path('/sys/class/block').iterdir()
if matches(p/'device', sn, topo)]
if len(blocks) == 1: break
time.sleep(.3)
if len(blocks) != 1: raise RuntimeError(f'{model}: no unique UF2 block device')
dev = '/dev/'+blocks[0].name
directory = Path(tempfile.mkdtemp(prefix='four-tx-bootinfo-'))
mounted = False
try:
run('sudo', '-n', 'mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', dev, str(directory))
mounted = True
names={p.name.upper():p for p in directory.iterdir()}
print(json.dumps({'mounted':dev,'files':list(names)}),flush=True)
if not names:
bpb=subprocess.run(['sudo','-n','dd','if='+dev,'bs=512','count=1','status=none'],check=True,capture_output=True,timeout=10).stdout
root=struct.unpack_from('<H',bpb,14)[0]+bpb[16]*struct.unpack_from('<H',bpb,22)[0]
rawroot=subprocess.run(['sudo','-n','dd','if='+dev,'bs=512','count=2','skip='+str(root),'status=none'],check=True,capture_output=True,timeout=10).stdout
print(json.dumps({'bpb_hex':bpb.hex(),'root_sector':root,'root_hex':rawroot.hex()}),flush=True)
continue
info = names['INFO_UF2.TXT'].read_text()
# Read only the beginning of the synthetic flash view, not Mesh identities.
sd = None
with names['CURRENT.UF2'].open('rb') as current:
for _ in range(64):
block=current.read(512)
if len(block)!=512: break
magic0,magic1,flags,addr,size=struct.unpack_from('<5I',block)
if magic0!=0x0A324655 or magic1!=0x9E5D5157 or size!=256:
raise RuntimeError('Malformed readback UF2')
if addr<=0x3008 and addr+size>0x300d:
sd={'app_base':struct.unpack_from('<I',block,32+0x3008-addr)[0],
'fwid':struct.unpack_from('<H',block,32+0x300c-addr)[0]}
break
print(json.dumps({'model': model, 'serial': sn, 'topology': topo, 'block': dev,
'info_uf2': info, 'installed_softdevice':sd}), flush=True)
finally:
if mounted: run('sudo', '-n', 'umount', str(directory))
directory.rmdir()
finally:
if mm_active: run('sudo', '-n', 'systemctl', 'start', 'ModemManager.service')
+26
View File
@@ -0,0 +1,26 @@
"""Receive the locally built, hash-manifested bundle through the configured bridge."""
import base64
import hashlib
import io
import json
from pathlib import Path
import subprocess
import zipfile
root=Path.home()/'hwtest/runs/four-tx-sf10-20260914'
payload=(root/'bundle.zip').read_bytes()
if hashlib.sha256(payload).hexdigest()!='fad0bf8b76412e3ee362bc6632ff1e8e7bbf2746e6f51d7e3d69a8f0f95dfed0':
raise RuntimeError('Wrong assembled bundle')
if (root/'manifest.json').exists():raise RuntimeError('Bundle already unpacked')
with zipfile.ZipFile(io.BytesIO(payload)) as z:
manifest=json.loads(z.read('manifest.json'))
if set(z.namelist())!=set(manifest['files'])|{'manifest.json'}:raise RuntimeError('Unexpected bundle entries')
for name in z.namelist():
if Path(name).name!=name or name in ('.','..'):raise RuntimeError('Unsafe bundle entry')
b=z.read(name)
if name!='manifest.json' and hashlib.sha256(b).hexdigest()!=manifest['files'][name]['sha256']:
raise RuntimeError('Bundle hash mismatch')
(root/name).write_bytes(b)
print(json.dumps({'bundle_sha256':hashlib.sha256(payload).hexdigest(),'directory':str(root)}),flush=True)
p=subprocess.run(['python3','-u',str(root/'profile_four_tx_deploy.py')],cwd=root)
raise SystemExit(p.returncode)
+117
View File
@@ -0,0 +1,117 @@
"""Identity/hash-gated application DFU on selected four TXs plus V4 bench RX."""
import hashlib
import io
import json
from pathlib import Path
import subprocess
import time
import zipfile
from profile_four_tx_fixture import TXS,RX,resolve
from profile_switch import exchange,configure_session
import serial
from serial.tools import list_ports
ROOT=Path(__file__).resolve().parent
REPORT=ROOT/'deployment.json'
if REPORT.exists(): raise RuntimeError('A deployment report already exists; no implicit reflashing')
manifest=json.loads((ROOT/'manifest.json').read_text())
report={'complete':False,'tx':[],'rx':{},'services_before':{}}
def save():REPORT.write_text(json.dumps(report,indent=2)+'\n')
def run(args,timeout=120):
p=subprocess.run([str(a) for a in args],capture_output=True,text=True,timeout=timeout)
print(json.dumps({'command':args,'rc':p.returncode,'out':p.stdout,'err':p.stderr},default=str),flush=True)
if p.returncode:raise RuntimeError('Operation failed: '+str(args[0]))
return p.stdout+p.stderr
def service(name,action):run(['sudo','-n','systemctl',action,name],20)
def boot_port(t):
port=resolve(t)
p=next(p for p in list_ports.comports() if p.device==port)
if (p.vid,p.pid)==(t['boot_vid'],t['boot_pid']):return port
if p.vid!=0x239A or p.pid not in (0x8029,0x4405):
raise RuntimeError('Neither identified application nor identified UF2 bootloader')
# Enter immediately before this board's DFU, rather than assuming it stayed
# in recovery while the other images were built/transferred.
try:
with serial.Serial(port,115200,timeout=.2,write_timeout=2) as stream:
stream.dtr=True
if t['channel']!=2:
stream.write(b'+++MESHCORE-TERM-STOP\r\n+++MESHCORE-TERM-START\r\n')
stream.flush();time.sleep(.7);stream.reset_input_buffer()
stream.write(b'uf2reset\r\n');stream.flush();time.sleep(.7)
except (OSError,serial.SerialException):pass
deadline=time.monotonic()+20
while time.monotonic()<deadline:
try:return resolve(t,boot=True)
except RuntimeError:time.sleep(.3)
raise RuntimeError('Exact bootloader did not enumerate: '+t['board'])
def ready(t):
deadline=time.monotonic()+25
error=None
while time.monotonic()<deadline:
try:
name=resolve(t)
p=serial.Serial();p.port=name;p.baudrate=115200;p.timeout=.2;p.write_timeout=2
configure_session(p);p.open()
try:
info=exchange(p,'info','board',3)
if info.get('bench')!='production-profile-switch-v8' or not info.get('ready'):
raise RuntimeError('Unready bench image')
return dict(port=name,info=info)
finally:p.close()
except (OSError,RuntimeError,TimeoutError) as e:error=e;time.sleep(.3)
raise RuntimeError('No matching running HIL on '+t['board']+': '+str(error))
run(['sudo','-n','true'],10)
for name,record in manifest['files'].items():
if Path(name).name!=name:raise RuntimeError('Unsafe artifact name')
b=(ROOT/name).read_bytes()
if len(b)!=record['bytes'] or hashlib.sha256(b).hexdigest()!=record['sha256']:
raise RuntimeError('Artifact checksum mismatch: '+name)
for t in TXS:resolve(t)
for name in ('ModemManager.service','meshcore-memory-soak.service'):
report['services_before'][name]=subprocess.run(['systemctl','is-active','--quiet',name]).returncode==0
save()
try:
if report['services_before']['ModemManager.service']:service('ModemManager.service','stop')
for t in TXS:
package=ROOT/(t['env']+'.zip')
with zipfile.ZipFile(package) as z:
m=json.loads(z.read('manifest.json'))['manifest']
if set(m)!={'application','dfu_version'} or m['application']['init_packet_data']['softdevice_req']!=[t['fwid']]:
raise RuntimeError('Not the matching application-only DFU package')
port=boot_port(t)
entry=dict(target=t,port_before=port,package_sha256=manifest['files'][package.name]['sha256'])
report['tx'].append(entry);save()
output=run([str(Path.home()/'.local/bin/adafruit-nrfutil'),'dfu','serial','--package',str(package),'-p',port,'-b','115200'],120)
if 'Device programmed.' not in output:raise RuntimeError('DFU lacks explicit completion')
entry['after']=ready(t)
info=entry['after']['info']
if any(info.get(k)!=v for k,v in dict(board=t['board'],sd_fwid=t['fwid'],app_base=t['app_base'],fixed_tx=1,prepared=False,failed=False,autonomous_tx=False).items()):
raise RuntimeError('Installed fixed TX identity/layout mismatch')
entry['complete']=True;save()
if report['services_before']['meshcore-memory-soak.service']:service('meshcore-memory-soak.service','stop')
port=resolve(RX)
tool=str(Path.home()/'.local/bin/esptool')
identity=run([tool,'--chip','esp32s3','--port',port,'--baud','115200','flash-id'],30)
if RX['serial'].lower() not in identity.lower() or '16MB' not in identity.replace(' ',''):
raise RuntimeError('V4 MAC/flash size mismatch')
args=[tool,'--chip','esp32s3','--port',port,'--baud','115200','write-flash','--flash-size','16MB',
'0x0',str(ROOT/'rx-bootloader.bin'),'0x8000',str(ROOT/'rx-partitions.bin'),
'0xe000',str(ROOT/'rx-boot_app0.bin'),'0x10000',str(ROOT/'rx-firmware.bin')]
report['rx']['write_started']=True;save()
run(args,180)
report['rx']['after']=ready(RX)
if report['rx']['after']['info'].get('rx_gain_reg')!=0x94:raise RuntimeError('V4 RX gain mismatch')
report['rx']['complete']=True
report['complete']=True
except Exception as e:
report['error']=str(e)
raise
finally:
if report['services_before']['ModemManager.service']:service('ModemManager.service','start')
# The soak logger cannot run against a RAM-only bench image. Leave it
# paused after V4 replacement; do not silently restore unrelated firmware.
if not report['rx'].get('write_started') and report['services_before']['meshcore-memory-soak.service']:
service('meshcore-memory-soak.service','start')
save()
@@ -0,0 +1,19 @@
"""Copy a reviewed local deployment-script revision; keep the original bundle."""
import base64
import hashlib
import json
from pathlib import Path
import subprocess
root=Path.home()/'hwtest/runs/four-tx-sf10-20260914'
data=base64.b64decode('__PAYLOAD_B64__',validate=True)
previous=root/'deployment.json'
if previous.exists():
report=json.loads(previous.read_text())
if report.get('tx') or report.get('rx',{}).get('write_started'):
raise RuntimeError('Prior deployment reached a write; inspect before retrying')
previous.rename(root/'deployment-preflight-v2.json')
path=root/'profile_four_tx_deploy_v3.py'
with path.open('xb') as f:f.write(data)
print(json.dumps({'script':path.name,'sha256':hashlib.sha256(data).hexdigest()}),flush=True)
raise SystemExit(subprocess.run(['python3','-u',str(path)],cwd=root).returncode)
+8
View File
@@ -0,0 +1,8 @@
"""Return only this bench's synthetic-packet capture and deployment records."""
import base64
import json
from pathlib import Path
import zlib
root=Path.home()/'hwtest/runs/four-tx-sf10-20260914'
data={name:json.loads((root/name).read_text()) for name in ('results.json','deployment.json','manifest.json','cleanup.json')}
print('FOUR_TX_CAPTURE '+base64.b64encode(zlib.compress(json.dumps(data).encode(),9)).decode(),flush=True)
+18
View File
@@ -0,0 +1,18 @@
"""Retrieve the finished capture through the user's already-configured bridge."""
import base64
import hashlib
import json
from pathlib import Path
import subprocess
import zlib
root=Path(__file__).resolve().parents[2]
p=subprocess.run(['pwsh','-NoProfile','-File',r'C:\git\Adafruit_nRF52_Bootloader_OTAFIX\.tmp_mercermesh_bridge.ps1',
'-ScriptPath',str(Path(__file__).with_name('profile_four_tx_export.py')),'-DeadlineSeconds','90'],
capture_output=True,text=True,cwd=root,timeout=100,check=True)
lines=[line.removeprefix('FOUR_TX_CAPTURE ') for line in p.stdout.splitlines() if line.startswith('FOUR_TX_CAPTURE ')]
if len(lines)!=1 or 'MERCERMESH_OPERATION_EXIT 0' not in p.stdout:raise RuntimeError('Incomplete capture export')
data=json.loads(zlib.decompress(base64.b64decode(lines[0],validate=True)))
for name,record in data.items():
target=root/'tools/hil'/('profile_four_tx_sf10_single_'+name)
with target.open('x',encoding='utf8') as f:json.dump(record,f,indent=2);f.write('\n')
print(json.dumps({'file':str(target),'sha256':hashlib.sha256(target.read_bytes()).hexdigest()}))
+29
View File
@@ -0,0 +1,29 @@
"""Exact five-radio fixture. Never enumerate duplicate CDC interfaces as boards."""
TXS = (
dict(channel=0, serial='9AB3B64C641BA927', topology='1-1.3.1', board='RAK4631',
env='profile_fixed_rak_tx', fwid=0xB6, app_base=0x26000, boot_vid=0x239A, boot_pid=0x0029),
dict(channel=1, serial='651F8E496197F882', topology='1-1.2.3', board='Heltec T096',
env='profile_fixed_t096_tx', fwid=0xB6, app_base=0x26000, boot_vid=0x239A, boot_pid=0x0071),
dict(channel=2, serial='9352162A72082314', topology='1-1.2.2', board='Heltec MeshTower V2',
env='profile_fixed_tower_tx', fwid=0xB6, app_base=0x26000, boot_vid=0x239A, boot_pid=0x0071),
dict(channel=3, serial='34A9141999729D5D', topology='1-1.2.1', board='Seeed T1000-E LR1110',
env='profile_fixed_t1000_tx', fwid=0x123, app_base=0x27000, boot_vid=0x2886, boot_pid=0x0057),
)
RX = dict(serial='44:1B:F6:69:CF:98', topology='1-1.3.3', board='Heltec V4', env='profile_four_tx_v4_rx')
def resolve(target, boot=False, idle=True):
from serial.tools import list_ports
from pathlib import Path
import subprocess
matches=[p for p in list_ports.comports() if p.serial_number==target['serial']
and p.location==target['topology']+':1.0']
if len(matches)!=1: raise RuntimeError('Exact primary USB interface missing/ambiguous: '+target['board'])
p=matches[0]
if boot and (p.vid,p.pid)!=(target['boot_vid'],target['boot_pid']):
raise RuntimeError(f'Unexpected bootloader identity: {p}')
if not boot and target is RX and (p.vid,p.pid)!=(0x303A,0x1001):
raise RuntimeError('Wrong RX USB chip')
if idle:
status=subprocess.run(['fuser',p.device],capture_output=True,timeout=5)
if status.returncode!=1: raise RuntimeError('Port occupied or ownership check failed: '+p.device)
return p.device
+40
View File
@@ -0,0 +1,40 @@
"""After the collector exits, reboot only the five selected bench radios idle."""
import json
from pathlib import Path
import subprocess
import sys
import time
root=Path.home()/'hwtest/runs/four-tx-sf10-20260914'
sys.path.insert(0,str(root))
from profile_four_tx_fixture import TXS,RX
from profile_four_tx import open_radio
from profile_switch import exchange
control=json.loads((root/'run-control.json').read_text())
if Path(f"/proc/{control['pid']}").exists():raise RuntimeError('Collector still running; do not interrupt it')
for t in (*TXS,RX):
p,info=open_radio(t)
try:
p.write(b'reboot\n');p.flush();time.sleep(.7)
except OSError:pass
finally:p.close()
time.sleep(3)
report={'radios':[],'services':{}}
for t in (*TXS,RX):
p,info=open_radio(t)
try:
entry={'serial':t['serial'],'board':t['board'],'info':info}
if t is RX:
entry['status']=exchange(p,'scanstatus 1','result',3,1,cached=True)
if entry['status'].get('active') or entry['status'].get('channels')!=0:
raise RuntimeError('RX did not return idle')
elif info.get('prepared') or info.get('packet_count')!=0:
raise RuntimeError('TX did not return idle')
report['radios'].append(entry)
finally:p.close()
for name in ('ModemManager.service','meshcore-memory-soak.service','mctomqtt.service','meshcore-host-cli.service'):
report['services'][name]=subprocess.run(['systemctl','is-active',name],capture_output=True,text=True).stdout.strip()
if report['services']['meshcore-memory-soak.service']!='inactive':raise RuntimeError('Soak should be paused on bench RX')
if any(report['services'][name]!='active' for name in ('ModemManager.service','mctomqtt.service','meshcore-host-cli.service')):
raise RuntimeError('Expected original gateway/service state not restored')
(root/'cleanup.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report),flush=True)
+25
View File
@@ -0,0 +1,25 @@
"""Read-only inventory for the user-authorized MercerMesh four-TX bench."""
import json
import os
from pathlib import Path
import socket
import subprocess
print(json.dumps(dict(host=socket.gethostname(),user=os.environ.get('USER'),home=str(Path.home()))),flush=True)
for path in (Path.home()/'AGENTS.md',Path.home()/'hwtest'/'AGENTS.md'):
if path.is_file(): print(str(path)+'\n'+path.read_text(),flush=True)
for command in (['id'],['lsusb'],['lsusb','-t'],['ls','-l','/dev/serial/by-id'],
['systemctl','list-units','--type=service','--state=running','--no-pager','--plain']):
done=subprocess.run(command,capture_output=True,text=True,timeout=15)
print(json.dumps(dict(command=command,rc=done.returncode,stdout=done.stdout,stderr=done.stderr)),flush=True)
try:
from serial.tools import list_ports
ports=list(list_ports.comports())
print(json.dumps(dict(ports=[dict(device=p.device,vid=p.vid,pid=p.pid,serial=p.serial_number,
product=p.product,manufacturer=p.manufacturer,interface=p.interface,
location=p.location) for p in ports])),flush=True)
for p in ports:
done=subprocess.run(['fuser','-v',p.device],capture_output=True,text=True,timeout=10)
print(json.dumps(dict(port=p.device,owners=done.stdout,details=done.stderr,rc=done.returncode)),flush=True)
except ImportError:
print('System Python lacks pyserial; USB inventory above remains read-only.',flush=True)
+10
View File
@@ -0,0 +1,10 @@
"""Frame an already-built firmware bundle for the configured SSH transport."""
import hashlib
from pathlib import Path
import struct
root=Path(__file__).resolve().parents[2]/'out/profile-four-tx-20260914'
data=(root/'bundle.zip').read_bytes();digest=hashlib.sha256(data).digest()
for offset in range(0,len(data),32768):
(root/f'chunk-{offset//32768:03d}.bin').write_bytes(struct.pack('<II',offset,len(data))+digest+data[offset:offset+32768])
print('Framed',len(data),'bytes')
+55
View File
@@ -0,0 +1,55 @@
"""Package already-built bench artifacts locally, with exact DFU/image gates."""
import hashlib
import io
import json
from pathlib import Path
import subprocess
import zipfile
from intelhex import IntelHex
from profile_four_tx_fixture import TXS,RX
ROOT=Path(__file__).resolve().parents[2]
DEST=ROOT/'out'/'profile-four-tx-20260914'
DEST.mkdir(parents=True,exist_ok=True)
contents={}
manifest={'git_head':subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip(),
'scope':'Dirty-worktree HIL artifacts; four physical fixed-channel TXs and one fast single-pass RX',
'targets':TXS,'receiver':RX,'files':{}}
def add(name,data):
if name in contents: raise ValueError('Duplicate artifact')
contents[name]=data
manifest['files'][name]={'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()}
for t in TXS:
build=ROOT/'.pio/build'/t['env']
payload=(build/'firmware.zip').read_bytes()
with zipfile.ZipFile(io.BytesIO(payload)) as z:
m=json.loads(z.read('manifest.json'))['manifest']
if set(m)!={'application','dfu_version'}: raise ValueError('Not application-only DFU')
app=m['application']
if app['init_packet_data']['softdevice_req']!=[t['fwid']]: raise ValueError('Wrong SoftDevice requirement')
binary=z.read(app['bin_file'])
if len(binary)>0xED000-t['app_base']: raise ValueError('Image exceeds safe application range')
image=IntelHex(str(build/'firmware.hex'))
addresses=image.addresses()
if min(addresses)!=t['app_base'] or max(addresses)>=0xED000: raise ValueError('HEX writes outside application')
if bytes(image.tobinarray())!=binary:raise ValueError('DFU binary disagrees with linked application')
add(t['env']+'.zip',payload)
manifest['files'][t['env']+'.zip'].update(app_base=min(addresses),image_end=max(addresses)+1,fwid=t['fwid'])
for name in ('firmware.bin','partitions.bin','bootloader.bin'):
add('rx-'+name,(ROOT/'.pio/build'/RX['env']/name).read_bytes())
add('rx-boot_app0.bin',(Path.home()/'.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin').read_bytes())
for name in ('profile_four_tx_fixture.py','profile_four_tx_deploy.py','profile_four_tx.py',
'profile_switch.py','profile_switch_channels.py','profile_switch_packets.py',
'profile_switch_sweep.py'):
add(name,(ROOT/'tools/hil'/name).read_bytes())
for name in ('profile_fixed_tx.cpp','profile_fixed_tx.ini','profile_switch.cpp','profile_switch_channels.h',
'ProfileChannelTrace.h','ProfileFrequencyOffset.h'):
add(name,(ROOT/'tools/hil'/name).read_bytes())
with zipfile.ZipFile(DEST/'bundle.zip','x',zipfile.ZIP_DEFLATED) as z:
for name,data in contents.items():z.writestr(name,data)
z.writestr('manifest.json',json.dumps(manifest,indent=2))
(DEST/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
print(json.dumps({'bundle':str(DEST/'bundle.zip'),'sha256':hashlib.sha256((DEST/'bundle.zip').read_bytes()).hexdigest(),
'files':manifest['files']},indent=2))
+4
View File
@@ -0,0 +1,4 @@
import json
from serial.tools import list_ports
print(json.dumps([dict(port=p.device,serial=p.serial_number,vid=p.vid,pid=p.pid,product=p.product,location=p.location)
for p in list_ports.comports()]),flush=True)
+16
View File
@@ -0,0 +1,16 @@
"""Read-only MercerMesh tool, privilege and service preflight."""
import json
import os
import shutil
import subprocess
from pathlib import Path
for name in ('python3', 'esptool', 'adafruit-nrfutil', 'mount', 'fuser', 'lsblk'):
paths = [shutil.which(name), str(Path.home()/'.local/bin'/name)]
print(json.dumps({'tool': name, 'paths': [p for p in paths if p and os.path.exists(p)]}), flush=True)
for command in (['sudo', '-n', 'true'],
['systemctl', 'show', 'meshcore-memory-soak.service', '-p', 'ActiveState', '-p', 'ExecStart'],
['lsblk', '-J', '-o', 'NAME,PATH,FSTYPE,MOUNTPOINTS,TYPE'],
['ls', '-la', '/home/mikec/hwtest']):
r = subprocess.run(command, text=True, capture_output=True, timeout=15)
print(json.dumps({'command': command, 'rc': r.returncode, 'out': r.stdout, 'err': r.stderr}), flush=True)
@@ -0,0 +1,4 @@
import json
from pathlib import Path
root=Path.home()/'hwtest/runs/four-tx-sf10-20260914'
print((root/'deployment.json').read_text(),flush=True)
@@ -0,0 +1,9 @@
"""Export only the 7.7-symbol experiment records, without radio I/O."""
import base64
import json
from pathlib import Path
import zlib
root=Path.home()/'hwtest/runs/four-tx-sf10-7p7-20260914'
names=('results.json','deployment.json','manifest.json','launch.json','cleanup.json')
records={name:(root/name).read_text() for name in names}
print('FOUR_TX_7P7_CAPTURE '+base64.b64encode(zlib.compress(json.dumps(records).encode(),9)).decode(),flush=True)
+24
View File
@@ -0,0 +1,24 @@
"""Retrieve 7.7-symbol experiment records without changing earlier captures."""
import base64
import hashlib
import json
from pathlib import Path
import subprocess
import zlib
root=Path(__file__).resolve().parents[2]
p=subprocess.run(['pwsh','-NoProfile','-File',r'C:\git\Adafruit_nRF52_Bootloader_OTAFIX\.tmp_mercermesh_bridge.ps1',
'-ScriptPath',str(Path(__file__).with_name('profile_four_tx_repeat_export.py')),'-DeadlineSeconds','60'],
capture_output=True,text=True,cwd=root,timeout=65,check=True)
prefix='FOUR_TX_7P7_CAPTURE '
lines=[line.removeprefix(prefix) for line in p.stdout.splitlines() if line.startswith(prefix)]
if len(lines)!=1 or 'MERCERMESH_OPERATION_EXIT 0' not in p.stdout:raise RuntimeError('Incomplete capture export')
records=json.loads(zlib.decompress(base64.b64decode(lines[0],validate=True)))
names={'results.json','deployment.json','manifest.json','launch.json','cleanup.json'}
if set(records)!=names:raise RuntimeError('Unexpected record names')
targets={name:root/'tools/hil'/('profile_four_tx_sf10_7p7_'+name) for name in names}
if any(path.exists() for path in targets.values()):raise RuntimeError('Preserve existing local captures')
for name,record in records.items():
json.loads(record)
data=record.encode('utf8')
with targets[name].open('xb') as f:f.write(data)
print(json.dumps({'file':str(targets[name]),'sha256':hashlib.sha256(data).hexdigest()}))
+99
View File
@@ -0,0 +1,99 @@
"""Reuse the verified five-radio firmware for the requested 7.7-symbol repeat.
Executed on the Pi through the existing configured bridge. The only payload
is the reviewed collector; dependencies and image provenance are hash-checked
against the previous bench bundle. No flashing or hub reset is performed.
"""
import base64
import hashlib
import json
from pathlib import Path
import signal
import subprocess
import sys
import time
previous=Path.home()/'hwtest/runs/four-tx-sf10-20260914'
root=Path.home()/'hwtest/runs/four-tx-sf10-7p7-20260914'
dwell_symbols=7.7
if root.exists():raise RuntimeError('Run directory already exists; no implicit rerun')
manifest=json.loads((previous/'manifest.json').read_text())
deployment=json.loads((previous/'deployment.json').read_text())
if not deployment.get('complete'):raise RuntimeError('Previous deployment not verified')
if subprocess.run(['systemctl','is-active','--quiet','meshcore-memory-soak.service']).returncode==0:
raise RuntimeError('V4 soak active; do not interrupt another test')
dependencies=('profile_four_tx_fixture.py','profile_switch.py','profile_switch_channels.py',
'profile_switch_packets.py','profile_switch_sweep.py')
files={name:(previous/name).read_bytes() for name in dependencies}
for name,data in files.items():
if hashlib.sha256(data).hexdigest()!=manifest['files'][name]['sha256']:
raise RuntimeError('Changed bench dependency: '+name)
code=base64.b64decode('__PAYLOAD_B64__',validate=True)
root.mkdir()
for name,data in files.items():
with (root/name).open('xb') as f:f.write(data)
with (root/'profile_four_tx.py').open('xb') as f:f.write(code)
for name in ('deployment.json','manifest.json'):
with (root/name).open('xb') as f:f.write((previous/name).read_bytes())
launch=dict(previous_run=str(previous),run=str(root),firmware_reused=True,dwell_symbols=dwell_symbols,
collector_sha256=hashlib.sha256(code).hexdigest(),
started_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()))
with (root/'launch.json').open('x') as f:json.dump(launch,f,indent=2);f.write('\n')
print(json.dumps(launch),flush=True)
mm=subprocess.run(['systemctl','is-active','--quiet','ModemManager.service']).returncode==0
child=None
rc=1
cleanup={'radios':[],'services':{},'errors':[]}
try:
if mm:subprocess.run(['sudo','-n','systemctl','stop','ModemManager.service'],check=True)
child=subprocess.Popen(['python3','-u',str(root/'profile_four_tx.py'),'--dwell-symbols',str(dwell_symbols)],cwd=root)
with (root/'run-control.json').open('x') as f:
json.dump({'pid':child.pid,'collector':'profile_four_tx.py','modemmanager_before':mm},f)
rc=child.wait(timeout=6000)
finally:
if child is not None and child.poll() is None:
child.send_signal(signal.SIGINT)
try:child.wait(timeout=10)
except subprocess.TimeoutExpired:
child.terminate()
try:child.wait(timeout=5)
except subprocess.TimeoutExpired:child.kill();child.wait(timeout=5)
# Independently verify idle after the collector closes its ports. Keep
# each reboot session open long enough for the native USB command to land.
sys.path.insert(0,str(root))
from profile_four_tx_fixture import TXS,RX
from profile_four_tx import open_radio
from profile_switch import exchange
time.sleep(3)
for target in ((*TXS,RX) if child is not None else ()):
try:
p,info=open_radio(target)
try:p.write(b'reboot\n');p.flush();time.sleep(.7)
finally:p.close()
except Exception as error:cleanup['errors'].append(target['board']+': '+str(error))
time.sleep(3)
for target in ((*TXS,RX) if child is not None else ()):
try:
p,info=open_radio(target)
try:
row={'board':target['board'],'serial':target['serial'],'info':info}
if info.get('board')!=target['board']:raise RuntimeError('Wrong live board')
if target is RX:
row['status']=exchange(p,'scanstatus 1','result',3,1,cached=True)
if row['status'].get('active') or row['status'].get('channels')!=0:
raise RuntimeError('RX not idle')
elif info.get('prepared') or info.get('packet_count')!=0 or info.get('failed') or info.get('autonomous_tx'):
raise RuntimeError('TX not idle')
cleanup['radios'].append(row)
finally:p.close()
except Exception as error:cleanup['errors'].append(target['board']+': '+str(error))
if mm:
restored=subprocess.run(['sudo','-n','systemctl','start','ModemManager.service'])
if restored.returncode:cleanup['errors'].append('ModemManager restore failed')
for name in ('ModemManager.service','meshcore-memory-soak.service','mctomqtt.service','meshcore-host-cli.service'):
cleanup['services'][name]=subprocess.run(['systemctl','is-active',name],capture_output=True,text=True).stdout.strip()
cleanup['verified_idle']=len(cleanup['radios'])==5 and not cleanup['errors']
with (root/'cleanup.json').open('x') as f:json.dump(cleanup,f,indent=2);f.write('\n')
print(json.dumps({'collector_exit':rc,'cleanup':cleanup}),flush=True)
if not cleanup['verified_idle']:raise RuntimeError('Bench cleanup requires attention')
raise SystemExit(rc)
+30
View File
@@ -0,0 +1,30 @@
"""Run the current reviewed collector through the configured MercerMesh bridge."""
import base64
import hashlib
import json
from pathlib import Path
import subprocess
import signal
root=Path.home()/'hwtest/runs/four-tx-sf10-20260914'
code=base64.b64decode('__PAYLOAD_B64__',validate=True)
path=root/'profile_four_tx_v2.py'
with path.open('xb') as f:f.write(code)
print(json.dumps({'collector':path.name,'sha256':hashlib.sha256(code).hexdigest()}),flush=True)
mm=subprocess.run(['systemctl','is-active','--quiet','ModemManager.service']).returncode==0
child=None
try:
if mm:subprocess.run(['sudo','-n','systemctl','stop','ModemManager.service'],check=True)
child=subprocess.Popen(['python3','-u',str(path)],cwd=root)
(root/'run-control.json').write_text(json.dumps({'pid':child.pid,'collector':path.name,'modemmanager_before':mm}))
rc=child.wait(timeout=6000)
finally:
if child is not None and child.poll() is None:
child.send_signal(signal.SIGINT) # Let the collector close/reboot its exact ports.
try:child.wait(timeout=10)
except subprocess.TimeoutExpired:
child.terminate()
try:child.wait(timeout=5)
except subprocess.TimeoutExpired:child.kill();child.wait(timeout=5)
if mm:subprocess.run(['sudo','-n','systemctl','start','ModemManager.service'],check=True)
raise SystemExit(rc)
@@ -0,0 +1,205 @@
{
"radios": [
{
"board": "RAK4631",
"serial": "9AB3B64C641BA927",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"board": "Heltec T096",
"serial": "651F8E496197F882",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"board": "Heltec MeshTower V2",
"serial": "9352162A72082314",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"board": "Seeed T1000-E LR1110",
"serial": "34A9141999729D5D",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
{
"board": "Heltec V4",
"serial": "44:1B:F6:69:CF:98",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
},
"status": {
"result": 1,
"channels": 0,
"active": false,
"received": 0,
"missed": 0,
"deferred": 0,
"failures": 0,
"rx_mode_errors": 0,
"cache_errors": 0,
"rx_errors": 0,
"optimized_rx_resumes": 0,
"modulation_cache": true,
"rf_commands": 1,
"modulation_commands": 3,
"rx_gain_reg": 148,
"switch": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"with_modulation": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"without_modulation": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"settle_us": 0,
"continue_on_miss": false,
"frequency_repeat": false,
"tcxo_us": 1600,
"channel_step_khz": 250,
"mixed_profiles": false,
"retune_passes": 1,
"second_pass_blocked": 0,
"first_pass_detour": false,
"detour": {
"n": 0,
"bad": 0,
"first_rf": 0,
"final_rf": 0,
"first_mod": 0,
"final_mod": 0
},
"first_pass": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"second_pass": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"accept_offchannel": false,
"strict_received": 0,
"offchannel_received": 0,
"rollback": 0,
"warm_standby": false,
"batched_modulation": true,
"bulk_spi": false,
"settling": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"switch_and_settle": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"idle_cycle": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"idle_dwell": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"held_dwell": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
}
}
}
],
"services": {
"ModemManager.service": "active",
"meshcore-memory-soak.service": "inactive",
"mctomqtt.service": "active",
"meshcore-host-cli.service": "active"
},
"errors": [],
"verified_idle": true
}
@@ -0,0 +1,254 @@
{
"complete": true,
"tx": [
{
"target": {
"channel": 0,
"serial": "9AB3B64C641BA927",
"topology": "1-1.3.1",
"board": "RAK4631",
"env": "profile_fixed_rak_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 41
},
"port_before": "/dev/ttyACM3",
"package_sha256": "0a67e811f2b7e33208866ba27d4e6c396a1c077b022bf84f6c986689bf71ca87",
"after": {
"port": "/dev/ttyACM3",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 1,
"serial": "651F8E496197F882",
"topology": "1-1.2.3",
"board": "Heltec T096",
"env": "profile_fixed_t096_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
"port_before": "/dev/ttyACM2",
"package_sha256": "d6d2a4e22bb4f654ad44e29f9ae684b671e13f91583cd56c2c27bb670045e9c0",
"after": {
"port": "/dev/ttyACM2",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 2,
"serial": "9352162A72082314",
"topology": "1-1.2.2",
"board": "Heltec MeshTower V2",
"env": "profile_fixed_tower_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
"port_before": "/dev/ttyACM4",
"package_sha256": "28fdb7373a2628aaed9ca8c0d305ac220c29035604364e621f48a8d4c5a255c8",
"after": {
"port": "/dev/ttyACM1",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 3,
"serial": "34A9141999729D5D",
"topology": "1-1.2.1",
"board": "Seeed T1000-E LR1110",
"env": "profile_fixed_t1000_tx",
"fwid": 291,
"app_base": 159744,
"boot_vid": 10374,
"boot_pid": 87
},
"port_before": "/dev/ttyACM4",
"package_sha256": "153c8f129f1d31d9fa40f1931cb39a639179902f0c0e9ffd9b05e0bef854c140",
"after": {
"port": "/dev/ttyACM4",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
"complete": true
}
],
"rx": {
"write_started": true,
"after": {
"port": "/dev/ttyACM6",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
}
},
"complete": true
},
"services_before": {
"ModemManager.service": true,
"meshcore-memory-soak.service": true
},
"previous_error": "V4 RX gain mismatch",
"post_boot_verification": [
{
"target": "RAK4631",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Heltec T096",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Heltec MeshTower V2",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Seeed T1000-E LR1110",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
{
"target": "Heltec V4",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
}
}
]
}
@@ -0,0 +1,8 @@
{
"previous_run": "/home/mikec/hwtest/runs/four-tx-sf10-20260914",
"run": "/home/mikec/hwtest/runs/four-tx-sf10-6p1-20260914",
"firmware_reused": true,
"dwell_symbols": 6.1,
"collector_sha256": "cceb8a570f4aedeaf45d28b4b4590b23df3c1efd490c8e8e9cd74a23ba00024f",
"started_utc": "2026-09-14T22:44:47Z"
}
@@ -0,0 +1,154 @@
{
"git_head": "2b5a6f209835f0f67ed73fee78d36447a4566ce6",
"scope": "Dirty-worktree HIL artifacts; four physical fixed-channel TXs and one fast single-pass RX",
"targets": [
{
"channel": 0,
"serial": "9AB3B64C641BA927",
"topology": "1-1.3.1",
"board": "RAK4631",
"env": "profile_fixed_rak_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 41
},
{
"channel": 1,
"serial": "651F8E496197F882",
"topology": "1-1.2.3",
"board": "Heltec T096",
"env": "profile_fixed_t096_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
{
"channel": 2,
"serial": "9352162A72082314",
"topology": "1-1.2.2",
"board": "Heltec MeshTower V2",
"env": "profile_fixed_tower_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
{
"channel": 3,
"serial": "34A9141999729D5D",
"topology": "1-1.2.1",
"board": "Seeed T1000-E LR1110",
"env": "profile_fixed_t1000_tx",
"fwid": 291,
"app_base": 159744,
"boot_vid": 10374,
"boot_pid": 87
}
],
"receiver": {
"serial": "44:1B:F6:69:CF:98",
"topology": "1-1.3.3",
"board": "Heltec V4",
"env": "profile_four_tx_v4_rx"
},
"files": {
"profile_fixed_rak_tx.zip": {
"bytes": 93308,
"sha256": "0a67e811f2b7e33208866ba27d4e6c396a1c077b022bf84f6c986689bf71ca87",
"app_base": 155648,
"image_end": 248136,
"fwid": 182
},
"profile_fixed_t096_tx.zip": {
"bytes": 93291,
"sha256": "d6d2a4e22bb4f654ad44e29f9ae684b671e13f91583cd56c2c27bb670045e9c0",
"app_base": 155648,
"image_end": 248120,
"fwid": 182
},
"profile_fixed_tower_tx.zip": {
"bytes": 93356,
"sha256": "28fdb7373a2628aaed9ca8c0d305ac220c29035604364e621f48a8d4c5a255c8",
"app_base": 155648,
"image_end": 248184,
"fwid": 182
},
"profile_fixed_t1000_tx.zip": {
"bytes": 88020,
"sha256": "153c8f129f1d31d9fa40f1931cb39a639179902f0c0e9ffd9b05e0bef854c140",
"app_base": 159744,
"image_end": 246944,
"fwid": 291
},
"rx-firmware.bin": {
"bytes": 366800,
"sha256": "e112b566ec72b84692e65a774d1ccce1d8789d7eed19c9e453a7837a594ae29d"
},
"rx-partitions.bin": {
"bytes": 3072,
"sha256": "bd0f7954aca2ef7d925ee21aaa1f3dc8822d1d6ce5cbbd26a135e5886bfff6ce"
},
"rx-bootloader.bin": {
"bytes": 15104,
"sha256": "2a71d69b471e20c2bac7fb469f3c6a807b3ebee780e348e5889db0da849ca363"
},
"rx-boot_app0.bin": {
"bytes": 8192,
"sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0"
},
"profile_four_tx_fixture.py": {
"bytes": 1852,
"sha256": "8c2e12e2450648c6a7a25d4d2567666e2df21eebc7abaa712b1909cd33200c6e"
},
"profile_four_tx_deploy.py": {
"bytes": 5044,
"sha256": "b8f804d511dcd4195caf63d0335e31d8818bafa66b21a4a381382773d3c6c3c6"
},
"profile_four_tx.py": {
"bytes": 11040,
"sha256": "3bcc13b7c121bb1148bc69f1083a433114975ca19780aaaf88a29f675afc0d87"
},
"profile_switch.py": {
"bytes": 6249,
"sha256": "097e61cc128f96aa36964578eecfa3fae2de49f75867fe09fc0ac039fd639aae"
},
"profile_switch_channels.py": {
"bytes": 23912,
"sha256": "9766bafe61e5b8d2ffbe06e8f2ccf8a7be28bcf2edb922e9790347ea9d92e000"
},
"profile_switch_packets.py": {
"bytes": 6679,
"sha256": "6f572549af4f4cf01c947160704d5f6d2b647e84457f064c3857e0153b8ca29c"
},
"profile_switch_sweep.py": {
"bytes": 4260,
"sha256": "710509970501bf2c5fdaec29ed68dff735a526488acac1bd209d30b3bb601fa5"
},
"profile_fixed_tx.cpp": {
"bytes": 6984,
"sha256": "26d0e01148e0433099e4d325215618c3c18ea44bee05a5d1bc1643d2020ea108"
},
"profile_fixed_tx.ini": {
"bytes": 2371,
"sha256": "4d5a5f16a2b16dd9d4a8e7a0b2f9d0b46dca023105e30cc0eb5ea4fd81b81211"
},
"profile_switch.cpp": {
"bytes": 26548,
"sha256": "0b9852726dd22c5077b741ce3f93bd76b6e03be7f47196e38faf0c5186bafca6"
},
"profile_switch_channels.h": {
"bytes": 19414,
"sha256": "32b29b5ef41c04b92b63427df294ebdbb49410d145f85cd213e99fc1794f9d3f"
},
"ProfileChannelTrace.h": {
"bytes": 2227,
"sha256": "5b417732b0c9b412802ede3fcada95e3b4f5d038c65a06816677bacdcd657c86"
},
"ProfileFrequencyOffset.h": {
"bytes": 1114,
"sha256": "cd54f7af39330bdbf1c0c20db73095815b9c07ac3d57aa3a3af6ee8dbf9df90d"
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"reason": "User requested stop and next test at 7.7 symbols", "pid": 8689}
@@ -0,0 +1,209 @@
{
"radios": [
{
"board": "RAK4631",
"serial": "9AB3B64C641BA927",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"board": "Heltec T096",
"serial": "651F8E496197F882",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"board": "Heltec MeshTower V2",
"serial": "9352162A72082314",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"board": "Seeed T1000-E LR1110",
"serial": "34A9141999729D5D",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
{
"board": "Heltec V4",
"serial": "44:1B:F6:69:CF:98",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
},
"status": {
"result": 1,
"channels": 0,
"active": false,
"received": 0,
"missed": 0,
"deferred": 0,
"failures": 0,
"rx_mode_errors": 0,
"cache_errors": 0,
"rx_errors": 0,
"optimized_rx_resumes": 0,
"modulation_cache": true,
"rf_commands": 1,
"modulation_commands": 3,
"rx_gain_reg": 148,
"switch": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"with_modulation": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"without_modulation": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"settle_us": 0,
"continue_on_miss": false,
"frequency_repeat": false,
"tcxo_us": 1600,
"channel_step_khz": 250,
"mixed_profiles": false,
"retune_passes": 1,
"second_pass_blocked": 0,
"first_pass_detour": false,
"detour": {
"n": 0,
"bad": 0,
"first_rf": 0,
"final_rf": 0,
"first_mod": 0,
"final_mod": 0
},
"first_pass": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"second_pass": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"accept_offchannel": false,
"strict_received": 0,
"offchannel_received": 0,
"rollback": 0,
"warm_standby": false,
"batched_modulation": true,
"bulk_spi": false,
"settling": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"switch_and_settle": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"idle_cycle": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"idle_dwell": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"held_dwell": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
}
}
}
],
"services": {
"ModemManager.service": "active",
"meshcore-memory-soak.service": "inactive",
"mctomqtt.service": "active",
"meshcore-host-cli.service": "active"
},
"errors": [
"Heltec T096: Exact primary USB interface missing/ambiguous: Heltec T096",
"Heltec MeshTower V2: [Errno 13] could not open port /dev/ttyACM1: [Errno 13] Permission denied: '/dev/ttyACM1'",
"Seeed T1000-E LR1110: [Errno 13] could not open port /dev/ttyACM4: [Errno 13] Permission denied: '/dev/ttyACM4'"
],
"verified_idle": false
}
@@ -0,0 +1,254 @@
{
"complete": true,
"tx": [
{
"target": {
"channel": 0,
"serial": "9AB3B64C641BA927",
"topology": "1-1.3.1",
"board": "RAK4631",
"env": "profile_fixed_rak_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 41
},
"port_before": "/dev/ttyACM3",
"package_sha256": "0a67e811f2b7e33208866ba27d4e6c396a1c077b022bf84f6c986689bf71ca87",
"after": {
"port": "/dev/ttyACM3",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 1,
"serial": "651F8E496197F882",
"topology": "1-1.2.3",
"board": "Heltec T096",
"env": "profile_fixed_t096_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
"port_before": "/dev/ttyACM2",
"package_sha256": "d6d2a4e22bb4f654ad44e29f9ae684b671e13f91583cd56c2c27bb670045e9c0",
"after": {
"port": "/dev/ttyACM2",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 2,
"serial": "9352162A72082314",
"topology": "1-1.2.2",
"board": "Heltec MeshTower V2",
"env": "profile_fixed_tower_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
"port_before": "/dev/ttyACM4",
"package_sha256": "28fdb7373a2628aaed9ca8c0d305ac220c29035604364e621f48a8d4c5a255c8",
"after": {
"port": "/dev/ttyACM1",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 3,
"serial": "34A9141999729D5D",
"topology": "1-1.2.1",
"board": "Seeed T1000-E LR1110",
"env": "profile_fixed_t1000_tx",
"fwid": 291,
"app_base": 159744,
"boot_vid": 10374,
"boot_pid": 87
},
"port_before": "/dev/ttyACM4",
"package_sha256": "153c8f129f1d31d9fa40f1931cb39a639179902f0c0e9ffd9b05e0bef854c140",
"after": {
"port": "/dev/ttyACM4",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
"complete": true
}
],
"rx": {
"write_started": true,
"after": {
"port": "/dev/ttyACM6",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
}
},
"complete": true
},
"services_before": {
"ModemManager.service": true,
"meshcore-memory-soak.service": true
},
"previous_error": "V4 RX gain mismatch",
"post_boot_verification": [
{
"target": "RAK4631",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Heltec T096",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Heltec MeshTower V2",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Seeed T1000-E LR1110",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
{
"target": "Heltec V4",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
}
}
]
}
@@ -0,0 +1,8 @@
{
"previous_run": "/home/mikec/hwtest/runs/four-tx-sf10-20260914",
"run": "/home/mikec/hwtest/runs/four-tx-sf10-7p7-20260914",
"firmware_reused": true,
"dwell_symbols": 7.7,
"collector_sha256": "cceb8a570f4aedeaf45d28b4b4590b23df3c1efd490c8e8e9cd74a23ba00024f",
"started_utc": "2026-09-14T22:58:14Z"
}
@@ -0,0 +1,154 @@
{
"git_head": "2b5a6f209835f0f67ed73fee78d36447a4566ce6",
"scope": "Dirty-worktree HIL artifacts; four physical fixed-channel TXs and one fast single-pass RX",
"targets": [
{
"channel": 0,
"serial": "9AB3B64C641BA927",
"topology": "1-1.3.1",
"board": "RAK4631",
"env": "profile_fixed_rak_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 41
},
{
"channel": 1,
"serial": "651F8E496197F882",
"topology": "1-1.2.3",
"board": "Heltec T096",
"env": "profile_fixed_t096_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
{
"channel": 2,
"serial": "9352162A72082314",
"topology": "1-1.2.2",
"board": "Heltec MeshTower V2",
"env": "profile_fixed_tower_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
{
"channel": 3,
"serial": "34A9141999729D5D",
"topology": "1-1.2.1",
"board": "Seeed T1000-E LR1110",
"env": "profile_fixed_t1000_tx",
"fwid": 291,
"app_base": 159744,
"boot_vid": 10374,
"boot_pid": 87
}
],
"receiver": {
"serial": "44:1B:F6:69:CF:98",
"topology": "1-1.3.3",
"board": "Heltec V4",
"env": "profile_four_tx_v4_rx"
},
"files": {
"profile_fixed_rak_tx.zip": {
"bytes": 93308,
"sha256": "0a67e811f2b7e33208866ba27d4e6c396a1c077b022bf84f6c986689bf71ca87",
"app_base": 155648,
"image_end": 248136,
"fwid": 182
},
"profile_fixed_t096_tx.zip": {
"bytes": 93291,
"sha256": "d6d2a4e22bb4f654ad44e29f9ae684b671e13f91583cd56c2c27bb670045e9c0",
"app_base": 155648,
"image_end": 248120,
"fwid": 182
},
"profile_fixed_tower_tx.zip": {
"bytes": 93356,
"sha256": "28fdb7373a2628aaed9ca8c0d305ac220c29035604364e621f48a8d4c5a255c8",
"app_base": 155648,
"image_end": 248184,
"fwid": 182
},
"profile_fixed_t1000_tx.zip": {
"bytes": 88020,
"sha256": "153c8f129f1d31d9fa40f1931cb39a639179902f0c0e9ffd9b05e0bef854c140",
"app_base": 159744,
"image_end": 246944,
"fwid": 291
},
"rx-firmware.bin": {
"bytes": 366800,
"sha256": "e112b566ec72b84692e65a774d1ccce1d8789d7eed19c9e453a7837a594ae29d"
},
"rx-partitions.bin": {
"bytes": 3072,
"sha256": "bd0f7954aca2ef7d925ee21aaa1f3dc8822d1d6ce5cbbd26a135e5886bfff6ce"
},
"rx-bootloader.bin": {
"bytes": 15104,
"sha256": "2a71d69b471e20c2bac7fb469f3c6a807b3ebee780e348e5889db0da849ca363"
},
"rx-boot_app0.bin": {
"bytes": 8192,
"sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0"
},
"profile_four_tx_fixture.py": {
"bytes": 1852,
"sha256": "8c2e12e2450648c6a7a25d4d2567666e2df21eebc7abaa712b1909cd33200c6e"
},
"profile_four_tx_deploy.py": {
"bytes": 5044,
"sha256": "b8f804d511dcd4195caf63d0335e31d8818bafa66b21a4a381382773d3c6c3c6"
},
"profile_four_tx.py": {
"bytes": 11040,
"sha256": "3bcc13b7c121bb1148bc69f1083a433114975ca19780aaaf88a29f675afc0d87"
},
"profile_switch.py": {
"bytes": 6249,
"sha256": "097e61cc128f96aa36964578eecfa3fae2de49f75867fe09fc0ac039fd639aae"
},
"profile_switch_channels.py": {
"bytes": 23912,
"sha256": "9766bafe61e5b8d2ffbe06e8f2ccf8a7be28bcf2edb922e9790347ea9d92e000"
},
"profile_switch_packets.py": {
"bytes": 6679,
"sha256": "6f572549af4f4cf01c947160704d5f6d2b647e84457f064c3857e0153b8ca29c"
},
"profile_switch_sweep.py": {
"bytes": 4260,
"sha256": "710509970501bf2c5fdaec29ed68dff735a526488acac1bd209d30b3bb601fa5"
},
"profile_fixed_tx.cpp": {
"bytes": 6984,
"sha256": "26d0e01148e0433099e4d325215618c3c18ea44bee05a5d1bc1643d2020ea108"
},
"profile_fixed_tx.ini": {
"bytes": 2371,
"sha256": "4d5a5f16a2b16dd9d4a8e7a0b2f9d0b46dca023105e30cc0eb5ea4fd81b81211"
},
"profile_switch.cpp": {
"bytes": 26548,
"sha256": "0b9852726dd22c5077b741ce3f93bd76b6e03be7f47196e38faf0c5186bafca6"
},
"profile_switch_channels.h": {
"bytes": 19414,
"sha256": "32b29b5ef41c04b92b63427df294ebdbb49410d145f85cd213e99fc1794f9d3f"
},
"ProfileChannelTrace.h": {
"bytes": 2227,
"sha256": "5b417732b0c9b412802ede3fcada95e3b4f5d038c65a06816677bacdcd657c86"
},
"ProfileFrequencyOffset.h": {
"bytes": 1114,
"sha256": "cd54f7af39330bdbf1c0c20db73095815b9c07ac3d57aa3a3af6ee8dbf9df90d"
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,549 @@
{
"sampling_complete": true,
"post_run_validation_passed": true,
"original_collector_complete": true,
"raw_capture_sha256": "5e3f9a80af1384c3072303428b75c8550ac42d2ac95bd1234bf92cb0aea625e8",
"validation_note": null,
"started_utc": "2026-09-14T22:58:15Z",
"finished_utc": "2026-09-14T23:11:45Z",
"dwell_symbols": 7.7,
"dwell_us": 63079,
"rates": {
"overall": {
"attempted": 400,
"strict_received": 395,
"payload_received": 395,
"offchannel_received": 0,
"timeouts": 5,
"invalid_payloads": 0,
"strict_errors": 5,
"delivery_errors": 5,
"strict_error_percent": 1.25,
"delivery_error_percent": 1.25
},
"per_channel": [
{
"channel": 0,
"attempted": 100,
"strict_received": 100,
"payload_received": 100,
"offchannel_received": 0,
"timeouts": 0,
"invalid_payloads": 0,
"strict_errors": 0,
"delivery_errors": 0,
"strict_error_percent": 0.0,
"delivery_error_percent": 0.0
},
{
"channel": 1,
"attempted": 100,
"strict_received": 99,
"payload_received": 99,
"offchannel_received": 0,
"timeouts": 1,
"invalid_payloads": 0,
"strict_errors": 1,
"delivery_errors": 1,
"strict_error_percent": 1.0,
"delivery_error_percent": 1.0
},
{
"channel": 2,
"attempted": 100,
"strict_received": 97,
"payload_received": 97,
"offchannel_received": 0,
"timeouts": 3,
"invalid_payloads": 0,
"strict_errors": 3,
"delivery_errors": 3,
"strict_error_percent": 3.0,
"delivery_error_percent": 3.0
},
{
"channel": 3,
"attempted": 100,
"strict_received": 99,
"payload_received": 99,
"offchannel_received": 0,
"timeouts": 1,
"invalid_payloads": 0,
"strict_errors": 1,
"delivery_errors": 1,
"strict_error_percent": 1.0,
"delivery_error_percent": 1.0
}
]
},
"status": {
"result": 349208370,
"channels": 4,
"active": true,
"received": 395,
"missed": 5,
"deferred": 3463365,
"failures": 0,
"rx_mode_errors": 0,
"cache_errors": 0,
"rx_errors": 0,
"optimized_rx_resumes": 5046,
"modulation_cache": true,
"rf_commands": 5046,
"modulation_commands": 395,
"rx_gain_reg": 148,
"switch": {
"n": 5046,
"min_us": 438,
"mean_us": 524.146,
"max_us": 804
},
"with_modulation": {
"n": 395,
"min_us": 760,
"mean_us": 783.18,
"max_us": 804
},
"without_modulation": {
"n": 4651,
"min_us": 438,
"mean_us": 502.147,
"max_us": 686
},
"settle_us": 0,
"continue_on_miss": true,
"frequency_repeat": false,
"tcxo_us": 1600,
"channel_step_khz": 1000,
"mixed_profiles": false,
"retune_passes": 1,
"second_pass_blocked": 0,
"first_pass_detour": false,
"detour": {
"n": 0,
"bad": 0,
"first_rf": 0,
"final_rf": 0,
"first_mod": 0,
"final_mod": 0
},
"first_pass": {
"n": 5046,
"min_us": 434,
"mean_us": 516.295,
"max_us": 793
},
"second_pass": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"accept_offchannel": false,
"strict_received": 395,
"offchannel_received": 0,
"rollback": 0,
"warm_standby": true,
"batched_modulation": true,
"bulk_spi": true,
"settling": {
"n": 5046,
"min_us": 1,
"mean_us": 1.75,
"max_us": 9
},
"switch_and_settle": {
"n": 5046,
"min_us": 440,
"mean_us": 525.896,
"max_us": 806
},
"idle_cycle": {
"n": 860,
"min_us": 254208,
"mean_us": 254351.387,
"max_us": 255482
},
"idle_dwell": {
"n": 4645,
"min_us": 63079,
"mean_us": 63083.54,
"max_us": 64108
},
"held_dwell": {
"n": 401,
"min_us": 63085,
"mean_us": 496123.798,
"max_us": 629433
}
},
"baseline": [
{
"channel": 0,
"received": 100,
"signals": {
"rssi": {
"n": 100,
"mean": -21.02,
"min": -22.0,
"max": -21.0
},
"snr": {
"n": 100,
"mean": 8.113,
"min": 7.8,
"max": 9.0
}
},
"status": {
"result": 349207662,
"stationary": true,
"active": true,
"received": 100,
"missed": 0,
"read_errors": 0,
"foreign": 0,
"failures": 0,
"rf_commands": 0,
"rf_word": 953679872,
"rx_gain_reg": 148,
"rx_mode": 1,
"device_errors": 0
}
},
{
"channel": 1,
"received": 100,
"signals": {
"rssi": {
"n": 100,
"mean": -4.06,
"min": -5.0,
"max": -3.0
},
"snr": {
"n": 100,
"mean": 8.623,
"min": 7.8,
"max": 9.8
}
},
"status": {
"result": 349207764,
"stationary": true,
"active": true,
"received": 100,
"missed": 0,
"read_errors": 0,
"foreign": 0,
"failures": 0,
"rf_commands": 0,
"rf_word": 954728448,
"rx_gain_reg": 148,
"rx_mode": 1,
"device_errors": 0
}
},
{
"channel": 2,
"received": 100,
"signals": {
"rssi": {
"n": 100,
"mean": -45.75,
"min": -47.0,
"max": -44.0
},
"snr": {
"n": 100,
"mean": 8.548,
"min": 8.0,
"max": 9.5
}
},
"status": {
"result": 349207866,
"stationary": true,
"active": true,
"received": 100,
"missed": 0,
"read_errors": 0,
"foreign": 0,
"failures": 0,
"rf_commands": 0,
"rf_word": 955777024,
"rx_gain_reg": 148,
"rx_mode": 1,
"device_errors": 0
}
},
{
"channel": 3,
"received": 100,
"signals": {
"rssi": {
"n": 100,
"mean": -18.63,
"min": -19.0,
"max": -18.0
},
"snr": {
"n": 100,
"mean": 8.312,
"min": 7.8,
"max": 9.0
}
},
"status": {
"result": 349207968,
"stationary": true,
"active": true,
"received": 100,
"missed": 0,
"read_errors": 0,
"foreign": 0,
"failures": 0,
"rf_commands": 0,
"rf_word": 956825600,
"rx_gain_reg": 148,
"rx_mode": 1,
"device_errors": 0
}
}
],
"per_tx": [
{
"channel": 0,
"signals": {
"rssi": {
"n": 100,
"mean": -21.05,
"min": -22.0,
"max": -21.0
},
"snr": {
"n": 100,
"mean": 8.142,
"min": 7.8,
"max": 8.8
}
}
},
{
"channel": 1,
"signals": {
"rssi": {
"n": 99,
"mean": -4.353535353535354,
"min": -5.0,
"max": -4.0
},
"snr": {
"n": 99,
"mean": 8.977777777777778,
"min": 8.0,
"max": 10.0
}
}
},
{
"channel": 2,
"signals": {
"rssi": {
"n": 97,
"mean": -44.855670103092784,
"min": -46.0,
"max": -44.0
},
"snr": {
"n": 97,
"mean": 8.628865979381443,
"min": 7.8,
"max": 9.8
}
}
},
{
"channel": 3,
"signals": {
"rssi": {
"n": 99,
"mean": -18.555555555555557,
"min": -20.0,
"max": -18.0
},
"snr": {
"n": 99,
"mean": 8.256565656565657,
"min": 7.8,
"max": 9.0
}
}
}
],
"missing": [
{
"attempt": 158,
"round": 40,
"channel": 3,
"received": {
"received": 349208127,
"channel": 3,
"valid": false,
"timeout": true,
"device_errors": 0,
"rx_mode": 1
},
"pause_ms": 268.067314273167,
"tx": {
"sent": 349208127,
"channel": 3,
"len": 16,
"preamble": 32,
"rc": 0,
"power_dbm": -9,
"sf": 10,
"bw_khz": 125,
"freq_khz": 912500,
"channel_step_khz": 1000,
"transmit_call_us": 533203,
"rf_commands": 0,
"modulation_commands": 0,
"packet_count": 140,
"fixed_tx": true,
"failed": false
}
},
{
"attempt": 184,
"round": 46,
"channel": 1,
"received": {
"received": 349208153,
"channel": 1,
"valid": false,
"timeout": true,
"device_errors": 0,
"rx_mode": 1
},
"pause_ms": 467.491692219345,
"tx": {
"sent": 349208153,
"channel": 1,
"len": 16,
"preamble": 32,
"rc": 0,
"power_dbm": -9,
"sf": 10,
"bw_khz": 125,
"freq_khz": 910500,
"channel_step_khz": 1000,
"transmit_call_us": 533203,
"rf_commands": 0,
"modulation_commands": 0,
"packet_count": 146,
"fixed_tx": true,
"failed": false
}
},
{
"attempt": 200,
"round": 50,
"channel": 2,
"received": {
"received": 349208169,
"channel": 2,
"valid": false,
"timeout": true,
"device_errors": 0,
"rx_mode": 1
},
"pause_ms": 59.46570585514582,
"tx": {
"sent": 349208169,
"channel": 2,
"len": 16,
"preamble": 32,
"rc": 0,
"power_dbm": -9,
"sf": 10,
"bw_khz": 125,
"freq_khz": 911500,
"channel_step_khz": 1000,
"transmit_call_us": 533203,
"rf_commands": 0,
"modulation_commands": 0,
"packet_count": 150,
"fixed_tx": true,
"failed": false
}
},
{
"attempt": 293,
"round": 74,
"channel": 2,
"received": {
"received": 349208262,
"channel": 2,
"valid": false,
"timeout": true,
"device_errors": 0,
"rx_mode": 1
},
"pause_ms": 161.8363382928233,
"tx": {
"sent": 349208262,
"channel": 2,
"len": 16,
"preamble": 32,
"rc": 0,
"power_dbm": -9,
"sf": 10,
"bw_khz": 125,
"freq_khz": 911500,
"channel_step_khz": 1000,
"transmit_call_us": 533203,
"rf_commands": 0,
"modulation_commands": 0,
"packet_count": 174,
"fixed_tx": true,
"failed": false
}
},
{
"attempt": 314,
"round": 79,
"channel": 2,
"received": {
"received": 349208283,
"channel": 2,
"valid": false,
"timeout": true,
"device_errors": 0,
"rx_mode": 1
},
"pause_ms": 353.264104239211,
"tx": {
"sent": 349208283,
"channel": 2,
"len": 16,
"preamble": 32,
"rc": 0,
"power_dbm": -9,
"sf": 10,
"bw_khz": 125,
"freq_khz": 911500,
"channel_step_khz": 1000,
"transmit_call_us": 533204,
"rf_commands": 0,
"modulation_commands": 0,
"packet_count": 179,
"fixed_tx": true,
"failed": false
}
}
],
"unique_tx_sequences": 800,
"tx_configuration_commands_during_packets": 0,
"transport_replays": 0
}
@@ -0,0 +1,203 @@
{
"radios": [
{
"serial": "9AB3B64C641BA927",
"board": "RAK4631",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"serial": "651F8E496197F882",
"board": "Heltec T096",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"serial": "9352162A72082314",
"board": "Heltec MeshTower V2",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"serial": "34A9141999729D5D",
"board": "Seeed T1000-E LR1110",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
{
"serial": "44:1B:F6:69:CF:98",
"board": "Heltec V4",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
},
"status": {
"result": 1,
"channels": 0,
"active": false,
"received": 0,
"missed": 0,
"deferred": 0,
"failures": 0,
"rx_mode_errors": 0,
"cache_errors": 0,
"rx_errors": 0,
"optimized_rx_resumes": 0,
"modulation_cache": true,
"rf_commands": 1,
"modulation_commands": 3,
"rx_gain_reg": 148,
"switch": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"with_modulation": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"without_modulation": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"settle_us": 0,
"continue_on_miss": false,
"frequency_repeat": false,
"tcxo_us": 1600,
"channel_step_khz": 250,
"mixed_profiles": false,
"retune_passes": 1,
"second_pass_blocked": 0,
"first_pass_detour": false,
"detour": {
"n": 0,
"bad": 0,
"first_rf": 0,
"final_rf": 0,
"first_mod": 0,
"final_mod": 0
},
"first_pass": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"second_pass": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"accept_offchannel": false,
"strict_received": 0,
"offchannel_received": 0,
"rollback": 0,
"warm_standby": false,
"batched_modulation": true,
"bulk_spi": false,
"settling": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"switch_and_settle": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"idle_cycle": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"idle_dwell": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
},
"held_dwell": {
"n": 0,
"min_us": 0,
"mean_us": 0.0,
"max_us": 0
}
}
}
],
"services": {
"ModemManager.service": "active",
"meshcore-memory-soak.service": "inactive",
"mctomqtt.service": "active",
"meshcore-host-cli.service": "active"
}
}
@@ -0,0 +1,254 @@
{
"complete": true,
"tx": [
{
"target": {
"channel": 0,
"serial": "9AB3B64C641BA927",
"topology": "1-1.3.1",
"board": "RAK4631",
"env": "profile_fixed_rak_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 41
},
"port_before": "/dev/ttyACM3",
"package_sha256": "0a67e811f2b7e33208866ba27d4e6c396a1c077b022bf84f6c986689bf71ca87",
"after": {
"port": "/dev/ttyACM3",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 1,
"serial": "651F8E496197F882",
"topology": "1-1.2.3",
"board": "Heltec T096",
"env": "profile_fixed_t096_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
"port_before": "/dev/ttyACM2",
"package_sha256": "d6d2a4e22bb4f654ad44e29f9ae684b671e13f91583cd56c2c27bb670045e9c0",
"after": {
"port": "/dev/ttyACM2",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 2,
"serial": "9352162A72082314",
"topology": "1-1.2.2",
"board": "Heltec MeshTower V2",
"env": "profile_fixed_tower_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
"port_before": "/dev/ttyACM4",
"package_sha256": "28fdb7373a2628aaed9ca8c0d305ac220c29035604364e621f48a8d4c5a255c8",
"after": {
"port": "/dev/ttyACM1",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
"complete": true
},
{
"target": {
"channel": 3,
"serial": "34A9141999729D5D",
"topology": "1-1.2.1",
"board": "Seeed T1000-E LR1110",
"env": "profile_fixed_t1000_tx",
"fwid": 291,
"app_base": 159744,
"boot_vid": 10374,
"boot_pid": 87
},
"port_before": "/dev/ttyACM4",
"package_sha256": "153c8f129f1d31d9fa40f1931cb39a639179902f0c0e9ffd9b05e0bef854c140",
"after": {
"port": "/dev/ttyACM4",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
"complete": true
}
],
"rx": {
"write_started": true,
"after": {
"port": "/dev/ttyACM6",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
}
},
"complete": true
},
"services_before": {
"ModemManager.service": true,
"meshcore-memory-soak.service": true
},
"previous_error": "V4 RX gain mismatch",
"post_boot_verification": [
{
"target": "RAK4631",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "RAK4631",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Heltec T096",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec T096",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Heltec MeshTower V2",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Heltec MeshTower V2",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 182,
"app_base": 155648,
"autonomous_tx": false
}
},
{
"target": "Seeed T1000-E LR1110",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"fixed_tx": 1,
"board": "Seeed T1000-E LR1110",
"prepared": false,
"failed": false,
"channel": 99,
"packet_count": 0,
"sd_fwid": 291,
"app_base": 159744,
"autonomous_tx": false
}
},
{
"target": "Heltec V4",
"info": {
"ready": true,
"bench": "production-profile-switch-v8",
"channel_sweep": 1,
"channel_trace": 2,
"channel_sf_select": 1,
"channel_bw_select": 1,
"channel_step_select": 1,
"payload_delivery_ab": 1,
"preamble_diagnostic": 1,
"modulation_cache_ab": 1,
"frequency_repeat_ab": 1,
"stationary_baseline": 1,
"stationary_offset": 1,
"rollback_ab": 1,
"rx_gain_reg": 148,
"board": "Heltec V4",
"fem": "KCT8103L",
"tx_fem_mode": "amplified"
}
}
]
}
@@ -0,0 +1,154 @@
{
"git_head": "2b5a6f209835f0f67ed73fee78d36447a4566ce6",
"scope": "Dirty-worktree HIL artifacts; four physical fixed-channel TXs and one fast single-pass RX",
"targets": [
{
"channel": 0,
"serial": "9AB3B64C641BA927",
"topology": "1-1.3.1",
"board": "RAK4631",
"env": "profile_fixed_rak_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 41
},
{
"channel": 1,
"serial": "651F8E496197F882",
"topology": "1-1.2.3",
"board": "Heltec T096",
"env": "profile_fixed_t096_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
{
"channel": 2,
"serial": "9352162A72082314",
"topology": "1-1.2.2",
"board": "Heltec MeshTower V2",
"env": "profile_fixed_tower_tx",
"fwid": 182,
"app_base": 155648,
"boot_vid": 9114,
"boot_pid": 113
},
{
"channel": 3,
"serial": "34A9141999729D5D",
"topology": "1-1.2.1",
"board": "Seeed T1000-E LR1110",
"env": "profile_fixed_t1000_tx",
"fwid": 291,
"app_base": 159744,
"boot_vid": 10374,
"boot_pid": 87
}
],
"receiver": {
"serial": "44:1B:F6:69:CF:98",
"topology": "1-1.3.3",
"board": "Heltec V4",
"env": "profile_four_tx_v4_rx"
},
"files": {
"profile_fixed_rak_tx.zip": {
"bytes": 93308,
"sha256": "0a67e811f2b7e33208866ba27d4e6c396a1c077b022bf84f6c986689bf71ca87",
"app_base": 155648,
"image_end": 248136,
"fwid": 182
},
"profile_fixed_t096_tx.zip": {
"bytes": 93291,
"sha256": "d6d2a4e22bb4f654ad44e29f9ae684b671e13f91583cd56c2c27bb670045e9c0",
"app_base": 155648,
"image_end": 248120,
"fwid": 182
},
"profile_fixed_tower_tx.zip": {
"bytes": 93356,
"sha256": "28fdb7373a2628aaed9ca8c0d305ac220c29035604364e621f48a8d4c5a255c8",
"app_base": 155648,
"image_end": 248184,
"fwid": 182
},
"profile_fixed_t1000_tx.zip": {
"bytes": 88020,
"sha256": "153c8f129f1d31d9fa40f1931cb39a639179902f0c0e9ffd9b05e0bef854c140",
"app_base": 159744,
"image_end": 246944,
"fwid": 291
},
"rx-firmware.bin": {
"bytes": 366800,
"sha256": "e112b566ec72b84692e65a774d1ccce1d8789d7eed19c9e453a7837a594ae29d"
},
"rx-partitions.bin": {
"bytes": 3072,
"sha256": "bd0f7954aca2ef7d925ee21aaa1f3dc8822d1d6ce5cbbd26a135e5886bfff6ce"
},
"rx-bootloader.bin": {
"bytes": 15104,
"sha256": "2a71d69b471e20c2bac7fb469f3c6a807b3ebee780e348e5889db0da849ca363"
},
"rx-boot_app0.bin": {
"bytes": 8192,
"sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0"
},
"profile_four_tx_fixture.py": {
"bytes": 1852,
"sha256": "8c2e12e2450648c6a7a25d4d2567666e2df21eebc7abaa712b1909cd33200c6e"
},
"profile_four_tx_deploy.py": {
"bytes": 5044,
"sha256": "b8f804d511dcd4195caf63d0335e31d8818bafa66b21a4a381382773d3c6c3c6"
},
"profile_four_tx.py": {
"bytes": 11040,
"sha256": "3bcc13b7c121bb1148bc69f1083a433114975ca19780aaaf88a29f675afc0d87"
},
"profile_switch.py": {
"bytes": 6249,
"sha256": "097e61cc128f96aa36964578eecfa3fae2de49f75867fe09fc0ac039fd639aae"
},
"profile_switch_channels.py": {
"bytes": 23912,
"sha256": "9766bafe61e5b8d2ffbe06e8f2ccf8a7be28bcf2edb922e9790347ea9d92e000"
},
"profile_switch_packets.py": {
"bytes": 6679,
"sha256": "6f572549af4f4cf01c947160704d5f6d2b647e84457f064c3857e0153b8ca29c"
},
"profile_switch_sweep.py": {
"bytes": 4260,
"sha256": "710509970501bf2c5fdaec29ed68dff735a526488acac1bd209d30b3bb601fa5"
},
"profile_fixed_tx.cpp": {
"bytes": 6984,
"sha256": "26d0e01148e0433099e4d325215618c3c18ea44bee05a5d1bc1643d2020ea108"
},
"profile_fixed_tx.ini": {
"bytes": 2371,
"sha256": "4d5a5f16a2b16dd9d4a8e7a0b2f9d0b46dca023105e30cc0eb5ea4fd81b81211"
},
"profile_switch.cpp": {
"bytes": 26548,
"sha256": "0b9852726dd22c5077b741ce3f93bd76b6e03be7f47196e38faf0c5186bafca6"
},
"profile_switch_channels.h": {
"bytes": 19414,
"sha256": "32b29b5ef41c04b92b63427df294ebdbb49410d145f85cd213e99fc1794f9d3f"
},
"ProfileChannelTrace.h": {
"bytes": 2227,
"sha256": "5b417732b0c9b412802ede3fcada95e3b4f5d038c65a06816677bacdcd657c86"
},
"ProfileFrequencyOffset.h": {
"bytes": 1114,
"sha256": "cd54f7af39330bdbf1c0c20db73095815b9c07ac3d57aa3a3af6ee8dbf9df90d"
}
}
}
Loaded 100 of 374 files, more files were not shown because too many files have changed in this diff. Show more