1414import json
1515import logging
1616import os
17+ import platform
18+ import select
1719import signal
1820import subprocess
1921import 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
369477def 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