px4-configuration 0.1.0__py3-none-any.whl

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,131 @@
1
+ """Service for PX4 health and calibration snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Optional, Dict, Any
7
+
8
+ from mavsdk import System
9
+
10
+ from ..config import settings
11
+ from ..models import Px4HealthResponse, CalibrationHealth
12
+
13
+
14
+ async def _get_health_once(drone: System, timeout_s: float = 5.0):
15
+ """
16
+ Wait for the first telemetry.health() message.
17
+
18
+ Raises asyncio.TimeoutError if nothing is received within timeout_s.
19
+ """
20
+ async def _first():
21
+ async for health in drone.telemetry.health():
22
+ return health
23
+
24
+ return await asyncio.wait_for(_first(), timeout_s)
25
+
26
+
27
+ async def _get_gps_info_once(drone: System, timeout_s: float = 5.0):
28
+ """
29
+ Wait for the first telemetry.gps_info() message.
30
+
31
+ Raises asyncio.TimeoutError if nothing is received within timeout_s.
32
+ """
33
+ async def _first():
34
+ async for gps_info in drone.telemetry.gps_info():
35
+ return gps_info
36
+
37
+ return await asyncio.wait_for(_first(), timeout_s)
38
+
39
+
40
+ async def read_px4_health(
41
+ port: Optional[str] = None,
42
+ baudrate: Optional[int] = None,
43
+ timeout_s: float = 10.0,
44
+ ) -> Px4HealthResponse:
45
+ """
46
+ Connect to PX4 via MAVSDK and return a one-shot health snapshot.
47
+
48
+ - Uses telemetry.health() for calibration + armable flags.
49
+ - Uses telemetry.gps_info() for fix type + satellites.
50
+ - Returns Px4HealthResponse; never raises to the API layer.
51
+ """
52
+
53
+ # Fallback to global settings if not provided explicitly
54
+ port = port or settings.default_port
55
+ baudrate = baudrate or settings.default_baudrate
56
+ serial = f"serial://{port}:{baudrate}"
57
+
58
+ drone = System()
59
+
60
+ try:
61
+ # Connect to PX4
62
+ await drone.connect(system_address=serial)
63
+
64
+ # Get health & GPS within the global timeout
65
+ health = await _get_health_once(drone, timeout_s=timeout_s / 2)
66
+ try:
67
+ gps_info = await _get_gps_info_once(drone, timeout_s=timeout_s / 2)
68
+ except asyncio.TimeoutError:
69
+ gps_info = None
70
+
71
+ # Build calibration health
72
+ calibration = CalibrationHealth(
73
+ gyroscope_ok=health.is_gyrometer_calibration_ok,
74
+ accelerometer_ok=health.is_accelerometer_calibration_ok,
75
+ magnetometer_ok=health.is_magnetometer_calibration_ok,
76
+ )
77
+
78
+ # GPS fields
79
+ gps_fix_type: Optional[str] = None
80
+ satellites_used: Optional[int] = None
81
+
82
+ if gps_info is not None:
83
+ # gps_info.fix_type is an enum; prefer its name
84
+ if hasattr(gps_info.fix_type, "name"):
85
+ gps_fix_type = gps_info.fix_type.name.lower()
86
+ else:
87
+ gps_fix_type = str(gps_info.fix_type).lower()
88
+
89
+ # MAVSDK docs: num_satellites = number of visible satellites in use
90
+ satellites_used = int(getattr(gps_info, "num_satellites", 0))
91
+
92
+ # High-level status heuristic
93
+ if calibration.requires_calibration:
94
+ status = "degraded"
95
+ else:
96
+ status = "ok"
97
+
98
+ # Raw health flags (optional, but handy for debugging / advanced UI)
99
+ raw_health: Dict[str, Any] = {
100
+ "is_gyrometer_calibration_ok": health.is_gyrometer_calibration_ok,
101
+ "is_accelerometer_calibration_ok": health.is_accelerometer_calibration_ok,
102
+ "is_magnetometer_calibration_ok": health.is_magnetometer_calibration_ok,
103
+ "is_local_position_ok": getattr(health, "is_local_position_ok", None),
104
+ "is_global_position_ok": getattr(health, "is_global_position_ok", None),
105
+ "is_home_position_ok": getattr(health, "is_home_position_ok", None),
106
+ "is_armable": health.is_armable,
107
+ }
108
+
109
+ return Px4HealthResponse(
110
+ status=status,
111
+ connection_port=port,
112
+ connection_baudrate=baudrate,
113
+ calibration=calibration,
114
+ gps_fix_type=gps_fix_type,
115
+ satellites_used=satellites_used,
116
+ armable=health.is_armable,
117
+ raw_health=raw_health,
118
+ )
119
+
120
+ except (asyncio.TimeoutError, Exception):
121
+ # On any connection / telemetry failure, report "disconnected"
122
+ return Px4HealthResponse(
123
+ status="disconnected",
124
+ connection_port=port,
125
+ connection_baudrate=baudrate,
126
+ calibration=None,
127
+ gps_fix_type=None,
128
+ satellites_used=None,
129
+ armable=False,
130
+ raw_health=None,
131
+ )
@@ -0,0 +1,150 @@
1
+ """Service for PX4 parameter upload operations."""
2
+
3
+ import json
4
+ from typing import AsyncGenerator
5
+
6
+ from mavsdk import System
7
+
8
+
9
+ async def upload_parameters(
10
+ param_file_path: str,
11
+ port: str,
12
+ baudrate: int,
13
+ ) -> AsyncGenerator[str, None]:
14
+ """Upload PX4 parameters from a .params file, streaming progress via SSE."""
15
+
16
+ drone = None
17
+ try:
18
+ drone = System()
19
+ serial = f"serial://{port}:{baudrate}"
20
+
21
+ yield "data: {}\n\n".format(
22
+ json.dumps({"type": "status", "message": f"Connecting to {port}..."})
23
+ )
24
+ await drone.connect(system_address=serial)
25
+ yield "data: {}\n\n".format(
26
+ json.dumps({"type": "status", "message": "Connected to device"})
27
+ )
28
+
29
+ param_plugin = drone.param
30
+ yield "data: {}\n\n".format(
31
+ json.dumps({"type": "status", "message": "Fetching current parameters..."})
32
+ )
33
+ params = await param_plugin.get_all_params()
34
+ float_params = params.float_params
35
+ int_params = params.int_params
36
+ custom_params = params.custom_params
37
+ int_param_names = [p.name for p in int_params]
38
+ float_param_names = [p.name for p in float_params]
39
+ custom_param_names = [p.name for p in custom_params]
40
+
41
+ yield "data: {}\n\n".format(
42
+ json.dumps({"type": "status", "message": "Checking if vehicle is landed..."})
43
+ )
44
+ async for is_in_air in drone.telemetry.in_air():
45
+ if is_in_air:
46
+ yield "data: {}\n\n".format(
47
+ json.dumps(
48
+ {
49
+ "type": "warning",
50
+ "message": "Waiting until vehicle is landed...",
51
+ }
52
+ )
53
+ )
54
+ else:
55
+ break
56
+
57
+ yield "data: {}\n\n".format(
58
+ json.dumps(
59
+ {
60
+ "type": "status",
61
+ "message": "Uploading parameters... Please do not arm the vehicle!",
62
+ }
63
+ )
64
+ )
65
+
66
+ with open(param_file_path, "r", encoding="utf-8") as param_file:
67
+ total_lines = sum(
68
+ 1 for line in param_file if line.strip() and not line.startswith("#")
69
+ )
70
+
71
+ uploaded_count = 0
72
+ with open(param_file_path, "r", encoding="utf-8") as param_file:
73
+ for line in param_file:
74
+ if line.startswith("#") or not line.strip():
75
+ continue
76
+
77
+ columns = line.strip().split(",")
78
+ if len(columns) < 2:
79
+ continue
80
+
81
+ name = columns[0].strip()
82
+ value = columns[1].strip()
83
+
84
+ try:
85
+ if name in int_param_names:
86
+ await drone.param.set_param_int(name, int(value))
87
+ elif name in float_param_names:
88
+ await drone.param.set_param_float(name, float(value))
89
+ elif name in custom_param_names:
90
+ await drone.param.set_param_custom(name, value)
91
+ else:
92
+ yield "data: {}\n\n".format(
93
+ json.dumps(
94
+ {
95
+ "type": "warning",
96
+ "message": f"Parameter {name} not found, skipping",
97
+ }
98
+ )
99
+ )
100
+ continue
101
+
102
+ uploaded_count += 1
103
+ yield "data: {}\n\n".format(
104
+ json.dumps(
105
+ {
106
+ "type": "progress",
107
+ "message": f"Uploaded {name} = {value}",
108
+ "progress": uploaded_count,
109
+ "total": total_lines,
110
+ }
111
+ )
112
+ )
113
+ except Exception as exc: # pragma: no cover - streaming generator
114
+ yield "data: {}\n\n".format(
115
+ json.dumps(
116
+ {
117
+ "type": "error",
118
+ "message": f"Failed to set {name}: {exc}",
119
+ }
120
+ )
121
+ )
122
+
123
+ yield "data: {}\n\n".format(
124
+ json.dumps({"type": "status", "message": "Parameters uploaded, rebooting..."})
125
+ )
126
+ await drone.action.reboot()
127
+ yield "data: {}\n\n".format(
128
+ json.dumps(
129
+ {
130
+ "type": "success",
131
+ "message": "Parameters uploaded and vehicle rebooted",
132
+ }
133
+ )
134
+ )
135
+
136
+ except Exception as exc: # pragma: no cover - streaming generator
137
+ yield "data: {}\n\n".format(
138
+ json.dumps(
139
+ {
140
+ "type": "error",
141
+ "message": f"Parameter upload failed: {exc}",
142
+ }
143
+ )
144
+ )
145
+ raise
146
+ finally:
147
+ if drone:
148
+ # No explicit teardown required
149
+ pass
150
+
@@ -0,0 +1,89 @@
1
+ """Service for uploading files to PX4 SD card via FTP."""
2
+
3
+ import json
4
+ from typing import AsyncGenerator
5
+
6
+ from mavsdk import System
7
+
8
+
9
+ async def upload_to_sdcard(
10
+ file_path: str,
11
+ port: str,
12
+ baudrate: int,
13
+ remote_path: str = "/fs/microsd/etc",
14
+ ) -> AsyncGenerator[str, None]:
15
+ """Upload a file to the PX4 SD card via MAVSDK FTP and stream progress."""
16
+
17
+ drone = None
18
+ try:
19
+ drone = System()
20
+ serial = f"serial://{port}:{baudrate}"
21
+
22
+ yield "data: {}\n\n".format(
23
+ json.dumps({"type": "status", "message": f"Connecting to {port}..."})
24
+ )
25
+ await drone.connect(system_address=serial)
26
+ yield "data: {}\n\n".format(
27
+ json.dumps({"type": "status", "message": "Connected to device"})
28
+ )
29
+
30
+ yield "data: {}\n\n".format(
31
+ json.dumps(
32
+ {
33
+ "type": "status",
34
+ "message": f"Listing directory {remote_path}...",
35
+ }
36
+ )
37
+ )
38
+ try:
39
+ directory_list = await drone.ftp.list_directory(remote_path)
40
+ yield "data: {}\n\n".format(
41
+ json.dumps(
42
+ {
43
+ "type": "status",
44
+ "message": f"Directory listing: {directory_list}",
45
+ }
46
+ )
47
+ )
48
+ except Exception as exc: # pragma: no cover
49
+ yield "data: {}\n\n".format(
50
+ json.dumps(
51
+ {
52
+ "type": "warning",
53
+ "message": f"Could not list directory: {exc}",
54
+ }
55
+ )
56
+ )
57
+
58
+ yield "data: {}\n\n".format(
59
+ json.dumps(
60
+ {
61
+ "type": "status",
62
+ "message": f"Uploading {file_path} to {remote_path}...",
63
+ }
64
+ )
65
+ )
66
+ async for progress_data in drone.ftp.upload(file_path, remote_path):
67
+ yield "data: {}\n\n".format(
68
+ json.dumps({"type": "progress", "data": str(progress_data)})
69
+ )
70
+
71
+ yield "data: {}\n\n".format(
72
+ json.dumps({"type": "success", "message": "File uploaded successfully"})
73
+ )
74
+
75
+ except Exception as exc: # pragma: no cover - streaming generator
76
+ yield "data: {}\n\n".format(
77
+ json.dumps(
78
+ {
79
+ "type": "error",
80
+ "message": f"SD card upload failed: {exc}",
81
+ }
82
+ )
83
+ )
84
+ raise
85
+ finally:
86
+ if drone:
87
+ # No explicit teardown required
88
+ pass
89
+
@@ -0,0 +1,65 @@
1
+ """Service for PX4 shell connection management."""
2
+
3
+ import asyncio
4
+ from typing import Optional
5
+
6
+ from mavsdk import System
7
+
8
+
9
+ class ShellConnection:
10
+ """Manage a persistent MAVSDK shell connection with queued output."""
11
+
12
+ def __init__(self, port: str, baudrate: int) -> None:
13
+ self.port = port
14
+ self.baudrate = baudrate
15
+ self.drone: Optional[System] = None
16
+ self.output_queue: "asyncio.Queue[str]" = asyncio.Queue()
17
+ self.connected = False
18
+
19
+ async def connect(self) -> None:
20
+ """Establish connection to PX4 and start shell monitoring."""
21
+
22
+ self.drone = System()
23
+ serial = f"serial://{self.port}:{self.baudrate}"
24
+ await self.drone.connect(system_address=serial)
25
+
26
+ async for state in self.drone.core.connection_state():
27
+ if state.is_connected:
28
+ self.connected = True
29
+ break
30
+
31
+ asyncio.create_task(self._observe_shell())
32
+
33
+ async def _observe_shell(self) -> None:
34
+ """Monitor shell output and place data into the queue."""
35
+
36
+ if not self.drone:
37
+ return
38
+
39
+ try:
40
+ async for output in self.drone.shell.receive():
41
+ await self.output_queue.put(output)
42
+ except Exception as exc: # pragma: no cover - streaming loop
43
+ await self.output_queue.put(f"Error receiving shell output: {exc}")
44
+
45
+ async def send_command(self, command: str) -> None:
46
+ """Send a command to the PX4 shell."""
47
+
48
+ if not self.connected or not self.drone:
49
+ raise RuntimeError("Not connected to PX4")
50
+ await self.drone.shell.send(command)
51
+
52
+ async def receive_output(self) -> Optional[str]:
53
+ """Receive shell output (non-blocking)."""
54
+
55
+ try:
56
+ return await asyncio.wait_for(self.output_queue.get(), timeout=0.1)
57
+ except asyncio.TimeoutError:
58
+ return None
59
+
60
+ async def disconnect(self) -> None:
61
+ """Close the connection if required."""
62
+
63
+ self.connected = False
64
+ # MAVSDK handles cleanup on garbage collection; nothing else required here.
65
+
@@ -0,0 +1 @@
1
+ """Utility functions for the PX4 configuration API."""
@@ -0,0 +1,248 @@
1
+ """Parser for extracting structured data from MAVSDK calibration progress updates."""
2
+
3
+ import re
4
+ from typing import Optional, Dict, Any, List
5
+ import math
6
+
7
+
8
+ def parse_accelerometer_progress(progress_data) -> Dict[str, Any]:
9
+ """
10
+ Parse accelerometer calibration progress data into structured format.
11
+
12
+ Args:
13
+ progress_data: ProgressData object from mavsdk
14
+
15
+ Returns:
16
+ Dictionary with structured calibration information
17
+ """
18
+ result: Dict[str, Any] = {
19
+ "type": "accelerometer_progress",
20
+ "progress": None,
21
+ "status_text": None,
22
+ "pending_orientations": [],
23
+ "current_orientation": None,
24
+ "current_message": None,
25
+ "result": None,
26
+ "is_complete": False,
27
+ }
28
+
29
+ # Extract progress if available
30
+ if hasattr(progress_data, 'has_progress') and progress_data.has_progress:
31
+ progress_value = progress_data.progress
32
+ if not (math.isnan(progress_value) if isinstance(progress_value, float) else False):
33
+ result["progress"] = float(progress_value)
34
+
35
+ # Extract status text if available
36
+ if hasattr(progress_data, 'has_status_text') and progress_data.has_status_text:
37
+ status_text = progress_data.status_text
38
+ result["status_text"] = status_text
39
+
40
+ # Parse pending orientations (e.g., "pending: back front left right up down")
41
+ pending_match = re.search(r'pending:\s*([a-z\s]+)', status_text, re.IGNORECASE)
42
+ if pending_match:
43
+ orientations = [o.strip() for o in pending_match.group(1).split() if o.strip()]
44
+ result["pending_orientations"] = orientations
45
+
46
+ # Parse current orientation being measured
47
+ orientation_patterns = [
48
+ r'(\w+)\s+orientation detected',
49
+ r'(\w+)\s+side result:',
50
+ r'(\w+)\s+side done',
51
+ r'Hold still, measuring (\w+) side',
52
+ ]
53
+ for pattern in orientation_patterns:
54
+ match = re.search(pattern, status_text, re.IGNORECASE)
55
+ if match:
56
+ result["current_orientation"] = match.group(1).lower()
57
+ break
58
+
59
+ # Extract result values (e.g., "down side result: [0.020 0.819 -9.750]")
60
+ result_match = re.search(r'result:\s*\[([\d\.\-\s]+)\]', status_text)
61
+ if result_match:
62
+ try:
63
+ values = [float(v) for v in result_match.group(1).split()]
64
+ result["result"] = values
65
+ except ValueError:
66
+ pass
67
+
68
+ # Determine current message/instruction
69
+ if "progress" in status_text.lower() and "<" in status_text and ">" in status_text:
70
+ # This seems to be a completion indicator (e.g., "progress <102>")
71
+ result["is_complete"] = True
72
+ result["current_message"] = "complete"
73
+ elif "hold vehicle still" in status_text.lower():
74
+ result["current_message"] = "hold_still"
75
+ elif "detected rest position" in status_text.lower():
76
+ result["current_message"] = "rest_detected"
77
+ elif "measuring" in status_text.lower():
78
+ result["current_message"] = "measuring"
79
+ elif "done, rotate" in status_text.lower():
80
+ result["current_message"] = "rotate"
81
+ elif "already completed" in status_text.lower():
82
+ result["current_message"] = "already_completed"
83
+ elif "orientation detected" in status_text.lower():
84
+ result["current_message"] = "orientation_detected"
85
+ else:
86
+ # Default: use the status text as message
87
+ result["current_message"] = status_text
88
+
89
+ return result
90
+
91
+
92
+ def parse_gyroscope_progress(progress_data) -> Dict[str, Any]:
93
+ """
94
+ Parse gyroscope calibration progress data into structured format.
95
+
96
+ Gyroscope calibration only provides progress values (0.0 to 1.0) without status text.
97
+ The vehicle should be kept still during calibration.
98
+
99
+ Args:
100
+ progress_data: ProgressData object from mavsdk
101
+
102
+ Returns:
103
+ Dictionary with structured calibration information
104
+ """
105
+ result: Dict[str, Any] = {
106
+ "type": "gyroscope_progress",
107
+ "progress": None,
108
+ "status_text": None,
109
+ "current_message": None,
110
+ "is_complete": False,
111
+ }
112
+
113
+ if hasattr(progress_data, 'has_progress') and progress_data.has_progress:
114
+ progress_value = progress_data.progress
115
+ if not (math.isnan(progress_value) if isinstance(progress_value, float) else False):
116
+ result["progress"] = float(progress_value)
117
+ # Mark as complete when progress reaches 1.0
118
+ if result["progress"] >= 1.0:
119
+ result["is_complete"] = True
120
+ result["current_message"] = "complete"
121
+ else:
122
+ result["current_message"] = "calibrating"
123
+
124
+ if hasattr(progress_data, 'has_status_text') and progress_data.has_status_text:
125
+ status_text = progress_data.status_text
126
+ result["status_text"] = status_text
127
+ if status_text and not result["current_message"]:
128
+ result["current_message"] = status_text
129
+
130
+ return result
131
+
132
+
133
+ def parse_magnetometer_progress(progress_data) -> Dict[str, Any]:
134
+ """
135
+ Parse magnetometer calibration progress data into structured format.
136
+
137
+ Magnetometer calibration involves rotating the vehicle through different orientations
138
+ while progress increments from 0.0 to 1.0.
139
+
140
+ Args:
141
+ progress_data: ProgressData object from mavsdk
142
+
143
+ Returns:
144
+ Dictionary with structured calibration information
145
+ """
146
+ result: Dict[str, Any] = {
147
+ "type": "magnetometer_progress",
148
+ "progress": None,
149
+ "status_text": None,
150
+ "pending_orientations": [],
151
+ "current_orientation": None,
152
+ "current_message": None,
153
+ "is_complete": False,
154
+ }
155
+
156
+ # Extract progress if available
157
+ if hasattr(progress_data, 'has_progress') and progress_data.has_progress:
158
+ progress_value = progress_data.progress
159
+ if not (math.isnan(progress_value) if isinstance(progress_value, float) else False):
160
+ result["progress"] = float(progress_value)
161
+ # Mark as complete when progress reaches 1.0
162
+ if result["progress"] >= 1.0:
163
+ result["is_complete"] = True
164
+
165
+ # Extract status text if available
166
+ if hasattr(progress_data, 'has_status_text') and progress_data.has_status_text:
167
+ status_text = progress_data.status_text
168
+ result["status_text"] = status_text
169
+
170
+ # Parse pending orientations (e.g., "pending: back front left right up down")
171
+ pending_match = re.search(r'pending:\s*([a-z\s]+)', status_text, re.IGNORECASE)
172
+ if pending_match:
173
+ orientations = [o.strip() for o in pending_match.group(1).split() if o.strip()]
174
+ result["pending_orientations"] = orientations
175
+
176
+ # Parse current orientation being measured
177
+ orientation_patterns = [
178
+ r'(\w+)\s+orientation detected',
179
+ r'(\w+)\s+side done',
180
+ r'(\w+)\s+side already completed',
181
+ ]
182
+ for pattern in orientation_patterns:
183
+ match = re.search(pattern, status_text, re.IGNORECASE)
184
+ if match:
185
+ result["current_orientation"] = match.group(1).lower()
186
+ break
187
+
188
+ # Determine current message/instruction
189
+ if "Rotate vehicle" in status_text:
190
+ result["current_message"] = "rotate"
191
+ elif "hold vehicle still" in status_text.lower():
192
+ result["current_message"] = "hold_still"
193
+ elif "detected rest position" in status_text.lower():
194
+ result["current_message"] = "rest_detected"
195
+ elif "detected motion" in status_text.lower():
196
+ result["current_message"] = "motion_detected"
197
+ elif "done, rotate" in status_text.lower():
198
+ result["current_message"] = "rotate"
199
+ elif "already completed" in status_text.lower():
200
+ result["current_message"] = "already_completed"
201
+ elif "orientation detected" in status_text.lower():
202
+ result["current_message"] = "orientation_detected"
203
+ else:
204
+ # Default: use the status text as message
205
+ result["current_message"] = status_text
206
+
207
+ return result
208
+
209
+
210
+ def parse_horizon_progress(progress_data) -> Dict[str, Any]:
211
+ """
212
+ Parse level horizon calibration progress data into structured format.
213
+
214
+ Horizon calibration only provides progress values (0.0 to 1.0) without status text.
215
+
216
+ Args:
217
+ progress_data: ProgressData object from mavsdk
218
+
219
+ Returns:
220
+ Dictionary with structured calibration information
221
+ """
222
+ result: Dict[str, Any] = {
223
+ "type": "horizon_progress",
224
+ "progress": None,
225
+ "status_text": None,
226
+ "current_message": None,
227
+ "is_complete": False,
228
+ }
229
+
230
+ if hasattr(progress_data, 'has_progress') and progress_data.has_progress:
231
+ progress_value = progress_data.progress
232
+ if not (math.isnan(progress_value) if isinstance(progress_value, float) else False):
233
+ result["progress"] = float(progress_value)
234
+ # Mark as complete when progress reaches 1.0
235
+ if result["progress"] >= 1.0:
236
+ result["is_complete"] = True
237
+ result["current_message"] = "complete"
238
+ else:
239
+ result["current_message"] = "calibrating"
240
+
241
+ if hasattr(progress_data, 'has_status_text') and progress_data.has_status_text:
242
+ status_text = progress_data.status_text
243
+ result["status_text"] = status_text
244
+ if status_text and not result["current_message"]:
245
+ result["current_message"] = status_text
246
+
247
+ return result
248
+