Skip to content

Commit 1f7505e

Browse files
authored
Periodic hitl (#89)
* Adds multithreading to hitl modules * Adds flight controller start method for hitl threading * Removes positions json path * Adds proper position listening for mission waypoints in hitl * Removes camera part of threading test * Actually removes camera from test * Fixes time sleep function in hitl threading * Makes gps speed faster * Moves position frequency control to position module * Changes threading test order * Fixes linting issues * Fixes port for physical test * Removes auto mode and waypoint injection from threading test * Removes stuff * Adds time between images as a parameter * Format fix
1 parent 14dae38 commit 1f7505e

File tree

5 files changed

+212
-25
lines changed

5 files changed

+212
-25
lines changed

modules/hitl/camera_emulator.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import os
9+
import time
910
import pyvirtualcam
1011
import cv2
1112

@@ -22,18 +23,34 @@ class CameraEmulator:
2223
__create_key = object()
2324

2425
@classmethod
25-
def create(cls, images_path: str) -> "tuple[True, CameraEmulator] | tuple[False, None]":
26+
def create(
27+
cls, images_path: str, time_between_images: float = 1.0
28+
) -> "tuple[True, CameraEmulator] | tuple[False, None]":
2629
"""
2730
Setup camera emulator.
2831
2932
Args:
3033
images_path: Path to the directory containing images for the camera emulator. Cycles through these images to simulate camera input (every 1 second).
34+
time_between_images: Time in seconds between image changes.
3135
3236
Returns:
3337
Success, CameraEmulator instance.
3438
"""
3539

3640
if not isinstance(images_path, str):
41+
print("Images path is not a string")
42+
return False, None
43+
44+
if not os.path.isdir(images_path):
45+
print("Images path is not a valid directory")
46+
return False, None
47+
48+
if not isinstance(time_between_images, (int, float)):
49+
print("Time between images is not a number")
50+
return False, None
51+
52+
if time_between_images <= 0:
53+
print("Time between images must be positive")
3754
return False, None
3855

3956
try:
@@ -51,10 +68,16 @@ def create(cls, images_path: str) -> "tuple[True, CameraEmulator] | tuple[False,
5168
if virtual_camera_instance is None:
5269
return False, None
5370

54-
return True, CameraEmulator(cls.__create_key, images_path, virtual_camera_instance)
71+
return True, CameraEmulator(
72+
cls.__create_key, images_path, time_between_images, virtual_camera_instance
73+
)
5574

5675
def __init__(
57-
self, class_private_create_key: object, images_path: str, virtual_camera: pyvirtualcam
76+
self,
77+
class_private_create_key: object,
78+
images_path: str,
79+
time_between_images: float,
80+
virtual_camera: pyvirtualcam,
5881
) -> None:
5982
"""
6083
Private constructor, use create() method.
@@ -66,10 +89,33 @@ def __init__(
6689
self.__image_paths: "list[str]" = []
6790
self.__current_frame = None
6891
self.__image_index = 0
92+
self.__next_image_time = time.time() + time_between_images
93+
self.__time_between_images = time_between_images
6994

7095
self.__get_images()
7196
self.update_current_image()
7297

98+
def periodic(self) -> None:
99+
"""
100+
Periodic function.
101+
"""
102+
try:
103+
# Send frame and pace to target FPS
104+
self.send_frame()
105+
self.sleep_until_next_frame()
106+
107+
now = time.time()
108+
if now >= self.__next_image_time:
109+
# Cycle image once per second
110+
try:
111+
self.next_image()
112+
self.update_current_image()
113+
except Exception as exc: # pylint: disable=broad-except
114+
print(f"HITL camera image update error: {exc}")
115+
self.__next_image_time = now + self.__time_between_images
116+
except Exception as exc: # pylint: disable=broad-except
117+
print(f"HITL camera periodic error: {exc}")
118+
73119
def send_frame(self) -> None:
74120
"""
75121
sends a new frame to virtual camera, should be called in a loop

modules/hitl/hitl_base.py

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import time
6+
from threading import Event, Thread
67
from modules.hitl.position_emulator import PositionEmulator
78
from modules.hitl.camera_emulator import CameraEmulator
89
from ..mavlink import dronekit
@@ -47,7 +48,6 @@ def create(
4748

4849
if position_module:
4950
result, position_emulator = PositionEmulator.create(drone)
50-
position_emulator.inject_position() # Inject initial position
5151
if not result:
5252
return False, None
5353

@@ -81,26 +81,75 @@ def __init__(
8181
self.position_emulator = position_emulator
8282
self.camera_emulator = camera_emulator
8383

84-
def set_inject_position(self) -> None:
84+
self._stop_event: Event | None = None
85+
self._threads: list[Thread] = []
86+
87+
def start(self) -> None:
88+
"""
89+
Start HITL module threads.
8590
"""
86-
Set the position to inject into the drone.
87-
Print out a message if position emulator is not enabled.
91+
if self._stop_event is not None:
92+
return
93+
94+
self._stop_event = Event()
95+
self._threads = []
96+
97+
if self.position_emulator is not None:
98+
t = Thread(target=self.run_position, name="HITL-Position", daemon=True)
99+
self._threads.append(t)
100+
t.start()
88101

102+
if self.camera_emulator is not None:
103+
t = Thread(target=self.run_camera, name="HITL-Camera", daemon=True)
104+
self._threads.append(t)
105+
t.start()
106+
107+
def shutdown(self, join_timeout: float | None = 5.0) -> None:
108+
"""
109+
Signal threads to stop and join them.
89110
"""
90-
if self.position_emulator is None:
91-
print("Position emulator is not enabled.")
111+
if self._stop_event is None:
92112
return
93-
# pylint: disable=protected-access
94-
position_target = self.drone._master.recv_match(...)
95-
# pylint: enable=protected-access
96-
if position_target:
97-
latitude = position_target.lat_int / 1e7
98-
longitude = position_target.lon_int / 1e7
99-
altitude = position_target.alt
100-
101-
self.position_emulator.inject_position(latitude, longitude, altitude)
102-
print(f"Injected position: lat={latitude}, lon={longitude}, alt={altitude}")
103-
else:
104-
print("No POSITION_TARGET_GLOBAL_INT message received.")
105-
106-
time.sleep(3)
113+
114+
self._stop_event.set()
115+
116+
for t in self._threads:
117+
if t.is_alive():
118+
t.join(timeout=join_timeout)
119+
120+
self._threads.clear()
121+
self._stop_event = None
122+
123+
def __del__(self) -> None:
124+
"""
125+
Best-effort cleanup when HITL object is destroyed.
126+
Ensures threads are stopped and the drone connection is closed.
127+
"""
128+
try:
129+
self.shutdown()
130+
except Exception: # pylint: disable=broad-except
131+
pass
132+
133+
def run_position(self) -> None:
134+
"""
135+
Runs the position emulator periodic function in a loop.
136+
"""
137+
assert self._stop_event is not None
138+
while not self._stop_event.is_set():
139+
try:
140+
self.position_emulator.periodic()
141+
except Exception as exc: # pylint: disable=broad-except
142+
print(f"HITL position thread error: {exc}")
143+
time.sleep(0.1)
144+
145+
def run_camera(self) -> None:
146+
"""
147+
Runs the camera emulator periodic function in a loop.
148+
"""
149+
assert self._stop_event is not None
150+
while not self._stop_event.is_set():
151+
try:
152+
self.camera_emulator.periodic()
153+
except Exception as exc: # pylint: disable=broad-except
154+
print(f"HITL camera thread error: {exc}")
155+
time.sleep(0.1)

modules/hitl/position_emulator.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,61 @@ def __init__(self, class_private_create_key: object, drone: dronekit.Vehicle) ->
3232
"""
3333
assert class_private_create_key is PositionEmulator.__create_key, "Use create() method"
3434

35+
self.target_position = (43.43405014107003, -80.57898027451816, 373.0) # lat, lon, alt
36+
3537
self.drone = drone
3638

39+
def set_target_position(self, latitude: float, longitude: float, altitude: float) -> None:
40+
"""
41+
Sets the target position manually (currently a fallback if Ardupilot target doesnt work).
42+
43+
Args:
44+
latitude: Latitude in degrees.
45+
longitude: Longitude in degrees.
46+
altitude: Altitude in meters.
47+
"""
48+
self.target_position = (latitude, longitude, altitude)
49+
50+
def get_target_position(self) -> tuple[float, float, float]:
51+
"""
52+
Gets the target position from the Ardupilot target.
53+
54+
Returns:
55+
Target position as (latitude, longitude, altitude).
56+
"""
57+
# pylint: disable=protected-access
58+
position_target = None
59+
try:
60+
position_target = self.drone._master.recv_match(
61+
type="POSITION_TARGET_GLOBAL_INT", blocking=False
62+
)
63+
except Exception as exc: # pylint: disable=broad-except
64+
print(f"HITL get_target_position recv_match error: {exc}")
65+
position_target = None
66+
# pylint: enable=protected-access
67+
68+
if position_target:
69+
latitude = position_target.lat_int / 1e7
70+
longitude = position_target.lon_int / 1e7
71+
altitude = position_target.alt
72+
return (latitude, longitude, altitude)
73+
74+
# Optionally log if no message received
75+
# print("No POSITION_TARGET_GLOBAL_INT message received.")
76+
77+
return self.target_position
78+
79+
def periodic(self) -> None:
80+
"""
81+
Periodic function.
82+
"""
83+
84+
self.target_position = self.get_target_position()
85+
86+
self.inject_position(
87+
self.target_position[0], self.target_position[1], self.target_position[2]
88+
)
89+
3790
def inject_position(
3891
self,
3992
latitude: float = 43.43405014107003,
@@ -68,7 +121,6 @@ def inject_position(
68121
10, # satellites_visible
69122
0, # yaw (deg*100)
70123
]
71-
print("Packing values:", values)
72124
gps_input_msg = self.drone.message_factory.gps_input_encode(*values)
73125
self.drone.send_mavlink(gps_input_msg)
74126
self.drone.flush()

modules/mavlink/flight_controller.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@ def create(
229229
success, hitl_instance = hitl_base.HITL.create(
230230
drone, hitl_enabled, position_module, camera_module, images_path
231231
)
232-
if not success:
232+
if success:
233+
if hitl_enabled and hitl_instance is not None:
234+
hitl_instance.start()
235+
else:
233236
print("Error creating HITL module")
234237

235238
except dronekit.TimeoutError:
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""
2+
Tests the threading behavior of the HITL module (gps and camera modules).
3+
"""
4+
5+
# Physical connection to Pixhawk: /dev/ttyAMA0
6+
# Simulated connection to Pixhawk: tcp:localhost:5762
7+
PIXHAWK_ADDRESS = "/dev/ttyAMA0"
8+
9+
import os
10+
import time
11+
from modules.mavlink import flight_controller
12+
13+
14+
def main() -> int:
15+
"""
16+
Main function.
17+
"""
18+
images_folder_path = os.path.join("tests", "integration", "camera_emulator", "images")
19+
20+
result, controller = flight_controller.FlightController.create(
21+
PIXHAWK_ADDRESS, 57600, True, True, True, images_folder_path
22+
)
23+
if not result:
24+
print("Failed to create flight controller")
25+
return -1
26+
27+
time.sleep(10)
28+
29+
return 0
30+
31+
32+
if __name__ == "__main__":
33+
result_main = main()
34+
if result_main != 0:
35+
print(f"ERROR: Status code: {result_main}")
36+
37+
print("Done!")

0 commit comments

Comments
 (0)