Files
HaloKeymind/tools/hil/profile_switch_channels.py
T
mikecarper 091c8d8324 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.
2026-09-14 22:34:17 -07:00

377 lines
23 KiB
Python

"""SF5..10, 125/250 kHz scan: stop-first capacity or fixed-sample error rate.
XIAO scanner / Indicator reference recommended. Fixed 32-symbol preamble,
4.8-symbol nominal visits, 16-byte synthetic packets at -9 dBm. This measures
100% of a finite sample, not a proof of a zero packet-error rate. Explicit
--continue-on-miss finishes the four-channel sample without RF retries.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
import json
import math
from pathlib import Path
import random
import secrets
import time
from profile_switch import exchange, exchange_ready, read_packet_response
from profile_switch_packets import open_port
def channel_dwell_us(sf, symbols, bw_khz=125):
if sf not in range(5, 11) or bw_khz not in (125, 250) or not math.isfinite(symbols) or not 4 <= symbols <= 32:
raise ValueError("SF 5..10, BW 125/250; dwell finite, 4..32 symbols required")
return math.ceil(symbols * (1 << sf) * (1000 / bw_khz))
def reception_validity(received, channel, accept_offchannel=False):
strict = (received.get("valid") is True and received.get("len") == 16
and received.get("channel") == channel)
payload = (received.get("payload_valid") is True and received.get("len") == 16
and received.get("payload_channel") == channel and received.get("expected_channel") == channel)
return strict, strict or (accept_offchannel and payload)
def packet_error_summary(trials, channels):
"""Keep strict attribution errors separate from missing/invalid payloads."""
def summarize(selected):
n=len(selected)
strict=payload=timeouts=0
for trial in selected:
s,p=reception_validity(trial['received'],trial['channel'],True)
strict+=s;payload+=p;timeouts+=trial['received'].get('timeout') is True
return dict(attempted=n,strict_received=strict,payload_received=payload,
offchannel_received=payload-strict,timeouts=timeouts,
invalid_payloads=n-payload-timeouts,strict_errors=n-strict,delivery_errors=n-payload,
strict_error_percent=100*(n-strict)/n if n else None,
delivery_error_percent=100*(n-payload)/n if n else None)
return dict(overall=summarize(trials),per_channel=[
dict(channel=ch,**summarize([t for t in trials if t['channel']==ch])) for ch in range(channels)])
def capacity_levels(start, probe, finish, checkpoint, samples=100, maximum=64, seed=606125, dwell_us=2458, settle_us=0, continue_on_miss=False):
"""Stop-first capacity by default; explicit fixed four-channel sample may continue."""
if continue_on_miss and maximum!=4:
raise ValueError('Fixed-sample error-rate mode requires exactly four channels')
rng = random.Random(seed)
result = {"levels": [], "complete": False, "last_perfect_channels": None}
for channels in range(4, maximum + 1):
config = start(channels)
level = {"channels": channels, "config": config, "attempted": 0, "received": 0,
"per_channel": [{"attempted": 0, "received": 0} for _ in range(channels)],
"trials": [], "complete": False}
result["levels"].append(level)
checkpoint(result)
for sample_round in range(samples):
order = list(range(channels))
rng.shuffle(order)
for channel in order:
# Vary arrivals across about two nominal round trips; do not
# select a known receive phase or tune the listener to TX.
pause_ms = rng.uniform(0, 2 * channels * ((dwell_us + settle_us) / 1000 + 0.7))
trial = probe(channel, pause_ms)
trial.update(channel=channel, sample_round=sample_round + 1, pause_ms=pause_ms)
level["trials"].append(trial)
level["attempted"] += 1
level["per_channel"][channel]["attempted"] += 1
if trial["valid"]:
level["received"] += 1
level["per_channel"][channel]["received"] += 1
elif not continue_on_miss:
level["status"] = finish()
result.update(complete=True, stopped="first_rf_miss", first_failed_channels=channels,
failed_sequence=trial["sequence"])
checkpoint(result)
return result
checkpoint(result)
level["status"] = finish()
level["complete"] = True
if level['received']==level['attempted']:
result["last_perfect_channels"] = channels
if continue_on_miss:
level['error_rates']=packet_error_summary(level['trials'],channels)
checkpoint(result)
result.update(complete=True, stopped="fixed_sample_complete" if continue_on_miss else "channel_limit_without_miss")
checkpoint(result)
return result
def collect_trace(port, sequence):
offset, events, metadata = 0, [], None
while True:
page = exchange(port, f"scantrace {sequence} {offset}", "result", 3, sequence, cached=True)
if (page.get("trace") != sequence or page.get("offset") != offset
or page.get("next") != offset + len(page.get("events", []))
or not offset <= page.get("next", -1) <= page.get("total", -1)):
raise RuntimeError("Mismatched diagnostic trace page")
if metadata is None:
metadata = {k: v for k, v in page.items() if k not in ("events", "offset", "next")}
elif page["total"] != metadata["total"] or page["origin_us"] != metadata["origin_us"]:
raise RuntimeError("Diagnostic trace changed during retrieval")
events.extend(page["events"])
offset = page["next"]
if offset == page["total"]:
return dict(metadata, events=events)
if not page["events"]:
raise RuntimeError("Diagnostic trace made no progress")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--receiver", required=True)
parser.add_argument("--sender", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--samples", type=int, default=100)
parser.add_argument("--max-channels", type=int, default=64)
parser.add_argument("--preamble", type=int, default=32)
parser.add_argument("--seed", type=int, default=606125)
parser.add_argument("--dwell-symbols", type=float, default=4.8)
parser.add_argument("--trace", action="store_true")
parser.add_argument("--sf", type=int, choices=range(5, 11), default=6)
parser.add_argument("--bw-khz", type=int, choices=(125, 250), default=125)
parser.add_argument("--settle-us", type=int, default=0)
parser.add_argument("--modulation-cache", choices=("on", "off"), default="on")
parser.add_argument("--frequency-repeat", choices=("on", "off"), default="off")
parser.add_argument("--rollback", type=int, choices=range(16), default=0)
parser.add_argument("--retune-passes",type=int,choices=(1,2),default=1)
parser.add_argument('--first-pass-detour',action='store_true')
parser.add_argument('--first-pass-offset-hz',type=int,choices=(10,100),default=10)
parser.add_argument('--continue-on-miss',action='store_true',help='Finish a fixed four-channel sample despite RF misses; never retry packets')
parser.add_argument("--tcxo-us", type=int, choices=(1600, 6000))
parser.add_argument("--channel-step-khz", type=int, choices=(250, 1000), default=250)
parser.add_argument("--accept-offchannel", action="store_true",
help="Separate delivery diagnostic: accept intact expected payloads on another RX channel")
parser.add_argument("--expect-rx-gain", choices=("normal", "boosted"), default="normal")
args = parser.parse_args()
if (args.receiver == args.sender or args.output.exists() or not 1 <= args.samples <= 1000
or not 4 <= args.max_channels <= 64 or not 12 <= args.preamble <= 256
or not math.isfinite(args.dwell_symbols) or not 4 <= args.dwell_symbols <= 32
or not 0 <= args.settle_us <= 24000 or (args.channel_step_khz==1000 and args.max_channels!=4)
or (args.first_pass_detour and args.retune_passes!=2)
or (args.first_pass_offset_hz!=10 and not args.first_pass_detour)
or (args.continue_on_miss and args.max_channels!=4)
or (args.retune_passes==2 and (args.sf!=10 or args.bw_khz!=125 or args.max_channels!=4
or args.preamble!=32 or args.rollback!=0 or args.modulation_cache!='off'))):
parser.error("different ports, fresh output, samples 1..1000, channels 4..64, preamble 12..256 required")
dwell_us = channel_dwell_us(args.sf, args.dwell_symbols, args.bw_khz)
result = {"schema": 5, "experiment": f"sf{args.sf}_{args.bw_khz}_channel_capacity", "complete": False,
"receiver": args.receiver, "sender": args.sender, "sf": args.sf, "bw_khz": args.bw_khz,
"cr": 5, "preamble_symbols": args.preamble, "dwell_symbols": args.dwell_symbols, "dwell_us": dwell_us,
"modulation_cache": args.modulation_cache == "on",
"frequency_repeat": args.frequency_repeat == "on",
"rollback": args.rollback,
"retune_passes": args.retune_passes,
"first_pass_detour":args.first_pass_detour,
"requested_first_pass_offset_hz":args.first_pass_offset_hz if args.first_pass_detour else 0,
"requested_tcxo_us": args.tcxo_us,
"settle_us": args.settle_us,
"expected_rx_gain_reg": 0x94 if args.expect_rx_gain == "normal" else 0x96,
"trace_enabled": args.trace, "irq_poll_us": 64 if args.trace else 0,
"samples_per_channel": args.samples, "packet_bytes": 16, "power_dbm": -9,
"base_mhz": 909.5, "channel_step_mhz": args.channel_step_khz/1000, "seed": args.seed,
"scope": "Lab N-channel scheduler over production retunes; finite short-range sample, not guaranteed PER"}
result["accept_offchannel"] = args.accept_offchannel
result['continue_on_miss']=args.continue_on_miss
if args.continue_on_miss:
result['experiment']=f'sf{args.sf}_{args.bw_khz}_fixed_sample_error_rate'
result['expectation_timeout_ms']=10000
if args.accept_offchannel:
result["experiment"] = f"sf{args.sf}_{args.bw_khz}_payload_delivery_diagnostic"
result["scope"] = "Expected intact payload delivery regardless of RX channel; NOT strict same-channel capacity"
rx = tx = None
sequence = secrets.randbelow(0x3fffffff) + 1
def next_sequence():
nonlocal sequence
sequence += 1
return sequence
def save(progress=None):
if progress:
result.update(progress)
args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
def start(channels):
seq = next_sequence()
reply = exchange_ready(rx, f"scanstart {channels} {args.preamble} {seq} {dwell_us} {int(args.trace)} {args.sf} {args.bw_khz}", "listening", 1, seq, cached=True)
if (reply.get("channels") != channels or reply.get("sf") != args.sf or reply.get("bw_khz") != args.bw_khz
or reply.get("preamble") != args.preamble or reply.get("dwell_us") != dwell_us
or reply.get("trace") != args.trace or reply.get("settle_us",0) != args.settle_us):
raise RuntimeError("Scanner configuration mismatch")
if reply.get("rollback", 0) != args.rollback:
raise RuntimeError("Scanner rollback configuration mismatch")
if reply.get("step_mhz") != args.channel_step_khz/1000:
raise RuntimeError("Scanner spacing mismatch")
print(f"Testing SF{args.sf}/{args.bw_khz}, {channels} channels; {args.samples} packets/channel, {args.preamble}-symbol preamble, {args.dwell_symbols:g}-symbol visits", flush=True)
return reply
def probe(channel, pause_ms):
seq = next_sequence()
armed_start = time.monotonic_ns()
armed = exchange(rx, f"scanexpect {channel} {seq}", "listening", 1, seq, cached=True)
armed_end = time.monotonic_ns()
if armed.get("channel") != channel or not armed.get("scanning"):
raise RuntimeError("Expectation mismatch")
# USB acknowledgement retrieval may take time, but expectation arming
# itself does not change the scanner's frequency or visit clock.
time.sleep(pause_ms / 1000)
with ThreadPoolExecutor(max_workers=1) as pool:
incoming = pool.submit(read_packet_response, rx, "received", seq, 12)
sent_start = time.monotonic_ns()
sent = exchange(tx, f"scantx {channel} {seq} 16 {args.preamble} {args.sf} {args.bw_khz}", "sent", 3, seq, cached=True)
sent_end = time.monotonic_ns()
received = incoming.result()
if (sent.get("rc") != 0 or sent.get("channel") != channel or sent.get("len") != 16
or sent.get("preamble") != args.preamble or sent.get("sf") != args.sf or sent.get("bw_khz") != args.bw_khz):
raise RuntimeError(f"Reference TX failed/mismatched; not a capacity result: {sent}")
if received.get("device_errors") or (received.get("timeout") and received.get("rx_mode") != 1):
raise RuntimeError(f"Receiver hardware failure; not a capacity result: {received}")
if result["sender_info"].get("channel_step_select") == 1:
if sent.get("freq_khz") != 909500+args.channel_step_khz*channel or sent.get("channel_step_khz") != args.channel_step_khz:
raise RuntimeError("Transmitter frequency/spacing mismatch")
strict, valid = reception_validity(received, channel, args.accept_offchannel)
if args.accept_offchannel and not received.get("timeout") and received.get("accepted") != valid:
raise RuntimeError("Host/receiver payload acceptance mismatch")
trial = dict(sequence=seq, valid=valid, strict_valid=strict, armed=armed, sent=sent, received=received,
host_times_ns=dict(arm_start=armed_start, arm_end=armed_end,
tx_start=sent_start, tx_end=sent_end))
if args.trace:
trial["trace"] = collect_trace(rx, seq)
print(f"Probe {seq}, channel {channel}: {'received' if valid else 'MISSED'}", flush=True)
return trial
def finish():
seq = next_sequence()
status = exchange(rx, f"scanstatus {seq}", "result", 1, seq, cached=True)
if any(status[k] for k in ("failures", "rx_mode_errors", "cache_errors")):
raise RuntimeError(f"Scanner failed; not a capacity result: {status}")
if status.get("rx_gain_reg") != result["expected_rx_gain_reg"]:
raise RuntimeError("Receiver gain changed during scan")
if status.get("settle_us",0) != args.settle_us:
raise RuntimeError("Settling-delay policy mismatch")
if status.get("frequency_repeat", False) != result["frequency_repeat"]:
raise RuntimeError("Frequency-repeat policy mismatch")
if status.get('retune_passes',1)!=args.retune_passes or status.get('second_pass_blocked',0):
raise RuntimeError('Full retune repetition policy/guard mismatch')
if status.get('first_pass_detour',False)!=args.first_pass_detour:
raise RuntimeError('First-pass detour policy mismatch')
if args.first_pass_detour and (status['detour']['n']!=status['switch']['n'] or status['detour']['bad']):
raise RuntimeError('Actual detour/corrected settings did not match every hop')
if status["rf_commands"] != status["switch"]["n"] * args.retune_passes * (2 if result["frequency_repeat"] else 1):
raise RuntimeError("Actual 0x86 count does not match the requested per-hop policy")
if args.retune_passes==2 and (status['modulation_commands']!=2*status['switch']['n']
or status['optimized_rx_resumes']!=2*status['switch']['n']
or status['first_pass']['n']!=status['switch']['n']
or status['second_pass']['n']!=status['switch']['n']):
raise RuntimeError('Both full retune passes were not physically exercised')
if args.settle_us and (status["settling"]["n"] != status["switch"]["n"]
or status["settling"]["min_us"] < args.settle_us):
raise RuntimeError("Requested settling delay was not applied to every hop")
if status.get("rollback", 0) != args.rollback or not status["switch"]["n"]:
raise RuntimeError("Scanner did not exercise requested retunes")
if status.get("accept_offchannel",False) != args.accept_offchannel:
raise RuntimeError("Receive acceptance policy mismatch")
if status.get('continue_on_miss',False)!=args.continue_on_miss:
raise RuntimeError('Receive continuation policy mismatch')
if status.get("mixed_profiles",False):
raise RuntimeError("Mixed modulation is not a uniform-channel capacity result")
if args.tcxo_us is not None and status.get("tcxo_us") != args.tcxo_us:
raise RuntimeError("TCXO delay does not match rollback request")
if bool(status["optimized_rx_resumes"]) != (args.rollback & 9 == 0):
raise RuntimeError("Actual fast-RX use disagrees with rollback policy")
print(f"{status['channels']} channels: {status['received']} received, {status['missed']} missed; "
f"switch mean {status['switch']['mean_us']:.1f} us; idle dwell mean {status['idle_dwell']['mean_us']:.1f} us", flush=True)
return status
try:
rx = open_port(args.receiver)
tx = open_port(args.sender)
for port in (rx, tx):
info = exchange(port, "info", "bench", 3)
result["receiver_info" if port is rx else "sender_info"] = info
if port is rx and info.get("rx_gain_reg") != result["expected_rx_gain_reg"]:
raise RuntimeError("Receiver gain does not match requested control")
receiver_missing = (port is rx and (info.get("channel_sweep") != 1
or info.get("modulation_cache_ab") != 1
or (args.trace and info.get("channel_trace", 0) < 1)))
sender_missing = (port is tx and info.get("channel_tx") != 1 and info.get("channel_sweep") != 1)
if (receiver_missing or sender_missing or info.get("channel_sf_select") != 1
or info.get("channel_bw_select") != 1):
raise RuntimeError("Firmware does not support the channel-capacity test")
if info.get("channel_step_select") == 1:
if exchange(port,f"scanstep {args.channel_step_khz}","channel_step_khz",2)["channel_step_khz"] != args.channel_step_khz:
raise RuntimeError("Channel spacing acknowledgement mismatch")
elif args.channel_step_khz != 250:
raise RuntimeError("Both endpoints must support channel spacing selection")
if result["receiver_info"].get("frequency_repeat_ab") == 1:
policy = result["frequency_repeat"]
if exchange(rx, f"scanfreqrepeat {int(policy)}", "frequency_repeat", 2)["frequency_repeat"] != policy:
raise RuntimeError("Frequency-repeat acknowledgement mismatch")
elif result["frequency_repeat"]:
raise RuntimeError("Receiver firmware lacks frequency-repeat capability")
if args.retune_passes==2:
capability=exchange(rx,'retuneinfo','full_retune_repeat',2)
if capability.get('full_retune_repeat')!=1 or capability.get('guarded') is not True:
raise RuntimeError('Receiver lacks guarded full retune repetition')
if exchange(rx,'scanretunepasses 2','retune_passes',2)['retune_passes']!=2:
raise RuntimeError('Full retune repetition acknowledgement mismatch')
if args.first_pass_detour:
if exchange(rx,f'scandetouroffset {args.first_pass_offset_hz}','requested_offset_hz',2)['requested_offset_hz']!=args.first_pass_offset_hz:
raise RuntimeError('First-pass offset acknowledgement mismatch')
capability=exchange(rx,'detourinfo','first_pass_detour',2)
steps=105 if args.first_pass_offset_hz==100 else 10
if any(capability.get(k)!=v for k,v in dict(first_pass_detour=1,requested_offset_khz=args.first_pass_offset_hz/1000,rf_offset_steps=steps,offset_hz=steps*32000000/33554432,first_cr=6,final_cr=5).items()):
raise RuntimeError('First-pass detour capability mismatch')
result['detour_configuration']=capability
if exchange(rx,'scanfirstdetour 1','first_pass_detour',2)['first_pass_detour'] is not True:
raise RuntimeError('First-pass detour acknowledgement mismatch')
if result["receiver_info"].get("payload_delivery_ab") == 1:
if exchange(rx,f"scanacceptoffchannel {int(args.accept_offchannel)}","accept_offchannel",2)["accept_offchannel"] != args.accept_offchannel:
raise RuntimeError("Acceptance policy acknowledgement mismatch")
elif args.accept_offchannel:
raise RuntimeError("Receiver lacks explicitly separate payload-delivery diagnostic")
if args.continue_on_miss:
capability=exchange(rx,'continueinfo','fixed_sample_scan',2)
if capability!=dict(fixed_sample_scan=1,timeout_ms=10000,retune_on_miss=False):
raise RuntimeError('Unexpected fixed-sample continuation capability')
if exchange(rx,'scancontinue 1','continue_on_miss',2)['continue_on_miss'] is not True:
raise RuntimeError('Fixed-sample continuation acknowledgement mismatch')
policy = args.modulation_cache == "on"
if args.tcxo_us is not None:
if exchange(rx, f"scantcxo {args.tcxo_us}", "tcxo_us", 2)["tcxo_us"] != args.tcxo_us:
raise RuntimeError("TCXO timing acknowledgement mismatch")
if args.rollback:
if result["receiver_info"].get("rollback_ab") != 1:
raise RuntimeError("Receiver lacks rollback controls")
if result["receiver_info"].get("rollback_ab") == 1:
if exchange(rx, f"scanrollback {args.rollback}", "rollback", 2)["rollback"] != args.rollback:
raise RuntimeError("Rollback acknowledgement mismatch")
if exchange(rx, f"scanmodcache {int(policy)}", "modulation_cache", 2)["modulation_cache"] != policy:
raise RuntimeError("Modulation-cache policy mismatch")
if exchange(rx, f"scansettle {args.settle_us}", "settle_us", 2)["settle_us"] != args.settle_us:
raise RuntimeError("Settling-delay acknowledgement mismatch")
capacity_levels(start, probe, finish, save, args.samples, args.max_channels, args.seed, dwell_us, args.settle_us,args.continue_on_miss)
if args.continue_on_miss:
level=result['levels'][0];status=level['status']
if status['received']!=level['received'] or status['missed']!=level['attempted']-level['received']:
raise RuntimeError('Host/device packet counts disagree')
print(f"Stopped: {result['stopped']}; last perfect channel count: {result['last_perfect_channels']}", flush=True)
except Exception as error:
result.update(complete=False, stopped="fixture_error", error=str(error))
raise
finally:
if rx:
try:
rx.write(b"scanstop\n")
rx.flush()
except Exception:
pass
for port in (rx, tx):
if port:
port.close()
save()
if __name__ == "__main__":
main()