easylink-mavlink 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: easylink-mavlink
3
+ Version: 0.1.0
4
+ Summary: Lightweight, zero-friction Python library for MAVLink drone control (ArduPilot & PX4)
5
+ Author: EASYLink Contributors
6
+ Project-URL: Homepage, https://github.com/easylink/easylink
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Scientific/Engineering
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: pymavlink>=2.4.41
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0; extra == "dev"
16
+
17
+ # PY-EASYLink (`easylink-mavlink`)
18
+
19
+ Lightweight, zero-friction Python library for MAVLink drone control (supporting both ArduPilot and PX4).
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install easylink-mavlink
25
+ ```
26
+
27
+ ## Quickstart
28
+
29
+ ```python
30
+ from easylink import Drone
31
+
32
+ # Auto-discovers SITL simulator or hardware serial port
33
+ drone = Drone()
34
+ drone.connect()
35
+
36
+ drone.arm()
37
+ drone.takeoff(10.0)
38
+
39
+ pos = drone.position
40
+ print(f"Current Position: Lat={pos.lat}, Lon={pos.lon}, Alt={pos.alt}m")
41
+
42
+ drone.hover(5.0)
43
+ drone.home()
44
+ drone.disconnect()
45
+ ```
46
+
47
+ ## Features
48
+
49
+ - **Auto-Discovery**: Probes local SITL ports (`udp:127.0.0.1:14550`, etc.) and serial ports (`/dev/ttyAMA0`, `COM3`, etc.).
50
+ - **Dual Firmware Support**: Auto-detects ArduPilot vs PX4 from heartbeats.
51
+ - **Packet Dispatcher**: Solves packet-stealing and threading collisions cleanly.
52
+ - **Fluent Mission Builder**: `drone.mission.clear().add_takeoff(15).add_waypoint(lat, lon, alt).add_land().upload()`
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,40 @@
1
+ # PY-EASYLink (`easylink-mavlink`)
2
+
3
+ Lightweight, zero-friction Python library for MAVLink drone control (supporting both ArduPilot and PX4).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install easylink-mavlink
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from easylink import Drone
15
+
16
+ # Auto-discovers SITL simulator or hardware serial port
17
+ drone = Drone()
18
+ drone.connect()
19
+
20
+ drone.arm()
21
+ drone.takeoff(10.0)
22
+
23
+ pos = drone.position
24
+ print(f"Current Position: Lat={pos.lat}, Lon={pos.lon}, Alt={pos.alt}m")
25
+
26
+ drone.hover(5.0)
27
+ drone.home()
28
+ drone.disconnect()
29
+ ```
30
+
31
+ ## Features
32
+
33
+ - **Auto-Discovery**: Probes local SITL ports (`udp:127.0.0.1:14550`, etc.) and serial ports (`/dev/ttyAMA0`, `COM3`, etc.).
34
+ - **Dual Firmware Support**: Auto-detects ArduPilot vs PX4 from heartbeats.
35
+ - **Packet Dispatcher**: Solves packet-stealing and threading collisions cleanly.
36
+ - **Fluent Mission Builder**: `drone.mission.clear().add_takeoff(15).add_waypoint(lat, lon, alt).add_land().upload()`
37
+
38
+ ## License
39
+
40
+ MIT
@@ -0,0 +1,26 @@
1
+ from .drone import Drone
2
+ from .mission import MissionManager
3
+ from .telemetry import Position, Attitude, Battery, GPSInfo
4
+ from .exceptions import (
5
+ EASYLinkError,
6
+ ConnectionError,
7
+ ArmingError,
8
+ CommandTimeoutError,
9
+ ModeError,
10
+ PreFlightCheckError
11
+ )
12
+
13
+ __all__ = [
14
+ 'Drone',
15
+ 'MissionManager',
16
+ 'Position',
17
+ 'Attitude',
18
+ 'Battery',
19
+ 'GPSInfo',
20
+ 'EASYLinkError',
21
+ 'ConnectionError',
22
+ 'ArmingError',
23
+ 'CommandTimeoutError',
24
+ 'ModeError',
25
+ 'PreFlightCheckError'
26
+ ]
@@ -0,0 +1,219 @@
1
+ import time
2
+ from pymavlink import mavutil
3
+ from .exceptions import CommandTimeoutError, ArmingError
4
+
5
+ class ActionManager:
6
+ """Handles vehicle actions such as arming, disarming, takeoff, landing, RTL, and kill."""
7
+
8
+ def __init__(self, connection_mgr, adapter_mgr):
9
+ self.conn_mgr = connection_mgr
10
+ self.adapter_mgr = adapter_mgr
11
+
12
+ @property
13
+ def master(self):
14
+ return self.conn_mgr.master
15
+
16
+ @property
17
+ def dispatcher(self):
18
+ drone_inst = getattr(self.conn_mgr, 'drone', None)
19
+ return drone_inst._telemetry if drone_inst else None
20
+
21
+ def arm(self, timeout: float = 15.0):
22
+ """Arms vehicle motors. Swaps to STABILIZE mode first for pre-arm integrity checks, then guided mode."""
23
+ master = self.master
24
+ is_px4 = self.conn_mgr.autopilot_type == mavutil.mavlink.MAV_AUTOPILOT_PX4
25
+
26
+ # 1. Switch to initial arm-friendly mode
27
+ stabilize_mode = 'ALT_HOLD' if is_px4 else 'STABILIZE'
28
+ if master.mode_mapping() and stabilize_mode in master.mode_mapping():
29
+ mode_id = master.mode_mapping()[stabilize_mode]
30
+ master.mav.command_long_send(
31
+ self.conn_mgr.target_system,
32
+ self.conn_mgr.target_component,
33
+ mavutil.mavlink.MAV_CMD_DO_SET_MODE,
34
+ 0,
35
+ mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
36
+ mode_id, 0, 0, 0, 0, 0
37
+ )
38
+ time.sleep(0.3)
39
+
40
+ # 2. Send Arm command
41
+ master.mav.command_long_send(
42
+ self.conn_mgr.target_system,
43
+ self.conn_mgr.target_component,
44
+ mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
45
+ 0,
46
+ 1, # 1 to arm
47
+ 0, 0, 0, 0, 0, 0
48
+ )
49
+
50
+ # 3. Await armed HEARTBEAT via dispatcher queue
51
+ disp = self.dispatcher
52
+ armed_ok = False
53
+ if disp:
54
+ msg = disp.wait_for_message(
55
+ 'HEARTBEAT',
56
+ timeout=timeout,
57
+ filter_fn=lambda m: bool(m.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
58
+ )
59
+ if msg:
60
+ armed_ok = True
61
+ else:
62
+ start_time = time.time()
63
+ while time.time() - start_time < timeout:
64
+ msg = master.recv_match(type='HEARTBEAT', blocking=True, timeout=0.5)
65
+ if msg and (msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED):
66
+ armed_ok = True
67
+ break
68
+
69
+ if not armed_ok:
70
+ raise ArmingError("Arming request timed out or was rejected by the flight controller.")
71
+
72
+ # 4. Transition to GUIDED/OFFBOARD for navigation commands
73
+ time.sleep(0.3)
74
+ self.adapter_mgr.set_guided_mode(master)
75
+ return True
76
+
77
+ def disarm(self, timeout: float = 15.0):
78
+ """Disarms vehicle motors safely."""
79
+ self.master.mav.command_long_send(
80
+ self.conn_mgr.target_system,
81
+ self.conn_mgr.target_component,
82
+ mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
83
+ 0,
84
+ 0, # 0 to disarm
85
+ 0, 0, 0, 0, 0, 0
86
+ )
87
+
88
+ disp = self.dispatcher
89
+ if disp:
90
+ msg = disp.wait_for_message(
91
+ 'HEARTBEAT',
92
+ timeout=timeout,
93
+ filter_fn=lambda m: not bool(m.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
94
+ )
95
+ if msg:
96
+ return True
97
+ else:
98
+ start_time = time.time()
99
+ while time.time() - start_time < timeout:
100
+ msg = self.master.recv_match(type='HEARTBEAT', blocking=True, timeout=0.5)
101
+ if msg and not (msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED):
102
+ return True
103
+ raise ArmingError("Disarming request timed out or was rejected by the flight controller.")
104
+
105
+ def takeoff(self, altitude: float, timeout: float = 30.0):
106
+ """Commands drone takeoff to specified relative altitude in meters."""
107
+ self.adapter_mgr.set_guided_mode(self.master)
108
+ time.sleep(0.2)
109
+
110
+ self.master.mav.command_long_send(
111
+ self.conn_mgr.target_system,
112
+ self.conn_mgr.target_component,
113
+ mavutil.mavlink.MAV_CMD_NAV_TAKEOFF,
114
+ 0,
115
+ 0, 0, 0, 0, 0, 0,
116
+ altitude
117
+ )
118
+
119
+ disp = self.dispatcher
120
+ if disp:
121
+ ack = disp.wait_for_message(
122
+ 'COMMAND_ACK',
123
+ timeout=3.0,
124
+ filter_fn=lambda m: getattr(m, 'command', None) == mavutil.mavlink.MAV_CMD_NAV_TAKEOFF
125
+ )
126
+ if ack and ack.result in [mavutil.mavlink.MAV_RESULT_TEMPORARILY_REJECTED, mavutil.mavlink.MAV_RESULT_DENIED]:
127
+ raise CommandTimeoutError(f"Takeoff command rejected by vehicle: result {ack.result}")
128
+
129
+ # Block until target altitude is reached (within 90% threshold)
130
+ start_time = time.time()
131
+ while time.time() - start_time < timeout:
132
+ if disp:
133
+ current_alt = disp.position.alt
134
+ if current_alt >= altitude * 0.90:
135
+ return True
136
+ time.sleep(0.2)
137
+ else:
138
+ msg = self.master.recv_match(type='GLOBAL_POSITION_INT', blocking=True, timeout=0.5)
139
+ if msg and (msg.relative_alt / 1000.0) >= altitude * 0.90:
140
+ return True
141
+ raise CommandTimeoutError("Takeoff command issued, but target altitude was not reached within timeout.")
142
+
143
+ def land(self, blocking: bool = True, timeout: float = 60.0):
144
+ """Commands landing at current position. If blocking=True, waits until motors disarm on ground."""
145
+ self.master.mav.command_long_send(
146
+ self.conn_mgr.target_system,
147
+ self.conn_mgr.target_component,
148
+ mavutil.mavlink.MAV_CMD_NAV_LAND,
149
+ 0,
150
+ 0, 0, 0, 0, 0, 0, 0
151
+ )
152
+
153
+ if not blocking:
154
+ return True
155
+
156
+ # Blocking wait: Watch for armed state to drop (disarm on ground)
157
+ disp = self.dispatcher
158
+ start_time = time.time()
159
+ while time.time() - start_time < timeout:
160
+ if disp:
161
+ if not disp.is_armed:
162
+ return True
163
+ time.sleep(0.5)
164
+ else:
165
+ msg = self.master.recv_match(type='HEARTBEAT', blocking=True, timeout=0.5)
166
+ if msg and not bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED):
167
+ return True
168
+ return True
169
+
170
+ def rtl(self, blocking: bool = True, timeout: float = 90.0):
171
+ """Triggers Return-To-Launch (RTL). If blocking=True, waits until vehicle lands & disarms."""
172
+ if self.master.mode_mapping() and 'RTL' in self.master.mode_mapping():
173
+ mode_id = self.master.mode_mapping()['RTL']
174
+ self.master.mav.command_long_send(
175
+ self.conn_mgr.target_system,
176
+ self.conn_mgr.target_component,
177
+ mavutil.mavlink.MAV_CMD_DO_SET_MODE,
178
+ 0,
179
+ mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
180
+ mode_id, 0, 0, 0, 0, 0
181
+ )
182
+ else:
183
+ self.master.mav.command_long_send(
184
+ self.conn_mgr.target_system,
185
+ self.conn_mgr.target_component,
186
+ mavutil.mavlink.MAV_CMD_NAV_RETURN_TO_LAUNCH,
187
+ 0,
188
+ 0, 0, 0, 0, 0, 0, 0
189
+ )
190
+
191
+ if not blocking:
192
+ return True
193
+
194
+ # Blocking wait: Watch for armed state to drop (vehicle returned, landed, and disarmed)
195
+ disp = self.dispatcher
196
+ start_time = time.time()
197
+ while time.time() - start_time < timeout:
198
+ if disp:
199
+ if not disp.is_armed:
200
+ return True
201
+ time.sleep(0.5)
202
+ else:
203
+ msg = self.master.recv_match(type='HEARTBEAT', blocking=True, timeout=0.5)
204
+ if msg and not bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED):
205
+ return True
206
+ return True
207
+
208
+ def kill(self):
209
+ """Emergency motor kill (disarms immediately regardless of flight state)."""
210
+ self.master.mav.command_long_send(
211
+ self.conn_mgr.target_system,
212
+ self.conn_mgr.target_component,
213
+ mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
214
+ 0,
215
+ 0, # Disarm
216
+ 21196, # Magic number to force kill
217
+ 0, 0, 0, 0, 0
218
+ )
219
+ return True
@@ -0,0 +1,71 @@
1
+ from pymavlink import mavutil
2
+
3
+ class CameraManager:
4
+ """Manages simple camera commands via MAVLink."""
5
+
6
+ def __init__(self, connection_mgr):
7
+ self.conn_mgr = connection_mgr
8
+
9
+ @property
10
+ def master(self):
11
+ return self.conn_mgr.master
12
+
13
+ def take_photo(self):
14
+ """Sends command to trigger single camera photo capture."""
15
+ self.master.mav.command_long_send(
16
+ self.conn_mgr.target_system,
17
+ self.conn_mgr.target_component,
18
+ mavutil.mavlink.MAV_CMD_IMAGE_START_CAPTURE,
19
+ 0,
20
+ 0, # Reserved (0)
21
+ 0, # Interval (0 for single image)
22
+ 1, # Total number of images to capture (1)
23
+ 0, 0, 0, 0
24
+ )
25
+
26
+ def start_video(self):
27
+ """Sends command to start video capture."""
28
+ self.master.mav.command_long_send(
29
+ self.conn_mgr.target_system,
30
+ self.conn_mgr.target_component,
31
+ mavutil.mavlink.MAV_CMD_VIDEO_START_CAPTURE,
32
+ 0,
33
+ 0, # Camera ID
34
+ 0, 0, 0, 0, 0, 0
35
+ )
36
+
37
+ def stop_video(self):
38
+ """Sends command to stop video capture."""
39
+ self.master.mav.command_long_send(
40
+ self.conn_mgr.target_system,
41
+ self.conn_mgr.target_component,
42
+ mavutil.mavlink.MAV_CMD_VIDEO_STOP_CAPTURE,
43
+ 0,
44
+ 0, # Camera ID
45
+ 0, 0, 0, 0, 0, 0
46
+ )
47
+
48
+
49
+ class GimbalManager:
50
+ """Manages camera gimbal angle controls."""
51
+
52
+ def __init__(self, connection_mgr):
53
+ self.conn_mgr = connection_mgr
54
+
55
+ @property
56
+ def master(self):
57
+ return self.conn_mgr.master
58
+
59
+ def set_pitch_yaw(self, pitch: float, yaw: float):
60
+ """Sets pitch and yaw angles of camera gimbal mount."""
61
+ self.master.mav.command_long_send(
62
+ self.conn_mgr.target_system,
63
+ self.conn_mgr.target_component,
64
+ mavutil.mavlink.MAV_CMD_DO_MOUNT_CONTROL,
65
+ 0,
66
+ pitch, # Pitch angle in degrees
67
+ 0, # Roll (0)
68
+ yaw, # Yaw angle in degrees
69
+ 0, 0, 0,
70
+ mavutil.mavlink.MAV_MOUNT_MODE_MAVLINK_TARGETING # Mount mode
71
+ )
@@ -0,0 +1,91 @@
1
+ import sys
2
+ import time
3
+ import logging
4
+ from pymavlink import mavutil
5
+ from .exceptions import ConnectionError
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class ConnectionManager:
10
+ """Manages connection establishment, port scanning/auto-discovery, and heartbeat listening."""
11
+
12
+ def __init__(self, target: str = None):
13
+ self.target = target
14
+ self.drone = None
15
+ self.master = None
16
+ self.target_system = 1
17
+ self.target_component = 1
18
+ self.autopilot_type = None
19
+ self.is_connected = False
20
+
21
+ def _get_candidates(self):
22
+ """Generates list of possible connection targets when no target is specified."""
23
+ if self.target:
24
+ return [self.target]
25
+
26
+ candidates = []
27
+
28
+ # 1. Simulators (SITL) defaults - high priority
29
+ candidates.append("udp:127.0.0.1:14550")
30
+ candidates.append("udp:127.0.0.1:14551")
31
+ candidates.append("tcp:127.0.0.1:5760")
32
+ candidates.append("tcp:127.0.0.1:5762")
33
+
34
+ # 2. Hardware Serial Port scanning
35
+ if sys.platform.startswith("win"):
36
+ # Scan common active Windows COM ports (prioritize COM3-COM8, COM1, COM2)
37
+ for i in [3, 4, 5, 6, 7, 8, 1, 2, 9, 10, 11, 12]:
38
+ candidates.append(f"COM{i}:57600")
39
+ candidates.append(f"COM{i}:115200")
40
+ else:
41
+ # Linux / macOS / Raspberry Pi
42
+ serial_devs = [
43
+ "/dev/ttyUSB0", "/dev/ttyUSB1",
44
+ "/dev/ttyACM0", "/dev/ttyACM1",
45
+ "/dev/ttyAMA0", "/dev/ttyAMA1",
46
+ "/dev/ttyS0"
47
+ ]
48
+ for dev in serial_devs:
49
+ candidates.append(f"{dev}:57600")
50
+ candidates.append(f"{dev}:115200")
51
+
52
+ return candidates
53
+
54
+ def connect(self, timeout: float = 15.0):
55
+ candidates = self._get_candidates()
56
+
57
+ for candidate in candidates:
58
+ try:
59
+ # Attempt to open connection
60
+ self.master = mavutil.mavlink_connection(candidate, timeout=0.5)
61
+
62
+ # Check for heartbeat to confirm active vehicle
63
+ start_time = time.time()
64
+ probe_timeout = timeout if self.target else 0.5
65
+
66
+ while time.time() - start_time < probe_timeout:
67
+ msg = self.master.recv_match(type='HEARTBEAT', blocking=True, timeout=0.3)
68
+ if msg:
69
+ self.target_system = self.master.target_system
70
+ self.target_component = self.master.target_component
71
+ self.autopilot_type = msg.autopilot
72
+ self.is_connected = True
73
+ return
74
+ except Exception:
75
+ if self.master:
76
+ try:
77
+ self.master.close()
78
+ except Exception:
79
+ pass
80
+ self.master = None
81
+
82
+ raise ConnectionError("Failed to establish MAVLink connection. No active vehicle responded to probes.")
83
+
84
+ def disconnect(self):
85
+ if self.master:
86
+ try:
87
+ self.master.close()
88
+ except Exception:
89
+ pass
90
+ self.is_connected = False
91
+ self.master = None