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,409 @@
1
+ # petal-leafsdk/plugin.py
2
+ # Petal plugin for Leaf SDK integration and mission planning
3
+
4
+ import time
5
+ from . import logger
6
+
7
+ from typing import Any, Dict
8
+ import asyncio, httpx
9
+ import traceback
10
+
11
+ from pymavlink.dialects.v20 import droneleaf_mav_msgs as leafMAV
12
+ from pymavlink import mavutil
13
+
14
+ from petal_app_manager.plugins.base import Petal
15
+ from petal_app_manager.plugins.decorators import http_action
16
+ from petal_app_manager.proxies import (
17
+ MQTTProxy,
18
+ MavLinkExternalProxy,
19
+ RedisProxy
20
+ )
21
+
22
+ from petal_leafsdk.data_model import CancelMissionRequest, MissionGraph, ProgressUpdateSubscription, SafeReturnPlanRequestAddress
23
+ from petal_leafsdk.mission_plan_executor import MissionPlanExecutor, MissionState
24
+
25
+ # Mission imports
26
+ from leafsdk.core.mission.mission_clock import MissionClock
27
+ from leafsdk.core.mission.mission_plan import MissionPlan
28
+ from leafsdk.utils.logstyle import LogIcons
29
+
30
+
31
+ class PetalMissionPlanner(Petal):
32
+ name = "petal-mission-planner"
33
+ version = "v0.1.0"
34
+ use_mqtt_proxy = True # Enable MQTT-aware startup
35
+
36
+ def startup(self):
37
+ super().startup()
38
+ self.running = False
39
+ self.mission_ready = False
40
+ self.mission_clock = None
41
+ self.mission_plan = None
42
+ self.subscriber_address = None # For progress updates
43
+ self.safe_return_waypoint_request_address = None # For safe return waypoint requests
44
+ self.redis_proxy: RedisProxy = self._proxies.get("redis") # Get the Redis proxy instance
45
+ self.mqtt_proxy: MQTTProxy = self._proxies.get("mqtt") # Get the MQTT proxy instance
46
+ self.mqtt_subscription_id = None
47
+
48
+ self._setup_mavlink_handlers()
49
+ self._init_mavlink_and_mission()
50
+ self.mission_executor = MissionPlanExecutor(self.mission_plan, self.mavlink_proxy, self.redis_proxy)
51
+
52
+ self.loop = asyncio.get_event_loop() # store loop reference
53
+
54
+ def _setup_mavlink_handlers(self):
55
+ self._mavlink_handlers = {}
56
+
57
+ def _handler_mavlink_mission_run_ack(msg: mavutil.mavlink.MAVLink_message) -> bool:
58
+ """Called when the FC acknowledges mission start."""
59
+ if self.running:
60
+ logger.warning(f"{LogIcons.WARNING} Mission already running — ignoring duplicate run ACK.")
61
+ return True
62
+ elif not self.mission_ready:
63
+ logger.warning(f"{LogIcons.WARNING} There is no mission ready to start.")
64
+ return True
65
+ try:
66
+ logger.info(f"{LogIcons.SUCCESS} Received MAVLink mission run acknowledgment from flight controller.")
67
+ self.running = True
68
+
69
+ # Schedule on stored event loop safely, even from another thread
70
+ self.loop.call_soon_threadsafe(lambda: asyncio.create_task(self.mission_loop()))
71
+ except Exception as e:
72
+ logger.error(f"{LogIcons.ERROR} Failed to start mission loop: {e}, trace: {traceback.format_exc()}")
73
+
74
+ return True
75
+
76
+ def _handler_mavlink_qgc_control_cmd(msg: mavutil.mavlink.MAVLink_message) -> bool:
77
+ control_cmd_name = leafMAV.enums['LEAF_CONTROL_COMMAND'][msg.cmd].name
78
+ logger.info(f"{LogIcons.SUCCESS} Received QGC control command: {control_cmd_name}")
79
+
80
+ if control_cmd_name == "LEAF_CONTROL_PAUSE":
81
+ self.pause()
82
+ elif control_cmd_name == "LEAF_CONTROL_RESUME":
83
+ self.resume()
84
+ elif control_cmd_name == "LEAF_CONTROL_CANCEL":
85
+ self.cancel(action="HOVER")
86
+
87
+ return True
88
+
89
+ def _handler_mavlink_ack_mission_abort(msg: mavutil.mavlink.MAVLink_message) -> bool:
90
+ """Handle abort acknowledgment."""
91
+ logger.info(f"{LogIcons.SUCCESS} Received MAVLink mission abort acknowledgment from flight controller.")
92
+ if self.running:
93
+ self.abort()
94
+ else:
95
+ logger.info(f"{LogIcons.RUN} No active mission — resetting mission plan.")
96
+ self._reset_mission()
97
+
98
+ return True
99
+
100
+ def _handler_mavlink_ack_mission_resume(msg: mavutil.mavlink.MAVLink_message) -> bool:
101
+ """Handle resume acknowledgment."""
102
+ logger.info(f"{LogIcons.SUCCESS} Received MAVLink mission resume acknowledgment from flight controller.")
103
+ return True
104
+
105
+ self._mavlink_handlers[str(leafMAV.MAVLINK_MSG_ID_LEAF_ACK_MISSION_RUN)] = _handler_mavlink_mission_run_ack
106
+ self._mavlink_handlers[str(leafMAV.MAVLINK_MSG_ID_LEAF_QGC_CONTROL_CMD)] = _handler_mavlink_qgc_control_cmd
107
+ self._mavlink_handlers[str(leafMAV.MAVLINK_MSG_ID_LEAF_ACK_MISSION_ABORT)] = _handler_mavlink_ack_mission_abort
108
+ self._mavlink_handlers[str(leafMAV.MAVLINK_MSG_ID_LEAF_ACK_MISSION_RESUME)] = _handler_mavlink_ack_mission_resume
109
+
110
+ def _init_mavlink_and_mission(self):
111
+ logger.info(f"{LogIcons.RUN} Initializing MAVLink connection via proxy...")
112
+ # Use the external MAVLink proxy if available
113
+ self.mavlink_proxy: MavLinkExternalProxy = self._proxies.get("ext_mavlink")
114
+ self.mission_clock = MissionClock(rate_hz=50)
115
+ self.mission_plan = MissionPlan(name="main")
116
+ logger.info(f"{LogIcons.SUCCESS} MAVLink initialized and mission plan ready.")
117
+
118
+ for key, handler in self._mavlink_handlers.items():
119
+ self.mavlink_proxy.register_handler(
120
+ key=key,
121
+ fn=handler,
122
+ duplicate_filter_interval=0.7
123
+ )
124
+
125
+ async def async_startup(self):
126
+ """
127
+ Called after startup to handle async operations like MQTT subscriptions.
128
+
129
+ Note: The MQTT-aware startup logic (organization ID monitoring, event loop setup)
130
+ is handled by the main application's _mqtt_aware_petal_startup function.
131
+ This method will be called by that function after organization ID is available.
132
+ """
133
+ # This method is intentionally simple - the main app handles:
134
+ # 1. Setting self._loop
135
+ # 2. Waiting for organization ID
136
+ # 3. Calling self._setup_mqtt_topics() when ready
137
+ # 4. Starting organization ID monitoring if needed
138
+
139
+ logger.info("{LogIcons.RUN} Performing MQTT-aware async startup...")
140
+ pass
141
+
142
+ async def _setup_mqtt_topics(self):
143
+ logger.info(f"{LogIcons.RUN} Setting up MQTT topics...")
144
+ await self._mqtt_subscribe_to_mission_plan()
145
+ logger.info(f"{LogIcons.SUCCESS} All MQTT topics active")
146
+
147
+ async def run_mission(self, mission_graph: dict):
148
+ try:
149
+ self.mission_executor.reset()
150
+ self.mission_executor.load_plan(mission_graph)
151
+ logger.info(f"{LogIcons.RUN} Loading mission: {self.mission_plan.name}")
152
+ self.mission_ready = self.mission_executor.prepare()
153
+
154
+ if self.mission_ready:
155
+ logger.info(f"{LogIcons.SUCCESS} Mission '{self.mission_plan.name}' loaded and ready.")
156
+ # Send mission run message
157
+ msg = leafMAV.MAVLink_leaf_do_mission_run_message(
158
+ target_system=self.mavlink_proxy.target_system,
159
+ mission_id=self.mission_plan.id.encode("ascii"),
160
+ forced=0
161
+ )
162
+ self.mavlink_proxy.send(key='mav', msg=msg, burst_count=4, burst_interval=0.1)
163
+ # Wait for MAVLink ACK (non-blocking)
164
+ logger.info(f"{LogIcons.RUN} Sent mission run request — awaiting ACK to start execution...")
165
+ else:
166
+ logger.error(f"{LogIcons.ERROR} Mission '{self.mission_plan.name}' failed to prepare.")
167
+ return
168
+
169
+ except Exception as e:
170
+ logger.error(f"{LogIcons.ERROR} Mission run request error: {e}")
171
+ logger.error(traceback.format_exc())
172
+
173
+ async def _wait_for_mission_ack(self, timeout: float = 20.0):
174
+ start = time.monotonic()
175
+ while not self.running and (time.monotonic() - start < timeout):
176
+ await asyncio.sleep(0.1)
177
+
178
+ async def mission_loop(self):
179
+ """Main mission execution loop — started when ACK is received."""
180
+ logger.info(f"{LogIcons.RUN} Mission loop started.")
181
+ try:
182
+ while self.running:
183
+ self.mission_clock.tick()
184
+ status = self.mission_executor.run_step()
185
+ self.running = status["state"] in (str(MissionState.RUNNING), str(MissionState.PAUSED))
186
+
187
+ if status["step_completed"]:
188
+ await self.publish_status_update(status)
189
+
190
+ await self.mission_clock.tock(blocking=False)
191
+ self.mission_ready = False
192
+ except Exception as e:
193
+ self.cancel(action="HOVER")
194
+ logger.error(f"{LogIcons.ERROR} Mission execution error: {e}, trace: {traceback.format_exc()}")
195
+
196
+ logger.info(f"{LogIcons.SUCCESS} Mission completed with final status: {status['state'].name}")
197
+
198
+ # Send mission done message when loop exits
199
+ msg = leafMAV.MAVLink_leaf_done_mission_run_message(
200
+ target_system=self.mavlink_proxy.target_system,
201
+ mission_id=self.mission_plan.id.encode("ascii"),
202
+ )
203
+ self.mavlink_proxy.send(key='mav', msg=msg, burst_count=4, burst_interval=0.1)
204
+
205
+ async def publish_status_update(self, status: dict):
206
+ if self.subscriber_address:
207
+ try:
208
+ async with httpx.AsyncClient(timeout=5.0) as client:
209
+ await client.post(
210
+ self.subscriber_address,
211
+ json=status,
212
+ headers={"Content-Type": "application/json"},
213
+ )
214
+ except Exception as e:
215
+ logger.warning(f"{LogIcons.WARNING} Failed to report progress: {e}")
216
+
217
+ def pause(self):
218
+ if self.mission_plan is None:
219
+ logger.warning(f"{LogIcons.WARNING} No mission plan available to pause")
220
+
221
+ try:
222
+ self.mission_executor.pause()
223
+ except Exception as e:
224
+ logger.error(f"{LogIcons.ERROR} Failed to pause mission: {e}, trace: {traceback.format_exc()}")
225
+
226
+ def resume(self):
227
+ if self.mission_plan is None:
228
+ logger.warning(f"{LogIcons.WARNING} No mission plan available to resume")
229
+
230
+ try:
231
+ self.mission_executor.resume()
232
+ except Exception as e:
233
+ logger.error(f"{LogIcons.ERROR} Failed to resume mission: {e}")
234
+
235
+ def cancel(self, action: str = "NONE"):
236
+ if self.mission_plan is None:
237
+ logger.warning(f"{LogIcons.WARNING} No mission plan available to cancel")
238
+
239
+ try:
240
+ self.mission_executor.cancel(action)
241
+ except Exception as e:
242
+ logger.error(f"{LogIcons.ERROR} Failed to cancel mission: {e}")
243
+
244
+ def abort(self):
245
+ if self.mission_plan is None:
246
+ logger.warning(f"{LogIcons.WARNING} No mission plan available to abort")
247
+
248
+ try:
249
+ self.mission_executor.abort()
250
+ except Exception as e:
251
+ logger.error(f"{LogIcons.ERROR} Failed to abort mission: {e}")
252
+
253
+ def _reset_mission(self):
254
+ """Safely clear mission state."""
255
+ self.running = False
256
+ self.mission_ready = False
257
+ self.mission_executor.reset()
258
+ logger.info(f"{LogIcons.SUCCESS} Mission state reset successfully.")
259
+
260
+ @http_action(
261
+ method="POST",
262
+ path="/mission/plan",
263
+ description="Receives a mission graph and runs it in the background",
264
+ summary="Execute Mission Plan",
265
+ tags=["mission"]
266
+ )
267
+ async def receive_mission(self, data: MissionGraph):
268
+ if self.running or self.mission_ready:
269
+ logger.warning(f"{LogIcons.WARNING} A mission is already loaded.")
270
+ return {"status": f"{LogIcons.WARNING} A mission is already loaded", "error": "Mission in progress"}
271
+
272
+ mission_graph = data.model_dump(by_alias=True)
273
+
274
+ # ✅ Run in background (non-blocking)
275
+ asyncio.create_task(self.run_mission(mission_graph))
276
+
277
+ return {"status": "⏳ Mission is running in background"}
278
+
279
+ @http_action(
280
+ method="POST",
281
+ path="/mission/subscribe_to_progress_updates",
282
+ description="Receives an address where mission progress updates will be posted"
283
+ )
284
+ async def subscribe_to_progress_updates(self, data: ProgressUpdateSubscription):
285
+ self.subscriber_address = data.address.rstrip("/") # remove trailing slash if present
286
+ logger.info(f"{LogIcons.SUCCESS} Subscribed to mission progress updates at: {self.subscriber_address}")
287
+ return {"status": f"{LogIcons.SUCCESS} Subscribed"}
288
+
289
+ # Not used in the current implementation, but can be used to set a safe return waypoint request address
290
+ @http_action(
291
+ method="POST",
292
+ path="/mission/set_safe_return_plan_request_address",
293
+ description="Sets the address for safe return plan requests"
294
+ )
295
+ async def set_safe_return_plan_request_address(self, data: SafeReturnPlanRequestAddress):
296
+ self.safe_return_plan_request_address = data.address.rstrip("/") # remove trailing slash if present
297
+ logger.info(f"{LogIcons.SUCCESS} Set safe return plan request address to: {self.safe_return_plan_request_address}")
298
+ return {"status": f"{LogIcons.SUCCESS} Safe return plan request address set"}
299
+
300
+ # Not used in the current implementation, but can be used to handle safe return plan requests
301
+ @http_action(
302
+ method="GET",
303
+ path="/mission/safe_return_plan_request",
304
+ description="Receives a safe return plan request and feeds it to the mission planner"
305
+ )
306
+ async def safe_return_plan_request(self):
307
+ if not self.safe_return_plan_request_address:
308
+ logger.warning(f"{LogIcons.WARNING} No safe return plan request address set")
309
+ return {"status": f"{LogIcons.WARNING} No safe return plan request address set", "error": "Address not initialized"}
310
+
311
+ pass
312
+
313
+ @http_action(
314
+ method="POST",
315
+ path="/mission/pause",
316
+ description="Pauses the currently running mission"
317
+ )
318
+ async def pause_mission(self):
319
+ if self.mission_plan is None:
320
+ logger.warning(f"{LogIcons.WARNING} No mission plan available to pause")
321
+ return {"status": f"{LogIcons.WARNING} No mission plan available", "error": "Mission plan not initialized"}
322
+
323
+ try:
324
+ self.mission_executor.pause()
325
+
326
+ return {"status": f"{LogIcons.PAUSE} Mission pause command received successfully!"}
327
+ except Exception as e:
328
+ logger.error(f"{LogIcons.ERROR} Failed to pause mission: {e}")
329
+ return {"status": f"{LogIcons.ERROR} Failed to pause mission", "error": str(e)}
330
+
331
+ @http_action(
332
+ method="POST",
333
+ path="/mission/resume",
334
+ description="Resumes a paused mission"
335
+ )
336
+ async def resume_mission(self):
337
+ if self.mission_plan is None:
338
+ logger.warning(f"{LogIcons.WARNING} No mission plan available to resume")
339
+ return {"status": f"{LogIcons.WARNING} No mission plan available", "error": "Mission plan not initialized"}
340
+
341
+ try:
342
+ self.mission_executor.resume()
343
+
344
+ return {"status": f"{LogIcons.RESUME} Mission resume command received successfully!"}
345
+ except Exception as e:
346
+ logger.error(f"{LogIcons.ERROR} Failed to resume mission: {e}")
347
+ return {"status": f"{LogIcons.ERROR} Failed to resume mission", "error": str(e)}
348
+
349
+ @http_action(
350
+ method="POST",
351
+ path="/mission/cancel",
352
+ description="Cancels the currently running mission"
353
+ )
354
+ async def cancel_mission(self, data: CancelMissionRequest):
355
+ if self.mission_plan is None:
356
+ logger.warning(f"{LogIcons.WARNING} No mission plan available to cancel")
357
+ return {"status": f"{LogIcons.WARNING} No mission plan available", "error": "Mission plan not initialized"}
358
+
359
+ try:
360
+ self.mission_executor.cancel(data.action)
361
+
362
+ return {"status": f"{LogIcons.CANCEL} Mission cancel command received successfully!"}
363
+ except Exception as e:
364
+ logger.error(f"{LogIcons.ERROR} Failed to cancel mission: {e}")
365
+ return {"status": f"{LogIcons.ERROR} Failed to cancel mission", "error": str(e)}
366
+
367
+ async def _mqtt_subscribe_to_mission_plan(self):
368
+ if self.mqtt_proxy is None:
369
+ logger.warning(f"{LogIcons.WARNING} MQTT proxy not available. MQTT functionalities will be disabled.")
370
+ return
371
+ self.mqtt_subscription_id = self.mqtt_proxy.register_handler(self._mqtt_command_handler_master)
372
+ logger.info(f"{LogIcons.SUCCESS} registered MQTT command handler with subscription ID: {self.mqtt_subscription_id}")
373
+
374
+ async def _mqtt_command_handler_master(self, topic: str, payload: Dict[str, Any]):
375
+ command = payload.get('command')
376
+ message_id = payload.get('messageId')
377
+
378
+ if command is None or message_id is None:
379
+ logger.warning(f"{LogIcons.WARNING} Invalid command payload received via MQTT.")
380
+ return
381
+
382
+ if command == "petal-leafsdk/mission_plan":
383
+ data_recvd: MissionGraph = payload.get("payload")["mission_plan_json"]
384
+ asyncio.create_task(self._mqtt_command_handler_mission_plan(message_id, data_recvd))
385
+
386
+ async def _mqtt_command_handler_mission_plan(self, msg_id: str, data: Dict[str, Any]):
387
+ if self.running or self.mission_ready:
388
+ logger.warning(f"{LogIcons.WARNING} A mission is already loaded.")
389
+ await self.mqtt_proxy.publish_message({
390
+ 'messageId': msg_id,
391
+ 'status': 'error',
392
+ 'error': 'A mission is already loaded'
393
+ })
394
+ return
395
+
396
+ logger.info(f"{LogIcons.SUCCESS} Received mission plan via MQTT.")
397
+ data_model = MissionGraph.model_validate(data, by_alias=True)
398
+ mission_graph = data_model.model_dump(by_alias=True)
399
+
400
+ # ✅ Run in background (non-blocking)
401
+ asyncio.create_task(self.run_mission(mission_graph))
402
+
403
+ # Send response
404
+ await self.mqtt_proxy.publish_message({
405
+ 'messageId': msg_id,
406
+ 'status': 'success',
407
+ 'result': 'Command executed successfully'
408
+ })
409
+ logger.info(f"{LogIcons.SUCCESS} MQTT command response sent.")
@@ -0,0 +1,33 @@
1
+ # leafsdk/utils/redis_helpers.py
2
+ # Redis helper functions for LeafSDK
3
+
4
+ from leafsdk import logger
5
+ from leafsdk.utils.logstyle import LogIcons
6
+ from petal_app_manager.proxies.redis import RedisProxy
7
+ from typing import Optional
8
+
9
+ def setup_redis_subscriptions(pattern: str, callback: callable, redis_proxy: Optional[RedisProxy] = None):
10
+ """Setup Redis subscriptions - call this after object creation if using Redis"""
11
+ if redis_proxy is None:
12
+ logger.warning(f"{LogIcons.WARNING} Redis proxy not provided, skipping Redis subscriptions")
13
+ return
14
+
15
+ try:
16
+ # Subscribe to a general broadcast channel
17
+ redis_proxy.register_pattern_channel_callback(channel=pattern, callback=callback)
18
+
19
+ logger.info(f"{LogIcons.SUCCESS} Redis subscriptions set up successfully to {pattern}.")
20
+ except Exception as e:
21
+ logger.error(f"{LogIcons.ERROR} Failed to set up Redis subscriptions: {e}")
22
+
23
+ def unsetup_redis_subscriptions(pattern: str, redis_proxy: Optional[RedisProxy] = None):
24
+ """Unsubscribe from Redis channels - call this when the step is no longer needed"""
25
+ if redis_proxy is None:
26
+ logger.warning(f"{LogIcons.WARNING} Redis proxy not provided, skipping Redis unsubscriptions")
27
+ return
28
+
29
+ try:
30
+ redis_proxy.unregister_pattern_channel_callback(channel=pattern)
31
+ logger.info(f"{LogIcons.SUCCESS} Redis subscriptions to {pattern} have been removed.")
32
+ except Exception as e:
33
+ logger.error(f"{LogIcons.ERROR} Failed to remove Redis subscriptions: {e}")
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.1
2
+ Name: petal-leafsdk
3
+ Version: 0.2.4
4
+ Summary: SDK for mission planning
5
+ Author-Email: Khalil Al Handawi <khalil.alhandawi@droneleaf.io>, Suleyman Yildirim <suleyman.yildirim@droneleaf.io>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: petal-app-manager>=0.1.26
9
+ Requires-Dist: LeafSDK<0.3.3,>=0.3.2
10
+ Description-Content-Type: text/markdown
11
+
12
+ # petal-hello-world
@@ -0,0 +1,11 @@
1
+ petal_leafsdk-0.2.4.dist-info/METADATA,sha256=MC0dOqseZDv50HevTa0Gqr1L6E5E-StcBJAz4Ssn4z0,383
2
+ petal_leafsdk-0.2.4.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ petal_leafsdk-0.2.4.dist-info/entry_points.txt,sha256=fYAqfL1mH5znPmRSqSfY_MrjR21WhjTGLQb7ZYS2BT0,110
4
+ petal_leafsdk/__init__.py,sha256=dDZJMO6wrldo0ANuAjfphZUw-1f2mfep_1K6ykQZkNk,425
5
+ petal_leafsdk/data_model.py,sha256=ScZOK4haGqYvJPHrQZIbfqeLDUvoCUvWPDvaIg46m9w,9035
6
+ petal_leafsdk/mavlink_helpers.py,sha256=EUYogmf0ND_BLgL8VPeZw2rOc5H2raZqOphIhllq_hA,2755
7
+ petal_leafsdk/mission_plan_executor.py,sha256=KrBtoncEg3NhL_muc5BCiPleetTKVGP1qO8-Ap1ApZU,17135
8
+ petal_leafsdk/mission_step_executor.py,sha256=CFuMdOtgbPReOOp_7ybL6LweRh3qp8zkWlKR-v1nNSA,28205
9
+ petal_leafsdk/plugin.py,sha256=YUNyDS2_6QAt7bnq4Tj3DUnKSD-BcDDixXMCH1QJnEo,18412
10
+ petal_leafsdk/redis_helpers.py,sha256=dfloq4ltMn3LGafTPQ9TnlpoFETALtOlhbeWrtc_aP8,1550
11
+ petal_leafsdk-0.2.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.6)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+
5
+ [petal.plugins]
6
+ mission_planner = petal_leafsdk.plugin:PetalMissionPlanner
7
+