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.
- petal_user_journey_coordinator/__init__.py +18 -0
- petal_user_journey_coordinator/controllers.py +3406 -0
- petal_user_journey_coordinator/data_model.py +556 -0
- petal_user_journey_coordinator/plugin.py +2167 -0
- petal_user_journey_coordinator-0.1.5.dist-info/METADATA +87 -0
- petal_user_journey_coordinator-0.1.5.dist-info/RECORD +8 -0
- petal_user_journey_coordinator-0.1.5.dist-info/WHEEL +4 -0
- petal_user_journey_coordinator-0.1.5.dist-info/entry_points.txt +7 -0
|
@@ -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
|
+
}
|