251 lines
9.3 KiB
Python
251 lines
9.3 KiB
Python
# Copyright 2019 Mycroft AI Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
"""Periodically run by skill manager to update skills and post the manifest."""
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from time import time
|
|
|
|
from msm import MsmException
|
|
|
|
from mycroft.api import DeviceApi, is_paired
|
|
from mycroft.configuration import Configuration
|
|
from mycroft.util import connected
|
|
from mycroft.util.combo_lock import ComboLock
|
|
from mycroft.util.log import LOG
|
|
from .msm_wrapper import build_msm_config, create_msm
|
|
|
|
ONE_HOUR = 3600
|
|
FIVE_MINUTES = 300 # number of seconds in a minute
|
|
|
|
|
|
class SkillUpdater:
|
|
"""Class facilitating skill update / install actions.
|
|
|
|
Arguments
|
|
bus (MessageBusClient): Optional bus emitter Used to communicate
|
|
with the mycroft core system and handle
|
|
commands.
|
|
"""
|
|
_installed_skills_file_path = None
|
|
_msm = None
|
|
|
|
def __init__(self, bus=None):
|
|
self.msm_lock = ComboLock('/tmp/mycroft-msm.lck')
|
|
self.install_retries = 0
|
|
update_interval = self.config['skills']['update_interval']
|
|
self.update_interval = int(update_interval) * ONE_HOUR
|
|
self.dot_msm_path = os.path.join(self.msm.skills_dir, '.msm')
|
|
self.next_download = self._determine_next_download_time()
|
|
self._log_next_download_time()
|
|
self.installed_skills = set()
|
|
self.default_skill_install_error = False
|
|
|
|
if bus:
|
|
self._register_bus_handlers()
|
|
|
|
def _register_bus_handlers(self):
|
|
"""TODO: Register bus handlers for triggering updates and such."""
|
|
|
|
def _determine_next_download_time(self):
|
|
"""Determine the initial values of the next/last download times.
|
|
|
|
Update immediately if the .msm or installed skills file is missing
|
|
otherwise use the timestamp on .msm as a basis.
|
|
"""
|
|
msm_files_exist = (
|
|
os.path.exists(self.dot_msm_path) and
|
|
os.path.exists(self.installed_skills_file_path)
|
|
)
|
|
if msm_files_exist:
|
|
mtime = os.path.getmtime(self.dot_msm_path)
|
|
next_download = mtime + self.update_interval
|
|
else:
|
|
# Last update can't be found or the requirements don't seem to be
|
|
# installed trigger update before skill loading
|
|
next_download = time() - 1
|
|
|
|
return next_download
|
|
|
|
@property
|
|
def config(self):
|
|
"""Property representing the device configuration."""
|
|
return Configuration.get()
|
|
|
|
@property
|
|
def installed_skills_file_path(self):
|
|
"""Property representing the path of the installed skills file."""
|
|
if self._installed_skills_file_path is None:
|
|
virtual_env_path = os.path.dirname(os.path.dirname(sys.executable))
|
|
if os.access(virtual_env_path, os.W_OK | os.R_OK | os.X_OK):
|
|
self._installed_skills_file_path = os.path.join(
|
|
virtual_env_path,
|
|
'.mycroft-skills'
|
|
)
|
|
else:
|
|
self._installed_skills_file_path = os.path.expanduser(
|
|
'~/.mycroft/.mycroft-skills'
|
|
)
|
|
|
|
return self._installed_skills_file_path
|
|
|
|
@property
|
|
def msm(self):
|
|
if self._msm is None:
|
|
msm_config = build_msm_config(self.config)
|
|
self._msm = create_msm(msm_config)
|
|
|
|
return self._msm
|
|
|
|
@property
|
|
def default_skill_names(self) -> tuple:
|
|
"""Property representing the default skills expected to be installed"""
|
|
default_skill_groups = dict(self.msm.repo.get_default_skill_names())
|
|
default_skills = set(default_skill_groups['default'])
|
|
platform_default_skills = default_skill_groups.get(self.msm.platform)
|
|
if platform_default_skills is None:
|
|
log_msg = 'No default skills found for platform {}'
|
|
LOG.info(log_msg.format(self.msm.platform))
|
|
else:
|
|
default_skills.update(platform_default_skills)
|
|
|
|
return tuple(default_skills)
|
|
|
|
def _load_installed_skills(self):
|
|
"""Load the last known skill listing from a file."""
|
|
if os.path.isfile(self.installed_skills_file_path):
|
|
with open(self.installed_skills_file_path) as skills_file:
|
|
self.installed_skills = {
|
|
i.strip() for i in skills_file.readlines() if i.strip()
|
|
}
|
|
|
|
def _save_installed_skills(self):
|
|
"""Save the skill listing after the download to a file."""
|
|
with open(self.installed_skills_file_path, 'w') as skills_file:
|
|
for skill_name in self.installed_skills:
|
|
skills_file.write(skill_name + '\n')
|
|
|
|
def update_skills(self, quick=False):
|
|
"""Invoke MSM to install default skills and/or update installed skills
|
|
|
|
Args:
|
|
quick (bool): Expedite the download by running with more threads?
|
|
"""
|
|
LOG.info('Beginning skill update...')
|
|
success = True
|
|
if connected():
|
|
self._load_installed_skills()
|
|
with self.msm_lock, self.msm.lock:
|
|
self._apply_install_or_update(quick)
|
|
self._save_installed_skills()
|
|
# Schedule retry in 5 minutes on failure, after 10 shorter periods
|
|
# Go back to 60 minutes wait
|
|
if self.default_skill_install_error and self.install_retries < 10:
|
|
self._schedule_retry()
|
|
success = False
|
|
else:
|
|
self.install_retries = 0
|
|
self._update_download_time()
|
|
else:
|
|
self.handle_not_connected()
|
|
success = False
|
|
|
|
if success:
|
|
LOG.info('Skill update complete')
|
|
|
|
return success
|
|
|
|
def handle_not_connected(self):
|
|
"""Notifications of the device not being connected to the internet"""
|
|
LOG.error('msm failed, network connection not available')
|
|
self.next_download = time() + FIVE_MINUTES
|
|
|
|
def _apply_install_or_update(self, quick):
|
|
"""Invoke MSM to install or update a skill."""
|
|
try:
|
|
# Determine if all defaults are installed
|
|
defaults = all(
|
|
[s.is_local for s in self.msm.default_skills.values()]
|
|
)
|
|
num_threads = 20 if not defaults or quick else 2
|
|
self.msm.apply(
|
|
self.install_or_update,
|
|
self.msm.list(),
|
|
max_threads=num_threads
|
|
)
|
|
self.post_manifest()
|
|
|
|
except MsmException as e:
|
|
LOG.error('Failed to update skills: {}'.format(repr(e)))
|
|
|
|
def post_manifest(self):
|
|
"""Post the manifest of the device's skills to the backend."""
|
|
upload_allowed = self.config['skills'].get('upload_skill_manifest')
|
|
if upload_allowed and is_paired():
|
|
try:
|
|
device_api = DeviceApi()
|
|
device_api.upload_skills_data(self.msm.device_skill_state)
|
|
except Exception:
|
|
LOG.exception('Could not upload skill manifest')
|
|
|
|
def install_or_update(self, skill):
|
|
"""Install missing defaults and update existing skills"""
|
|
if self._get_device_skill_state(skill.name).get('beta', False):
|
|
skill.sha = None # Will update to latest head
|
|
if skill.is_local:
|
|
skill.update()
|
|
if skill.name not in self.installed_skills:
|
|
skill.update_deps()
|
|
elif skill.name in self.default_skill_names:
|
|
try:
|
|
self.msm.install(skill, origin='default')
|
|
except Exception:
|
|
if skill.name in self.default_skill_names:
|
|
LOG.warning(
|
|
'Failed to install default skill: ' + skill.name
|
|
)
|
|
self.default_skill_install_error = True
|
|
raise
|
|
self.installed_skills.add(skill.name)
|
|
|
|
def _get_device_skill_state(self, skill_name):
|
|
"""Get skill data structure from name."""
|
|
device_skill_state = {}
|
|
for msm_skill_state in self.msm.device_skill_state.get('skills', []):
|
|
if msm_skill_state.get('name') == skill_name:
|
|
device_skill_state = msm_skill_state
|
|
|
|
return device_skill_state
|
|
|
|
def _schedule_retry(self):
|
|
"""Schedule the next skill update in the event of a failure."""
|
|
self.install_retries += 1
|
|
self.next_download = time() + FIVE_MINUTES
|
|
self._log_next_download_time()
|
|
self.default_skill_install_error = False
|
|
|
|
def _update_download_time(self):
|
|
"""Update timestamp on .msm file to be used when system is restarted"""
|
|
with open(self.dot_msm_path, 'a'):
|
|
os.utime(self.dot_msm_path, None)
|
|
self.next_download = time() + self.update_interval
|
|
self._log_next_download_time()
|
|
|
|
def _log_next_download_time(self):
|
|
LOG.info(
|
|
'Next scheduled skill update: ' +
|
|
str(datetime.fromtimestamp(self.next_download))
|
|
)
|