Files
MeshCore/tools/hil/meshtext_auto_radio2.py
T

654 lines
35 KiB
Python

#!/usr/bin/env python3
"""Meshtext Companion / stock-peer automatic-radio2 hardware sweep.
CHANGES BOTH RADIOS' SETTINGS AND TRANSMITS LoRa. The operator must back up
and restore both radios separately. No firmware flashing or PlatformIO is used.
One private random AES-128/256 key is held in memory, never included in the report.
The US stock hash frequencies are intentional, not the 909.5-MHz lab baseline.
The Companion primary modem is not changed. Other channel slots are not erased.
Dependencies: pyserial, stock peer SDK, pypubsub. Run --plan without opening ports.
The installed stable stock schema has nine matching preset enums; the
MediumTurbo tuple is tested as an explicitly reported custom stock-radio mode.
Use distinct USB ports, no other attached clients, and conservative TX power.
"""
from __future__ import annotations
import argparse
from collections import deque
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import importlib.metadata
import json
import os
from pathlib import Path
import random
import re
import secrets
import struct
import threading
import time
from esp32_companion_serial_stress import (
DeviceFrameReader, FrameTimeout, TransportCounters, encode_host_frame,
make_app_start_spec, validate_app_start_response,
)
class TestFailure(RuntimeError):
pass
def require(condition, message):
if not condition:
raise TestFailure(message)
@dataclass(frozen=True)
class Preset:
name: str
bandwidth: int
sf: int
cr: int
stock_enum: str | None
@property
def alias(self):
return "mt-" + self.name.lower()
@property
def frequency_slot(self):
value = 5381
for byte in self.name.encode("ascii"):
value = (value * 33 + byte) & 0xffffffff
return value % (26000 // self.bandwidth) + 1
@property
def frequency(self):
return 902 + self.bandwidth / 2000 + (self.frequency_slot - 1) * self.bandwidth / 1000
def describe(self):
return {"name": self.name, "alias": self.alias, "frequency_mhz": self.frequency,
"frequency_slot": self.frequency_slot, "bandwidth_khz": self.bandwidth,
"sf": self.sf, "cr": self.cr, "preamble": 16,
"stock_mode": self.stock_enum or "CUSTOM (MediumTurbo tuple; no stable enum)"}
PRESETS = (
Preset("LongFast", 250, 11, 5, "LONG_FAST"),
Preset("ShortTurbo", 500, 7, 5, "SHORT_TURBO"),
Preset("ShortFast", 250, 7, 5, "SHORT_FAST"),
Preset("ShortSlow", 250, 8, 5, "SHORT_SLOW"),
Preset("MediumFast", 250, 9, 5, "MEDIUM_FAST"),
Preset("MediumSlow", 250, 10, 5, "MEDIUM_SLOW"),
Preset("LongTurbo", 500, 11, 8, "LONG_TURBO"),
Preset("LongMod", 125, 11, 8, "LONG_MODERATE"),
Preset("LongSlow", 125, 12, 8, "LONG_SLOW"),
Preset("MediumTurbo", 500, 9, 5, None),
)
def choose_presets(names):
selected = []
for name in names.split(","):
normalized = name.strip().lower().replace("_", "")
if normalized in ("longmoderate", "longmod"):
normalized = "longmod"
found = next((p for p in PRESETS if p.name.lower() == normalized), None)
require(found is not None, "unknown preset: " + name)
require(found not in selected, "duplicate preset: " + name)
selected.append(found)
require(selected, "at least one preset is required")
return selected
def parse_profile(reply):
match = re.search(r"(?:^|>\s*)([0-9.]+),([0-9.]+),(\d+),(\d+),(rx|rxtx),(\d+)", reply)
require(match, "radio2 did not report a modem tuple")
return (float(match[1]), float(match[2]), int(match[3]), int(match[4]), match[5], int(match[6]))
def temporary_off(reply):
return reply in ("> off", "off", "TempRadio inactive")
def decode_channel_frame(frame):
if not frame or frame[0] not in (8, 17):
return None
offset = 4 if frame[0] == 17 else 1
require(len(frame) >= offset + 7, "truncated Companion channel frame")
return {"channel": frame[offset], "path": frame[offset + 1], "type": frame[offset + 2],
"timestamp": struct.unpack_from("<I", frame, offset + 3)[0],
"text": frame[offset + 7:]}
class Companion:
def __init__(self, port):
import serial
self.port = serial.Serial(port=None, baudrate=115200, timeout=0.1,
write_timeout=2, exclusive=True)
self.port.port, self.port.dtr, self.port.rts = port, True, False
self.port.open()
self.reader = DeviceFrameReader(TransportCounters())
self.max_channels = 0
def close(self):
self.port.close()
def request(self, payload, expected, timeout=5):
encoded = encode_host_frame(payload)
require(self.port.write(encoded) == len(encoded), "incomplete Companion write")
self.port.flush()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
frame = self.reader.read_frame(self.port, min(0.5, deadline - time.monotonic()))
except FrameTimeout:
continue
if frame[0] >= 0x80:
continue
if frame[0] == 1:
raise TestFailure("Companion command rejected, error " + str(frame[1:2].hex()))
require(frame[0] in expected, "unexpected Companion response " + str(frame[0]))
return frame
raise TestFailure("Companion response timeout for command " + str(payload[0]))
def start(self):
info = self.request(bytes((22, 14)), (13,))
require(len(info) >= 80, "truncated Companion device info")
self.max_channels = info[3]
frame = self.request(make_app_start_spec("Meshtext-Auto-HIL").payload, (5,))
return {"protocol": info[1], "max_channels": self.max_channels,
"model": info[20:60].split(b"\0")[0].decode("utf-8", "replace"),
"firmware": info[60:80].split(b"\0")[0].decode("utf-8", "replace"),
"primary": validate_app_start_response(frame)}
def cli(self, command):
frame = self.request(b"\x42" + command.encode("utf-8"), (0x1d,))
text = frame[1:].decode("utf-8", "strict").strip()
require(not re.match(r"(?:Error|ERR|Err)\b", text), "Companion CLI rejected " + command.split()[0])
return text
def channel_name(self, slot):
frame = self.request(bytes((31, slot)), (18,))
require(len(frame) >= 50, "truncated channel info")
return frame[2:34].split(b"\0")[0].decode("utf-8", "strict")
def mt_node(self):
match = re.search(r"!([0-9a-fA-F]{8})", self.cli("get mt.node"))
require(match, "missing Companion Meshtext node ID")
return int(match[1], 16)
def send_text(self, slot, token):
self.request(bytes((3, 0, slot)) + struct.pack("<I", int(time.time())) + token.encode(), (0,))
def wait_text(self, slot, from_node, token, timeout):
expected = (f"!{from_node:08x}: " + token).encode()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
frame = self.request(b"\x0a", (7, 8, 10, 16, 17), timeout=min(5, max(0.1, deadline - time.monotonic())))
channel = decode_channel_frame(frame)
if channel and channel["channel"] == slot and channel["text"] == expected:
return {"received": True, "timestamp": channel["timestamp"], "path": channel["path"]}
time.sleep(0.1)
return {"received": False}
def wait_auto(self, preset, timeout=12):
deadline = time.monotonic() + timeout
last = ""
while time.monotonic() < deadline:
last = self.cli("get radio2")
if "auto-owned" in last:
actual = parse_profile(last)
desired = (preset.frequency, preset.bandwidth, preset.sf, preset.cr, "rxtx", 16)
if (abs(actual[0] - desired[0]) <= 0.001 and actual[1:] == desired[1:]):
status = self.cli("get mt.status")
require("auto radio2" in status and "; active;" in status, "Meshtext automatic profile is not active")
return {"radio2": last, "status": status}
time.sleep(0.25)
raise TestFailure("automatic radio2 did not reach expected tuple: " + last)
class StockPeer:
def __init__(self, port):
from pubsub import pub
self.port, self.iface = port, None
self.events = deque(maxlen=4096)
self.lock = threading.Lock()
pub.subscribe(self.on_packet, "meshtastic.receive")
self.pub = pub
self.connect()
def on_packet(self, packet, interface):
if interface is self.iface:
with self.lock:
self.events.append((time.monotonic(), packet))
def connect(self):
from meshtastic.serial_interface import SerialInterface
self.iface = SerialInterface(devPath=self.port, timeout=20)
require(self.iface.myInfo is not None and self.iface.localNode.localConfig is not None,
"stock radio configuration was not loaded")
def close(self):
self.pub.unsubscribe(self.on_packet, "meshtastic.receive")
if self.iface:
self.iface.close()
self.iface = None
@property
def node(self):
return int(self.iface.myInfo.my_node_num)
def identity(self):
metadata = getattr(self.iface, "metadata", None)
return {"node": f"!{self.node:08x}", "firmware": getattr(metadata, "firmware_version", "unknown")}
def configure(self, preset, key, tx_power, hops):
from meshtastic.protobuf import channel_pb2, config_pb2
from meshtastic.mesh_interface import MeshInterface
from meshtastic.util import to_node_num
node = self.iface.localNode
# The SDK's ensureSessionKey() only queues a request. Wait for its
# response so each subsequent write does not enqueue another request.
# Stock's local receive queue holds only four packets; pace writes too.
node.ensureSessionKey()
deadline = time.monotonic() + 10
while not self.iface._getOrCreateByNum(to_node_num(node.nodeNum)).get("adminSessionPassKey"):
require(time.monotonic() < deadline, "stock admin session setup timed out")
time.sleep(0.05)
node.beginSettingsTransaction()
time.sleep(0.75)
primary = node.channels[0]
primary.role = channel_pb2.Channel.PRIMARY
primary.settings.name, primary.settings.psk = preset.name, key
primary.settings.uplink_enabled = primary.settings.downlink_enabled = False
# Avoid local relaying contaminating a two-radio reception measurement.
node.localConfig.device.role = config_pb2.Config.DeviceConfig.CLIENT_MUTE
node.writeConfig("device")
time.sleep(0.75)
lora = node.localConfig.lora
lora.region = config_pb2.Config.LoRaConfig.US
lora.use_preset = preset.stock_enum is not None
if preset.stock_enum:
require(preset.stock_enum in config_pb2.Config.LoRaConfig.ModemPreset.keys(),
"installed stock schema lacks " + preset.stock_enum)
lora.modem_preset = config_pb2.Config.LoRaConfig.ModemPreset.Value(preset.stock_enum)
# Clear BW/SF overrides for native presets. Stock AdminModule clamps
# coding_rate=0 to 5, so request the preset's CR explicitly, including 8.
custom_tuple = (0, 0, preset.cr) if preset.stock_enum else (preset.bandwidth, preset.sf, preset.cr)
lora.bandwidth, lora.spread_factor, lora.coding_rate = custom_tuple
# Nine native presets independently exercise the stock name-hash default.
# Custom MediumTurbo uses an explicit slot because stable has no enum
# whose stock default preset behavior can be independently compared.
requested_slot = 0 if preset.stock_enum else preset.frequency_slot
lora.channel_num, lora.override_frequency, lora.frequency_offset = requested_slot, 0, 0
lora.hop_limit, lora.tx_power, lora.tx_enabled = hops, tx_power, True
lora.config_ok_to_mqtt, lora.ignore_mqtt = False, True
node.writeConfig("lora")
time.sleep(0.75)
node.writeChannel(0)
time.sleep(0.75)
node.commitSettingsTransaction()
time.sleep(2)
self.iface.close()
self.iface = None
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
try:
self.connect()
node = self.iface.localNode
observed, primary = node.localConfig.lora, node.channels[0]
# Stable firmware bootstraps effective BW/SF into these fields
# even in preset mode. The write clears overrides; normalized
# readback may contain zero or the preset's effective value.
observed_tuple = (observed.bandwidth, observed.spread_factor, observed.coding_rate)
tuple_matches = (all(value in (0, effective) for value, effective in zip(
observed_tuple[:2], (preset.bandwidth, preset.sf)))
and observed.coding_rate == preset.cr if preset.stock_enum
else observed_tuple == custom_tuple)
good = (primary.role == channel_pb2.Channel.PRIMARY
and primary.settings.name == preset.name and primary.settings.psk == key
and not primary.settings.uplink_enabled and not primary.settings.downlink_enabled
and observed.region == config_pb2.Config.LoRaConfig.US
and observed.channel_num == requested_slot
and observed.override_frequency == 0 and observed.frequency_offset == 0
and observed.use_preset == (preset.stock_enum is not None)
and tuple_matches
and observed.tx_power == tx_power
and observed.tx_enabled and observed.hop_limit == hops
and not observed.config_ok_to_mqtt and observed.ignore_mqtt
and node.localConfig.device.role == config_pb2.Config.DeviceConfig.CLIENT_MUTE)
if preset.stock_enum:
good = good and observed.modem_preset == config_pb2.Config.LoRaConfig.ModemPreset.Value(preset.stock_enum)
if good:
return {**self.identity(), "readback": {
"use_preset": observed.use_preset, "modem_preset": observed.modem_preset,
"channel_num": observed.channel_num, "bandwidth": observed.bandwidth,
"spread_factor": observed.spread_factor, "coding_rate": observed.coding_rate,
"tx_power": observed.tx_power, "hop_limit": observed.hop_limit}}
self.iface.close()
self.iface = None
except (OSError, TimeoutError, MeshInterface.MeshInterfaceError):
if self.iface:
self.iface.close()
self.iface = None
time.sleep(1)
raise TestFailure("stock configuration readback did not match requested private US preset")
def packets_since(self, timestamp, sender):
with self.lock:
return [p for when, p in self.events if when >= timestamp and p.get("from") == sender]
def wait_packet(self, sender, predicate, since, timeout):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for packet in self.packets_since(since, sender):
if predicate(packet):
return packet
time.sleep(0.05)
return None
def send_text(self, token, hops):
return self.iface.sendText(token, destinationId="^all", wantAck=False,
wantResponse=False, channelIndex=0, hopLimit=hops)
def text_payload(packet):
return packet.get("decoded", {}).get("payload")
def print_packet_progress(label, direction, packets, total):
# Only receipt metadata is logged; payload tokens and keys stay out of stdout.
latest = packets[-1]
outcome = "received" if latest["received"] else "timeout"
elapsed = (f" in {latest['elapsed_seconds']:.3f}s"
if "elapsed_seconds" in latest else "")
received = sum(packet["received"] for packet in packets)
print(f" {label} {direction}: packet {len(packets)}/{total} {outcome}{elapsed}; "
f"received {received}/{len(packets)}", flush=True)
def assess_traffic(case, minimum_received):
counts = {direction: {"received": sum(packet["received"] for packet in case[direction]),
"attempted": len(case[direction])}
for direction in ("v4_to_v3", "v3_to_v4")}
failures = [{"direction": direction, **count, "required": minimum_received}
for direction, count in counts.items() if count["received"] < minimum_received]
case["traffic_counts"], case["traffic_failure"] = counts, failures
return failures
def pause_after_packet(packet, gap_seconds, jitter_seconds, rng):
gap = gap_seconds if jitter_seconds == 0 else rng.uniform(gap_seconds, gap_seconds + jitter_seconds)
packet["gap_after_seconds"] = gap
time.sleep(gap)
def write_report(path, data):
# A report must never overwrite an earlier test; the key is not in data.
with path.open("x", encoding="utf-8") as stream:
json.dump(data, stream, indent=2)
stream.write("\n")
try:
os.chmod(path, 0o600)
except OSError:
pass
def run(args, presets):
key = secrets.token_bytes(args.key_bits // 8)
run_id = secrets.token_hex(4)
gap_seed = int(run_id, 16)
gap_rng = random.Random(gap_seed)
report = {"schema": 1, "started_utc": datetime.now(timezone.utc).isoformat(),
"result": "FAIL", "run_id": run_id, "region": "US", "companion_region_setting": args.region,
"tx_power_dbm": args.tx_power, "hops": args.hops,
"key_bits": args.key_bits, "key_fingerprint": hashlib.sha256(key).hexdigest()[:16],
"minimum_received_per_direction": args.minimum_received,
"continue_on_loss": args.continue_on_loss, "gap_seconds": args.gap_seconds,
"gap_jitter_seconds": args.gap_jitter_seconds, "gap_jitter_seed": gap_seed,
"auto_clear_requested": args.auto_clear,
"settings_restored": False, "restore_owner": "operator",
"packages": {n: importlib.metadata.version(n) for n in ("pyserial", "meshtastic", "pypubsub")},
"cases": []}
companion = stock = None
try:
if args.gap_jitter_seconds:
print(f"Gap jitter seed={gap_seed}; packet gaps {args.gap_seconds:g}.."
f"{args.gap_seconds + args.gap_jitter_seconds:g}s", flush=True)
companion = Companion(args.companion_port)
report["companion"] = companion.start()
require(args.channel_slot < companion.max_channels, "Companion channel slot out of range")
for slot in range(companion.max_channels):
if slot == args.channel_slot:
continue
name = companion.channel_name(slot).lstrip("#")
require(not name.startswith(("mt-", "r2-")),
"another Meshtext/radio2 slot conflicts with the sweep; operator must back up and resolve it first")
require(temporary_off(companion.cli("get tempradio")), "temporary primary profile is active")
require(temporary_off(companion.cli("get tempradio2")), "temporary radio2 profile is active")
report["primary_before"] = companion.cli("get radio")
for command in (f"set mt.region {args.region}", "set mt.slot 0", f"set mt.hops {args.hops}",
f"set tx {args.tx_power}", "set radio2.cross off", "set radio2 auto"):
companion.cli(command)
time.sleep(2.2)
mt_node = companion.mt_node()
report["companion"]["mt_node"] = f"!{mt_node:08x}"
stock = StockPeer(args.stock_port)
require(stock.node != mt_node, "stock and Companion Meshtext sender IDs collide")
for preset in presets:
case = {**preset.describe(), "result": "FAIL", "v4_to_v3": [], "v3_to_v4": []}
report["cases"].append(case)
print(f"Testing {preset.name}: {preset.frequency:.4f} MHz / {preset.bandwidth} / SF{preset.sf} / CR{preset.cr}", flush=True)
case["stock"] = stock.configure(preset, key, args.tx_power, args.hops)
observed = case["stock"]["readback"]
print(f" Stock readback verified: {'preset' if observed['use_preset'] else 'custom'}, "
f"slot={observed['channel_num']}, BW={observed['bandwidth']}, "
f"SF={observed['spread_factor']}, CR={observed['coding_rate']}, "
f"TX={observed['tx_power']} dBm, hops={observed['hop_limit']}", flush=True)
companion.cli(f"set mt.channel {args.channel_slot} {preset.alias} {key.hex()}")
case["automatic"] = companion.wait_auto(preset)
require(companion.cli("get radio") == report["primary_before"], "primary RF settings changed")
since = time.monotonic()
before = companion.cli("get mt.status")
time.sleep(args.quiet_seconds)
after = companion.cli("get mt.status")
before_tx, after_tx = re.search(r"\bTX=(\d+)", before), re.search(r"\bTX=(\d+)", after)
require(before_tx and after_tx and before_tx[1] == after_tx[1], "unsolicited Companion Meshtext TX during quiet window")
require(not stock.packets_since(since, mt_node), "unsolicited Companion Meshtext packet observed")
case["quiet_seconds"] = args.quiet_seconds
for sequence in range(args.messages_per_direction):
token = f"Meshtext-HIL {run_id} {preset.name} V4-V3 {sequence}"
started = time.monotonic()
companion.send_text(args.channel_slot, token)
packet = stock.wait_packet(mt_node, lambda p: text_payload(p) == token.encode(), started, args.timeout)
case["v4_to_v3"].append({"token": token, "received": packet is not None,
"elapsed_seconds": round(time.monotonic() - started, 3),
"packet_id": packet.get("id") if packet else None})
print_packet_progress(preset.name, "V4->V3", case["v4_to_v3"], args.messages_per_direction)
# No automatic retransmit or ACK assumption: each token is one new send.
pause_after_packet(case["v4_to_v3"][-1], args.gap_seconds, args.gap_jitter_seconds, gap_rng)
token = f"Meshtext-HIL {run_id} {preset.name} V3-V4 {sequence}"
started = time.monotonic()
sent = stock.send_text(token, args.hops)
received = companion.wait_text(args.channel_slot, stock.node, token, args.timeout)
case["v3_to_v4"].append({"token": token, **received,
"elapsed_seconds": round(time.monotonic() - started, 3), "packet_id": sent.id})
print_packet_progress(preset.name, "V3->V4", case["v3_to_v4"], args.messages_per_direction)
pause_after_packet(case["v3_to_v4"][-1], args.gap_seconds, args.gap_jitter_seconds, gap_rng)
# Capture RX/TX/failure counters even when reception misses the threshold.
case["status_after_traffic"] = companion.cli("get mt.status")
case["radio2_after_traffic"] = companion.cli("get radio2")
case["radio2_status_after_traffic"] = companion.cli("get radio2.status")
case["radio2_scan_after_traffic"] = companion.cli("get radio2.scan")
traffic_failures = assess_traffic(case, args.minimum_received)
if traffic_failures and not args.continue_on_loss:
raise TestFailure(preset.name + " insufficient received messages in " + traffic_failures[0]["direction"])
if args.nodeinfo:
from meshtastic.protobuf import mesh_pb2
started = time.monotonic()
companion.cli(f"send mt.nodeinfo {args.channel_slot}")
packet = stock.wait_packet(mt_node, lambda p: p.get("decoded", {}).get("portnum") == "NODEINFO_APP", started, args.timeout)
require(packet is not None, "manual NodeInfo not received")
user = mesh_pb2.User()
user.ParseFromString(text_payload(packet))
require(user.id == f"!{mt_node:08x}" and user.role == 1 and user.is_unmessagable
and 1 <= len(user.short_name.encode()) <= 4 and len(user.long_name.encode()) <= 24
and not user.public_key and not packet["raw"].want_ack
and not packet["raw"].decoded.want_response, "manual NodeInfo fields/flags invalid")
case["nodeinfo"] = {"received": True, "id": user.id, "role": user.role,
"long_name": user.long_name, "short_name": user.short_name}
case["result"] = "FAIL" if traffic_failures else "PASS"
print(f"{case['result']} {preset.name}: " + ", ".join(f"{d}={sum(p['received'] for p in case[d])}/{len(case[d])}" for d in ("v4_to_v3", "v3_to_v4")), flush=True)
if args.reboot:
# Reboot only the Companion; keep the stock observer connected.
# The NVS settings/channel remain, but the auto-owned modem is RAM-only.
print("Testing Companion reboot / automatic-profile reacquisition", flush=True)
time.sleep(max(1, args.gap_seconds))
old_identity = report["companion"]["primary"]["public_key_prefix"]
frame = encode_host_frame(b"\x13reboot")
require(companion.port.write(frame) == len(frame), "incomplete Companion reboot command")
companion.port.flush()
companion.close()
companion = None
time.sleep(3)
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
candidate = None
try:
candidate = Companion(args.companion_port)
info = candidate.start()
except (OSError, TestFailure):
if candidate:
candidate.close()
time.sleep(1)
continue
companion = candidate
require(info["primary"]["public_key_prefix"] == old_identity,
"Companion identity changed on reconnect; wrong device or lost persistence")
break
require(companion is not None, "Companion reboot reconnect timed out")
require(companion.mt_node() == mt_node, "Meshtext sender ID changed after reboot")
persistence = {"automatic": companion.wait_auto(presets[-1]), "result": "FAIL",
"v4_to_v3": [], "v3_to_v4": []}
report["reboot_persistence"] = persistence
for sequence in range(args.messages_per_direction):
token = f"Meshtext-HIL {run_id} REBOOT V4-V3 {sequence}"
started = time.monotonic()
companion.send_text(args.channel_slot, token)
received = stock.wait_packet(mt_node, lambda p: text_payload(p) == token.encode(), started, args.timeout) is not None
persistence["v4_to_v3"].append({"token": token, "received": received})
print_packet_progress("Reboot", "V4->V3", persistence["v4_to_v3"], args.messages_per_direction)
pause_after_packet(persistence["v4_to_v3"][-1], args.gap_seconds, args.gap_jitter_seconds, gap_rng)
token = f"Meshtext-HIL {run_id} REBOOT V3-V4 {sequence}"
stock.send_text(token, args.hops)
received = companion.wait_text(args.channel_slot, stock.node, token, args.timeout)
persistence["v3_to_v4"].append({"token": token, **received})
print_packet_progress("Reboot", "V3->V4", persistence["v3_to_v4"], args.messages_per_direction)
pause_after_packet(persistence["v3_to_v4"][-1], args.gap_seconds, args.gap_jitter_seconds, gap_rng)
persistence["status_after_traffic"] = companion.cli("get mt.status")
persistence["radio2_after_traffic"] = companion.cli("get radio2")
persistence["radio2_status_after_traffic"] = companion.cli("get radio2.status")
persistence["radio2_scan_after_traffic"] = companion.cli("get radio2.scan")
reboot_failures = assess_traffic(persistence, args.minimum_received)
require(not reboot_failures, "insufficient received messages after reboot in "
+ (reboot_failures[0]["direction"] if reboot_failures else ""))
persistence["result"] = "PASS"
if args.auto_clear:
print("Testing automatic radio2 release / channel re-add", flush=True)
cleanup = {"result": "FAIL"}
report["auto_clear"] = cleanup
def capture_cleanup_state(label):
state = {}
cleanup[label] = state
for name, command in (("mt_status", "get mt.status"), ("radio2", "get radio2"),
("radio2_status", "get radio2.status"), ("radio2_scan", "get radio2.scan"),
("primary", "get radio")):
state[name] = companion.cli(command)
require(state["primary"] == report["primary_before"], "primary RF settings changed")
return state
capture_cleanup_state("before")
companion.cli(f"delete mt.channel {args.channel_slot}")
removed = capture_cleanup_state("removed")
require(re.fullmatch(r"(?:>\s*)?off(?:\s+\(automatic allowed\))?", removed["radio2"]),
"automatic radio2 did not release after the last Meshtext channel was deleted")
require("no Meshtext channels" in removed["mt_status"], "Meshtext backend did not become inactive after channel deletion")
companion.cli(f"set mt.channel {args.channel_slot} {presets[-1].alias} {key.hex()}")
cleanup["automatic_readded"] = companion.wait_auto(presets[-1])
capture_cleanup_state("readded")
cleanup["result"] = "PASS"
print("PASS automatic radio2 release / channel re-add", flush=True)
report["primary_after"] = companion.cli("get radio")
require(report["primary_after"] == report["primary_before"], "primary RF settings changed")
failed_cases = [case["name"] for case in report["cases"] if case["result"] != "PASS"]
require(not failed_cases, "traffic reception threshold failed for presets: " + ", ".join(failed_cases))
report["result"] = "PASS"
except (Exception, SystemExit, KeyboardInterrupt) as error:
message = str(error).replace(key.hex(), "<redacted>")
report["failure"] = {"type": type(error).__name__, "message": message[:500]}
finally:
report["failed_cases"] = [case["name"] for case in report["cases"] if case["result"] != "PASS"]
for name, device in (("stock", stock), ("companion", companion)):
if device:
try:
device.close()
except Exception as error:
report.setdefault("close_failures", []).append({"device": name, "type": type(error).__name__})
report["result"] = "FAIL"
report["finished_utc"] = datetime.now(timezone.utc).isoformat()
write_report(args.output, report)
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--companion-port")
parser.add_argument("--stock-port")
parser.add_argument("--channel-slot", type=int, default=1, help="Companion channel index to overwrite; backup separately")
parser.add_argument("--key-bits", type=int, choices=(128, 256), default=128)
parser.add_argument("--region", choices=("auto", "us"), default="auto", help="Companion inference from unambiguous US primary frequency")
parser.add_argument("--presets", default=",".join(p.name for p in PRESETS))
parser.add_argument("--tx-power", type=int, default=-9, help="conservative chip TX dBm, -9..-1 (stock 0 means maximum)")
parser.add_argument("--hops", type=int, default=7, help="production default 7; use 0 for an initial-hop-only lab test")
parser.add_argument("--messages-per-direction", type=int, default=3)
parser.add_argument("--minimum-received", type=int, default=1, help="connectivity threshold; report all losses, use 3 for 3/3")
parser.add_argument("--continue-on-loss", action="store_true", help="collect traffic losses across presets; failed cases still fail the run")
parser.add_argument("--timeout", type=float, default=15)
parser.add_argument("--gap-seconds", type=float, default=1)
parser.add_argument("--gap-jitter-seconds", type=float, default=0, help="seeded uniform extra delay after each packet; recorded in report")
parser.add_argument("--quiet-seconds", type=float, default=5)
parser.add_argument("--nodeinfo", action="store_true")
parser.add_argument("--reboot", action="store_true", help="reboot Companion at the end and verify RAM auto-profile reacquisition/chat")
parser.add_argument("--auto-clear", action="store_true", help="delete/re-add the final Meshtext channel and verify automatic radio2 release/reacquisition")
parser.add_argument("--output", type=Path)
parser.add_argument("--plan", action="store_true", help="print matrix without importing/opening serial interfaces")
args = parser.parse_args()
try:
presets = choose_presets(args.presets)
require(0 <= args.channel_slot <= 255, "channel slot must be 0..255")
require(-9 <= args.tx_power <= -1 and 0 <= args.hops <= 7, "invalid lab TX power/hops; stock TX 0 means maximum")
require(1 <= args.minimum_received <= args.messages_per_direction, "invalid message/reception counts")
require(0 < args.timeout <= 60 and args.gap_seconds >= 0 and args.gap_jitter_seconds >= 0
and args.quiet_seconds > 0, "invalid timing")
if args.plan:
print(json.dumps([p.describe() for p in presets], indent=2))
return 0
require(args.companion_port and args.stock_port and args.output, "ports and --output are required")
require(args.companion_port != args.stock_port, "ports must be distinct")
require(not args.output.exists() and args.output.parent.is_dir(), "choose a fresh report in an existing directory")
report = run(args, presets)
print(json.dumps({"result": report["result"], "output": str(args.output),
"failure": report.get("failure"), "restore_required": True}, indent=2))
return 0 if report["result"] == "PASS" else 1
except TestFailure as error:
parser.error(str(error))
if __name__ == "__main__":
raise SystemExit(main())