Files
HaloKeymind/tools/hil/profile_four_tx.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

201 lines
12 KiB
Python

"""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()