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

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 

19 

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 

35 

36from ovos_plugin_manager.skills import find_skill_plugins 

37 

38 

39def on_started(): 

40 LOG.info('Skills Manager is starting up.') 

41 

42 

43def on_alive(): 

44 LOG.info('Skills Manager is alive.') 

45 

46 

47def on_ready(): 

48 LOG.info('Skills Manager is ready.') 

49 

50 

51def on_error(e='Unknown'): 

52 LOG.info(f'Skills Manager failed to launch ({e})') 

53 

54 

55def on_stopping(): 

56 LOG.info('Skills Manager is shutting down...') 

57 

58 

59class SkillManager(Thread): 

60 """Manages the loading, activation, and deactivation of Mycroft skills.""" 

61 

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 

70 

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() 

92 

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!") 

108 

109 self.config = Configuration() 

110 

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. 

115 

116 self._define_message_bus_events() 

117 self.daemon = True 

118 

119 self.status.bind(self.bus) 

120 

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() 

132 

133 @property 

134 def blacklist(self): 

135 """Get the list of blacklisted skills from the configuration. 

136 

137 Returns: 

138 list: List of blacklisted skill ids. 

139 """ 

140 return Configuration().get("skills", {}).get("blacklisted_skills", []) 

141 

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) 

150 

151 def _handle_settings_file_change(self, path: str): 

152 """Handle changes to skill settings files. 

153 

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})) 

162 

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() 

170 

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() 

179 

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")) 

186 

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) 

194 

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) 

202 

203 @property 

204 def skills_config(self): 

205 """Get the skills service configuration. 

206 

207 Returns: 

208 dict: Skills configuration. 

209 """ 

210 return self.config['skills'] 

211 

212 def handle_gui_connected(self, message): 

213 """Handle GUI connection event. 

214 

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() 

224 

225 def handle_gui_disconnected(self, message): 

226 """Handle GUI disconnection event. 

227 

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() 

234 

235 def handle_internet_disconnected(self, message): 

236 """Handle internet disconnection event. 

237 

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() 

244 

245 def handle_network_disconnected(self, message): 

246 """Handle network disconnection event. 

247 

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() 

254 

255 def handle_internet_connected(self, message): 

256 """Handle internet connection event. 

257 

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() 

266 

267 def handle_network_connected(self, message): 

268 """Handle network connection event. 

269 

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() 

277 

278 def load_plugin_skills(self, network=None, internet=None): 

279 """Load plugin skills based on network and internet status. 

280 

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 

309 

310 def _get_internal_skill_bus(self): 

311 """Get a dedicated skill bus connection per skill. 

312 

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 

326 

327 def _get_plugin_skill_loader(self, skill_id, init_bus=True, skill_class=None): 

328 """Get a plugin skill loader. 

329 

330 Args: 

331 skill_id (str): ID of the skill. 

332 init_bus (bool): Whether to initialize the internal skill bus. 

333 

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 

344 

345 def _load_plugin_skill(self, skill_id, skill_plugin): 

346 """Load a plugin skill. 

347 

348 Args: 

349 skill_id (str): ID of the skill. 

350 skill_plugin: Plugin skill instance. 

351 

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 

363 

364 return skill_loader if load_status else None 

365 

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") 

377 

378 def run(self): 

379 """Run the skill manager thread.""" 

380 self.status.set_alive() 

381 

382 LOG.debug("Waiting for IntentService startup") 

383 self.wait_for_intent_service() 

384 LOG.debug("IntentService reported ready") 

385 

386 self._load_on_startup() 

387 

388 # trigger a sync so we dont need to wait for the plugin to volunteer info 

389 self._sync_skill_loading_state() 

390 

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()})) 

397 

398 self.bus.emit(Message('mycroft.skills.initialized')) 

399 

400 self.status.set_ready() 

401 

402 LOG.info("ovos-core is ready! additional skills can now be loaded") 

403 

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.') 

414 

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() 

421 

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() 

429 

430 def _unload_on_network_disconnect(self): 

431 """Unload skills that require a network connection to work.""" 

432 # TODO - implementation missing 

433 

434 def _unload_on_internet_disconnect(self): 

435 """Unload skills that require an internet connection to work.""" 

436 # TODO - implementation missing 

437 

438 def _unload_on_gui_disconnect(self): 

439 """Unload skills that require a GUI to work.""" 

440 # TODO - implementation missing 

441 

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) 

447 

448 def _load_new_skills(self, network=None, internet=None, gui=None): 

449 """Handle loading of skills installed since startup. 

450 

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) 

462 

463 loaded_new = self.load_plugin_skills(network=network, internet=internet) 

464 

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}") 

479 

480 def _unload_plugin_skill(self, skill_id): 

481 """Unload a plugin skill. 

482 

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) 

499 

500 def is_alive(self, message=None): 

501 """Respond to is_alive status request.""" 

502 return self.status.state >= ProcessState.ALIVE 

503 

504 def is_all_loaded(self, message=None): 

505 """ Respond to all_loaded status request.""" 

506 return self.status.state == ProcessState.READY 

507 

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} 

518 

519 self.bus.emit(Message('mycroft.skills.list', data=message_data)) 

520 except Exception: 

521 LOG.exception('Failed to send skill list') 

522 

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}'})) 

536 

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!') 

550 

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}'})) 

564 

565 def stop(self): 

566 """alias for shutdown (backwards compat)""" 

567 return self.shutdown() 

568 

569 def shutdown(self): 

570 """Tell the manager to shutdown.""" 

571 self.status.set_stopping() 

572 self._stop_event.set() 

573 

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})")