Coverage for ovos_core/skill_manager.py: 72%
343 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-10 17:58 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-10 17:58 +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 if load_status:
359 self.bus.emit(Message("mycroft.skill.loaded", {"skill_id": skill_id}))
360 except Exception:
361 LOG.exception(f'Load of skill {skill_id} failed!')
362 load_status = False
363 finally:
364 self.plugin_skills[skill_id] = skill_loader
366 return skill_loader if load_status else None
368 def wait_for_intent_service(self):
369 """ensure IntentService reported ready to accept skill messages"""
370 while not self._stop_event.is_set():
371 response = self.bus.wait_for_response(
372 Message('mycroft.intents.is_ready',
373 context={"source": "skills", "destination": "intents"}),
374 timeout=5)
375 if response and response.data.get('status'):
376 return
377 threading.Event().wait(1)
378 raise RuntimeError("Skill manager stopped while waiting for intent service")
380 def run(self):
381 """Run the skill manager thread."""
382 self.status.set_alive()
384 LOG.debug("Waiting for IntentService startup")
385 self.wait_for_intent_service()
386 LOG.debug("IntentService reported ready")
388 self._load_on_startup()
390 # trigger a sync so we dont need to wait for the plugin to volunteer info
391 self._sync_skill_loading_state()
393 if not all((self._network_loaded.is_set(),
394 self._internet_loaded.is_set())):
395 self.bus.emit(Message(
396 'mycroft.skills.error',
397 {'internet_loaded': self._internet_loaded.is_set(),
398 'network_loaded': self._network_loaded.is_set()}))
400 self.bus.emit(Message('mycroft.skills.initialized'))
402 self.status.set_ready()
404 LOG.info("ovos-core is ready! additional skills can now be loaded")
406 # Scan the file folder that contains Skills. If a Skill is updated,
407 # unload the existing version from memory and reload from the disk.
408 while not self._stop_event.wait(30):
409 try:
410 self._load_new_skills()
411 self._watchdog()
412 except Exception:
413 LOG.exception('Something really unexpected has occurred '
414 'and the skill manager loop safety harness was '
415 'hit.')
417 def _load_on_network(self):
418 """Load skills that require a network connection."""
419 if self._detected_installed_skills: # ensure we have skills installed
420 LOG.info('Loading skills that require network...')
421 self._load_new_skills(network=True, internet=False)
422 self._network_loaded.set()
424 def _load_on_internet(self):
425 """Load skills that require both internet and network connections."""
426 if self._detected_installed_skills: # ensure we have skills installed
427 LOG.info('Loading skills that require internet (and network)...')
428 self._load_new_skills(network=True, internet=True)
429 self._internet_loaded.set()
430 self._network_loaded.set()
432 def _unload_on_network_disconnect(self):
433 """Unload skills that require a network connection to work."""
434 # TODO - implementation missing
436 def _unload_on_internet_disconnect(self):
437 """Unload skills that require an internet connection to work."""
438 # TODO - implementation missing
440 def _unload_on_gui_disconnect(self):
441 """Unload skills that require a GUI to work."""
442 # TODO - implementation missing
444 def _load_on_startup(self):
445 """Handle offline skills load on startup."""
446 if self._detected_installed_skills: # ensure we have skills installed
447 LOG.info('Loading offline skills...')
448 self._load_new_skills(network=False, internet=False)
450 def _load_new_skills(self, network=None, internet=None, gui=None):
451 """Handle loading of skills installed since startup.
453 Args:
454 network (bool): Network connection status.
455 internet (bool): Internet connection status.
456 gui (bool): GUI connection status.
457 """
458 if network is None:
459 network = self._network_event.is_set()
460 if internet is None:
461 internet = self._connected_event.is_set()
462 if gui is None:
463 gui = self._gui_event.is_set() or is_gui_connected(self.bus)
465 loaded_new = self.load_plugin_skills(network=network, internet=internet)
467 if loaded_new:
468 LOG.debug("Requesting pipeline intent training")
469 try:
470 response = self.bus.wait_for_response(Message("mycroft.skills.train"),
471 "mycroft.skills.trained",
472 timeout=60) # 60 second timeout
473 if not response:
474 LOG.error("Intent training timed out")
475 elif response.data.get('error'):
476 LOG.error(f"Intent training failed: {response.data['error']}")
477 else:
478 LOG.debug(f"pipelines trained and ready to go")
479 except Exception as e:
480 LOG.exception(f"Error during Intent training: {e}")
482 def _unload_plugin_skill(self, skill_id):
483 """Unload a plugin skill.
485 Args:
486 skill_id (str): Identifier of the plugin skill to unload.
487 """
488 if skill_id in self.plugin_skills:
489 LOG.info('Unloading plugin skill: ' + skill_id)
490 skill_loader = self.plugin_skills[skill_id]
491 if skill_loader.instance is not None:
492 try:
493 skill_loader.instance.shutdown()
494 except Exception:
495 LOG.exception('Failed to run skill specific shutdown code: ' + skill_loader.skill_id)
496 try:
497 skill_loader.instance.default_shutdown()
498 except Exception:
499 LOG.exception('Failed to shutdown skill: ' + skill_loader.skill_id)
500 self.plugin_skills.pop(skill_id)
502 def is_alive(self, message=None):
503 """Respond to is_alive status request."""
504 return self.status.state >= ProcessState.ALIVE
506 def is_all_loaded(self, message=None):
507 """ Respond to all_loaded status request."""
508 return self.status.state == ProcessState.READY
510 def send_skill_list(self, message=None):
511 """Send list of loaded skills."""
512 try:
513 message_data = {}
514 # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for
515 skills = self.plugin_skills
516 for skill_loader in skills.values():
517 message_data[skill_loader.skill_id] = {
518 "active": skill_loader.active and skill_loader.loaded,
519 "id": skill_loader.skill_id}
521 self.bus.emit(Message('mycroft.skills.list', data=message_data))
522 except Exception:
523 LOG.exception('Failed to send skill list')
525 def deactivate_skill(self, message):
526 """Deactivate a skill."""
527 try:
528 # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for
529 skills = self.plugin_skills
530 for skill_loader in skills.values():
531 if message.data['skill'] == skill_loader.skill_id:
532 LOG.info("Deactivating (unloading) skill: " + skill_loader.skill_id)
533 skill_loader.deactivate()
534 self.bus.emit(message.response())
535 except Exception as err:
536 LOG.exception('Failed to deactivate ' + message.data['skill'])
537 self.bus.emit(message.response({'error': f'failed: {err}'}))
539 def deactivate_except(self, message):
540 """Deactivate all skills except the provided."""
541 try:
542 skill_to_keep = message.data['skill']
543 LOG.info(f'Deactivating (unloading) all skills except {skill_to_keep}')
544 # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for
545 skills = self.plugin_skills
546 for skill in skills.values():
547 if skill.skill_id != skill_to_keep:
548 skill.deactivate()
549 LOG.info('Couldn\'t find skill ' + message.data['skill'])
550 except Exception:
551 LOG.exception('An error occurred during skill deactivation!')
553 def activate_skill(self, message):
554 """Activate a deactivated skill."""
555 try:
556 # TODO handle external skills, OVOSAbstractApp/Hivemind skills are not accounted for
557 skills = self.plugin_skills
558 for skill_loader in skills.values():
559 if (message.data['skill'] in ('all', skill_loader.skill_id)
560 and not skill_loader.active):
561 skill_loader.activate()
562 self.bus.emit(message.response())
563 except Exception as err:
564 LOG.exception(f'Couldn\'t activate (load) skill {message.data["skill"]}')
565 self.bus.emit(message.response({'error': f'failed: {err}'}))
567 def stop(self):
568 """alias for shutdown (backwards compat)"""
569 return self.shutdown()
571 def shutdown(self):
572 """Tell the manager to shutdown."""
573 self.status.set_stopping()
574 self._stop_event.set()
576 # Do a clean shutdown of all skills
577 for skill_id in list(self.plugin_skills.keys()):
578 try:
579 self._unload_plugin_skill(skill_id)
580 except Exception as e:
581 LOG.error(f"Failed to cleanly unload skill '{skill_id}' ({e})")
582 if self.intents:
583 try:
584 self.intents.shutdown()
585 except Exception as e:
586 LOG.error(f"Failed to cleanly unload intent service ({e})")
587 if self.osm:
588 try:
589 self.osm.shutdown()
590 except Exception as e:
591 LOG.error(f"Failed to cleanly unload skill installer ({e})")
592 if self.event_scheduler:
593 try:
594 self.event_scheduler.shutdown()
595 except Exception as e:
596 LOG.error(f"Failed to cleanly unload event scheduler ({e})")
597 if self._settings_watchdog:
598 try:
599 self._settings_watchdog.shutdown()
600 except Exception as e:
601 LOG.error(f"Failed to cleanly unload settings watchdog ({e})")