Coverage for ovos_core/skill_manager.py: 71%
341 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-17 13:44 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-17 13:44 +0000
1# Copyright 2017 Mycroft AI Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14#
15"""Load, update and manage skills on this device."""
16import os
17import threading
18from threading import Thread, Event
20from ovos_bus_client.apis.enclosure import EnclosureAPI
21from ovos_bus_client.client import MessageBusClient
22from ovos_bus_client.message import Message
23from ovos_bus_client.util.scheduler import EventScheduler
24from ovos_config.config import Configuration
25from ovos_config.locations import get_xdg_config_save_path
26from ovos_utils.file_utils import FileWatcher
27from ovos_utils.gui import is_gui_connected
28from ovos_utils.log import LOG
29from ovos_utils.network_utils import is_connected_http
30from ovos_utils.process_utils import ProcessStatus, StatusCallbackMap, ProcessState
31from ovos_workshop.skill_launcher import PluginSkillLoader
32from ovos_core.skill_installer import SkillsStore
33from ovos_core.intent_services import IntentService
34from ovos_workshop.skills.api import SkillApi
36from ovos_plugin_manager.skills import find_skill_plugins
39def on_started():
40 LOG.info('Skills Manager is starting up.')
43def on_alive():
44 LOG.info('Skills Manager is alive.')
47def on_ready():
48 LOG.info('Skills Manager is ready.')
51def on_error(e='Unknown'):
52 LOG.info(f'Skills Manager failed to launch ({e})')
55def on_stopping():
56 LOG.info('Skills Manager is shutting down...')
59class SkillManager(Thread):
60 """Manages the loading, activation, and deactivation of Mycroft skills."""
62 def __init__(self, bus, watchdog=None, alive_hook=on_alive, started_hook=on_started, ready_hook=on_ready,
63 error_hook=on_error, stopping_hook=on_stopping,
64 enable_installer=False,
65 enable_intent_service=False,
66 enable_event_scheduler=False,
67 enable_file_watcher=True,
68 enable_skill_api=False):
69 """Constructor
71 Args:
72 bus (event emitter): Mycroft messagebus connection
73 watchdog (callable): optional watchdog function
74 alive_hook (callable): callback function for skill alive status
75 started_hook (callable): callback function for skill started status
76 ready_hook (callable): callback function for skill ready status
77 error_hook (callable): callback function for skill error status
78 stopping_hook (callable): callback function for skill stopping status
79 """
80 super(SkillManager, self).__init__()
81 self.bus = bus
82 self._settings_watchdog = None
83 # Set watchdog to argument or function returning None
84 self._watchdog = watchdog or (lambda: None)
85 callbacks = StatusCallbackMap(on_started=started_hook,
86 on_alive=alive_hook,
87 on_ready=ready_hook,
88 on_error=error_hook,
89 on_stopping=stopping_hook)
90 self.status = ProcessStatus('skills', callback_map=callbacks)
91 self.status.set_started()
93 self._setup_event = Event()
94 self._stop_event = Event()
95 self._connected_event = Event()
96 self._network_event = Event()
97 self._gui_event = Event()
98 self._network_loaded = Event()
99 self._internet_loaded = Event()
100 self._network_skill_timeout = 300
101 self._allow_state_reloads = True
102 self._logged_skill_warnings = list()
103 self._detected_installed_skills = bool(find_skill_plugins())
104 if not self._detected_installed_skills:
105 LOG.warning(
106 "No installed skills detected! if you are running skills in standalone mode ignore this warning,"
107 " otherwise you probably want to install skills first!")
109 self.config = Configuration()
111 self.plugin_skills = {}
112 self.enclosure = EnclosureAPI(bus)
113 self.num_install_retries = 0
114 self.empty_skill_dirs = set() # Save a record of empty skill dirs.
116 self._define_message_bus_events()
117 self.daemon = True
119 self.status.bind(self.bus)
121 # init subsystems
122 self.osm = SkillsStore(self.bus) if enable_installer else None
123 self.event_scheduler = EventScheduler(self.bus, autostart=False) if enable_event_scheduler else None
124 if self.event_scheduler:
125 self.event_scheduler.daemon = True # TODO - add kwarg in EventScheduler
126 self.event_scheduler.start()
127 self.intents = IntentService(self.bus) if enable_intent_service else None
128 if enable_skill_api:
129 SkillApi.connect_bus(self.bus)
130 if enable_file_watcher:
131 self._init_filewatcher()
133 @property
134 def blacklist(self):
135 """Get the list of blacklisted skills from the configuration.
137 Returns:
138 list: List of blacklisted skill ids.
139 """
140 return Configuration().get("skills", {}).get("blacklisted_skills", [])
142 def _init_filewatcher(self):
143 """Initialize the file watcher to monitor skill settings files for changes."""
144 sspath = f"{get_xdg_config_save_path()}/skills/"
145 os.makedirs(sspath, exist_ok=True)
146 self._settings_watchdog = FileWatcher([sspath],
147 callback=self._handle_settings_file_change,
148 recursive=True,
149 ignore_creation=True)
151 def _handle_settings_file_change(self, path: str):
152 """Handle changes to skill settings files.
154 Args:
155 path (str): Path to the settings file that has changed.
156 """
157 if path.endswith("/settings.json"):
158 skill_id = path.split("/")[-2]
159 LOG.info(f"skill settings.json change detected for {skill_id}")
160 self.bus.emit(Message("ovos.skills.settings_changed",
161 {"skill_id": skill_id}))
163 def _sync_skill_loading_state(self):
164 """Synchronize the loading state of skills with the current system state."""
165 resp = self.bus.wait_for_response(Message("ovos.PHAL.internet_check"))
166 network = False
167 internet = False
168 if not self._gui_event.is_set() and is_gui_connected(self.bus):
169 self._gui_event.set()
171 if resp:
172 if resp.data.get('internet_connected'):
173 network = internet = True
174 elif resp.data.get('network_connected'):
175 network = True
176 else:
177 LOG.debug("ovos-phal-plugin-connectivity-events not detected, performing direct network checks")
178 network = internet = is_connected_http()
180 if internet and not self._connected_event.is_set():
181 LOG.debug("Notify internet connected")
182 self.bus.emit(Message("mycroft.internet.connected"))
183 elif network and not self._network_event.is_set():
184 LOG.debug("Notify network connected")
185 self.bus.emit(Message("mycroft.network.connected"))
187 def _define_message_bus_events(self):
188 """Define message bus events with handlers defined in this class."""
189 # Update upon request
190 self.bus.on('skillmanager.list', self.send_skill_list)
191 self.bus.on('skillmanager.deactivate', self.deactivate_skill)
192 self.bus.on('skillmanager.keep', self.deactivate_except)
193 self.bus.on('skillmanager.activate', self.activate_skill)
195 # Load skills waiting for connectivity
196 self.bus.on("mycroft.network.connected", self.handle_network_connected)
197 self.bus.on("mycroft.internet.connected", self.handle_internet_connected)
198 self.bus.on("mycroft.gui.available", self.handle_gui_connected)
199 self.bus.on("mycroft.network.disconnected", self.handle_network_disconnected)
200 self.bus.on("mycroft.internet.disconnected", self.handle_internet_disconnected)
201 self.bus.on("mycroft.gui.unavailable", self.handle_gui_disconnected)
203 @property
204 def skills_config(self):
205 """Get the skills service configuration.
207 Returns:
208 dict: Skills configuration.
209 """
210 return self.config['skills']
212 def handle_gui_connected(self, message):
213 """Handle GUI connection event.
215 Args:
216 message: Message containing information about the GUI connection.
217 """
218 # Some GUI extensions, such as mobile, may request that skills never unload
219 self._allow_state_reloads = not message.data.get("permanent", False)
220 if not self._gui_event.is_set():
221 LOG.debug("GUI Connected")
222 self._gui_event.set()
223 self._load_new_skills()
225 def handle_gui_disconnected(self, message):
226 """Handle GUI disconnection event.
228 Args:
229 message: Message containing information about the GUI disconnection.
230 """
231 if self._allow_state_reloads:
232 self._gui_event.clear()
233 self._unload_on_gui_disconnect()
235 def handle_internet_disconnected(self, message):
236 """Handle internet disconnection event.
238 Args:
239 message: Message containing information about the internet disconnection.
240 """
241 if self._allow_state_reloads:
242 self._connected_event.clear()
243 self._unload_on_internet_disconnect()
245 def handle_network_disconnected(self, message):
246 """Handle network disconnection event.
248 Args:
249 message: Message containing information about the network disconnection.
250 """
251 if self._allow_state_reloads:
252 self._network_event.clear()
253 self._unload_on_network_disconnect()
255 def handle_internet_connected(self, message):
256 """Handle internet connection event.
258 Args:
259 message: Message containing information about the internet connection.
260 """
261 if not self._connected_event.is_set():
262 LOG.debug("Internet Connected")
263 self._network_event.set()
264 self._connected_event.set()
265 self._load_on_internet()
267 def handle_network_connected(self, message):
268 """Handle network connection event.
270 Args:
271 message: Message containing information about the network connection.
272 """
273 if not self._network_event.is_set():
274 LOG.debug("Network Connected")
275 self._network_event.set()
276 self._load_on_network()
278 def load_plugin_skills(self, network=None, internet=None):
279 """Load plugin skills based on network and internet status.
281 Args:
282 network (bool): Network connection status.
283 internet (bool): Internet connection status.
284 """
285 loaded_new = False
286 if network is None:
287 network = self._network_event.is_set()
288 if internet is None:
289 internet = self._connected_event.is_set()
290 plugins = find_skill_plugins()
291 for skill_id, plug in plugins.items():
292 if skill_id in self.blacklist:
293 if skill_id not in self._logged_skill_warnings:
294 self._logged_skill_warnings.append(skill_id)
295 LOG.warning(f"{skill_id} is blacklisted, it will NOT be loaded")
296 LOG.info(f"Consider uninstalling {skill_id} instead of blacklisting it")
297 continue
298 if skill_id not in self.plugin_skills:
299 skill_loader = self._get_plugin_skill_loader(skill_id, init_bus=False,
300 skill_class=plug)
301 requirements = skill_loader.runtime_requirements
302 if not network and requirements.network_before_load:
303 continue
304 if not internet and requirements.internet_before_load:
305 continue
306 self._load_plugin_skill(skill_id, plug)
307 loaded_new = True
308 return loaded_new
310 def _get_internal_skill_bus(self):
311 """Get a dedicated skill bus connection per skill.
313 Returns:
314 MessageBusClient: Internal skill bus.
315 """
316 if not self.config["websocket"].get("shared_connection", True):
317 # See BusBricker skill to understand why this matters.
318 # Any skill can manipulate the bus from other skills.
319 # This patch ensures each skill gets its own connection that can't be manipulated by others.
320 # https://github.com/EvilJarbas/BusBrickerSkill
321 bus = MessageBusClient(cache=True)
322 bus.run_in_thread()
323 else:
324 bus = self.bus
325 return bus
327 def _get_plugin_skill_loader(self, skill_id, init_bus=True, skill_class=None):
328 """Get a plugin skill loader.
330 Args:
331 skill_id (str): ID of the skill.
332 init_bus (bool): Whether to initialize the internal skill bus.
334 Returns:
335 PluginSkillLoader: Plugin skill loader instance.
336 """
337 bus = None
338 if init_bus:
339 bus = self._get_internal_skill_bus()
340 loader = PluginSkillLoader(bus, skill_id)
341 if skill_class:
342 loader.skill_class = skill_class
343 return loader
345 def _load_plugin_skill(self, skill_id, skill_plugin):
346 """Load a plugin skill.
348 Args:
349 skill_id (str): ID of the skill.
350 skill_plugin: Plugin skill instance.
352 Returns:
353 PluginSkillLoader: Loaded plugin skill loader instance if successful, None otherwise.
354 """
355 skill_loader = self._get_plugin_skill_loader(skill_id, skill_class=skill_plugin)
356 try:
357 load_status = skill_loader.load(skill_plugin)
358 except Exception:
359 LOG.exception(f'Load of skill {skill_id} failed!')
360 load_status = False
361 finally:
362 self.plugin_skills[skill_id] = skill_loader
364 return skill_loader if load_status else None
366 def wait_for_intent_service(self):
367 """ensure IntentService reported ready to accept skill messages"""
368 while not self._stop_event.is_set():
369 response = self.bus.wait_for_response(
370 Message('mycroft.intents.is_ready',
371 context={"source": "skills", "destination": "intents"}),
372 timeout=5)
373 if response and response.data.get('status'):
374 return
375 threading.Event().wait(1)
376 raise RuntimeError("Skill manager stopped while waiting for intent service")
378 def run(self):
379 """Run the skill manager thread."""
380 self.status.set_alive()
382 LOG.debug("Waiting for IntentService startup")
383 self.wait_for_intent_service()
384 LOG.debug("IntentService reported ready")
386 self._load_on_startup()
388 # trigger a sync so we dont need to wait for the plugin to volunteer info
389 self._sync_skill_loading_state()
391 if not all((self._network_loaded.is_set(),
392 self._internet_loaded.is_set())):
393 self.bus.emit(Message(
394 'mycroft.skills.error',
395 {'internet_loaded': self._internet_loaded.is_set(),
396 'network_loaded': self._network_loaded.is_set()}))
398 self.bus.emit(Message('mycroft.skills.initialized'))
400 self.status.set_ready()
402 LOG.info("ovos-core is ready! additional skills can now be loaded")
404 # Scan the file folder that contains Skills. If a Skill is updated,
405 # unload the existing version from memory and reload from the disk.
406 while not self._stop_event.wait(30):
407 try:
408 self._load_new_skills()
409 self._watchdog()
410 except Exception:
411 LOG.exception('Something really unexpected has occurred '
412 'and the skill manager loop safety harness was '
413 'hit.')
415 def _load_on_network(self):
416 """Load skills that require a network connection."""
417 if self._detected_installed_skills: # ensure we have skills installed
418 LOG.info('Loading skills that require network...')
419 self._load_new_skills(network=True, internet=False)
420 self._network_loaded.set()
422 def _load_on_internet(self):
423 """Load skills that require both internet and network connections."""
424 if self._detected_installed_skills: # ensure we have skills installed
425 LOG.info('Loading skills that require internet (and network)...')
426 self._load_new_skills(network=True, internet=True)
427 self._internet_loaded.set()
428 self._network_loaded.set()
430 def _unload_on_network_disconnect(self):
431 """Unload skills that require a network connection to work."""
432 # TODO - implementation missing
434 def _unload_on_internet_disconnect(self):
435 """Unload skills that require an internet connection to work."""
436 # TODO - implementation missing
438 def _unload_on_gui_disconnect(self):
439 """Unload skills that require a GUI to work."""
440 # TODO - implementation missing
442 def _load_on_startup(self):
443 """Handle offline skills load on startup."""
444 if self._detected_installed_skills: # ensure we have skills installed
445 LOG.info('Loading offline skills...')
446 self._load_new_skills(network=False, internet=False)
448 def _load_new_skills(self, network=None, internet=None, gui=None):
449 """Handle loading of skills installed since startup.
451 Args:
452 network (bool): Network connection status.
453 internet (bool): Internet connection status.
454 gui (bool): GUI connection status.
455 """
456 if network is None:
457 network = self._network_event.is_set()
458 if internet is None:
459 internet = self._connected_event.is_set()
460 if gui is None:
461 gui = self._gui_event.is_set() or is_gui_connected(self.bus)
463 loaded_new = self.load_plugin_skills(network=network, internet=internet)
465 if loaded_new:
466 LOG.debug("Requesting pipeline intent training")
467 try:
468 response = self.bus.wait_for_response(Message("mycroft.skills.train"),
469 "mycroft.skills.trained",
470 timeout=60) # 60 second timeout
471 if not response:
472 LOG.error("Intent training timed out")
473 elif response.data.get('error'):
474 LOG.error(f"Intent training failed: {response.data['error']}")
475 else:
476 LOG.debug(f"pipelines trained and ready to go")
477 except Exception as e:
478 LOG.exception(f"Error during Intent training: {e}")
480 def _unload_plugin_skill(self, skill_id):
481 """Unload a plugin skill.
483 Args:
484 skill_id (str): Identifier of the plugin skill to unload.
485 """
486 if skill_id in self.plugin_skills:
487 LOG.info('Unloading plugin skill: ' + skill_id)
488 skill_loader = self.plugin_skills[skill_id]
489 if skill_loader.instance is not None:
490 try:
491 skill_loader.instance.shutdown()
492 except Exception:
493 LOG.exception('Failed to run skill specific shutdown code: ' + skill_loader.skill_id)
494 try:
495 skill_loader.instance.default_shutdown()
496 except Exception:
497 LOG.exception('Failed to shutdown skill: ' + skill_loader.skill_id)
498 self.plugin_skills.pop(skill_id)
500 def is_alive(self, message=None):
501 """Respond to is_alive status request."""
502 return self.status.state >= ProcessState.ALIVE
504 def is_all_loaded(self, message=None):
505 """ Respond to all_loaded status request."""
506 return self.status.state == ProcessState.READY
508 def send_skill_list(self, message=None):
509 """Send list of loaded skills."""
510 try:
511 message_data = {}
512 # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for
513 skills = self.plugin_skills
514 for skill_loader in skills.values():
515 message_data[skill_loader.skill_id] = {
516 "active": skill_loader.active and skill_loader.loaded,
517 "id": skill_loader.skill_id}
519 self.bus.emit(Message('mycroft.skills.list', data=message_data))
520 except Exception:
521 LOG.exception('Failed to send skill list')
523 def deactivate_skill(self, message):
524 """Deactivate a skill."""
525 try:
526 # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for
527 skills = self.plugin_skills
528 for skill_loader in skills.values():
529 if message.data['skill'] == skill_loader.skill_id:
530 LOG.info("Deactivating (unloading) skill: " + skill_loader.skill_id)
531 skill_loader.deactivate()
532 self.bus.emit(message.response())
533 except Exception as err:
534 LOG.exception('Failed to deactivate ' + message.data['skill'])
535 self.bus.emit(message.response({'error': f'failed: {err}'}))
537 def deactivate_except(self, message):
538 """Deactivate all skills except the provided."""
539 try:
540 skill_to_keep = message.data['skill']
541 LOG.info(f'Deactivating (unloading) all skills except {skill_to_keep}')
542 # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for
543 skills = self.plugin_skills
544 for skill in skills.values():
545 if skill.skill_id != skill_to_keep:
546 skill.deactivate()
547 LOG.info('Couldn\'t find skill ' + message.data['skill'])
548 except Exception:
549 LOG.exception('An error occurred during skill deactivation!')
551 def activate_skill(self, message):
552 """Activate a deactivated skill."""
553 try:
554 # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for
555 skills = self.plugin_skills
556 for skill_loader in skills.values():
557 if (message.data['skill'] in ('all', skill_loader.skill_id)
558 and not skill_loader.active):
559 skill_loader.activate()
560 self.bus.emit(message.response())
561 except Exception as err:
562 LOG.exception(f'Couldn\'t activate (load) skill {message.data["skill"]}')
563 self.bus.emit(message.response({'error': f'failed: {err}'}))
565 def stop(self):
566 """alias for shutdown (backwards compat)"""
567 return self.shutdown()
569 def shutdown(self):
570 """Tell the manager to shutdown."""
571 self.status.set_stopping()
572 self._stop_event.set()
574 # Do a clean shutdown of all skills
575 for skill_id in list(self.plugin_skills.keys()):
576 try:
577 self._unload_plugin_skill(skill_id)
578 except Exception as e:
579 LOG.error(f"Failed to cleanly unload skill '{skill_id}' ({e})")
580 if self.intents:
581 try:
582 self.intents.shutdown()
583 except Exception as e:
584 LOG.error(f"Failed to cleanly unload intent service ({e})")
585 if self.osm:
586 try:
587 self.osm.shutdown()
588 except Exception as e:
589 LOG.error(f"Failed to cleanly unload skill installer ({e})")
590 if self.event_scheduler:
591 try:
592 self.event_scheduler.shutdown()
593 except Exception as e:
594 LOG.error(f"Failed to cleanly unload event scheduler ({e})")
595 if self._settings_watchdog:
596 try:
597 self._settings_watchdog.shutdown()
598 except Exception as e:
599 LOG.error(f"Failed to cleanly unload settings watchdog ({e})")