petal-leafsdk 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ # petal_leafsdk/mission_plan_executor.py
2
+ import json
3
+ import json
4
+ from typing import Optional, Literal
5
+ import traceback
6
+ from dataclasses import dataclass
7
+ import networkx as nx
8
+ from enum import Enum, auto
9
+
10
+ from leafsdk.core.mission.mission_plan import MissionPlan
11
+ from leafsdk.utils.logstyle import LogIcons
12
+ from leafsdk import logger
13
+
14
+ from petal_leafsdk.mission_step_executor import get_mission_step_executor
15
+ from petal_leafsdk.mission_step_executor import get_mission_step_executor
16
+
17
+ from petal_app_manager.proxies.external import MavLinkExternalProxy
18
+ from petal_app_manager.proxies.redis import RedisProxy
19
+ from pymavlink.dialects.v20 import droneleaf_mav_msgs as leafMAV
20
+
21
+
22
+
23
+ class MissionPlanExecutor:
24
+ def __init__(self, plan: MissionPlan, mav_proxy: MavLinkExternalProxy, redis_proxy: RedisProxy):
25
+ self.plan = plan
26
+ self.execution_graph = self.get_execution_graph(plan)
27
+ self.mav_proxy = mav_proxy
28
+ self.redis_proxy = redis_proxy
29
+
30
+ self._mission_status = MissionStatus()
31
+ self._current_step = None
32
+ self._current_node = None
33
+ self._mission_control_cmd: MissionControlCommand = MissionControlCommand.NONE
34
+
35
+ def get_execution_graph(self, plan: MissionPlan) -> nx.MultiDiGraph:
36
+ """Generate the execution graph from the mission plan."""
37
+ graph = plan.mission_graph.copy()
38
+ # Traverse and replace steps with their executors
39
+ for name, data in graph.nodes(data=True):
40
+ step = data['step']
41
+ executor = get_mission_step_executor(step)
42
+ graph.nodes[name]['step'] = executor
43
+ return graph
44
+
45
+
46
+ def load_plan(self, data: dict | str):
47
+ """Load a new mission plan."""
48
+ self.plan.load(data)
49
+ self.execution_graph = self.get_execution_graph(self.plan)
50
+ logger.info(f"{LogIcons.SUCCESS} Mission plan loaded successfully.")
51
+
52
+
53
+ def run_step(self):
54
+ """Execute the current mission step.
55
+
56
+ Returns:
57
+ dict: Current mission status after executing the step.
58
+ Keys include:
59
+ - step_id
60
+ - step_description
61
+ - next_step_id
62
+ - next_step_description
63
+ - step_completed
64
+ - state
65
+ """
66
+ if self._mission_status.state == MissionState.CANCELLED:
67
+ return self._mission_status.as_dict()
68
+ elif self._mission_control_cmd == MissionControlCommand.CANCEL:
69
+ if self._mission_status.state in [MissionState.RUNNING, MissionState.PAUSED]:
70
+ self._canceled()
71
+ return self._mission_status.as_dict()
72
+
73
+ elif self._mission_control_cmd == MissionControlCommand.RESUME:
74
+ if self._mission_status.state == MissionState.PAUSED:
75
+ self._mission_status.state = MissionState.RUNNING
76
+ self._mission_control_cmd = MissionControlCommand.NONE
77
+
78
+ elif self._mission_status.state == MissionState.PAUSED:
79
+ return self._mission_status.as_dict()
80
+ elif self._mission_control_cmd == MissionControlCommand.PAUSE_NOW:
81
+ if self._mission_status.state == MissionState.RUNNING:
82
+ self._paused()
83
+ return self._mission_status.as_dict()
84
+
85
+ if self._current_step is None:
86
+ logger.warning(f"{LogIcons.WARNING} Cannot run step: no current step available")
87
+ self._mission_status.reset()
88
+ return self._mission_status.as_dict()
89
+
90
+ if self._current_step.first_exec():
91
+ logger.info(f"{LogIcons.RUN} Executing step: {self._current_node}")
92
+
93
+ try:
94
+ result, completed = self._current_step.execute_step(mav_proxy=self.mav_proxy, redis_proxy=self.redis_proxy)
95
+ except Exception as e:
96
+ self._failed(e)
97
+ return self._mission_status.as_dict()
98
+
99
+ if completed:
100
+ logger.info(f"Step {self._current_step.description()} Completed")
101
+ prev_node = self._current_node
102
+ self._current_node = self._get_next_node(result)
103
+
104
+ if self._current_node is None:
105
+ self._completed(prev_node)
106
+ return self._mission_status.as_dict()
107
+ else:
108
+ if self._mission_control_cmd == MissionControlCommand.PAUSE_LATER:
109
+ self._paused()
110
+ logger.info(f"Mission paused after completetion of step: {self._current_node}")
111
+ return self._mission_status.as_dict()
112
+
113
+ # Update current step to the next node immediately
114
+ self._current_step = self.execution_graph.nodes[self._current_node]['step']
115
+ self._mission_status.step_transition(prev_node, self._current_node, self.execution_graph)
116
+ return self._mission_status.as_dict()
117
+
118
+ self._mission_status.running(self._current_node, self.execution_graph)
119
+ return self._mission_status.as_dict()
120
+
121
+
122
+ def _get_next_node(self, result) -> Optional[str]:
123
+ """Determine the next node based on current node and conditions."""
124
+ next_node = None
125
+ for successor in self.execution_graph.successors(self._current_node):
126
+ condition = self.execution_graph.edges[self._current_node, successor, 0].get("condition")
127
+ if condition is None or condition == result:
128
+ next_node = successor
129
+ break
130
+ return next_node
131
+
132
+
133
+ def prepare(self) -> bool:
134
+ """Prepare the mission plan for execution."""
135
+ try:
136
+ self.plan.validate()
137
+ self._current_node = self.plan._head_node
138
+ self._current_step = self.execution_graph.nodes[self._current_node]['step']
139
+ for name, _ in self.plan._get_steps():
140
+ step = self.execution_graph.nodes[name]['step']
141
+ step.setup(mav_proxy=self.mav_proxy, redis_proxy=self.redis_proxy)
142
+ self._mission_control_cmd = MissionState.RUNNING
143
+
144
+ # Send joystick enable/disable command
145
+ joystick_mode_map = {
146
+ "disabled": 0,
147
+ "enabled": 1,
148
+ "enabled_on_pause": 2
149
+ }
150
+ joystick_cmd = joystick_mode_map.get(self.plan.config.joystick_mode.value, 1) # Default to ENABLED if unknown
151
+ self.redis_proxy.publish(
152
+ channel="/FlightLogic/joystick_mode",
153
+ message=json.dumps({"payload": joystick_cmd})
154
+ )
155
+ logger.info(f"{LogIcons.SUCCESS} Joystick control set to {self.plan.config.joystick_mode.value.upper()}.")
156
+ logger.info(f"{LogIcons.SUCCESS} Mission plan has been prepared and ready for execution.")
157
+ return True
158
+ except Exception as e:
159
+ logger.error(f"{LogIcons.ERROR} Mission plan preparation failed: {e}")
160
+ logger.error(traceback.format_exc())
161
+ return False
162
+
163
+
164
+ def pause(self, action: Optional[Literal["NONE"]] = "NONE"):
165
+ """Pause the mission execution."""
166
+ logger.info(f"{LogIcons.RUN} Mission pause commanded.")
167
+
168
+ if self._mission_status.state != MissionState.RUNNING:
169
+ logger.warning(f"{LogIcons.WARNING} Mission cannot be paused, current state: {self._mission_status.state.name}.")
170
+ return False
171
+
172
+ if self._current_step is None:
173
+ logger.warning(f"{LogIcons.WARNING} Cannot pause, no current step to pause.")
174
+ return False
175
+
176
+ if self.mav_proxy is None:
177
+ logger.warning(f"{LogIcons.WARNING} Cannot pause, MAVLink proxy is required.")
178
+ return False
179
+
180
+ if self._current_step.is_pausable():
181
+ self._mission_control_cmd = MissionControlCommand.PAUSE_NOW
182
+ logger.info(f"{LogIcons.RUN} Mission will be paused Immediately")
183
+ else:
184
+ self._mission_control_cmd = MissionControlCommand.PAUSE_LATER
185
+ logger.info(f"{LogIcons.RUN} Mission will pause after the step is completed.")
186
+
187
+ return True
188
+
189
+
190
+ def resume(self):
191
+ """Resume the mission execution."""
192
+ if self._mission_status.state != MissionState.PAUSED:
193
+ logger.warning(f"{LogIcons.WARNING} Mission cannot be resumed, current state: {self._mission_status.state.name}.")
194
+ return False
195
+
196
+ if self._current_step is None:
197
+ logger.warning(f"{LogIcons.WARNING} Cannot resume, no current step to resume.")
198
+ return False
199
+
200
+ if self.mav_proxy is None:
201
+ logger.warning(f"{LogIcons.WARNING} Cannot resume, MAVLink proxy is required.")
202
+ return False
203
+
204
+ self._current_step.resume()
205
+ self._mission_control_cmd = MissionControlCommand.RESUME
206
+
207
+ msg = leafMAV.MAVLink_leaf_control_cmd_message(
208
+ target_system=self.mav_proxy.target_system,
209
+ cmd=1,
210
+ action=0,
211
+ mission_id=self.plan.id.encode('ascii') # Use unique mission ID for tracking
212
+ )
213
+ self.mav_proxy.send(key='mav', msg=msg, burst_count=4, burst_interval=0.1)
214
+ logger.info(f"{LogIcons.RUN} Mission resume commanded.")
215
+
216
+ return True
217
+
218
+
219
+ def cancel(self, action: Optional[Literal["NONE", "HOVER", "RETURN_TO_HOME", "LAND_IMMEDIATELY"]] = "HOVER"):
220
+ # Map action strings to command codes
221
+ action_map = {
222
+ "NONE": 0,
223
+ "HOVER": 1,
224
+ "RETURN_TO_HOME": 2,
225
+ "LAND_IMMEDIATELY": 3
226
+ }
227
+ action_code = action_map.get(action, 1) # Default to HOVER if invalid action
228
+
229
+ """Cancel the mission execution completely."""
230
+ if self._mission_status.state != MissionState.RUNNING and self._mission_status.state != MissionState.PAUSED:
231
+ logger.warning(f"{LogIcons.WARNING} Mission cannot be canceled, current state: {self._mission_status.state.name}.")
232
+ return False
233
+
234
+ if self._current_step is None:
235
+ logger.warning(f"{LogIcons.WARNING} Cannot cancel, no current step to cancel.")
236
+ return False
237
+
238
+ if self._current_step.is_cancelable() is False:
239
+ logger.warning(f"{LogIcons.WARNING} Current step does not support cancellation: {self._current_node}.")
240
+ return False
241
+
242
+ if self.mav_proxy is None:
243
+ logger.warning(f"{LogIcons.WARNING} Cannot cancel, MAVLink proxy is required.")
244
+ return False
245
+
246
+ self._mission_control_cmd = MissionControlCommand.CANCEL
247
+
248
+ # Send MAVLink cancel command
249
+ msg = leafMAV.MAVLink_leaf_control_cmd_message(
250
+ target_system=self.mav_proxy.target_system,
251
+ cmd=2,
252
+ action=action_code,
253
+ mission_id=self.plan.id.encode('ascii') # Use unique mission ID for tracking
254
+ )
255
+ self.mav_proxy.send(key='mav', msg=msg, burst_count=4, burst_interval=0.1)
256
+ logger.info(f"{LogIcons.RUN} Mission cancel commanded with action: {action}.")
257
+
258
+ return True
259
+
260
+
261
+ def abort(self):
262
+ """Abort the mission immediately without any graceful shutdown."""
263
+ self._mission_control_cmd = MissionControlCommand.CANCEL
264
+
265
+ logger.info(f"{LogIcons.CANCEL} Mission aborted immediately.")
266
+
267
+
268
+ def _completed(self, prev_node: str=None):
269
+ """Handle mission completion procedures."""
270
+ state_change_flag = self._mission_status.completed(prev_node, self.execution_graph)
271
+ self._mission_control_cmd = MissionState.COMPLETED
272
+ self._current_step = None
273
+ if state_change_flag:
274
+ logger.info(f"{LogIcons.SUCCESS} Mission complete.")
275
+
276
+ def _failed(self, e: Exception):
277
+ """Handle mission failure procedures."""
278
+ state_change_flag = self._mission_status.failed(self._current_node, self.execution_graph)
279
+ self._mission_control_cmd = MissionState.FAILED
280
+ self._current_node = None
281
+ self._current_step = None
282
+ if state_change_flag:
283
+ logger.error(f"{LogIcons.ERROR} Step {self._current_node} failed: {e}\n{traceback.format_exc()}")
284
+
285
+ def _send_pause_to_FC(self) -> None:
286
+ action_map = {
287
+ "NONE": 0
288
+ }
289
+ action_code = action_map.get("NONE", 0) # Default to HOVER if invalid action
290
+ msg = leafMAV.MAVLink_leaf_control_cmd_message(
291
+ target_system=self.mav_proxy.target_system,
292
+ cmd=0,
293
+ action=action_code,
294
+ mission_id=self.plan.id.encode('ascii')
295
+ )
296
+ self.mav_proxy.send(key='mav', msg=msg, burst_count=4, burst_interval=0.1)
297
+
298
+ def _paused(self):
299
+ """Internal method to perform pause procedures."""
300
+ state_change_flag = self._mission_status.paused(self._current_node, self.execution_graph)
301
+ if state_change_flag:
302
+ self._current_step.pause(self.plan.id.encode('ascii'), mav_proxy=self.mav_proxy, redis_proxy=self.redis_proxy)
303
+ self._send_pause_to_FC()
304
+ logger.info(f"{LogIcons.PAUSE} Mission paused at step: {self._current_node}")
305
+
306
+ def _canceled(self):
307
+ """Internal method to perform cancellation procedures."""
308
+ state_change_flag = self._mission_status.canceled(self._current_node, self.execution_graph)
309
+ if state_change_flag:
310
+ self._current_step.cancel(mav_proxy=self.mav_proxy, redis_proxy=self.redis_proxy)
311
+ logger.info(f"{LogIcons.CANCEL} Mission cancelled at step: {self._current_node}")
312
+ self._current_node = None
313
+ self._current_step = None
314
+
315
+
316
+ def reset(self):
317
+ """Reset the mission plan executor to its initial state."""
318
+ self.plan.reset()
319
+ self._mission_status.reset()
320
+ self._current_step = None
321
+ self._current_node = None
322
+ self._mission_control_cmd = None
323
+ logger.info(f"{LogIcons.SUCCESS} MissionPlanExecutor has been reset.")
324
+
325
+
326
+ class MissionState(Enum):
327
+ IDLE = auto()
328
+ RUNNING = auto()
329
+ PAUSED = auto()
330
+ CANCELLED = auto()
331
+ COMPLETED = auto()
332
+ FAILED = auto()
333
+
334
+ def __str__(self):
335
+ return self.name
336
+
337
+
338
+ class MissionControlCommand(Enum):
339
+ NONE = auto()
340
+ PAUSE_NOW = auto()
341
+ PAUSE_LATER = auto()
342
+ RESUME = auto()
343
+ CANCEL = auto()
344
+
345
+ def __str__(self):
346
+ return self.name
347
+
348
+
349
+ @dataclass
350
+ class MissionStatus:
351
+ step_id: Optional[str] = None
352
+ step_description: Optional[str] = None
353
+ next_step_id: Optional[str] = None
354
+ next_step_description: Optional[str] = None
355
+ step_completed: bool = False
356
+ state: MissionState = MissionState.IDLE
357
+
358
+
359
+ def as_dict(self) -> dict:
360
+ """Return a serializable dictionary for external systems."""
361
+ data = self.__dict__.copy()
362
+ data["state"] = str(self.state) # convert enum to string
363
+ return data
364
+
365
+ def reset(self):
366
+ self.step_id = None
367
+ self.step_description = None
368
+ self.next_step_id = None
369
+ self.next_step_description = None
370
+ self.step_completed = False
371
+ self.state = MissionState.IDLE
372
+
373
+
374
+ def set_step(self, node: str, graph: nx.MultiDiGraph):
375
+ self.step_id = str(node)
376
+ self.step_description = graph.nodes[node]['step'].description()
377
+
378
+
379
+ def set_next_step(self, node: str, graph: nx.MultiDiGraph):
380
+ self.next_step_id = str(node)
381
+ self.next_step_description = graph.nodes[node]['step'].description()
382
+
383
+
384
+ def completed(self, node: str, graph: nx.MultiDiGraph) -> bool:
385
+ _state = self.state
386
+ self.reset()
387
+ self.state = MissionState.COMPLETED
388
+ self.step_completed = True
389
+ self.set_step(node, graph)
390
+ return _state != self.state
391
+
392
+
393
+ def step_transition(self, prev_node: str, node: str, graph: nx.MultiDiGraph) -> bool:
394
+ _state = self.state
395
+ self.reset()
396
+ self.state = MissionState.RUNNING
397
+ self.step_completed = True
398
+ self.set_step(prev_node, graph)
399
+ self.set_next_step(node, graph)
400
+ return _state != self.state
401
+
402
+
403
+ def running(self, node: str, graph: nx.MultiDiGraph) -> bool:
404
+ _state = self.state
405
+ self.reset()
406
+ self.state = MissionState.RUNNING
407
+ self.set_step(node, graph)
408
+ return _state != self.state
409
+
410
+
411
+ def paused(self, node: str, graph: nx.MultiDiGraph) -> bool:
412
+ _state = self.state
413
+ self.reset()
414
+ self.state = MissionState.PAUSED
415
+ self.step_completed = True
416
+ self.set_step(node, graph)
417
+ return _state != self.state
418
+
419
+
420
+ def canceled(self, node: str, graph: nx.MultiDiGraph) -> bool:
421
+ _state = self.state
422
+ self.reset()
423
+ self.state = MissionState.CANCELLED
424
+ self.step_completed = True
425
+ self.set_step(node, graph)
426
+ return _state != self.state
427
+
428
+
429
+ def failed(self, node: str, graph: nx.MultiDiGraph) -> bool:
430
+ _state = self.state
431
+ self.reset()
432
+ self.state = MissionState.FAILED
433
+ self.set_step(node, graph)
434
+ return _state != self.state