Skip to content

Commit 90505e5

Browse files
committed
test(backend_flightcontroller): Add BDD tests
1 parent 3f5d2a0 commit 90505e5

18 files changed

+8175
-949
lines changed

.codespell-exclude-file

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
"Version": "LAF"
55
datas= [],
66
ardupilot_methodic_configuratorAny.datas,
7+
critical_param_prefixes = ["FRAME", "BATT", "COMPASS", "SERVO", "MOT"]

ARCHITECTURE_2_flight_controller_communication.md

Lines changed: 347 additions & 54 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ version = {attr = "ardupilot_methodic_configurator.__version__"}
144144

145145
[tool.codespell]
146146
check-filenames = true
147-
ignore-words-list = "datas,intoto,juli,laf,ned,parm,sade,sitl,thst,sie"
147+
ignore-words-list = "datas,intoto,juli,laf,ned,parm,sade,sitl,thst,sie,mot"
148148
skip = "*.pdef.xml,*.pdf,*.po"
149149

150150
[tool.ruff]

tests/conftest.py

Lines changed: 146 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import json
1515
import logging
1616
import os
17+
import platform
18+
import select
1719
import signal
1820
import subprocess
1921
import time
@@ -256,19 +258,36 @@ def __init__(self, sitl_binary: Optional[str] = None) -> None:
256258
self.sitl_binary = sitl_binary or os.environ.get("SITL_BINARY")
257259
self.process: Optional[subprocess.Popen] = None
258260
self.connection_string = "tcp:127.0.0.1:5760"
261+
self._ready = False
259262

260263
def is_available(self) -> bool:
261264
"""Check if SITL binary is available."""
262265
return self.sitl_binary is not None and Path(self.sitl_binary).exists()
263266

264-
def start(self) -> bool: # pylint: disable=too-many-return-statements # noqa: PLR0911
267+
def is_running(self) -> bool:
268+
"""Check if SITL process is currently running."""
269+
return self.process is not None and self.process.poll() is None
270+
271+
def ensure_running(self) -> bool:
272+
"""Ensure the SITL process is running, starting it if necessary."""
273+
if self.is_running() and self._ready:
274+
return True
275+
return self.start()
276+
277+
def start(self) -> bool: # pylint: disable=too-many-return-statements, too-many-branches, too-many-statements, too-many-locals # noqa: PLR0911, PLR0915
265278
"""Start SITL process."""
279+
if self.is_running():
280+
logging.info("SITL already running, reusing existing instance")
281+
return True
282+
266283
if not self.is_available():
267284
return False
268285

269286
# Kill any existing SITL processes
270287
self.stop()
271288

289+
self._ready = False
290+
272291
if self.sitl_binary is None:
273292
return False
274293

@@ -284,71 +303,157 @@ def start(self) -> bool: # pylint: disable=too-many-return-statements # noqa: P
284303
if not any(keyword in sitl_path.name.lower() for keyword in ["arducopter", "sitl", "copter"]):
285304
logging.warning("SITL binary name looks suspicious: %s", sitl_path.name)
286305

287-
cmd = [
288-
self.sitl_binary,
306+
# Build SITL command
307+
sitl_args = [
289308
"--model",
290309
"quad",
291310
"--home",
292311
"40.071374,-105.229930,1440,0", # Random location
293312
"--defaults",
294-
f"{Path(self.sitl_binary).parent}/copter.parm",
313+
"copter.parm", # Relative to SITL binary directory
295314
"--sysid",
296315
"1",
297316
"--speedup",
298-
"10", # Speed up simulation for testing
317+
"1", # Real-time for better connection stability on Windows/WSL
299318
]
300319

301-
try:
320+
# On Windows, run SITL through WSL
321+
if platform.system() == "Windows":
322+
# Convert Windows path to WSL path
323+
wsl_sitl_path = str(sitl_path).replace("\\", "/").replace("C:", "/mnt/c")
324+
wsl_cwd = str(sitl_path.parent).replace("\\", "/").replace("C:", "/mnt/c")
325+
326+
# Run SITL in WSL - simpler command that keeps process alive
327+
cmd = ["wsl", "cd", wsl_cwd, "&&", wsl_sitl_path, *sitl_args]
328+
else:
329+
cmd = [self.sitl_binary, *sitl_args]
330+
331+
# Set environment to force unbuffered output
332+
env = os.environ.copy()
333+
env["PYTHONUNBUFFERED"] = "1"
334+
335+
sitl_ready = False
336+
337+
try: # pylint: disable=too-many-nested-blocks
338+
# Change to SITL binary directory so it finds copter.parm
339+
cwd = str(sitl_path.parent)
340+
302341
# pylint: disable=consider-using-with
303342
self.process = subprocess.Popen( # noqa: S603
304343
cmd,
305344
stdout=subprocess.PIPE,
306-
stderr=subprocess.PIPE,
345+
stderr=subprocess.STDOUT, # Merge stderr into stdout
307346
start_new_session=True, # Create new process group
347+
bufsize=0, # Unbuffered for real-time output
348+
universal_newlines=True,
349+
env=env,
350+
cwd=cwd,
308351
)
309352
# pylint: enable=consider-using-with
310353

311-
# Wait for SITL to initialize (just check if process is still running)
312-
timeout = 10 # Reduced timeout since we don't need to wait for MAVLink
354+
# Wait for SITL to initialize and print startup messages
355+
timeout = 20 # Increased timeout for slower systems
313356
start_time = time.time()
357+
startup_output = []
358+
314359
while time.time() - start_time < timeout:
315360
if self.process.poll() is not None:
316361
# Process died
317-
_, stderr = self.process.communicate()
318-
if stderr:
319-
logging.error("SITL process died: %s", stderr.decode())
320-
pytest.fail("SITL process died")
362+
stdout, _ = self.process.communicate()
363+
error_msg = f"SITL process died with exit code {self.process.returncode}."
364+
if stdout:
365+
error_msg += f" Output: {stdout[:500]}" # First 500 chars
366+
logging.error(error_msg)
367+
pytest.fail(error_msg)
321368
return False
322-
time.sleep(1)
323369

324-
# Don't test MAVLink connection here - let the test do that
325-
return True
370+
# Check for ready indicator in output
371+
if self.process.stdout:
372+
# Check if there's data to read (non-blocking)
373+
if platform.system() != "Windows":
374+
ready, _, _ = select.select([self.process.stdout], [], [], 0.1)
375+
if ready:
376+
line = self.process.stdout.readline()
377+
if line:
378+
startup_output.append(line.strip())
379+
logging.info("SITL: %s", line.strip()) # Changed to info for visibility
380+
# Look for signs SITL is ready - be more specific
381+
if "bind port 5760" in line.lower() or "waiting for connection" in line.lower():
382+
logging.info("SITL is ready and waiting for connections")
383+
sitl_ready = True
384+
# Don't return yet, consume a bit more output
385+
time.sleep(1) # Let SITL stabilize
386+
return True
387+
else:
388+
# On Windows, just wait
389+
time.sleep(1)
390+
else:
391+
time.sleep(0.5)
392+
393+
# If we got here, check if we saw the ready message
394+
if sitl_ready:
395+
logging.info("SITL is ready")
396+
return True
397+
398+
# If process is still running, assume it's ready
399+
if self.process.poll() is None:
400+
logging.warning("SITL startup timeout reached but process still running, assuming SITL is ready")
401+
return True
402+
403+
logging.error("SITL failed to start within timeout")
404+
return False
326405

327406
except (OSError, subprocess.SubprocessError, FileNotFoundError, PermissionError) as e:
328407
logging.error("Failed to start SITL: %s", e)
329408
pytest.fail(f"Failed to start SITL: {e}")
330409
return False
410+
finally:
411+
if sitl_ready:
412+
self._ready = True
413+
elif self.is_running():
414+
# Even if we hit timeout but process is running, assume ready to allow reuse
415+
self._ready = True
331416

332417
def stop(self) -> None:
333418
"""Stop SITL process."""
334419
if self.process:
335420
try:
336-
# Kill the entire process group
337-
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
338-
339-
# Wait for process to terminate
340-
try:
341-
self.process.wait(timeout=10)
342-
except subprocess.TimeoutExpired:
343-
# Force kill if it doesn't terminate gracefully
344-
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
345-
self.process.wait(timeout=5)
421+
self._ready = False
422+
if platform.system() == "Windows":
423+
# On Windows, kill the WSL bash process and any child processes
424+
# First try graceful termination
425+
subprocess.run(["wsl", "pkill", "-f", "arducopter"], check=False, capture_output=True) # noqa: S607
426+
self.process.terminate()
427+
try:
428+
self.process.wait(timeout=10)
429+
except subprocess.TimeoutExpired:
430+
# Force kill
431+
subprocess.run(
432+
["wsl", "pkill", "-9", "-f", "arducopter"], # noqa: S607
433+
check=False,
434+
capture_output=True,
435+
)
436+
self.process.kill()
437+
self.process.wait(timeout=5)
438+
else:
439+
# Kill the entire process group on Linux
440+
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
441+
442+
# Wait for process to terminate
443+
try:
444+
self.process.wait(timeout=10)
445+
except subprocess.TimeoutExpired:
446+
# Force kill if it doesn't terminate gracefully
447+
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
448+
self.process.wait(timeout=5)
346449

347450
except (ProcessLookupError, OSError):
348451
# Process already dead
349452
pass
350453
finally:
351454
self.process = None
455+
else:
456+
self._ready = False
352457

353458

354459
@pytest.fixture(scope="session")
@@ -359,6 +464,9 @@ def sitl_manager() -> Generator[SITLManager, None, None]:
359464
if not manager.is_available():
360465
pytest.skip("ArduCopter SITL binary not available")
361466

467+
if not manager.start():
468+
pytest.skip("Failed to start ArduCopter SITL")
469+
362470
yield manager
363471

364472
# Cleanup
@@ -368,23 +476,24 @@ def sitl_manager() -> Generator[SITLManager, None, None]:
368476
@pytest.fixture
369477
def sitl_flight_controller(sitl_manager: SITLManager) -> Generator[FlightController, None, None]: # pylint: disable=redefined-outer-name
370478
"""FlightController connected to SITL instance."""
371-
# Start SITL for this test
372-
if not sitl_manager.start():
479+
if not sitl_manager.ensure_running():
373480
pytest.fail("Could not start SITL")
374481

375-
# Give SITL more time to initialize
376-
time.sleep(5)
482+
# Allow brief stabilization if SITL was just started
483+
time.sleep(2)
377484

378-
# Create and connect flight controller
379485
fc = FlightController(reboot_time=2, baudrate=115200)
380-
result = fc.connect(device=sitl_manager.connection_string)
381486

382-
if result: # Connection failed
383-
sitl_manager.stop()
384-
pytest.fail(f"Could not connect to SITL: {result}")
487+
# Attempt to connect, retrying once if SITL is still warming up
488+
connection_error = fc.connect(device=sitl_manager.connection_string)
489+
if connection_error:
490+
time.sleep(3)
491+
connection_error = fc.connect(device=sitl_manager.connection_string)
492+
493+
if connection_error:
494+
pytest.fail(f"Could not connect to SITL: {connection_error}")
385495

386496
yield fc
387497

388-
# Cleanup
498+
# Cleanup connection but keep SITL running for subsequent tests
389499
fc.disconnect()
390-
sitl_manager.stop()

0 commit comments

Comments
 (0)