Files
MeshCore/tools/hil/meshtext_delivery.py
T
mikecarper c6e43c5530 Add guarded Meshtext, radio2 and multi-hop OTA hardware tests
Add identity-bound app flash adapters, exact-receipt radio2 sweeps through preamble 384, measured packet-window goodput, stock-peer delivery probes, Zopfli1000 USB OTA serving and bridge outage/recovery checks. Keep dwell experiments opt-in, production timing unchanged, generated artifacts outside Git, and wire offline safety tests into CI.
2026-10-15 21:05:40 -07:00

774 lines
39 KiB
Python

#!/usr/bin/env python3
"""Exercise Meshtext first-chat metadata and heard-repeat delivery on two radios.
CHANGES BOTH RADIOS' SETTINGS AND TRANSMITS LoRa. Requires explicit consent.
The fixed private lab channel uses 909.5 MHz / 500 kHz / SF5 / CR5, TX 2 dBm.
Keys remain in memory and are never included in the report. This does not flash
firmware, back up settings, or restore them. Run --plan without dependencies or
ports. A stock peer's decoded events are not an independent raw RF capture.
Example:
python meshtext_delivery.py --allow-radio-changes --companion-port /dev/ttyACM9 \
--stock-port /dev/ttyUSB1 --report /tmp/meshtext-delivery-new.json
"""
from __future__ import annotations
import argparse
import base64
from datetime import datetime, timezone
import importlib.metadata
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import subprocess
import sys
import time
RF = {"frequency_mhz": 909.5, "bandwidth_khz": 500, "sf": 5, "cr": 5,
"tx_power_dbm": 2, "stock_preamble_symbols": 16}
CASES = ("quiet", "first-use", "exhaustion", "once", "echo", "inbound", "hops-zero",
"same-tuple", "rename")
class Failure(RuntimeError):
pass
def check(condition, message):
if not condition:
raise Failure(message)
def retry_stats(reply):
values = dict(re.findall(r"\b(retry|heard|exhausted|pending|count)=(\d+)", reply))
check(len(values) == 5, "get mt.retry did not provide all five counters")
return {name: int(value) for name, value in values.items()}
def tx_stats(reply):
match = re.search(r"\bRX=(\d+) TX=(\d+) fail=(\d+)", reply)
check(match is not None, "get mt.status did not provide RF completion counters")
return dict(zip(("rx", "tx", "fail"), map(int, match.groups())))
def delta(after, before):
return {name: after[name] - before[name] for name in after}
def port_number(packet):
value = packet.get("decoded", {}).get("portnum")
# The Python SDK normally emits enum names, but accept numeric SDK output.
return {1: "TEXT_MESSAGE_APP", 3: "POSITION_APP", 4: "NODEINFO_APP"}.get(value, value)
def packet_summary(packet):
return {"id": packet.get("id"), "port": port_number(packet),
"hop_limit": packet.get("hopLimit"), "hop_start": packet.get("hopStart")}
def cli_command_label(command):
words = command.split()
# Identify the setting, never its value (especially mt.channel keys).
return " ".join(words[:2] if words and words[0] in ("get", "set", "send", "delete") else words[:1])
def safe_cli_reply(command, reply):
# Ordinary firmware replies do not echo the key; still redact it if a
# diagnostic implementation does so. Do not save the full command itself.
words = command.split()
if len(words) >= 5 and words[:2] == ["set", "mt.channel"]:
reply = reply.replace(words[-1], "<redacted>")
return re.sub(r"(?<![0-9a-fA-F])[0-9a-fA-F]{32,64}(?![0-9a-fA-F])", "<redacted>", reply)[:500]
def require_idle_port(path):
# Do not reset/steal a serial session held by a flasher or client. BusyBox
# fuser lacks '--', so the inspected absolute path is a separate argument.
inspector = shutil.which("fuser") if sys.platform.startswith("linux") else None
if inspector:
result = subprocess.run([inspector, os.path.realpath(path)], capture_output=True, timeout=3)
check(result.returncode == 1, "selected serial port is busy or ownership inspection failed")
def enter_binary_mode(port):
"""Explicitly release a Full Companion's default ASCII USB terminal.
The startup probe is intentionally not assumed to be a permanent Binary
mode. Assert host presence only after opening with RTS/DTR safely released,
then drain the terminal's acknowledgement before creating framed requests.
Never retain/log the discarded plaintext (it may include unrelated logs).
"""
port.rts = False
port.dtr = True
token = b"+++MESHCORE-TERM-STOP\r"
check(port.write(token) == len(token), "incomplete USB Binary-mode negotiation write")
port.flush()
time.sleep(0.3)
deadline = time.monotonic() + 1
discarded = 0
tail = b""
while time.monotonic() < deadline:
chunk = port.read(min(port.in_waiting or 1, 4096))
if not chunk:
break
discarded += len(chunk)
check(discarded <= 65536, "USB terminal output exceeded bounded negotiation drain")
tail = (tail + chunk)[-512:]
check(b"set usb.logging off before Binary mode" not in tail,
"USB logging owns this port; set usb.logging off in the terminal before this test")
return {"explicit_stop_token": True, "discarded_terminal_bytes": discarded, "dtr": True}
def reconnect_stock_readback(peer, validate):
"""Allow a committed stock configuration to settle across its reboot.
Each SDK connect has the existing helper's 20-second timeout. Only static
field names/error types enter this history, never the observed secret key.
"""
history = []
for attempt in range(1, 4):
try:
peer.connect()
mismatches = validate(peer.iface.localNode)
if not mismatches:
return {"attempts": attempt, "retry_history": history}
except Exception as error:
mismatches = ["connect/readback error: " + type(error).__name__]
history.append({"attempt": attempt, "mismatched_fields": mismatches})
if peer.iface is not None:
try:
peer.iface.close()
finally:
peer.iface = None
if attempt < 3:
time.sleep(2)
raise Failure("stock configuration did not settle after three bounded reconnects; "
"last mismatched fields: " + ", ".join(history[-1]["mismatched_fields"]))
def require_stock_connected(peer):
"""Silence is evidence only while the stock receive path is connected."""
iface = getattr(peer, "iface", None)
check(iface is not None, "stock SDK disconnected; quiet/RX observations are invalid")
state = {}
connected = getattr(iface, "isConnected", None)
if hasattr(connected, "is_set"):
state["sdk_connected"] = bool(connected.is_set())
elif isinstance(connected, bool):
state["sdk_connected"] = connected
stream = getattr(iface, "stream", None)
if stream is not None and hasattr(stream, "is_open"):
state["serial_open"] = bool(stream.is_open)
check(state, "stock SDK exposes no supported connection-health indicator")
check(all(state.values()), "stock SDK disconnected; quiet/RX observations are invalid")
return state
def dependencies():
"""Delay hardware dependencies so --plan also works on a clean computer."""
import serial
from meshtext_auto_radio2 import Companion, StockPeer
from esp32_companion_serial_stress import DeviceFrameReader, TransportCounters
from serial_session import open_configured_session
class SafeCompanion(Companion):
def __init__(self, port):
self.port = serial.Serial(port=None, baudrate=115200, timeout=0.05,
write_timeout=2, exclusive=True)
self.port.port, self.port.dtr, self.port.rts = port, False, False
open_configured_session(self.port)
self.reader = DeviceFrameReader(TransportCounters())
self.max_channels = 0
def start(self):
negotiation = enter_binary_mode(self.port)
# The reader has not received any framed traffic yet; the ASCII
# acknowledgement must not become a stale framed response.
self.reader = DeviceFrameReader(TransportCounters())
return {**super().start(), "usb_binary_negotiation": negotiation}
def cli(self, command):
label = cli_command_label(command)
try:
frame = self.request(b"\x42" + command.encode("utf-8"), (0x1d,))
reply = frame[1:].decode("utf-8", "strict").strip()
except Exception as error:
raise Failure("Companion CLI " + label + " failed: "
+ safe_cli_reply(command, str(error))) from error
check(not re.match(r"(?:Error|ERR|Err)\b", reply),
"Companion CLI " + label + " rejected: " + safe_cli_reply(command, reply))
return reply
class LabStock(StockPeer):
def configure_lab(self, key, role_name, hops=7):
from meshtastic.protobuf import channel_pb2, config_pb2
from meshtastic.util import to_node_num
node = self.iface.localNode
node.ensureSessionKey()
deadline = time.monotonic() + 10
while not self.iface._getOrCreateByNum(to_node_num(node.nodeNum)).get("adminSessionPassKey"):
check(time.monotonic() < deadline, "stock admin session setup timed out")
time.sleep(0.05)
node.beginSettingsTransaction()
time.sleep(0.75)
suppressed = []
def optional(message, field, value):
if field in message.DESCRIPTOR.fields_by_name:
setattr(message, field, value)
suppressed.append(field)
device = node.localConfig.device
device.role = config_pb2.Config.DeviceConfig.Role.Value(role_name)
device.rebroadcast_mode = config_pb2.Config.DeviceConfig.RebroadcastMode.Value("ALL")
optional(device, "node_info_broadcast_secs", 604800)
node.writeConfig("device")
time.sleep(0.75)
position = node.localConfig.position
optional(position, "position_broadcast_secs", 604800)
optional(position, "position_broadcast_smart_enabled", False)
optional(position, "fixed_position", False)
gps_field = position.DESCRIPTOR.fields_by_name.get("gps_mode")
if gps_field and "DISABLED" in gps_field.enum_type.values_by_name:
optional(position, "gps_mode", gps_field.enum_type.values_by_name["DISABLED"].number)
node.writeConfig("position")
time.sleep(0.75)
telemetry = getattr(node.moduleConfig, "telemetry", None)
if telemetry is not None:
for field in telemetry.DESCRIPTOR.fields:
if field.name.endswith("_enabled"):
optional(telemetry, field.name, False)
elif field.name.endswith("_interval"):
optional(telemetry, field.name, 604800)
node.writeConfig("telemetry")
time.sleep(0.75)
primary = node.channels[0]
primary.role = channel_pb2.Channel.PRIMARY
primary.settings.name, primary.settings.psk = "labtest", key
primary.settings.uplink_enabled = primary.settings.downlink_enabled = False
lora = node.localConfig.lora
lora.region = config_pb2.Config.LoRaConfig.US
lora.use_preset = False
lora.bandwidth, lora.spread_factor, lora.coding_rate = 500, 5, 5
lora.channel_num, lora.override_frequency, lora.frequency_offset = 0, 909.5, 0
lora.hop_limit, lora.tx_power, lora.tx_enabled = hops, 2, 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)
# Disable other channels rather than altering their secret keys.
disabled = []
for index, channel in enumerate(node.channels[1:], 1):
if channel.role != channel_pb2.Channel.DISABLED:
channel.role = channel_pb2.Channel.DISABLED
node.writeChannel(index)
disabled.append(index)
time.sleep(0.75)
node.commitSettingsTransaction()
# A successful commit/readback can precede the actual stock reboot.
# Reopening too early gives a convincing config followed by a dead
# receive stream. Allow that deferred reboot to finish first.
time.sleep(20)
self.iface.close()
self.iface = None
def validate(observed_node):
observed, channel = observed_node.localConfig.lora, observed_node.channels[0]
checks = {
"channel.role": channel.role == channel_pb2.Channel.PRIMARY,
"channel.name": channel.settings.name == "labtest",
"channel.private_key_match": channel.settings.psk == key,
"channel.mqtt_off": not channel.settings.uplink_enabled and not channel.settings.downlink_enabled,
"lora.region": observed.region == config_pb2.Config.LoRaConfig.US,
"lora.custom_mode": not observed.use_preset,
"lora.bandwidth": observed.bandwidth == 500,
"lora.spread_factor": observed.spread_factor == 5,
"lora.coding_rate": observed.coding_rate == 5,
"lora.override_frequency": abs(observed.override_frequency - 909.5) < 0.001,
"lora.frequency_offset": observed.frequency_offset == 0,
"lora.channel_num": observed.channel_num == 0,
"lora.tx_power": observed.tx_power == 2,
"lora.tx_enabled": observed.tx_enabled,
"lora.hop_limit": observed.hop_limit == hops,
"lora.mqtt_off": not observed.config_ok_to_mqtt and observed.ignore_mqtt,
"device.role": observed_node.localConfig.device.role
== config_pb2.Config.DeviceConfig.Role.Value(role_name),
"device.rebroadcast_mode": observed_node.localConfig.device.rebroadcast_mode
== config_pb2.Config.DeviceConfig.RebroadcastMode.Value("ALL"),
}
return [field for field, matched in checks.items() if not matched]
settled = reconnect_stock_readback(self, validate)
return {**self.identity(), "role": role_name, "rebroadcast": "ALL", "rf": RF,
"periodic_suppression_requested": suppressed,
"secondary_channel_slots_disabled": disabled, "readback": settled}
return SafeCompanion, LabStock
class Report:
def __init__(self, path, data):
# Reserve the destination before any radio setting is changed.
self.stream = path.open("x", encoding="utf-8")
try:
os.chmod(path, 0o600)
except OSError:
pass
self.data = data
self.save()
def save(self):
self.stream.seek(0)
json.dump(self.data, self.stream, indent=2)
self.stream.write("\n")
self.stream.truncate()
self.stream.flush()
os.fsync(self.stream.fileno())
def case(self, name):
result = {"name": name, "result": "RUNNING"}
self.data["cases"].append(result)
self.save()
print("Testing " + name, flush=True)
return result
def finish_case(self, case):
case["result"] = "PASS"
self.save()
print("PASS " + case["name"], flush=True)
def snapshot(companion):
status, retry = companion.cli("get mt.status"), companion.cli("get mt.retry")
return {"status": status, "retry_status": retry,
"traffic": tx_stats(status), "delivery": retry_stats(retry)}
def wait_idle(companion, timeout):
deadline = time.monotonic() + timeout
latest = None
while time.monotonic() < deadline:
latest = snapshot(companion)
if latest["delivery"]["pending"] == 0:
return latest
time.sleep(0.1)
raise Failure("Meshtext delivery queue did not become idle; last=" + str(latest))
def wait_active(companion, timeout):
deadline = time.monotonic() + timeout
first = last = ""
polls = 0
while time.monotonic() < deadline:
last = companion.cli("get mt.status")
if not first:
first = last
polls += 1
if "; active;" in last:
return {"first_status": first, "active_status": last, "polls": polls}
time.sleep(0.1)
raise Failure("Meshtext did not become active after settings publication; last=" + last)
def inspect_nodeinfo(packet, node_id):
from meshtastic.protobuf import mesh_pb2
user = mesh_pb2.User()
user.ParseFromString(packet.get("decoded", {}).get("payload", b""))
check(user.id == f"!{node_id:08x}", "NodeInfo sender ID mismatch")
check(user.role == 1 and user.is_unmessagable, "NodeInfo is not a receive-only hidden client")
check(1 <= len(user.short_name.encode()) <= 4 and len(user.long_name.encode()) <= 24,
"NodeInfo names exceed supported bounds")
check(not user.public_key, "unexpected public encryption key in NodeInfo")
raw = packet.get("raw")
if raw is not None:
check(not raw.want_ack and not raw.decoded.want_response, "NodeInfo requests a response")
return {"id": user.id, "short_name": user.short_name, "long_name": user.long_name,
"role": user.role, "is_unmessagable": user.is_unmessagable,
"payload_type": "User (no position fields)", "packet": packet_summary(packet)}
def run(args):
key = secrets.token_bytes(args.key_bits // 8)
run_id = secrets.token_hex(4)
selected = set(CASES if args.case == "all" else args.case.split(","))
data = {"schema": 1, "started_utc": datetime.now(timezone.utc).isoformat(),
"result": "RUNNING", "run_id": run_id, "rf": RF, "key_bits": args.key_bits,
"cases_requested": sorted(selected), "settings_restored": False,
"restore_owner": "operator", "cases": [],
"limitations": ["Stock decoded delivery events are not an independent raw RF capture.",
"Exact retry ciphertext identity is covered by host tests, not inspected by this runner.",
"No POSITION_APP means none observed during bounded windows, not a proof of future behavior.",
"The stock device may still send boot metadata despite long periodic intervals."]}
report = Report(args.report, data)
companion = stock = None
try:
Companion, Stock = dependencies()
data["packages"] = {name: importlib.metadata.version(name)
for name in ("pyserial", "meshtastic", "pypubsub")}
require_idle_port(args.companion_port)
require_idle_port(args.stock_port)
companion = Companion(args.companion_port)
data["companion"] = companion.start()
check(args.channel_slot < companion.max_channels, "Companion channel slot out of range")
# The primary is intentionally not changed here: CLI 'set radio' is
# reboot-only. Preconfigure it, and preferably use the lab TX guard too.
data["primary"] = companion.cli("get radio")
check(abs(float(data["primary"].lstrip("> ").split(",")[0]) - 909.5) < 0.001,
"preconfigure primary to 909.5 MHz (or flash a lab profile) before this runner")
for slot in range(companion.max_channels):
if slot != args.channel_slot:
name = companion.channel_name(slot).lstrip("#")
check(not name.startswith(("mt-", "r2-")),
"another Meshtext/radio2 channel conflicts; remove it explicitly first")
target_name = companion.channel_name(args.channel_slot).lstrip("#")
if target_name.startswith("mt-"):
companion.cli(f"delete mt.channel {args.channel_slot}")
# Clear temporary leases before installing a manual lab-only profile.
for command in ("normalradio", "set tempradio2 off", "set mt.region us",
"set mt.slot 0", "set mt.hops 7", "set tx 2", "set radio2.cross off",
"set flood.retry.count 2", "set flood.retry.advert off",
"set radio2 909.5,500,5,5,rxtx,32"):
companion.cli(command)
stock = Stock(args.stock_port)
data["stock_silent"] = stock.configure_lab(key, "CLIENT_MUTE")
stock_role = "CLIENT_MUTE"
node_id = companion.mt_node()
check(stock.node != node_id, "stock and Companion sender IDs collide")
data["companion"]["mt_node"] = f"!{node_id:08x}"
# Installing a new random key resets first-use identity announcement.
companion.cli(f"set mt.channel {args.channel_slot} mt-labtest {key.hex()}")
data["initial"] = snapshot(companion)
data["radio2"] = companion.cli("get radio2")
check("; active;" in data["initial"]["status"], "Meshtext profile is not active")
report.save()
if "quiet" in selected:
case = report.case("quiet")
case["stock_connected_before"] = require_stock_connected(stock)
before, since = snapshot(companion), time.monotonic()
time.sleep(args.quiet_seconds)
case["stock_connected_after"] = require_stock_connected(stock)
after = snapshot(companion)
case.update(before=before, after=after, observed_seconds=args.quiet_seconds,
packets=[packet_summary(p) for p in stock.packets_since(since, node_id)])
report.save()
check(after["traffic"]["tx"] == before["traffic"]["tx"],
"Companion transmitted Meshtext before any user chat")
check(not case["packets"], "stock observed unsolicited DUT traffic")
report.finish_case(case)
if selected == {"quiet"}:
data["final"] = after
data["result"] = "PASS"
return data
# The first send is also needed to prime the once/echo cases. Report it
# even when only a dependent test was requested.
case = report.case("first-use")
token = f"MT-delivery {run_id} first"
before, since = snapshot(companion), time.monotonic()
companion.send_text(args.channel_slot, token)
text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload")
== token.encode(), since, args.timeout)
check(text is not None, "stock did not receive first chat")
info = stock.wait_packet(node_id, lambda p: port_number(p) == "NODEINFO_APP", since, args.timeout)
check(info is not None, "stock did not receive automatic NodeInfo after first chat")
case["nodeinfo"] = inspect_nodeinfo(info, node_id)
after = wait_idle(companion, args.timeout)
packets = stock.packets_since(since, node_id)
texts = [p for p in packets if p.get("decoded", {}).get("payload") == token.encode()]
infos = [p for p in packets if port_number(p) == "NODEINFO_APP"]
case.update(before=before, after=after, traffic_delta=delta(after["traffic"], before["traffic"]),
delivery_delta=delta(after["delivery"], before["delivery"]),
packets=[packet_summary(p) for p in packets], decoded_text_events=len(texts),
decoded_nodeinfo_events=len(infos))
report.save()
check(not any(port_number(p) == "POSITION_APP" for p in packets),
"DUT emitted a position packet after first chat")
check(len(infos) == 1, "automatic NodeInfo was not observed exactly once")
check(packets.index(text) < packets.index(info), "automatic NodeInfo arrived before user text")
report.finish_case(case)
if "exhaustion" in selected:
case = report.case("silent-peer-exhaustion")
case.update(evidence_from="first-use", **{name: report.data["cases"][-2][name]
for name in ("traffic_delta", "delivery_delta", "decoded_text_events")})
report.save()
check(case["delivery_delta"]["retry"] == 2
and case["delivery_delta"]["exhausted"] == 1
and case["delivery_delta"]["heard"] == 0,
"silent peer did not cause exactly two retries and one exhaustion")
check(case["traffic_delta"]["tx"] == 4 and case["traffic_delta"]["fail"] == 0,
"expected completed RF TX: original chat + two retries + one NodeInfo")
check(case["decoded_text_events"] == 1,
"stock delivered duplicate user text for the same retried packet ID")
report.finish_case(case)
if "once" in selected:
case = report.case("second-chat-no-new-nodeinfo")
token = f"MT-delivery {run_id} second"
before, since = snapshot(companion), time.monotonic()
companion.send_text(args.channel_slot, token)
text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload")
== token.encode(), since, args.timeout)
after = wait_idle(companion, args.timeout)
packets = stock.packets_since(since, node_id)
case.update(received=text is not None, before=before, after=after,
packets=[packet_summary(p) for p in packets])
report.save()
check(text is not None, "stock did not receive second chat")
check(not any(port_number(p) in ("NODEINFO_APP", "POSITION_APP") for p in packets),
"second chat emitted extra NodeInfo or position")
check(after["traffic"]["tx"] - before["traffic"]["tx"] == 3,
"second silent-peer chat did not complete precisely three text TXs")
report.finish_case(case)
if "echo" in selected:
case = report.case("heard-repeat-cancellation")
case["stock"] = stock.configure_lab(key, "CLIENT")
stock_role = "CLIENT"
# At SF5 the DUT's first two attempts can finish before a stock
# CLIENT's relay backoff (~2 s). Leave a genuine observation window.
companion.cli("set flood.retry.count 15")
case["retry_budget"] = 15
case["attempts"] = []
report.save()
passed = False
for attempt in range(args.echo_attempts):
token = f"MT-delivery {run_id} echo {attempt}"
before, since = snapshot(companion), time.monotonic()
companion.send_text(args.channel_slot, token)
text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload")
== token.encode(), since, args.timeout)
after = wait_idle(companion, args.timeout)
delivery = delta(after["delivery"], before["delivery"])
observation = {"received_by_stock": text is not None, "before": before, "after": after,
"delivery_delta": delivery,
"traffic_delta": delta(after["traffic"], before["traffic"]),
"packets": [packet_summary(p) for p in stock.packets_since(since, node_id)]}
case["attempts"].append(observation)
report.save()
if text is not None and delivery["heard"] >= 1 and delivery["exhausted"] == 0 \
and delivery["retry"] < 15:
passed = True
break
check(passed, "no progressed stock relay echo cancelled a retry before exhaustion; RF/scanning needs investigation")
case["proof"] = "DUT heard counter advanced on a matching progressed frame; pending=0 before budget exhaustion"
report.finish_case(case)
if "inbound" in selected:
case = report.case("stock-to-companion")
token = f"MT-delivery {run_id} inbound"
sent = stock.send_text(token, 7)
received = companion.wait_text(args.channel_slot, stock.node, token, args.timeout)
case.update(packet_id=sent.id, **received)
report.save()
check(received["received"], "Companion did not decode the stock private-channel chat")
report.finish_case(case)
if "hops-zero" in selected:
case = report.case("zero-hops-no-useless-retries")
companion.cli("set mt.hops 0")
companion.cli("set flood.retry.count 2")
before, since = snapshot(companion), time.monotonic()
token = f"MT-delivery {run_id} zero"
companion.send_text(args.channel_slot, token)
text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload")
== token.encode(), since, args.timeout)
after = wait_idle(companion, args.timeout)
time.sleep(1)
final = snapshot(companion)
case.update(received=text is not None, before=before, after=after, final=final,
delivery_delta=delta(final["delivery"], before["delivery"]),
traffic_delta=delta(final["traffic"], before["traffic"]))
report.save()
check(text is not None, "stock did not receive zero-hop text")
check(case["traffic_delta"]["tx"] == 1 and case["delivery_delta"]["retry"] == 0,
"zero-hop text caused a useless retransmission or identity announcement")
report.finish_case(case)
if selected & {"same-tuple", "rename"}:
# Previous all-case hops-zero tests intentionally leave hops=0.
# Restore the baseline before observing either new configuration.
wait_idle(companion, args.timeout)
companion.cli("set mt.hops 7")
companion.cli("set flood.retry.count 0")
companion.cli("set flood.retry.advert off")
if "same-tuple" in selected:
case = report.case("same-tuple-publication-recovery")
before, since = snapshot(companion), time.monotonic()
companion.cli("set radio2 909.5,500,5,5,rxtx,32")
# Do not repair/reapply any setting while the publication lease
# expires: only read-only status polls may cause this test to pass.
case["publication"] = wait_active(companion, args.timeout)
after = snapshot(companion)
case.update(before=before, after=after,
traffic_delta=delta(after["traffic"], before["traffic"]),
primary_after=companion.cli("get radio"),
packets=[packet_summary(p) for p in stock.packets_since(since, node_id)],
public_safety="primary unchanged; no Public transmission requested")
report.save()
check(case["traffic_delta"]["tx"] == 0, "same-tuple configuration caused unsolicited Meshtext TX")
check(case["primary_after"] == data["primary"], "same-tuple radio2 update changed primary radio")
check(not case["packets"], "stock observed unsolicited DUT traffic after same-tuple update")
report.finish_case(case)
if "rename" in selected:
case = report.case("rename-rearms-first-chat-nodeinfo")
original_name = data["companion"].get("primary", {}).get("node_name")
check(isinstance(original_name, str) and original_name
and not any(character in original_name for character in "\r\n\0"),
"APP_START did not provide a safely restorable original node name")
if stock_role != "CLIENT_MUTE":
case["stock"] = stock.configure_lab(key, "CLIENT_MUTE")
stock_role = "CLIENT_MUTE"
require_stock_connected(stock)
new_name = "MT-HIL-" + run_id
check(len(new_name.encode()) < 24 and new_name != original_name, "invalid unique rename test name")
case.update(original_name=original_name, test_name=new_name, original_name_restored=False)
renamed = False
try:
before = snapshot(companion)
companion.cli("set name " + new_name)
renamed = True
time.sleep(0.4)
case["after_rename_quiet"] = snapshot(companion)
check(case["after_rename_quiet"]["traffic"]["tx"] == before["traffic"]["tx"],
"renaming the node transmitted Meshtext before user chat")
since = time.monotonic()
tokens = []
case["probes"] = []
case["probe_timeout_seconds"] = min(5, args.timeout)
# A zero-retry first chat can be lost over RF even though it
# correctly triggered the one identity TX. Use distinct new
# chats, retain all misses, and observe NodeInfo from before
# the first send rather than demanding it after a received one.
for probe in range(3):
require_stock_connected(stock)
token = f"MT-delivery {run_id} renamed {probe}"
tokens.append(token)
companion.send_text(args.channel_slot, token)
text = stock.wait_packet(node_id, lambda p: p.get("decoded", {}).get("payload")
== token.encode(), since, min(5, args.timeout))
info = stock.wait_packet(node_id, lambda p: port_number(p) == "NODEINFO_APP",
since, min(5, args.timeout))
case["probes"].append({"index": probe, "received": text is not None,
"received_in_probe_window": text is not None,
"packet_id": text.get("id") if text else None})
report.save()
if text is not None and info is not None:
break
after = wait_idle(companion, args.timeout)
require_stock_connected(stock)
packets = stock.packets_since(since, node_id)
for token, probe in zip(tokens, case["probes"]):
text = next((packet for packet in packets
if packet.get("decoded", {}).get("payload") == token.encode()), None)
probe["received"] = text is not None
probe["packet_id"] = text.get("id") if text else None
infos = [packet for packet in packets if port_number(packet) == "NODEINFO_APP"]
case.update(before=before, after=after,
attempted_texts=len(tokens),
received_texts=sum(probe["received"] for probe in case["probes"]),
decoded_nodeinfo_events=len(infos),
traffic_delta=delta(after["traffic"], before["traffic"]),
packets=[packet_summary(p) for p in packets])
report.save()
check(case["received_texts"] >= 1, "stock did not receive any of three distinct chats after rename")
check(len(infos) == 1, "rename did not produce exactly one observed automatic NodeInfo")
case["nodeinfo"] = inspect_nodeinfo(infos[0], node_id)
report.save()
check(case["nodeinfo"]["long_name"] == new_name, "automatic NodeInfo retained the old long name")
check(case["traffic_delta"]["tx"] == len(tokens) + 1 and case["traffic_delta"]["fail"] == 0,
"renamed chat probes did not complete precisely attempted texts + one NodeInfo TX")
check(not any(port_number(p) == "POSITION_APP" for p in packets),
"renamed first chat emitted position data")
finally:
if renamed:
before_restore = snapshot(companion)
companion.cli("set name " + original_name)
time.sleep(0.4)
after_restore = snapshot(companion)
restored_name = companion.cli("get name")
check(restored_name in (original_name, "> " + original_name), "original node name was not restored")
case["original_name_restored"] = True
case["restore_traffic_delta"] = delta(after_restore["traffic"], before_restore["traffic"])
report.save()
check(case["restore_traffic_delta"]["tx"] == 0,
"restoring the original name caused unsolicited Meshtext TX")
report.finish_case(case)
data["final"] = snapshot(companion)
data["result"] = "PASS"
except (Exception, KeyboardInterrupt) as error:
message = str(error)
for secret in (key.hex(), str(key), repr(key), base64.b64encode(key).decode()):
message = message.replace(secret, "<redacted>")
data["result"] = "FAIL"
data["failure"] = {"type": type(error).__name__, "message": message[:1500]}
if data["cases"] and data["cases"][-1]["result"] == "RUNNING":
data["cases"][-1]["result"] = "FAIL"
finally:
for name, device in (("stock", stock), ("companion", companion)):
if device is not None:
try:
device.close()
except Exception as error:
data.setdefault("close_failures", []).append({"device": name, "type": type(error).__name__})
data["result"] = "FAIL"
data["finished_utc"] = datetime.now(timezone.utc).isoformat()
report.save()
report.stream.close()
return data
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--companion-port")
parser.add_argument("--stock-port")
parser.add_argument("--report", type=Path)
parser.add_argument("--channel-slot", type=int, default=1)
parser.add_argument("--key-bits", type=int, choices=(128, 256), default=128)
parser.add_argument("--case", default="all", help="all or comma-separated: " + ",".join(CASES))
parser.add_argument("--timeout", type=float, default=25, help="each bounded observation, at most 30 s")
parser.add_argument("--quiet-seconds", type=float, default=5)
parser.add_argument("--echo-attempts", type=int, default=3)
parser.add_argument("--allow-radio-changes", action="store_true")
parser.add_argument("--plan", action="store_true")
args = parser.parse_args()
try:
check(args.case == "all" or set(args.case.split(",")) <= set(CASES), "unknown case")
check(0 <= args.channel_slot <= 255, "channel-slot must be 0..255")
check(0 < args.timeout <= 30 and 0 < args.quiet_seconds <= 30,
"each wait must be positive and at most 30 s")
check(1 <= args.echo_attempts <= 5, "echo-attempts must be 1..5")
if args.plan:
print(json.dumps({"rf": RF, "cases": CASES, "key_bits": args.key_bits,
"consent_required": "--allow-radio-changes",
"primary_precondition": "DUT primary already 909.5 MHz; use lab TX-frequency guard",
"silent_role": "CLIENT_MUTE", "echo_role": "CLIENT",
"echo_retry_budget": 15, "settings_restored": False}, indent=2))
return 0
check(args.allow_radio_changes, "--allow-radio-changes is required before opening ports")
check(args.companion_port and args.stock_port and args.report,
"--companion-port, --stock-port and --report are required")
check(os.path.realpath(args.companion_port) != os.path.realpath(args.stock_port), "ports must be distinct")
check(not args.report.exists() and args.report.parent.is_dir(),
"choose a fresh report in an existing directory")
result = run(args)
print(json.dumps({"result": result["result"], "report": str(args.report),
"failure": result.get("failure"), "settings_restored": False}, indent=2))
return 0 if result["result"] == "PASS" else 1
except Failure as error:
parser.error(str(error))
if __name__ == "__main__":
raise SystemExit(main())