Run Unit Tests / nrf52-companion-smoke (push) Canceled after 0s
Run Unit Tests / nrf52-sensor-smoke (push) Canceled after 0s
Run Unit Tests / esp32-sensor-smoke (push) Canceled after 0s
Run Unit Tests / management-reports (push) Canceled after 0s
Run Unit Tests / stm32-companion-smoke (push) Canceled after 0s
Run Unit Tests / bootloader-version (push) Canceled after 0s
Run Unit Tests / test (push) Canceled after 0s
Run Unit Tests / hosted-doc-links (push) Canceled after 0s
Build and deploy Docs site to GitHub Pages / github-pages (push) Canceled after 0s
457 lines
22 KiB
Python
457 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Identity-bound Morse TX/RX trial at 902.1 MHz, low TX power.
|
|
|
|
No flashing, erasure, service stopping or power cycling. Received text comes
|
|
from the actual firmware's CLI capture pages, never a host-side decoder.
|
|
The active LoRa packet profile stays at 909.5 MHz, 500 kHz, SF5, CR5.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import math
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
import re
|
|
import time
|
|
import serial
|
|
from serial.tools import list_ports
|
|
|
|
SERIALS = {"rak": "9AB3B64C641BA927", "t096": "651F8E496197F882",
|
|
"pocket": "93DA83ADF7B2092B"}
|
|
DEFAULT_CW_FREQUENCY = 902.1
|
|
PACKET_RADIO = "909.5,500,5,5"
|
|
STANDARD_TIMING = {"jitter_percent": 0, "drift_percent": 0,
|
|
"extra_gap_percent": 0, "seed": 1}
|
|
MESSAGES = (
|
|
"SOS TEST DE N0CALL 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
"E T I M S O H Z 5 0 PARIS QUICK BROWN FOX JUMPS OVER LAZY DOG",
|
|
". , ? ' ! / ( ) & : ; = + - _ \" $ @",
|
|
)
|
|
|
|
|
|
def parse_cw_frequency(text):
|
|
"""Keep CW in the lab band, clear of the 500 kHz packet RX bandwidth."""
|
|
try:
|
|
frequency = round(float(text), 6)
|
|
except (ValueError, TypeError, OverflowError):
|
|
raise argparse.ArgumentTypeError("CW frequency must be a finite MHz value") from None
|
|
if not math.isfinite(frequency) or not 902 <= frequency <= 928:
|
|
raise argparse.ArgumentTypeError("CW frequency must be between 902 and 928 MHz")
|
|
if abs(frequency - 909.5) <= .260 + 1e-9:
|
|
raise argparse.ArgumentTypeError("CW frequency overlaps the 909.5 MHz packet radio and guard")
|
|
return frequency
|
|
|
|
|
|
def parse_human_timing(text):
|
|
match = re.fullmatch(r"(\d+),([+-]?\d+),(\d+),(\d+)", text)
|
|
if not match:
|
|
raise argparse.ArgumentTypeError("Use jitter,drift,extraGap,seed (for example15,10,25,12345)")
|
|
jitter, drift, gap, seed = map(int, match.groups())
|
|
if not 0 <= jitter <= 20 or not -20 <= drift <= 20 or not 0 <= gap <= 50 or not 1 <= seed <= 0xffffffff:
|
|
raise argparse.ArgumentTypeError("Jitter0-20%, drift-20..20%, extra gap0-50%, seed1..4294967295 only")
|
|
return {"jitter_percent": jitter, "drift_percent": drift,
|
|
"extra_gap_percent": gap, "seed": seed}
|
|
|
|
|
|
def parse_rx_squelch(text):
|
|
"""A volatile HIL receive threshold, never a radio TX-power setting."""
|
|
if text == "auto":
|
|
return None
|
|
if not re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", text):
|
|
raise argparse.ArgumentTypeError("RX squelch must be auto or a finite -160..0 dBm value")
|
|
value = float(text)
|
|
if not math.isfinite(value) or not -160 <= value <= 0:
|
|
raise argparse.ArgumentTypeError("RX squelch must be between -160 and 0 dBm")
|
|
return round(value, 3)
|
|
|
|
|
|
def live_temporary_profile(reply):
|
|
"""Parse only confirmed live profiles, never saved/pending radio settings."""
|
|
fields = r"(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)"
|
|
companion = re.search(r"TempRadio active:\s*" + fields + r"(?=\s|$)", reply)
|
|
infrastructure = re.search(
|
|
r"(?m)^\s*(?:->\s*)?>\s*" + fields + r"(?:,\d+)?\s*$", reply)
|
|
match = companion or infrastructure
|
|
if match is None:
|
|
return None
|
|
return float(match[1]), float(match[2]), int(match[3]), int(match[4])
|
|
|
|
|
|
def matched_console_reply(data, command, companion):
|
|
"""Require this command's complete echo before its reply and final prompt."""
|
|
text = data.decode("utf-8", errors="replace")
|
|
echoes = list(re.finditer(r"(?:^|\r?\n)(?:> )?" + re.escape(command) + r"\r?\n", text))
|
|
if not echoes:
|
|
return None
|
|
# A timed-out session is never reused, so repeated polling cannot confuse
|
|
# an old echo with a new request. Discard complete *different* stale frames.
|
|
echo = echoes[-1]
|
|
tail = text[echo.end():]
|
|
response = re.search(r"(?m)^ (?:-> |ERROR:|OK(?=\r?(?:\n|$)| -))", tail)
|
|
if response is None:
|
|
return None
|
|
ending = re.search(r"\r?\n> " if companion else r"\r?\n",
|
|
tail[response.start():])
|
|
if ending is None:
|
|
return None
|
|
end = echo.end() + response.start() + ending.end()
|
|
return text[echo.start():end].lstrip("\r\n")
|
|
|
|
|
|
class Console:
|
|
def __init__(self, serial_number, companion=True):
|
|
matches = [p for p in list_ports.comports()
|
|
if (p.serial_number or "").upper() == serial_number
|
|
and p.interface != "MeshCore Logging"]
|
|
if len(matches) != 1:
|
|
raise RuntimeError(f"Missing/ambiguous application USB identity {serial_number}")
|
|
self.serial_number = serial_number
|
|
self.companion = companion
|
|
self.port = serial.Serial(port=None, baudrate=115200, timeout=.015)
|
|
self.port.dtr, self.port.rts = True, False
|
|
self.port.port = matches[0].device
|
|
self.port.open()
|
|
self.commands = []
|
|
self.failed = False
|
|
try:
|
|
hello = self.read_quiet(1)
|
|
if companion and "WELCOME" not in hello:
|
|
self.port.write(b"+++MESHCORE-TERM-START\r\n")
|
|
self.read_quiet(1)
|
|
self.command("ver")
|
|
self.command("board")
|
|
except BaseException:
|
|
self.port.close()
|
|
raise
|
|
|
|
def read_quiet(self, timeout=2):
|
|
data = bytearray()
|
|
end = quiet = time.monotonic() + timeout
|
|
while time.monotonic() < end and time.monotonic() < quiet:
|
|
chunk = self.port.read(self.port.in_waiting or 1)
|
|
if chunk:
|
|
data.extend(chunk)
|
|
quiet = time.monotonic() + .12
|
|
return data.decode("utf-8", errors="replace")
|
|
|
|
def command(self, text, timeout=10, allow_error=False):
|
|
if getattr(self, "failed", False):
|
|
raise RuntimeError(("CLI session desynchronized; refusing further commands", self.serial_number, text))
|
|
if "\r" in text or "\n" in text:
|
|
raise ValueError("One CLI command per request only")
|
|
data = bytearray()
|
|
try:
|
|
if self.port.in_waiting:
|
|
self.read_quiet(.15)
|
|
self.port.write((text + "\r\n").encode())
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
data.extend(self.port.read(self.port.in_waiting or 1))
|
|
reply = matched_console_reply(data, text, self.companion)
|
|
if reply is not None:
|
|
break
|
|
else:
|
|
raise RuntimeError(("Incomplete CLI response", self.serial_number, text,
|
|
data.decode("utf-8", errors="replace")))
|
|
except BaseException as error:
|
|
# Do not issue another command after a missing echo/reply/transport
|
|
# completion: a late response would otherwise shift every exchange.
|
|
self.failed = True
|
|
self.commands.append({"command": text, "reply": data.decode("utf-8", errors="replace"),
|
|
"failed": True, "error": str(error),
|
|
"utc": datetime.now(timezone.utc).isoformat()})
|
|
raise
|
|
self.commands.append({"command": text, "reply": reply})
|
|
if not reply or (not allow_error and re.search(r"\b(?:Error|ERROR|ERR)\b", reply)):
|
|
raise RuntimeError((self.serial_number, text, reply))
|
|
return reply
|
|
|
|
def setup(self, cw_frequency=DEFAULT_CW_FREQUENCY):
|
|
self.command("morse stop")
|
|
self.start_when_idle("set radio.rxps off")
|
|
self.start_when_idle("set tx -9")
|
|
self.start_when_idle("set radio2 off")
|
|
self.command(f"set morse.freq {cw_frequency:.6f}")
|
|
self.command("set morse.wpm 25")
|
|
self.set_hil_timing()
|
|
self.set_rx_squelch()
|
|
self.apply_live_temporary(PACKET_RADIO)
|
|
|
|
def set_hil_timing(self, timing=None):
|
|
chosen = timing or STANDARD_TIMING
|
|
values = tuple(chosen[field] for field in ("jitter_percent", "drift_percent", "extra_gap_percent", "seed"))
|
|
command = "set morse.hil.timing " + ",".join(map(str, values))
|
|
try:
|
|
self.start_when_idle(command)
|
|
except RuntimeError as error:
|
|
# A shipping image contains no timing variation state. Any other
|
|
# error (including busy/recovery) must not masquerade as standard.
|
|
message = str(error)
|
|
unsupported = "Error: HIL-only" in message or (
|
|
"morse.hil.timing" in message and re.search(r"\bunknown (?:command|setting)\b", message, re.I))
|
|
if timing is None and unsupported and not getattr(self, "failed", False):
|
|
return "production image: standard timing only"
|
|
raise
|
|
reply = self.command("get morse.hil.timing")
|
|
observed = re.search(r"jitter=(\d+)%; drift=(-?\d+)%; extraGap=(\d+)%; seed=(\d+)", reply)
|
|
if not observed or tuple(map(int, observed.groups())) != values:
|
|
raise RuntimeError(("HIL timing readback mismatch", chosen, reply))
|
|
return reply
|
|
|
|
def set_rx_squelch(self, value=None):
|
|
if value is not None and (not math.isfinite(value) or not -160 <= value <= 0):
|
|
raise ValueError("RX squelch must be auto or between -160 and 0 dBm")
|
|
chosen = "auto" if value is None else f"{value:.3f}"
|
|
try:
|
|
self.start_when_idle("set morse.hil.squelch " + chosen)
|
|
except RuntimeError as error:
|
|
message = str(error)
|
|
unsupported = "Error: HIL-only" in message or (
|
|
"morse.hil.squelch" in message and re.search(r"\bunknown (?:command|setting)\b", message, re.I))
|
|
if value is None and unsupported and not getattr(self, "failed", False):
|
|
return "production image: adaptive RX squelch only"
|
|
raise
|
|
reply = self.command("get morse.hil.squelch")
|
|
observed = re.search(r"> (auto|[+-]?\d+(?:\.\d+)?)(?: dBm)?; volatile HIL RX only", reply)
|
|
if (not observed or (value is None and observed[1] != "auto")
|
|
or (value is not None and (observed[1] == "auto"
|
|
or not math.isclose(float(observed[1]), value, abs_tol=.00051, rel_tol=0)))):
|
|
raise RuntimeError(("HIL RX squelch readback mismatch", chosen, reply))
|
|
return reply
|
|
|
|
def apply_live_temporary(self, profile, timeout=12):
|
|
expected = tuple(float(value) for value in profile.split(","))
|
|
if len(expected) != 4:
|
|
raise ValueError("Expected frequency, bandwidth, SF and CR")
|
|
self.start_when_idle("set tempradio " + profile + ",30")
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
reply = self.command("get tempradio")
|
|
if live_temporary_profile(reply) == expected:
|
|
return reply
|
|
if time.monotonic() >= deadline:
|
|
raise RuntimeError(("Live temporary radio did not apply", self.serial_number,
|
|
profile, reply))
|
|
time.sleep(.1)
|
|
|
|
def start_when_idle(self, text):
|
|
deadline = time.monotonic() + 10
|
|
while True:
|
|
reply = self.command(text, timeout=15, allow_error=True)
|
|
if "Error: radio busy" not in reply:
|
|
if re.search(r"\b(?:Error|ERROR|ERR)\b", reply):
|
|
raise RuntimeError((self.serial_number, text, reply))
|
|
return reply
|
|
if time.monotonic() >= deadline:
|
|
raise RuntimeError(("Radio remained busy", self.serial_number, text, reply))
|
|
time.sleep(.1)
|
|
|
|
def capture(self):
|
|
text, offset = "", 0
|
|
for _ in range(100):
|
|
# A valid text capture can itself contain ERROR; only the framed
|
|
# page parser can distinguish that payload from a CLI failure.
|
|
reply = self.command(f"morse read {offset}", allow_error=True)
|
|
page = re.search(
|
|
r"> (\d{4})/(\d{4}) next=(\d{4}|END ) truncated=(\d) ([^\r\n]*)",
|
|
reply)
|
|
if not page or int(page[1]) != offset or int(page[4]):
|
|
raise RuntimeError(("Malformed/truncated capture page", reply))
|
|
text += page[5]
|
|
if page[3].strip() == "END":
|
|
return text
|
|
following = int(page[3])
|
|
if following != len(text) or following <= offset:
|
|
raise RuntimeError(("Non-progressing capture", reply))
|
|
offset = following
|
|
raise RuntimeError("Capture paging did not finish")
|
|
|
|
def trace(self):
|
|
rows, offset, total = [], 0, None
|
|
for _ in range(100):
|
|
reply = self.command(f"morse trace {offset}")
|
|
page = re.search(r"> (\d{3})/(\d{3}) t=(\d+) n=(\d{3}|END) x=([01]) ([^\r\n]*)", reply)
|
|
if not page or int(page[1]) != offset:
|
|
raise RuntimeError(("Malformed trace page", reply))
|
|
if total is not None and total != int(page[3]):
|
|
raise RuntimeError("Trace changed during retrieval")
|
|
total = int(page[3])
|
|
for item in page[6].split(";"):
|
|
if item:
|
|
at, rssi, flags = map(int, item.split(","))
|
|
rows.append({"ms": at, "dbm": rssi, "flags": flags})
|
|
if page[4] == "END":
|
|
if len(rows) != int(page[2]):
|
|
raise RuntimeError("Incomplete retained trace")
|
|
return {"total": total, "overwritten": bool(int(page[5])), "rows": rows}
|
|
following = int(page[4])
|
|
if following != len(rows) or following <= offset:
|
|
raise RuntimeError("Non-progressing trace")
|
|
offset = following
|
|
raise RuntimeError("Trace paging did not finish")
|
|
|
|
def close(self):
|
|
try:
|
|
if not getattr(self, "failed", False):
|
|
self.command("morse stop", allow_error=True)
|
|
self.set_hil_timing()
|
|
self.set_rx_squelch()
|
|
self.command("set morse.wpm 25", allow_error=True)
|
|
finally:
|
|
self.port.close()
|
|
|
|
|
|
def trial(tx, rx, wpm, message, seconds=60, trace=False, human_timing=None, rx_squelch=None):
|
|
tx.command("morse stop")
|
|
rx.command("morse stop")
|
|
tx.command(f"set morse.wpm {wpm}")
|
|
rx.command(f"set morse.wpm {wpm}")
|
|
timing_status = tx.set_hil_timing(human_timing)
|
|
squelch_status = rx.set_rx_squelch(rx_squelch)
|
|
rx.start_when_idle(f"morse listen {seconds / 60:.4f}")
|
|
# Establish a real quiet RSSI floor before any carrier transmission.
|
|
time.sleep(.8)
|
|
started = time.monotonic()
|
|
tx.start_when_idle("morse send " + message)
|
|
while time.monotonic() - started < 180:
|
|
if "> off;" in tx.command("get morse"):
|
|
break
|
|
time.sleep(.15)
|
|
else:
|
|
raise RuntimeError("Transmitter did not stop")
|
|
tx_finished = time.monotonic()
|
|
time.sleep(.6)
|
|
rx_status = rx.command("get morse")
|
|
stats = rx.command("morse stats")
|
|
rx.command("morse stop")
|
|
decoded = rx.capture()
|
|
result = {"wpm": wpm, "nominal_dot_ms": (1200 + wpm // 2) // wpm,
|
|
"tx_serial": tx.serial_number, "rx_serial": rx.serial_number,
|
|
"sent": message, "decoded": decoded, "passed": decoded.strip() == message,
|
|
"tx_elapsed_s": round(tx_finished - started, 3),
|
|
"human_timing": human_timing or STANDARD_TIMING, "tx_timing_status": timing_status,
|
|
"rx_squelch_dbm": rx_squelch, "rx_squelch_status": squelch_status,
|
|
"rx_status": rx_status, "rx_stats": stats}
|
|
if trace:
|
|
result["trace"] = rx.trace()
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--output", required=True, type=Path)
|
|
parser.add_argument("--cw-frequency", type=parse_cw_frequency, default=DEFAULT_CW_FREQUENCY,
|
|
help="CW MHz (default 902.1); must not overlap the packet radio")
|
|
parser.add_argument("--rates", default="25,40,60,75,80,100,120,150,200,240")
|
|
parser.add_argument("--repeats", type=int, default=1)
|
|
parser.add_argument("--messages", type=int, default=3, choices=(1, 2, 3))
|
|
parser.add_argument("--partner", choices=("t096", "pocket"), default="t096",
|
|
help="Exact USB partner to pair with the RAK; no other radio is opened")
|
|
parser.add_argument("--direction", choices=("both", "to-rak", "from-rak"), default="both",
|
|
help="Test both directions, partner TX to RAK, or RAK TX to partner")
|
|
parser.add_argument("--trace", action="store_true", help="Requires a MESH_MORSE_HIL trace build")
|
|
parser.add_argument("--human-timing", nargs="?", type=parse_human_timing,
|
|
const="15,10,25,12345",
|
|
help="Device-timed HIL jitter,drift,extraGap,seed; default preset15,10,25,12345")
|
|
parser.add_argument("--rx-squelch", type=parse_rx_squelch, default=None,
|
|
help="HIL receiver squelch floor in dBm (-160..0), default adaptive auto")
|
|
parser.add_argument("--quiet-seconds", type=int, default=0,
|
|
help="No-transmitter receive control, 1-30 seconds")
|
|
parser.add_argument("--t096-rxgain", choices=("on", "off"),
|
|
help="Temporary diagnostic gain setting; restored afterward")
|
|
args = parser.parse_args()
|
|
if args.output.exists():
|
|
parser.error("Refusing to overwrite an existing result")
|
|
rates = [int(value) for value in args.rates.split(",")]
|
|
if not rates or any(rate < 1 or rate > 255 for rate in rates) or not 1 <= args.repeats <= 10:
|
|
parser.error("Rates 1-255 and repeats 1-10 only")
|
|
if args.quiet_seconds and not 1 <= args.quiet_seconds <= 30:
|
|
parser.error("Quiet control duration must be 1-30 seconds")
|
|
if args.t096_rxgain and args.partner != "t096":
|
|
parser.error("--t096-rxgain requires --partner t096")
|
|
results = {"frequency_mhz": args.cw_frequency, "packet_radio": PACKET_RADIO,
|
|
"requested_chip_power_dbm": -9,
|
|
"partner": args.partner, "direction": args.direction,
|
|
"human_timing": args.human_timing or STANDARD_TIMING,
|
|
"rx_squelch_dbm": args.rx_squelch,
|
|
"scope": "Experimental HIL applications on this pair; not production speed qualification or all radio families.",
|
|
"trials": [], "finished": False}
|
|
devices = {}
|
|
original_gain = None
|
|
try:
|
|
for name in ("rak", args.partner):
|
|
devices[name] = Console(SERIALS[name])
|
|
for device in devices.values():
|
|
device.setup(args.cw_frequency)
|
|
if args.t096_rxgain:
|
|
gain_reply = devices["t096"].command("get radio.fem.rxgain")
|
|
match = re.search(r"> (on|off)\b", gain_reply)
|
|
if not match:
|
|
raise RuntimeError(("Unrecognized gain status", gain_reply))
|
|
original_gain = match[1]
|
|
devices["t096"].command("set radio.fem.rxgain " + args.t096_rxgain)
|
|
if args.quiet_seconds:
|
|
squelch_status = {}
|
|
for name, device in devices.items():
|
|
squelch_status[name] = device.set_rx_squelch(args.rx_squelch)
|
|
device.start_when_idle(f"morse listen {args.quiet_seconds / 60:.4f}")
|
|
time.sleep(args.quiet_seconds + .2)
|
|
for name, device in devices.items():
|
|
result = {"kind": "quiet_control", "receiver": name,
|
|
"rx_squelch_dbm": args.rx_squelch, "rx_squelch_status": squelch_status[name],
|
|
"rx_stats": device.command("morse stats")}
|
|
device.command("morse stop")
|
|
result["decoded"] = device.capture()
|
|
result["passed"] = not result["decoded"]
|
|
if args.trace:
|
|
result["trace"] = device.trace()
|
|
results["trials"].append(result)
|
|
print(json.dumps({key: value for key, value in result.items()
|
|
if key != "trace"}), flush=True)
|
|
results["finished"] = True
|
|
return
|
|
directions = []
|
|
if args.direction in ("both", "to-rak"):
|
|
directions.append((args.partner, "rak"))
|
|
if args.direction in ("both", "from-rak"):
|
|
directions.append(("rak", args.partner))
|
|
for rate in rates:
|
|
for tx_name, rx_name in directions:
|
|
for repeat in range(args.repeats):
|
|
for message in MESSAGES[:args.messages]:
|
|
options = {"trace": args.trace}
|
|
if args.human_timing is not None:
|
|
options["human_timing"] = args.human_timing
|
|
if args.rx_squelch is not None:
|
|
options["rx_squelch"] = args.rx_squelch
|
|
result = trial(devices[tx_name], devices[rx_name], rate, message, **options)
|
|
result["repeat"] = repeat
|
|
results["trials"].append(result)
|
|
printable = {key: value for key, value in result.items() if key != "trace"}
|
|
if "trace" in result:
|
|
printable["trace_rows"] = len(result["trace"]["rows"])
|
|
print(json.dumps(printable), flush=True)
|
|
args.output.write_text(json.dumps(results, indent=2) + "\n")
|
|
results["finished"] = True
|
|
except BaseException as error:
|
|
results["error"] = str(error)
|
|
results["error_utc"] = datetime.now(timezone.utc).isoformat()
|
|
raise
|
|
finally:
|
|
if original_gain is not None:
|
|
try:
|
|
devices["t096"].command("morse stop", allow_error=True)
|
|
devices["t096"].command("set radio.fem.rxgain " + original_gain)
|
|
except Exception as error:
|
|
results.setdefault("cleanup_errors", []).append(str(error))
|
|
for device in devices.values():
|
|
try:
|
|
device.close()
|
|
except Exception as error:
|
|
results.setdefault("cleanup_errors", []).append(str(error))
|
|
results["commands"] = {name: device.commands for name, device in devices.items()}
|
|
args.output.write_text(json.dumps(results, indent=2) + "\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|