petal-user-journey-coordinator 0.1.5__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,3406 @@
1
+ import uuid
2
+ import asyncio
3
+ import threading
4
+ import time
5
+ import math
6
+ import logging
7
+ import subprocess
8
+ import ipaddress
9
+ import re
10
+ from petal_app_manager.proxies import (
11
+ MQTTProxy,
12
+ MavLinkExternalProxy,
13
+ LocalDBProxy,
14
+ RedisProxy
15
+ )
16
+ from typing import Dict, Any, List, Union, Optional, Callable
17
+ from datetime import datetime
18
+ from abc import ABC, abstractmethod
19
+ import logging
20
+ import time
21
+ from pymavlink import mavutil
22
+ from pymavlink.dialects.v20 import all as mavlink_dialect
23
+ import threading
24
+ import asyncio
25
+ import math
26
+
27
+ from .data_model import (
28
+ RotorCountParameter,
29
+ GPSModulePayload,
30
+ DistanceModulePayload,
31
+ OpticalFlowModulePayload,
32
+ GPSSpatialOffsetPayload,
33
+ DistanceSpatialOffsetPayload,
34
+ OpticalFlowSpatialOffsetPayload,
35
+ ESCCalibrationPayload,
36
+ ESCCalibrationLimitsPayload,
37
+ ESCForceRunAllPayload,
38
+ ESCForceRunSinglePayload,
39
+ PublishPayload,
40
+ ConnectToWifiAndVerifyOptitrackPayload,
41
+ WifiOptitrackConnectionResponse,
42
+ SetStaticIpAddressPayload,
43
+ SetStaticIpAddressResponse
44
+ )
45
+
46
+
47
+ # GPS Module configuration mappings
48
+ GPS_MODULE_CONFIGS = {
49
+ "Mosaic-H RTX": {
50
+ "GPS_1_CONFIG": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # GPS1
51
+ "GPS_1_GNSS": {"value": 31, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32},
52
+ "GPS_1_PROTOCOL": {"value": 12, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # SBF protocol
53
+ "SER_GPS1_BAUD": {"value": 115200, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32},
54
+ "EKF2_GPS_CTRL": {"value": 15, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32},
55
+ },
56
+ "Emlid Reach M2": {
57
+ "GPS_1_CONFIG": {"value": 2, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # GPS2
58
+ "GPS_1_GNSS": {"value": 31, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32},
59
+ "GPS_1_PROTOCOL": {"value": 5, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # RTCM3 protocol
60
+ }
61
+ }
62
+
63
+ # Distance Module configuration mappings
64
+ DISTANCE_MODULE_CONFIGS = {
65
+ "LiDAR Lite v3": {
66
+ "SENS_EN_LL40LS": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Enable LiDAR Lite
67
+ "EKF2_RNG_AID": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Enable range aid
68
+ "EKF2_RNG_A_HMAX": {"value": 40.0, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Max height
69
+ "EKF2_RNG_A_VMAX": {"value": 1.0, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Max velocity
70
+ },
71
+ "TF02 Pro": {
72
+ "SENS_TFMINI_CFG": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # TF02 Pro config
73
+ "EKF2_RNG_AID": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Enable range aid
74
+ "EKF2_RNG_A_HMAX": {"value": 25.0, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Max height
75
+ "EKF2_RNG_A_VMAX": {"value": 0.8, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Max velocity
76
+ "SER_TEL3_BAUD": {"value": 115200, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Serial baud rate
77
+ }
78
+ }
79
+
80
+ # Optical Flow Module configuration mappings
81
+ OPTICAL_FLOW_MODULE_CONFIGS = {
82
+ "PX4Flow v1.3": {
83
+ "SENS_EN_PX4FLOW": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Enable PX4Flow
84
+ "EKF2_OF_CTRL": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Enable optical flow control
85
+ "EKF2_OF_QMIN": {"value": 80, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Min quality
86
+ "EKF2_OF_DELAY": {"value": 20.0, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Delay compensation
87
+ },
88
+ "PMW3901 Based": {
89
+ "SENS_FLOW_ROT": {"value": 0, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Flow rotation
90
+ "EKF2_OF_CTRL": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Enable optical flow control
91
+ "EKF2_OF_QMIN": {"value": 70, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Min quality
92
+ "EKF2_OF_DELAY": {"value": 15.0, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Delay compensation
93
+ "SER_TEL2_BAUD": {"value": 921600, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Serial baud rate
94
+ },
95
+ "ARK Flow": {
96
+ # Enable DroneCAN with dynamic node allocation and range sensor
97
+ "UAVCAN_ENABLE": {"value": 2, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Enable DroneCAN sensors with DNA
98
+ # DISABLE optical flow fusion initially (as per documentation)
99
+ "EKF2_OF_CTRL": {"value": 0, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # DISABLE optical flow fusion
100
+ # Enable DroneCAN range sensor subscription
101
+ "UAVCAN_SUB_RNG": {"value": 1, "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32}, # Enable UAVCAN range sensor
102
+ # Range sensor configuration
103
+ "EKF2_RNG_A_HMAX": {"value": 10.0, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Max height 10m
104
+ "EKF2_RNG_QLTY_T": {"value": 0.2, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Range quality threshold
105
+ "UAVCAN_RNG_MIN": {"value": 0.08, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Min range 0.08m
106
+ "UAVCAN_RNG_MAX": {"value": 30.0, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Max range 30m
107
+ # Flow sensor configuration
108
+ "SENS_FLOW_MINHGT": {"value": 0.08, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Min flow height
109
+ "SENS_FLOW_MAXHGT": {"value": 25.0, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Max flow height
110
+ "SENS_FLOW_MAXR": {"value": 7.4, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Max angular flow rate (PAW3902)
111
+ # Controller tuning for optical flow only operation
112
+ "MPC_XY_P": {"value": 0.5, "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32}, # Reduce horizontal position gain
113
+ }
114
+ }
115
+
116
+
117
+ class BaseParameterHandler(ABC):
118
+ """Abstract base class for parameter configuration handlers"""
119
+
120
+ def __init__(self, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger):
121
+ self.mavlink_proxy = mavlink_proxy
122
+ self.logger = logger
123
+
124
+ @abstractmethod
125
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
126
+ """Process the payload and return response data"""
127
+ pass
128
+
129
+ async def set_parameter(self, name: str, value: Union[str, int, float], ptype: int, message_id: str) -> Dict[str, Any]:
130
+ """Helper method to set a single parameter"""
131
+ try:
132
+ success = await self.mavlink_proxy.set_param(name=name, value=value, ptype=ptype)
133
+
134
+ if success:
135
+ current_value = await self.mavlink_proxy.get_param(name)
136
+ self.logger.info(f"[{message_id}] Set {name} = {value}")
137
+ return {
138
+ "status": "success",
139
+ "set_value": value,
140
+ "confirmed_value": current_value
141
+ }
142
+ else:
143
+ self.logger.error(f"[{message_id}] Failed to set {name} = {value}")
144
+ return {
145
+ "status": "failed",
146
+ "set_value": value
147
+ }
148
+ except Exception as e:
149
+ self.logger.error(f"[{message_id}] Error setting {name}: {e}")
150
+ return {
151
+ "status": "error",
152
+ "error": str(e)
153
+ }
154
+
155
+ async def set_multiple_parameters(self, param_configs: Dict[str, Dict[str, Any]], message_id: str) -> Dict[str, Any]:
156
+ """Helper method to set multiple parameters"""
157
+ results = {}
158
+ failed_params = []
159
+
160
+ for param_name, config in param_configs.items():
161
+ result = await self.set_parameter(
162
+ param_name,
163
+ config["value"],
164
+ config["type"],
165
+ message_id
166
+ )
167
+
168
+ results[param_name] = result
169
+ if result["status"] != "success":
170
+ failed_params.append(param_name)
171
+
172
+ if failed_params:
173
+ return {
174
+ "status": "partial_success",
175
+ "message": f"Configuration completed with {len(failed_params)} failed parameters",
176
+ "failed_parameters": failed_params,
177
+ "results": results,
178
+ "error_code": "PARTIAL_CONFIG_FAILURE"
179
+ }
180
+ else:
181
+ return {
182
+ "status": "success",
183
+ "message": "Configuration completed successfully",
184
+ "results": results,
185
+ "timestamp": time.time()
186
+ }
187
+
188
+
189
+ class RotorCountHandler(BaseParameterHandler):
190
+ """Handler for rotor count configuration"""
191
+
192
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
193
+ geo_payload = RotorCountParameter(**payload)
194
+ self.logger.info(f"[{message_id}] Processing rotor count: {geo_payload.rotor_count}")
195
+
196
+ result = await self.set_parameter(
197
+ "CA_ROTOR_COUNT",
198
+ int(geo_payload.rotor_count),
199
+ mavutil.mavlink.MAV_PARAM_TYPE_INT32,
200
+ message_id
201
+ )
202
+
203
+ if result["status"] == "success":
204
+ return {
205
+ "status": "success",
206
+ "message": f"CA_ROTOR_COUNT set to {geo_payload.rotor_count}",
207
+ "rotor_count": geo_payload.rotor_count,
208
+ "confirmed_value": result["confirmed_value"],
209
+ "timestamp": time.time()
210
+ }
211
+ else:
212
+ return {
213
+ "status": "error",
214
+ "message": f"Failed to set CA_ROTOR_COUNT to {geo_payload.rotor_count}",
215
+ "error_code": "PARAM_SET_FAILED"
216
+ }
217
+
218
+
219
+ class GPSModuleHandler(BaseParameterHandler):
220
+ """Handler for GPS module configuration"""
221
+
222
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
223
+ gps_payload = GPSModulePayload(**payload)
224
+ self.logger.info(f"[{message_id}] Processing GPS module: {gps_payload.gps_module}")
225
+
226
+ if gps_payload.gps_module not in GPS_MODULE_CONFIGS:
227
+ return {
228
+ "status": "error",
229
+ "message": f"Unknown GPS module: {gps_payload.gps_module}. Available: {list(GPS_MODULE_CONFIGS.keys())}",
230
+ "error_code": "UNKNOWN_GPS_MODULE"
231
+ }
232
+
233
+ config = GPS_MODULE_CONFIGS[gps_payload.gps_module]
234
+ result = await self.set_multiple_parameters(config, message_id)
235
+
236
+ if result["status"] in ["success", "partial_success"]:
237
+ result["gps_module"] = gps_payload.gps_module
238
+ result["message"] = f"GPS module {gps_payload.gps_module} " + result["message"]
239
+
240
+ return result
241
+
242
+
243
+ class DistanceModuleHandler(BaseParameterHandler):
244
+ """Handler for distance module configuration"""
245
+
246
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
247
+ dist_payload = DistanceModulePayload(**payload)
248
+ self.logger.info(f"[{message_id}] Processing distance module: {dist_payload.dist_module}")
249
+
250
+ if dist_payload.dist_module not in DISTANCE_MODULE_CONFIGS:
251
+ return {
252
+ "status": "error",
253
+ "message": f"Unknown distance module: {dist_payload.dist_module}. Available: {list(DISTANCE_MODULE_CONFIGS.keys())}",
254
+ "error_code": "UNKNOWN_DISTANCE_MODULE"
255
+ }
256
+
257
+ config = DISTANCE_MODULE_CONFIGS[dist_payload.dist_module]
258
+ result = await self.set_multiple_parameters(config, message_id)
259
+
260
+ if result["status"] in ["success", "partial_success"]:
261
+ result["dist_module"] = dist_payload.dist_module
262
+ result["message"] = f"Distance module {dist_payload.dist_module} " + result["message"]
263
+
264
+ return result
265
+
266
+
267
+ class OpticalFlowModuleHandler(BaseParameterHandler):
268
+ """Handler for optical flow module configuration"""
269
+
270
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
271
+ oflow_payload = OpticalFlowModulePayload(**payload)
272
+ self.logger.info(f"[{message_id}] Processing optical flow module: {oflow_payload.oflow_module}")
273
+
274
+ if oflow_payload.oflow_module not in OPTICAL_FLOW_MODULE_CONFIGS:
275
+ return {
276
+ "status": "error",
277
+ "message": f"Unknown optical flow module: {oflow_payload.oflow_module}. Available: {list(OPTICAL_FLOW_MODULE_CONFIGS.keys())}",
278
+ "error_code": "UNKNOWN_OPTICAL_FLOW_MODULE"
279
+ }
280
+
281
+ config = OPTICAL_FLOW_MODULE_CONFIGS[oflow_payload.oflow_module]
282
+ result = await self.set_multiple_parameters(config, message_id)
283
+
284
+ if result["status"] in ["success", "partial_success"]:
285
+ result["oflow_module"] = oflow_payload.oflow_module
286
+ result["message"] = f"Optical flow module {oflow_payload.oflow_module} " + result["message"]
287
+
288
+ return result
289
+
290
+
291
+ class GPSSpatialOffsetHandler(BaseParameterHandler):
292
+ """Handler for GPS spatial offset configuration"""
293
+
294
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
295
+ offset_payload = GPSSpatialOffsetPayload(**payload)
296
+ self.logger.info(f"[{message_id}] Processing GPS spatial offsets")
297
+
298
+ param_configs = {
299
+ "EKF2_GPS_POS_X": {
300
+ "value": float(offset_payload.gps_module_spatial_offset_x_m),
301
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
302
+ },
303
+ "EKF2_GPS_POS_Y": {
304
+ "value": float(offset_payload.gps_module_spatial_offset_y_m),
305
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
306
+ },
307
+ "EKF2_GPS_POS_Z": {
308
+ "value": float(offset_payload.gps_module_spatial_offset_z_m),
309
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
310
+ }
311
+ }
312
+
313
+ result = await self.set_multiple_parameters(param_configs, message_id)
314
+
315
+ if result["status"] in ["success", "partial_success"]:
316
+ result["offsets"] = {
317
+ "x": offset_payload.gps_module_spatial_offset_x_m,
318
+ "y": offset_payload.gps_module_spatial_offset_y_m,
319
+ "z": offset_payload.gps_module_spatial_offset_z_m
320
+ }
321
+ result["message"] = f"GPS spatial offsets " + result["message"]
322
+
323
+ return result
324
+
325
+
326
+ class DistanceSpatialOffsetHandler(BaseParameterHandler):
327
+ """Handler for distance sensor spatial offset configuration"""
328
+
329
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
330
+ offset_payload = DistanceSpatialOffsetPayload(**payload)
331
+ self.logger.info(f"[{message_id}] Processing distance sensor spatial offsets")
332
+
333
+ param_configs = {
334
+ "EKF2_RNG_POS_X": {
335
+ "value": float(offset_payload.dist_module_spatial_offset_x_m),
336
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
337
+ },
338
+ "EKF2_RNG_POS_Y": {
339
+ "value": float(offset_payload.dist_module_spatial_offset_y_m),
340
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
341
+ },
342
+ "EKF2_RNG_POS_Z": {
343
+ "value": float(offset_payload.dist_module_spatial_offset_z_m),
344
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
345
+ }
346
+ }
347
+
348
+ result = await self.set_multiple_parameters(param_configs, message_id)
349
+
350
+ if result["status"] in ["success", "partial_success"]:
351
+ result["offsets"] = {
352
+ "x": offset_payload.dist_module_spatial_offset_x_m,
353
+ "y": offset_payload.dist_module_spatial_offset_y_m,
354
+ "z": offset_payload.dist_module_spatial_offset_z_m
355
+ }
356
+ result["message"] = f"Distance sensor spatial offsets " + result["message"]
357
+
358
+ return result
359
+
360
+
361
+ class OpticalFlowSpatialOffsetHandler(BaseParameterHandler):
362
+ """Handler for optical flow spatial offset configuration"""
363
+
364
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
365
+ offset_payload = OpticalFlowSpatialOffsetPayload(**payload)
366
+ self.logger.info(f"[{message_id}] Processing optical flow spatial offsets")
367
+
368
+ param_configs = {
369
+ "EKF2_OF_POS_X": {
370
+ "value": float(offset_payload.oflow_module_spatial_offset_x_m),
371
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
372
+ },
373
+ "EKF2_OF_POS_Y": {
374
+ "value": float(offset_payload.oflow_module_spatial_offset_y_m),
375
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
376
+ },
377
+ "EKF2_OF_POS_Z": {
378
+ "value": float(offset_payload.oflow_module_spatial_offset_z_m),
379
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_REAL32
380
+ }
381
+ }
382
+
383
+ result = await self.set_multiple_parameters(param_configs, message_id)
384
+
385
+ if result["status"] in ["success", "partial_success"]:
386
+ result["offsets"] = {
387
+ "x": offset_payload.oflow_module_spatial_offset_x_m,
388
+ "y": offset_payload.oflow_module_spatial_offset_y_m,
389
+ "z": offset_payload.oflow_module_spatial_offset_z_m
390
+ }
391
+ result["message"] = f"Optical flow spatial offsets " + result["message"]
392
+
393
+ return result
394
+
395
+
396
+ class ESCCalibrationLimitsHandler(BaseParameterHandler):
397
+ """Handler for ESC calibration limits configuration"""
398
+
399
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
400
+ limits_payload = ESCCalibrationLimitsPayload(**payload)
401
+ self.logger.info(f"[{message_id}] Processing ESC calibration limits: MAX={limits_payload.motors_common_max_pwm}, MIN={limits_payload.motors_common_min_pwm}")
402
+
403
+ # Validate that max > min
404
+ if limits_payload.motors_common_max_pwm <= limits_payload.motors_common_min_pwm:
405
+ return {
406
+ "status": "error",
407
+ "message": f"Maximum PWM ({limits_payload.motors_common_max_pwm}) must be greater than minimum PWM ({limits_payload.motors_common_min_pwm})",
408
+ "failed_parameters": [],
409
+ "successful_parameters": []
410
+ }
411
+
412
+ # Get motor count from CA_ROTOR_COUNT
413
+ try:
414
+ rotor_count_result = await self.mavlink_proxy.get_param("CA_ROTOR_COUNT")
415
+ if rotor_count_result is None:
416
+ logging.error(f"[{message_id}] CA_ROTOR_COUNT not set, exiting with error")
417
+ return {
418
+ "status": "error",
419
+ "message": "CA_ROTOR_COUNT parameter not set on vehicle",
420
+ "error_code": "PARAM_NOT_SET"
421
+ }
422
+
423
+ if rotor_count_result.get("value") is None:
424
+ logging.error(f"[{message_id}] CA_ROTOR_COUNT has no value, exiting with error")
425
+ return {
426
+ "status": "error",
427
+ "message": "CA_ROTOR_COUNT parameter has no value",
428
+ "error_code": "PARAM_NO_VALUE"
429
+ }
430
+
431
+ motor_count = int(rotor_count_result.get("value"))
432
+
433
+ self.logger.info(f"[{message_id}] Setting PWM limits for {motor_count} motors")
434
+ except Exception as e:
435
+ self.logger.warning(f"[{message_id}] Could not get CA_ROTOR_COUNT: {e}. Exiting with error.")
436
+ return {
437
+ "status": "error",
438
+ "message": f"Could not retrieve CA_ROTOR_COUNT: {e}",
439
+ "error_code": "PARAM_RETRIEVE_FAILED"
440
+ }
441
+
442
+ # Build parameter configurations for all motors
443
+ param_configs = {}
444
+ for motor_idx in range(1, motor_count + 1):
445
+ param_configs[f"PWM_AUX_MAX{motor_idx}"] = {
446
+ "value": limits_payload.motors_common_max_pwm,
447
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32
448
+ }
449
+ param_configs[f"PWM_AUX_MIN{motor_idx}"] = {
450
+ "value": limits_payload.motors_common_min_pwm,
451
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32
452
+ }
453
+
454
+ result = await self.set_multiple_parameters(param_configs, message_id)
455
+
456
+ if result["status"] in ["success", "partial_success"]:
457
+ result["motor_count"] = motor_count
458
+ result["max_pwm"] = limits_payload.motors_common_max_pwm
459
+ result["min_pwm"] = limits_payload.motors_common_min_pwm
460
+ result["message"] = f"ESC calibration limits updated for {motor_count} motors - " + result["message"]
461
+
462
+ return result
463
+
464
+
465
+ class KillSwitchConfigHandler(BaseParameterHandler):
466
+ """Handler for kill switch configuration"""
467
+
468
+ async def process_payload(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
469
+ """Configure kill switch parameters."""
470
+ self.logger.info(f"[{message_id}] Configuring kill switch parameters")
471
+
472
+ # Build parameter configurations
473
+ param_configs = {}
474
+
475
+ # 1. Set RC_MAP_KILL_SW to channel 7
476
+ param_configs["RC_MAP_KILL_SW"] = {
477
+ "value": 7,
478
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32
479
+ }
480
+
481
+ # 2. Clear all flight mode assignments (COM_FLTMODE1 to COM_FLTMODE6)
482
+ for mode_idx in range(1, 7):
483
+ param_configs[f"COM_FLTMODE{mode_idx}"] = {
484
+ "value": -1,
485
+ "type": mavutil.mavlink.MAV_PARAM_TYPE_INT32
486
+ }
487
+
488
+ result = await self.set_multiple_parameters(param_configs, message_id)
489
+
490
+ if result["status"] in ["success", "partial_success"]:
491
+ result["kill_switch_channel"] = 7
492
+ result["cleared_flight_modes"] = list(range(1, 7))
493
+ result["message"] = f"Kill switch configured on channel 7, flight modes cleared - " + result["message"]
494
+
495
+ return result
496
+
497
+
498
+ class BaseTimeoutController(ABC):
499
+ """
500
+ Base class for timeout-controlled operations via MAVLink.
501
+ Provides common functionality for sending commands and managing timeouts.
502
+ """
503
+
504
+ def __init__(self, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger):
505
+ self.mavlink_proxy = mavlink_proxy
506
+ self.logger = logger
507
+ self.is_active = False
508
+ self.current_task: Optional[asyncio.Task] = None
509
+ self.stop_event = threading.Event()
510
+ self.last_command_time = None
511
+ self.current_safety_timeout = 3.0
512
+ self.lock = threading.Lock()
513
+
514
+ async def send_mav_start_cmd(self, target_numbers: List[int], values: List[float], timeout: float = 3.0):
515
+ """
516
+ Send MAV_CMD_ACTUATOR_TEST command to start operation target(s).
517
+
518
+ Args:
519
+ target_numbers: List of target numbers (1-based, e.g., motor numbers)
520
+ values: List of command values [-1..1] corresponding to targets
521
+ timeout: Command timeout in seconds (<=3)
522
+ """
523
+ if len(target_numbers) != len(values):
524
+ raise ValueError("Target numbers and values lists must have same length")
525
+
526
+ for target_num, value in zip(target_numbers, values):
527
+ await self._send_actuator_test_command(target_num, value, timeout)
528
+
529
+ async def send_mav_stop_cmd(self, target_numbers: List[int], timeout: float = 3.0):
530
+ """
531
+ Send MAV_CMD_ACTUATOR_TEST command to stop operation target(s) with explicit disarm.
532
+
533
+ Args:
534
+ target_numbers: List of target numbers (1-based, e.g., motor numbers) to stop
535
+ timeout: Command timeout in seconds (<=3)
536
+ """
537
+ for target_num in target_numbers:
538
+ await self._send_actuator_test_command(target_num, float('nan'), timeout)
539
+ self.logger.info(f"Sent explicit disarm command for target {target_num}")
540
+
541
+ async def _send_actuator_test_command(self, target_number: int, value: float, timeout: float):
542
+ """
543
+ Send a single MAV_CMD_ACTUATOR_TEST command.
544
+
545
+ Args:
546
+ target_number: Target number (1-based, e.g., motor number)
547
+ value: Test value [-1..1], NaN to stop
548
+ timeout: Command timeout in seconds (<=3)
549
+ """
550
+ try:
551
+ # Build the command message
552
+ command_msg = self.mavlink_proxy.build_req_msg_long(mavutil.mavlink.MAV_CMD_ACTUATOR_TEST)
553
+
554
+ # Set command parameters
555
+ command_msg.param1 = value # Test value [-1..1]
556
+ command_msg.param2 = timeout # Timeout seconds
557
+ command_msg.param3 = 0 # Reserved
558
+ command_msg.param4 = 0 # Reserved
559
+ command_msg.param5 = mavutil.mavlink.ACTUATOR_OUTPUT_FUNCTION_MOTOR1 + (target_number - 1) # Motor function
560
+ command_msg.param6 = 0 # Reserved
561
+ command_msg.param7 = 0 # Reserved
562
+
563
+ # Send the command
564
+ self.mavlink_proxy.send("mav", command_msg)
565
+
566
+ # Log command details
567
+ if math.isnan(value):
568
+ self.logger.info(f"Sent DISARM command for target {target_number}")
569
+ else:
570
+ self.logger.info(f"Sent MAV_CMD_ACTUATOR_TEST for target {target_number} with value {value}")
571
+
572
+ except Exception as e:
573
+ self.logger.error(f"Failed to send MAV_CMD_ACTUATOR_TEST: {e}")
574
+ raise
575
+
576
+ def refresh_timeout(self, new_timeout: float):
577
+ """
578
+ Refresh the safety timeout with a new value.
579
+
580
+ Args:
581
+ new_timeout: New timeout value in seconds
582
+ """
583
+ with self.lock:
584
+ self.current_safety_timeout = min(new_timeout, 3.0) # Cap at 3 seconds
585
+ self.last_command_time = time.time()
586
+ self.logger.debug(f"Refreshed timeout to {self.current_safety_timeout}s")
587
+
588
+ def is_timeout_expired(self) -> bool:
589
+ """
590
+ Check if the current timeout has expired.
591
+
592
+ Returns:
593
+ True if timeout has expired, False otherwise
594
+ """
595
+ with self.lock:
596
+ if self.last_command_time is None:
597
+ return False
598
+ return time.time() - self.last_command_time > self.current_safety_timeout
599
+
600
+ async def emergency_stop(self, target_numbers: List[int]):
601
+ """
602
+ Emergency stop for specified targets with immediate disarm.
603
+
604
+ Args:
605
+ target_numbers: List of target numbers to stop
606
+ """
607
+ self.logger.warning(f"Emergency stop requested for targets: {target_numbers}")
608
+ self.stop_event.set()
609
+ await self.send_mav_stop_cmd(target_numbers, timeout=3.0)
610
+ self.is_active = False
611
+
612
+ @abstractmethod
613
+ async def start_operation(self, payload: Dict[str, Any]) -> None:
614
+ """
615
+ Start the timeout-controlled operation with given payload.
616
+ Must be implemented by subclasses.
617
+ """
618
+ pass
619
+
620
+ @abstractmethod
621
+ async def stop_operation(self) -> None:
622
+ """
623
+ Stop the timeout-controlled operation.
624
+ Must be implemented by subclasses.
625
+ """
626
+ pass
627
+
628
+ @abstractmethod
629
+ def get_operation_targets(self) -> List[int]:
630
+ """
631
+ Get the list of target numbers this controller manages.
632
+ Must be implemented by subclasses.
633
+ """
634
+ pass
635
+
636
+
637
+ class ESCCalibrationController(BaseTimeoutController):
638
+ """
639
+ Enhanced ESC calibration controller with step-by-step workflow.
640
+
641
+ Workflow:
642
+ 1. Set PWM_AUX_TIM0 based on ESC signal interface type
643
+ 2. Set PWM_AUX_MAX/MIN for each motor
644
+ 3. Execute throttle commands based on user input (maximum/minimum/stop)
645
+ """
646
+
647
+ def __init__(self, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger, motor_count: int = 4):
648
+ super().__init__(mavlink_proxy, logger)
649
+ self.motor_count = motor_count
650
+ self.calibration_state = "idle" # idle, configured, maximum, minimum, stopped
651
+
652
+ # ESC signal interface configurations
653
+ self.esc_interface_configs = {
654
+ "PWM": {
655
+ "PWM_AUX_TIM0": 400, # PWM 400Hz
656
+ "description": "Standard PWM 400Hz"
657
+ },
658
+ "OneShot125": {
659
+ "PWM_AUX_TIM0": 0, # OneShot125 mode
660
+ "description": "OneShot125 ESC protocol"
661
+ },
662
+ "OneShot42": {
663
+ "PWM_AUX_TIM0": 1, # OneShot42 mode
664
+ "description": "OneShot42 ESC protocol"
665
+ },
666
+ "Multishot": {
667
+ "PWM_AUX_TIM0": 2, # Multishot mode
668
+ "description": "Multishot ESC protocol"
669
+ },
670
+ "DShot150": {
671
+ "PWM_AUX_TIM0": 3, # DShot150 mode
672
+ "description": "DShot150 digital ESC protocol"
673
+ },
674
+ "DShot300": {
675
+ "PWM_AUX_TIM0": 4, # DShot300 mode
676
+ "description": "DShot300 digital ESC protocol"
677
+ },
678
+ "DShot600": {
679
+ "PWM_AUX_TIM0": 5, # DShot600 mode
680
+ "description": "DShot600 digital ESC protocol"
681
+ },
682
+ "DShot1200": {
683
+ "PWM_AUX_TIM0": 6, # DShot1200 mode
684
+ "description": "DShot1200 digital ESC protocol"
685
+ }
686
+ }
687
+
688
+ def get_operation_targets(self) -> List[int]:
689
+ return list(range(1, self.motor_count + 1))
690
+
691
+ def get_available_interfaces(self) -> Dict[str, str]:
692
+ """Get available ESC interface types and their descriptions."""
693
+ return {interface: config["description"] for interface, config in self.esc_interface_configs.items()}
694
+
695
+ async def _setup_interface_parameters(self, interface_type: str, rotor_count: Optional[int], message_id: str) -> bool:
696
+ """Setup interface parameters for ESC calibration."""
697
+ try:
698
+ if interface_type not in self.esc_interface_configs:
699
+ self.logger.error(f"[{message_id}] Unsupported ESC interface: {interface_type}")
700
+ available_interfaces = list(self.esc_interface_configs.keys())
701
+ self.logger.info(f"[{message_id}] Available interfaces: {available_interfaces}")
702
+ return False
703
+
704
+ config = self.esc_interface_configs[interface_type]
705
+ self.logger.info(f"[{message_id}] Configuring {config['description']} interface")
706
+
707
+ # Step 1: Set PWM_AUX_TIM0 for the interface type
708
+ success = await self.mavlink_proxy.set_param(
709
+ name="PWM_AUX_TIM0",
710
+ value=config["PWM_AUX_TIM0"],
711
+ ptype=mavutil.mavlink.MAV_PARAM_TYPE_INT32
712
+ )
713
+
714
+ if not success:
715
+ self.logger.error(f"[{message_id}] Failed to set PWM_AUX_TIM0")
716
+ return False
717
+
718
+ self.logger.info(f"[{message_id}] Set PWM_AUX_TIM0 = {config['PWM_AUX_TIM0']} for {interface_type}")
719
+
720
+ # Step 2: Handle motor count - use provided value or get from CA_ROTOR_COUNT
721
+ if rotor_count is not None:
722
+ self.motor_count = rotor_count
723
+ self.logger.info(f"[{message_id}] Using provided motor count: {self.motor_count}")
724
+ else:
725
+ # Get current motor count from CA_ROTOR_COUNT
726
+ rotor_count_param = await self.mavlink_proxy.get_param("CA_ROTOR_COUNT")
727
+ if rotor_count_param is not None:
728
+ self.motor_count = int(rotor_count_param.get("value", None))
729
+ if self.motor_count is None or self.motor_count <= 0:
730
+ self.logger.error(f"[{message_id}] Invalid CA_ROTOR_COUNT value, cannot proceed")
731
+ return False
732
+ self.logger.info(f"[{message_id}] Updated motor count from CA_ROTOR_COUNT: {self.motor_count}")
733
+ else:
734
+ self.logger.warning(f"[{message_id}] Could not get CA_ROTOR_COUNT, using default: {self.motor_count}")
735
+
736
+ # set motor count
737
+ success = await self.mavlink_proxy.set_param(
738
+ name="CA_ROTOR_COUNT",
739
+ value=self.motor_count,
740
+ ptype=mavutil.mavlink.MAV_PARAM_TYPE_INT32
741
+ )
742
+ if not success:
743
+ self.logger.error(f"[{message_id}] Failed to set CA_ROTOR_COUNT")
744
+ return False
745
+ self.logger.info(f"[{message_id}] Set CA_ROTOR_COUNT = {self.motor_count}")
746
+
747
+ # Step 3: Set PWM_AUX_MAX and PWM_AUX_MIN and PWM_AUX_FUNC for each motor (only for PWM-based protocols)
748
+ if interface_type in ["PWM"]: # Only set PWM limits for PWM mode
749
+ for motor_idx in range(1, self.motor_count + 1):
750
+ # Set PWM_AUX_MAX{motor_idx} = 2000
751
+ max_param = f"PWM_AUX_MAX{motor_idx}"
752
+ success = await self.mavlink_proxy.set_param(
753
+ name=max_param,
754
+ value=2000,
755
+ ptype=mavutil.mavlink.MAV_PARAM_TYPE_INT32
756
+ )
757
+ if not success:
758
+ self.logger.error(f"[{message_id}] Failed to set {max_param}")
759
+ return False
760
+
761
+ # Set PWM_AUX_MIN{motor_idx} = 1000
762
+ min_param = f"PWM_AUX_MIN{motor_idx}"
763
+ success = await self.mavlink_proxy.set_param(
764
+ name=min_param,
765
+ value=1000,
766
+ ptype=mavutil.mavlink.MAV_PARAM_TYPE_INT32
767
+ )
768
+ if not success:
769
+ self.logger.error(f"[{message_id}] Failed to set {min_param}")
770
+ return False
771
+
772
+ # Set PWM_AUX_FUNC{motor_idx} = MOTORx
773
+ func_param = f"PWM_AUX_FUNC{motor_idx}"
774
+ success = await self.mavlink_proxy.set_param(
775
+ name=func_param,
776
+ value=100+motor_idx,
777
+ ptype=mavutil.mavlink.MAV_PARAM_TYPE_INT32
778
+ )
779
+ if not success:
780
+ self.logger.error(f"[{message_id}] Failed to set {func_param}")
781
+ return False
782
+
783
+ self.logger.info(f"[{message_id}] Set {max_param}=2000, {min_param}=1000, {func_param}={100+motor_idx}")
784
+ else:
785
+ self.logger.info(f"[{message_id}] Digital protocol {interface_type} - skipping PWM range configuration")
786
+
787
+ return True
788
+
789
+ except Exception as e:
790
+ self.logger.error(f"[{message_id}] Error setting interface parameters: {e}")
791
+ return False
792
+
793
+ async def _send_throttle_command(self, throttle_type: str, message_id: str) -> bool:
794
+ """Send throttle command to all motors."""
795
+ try:
796
+ for motor_idx in range(1, self.motor_count + 1):
797
+ if throttle_type == "maximum":
798
+ # Maximum throttle (param1=1.0)
799
+ param1 = 1.0
800
+ timeout = 3.0
801
+ elif throttle_type == "minimum":
802
+ # Minimum throttle (param1=0.0)
803
+ param1 = 0.0
804
+ timeout = 3.0
805
+ elif throttle_type == "stop":
806
+ # Stop motors (param1=NaN, timeout=0)
807
+ param1 = float('nan')
808
+ timeout = 0.0
809
+ else:
810
+ self.logger.error(f"[{message_id}] Unknown throttle type: {throttle_type}")
811
+ return False
812
+
813
+ # param5 = 110x where x is motor index
814
+ param5 = float(1100 + motor_idx)
815
+
816
+ # Build the command message
817
+ command_msg = self.mavlink_proxy.master.mav.command_long_encode(
818
+ 1, # TODO: investigate best practice
819
+ 1, # TODO: investigate best practice
820
+ mavutil.mavlink.MAV_CMD_ACTUATOR_TEST,
821
+ 0, # confirmation
822
+ param1, # Motor value (0-1 or NaN)
823
+ timeout, # Timeout in seconds
824
+ 0, # Reserved
825
+ 0, # Reserved
826
+ param5, # Motor mapping (110x)
827
+ 0, # Reserved
828
+ 0 # Reserved
829
+ )
830
+
831
+ # Send the command
832
+ self.mavlink_proxy.send("mav", command_msg)
833
+
834
+ action = "maximum throttle" if throttle_type == "maximum" else \
835
+ "minimum throttle" if throttle_type == "minimum" else "stopped"
836
+ self.logger.info(f"[{message_id}] Motor {motor_idx}: {action} (param1={param1}, param5={param5})")
837
+
838
+ # Small delay between motor commands
839
+ await asyncio.sleep(0.1)
840
+
841
+ return True
842
+
843
+ except Exception as e:
844
+ self.logger.error(f"[{message_id}] Error sending throttle command: {e}")
845
+ return False
846
+
847
+ async def start_operation(self, payload: ESCCalibrationPayload) -> None:
848
+ """Start ESC calibration with enhanced workflow. Called only once to initialize."""
849
+ message_id = str(uuid.uuid4())[:8]
850
+
851
+ try:
852
+ # Handle force cancel or stop
853
+ if payload.force_cancel_calibration:
854
+ self.logger.info(f"[{message_id}] ESC calibration cancelled/stopped")
855
+ await self._send_throttle_command("stop", message_id)
856
+ self.calibration_state = "stopped"
857
+ self.is_active = False
858
+ return
859
+
860
+ # This should only run once - setup interface parameters
861
+ self.logger.info(f"[{message_id}] Starting ESC calibration setup (one-time initialization)...")
862
+
863
+ # Setup interface parameters with optional rotor count
864
+ interface_type = payload.esc_interface_signal_type or "PWM"
865
+ if not await self._setup_interface_parameters(interface_type, payload.ca_rotor_count, message_id):
866
+ raise Exception("Failed to setup interface parameters")
867
+
868
+ # Set initial calibration state from payload
869
+ self.calibration_state = "configured"
870
+ self.logger.info(f"[{message_id}] Initial state set to CONFIGURED (waiting for throttle commands)")
871
+
872
+ # Update timeout and start the calibration task
873
+ self.refresh_timeout(payload.safety_timeout_s)
874
+ self.is_active = True
875
+ self.stop_event.clear()
876
+
877
+ # Create the calibration loop task directly - this is the modern approach
878
+ # and works properly when called from async context scheduled by run_coroutine_threadsafe
879
+ try:
880
+ self.current_task = asyncio.create_task(self._calibration_loop())
881
+ self.logger.info(f"[{message_id}] ESC calibration task created - setup complete")
882
+ except Exception as e:
883
+ self.logger.error(f"[{message_id}] Failed to create calibration loop task: {e}")
884
+ # Reset state on failure
885
+ self.is_active = False
886
+ self.calibration_state = "stopped"
887
+ raise
888
+
889
+ except Exception as e:
890
+ self.logger.error(f"[{message_id}] ESC calibration error: {e}")
891
+ # Emergency stop on error
892
+ await self._send_throttle_command("stop", message_id)
893
+ self.calibration_state = "stopped"
894
+ self.is_active = False
895
+
896
+ async def stop_operation(self) -> None:
897
+ """Stop ESC calibration and disarm all motors."""
898
+ message_id = str(uuid.uuid4())[:8]
899
+ self.logger.info(f"[{message_id}] Emergency stop ESC calibration")
900
+
901
+ # Signal stop and set inactive
902
+ self.stop_event.set()
903
+ self.is_active = False
904
+
905
+ # Send emergency stop to all motors
906
+ try:
907
+ await self._send_throttle_command("stop", message_id)
908
+ except Exception as e:
909
+ self.logger.error(f"[{message_id}] Error stopping motors: {e}")
910
+
911
+ self.calibration_state = "stopped"
912
+
913
+ # Cancel and wait for task completion with timeout
914
+ if self.current_task and not self.current_task.done():
915
+ self.current_task.cancel()
916
+ try:
917
+ # Wait for task cancellation with timeout to avoid hanging
918
+ await self.current_task
919
+ except asyncio.CancelledError:
920
+ self.logger.info(f"[{message_id}] Task cancelled successfully")
921
+ except asyncio.TimeoutError:
922
+ self.logger.warning(f"[{message_id}] Task cancellation timed out - forcing cleanup")
923
+ except Exception as e:
924
+ self.logger.warning(f"[{message_id}] Error during task cancellation: {e}")
925
+
926
+ # Clear task reference and reset stop event for recovery
927
+ self.current_task = None
928
+ self.stop_event.clear()
929
+ self.logger.info(f"[{message_id}] ESC calibration stop complete - ready for recovery")
930
+
931
+ async def _calibration_loop(self):
932
+ """Main calibration loop that reacts to calibration state changes and maintains throttle commands."""
933
+ message_id = str(uuid.uuid4())[:8]
934
+ self.logger.info(f"[{message_id}] Starting ESC calibration loop")
935
+
936
+ last_state = None
937
+
938
+ try:
939
+ while self.is_active and not self.stop_event.is_set() and not self.is_timeout_expired():
940
+ current_state = self.calibration_state
941
+
942
+ # React to state changes
943
+ if current_state != last_state:
944
+ if current_state == "maximum":
945
+ self.logger.info(f"[{message_id}] State changed to MAXIMUM - sending maximum throttle")
946
+ self.logger.warning(f"[{message_id}] ⚠️ POWER UP THE DRONE NOW! ESCs are receiving maximum throttle signal.")
947
+ await self._send_throttle_command("maximum", message_id)
948
+
949
+ elif current_state == "minimum":
950
+ self.logger.info(f"[{message_id}] State changed to MINIMUM - sending minimum throttle")
951
+ await self._send_throttle_command("minimum", message_id)
952
+ self.logger.info(f"[{message_id}] ✅ Minimum throttle sent. ESC calibration should be complete.")
953
+
954
+ elif current_state == "configured":
955
+ self.logger.info(f"[{message_id}] State is CONFIGURED - waiting for throttle commands")
956
+
957
+ last_state = current_state
958
+
959
+ # Maintain current throttle state by sending periodic commands
960
+ if current_state == "maximum":
961
+ await self._send_throttle_command("maximum", message_id)
962
+ self.logger.debug(f"[{message_id}] Maintaining maximum throttle")
963
+
964
+ elif current_state == "minimum":
965
+ await self._send_throttle_command("minimum", message_id)
966
+ self.logger.debug(f"[{message_id}] Maintaining minimum throttle")
967
+
968
+ # Wait before next iteration (send commands every 0.5 seconds)
969
+ await asyncio.sleep(0.5)
970
+
971
+ # Timeout or stop reached
972
+ if self.is_timeout_expired():
973
+ self.logger.warning(f"[{message_id}] ESC calibration timeout reached")
974
+ else:
975
+ self.logger.info(f"[{message_id}] ESC calibration stopped normally")
976
+
977
+ except asyncio.CancelledError:
978
+ self.logger.info(f"[{message_id}] ESC calibration loop cancelled")
979
+ except Exception as e:
980
+ self.logger.error(f"[{message_id}] Error in ESC calibration loop: {e}")
981
+ finally:
982
+ # Always stop motors and reset state when exiting (timeout, cancel, or error)
983
+ try:
984
+ await self._send_throttle_command("stop", message_id)
985
+ except Exception as e:
986
+ self.logger.error(f"[{message_id}] Error stopping motors in cleanup: {e}")
987
+
988
+ # Reset all state to allow recovery
989
+ self.calibration_state = "stopped"
990
+ self.is_active = False
991
+ self.current_task = None
992
+ self.stop_event.clear()
993
+ self.logger.info(f"[{message_id}] ESC calibration loop cleanup complete - ready for recovery")
994
+
995
+
996
+ class ESCForceRunAllController(BaseTimeoutController):
997
+ """
998
+ Controller for running all motors with a common command value using MAV_CMD_ACTUATOR_TEST.
999
+ """
1000
+
1001
+ def __init__(self, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger, motor_count: int = 4):
1002
+ super().__init__(mavlink_proxy, logger)
1003
+ self.motor_count = motor_count
1004
+ self.current_command_value = 0.0
1005
+ self.MIN_RAW_COMMAND_EXPECTED = 1000.0
1006
+ self.MAX_RAW_COMMAND_EXPECTED = 2000.0
1007
+ self.min_raw_command = None
1008
+ self.max_raw_command = None
1009
+
1010
+ def get_operation_targets(self) -> List[int]:
1011
+ return list(range(1, self.motor_count + 1))
1012
+
1013
+ def _map_command_to_pwm_range(self, command_value: float, min_pwm: float = 1000.0, max_pwm: float = 2000.0) -> float:
1014
+ """Map command value from [min..max] to [0..1] for PWM range 1000-2000 as an example."""
1015
+ # Input: [min..max], Output: [0..1] for 1000-2000 PWM range
1016
+ return max(0.0, min(1.0, (command_value - min_pwm) / (max_pwm - min_pwm)))
1017
+
1018
+ async def _send_actuator_test_all_motors(self, motor_value: float, timeout: float) -> bool:
1019
+ """Send MAV_CMD_ACTUATOR_TEST command to all motors."""
1020
+ try:
1021
+ for motor_idx in range(1, self.motor_count + 1):
1022
+ # param5 = 110x where x is motor index
1023
+ param5 = float(1100 + motor_idx)
1024
+
1025
+ # Build the command message
1026
+ command_msg = self.mavlink_proxy.master.mav.command_long_encode(
1027
+ 1, # TODO: investigate best practice
1028
+ 1, # TODO: investigate best practice
1029
+ mavutil.mavlink.MAV_CMD_ACTUATOR_TEST,
1030
+ 0, # confirmation
1031
+ motor_value, # Motor value (0-1 or NaN)
1032
+ timeout, # Timeout in seconds
1033
+ 0, # Reserved
1034
+ 0, # Reserved
1035
+ param5, # Motor mapping (110x)
1036
+ 0, # Reserved
1037
+ 0 # Reserved
1038
+ )
1039
+
1040
+ # Send the command
1041
+ self.mavlink_proxy.send("mav", command_msg)
1042
+
1043
+ # Small delay between motor commands
1044
+ await asyncio.sleep(0.02) # 20ms delay
1045
+
1046
+ return True
1047
+
1048
+ except Exception as e:
1049
+ self.logger.error(f"Error sending actuator test commands: {e}")
1050
+ return False
1051
+
1052
+ async def _stop_all_motors(self) -> bool:
1053
+ """Stop all motors using NaN value."""
1054
+ try:
1055
+ # Use NaN to stop motors (0x7FC00000 for float32 NaN)
1056
+ nan_value = float('nan')
1057
+
1058
+ for motor_idx in range(1, self.motor_count + 1):
1059
+ param5 = float(1100 + motor_idx)
1060
+
1061
+ # Build the command message
1062
+ command_msg = self.mavlink_proxy.master.mav.command_long_encode(
1063
+ 1, # TODO: investigate best practice
1064
+ 1, # TODO: investigate best practice
1065
+ mavutil.mavlink.MAV_CMD_ACTUATOR_TEST,
1066
+ 0, # confirmation
1067
+ nan_value, # Motor value (0-1 or NaN)
1068
+ 0.0, # Timeout in seconds
1069
+ 0, # Reserved
1070
+ 0, # Reserved
1071
+ param5, # Motor mapping (110x)
1072
+ 0, # Reserved
1073
+ 0 # Reserved
1074
+ )
1075
+
1076
+ # Send the command
1077
+ self.mavlink_proxy.send("mav", command_msg)
1078
+
1079
+ self.logger.info(f"Motor {motor_idx} stopped")
1080
+
1081
+ await asyncio.sleep(0.02) # 20ms delay
1082
+
1083
+ return True
1084
+
1085
+ except Exception as e:
1086
+ self.logger.error(f"Error stopping motors: {e}")
1087
+ return False
1088
+
1089
+ async def start_operation(self, payload: ESCForceRunAllPayload) -> None:
1090
+ """Start running all motors with common command value."""
1091
+ if payload.force_cancel:
1092
+ self.logger.info("Force cancel requested, stopping all motors")
1093
+ await self.stop_operation()
1094
+ return
1095
+
1096
+ # Update command value and timeout
1097
+ self.current_command_value = payload.motors_common_command
1098
+ self.refresh_timeout(payload.safety_timeout_s)
1099
+
1100
+ # get min/max raw command values for mapping
1101
+ min_raw_result = await self.mavlink_proxy.get_param("PWM_AUX_MIN1")
1102
+ max_raw_result = await self.mavlink_proxy.get_param("PWM_AUX_MAX1")
1103
+
1104
+ if min_raw_result is None or max_raw_result is None:
1105
+ self.logger.error("Could not get PWM_AUX_MIN1 or PWM_AUX_MAX1, stopping operation")
1106
+ await self.stop_operation()
1107
+ return
1108
+
1109
+ if min_raw_result.get("value") is None or max_raw_result.get("value") is None:
1110
+ self.logger.error("PWM_AUX_MIN1 or PWM_AUX_MAX1 parameter has no value, stopping operation")
1111
+ await self.stop_operation()
1112
+ return
1113
+
1114
+ # assert min < max, min = 1000, max = 2000 as typical defaults
1115
+ self.min_raw_command = float(min_raw_result.get("value"))
1116
+ self.max_raw_command = float(max_raw_result.get("value"))
1117
+
1118
+ if self.min_raw_command >= self.max_raw_command:
1119
+ self.logger.error(f"Invalid PWM range: min {self.min_raw_command} >= max {self.max_raw_command}, stopping operation")
1120
+ await self.stop_operation()
1121
+ return
1122
+
1123
+ if self.min_raw_command < self.MIN_RAW_COMMAND_EXPECTED or self.max_raw_command > self.MAX_RAW_COMMAND_EXPECTED:
1124
+ self.logger.error(f"Unusual PWM range: min {self.min_raw_command}, max {self.max_raw_command}")
1125
+ await self.stop_operation()
1126
+ return
1127
+
1128
+ # check that all motors have the same min/max
1129
+ for motor_idx in range(2, self.motor_count + 1):
1130
+ min_param = f"PWM_AUX_MIN{motor_idx}"
1131
+ max_param = f"PWM_AUX_MAX{motor_idx}"
1132
+ min_result = await self.mavlink_proxy.get_param(min_param)
1133
+ max_result = await self.mavlink_proxy.get_param(max_param)
1134
+ if min_result is None or max_result is None:
1135
+ self.logger.error(f"Could not get {min_param} or {max_param}, stopping operation")
1136
+ await self.stop_operation()
1137
+ return
1138
+ if min_result.get("value") != self.min_raw_command or max_result.get("value") != self.max_raw_command:
1139
+ self.logger.error(f"Inconsistent PWM range on motor {motor_idx}: min {min_result.get('value')}, max {max_result.get('value')}, expected min {self.min_raw_command}, max {self.max_raw_command}. Stopping operation.")
1140
+ await self.stop_operation()
1141
+ return
1142
+
1143
+ if not self.is_active:
1144
+ self.logger.info(f"Starting force run all motors with value {self.current_command_value}")
1145
+ self.is_active = True
1146
+ self.stop_event.clear()
1147
+
1148
+ # Create the force run all task directly
1149
+ try:
1150
+ self.current_task = asyncio.create_task(self._force_run_loop())
1151
+ self.logger.info("Force run all loop task created successfully")
1152
+ except Exception as e:
1153
+ self.logger.error(f"Failed to create force run all loop task: {e}")
1154
+ # Reset state on failure
1155
+ self.is_active = False
1156
+ raise
1157
+ else:
1158
+ self.logger.debug(f"Force run all already active, updating value to {self.current_command_value}")
1159
+
1160
+ async def stop_operation(self) -> None:
1161
+ """Stop force run all and disarm all motors."""
1162
+ self.logger.info("Stopping force run all motors")
1163
+ self.stop_event.set()
1164
+ self.is_active = False
1165
+
1166
+ # Send explicit stop to all motors
1167
+ try:
1168
+ await self._stop_all_motors()
1169
+ except Exception as e:
1170
+ self.logger.error(f"Error stopping motors: {e}")
1171
+
1172
+ if self.current_task and not self.current_task.done():
1173
+ self.current_task.cancel()
1174
+ try:
1175
+ # Wait for task cancellation with timeout to avoid hanging
1176
+ await self.current_task
1177
+ except asyncio.CancelledError:
1178
+ self.logger.info("Force run all task cancelled successfully")
1179
+ except asyncio.TimeoutError:
1180
+ self.logger.warning("Force run all task cancellation timed out - forcing cleanup")
1181
+ except Exception as e:
1182
+ self.logger.warning(f"Error during force run all task cancellation: {e}")
1183
+
1184
+ # Clear task reference and reset for recovery
1185
+ self.current_task = None
1186
+ self.stop_event.clear()
1187
+ self.logger.info("Force run all stop complete - ready for recovery")
1188
+
1189
+ async def _force_run_loop(self):
1190
+ """Main force run loop for all motors."""
1191
+ try:
1192
+ while not self.stop_event.is_set() and self.is_active:
1193
+ # Check timeout
1194
+ if self.is_timeout_expired():
1195
+ self.logger.warning("Force run all timeout expired, stopping")
1196
+ break
1197
+
1198
+ # Map command value to [0..1] range
1199
+ motor_value = self._map_command_to_pwm_range(self.current_command_value, self.min_raw_command , self.max_raw_command)
1200
+
1201
+ # Send command to all motors with 1 second timeout
1202
+ success = await self._send_actuator_test_all_motors(motor_value, 1.0)
1203
+
1204
+ if not success:
1205
+ self.logger.error("Failed to send commands to motors, stopping operation")
1206
+ break
1207
+
1208
+ # Wait before next command (800ms to ensure commands don't timeout)
1209
+ await asyncio.sleep(0.8)
1210
+
1211
+ except asyncio.CancelledError:
1212
+ self.logger.info("Force run all task cancelled")
1213
+ except Exception as e:
1214
+ self.logger.error(f"Error in force run all loop: {e}")
1215
+ finally:
1216
+ # Ensure motors are stopped and state is reset for recovery
1217
+ try:
1218
+ await self._stop_all_motors()
1219
+ except Exception as e:
1220
+ self.logger.error(f"Error stopping motors in cleanup: {e}")
1221
+
1222
+ self.is_active = False
1223
+ self.current_task = None
1224
+ self.stop_event.clear()
1225
+ self.logger.info("Force run all cleanup complete - ready for recovery")
1226
+
1227
+
1228
+ class ESCForceRunSingleController(BaseTimeoutController):
1229
+ """
1230
+ Controller for running a single motor with a specific command value using MAV_CMD_ACTUATOR_TEST.
1231
+ """
1232
+
1233
+ def __init__(self, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger):
1234
+ super().__init__(mavlink_proxy, logger)
1235
+ self.target_motor = 1
1236
+ self.current_command_value = 0.0
1237
+ self.MIN_RAW_COMMAND_EXPECTED = 1000.0
1238
+ self.MAX_RAW_COMMAND_EXPECTED = 2000.0
1239
+ self.min_raw_command = None
1240
+ self.max_raw_command = None
1241
+
1242
+ def get_operation_targets(self) -> List[int]:
1243
+ return [self.target_motor]
1244
+
1245
+ def _map_command_to_pwm_range(self, command_value: float, min_pwm: float = 1000.0, max_pwm: float = 2000.0) -> float:
1246
+ """Map command value from [min..max] to [0..1] for PWM range 1000-2000 as an example."""
1247
+ # Input: [min..max], Output: [0..1] for 1000-2000 PWM range
1248
+ return max(0.0, min(1.0, (command_value - min_pwm) / (max_pwm - min_pwm)))
1249
+
1250
+ async def _send_actuator_test_single_motor(self, motor_value: float, timeout: float) -> bool:
1251
+ """Send MAV_CMD_ACTUATOR_TEST command to single motor."""
1252
+ try:
1253
+ # param5 = 110x where x is motor index
1254
+ param5 = float(1100 + self.target_motor)
1255
+
1256
+ # Build the command message
1257
+ command_msg = self.mavlink_proxy.master.mav.command_long_encode(
1258
+ 1, # TODO: investigate best practice
1259
+ 1, # TODO: investigate best practice
1260
+ mavutil.mavlink.MAV_CMD_ACTUATOR_TEST,
1261
+ 0, # confirmation
1262
+ motor_value, # Motor value (0-1 or NaN)
1263
+ timeout, # Timeout in seconds
1264
+ 0, # Reserved
1265
+ 0, # Reserved
1266
+ param5, # Motor mapping (110x)
1267
+ 0, # Reserved
1268
+ 0 # Reserved
1269
+ )
1270
+
1271
+ # Send the command
1272
+ self.mavlink_proxy.send("mav", command_msg)
1273
+
1274
+ self.logger.debug(f"Motor {self.target_motor}: command sent (value={motor_value}, timeout={timeout})")
1275
+
1276
+ return True
1277
+
1278
+ except Exception as e:
1279
+ self.logger.error(f"Error sending actuator test command to motor {self.target_motor}: {e}")
1280
+ return False
1281
+
1282
+ async def _stop_single_motor(self) -> bool:
1283
+ """Stop single motor using NaN value."""
1284
+ try:
1285
+ # Use NaN to stop motor
1286
+ nan_value = float('nan')
1287
+ param5 = float(1100 + self.target_motor)
1288
+
1289
+ # Build the command message
1290
+ command_msg = self.mavlink_proxy.master.mav.command_long_encode(
1291
+ 1, # TODO: investigate best practice
1292
+ 1, # TODO: investigate best practice
1293
+ mavutil.mavlink.MAV_CMD_ACTUATOR_TEST,
1294
+ 0, # confirmation
1295
+ nan_value, # Motor value (0-1 or NaN)
1296
+ 0.0, # Timeout in seconds
1297
+ 0, # Reserved
1298
+ 0, # Reserved
1299
+ param5, # Motor mapping (110x)
1300
+ 0, # Reserved
1301
+ 0 # Reserved
1302
+ )
1303
+
1304
+ # Send the command
1305
+ self.mavlink_proxy.send("mav", command_msg)
1306
+
1307
+ self.logger.info(f"Motor {self.target_motor} stopped")
1308
+
1309
+ return True
1310
+
1311
+ except Exception as e:
1312
+ self.logger.error(f"Error stopping motor {self.target_motor}: {e}")
1313
+ return False
1314
+
1315
+ async def start_operation(self, payload: ESCForceRunSinglePayload) -> None:
1316
+ """Start running single motor with specific command value."""
1317
+ if payload.force_cancel:
1318
+ self.logger.info(f"Force cancel requested for motor {payload.motor_idx}, stopping")
1319
+ await self.stop_operation()
1320
+ return
1321
+
1322
+ # Update motor, command value and timeout
1323
+ self.target_motor = payload.motor_idx
1324
+ self.current_command_value = payload.motor_command
1325
+ self.refresh_timeout(payload.safety_timeout_s)
1326
+
1327
+ # get min/max raw command values for mapping
1328
+ min_raw_result = await self.mavlink_proxy.get_param(f"PWM_AUX_MIN{self.target_motor}")
1329
+ max_raw_result = await self.mavlink_proxy.get_param(f"PWM_AUX_MAX{self.target_motor}")
1330
+
1331
+ if min_raw_result is None or max_raw_result is None:
1332
+ self.logger.error(f"Could not get PWM_AUX_MIN{self.target_motor} or PWM_AUX_MAX{self.target_motor}, stopping operation")
1333
+ await self.stop_operation()
1334
+ return
1335
+
1336
+ if min_raw_result.get("value") is None or max_raw_result.get("value") is None:
1337
+ self.logger.error(f"PWM_AUX_MIN{self.target_motor} or PWM_AUX_MAX{self.target_motor} parameter has no value, stopping operation")
1338
+ await self.stop_operation()
1339
+ return
1340
+
1341
+ # assert min < max, min = 1000, max = 2000 as typical defaults
1342
+ self.min_raw_command = float(min_raw_result.get("value"))
1343
+ self.max_raw_command = float(max_raw_result.get("value"))
1344
+
1345
+ if self.min_raw_command >= self.max_raw_command:
1346
+ self.logger.error(f"Invalid PWM range: min {self.min_raw_command} >= max {self.max_raw_command}, stopping operation")
1347
+ await self.stop_operation()
1348
+ return
1349
+
1350
+ if self.min_raw_command < self.MIN_RAW_COMMAND_EXPECTED or self.max_raw_command > self.MAX_RAW_COMMAND_EXPECTED:
1351
+ self.logger.error(f"Unusual PWM range: min {self.min_raw_command}, max {self.max_raw_command}")
1352
+ await self.stop_operation()
1353
+ return
1354
+
1355
+ if not self.is_active:
1356
+ self.logger.info(f"Starting force run motor {self.target_motor} with value {self.current_command_value}")
1357
+ self.is_active = True
1358
+ self.stop_event.clear()
1359
+
1360
+ # Create the force run single task directly
1361
+ try:
1362
+ self.current_task = asyncio.create_task(self._force_run_single_loop())
1363
+ self.logger.info(f"Force run single loop task created successfully for motor {self.target_motor}")
1364
+ except Exception as e:
1365
+ self.logger.error(f"Failed to create force run single loop task: {e}")
1366
+ # Reset state on failure
1367
+ self.is_active = False
1368
+ raise
1369
+ else:
1370
+ self.logger.debug(f"Force run single already active, updating motor {self.target_motor} value to {self.current_command_value}")
1371
+
1372
+ async def stop_operation(self) -> None:
1373
+ """Stop force run single and disarm motor."""
1374
+ self.logger.info(f"Stopping force run motor {self.target_motor}")
1375
+ self.stop_event.set()
1376
+ self.is_active = False
1377
+
1378
+ # Send explicit stop to target motor
1379
+ try:
1380
+ await self._stop_single_motor()
1381
+ except Exception as e:
1382
+ self.logger.error(f"Error stopping motor: {e}")
1383
+
1384
+ if self.current_task and not self.current_task.done():
1385
+ self.current_task.cancel()
1386
+ try:
1387
+ # Wait for task cancellation with timeout to avoid hanging
1388
+ await self.current_task
1389
+ except asyncio.CancelledError:
1390
+ self.logger.info(f"Force run motor {self.target_motor} task cancelled successfully")
1391
+ except asyncio.TimeoutError:
1392
+ self.logger.warning(f"Force run motor {self.target_motor} task cancellation timed out - forcing cleanup")
1393
+ except Exception as e:
1394
+ self.logger.warning(f"Error during force run single task cancellation: {e}")
1395
+
1396
+ # Clear task reference and reset for recovery
1397
+ self.current_task = None
1398
+ self.stop_event.clear()
1399
+ self.logger.info(f"Force run motor {self.target_motor} stop complete - ready for recovery")
1400
+
1401
+ async def _force_run_single_loop(self):
1402
+ """Main force run loop for single motor."""
1403
+ try:
1404
+ while not self.stop_event.is_set() and self.is_active:
1405
+ # Check timeout
1406
+ if self.is_timeout_expired():
1407
+ self.logger.warning(f"Force run motor {self.target_motor} timeout expired, stopping")
1408
+ break
1409
+
1410
+ # Map command value to [0..1] range
1411
+ motor_value = self._map_command_to_pwm_range(self.current_command_value, self.min_raw_command , self.max_raw_command)
1412
+
1413
+ # Send command to target motor with 1 second timeout
1414
+ success = await self._send_actuator_test_single_motor(motor_value, 1.0)
1415
+
1416
+ if not success:
1417
+ self.logger.error(f"Failed to send command to motor {self.target_motor}, stopping operation")
1418
+ break
1419
+
1420
+ # Wait before next command (800ms to ensure commands don't timeout)
1421
+ await asyncio.sleep(0.8)
1422
+
1423
+ except asyncio.CancelledError:
1424
+ self.logger.info(f"Force run motor {self.target_motor} task cancelled")
1425
+ except Exception as e:
1426
+ self.logger.error(f"Error in force run single loop: {e}")
1427
+ finally:
1428
+ # Ensure motor is stopped and state is reset for recovery
1429
+ try:
1430
+ await self._stop_single_motor()
1431
+ except Exception as e:
1432
+ self.logger.error(f"Error stopping motor in cleanup: {e}")
1433
+
1434
+ self.is_active = False
1435
+ self.current_task = None
1436
+ self.stop_event.clear()
1437
+ self.logger.info(f"Force run motor {self.target_motor} cleanup complete - ready for recovery")
1438
+
1439
+
1440
+ class BasePubSubController(ABC):
1441
+ """
1442
+ Base class for publish/subscribe controllers that handle data streaming.
1443
+
1444
+ Controllers implementing this pattern:
1445
+ 1. Subscribe to petal-user-journey-coordinator/subscribe_<stream_name>
1446
+ 2. Publish to petal-user-journey-coordinator/publish_<stream_name> at specified rate
1447
+ 3. Stop publishing when receiving petal-user-journey-coordinator/unsubscribe_<stream_name>
1448
+ """
1449
+
1450
+ def __init__(self, stream_name: str, mqtt_proxy: MQTTProxy, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger):
1451
+ self.stream_name = stream_name
1452
+ self.mqtt_proxy = mqtt_proxy
1453
+ self.mavlink_proxy = mavlink_proxy
1454
+ self.logger = logger
1455
+ self.is_active = False
1456
+ self.current_task: Optional[asyncio.Task] = None
1457
+ self.stop_event = threading.Event()
1458
+ self.publish_rate_hz = 1.0
1459
+ self.stream_id = ""
1460
+ self.lock = threading.Lock()
1461
+ self._mavlink_stop_handler = None
1462
+
1463
+ @abstractmethod
1464
+ def _setup_mavlink_handler(self) -> Callable[[], None]:
1465
+ """
1466
+ Set up MAVLink message handler for the data source.
1467
+ Returns a stop function to unregister the handler.
1468
+ """
1469
+ pass
1470
+
1471
+ @abstractmethod
1472
+ def _get_sample_data(self) -> Dict[str, Any]:
1473
+ """
1474
+ Get the current sample data to publish.
1475
+ This should return the latest data received from MAVLink.
1476
+ """
1477
+ pass
1478
+
1479
+ async def start_streaming(self, stream_id: str, rate_hz: float) -> None:
1480
+ """Start streaming data at the specified rate."""
1481
+ if self.is_active:
1482
+ self.logger.info(f"Stream {self.stream_name} already active, updating rate to {rate_hz} Hz")
1483
+ with self.lock:
1484
+ self.publish_rate_hz = rate_hz
1485
+ return
1486
+
1487
+ with self.lock:
1488
+ self.stream_id = stream_id
1489
+ self.publish_rate_hz = rate_hz
1490
+ self.is_active = True
1491
+ self.stop_event.clear()
1492
+
1493
+ # Set up MAVLink handler
1494
+ self._mavlink_stop_handler = self._setup_mavlink_handler()
1495
+
1496
+ # Create the publishing task directly
1497
+ try:
1498
+ self.current_task = asyncio.create_task(self._publishing_loop())
1499
+ self.logger.info(f"Started streaming {self.stream_name} at {rate_hz} Hz - publishing loop task created successfully")
1500
+ except Exception as e:
1501
+ self.logger.error(f"Failed to create publishing loop task for {self.stream_name}: {e}")
1502
+ # Reset state on failure
1503
+ self.is_active = False
1504
+ raise
1505
+
1506
+ async def stop_streaming(self) -> None:
1507
+ """Stop streaming data."""
1508
+ if not self.is_active:
1509
+ return
1510
+
1511
+ self.logger.info(f"Stopping stream {self.stream_name}")
1512
+ with self.lock:
1513
+ self.stop_event.set()
1514
+ self.is_active = False
1515
+
1516
+ # Stop MAVLink handler
1517
+ if self._mavlink_stop_handler:
1518
+ self._mavlink_stop_handler()
1519
+ self._mavlink_stop_handler = None
1520
+
1521
+ # Cancel publishing task
1522
+ if self.current_task and not self.current_task.done():
1523
+ self.current_task.cancel()
1524
+ try:
1525
+ await self.current_task
1526
+ except asyncio.CancelledError:
1527
+ pass
1528
+
1529
+ async def _publishing_loop(self):
1530
+ """Main publishing loop that sends data at the specified rate."""
1531
+ interval = 1.0 / self.publish_rate_hz
1532
+
1533
+ try:
1534
+ while not self.stop_event.is_set() and self.is_active:
1535
+ # Get current data
1536
+ sample_data = self._get_sample_data()
1537
+
1538
+ if sample_data:
1539
+ # Create publish payload
1540
+ publish_payload = PublishPayload(
1541
+ published_stream_id=self.stream_id,
1542
+ stream_payload=sample_data
1543
+ )
1544
+
1545
+ mqtt_message = {
1546
+ "messageId": str(uuid.uuid4()),
1547
+ "command": f"/petal-user-journey-coordinator/publish_{self.stream_name}",
1548
+ "timestamp": datetime.now().isoformat(),
1549
+ "payload": publish_payload.model_dump()
1550
+ }
1551
+
1552
+ # Send to MQTT
1553
+ await self.mqtt_proxy.publish_message(
1554
+ payload=mqtt_message
1555
+ )
1556
+
1557
+ # Wait for next publish interval
1558
+ await asyncio.sleep(interval)
1559
+
1560
+ except asyncio.CancelledError:
1561
+ self.logger.info(f"Publishing loop for {self.stream_name} cancelled")
1562
+ except Exception as e:
1563
+ self.logger.error(f"Error in publishing loop for {self.stream_name}: {e}")
1564
+ finally:
1565
+ self.is_active = False
1566
+
1567
+
1568
+ class RCChannelsController(BasePubSubController):
1569
+ """Controller for streaming RC_CHANNELS MAVLink data."""
1570
+
1571
+ def __init__(self, mqtt_proxy: MQTTProxy, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger):
1572
+ super().__init__("rc_value_stream", mqtt_proxy, mavlink_proxy, logger)
1573
+ self._latest_sample = None
1574
+ self._sample_lock = threading.Lock()
1575
+
1576
+ def _setup_mavlink_handler(self) -> Callable[[], None]:
1577
+ """Set up RC_CHANNELS message handler."""
1578
+
1579
+ # TESTING: dummy values with time
1580
+ value_min = 1000
1581
+ value_max = 2000
1582
+ t_start = time.time()
1583
+
1584
+ def _handler(pkt):
1585
+ with self._sample_lock:
1586
+ self._latest_sample = {
1587
+ "time_boot_ms": pkt.time_boot_ms,
1588
+ "chancount": pkt.chancount,
1589
+ **{f"chan{i}_raw": getattr(pkt, f"chan{i}_raw") for i in range(1, 19)},
1590
+ "rssi": pkt.rssi,
1591
+ }
1592
+
1593
+ # # TESTING: dummy values with time
1594
+ # self._latest_sample = {
1595
+ # "time_boot_ms": 0,
1596
+ # "chancount": 0,
1597
+ # # send sinusoidal signal between 1000 and 2000 with period of 1 second for each channel
1598
+ # **{f"chan{i}_raw": int(1000 + 500 * math.sin(2 * math.pi * (time.time() - t_start) / 1)) for i in range(1, 19)},
1599
+ # "rssi": 0,
1600
+ # }
1601
+
1602
+ # Register handler with deduplication
1603
+ # TODO: use MAVLINK_MSG_ID_RC_CHANNELS
1604
+ self.mavlink_proxy.register_handler(str(mavlink_dialect.MAVLINK_MSG_ID_RC_CHANNELS), _handler, duplicate_filter_interval=0.02)
1605
+
1606
+ def stop():
1607
+ self.mavlink_proxy.unregister_handler(str(mavlink_dialect.MAVLINK_MSG_ID_RC_CHANNELS), _handler)
1608
+
1609
+ return stop
1610
+
1611
+ def _get_sample_data(self) -> Dict[str, Any]:
1612
+ """Get the latest RC channels data."""
1613
+ with self._sample_lock:
1614
+ return self._latest_sample.copy() if self._latest_sample else {}
1615
+
1616
+
1617
+ class PositionChannelsController(BasePubSubController):
1618
+ """Controller for streaming real-time pose data from MAVLink LOCAL_POSITION_NED and ATTITUDE messages."""
1619
+
1620
+ def __init__(
1621
+ self,
1622
+ mqtt_proxy: MQTTProxy,
1623
+ mavlink_proxy: MavLinkExternalProxy,
1624
+ logger: logging.Logger,
1625
+ rectangle_a: float,
1626
+ rectangle_b: float,
1627
+ points_per_edge: int,
1628
+ corner_exclusion_radius: float,
1629
+ max_matching_distance: float,
1630
+ corner_points: List[Dict[str, float]],
1631
+ reference_trajectory: List[Dict[str, float]]
1632
+ ):
1633
+ super().__init__("real_time_pose", mqtt_proxy, mavlink_proxy, logger)
1634
+ self._latest_position = None
1635
+ self._latest_attitude = None
1636
+ self._sample_lock = threading.Lock()
1637
+
1638
+ self.rectangle_a = rectangle_a
1639
+ self.rectangle_b = rectangle_b
1640
+ self.points_per_edge = points_per_edge
1641
+ self.corner_exclusion_radius = corner_exclusion_radius
1642
+ self.max_matching_distance = max_matching_distance
1643
+ self.corner_points = corner_points
1644
+ self.reference_trajectory = reference_trajectory
1645
+
1646
+ self.ref_x = 0.0
1647
+ self.ref_y = 0.0
1648
+
1649
+ self.acquired_ref = False
1650
+
1651
+ def _setup_mavlink_handler(self) -> Callable[[], None]:
1652
+ """Set up LOCAL_POSITION_NED and ATTITUDE message handlers."""
1653
+
1654
+ def _position_handler(pkt):
1655
+ with self._sample_lock:
1656
+ # Convert NED to ENU: x(E) = y(N), y(N) = x(E), z(U) = -z(D)
1657
+
1658
+ # check that pkt.x, pkt.y, pkt.z are not None, set them to zero
1659
+ if pkt.x is None or pkt.y is None or pkt.z is None:
1660
+ x = 0.0
1661
+ y = 0.0
1662
+ z = 0.0
1663
+ valid_position = False
1664
+ else:
1665
+ x = pkt.x - self.ref_x
1666
+ y = pkt.y - self.ref_y
1667
+ z = pkt.z
1668
+ valid_position = True
1669
+
1670
+ if not self.acquired_ref:
1671
+ self.ref_x = pkt.x
1672
+ self.ref_y = pkt.y
1673
+ self.acquired_ref = True
1674
+ self.logger.info(f"Acquired reference position: ref_x={self.ref_x}, ref_y={self.ref_y}")
1675
+
1676
+
1677
+ self._latest_position = {
1678
+ "time_boot_ms": pkt.time_boot_ms,
1679
+ "x": y, # ENU x = NED y (East)
1680
+ "y": x, # ENU y = NED x (North)
1681
+ "z": -z, # ENU z = -NED z (Up = -Down)
1682
+ "vx": pkt.vy, # ENU vx = NED vy
1683
+ "vy": pkt.vx, # ENU vy = NED vx
1684
+ "vz": -pkt.vz, # ENU vz = -NED vz
1685
+ "valid_position": valid_position
1686
+ }
1687
+
1688
+ def _attitude_handler(pkt):
1689
+ with self._sample_lock:
1690
+ # TODO: convert to roll_deg and x_m
1691
+ # check that pkt.roll, pkt.pitch, pkt.yaw are not None set them to zero
1692
+ if pkt.roll is None or pkt.pitch is None or pkt.yaw is None:
1693
+ roll = 0.0
1694
+ pitch = 0.0
1695
+ yaw = 0.0
1696
+ valid_altitude = False
1697
+ else:
1698
+ roll = pkt.roll
1699
+ pitch = pkt.pitch
1700
+ yaw = pkt.yaw
1701
+ valid_altitude = True
1702
+
1703
+ self._latest_attitude = {
1704
+ "time_boot_ms": pkt.time_boot_ms,
1705
+ "roll": math.degrees(roll),
1706
+ "pitch": math.degrees(pitch),
1707
+ "yaw": math.degrees(yaw),
1708
+ "rollspeed": pkt.rollspeed,
1709
+ "pitchspeed": pkt.pitchspeed,
1710
+ "yawspeed": pkt.yawspeed,
1711
+ "valid_altitude": valid_altitude
1712
+ }
1713
+
1714
+ # Register handlers with deduplication
1715
+ self.mavlink_proxy.register_handler(str(mavlink_dialect.MAVLINK_MSG_ID_LOCAL_POSITION_NED), _position_handler, duplicate_filter_interval=0.02)
1716
+ self.mavlink_proxy.register_handler(str(mavlink_dialect.MAVLINK_MSG_ID_ATTITUDE), _attitude_handler, duplicate_filter_interval=0.02)
1717
+
1718
+ def stop():
1719
+ self.mavlink_proxy.unregister_handler(str(mavlink_dialect.MAVLINK_MSG_ID_LOCAL_POSITION_NED), _position_handler)
1720
+ self.mavlink_proxy.unregister_handler(str(mavlink_dialect.MAVLINK_MSG_ID_ATTITUDE), _attitude_handler)
1721
+
1722
+ return stop
1723
+
1724
+ def _get_sample_data(self) -> Dict[str, Any]:
1725
+ """Get the latest combined pose data (position in ENU + attitude)."""
1726
+ with self._sample_lock:
1727
+ if self._latest_position is None and self._latest_attitude is None:
1728
+ return {}
1729
+
1730
+ # Combine position and attitude data
1731
+ sample_data = {}
1732
+
1733
+ if self._latest_position is not None:
1734
+ sample_data.update({
1735
+ "x": self._latest_position.get("x", 0.0),
1736
+ "y": self._latest_position.get("y", 0.0),
1737
+ "z": self._latest_position.get("z", 0.0),
1738
+ "position_valid": self._latest_position.get("valid_position", False),
1739
+ })
1740
+ else:
1741
+ sample_data.update({
1742
+ "x": 0.0,
1743
+ "y": 0.0,
1744
+ "z": 0.0,
1745
+ "position_valid": False,
1746
+ })
1747
+
1748
+ if self._latest_attitude is not None:
1749
+ sample_data.update({
1750
+ "roll": self._latest_attitude.get("roll", 0.0),
1751
+ "pitch": self._latest_attitude.get("pitch", 0.0),
1752
+ "yaw": self._latest_attitude.get("yaw", 0.0),
1753
+ "altitude_valid": self._latest_attitude.get("valid_altitude", False),
1754
+ })
1755
+ else:
1756
+ sample_data.update({
1757
+ "roll": 0.0,
1758
+ "pitch": 0.0,
1759
+ "yaw": 0.0,
1760
+ "altitude_valid": False,
1761
+ })
1762
+
1763
+ if self._latest_position is not None and self._latest_attitude is not None:
1764
+ # Calculate reference trajectory error
1765
+ # Use the static helper method from TrajectoryVerificationController
1766
+ error_result = TrajectoryVerificationController.calculate_single_point_error(
1767
+ actual_point={
1768
+ "x": self._latest_position["x"],
1769
+ "y": self._latest_position["y"],
1770
+ "yaw": self._latest_attitude["yaw"]
1771
+ },
1772
+ reference_trajectory=self.reference_trajectory,
1773
+ corner_points=self.corner_points,
1774
+ corner_exclusion_radius=self.corner_exclusion_radius,
1775
+ max_matching_distance=self.max_matching_distance,
1776
+ debug_yaw=True
1777
+ )
1778
+
1779
+ closest_ref_point = error_result.get("closest_ref_point", {})
1780
+ if closest_ref_point:
1781
+ closest_ref_x = closest_ref_point.get("x", 0.0)
1782
+ closest_ref_y = closest_ref_point.get("y", 0.0)
1783
+ closest_ref_yaw = closest_ref_point.get("yaw", 0.0)
1784
+ position_error = error_result.get("position_error", 0.0)
1785
+ yaw_error = error_result.get("yaw_error", 0.0)
1786
+ else:
1787
+ closest_ref_x = 0.0
1788
+ closest_ref_y = 0.0
1789
+ closest_ref_yaw = 0.0
1790
+ position_error = 0.0
1791
+ yaw_error = 0.0
1792
+
1793
+ if position_error is None:
1794
+ position_error = 0.0
1795
+ if yaw_error is None:
1796
+ yaw_error = 0.0
1797
+
1798
+ sample_data.update({
1799
+ "closest_ref_x": closest_ref_x,
1800
+ "closest_ref_y": closest_ref_y,
1801
+ "closest_ref_yaw": closest_ref_yaw,
1802
+ "position_error": position_error,
1803
+ "yaw_error": yaw_error
1804
+ })
1805
+
1806
+ else:
1807
+ sample_data.update({
1808
+ "closest_ref_x": 0.0,
1809
+ "closest_ref_y": 0.0,
1810
+ "closest_ref_yaw": 0.0,
1811
+ "position_error": 0.0,
1812
+ "yaw_error": 0.0
1813
+ })
1814
+
1815
+ return sample_data
1816
+
1817
+ def reset_reference_position(self):
1818
+ """Reset the reference position to current position on next update."""
1819
+ with self._sample_lock:
1820
+ self.acquired_ref = False
1821
+ self.logger.info("Reference position reset requested")
1822
+
1823
+ def _norm_statustext(pkt) -> str:
1824
+ """
1825
+ Robustly extract STATUSTEXT text as a clean Python str.
1826
+ Works for PX4 v1.4.x where 'text' may come padded / bytes.
1827
+ """
1828
+ txt = getattr(pkt, "text", b"")
1829
+ if isinstance(txt, (bytes, bytearray)):
1830
+ txt = txt.decode("utf-8", "ignore")
1831
+ return txt.strip("\x00").strip()
1832
+
1833
+
1834
+ def _classify_kill_transition(text: str) -> Optional[bool]:
1835
+ """
1836
+ Classify a STATUSTEXT message to determine if it indicates kill switch engaged/disengaged.
1837
+ Enhanced patterns for specific PX4 kill switch messages.
1838
+
1839
+ Returns:
1840
+ True if kill switch is engaged (motors stopped)
1841
+ False if kill switch is disengaged (motors enabled)
1842
+ None if message is not kill switch related
1843
+ """
1844
+ t = text.lower()
1845
+
1846
+ # quick exit if not obviously relevant
1847
+ if "kill" not in t and "lockdown" not in t and "emergency" not in t:
1848
+ return None
1849
+
1850
+ # PX4 specific kill switch patterns - engaged (kill active)
1851
+ kill_engaged_patterns = [
1852
+ "kill-switch is engaged",
1853
+ "kill switch engaged",
1854
+ "emergency kill active",
1855
+ "motors stopped by kill switch",
1856
+ "kill switch activated",
1857
+ "safety kill engaged",
1858
+ "kill-switch engaged",
1859
+ "emergency stop active",
1860
+ "kill: on",
1861
+ "kill on",
1862
+ "kill activated",
1863
+ "kill enabled",
1864
+ "lockdown enabled",
1865
+ "lockdown on",
1866
+ "emergency kill: on"
1867
+ ]
1868
+
1869
+ # PX4 specific kill switch patterns - disengaged (kill inactive)
1870
+ kill_disengaged_patterns = [
1871
+ "kill-switch is disengaged",
1872
+ "kill switch disengaged",
1873
+ "emergency kill inactive",
1874
+ "kill switch deactivated",
1875
+ "safety kill disengaged",
1876
+ "kill-switch disengaged",
1877
+ "emergency stop inactive",
1878
+ "motors enabled",
1879
+ "kill: off",
1880
+ "kill off",
1881
+ "kill deactivated",
1882
+ "kill disabled",
1883
+ "lockdown disabled",
1884
+ "lockdown off",
1885
+ "emergency kill: off",
1886
+ "released kill",
1887
+ "kill released"
1888
+ ]
1889
+
1890
+ # Check for engaged patterns
1891
+ for pattern in kill_engaged_patterns:
1892
+ if pattern in t:
1893
+ return True
1894
+
1895
+ # Check for disengaged patterns
1896
+ for pattern in kill_disengaged_patterns:
1897
+ if pattern in t:
1898
+ return False
1899
+
1900
+ # Additional context-based detection for generic messages
1901
+ if "kill" in t:
1902
+ if any(word in t for word in ["engag", "activat", "enable"]):
1903
+ return True
1904
+ if any(word in t for word in ["disengag", "deactivat", "disable", "off", "release"]):
1905
+ return False
1906
+
1907
+ if "lockdown" in t:
1908
+ if any(word in t for word in ["enable", "on"]):
1909
+ return True
1910
+ if any(word in t for word in ["disable", "off"]):
1911
+ return False
1912
+
1913
+ return None
1914
+
1915
+
1916
+ class KillSwitchController(BasePubSubController):
1917
+ """Controller for streaming kill switch status from PX4 STATUSTEXT messages with automatic parameter configuration."""
1918
+
1919
+ def __init__(self, mqtt_proxy: MQTTProxy, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger):
1920
+ super().__init__("ks_status_stream", mqtt_proxy, mavlink_proxy, logger)
1921
+
1922
+ # Kill switch state tracking
1923
+ self.is_killed: Optional[bool] = None # None = unknown
1924
+ self.last_text: Optional[str] = None
1925
+ self.last_change_monotonic: Optional[float] = None
1926
+ self.last_severity: Optional[int] = None
1927
+ self.setup_complete = False
1928
+
1929
+ async def start_streaming(self, stream_id: str, rate_hz: float) -> None:
1930
+ """Start streaming data at the specified rate with automatic kill switch parameter configuration."""
1931
+ with self.lock:
1932
+ if self.is_active:
1933
+ self.logger.info(f"Stream {self.stream_name} already active, updating rate to {rate_hz} Hz")
1934
+ self.publish_rate_hz = rate_hz
1935
+ return
1936
+
1937
+ # Configure kill switch parameters before starting streaming
1938
+ if not self.setup_complete:
1939
+ await self._configure_kill_switch_parameters()
1940
+
1941
+ # Call parent implementation to start streaming
1942
+ await super().start_streaming(stream_id, rate_hz)
1943
+
1944
+ async def _configure_kill_switch_parameters(self):
1945
+ """Configure kill switch parameters using KillSwitchConfigHandler."""
1946
+ try:
1947
+ self.logger.info("Configuring kill switch parameters...")
1948
+
1949
+ # Create parameter handler for configuration
1950
+ config_handler = KillSwitchConfigHandler(self.mavlink_proxy, self.logger)
1951
+
1952
+ # Configure parameters
1953
+ result = await config_handler.process_payload({}, "ks_auto_config")
1954
+
1955
+ if result["status"] == "success":
1956
+ self.logger.info("Kill switch parameters configured successfully")
1957
+ self.setup_complete = True
1958
+ else:
1959
+ self.logger.warning(f"Kill switch parameter setup incomplete: {result}")
1960
+ except Exception as e:
1961
+ self.logger.error(f"Failed to configure kill switch parameters: {e}")
1962
+ # Continue with streaming even if parameter setup fails
1963
+
1964
+ def _setup_mavlink_handler(self) -> Callable[[], None]:
1965
+ """Set up MAVLink STATUSTEXT handler for kill switch monitoring."""
1966
+
1967
+ def _statustext_handler(pkt) -> None:
1968
+ """Handle STATUSTEXT messages and extract kill switch state."""
1969
+ txt = _norm_statustext(pkt)
1970
+ decision = _classify_kill_transition(txt)
1971
+ self.logger.info(f"STATUSTEXT received: '{txt}' -> kill switch state: {decision}")
1972
+
1973
+ if decision is None:
1974
+ return # Not a kill switch related message
1975
+
1976
+ # Update state if it changed
1977
+ if self.is_killed is None or decision != self.is_killed:
1978
+ self.is_killed = decision
1979
+ self.last_text = txt
1980
+ self.last_change_monotonic = time.monotonic()
1981
+ self.last_severity = getattr(pkt, "severity", None)
1982
+
1983
+ self.logger.info(f"Kill switch state changed: {'ENGAGED' if self.is_killed else 'DISENGAGED'} - {txt}")
1984
+
1985
+ # Register handler for STATUSTEXT messages
1986
+ self.mavlink_proxy.register_handler(str(mavlink_dialect.MAVLINK_MSG_ID_STATUSTEXT), _statustext_handler)
1987
+
1988
+ # Return stop function
1989
+ def stop_handler():
1990
+ self.mavlink_proxy.unregister_handler(str(mavlink_dialect.MAVLINK_MSG_ID_STATUSTEXT), _statustext_handler)
1991
+
1992
+ return stop_handler
1993
+
1994
+ def _get_sample_data(self) -> Dict[str, Any]:
1995
+ """Get current kill switch status data."""
1996
+ return {
1997
+ "timestamp": datetime.now().isoformat(),
1998
+ "is_killed": self.is_killed,
1999
+ "status": "engaged" if self.is_killed else "disengaged" if self.is_killed is False else "unknown",
2000
+ "last_text": self.last_text,
2001
+ "last_change_time": self.last_change_monotonic,
2002
+ "severity": self.last_severity,
2003
+ "setup_complete": self.setup_complete
2004
+ }
2005
+
2006
+
2007
+ class MultiFunctionalSwitchAController(BasePubSubController):
2008
+ """Controller for streaming Multi-functional Switch A data from MAVLink messages."""
2009
+
2010
+ def __init__(self, mqtt_proxy: MQTTProxy, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger):
2011
+ super().__init__("mfs_a_status_stream", mqtt_proxy, mavlink_proxy, logger)
2012
+
2013
+ # Multi-functional Switch A state tracking
2014
+ self.latest_mfs_a_data: Optional[Dict[str, Any]] = None
2015
+
2016
+ def _setup_mavlink_handler(self) -> Callable[[], None]:
2017
+ """
2018
+ Set up MAVLink handler for Multi-functional Switch A data.
2019
+
2020
+ Note: This method should be implemented when the specific MAVLink message
2021
+ type for MFS A is identified. For now, it returns a placeholder.
2022
+ """
2023
+ def _mfs_a_handler(pkt) -> None:
2024
+ """Handle MAVLink messages for Multi-functional Switch A."""
2025
+ # TODO: Implement specific MAVLink message handling for MFS A
2026
+ # This will depend on the actual MAVLink message type used for MFS A
2027
+ # Example structure:
2028
+ # self.latest_mfs_a_data = {
2029
+ # "timestamp": datetime.now().isoformat(),
2030
+ # "switch_value": getattr(pkt, "switch_value", 0),
2031
+ # "raw_data": getattr(pkt, "raw_data", None),
2032
+ # # Add other relevant fields based on actual MAVLink message
2033
+ # }
2034
+ self.logger.debug("MFS A MAVLink handler called (placeholder implementation)")
2035
+
2036
+ # TODO: Register handler for the specific MAVLink message type for MFS A
2037
+ # Example: self.mavlink_proxy.register_handler("MFS_A_MESSAGE_TYPE", _mfs_a_handler)
2038
+ # For now, return a no-op stop function
2039
+ def stop_handler():
2040
+ # TODO: Unregister the actual handler when implemented
2041
+ # self.mavlink_proxy.unregister_handler("MFS_A_MESSAGE_TYPE", _mfs_a_handler)
2042
+ pass
2043
+
2044
+ return stop_handler
2045
+
2046
+ def _get_sample_data(self) -> Dict[str, Any]:
2047
+ """Get current Multi-functional Switch A data."""
2048
+ if self.latest_mfs_a_data:
2049
+ return self.latest_mfs_a_data
2050
+ else:
2051
+ # Return default/placeholder data when no MAVLink data is available
2052
+ return {
2053
+ "timestamp": datetime.now().isoformat(),
2054
+ "subscribed_stream_id": "px4_mfs_a_raw",
2055
+ "switch_value": 0,
2056
+ "status": "no_data",
2057
+ "message": "Waiting for MAVLink data"
2058
+ }
2059
+
2060
+
2061
+ class MultiFunctionalSwitchBController(BasePubSubController):
2062
+ """Controller for streaming Multi-functional Switch B data from MAVLink messages."""
2063
+
2064
+ def __init__(self, mqtt_proxy: MQTTProxy, mavlink_proxy: MavLinkExternalProxy, logger: logging.Logger):
2065
+ super().__init__("mfs_b_status_stream", mqtt_proxy, mavlink_proxy, logger)
2066
+
2067
+ # Multi-functional Switch B state tracking
2068
+ self.latest_mfs_b_data: Optional[Dict[str, Any]] = None
2069
+
2070
+ def _setup_mavlink_handler(self) -> Callable[[], None]:
2071
+ """
2072
+ Set up MAVLink handler for Multi-functional Switch B data.
2073
+
2074
+ Note: This method should be implemented when the specific MAVLink message
2075
+ type for MFS B is identified. For now, it returns a placeholder.
2076
+ """
2077
+ def _mfs_b_handler(pkt) -> None:
2078
+ """Handle MAVLink messages for Multi-functional Switch B."""
2079
+ # TODO: Implement specific MAVLink message handling for MFS B
2080
+ # This will depend on the actual MAVLink message type used for MFS B
2081
+ # Example structure:
2082
+ # self.latest_mfs_b_data = {
2083
+ # "timestamp": datetime.now().isoformat(),
2084
+ # "switch_value": getattr(pkt, "switch_value", 0),
2085
+ # "raw_data": getattr(pkt, "raw_data", None),
2086
+ # # Add other relevant fields based on actual MAVLink message
2087
+ # }
2088
+ self.logger.debug("MFS B MAVLink handler called (placeholder implementation)")
2089
+
2090
+ # TODO: Register handler for the specific MAVLink message type for MFS B
2091
+ # Example: self.mavlink_proxy.register_handler("MFS_B_MESSAGE_TYPE", _mfs_b_handler)
2092
+ # For now, return a no-op stop function
2093
+ def stop_handler():
2094
+ # TODO: Unregister the actual handler when implemented
2095
+ # self.mavlink_proxy.unregister_handler("MFS_B_MESSAGE_TYPE", _mfs_b_handler)
2096
+ pass
2097
+
2098
+ return stop_handler
2099
+
2100
+ def _get_sample_data(self) -> Dict[str, Any]:
2101
+ """Get current Multi-functional Switch B data."""
2102
+ if self.latest_mfs_b_data:
2103
+ return self.latest_mfs_b_data
2104
+ else:
2105
+ # Return default/placeholder data when no MAVLink data is available
2106
+ return {
2107
+ "timestamp": datetime.now().isoformat(),
2108
+ "subscribed_stream_id": "px4_mfs_b_raw",
2109
+ "switch_value": 0,
2110
+ "status": "no_data",
2111
+ "message": "Waiting for MAVLink data"
2112
+ }
2113
+
2114
+
2115
+ class TrajectoryVerificationController:
2116
+ """
2117
+ Controller for verifying position and yaw trajectory against a predefined rectangular path.
2118
+ """
2119
+
2120
+ def __init__(
2121
+ self,
2122
+ mqtt_proxy: MQTTProxy,
2123
+ logger: logging.Logger,
2124
+ rectangle_a: float = 2.0,
2125
+ rectangle_b: float = 2.0,
2126
+ points_per_edge: int = 10,
2127
+ corner_exclusion_radius: float = 0.2
2128
+ ):
2129
+ self.mqtt_proxy = mqtt_proxy
2130
+ self.logger = logger
2131
+ self.is_active = False
2132
+ self.trajectory_points = []
2133
+ self.lock = threading.Lock()
2134
+
2135
+ # Rectangle trajectory parameters (in meters)
2136
+ self.rectangle_a = rectangle_a # width
2137
+ self.rectangle_b = rectangle_b # height
2138
+ # Number of interpolated points per edge (including start point, excluding end point)
2139
+ self.points_per_edge = points_per_edge
2140
+
2141
+ # Tolerances
2142
+ self.position_tolerance = 0.5 # meters
2143
+ self.yaw_tolerance = 10.0 # degrees
2144
+
2145
+ # Corner exclusion radius for yaw error calculation (to avoid mixed directions when picking up drone)
2146
+ self.corner_exclusion_radius = corner_exclusion_radius # meters
2147
+
2148
+ # Reference trajectory (rectangle corners in ENU frame)
2149
+ self.reference_trajectory = self._generate_rectangle_trajectory()
2150
+
2151
+ @staticmethod
2152
+ def calculate_single_point_error(actual_point: Dict[str, float],
2153
+ reference_trajectory: List[Dict[str, float]],
2154
+ corner_points: List[Dict[str, float]] = None,
2155
+ corner_exclusion_radius: float = 0.2,
2156
+ max_matching_distance: float = None,
2157
+ debug_yaw:bool = False) -> Dict[str, Any]:
2158
+ """
2159
+ Calculate position and yaw errors for a single trajectory point against reference trajectory.
2160
+
2161
+ Args:
2162
+ actual_point: Dictionary with keys 'x', 'y', 'yaw' (yaw in degrees)
2163
+ reference_trajectory: List of reference points with 'x', 'y', 'yaw' (yaw in degrees)
2164
+ corner_points: List of corner points for exclusion zone (optional, defaults to standard rectangle corners)
2165
+ corner_exclusion_radius: Radius around corners to exclude from yaw error calculation
2166
+ max_matching_distance: Maximum distance to consider a reference point as matching (optional)
2167
+
2168
+ Returns:
2169
+ Dictionary containing:
2170
+ - position_error: float (distance to closest reference point, or None if no match)
2171
+ - yaw_error: float (angular difference in degrees, or None if no match or near corner)
2172
+ - closest_ref_point: dict (the matched reference point, or None)
2173
+ - closest_ref_index: int (index of matched reference point, or -1)
2174
+ - near_corner: bool (whether the point is near a corner exclusion zone)
2175
+ - matched: bool (whether a reference point was found within matching distance)
2176
+ """
2177
+ if not reference_trajectory:
2178
+ return {
2179
+ "position_error": None,
2180
+ "yaw_error": None,
2181
+ "closest_ref_point": None,
2182
+ "closest_ref_index": -1,
2183
+ "near_corner": False,
2184
+ "matched": False
2185
+ }
2186
+
2187
+ # Set default corner points if not provided (standard 2x2 rectangle)
2188
+ if corner_points is None:
2189
+ corner_points = [
2190
+ {"x": 0.0, "y": 0.0}, # Start/end corner
2191
+ {"x": 2.0, "y": 0.0}, # East corner
2192
+ {"x": 2.0, "y": 2.0}, # Northeast corner
2193
+ {"x": 0.0, "y": 2.0}, # West corner
2194
+ ]
2195
+
2196
+ # Set default max matching distance if not provided
2197
+ if max_matching_distance is None:
2198
+ # Use 20% of the largest dimension as default
2199
+ max_x = max(point["x"] for point in reference_trajectory)
2200
+ max_y = max(point["y"] for point in reference_trajectory)
2201
+ max_matching_distance = max(max_x, max_y) * 0.2
2202
+
2203
+ # Find closest reference point to this trajectory point
2204
+ min_dist = float('inf')
2205
+ closest_ref_point = None
2206
+ closest_ref_index = -1
2207
+
2208
+ for ref_idx, ref_point in enumerate(reference_trajectory):
2209
+ dist = math.sqrt(
2210
+ (actual_point["x"] - ref_point["x"])**2 +
2211
+ (actual_point["y"] - ref_point["y"])**2
2212
+ )
2213
+ if dist < min_dist:
2214
+ min_dist = dist
2215
+ closest_ref_point = ref_point
2216
+ closest_ref_index = ref_idx
2217
+
2218
+ # Check if we found a reasonably close reference point
2219
+ if not closest_ref_point or min_dist > max_matching_distance:
2220
+ return {
2221
+ "position_error": None,
2222
+ "yaw_error": None,
2223
+ "closest_ref_point": None,
2224
+ "closest_ref_index": -1,
2225
+ "near_corner": False,
2226
+ "matched": False
2227
+ }
2228
+
2229
+ # Check if this trajectory point is near any corner (exclude yaw error calculation)
2230
+ near_corner = False
2231
+ for corner in corner_points:
2232
+ corner_dist = math.sqrt(
2233
+ (actual_point["x"] - corner["x"])**2 +
2234
+ (actual_point["y"] - corner["y"])**2
2235
+ )
2236
+ if corner_dist <= corner_exclusion_radius:
2237
+ near_corner = True
2238
+ break
2239
+
2240
+ # Calculate yaw error (only if not near a corner)
2241
+ if "yaw" in actual_point and "yaw" in closest_ref_point:
2242
+ # Yaw error (shortest angular distance between angles in -180 to 180 range)
2243
+ actual_yaw = actual_point["yaw"]
2244
+ ref_yaw = closest_ref_point["yaw"]
2245
+
2246
+ # Calculate the angular difference
2247
+ diff = actual_yaw - ref_yaw
2248
+
2249
+ # Normalize difference to [-180, 180] range to find shortest path
2250
+ while diff > 180:
2251
+ diff -= 360
2252
+ while diff <= -180:
2253
+ diff += 360
2254
+
2255
+ # Take absolute value for error magnitude
2256
+ if not near_corner:
2257
+ yaw_error = abs(diff)
2258
+ else:
2259
+ if not debug_yaw:
2260
+ yaw_error = None
2261
+ else:
2262
+ yaw_error = abs(diff) # For debugging, still provide yaw error
2263
+ else:
2264
+ yaw_error = None
2265
+
2266
+ return {
2267
+ "position_error": min_dist,
2268
+ "yaw_error": yaw_error,
2269
+ "closest_ref_point": closest_ref_point,
2270
+ "closest_ref_index": closest_ref_index,
2271
+ "near_corner": near_corner,
2272
+ "matched": True
2273
+ }
2274
+
2275
+ def _generate_rectangle_trajectory(self) -> List[Dict[str, float]]:
2276
+ """Generate reference rectangular trajectory with interpolated points along each edge."""
2277
+ trajectory = []
2278
+
2279
+ # Define corner points of the rectangle with corrected yaw angles (-180 to 180)
2280
+ # Starting with yaw = 90° (pointing east), then following the path
2281
+ corners = [
2282
+ {"x": 0.0, "y": 0.0, "yaw": 90.0}, # Start point (facing east)
2283
+ {"x": self.rectangle_a, "y": 0.0, "yaw": 0.0}, # East corner (turn to face north)
2284
+ {"x": self.rectangle_a, "y": self.rectangle_b, "yaw": -90.0}, # Northeast corner (turn to face west)
2285
+ {"x": 0.0, "y": self.rectangle_b, "yaw": -180.0}, # West corner (turn to face south)
2286
+ {"x": 0.0, "y": 0.0, "yaw": 90.0}, # Back to start (turn to face east)
2287
+ ]
2288
+
2289
+ # Maximum distance to consider a reference point as "close enough" to a trajectory point
2290
+ self.max_matching_distance = max(self.rectangle_a, self.rectangle_b) * 0.2 # 20% of largest dimension
2291
+
2292
+
2293
+ self.corner_points = corners[:-1] # Exclude the duplicate last point for corner exclusion checks
2294
+
2295
+ # Interpolate points between each pair of corners
2296
+ for i in range(len(corners) - 1):
2297
+ start_corner = corners[i]
2298
+ end_corner = corners[i + 1]
2299
+
2300
+ # Add interpolated points along this edge
2301
+ for j in range(self.points_per_edge):
2302
+ t = j / self.points_per_edge # Interpolation parameter (0 to 1, excluding 1)
2303
+
2304
+ # Linear interpolation for position
2305
+ x = start_corner["x"] + t * (end_corner["x"] - start_corner["x"])
2306
+ y = start_corner["y"] + t * (end_corner["y"] - start_corner["y"])
2307
+
2308
+ # Yaw angle depends on the direction of movement (using -180 to 180 range)
2309
+ if abs(end_corner["x"] - start_corner["x"]) > abs(end_corner["y"] - start_corner["y"]):
2310
+ # Horizontal movement
2311
+ if end_corner["x"] > start_corner["x"]:
2312
+ yaw = 90.0 # Moving east
2313
+ else:
2314
+ yaw = -90.0 # Moving west
2315
+ else:
2316
+ # Vertical movement
2317
+ if end_corner["y"] > start_corner["y"]:
2318
+ yaw = 0.0 # Moving north
2319
+ else:
2320
+ yaw = -180.0 # Moving south
2321
+
2322
+ trajectory.append({"x": x, "y": y, "yaw": yaw})
2323
+
2324
+ # Add the final point (back to start)
2325
+ trajectory.append(corners[-1])
2326
+
2327
+ self.logger.info(f"Generated rectangle trajectory with {len(trajectory)} reference points")
2328
+
2329
+ # Log a few sample points for debugging
2330
+ if trajectory:
2331
+ self.logger.debug("Reference trajectory sample points:")
2332
+ for i, point in enumerate(trajectory[::max(1, len(trajectory)//8)]): # Show ~8 sample points
2333
+ self.logger.debug(f" Point {i}: x={point['x']:.2f}m, y={point['y']:.2f}m, yaw={point['yaw']:.1f}°")
2334
+
2335
+ return trajectory
2336
+
2337
+ def start_verification(self) -> None:
2338
+ """Start trajectory verification process."""
2339
+ with self.lock:
2340
+ if self.is_active:
2341
+ self.logger.warning("Trajectory verification already active")
2342
+ return
2343
+
2344
+ self.is_active = True
2345
+ self.trajectory_points = []
2346
+ self.logger.info("Started trajectory verification")
2347
+
2348
+ def add_trajectory_point(self, x: float, y: float, yaw: float) -> None:
2349
+ """Add a trajectory point during verification."""
2350
+ with self.lock:
2351
+ if not self.is_active:
2352
+ return
2353
+
2354
+ # Normalize to -180 to 180 range
2355
+ while yaw > 180:
2356
+ yaw -= 360
2357
+ while yaw <= -180:
2358
+ yaw += 360
2359
+
2360
+ point = {
2361
+ "x": x,
2362
+ "y": y,
2363
+ "yaw": yaw, # in degrees
2364
+ "timestamp": time.time()
2365
+ }
2366
+ self.trajectory_points.append(point)
2367
+
2368
+ def _calculate_trajectory_error(self) -> Dict[str, float]:
2369
+ """Calculate position and yaw errors against reference trajectory."""
2370
+ if not self.trajectory_points:
2371
+ return {"position_error": float('inf'), "yaw_error": float('inf'), "coverage": 0.0}
2372
+
2373
+ position_errors = []
2374
+ yaw_errors = []
2375
+ matched_ref_indices = set() # Track which reference points were matched
2376
+
2377
+ # Loop over trajectory points and calculate errors for each
2378
+ for actual_point in self.trajectory_points:
2379
+ # Use the helper method to calculate errors for this point
2380
+ result = self.calculate_single_point_error(
2381
+ actual_point=actual_point,
2382
+ reference_trajectory=self.reference_trajectory,
2383
+ corner_points=self.corner_points,
2384
+ corner_exclusion_radius=self.corner_exclusion_radius,
2385
+ max_matching_distance=self.max_matching_distance
2386
+ )
2387
+
2388
+ # Only process if we found a matching reference point
2389
+ if result["matched"]:
2390
+ # Track which reference point was matched
2391
+ matched_ref_indices.add(result["closest_ref_index"])
2392
+
2393
+ # Add position error
2394
+ if result["position_error"] is not None:
2395
+ position_errors.append(result["position_error"])
2396
+
2397
+ # Add yaw error (only if not near corner and yaw error was calculated)
2398
+ if result["yaw_error"] is not None:
2399
+ yaw_errors.append(result["yaw_error"])
2400
+
2401
+ # Calculate trajectory coverage (percentage of reference points that were matched)
2402
+ coverage = (len(matched_ref_indices) / len(self.reference_trajectory)) * 100 if self.reference_trajectory else 0
2403
+
2404
+ # Calculate RMS errors
2405
+ position_rms = math.sqrt(sum(e**2 for e in position_errors) / len(position_errors)) if position_errors else float('inf')
2406
+ yaw_rms = math.sqrt(sum(e**2 for e in yaw_errors) / len(yaw_errors)) if yaw_errors else float('inf')
2407
+
2408
+ # Calculate mean errors as well
2409
+ position_mean = sum(position_errors) / len(position_errors) if position_errors else float('inf')
2410
+ yaw_mean = sum(yaw_errors) / len(yaw_errors) if yaw_errors else float('inf')
2411
+
2412
+ return {
2413
+ "position_error": position_rms,
2414
+ "yaw_error": yaw_rms,
2415
+ "position_mean_error": position_mean,
2416
+ "yaw_mean_error": yaw_mean,
2417
+ "max_position_error": max(position_errors) if position_errors else float('inf'),
2418
+ "max_yaw_error": max(yaw_errors) if yaw_errors else float('inf'),
2419
+ "coverage": coverage,
2420
+ "matched_points": len(matched_ref_indices),
2421
+ "total_reference_points": len(self.reference_trajectory),
2422
+ "yaw_errors_calculated": len(yaw_errors), # Number of yaw errors calculated (excluding corners)
2423
+ "position_errors_calculated": len(position_errors) # Number of position errors calculated
2424
+ }
2425
+
2426
+ def plot_trajectories(self, output_file: str = None, show_plot: bool = False,
2427
+ title: str = "Trajectory Verification", figsize: tuple = (12, 10)) -> str:
2428
+ """
2429
+ Plot reference and actual trajectories with yaw indicators.
2430
+
2431
+ Args:
2432
+ output_file: Path to save the plot. If None, generates a timestamped filename.
2433
+ show_plot: Whether to display the plot interactively.
2434
+ title: Title for the plot.
2435
+ figsize: Figure size as (width, height) in inches.
2436
+
2437
+ Returns:
2438
+ Path to the saved plot file.
2439
+ """
2440
+ try:
2441
+ import matplotlib.pyplot as plt
2442
+ import matplotlib.patches as patches
2443
+ from matplotlib.patches import FancyArrowPatch
2444
+ import numpy as np
2445
+ except ImportError as e:
2446
+ self.logger.error(f"Matplotlib not available for plotting: {e}")
2447
+ raise ImportError("matplotlib is required for plotting. Install with: pip install matplotlib")
2448
+
2449
+ # Create figure and axis
2450
+ fig, ax = plt.subplots(1, 1, figsize=figsize)
2451
+
2452
+ # Plot reference trajectory
2453
+ if self.reference_trajectory:
2454
+ ref_x = [point["x"] for point in self.reference_trajectory]
2455
+ ref_y = [point["y"] for point in self.reference_trajectory]
2456
+ ref_yaw = [point["yaw"] for point in self.reference_trajectory]
2457
+
2458
+ # Plot reference path as connected lines
2459
+ ax.plot(ref_x, ref_y, 'b-', linewidth=2, label='Reference Trajectory', alpha=0.7)
2460
+ ax.scatter(ref_x, ref_y, c='blue', s=30, alpha=0.6, zorder=3)
2461
+
2462
+ # Add yaw indicators for reference trajectory (every 5th point to avoid clutter)
2463
+ arrow_scale = 0.15 # Length of yaw arrows
2464
+ for i in range(0, len(self.reference_trajectory), 5):
2465
+ point = self.reference_trajectory[i]
2466
+ x, y, yaw_deg = point["x"], point["y"], point["yaw"]
2467
+
2468
+ # Convert yaw to unit vector (yaw is in degrees, 0° = East, 90° = North)
2469
+ yaw_rad = math.radians(yaw_deg)
2470
+ dx = arrow_scale * math.cos(yaw_rad)
2471
+ dy = arrow_scale * math.sin(yaw_rad)
2472
+
2473
+ # Draw arrow
2474
+ arrow = FancyArrowPatch((x, y), (x + dx, y + dy),
2475
+ arrowstyle='->', mutation_scale=15,
2476
+ color='blue', alpha=0.8, linewidth=1.5)
2477
+ ax.add_patch(arrow)
2478
+
2479
+ # Plot actual trajectory
2480
+ if self.trajectory_points:
2481
+ actual_x = [point["x"] for point in self.trajectory_points]
2482
+ actual_y = [point["y"] for point in self.trajectory_points]
2483
+ actual_yaw = [point["yaw"] for point in self.trajectory_points]
2484
+
2485
+ # Plot actual path as connected lines
2486
+ ax.plot(actual_x, actual_y, 'r-', linewidth=2, label='Actual Trajectory', alpha=0.8)
2487
+ ax.scatter(actual_x, actual_y, c='red', s=40, alpha=0.8, zorder=4)
2488
+
2489
+ # Add yaw indicators for actual trajectory
2490
+ arrow_scale = 0.12 # Slightly smaller arrows for actual trajectory
2491
+ for i, point in enumerate(self.trajectory_points):
2492
+ x, y, yaw_deg = point["x"], point["y"], point["yaw"]
2493
+
2494
+ # Convert yaw to unit vector
2495
+ yaw_rad = math.radians(yaw_deg)
2496
+ dx = arrow_scale * math.cos(yaw_rad)
2497
+ dy = arrow_scale * math.sin(yaw_rad)
2498
+
2499
+ # Draw arrow
2500
+ arrow = FancyArrowPatch((x, y), (x + dx, y + dy),
2501
+ arrowstyle='->', mutation_scale=12,
2502
+ color='red', alpha=0.9, linewidth=1.2)
2503
+ ax.add_patch(arrow)
2504
+
2505
+ # Add corner exclusion zones
2506
+ corner_points = [
2507
+ {"x": 0.0, "y": 0.0},
2508
+ {"x": self.rectangle_a, "y": 0.0},
2509
+ {"x": self.rectangle_a, "y": self.rectangle_b},
2510
+ {"x": 0.0, "y": self.rectangle_b},
2511
+ ]
2512
+
2513
+ for corner in corner_points:
2514
+ circle = plt.Circle((corner["x"], corner["y"]), self.corner_exclusion_radius,
2515
+ fill=False, linestyle='--', color='gray', alpha=0.5, linewidth=1)
2516
+ ax.add_patch(circle)
2517
+
2518
+ # Set equal aspect ratio and add grid
2519
+ ax.set_aspect('equal')
2520
+ ax.grid(True, alpha=0.3)
2521
+
2522
+ # Set axis limits with some padding
2523
+ all_x = []
2524
+ all_y = []
2525
+ if self.reference_trajectory:
2526
+ all_x.extend([p["x"] for p in self.reference_trajectory])
2527
+ all_y.extend([p["y"] for p in self.reference_trajectory])
2528
+ if self.trajectory_points:
2529
+ all_x.extend([p["x"] for p in self.trajectory_points])
2530
+ all_y.extend([p["y"] for p in self.trajectory_points])
2531
+
2532
+ if all_x and all_y:
2533
+ padding = 0.3
2534
+ ax.set_xlim(min(all_x) - padding, max(all_x) + padding)
2535
+ ax.set_ylim(min(all_y) - padding, max(all_y) + padding)
2536
+ else:
2537
+ # Default limits if no data
2538
+ ax.set_xlim(-0.5, self.rectangle_a + 0.5)
2539
+ ax.set_ylim(-0.5, self.rectangle_b + 0.5)
2540
+
2541
+ # Labels and title
2542
+ ax.set_xlabel('X Position (m)', fontsize=12)
2543
+ ax.set_ylabel('Y Position (m)', fontsize=12)
2544
+ ax.set_title(title, fontsize=14, fontweight='bold')
2545
+
2546
+ # Add legend
2547
+ legend_elements = []
2548
+ if self.reference_trajectory:
2549
+ legend_elements.append(plt.Line2D([0], [0], color='blue', linewidth=2, label='Reference Trajectory'))
2550
+ if self.trajectory_points:
2551
+ legend_elements.append(plt.Line2D([0], [0], color='red', linewidth=2, label='Actual Trajectory'))
2552
+ legend_elements.append(plt.Line2D([0], [0], color='gray', linestyle='--', label='Corner Exclusion Zones'))
2553
+
2554
+ ax.legend(handles=legend_elements, loc='upper right', fontsize=10)
2555
+
2556
+ # Add statistics text box if trajectory data exists
2557
+ if self.trajectory_points:
2558
+ errors = self._calculate_trajectory_error()
2559
+ stats_text = (
2560
+ f"Position RMS Error: {errors['position_error']:.3f}m\n"
2561
+ f"Yaw RMS Error: {errors['yaw_error']:.1f}°\n"
2562
+ f"Coverage: {errors['coverage']:.1f}%\n"
2563
+ f"Points: {len(self.trajectory_points)} actual, {len(self.reference_trajectory)} reference\n"
2564
+ f"Tolerances: ±{self.position_tolerance:.1f}m, ±{self.yaw_tolerance:.1f}°"
2565
+ )
2566
+
2567
+ # Position text box in upper left
2568
+ ax.text(0.02, 0.98, stats_text, transform=ax.transAxes,
2569
+ bbox=dict(boxstyle="round,pad=0.3", facecolor="wheat", alpha=0.8),
2570
+ verticalalignment='top', fontsize=9, fontfamily='monospace')
2571
+
2572
+ # Add coordinate system indicator
2573
+ ax.annotate('', xy=(0.15, 0.1), xytext=(0.05, 0.1),
2574
+ arrowprops=dict(arrowstyle='->', color='black', lw=1.5),
2575
+ transform=ax.transAxes)
2576
+ ax.annotate('', xy=(0.1, 0.2), xytext=(0.1, 0.1),
2577
+ arrowprops=dict(arrowstyle='->', color='black', lw=1.5),
2578
+ transform=ax.transAxes)
2579
+ ax.text(0.16, 0.09, 'X (East)', transform=ax.transAxes, fontsize=8)
2580
+ ax.text(0.06, 0.21, 'Y (North)', transform=ax.transAxes, fontsize=8)
2581
+
2582
+ plt.tight_layout()
2583
+
2584
+ # Generate filename if not provided
2585
+ if output_file is None:
2586
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
2587
+ output_file = f"trajectory_verification_{timestamp}.png"
2588
+
2589
+ # Save the plot
2590
+ plt.savefig(output_file, dpi=300, bbox_inches='tight')
2591
+ self.logger.info(f"Trajectory plot saved to: {output_file}")
2592
+
2593
+ # Show plot if requested
2594
+ if show_plot:
2595
+ plt.show()
2596
+ else:
2597
+ plt.close()
2598
+
2599
+ return output_file
2600
+
2601
+ async def finish_verification(self, generate_plot: bool = False,
2602
+ plot_filename: str = None) -> Dict[str, Any]:
2603
+ """Finish verification and return results."""
2604
+
2605
+ if not self.is_active:
2606
+ return {
2607
+ "was_successful": False,
2608
+ "results_text": "Verification was not active",
2609
+ "results_json": {
2610
+ "overall_result": "FAILED",
2611
+ "was_successful": False,
2612
+ "error": "Verification was not active"
2613
+ }
2614
+ }
2615
+
2616
+ with self.lock:
2617
+ self.is_active = False
2618
+
2619
+ # Calculate errors
2620
+ errors = self._calculate_trajectory_error()
2621
+
2622
+ # Check against tolerances
2623
+ position_passed = errors["position_error"] <= self.position_tolerance
2624
+ yaw_passed = errors["yaw_error"] <= self.yaw_tolerance
2625
+ coverage_passed = errors["coverage"] >= 70.0 # Require at least 70% coverage
2626
+ overall_success = position_passed and yaw_passed and coverage_passed
2627
+
2628
+ # make errors compatible with json export
2629
+ invalid_flags = {}
2630
+ for key in errors:
2631
+ if isinstance(errors[key], float) and (math.isinf(errors[key]) or math.isnan(errors[key])):
2632
+ errors[key] = 0.0
2633
+ invalid_flags[key] = True
2634
+ else:
2635
+ invalid_flags[key] = False
2636
+
2637
+ # Generate results text with invalid flags
2638
+ results_text = (
2639
+ f"Trajectory verification completed:\n"
2640
+ f"• Position RMS error: {errors['position_error']:.3f}m "
2641
+ f"{'[INVALID]' if invalid_flags['position_error'] else ''}"
2642
+ f"(tolerance: {self.position_tolerance}m) {'✓' if position_passed else '✗'}\n"
2643
+ f"• Position mean error: {errors['position_mean_error']:.3f}m "
2644
+ f"{'[INVALID]' if invalid_flags.get('position_mean_error', False) else ''}\n"
2645
+ f"• Yaw RMS error: {errors['yaw_error']:.1f}° "
2646
+ f"{'[INVALID]' if invalid_flags['yaw_error'] else ''}"
2647
+ f"(tolerance: {self.yaw_tolerance}°) {'✓' if yaw_passed else '✗'}\n"
2648
+ f"• Yaw mean error: {errors['yaw_mean_error']:.1f}° "
2649
+ f"{'[INVALID]' if invalid_flags.get('yaw_mean_error', False) else ''}\n"
2650
+ f"• Max position error: {errors['max_position_error']:.3f}m "
2651
+ f"{'[INVALID]' if invalid_flags.get('max_position_error', False) else ''}\n"
2652
+ f"• Max yaw error: {errors['max_yaw_error']:.1f}° "
2653
+ f"{'[INVALID]' if invalid_flags.get('max_yaw_error', False) else ''}\n"
2654
+ f"• Trajectory coverage: {errors['coverage']:.1f}% "
2655
+ f"({errors['matched_points']}/{errors['total_reference_points']} reference points matched) "
2656
+ f"{'✓' if coverage_passed else '✗'}\n"
2657
+ f"• Points collected: {len(self.trajectory_points)}\n"
2658
+ f"• Yaw errors calculated: {errors['yaw_errors_calculated']} (excluding {self.corner_exclusion_radius}m corner zones)\n"
2659
+ f"• Overall result: {'PASSED' if overall_success else 'FAILED'}"
2660
+ )
2661
+
2662
+ # Create structured JSON results with invalid flags embedded in values
2663
+ results_json = {
2664
+ "overall_result": "PASSED" if overall_success else "FAILED",
2665
+ "was_successful": overall_success,
2666
+ "position_analysis": {
2667
+ "rms_error_m": {
2668
+ "value": round(errors["position_error"], 3),
2669
+ "invalid": invalid_flags["position_error"]
2670
+ },
2671
+ "mean_error_m": {
2672
+ "value": round(errors["position_mean_error"], 3),
2673
+ "invalid": invalid_flags.get("position_mean_error", False)
2674
+ },
2675
+ "max_error_m": {
2676
+ "value": round(errors["max_position_error"], 3),
2677
+ "invalid": invalid_flags.get("max_position_error", False)
2678
+ },
2679
+ "tolerance_m": self.position_tolerance,
2680
+ "passed": position_passed
2681
+ },
2682
+ "yaw_analysis": {
2683
+ "rms_error_deg": {
2684
+ "value": round(errors["yaw_error"], 1),
2685
+ "invalid": invalid_flags["yaw_error"]
2686
+ },
2687
+ "mean_error_deg": {
2688
+ "value": round(errors["yaw_mean_error"], 1),
2689
+ "invalid": invalid_flags.get("yaw_mean_error", False)
2690
+ },
2691
+ "max_error_deg": {
2692
+ "value": round(errors["max_yaw_error"], 1),
2693
+ "invalid": invalid_flags.get("max_yaw_error", False)
2694
+ },
2695
+ "tolerance_deg": self.yaw_tolerance,
2696
+ "passed": yaw_passed
2697
+ },
2698
+ "trajectory_coverage": {
2699
+ "coverage_percent": {
2700
+ "value": round(errors["coverage"], 1),
2701
+ "invalid": invalid_flags.get("coverage", False)
2702
+ },
2703
+ "matched_reference_points": errors["matched_points"],
2704
+ "total_reference_points": errors["total_reference_points"],
2705
+ "minimum_required_percent": 70.0,
2706
+ "passed": coverage_passed
2707
+ },
2708
+ "data_collection": {
2709
+ "points_collected": len(self.trajectory_points),
2710
+ "reference_trajectory_points": len(self.reference_trajectory),
2711
+ "position_errors_calculated": errors["position_errors_calculated"],
2712
+ "yaw_errors_calculated": errors["yaw_errors_calculated"]
2713
+ },
2714
+ "rectangle_parameters": {
2715
+ "width_m": self.rectangle_a,
2716
+ "height_m": self.rectangle_b,
2717
+ "points_per_edge": self.points_per_edge,
2718
+ "corner_exclusion_radius_m": self.corner_exclusion_radius
2719
+ }
2720
+ }
2721
+
2722
+ self.logger.info(f"Verification completed: {results_text}")
2723
+
2724
+ # Publish results
2725
+ results_payload = {
2726
+ "was_successful": overall_success,
2727
+ "results_text": results_text,
2728
+ "results_json": results_json
2729
+ }
2730
+
2731
+ mqtt_message = {
2732
+ "messageId": str(uuid.uuid4()),
2733
+ "command": "/petal-user-journey-coordinator/verify_pos_yaw_directions_results",
2734
+ "timestamp": datetime.now().isoformat(),
2735
+ "payload": results_payload
2736
+ }
2737
+
2738
+ await self.mqtt_proxy.publish_message(
2739
+ payload=mqtt_message
2740
+ )
2741
+
2742
+ # Generate plot if requested
2743
+ if generate_plot:
2744
+ try:
2745
+ plot_file = self.plot_trajectories(
2746
+ output_file=plot_filename
2747
+ )
2748
+ self.logger.info(f"Trajectory plot saved to: {plot_file}")
2749
+ results_payload["plot_file"] = plot_file
2750
+ except Exception as e:
2751
+ self.logger.warning(f"Failed to generate trajectory plot: {e}")
2752
+
2753
+ return results_payload
2754
+
2755
+
2756
+ class WifiOptitrackConnectivityController:
2757
+ """
2758
+ Controller for connecting to WiFi and verifying OptiTrack server connectivity.
2759
+ """
2760
+
2761
+ def __init__(self, mqtt_proxy: "MQTTProxy", logger: logging.Logger):
2762
+ self.mqtt_proxy = mqtt_proxy
2763
+ self.logger = logger
2764
+ self.is_active = False
2765
+ self.lock = threading.Lock()
2766
+
2767
+ # ---------- helpers: interface & IP/gateway ----------
2768
+
2769
+ async def _get_wifi_iface(self) -> Optional[str]:
2770
+ """Return Wi-Fi interface name (e.g., 'wlp5s0'), preferring a connected device."""
2771
+ # Try NetworkManager first
2772
+ cmd = ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "dev", "status"]
2773
+ p = await asyncio.create_subprocess_exec(
2774
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
2775
+ )
2776
+ out, _ = await p.communicate()
2777
+ if p.returncode == 0:
2778
+ devices = []
2779
+ for line in out.decode().splitlines():
2780
+ # e.g. "wlp5s0:wifi:connected"
2781
+ parts = line.split(":")
2782
+ if len(parts) >= 3 and parts[1] == "wifi":
2783
+ devices.append((parts[0], parts[2]))
2784
+ # Prefer connected iface
2785
+ for dev, state in devices:
2786
+ if state == "connected":
2787
+ return dev
2788
+ # Else return first Wi-Fi iface if any
2789
+ if devices:
2790
+ return devices[0][0]
2791
+
2792
+ # Fallback: parse `iw dev`
2793
+ cmd = ["iw", "dev"]
2794
+ p = await asyncio.create_subprocess_exec(
2795
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
2796
+ )
2797
+ out, _ = await p.communicate()
2798
+ if p.returncode == 0:
2799
+ import re
2800
+ m = re.search(r"Interface\s+(\S+)", out.decode())
2801
+ if m:
2802
+ return m.group(1)
2803
+ return None
2804
+
2805
+ async def _get_ip_and_gateway(self, iface: str) -> Dict[str, Optional[str]]:
2806
+ """
2807
+ Return {'ip': IPv4 or None, 'gateway': IPv4 or None} for iface using nmcli.
2808
+ """
2809
+ async def run(cmd):
2810
+ p = await asyncio.create_subprocess_exec(
2811
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
2812
+ )
2813
+ out, _ = await p.communicate()
2814
+ return out.decode().strip() if p.returncode == 0 else ""
2815
+
2816
+ # IP may have CIDR suffix, pick first line if multiple
2817
+ ip_raw = await run(["nmcli", "-t", "-g", "IP4.ADDRESS", "dev", "show", iface])
2818
+ gw_raw = await run(["nmcli", "-t", "-g", "IP4.GATEWAY", "dev", "show", iface])
2819
+
2820
+ ip = ip_raw.splitlines()[0] if ip_raw else ""
2821
+ ip = ip.split("/", 1)[0] if ip else None
2822
+ gw = gw_raw.splitlines()[0] if gw_raw else None
2823
+ return {"ip": ip, "gateway": gw}
2824
+
2825
+ # ---------- public entrypoint ----------
2826
+
2827
+ async def connect_and_verify(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
2828
+ with self.lock:
2829
+ if self.is_active:
2830
+ return {
2831
+ "was_successful": False,
2832
+ "status_message": "WiFi connection process already active",
2833
+ "assigned_ip_address": ""
2834
+ }
2835
+ self.is_active = True
2836
+
2837
+ try:
2838
+ wifi_payload = ConnectToWifiAndVerifyOptitrackPayload(**payload)
2839
+ ssid = wifi_payload.positioning_system_network_wifi_ssid
2840
+
2841
+ self.logger.info(f"[{message_id}] Starting WiFi connection to SSID: {ssid}")
2842
+
2843
+ # Step 1: Connect to WiFi (auto-detect iface)
2844
+ # connection_result = await self._connect_to_wifi(
2845
+ # ssid=ssid,
2846
+ # password=wifi_payload.positioning_system_network_wifi_pass,
2847
+ # message_id=message_id
2848
+ # )
2849
+ # Force success for testing
2850
+ connection_result = {"success": True, "ip_address": "10.0.0.100", "error": None}
2851
+
2852
+ if not connection_result["success"]:
2853
+ return await self._send_response(
2854
+ message_id=message_id,
2855
+ was_successful=False,
2856
+ status_message=f"WiFi connection failed: {connection_result['error']}",
2857
+ assigned_ip=""
2858
+ )
2859
+
2860
+ assigned_ip = connection_result["ip_address"]
2861
+ self.logger.info(f"[{message_id}] WiFi connected successfully, assigned IP: {assigned_ip}")
2862
+
2863
+ # Step 2: Verify IP is in expected subnet
2864
+ subnet_valid = await self._verify_subnet(
2865
+ assigned_ip=assigned_ip,
2866
+ expected_subnet_or_mask=wifi_payload.positioning_system_network_wifi_subnet, # can be "255.255.255.0" or "10.0.0.0/24"
2867
+ message_id=message_id,
2868
+ reference_ip=wifi_payload.positioning_system_network_server_ip_address # e.g. "10.0.0.27"
2869
+ )
2870
+ if not subnet_valid["success"]:
2871
+ return await self._send_response(
2872
+ message_id=message_id,
2873
+ was_successful=False,
2874
+ status_message=f"IP subnet validation failed: {subnet_valid['error']}",
2875
+ assigned_ip=assigned_ip
2876
+ )
2877
+
2878
+ # Step 3: Ping OptiTrack server
2879
+ ping_result = await self._ping_optitrack_server(
2880
+ server_ip=wifi_payload.positioning_system_network_server_ip_address,
2881
+ message_id=message_id
2882
+ )
2883
+ if not ping_result["success"]:
2884
+ return await self._send_response(
2885
+ message_id=message_id,
2886
+ was_successful=False,
2887
+ status_message=f"OptiTrack server ping failed: {ping_result['error']}",
2888
+ assigned_ip=assigned_ip
2889
+ )
2890
+
2891
+ # Success
2892
+ success_message = (
2893
+ f"Successfully connected to WiFi '{ssid}' with IP {assigned_ip} "
2894
+ f"and verified OptiTrack server connectivity at "
2895
+ f"{wifi_payload.positioning_system_network_server_ip_address}"
2896
+ )
2897
+ return await self._send_response(
2898
+ message_id=message_id,
2899
+ was_successful=True,
2900
+ status_message=success_message,
2901
+ assigned_ip=assigned_ip
2902
+ )
2903
+
2904
+ except Exception as e:
2905
+ self.logger.error(f"[{message_id}] Unexpected error in WiFi/OptiTrack connection: {e}")
2906
+ return await self._send_response(
2907
+ message_id=message_id,
2908
+ was_successful=False,
2909
+ status_message=f"Unexpected error: {str(e)}",
2910
+ assigned_ip=""
2911
+ )
2912
+ finally:
2913
+ with self.lock:
2914
+ self.is_active = False
2915
+
2916
+ # ---------- Wi-Fi connect ----------
2917
+
2918
+ async def _connect_to_wifi(self, ssid: str, password: str, message_id: str) -> Dict[str, Any]:
2919
+ """
2920
+ Connect to WiFi network using NetworkManager (nmcli). Autodetects Wi-Fi interface.
2921
+ Returns: {'success': bool, 'ip_address': str|None, 'error': str|None}
2922
+ """
2923
+ try:
2924
+ iface = await self._get_wifi_iface()
2925
+ if not iface:
2926
+ return {"success": False, "ip_address": None, "error": "No Wi-Fi interface found (nmcli/iw)"}
2927
+
2928
+ self.logger.info(f"[{message_id}] Wi-Fi interface detected: {iface}")
2929
+ self.logger.info(f"[{message_id}] Checking current WiFi status...")
2930
+
2931
+ # If already connected to SSID, just read IP & return
2932
+ check_cmd = ["nmcli", "-t", "-f", "ACTIVE,SSID", "dev", "wifi"]
2933
+ p = await asyncio.create_subprocess_exec(
2934
+ *check_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
2935
+ )
2936
+ out, _ = await p.communicate()
2937
+ if p.returncode == 0:
2938
+ for line in out.decode().splitlines():
2939
+ # "yes:<ssid>" means connected
2940
+ if line.startswith("yes:") and line.split("yes:", 1)[1] == ssid:
2941
+ ip_gw = await self._get_ip_and_gateway(iface)
2942
+ if ip_gw["ip"]:
2943
+ return {"success": True, "ip_address": ip_gw["ip"], "error": None}
2944
+ # fall through to reconnect if we have no IP
2945
+
2946
+ # Disconnect interface (don’t assume wlan0)
2947
+ self.logger.info(f"[{message_id}] Disconnecting interface {iface}...")
2948
+ await asyncio.create_subprocess_exec("nmcli", "dev", "disconnect", iface)
2949
+ await asyncio.sleep(2)
2950
+
2951
+ # Connect and wait up to 30s
2952
+ self.logger.info(f"[{message_id}] Connecting to WiFi network: {ssid}")
2953
+ connect_cmd = [
2954
+ "nmcli", "-w", "30", "dev", "wifi", "connect", ssid,
2955
+ "password", password, "ifname", iface
2956
+ ]
2957
+ p = await asyncio.create_subprocess_exec(
2958
+ *connect_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
2959
+ )
2960
+ _, err = await p.communicate()
2961
+ if p.returncode != 0:
2962
+ return {"success": False, "ip_address": None, "error": f"nmcli connect failed: {err.decode().strip()}"}
2963
+
2964
+ # Poll for DHCP IP (max ~10s)
2965
+ self.logger.info(f"[{message_id}] Waiting for DHCP IP assignment...")
2966
+ ip_address = None
2967
+ for _ in range(20):
2968
+ ip_gw = await self._get_ip_and_gateway(iface)
2969
+ ip_address = ip_gw["ip"]
2970
+ if ip_address:
2971
+ self.logger.info(f"[{message_id}] IP acquired on {iface}: {ip_address} (gw {ip_gw['gateway']})")
2972
+ break
2973
+ await asyncio.sleep(0.5)
2974
+
2975
+ if not ip_address:
2976
+ return {"success": False, "ip_address": None, "error": "Connected but no DHCP IPv4 address"}
2977
+
2978
+ return {"success": True, "ip_address": ip_address, "error": None}
2979
+
2980
+ except FileNotFoundError as e:
2981
+ # nmcli or iw missing
2982
+ return {"success": False, "ip_address": None, "error": f"Dependency missing: {e}"}
2983
+ except Exception as e:
2984
+ self.logger.error(f"[{message_id}] Exception during WiFi connection: {e}")
2985
+ return {"success": False, "ip_address": None, "error": f"Exception: {str(e)}"}
2986
+
2987
+ # ---------- IP fetch (kept for compatibility; now autodetects iface) ----------
2988
+
2989
+ async def _get_current_ip_address(self, message_id: str, iface: Optional[str] = None) -> Optional[str]:
2990
+ """Get current IPv4 of Wi-Fi interface using nmcli; auto-detect iface if None."""
2991
+ try:
2992
+ if iface is None:
2993
+ iface = await self._get_wifi_iface()
2994
+ if not iface:
2995
+ self.logger.error(f"[{message_id}] No Wi-Fi interface found for IP query")
2996
+ return None
2997
+ res = await self._get_ip_and_gateway(iface)
2998
+ if res["ip"]:
2999
+ self.logger.info(f"[{message_id}] Found IP address on {iface}: {res['ip']}")
3000
+ return res["ip"]
3001
+ except Exception as e:
3002
+ self.logger.error(f"[{message_id}] Error getting IP address: {e}")
3003
+ return None
3004
+
3005
+ # ---------- subnet check ----------
3006
+
3007
+ async def _verify_subnet(
3008
+ self,
3009
+ assigned_ip: str,
3010
+ expected_subnet_or_mask: str,
3011
+ message_id: str,
3012
+ reference_ip: Optional[str] = None, # used when a plain netmask is given
3013
+ ) -> Dict[str, Any]:
3014
+ """
3015
+ Verify that the assigned IP is within the expected subnet.
3016
+
3017
+ Supports:
3018
+ - CIDR: "10.0.0.0/24" or "10.0.0.1/24" (strict=False normalizes)
3019
+ - Dotted netmask: "255.255.255.0" (requires reference_ip to build the network)
3020
+ - Bare prefix length: "24" (optional convenience)
3021
+
3022
+ If a dotted/bare mask is provided, we compute the network using `reference_ip`
3023
+ (typically your OptiTrack server IP, so we verify both are on the same subnet).
3024
+ """
3025
+ import ipaddress
3026
+ import re
3027
+
3028
+ try:
3029
+ self.logger.info(
3030
+ f"[{message_id}] Subnet verify: assigned_ip={assigned_ip}, "
3031
+ f"expected='{expected_subnet_or_mask}', reference_ip={reference_ip}"
3032
+ )
3033
+
3034
+ # Case A: A CIDR string is provided (contains '/')
3035
+ if "/" in expected_subnet_or_mask:
3036
+ net = ipaddress.IPv4Network(expected_subnet_or_mask, strict=False)
3037
+
3038
+ else:
3039
+ # No '/', so it's either a dotted mask (e.g. 255.255.255.0) or a bare prefix (e.g. "24")
3040
+ if reference_ip is None:
3041
+ # We prefer reference_ip (server IP). Fall back to assigned_ip if not provided.
3042
+ reference_ip = assigned_ip
3043
+ self.logger.warning(
3044
+ f"[{message_id}] reference_ip not provided; falling back to assigned_ip={assigned_ip}"
3045
+ )
3046
+
3047
+ # Bare prefix-length?
3048
+ if re.fullmatch(r"\d{1,2}", expected_subnet_or_mask):
3049
+ # e.g., "24"
3050
+ net = ipaddress.IPv4Network(f"{reference_ip}/{expected_subnet_or_mask}", strict=False)
3051
+ else:
3052
+ # Treat as dotted mask (ipaddress supports dotted masks)
3053
+ # e.g., IPv4Network("10.0.0.27/255.255.255.0", strict=False) -> 10.0.0.0/24
3054
+ net = ipaddress.IPv4Network(f"{reference_ip}/{expected_subnet_or_mask}", strict=False)
3055
+
3056
+ ip = ipaddress.IPv4Address(assigned_ip)
3057
+
3058
+ if ip in net:
3059
+ self.logger.info(
3060
+ f"[{message_id}] IP verification successful: {assigned_ip} is in {net.with_prefixlen}"
3061
+ )
3062
+ return {"success": True, "network": net.with_prefixlen}
3063
+
3064
+ error_msg = f"IP {assigned_ip} is not in expected subnet {net.with_prefixlen}"
3065
+ self.logger.error(f"[{message_id}] {error_msg}")
3066
+ return {"success": False, "error": error_msg, "network": net.with_prefixlen}
3067
+
3068
+ except Exception as e:
3069
+ error_msg = f"Subnet verification error: {str(e)}"
3070
+ self.logger.error(f"[{message_id}] {error_msg}")
3071
+ return {"success": False, "error": error_msg}
3072
+
3073
+ # ---------- ping check ----------
3074
+
3075
+ async def _ping_optitrack_server(self, server_ip: str, message_id: str, timeout: int = 5, count: int = 3) -> Dict[str, Any]:
3076
+ """Ping the OptiTrack server; bind to Wi-Fi iface if available to avoid route ambiguity."""
3077
+ try:
3078
+ self.logger.info(f"[{message_id}] Pinging OptiTrack server at {server_ip} ({count} attempts)")
3079
+ iface = await self._get_wifi_iface()
3080
+
3081
+ cmd = ["ping", "-c", str(count), "-W", str(timeout), server_ip]
3082
+ # Prefer pinging via Wi-Fi interface when multiple NICs exist
3083
+ if iface:
3084
+ cmd = ["ping", "-I", iface, "-c", str(count), "-W", str(timeout), server_ip]
3085
+
3086
+ p = await asyncio.create_subprocess_exec(
3087
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
3088
+ )
3089
+ out, err = await p.communicate()
3090
+
3091
+ if p.returncode == 0 and b"packet loss" in out:
3092
+ self.logger.info(f"[{message_id}] Ping successful to {server_ip}")
3093
+ return {"success": True}
3094
+ else:
3095
+ # Prefer stderr; if empty, include stdout summary
3096
+ err_txt = err.decode().strip() or out.decode().strip()
3097
+ return {"success": False, "error": f"Ping failed: {err_txt}"}
3098
+
3099
+ except Exception as e:
3100
+ err = f"Ping error: {e}"
3101
+ self.logger.error(f"[{message_id}] {err}")
3102
+ return {"success": False, "error": err}
3103
+
3104
+ # ---------- MQTT response ----------
3105
+
3106
+ async def _send_response(self, message_id: str, was_successful: bool,
3107
+ status_message: str, assigned_ip: str) -> Dict[str, Any]:
3108
+ """Send response message via MQTT."""
3109
+ try:
3110
+ response_payload = WifiOptitrackConnectionResponse(
3111
+ was_successful=was_successful,
3112
+ status_message=status_message,
3113
+ assigned_ip_address=assigned_ip
3114
+ )
3115
+ mqtt_message = {
3116
+ "messageId": message_id,
3117
+ "deviceId": getattr(self.mqtt_proxy, 'device_id', 'unknown'),
3118
+ "command": "petal-user-journey-coordinator/acknowledge",
3119
+ "timestamp": datetime.now().isoformat(),
3120
+ "payload": response_payload.model_dump()
3121
+ }
3122
+ await self.mqtt_proxy.publish_message(payload=mqtt_message)
3123
+ self.logger.info(f"[{message_id}] Response sent: {was_successful}")
3124
+ return response_payload.model_dump()
3125
+ except Exception as e:
3126
+ self.logger.error(f"[{message_id}] Failed to send response: {e}")
3127
+ return {
3128
+ "was_successful": was_successful,
3129
+ "status_message": status_message,
3130
+ "assigned_ip_address": assigned_ip
3131
+ }
3132
+
3133
+ async def set_static_ip_address(self, payload: Dict[str, Any], message_id: str) -> Dict[str, Any]:
3134
+ """
3135
+ Set a static IP address within the specified subnet and gateway network.
3136
+
3137
+ This method:
3138
+ 1. Gets the current IP address from the WiFi interface
3139
+ 2. Calculates the appropriate gateway based on the server IP
3140
+ 3. Verifies the current IP is within the expected subnet/gateway
3141
+ 4. Configures a static IP address using NetworkManager
3142
+
3143
+ Args:
3144
+ payload: Static IP configuration
3145
+ message_id: Unique message identifier
3146
+
3147
+ Returns:
3148
+ Dict containing success status and assigned static IP
3149
+ """
3150
+ with self.lock:
3151
+ if self.is_active:
3152
+ return await self._send_static_ip_response(
3153
+ message_id=message_id,
3154
+ was_successful=False,
3155
+ assigned_static_ip="",
3156
+ error_message="Static IP configuration process already active"
3157
+ )
3158
+ self.is_active = True
3159
+
3160
+ try:
3161
+ static_ip_payload = SetStaticIpAddressPayload(**payload)
3162
+
3163
+ self.logger.info(f"[{message_id}] Starting static IP configuration")
3164
+
3165
+ # Step 1: Get current WiFi interface and IP
3166
+ wifi_iface = await self._get_wifi_iface()
3167
+ if not wifi_iface:
3168
+ return await self._send_static_ip_response(
3169
+ message_id=message_id,
3170
+ was_successful=False,
3171
+ assigned_static_ip="",
3172
+ error_message="No WiFi interface found"
3173
+ )
3174
+
3175
+ # Get current IP and gateway
3176
+ ip_info = await self._get_ip_and_gateway(wifi_iface)
3177
+ current_ip = ip_info.get("ip")
3178
+ current_gateway = ip_info.get("gateway")
3179
+
3180
+ if not current_ip:
3181
+ return await self._send_static_ip_response(
3182
+ message_id=message_id,
3183
+ was_successful=False,
3184
+ assigned_static_ip="",
3185
+ error_message=f"No IP address found on WiFi interface {wifi_iface}"
3186
+ )
3187
+
3188
+ self.logger.info(f"[{message_id}] Current IP: {current_ip}, Gateway: {current_gateway}")
3189
+
3190
+ # Step 2: Calculate target network and gateway from server IP
3191
+ server_ip = static_ip_payload.positioning_system_network_server_ip_address
3192
+ subnet_mask = static_ip_payload.positioning_system_network_wifi_subnet
3193
+
3194
+ # Determine target gateway from server IP (assumes server IP is within the target network)
3195
+ target_gateway = await self._calculate_gateway_from_server_ip(server_ip, subnet_mask, message_id)
3196
+ if not target_gateway:
3197
+ return await self._send_static_ip_response(
3198
+ message_id=message_id,
3199
+ was_successful=False,
3200
+ assigned_static_ip="",
3201
+ error_message="Failed to calculate target gateway from server IP"
3202
+ )
3203
+
3204
+ # Step 3: Verify current IP is within the target gateway network
3205
+ verification_result = await self._verify_subnet(
3206
+ assigned_ip=current_ip,
3207
+ expected_subnet_or_mask=subnet_mask,
3208
+ reference_ip=server_ip,
3209
+ message_id=message_id
3210
+ )
3211
+
3212
+ if not verification_result["success"]:
3213
+ return await self._send_static_ip_response(
3214
+ message_id=message_id,
3215
+ was_successful=False,
3216
+ assigned_static_ip="",
3217
+ error_message=f"Current IP {current_ip} is not within target network: {verification_result.get('error', 'Unknown error')}"
3218
+ )
3219
+
3220
+ # Step 4: Configure static IP using NetworkManager
3221
+ static_config_result = await self._configure_static_ip(
3222
+ interface=wifi_iface,
3223
+ ip_address=current_ip,
3224
+ subnet_mask=subnet_mask,
3225
+ gateway=target_gateway,
3226
+ message_id=message_id
3227
+ )
3228
+
3229
+ if not static_config_result["success"]:
3230
+ return await self._send_static_ip_response(
3231
+ message_id=message_id,
3232
+ was_successful=False,
3233
+ assigned_static_ip="",
3234
+ error_message=f"Failed to configure static IP: {static_config_result['error']}"
3235
+ )
3236
+
3237
+ # Success case
3238
+ self.logger.info(f"[{message_id}] Successfully configured static IP: {current_ip}")
3239
+ return await self._send_static_ip_response(
3240
+ message_id=message_id,
3241
+ was_successful=True,
3242
+ assigned_static_ip=current_ip,
3243
+ error_message=""
3244
+ )
3245
+
3246
+ except Exception as e:
3247
+ self.logger.error(f"[{message_id}] Unexpected error in static IP configuration: {e}")
3248
+ return await self._send_static_ip_response(
3249
+ message_id=message_id,
3250
+ was_successful=False,
3251
+ assigned_static_ip="",
3252
+ error_message=f"Unexpected error: {str(e)}"
3253
+ )
3254
+ finally:
3255
+ with self.lock:
3256
+ self.is_active = False
3257
+
3258
+ async def _calculate_gateway_from_server_ip(self, server_ip: str, subnet_mask: str, message_id: str) -> Optional[str]:
3259
+ """
3260
+ Calculate the gateway IP from the server IP and subnet mask.
3261
+ Assumes the gateway is the first usable IP in the network (e.g., 10.0.0.1 for 10.0.0.0/24).
3262
+ """
3263
+ try:
3264
+ import ipaddress
3265
+
3266
+ # Parse subnet mask format (handle both CIDR and dotted notation)
3267
+ if "/" in subnet_mask:
3268
+ # CIDR notation (e.g., "10.0.0.0/24")
3269
+ network = ipaddress.IPv4Network(subnet_mask, strict=False)
3270
+ elif re.fullmatch(r"\d{1,2}", subnet_mask):
3271
+ # Bare prefix length (e.g., "24")
3272
+ network = ipaddress.IPv4Network(f"{server_ip}/{subnet_mask}", strict=False)
3273
+ else:
3274
+ # Dotted notation (e.g., "255.255.255.0")
3275
+ network = ipaddress.IPv4Network(f"{server_ip}/{subnet_mask}", strict=False)
3276
+
3277
+ # Gateway is typically the first usable IP in the network
3278
+ gateway = str(list(network.hosts())[0])
3279
+ self.logger.info(f"[{message_id}] Calculated gateway: {gateway} for network: {network}")
3280
+ return gateway
3281
+
3282
+ except Exception as e:
3283
+ self.logger.error(f"[{message_id}] Error calculating gateway: {e}")
3284
+ return None
3285
+
3286
+ async def _configure_static_ip(self, interface: str, ip_address: str, subnet_mask: str, gateway: str, message_id: str) -> Dict[str, Any]:
3287
+ """
3288
+ Configure static IP address using NetworkManager.
3289
+ """
3290
+ try:
3291
+ import subprocess
3292
+ import asyncio
3293
+
3294
+ self.logger.info(f"[{message_id}] Configuring static IP {ip_address} with gateway {gateway} on {interface}")
3295
+
3296
+ # Get the current connection name for this interface
3297
+ conn_cmd = ["nmcli", "-t", "-f", "NAME,DEVICE", "connection", "show", "--active"]
3298
+ result = await asyncio.create_subprocess_exec(
3299
+ *conn_cmd,
3300
+ stdout=asyncio.subprocess.PIPE,
3301
+ stderr=asyncio.subprocess.PIPE
3302
+ )
3303
+ stdout, stderr = await result.communicate()
3304
+
3305
+ connection_name = None
3306
+ if result.returncode == 0:
3307
+ for line in stdout.decode().strip().split('\n'):
3308
+ if line:
3309
+ name, device = line.split(':', 1)
3310
+ if device == interface:
3311
+ connection_name = name
3312
+ break
3313
+
3314
+ if not connection_name:
3315
+ return {"success": False, "error": f"No active connection found for interface {interface}"}
3316
+
3317
+ self.logger.info(f"[{message_id}] Found connection: {connection_name}")
3318
+
3319
+ # Calculate CIDR notation from subnet mask
3320
+ if "/" in subnet_mask:
3321
+ cidr_ip = f"{ip_address}/{subnet_mask.split('/')[-1]}"
3322
+ elif re.fullmatch(r"\d{1,2}", subnet_mask):
3323
+ cidr_ip = f"{ip_address}/{subnet_mask}"
3324
+ else:
3325
+ # Convert dotted notation to CIDR
3326
+ import ipaddress
3327
+ network = ipaddress.IPv4Network(f"{ip_address}/{subnet_mask}", strict=False)
3328
+ cidr_ip = f"{ip_address}/{network.prefixlen}"
3329
+
3330
+ # Configure static IP using nmcli
3331
+ modify_cmd = [
3332
+ "nmcli", "connection", "modify", connection_name,
3333
+ "ipv4.method", "manual",
3334
+ "ipv4.addresses", cidr_ip,
3335
+ "ipv4.gateway", gateway
3336
+ ]
3337
+
3338
+ result = await asyncio.create_subprocess_exec(
3339
+ *modify_cmd,
3340
+ stdout=asyncio.subprocess.PIPE,
3341
+ stderr=asyncio.subprocess.PIPE
3342
+ )
3343
+ stdout, stderr = await result.communicate()
3344
+
3345
+ if result.returncode != 0:
3346
+ error_msg = stderr.decode().strip() or stdout.decode().strip()
3347
+ return {"success": False, "error": f"Failed to modify connection: {error_msg}"}
3348
+
3349
+ # Restart the connection to apply changes
3350
+ restart_cmd = ["nmcli", "connection", "up", connection_name]
3351
+ result = await asyncio.create_subprocess_exec(
3352
+ *restart_cmd,
3353
+ stdout=asyncio.subprocess.PIPE,
3354
+ stderr=asyncio.subprocess.PIPE
3355
+ )
3356
+ stdout, stderr = await result.communicate()
3357
+
3358
+ if result.returncode != 0:
3359
+ error_msg = stderr.decode().strip() or stdout.decode().strip()
3360
+ return {"success": False, "error": f"Failed to restart connection: {error_msg}"}
3361
+
3362
+ # Wait a moment for the configuration to take effect
3363
+ await asyncio.sleep(3)
3364
+
3365
+ # Verify the static IP is configured
3366
+ verification = await self._get_ip_and_gateway(interface)
3367
+ if verification.get("ip") == ip_address:
3368
+ self.logger.info(f"[{message_id}] Static IP configuration verified: {ip_address}")
3369
+ return {"success": True}
3370
+ else:
3371
+ return {"success": False, "error": f"IP verification failed. Expected: {ip_address}, Got: {verification.get('ip')}"}
3372
+
3373
+ except Exception as e:
3374
+ self.logger.error(f"[{message_id}] Exception during static IP configuration: {e}")
3375
+ return {"success": False, "error": f"Exception: {str(e)}"}
3376
+
3377
+ async def _send_static_ip_response(self, message_id: str, was_successful: bool,
3378
+ assigned_static_ip: str, error_message: str) -> Dict[str, Any]:
3379
+ """Send static IP configuration response via MQTT."""
3380
+ try:
3381
+
3382
+ response_payload = SetStaticIpAddressResponse(
3383
+ assigned_static_ip=assigned_static_ip,
3384
+ was_successful=was_successful
3385
+ )
3386
+
3387
+ mqtt_message = {
3388
+ "messageId": message_id,
3389
+ "deviceId": getattr(self.mqtt_proxy, 'device_id', 'unknown'),
3390
+ "command": "petal-user-journey-coordinator/set_static_ip_address_ack",
3391
+ "timestamp": datetime.now().isoformat(),
3392
+ "payload": response_payload.model_dump()
3393
+ }
3394
+
3395
+ await self.mqtt_proxy.publish_message(payload=mqtt_message)
3396
+
3397
+ status_msg = "Static IP configuration successful" if was_successful else f"Static IP configuration failed: {error_message}"
3398
+ self.logger.info(f"[{message_id}] {status_msg}")
3399
+ return response_payload.model_dump()
3400
+
3401
+ except Exception as e:
3402
+ self.logger.error(f"[{message_id}] Failed to send static IP response: {e}")
3403
+ return {
3404
+ "assigned_static_ip": assigned_static_ip,
3405
+ "was_successful": was_successful
3406
+ }