Skip to content

Commit b548c31

Browse files
committed
Improve Dual RX mixed SITL test
1 parent 3215245 commit b548c31

1 file changed

Lines changed: 138 additions & 26 deletions

File tree

src/test/dualrx/dualrx_mixed_sitl_test.py

Lines changed: 138 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55

66
import argparse
77
from pathlib import Path
8+
import shutil
9+
import socket
10+
import struct
811
import tempfile
912
import threading
1013
import time
11-
12-
from pymavlink import mavutil
14+
import traceback
1315

1416
from dualrx_sitl_test import (
1517
BOX_BEEPER,
@@ -40,6 +42,13 @@
4042
UART_RX2 = 4
4143
BOX_MSP_RC_OVERRIDE = 50
4244

45+
MAVLINK_V1_MAGIC = 0xFE
46+
MAVLINK_MSG_ID_RC_CHANNELS_OVERRIDE = 70
47+
MAVLINK_MSG_ID_RC_CHANNELS_OVERRIDE_CRC = 124
48+
MAVLINK_TARGET_SYSTEM = 1
49+
MAVLINK_TARGET_COMPONENT = 1
50+
MAVLINK_SOURCE_COMPONENT = 191
51+
4352

4453
class PeriodicIngress:
4554
def __init__(self, name: str, channels: list[int], period_s: float = 0.02) -> None:
@@ -50,7 +59,7 @@ def __init__(self, name: str, channels: list[int], period_s: float = 0.02) -> No
5059
self._lock = threading.Lock()
5160
self._stop = threading.Event()
5261
self._thread: threading.Thread | None = None
53-
self.error: BaseException | None = None
62+
self.error: Exception | None = None
5463

5564
def start(self) -> None:
5665
self._thread = threading.Thread(target=self._run_guarded, name=self.name, daemon=True)
@@ -69,12 +78,12 @@ def set_mode(self, mode: str) -> None:
6978

7079
def check(self) -> None:
7180
if self.error is not None:
72-
raise TestFailure(f"{self.name} background thread failed: {self.error}")
81+
raise TestFailure(f"{self.name} background thread failed: {type(self.error).__name__}: {self.error}") from self.error
7382

7483
def _run_guarded(self) -> None:
7584
try:
7685
self._run()
77-
except BaseException as exc:
86+
except Exception as exc:
7887
if not self._stop.is_set():
7988
self.error = exc
8089
self._stop.set()
@@ -83,21 +92,64 @@ def _run(self) -> None:
8392
raise NotImplementedError
8493

8594

95+
def mavlink_x25_crc(data: bytes, crc: int = 0xFFFF) -> int:
96+
for value in data:
97+
tmp = value ^ (crc & 0xFF)
98+
tmp ^= (tmp << 4) & 0xFF
99+
crc = ((crc >> 8) ^ (tmp << 8) ^ (tmp << 3) ^ (tmp >> 4)) & 0xFFFF
100+
return crc
101+
102+
103+
def mavlink_v1_rc_override(seq: int, source_system: int, channels: list[int]) -> bytes:
104+
if len(channels) < 8:
105+
raise ValueError("RC_CHANNELS_OVERRIDE test frame needs at least 8 channels")
106+
payload = struct.pack(
107+
"<8HBB",
108+
*[int(value) & 0xFFFF for value in channels[:8]],
109+
MAVLINK_TARGET_SYSTEM,
110+
MAVLINK_TARGET_COMPONENT,
111+
)
112+
header = bytes([
113+
len(payload),
114+
seq & 0xFF,
115+
source_system & 0xFF,
116+
MAVLINK_SOURCE_COMPONENT,
117+
MAVLINK_MSG_ID_RC_CHANNELS_OVERRIDE,
118+
])
119+
crc = mavlink_x25_crc(header + payload)
120+
crc = mavlink_x25_crc(bytes([MAVLINK_MSG_ID_RC_CHANNELS_OVERRIDE_CRC]), crc)
121+
return bytes([MAVLINK_V1_MAGIC]) + header + payload + struct.pack("<H", crc)
122+
123+
86124
class MavlinkIngress(PeriodicIngress):
87125
def __init__(self, name: str, host: str, port: int, channels: list[int], source_system: int) -> None:
88126
super().__init__(name, channels)
89-
self.connection = mavutil.mavlink_connection(
90-
f"tcp:{host}:{port}",
91-
source_system=source_system,
92-
source_component=191,
93-
autoreconnect=False,
94-
)
127+
self.source_system = source_system
128+
self.sequence = 0
129+
self.sock: socket.socket | None = None
130+
deadline = time.monotonic() + 5.0
131+
last_error: OSError | None = None
132+
while time.monotonic() < deadline:
133+
try:
134+
self.sock = socket.create_connection((host, port), timeout=1.0)
135+
self.sock.settimeout(None)
136+
break
137+
except OSError as exc:
138+
last_error = exc
139+
time.sleep(0.05)
140+
if self.sock is None:
141+
raise TimeoutError(f"{name}: could not connect to {host}:{port}: {last_error}")
95142

96143
def close(self) -> None:
97144
super().close()
98-
self.connection.close()
145+
if self.sock is not None:
146+
try:
147+
self.sock.close()
148+
except OSError:
149+
pass
99150

100151
def _run(self) -> None:
152+
assert self.sock is not None
101153
next_send = time.monotonic()
102154
while not self._stop.is_set():
103155
now = time.monotonic()
@@ -107,11 +159,12 @@ def _run(self) -> None:
107159
if mode != "off" and now >= next_send:
108160
if mode == "partial":
109161
channels[:4] = [0xFFFF] * 4
110-
self.connection.mav.rc_channels_override_send(1, 1, *channels[:8])
162+
frame = mavlink_v1_rc_override(self.sequence, self.source_system, channels)
163+
self.sock.sendall(frame)
164+
self.sequence = (self.sequence + 1) & 0xFF
111165
next_send = now + self.period_s
112166
elif mode == "off":
113167
next_send = now
114-
self.connection.recv_match(blocking=False)
115168
time.sleep(0.002)
116169

117170

@@ -251,7 +304,12 @@ def exercise_legacy_msp_override(msp: MspClient, rx1, rx2) -> None:
251304

252305

253306
def configure_case(kind: str) -> list[str]:
254-
commands = ["set dual_rx_enabled = ON", "aux 0 13 0 1700 2100", "feature TELEMETRY"]
307+
commands = [
308+
"set dual_rx_enabled = ON",
309+
"set mavlink_sysid = 1",
310+
"aux 0 13 0 1700 2100",
311+
"feature TELEMETRY",
312+
]
255313
if kind == "crsf-mavlink":
256314
commands += [
257315
"set receiver_type = SERIAL",
@@ -284,22 +342,62 @@ def configure_case(kind: str) -> list[str]:
284342
return commands
285343

286344

345+
def sitl_log_count(sitl: SitlProcess, marker: str) -> int:
346+
try:
347+
text = sitl.log_path.read_text(encoding="utf-8", errors="replace")
348+
except OSError:
349+
return 0
350+
return text.count(marker)
351+
352+
353+
def wait_for_sitl_reset(sitl: SitlProcess, reset_count_before: int, timeout_s: float = 8.0) -> None:
354+
"""Wait until CLI `save` has persisted configuration and entered reset."""
355+
deadline = time.monotonic() + timeout_s
356+
last_reset_count = reset_count_before
357+
while time.monotonic() < deadline:
358+
sitl.check_alive()
359+
last_reset_count = sitl_log_count(sitl, "[SYSTEM] Reset")
360+
if last_reset_count > reset_count_before:
361+
return
362+
time.sleep(0.02)
363+
raise TestFailure(
364+
"SITL did not enter CLI save/reset cycle within "
365+
f"{timeout_s:.1f}s (reset {reset_count_before}->{last_reset_count})\n"
366+
f"{sitl.tail_log(lines=80)}"
367+
)
368+
369+
370+
def hard_restart_sitl_after_configuration(sitl: SitlProcess, reset_count_before: int) -> None:
371+
"""Replace SITL's in-process exec reboot with a clean harness restart.
372+
373+
The mixed serial layout opens UARTs lazily. During the exec-based reboot a
374+
TCP listener can survive long enough for the new instance to hit EADDRINUSE,
375+
leaving a receiver connected to the dying listener. Once `save` has reached
376+
systemReset(), terminate that process completely and start a fresh one from
377+
the saved EEPROM so every UART listener is recreated from a clean process.
378+
"""
379+
wait_for_sitl_reset(sitl, reset_count_before)
380+
sitl.stop()
381+
time.sleep(0.05)
382+
sitl.start()
383+
384+
287385
def run_case(kind: str, binary: Path, repo: Path, tcp_base: int, temp_dir: Path) -> None:
288386
eeprom = temp_dir / f"{kind}.bin"
289-
sitl = SitlProcess(binary, repo, eeprom, tcp_base, temp_dir / f"{kind}.log")
387+
log_path = temp_dir / f"{kind}.log"
388+
sitl = SitlProcess(binary, repo, eeprom, tcp_base, log_path)
290389
msp: MspClient | None = None
291390
ingresses: list = []
391+
print(f"\n[CASE] {kind}")
292392
try:
293393
sitl.start()
294394
msp_port = tcp_port(tcp_base, UART_MSP)
295395
wait_tcp("127.0.0.1", msp_port, 8.0, sitl)
296396
time.sleep(0.15)
397+
reset_count = sitl_log_count(sitl, "[SYSTEM] Reset")
297398
configure_cli("127.0.0.1", msp_port, configure_case(kind))
298-
time.sleep(1.0)
399+
hard_restart_sitl_after_configuration(sitl, reset_count)
299400
wait_tcp("127.0.0.1", msp_port, 8.0, sitl)
300-
wait_tcp("127.0.0.1", tcp_port(tcp_base, UART_RX1), 8.0, sitl)
301-
if kind != "crsf-msp":
302-
wait_tcp("127.0.0.1", tcp_port(tcp_base, UART_RX2), 8.0, sitl)
303401

304402
msp = MspClient("127.0.0.1", msp_port, timeout_s=2.0)
305403
status = msp.link_status()
@@ -333,6 +431,17 @@ def run_case(kind: str, binary: Path, repo: Path, tcp_base: int, temp_dir: Path)
333431
if kind == "crsf-mavlink":
334432
exercise_legacy_msp_override(msp, rx1, rx2)
335433
exercise_selector(msp, rx1, rx2, kind.replace("-", " + ").upper())
434+
except Exception as exc:
435+
process_code = sitl.process.poll() if sitl.process is not None else None
436+
sitl_state = "still running" if sitl.process is not None and process_code is None else f"exited with code {process_code}"
437+
log_tail = sitl.tail_log(lines=120)
438+
raise TestFailure(
439+
f"{kind} failed: {type(exc).__name__}: {exc}\n"
440+
f"SITL state: {sitl_state}\n"
441+
f"SITL log: {log_path}\n"
442+
f"EEPROM: {eeprom}\n"
443+
f"--- SITL LOG TAIL ---\n{log_tail}\n--- END SITL LOG TAIL ---"
444+
) from exc
336445
finally:
337446
for ingress in reversed(ingresses):
338447
ingress.close()
@@ -357,14 +466,17 @@ def main() -> int:
357466
print("Dual RX mixed-ingress SITL test")
358467
print(f" repo: {repo}")
359468
print(f" SITL: {binary}\n")
469+
470+
temp_dir = Path(tempfile.mkdtemp(prefix="inav-dualrx-mixed-"))
360471
try:
361-
with tempfile.TemporaryDirectory(prefix="inav-dualrx-mixed-") as temp_name:
362-
temp_dir = Path(temp_name)
363-
for kind in ("crsf-mavlink", "crsf-msp", "mavlink-mavlink"):
364-
run_case(kind, binary, repo, args.tcp_base, temp_dir)
365-
except BaseException as exc:
366-
print(f"\n[FAIL] {exc}")
472+
for kind in ("crsf-mavlink", "crsf-msp", "mavlink-mavlink"):
473+
run_case(kind, binary, repo, args.tcp_base, temp_dir)
474+
except Exception:
475+
print(f"\n[FAIL] Full diagnostics retained in: {temp_dir}")
476+
traceback.print_exc()
367477
return 1
478+
479+
shutil.rmtree(temp_dir, ignore_errors=True)
368480
print("\nALL DUAL RX MIXED-INGRESS SITL TESTS PASSED")
369481
return 0
370482

0 commit comments

Comments
 (0)