diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e1a1ba04d4..027deb727c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,26 +4,32 @@ So you want to contribute to Mycroft? This should be as easy as possible for you but there are a few things to consider when contributing. The following guidelines for contribution should be followed if you want to submit a pull request. -## How to prepare +## How to Prepare * You need a [GitHub account](https://github.com/signup/free) -* Submit an [issue ticket](https://github.com/MycroftAI/mycroft/issues) for your issue if there is not one yet. +* Submit an [issue ticket](https://github.com/MycroftAI/mycroft-core/issues) for your issue if one does not already exist. * Describe the issue and include steps to reproduce if it's a bug. * Ensure to mention the earliest version that you know is affected. -* If you are able and want to fix this, fork the repository on GitHub +* If you are able and want to fix this, fork the repository on GitHub and follow the instructions below. ## Make Changes 1. [Fork the Project](https://help.github.com/articles/fork-a-repo/) - 2. [Create a new Issue](https://help.github.com/articles/creating-an-issue/) - 3. Create a **feature** or **bugfix** branch based on **dev** with your issue identifier. For example, if your issue identifier is: **issue-123** then you will create either: **feature/issue-123** or **bugfix/issue-123**. Use **feature** prefix for issues related to new functionalities or enhancements and **bugfix** in case of bugs found on the **dev** branch - 4. Make sure you stick to the coding style and OO patterns that are used already. - 5. Document code using [Google-style docstrings](http://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html). Our automated documentation tools expect that format. All functions and class methods that are expected to be called externally should include a docstring. (And those that aren't [should be prefixed with a single underscore](https://docs.python.org/2/tutorial/classes.html#private-variables-and-class-local-references)). - 6. Make commits in logical units and describe them properly. Use your issue identifier at the very begin of each commit. For instance: -`git commit -m "Issues-123 - Fixing 'A' sound on Spelling Skill"` - 7. Before committing, format your code following the PEP8 rules and organize your imports removing unused libs. To check whether you are following these rules, install pep8 and run `pep8 mycroft test` while in the `mycroft-core` folder. This will check for formatting issues in the `mycroft` and `test` folders. - 8. Once you have committed everything and are done with your branch, you have to rebase your code with **dev**. Do the following steps: + 2. Clone onto your local machine and set MycroftAI/mycroft-core as your upstream branch + ``` +git clone https://github.com// +cd +git remote add upstream https://github.com/MycroftAI/mycroft-core + ``` + 3. If one does not already exist, [create a new issue](https://help.github.com/articles/creating-an-issue/) on the [MycroftAI/mycroft-core Issues Tracker](https://github.com/MycroftAI/mycroft-core/issues) + 4. Create a **feature** or **bugfix** branch in your forked repo, based on **dev** with your issue identifier. For example, if your issue identifier is: **issue-123** then you will create either: **feature/issue-123** or **bugfix/issue-123**. Use **feature** prefix for issues related to new functionalities or enhancements and **bugfix** in case of bugs found on the **dev** branch + 5. Make sure you stick to the coding style and OO patterns that are used already. + 6. Document code using [Google-style docstrings](http://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html). Our automated documentation tools expect that format. All functions and class methods that are expected to be called externally should include a docstring. (And those that aren't [should be prefixed with a single underscore](https://docs.python.org/2/tutorial/classes.html#private-variables-and-class-local-references)). + 7. Make commits in logical units and describe them properly. Use your issue identifier at the very beginning of each commit. For instance: +`git commit -m "Issue-123 - Fixing 'A' sound on Spelling Skill"` + 8. Before committing, format your code following the PEP8 rules and organize your imports removing unused libs. To check whether you are following these rules, install pep8 and run `pep8 mycroft test` while in the `mycroft-core` folder. This will check for formatting issues in the `mycroft` and `test` folders. + 9. Once you have committed everything and are done with your branch, you have to rebase your code with **dev**. Do the following steps: 1. Make sure you do not have any changes left on your branch 2. Checkout on dev branch and make sure it is up-to-date 3. Checkout your branch and rebase it with dev @@ -38,11 +44,11 @@ git checkout git rebase dev git push -f ``` - 9. If possible, create unit tests for your changes - * [Unit Tests for most contributions](https://github.com/MycroftAI/mycroft-core/tree/dev/test) - * [Intent Tests for new skills](https://docs.mycroft.ai/development/creating-a-skill#testing-your-skill) - * We utilize TRAVIS-CI, which will test each pull request. To test locally you can run: `./start-mycroft.sh unittest` - 10. Once everything is OK, you can finally [create a Pull Request (PR) on Github](https://help.github.com/articles/using-pull-requests/) in order to be reviewed and merged. + 10. If possible, create unit tests for your changes + * [Unit Tests for most contributions](https://github.com/MycroftAI/mycroft-core/tree/dev/test) + * [Intent Tests for new skills](https://mycroft-ai.gitbook.io/docs/#testing-your-skill) + * We utilize TRAVIS-CI, which will test each pull request. To test locally you can run: `./start-mycroft.sh unittest` + 11. Once everything is okay, you can finally [create a Pull Request (PR)](https://help.github.com/articles/using-pull-requests/) on [MycroftAi/mycroft-core](https://github.com/MycroftAI/mycroft-core/pulls) to have your code reviewed and merged. **Note**: Even if you have write access to the master branch, do not work directly on master! diff --git a/.travis.yml b/.travis.yml index c01c851d87..a0b9ab2c1c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,8 +26,6 @@ install: - mkdir ${TMPDIR} - echo ${TMPDIR} - VIRTUALENV_ROOT=${VIRTUAL_ENV} ./dev_setup.sh - - pip install -r requirements.txt - - pip install -r test-requirements.txt # command to run tests script: - pycodestyle mycroft test diff --git a/Jenkinsfile b/Jenkinsfile index 3a9ebb01aa..611cdd0522 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,6 +9,23 @@ pipeline { } stages { // Run the build in the against the dev branch to check for compile errors + stage('Add CLA label to PR') { + when { + anyOf { + changeRequest target: 'dev' + } + } + environment { + //spawns GITHUB_USR and GITHUB_PSW environment variables + GITHUB=credentials('38b2e4a6-167a-40b2-be6f-d69be42c8190') + } + steps { + // Using an install of Github repo CLA tagger + // (https://github.com/forslund/github-repo-cla) + sh '~/github-repo-cla/mycroft-core-cla-check.sh' + } + } + stage('Run Integration Tests') { when { anyOf { @@ -29,8 +46,7 @@ pipeline { } steps { echo 'Building Mark I Voight-Kampff Docker Image' - sh 'cp test/Dockerfile.test Dockerfile' - sh 'docker build \ + sh 'docker build -f test/Dockerfile \ --target voight_kampff_builder \ --build-arg platform=mycroft_mark_1 \ -t voight-kampff-mark-1:${BRANCH_ALIAS} .' @@ -167,6 +183,32 @@ pipeline { } } } + // Build snap package for release + stage('Build development Snap package') { + when { + anyOf { + branch 'dev' + } + } + steps { + echo "Launching package build for ${env.BRANCH_NAME}" + build (job: '../Mycroft-snap/dev', wait: false, + parameters: [[$class: 'StringParameterValue', + name: 'BRANCH', value: env.BRANCH_NAME]]) + } + } + + stage('Build Release Snap package') { + when { + tag "release/v*.*.*" + } + steps { + echo "Launching package build for ${env.TAG_NAME}" + build (job: '../Mycroft-snap/dev', wait: false, + parameters: [[$class: 'StringParameterValue', + name: 'BRANCH', value: env.TAG_NAME]]) + } + } // Build a voight_kampff image for major releases. This will be used // by the mycroft-skills repository to test skill changes. Skills are // tested against major releases to determine if they play nicely with @@ -187,8 +229,7 @@ pipeline { } steps { echo 'Building ${TAG_NAME} Docker Image for Skill Testing' - sh 'cp test/Dockerfile.test Dockerfile' - sh 'docker build \ + sh 'docker build -f test/Dockerfile \ --target voight_kampff_builder \ --build-arg platform=mycroft_mark_1 \ -t voight-kampff-mark-1:${SKILL_BRANCH} .' diff --git a/LICENSE.md b/LICENSE.md index b2719d2a5e..b882a785c0 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -208,4 +208,4 @@ Component licenses for mycroft-core: The mycroft-core software references various Python Packages (via PIP), each of which has a separate license. All are compatible with the Apache 2.0 license. See the referenced packages listed in the -"requirements.txt" file for specific terms and conditions. +"requirements/requirements.txt" file for specific terms and conditions. diff --git a/MANIFEST.in b/MANIFEST.in index 1888d6da7b..1708d5345f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ recursive-include mycroft/client/speech/recognizer/model * -include requirements.txt +include requirements/requirements.txt include mycroft/configuration/*.conf recursive-include mycroft/res * recursive-include mycroft/res/snd * diff --git a/bin/mycroft-help b/bin/mycroft-help index 2e87b369e0..822e078a2a 100755 --- a/bin/mycroft-help +++ b/bin/mycroft-help @@ -22,23 +22,28 @@ echo -e "\e[36mMycroft\e[0m is your open source voice assistant. Full source" echo "can be found at: ${DIR}" echo echo "Mycroft-specific commands you can use from the Linux command prompt:" -echo " mycroft-cli-client command line client, useful for debugging" +echo " mycroft-cli-client Command line client, useful for debugging" echo " mycroft-msm Mycroft Skills Manager, to manage your Skills" -echo " mycroft-msk Mycroft Skills Kit, create and share Skills" echo " mycroft-start Launch/restart Mycroft services" echo " mycroft-stop Stop Mycroft services" echo echo "Scripting Utilities:" -echo " mycroft-speak have Mycroft speak a phrase to the user" -echo " mycroft-say-to send an utterance to Mycroft as if spoken by a user" +echo " mycroft-listen Activate the microphone to listen for a command" +echo " mycroft-speak Have Mycroft speak a phrase to the user" +echo " mycroft-say-to Send an utterance to Mycroft as if spoken by a user" echo echo "Mycroft's Python Virtual Environment (venv) control:" -echo " mycroft-venv-activate enter the venv" -echo " mycroft-venv-deactivate exit the venv" -echo " mycroft-pip install a Python package within the venv" +echo " mycroft-pip Install a Python package within the venv" +echo " mycroft-venv-activate Enter the venv" +echo " mycroft-venv-deactivate Exit the venv" +echo +echo "Skill Development:" +echo " mycroft-msk Mycroft Skills Kit, create and share Skills" +echo " mycroft-skill-testrunner Run integration tests on Mycroft Skills" echo echo "Other:" -echo " mycroft-mic-test record and playback to directly test microphone" -echo " mycroft-help display this message" +echo " mycroft-config Manage your local Mycroft configuration files" +echo " mycroft-mic-test Record and playback to directly test microphone" +echo " mycroft-help Display this message" echo -echo "For more information, see https://mycroft.ai and https://github.com/MycroftAI" +echo "For more information, see https://mycroft.ai/documentation" diff --git a/bin/mycroft-skill-testrunner b/bin/mycroft-skill-testrunner index 53ebda644a..0e9a7297b4 100755 --- a/bin/mycroft-skill-testrunner +++ b/bin/mycroft-skill-testrunner @@ -20,12 +20,40 @@ DIR="$( dirname "$SOURCE" )" # Enter the Mycroft venv source "$DIR/../venv-activate.sh" -q +function vktest-clear() { + FEATURES_DIR="$DIR/../test/integrationtests/voight_kampff/features" + num_feature_files=$(ls $FEATURES_DIR | wc -l) + # A clean directory will have `steps/` and `environment.py` + if [ $num_feature_files -gt "2" ] ; then + echo "Removing Feature files..." + rm ${DIR}/../test/integrationtests/voight_kampff/features/*.feature + rm ${DIR}/../test/integrationtests/voight_kampff/features/*.config.json + fi + STEPS_DIR="$FEATURES_DIR/steps" + num_steps_files=$(ls $STEPS_DIR | wc -l) + if [ $num_steps_files -gt "2" ] ; then + echo "Removing Custom Step files..." + TMP_DIR="$STEPS_DIR/tmp" + mkdir $TMP_DIR + mv "$STEPS_DIR/configuration.py" $TMP_DIR + mv "$STEPS_DIR/utterance_responses.py" $TMP_DIR + rm ${STEPS_DIR}/*.py + mv ${TMP_DIR}/* $STEPS_DIR + rmdir $TMP_DIR + fi + echo "Voight Kampff tests clear." +} + # Invoke the individual skill tester if [ "$#" -eq 0 ] ; then python -m test.integrationtests.skills.runner . elif [ "$1" = "vktest" ] ; then - shift - python -m test.integrationtests.voight_kampff "$@" + if [ "$2" = "clear" ] ; then + vktest-clear + else + shift + python -m test.integrationtests.voight_kampff "$@" + fi else python -m test.integrationtests.skills.runner $@ fi diff --git a/dev_setup.sh b/dev_setup.sh index 3c59bc427b..de110dc8e9 100755 --- a/dev_setup.sh +++ b/dev_setup.sh @@ -294,7 +294,7 @@ function os_is() { } function os_is_like() { - grep "^ID_LIKE=" /etc/os-release | awk -F'=' '/^ID_LIKE/ {print $2}' | sed 's/\"//g' | grep -P -q '(^|\s)'"$1"'(\s|$)' + grep "^ID_LIKE=" /etc/os-release | awk -F'=' '/^ID_LIKE/ {print $2}' | sed 's/\"//g' | grep -q "\\b$1\\b" } function redhat_common_install() { @@ -337,7 +337,7 @@ function open_suse_install() { function fedora_install() { - $SUDO dnf install -y git python3 python3-devel python3-pip python3-setuptools python3-virtualenv pygobject3-devel libtool libffi-devel openssl-devel autoconf bison swig glib2-devel portaudio-devel mpg123 mpg123-plugins-pulseaudio screen curl pkgconfig libicu-devel automake libjpeg-turbo-devel fann-devel gcc-c++ redhat-rpm-config jq + $SUDO dnf install -y git python3 python3-devel python3-pip python3-setuptools python3-virtualenv pygobject3-devel libtool libffi-devel openssl-devel autoconf bison swig glib2-devel portaudio-devel mpg123 mpg123-plugins-pulseaudio screen curl pkgconfig libicu-devel automake libjpeg-turbo-devel fann-devel gcc-c++ redhat-rpm-config jq make } @@ -368,6 +368,14 @@ function redhat_install() { } +function gentoo_install() { + $SUDO emerge --noreplace dev-vcs/git dev-lang/python dev-python/setuptools dev-python/pygobject dev-python/requests sys-devel/libtool virtual/libffi virtual/jpeg dev-libs/openssl sys-devel/autoconf sys-devel/bison dev-lang/swig dev-libs/glib media-libs/portaudio media-sound/mpg123 media-libs/flac net-misc/curl sci-mathematics/fann sys-devel/gcc app-misc/jq media-libs/alsa-lib dev-libs/icu +} + +function alpine_install() { + $SUDO apk add alpine-sdk git python3 py3-pip py3-setuptools py3-virtualenv mpg123 vorbis-tools pulseaudio-utils fann-dev automake autoconf libtool pcre2-dev pulseaudio-dev alsa-lib-dev swig python3-dev portaudio-dev libjpeg-turbo-dev +} + function install_deps() { echo 'Installing packages...' if found_exe zypper ; then @@ -390,10 +398,18 @@ function install_deps() { # Fedora echo "$GREEN Installing packages for Fedora...$RESET" fedora_install - elif found_exe pacman; then + elif found_exe pacman && os_is arch ; then # Arch Linux echo "$GREEN Installing packages for Arch...$RESET" arch_install + elif found_exe emerge && os_is gentoo; then + # Gentoo Linux + echo "$GREEN Installing packages for Gentoo Linux ...$RESET" + gentoo_install + elif found_exe apk && os_is alpine; then + # Alpine Linux + echo "$GREEN Installing packages for Alpine Linux...$RESET" + alpine_install else echo echo -e "${YELLOW}Could not find package manager @@ -495,16 +511,28 @@ if ! grep -q "$TOP" $VENV_PATH_FILE ; then fi # install required python modules -if ! pip install -r requirements.txt ; then - echo 'Warning: Failed to install all requirements. Continue? y/N' +if ! pip install -r requirements/requirements.txt ; then + echo 'Warning: Failed to install required dependencies. Continue? y/N' read -n1 continue if [[ $continue != 'y' ]] ; then exit 1 fi fi -if ! pip install -r test-requirements.txt ; then - echo "Warning test requirements wasn't installed, Note: normal operation should still work fine..." +# install optional python modules +if [[ ! $(pip install -r requirements/extra-audiobackend.txt) || + ! $(pip install -r requirements/extra-stt.txt) || + ! $(pip install -r requirements/extra-mark1.txt) ]] ; then + echo 'Warning: Failed to install some optional dependencies. Continue? y/N' + read -n1 continue + if [[ $continue != 'y' ]] ; then + exit 1 + fi +fi + + +if ! pip install -r requirements/tests.txt ; then + echo "Warning: Test requirements failed to install. Note: normal operation should still work fine..." fi SYSMEM=$(free | awk '/^Mem:/ { print $2 }') @@ -563,4 +591,4 @@ if [[ ! -w /var/log/mycroft/ ]] ; then fi #Store a fingerprint of setup -md5sum requirements.txt test-requirements.txt dev_setup.sh > .installed +md5sum requirements/requirements.txt requirements/extra-audiobackend.txt requirements/extra-stt.txt requirements/extra-mark1.txt requirements/tests.txt dev_setup.sh > .installed diff --git a/mycroft/audio/__main__.py b/mycroft/audio/__main__.py index d88f29f528..9944763e33 100644 --- a/mycroft/audio/__main__.py +++ b/mycroft/audio/__main__.py @@ -12,8 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -""" - Mycroft audio service. +"""Mycroft audio service. This handles playback of audio and speech """ @@ -27,23 +26,48 @@ import mycroft.audio.speech as speech from .audioservice import AudioService -def main(): +def on_ready(): + LOG.info('Audio service is ready.') + + +def on_error(e='Unknown'): + LOG.error('Audio service failed to launch ({}).'.format(repr(e))) + + +def on_stopping(): + LOG.info('Audio service is shutting down...') + + +def main(ready_hook=on_ready, error_hook=on_error, stopping_hook=on_stopping): """ Main function. Run when file is invoked. """ - reset_sigint_handler() - check_for_signal("isSpeaking") - bus = MessageBusClient() # Connect to the Mycroft Messagebus - Configuration.set_config_update_handlers(bus) - speech.init(bus) + try: + reset_sigint_handler() + check_for_signal("isSpeaking") + bus = MessageBusClient() # Connect to the Mycroft Messagebus + Configuration.set_config_update_handlers(bus) + speech.init(bus) - LOG.info("Starting Audio Services") - bus.on('message', create_echo_function('AUDIO', ['mycroft.audio.service'])) - audio = AudioService(bus) # Connect audio service instance to message bus - create_daemon(bus.run_forever) + LOG.info("Starting Audio Services") + bus.on('message', create_echo_function('AUDIO', + ['mycroft.audio.service'])) - wait_for_exit_signal() + # Connect audio service instance to message bus + audio = AudioService(bus) + except Exception as e: + error_hook(e) + else: + create_daemon(bus.run_forever) + if audio.wait_for_load() and len(audio.service) > 0: + # If at least one service exists, report ready + ready_hook() + wait_for_exit_signal() + stopping_hook() + else: + error_hook('No audio services loaded') - speech.shutdown() - audio.shutdown() + speech.shutdown() + audio.shutdown() -main() +if __name__ == '__main__': + main() diff --git a/mycroft/audio/audioservice.py b/mycroft/audio/audioservice.py index 25116cb3a5..40c1e70739 100644 --- a/mycroft/audio/audioservice.py +++ b/mycroft/audio/audioservice.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import imp +import importlib import sys import time from os import listdir @@ -22,15 +22,17 @@ from threading import Lock from mycroft.configuration import Configuration from mycroft.messagebus.message import Message from mycroft.util.log import LOG +from mycroft.util.monotonic_event import MonotonicEvent from .services import RemoteAudioBackend +MINUTES = 60 # Seconds in a minute MAINMODULE = '__init__' sys.path.append(abspath(dirname(__file__))) -def create_service_descriptor(service_folder): +def create_service_spec(service_folder): """Prepares a descriptor that can be used together with imp. Args: @@ -39,7 +41,11 @@ def create_service_descriptor(service_folder): Returns: Dict with import information """ - info = imp.find_module(MAINMODULE, [service_folder]) + module_name = 'audioservice_' + basename(service_folder) + path = join(service_folder, MAINMODULE + '.py') + spec = importlib.util.spec_from_file_location(module_name, path) + mod = importlib.util.module_from_spec(spec) + info = {'spec': spec, 'mod': mod, 'module_name': module_name} return {"name": basename(service_folder), "info": info} @@ -66,7 +72,7 @@ def get_services(services_folder): not MAINMODULE + ".py" in listdir(name)): continue try: - services.append(create_service_descriptor(name)) + services.append(create_service_spec(name)) except Exception: LOG.error('Failed to create service from ' + name, exc_info=True) @@ -74,7 +80,7 @@ def get_services(services_folder): not MAINMODULE + ".py" in listdir(location)): continue try: - services.append(create_service_descriptor(location)) + services.append(create_service_spec(location)) except Exception: LOG.error('Failed to create service from ' + location, exc_info=True) @@ -99,8 +105,11 @@ def load_services(config, bus, path=None): for descriptor in service_directories: LOG.info('Loading ' + descriptor['name']) try: - service_module = imp.load_module(descriptor["name"] + MAINMODULE, - *descriptor["info"]) + service_module = descriptor['info']['mod'] + spec = descriptor['info']['spec'] + module_name = descriptor['info']['module_name'] + sys.modules[module_name] = service_module + spec.loader.exec_module(service_module) except Exception as e: LOG.error('Failed to import module ' + descriptor['name'] + '\n' + repr(e)) @@ -144,6 +153,7 @@ class AudioService: self.play_start_time = 0 self.volume_is_low = False + self._loaded = MonotonicEvent() bus.once('open', self.load_services_callback) def load_services_callback(self): @@ -152,7 +162,6 @@ class AudioService: service and default and registers the event handlers for the subsystem. """ - services = load_services(self.config, self.bus) # Sort services so local services are checked first local = [s for s in services if not isinstance(s, RemoteAudioBackend)] @@ -190,7 +199,20 @@ class AudioService: self.bus.on('recognizer_loop:audio_output_start', self._lower_volume) self.bus.on('recognizer_loop:record_begin', self._lower_volume) self.bus.on('recognizer_loop:audio_output_end', self._restore_volume) - self.bus.on('recognizer_loop:record_end', self._restore_volume) + self.bus.on('recognizer_loop:record_end', + self._restore_volume_after_record) + + self._loaded.set() # Report services loaded + + def wait_for_load(self, timeout=3 * MINUTES): + """Wait for services to be loaded. + + Arguments: + timeout (float): Seconds to wait (default 3 minutes) + Returns: + (bool) True if loading completed within timeout, else False. + """ + return self._loaded.wait(timeout) def track_start(self, track): """Callback method called from the services to indicate start of @@ -294,6 +316,31 @@ class AudioService: if not self.volume_is_low: self.current.restore_volume() + def _restore_volume_after_record(self, message=None): + """ + Restores the volume when Mycroft is done recording. + If no utterance detected, restore immediately. + If no response is made in reasonable time, then also restore. + + Args: + message: message bus message, not used but required + """ + def restore_volume(): + LOG.debug('restoring volume') + self.current.restore_volume() + + if self.current: + self.bus.on('recognizer_loop:speech.recognition.unknown', + restore_volume) + speak_msg_detected = self.bus.wait_for_message('speak', + timeout=8.0) + if not speak_msg_detected: + restore_volume() + self.bus.remove('recognizer_loop:speech.recognition.unknown', + restore_volume) + else: + LOG.debug("No audio service to restore volume of") + def play(self, tracks, prefered_service, repeat=False): """ play starts playing the audio on the prefered service if it @@ -440,4 +487,5 @@ class AudioService: self.bus.remove('recognizer_loop:record_begin', self._lower_volume) self.bus.remove('recognizer_loop:audio_output_end', self._restore_volume) - self.bus.remove('recognizer_loop:record_end', self._restore_volume) + self.bus.remove('recognizer_loop:record_end', + self._restore_volume_after_record) diff --git a/mycroft/client/enclosure/__main__.py b/mycroft/client/enclosure/__main__.py index 7d75ee0a78..87dedb8313 100644 --- a/mycroft/client/enclosure/__main__.py +++ b/mycroft/client/enclosure/__main__.py @@ -12,44 +12,82 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import sys +"""Entrypoint for enclosure service. +This provides any "enclosure" specific functionality, for example GUI or +control over the Mark-1 Faceplate. +""" +from mycroft.configuration import LocalConf, SYSTEM_CONFIG from mycroft.util.log import LOG -from mycroft.messagebus.client import MessageBusClient -from mycroft.configuration import Configuration, LocalConf, SYSTEM_CONFIG +from mycroft.util import (create_daemon, wait_for_exit_signal, + reset_sigint_handler) -def main(): - # Read the system configuration - system_config = LocalConf(SYSTEM_CONFIG) - platform = system_config.get("enclosure", {}).get("platform") +def on_ready(): + LOG.info("Enclosure started!") + +def on_stopping(): + LOG.info('Enclosure is shutting down...') + + +def on_error(e='Unknown'): + LOG.error('Enclosure failed to start. ({})'.format(repr(e))) + + +def create_enclosure(platform): + """Create an enclosure based on the provided platform string. + + Arguments: + platform (str): platform name string + + Returns: + Enclosure object + """ if platform == "mycroft_mark_1": - LOG.debug("Creating Mark I Enclosure") + LOG.info("Creating Mark I Enclosure") from mycroft.client.enclosure.mark1 import EnclosureMark1 enclosure = EnclosureMark1() elif platform == "mycroft_mark_2": - LOG.debug("Creating Mark II Enclosure") + LOG.info("Creating Mark II Enclosure") from mycroft.client.enclosure.mark2 import EnclosureMark2 enclosure = EnclosureMark2() else: - LOG.debug("Creating generic enclosure, platform='{}'".format(platform)) + LOG.info("Creating generic enclosure, platform='{}'".format(platform)) # TODO: Mechanism to load from elsewhere. E.g. read a script path from # the mycroft.conf, then load/launch that script. from mycroft.client.enclosure.generic import EnclosureGeneric enclosure = EnclosureGeneric() + return enclosure + + +def main(ready_hook=on_ready, error_hook=on_error, stopping_hook=on_stopping): + # Read the system configuration + """Launch one of the available enclosure implementations. + + This depends on the configured platform and can currently either be + mycroft_mark_1 or mycroft_mark_2, if unconfigured a generic enclosure with + only the GUI bus will be started. + """ + # Read the system configuration + system_config = LocalConf(SYSTEM_CONFIG) + platform = system_config.get("enclosure", {}).get("platform") + + enclosure = create_enclosure(platform) if enclosure: try: LOG.debug("Enclosure started!") - enclosure.run() + reset_sigint_handler() + create_daemon(enclosure.run) + ready_hook() + wait_for_exit_signal() + stopping_hook() except Exception as e: print(e) - finally: - sys.exit() else: - LOG.debug("No enclosure available for this hardware, running headless") + LOG.info("No enclosure available for this hardware, running headless") if __name__ == "__main__": diff --git a/mycroft/client/enclosure/base.py b/mycroft/client/enclosure/base.py index f97138f7e8..5384ae0bee 100644 --- a/mycroft/client/enclosure/base.py +++ b/mycroft/client/enclosure/base.py @@ -104,6 +104,7 @@ class Enclosure: self.bus.on("gui.page.delete", self.on_gui_delete_page) self.bus.on("gui.clear.namespace", self.on_gui_delete_namespace) self.bus.on("gui.event.send", self.on_gui_send_event) + self.bus.on("gui.status.request", self.handle_gui_status_request) def run(self): try: @@ -114,6 +115,16 @@ class Enclosure: ###################################################################### # GUI client API + @property + def gui_connected(self): + """Returns True if at least 1 gui is connected, else False""" + return len(GUIWebsocketHandler.clients) > 0 + + def handle_gui_status_request(self, message): + """Reply to gui status request, allows querying if a gui is + connected using the message bus""" + self.bus.emit(message.reply("gui.status.request.response", + {"connected": self.gui_connected})) def send(self, msg_dict): """ Send to all registered GUIs. """ diff --git a/mycroft/client/enclosure/generic/__init__.py b/mycroft/client/enclosure/generic/__init__.py index 6256498add..a86dda739c 100644 --- a/mycroft/client/enclosure/generic/__init__.py +++ b/mycroft/client/enclosure/generic/__init__.py @@ -15,7 +15,6 @@ import subprocess import time import sys -from alsaaudio import Mixer from threading import Thread, Timer import mycroft.dialog diff --git a/mycroft/client/speech/__main__.py b/mycroft/client/speech/__main__.py index b98c08c54f..798154a024 100644 --- a/mycroft/client/speech/__main__.py +++ b/mycroft/client/speech/__main__.py @@ -171,18 +171,19 @@ def handle_open(): EnclosureAPI(bus).reset() -def main(): - global bus - global loop - global config - reset_sigint_handler() - PIDLock("voice") - bus = MessageBusClient() # Mycroft messagebus, see mycroft.messagebus - Configuration.set_config_update_handlers(bus) - config = Configuration.get() +def on_ready(): + LOG.info('Speech client is ready.') - # Register handlers on internal RecognizerLoop bus - loop = RecognizerLoop() + +def on_stopping(): + LOG.info('Speech service is shutting down...') + + +def on_error(e='Unknown'): + LOG.error('Audio service failed to launch ({}).'.format(repr(e))) + + +def connect_loop_events(loop): loop.on('recognizer_loop:utterance', handle_utterance) loop.on('recognizer_loop:speech.recognition.unknown', handle_unknown) loop.on('speak', handle_speak) @@ -192,6 +193,8 @@ def main(): loop.on('recognizer_loop:record_end', handle_record_end) loop.on('recognizer_loop:no_internet', handle_no_internet) + +def connect_bus_events(bus): # Register handlers for events on main Mycroft messagebus bus.on('open', handle_open) bus.on('complete_intent_failure', handle_complete_intent_failure) @@ -207,10 +210,31 @@ def main(): bus.on('mycroft.stop', handle_stop) bus.on('message', create_echo_function('VOICE')) - create_daemon(bus.run_forever) - create_daemon(loop.run) - wait_for_exit_signal() +def main(ready_hook=on_ready, error_hook=on_error, stopping_hook=on_stopping, + watchdog=lambda: None): + global bus + global loop + global config + try: + reset_sigint_handler() + PIDLock("voice") + bus = MessageBusClient() # Mycroft messagebus, see mycroft.messagebus + Configuration.set_config_update_handlers(bus) + config = Configuration.get() + + # Register handlers on internal RecognizerLoop bus + loop = RecognizerLoop(watchdog) + connect_loop_events(loop) + connect_bus_events(bus) + create_daemon(bus.run_forever) + create_daemon(loop.run) + except Exception as e: + error_hook(e) + else: + ready_hook() + wait_for_exit_signal() + stopping_hook() if __name__ == "__main__": diff --git a/mycroft/client/speech/data_structures.py b/mycroft/client/speech/data_structures.py new file mode 100644 index 0000000000..3c67ff51b4 --- /dev/null +++ b/mycroft/client/speech/data_structures.py @@ -0,0 +1,102 @@ +# Copyright 2020 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. +# +"""Data structures used by the speech client.""" + + +class RollingMean: + """Simple rolling mean calculation optimized for speed. + + The optimization is made for cases where value retrieval is made at a + comparative rate to the sample additions. + + Arguments: + mean_samples: Number of samples to use for mean value + """ + def __init__(self, mean_samples): + self.num_samples = mean_samples + self.samples = [] + self.value = None # Leave unintialized + self.replace_pos = 0 # Position to replace + + def append_sample(self, sample): + """Add a sample to the buffer. + + The sample will be appended if there is room in the buffer, + otherwise it will replace the oldest sample in the buffer. + """ + sample = float(sample) + current_len = len(self.samples) + if current_len < self.num_samples: + # build the mean + self.samples.append(sample) + if self.value is not None: + avgsum = self.value * current_len + sample + self.value = avgsum / (current_len + 1) + else: # If no samples are in the buffer set the sample as mean + self.value = sample + else: + # Remove the contribution of the old sample + replace_val = self.samples[self.replace_pos] + self.value -= replace_val / self.num_samples + + # Replace it with the new sample and update the mean with it's + # contribution + self.value += sample / self.num_samples + self.samples[self.replace_pos] = sample + + # Update replace position + self.replace_pos = (self.replace_pos + 1) % self.num_samples + + +class CyclicAudioBuffer: + """A Cyclic audio buffer for storing binary data. + + TODO: The class is still unoptimized and performance can probably be + enhanced. + + Arguments: + size (int): size in bytes + initial_data (bytes): initial buffer data + """ + def __init__(self, size, initial_data): + self.size = size + # Get at most size bytes from the end of the initial data + self._buffer = initial_data[-size:] + + def append(self, data): + """Add new data to the buffer, and slide out data if the buffer is full + + Arguments: + data (bytes): binary data to append to the buffer. If buffer size + is exceeded the oldest data will be dropped. + """ + buff = self._buffer + data + if len(buff) > self.size: + buff = buff[-self.size:] + self._buffer = buff + + def get(self): + """Get the binary data.""" + return self._buffer + + def get_last(self, size): + """Get the last entries of the buffer.""" + return self._buffer[-size:] + + def __getitem__(self, key): + return self._buffer[key] + + def __len__(self): + return len(self._buffer) diff --git a/mycroft/client/speech/hotword_factory.py b/mycroft/client/speech/hotword_factory.py index 32011be009..f763a6d9ba 100644 --- a/mycroft/client/speech/hotword_factory.py +++ b/mycroft/client/speech/hotword_factory.py @@ -24,13 +24,14 @@ from contextlib import suppress from glob import glob from os.path import dirname, exists, join, abspath, expanduser, isfile, isdir from shutil import rmtree -from threading import Timer, Event, Thread +from threading import Timer, Thread from urllib.error import HTTPError from petact import install_package from mycroft.configuration import Configuration, LocalConf, USER_CONFIG from mycroft.util.log import LOG +from mycroft.util.monotonic_event import MonotonicEvent RECOGNIZER_DIR = join(abspath(dirname(__file__)), "recognizer") INIT_TIMEOUT = 10 # In seconds @@ -44,15 +45,32 @@ class NoModelAvailable(Exception): pass +def msec_to_sec(msecs): + """Convert milliseconds to seconds. + + Arguments: + msecs: milliseconds + + Returns: + input converted from milliseconds to seconds + """ + return msecs / 1000 + + class HotWordEngine: def __init__(self, key_phrase="hey mycroft", config=None, lang="en-us"): self.key_phrase = str(key_phrase).lower() - # rough estimate 1 phoneme per 2 chars - self.num_phonemes = len(key_phrase) / 2 + 1 + if config is None: config = Configuration.get().get("hot_words", {}) config = config.get(self.key_phrase, {}) self.config = config + + # rough estimate 1 phoneme per 2 chars + self.num_phonemes = len(key_phrase) / 2 + 1 + phoneme_duration = msec_to_sec(config.get('phoneme_duration', 120)) + self.expected_duration = self.num_phonemes * phoneme_duration + self.listener_config = Configuration.get().get("listener", {}) self.lang = str(self.config.get("lang", lang)).lower() @@ -100,9 +118,16 @@ class PocketsphinxHotWord(HotWordEngine): return file_name def create_config(self, dict_name, config): + """If language config doesn't exist then + we use default language (english) config as a fallback. + """ model_file = join(RECOGNIZER_DIR, 'model', self.lang, 'hmm') if not exists(model_file): - LOG.error('PocketSphinx model not found at ' + str(model_file)) + LOG.error( + 'PocketSphinx model not found at "{}". '.format(model_file) + + 'Falling back to en-us model' + ) + model_file = join(RECOGNIZER_DIR, 'model', 'en-us', 'hmm') config.set_string('-hmm', model_file) config.set_string('-dict', dict_name) config.set_string('-keyphrase', self.key_phrase) @@ -385,7 +410,7 @@ class HotWordFactory: def load_module(module, hotword, config, lang, loop): LOG.info('Loading "{}" wake word via {}'.format(hotword, module)) instance = None - complete = Event() + complete = MonotonicEvent() def initialize(): nonlocal instance, complete diff --git a/mycroft/client/speech/listener.py b/mycroft/client/speech/listener.py index 3b95883cc3..7251c5e8e9 100644 --- a/mycroft/client/speech/listener.py +++ b/mycroft/client/speech/listener.py @@ -16,7 +16,7 @@ import time from threading import Thread import speech_recognition as sr import pyaudio -from pyee import EventEmitter +from pyee import BaseEventEmitter from requests import RequestException from requests.exceptions import ConnectionError @@ -271,14 +271,19 @@ def recognizer_conf_hash(config): return hash(json.dumps(c, sort_keys=True)) -class RecognizerLoop(EventEmitter): +class RecognizerLoop(BaseEventEmitter): """ EventEmitter loop running speech recognition. Local wake word recognizer and remote general speech recognition. + + Arguments: + watchdog: (callable) function to call periodically indicating + operational status. """ - def __init__(self): + def __init__(self, watchdog=None): super(RecognizerLoop, self).__init__() + self._watchdog = watchdog self.mute_calls = 0 self._load_config() @@ -305,7 +310,7 @@ class RecognizerLoop(EventEmitter): # TODO - localization self.wakeup_recognizer = self.create_wakeup_recognizer() self.responsive_recognizer = ResponsiveRecognizer( - self.wakeword_recognizer) + self.wakeword_recognizer, self._watchdog) self.state = RecognizerLoopState() def create_wake_word_recognizer(self): diff --git a/mycroft/client/speech/mic.py b/mycroft/client/speech/mic.py index d72c216841..c3d4d092ea 100644 --- a/mycroft/client/speech/mic.py +++ b/mycroft/client/speech/mic.py @@ -15,7 +15,7 @@ import audioop from time import sleep, time as get_time -from collections import deque +from collections import deque, namedtuple import datetime import json import os @@ -44,20 +44,26 @@ from mycroft.util import ( ) from mycroft.util.log import LOG +from .data_structures import RollingMean, CyclicAudioBuffer + + +WakeWordData = namedtuple('WakeWordData', + ['audio', 'found', 'stopped', 'end_audio']) + class MutableStream: def __init__(self, wrapped_stream, format, muted=False): assert wrapped_stream is not None self.wrapped_stream = wrapped_stream - self.muted = muted - if muted: - self.mute() - self.SAMPLE_WIDTH = pyaudio.get_sample_size(format) self.muted_buffer = b''.join([b'\x00' * self.SAMPLE_WIDTH]) self.read_lock = Lock() + self.muted = muted + if muted: + self.mute() + def mute(self): """Stop the stream and set the muted flag.""" with self.read_lock: @@ -180,11 +186,131 @@ class MutableMicrophone(Microphone): def is_muted(self): return self.muted + def duration_to_bytes(self, sec): + """Converts a duration in seconds to number of recorded bytes. + + Arguments: + sec: number of seconds + + Returns: + (int) equivalent number of bytes recorded by this Mic + """ + return int(sec * self.SAMPLE_RATE) * self.SAMPLE_WIDTH + def get_silence(num_bytes): return b'\0' * num_bytes +class NoiseTracker: + """Noise tracker, used to deterimine if an audio utterance is complete. + + The current implementation expects a number of loud chunks (not necessary + in one continous sequence) followed by a short period of continous quiet + audio data to be considered complete. + + Arguments: + minimum (int): lower noise level will be threshold for "quiet" level + maximum (int): ceiling of noise level + sec_per_buffer (float): the length of each buffer used when updating + the tracker + loud_time_limit (float): time in seconds of low noise to be considered + a complete sentence + silence_time_limit (float): time limit for silence to abort sentence + silence_after_loud (float): time of silence to finalize the sentence. + default 0.25 seconds. + """ + def __init__(self, minimum, maximum, sec_per_buffer, loud_time_limit, + silence_time_limit, silence_after_loud_time=0.25): + self.min_level = minimum + self.max_level = maximum + self.sec_per_buffer = sec_per_buffer + + self.num_loud_chunks = 0 + self.level = 0 + + # Smallest number of loud chunks required to return loud enough + self.min_loud_chunks = int(loud_time_limit / sec_per_buffer) + + self.max_silence_duration = silence_time_limit + self.silence_duration = 0 + + # time of quite period after long enough loud data to consider the + # sentence complete + self.silence_after_loud = silence_after_loud_time + + # Constants + self.increase_multiplier = 200 + self.decrease_multiplier = 100 + + def _increase_noise(self): + """Bumps the current level. + + Modifies the noise level with a factor depending in the buffer length. + """ + if self.level < self.max_level: + self.level += self.increase_multiplier * self.sec_per_buffer + + def _decrease_noise(self): + """Decrease the current level. + + Modifies the noise level with a factor depending in the buffer length. + """ + if self.level > self.min_level: + self.level -= self.decrease_multiplier * self.sec_per_buffer + + def update(self, is_loud): + """Update the tracking. with either a loud chunk or a quiet chunk. + + Arguments: + is_loud: True if a loud chunk should be registered + False if a quiet chunk should be registered + """ + if is_loud: + self._increase_noise() + self.num_loud_chunks += 1 + else: + self._decrease_noise() + # Update duration of energy under the threshold level + if self._quiet_enough(): + self.silence_duration += self.sec_per_buffer + else: # Reset silence duration + self.silence_duration = 0 + + def _loud_enough(self): + """Check if the noise loudness criteria is fulfilled. + + The noise is considered loud enough if it's been over the threshold + for a certain number of chunks (accumulated, not in a row). + """ + return self.num_loud_chunks > self.min_loud_chunks + + def _quiet_enough(self): + """Check if the noise quietness criteria is fulfilled. + + The quiet level is instant and will return True if the level is lower + or equal to the minimum noise level. + """ + return self.level <= self.min_level + + def recording_complete(self): + """Has the end creteria for the recording been met. + + If the noise level has decresed from a loud level to a low level + the user has stopped speaking. + + Alternatively if a lot of silence was recorded without detecting + a loud enough phrase. + """ + too_much_silence = (self.silence_duration > self.max_silence_duration) + if too_much_silence: + LOG.debug('Too much silence recorded without start of sentence ' + 'detected') + return ((self._quiet_enough() and + self.silence_duration > self.silence_after_loud) and + (self._loud_enough() or too_much_silence)) + + class ResponsiveRecognizer(speech_recognition.Recognizer): # Padding of silence when feeding to pocketsphinx SILENCE_SEC = 0.01 @@ -197,18 +323,11 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): # before a phrase will be considered complete MIN_SILENCE_AT_END = 0.25 - # The maximum seconds a phrase can be recorded, - # provided there is noise the entire time - RECORDING_TIMEOUT = 10.0 - - # The maximum time it will continue to record silence - # when not enough noise has been detected - RECORDING_TIMEOUT_WITH_SILENCE = 3.0 - # Time between pocketsphinx checks for the wake word SEC_BETWEEN_WW_CHECKS = 0.2 - def __init__(self, wake_word_recognizer): + def __init__(self, wake_word_recognizer, watchdog=None): + self._watchdog = watchdog or (lambda: None) # Default to dummy func self.config = Configuration.get() listener_config = self.config.get('listener') self.upload_url = listener_config['wake_word_upload']['url'] @@ -217,7 +336,7 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): self.overflow_exc = listener_config.get('overflow_exception', False) - speech_recognition.Recognizer.__init__(self) + super().__init__() self.wake_word_recognizer = wake_word_recognizer self.audio = pyaudio.PyAudio() self.multiplier = listener_config.get('multiplier') @@ -235,23 +354,24 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): if self.save_utterances and not isdir(self.saved_utterances_dir): os.mkdir(self.saved_utterances_dir) - self.upload_lock = Lock() - self.filenames_to_upload = [] self.mic_level_file = os.path.join(get_ipc_directory(), "mic_level") # Signal statuses self._stop_signaled = False self._listen_triggered = False - # The maximum audio in seconds to keep for transcribing a phrase - # The wake word must fit in this time - num_phonemes = wake_word_recognizer.num_phonemes - len_phoneme = listener_config.get('phoneme_duration', 120) / 1000.0 - self.TEST_WW_SEC = num_phonemes * len_phoneme - self.SAVED_WW_SEC = max(3, self.TEST_WW_SEC) - self._account_id = None + # The maximum seconds a phrase can be recorded, + # provided there is noise the entire time + self.recording_timeout = listener_config.get('recording_timeout', + 10.0) + + # The maximum time it will continue to record silence + # when not enough noise has been detected + self.recording_timeout_with_silence = listener_config.get( + 'recording_timeout_with_silence', 3.0) + @property def account_id(self): """Fetch account from backend when needed. @@ -288,7 +408,7 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): Essentially, this code waits for a period of silence and then returns the audio. If silence isn't detected, it will terminate and return - a buffer of RECORDING_TIMEOUT duration. + a buffer of self.recording_timeout duration. Args: source (AudioSource): Source producing the audio chunks @@ -303,37 +423,16 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): bytearray: complete audio buffer recorded, including any silence at the end of the user's utterance """ - - num_loud_chunks = 0 - noise = 0 - - max_noise = 25 - min_noise = 0 - - silence_duration = 0 - - def increase_noise(level): - if level < max_noise: - return level + 200 * sec_per_buffer - return level - - def decrease_noise(level): - if level > min_noise: - return level - 100 * sec_per_buffer - return level - - # Smallest number of loud chunks required to return - min_loud_chunks = int(self.MIN_LOUD_SEC_PER_PHRASE / sec_per_buffer) + noise_tracker = NoiseTracker(0, 25, sec_per_buffer, + self.MIN_LOUD_SEC_PER_PHRASE, + self.recording_timeout_with_silence) # Maximum number of chunks to record before timing out - max_chunks = int(self.RECORDING_TIMEOUT / sec_per_buffer) + max_chunks = int(self.recording_timeout / sec_per_buffer) num_chunks = 0 - # Will return if exceeded this even if there's not enough loud chunks - max_chunks_of_silence = int(self.RECORDING_TIMEOUT_WITH_SILENCE / - sec_per_buffer) - - # bytearray to store audio in + # bytearray to store audio in, initialized with a single sample of + # silence. byte_data = get_silence(source.SAMPLE_WIDTH) if stream: @@ -354,33 +453,20 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): energy = self.calc_energy(chunk, source.SAMPLE_WIDTH) test_threshold = self.energy_threshold * self.multiplier is_loud = energy > test_threshold - if is_loud: - noise = increase_noise(noise) - num_loud_chunks += 1 - else: - noise = decrease_noise(noise) + noise_tracker.update(is_loud) + if not is_loud: self._adjust_threshold(energy, sec_per_buffer) + # The phrase is complete if the noise_tracker end of sentence + # criteria is met or if the top-button is pressed + phrase_complete = (noise_tracker.recording_complete() or + check_for_signal('buttonPress')) + + # Periodically write the energy level to the mic level file. if num_chunks % 10 == 0: + self._watchdog() self.write_mic_level(energy, source) - was_loud_enough = num_loud_chunks > min_loud_chunks - - quiet_enough = noise <= min_noise - if quiet_enough: - silence_duration += sec_per_buffer - if silence_duration < self.MIN_SILENCE_AT_END: - quiet_enough = False # gotta be silent for min of 1/4 sec - else: - silence_duration = 0 - recorded_too_much_silence = num_chunks > max_chunks_of_silence - if quiet_enough and (was_loud_enough or recorded_too_much_silence): - phrase_complete = True - - # Pressing top-button will end recording immediately - if check_for_signal('buttonPress'): - phrase_complete = True - return byte_data def write_mic_level(self, energy, source): @@ -392,17 +478,12 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): ) ) - @staticmethod - def sec_to_bytes(sec, source): - return int(sec * source.SAMPLE_RATE) * source.SAMPLE_WIDTH - def _skip_wake_word(self): """Check if told programatically to skip the wake word For example when we are in a dialog with the user. """ - # TODO: remove startListening signal check in 20.02 - if check_for_signal('startListening') or self._listen_triggered: + if self._listen_triggered: return True # Pressing the Mark 1 button can start recording (unless @@ -420,9 +501,7 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): return False def stop(self): - """ - Signal stop and exit waiting state. - """ + """Signal stop and exit waiting state.""" self._stop_signaled = True def _compile_metadata(self): @@ -443,140 +522,141 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): 'model': str(model_hash) } - def _upload_wake_word(self, audio, metadata): - requests.post( - self.upload_url, files={ - 'audio': BytesIO(audio.get_wav_data()), - 'metadata': StringIO(json.dumps(metadata)) - } - ) - def trigger_listen(self): """Externally trigger listening.""" LOG.debug('Listen triggered from external source.') self._listen_triggered = True - def _wait_until_wake_word(self, source, sec_per_buffer, emitter): + def _upload_wakeword(self, audio, metadata): + """Upload the wakeword in a background thread.""" + LOG.debug( + "Wakeword uploading has been disabled. The API endpoint used in " + "Mycroft-core v20.2 and below has been deprecated. To contribute " + "new wakeword samples please upgrade to v20.8 or above." + ) + # def upload(audio, metadata): + # requests.post(self.upload_url, + # files={'audio': BytesIO(audio.get_wav_data()), + # 'metadata': StringIO(json.dumps(metadata))}) + # Thread(target=upload, daemon=True, args=(audio, metadata)).start() + + def _send_wakeword_info(self, emitter): + """Send messagebus message indicating that a wakeword was received. + + Arguments: + emitter: bus emitter to send information on. + """ + SessionManager.touch() + payload = {'utterance': self.wake_word_name, + 'session': SessionManager.get().session_id} + emitter.emit("recognizer_loop:wakeword", payload) + + def _write_wakeword_to_disk(self, audio, metadata): + """Write wakeword to disk. + + Arguments: + audio: Audio data to write + metadata: List of metadata about the captured wakeword + """ + filename = join(self.saved_wake_words_dir, + '_'.join(str(metadata[k]) for k in sorted(metadata)) + + '.wav') + with open(filename, 'wb') as f: + f.write(audio.get_wav_data()) + + def _handle_wakeword_found(self, audio_data, source): + """Perform actions to be triggered after a wakeword is found. + + This includes: emit event on messagebus that a wakeword is heard, + store wakeword to disk if configured and sending the wakeword data + to the cloud in case the user has opted into the data sharing. + """ + # Save and upload positive wake words as appropriate + upload_allowed = (self.config['opt_in'] and not self.upload_disabled) + if (self.save_wake_words or upload_allowed): + audio = self._create_audio_data(audio_data, source) + metadata = self._compile_metadata() + if self.save_wake_words: + # Save wake word locally + self._write_wakeword_to_disk(audio, metadata) + # Upload wake word for opt_in people + if upload_allowed: + self._upload_wakeword(audio, metadata) + + def _wait_until_wake_word(self, source, sec_per_buffer): """Listen continuously on source until a wake word is spoken - Args: + Arguments: source (AudioSource): Source producing the audio chunks sec_per_buffer (float): Fractional number of seconds in each chunk """ + + # The maximum audio in seconds to keep for transcribing a phrase + # The wake word must fit in this time + ww_duration = self.wake_word_recognizer.expected_duration + ww_test_duration = max(3, ww_duration) + + mic_write_counter = 0 num_silent_bytes = int(self.SILENCE_SEC * source.SAMPLE_RATE * source.SAMPLE_WIDTH) silence = get_silence(num_silent_bytes) - # bytearray to store audio in - byte_data = silence + # Max bytes for byte_data before audio is removed from the front + max_size = source.duration_to_bytes(ww_duration) + test_size = source.duration_to_bytes(ww_test_duration) + audio_buffer = CyclicAudioBuffer(max_size, silence) buffers_per_check = self.SEC_BETWEEN_WW_CHECKS / sec_per_buffer buffers_since_check = 0.0 - # Max bytes for byte_data before audio is removed from the front - max_size = self.sec_to_bytes(self.SAVED_WW_SEC, source) - test_size = self.sec_to_bytes(self.TEST_WW_SEC, source) - - said_wake_word = False - # Rolling buffer to track the audio energy (loudness) heard on # the source recently. An average audio energy is maintained # based on these levels. - energies = [] - idx_energy = 0 - avg_energy = 0.0 - energy_avg_samples = int(5 / sec_per_buffer) # avg over last 5 secs - counter = 0 + average_samples = int(5 / sec_per_buffer) # average over last 5 secs + audio_mean = RollingMean(average_samples) # These are frames immediately after wake word is detected # that we want to keep to send to STT ww_frames = deque(maxlen=7) - while not said_wake_word and not self._stop_signaled: - if self._skip_wake_word(): - break + said_wake_word = False + while (not said_wake_word and not self._stop_signaled and + not self._skip_wake_word()): chunk = self.record_sound_chunk(source) + audio_buffer.append(chunk) ww_frames.append(chunk) energy = self.calc_energy(chunk, source.SAMPLE_WIDTH) + audio_mean.append_sample(energy) + if energy < self.energy_threshold * self.multiplier: self._adjust_threshold(energy, sec_per_buffer) + # maintain the threshold using average + if self.energy_threshold < energy < audio_mean.value * 1.5: + # bump the threshold to just above this value + self.energy_threshold = energy * 1.2 - if len(energies) < energy_avg_samples: - # build the average - energies.append(energy) - avg_energy += float(energy) / energy_avg_samples - else: - # maintain the running average and rolling buffer - avg_energy -= float(energies[idx_energy]) / energy_avg_samples - avg_energy += float(energy) / energy_avg_samples - energies[idx_energy] = energy - idx_energy = (idx_energy + 1) % energy_avg_samples - - # maintain the threshold using average - if energy < avg_energy * 1.5: - if energy > self.energy_threshold: - # bump the threshold to just above this value - self.energy_threshold = energy * 1.2 - - # Periodically output energy level stats. This can be used to + # Periodically output energy level stats. This can be used to # visualize the microphone input, e.g. a needle on a meter. - if counter % 3: + if mic_write_counter % 3: + self._watchdog() self.write_mic_level(energy, source) - counter += 1 - - # At first, the buffer is empty and must fill up. After that - # just drop the first chunk bytes to keep it the same size. - needs_to_grow = len(byte_data) < max_size - if needs_to_grow: - byte_data += chunk - else: # Remove beginning of audio and add new chunk to end - byte_data = byte_data[len(chunk):] + chunk + mic_write_counter += 1 buffers_since_check += 1.0 + # Send chunk to wake_word_recognizer self.wake_word_recognizer.update(chunk) + if buffers_since_check > buffers_per_check: buffers_since_check -= buffers_per_check - chopped = byte_data[-test_size:] \ - if test_size < len(byte_data) else byte_data - audio_data = chopped + silence + audio_data = audio_buffer.get_last(test_size) + silence said_wake_word = \ self.wake_word_recognizer.found_wake_word(audio_data) - # Save positive wake words as appropriate - if said_wake_word: - SessionManager.touch() - payload = { - 'utterance': self.wake_word_name, - 'session': SessionManager.get().session_id, - } - emitter.emit("recognizer_loop:wakeword", payload) - - audio = None - mtd = None - if self.save_wake_words: - # Save wake word locally - audio = self._create_audio_data(byte_data, source) - mtd = self._compile_metadata() - module = self.wake_word_recognizer.__class__.__name__ - - fn = join( - self.saved_wake_words_dir, - '_'.join(str(mtd[k]) for k in sorted(mtd)) + '.wav' - ) - with open(fn, 'wb') as f: - f.write(audio.get_wav_data()) - - if self.config['opt_in'] and not self.upload_disabled: - # Upload wake word for opt_in people - Thread( - target=self._upload_wake_word, daemon=True, - args=[audio or - self._create_audio_data(byte_data, source), - mtd or self._compile_metadata()] - ).start() - return ww_frames + self._listen_triggered = False + return WakeWordData(audio_data, said_wake_word, + self._stop_signaled, ww_frames) @staticmethod def _create_audio_data(raw_data, source): @@ -586,6 +666,17 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): """ return AudioData(raw_data, source.SAMPLE_RATE, source.SAMPLE_WIDTH) + def mute_and_confirm_listening(self, source): + audio_file = resolve_resource_file( + self.config.get('sounds').get('start_listening')) + if audio_file: + source.mute() + play_wav(audio_file).wait() + source.unmute() + return True + else: + return False + def listen(self, source, emitter, stream=None): """Listens for chunks of audio that Mycroft should perform STT on. @@ -617,22 +708,23 @@ class ResponsiveRecognizer(speech_recognition.Recognizer): self.adjust_for_ambient_noise(source, 1.0) LOG.debug("Waiting for wake word...") - ww_frames = self._wait_until_wake_word(source, sec_per_buffer, emitter) + ww_data = self._wait_until_wake_word(source, sec_per_buffer) - self._listen_triggered = False - if self._stop_signaled: + ww_frames = None + if ww_data.found: + # If the wakeword was heard send it + self._send_wakeword_info(emitter) + self._handle_wakeword_found(ww_data.audio, source) + ww_frames = ww_data.end_audio + if ww_data.stopped: + # If the waiting returned from a stop signal return LOG.debug("Recording...") # If enabled, play a wave file with a short sound to audibly # indicate recording has begun. if self.config.get('confirm_listening'): - audio_file = resolve_resource_file( - self.config.get('sounds').get('start_listening')) - if audio_file: - source.mute() - play_wav(audio_file).wait() - source.unmute() + if self.mute_and_confirm_listening(source): # Clear frames from wakeword detctions since they're # irrelevant after mute - play wav - unmute sequence ww_frames = None diff --git a/mycroft/client/text/text_client.py b/mycroft/client/text/text_client.py index 44d11d6108..0c841d71a8 100644 --- a/mycroft/client/text/text_client.py +++ b/mycroft/client/text/text_client.py @@ -1018,7 +1018,7 @@ def show_skills(skills): scr.addstr(curses.LINES - 1, 0, center(23) + "Press any key to continue", CLR_HEADING) scr.refresh() - scr.get_wch() # blocks + wait_for_any_key() prepare_page() elif row == curses.LINES - 2: # Reached bottom of screen, start at top and move output to a @@ -1065,6 +1065,21 @@ def _get_cmd_param(cmd, keyword): return parts[-1] +def wait_for_any_key(): + """Block until key is pressed. + + This works around curses.error that can occur on old versions of ncurses. + """ + while True: + try: + scr.get_wch() # blocks + except curses.error: + # Loop if get_wch throws error + time.sleep(0.05) + else: + break + + def handle_cmd(cmd): global show_meter global screen_mode @@ -1142,7 +1157,8 @@ def handle_cmd(cmd): if message: show_skills(message.data) - scr.get_wch() # blocks + wait_for_any_key() + screen_mode = SCR_MAIN set_screen_dirty() elif "deactivate" in cmd: diff --git a/mycroft/configuration/config.py b/mycroft/configuration/config.py index 4322dfdb92..dd82eae651 100644 --- a/mycroft/configuration/config.py +++ b/mycroft/configuration/config.py @@ -28,8 +28,8 @@ from .locations import (DEFAULT_CONFIG, SYSTEM_CONFIG, USER_CONFIG, def is_remote_list(values): - ''' check if this list corresponds to a backend formatted collection of - dictionaries ''' + """Check if list corresponds to a backend formatted collection of dicts + """ for v in values: if not isinstance(v, dict): return False @@ -39,13 +39,11 @@ def is_remote_list(values): def translate_remote(config, setting): - """ - Translate config names from server to equivalents usable - in mycroft-core. + """Translate config names from server to equivalents for mycroft-core. - Args: - config: base config to populate - settings: remote settings to be translated + Arguments: + config: base config to populate + settings: remote settings to be translated """ IGNORED_SETTINGS = ["uuid", "@type", "active", "user", "device"] @@ -69,12 +67,11 @@ def translate_remote(config, setting): def translate_list(config, values): - """ - Translate list formated by mycroft server. + """Translate list formated by mycroft server. - Args: - config (dict): target config - values (list): list from mycroft server config + Arguments: + config (dict): target config + values (list): list from mycroft server config """ for v in values: module = v["@type"] @@ -85,9 +82,7 @@ def translate_list(config, values): class LocalConf(dict): - """ - Config dict from file. - """ + """Config dictionary from file.""" def __init__(self, path): super(LocalConf, self).__init__() if path: @@ -95,11 +90,10 @@ class LocalConf(dict): self.load_local(path) def load_local(self, path): - """ - Load local json file into self. + """Load local json file into self. - Args: - path (str): file to load + Arguments: + path (str): file to load """ if exists(path) and isfile(path): try: @@ -115,10 +109,10 @@ class LocalConf(dict): LOG.debug("Configuration '{}' not defined, skipping".format(path)) def store(self, path=None): - """ - Cache the received settings locally. The cache will be used if - the remote is unreachable to load settings that are as close - to the user's as possible + """Cache the received settings locally. + + The cache will be used if the remote is unreachable to load settings + that are as close to the user's as possible. """ path = path or self.path with open(path, 'w') as f: @@ -129,9 +123,7 @@ class LocalConf(dict): class RemoteConf(LocalConf): - """ - Config dict fetched from mycroft.ai - """ + """Config dictionary fetched from mycroft.ai.""" def __init__(self, cache=None): super(RemoteConf, self).__init__(None) @@ -176,18 +168,23 @@ class RemoteConf(LocalConf): class Configuration: + """Namespace for operations on the configuration singleton.""" __config = {} # Cached config __patch = {} # Patch config that skills can update to override config @staticmethod def get(configs=None, cache=True): - """ - Get configuration, returns cached instance if available otherwise - builds a new configuration dict. + """Get configuration - Args: - configs (list): List of configuration dicts - cache (boolean): True if the result should be cached + Returns cached instance if available otherwise builds a new + configuration dict. + + Arguments: + configs (list): List of configuration dicts + cache (boolean): True if the result should be cached + + Returns: + (dict) configuration dictionary. """ if Configuration.__config: return Configuration.__config @@ -196,14 +193,14 @@ class Configuration: @staticmethod def load_config_stack(configs=None, cache=False): - """ - load a stack of config dicts into a single dict + """Load a stack of config dicts into a single dict - Args: - configs (list): list of dicts to load - cache (boolean): True if result should be cached + Arguments: + configs (list): list of dicts to load + cache (boolean): True if result should be cached - Returns: merged dict of all configuration files + Returns: + (dict) merged dict of all configuration files """ if not configs: configs = [LocalConf(DEFAULT_CONFIG), RemoteConf(), @@ -233,29 +230,40 @@ class Configuration: def set_config_update_handlers(bus): """Setup websocket handlers to update config. - Args: + Arguments: bus: Message bus client instance """ bus.on("configuration.updated", Configuration.updated) bus.on("configuration.patch", Configuration.patch) + bus.on("configuration.patch.clear", Configuration.patch_clear) @staticmethod def updated(message): - """ - handler for configuration.updated, triggers an update - of cached config. + """Handler for configuration.updated, + + Triggers an update of cached config. """ Configuration.load_config_stack(cache=True) @staticmethod def patch(message): - """ - patch the volatile dict usable by skills + """Patch the volatile dict usable by skills - Args: - message: Messagebus message should contain a config - in the data payload. + Arguments: + message: Messagebus message should contain a config + in the data payload. """ config = message.data.get("config", {}) merge_dict(Configuration.__patch, config) Configuration.load_config_stack(cache=True) + + @staticmethod + def patch_clear(message): + """Clear the config patch space. + + Arguments: + message: Messagebus message should contain a config + in the data payload. + """ + Configuration.__patch = {} + Configuration.load_config_stack(cache=True) diff --git a/mycroft/configuration/mycroft.conf b/mycroft/configuration/mycroft.conf index fb781f4eb0..d86e52b972 100644 --- a/mycroft/configuration/mycroft.conf +++ b/mycroft/configuration/mycroft.conf @@ -102,7 +102,7 @@ // Relative to "data_dir" "cache": ".skills-repo", "url": "https://github.com/MycroftAI/mycroft-skills", - "branch": "20.02" + "branch": "20.08" } }, "upload_skill_manifest": true, @@ -189,7 +189,11 @@ "multiplier": 1.0, "energy_ratio": 1.5, "wake_word": "hey mycroft", - "stand_up_word": "wake up" + "stand_up_word": "wake up", + + // Settings used by microphone to set recording timeout + "recording_timeout": 10.0, + "recording_timeout_with_silence": 3.0 }, // Settings used for any precise wake words @@ -281,9 +285,16 @@ // Text to Speech parameters // Override: REMOTE "tts": { - // Engine. Options: "mimic", "google", "marytts", "fatts", "espeak", "spdsay", "responsive_voice", "yandex" + // Engine. Options: "mimic", "google", "marytts", "fatts", "espeak", + // "spdsay", "responsive_voice", "yandex", "polly" "pulse_duck": false, "module": "mimic", + "polly": { + "voice": "Matthew", + "region": "us-east-1", + "access_key_id": "", + "secret_access_key": "" + }, "mimic": { "voice": "ap" }, diff --git a/mycroft/dialog/__init__.py b/mycroft/dialog/__init__.py index cd15a83b4f..460c802e9e 100644 --- a/mycroft/dialog/__init__.py +++ b/mycroft/dialog/__init__.py @@ -14,5 +14,4 @@ # """Provides utilities for rendering dialogs and populating with custom data.""" -from .dialog import (MustacheDialogRenderer, DialogLoader, - load_dialogs, get) +from .dialog import (MustacheDialogRenderer, load_dialogs, get) diff --git a/mycroft/dialog/dialog.py b/mycroft/dialog/dialog.py index c0332dd662..e109256780 100644 --- a/mycroft/dialog/dialog.py +++ b/mycroft/dialog/dialog.py @@ -120,28 +120,6 @@ class MustacheDialogRenderer: return line -class DialogLoader: - """Loads a collection of dialog files into a renderer implementation. - - TODO: Remove in 20.02 - """ - - def __init__(self, renderer_factory=MustacheDialogRenderer): - LOG.warning('Deprecated, use load_dialogs() instead.') - self.__renderer = renderer_factory() - - def load(self, dialog_dir): - """Load all dialog files within the specified directory. - - Args: - dialog_dir (str): directory that contains dialog files - - Returns: - a loaded instance of a dialog renderer - """ - return load_dialogs(dialog_dir, self.__renderer) - - def load_dialogs(dialog_dir, renderer=None): """Load all dialog files within the specified directory. diff --git a/mycroft/enclosure/gui.py b/mycroft/enclosure/gui.py index 39cb866277..26fa808c5a 100644 --- a/mycroft/enclosure/gui.py +++ b/mycroft/enclosure/gui.py @@ -34,11 +34,21 @@ class SkillGUI: def __init__(self, skill): self.__session_data = {} # synced to GUI for use by this skill's pages - self.page = None # the active GUI page (e.g. QML template) to show + self.page = None # the active GUI page (e.g. QML template) to show self.skill = skill self.on_gui_changed_callback = None self.config = Configuration.get() + @property + def connected(self): + """Returns True if at least 1 gui is connected, else False""" + if self.skill.bus: + reply = self.skill.bus.wait_for_response( + Message("gui.status.request"), "gui.status.request.response") + if reply: + return reply.data["connected"] + return False + @property def remote_url(self): """Returns configuration value for url of remote-server.""" @@ -111,7 +121,7 @@ class SkillGUI: self.skill.bus.emit(Message("gui.clear.namespace", {"__from": self.skill.skill_id})) - def send_event(self, event_name, params={}): + def send_event(self, event_name, params=None): """Trigger a gui event. Arguments: @@ -119,12 +129,14 @@ class SkillGUI: params: json serializable object containing any parameters that should be sent along with the request. """ + params = params or {} self.skill.bus.emit(Message("gui.event.send", {"__from": self.skill.skill_id, "event_name": event_name, "params": params})) - def show_page(self, name, override_idle=None): + def show_page(self, name, override_idle=None, + override_animations=False): """Begin showing the page in the GUI Arguments: @@ -133,10 +145,14 @@ class SkillGUI: True: Takes over the resting page indefinitely (int): Delays resting page for the specified number of seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. """ - self.show_pages([name], 0, override_idle) + self.show_pages([name], 0, override_idle, override_animations) - def show_pages(self, page_names, index=0, override_idle=None): + def show_pages(self, page_names, index=0, override_idle=None, + override_animations=False): """Begin showing the list of pages in the GUI. Arguments: @@ -148,6 +164,9 @@ class SkillGUI: True: Takes over the resting page indefinitely (int): Delays resting page for the specified number of seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. """ if not isinstance(page_names, list): raise ValueError('page_names must be a list') @@ -181,7 +200,8 @@ class SkillGUI: {"page": page_urls, "index": index, "__from": self.skill.skill_id, - "__idle": override_idle})) + "__idle": override_idle, + "__animations": override_animations})) def remove_page(self, page): """Remove a single page from the GUI. @@ -214,21 +234,30 @@ class SkillGUI: {"page": page_urls, "__from": self.skill.skill_id})) - def show_text(self, text, title=None, override_idle=None): + def show_text(self, text, title=None, override_idle=None, + override_animations=False): """Display a GUI page for viewing simple text. Arguments: text (str): Main text content. It will auto-paginate title (str): A title to display above the text content. + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. """ self.clear() self["text"] = text self["title"] = title - self.show_page("SYSTEM_TextFrame.qml", override_idle) + self.show_page("SYSTEM_TextFrame.qml", override_idle, + override_animations) def show_image(self, url, caption=None, title=None, fill=None, - override_idle=None): + override_idle=None, override_animations=False): """Display a GUI page for viewing an image. Arguments: @@ -237,35 +266,88 @@ class SkillGUI: title (str): A title to display above the image content fill (str): Fill type supports 'PreserveAspectFit', 'PreserveAspectCrop', 'Stretch' + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. """ self.clear() self["image"] = url self["title"] = title self["caption"] = caption self["fill"] = fill - self.show_page("SYSTEM_ImageFrame.qml", override_idle) + self.show_page("SYSTEM_ImageFrame.qml", override_idle, + override_animations) - def show_html(self, html, resource_url=None, override_idle=None): + def show_animated_image(self, url, caption=None, + title=None, fill=None, + override_idle=None, override_animations=False): + """Display a GUI page for viewing an image. + + Arguments: + url (str): Pointer to the .gif image + caption (str): A caption to show under the image + title (str): A title to display above the image content + fill (str): Fill type supports 'PreserveAspectFit', + 'PreserveAspectCrop', 'Stretch' + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self.clear() + self["image"] = url + self["title"] = title + self["caption"] = caption + self["fill"] = fill + self.show_page("SYSTEM_AnimatedImageFrame.qml", override_idle, + override_animations) + + def show_html(self, html, resource_url=None, override_idle=None, + override_animations=False): """Display an HTML page in the GUI. Arguments: html (str): HTML text to display resource_url (str): Pointer to HTML resources + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. """ self.clear() self["html"] = html self["resourceLocation"] = resource_url - self.show_page("SYSTEM_HtmlFrame.qml", override_idle) + self.show_page("SYSTEM_HtmlFrame.qml", override_idle, + override_animations) - def show_url(self, url, override_idle=None): + def show_url(self, url, override_idle=None, + override_animations=False): """Display an HTML page in the GUI. Arguments: url (str): URL to render + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. """ self.clear() self["url"] = url - self.show_page("SYSTEM_UrlFrame.qml", override_idle) + self.show_page("SYSTEM_UrlFrame.qml", override_idle, + override_animations) def shutdown(self): """Shutdown gui interface. diff --git a/mycroft/messagebus/client/__init__.py b/mycroft/messagebus/client/__init__.py index 9a15edfa0a..df9559aae8 100644 --- a/mycroft/messagebus/client/__init__.py +++ b/mycroft/messagebus/client/__init__.py @@ -11,4 +11,4 @@ # 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. -from .client import MessageBusClient +from .client import MessageBusClient, MessageWaiter diff --git a/mycroft/messagebus/client/client.py b/mycroft/messagebus/client/client.py index 80698c0ecf..68aec51fb7 100644 --- a/mycroft/messagebus/client/client.py +++ b/mycroft/messagebus/client/client.py @@ -27,14 +27,61 @@ from mycroft.messagebus.load_config import load_message_bus_config from mycroft.messagebus.message import Message from mycroft.util import create_echo_function from mycroft.util.log import LOG -from .threaded_event_emitter import ThreadedEventEmitter +from pyee import ExecutorEventEmitter + + +class MessageWaiter: + """Wait for a single message. + + Encapsulate the wait for a message logic separating the setup from + the actual waiting act so the waiting can be setuo, actions can be + performed and _then_ the message can be waited for. + + Argunments: + bus: Bus to check for messages on + message_type: message type to wait for + """ + def __init__(self, bus, message_type): + self.bus = bus + self.msg_type = message_type + self.received_msg = None + # Setup response handler + self.bus.once(message_type, self._handler) + + def _handler(self, message): + """Receive response data.""" + self.received_msg = message + + def wait(self, timeout=3.0): + """Wait for message. + + Arguments: + timeout (int or float): seconds to wait for message + + Returns: + Message or None + """ + start_time = time.monotonic() + while self.received_msg is None: + time.sleep(0.2) + if time.monotonic() - start_time > timeout: + try: + self.bus.remove(self.msg_type, self._handler) + except (ValueError, KeyError): + # ValueError occurs on pyee 5.0.1 removing handlers + # registered with once. + # KeyError may theoretically occur if the event occurs as + # the handler is removed + pass + break + return self.received_msg class MessageBusClient: def __init__(self, host=None, port=None, route=None, ssl=None): config_overrides = dict(host=host, port=port, route=route, ssl=ssl) self.config = load_message_bus_config(**config_overrides) - self.emitter = ThreadedEventEmitter() + self.emitter = ExecutorEventEmitter() self.client = self.create_client() self.retry = 5 self.connected_event = Event() @@ -120,42 +167,36 @@ class MessageBusClient: LOG.warning('Could not send {} message because connection ' 'has been closed'.format(message.msg_type)) - def wait_for_response(self, message, reply_type=None, timeout=None): + def wait_for_message(self, message_type, timeout=3.0): + """Wait for a message of a specific type. + + Arguments: + message_type (str): the message type of the expected message + timeout: seconds to wait before timeout, defaults to 3 + + Returns: + The received message or None if the response timed out + """ + + return MessageWaiter(self, message_type).wait(timeout) + + def wait_for_response(self, message, reply_type=None, timeout=3.0): """Send a message and wait for a response. - Args: + Arguments: message (Message): message to send reply_type (str): the message type of the expected reply. Defaults to ".response". timeout: seconds to wait before timeout, defaults to 3 + Returns: The received message or None if the response timed out """ - response = [] - - def handler(message): - """Receive response data.""" - response.append(message) - - # Setup response handler - self.once(reply_type or message.msg_type + '.response', handler) - # Send request + message_type = reply_type or message.msg_type + '.response' + waiter = MessageWaiter(self, message_type) # Setup response handler + # Send message and wait for it's response self.emit(message) - # Wait for response - start_time = time.monotonic() - while len(response) == 0: - time.sleep(0.2) - if time.monotonic() - start_time > (timeout or 3.0): - try: - self.remove(reply_type, handler) - except (ValueError, KeyError): - # ValueError occurs on pyee 1.0.1 removing handlers - # registered with once. - # KeyError may theoretically occur if the event occurs as - # the handler is removed - pass - return None - return response[0] + return waiter.wait(timeout) def on(self, event_name, func): self.emitter.on(event_name, func) diff --git a/mycroft/messagebus/client/threaded_event_emitter.py b/mycroft/messagebus/client/threaded_event_emitter.py deleted file mode 100644 index 027d37629e..0000000000 --- a/mycroft/messagebus/client/threaded_event_emitter.py +++ /dev/null @@ -1,65 +0,0 @@ -# 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. -# -from pyee import EventEmitter -from multiprocessing.pool import ThreadPool -from collections import defaultdict - - -class ThreadedEventEmitter(EventEmitter): - """ Event Emitter using the threadpool to run event functions in - separate threads. - """ - def __init__(self, threads=10): - super().__init__() - self.pool = ThreadPool(threads) - self.wrappers = defaultdict(list) - - def on(self, event, f=None): - """ Wrap on with a threaded launcher. """ - def wrapped(*args, **kwargs): - return self.pool.apply_async(f, args, kwargs) - - w = super().on(event, wrapped) - # Store mapping from function to wrapped function - self.wrappers[event].append((f, wrapped)) - return w - - def once(self, event, f=None): - """ Wrap once with a threaded launcher. """ - def wrapped(*args, **kwargs): - return self.pool.apply_async(f, args, kwargs) - - wrapped = super().once(event, wrapped) - self.wrappers[event].append((f, wrapped)) - return wrapped - - def remove_listener(self, event_name, func): - """ Wrap the remove to translate from function to wrapped - function. - """ - for w in self.wrappers[event_name]: - if w[0] == func: - self.wrappers[event_name].remove(w) - return super().remove_listener(event_name, w[1]) - # if no wrapper exists try removing the function - return super().remove_listener(event_name, func) - - def remove_all_listeners(self, event_name): - """Remove all listeners with name. - - event_name: identifier of event handler - """ - super().remove_all_listeners(event_name) - self.wrappers.pop(event_name) diff --git a/mycroft/messagebus/service/__main__.py b/mycroft/messagebus/service/__main__.py index 4849da6c80..4a2b9c3b5b 100644 --- a/mycroft/messagebus/service/__main__.py +++ b/mycroft/messagebus/service/__main__.py @@ -33,7 +33,19 @@ from mycroft.util import ( from mycroft.util.log import LOG -def main(): +def on_ready(): + LOG.info('Message bus service started!') + + +def on_error(e='Unknown'): + LOG.info('Message bus failed to start ({})'.format(repr(e))) + + +def on_stopping(): + LOG.info('Message bus is shutting down...') + + +def main(ready_hook=on_ready, error_hook=on_error, stopping_hook=on_stopping): import tornado.options LOG.info('Starting message bus service...') reset_sigint_handler() @@ -51,8 +63,9 @@ def main(): application = web.Application(routes, debug=True) application.listen(config.port, config.host) create_daemon(ioloop.IOLoop.instance().start) - LOG.info('Message bus service started!') + ready_hook() wait_for_exit_signal() + stopping_hook() if __name__ == "__main__": diff --git a/mycroft/messagebus/service/event_handler.py b/mycroft/messagebus/service/event_handler.py index ef2576334c..df0e1121d8 100644 --- a/mycroft/messagebus/service/event_handler.py +++ b/mycroft/messagebus/service/event_handler.py @@ -18,7 +18,7 @@ import sys import traceback from tornado.websocket import WebSocketHandler -from pyee import EventEmitter +from pyee import BaseEventEmitter from mycroft.messagebus.message import Message from mycroft.util.log import LOG @@ -29,7 +29,7 @@ client_connections = [] class MessageBusEventHandler(WebSocketHandler): def __init__(self, application, request, **kwargs): super().__init__(application, request, **kwargs) - self.emitter = EventEmitter() + self.emitter = BaseEventEmitter() def on(self, event_name, handler): self.emitter.on(event_name, handler) diff --git a/mycroft/res/text/cs-cz/and.word b/mycroft/res/text/cs-cz/and.word new file mode 100644 index 0000000000..2e65efe2a1 --- /dev/null +++ b/mycroft/res/text/cs-cz/and.word @@ -0,0 +1 @@ +a \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/backend.down.dialog b/mycroft/res/text/cs-cz/backend.down.dialog new file mode 100644 index 0000000000..6ebaa81c1f --- /dev/null +++ b/mycroft/res/text/cs-cz/backend.down.dialog @@ -0,0 +1,4 @@ +Mám problém s komunikací se servery Mycroftu. Prosím, dejte mi pár minut než na mě budete mluvit. +Mám problém s komunikací se servery Mycroftu. Prosím, počkejte pár minut než na mě budete mluvit. +Vypadá to, že se nemohu připojit k serverům Mycroftu. Prosím, dejte mi pár minut než na mě budete mluvit. +Vypadá to, že se nemohu připojit k serverům Mycroftu. Prosím, počkejte pár pár minut než na mě budete mluvit. \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/cancel.voc b/mycroft/res/text/cs-cz/cancel.voc new file mode 100644 index 0000000000..3088421965 --- /dev/null +++ b/mycroft/res/text/cs-cz/cancel.voc @@ -0,0 +1,3 @@ +zrušit +nevadí +zapomeň to diff --git a/mycroft/res/text/cs-cz/checking for updates.dialog b/mycroft/res/text/cs-cz/checking for updates.dialog new file mode 100644 index 0000000000..01c3c2f172 --- /dev/null +++ b/mycroft/res/text/cs-cz/checking for updates.dialog @@ -0,0 +1,2 @@ +Kontroluji aktualizace +Prosím o chvilku strpení, akltualizuji se diff --git a/mycroft/res/text/cs-cz/day.word b/mycroft/res/text/cs-cz/day.word new file mode 100644 index 0000000000..aaf7116c61 --- /dev/null +++ b/mycroft/res/text/cs-cz/day.word @@ -0,0 +1 @@ +den \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/days.word b/mycroft/res/text/cs-cz/days.word new file mode 100644 index 0000000000..ed195f76a3 --- /dev/null +++ b/mycroft/res/text/cs-cz/days.word @@ -0,0 +1 @@ +dny \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/hour.word b/mycroft/res/text/cs-cz/hour.word new file mode 100644 index 0000000000..e8a056520f --- /dev/null +++ b/mycroft/res/text/cs-cz/hour.word @@ -0,0 +1 @@ +hodina \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/hours.word b/mycroft/res/text/cs-cz/hours.word new file mode 100644 index 0000000000..c62f391367 --- /dev/null +++ b/mycroft/res/text/cs-cz/hours.word @@ -0,0 +1 @@ +hodiny \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/i didn't catch that.dialog b/mycroft/res/text/cs-cz/i didn't catch that.dialog new file mode 100644 index 0000000000..31107c589d --- /dev/null +++ b/mycroft/res/text/cs-cz/i didn't catch that.dialog @@ -0,0 +1,4 @@ +Omlouvám se, nerozumněl jsem +Bojím se, že jsem neporozumněl +Můžete to říci znovu? +Můžete to zopakovat? diff --git a/mycroft/res/text/cs-cz/last.voc b/mycroft/res/text/cs-cz/last.voc new file mode 100644 index 0000000000..91b20b5263 --- /dev/null +++ b/mycroft/res/text/cs-cz/last.voc @@ -0,0 +1,2 @@ +poslední možnost +poslední \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/learning disabled.dialog b/mycroft/res/text/cs-cz/learning disabled.dialog new file mode 100644 index 0000000000..f862630b9a --- /dev/null +++ b/mycroft/res/text/cs-cz/learning disabled.dialog @@ -0,0 +1 @@ +Interakční data nebudou dále odesílána do Mycroft AI. \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/learning enabled.dialog b/mycroft/res/text/cs-cz/learning enabled.dialog new file mode 100644 index 0000000000..d0bf9b620e --- /dev/null +++ b/mycroft/res/text/cs-cz/learning enabled.dialog @@ -0,0 +1 @@ +Od teď budu odesílat interakční data Mycroft AI pro účely učení. Momentálně to obsahuje i nahrávání wake-word aktivace. \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/message_loading.skills.dialog b/mycroft/res/text/cs-cz/message_loading.skills.dialog new file mode 100644 index 0000000000..38295c608a --- /dev/null +++ b/mycroft/res/text/cs-cz/message_loading.skills.dialog @@ -0,0 +1 @@ +< < < NAČÍTÁNÍ < < < \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/message_rebooting.dialog b/mycroft/res/text/cs-cz/message_rebooting.dialog new file mode 100644 index 0000000000..35d08e0f03 --- /dev/null +++ b/mycroft/res/text/cs-cz/message_rebooting.dialog @@ -0,0 +1 @@ +RESTARTUJI... \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/message_synching.clock.dialog b/mycroft/res/text/cs-cz/message_synching.clock.dialog new file mode 100644 index 0000000000..6acdb71613 --- /dev/null +++ b/mycroft/res/text/cs-cz/message_synching.clock.dialog @@ -0,0 +1 @@ +< < < SYNCHRONIZACE < < < \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/message_updating.dialog b/mycroft/res/text/cs-cz/message_updating.dialog new file mode 100644 index 0000000000..20e4c63e6f --- /dev/null +++ b/mycroft/res/text/cs-cz/message_updating.dialog @@ -0,0 +1 @@ +< < < AKTUALIZUJI < < < \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/minute.word b/mycroft/res/text/cs-cz/minute.word new file mode 100644 index 0000000000..02810dfef3 --- /dev/null +++ b/mycroft/res/text/cs-cz/minute.word @@ -0,0 +1 @@ +minuta \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/minutes.word b/mycroft/res/text/cs-cz/minutes.word new file mode 100644 index 0000000000..5181d686a3 --- /dev/null +++ b/mycroft/res/text/cs-cz/minutes.word @@ -0,0 +1 @@ +minuty \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/mycroft.intro.dialog b/mycroft/res/text/cs-cz/mycroft.intro.dialog new file mode 100644 index 0000000000..3a4a609523 --- /dev/null +++ b/mycroft/res/text/cs-cz/mycroft.intro.dialog @@ -0,0 +1 @@ +Dobrý den, já jsem Mycroft, váš nový hlasový asistent. Abych vám mohl asistovat, potřebuji být přpojen k internetu. Můžete mne připojit síťovým kabelem nebo přes wifi. Následujte tyto instrukce pro použití wifi: \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/no.voc b/mycroft/res/text/cs-cz/no.voc new file mode 100644 index 0000000000..3b4762b8df --- /dev/null +++ b/mycroft/res/text/cs-cz/no.voc @@ -0,0 +1,2 @@ +ne +nechci \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/not connected to the internet.dialog b/mycroft/res/text/cs-cz/not connected to the internet.dialog new file mode 100644 index 0000000000..acdb301537 --- /dev/null +++ b/mycroft/res/text/cs-cz/not connected to the internet.dialog @@ -0,0 +1,3 @@ +Zdá se, že nejsem připojen připojen k internetu, zkontrolujte prosím vaše síťové připojení. +Nemohu se připojit k internetu, zkontrolujte prosím vaše síťové připojení. +Mám problém se připojit k internetu, zkontrolujte prosím vaše síťové připojení. \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/not.loaded.dialog b/mycroft/res/text/cs-cz/not.loaded.dialog new file mode 100644 index 0000000000..4d1e7fad43 --- /dev/null +++ b/mycroft/res/text/cs-cz/not.loaded.dialog @@ -0,0 +1 @@ +Počkejte prosím chvíly než dokončím startování. \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/or.word b/mycroft/res/text/cs-cz/or.word new file mode 100644 index 0000000000..38c14cabbc --- /dev/null +++ b/mycroft/res/text/cs-cz/or.word @@ -0,0 +1 @@ +nebo \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/phonetic_spellings.txt b/mycroft/res/text/cs-cz/phonetic_spellings.txt new file mode 100644 index 0000000000..dfa3cb9a67 --- /dev/null +++ b/mycroft/res/text/cs-cz/phonetic_spellings.txt @@ -0,0 +1,5 @@ +jalepeno: hallipeenyo +ai: A.I. +mycroftai: mycroft A.I. +spotify: spot-ify +corgi: core-gee diff --git a/mycroft/res/text/cs-cz/reset to factory defaults.dialog b/mycroft/res/text/cs-cz/reset to factory defaults.dialog new file mode 100644 index 0000000000..a021d53970 --- /dev/null +++ b/mycroft/res/text/cs-cz/reset to factory defaults.dialog @@ -0,0 +1 @@ +Byl jsem resetován do továrního nastavení. \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/second.word b/mycroft/res/text/cs-cz/second.word new file mode 100644 index 0000000000..ef210e25cf --- /dev/null +++ b/mycroft/res/text/cs-cz/second.word @@ -0,0 +1 @@ +sekunda \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/seconds.word b/mycroft/res/text/cs-cz/seconds.word new file mode 100644 index 0000000000..91bf5861de --- /dev/null +++ b/mycroft/res/text/cs-cz/seconds.word @@ -0,0 +1 @@ +sekundy \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/skill.error.dialog b/mycroft/res/text/cs-cz/skill.error.dialog new file mode 100644 index 0000000000..f53b316f53 --- /dev/null +++ b/mycroft/res/text/cs-cz/skill.error.dialog @@ -0,0 +1 @@ +Naskytla se chyba při zracování požadavku v {{skill}} \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/skills updated.dialog b/mycroft/res/text/cs-cz/skills updated.dialog new file mode 100644 index 0000000000..059ac7d0fa --- /dev/null +++ b/mycroft/res/text/cs-cz/skills updated.dialog @@ -0,0 +1,2 @@ +Nyní jsem zaktualizován na poslední verzi. +Dovednosti jsou zaktualizovány. Jsem připraven vám pomáhat. \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/sorry I couldn't install default skills.dialog b/mycroft/res/text/cs-cz/sorry I couldn't install default skills.dialog new file mode 100644 index 0000000000..0e8bd5bf56 --- /dev/null +++ b/mycroft/res/text/cs-cz/sorry I couldn't install default skills.dialog @@ -0,0 +1 @@ +vyskytla se chyba při aktualizaci dovedností \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/ssh disabled.dialog b/mycroft/res/text/cs-cz/ssh disabled.dialog new file mode 100644 index 0000000000..f5efc6e37e --- /dev/null +++ b/mycroft/res/text/cs-cz/ssh disabled.dialog @@ -0,0 +1 @@ +SSH přihlašování bylo zakázáno \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/ssh enabled.dialog b/mycroft/res/text/cs-cz/ssh enabled.dialog new file mode 100644 index 0000000000..e4961c272d --- /dev/null +++ b/mycroft/res/text/cs-cz/ssh enabled.dialog @@ -0,0 +1 @@ +SSH přihlašování je povoleno \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/time.changed.reboot.dialog b/mycroft/res/text/cs-cz/time.changed.reboot.dialog new file mode 100644 index 0000000000..313f04b061 --- /dev/null +++ b/mycroft/res/text/cs-cz/time.changed.reboot.dialog @@ -0,0 +1 @@ +Potřebuji se restartovat po synchronizaci času s internetem, hned budu zpět. \ No newline at end of file diff --git a/mycroft/res/text/cs-cz/yes.voc b/mycroft/res/text/cs-cz/yes.voc new file mode 100644 index 0000000000..63974b07d4 --- /dev/null +++ b/mycroft/res/text/cs-cz/yes.voc @@ -0,0 +1,6 @@ +ano +povrzuji +jistě +potvrzeno +souhlasím +prosím \ No newline at end of file diff --git a/mycroft/res/text/en-us/configurations.json b/mycroft/res/text/en-us/configurations.json new file mode 100644 index 0000000000..b446e7cae4 --- /dev/null +++ b/mycroft/res/text/en-us/configurations.json @@ -0,0 +1,33 @@ +{ + "unit system": { + "metric": {"system_unit": "metric"}, + "imperial": {"system_unit": "imperial"} + }, + "location": { + "stockholm": { + "location": { + "city": { + "name": "Stockholm", + "state": { + "code": "SE.18", + "country": { + "code": "SE", + "name": "Sweden" + }, + "name": "Stockholm" + } + }, + "coordinate": { + "latitude": 59.38306, + "longitude": 16.66667 + }, + "timezone": { + "code": "Europe/Stockholm", + "dst_offset": 7200000, + "name": "Europe/Stockholm", + "offset": 3600000 + } + } + } + } +} diff --git a/mycroft/res/text/pl-pl/and.word b/mycroft/res/text/pl-pl/and.word new file mode 100644 index 0000000000..0ddf2bae71 --- /dev/null +++ b/mycroft/res/text/pl-pl/and.word @@ -0,0 +1 @@ +i diff --git a/mycroft/res/text/pl-pl/backend.down.dialog b/mycroft/res/text/pl-pl/backend.down.dialog new file mode 100644 index 0000000000..2822ca3f66 --- /dev/null +++ b/mycroft/res/text/pl-pl/backend.down.dialog @@ -0,0 +1,4 @@ +Wystąpił problem z połączeniem do serwerów Mycroft. Daj mi parę minut przed kolejnym poleceniem. +Wystąpił problem z połączeniem do serwerów Mycroft. Poczekaj parę minut przed kolejnym poleceniem. +Wygląda na to, że nie mogę się połączyć z serwerami Mycroft. Daj mi parę minut przed kolejnym poleceniem. +Wygląda na to, że nie mogę się połączyć z serwerami Mycroft. Poczekaj parę minut przed kolejnym poleceniem. diff --git a/mycroft/res/text/pl-pl/cancel.voc b/mycroft/res/text/pl-pl/cancel.voc new file mode 100644 index 0000000000..9e1d89cd11 --- /dev/null +++ b/mycroft/res/text/pl-pl/cancel.voc @@ -0,0 +1,4 @@ +anuluj +nieważne +zapomnij +stop diff --git a/mycroft/res/text/pl-pl/checking for updates.dialog b/mycroft/res/text/pl-pl/checking for updates.dialog new file mode 100644 index 0000000000..d5207300a5 --- /dev/null +++ b/mycroft/res/text/pl-pl/checking for updates.dialog @@ -0,0 +1,2 @@ +Sprawdzam aktualizacje +Aktualizuję się, daj mi chwilę diff --git a/mycroft/res/text/pl-pl/day.word b/mycroft/res/text/pl-pl/day.word new file mode 100644 index 0000000000..fdd7af4d09 --- /dev/null +++ b/mycroft/res/text/pl-pl/day.word @@ -0,0 +1 @@ +dzień diff --git a/mycroft/res/text/pl-pl/days.word b/mycroft/res/text/pl-pl/days.word new file mode 100644 index 0000000000..a7738f00f0 --- /dev/null +++ b/mycroft/res/text/pl-pl/days.word @@ -0,0 +1 @@ +dni diff --git a/mycroft/res/text/pl-pl/hour.word b/mycroft/res/text/pl-pl/hour.word new file mode 100644 index 0000000000..8d8bde0bd7 --- /dev/null +++ b/mycroft/res/text/pl-pl/hour.word @@ -0,0 +1 @@ +godzina diff --git a/mycroft/res/text/pl-pl/hours.word b/mycroft/res/text/pl-pl/hours.word new file mode 100644 index 0000000000..a53f25307c --- /dev/null +++ b/mycroft/res/text/pl-pl/hours.word @@ -0,0 +1 @@ +godziny diff --git a/mycroft/res/text/pl-pl/i didn't catch that.dialog b/mycroft/res/text/pl-pl/i didn't catch that.dialog new file mode 100644 index 0000000000..d381297abc --- /dev/null +++ b/mycroft/res/text/pl-pl/i didn't catch that.dialog @@ -0,0 +1,4 @@ +Przepraszam, nie rozumiem tego +Możesz powtórzyć? +Nie jestem pewny czy to zrozumiałem +Możesz powiedzieć ponownie? diff --git a/mycroft/res/text/pl-pl/last.voc b/mycroft/res/text/pl-pl/last.voc new file mode 100644 index 0000000000..f4786cd553 --- /dev/null +++ b/mycroft/res/text/pl-pl/last.voc @@ -0,0 +1,3 @@ +ostatni wybór +ostatnia opcja +ostatnia diff --git a/mycroft/res/text/pl-pl/learning disabled.dialog b/mycroft/res/text/pl-pl/learning disabled.dialog new file mode 100644 index 0000000000..28dc764531 --- /dev/null +++ b/mycroft/res/text/pl-pl/learning disabled.dialog @@ -0,0 +1 @@ +Dane interakcji nie będą już wysyłane do Mycroft AI. diff --git a/mycroft/res/text/pl-pl/learning enabled.dialog b/mycroft/res/text/pl-pl/learning enabled.dialog new file mode 100644 index 0000000000..e8c9219f26 --- /dev/null +++ b/mycroft/res/text/pl-pl/learning enabled.dialog @@ -0,0 +1 @@ +Od tej pory będę wysyłał dane interakcji do Mycroft AI dzięki czemu będę mądrzejszy. Obecnie wysyłam także nagrania polecenia aktywacji. diff --git a/mycroft/res/text/pl-pl/message_loading.skills.dialog b/mycroft/res/text/pl-pl/message_loading.skills.dialog new file mode 100644 index 0000000000..5975882500 --- /dev/null +++ b/mycroft/res/text/pl-pl/message_loading.skills.dialog @@ -0,0 +1 @@ +< < < ŁADUJĘ < < < diff --git a/mycroft/res/text/pl-pl/message_rebooting.dialog b/mycroft/res/text/pl-pl/message_rebooting.dialog new file mode 100644 index 0000000000..6d2e6fc097 --- /dev/null +++ b/mycroft/res/text/pl-pl/message_rebooting.dialog @@ -0,0 +1 @@ +RESETUJĘ... diff --git a/mycroft/res/text/pl-pl/message_synching.clock.dialog b/mycroft/res/text/pl-pl/message_synching.clock.dialog new file mode 100644 index 0000000000..042043e00a --- /dev/null +++ b/mycroft/res/text/pl-pl/message_synching.clock.dialog @@ -0,0 +1 @@ +< < < SYNCHRONIZACJA < < < diff --git a/mycroft/res/text/pl-pl/message_updating.dialog b/mycroft/res/text/pl-pl/message_updating.dialog new file mode 100644 index 0000000000..0b46671159 --- /dev/null +++ b/mycroft/res/text/pl-pl/message_updating.dialog @@ -0,0 +1 @@ +< < < AKTUALIZUJĘ < < < diff --git a/mycroft/res/text/pl-pl/minute.word b/mycroft/res/text/pl-pl/minute.word new file mode 100644 index 0000000000..1b52e61736 --- /dev/null +++ b/mycroft/res/text/pl-pl/minute.word @@ -0,0 +1 @@ +minuta diff --git a/mycroft/res/text/pl-pl/minutes.word b/mycroft/res/text/pl-pl/minutes.word new file mode 100644 index 0000000000..a2d78dc05c --- /dev/null +++ b/mycroft/res/text/pl-pl/minutes.word @@ -0,0 +1 @@ +minuty diff --git a/mycroft/res/text/pl-pl/mycroft.intro.dialog b/mycroft/res/text/pl-pl/mycroft.intro.dialog new file mode 100644 index 0000000000..6ac1d83bd1 --- /dev/null +++ b/mycroft/res/text/pl-pl/mycroft.intro.dialog @@ -0,0 +1 @@ +Cześć, jestem Mycroft, Twój nowy asystent. Żeby móc to robić, potrzebuję połączenia z Internetem. Możesz mnie podpiąć albo kablem Ethernet, albo do wifi. Aby połączyć z wifi, użyj tych instrukcji: diff --git a/mycroft/res/text/pl-pl/no.voc b/mycroft/res/text/pl-pl/no.voc new file mode 100644 index 0000000000..c4df798789 --- /dev/null +++ b/mycroft/res/text/pl-pl/no.voc @@ -0,0 +1,2 @@ +nie +odmawiam diff --git a/mycroft/res/text/pl-pl/not connected to the internet.dialog b/mycroft/res/text/pl-pl/not connected to the internet.dialog new file mode 100644 index 0000000000..09d04551b6 --- /dev/null +++ b/mycroft/res/text/pl-pl/not connected to the internet.dialog @@ -0,0 +1,3 @@ +Nie mogę się połączyć z Internetem, sprawdź proszę połączenie. +Wygląda na to, że nie jestem podłączony do Internetu, sprawdź proszę połączenie. +Połączenie z Internetem nie działa, zweryfikuj czy jest poprawne. diff --git a/mycroft/res/text/pl-pl/not.loaded.dialog b/mycroft/res/text/pl-pl/not.loaded.dialog new file mode 100644 index 0000000000..c463831331 --- /dev/null +++ b/mycroft/res/text/pl-pl/not.loaded.dialog @@ -0,0 +1 @@ +Poczekaj jeszcze chwilę aż skończę się uruchamiać. diff --git a/mycroft/res/text/pl-pl/or.word b/mycroft/res/text/pl-pl/or.word new file mode 100644 index 0000000000..5f914e5892 --- /dev/null +++ b/mycroft/res/text/pl-pl/or.word @@ -0,0 +1 @@ +albo diff --git a/mycroft/res/text/pl-pl/phonetic_spellings.txt b/mycroft/res/text/pl-pl/phonetic_spellings.txt new file mode 100644 index 0000000000..dfa3cb9a67 --- /dev/null +++ b/mycroft/res/text/pl-pl/phonetic_spellings.txt @@ -0,0 +1,5 @@ +jalepeno: hallipeenyo +ai: A.I. +mycroftai: mycroft A.I. +spotify: spot-ify +corgi: core-gee diff --git a/mycroft/res/text/pl-pl/reset to factory defaults.dialog b/mycroft/res/text/pl-pl/reset to factory defaults.dialog new file mode 100644 index 0000000000..ec5c805bed --- /dev/null +++ b/mycroft/res/text/pl-pl/reset to factory defaults.dialog @@ -0,0 +1 @@ +Zostałem przywrócony do ustawień fabrycznych. diff --git a/mycroft/res/text/pl-pl/second.word b/mycroft/res/text/pl-pl/second.word new file mode 100644 index 0000000000..f07a357fa7 --- /dev/null +++ b/mycroft/res/text/pl-pl/second.word @@ -0,0 +1 @@ +sekunda diff --git a/mycroft/res/text/pl-pl/seconds.word b/mycroft/res/text/pl-pl/seconds.word new file mode 100644 index 0000000000..b4b7bb9171 --- /dev/null +++ b/mycroft/res/text/pl-pl/seconds.word @@ -0,0 +1 @@ +sekundy diff --git a/mycroft/res/text/pl-pl/skill.error.dialog b/mycroft/res/text/pl-pl/skill.error.dialog new file mode 100644 index 0000000000..a0d4e6c96d --- /dev/null +++ b/mycroft/res/text/pl-pl/skill.error.dialog @@ -0,0 +1 @@ +Wystąpił błąd podczas przetwarzania polecenia przez {{skill}} diff --git a/mycroft/res/text/pl-pl/skills updated.dialog b/mycroft/res/text/pl-pl/skills updated.dialog new file mode 100644 index 0000000000..b62ebed8e0 --- /dev/null +++ b/mycroft/res/text/pl-pl/skills updated.dialog @@ -0,0 +1,2 @@ +Jestem na bieżąco z aktualizacjami +Aktualizacje zainstalowane, w czym mogę pomóc? diff --git a/mycroft/res/text/pl-pl/sorry I couldn't install default skills.dialog b/mycroft/res/text/pl-pl/sorry I couldn't install default skills.dialog new file mode 100644 index 0000000000..3f34d8bf02 --- /dev/null +++ b/mycroft/res/text/pl-pl/sorry I couldn't install default skills.dialog @@ -0,0 +1 @@ +wystąpił błąd podczas aktualizacji umiejętności diff --git a/mycroft/res/text/pl-pl/ssh disabled.dialog b/mycroft/res/text/pl-pl/ssh disabled.dialog new file mode 100644 index 0000000000..24de406d51 --- /dev/null +++ b/mycroft/res/text/pl-pl/ssh disabled.dialog @@ -0,0 +1 @@ +Logowanie przez SSH zostało wyłączone diff --git a/mycroft/res/text/pl-pl/ssh enabled.dialog b/mycroft/res/text/pl-pl/ssh enabled.dialog new file mode 100644 index 0000000000..3672b20b7c --- /dev/null +++ b/mycroft/res/text/pl-pl/ssh enabled.dialog @@ -0,0 +1 @@ +Logowanie przez SSH zostało włączone diff --git a/mycroft/res/text/pl-pl/time.changed.reboot.dialog b/mycroft/res/text/pl-pl/time.changed.reboot.dialog new file mode 100644 index 0000000000..0e2794e552 --- /dev/null +++ b/mycroft/res/text/pl-pl/time.changed.reboot.dialog @@ -0,0 +1 @@ +Muszę się zresetować po synchronizacja zegara, zaraz wracam. diff --git a/mycroft/res/text/pl-pl/yes.voc b/mycroft/res/text/pl-pl/yes.voc new file mode 100644 index 0000000000..987e9b7ff7 --- /dev/null +++ b/mycroft/res/text/pl-pl/yes.voc @@ -0,0 +1,6 @@ +tak +pewnie +jasne +oczywiście +potwierdzam +zgadza się diff --git a/mycroft/res/ui/SYSTEM_AnimatedImageFrame.qml b/mycroft/res/ui/SYSTEM_AnimatedImageFrame.qml new file mode 100644 index 0000000000..9631fb94fe --- /dev/null +++ b/mycroft/res/ui/SYSTEM_AnimatedImageFrame.qml @@ -0,0 +1,83 @@ +import QtQuick.Layouts 1.4 +import QtQuick 2.4 +import QtQuick.Controls 2.0 +import org.kde.kirigami 2.4 as Kirigami + +import Mycroft 1.0 as Mycroft + +Mycroft.Delegate { + id: systemImageFrame + skillBackgroundColorOverlay: "#000000" + property bool hasTitle: sessionData.title.length > 0 ? true : false + property bool hasCaption: sessionData.caption.length > 0 ? true : false + + ColumnLayout { + id: systemImageFrameLayout + anchors.fill: parent + + Kirigami.Heading { + id: systemImageTitle + visible: hasTitle + enabled: hasTitle + Layout.fillWidth: true + Layout.preferredHeight: paintedHeight + Kirigami.Units.largeSpacing + level: 3 + text: sessionData.title + wrapMode: Text.Wrap + font.family: "Noto Sans" + font.weight: Font.Bold + } + + AnimatedImage { + id: systemImageDisplay + visible: true + enabled: true + Layout.fillWidth: true + Layout.fillHeight: true + source: sessionData.image + property var fill: sessionData.fill + + onFillChanged: { + console.log(fill) + if(fill == "PreserveAspectCrop"){ + systemImageDisplay.fillMode = 2 + } else if (fill == "PreserveAspectFit"){ + console.log("inFit") + systemImageDisplay.fillMode = 1 + } else if (fill == "Stretch"){ + systemImageDisplay.fillMode = 0 + } else { + systemImageDisplay.fillMode = 0 + } + } + + + Rectangle { + id: systemImageCaptionBox + visible: hasCaption + enabled: hasCaption + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: systemImageCaption.paintedHeight + color: "#95000000" + + Kirigami.Heading { + id: systemImageCaption + level: 2 + anchors.left: parent.left + anchors.leftMargin: Kirigami.Units.largeSpacing + anchors.right: parent.right + anchors.rightMargin: Kirigami.Units.largeSpacing + anchors.verticalCenter: parent.verticalCenter + text: sessionData.caption + wrapMode: Text.Wrap + font.family: "Noto Sans" + font.weight: Font.Bold + } + } + } + } +} + + diff --git a/mycroft/skills/__main__.py b/mycroft/skills/__main__.py index 30a1d6d8d8..1f625b6f0a 100644 --- a/mycroft/skills/__main__.py +++ b/mycroft/skills/__main__.py @@ -43,7 +43,6 @@ from mycroft.util.log import LOG from .core import FallbackSkill from .event_scheduler import EventScheduler from .intent_service import IntentService -from .padatious_service import PadatiousService from .skill_manager import SkillManager RASPBERRY_PI_PLATFORMS = ('mycroft_mark_1', 'picroft', 'mycroft_mark_2pi') @@ -173,7 +172,20 @@ class DevicePrimer(object): wait_while_speaking() -def main(): +def on_ready(): + LOG.info('Skill service is ready.') + + +def on_error(e='Unknown'): + LOG.info('Skill service failed to launch ({})'.format(repr(e))) + + +def on_stopping(): + LOG.info('Skill service is shutting down...') + + +def main(ready_hook=on_ready, error_hook=on_error, stopping_hook=on_stopping, + watchdog=None): reset_sigint_handler() # Create PID file, prevent multiple instances of this service mycroft.lock.Lock('skills') @@ -185,18 +197,21 @@ def main(): bus = _start_message_bus_client() _register_intent_services(bus) event_scheduler = EventScheduler(bus) - skill_manager = _initialize_skill_manager(bus) + skill_manager = _initialize_skill_manager(bus, watchdog) _wait_for_internet_connection() if skill_manager is None: - skill_manager = _initialize_skill_manager(bus) + skill_manager = _initialize_skill_manager(bus, watchdog) device_primer = DevicePrimer(bus, config) device_primer.prepare_device() skill_manager.start() - + while not skill_manager.is_alive(): + time.sleep(0.1) + ready_hook() # Report ready status wait_for_exit_signal() + stopping_hook() # Report shutdown started shutdown(skill_manager, event_scheduler) @@ -224,24 +239,24 @@ def _register_intent_services(bus): bus: messagebus client to register the services on """ service = IntentService(bus) - try: - PadatiousService(bus, service) - except Exception as e: - LOG.exception('Failed to create padatious handlers ' - '({})'.format(repr(e))) - # Register handler to trigger fallback system + bus.on( + 'mycroft.skills.fallback', + FallbackSkill.make_intent_failure_handler(bus) + ) + # Backwards compatibility TODO: remove in 20.08 bus.on('intent_failure', FallbackSkill.make_intent_failure_handler(bus)) + return service -def _initialize_skill_manager(bus): +def _initialize_skill_manager(bus, watchdog): """Create a thread that monitors the loaded skills, looking for updates Returns: SkillManager instance or None if it couldn't be initialized """ try: - skill_manager = SkillManager(bus) + skill_manager = SkillManager(bus, watchdog) skill_manager.load_priority() except MsmException: # skill manager couldn't be created, wait for network connection and diff --git a/mycroft/skills/common_iot_skill.py b/mycroft/skills/common_iot_skill.py index bf6b349f8e..54f04c6fa9 100644 --- a/mycroft/skills/common_iot_skill.py +++ b/mycroft/skills/common_iot_skill.py @@ -456,11 +456,10 @@ class CommonIoTSkill(MycroftSkill, ABC): word_type: """ if words: - message = dig_for_message() - self.bus.emit(message.forward(_BusKeys.REGISTER, - data={"skill_id": self.skill_id, - "type": word_type, - "words": list(words)})) + self.bus.emit(Message(_BusKeys.REGISTER, + data={"skill_id": self.skill_id, + "type": word_type, + "words": list(words)})) def register_entities_and_scenes(self): """ diff --git a/mycroft/skills/common_play_skill.py b/mycroft/skills/common_play_skill.py index e8eed99afb..ed8e484fc4 100644 --- a/mycroft/skills/common_play_skill.py +++ b/mycroft/skills/common_play_skill.py @@ -13,7 +13,7 @@ # limitations under the License. import re -from enum import Enum +from enum import Enum, IntEnum from abc import ABC, abstractmethod from mycroft.messagebus.message import Message from .mycroft_skill import MycroftSkill @@ -29,6 +29,22 @@ class CPSMatchLevel(Enum): GENERIC = 6 +class CPSTrackStatus(IntEnum): + DISAMBIGUATION = 1 # not queued for playback, show in gui + PLAYING = 20 # Skill is handling playback internally + PLAYING_AUDIOSERVICE = 21 # Skill forwarded playback to audio service + PLAYING_GUI = 22 # Skill forwarded playback to gui + PLAYING_ENCLOSURE = 23 # Skill forwarded playback to enclosure + QUEUED = 30 # Waiting playback to be handled inside skill + QUEUED_AUDIOSERVICE = 31 # Waiting playback in audio service + QUEUED_GUI = 32 # Waiting playback in gui + QUEUED_ENCLOSURE = 33 # Waiting for playback in enclosure + PAUSED = 40 # media paused but ready to resume + STALLED = 60 # playback has stalled, reason may be unknown + BUFFERING = 61 # media is buffering from an external source + END_OF_MEDIA = 90 # playback finished, is the default state when CPS loads + + class CommonPlaySkill(MycroftSkill, ABC): """ To integrate with the common play infrastructure of Mycroft skills should use this base class and override the two methods @@ -168,6 +184,8 @@ class CommonPlaySkill(MycroftSkill, ABC): if 'utterance' not in kwargs: kwargs['utterance'] = self.play_service_string self.audioservice.play(*args, **kwargs) + self.CPS_send_status(uri=args[0], + status=CPSTrackStatus.PLAYING_AUDIOSERVICE) def stop(self): """Stop anything playing on the audioservice.""" @@ -222,3 +240,55 @@ class CommonPlaySkill(MycroftSkill, ABC): # Derived classes must implement this, e.g. # self.CPS_play("http://zoosh.com/stream_music") pass + + def CPS_send_status(self, artist='', track='', album='', image='', + uri='', track_length=None, elapsed_time=None, + playlist_position=None, + status=CPSTrackStatus.DISAMBIGUATION, **kwargs): + """Inform system of playback status. + + If a skill is handling playback and wants the playback control to be + aware of it's current status it can emit this message indicating that + it's performing playback and can provide some standard info. + + All parameters are optional so any can be left out. Also if extra + non-standard parameters are added, they too will be sent in the message + data. + + Arguments: + artist (str): Current track artist + track (str): Track name + album (str): Album title + image (str): url for image to show + uri (str): uri for track + track_length (float): track length in seconds + elapsed_time (float): current offset into track in seconds + playlist_position (int): Position in playlist of current track + """ + data = {'skill': self.name, + 'uri': uri, + 'artist': artist, + 'album': album, + 'track': track, + 'image': image, + 'track_length': track_length, + 'elapsed_time': elapsed_time, + 'playlist_position': playlist_position, + 'status': status + } + data = {**data, **kwargs} # Merge extra arguments + self.bus.emit(Message('play:status', data)) + + def CPS_send_tracklist(self, tracklist): + """Inform system of playlist track info. + + Provides track data for playlist + + Arguments: + tracklist (list/dict): Tracklist data + """ + tracklist = tracklist or [] + if not isinstance(tracklist, list): + tracklist = [tracklist] + for idx, track in enumerate(tracklist): + self.CPS_send_status(playlist_position=idx, **track) diff --git a/mycroft/skills/context.py b/mycroft/skills/context.py index 5c8b9fefae..598ebdf020 100644 --- a/mycroft/skills/context.py +++ b/mycroft/skills/context.py @@ -15,27 +15,32 @@ from functools import wraps """ - Helper decorators for handling context from skills. +Helper decorators for handling context from skills. """ def adds_context(context, words=''): - """ - Adds context to context manager. + """Decorator adding context to the Adapt context manager. + + Arguments: + context (str): context Keyword to insert + words (str): optional string content of Keyword """ def context_add_decorator(func): @wraps(func) def func_wrapper(*args, **kwargs): ret = func(*args, **kwargs) - args[0].set_context(context) + args[0].set_context(context, words) return ret return func_wrapper return context_add_decorator def removes_context(context): - """ - Removes context from the context manager. + """Decorator removing context from the Adapt context manager. + + Arguments: + context (str): Context keyword to remove """ def context_removes_decorator(func): @wraps(func) diff --git a/mycroft/skills/fallback_skill.py b/mycroft/skills/fallback_skill.py index cef2cd7b9c..d239074669 100644 --- a/mycroft/skills/fallback_skill.py +++ b/mycroft/skills/fallback_skill.py @@ -48,6 +48,7 @@ class FallbackSkill(MycroftSkill): utterance will not be see by any other Fallback handlers. """ fallback_handlers = {} + wrapper_map = [] # Map containing (handler, wrapper) tuples def __init__(self, name=None, bus=None, use_settings=True): super().__init__(name, bus, use_settings) @@ -60,18 +61,25 @@ class FallbackSkill(MycroftSkill): """Goes through all fallback handlers until one returns True""" def handler(message): + start, stop = message.data.get('fallback_range', (0, 101)) # indicate fallback handling start + LOG.debug('Checking fallbacks in range ' + '{} - {}'.format(start, stop)) bus.emit(message.forward("mycroft.skill.handler.start", data={'handler': "fallback"})) stopwatch = Stopwatch() handler_name = None with stopwatch: - for _, handler in sorted(cls.fallback_handlers.items(), - key=operator.itemgetter(0)): + sorted_handlers = sorted(cls.fallback_handlers.items(), + key=operator.itemgetter(0)) + handlers = [f[1] for f in sorted_handlers + if start <= f[0] < stop] + for handler in handlers: try: if handler(message): - # indicate completion + # indicate completion + status = True handler_name = get_handler_name(handler) bus.emit(message.forward( 'mycroft.skill.handler.complete', @@ -80,14 +88,21 @@ class FallbackSkill(MycroftSkill): break except Exception: LOG.exception('Exception in fallback.') - else: # No fallback could handle the utterance - bus.emit(message.forward('complete_intent_failure')) - warning = "No fallback could handle intent." - LOG.warning(warning) + else: + status = False # indicate completion with exception + warning = 'No fallback could handle intent.' bus.emit(message.forward('mycroft.skill.handler.complete', data={'handler': "fallback", 'exception': warning})) + if 'fallback_range' not in message.data: + # Old system TODO: Remove in 20.08 + # No fallback could handle the utterance + bus.emit(message.forward('complete_intent_failure')) + LOG.warning(warning) + + # return if the utterance was handled to the caller + bus.emit(message.response(data={'handled': status})) # Send timing metric if message.context.get('ident'): @@ -98,18 +113,25 @@ class FallbackSkill(MycroftSkill): return handler @classmethod - def _register_fallback(cls, handler, priority): + def _register_fallback(cls, handler, wrapper, priority): """Register a function to be called as a general info fallback Fallback should receive message and return a boolean (True if succeeded or False if failed) Lower priority gets run first 0 for high priority 100 for low priority + + Arguments: + handler (callable): original handler, used as a reference when + removing + wrapper (callable): wrapped version of handler + priority (int): fallback priority """ while priority in cls.fallback_handlers: priority += 1 - cls.fallback_handlers[priority] = handler + cls.fallback_handlers[priority] = wrapper + cls.wrapper_map.append((handler, wrapper)) def register_fallback(self, handler, priority): """Register a fallback with the list of fallback handlers and with the @@ -122,8 +144,28 @@ class FallbackSkill(MycroftSkill): return True return False - self.instance_fallback_handlers.append(wrapper) - self._register_fallback(wrapper, priority) + self.instance_fallback_handlers.append(handler) + self._register_fallback(handler, wrapper, priority) + + @classmethod + def _remove_registered_handler(cls, wrapper_to_del): + """Remove a registered wrapper. + + Arguments: + wrapper_to_del (callable): wrapped handler to be removed + + Returns: + (bool) True if one or more handlers were removed, otherwise False. + """ + found_handler = False + for priority, handler in list(cls.fallback_handlers.items()): + if handler == wrapper_to_del: + found_handler = True + del cls.fallback_handlers[priority] + + if not found_handler: + LOG.warning('No fallback matching {}'.format(wrapper_to_del)) + return found_handler @classmethod def remove_fallback(cls, handler_to_del): @@ -131,15 +173,27 @@ class FallbackSkill(MycroftSkill): Arguments: handler_to_del: reference to handler + Returns: + (bool) True if at least one handler was removed, otherwise False """ - for priority, handler in cls.fallback_handlers.items(): - if handler == handler_to_del: - del cls.fallback_handlers[priority] - return - LOG.warning('Could not remove fallback!') + # Find wrapper from handler or wrapper + wrapper_to_del = None + for h, w in cls.wrapper_map: + if handler_to_del in (h, w): + wrapper_to_del = w + break + + if wrapper_to_del: + cls.wrapper_map.remove((h, w)) + remove_ok = cls._remove_registered_handler(wrapper_to_del) + else: + LOG.warning('Could not find matching fallback handler') + remove_ok = False + return remove_ok def remove_instance_handlers(self): """Remove all fallback handlers registered by the fallback skill.""" + self.log.info('Removing all handlers...') while len(self.instance_fallback_handlers): handler = self.instance_fallback_handlers.pop() self.remove_fallback(handler) diff --git a/mycroft/skills/intent_service.py b/mycroft/skills/intent_service.py index f92b6ca18a..bd1407ce2f 100644 --- a/mycroft/skills/intent_service.py +++ b/mycroft/skills/intent_service.py @@ -12,157 +12,88 @@ # See the License for the specific language governing permissions and # limitations under the License. # +"""Mycroft's intent service, providing intent parsing since forever!""" from copy import copy import time -from adapt.context import ContextManagerFrame -from adapt.engine import IntentDeterminationEngine -from adapt.intent import IntentBuilder from mycroft.configuration import Configuration from mycroft.util.lang import set_active_lang from mycroft.util.log import LOG from mycroft.util.parse import normalize from mycroft.metrics import report_timing, Stopwatch -from mycroft.skills.padatious_service import PadatiousService +from .intent_services import ( + AdaptService, AdaptIntent, FallbackService, PadatiousService, IntentMatch +) from .intent_service_interface import open_intent_envelope - -class AdaptIntent(IntentBuilder): - def __init__(self, name=''): - super().__init__(name) +# TODO: Remove in 20.08 (Backwards compatibility) +from .intent_services.adapt_service import ContextManager -def workaround_one_of_context(best_intent): - """ Handle Adapt issue with context injection combined with one_of. +def _get_message_lang(message): + """Get the language from the message or the default language. - For all entries in the intent result where the value is None try to - populate using a value from the __tags__ structure. + Arguments: + message: message to check for language code. + + Returns: + The languge code from the message or the default language. """ - for key in best_intent: - if best_intent[key] is None: - for t in best_intent['__tags__']: - if key in t: - best_intent[key] = t[key][0]['entities'][0]['key'] - return best_intent + default_lang = Configuration.get().get('lang', 'en-us') + return message.data.get('lang', default_lang).lower() -class ContextManager: - """ - ContextManager - Use to track context throughout the course of a conversational session. - How to manage a session's lifecycle is not captured here. +def _normalize_all_utterances(utterances): + """Create normalized versions and pair them with the original utterance. + + This will create a list of tuples with the original utterance as the + first item and if normalizing changes the utterance the normalized version + will be set as the second item in the tuple, if normalization doesn't + change anything the tuple will only have the "raw" original utterance. + + Arguments: + utterances (list): list of utterances to normalize + + Returns: + list of tuples, [(original utterance, normalized) ... ] """ + # normalize() changes "it's a boy" to "it is a boy", etc. + norm_utterances = [normalize(u.lower(), remove_articles=False) + for u in utterances] - def __init__(self, timeout): - self.frame_stack = [] - self.timeout = timeout * 60 # minutes to seconds - - def clear_context(self): - self.frame_stack = [] - - def remove_context(self, context_id): - self.frame_stack = [(f, t) for (f, t) in self.frame_stack - if context_id in f.entities[0].get('data', [])] - - def inject_context(self, entity, metadata=None): - """ - Args: - entity(object): Format example... - {'data': 'Entity tag as ', - 'key': 'entity proper name as ', - 'confidence': ' - } - metadata(object): dict, arbitrary metadata about entity injected - """ - metadata = metadata or {} - try: - if len(self.frame_stack) > 0: - top_frame = self.frame_stack[0] - else: - top_frame = None - if top_frame and top_frame[0].metadata_matches(metadata): - top_frame[0].merge_context(entity, metadata) - else: - frame = ContextManagerFrame(entities=[entity], - metadata=metadata.copy()) - self.frame_stack.insert(0, (frame, time.time())) - except (IndexError, KeyError): - pass - - def get_context(self, max_frames=None, missing_entities=None): - """ Constructs a list of entities from the context. - - Args: - max_frames(int): maximum number of frames to look back - missing_entities(list of str): a list or set of tag names, - as strings - - Returns: - list: a list of entities - """ - missing_entities = missing_entities or [] - - relevant_frames = [frame[0] for frame in self.frame_stack if - time.time() - frame[1] < self.timeout] - if not max_frames or max_frames > len(relevant_frames): - max_frames = len(relevant_frames) - - missing_entities = list(missing_entities) - context = [] - last = '' - depth = 0 - for i in range(max_frames): - frame_entities = [entity.copy() for entity in - relevant_frames[i].entities] - for entity in frame_entities: - entity['confidence'] = entity.get('confidence', 1.0) \ - / (2.0 + depth) - context += frame_entities - - # Update depth - if entity['origin'] != last or entity['origin'] == '': - depth += 1 - last = entity['origin'] - - result = [] - if len(missing_entities) > 0: - for entity in context: - if entity.get('data') in missing_entities: - result.append(entity) - # NOTE: this implies that we will only ever get one - # of an entity kind from context, unless specified - # multiple times in missing_entities. Cannot get - # an arbitrary number of an entity kind. - missing_entities.remove(entity.get('data')) + # Create pairs of original and normalized counterparts for each entry + # in the input list. + combined = [] + for utt, norm in zip(utterances, norm_utterances): + if utt == norm: + combined.append((utt,)) else: - result = context + combined.append((utt, norm)) - # Only use the latest instance of each keyword - stripped = [] - processed = [] - for f in result: - keyword = f['data'][0][1] - if keyword not in processed: - stripped.append(f) - processed.append(keyword) - result = stripped - return result + LOG.debug("Utterances: {}".format(combined)) + return combined class IntentService: - def __init__(self, bus): - self.config = Configuration.get().get('context', {}) - self.engine = IntentDeterminationEngine() + """Mycroft intent service. parses utterances using a variety of systems. + The intent service also provides the internal API for registering and + querying the intent service. + """ + def __init__(self, bus): # Dictionary for translating a skill id to a name - self.skill_names = {} - # Context related intializations - self.context_keywords = self.config.get('keywords', []) - self.context_max_frames = self.config.get('max_frames', 3) - self.context_timeout = self.config.get('timeout', 2) - self.context_greedy = self.config.get('greedy', False) - self.context_manager = ContextManager(self.context_timeout) self.bus = bus + + self.skill_names = {} + config = Configuration.get() + self.adapt_service = AdaptService(config.get('context', {})) + try: + self.padatious_service = PadatiousService(bus, config['padatious']) + except Exception as err: + LOG.exception('Failed to create padatious handlers ' + '({})'.format(repr(err))) + self.fallback = FallbackService(bus) + self.bus.on('register_vocab', self.handle_register_vocab) self.bus.on('register_intent', self.handle_register_intent) self.bus.on('recognizer_loop:utterance', self.handle_utterance) @@ -172,6 +103,7 @@ class IntentService: self.bus.on('add_context', self.handle_add_context) self.bus.on('remove_context', self.handle_remove_context) self.bus.on('clear_context', self.handle_clear_context) + # Converse method self.bus.on('mycroft.speech.recognition.unknown', self.reset_converse) self.bus.on('mycroft.skills.loaded', self.update_skill_name_dict) @@ -184,7 +116,6 @@ class IntentService: self.converse_timeout = 5 # minutes to prune active_skills # Intents API - self.registered_intents = [] self.registered_vocab = [] self.bus.on('intent.service.adapt.get', self.handle_get_adapt) self.bus.on('intent.service.intent.get', self.handle_get_intent) @@ -195,6 +126,11 @@ class IntentService: self.bus.on('intent.service.adapt.vocab.manifest.get', self.handle_vocab_manifest) + @property + def registered_intents(self): + return [parser.__dict__ + for parser in self.adapt_service.engine.intent_parsers] + def update_skill_name_dict(self, message): """Messagebus handler, updates dict of id to skill name conversions.""" self.skill_names[message.data['id']] = message.data['name'] @@ -212,31 +148,51 @@ class IntentService: def reset_converse(self, message): """Let skills know there was a problem with speech recognition""" - lang = message.data.get('lang', "en-us") + lang = _get_message_lang(message) set_active_lang(lang) for skill in copy(self.active_skills): self.do_converse(None, skill[0], lang, message) def do_converse(self, utterances, skill_id, lang, message): + """Call skill and ask if they want to process the utterance. + + Arguments: + utterances (list of tuples): utterances paired with normalized + versions. + skill_id: skill to query. + lang (str): current language + message (Message): message containing interaction info. + """ converse_msg = (message.reply("skill.converse.request", { "skill_id": skill_id, "utterances": utterances, "lang": lang})) result = self.bus.wait_for_response(converse_msg, 'skill.converse.response') if result and 'error' in result.data: self.handle_converse_error(result) - return False + ret = False elif result is not None: - return result.data.get('result', False) + ret = result.data.get('result', False) else: - return False + ret = False + return ret def handle_converse_error(self, message): + """Handle error in converse system. + + Arguments: + message (Message): info about the error. + """ LOG.error(message.data['error']) skill_id = message.data["skill_id"] if message.data["error"] == "skill id does not exist": self.remove_active_skill(skill_id) def remove_active_skill(self, skill_id): + """Remove a skill from being targetable by converse. + + Arguments: + skill_id (str): skill to remove + """ for skill in self.active_skills: if skill[0] == skill_id: self.active_skills.remove(skill) @@ -260,42 +216,33 @@ class IntentService: LOG.warning('Skill ID was empty, won\'t add to list of ' 'active skills.') - def update_context(self, intent): - """Updates context with keyword from the intent. - - NOTE: This method currently won't handle one_of intent keywords - since it's not using quite the same format as other intent - keywords. This is under investigation in adapt, PR pending. - - Args: - intent: Intent to scan for keywords - """ - for tag in intent['__tags__']: - if 'entities' not in tag: - continue - context_entity = tag['entities'][0] - if self.context_greedy: - self.context_manager.inject_context(context_entity) - elif context_entity['data'][0][1] in self.context_keywords: - self.context_manager.inject_context(context_entity) - def send_metrics(self, intent, context, stopwatch): """Send timing metrics to the backend. NOTE: This only applies to those with Opt In. + + Arguments: + intent (IntentMatch or None): intet match info + context (dict): context info about the interaction + stopwatch (StopWatch): Timing info about the skill parsing. """ ident = context['ident'] if 'ident' in context else None - if intent: + # Determine what handled the intent + if intent and intent.intent_service == 'Converse': + intent_type = '{}:{}'.format(intent.skill_id, 'converse') + elif intent and intent.intent_service == 'Fallback': + intent_type = 'fallback' + elif intent: # Handled by an other intent parser # Recreate skill name from skill id - parts = intent.get('intent_type', '').split(':') + parts = intent.intent_type.split(':') intent_type = self.get_skill_name(parts[0]) if len(parts) > 1: intent_type = ':'.join([intent_type] + parts[1:]) - report_timing(ident, 'intent_service', stopwatch, - {'intent_type': intent_type}) - else: - report_timing(ident, 'intent_service', stopwatch, - {'intent_type': 'intent_failure'}) + else: # No intent was found + intent_type = 'intent_failure' + + report_timing(ident, 'intent_service', stopwatch, + {'intent_type': intent_type}) def handle_utterance(self, message): """Main entrypoint for handling user utterances with Mycroft skills @@ -308,102 +255,74 @@ class IntentService: 1) Active skills attempt to handle using converse() 2) Padatious high match intents (conf > 0.95) 3) Adapt intent handlers - 5) Fallbacks: - - Padatious near match intents (conf > 0.8) - - General fallbacks - - Padatious loose match intents (conf > 0.5) - - Unknown intent handler + 5) High Priority Fallbacks + 6) Padatious near match intents (conf > 0.8) + 7) General Fallbacks + 8) Padatious loose match intents (conf > 0.5) + 9) Catch all fallbacks including Unknown intent handler - Args: + If all these fail the complete_intent_failure message will be sent + and a generic info of the failure will be spoken. + + Arguments: message (Message): The messagebus data """ try: - # Get language of the utterance - lang = message.data.get('lang', "en-us") + lang = _get_message_lang(message) set_active_lang(lang) utterances = message.data.get('utterances', []) - # normalize() changes "it's a boy" to "it is a boy", etc. - norm_utterances = [normalize(u.lower(), remove_articles=False) - for u in utterances] - - # Build list with raw utterance(s) first, then optionally a - # normalized version following. - combined = utterances + list(set(norm_utterances) - - set(utterances)) - LOG.debug("Utterances: {}".format(combined)) + combined = _normalize_all_utterances(utterances) stopwatch = Stopwatch() - intent = None - padatious_intent = None + + # List of functions to use to match the utterance with intent. + # These are listed in priority order. + match_funcs = [ + self._converse, self.padatious_service.match_high, + self.adapt_service.match_intent, self.fallback.high_prio, + self.padatious_service.match_medium, self.fallback.medium_prio, + self.padatious_service.match_low, self.fallback.low_prio + ] + + match = None with stopwatch: - # Give active skills an opportunity to handle the utterance - converse = self._converse(combined, lang, message) + # Loop through the matching functions until a match is found. + for match_func in match_funcs: + match = match_func(combined, lang, message) + if match: + break + if match: + if match.skill_id: + self.add_active_skill(match.skill_id) + # If the service didn't report back the skill_id it + # takes on the responsibility of making the skill "active" - if not converse: - # No conversation, use intent system to handle utterance - intent = self._adapt_intent_match(utterances, - norm_utterances, lang) - for utt in combined: - _intent = PadatiousService.instance.calc_intent(utt) - if _intent: - best = padatious_intent.conf if padatious_intent \ - else 0.0 - if best < _intent.conf: - padatious_intent = _intent - LOG.debug("Padatious intent: {}".format(padatious_intent)) - LOG.debug(" Adapt intent: {}".format(intent)) + # Launch skill if not handled by the match function + if match.intent_type: + reply = message.reply(match.intent_type, match.intent_data) + self.bus.emit(reply) - if converse: - # Report that converse handled the intent and return - LOG.debug("Handled in converse()") - ident = None - if message.context and 'ident' in message.context: - ident = message.context['ident'] - report_timing(ident, 'intent_service', stopwatch, - {'intent_type': 'converse'}) - return - elif (intent and intent.get('confidence', 0.0) > 0.0 and - not (padatious_intent and padatious_intent.conf >= 0.95)): - # Send the message to the Adapt intent's handler unless - # Padatious is REALLY sure it was directed at it instead. - self.update_context(intent) - # update active skills - skill_id = intent['intent_type'].split(":")[0] - self.add_active_skill(skill_id) - # Adapt doesn't handle context injection for one_of keywords - # correctly. Workaround this issue if possible. - try: - intent = workaround_one_of_context(intent) - except LookupError: - LOG.error('Error during workaround_one_of_context') - reply = message.reply(intent.get('intent_type'), intent) else: - # Allow fallback system to handle utterance - # NOTE: A matched padatious_intent is handled this way, too - # TODO: Need to redefine intent_failure when STT can return - # multiple hypothesis -- i.e. len(utterances) > 1 - reply = message.reply('intent_failure', - {'utterance': utterances[0], - 'norm_utt': norm_utterances[0], - 'lang': lang}) - self.bus.emit(reply) - self.send_metrics(intent, message.context, stopwatch) - except Exception as e: - LOG.exception(e) + # Nothing was able to handle the intent + # Ask politely for forgiveness for failing in this vital task + self.send_complete_intent_failure(message) + self.send_metrics(match, message.context, stopwatch) + except Exception as err: + LOG.exception(err) def _converse(self, utterances, lang, message): """Give active skills a chance at the utterance - Args: + Arguments: utterances (list): list of utterances lang (string): 4 letter ISO language code message (Message): message to use to generate reply Returns: - bool: True if converse handled it, False if no skill processes it + IntentMatch if handled otherwise None. """ - + utterances = [item for tup in utterances for item in tup] # check for conversation time-out self.active_skills = [skill for skill in self.active_skills if time.time() - skill[ @@ -414,81 +333,57 @@ class IntentService: if self.do_converse(utterances, skill[0], lang, message): # update timestamp, or there will be a timeout where # intent stops conversing whether its being used or not - self.add_active_skill(skill[0]) - return True - return False + return IntentMatch('Converse', None, None, skill[0]) + return None - def _adapt_intent_match(self, raw_utt, norm_utt, lang): - """Run the Adapt engine to search for an matching intent + def send_complete_intent_failure(self, message): + """Send a message that no skill could handle the utterance. - Args: - raw_utt (list): list of utterances - norm_utt (list): same list of utterances, normalized - lang (string): language code, e.g "en-us" - - Returns: - Intent structure, or None if no match was found. + Arguments: + message (Message): original message to forward from """ - best_intent = None - - def take_best(intent, utt): - nonlocal best_intent - best = best_intent.get('confidence', 0.0) if best_intent else 0.0 - conf = intent.get('confidence', 0.0) - if conf > best: - best_intent = intent - # TODO - Shouldn't Adapt do this? - best_intent['utterance'] = utt - - for idx, utt in enumerate(raw_utt): - try: - intents = [i for i in self.engine.determine_intent( - utt, 100, - include_tags=True, - context_manager=self.context_manager)] - if intents: - take_best(intents[0], utt) - - # Also test the normalized version, but set the utterance to - # the raw version so skill has access to original STT - norm_intents = [i for i in self.engine.determine_intent( - norm_utt[idx], 100, - include_tags=True, - context_manager=self.context_manager)] - if norm_intents: - take_best(norm_intents[0], utt) - except Exception as e: - LOG.exception(e) - return best_intent + self.bus.emit(message.forward('complete_intent_failure')) def handle_register_vocab(self, message): + """Register adapt vocabulary. + + Arguments: + message (Message): message containing vocab info + """ start_concept = message.data.get('start') end_concept = message.data.get('end') regex_str = message.data.get('regex') alias_of = message.data.get('alias_of') - if regex_str: - self.engine.register_regex_entity(regex_str) - else: - self.engine.register_entity( - start_concept, end_concept, alias_of=alias_of) + self.adapt_service.register_vocab(start_concept, end_concept, + alias_of, regex_str) self.registered_vocab.append(message.data) def handle_register_intent(self, message): + """Register adapt intent. + + Arguments: + message (Message): message containing intent info + """ intent = open_intent_envelope(message) - self.engine.register_intent_parser(intent) + self.adapt_service.register_intent(intent) def handle_detach_intent(self, message): + """Remover adapt intent. + + Arguments: + message (Message): message containing intent info + """ intent_name = message.data.get('intent_name') - new_parsers = [ - p for p in self.engine.intent_parsers if p.name != intent_name] - self.engine.intent_parsers = new_parsers + self.adapt_service.detach_intent(intent_name) def handle_detach_skill(self, message): + """Remove all intents registered for a specific skill. + + Arguments: + message (Message): message containing intent info + """ skill_id = message.data.get('skill_id') - new_parsers = [ - p for p in self.engine.intent_parsers if - not p.name.startswith(skill_id)] - self.engine.intent_parsers = new_parsers + self.adapt_service.detach_skill(skill_id) def handle_add_context(self, message): """Add context @@ -509,7 +404,7 @@ class IntentService: entity['match'] = word entity['key'] = word entity['origin'] = origin - self.context_manager.inject_context(entity) + self.adapt_service.context_manager.inject_context(entity) def handle_remove_context(self, message): """Remove specific context @@ -519,49 +414,77 @@ class IntentService: """ context = message.data.get('context') if context: - self.context_manager.remove_context(context) + self.adapt_service.context_manager.remove_context(context) - def handle_clear_context(self, message): + def handle_clear_context(self, _): """Clears all keywords from context """ - self.context_manager.clear_context() + self.adapt_service.context_manager.clear_context() def handle_get_adapt(self, message): + """handler getting the adapt response for an utterance. + + Arguments: + message (Message): message containing utterance + """ utterance = message.data["utterance"] lang = message.data.get("lang", "en-us") - norm = normalize(utterance, lang, remove_articles=False) - intent = self._adapt_intent_match([utterance], [norm], lang) + combined = _normalize_all_utterances([utterance]) + intent = self.adapt_service.match_intent(combined, lang) + intent_data = intent.intent_data if intent else None self.bus.emit(message.reply("intent.service.adapt.reply", - {"intent": intent})) + {"intent": intent_data})) def handle_get_intent(self, message): + """Get intent from either adapt or padatious. + + Arguments: + message (Message): message containing utterance + """ utterance = message.data["utterance"] lang = message.data.get("lang", "en-us") - norm = normalize(utterance, lang, remove_articles=False) - intent = self._adapt_intent_match([utterance], [norm], lang) + combined = _normalize_all_utterances([utterance]) + adapt_intent = self.adapt_service.match_intent(combined, lang) # Adapt intent's handler is used unless # Padatious is REALLY sure it was directed at it instead. - padatious_intent = PadatiousService.instance.calc_intent(utterance) - if not padatious_intent and norm != utterance: - padatious_intent = PadatiousService.instance.calc_intent(norm) - if intent is None or ( - padatious_intent and padatious_intent.conf >= 0.95): - intent = padatious_intent.__dict__ + padatious_intent = self.padatious_service.match_high(combined) + intent = padatious_intent or adapt_intent + intent_data = intent.intent_data if intent else None self.bus.emit(message.reply("intent.service.intent.reply", - {"intent": intent})) + {"intent": intent_data})) def handle_get_skills(self, message): + """Send registered skills to caller. + + Argument: + message: query message to reply to. + """ self.bus.emit(message.reply("intent.service.skills.reply", {"skills": self.skill_names})) def handle_get_active_skills(self, message): + """Send active skills to caller. + + Argument: + message: query message to reply to. + """ self.bus.emit(message.reply("intent.service.active_skills.reply", {"skills": [s[0] for s in self.active_skills]})) def handle_manifest(self, message): + """Send adapt intent manifest to caller. + + Argument: + message: query message to reply to. + """ self.bus.emit(message.reply("intent.service.adapt.manifest", {"intents": self.registered_intents})) def handle_vocab_manifest(self, message): + """Send adapt vocabulary manifest to caller. + + Argument: + message: query message to reply to. + """ self.bus.emit(message.reply("intent.service.adapt.vocab.manifest", {"vocab": self.registered_vocab})) diff --git a/mycroft/skills/intent_services/__init__.py b/mycroft/skills/intent_services/__init__.py new file mode 100644 index 0000000000..46463f5c19 --- /dev/null +++ b/mycroft/skills/intent_services/__init__.py @@ -0,0 +1,4 @@ +from .adapt_service import AdaptService, AdaptIntent +from .base import IntentMatch +from .fallback_service import FallbackService +from .padatious_service import PadatiousService diff --git a/mycroft/skills/intent_services/adapt_service.py b/mycroft/skills/intent_services/adapt_service.py new file mode 100644 index 0000000000..304d04138c --- /dev/null +++ b/mycroft/skills/intent_services/adapt_service.py @@ -0,0 +1,263 @@ +# Copyright 2020 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. +# +"""An intent parsing service using the Adapt parser.""" +import time + +from adapt.context import ContextManagerFrame +from adapt.engine import IntentDeterminationEngine +from adapt.intent import IntentBuilder + +from mycroft.util.log import LOG +from .base import IntentMatch + + +class AdaptIntent(IntentBuilder): + """Wrapper for IntentBuilder setting a blank name. + + This is mainly here for backwards compatibility, adapt now support + automatically named IntentBulders. + """ + + +def _strip_result(context_features): + """Keep only the latest instance of each keyword. + + Arguments + context_features (iterable): context features to check. + """ + stripped = [] + processed = [] + for feature in context_features: + keyword = feature['data'][0][1] + if keyword not in processed: + stripped.append(feature) + processed.append(keyword) + return stripped + + +class ContextManager: + """Adapt Context Manager + + Use to track context throughout the course of a conversational session. + How to manage a session's lifecycle is not captured here. + """ + def __init__(self, timeout): + self.frame_stack = [] + self.timeout = timeout * 60 # minutes to seconds + + def clear_context(self): + """Remove all contexts.""" + self.frame_stack = [] + + def remove_context(self, context_id): + """Remove a specific context entry. + + Arguments: + context_id (str): context entry to remove + """ + self.frame_stack = [(f, t) for (f, t) in self.frame_stack + if context_id in f.entities[0].get('data', [])] + + def inject_context(self, entity, metadata=None): + """ + Args: + entity(object): Format example... + {'data': 'Entity tag as ', + 'key': 'entity proper name as ', + 'confidence': ' + } + metadata(object): dict, arbitrary metadata about entity injected + """ + metadata = metadata or {} + try: + if self.frame_stack: + top_frame = self.frame_stack[0] + else: + top_frame = None + if top_frame and top_frame[0].metadata_matches(metadata): + top_frame[0].merge_context(entity, metadata) + else: + frame = ContextManagerFrame(entities=[entity], + metadata=metadata.copy()) + self.frame_stack.insert(0, (frame, time.time())) + except (IndexError, KeyError): + pass + + def get_context(self, max_frames=None, missing_entities=None): + """ Constructs a list of entities from the context. + + Args: + max_frames(int): maximum number of frames to look back + missing_entities(list of str): a list or set of tag names, + as strings + + Returns: + list: a list of entities + """ + missing_entities = missing_entities or [] + + relevant_frames = [frame[0] for frame in self.frame_stack if + time.time() - frame[1] < self.timeout] + if not max_frames or max_frames > len(relevant_frames): + max_frames = len(relevant_frames) + + missing_entities = list(missing_entities) + context = [] + last = '' + depth = 0 + entity = {} + for i in range(max_frames): + frame_entities = [entity.copy() for entity in + relevant_frames[i].entities] + for entity in frame_entities: + entity['confidence'] = entity.get('confidence', 1.0) \ + / (2.0 + depth) + context += frame_entities + + # Update depth + if entity['origin'] != last or entity['origin'] == '': + depth += 1 + last = entity['origin'] + + result = [] + if missing_entities: + for entity in context: + if entity.get('data') in missing_entities: + result.append(entity) + # NOTE: this implies that we will only ever get one + # of an entity kind from context, unless specified + # multiple times in missing_entities. Cannot get + # an arbitrary number of an entity kind. + missing_entities.remove(entity.get('data')) + else: + result = context + + # Only use the latest keyword + return _strip_result(result) + + +class AdaptService: + """Intent service wrapping the Apdapt intent Parser.""" + def __init__(self, config): + self.config = config + self.engine = IntentDeterminationEngine() + # Context related intializations + self.context_keywords = self.config.get('keywords', []) + self.context_max_frames = self.config.get('max_frames', 3) + self.context_timeout = self.config.get('timeout', 2) + self.context_greedy = self.config.get('greedy', False) + self.context_manager = ContextManager(self.context_timeout) + + def update_context(self, intent): + """Updates context with keyword from the intent. + + NOTE: This method currently won't handle one_of intent keywords + since it's not using quite the same format as other intent + keywords. This is under investigation in adapt, PR pending. + + Args: + intent: Intent to scan for keywords + """ + for tag in intent['__tags__']: + if 'entities' not in tag: + continue + context_entity = tag['entities'][0] + if self.context_greedy: + self.context_manager.inject_context(context_entity) + elif context_entity['data'][0][1] in self.context_keywords: + self.context_manager.inject_context(context_entity) + + def match_intent(self, utterances, _=None, __=None): + """Run the Adapt engine to search for an matching intent. + + Arguments: + utterances (iterable): iterable of utterances, expected order + [raw, normalized, other] + + Returns: + Intent structure, or None if no match was found. + """ + best_intent = {} + + def take_best(intent, utt): + nonlocal best_intent + best = best_intent.get('confidence', 0.0) if best_intent else 0.0 + conf = intent.get('confidence', 0.0) + if conf > best: + best_intent = intent + # TODO - Shouldn't Adapt do this? + best_intent['utterance'] = utt + + for utt_tup in utterances: + for utt in utt_tup: + try: + intents = [i for i in self.engine.determine_intent( + utt, 100, + include_tags=True, + context_manager=self.context_manager)] + if intents: + take_best(intents[0], utt_tup[0]) + + except Exception as err: + LOG.exception(err) + + if best_intent: + self.update_context(best_intent) + skill_id = best_intent['intent_type'].split(":")[0] + ret = IntentMatch( + 'Adapt', best_intent['intent_type'], best_intent, skill_id + ) + else: + ret = None + return ret + + def register_vocab(self, start_concept, end_concept, alias_of, regex_str): + """Register vocabulary.""" + if regex_str: + self.engine.register_regex_entity(regex_str) + else: + self.engine.register_entity( + start_concept, end_concept, alias_of=alias_of) + + def register_intent(self, intent): + """Register new intent with adapt engine. + + Arguments: + intent (IntentParser): IntentParser to register + """ + self.engine.register_intent_parser(intent) + + def detach_skill(self, skill_id): + """Remove all intents for skill. + + Arguments: + skill_id (str): skill to process + """ + new_parsers = [ + p for p in self.engine.intent_parsers if + not p.name.startswith(skill_id) + ] + self.engine.intent_parsers = new_parsers + + def detach_intent(self, intent_name): + """Detatch a single intent + + Arguments: + intent_name (str): Identifier for intent to remove. + """ + new_parsers = [ + p for p in self.engine.intent_parsers if p.name != intent_name + ] + self.engine.intent_parsers = new_parsers diff --git a/mycroft/skills/intent_services/base.py b/mycroft/skills/intent_services/base.py new file mode 100644 index 0000000000..8b8ce43ae2 --- /dev/null +++ b/mycroft/skills/intent_services/base.py @@ -0,0 +1,14 @@ + + +from collections import namedtuple + + +# Intent match response tuple containing +# intent_service: Name of the service that matched the intent +# intent_type: intent name (used to call intent handler over the message bus) +# intent_data: data provided by the intent match +# skill_id: the skill this handler belongs to +IntentMatch = namedtuple('IntentMatch', + ['intent_service', 'intent_type', + 'intent_data', 'skill_id'] + ) diff --git a/mycroft/skills/intent_services/fallback_service.py b/mycroft/skills/intent_services/fallback_service.py new file mode 100644 index 0000000000..a037fcdbe1 --- /dev/null +++ b/mycroft/skills/intent_services/fallback_service.py @@ -0,0 +1,66 @@ +# Copyright 2020 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. +# +"""Intent service for Mycroft's fallback system.""" +from collections import namedtuple +from .base import IntentMatch + +FallbackRange = namedtuple('FallbackRange', ['start', 'stop']) + + +class FallbackService: + """Intent Service handling fallback skills.""" + def __init__(self, bus): + self.bus = bus + + def _fallback_range(self, utterances, lang, message, fb_range): + """Send fallback request for a specified priority range. + + Arguments: + utterances (list): List of tuples, + utterances and normalized version + lang (str): Langauge code + message: Message for session context + fb_range (FallbackRange): fallback order start and stop. + + Returns: + IntentMatch or None + """ + msg = message.reply( + 'mycroft.skills.fallback', + data={'utterance': utterances[0][0], + 'lang': lang, + 'fallback_range': (fb_range.start, fb_range.stop)} + ) + response = self.bus.wait_for_response(msg, timeout=10) + if response and response.data['handled']: + ret = IntentMatch('Fallback', None, {}, None) + else: + ret = None + return ret + + def high_prio(self, utterances, lang, message): + """Pre-padatious fallbacks.""" + return self._fallback_range(utterances, lang, message, + FallbackRange(0, 5)) + + def medium_prio(self, utterances, lang, message): + """General fallbacks.""" + return self._fallback_range(utterances, lang, message, + FallbackRange(5, 90)) + + def low_prio(self, utterances, lang, message): + """Low prio fallbacks with general matching such as chat-bot.""" + return self._fallback_range(utterances, lang, message, + FallbackRange(90, 101)) diff --git a/mycroft/skills/intent_services/padatious_service.py b/mycroft/skills/intent_services/padatious_service.py new file mode 100644 index 0000000000..a56cd1db40 --- /dev/null +++ b/mycroft/skills/intent_services/padatious_service.py @@ -0,0 +1,281 @@ +# Copyright 2020 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. +# +"""Intent service wrapping padatious.""" +from functools import lru_cache +from subprocess import call +from threading import Event +from time import time as get_time, sleep + +from os.path import expanduser, isfile + +from mycroft.configuration import Configuration +from mycroft.messagebus.message import Message +from mycroft.util.log import LOG +from .base import IntentMatch + + +class PadatiousService: + """Service class for padatious intent matching.""" + def __init__(self, bus, config): + self.padatious_config = config + self.bus = bus + intent_cache = expanduser(self.padatious_config['intent_cache']) + + try: + from padatious import IntentContainer + except ImportError: + LOG.error('Padatious not installed. Please re-run dev_setup.sh') + try: + call(['notify-send', 'Padatious not installed', + 'Please run build_host_setup and dev_setup again']) + except OSError: + pass + return + + self.container = IntentContainer(intent_cache) + + self._bus = bus + self.bus.on('padatious:register_intent', self.register_intent) + self.bus.on('padatious:register_entity', self.register_entity) + self.bus.on('detach_intent', self.handle_detach_intent) + self.bus.on('detach_skill', self.handle_detach_skill) + self.bus.on('mycroft.skills.initialized', self.train) + self.bus.on('intent.service.padatious.get', self.handle_get_padatious) + self.bus.on('intent.service.padatious.manifest.get', + self.handle_manifest) + self.bus.on('intent.service.padatious.entities.manifest.get', + self.handle_entity_manifest) + + self.finished_training_event = Event() + self.finished_initial_train = False + + self.train_delay = self.padatious_config['train_delay'] + self.train_time = get_time() + self.train_delay + + self.registered_intents = [] + self.registered_entities = [] + + def train(self, message=None): + """Perform padatious training. + + Arguments: + message (Message): optional triggering message + """ + padatious_single_thread = Configuration.get()[ + 'padatious']['single_thread'] + if message is None: + single_thread = padatious_single_thread + else: + single_thread = message.data.get('single_thread', + padatious_single_thread) + + self.finished_training_event.clear() + + LOG.info('Training... (single_thread={})'.format(single_thread)) + self.container.train(single_thread=single_thread) + LOG.info('Training complete.') + + self.finished_training_event.set() + if not self.finished_initial_train: + LOG.info("Mycroft is all loaded and ready to roll!") + self.bus.emit(Message('mycroft.ready')) + self.finished_initial_train = True + + def wait_and_train(self): + """Wait for minimum time between training and start training.""" + if not self.finished_initial_train: + return + sleep(self.train_delay) + if self.train_time < 0.0: + return + + if self.train_time <= get_time() + 0.01: + self.train_time = -1.0 + self.train() + + def __detach_intent(self, intent_name): + """ Remove an intent if it has been registered. + + Arguments: + intent_name (str): intent identifier + """ + if intent_name in self.registered_intents: + self.registered_intents.remove(intent_name) + self.container.remove_intent(intent_name) + + def handle_detach_intent(self, message): + """Messagebus handler for detaching padatious intent. + + Arguments: + message (Message): message triggering action + """ + self.__detach_intent(message.data.get('intent_name')) + + def handle_detach_skill(self, message): + """Messagebus handler for detaching all intents for skill. + + Arguments: + message (Message): message triggering action + """ + skill_id = message.data['skill_id'] + remove_list = [i for i in self.registered_intents if skill_id in i] + for i in remove_list: + self.__detach_intent(i) + + def _register_object(self, message, object_name, register_func): + """Generic method for registering a padatious object. + + Arguments: + message (Message): trigger for action + object_name (str): type of entry to register + register_func (callable): function to call for registration + """ + file_name = message.data['file_name'] + name = message.data['name'] + + LOG.debug('Registering Padatious ' + object_name + ': ' + name) + + if not isfile(file_name): + LOG.warning('Could not find file ' + file_name) + return + + register_func(name, file_name) + self.train_time = get_time() + self.train_delay + self.wait_and_train() + + def register_intent(self, message): + """Messagebus handler for registering intents. + + Arguments: + message (Message): message triggering action + """ + self.registered_intents.append(message.data['name']) + self._register_object(message, 'intent', self.container.load_intent) + + def register_entity(self, message): + """Messagebus handler for registering entities. + + Arguments: + message (Message): message triggering action + """ + self.registered_entities.append(message.data) + self._register_object(message, 'entity', self.container.load_entity) + + def _match_level(self, utterances, limit): + """Match intent and make sure a certain level of confidence is reached. + + Arguments: + utterances (list of tuples): Utterances to parse, originals paired + with optional normalized version. + limit (float): required confidence level. + """ + padatious_intent = None + LOG.debug('Padatious Matching confidence > {}'.format(limit)) + for utt in utterances: + for variant in utt: + intent = self.calc_intent(variant) + if intent: + best = padatious_intent.conf if padatious_intent else 0.0 + if best < intent.conf: + padatious_intent = intent + padatious_intent.matches['utterance'] = utt[0] + + if padatious_intent and padatious_intent.conf > limit: + skill_id = padatious_intent.name.split(':')[0] + ret = IntentMatch( + 'Padatious', padatious_intent.name, padatious_intent.matches, + skill_id + ) + else: + ret = None + return ret + + def match_high(self, utterances, _=None, __=None): + """Intent matcher for high confidence. + + Arguments: + utterances (list of tuples): Utterances to parse, originals paired + with optional normalized version. + """ + return self._match_level(utterances, 0.95) + + def match_medium(self, utterances, _=None, __=None): + """Intent matcher for medium confidence. + + Arguments: + utterances (list of tuples): Utterances to parse, originals paired + with optional normalized version. + """ + return self._match_level(utterances, 0.8) + + def match_low(self, utterances, _=None, __=None): + """Intent matcher for low confidence. + + Arguments: + utterances (list of tuples): Utterances to parse, originals paired + with optional normalized version. + """ + return self._match_level(utterances, 0.5) + + def handle_get_padatious(self, message): + """messagebus handler for perfoming padatious parsing. + + Arguments: + message (Message): message triggering the method + """ + utterance = message.data["utterance"] + norm = message.data.get('norm_utt', utterance) + intent = self.calc_intent(utterance) + if not intent and norm != utterance: + intent = self.calc_intent(norm) + if intent: + intent = intent.__dict__ + self.bus.emit(message.reply("intent.service.padatious.reply", + {"intent": intent})) + + def handle_manifest(self, message): + """Messagebus handler returning the registered padatious intents. + + Arguments: + message (Message): message triggering the method + """ + self.bus.emit(message.reply("intent.service.padatious.manifest", + {"intents": self.registered_intents})) + + def handle_entity_manifest(self, message): + """Messagebus handler returning the registered padatious entities. + + Arguments: + message (Message): message triggering the method + """ + self.bus.emit( + message.reply("intent.service.padatious.entities.manifest", + {"entities": self.registered_entities})) + + @lru_cache(maxsize=2) # 2 catches both raw and normalized utts in cache + def calc_intent(self, utt): + """Cached version of container calc_intent. + + This improves speed when called multiple times for different confidence + levels. + + NOTE: This cache will keep a reference to this class + (PadatiousService), but we can live with that since it is used as a + singleton. + + Arguments: + utt (str): utterance to calculate best intent for + """ + return self.container.calc_intent(utt) diff --git a/mycroft/skills/mycroft_skill/mycroft_skill.py b/mycroft/skills/mycroft_skill/mycroft_skill.py index 4f3b6d9da6..c75a8a9b5f 100644 --- a/mycroft/skills/mycroft_skill/mycroft_skill.py +++ b/mycroft/skills/mycroft_skill/mycroft_skill.py @@ -21,8 +21,11 @@ import traceback from itertools import chain from os import walk from os.path import join, abspath, dirname, basename, exists +from pathlib import Path from threading import Event, Timer +from xdg import BaseDirectory + from adapt.intent import Intent, IntentBuilder from mycroft import dialog @@ -123,16 +126,6 @@ class MycroftSkill: #: Member variable containing the absolute path of the skill's root #: directory. E.g. /opt/mycroft/skills/my-skill.me/ self.root_dir = dirname(abspath(sys.modules[self.__module__].__file__)) - if use_settings: - self.settings = get_local_settings(self.root_dir, self.name) - self._initial_settings = deepcopy(self.settings) - else: - self.settings = None - - #: Set to register a callback method that will be called every time - #: the skills settings are updated. The referenced method should - #: include any logic needed to handle the updated settings. - self.settings_change_callback = None self.gui = SkillGUI(self) @@ -141,6 +134,18 @@ class MycroftSkill: self.bind(bus) #: Mycroft global configuration. (dict) self.config_core = Configuration.get() + + self.settings = None + self.settings_write_path = None + + if use_settings: + self._init_settings() + + #: Set to register a callback method that will be called every time + #: the skills settings are updated. The referenced method should + #: include any logic needed to handle the updated settings. + self.settings_change_callback = None + self.dialog_renderer = None #: Filesystem access to skill specific folder. @@ -157,6 +162,37 @@ class MycroftSkill: self.event_scheduler = EventSchedulerInterface(self.name) self.intent_service = IntentServiceInterface() + def _init_settings(self): + """Setup skill settings.""" + + # To not break existing setups, + # save to skill directory if the file exists already + self.settings_write_path = Path(self.root_dir) + + # Otherwise save to XDG_CONFIG_DIR + if not self.settings_write_path.joinpath('settings.json').exists(): + self.settings_write_path = Path(BaseDirectory.save_config_path( + 'mycroft', 'skills', basename(self.root_dir))) + + # To not break existing setups, + # read from skill directory if the settings file exists there + settings_read_path = Path(self.root_dir) + + # Then, check XDG_CONFIG_DIR + if not settings_read_path.joinpath('settings.json').exists(): + for dir in BaseDirectory.load_config_paths('mycroft', + 'skills', + basename( + self.root_dir)): + path = Path(dir) + # If there is a settings file here, use it + if path.joinpath('settings.json').exists(): + settings_read_path = path + break + + self.settings = get_local_settings(settings_read_path, self.name) + self._initial_settings = deepcopy(self.settings) + @property def enclosure(self): if self._enclosure: @@ -271,7 +307,7 @@ class MycroftSkill: if remote_settings is not None: LOG.info('Updating settings for skill ' + self.name) self.settings.update(**remote_settings) - save_settings(self.root_dir, self.settings) + save_settings(self.settings_write_path, self.settings) if self.settings_change_callback is not None: self.settings_change_callback() @@ -544,7 +580,7 @@ class MycroftSkill: if not voc or not exists(voc): raise FileNotFoundError( - 'Could not find {}.voc file'.format(voc_filename)) + 'Could not find {}.voc file'.format(voc_filename)) # load vocab and flatten into a simple list vocab = read_vocab_file(voc) self.voc_match_cache[cache_key] = list(chain(*vocab)) @@ -811,8 +847,8 @@ class MycroftSkill: """Store settings and indicate that the skill handler has completed """ if self.settings != self._initial_settings: - save_settings(self.root_dir, self.settings) - self._initial_settings = self.settings + save_settings(self.settings_write_path, self.settings) + self._initial_settings = deepcopy(self.settings) if handler_info: msg_type = handler_info + '.complete' self.bus.emit(message.forward(msg_type, skill_data)) @@ -1098,9 +1134,17 @@ class MycroftSkill: wait (bool): set to True to block while the text is being spoken. """ - data = data or {} - self.speak(self.dialog_renderer.render(key, data), - expect_response, wait, meta={'dialog': key, 'data': data}) + if self.dialog_renderer: + data = data or {} + self.speak( + self.dialog_renderer.render(key, data), + expect_response, wait, meta={'dialog': key, 'data': data} + ) + else: + self.log.warning( + 'dialog_render is None, does the locale/dialog folder exist?' + ) + self.speak(key, expect_response, wait, {}) def acknowledge(self): """Acknowledge a successful request. @@ -1232,8 +1276,9 @@ class MycroftSkill: self.settings_change_callback = None # Store settings - if self.settings != self._initial_settings: - save_settings(self.root_dir, self.settings) + if self.settings != self._initial_settings and Path( + self.root_dir).exists(): + save_settings(self.settings_write_path, self.settings) if self.settings_meta: self.settings_meta.stop() diff --git a/mycroft/skills/settings.py b/mycroft/skills/settings.py index c210625f5b..ae6ebbdd06 100644 --- a/mycroft/skills/settings.py +++ b/mycroft/skills/settings.py @@ -93,18 +93,22 @@ def get_local_settings(skill_dir, skill_name) -> dict: def save_settings(skill_dir, skill_settings): """Save skill settings to file.""" settings_path = Path(skill_dir).joinpath('settings.json') - if Path(skill_dir).exists(): - with open(str(settings_path), 'w') as settings_file: - try: - json.dump(skill_settings, settings_file) - except Exception: - LOG.exception('error saving skill settings to ' - '{}'.format(settings_path)) - else: - LOG.info('Skill settings successfully saved to ' - '{}' .format(settings_path)) - else: - LOG.info('Skill folder no longer exists, can\'t save settings.') + + # Either the file already exists in /opt, or we are writing + # to XDG_CONFIG_DIR and always have the permission to make + # sure the file always exists + if not Path(settings_path).exists(): + settings_path.touch(mode=0o644) + + with open(str(settings_path), 'w') as settings_file: + try: + json.dump(skill_settings, settings_file) + except Exception: + LOG.exception('error saving skill settings to ' + '{}'.format(settings_path)) + else: + LOG.info('Skill settings successfully saved to ' + '{}' .format(settings_path)) def get_display_name(skill_name: str): @@ -113,28 +117,6 @@ def get_display_name(skill_name: str): return camel_case_split(skill_name) -def _extract_settings_from_meta(settings_meta: dict) -> dict: - """Extract the skill setting name/value pairs from settingsmeta.json - - Args: - settings_meta: contents of the settingsmeta.json - - Returns: - Dictionary of settings keyed by name - """ - fields = {} - try: - sections = settings_meta['skillMetadata']['sections'] - except KeyError: - pass - else: - for section in sections: - for field in section.get('fields', []): - fields[field['name']] = field['value'] - - return fields - - class SettingsMetaUploader: """Synchronize the contents of the settingsmeta.json file with the backend. @@ -171,10 +153,9 @@ class SettingsMetaUploader: return self._msm def get_local_skills(self): - return { - skill.path: skill for skill in - self.msm.local_skills.values() - } + """Generate a mapping of skill path to skill name for all local skills. + """ + return {skill.path: skill for skill in self.msm.local_skills.values()} @property def skill_gid(self): @@ -260,7 +241,7 @@ class SettingsMetaUploader: self.upload_timer.start() def stop(self): - """ Stop upload attempts if Timer is running.""" + """Stop upload attempts if Timer is running.""" if self.upload_timer: self.upload_timer.cancel() # Set stopped flag if upload is running when stop is called. @@ -321,20 +302,17 @@ class SettingsMetaUploader: class SkillSettingsDownloader: - """Manages the contents of the settings.json file. + """Manages download of skill settings. - The settings.json file contains a set of name/value pairs representing - the values of the settings defined in settingsmeta.json + Performs settings download on a repeating Timer. If a change is seen + the data is sent to the relevant skill. """ def __init__(self, bus): self.bus = bus self.continue_downloading = True - self.changed_callback = None - self.settings_meta_fields = None self.last_download_result = {} self.remote_settings = None - self.settings_changed = False self.api = DeviceApi() self.download_timer = None @@ -348,15 +326,13 @@ class SkillSettingsDownloader: def download(self): """Download the settings stored on the backend and check for changes""" if is_paired(): - download_success = self._get_remote_settings() - if download_success: - self.settings_changed = ( - self.last_download_result != self.remote_settings - ) - if self.settings_changed: + remote_settings = self._get_remote_settings() + if remote_settings: + settings_changed = self.last_download_result != remote_settings + if settings_changed: LOG.debug('Skill settings changed since last download') - self._emit_settings_change_events() - self.last_download_result = self.remote_settings + self._emit_settings_change_events(remote_settings) + self.last_download_result = remote_settings else: LOG.debug('No skill settings changes since last download') else: @@ -376,37 +352,32 @@ class SkillSettingsDownloader: """Get the settings for this skill from the server Returns: - skill_settings (dict or None): returns a dict if matches + skill_settings (dict or None): returns a dict on success, else None """ try: remote_settings = self.api.get_skill_settings() except Exception: LOG.exception('Failed to download remote settings from server.') - success = False - else: - self.remote_settings = remote_settings - success = True + remote_settings = None - return success + return remote_settings - def _emit_settings_change_events(self): - for skill_gid, remote_settings in self.remote_settings.items(): + def _emit_settings_change_events(self, remote_settings): + """Emit changed settings events for each affected skill.""" + for skill_gid, skill_settings in remote_settings.items(): settings_changed = False try: - previous_settings = self.last_download_result[skill_gid] - except KeyError: - if remote_settings: - settings_changed = True + previous_settings = self.last_download_result.get(skill_gid) except Exception: LOG.exception('error occurred handling setting change events') else: - if previous_settings != remote_settings: + if previous_settings != skill_settings: settings_changed = True if settings_changed: log_msg = 'Emitting skill.settings.change event for skill {} ' LOG.info(log_msg.format(skill_gid)) message = Message( 'mycroft.skills.settings.changed', - data={skill_gid: remote_settings} + data={skill_gid: skill_settings} ) self.bus.emit(message) diff --git a/mycroft/skills/skill_loader.py b/mycroft/skills/skill_loader.py index 2402740188..8581b92ed9 100644 --- a/mycroft/skills/skill_loader.py +++ b/mycroft/skills/skill_loader.py @@ -14,8 +14,9 @@ # """Periodically run by skill manager to load skills into memory.""" import gc -import imp +import importlib import os +from os.path import dirname import sys from time import time @@ -29,6 +30,62 @@ from .settings import SettingsMetaUploader SKILL_MAIN_MODULE = '__init__.py' +def remove_submodule_refs(module_name): + """Ensure submodules are reloaded by removing the refs from sys.modules. + + Python import system puts a reference for each module in the sys.modules + dictionary to bypass loading if a module is already in memory. To make + sure skills are completely reloaded these references are deleted. + + Arguments: + module_name: name of skill module. + """ + submodules = [] + LOG.debug('Skill module'.format(module_name)) + # Collect found submodules + for m in sys.modules: + if m.startswith(module_name + '.'): + submodules.append(m) + # Remove all references them to in sys.modules + for m in submodules: + LOG.debug('Removing sys.modules ref for {}'.format(m)) + del(sys.modules[m]) + + +def load_skill_module(path, skill_id): + """Load a skill module + + This function handles the differences between python 3.4 and 3.5+ as well + as makes sure the module is inserted into the sys.modules dict. + + Arguments: + path: Path to the skill main file (__init__.py) + skill_id: skill_id used as skill identifier in the module list + """ + module_name = skill_id.replace('.', '_') + + remove_submodule_refs(module_name) + + spec = importlib.util.spec_from_file_location(module_name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[module_name] = mod + spec.loader.exec_module(mod) + return mod + + +def _bad_mod_times(mod_times): + """Return all entries with modification time in the future. + + Arguments: + mod_times (dict): dict mapping file paths to modification times. + + Returns: + List of files with bad modification times. + """ + current_time = time() + return [path for path in mod_times if mod_times[path] > current_time] + + def _get_last_modified_time(path): """Get the last modified date of the most recently updated file in a path. @@ -55,7 +112,15 @@ def _get_last_modified_time(path): all_files.append(os.path.join(root_dir, f)) # check files of interest in the skill root directory - return max(os.path.getmtime(f) for f in all_files) + mod_times = {f: os.path.getmtime(f) for f in all_files} + # Ensure modification times are valid + bad_times = _bad_mod_times(mod_times) + if bad_times: + raise OSError('{} had bad modification times'.format(bad_times)) + if all_files: + return max(os.path.getmtime(f) for f in all_files) + else: + return 0 class SkillLoader: @@ -71,6 +136,8 @@ class SkillLoader: self.active = True self.config = Configuration.get() + self.modtime_error_log_written = False + @property def is_blacklisted(self): """Boolean value representing whether or not a skill is blacklisted.""" @@ -88,10 +155,14 @@ class SkillLoader: """ try: self.last_modified = _get_last_modified_time(self.skill_directory) - except FileNotFoundError as e: - LOG.error('Failed to get last_modification time ' - '({})'.format(repr(e))) + except OSError as err: self.last_modified = self.last_loaded + if not self.modtime_error_log_written: + self.modtime_error_log_written = True + LOG.error('Failed to get last_modification time ' + '({})'.format(repr(err))) + else: + self.modtime_error_log_written = False modified = self.last_modified > self.last_loaded @@ -139,7 +210,7 @@ class SkillLoader: """Call the shutdown method of the skill being reloaded.""" try: self.instance.default_shutdown() - except Exception as e: + except Exception: log_msg = 'An error occurred while shutting down {}' LOG.exception(log_msg.format(self.instance.name)) else: @@ -196,30 +267,23 @@ class SkillLoader: def _load_skill_source(self): """Use Python's import library to load a skill's source code.""" - # TODO: Replace the deprecated "imp" library with the newer "importlib" - module_name = self.skill_id.replace('.', '_') main_file_path = os.path.join(self.skill_directory, SKILL_MAIN_MODULE) - try: - with open(main_file_path, 'rb') as main_file: - skill_module = imp.load_module( - module_name, - main_file, - main_file_path, - ('.py', 'rb', imp.PY_SOURCE) - ) - except FileNotFoundError as f: + if not os.path.exists(main_file_path): error_msg = 'Failed to load {} due to a missing file.' - LOG.exception(error_msg.format(self.skill_id)) - except Exception as e: - LOG.exception('Failed to load skill: ' - '{} ({})'.format(self.skill_id, repr(e))) + LOG.error(error_msg.format(self.skill_id)) else: - module_is_skill = ( - hasattr(skill_module, 'create_skill') and - callable(skill_module.create_skill) - ) - if module_is_skill: - return skill_module + try: + skill_module = load_skill_module(main_file_path, self.skill_id) + except Exception as e: + LOG.exception('Failed to load skill: ' + '{} ({})'.format(self.skill_id, repr(e))) + else: + module_is_skill = ( + hasattr(skill_module, 'create_skill') and + callable(skill_module.create_skill) + ) + if module_is_skill: + return skill_module return None # Module wasn't loaded def _create_skill_instance(self, skill_module): @@ -259,7 +323,8 @@ class SkillLoader: if first_run: LOG.info("First run of " + self.skill_id) self.instance.settings["__mycroft_skill_firstrun"] = False - save_settings(self.skill_directory, self.instance.settings) + save_settings(self.instance.settings_write_path, + self.instance.settings) intro = self.instance.get_intro_message() if intro: self.instance.speak(intro) diff --git a/mycroft/skills/skill_manager.py b/mycroft/skills/skill_manager.py index 1b128b9500..bbcdc6b3b0 100644 --- a/mycroft/skills/skill_manager.py +++ b/mycroft/skills/skill_manager.py @@ -48,8 +48,8 @@ class UploadQueue: def start(self): """Start processing of the queue.""" - self.send() self.started = True + self.send() def stop(self): """Stop the queue, and hinder any further transmissions.""" @@ -110,14 +110,17 @@ def _shutdown_skill(instance): class SkillManager(Thread): _msm = None - def __init__(self, bus): + def __init__(self, bus, watchdog=None): """Constructor Arguments: bus (event emitter): Mycroft messagebus connection + watchdog (callable): optional watchdog function """ super(SkillManager, self).__init__() self.bus = bus + # Set watchdog to argument or function returning None + self._watchdog = watchdog or (lambda: None) self._stop_event = Event() self._connected_event = Event() self.config = Configuration.get() @@ -128,14 +131,15 @@ class SkillManager(Thread): self.initial_load_complete = False self.num_install_retries = 0 self.settings_downloader = SkillSettingsDownloader(self.bus) - self._define_message_bus_events() - self.skill_updater = SkillUpdater() - self.daemon = True # Statuses self._alive_status = False # True after priority skills has loaded self._loaded_status = False # True after all skills has loaded + self.skill_updater = SkillUpdater() + self._define_message_bus_events() + self.daemon = True + def _define_message_bus_events(self): """Define message bus events with handlers defined in this class.""" # Conversation management @@ -223,6 +227,11 @@ class SkillManager(Thread): """Load skills and update periodically from disk and internet.""" self._remove_git_locks() self._connected_event.wait() + if (not self.skill_updater.defaults_installed() and + self.skills_config["auto_update"]): + LOG.info('Not all default skills are installed, ' + 'performing skill update...') + self.skill_updater.update_skills() self._load_on_startup() # Sync backend and skills. @@ -243,6 +252,7 @@ class SkillManager(Thread): self.skill_updater.post_manifest() self.upload_queue.send() + self._watchdog() sleep(2) # Pause briefly before beginning next scan except Exception: LOG.exception('Something really unexpected has occured ' @@ -452,11 +462,6 @@ class SkillManager(Thread): reply = message.reply('skill.converse.response', data=dict(skill_id=skill_id, error=error_msg)) self.bus.emit(reply) - # Also emit the old error message to keep compatibility - # TODO Remove in 20.08 - reply = message.reply('skill.converse.error', - data=dict(skill_id=skill_id, error=error_msg)) - self.bus.emit(reply) def _emit_converse_response(self, result, message, skill_loader): reply = message.reply( diff --git a/mycroft/skills/skill_updater.py b/mycroft/skills/skill_updater.py index 9535ad9031..ace247e2db 100644 --- a/mycroft/skills/skill_updater.py +++ b/mycroft/skills/skill_updater.py @@ -31,6 +31,11 @@ ONE_HOUR = 3600 FIVE_MINUTES = 300 # number of seconds in a minute +def skill_is_blacklisted(skill): + blacklist = Configuration.get()['skills']['blacklisted_skills'] + return os.path.basename(skill.path) in blacklist or skill.name in blacklist + + class SkillUpdater: """Class facilitating skill update / install actions. @@ -219,6 +224,18 @@ class SkillUpdater: raise self.installed_skills.add(skill.name) + def defaults_installed(self): + """Check if all default skills are installed. + + Returns: + True if all default skills are installed, else False. + """ + defaults = [] + for skill in self.msm.default_skills.values(): + if not skill_is_blacklisted(skill): + defaults.append(skill) + return all([skill.is_local for skill in defaults]) + def _get_device_skill_state(self, skill_name): """Get skill data structure from name.""" device_skill_state = {} diff --git a/mycroft/stt/__init__.py b/mycroft/stt/__init__.py index 9a6a93ea63..f85ea4eb6c 100644 --- a/mycroft/stt/__init__.py +++ b/mycroft/stt/__init__.py @@ -108,14 +108,84 @@ class WITSTT(TokenSTT): return self.recognizer.recognize_wit(audio, self.token) -class IBMSTT(BasicSTT): +class IBMSTT(TokenSTT): + """ + IBM Speech to Text + Enables IBM Speech to Text access using API key. To use IBM as a + service provider, it must be configured locally in your config file. An + IBM Cloud account with Speech to Text enabled is required (limited free + tier may be available). STT config should match the following format: + + "stt": { + "module": "ibm", + "ibm": { + "credential": { + "token": "YOUR_API_KEY" + }, + "url": "URL_FROM_SERVICE" + } + } + """ def __init__(self): super(IBMSTT, self).__init__() def execute(self, audio, language=None): + if not self.token: + raise ValueError('API key (token) for IBM Cloud is not defined.') + + url_base = self.config.get('url', '') + if not url_base: + raise ValueError('URL for IBM Cloud is not defined.') + url = url_base + '/v1/recognize' + self.lang = language or self.lang - return self.recognizer.recognize_ibm(audio, self.username, - self.password, self.lang) + supported_languages = [ + 'ar-AR', 'pt-BR', 'zh-CN', 'nl-NL', 'en-GB', 'en-US', 'fr-FR', + 'de-DE', 'it-IT', 'ja-JP', 'ko-KR', 'es-AR', 'es-ES', 'es-CL', + 'es-CO', 'es-MX', 'es-PE' + ] + if self.lang not in supported_languages: + raise ValueError( + 'Unsupported language "{}" for IBM STT.'.format(self.lang)) + + audio_model = 'BroadbandModel' + if audio.sample_rate < 16000 and not self.lang == 'ar-AR': + audio_model = 'NarrowbandModel' + + params = { + 'model': '{}_{}'.format(self.lang, audio_model), + 'profanity_filter': 'false' + } + headers = { + 'Content-Type': 'audio/x-flac', + 'X-Watson-Learning-Opt-Out': 'true' + } + + response = post(url, auth=('apikey', self.token), headers=headers, + data=audio.get_flac_data(), params=params) + + if response.status_code == 200: + result = json.loads(response.text) + if result.get('error_code') is None: + if ('results' not in result or len(result['results']) < 1 or + 'alternatives' not in result['results'][0]): + raise Exception( + 'Transcription failed. Invalid or empty results.') + transcription = [] + for utterance in result['results']: + if 'alternatives' not in utterance: + raise Exception( + 'Transcription failed. Invalid or empty results.') + for hypothesis in utterance['alternatives']: + if 'transcript' in hypothesis: + transcription.append(hypothesis['transcript']) + return '\n'.join(transcription) + elif response.status_code == 401: # Unauthorized + raise Exception('Invalid API key for IBM Cloud.') + else: + raise Exception( + 'Request to IBM Cloud failed. Code: {} Body: {}'.format( + response.status_code, response.text)) class YandexSTT(STT): @@ -246,8 +316,6 @@ class DeepSpeechServerSTT(STT): def execute(self, audio, language=None): language = language or self.lang - if not language.startswith("en"): - raise ValueError("Deepspeech is currently english only") response = post(self.config.get("uri"), data=audio.get_wav_data()) return response.text diff --git a/mycroft/tts/espeak_tts.py b/mycroft/tts/espeak_tts.py index 4766e4746d..643524ca92 100644 --- a/mycroft/tts/espeak_tts.py +++ b/mycroft/tts/espeak_tts.py @@ -18,14 +18,23 @@ from .tts import TTS, TTSValidator class ESpeak(TTS): + """TTS module for generating speech using ESpeak.""" def __init__(self, lang, config): super(ESpeak, self).__init__(lang, config, ESpeakValidator(self)) - def execute(self, sentence, ident=None, listen=False): - self.begin_audio() - subprocess.call( - ['espeak', '-v', self.lang + '+' + self.voice, sentence]) - self.end_audio(listen) + def get_tts(self, sentence, wav_file): + """Generate WAV from sentence, phonemes aren't supported. + + Arguments: + sentence (str): sentence to generate audio for + wav_file (str): output file + + Returns: + tuple ((str) file location, None) + """ + subprocess.call(['espeak', '-v', self.lang + '+' + self.voice, + '-w', wav_file, sentence]) + return wav_file, None class ESpeakValidator(TTSValidator): @@ -40,8 +49,8 @@ class ESpeakValidator(TTSValidator): try: subprocess.call(['espeak', '--version']) except Exception: - raise Exception( - 'ESpeak is not installed. Run: sudo apt-get install espeak') + raise Exception('ESpeak is not installed. Please install it on ' + 'your system and restart Mycroft.') def get_tts_class(self): return ESpeak diff --git a/mycroft/tts/festival_tts.py b/mycroft/tts/festival_tts.py new file mode 100644 index 0000000000..e1d8c6143c --- /dev/null +++ b/mycroft/tts/festival_tts.py @@ -0,0 +1,62 @@ +# Copyright 2020 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. +# +import subprocess + +from .tts import TTS, TTSValidator + + +class Festival(TTS): + def __init__(self, lang, config): + super(Festival, self).__init__(lang, config, FestivalValidator(self)) + + def execute(self, sentence, ident=None, listen=False): + + encoding = self.config.get('encoding', 'utf8') + lang = self.config.get('lang', self.lang) + + text = subprocess.Popen(('echo', sentence), stdout=subprocess.PIPE) + + if encoding != 'utf8': + convert_cmd = ('iconv', '-f', 'utf8', '-t', encoding) + converted_text = subprocess.Popen(convert_cmd, + stdin=text.stdout, + stdout=subprocess.PIPE) + text.wait() + text = converted_text + + tts_cmd = ('festival', '--tts', '--language', lang) + + self.begin_audio() + subprocess.call(tts_cmd, stdin=text.stdout) + self.end_audio(listen) + + +class FestivalValidator(TTSValidator): + def __init__(self, tts): + super(FestivalValidator, self).__init__(tts) + + def validate_lang(self): + # TODO + pass + + def validate_connection(self): + try: + subprocess.call(['festival', '--version']) + except Exception: + raise Exception( + 'Festival is missing. Run: sudo apt-get install festival') + + def get_tts_class(self): + return Festival diff --git a/mycroft/tts/google_tts.py b/mycroft/tts/google_tts.py index 0bb3c0441e..d81cebeef7 100755 --- a/mycroft/tts/google_tts.py +++ b/mycroft/tts/google_tts.py @@ -13,16 +13,88 @@ # limitations under the License. # from gtts import gTTS +from gtts.lang import tts_langs from .tts import TTS, TTSValidator +from mycroft.util.log import LOG + +# Live list of languages +# Cached list of supported languages (2020-05-27) +_default_langs = {'af': 'Afrikaans', 'sq': 'Albanian', 'ar': 'Arabic', + 'hy': 'Armenian', 'bn': 'Bengali', 'bs': 'Bosnian', + 'ca': 'Catalan', 'hr': 'Croatian', 'cs': 'Czech', + 'da': 'Danish', 'nl': 'Dutch', 'en': 'English', + 'eo': 'Esperanto', 'et': 'Estonian', 'tl': 'Filipino', + 'fi': 'Finnish', 'fr': 'French', 'de': 'German', + 'el': 'Greek', 'gu': 'Gujarati', 'hi': 'Hindi', + 'hu': 'Hungarian', 'is': 'Icelandic', 'id': 'Indonesian', + 'it': 'Italian', 'ja': 'Japanese', 'jw': 'Javanese', + 'kn': 'Kannada', 'km': 'Khmer', 'ko': 'Korean', + 'la': 'Latin', 'lv': 'Latvian', 'mk': 'Macedonian', + 'ml': 'Malayalam', 'mr': 'Marathi', + 'my': 'Myanmar (Burmese)', 'ne': 'Nepali', + 'no': 'Norwegian', 'pl': 'Polish', 'pt': 'Portuguese', + 'ro': 'Romanian', 'ru': 'Russian', 'sr': 'Serbian', + 'si': 'Sinhala', 'sk': 'Slovak', 'es': 'Spanish', + 'su': 'Sundanese', 'sw': 'Swahili', 'sv': 'Swedish', + 'ta': 'Tamil', 'te': 'Telugu', 'th': 'Thai', 'tr': 'Turkish', + 'uk': 'Ukrainian', 'ur': 'Urdu', 'vi': 'Vietnamese', + 'cy': 'Welsh', 'zh-cn': 'Chinese (Mandarin/China)', + 'zh-tw': 'Chinese (Mandarin/Taiwan)', + 'en-us': 'English (US)', 'en-ca': 'English (Canada)', + 'en-uk': 'English (UK)', 'en-gb': 'English (UK)', + 'en-au': 'English (Australia)', 'en-gh': 'English (Ghana)', + 'en-in': 'English (India)', 'en-ie': 'English (Ireland)', + 'en-nz': 'English (New Zealand)', + 'en-ng': 'English (Nigeria)', + 'en-ph': 'English (Philippines)', + 'en-za': 'English (South Africa)', + 'en-tz': 'English (Tanzania)', 'fr-ca': 'French (Canada)', + 'fr-fr': 'French (France)', 'pt-br': 'Portuguese (Brazil)', + 'pt-pt': 'Portuguese (Portugal)', 'es-es': 'Spanish (Spain)', + 'es-us': 'Spanish (United States)' + } + + +_supported_langs = None + + +def get_supported_langs(): + """Get dict of supported languages. + + Tries to fetch remote list, if that fails a local cache will be used. + + Returns: + (dict): Lang code to lang name map. + """ + global _supported_langs + if not _supported_langs: + try: + _supported_langs = tts_langs() + except Exception: + LOG.warning('Couldn\'t fetch upto date language codes') + return _supported_langs or _default_langs + class GoogleTTS(TTS): """Interface to google TTS.""" def __init__(self, lang, config): + self._google_lang = None super(GoogleTTS, self).__init__(lang, config, GoogleTTSValidator( self), 'mp3') + @property + def google_lang(self): + """Property containing a converted language code suitable for gTTS.""" + supported_langs = get_supported_langs() + if not self._google_lang: + if self.lang.lower() in supported_langs: + self._google_lang = self.lang.lower() + elif self.lang[:2].lower() in supported_langs: + self._google_lang = self.lang[:2] + return self._google_lang or self.lang.lower() + def get_tts(self, sentence, wav_file): """Fetch tts audio using gTTS. @@ -32,7 +104,7 @@ class GoogleTTS(TTS): Returns: Tuple ((str) written file, None) """ - tts = gTTS(text=sentence, lang=self.lang) + tts = gTTS(text=sentence, lang=self.google_lang) tts.save(wav_file) return (wav_file, None) # No phonemes @@ -42,8 +114,9 @@ class GoogleTTSValidator(TTSValidator): super(GoogleTTSValidator, self).__init__(tts) def validate_lang(self): - # TODO - pass + lang = self.tts.google_lang + if lang.lower() not in get_supported_langs(): + raise ValueError("Language not supported by gTTS: {}".format(lang)) def validate_connection(self): try: diff --git a/mycroft/tts/mimic_tts.py b/mycroft/tts/mimic_tts.py index 88f1bb22cd..95a08ca3b6 100644 --- a/mycroft/tts/mimic_tts.py +++ b/mycroft/tts/mimic_tts.py @@ -29,10 +29,10 @@ from mycroft.util.log import LOG from .tts import TTS, TTSValidator -config = Configuration.get().get("tts").get("mimic") -data_dir = expanduser(Configuration.get()['data_dir']) +CONFIG = Configuration.get().get("tts").get("mimic") +DATA_DIR = expanduser(Configuration.get()['data_dir']) -BIN = config.get("path", +BIN = CONFIG.get("path", os.path.join(MYCROFT_ROOT_PATH, 'mimic', 'bin', 'mimic')) if not os.path.isfile(BIN): @@ -41,7 +41,7 @@ if not os.path.isfile(BIN): BIN = distutils.spawn.find_executable("mimic") -SUBSCRIBER_VOICES = {'trinity': join(data_dir, 'voices/mimic_tn')} +SUBSCRIBER_VOICES = {'trinity': join(DATA_DIR, 'voices/mimic_tn')} def download_subscriber_voices(selected_voice): @@ -136,9 +136,9 @@ class Mimic(TTS): args = [mimic_bin, '-voice', voice, '-psdur', '-ssml'] - stretch = config.get('duration_stretch', None) + stretch = self.config.get('duration_stretch', None) if stretch: - args += ['--setf', 'duration_stretch=' + stretch] + args += ['--setf', 'duration_stretch={}'.format(stretch)] return args def get_tts(self, sentence, wav_file): diff --git a/mycroft/tts/polly_tts.py b/mycroft/tts/polly_tts.py new file mode 100644 index 0000000000..32e724bc48 --- /dev/null +++ b/mycroft/tts/polly_tts.py @@ -0,0 +1,96 @@ +# Copyright 2017 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. +# +from mycroft.tts.tts import TTS, TTSValidator +from mycroft.configuration import Configuration + + +class PollyTTS(TTS): + def __init__(self, lang="en-us", config=None): + import boto3 + config = config or Configuration.get().get("tts", {}).get("polly", {}) + super(PollyTTS, self).__init__(lang, config, PollyTTSValidator(self), + audio_ext="mp3", + ssml_tags=["speak", "say-as", "voice", + "prosody", "break", + "emphasis", "sub", "lang", + "phoneme", "w", "whisper", + "amazon:auto-breaths", + "p", "s", "amazon:effect", + "mark"]) + + self.voice = self.config.get("voice", "Matthew") + self.key_id = self.config.get("access_key_id", '') + self.key = self.config.get("secret_access_key", '') + self.region = self.config.get("region", 'us-east-1') + self.polly = boto3.Session(aws_access_key_id=self.key_id, + aws_secret_access_key=self.key, + region_name=self.region).client('polly') + + def get_tts(self, sentence, wav_file): + text_type = "text" + if self.remove_ssml(sentence) != sentence: + text_type = "ssml" + sentence = sentence \ + .replace("\\whispered", "/amazon:effect") \ + .replace("whispered", "amazon:effect name=\"whispered\"") + response = self.polly.synthesize_speech( + OutputFormat=self.audio_ext, + Text=sentence, + TextType=text_type, + VoiceId=self.voice) + + with open(wav_file, 'wb') as f: + f.write(response['AudioStream'].read()) + return (wav_file, None) # No phonemes + + def describe_voices(self, language_code="en-US"): + if language_code.islower(): + a, b = language_code.split("-") + b = b.upper() + language_code = "-".join([a, b]) + # example 'it-IT' useful to retrieve voices + voices = self.polly.describe_voices(LanguageCode=language_code) + + return voices + + +class PollyTTSValidator(TTSValidator): + def __init__(self, tts): + super(PollyTTSValidator, self).__init__(tts) + + def validate_lang(self): + # TODO + pass + + def validate_dependencies(self): + try: + from boto3 import Session + except ImportError: + raise Exception( + 'PollyTTS dependencies not installed, please run pip install ' + 'boto3 ') + + def validate_connection(self): + try: + if not self.tts.voice: + raise Exception("Polly TTS Voice not configured") + output = self.tts.describe_voices() + except TypeError: + raise Exception( + 'PollyTTS server could not be verified. Please check your ' + 'internet connection and credentials.') + + def get_tts_class(self): + return PollyTTS diff --git a/mycroft/tts/tts.py b/mycroft/tts/tts.py index ccbc32e132..6dd70f3226 100644 --- a/mycroft/tts/tts.py +++ b/mycroft/tts/tts.py @@ -40,6 +40,9 @@ _TTS_ENV = deepcopy(os.environ) _TTS_ENV['PULSE_PROP'] = 'media.role=phone' +EMPTY_PLAYBACK_QUEUE_TUPLE = (None, None, None, None, None) + + class PlaybackThread(Thread): """Thread class for playing back tts audio and sending viseme data to enclosure. @@ -184,7 +187,7 @@ class TTS(metaclass=ABCMeta): def load_spellings(self): """Load phonetic spellings of words as dictionary""" - path = join('text', self.lang, 'phonetic_spellings.txt') + path = join('text', self.lang.lower(), 'phonetic_spellings.txt') spellings_file = resolve_resource_file(path) if not spellings_file: return {} @@ -317,8 +320,10 @@ class TTS(metaclass=ABCMeta): try: self._execute(sentence, ident, listen) except Exception: - # If an error occurs end the audio sequence - self.queue.put((None, None, None, None, None)) + # If an error occurs end the audio sequence through an empty entry + self.queue.put(EMPTY_PLAYBACK_QUEUE_TUPLE) + # Re-raise to allow the Exception to be handled externally as well. + raise def _execute(self, sentence, ident, listen): if self.phonetic_spelling: @@ -460,6 +465,7 @@ class TTSValidator(metaclass=ABCMeta): class TTSFactory: + from mycroft.tts.festival_tts import Festival from mycroft.tts.espeak_tts import ESpeak from mycroft.tts.fa_tts import FATTS from mycroft.tts.google_tts import GoogleTTS @@ -472,6 +478,7 @@ class TTSFactory: from mycroft.tts.mimic2_tts import Mimic2 from mycroft.tts.yandex_tts import YandexTTS from mycroft.tts.dummy_tts import DummyTTS + from mycroft.tts.polly_tts import PollyTTS CLASSES = { "mimic": Mimic, @@ -479,12 +486,14 @@ class TTSFactory: "google": GoogleTTS, "marytts": MaryTTS, "fatts": FATTS, + "festival": Festival, "espeak": ESpeak, "spdsay": SpdSay, "watson": WatsonTTS, "bing": BingTTS, "responsive_voice": ResponsiveVoice, "yandex": YandexTTS, + "polly": PollyTTS, "dummy": DummyTTS } diff --git a/mycroft/util/__init__.py b/mycroft/util/__init__.py index 60d1013ac1..3f7a667b66 100644 --- a/mycroft/util/__init__.py +++ b/mycroft/util/__init__.py @@ -12,556 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. # +"""Mycroft util library. + +A collections of utils and tools for making skill development easier. +""" from __future__ import absolute_import -import json -import logging import os -import re -import requests -import signal as sig -import socket -import subprocess -import tempfile -from copy import deepcopy -from stat import S_ISREG, ST_MTIME, ST_MODE, ST_SIZE -from threading import Thread -from time import sleep -from urllib.request import urlopen -from urllib.error import URLError - -import pyaudio -import psutil import mycroft.audio -import mycroft.configuration from mycroft.util.format import nice_number -# Officially exported methods from this file: -# play_wav, play_mp3, play_ogg, get_cache_directory, -# resolve_resource_file, wait_while_speaking -from mycroft.util.log import LOG -from mycroft.util.parse import extract_datetime, extract_number, normalize -# TODO: Other modules import signals functions from here, make consistent -from mycroft.util.signal import ( - create_file, - check_for_signal, - create_signal, - ensure_directory_exists, - get_ipc_directory -) - - -def resolve_resource_file(res_name): - """Convert a resource into an absolute filename. - - Resource names are in the form: 'filename.ext' - or 'path/filename.ext' - - The system wil look for ~/.mycroft/res_name first, and - if not found will look at /opt/mycroft/res_name, - then finally it will look for res_name in the 'mycroft/res' - folder of the source code package. - - Example: - With mycroft running as the user 'bob', if you called - resolve_resource_file('snd/beep.wav') - it would return either '/home/bob/.mycroft/snd/beep.wav' or - '/opt/mycroft/snd/beep.wav' or '.../mycroft/res/snd/beep.wav', - where the '...' is replaced by the path where the package has - been installed. - - Args: - res_name (str): a resource path/name - Returns: - str: path to resource or None if no resource found - """ - config = mycroft.configuration.Configuration.get() - - # First look for fully qualified file (e.g. a user setting) - if os.path.isfile(res_name): - return res_name - - # Now look for ~/.mycroft/res_name (in user folder) - filename = os.path.expanduser("~/.mycroft/" + res_name) - if os.path.isfile(filename): - return filename - - # Next look for /opt/mycroft/res/res_name - data_dir = os.path.expanduser(config['data_dir']) - filename = os.path.expanduser(os.path.join(data_dir, res_name)) - if os.path.isfile(filename): - return filename - - # Finally look for it in the source package - filename = os.path.join(os.path.dirname(__file__), '..', 'res', res_name) - filename = os.path.abspath(os.path.normpath(filename)) - if os.path.isfile(filename): - return filename - - return None # Resource cannot be resolved - - -def play_audio_file(uri: str, environment=None): - """ Play an audio file. - - This wraps the other play_* functions, choosing the correct one based on - the file extension. The function will return directly and play the file - in the background. - - Arguments: - uri: uri to play - environment (dict): optional environment for the subprocess call - - Returns: subprocess.Popen object. None if the format is not supported or - an error occurs playing the file. - - """ - extension_to_function = { - '.wav': play_wav, - '.mp3': play_mp3, - '.ogg': play_ogg - } - _, extension = os.path.splitext(uri) - play_function = extension_to_function.get(extension.lower()) - if play_function: - return play_function(uri, environment) - else: - LOG.error("Could not find a function capable of playing {uri}." - " Supported formats are {keys}." - .format(uri=uri, keys=list(extension_to_function.keys()))) - return None - - -_ENVIRONMENT = deepcopy(os.environ) -_ENVIRONMENT['PULSE_PROP'] = 'media.role=music' - - -def _get_pulse_environment(config): - """Return environment for pulse audio depeding on ducking config.""" - tts_config = config.get('tts', {}) - if tts_config and tts_config.get('pulse_duck'): - return _ENVIRONMENT - else: - return os.environ - - -def play_wav(uri, environment=None): - """ Play a wav-file. - - This will use the application specified in the mycroft config - and play the uri passed as argument. The function will return directly - and play the file in the background. - - Arguments: - uri: uri to play - environment (dict): optional environment for the subprocess call - - Returns: subprocess.Popen object - """ - config = mycroft.configuration.Configuration.get() - environment = environment or _get_pulse_environment(config) - play_cmd = config.get("play_wav_cmdline") - play_wav_cmd = str(play_cmd).split(" ") - for index, cmd in enumerate(play_wav_cmd): - if cmd == "%1": - play_wav_cmd[index] = (get_http(uri)) - try: - return subprocess.Popen(play_wav_cmd, env=environment) - except Exception as e: - LOG.error("Failed to launch WAV: {}".format(play_wav_cmd)) - LOG.debug("Error: {}".format(repr(e)), exc_info=True) - return None - - -def play_mp3(uri, environment=None): - """ Play a mp3-file. - - This will use the application specified in the mycroft config - and play the uri passed as argument. The function will return directly - and play the file in the background. - - Arguments: - uri: uri to play - environment (dict): optional environment for the subprocess call - - Returns: subprocess.Popen object - """ - config = mycroft.configuration.Configuration.get() - environment = environment or _get_pulse_environment(config) - play_cmd = config.get("play_mp3_cmdline") - play_mp3_cmd = str(play_cmd).split(" ") - for index, cmd in enumerate(play_mp3_cmd): - if cmd == "%1": - play_mp3_cmd[index] = (get_http(uri)) - try: - return subprocess.Popen(play_mp3_cmd, env=environment) - except Exception as e: - LOG.error("Failed to launch MP3: {}".format(play_mp3_cmd)) - LOG.debug("Error: {}".format(repr(e)), exc_info=True) - return None - - -def play_ogg(uri, environment=None): - """ Play a ogg-file. - - This will use the application specified in the mycroft config - and play the uri passed as argument. The function will return directly - and play the file in the background. - - Arguments: - uri: uri to play - environment (dict): optional environment for the subprocess call - - Returns: subprocess.Popen object - """ - config = mycroft.configuration.Configuration.get() - environment = environment or _get_pulse_environment(config) - play_cmd = config.get("play_ogg_cmdline") - play_ogg_cmd = str(play_cmd).split(" ") - for index, cmd in enumerate(play_ogg_cmd): - if cmd == "%1": - play_ogg_cmd[index] = (get_http(uri)) - try: - return subprocess.Popen(play_ogg_cmd, env=environment) - except Exception as e: - LOG.error("Failed to launch OGG: {}".format(play_ogg_cmd)) - LOG.debug("Error: {}".format(repr(e)), exc_info=True) - return None - - -def record(file_path, duration, rate, channels): - if duration > 0: - return subprocess.Popen( - ["arecord", "-r", str(rate), "-c", str(channels), "-d", - str(duration), file_path]) - else: - return subprocess.Popen( - ["arecord", "-r", str(rate), "-c", str(channels), file_path]) - - -def find_input_device(device_name): - """ Find audio input device by name. - - Arguments: - device_name: device name or regex pattern to match - - Returns: device_index (int) or None if device wasn't found - """ - LOG.info('Searching for input device: {}'.format(device_name)) - LOG.debug('Devices: ') - pa = pyaudio.PyAudio() - pattern = re.compile(device_name) - for device_index in range(pa.get_device_count()): - dev = pa.get_device_info_by_index(device_index) - LOG.debug(' {}'.format(dev['name'])) - if dev['maxInputChannels'] > 0 and pattern.match(dev['name']): - LOG.debug(' ^-- matched') - return device_index - return None - - -def get_http(uri): - return uri.replace("https://", "http://") - - -def remove_last_slash(url): - if url and url.endswith('/'): - url = url[:-1] - return url - - -def read_stripped_lines(filename): - with open(filename, 'r') as f: - return [line.strip() for line in f] - - -def read_dict(filename, div='='): - d = {} - with open(filename, 'r') as f: - for line in f: - (key, val) = line.split(div) - d[key.strip()] = val.strip() - return d - - -def connected(): - """ Check connection by connecting to 8.8.8.8 and if google.com is - reachable if this fails, Check Microsoft NCSI is used as a backup. - - Returns: - True if internet connection can be detected - """ - if _connected_dns(): - # Outside IP is reachable check if names are resolvable - return _connected_google() - else: - # DNS can't be reached, do a complete fetch in case it's blocked - return _connected_ncsi() - - -def _connected_ncsi(): - """ Check internet connection by retrieving the Microsoft NCSI endpoint. - - Returns: - True if internet connection can be detected - """ - try: - r = requests.get('http://www.msftncsi.com/ncsi.txt') - if r.text == 'Microsoft NCSI': - return True - except Exception: - pass - return False - - -def _connected_dns(host="8.8.8.8", port=53, timeout=3): - """ Check internet connection by connecting to DNS servers - - Returns: - True if internet connection can be detected - """ - # Thanks to 7h3rAm on - # Host: 8.8.8.8 (google-public-dns-a.google.com) - # OpenPort: 53/tcp - # Service: domain (DNS/TCP) - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(timeout) - s.connect((host, port)) - return True - except IOError: - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(timeout) - s.connect(("8.8.4.4", port)) - return True - except IOError: - return False - - -def _connected_google(): - """Check internet connection by connecting to www.google.com - Returns: - True if connection attempt succeeded - """ - connect_success = False - try: - urlopen('https://www.google.com', timeout=3) - except URLError as ue: - LOG.debug('Attempt to connect to internet failed: ' + str(ue.reason)) - else: - connect_success = True - - return connect_success - - -def curate_cache(directory, min_free_percent=5.0, min_free_disk=50): - """Clear out the directory if needed - - This assumes all the files in the directory can be deleted as freely - - Args: - directory (str): directory path that holds cached files - min_free_percent (float): percentage (0.0-100.0) of drive to keep free, - default is 5% if not specified. - min_free_disk (float): minimum allowed disk space in MB, default - value is 50 MB if not specified. - """ - - # Simpleminded implementation -- keep a certain percentage of the - # disk available. - # TODO: Would be easy to add more options, like whitelisted files, etc. - space = psutil.disk_usage(directory) - - # convert from MB to bytes - min_free_disk *= 1024 * 1024 - # space.percent = space.used/space.total*100.0 - percent_free = 100.0 - space.percent - if percent_free < min_free_percent and space.free < min_free_disk: - LOG.info('Low diskspace detected, cleaning cache') - # calculate how many bytes we need to delete - bytes_needed = (min_free_percent - percent_free) / 100.0 * space.total - bytes_needed = int(bytes_needed + 1.0) - - # get all entries in the directory w/ stats - entries = (os.path.join(directory, fn) for fn in os.listdir(directory)) - entries = ((os.stat(path), path) for path in entries) - - # leave only regular files, insert modification date - entries = ((stat[ST_MTIME], stat[ST_SIZE], path) - for stat, path in entries if S_ISREG(stat[ST_MODE])) - - # delete files with oldest modification date until space is freed - space_freed = 0 - for moddate, fsize, path in sorted(entries): - try: - os.remove(path) - space_freed += fsize - except Exception: - pass - - if space_freed > bytes_needed: - return # deleted enough! - - -def get_cache_directory(domain=None): - """Get a directory for caching data - - This directory can be used to hold temporary caches of data to - speed up performance. This directory will likely be part of a - small RAM disk and may be cleared at any time. So code that - uses these cached files must be able to fallback and regenerate - the file. - - Args: - domain (str): The cache domain. Basically just a subdirectory. - - Return: - str: a path to the directory where you can cache data - """ - config = mycroft.configuration.Configuration.get() - dir = config.get("cache_path") - if not dir: - # If not defined, use /tmp/mycroft/cache - dir = os.path.join(tempfile.gettempdir(), "mycroft", "cache") - return ensure_directory_exists(dir, domain) - - -def is_speaking(): - """Determine if Text to Speech is occurring - - Returns: - bool: True while still speaking - """ - LOG.info("mycroft.utils.is_speaking() is depreciated, use " - "mycroft.audio.is_speaking() instead.") - return mycroft.audio.is_speaking() - - -def wait_while_speaking(): - """Pause as long as Text to Speech is still happening - - Pause while Text to Speech is still happening. This always pauses - briefly to ensure that any preceeding request to speak has time to - begin. - """ - LOG.info("mycroft.utils.wait_while_speaking() is depreciated, use " - "mycroft.audio.wait_while_speaking() instead.") - return mycroft.audio.wait_while_speaking() - - -def stop_speaking(): - # TODO: Less hacky approach to this once Audio Manager is implemented - # Skills should only be able to stop speech they've initiated - LOG.info("mycroft.utils.stop_speaking() is depreciated, use " - "mycroft.audio.stop_speaking() instead.") - mycroft.audio.stop_speaking() - - -def get_arch(): - """ Get architecture string of system. """ - return os.uname()[4] - - -def reset_sigint_handler(): - """ - Reset the sigint handler to the default. This fixes KeyboardInterrupt - not getting raised when started via start-mycroft.sh - """ - sig.signal(sig.SIGINT, sig.default_int_handler) - - -def create_daemon(target, args=(), kwargs=None): - """Helper to quickly create and start a thread with daemon = True""" - t = Thread(target=target, args=args, kwargs=kwargs) - t.daemon = True - t.start() - return t - - -def wait_for_exit_signal(): - """Blocks until KeyboardInterrupt is received""" - try: - while True: - sleep(100) - except KeyboardInterrupt: - pass - - -_log_all_bus_messages = False - - -def create_echo_function(name, whitelist=None): - """ Standard logging mechanism for Mycroft processes. - - This handles the setup of the basic logging for all Mycroft - messagebus-based processes. - - Args: - name (str): Reference name of the process - whitelist (list, optional): List of "type" strings. If defined, only - messages in this list will be logged. - - Returns: - func: The echo function - """ - - from mycroft.configuration import Configuration - blacklist = Configuration.get().get("ignore_logs") - - # Make sure whitelisting doesn't remove the log level setting command - if whitelist: - whitelist.append('mycroft.debug.log') - - def echo(message): - global _log_all_bus_messages - try: - msg = json.loads(message) - msg_type = msg.get("type", "") - # Whitelist match beginning of message - # i.e 'mycroft.audio.service' will allow the message - # 'mycroft.audio.service.play' for example - if whitelist and not any([msg_type.startswith(e) - for e in whitelist]): - return - - if blacklist and msg_type in blacklist: - return - - if msg_type == "mycroft.debug.log": - # Respond to requests to adjust the logger settings - lvl = msg["data"].get("level", "").upper() - if lvl in ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]: - LOG.level = lvl - LOG(name).info("Changing log level to: {}".format(lvl)) - try: - logging.getLogger().setLevel(lvl) - logging.getLogger('urllib3').setLevel(lvl) - except Exception: - pass # We don't really care about if this fails... - else: - LOG(name).info("Invalid level provided: {}".format(lvl)) - - # Allow enable/disable of messagebus traffic - log_bus = msg["data"].get("bus", None) - if log_bus is not None: - LOG(name).info("Bus logging: {}".format(log_bus)) - _log_all_bus_messages = log_bus - elif msg_type == "registration": - # do not log tokens from registration messages - msg["data"]["token"] = None - message = json.dumps(msg) - except Exception as e: - LOG.info("Error: {}".format(repr(e)), exc_info=True) - - if _log_all_bus_messages: - # Listen for messages and echo them for logging - LOG(name).info("BUS: {}".format(message)) - return echo - - -def camel_case_split(identifier: str) -> str: - """Split camel case string""" - regex = '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)' - matches = re.finditer(regex, identifier) - return ' '.join([m.group(0) for m in matches]) +from .string_utils import camel_case_split +from .audio_utils import (play_audio_file, play_wav, play_ogg, play_mp3, + record, find_input_device) +from .file_utils import (resolve_resource_file, read_stripped_lines, read_dict, + create_file, ensure_directory_exists, + curate_cache, get_cache_directory) +from .network_utils import connected +from .process_utils import (reset_sigint_handler, create_daemon, + wait_for_exit_signal, create_echo_function) +from .log import LOG +from .parse import extract_datetime, extract_number, normalize +from .signal import check_for_signal, create_signal, get_ipc_directory +from .platform import get_arch diff --git a/mycroft/util/audio_test.py b/mycroft/util/audio_test.py index be6cf865e2..2e41efbecb 100644 --- a/mycroft/util/audio_test.py +++ b/mycroft/util/audio_test.py @@ -21,7 +21,7 @@ from speech_recognition import Recognizer from mycroft.client.speech.mic import MutableMicrophone from mycroft.configuration import Configuration -from mycroft.util import play_wav +from mycroft.util.audio_utils import play_wav from mycroft.util.log import LOG import logging diff --git a/mycroft/util/audio_utils.py b/mycroft/util/audio_utils.py new file mode 100644 index 0000000000..712c4a563d --- /dev/null +++ b/mycroft/util/audio_utils.py @@ -0,0 +1,206 @@ +# Copyright 2020 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. +# +"""Contains simple tools for performing audio related tasks such as playback +of audio, recording and listing devices. +""" +from copy import deepcopy +import os +import pyaudio +import re +import subprocess + +import mycroft.configuration +from .log import LOG + + +def play_audio_file(uri: str, environment=None): + """Play an audio file. + + This wraps the other play_* functions, choosing the correct one based on + the file extension. The function will return directly and play the file + in the background. + + Arguments: + uri: uri to play + environment (dict): optional environment for the subprocess call + + Returns: subprocess.Popen object. None if the format is not supported or + an error occurs playing the file. + """ + extension_to_function = { + '.wav': play_wav, + '.mp3': play_mp3, + '.ogg': play_ogg + } + _, extension = os.path.splitext(uri) + play_function = extension_to_function.get(extension.lower()) + if play_function: + return play_function(uri, environment) + else: + LOG.error("Could not find a function capable of playing {uri}." + " Supported formats are {keys}." + .format(uri=uri, keys=list(extension_to_function.keys()))) + return None + + +# Create a custom environment to use that can be ducked by a phone role. +# This is kept separate from the normal os.environ to ensure that the TTS +# role isn't affected and that any thirdparty software launched through +# a mycroft process can select if they wish to honor this. +_ENVIRONMENT = deepcopy(os.environ) +_ENVIRONMENT['PULSE_PROP'] = 'media.role=music' + + +def _get_pulse_environment(config): + """Return environment for pulse audio depeding on ducking config.""" + tts_config = config.get('tts', {}) + if tts_config and tts_config.get('pulse_duck'): + return _ENVIRONMENT + else: + return os.environ + + +def _play_cmd(cmd, uri, config, environment): + """Generic function for starting playback from a commandline and uri. + + Arguments: + cmd (str): commandline to execute %1 in the command line will be + replaced with the provided uri. + uri (str): uri to play + config (dict): config to use + environment: environment to execute in, can be used to supply specific + pulseaudio settings. + """ + environment = environment or _get_pulse_environment(config) + cmd_elements = str(cmd).split(" ") + cmdline = [e if e != '%1' else uri for e in cmd_elements] + return subprocess.Popen(cmdline, env=environment) + + +def play_wav(uri, environment=None): + """Play a wav-file. + + This will use the application specified in the mycroft config + and play the uri passed as argument. The function will return directly + and play the file in the background. + + Arguments: + uri: uri to play + environment (dict): optional environment for the subprocess call + + Returns: subprocess.Popen object or None if operation failed + """ + config = mycroft.configuration.Configuration.get() + play_wav_cmd = config['play_wav_cmdline'] + try: + return _play_cmd(play_wav_cmd, uri, config, environment) + except FileNotFoundError as e: + LOG.error("Failed to launch WAV: {} ({})".format(play_wav_cmd, + repr(e))) + except Exception: + LOG.exception("Failed to launch WAV: {}".format(play_wav_cmd)) + return None + + +def play_mp3(uri, environment=None): + """Play a mp3-file. + + This will use the application specified in the mycroft config + and play the uri passed as argument. The function will return directly + and play the file in the background. + + Arguments: + uri: uri to play + environment (dict): optional environment for the subprocess call + + Returns: subprocess.Popen object or None if operation failed + """ + config = mycroft.configuration.Configuration.get() + play_mp3_cmd = config.get("play_mp3_cmdline") + try: + return _play_cmd(play_mp3_cmd, uri, config, environment) + except FileNotFoundError as e: + LOG.error("Failed to launch MP3: {} ({})".format(play_mp3_cmd, + repr(e))) + except Exception: + LOG.exception("Failed to launch MP3: {}".format(play_mp3_cmd)) + return None + + +def play_ogg(uri, environment=None): + """Play an ogg-file. + + This will use the application specified in the mycroft config + and play the uri passed as argument. The function will return directly + and play the file in the background. + + Arguments: + uri: uri to play + environment (dict): optional environment for the subprocess call + + Returns: subprocess.Popen object, or None if operation failed + """ + config = mycroft.configuration.Configuration.get() + play_ogg_cmd = config.get("play_ogg_cmdline") + try: + return _play_cmd(play_ogg_cmd, uri, config, environment) + except FileNotFoundError as e: + LOG.error("Failed to launch OGG: {} ({})".format(play_ogg_cmd, + repr(e))) + except Exception: + LOG.exception("Failed to launch OGG: {}".format(play_ogg_cmd)) + return None + + +def record(file_path, duration, rate, channels): + """Simple function to record from the default mic. + + The recording is done in the background by the arecord commandline + application. + + Arguments: + file_path: where to store the recorded data + duration: how long to record + rate: sample rate + channels: number of channels + + Returns: + process for performing the recording. + """ + command = ['arecord', '-r', str(rate), '-c', str(channels)] + command += ['-d', str(duration)] if duration > 0 else [] + command += [file_path] + return subprocess.Popen(command) + + +def find_input_device(device_name): + """Find audio input device by name. + + Arguments: + device_name: device name or regex pattern to match + + Returns: device_index (int) or None if device wasn't found + """ + LOG.info('Searching for input device: {}'.format(device_name)) + LOG.debug('Devices: ') + pa = pyaudio.PyAudio() + pattern = re.compile(device_name) + for device_index in range(pa.get_device_count()): + dev = pa.get_device_info_by_index(device_index) + LOG.debug(' {}'.format(dev['name'])) + if dev['maxInputChannels'] > 0 and pattern.match(dev['name']): + LOG.debug(' ^-- matched') + return device_index + return None diff --git a/mycroft/util/download.py b/mycroft/util/download.py index cbe7bcf1f9..eb009900ba 100644 --- a/mycroft/util/download.py +++ b/mycroft/util/download.py @@ -12,44 +12,55 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from threading import Thread +"""Download utility based on wget. +The utility is a real simple implementation leveraging the wget command line +application supporting resume on failed download. +""" +from glob import glob import os -import requests from os.path import exists, dirname import subprocess +from threading import Thread -_running_downloads = {} +from .file_utils import ensure_directory_exists + +_running_downloads = {} # Cache of running downloads def _get_download_tmp(dest): + """Get temporary file for download. + + Arguments: + dest (str): path to download location + + Returns: + (str) path to temporary download location + """ tmp_base = dest + '.part' - if not exists(tmp_base): - return tmp_base + existing = glob(tmp_base + '*') + if len(existing) > 0: + return '{}.{}'.format(tmp_base, len(existing)) else: - i = 1 - while(True): - tmp = tmp_base + '.' + str(i) - if not exists(tmp): - return tmp - else: - i += 1 + return tmp_base class Downloader(Thread): - """ - Downloader is a thread based downloader instance when instanciated - it will download the provided url to a file on disk. + """Simple file downloader. - When the download is complete or failed the `.done` property will - be set to true and the `.status` will indicate the status code. - 200 = Success. + Downloader is a thread based downloader instance when instanciated + it will download the provided url to a file on disk. - Args: - url: Url to download - dest: Path to save data to - complet_action: Function to run when download is complete. - `func(dest)` + When the download is complete or failed the `.done` property will + be set to true and the `.status` will indicate the HTTP status code. + 200 = Success. + + Arguments: + url (str): Url to download + dest (str): Path to save data to + complete_action (callable): Function to run when download is complete + `func(dest)` + header: any special header needed for starting the transfer """ def __init__(self, url, dest, complete_action=None, header=None): @@ -63,15 +74,18 @@ class Downloader(Thread): self.header = header # Create directories as needed - if not exists(dirname(dest)): - os.makedirs(dirname(dest)) + ensure_directory_exists(dirname(dest), permissions=0o775) # Start thread self.daemon = True self.start() def perform_download(self, dest): + """Handle the download through wget. + Arguments: + dest (str): Save location + """ cmd = ['wget', '-c', self.url, '-O', dest, '--tries=20', '--read-timeout=5'] if self.header: @@ -79,12 +93,9 @@ class Downloader(Thread): return subprocess.call(cmd) def run(self): - """ - Does the actual download. - """ + """Do the actual download.""" tmp = _get_download_tmp(self.dest) self.status = self.perform_download(tmp) - if not self._abort and self.status == 0: self.finalize(tmp) else: @@ -97,31 +108,42 @@ class Downloader(Thread): _running_downloads.pop(arg_hash) def finalize(self, tmp): - """ - Move the .part file to the final destination and perform any - actions that should be performed at completion. + """Move temporary download data to final location. + + Move the .part file to the final destination and perform any + actions that should be performed at completion. + + Arguments: + tmp(str): temporary file path """ os.rename(tmp, self.dest) if self.complete_action: self.complete_action(self.dest) def cleanup(self, tmp): - """ - Cleanup after download attempt - """ + """Cleanup after download attempt.""" if exists(tmp): os.remove(self.dest + '.part') if self.status == 200: self.status = -1 def abort(self): - """ - Abort download process - """ + """Abort download process.""" self._abort = True def download(url, dest, complete_action=None, header=None): + """Start a download or fetch an already running. + + Arguments: + url (str): url to download + dest (str): path to save download to + complete_action (callable): Optional function to call on completion + header (str): Optional header to use for the download + + Returns: + Downloader object + """ global _running_downloads arg_hash = hash(url + dest) if arg_hash not in _running_downloads: diff --git a/mycroft/util/file_utils.py b/mycroft/util/file_utils.py new file mode 100644 index 0000000000..84555fc3bb --- /dev/null +++ b/mycroft/util/file_utils.py @@ -0,0 +1,267 @@ +# Copyright 2020 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. +# +"""Mycroft file utils. + +This module contains functions handling mycroft resource files and things like +accessing and curating mycroft's cache. +""" + +import os +import psutil +from stat import S_ISREG, ST_MTIME, ST_MODE, ST_SIZE +import tempfile + +import mycroft.configuration +from .log import LOG + + +def resolve_resource_file(res_name): + """Convert a resource into an absolute filename. + + Resource names are in the form: 'filename.ext' + or 'path/filename.ext' + + The system wil look for ~/.mycroft/res_name first, and + if not found will look at /opt/mycroft/res_name, + then finally it will look for res_name in the 'mycroft/res' + folder of the source code package. + + Example: + With mycroft running as the user 'bob', if you called + resolve_resource_file('snd/beep.wav') + it would return either '/home/bob/.mycroft/snd/beep.wav' or + '/opt/mycroft/snd/beep.wav' or '.../mycroft/res/snd/beep.wav', + where the '...' is replaced by the path where the package has + been installed. + + Arguments: + res_name (str): a resource path/name + Returns: + (str) path to resource or None if no resource found + """ + config = mycroft.configuration.Configuration.get() + + # First look for fully qualified file (e.g. a user setting) + if os.path.isfile(res_name): + return res_name + + # Now look for ~/.mycroft/res_name (in user folder) + filename = os.path.expanduser("~/.mycroft/" + res_name) + if os.path.isfile(filename): + return filename + + # Next look for /opt/mycroft/res/res_name + data_dir = os.path.join(os.path.expanduser(config['data_dir']), 'res') + filename = os.path.expanduser(os.path.join(data_dir, res_name)) + if os.path.isfile(filename): + return filename + + # Finally look for it in the source package + filename = os.path.join(os.path.dirname(__file__), '..', 'res', res_name) + filename = os.path.abspath(os.path.normpath(filename)) + if os.path.isfile(filename): + return filename + + return None # Resource cannot be resolved + + +def read_stripped_lines(filename): + """Read a file and return a list of stripped lines. + + Arguments: + filename (str): path to file to read. + + Returns: + (list) list of lines stripped from leading and ending white chars. + """ + with open(filename, 'r') as f: + for line in f: + line = line.strip() + if line: + yield line + + +def read_dict(filename, div='='): + """Read file into dict. + + A file containing: + foo = bar + baz = bog + + results in a dict + { + 'foo': 'bar', + 'baz': 'bog' + } + + Arguments: + filename (str): path to file + div (str): deviders between dict keys and values + + Returns: + (dict) generated dictionary + """ + d = {} + with open(filename, 'r') as f: + for line in f: + key, val = line.split(div) + d[key.strip()] = val.strip() + return d + + +def mb_to_bytes(size): + """Takes a size in MB and returns the number of bytes. + + Arguments: + size(int/float): size in Mega Bytes + + Returns: + (int/float) size in bytes + """ + return size * 1024 * 1024 + + +def _get_cache_entries(directory): + """Get information tuple for all regular files in directory. + + Arguments: + directory (str): path to directory to check + + Returns: + (tuple) (modification time, size, filepath) + """ + entries = (os.path.join(directory, fn) for fn in os.listdir(directory)) + entries = ((os.stat(path), path) for path in entries) + + # leave only regular files, insert modification date + return ((stat[ST_MTIME], stat[ST_SIZE], path) + for stat, path in entries if S_ISREG(stat[ST_MODE])) + + +def _delete_oldest(entries, bytes_needed): + """Delete files with oldest modification date until space is freed. + + Arguments: + entries (tuple): file + file stats tuple + bytes_needed (int): disk space that needs to be freed + """ + space_freed = 0 + for moddate, fsize, path in sorted(entries): + try: + os.remove(path) + space_freed += fsize + except Exception: + pass + + if space_freed > bytes_needed: + break # deleted enough! + + +def curate_cache(directory, min_free_percent=5.0, min_free_disk=50): + """Clear out the directory if needed. + + The curation will only occur if both the precentage and actual disk space + is below the limit. This assumes all the files in the directory can be + deleted as freely. + + Arguments: + directory (str): directory path that holds cached files + min_free_percent (float): percentage (0.0-100.0) of drive to keep free, + default is 5% if not specified. + min_free_disk (float): minimum allowed disk space in MB, default + value is 50 MB if not specified. + """ + # Simpleminded implementation -- keep a certain percentage of the + # disk available. + # TODO: Would be easy to add more options, like whitelisted files, etc. + space = psutil.disk_usage(directory) + + min_free_disk = mb_to_bytes(min_free_disk) + percent_free = 100.0 - space.percent + if percent_free < min_free_percent and space.free < min_free_disk: + LOG.info('Low diskspace detected, cleaning cache') + # calculate how many bytes we need to delete + bytes_needed = (min_free_percent - percent_free) / 100.0 * space.total + bytes_needed = int(bytes_needed + 1.0) + + # get all entries in the directory w/ stats + entries = _get_cache_entries(directory) + # delete as many as needed starting with the oldest + _delete_oldest(entries, bytes_needed) + + +def get_cache_directory(domain=None): + """Get a directory for caching data. + + This directory can be used to hold temporary caches of data to + speed up performance. This directory will likely be part of a + small RAM disk and may be cleared at any time. So code that + uses these cached files must be able to fallback and regenerate + the file. + + Arguments: + domain (str): The cache domain. Basically just a subdirectory. + + Returns: + (str) a path to the directory where you can cache data + """ + config = mycroft.configuration.Configuration.get() + directory = config.get("cache_path") + if not directory: + # If not defined, use /tmp/mycroft/cache + directory = os.path.join(tempfile.gettempdir(), "mycroft", "cache") + return ensure_directory_exists(directory, domain) + + +def ensure_directory_exists(directory, domain=None, permissions=0o777): + """Create a directory and give access rights to all + + Arguments: + directory (str): Root directory + domain (str): Domain. Basically a subdirectory to prevent things like + overlapping signal filenames. + rights (int): Directory permissions (default is 0o777) + + Returns: + (str) a path to the directory + """ + if domain: + directory = os.path.join(directory, domain) + + # Expand and normalize the path + directory = os.path.normpath(directory) + directory = os.path.expanduser(directory) + + if not os.path.isdir(directory): + try: + save = os.umask(0) + os.makedirs(directory, permissions) + except OSError: + LOG.warning("Failed to create: " + directory) + finally: + os.umask(save) + + return directory + + +def create_file(filename): + """Create the file filename and create any directories needed + + Arguments: + filename: Path to the file to be created + """ + ensure_directory_exists(os.path.dirname(filename), permissions=0o775) + with open(filename, 'w') as f: + f.write('') diff --git a/mycroft/util/monotonic_event.py b/mycroft/util/monotonic_event.py new file mode 100644 index 0000000000..1618924d45 --- /dev/null +++ b/mycroft/util/monotonic_event.py @@ -0,0 +1,63 @@ +# Copyright 2017 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. +# + +"""Events with respect for montonic time. + +The MontonicEvent class defined here wraps the normal class ensuring that +changes in system time are handled. +""" +from threading import Event +from time import sleep, monotonic + +from mycroft.util.log import LOG + + +class MonotonicEvent(Event): + """Event class with monotonic timeout. + + Normal Event doesn't do wait timeout in a monotonic manner and may be + affected by changes in system time. This class wraps the Event class + wait() method with logic guards ensuring monotonic operation. + """ + def wait_timeout(self, timeout): + """Handle timeouts in a monotonic way. + + Repeatingly wait as long the event hasn't been set and the + monotonic time doesn't indicate a timeout. + + Arguments: + timeout: timeout of wait in seconds + + Returns: + True if Event has been set, False if timeout expired + """ + result = False + end_time = monotonic() + timeout + + while not result and (monotonic() < end_time): + # Wait however many seconds are left until the timeout has passed + sleep(0.1) # Mainly a precaution to not busy wait + remaining_time = end_time - monotonic() + LOG.debug('Will wait for {} sec for Event'.format(remaining_time)) + result = super().wait(remaining_time) + + return result + + def wait(self, timeout=None): + if timeout is None: + ret = super().wait() + else: + ret = self.wait_timeout(timeout) + return ret diff --git a/mycroft/util/network_utils.py b/mycroft/util/network_utils.py new file mode 100644 index 0000000000..9a06ac4bae --- /dev/null +++ b/mycroft/util/network_utils.py @@ -0,0 +1,77 @@ +import requests +import socket +from urllib.request import urlopen +from urllib.error import URLError + +from .log import LOG + + +def connected(): + """Check connection by connecting to 8.8.8.8 and if google.com is + reachable if this fails, Check Microsoft NCSI is used as a backup. + + Returns: + True if internet connection can be detected + """ + if _connected_dns(): + # Outside IP is reachable check if names are resolvable + return _connected_google() + else: + # DNS can't be reached, do a complete fetch in case it's blocked + return _connected_ncsi() + + +def _connected_ncsi(): + """Check internet connection by retrieving the Microsoft NCSI endpoint. + + Returns: + True if internet connection can be detected + """ + try: + r = requests.get('http://www.msftncsi.com/ncsi.txt') + if r.text == 'Microsoft NCSI': + return True + except Exception: + pass + return False + + +def _connected_dns(host="8.8.8.8", port=53, timeout=3): + """Check internet connection by connecting to DNS servers + + Returns: + True if internet connection can be detected + """ + # Thanks to 7h3rAm on + # Host: 8.8.8.8 (google-public-dns-a.google.com) + # OpenPort: 53/tcp + # Service: domain (DNS/TCP) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((host, port)) + return True + except IOError: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect(("8.8.4.4", port)) + return True + except IOError: + return False + + +def _connected_google(): + """Check internet connection by connecting to www.google.com + Returns: + True if connection attempt succeeded + """ + connect_success = False + try: + urlopen('https://www.google.com', timeout=3) + except URLError as ue: + LOG.debug('Attempt to connect to internet failed: ' + str(ue.reason)) + else: + connect_success = True + + return connect_success diff --git a/mycroft/util/parse.py b/mycroft/util/parse.py index 036c3c26cc..de42ee14ca 100644 --- a/mycroft/util/parse.py +++ b/mycroft/util/parse.py @@ -30,7 +30,6 @@ The module does implement some useful functions like basic fuzzy matchin. from difflib import SequenceMatcher import lingua_franca.parse -from lingua_franca.lang.parse_en import extract_duration_en from lingua_franca.lang import get_active_lang, get_primary_lang_code from .time import now_local @@ -218,13 +217,7 @@ def extract_duration(text, lang=None): will have whitespace stripped from the ends. """ lang_code = get_primary_lang_code(lang) - - if lang_code == "en": - return extract_duration_en(text) - - # TODO: extract_duration for other languages - _log_unsupported_language(lang_code, ['en']) - return None + return lingua_franca.parse.extract_duration(text, lang_code) def get_gender(word, context="", lang=None): diff --git a/mycroft/util/platform.py b/mycroft/util/platform.py new file mode 100644 index 0000000000..0298ea9bc8 --- /dev/null +++ b/mycroft/util/platform.py @@ -0,0 +1,21 @@ +# Copyright 2020 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. +# +"""Utilities checking for platform features.""" +import os + + +def get_arch(): + """ Get architecture string of system. """ + return os.uname()[4] diff --git a/mycroft/util/process_utils.py b/mycroft/util/process_utils.py new file mode 100644 index 0000000000..1fe190f3b4 --- /dev/null +++ b/mycroft/util/process_utils.py @@ -0,0 +1,123 @@ +import json +import logging +import signal as sig +from threading import Thread +from time import sleep + +from .log import LOG + + +def reset_sigint_handler(): + """Reset the sigint handler to the default. + + This fixes KeyboardInterrupt not getting raised when started via + start-mycroft.sh + """ + sig.signal(sig.SIGINT, sig.default_int_handler) + + +def create_daemon(target, args=(), kwargs=None): + """Helper to quickly create and start a thread with daemon = True""" + t = Thread(target=target, args=args, kwargs=kwargs) + t.daemon = True + t.start() + return t + + +def wait_for_exit_signal(): + """Blocks until KeyboardInterrupt is received.""" + try: + while True: + sleep(100) + except KeyboardInterrupt: + pass + + +_log_all_bus_messages = False + + +def bus_logging_status(): + global _log_all_bus_messages + return _log_all_bus_messages + + +def _update_log_level(msg, name): + """Update log level for process. + + Arguments: + msg (Message): Message sent to trigger the log level change + name (str): Name of the current process + """ + global _log_all_bus_messages + + # Respond to requests to adjust the logger settings + lvl = msg["data"].get("level", "").upper() + if lvl in ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]: + LOG.level = lvl + LOG(name).info("Changing log level to: {}".format(lvl)) + try: + logging.getLogger().setLevel(lvl) + logging.getLogger('urllib3').setLevel(lvl) + except Exception: + pass # We don't really care about if this fails... + else: + LOG(name).info("Invalid level provided: {}".format(lvl)) + + # Allow enable/disable of messagebus traffic + log_bus = msg["data"].get("bus", None) + if log_bus is not None: + LOG(name).info("Bus logging: {}".format(log_bus)) + _log_all_bus_messages = log_bus + + +def create_echo_function(name, whitelist=None): + """Standard logging mechanism for Mycroft processes. + + This handles the setup of the basic logging for all Mycroft + messagebus-based processes. + TODO 20.08: extract log level setting thing completely from this function + + Arguments: + name (str): Reference name of the process + whitelist (list, optional): List of "type" strings. If defined, only + messages in this list will be logged. + + Returns: + func: The echo function + """ + + from mycroft.configuration import Configuration + blacklist = Configuration.get().get("ignore_logs") + + # Make sure whitelisting doesn't remove the log level setting command + if whitelist: + whitelist.append('mycroft.debug.log') + + def echo(message): + global _log_all_bus_messages + try: + msg = json.loads(message) + msg_type = msg.get("type", "") + # Whitelist match beginning of message + # i.e 'mycroft.audio.service' will allow the message + # 'mycroft.audio.service.play' for example + if whitelist and not any([msg_type.startswith(e) + for e in whitelist]): + return + + if blacklist and msg_type in blacklist: + return + + if msg_type == "mycroft.debug.log": + _update_log_level(msg, name) + elif msg_type == "registration": + # do not log tokens from registration messages + msg["data"]["token"] = None + message = json.dumps(msg) + except Exception as e: + LOG.info("Error: {}".format(repr(e)), exc_info=True) + + if _log_all_bus_messages: + # Listen for messages and echo them for logging + LOG(name).info("BUS: {}".format(message)) + return echo diff --git a/mycroft/util/signal.py b/mycroft/util/signal.py index a37f85ef75..efd57644f5 100644 --- a/mycroft/util/signal.py +++ b/mycroft/util/signal.py @@ -19,7 +19,7 @@ import os import os.path import mycroft -from mycroft.util.log import LOG +from .file_utils import ensure_directory_exists, create_file def get_ipc_directory(domain=None): @@ -43,50 +43,6 @@ def get_ipc_directory(domain=None): return ensure_directory_exists(dir, domain) -def ensure_directory_exists(directory, domain=None): - """ Create a directory and give access rights to all - - Args: - domain (str): The IPC domain. Basically a subdirectory to prevent - overlapping signal filenames. - - Returns: - str: a path to the directory - """ - if domain: - directory = os.path.join(directory, domain) - - # Expand and normalize the path - directory = os.path.normpath(directory) - directory = os.path.expanduser(directory) - - if not os.path.isdir(directory): - try: - save = os.umask(0) - os.makedirs(directory, 0o777) # give everyone rights to r/w here - except OSError: - LOG.warning("Failed to create: " + directory) - pass - finally: - os.umask(save) - - return directory - - -def create_file(filename): - """ Create the file filename and create any directories needed - - Args: - filename: Path to the file to be created - """ - try: - os.makedirs(os.path.dirname(filename)) - except OSError: - pass - with open(filename, 'w') as f: - f.write('') - - def create_signal(signal_name): """Create a named signal diff --git a/mycroft/util/string_utils.py b/mycroft/util/string_utils.py new file mode 100644 index 0000000000..1a4901bd19 --- /dev/null +++ b/mycroft/util/string_utils.py @@ -0,0 +1,24 @@ +# Copyright 2020 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. +# +"""Common string utilities used by various parts of core.""" + +import re + + +def camel_case_split(identifier: str) -> str: + """Split camel case string.""" + regex = '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)' + matches = re.finditer(regex, identifier) + return ' '.join([m.group(0) for m in matches]) diff --git a/mycroft/util/time.py b/mycroft/util/time.py index cb1a4af114..07e0620398 100644 --- a/mycroft/util/time.py +++ b/mycroft/util/time.py @@ -13,12 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. # +"""Time utils for getting and converting datetime objects for the Mycroft +system. This time is based on the setting in the Mycroft config and may or +may not match the system locale. +""" from datetime import datetime from dateutil.tz import gettz, tzlocal def default_timezone(): - """ Get the default timezone + """Get the default timezone Based on user location settings location.timezone.code or the default system value if no setting exists. @@ -42,7 +46,7 @@ def default_timezone(): def now_utc(): - """ Retrieve the current time in UTC + """Retrieve the current time in UTC Returns: (datetime): The current time in Universal Time, aka GMT @@ -51,9 +55,9 @@ def now_utc(): def now_local(tz=None): - """ Retrieve the current time + """Retrieve the current time - Args: + Arguments: tz (datetime.tzinfo, optional): Timezone, default to user's settings Returns: @@ -65,9 +69,9 @@ def now_local(tz=None): def to_utc(dt): - """ Convert a datetime with timezone info to a UTC datetime + """Convert a datetime with timezone info to a UTC datetime - Args: + Arguments: dt (datetime): A datetime (presumably in some local zone) Returns: (datetime): time converted to UTC @@ -80,9 +84,9 @@ def to_utc(dt): def to_local(dt): - """ Convert a datetime to the user's local timezone + """Convert a datetime to the user's local timezone - Args: + Arguments: dt (datetime): A datetime (if no timezone, defaults to UTC) Returns: (datetime): time converted to the local timezone @@ -95,9 +99,9 @@ def to_local(dt): def to_system(dt): - """ Convert a datetime to the system's local timezone + """Convert a datetime to the system's local timezone - Args: + Arguments: dt (datetime): A datetime (if no timezone, assumed to be UTC) Returns: (datetime): time converted to the operation system's timezone diff --git a/mycroft/version/__init__.py b/mycroft/version/__init__.py index 9d4a0b4d1c..0a5a848940 100644 --- a/mycroft/version/__init__.py +++ b/mycroft/version/__init__.py @@ -24,8 +24,8 @@ from mycroft.util.log import LOG # The following lines are replaced during the release process. # START_VERSION_BLOCK CORE_VERSION_MAJOR = 20 -CORE_VERSION_MINOR = 2 -CORE_VERSION_BUILD = 1 +CORE_VERSION_MINOR = 8 +CORE_VERSION_BUILD = 0 # END_VERSION_BLOCK CORE_VERSION_TUPLE = (CORE_VERSION_MAJOR, diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..6634fabcb0 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = test +norecursedirs = wake_word diff --git a/requirements/extra-audiobackend.txt b/requirements/extra-audiobackend.txt new file mode 100644 index 0000000000..f3d45ae662 --- /dev/null +++ b/requirements/extra-audiobackend.txt @@ -0,0 +1,2 @@ +pychromecast==3.2.2 +python-vlc==1.1.2 diff --git a/requirements/extra-mark1.txt b/requirements/extra-mark1.txt new file mode 100644 index 0000000000..ecacbeebf7 --- /dev/null +++ b/requirements/extra-mark1.txt @@ -0,0 +1 @@ +pyalsaaudio==0.8.2 diff --git a/requirements/extra-stt.txt b/requirements/extra-stt.txt new file mode 100644 index 0000000000..ae87275888 --- /dev/null +++ b/requirements/extra-stt.txt @@ -0,0 +1 @@ +google-api-python-client==1.6.4 diff --git a/requirements.txt b/requirements/requirements.txt similarity index 58% rename from requirements.txt rename to requirements/requirements.txt index 22720d93cc..685ffe2766 100644 --- a/requirements.txt +++ b/requirements/requirements.txt @@ -1,32 +1,28 @@ six==1.13.0 requests==2.20.0 -gTTS==2.0.4 +gTTS==2.1.1 PyAudio==0.2.11 -pyee==5.0.0 +pyee==7.0.1 SpeechRecognition==3.8.1 tornado==6.0.3 websocket-client==0.54.0 requests-futures==0.9.5 -pyalsaaudio==0.8.2 -xmlrunner==1.7.7 pyserial==3.0 psutil==5.6.6 pocketsphinx==0.1.0 inflection==0.3.1 -pillow==6.2.1 +pillow==7.1.2 python-dateutil==2.6.0 -pychromecast==3.2.2 -python-vlc==1.1.2 -google-api-python-client==1.6.4 fasteners==0.14.1 PyYAML==5.1.2 -lingua-franca==0.2.1 -msm==0.8.7 -msk==0.3.14 -adapt-parser==0.3.4 -padatious==0.4.6 +lingua-franca==0.2.2 +msm==0.8.8 +msk==0.3.16 +adapt-parser==0.3.6 +padatious==0.4.8 fann2==1.0.7 padaos==0.1.9 precise-runner==0.2.1 petact==0.1.2 +pyxdg==0.26 diff --git a/test-requirements.txt b/requirements/tests.txt similarity index 90% rename from test-requirements.txt rename to requirements/tests.txt index 874a0f130d..9e038123e5 100644 --- a/test-requirements.txt +++ b/requirements/tests.txt @@ -7,3 +7,5 @@ sphinx==2.2.1 sphinx-rtd-theme==0.4.3 git+https://github.com/behave/behave@v1.2.7.dev1 allure-behave==2.8.10 + +python-vlc==1.1.2 diff --git a/scripts/install-mimic.sh b/scripts/install-mimic.sh index 86ffc4a6ab..1d13545a28 100755 --- a/scripts/install-mimic.sh +++ b/scripts/install-mimic.sh @@ -18,7 +18,7 @@ set -Ee MIMIC_DIR=mimic -CORES=$1 +CORES=${1:-1} MIMIC_VERSION=1.2.0.2 # for ubuntu precise in travis, that does not provide pkg-config: diff --git a/scripts/my-info.sh b/scripts/my-info.sh index a3b944308e..e395411120 100644 --- a/scripts/my-info.sh +++ b/scripts/my-info.sh @@ -144,7 +144,7 @@ function checkmimic() { # pythoning! function checkPIP() { mlog "Python checks" - mlog " - Verifying ${MYCROFT_HOME}/requirements.txt:" + mlog " - Verifying ${MYCROFT_HOME}/requirements/requirements.txt:" if workon mycroft ; then pip list > /tmp/mycroft-piplist.$$ diff --git a/setup.py b/setup.py index c98f7b4f8f..963c503e4d 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ # limitations under the License. # from setuptools import setup, find_packages +import os import os.path BASEDIR = os.path.abspath(os.path.dirname(__file__)) @@ -44,6 +45,9 @@ def required(requirements_file): """ Read requirements file and remove comments and empty lines. """ with open(os.path.join(BASEDIR, requirements_file), 'r') as f: requirements = f.read().splitlines() + if 'MYCROFT_LOOSE_REQUIREMENTS' in os.environ: + print('USING LOOSE REQUIREMENTS!') + requirements = [r.replace('==', '>=') for r in requirements] return [pkg for pkg in requirements if pkg.strip() and not pkg.startswith("#")] @@ -56,7 +60,12 @@ setup( author_email='devs@mycroft.ai', url='https://github.com/MycroftAI/mycroft-core', description='Mycroft Core', - install_requires=required('requirements.txt'), + install_requires=required('requirements/requirements.txt'), + extras_require={ + 'audio-backend': required('requirements/extra-audiobackend.txt'), + 'mark1': required('requirements/extra-mark1.txt'), + 'stt': required('requirements/extra-stt.txt') + }, packages=find_packages(include=['mycroft*']), include_package_data=True, diff --git a/start-mycroft.sh b/start-mycroft.sh index 3bc4f7bf3a..05814c510d 100755 --- a/start-mycroft.sh +++ b/start-mycroft.sh @@ -238,8 +238,7 @@ case ${_opt} in pytest test/integrationtests/skills/discover_tests.py "$@" ;; "vktest") - source-venv - python -m test.integrationtests.voight_kampff "$@" + source "$DIR/bin/mycroft-skill-testrunner" vktest "$@" ;; "audiotest") launch-process ${_opt} diff --git a/test/Dockerfile.test b/test/Dockerfile similarity index 85% rename from test/Dockerfile.test rename to test/Dockerfile index 87e3c85287..0ae41a4848 100644 --- a/test/Dockerfile.test +++ b/test/Dockerfile @@ -64,12 +64,18 @@ RUN python3 -m venv "/opt/mycroft/mycroft-core/.venv" # determine if any changes have been made since it last started WORKDIR /opt/mycroft/mycroft-core RUN .venv/bin/python -m pip install pip==20.0.2 -COPY requirements.txt . +COPY requirements/requirements.txt . RUN .venv/bin/python -m pip install -r requirements.txt -COPY test-requirements.txt . -RUN .venv/bin/python -m pip install -r test-requirements.txt +COPY requirements/extra-audiobackend.txt . +COPY requirements/extra-stt.txt . +COPY requirements/extra-mark1.txt . +RUN .venv/bin/python -m pip install -r extra-audiobackend.txt \ + && .venv/bin/python -m pip install -r extra-stt.txt \ + && .venv/bin/python -m pip install -r extra-mark1.txt +COPY requirements/tests.txt . +RUN .venv/bin/python -m pip install -r tests.txt COPY dev_setup.sh . -RUN md5sum requirements.txt test-requirements.txt dev_setup.sh > .installed +RUN md5sum requirements.txt tests.txt extra-audiobackend.txt extra-stt.txt extra-mark1.txt dev_setup.sh > .installed # Add the mycroft core virtual environment to the system path. ENV PATH /opt/mycroft/mycroft-core/.venv/bin:$PATH diff --git a/test/integrationtests/messagebus/messagebus_test.py b/test/integrationtests/messagebus/messagebus_test.py index e9b76a42de..366ab4311b 100644 --- a/test/integrationtests/messagebus/messagebus_test.py +++ b/test/integrationtests/messagebus/messagebus_test.py @@ -43,7 +43,7 @@ class TestMessagebusMethods(unittest.TestCase): threaded and will require cleanup """ # start the mycroft service. and get the pid of the script. - self.pid = Popen(["python", "mycroft/messagebus/service/main.py"]).pid + self.pid = Popen(["python3", "-m", "mycroft.messagebus.service"]).pid # Create the two web clients self.ws1 = MessageBusClient() self.ws2 = MessageBusClient() @@ -101,69 +101,6 @@ class TestMessagebusMethods(unittest.TestCase): self.assertTrue(self.handle2) -class TestMessageMethods(unittest.TestCase): - """This tests the Message class functions - """ - def setUp(self): - """This sets up some basic messages for testing. - """ - - self.empty_message = Message("empty") - self.message1 = Message("enclosure.reset") - self.message2 = Message("enclosure.system.blink", - {'target': 4}, {'target': 5}) - self.message3 = Message("status", "OK") - # serialized results of each of the messages - self.serialized = ['{"data": {}, "type": "empty", "context": null}', - '{"data": {}, "type": "enclosure.reset",\ - "context": null}', - '{"data": { "target": 4}, \ - "type": "enclosure.system.blink", \ - "context": {"target": 5}}', - '{"data": "OK", "type": "status", \ - "context": null}'] - - def test_serialize(self): - """This test the serialize method - """ - self.assertEqual(self.empty_message.serialize(), self.serialized[0]) - self.assertEqual(self.message1.serialize(), self.serialized[1]) - self.assertEqual(self.message2.serialize(), self.serialized[2]) - self.assertEqual(self.message3.serialize(), self.serialized[3]) - - def test_deserialize(self): - """This test's the deserialize method - """ - messages = [] - # create the messages from the serialized strings above - messages.append(Message.deserialize(self.serialized[0])) - messages.append(Message.deserialize(self.serialized[1])) - messages.append(Message.deserialize(self.serialized[2])) - # check the created messages match the strings - self.assertEqual(messages[0].serialize(), self.serialized[0]) - self.assertEqual(messages[1].serialize(), self.serialized[1]) - self.assertEqual(messages[2].serialize(), self.serialized[2]) - - def test_reply(self): - """This tests the reply method - This is probably incomplete as the use of the reply message escapes me. - """ - message = self.empty_message.reply("status", "OK") - self.assertEqual(message.serialize(), - '{"data": "OK", "type": "status", "context": {}}') - message = self.message1.reply("status", "OK") - self.assertEqual(message.serialize(), - '{"data": "OK", "type": "status", "context": {}}') - message = self.message2.reply("status", "OK") - - def test_publish(self): - """This is for testing the publish method - - TODO: Needs to be completed - """ - pass - - if __name__ == '__main__': """This is to start the testing""" unittest.main() diff --git a/test/integrationtests/skills/discover_tests.py b/test/integrationtests/skills/discover_tests.py index 32d8479c2b..e6553a04ac 100644 --- a/test/integrationtests/skills/discover_tests.py +++ b/test/integrationtests/skills/discover_tests.py @@ -16,13 +16,14 @@ import pytest import glob import os -from os.path import exists, join, expanduser, abspath -import imp +from os.path import join, expanduser, abspath from mycroft.configuration import Configuration from test.integrationtests.skills.skill_tester import MockSkillsLoader from test.integrationtests.skills.skill_tester import SkillTest +from .runner import load_test_environment + def discover_tests(skills_dir): """ Find all tests for the skills in the default skill path, @@ -43,16 +44,7 @@ def discover_tests(skills_dir): for skill in skills: # Load test environment file - test_env = None - if exists(os.path.join(skill, 'test/__init__.py')): - module = imp.load_source(skill + '.test_env', - os.path.join(skill, 'test/__init__.py')) - if (hasattr(module, 'test_runner') and - callable(module.test_runner) or - hasattr(module, 'test_setup') and - callable(module.test_setup)): - test_env = module - + test_env = load_test_environment(skill) # Find all intent test files test_intent_files = [ (f, test_env) for f diff --git a/test/integrationtests/skills/runner.py b/test/integrationtests/skills/runner.py index 4c7f7849d7..a26f11e052 100644 --- a/test/integrationtests/skills/runner.py +++ b/test/integrationtests/skills/runner.py @@ -27,7 +27,7 @@ import unittest import os from os.path import exists import sys -import imp +import importlib import argparse from test.integrationtests.skills.skill_tester import MockSkillsLoader from test.integrationtests.skills.skill_tester import SkillTest @@ -47,6 +47,31 @@ HOME_DIR = os.path.dirname(args.skill_path + '/') sys.argv = sys.argv[:1] +def load_test_environment(skill): + """Load skill's test environment if present + + Arguments: + skill (str): path to skill root folder + + Returns: + Module if a valid test environment module was found else None + """ + test_env = None + test_env_path = os.path.join(skill, 'test/__init__.py') + if exists(test_env_path): + skill_env = skill + '.test_env' + spec = importlib.util.spec_from_file_location(skill_env, test_env_path) + module = importlib.util.module_from_spec(spec) + sys.modules[skill_env] = module + spec.loader.exec_module(module) + if (hasattr(module, 'test_runner') and + callable(module.test_runner) or + hasattr(module, 'test_setup') and + callable(module.test_setup)): + test_env = module + return test_env + + def discover_tests(): """Find skills with test files @@ -74,15 +99,7 @@ def discover_tests(): tests[skill] = test_intent_files # Load test environment script - test_env = None - if exists(os.path.join(skill, 'test/__init__.py')): - module = imp.load_source(skill + '.test_env', - os.path.join(skill, 'test/__init__.py')) - if (hasattr(module, 'test_runner') and - callable(module.test_runner) or - hasattr(module, 'test_setup') and - callable(module.test_setup)): - test_env = module + test_env = load_test_environment(skill) test_envs[skill] = test_env return tests, test_envs diff --git a/test/integrationtests/skills/skill_tester.py b/test/integrationtests/skills/skill_tester.py index cc44ae56ba..118ad37ea3 100644 --- a/test/integrationtests/skills/skill_tester.py +++ b/test/integrationtests/skills/skill_tester.py @@ -39,7 +39,7 @@ import os import re import ast from os.path import join, isdir, basename -from pyee import EventEmitter +from pyee import BaseEventEmitter from numbers import Number from mycroft.messagebus.message import Message from mycroft.skills.core import MycroftSkill, FallbackSkill @@ -167,7 +167,7 @@ class InterceptEmitter(object): """ def __init__(self): - self.emitter = EventEmitter() + self.emitter = BaseEventEmitter() self.q = None def on(self, event, f): diff --git a/test/integrationtests/voight_kampff/__init__.py b/test/integrationtests/voight_kampff/__init__.py index c77a32ba3d..a70a3330ab 100644 --- a/test/integrationtests/voight_kampff/__init__.py +++ b/test/integrationtests/voight_kampff/__init__.py @@ -14,4 +14,5 @@ # from .tools import (emit_utterance, wait_for_dialog, then_wait, - mycroft_responses, print_mycroft_responses) + then_wait_fail, mycroft_responses, + print_mycroft_responses) diff --git a/test/integrationtests/voight_kampff/features/environment.py b/test/integrationtests/voight_kampff/features/environment.py index fa1d870110..6b1c3c8fcb 100644 --- a/test/integrationtests/voight_kampff/features/environment.py +++ b/test/integrationtests/voight_kampff/features/environment.py @@ -19,6 +19,7 @@ from behave.contrib.scenario_autoretry import patch_scenario_with_autoretry from msm import MycroftSkillsManager from mycroft.audio import wait_while_speaking +from mycroft.configuration import Configuration from mycroft.messagebus.client import MessageBusClient from mycroft.messagebus import Message from mycroft.util import create_daemon @@ -89,9 +90,19 @@ def before_all(context): else: sleep(1) + # Temporary bugfix - First test to run sometimes fails + # Sleeping to see if something isn't finished setting up when tests start + # More info in Jira Ticket MYC-370 + # TODO - remove and fix properly dependant on if failures continue + sleep(10) + context.bus = bus + context.step_timeout = 10 # Reset the step_timeout to 10 seconds context.matched_message = None context.log = log + context.original_config = {} + context.config = Configuration.get() + Configuration.set_config_update_handlers(bus) def before_feature(context, feature): @@ -110,9 +121,27 @@ def after_feature(context, feature): sleep(1) +def reset_config(context): + """Reset configuration with changes stored in original_config of context. + """ + context.log.info('Resetting patched configuration...') + + context.bus.emit(Message('configuration.patch.clear')) + key = list(context.original_config)[0] + while context.config[key] != context.original_config[key]: + sleep(0.5) + context.original_config = {} + + def after_scenario(context, scenario): + """Wait for mycroft completion and reset any changed state.""" # TODO wait for skill handler complete sleep(0.5) wait_while_speaking() context.bus.clear_messages() context.matched_message = None + context.step_timeout = 10 # Reset the step_timeout to 10 seconds + + if context.original_config: + # something has changed, reset changes by done in the context + reset_config(context) diff --git a/test/integrationtests/voight_kampff/features/steps/configuration.py b/test/integrationtests/voight_kampff/features/steps/configuration.py new file mode 100644 index 0000000000..89f224d084 --- /dev/null +++ b/test/integrationtests/voight_kampff/features/steps/configuration.py @@ -0,0 +1,105 @@ +# Copyright 2020 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. +# +import json +from os.path import join, exists +import time + +from behave import given +from mycroft.messagebus import Message +from mycroft.util import resolve_resource_file + + +def patch_config(context, patch): + """Apply patch to config and wait for it to take effect. + + Arguments: + context: Behave context for test + patch: patch to apply + """ + # store originals in context + for key in patch: + # If this patch is redefining an already changed key don't update + if key not in context.original_config: + context.original_config[key] = context.config.get(key) + + # Patch config + patch_config_msg = Message('configuration.patch', {'config': patch}) + context.bus.emit(patch_config_msg) + + # Wait until one of the keys has been updated + key = list(patch.keys())[0] + while context.config.get(key) != patch[key]: + time.sleep(0.5) + + +def get_config_file_definition(configs_path, config, value): + """Read config definition file and return the matching patch dict. + + Arguments: + configs_path: path to the configuration patch json file + config: config value to fetch from the file + value: predefined value to fetch + + Returns: + Patch dictionary or None. + """ + with open(configs_path) as f: + configs = json.load(f) + return configs.get(config, {}).get(value) + + +def get_global_config_definition(context, config, value): + """Get config definitions included with Mycroft. + + Arguments: + context: behave test context + config: config value to fetch from the file + value: predefined value to fetch + + Returns: + Patch dictionary or None. + """ + configs_path = resolve_resource_file(join('text', context.lang, + 'configurations.json')) + return get_config_file_definition(configs_path, config, value) + + +def get_feature_config_definition(context, config, value): + """Get config feature specific config defintion + + Arguments: + context: behave test context + config: config value to fetch from the file + value: predefined value to fetch + + Returns: + Patch dictionary or None. + """ + feature_config = context.feature.filename.replace('.feature', + '.config.json') + if exists(feature_config): + return get_config_file_definition(feature_config, config, value) + else: + return None + + +@given('the user\'s {config} is {value}') +def given_config(context, config, value): + """Patch the configuration with a specific config.""" + config = config.strip('"') + value = value.strip('"') + patch_dict = (get_feature_config_definition(context, config, value) or + get_global_config_definition(context, config, value)) + patch_config(context, patch_dict) diff --git a/test/integrationtests/voight_kampff/features/steps/utterance_responses.py b/test/integrationtests/voight_kampff/features/steps/utterance_responses.py index 4bd36056bc..cb703189d0 100644 --- a/test/integrationtests/voight_kampff/features/steps/utterance_responses.py +++ b/test/integrationtests/voight_kampff/features/steps/utterance_responses.py @@ -1,4 +1,4 @@ -# Copyright 2017 Mycroft AI Inc. +# Copyright 2020 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. @@ -26,7 +26,8 @@ from behave import given, when, then from mycroft.messagebus import Message from mycroft.audio import wait_while_speaking -from test.integrationtests.voight_kampff import mycroft_responses, then_wait +from test.integrationtests.voight_kampff import (mycroft_responses, then_wait, + then_wait_fail) TIMEOUT = 10 @@ -122,6 +123,20 @@ def given_english(context): context.lang = 'en-us' +@given('a {timeout} seconds timeout') +@given('a {timeout} second timeout') +def given_timeout(context, timeout): + """Set the timeout for the steps in this scenario.""" + context.step_timeout = float(timeout) + + +@given('a {timeout} minutes timeout') +@given('a {timeout} minute timeout') +def given_timeout(context, timeout): + """Set the timeout for the steps in this scenario.""" + context.step_timeout = float(timeout) * 60 + + @when('the user says "{text}"') def when_user_says(context, text): context.bus.emit(Message('recognizer_loop:utterance', @@ -146,6 +161,24 @@ def then_dialog(context, skill, dialog): assert passed, assert_msg or 'Mycroft didn\'t respond' +@then('"{skill}" should not reply') +def then_do_not_reply(context, skill): + + def check_all_dialog(message): + msg_skill = message.data.get('meta').get('skill') + utt = message.data['utterance'].lower() + skill_responded = skill == msg_skill + debug_msg = ("{} responded with '{}'. \n".format(skill, utt) + if skill_responded else '') + return (skill_responded, debug_msg) + + passed, debug = then_wait_fail('speak', check_all_dialog, context) + if not passed: + assert_msg = debug + assert_msg += mycroft_responses(context) + assert passed, assert_msg or '{} responded'.format(skill) + + @then('"{skill}" should reply with "{example}"') def then_example(context, skill, example): skill_path = context.msm.find_skill(skill).path @@ -214,6 +247,7 @@ def then_user_follow_up(context, text): @then('mycroft should send the message "{message_type}"') def then_messagebus_message(context, message_type): + """Set a timeout for the current Scenario.""" cnt = 0 while context.bus.get_messages(message_type) == []: if cnt > int(TIMEOUT * (1.0 / SLEEP_LENGTH)): diff --git a/test/integrationtests/voight_kampff/test_setup.py b/test/integrationtests/voight_kampff/test_setup.py index a3754169c5..a2b6f522de 100644 --- a/test/integrationtests/voight_kampff/test_setup.py +++ b/test/integrationtests/voight_kampff/test_setup.py @@ -32,12 +32,19 @@ The script sets up the selected tests in the feature directory so they can be found and executed by the behave framework. The script also ensures that the skills marked for testing are installed and -that anyi specified extra skills also gets installed into the environment. +that any specified extra skills also gets installed into the environment. """ FEATURE_DIR = join(dirname(__file__), 'features') + '/' +def copy_config_definition_files(source, destination): + """Copy all feature files from source to destination.""" + # Copy feature files to the feature directory + for f in glob(join(source, '*.config.json')): + shutil.copyfile(f, join(destination, basename(f))) + + def copy_feature_files(source, destination): """Copy all feature files from source to destination.""" # Copy feature files to the feature directory @@ -141,6 +148,7 @@ def collect_test_cases(msm, skills): behave_dir = join(skill.path, 'test', 'behave') if exists(behave_dir): copy_feature_files(behave_dir, FEATURE_DIR) + copy_config_definition_files(behave_dir, FEATURE_DIR) step_dir = join(behave_dir, 'steps') if exists(step_dir): diff --git a/test/integrationtests/voight_kampff/tools.py b/test/integrationtests/voight_kampff/tools.py index 7644e5a6ee..7941209b83 100644 --- a/test/integrationtests/voight_kampff/tools.py +++ b/test/integrationtests/voight_kampff/tools.py @@ -23,7 +23,7 @@ from mycroft.messagebus import Message TIMEOUT = 10 -def then_wait(msg_type, criteria_func, context, timeout=TIMEOUT): +def then_wait(msg_type, criteria_func, context, timeout=None): """Wait for a specified time for criteria to be fulfilled. Arguments: @@ -31,11 +31,13 @@ def then_wait(msg_type, criteria_func, context, timeout=TIMEOUT): criteria_func: Function to determine if a message fulfilling the test case has been found. context: behave context - timeout: Time allowance for a message fulfilling the criteria + timeout: Time allowance for a message fulfilling the criteria, if + provided will override the normal normal step timeout. Returns: tuple (bool, str) test status and debug output """ + timeout = timeout or context.step_timeout start_time = time.monotonic() debug = '' while time.monotonic() < start_time + timeout: @@ -51,6 +53,23 @@ def then_wait(msg_type, criteria_func, context, timeout=TIMEOUT): return False, debug +def then_wait_fail(msg_type, criteria_func, context, timeout=None): + """Wait for a specified time, failing if criteria is fulfilled. + + Arguments: + msg_type: message type to watch + criteria_func: Function to determine if a message fulfilling the + test case has been found. + context: behave context + timeout: Time allowance for a message fulfilling the criteria + + Returns: + tuple (bool, str) test status and debug output + """ + status, debug = then_wait(msg_type, criteria_func, context, timeout) + return (not status, debug) + + def mycroft_responses(context): """Collect and format mycroft responses from context. @@ -91,14 +110,20 @@ def emit_utterance(bus, utt): context={'client_name': 'mycroft_listener'})) -def wait_for_dialog(bus, dialogs, timeout=TIMEOUT): +def wait_for_dialog(bus, dialogs, context=None, timeout=None): """Wait for one of the dialogs given as argument. Arguments: bus (InterceptAllBusClient): Bus instance to listen on dialogs (list): list of acceptable dialogs - timeout (int): how long to wait for the messagem, defaults to 10 sec. + context (behave Context): optional context providing scenario timeout + timeout (int): how long to wait for the message, defaults to timeout + provided by context or 10 seconds """ + if context: + timeout = timeout or context.step_timeout + else: + timeout = timeout or TIMEOUT start_time = time.monotonic() while time.monotonic() < start_time + timeout: for message in bus.get_messages('speak'): diff --git a/test/unittests/client/test_data_structures.py b/test/unittests/client/test_data_structures.py new file mode 100644 index 0000000000..d60d7dd2a3 --- /dev/null +++ b/test/unittests/client/test_data_structures.py @@ -0,0 +1,77 @@ +# Copyright 2020 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. +# +from unittest import TestCase + +from mycroft.client.speech.data_structures import (RollingMean, + CyclicAudioBuffer) + + +class TestRollingMean(TestCase): + def test_before_rolling(self): + mean = RollingMean(10) + for i in range(5): + mean.append_sample(i) + + self.assertEqual(mean.value, 2) + for i in range(5): + mean.append_sample(i) + self.assertEqual(mean.value, 2) + + def test_during_rolling(self): + mean = RollingMean(10) + for _ in range(10): + mean.append_sample(5) + self.assertEqual(mean.value, 5) + + for _ in range(5): + mean.append_sample(1) + # Values should now be 5, 5, 5, 5, 5, 1, 1, 1, 1, 1 + self.assertAlmostEqual(mean.value, 3) + + for _ in range(5): + mean.append_sample(2) + # Values should now be 1, 1, 1, 1, 1, 2, 2, 2, 2, 2 + self.assertAlmostEqual(mean.value, 1.5) + + +class TestCyclicBuffer(TestCase): + def test_init(self): + buff = CyclicAudioBuffer(16, b'abc') + self.assertEqual(buff.get(), b'abc') + self.assertEqual(len(buff), 3) + + def test_init_larger_inital_data(self): + size = 16 + buff = CyclicAudioBuffer(size, b'a' * (size + 3)) + self.assertEqual(buff.get(), b'a' * size) + + def test_append_with_room_left(self): + buff = CyclicAudioBuffer(16, b'abc') + buff.append(b'def') + self.assertEqual(buff.get(), b'abcdef') + + def test_append_with_full(self): + buff = CyclicAudioBuffer(3, b'abc') + buff.append(b'de') + self.assertEqual(buff.get(), b'cde') + self.assertEqual(len(buff), 3) + + def test_get_last(self): + buff = CyclicAudioBuffer(3, b'abcdef') + self.assertEqual(buff.get_last(3), b'def') + + def test_get_item(self): + buff = CyclicAudioBuffer(6, b'abcdef') + self.assertEqual(buff[:], b'abcdef') diff --git a/test/unittests/client/test_dynamic_energy_test.py b/test/unittests/client/test_dynamic_energy_test.py index 0113e556bf..6bc1ddc829 100644 --- a/test/unittests/client/test_dynamic_energy_test.py +++ b/test/unittests/client/test_dynamic_energy_test.py @@ -13,14 +13,14 @@ # limitations under the License. # import audioop -import unittest +from unittest import TestCase, mock from speech_recognition import AudioSource from mycroft.client.speech.mic import ResponsiveRecognizer -class MockStream(object): +class MockStream: def __init__(self): self.chunks = [] @@ -48,14 +48,16 @@ class MockSource(AudioSource): self.SAMPLE_WIDTH = 2 -class DynamicEnergytest(unittest.TestCase): - def setUp(self): - pass +class MockHotwordEngine(mock.Mock): + def __init__(self, *arg, **kwarg): + super().__init__(*arg, **kwarg) + self.num_phonemes = 10 - @unittest.skip('Disabled while unittests are brought upto date') + +class DynamicEnergytest(TestCase): def testMaxAudioWithBaselineShift(self): - low_base = b"".join(["\x10\x00\x01\x00"] * 100) - higher_base = b"".join(["\x01\x00\x00\x01"] * 100) + low_base = b"\x10\x00\x01\x00" * 100 + higher_base = b"\x01\x00\x00\x01" * 100 source = MockSource() @@ -63,7 +65,7 @@ class DynamicEnergytest(unittest.TestCase): source.stream.inject(low_base) source.stream.inject(higher_base) - recognizer = ResponsiveRecognizer(None) + recognizer = ResponsiveRecognizer(MockHotwordEngine()) sec_per_buffer = float(source.CHUNK) / (source.SAMPLE_RATE * source.SAMPLE_WIDTH) @@ -73,7 +75,7 @@ class DynamicEnergytest(unittest.TestCase): test_seconds -= sec_per_buffer data = source.stream.read(source.CHUNK) energy = recognizer.calc_energy(data, source.SAMPLE_WIDTH) - recognizer.adjust_threshold(energy, sec_per_buffer) + recognizer._adjust_threshold(energy, sec_per_buffer) higher_base_energy = audioop.rms(higher_base, source.SAMPLE_WIDTH) # after recalibration (because of max audio length) new threshold diff --git a/test/unittests/client/test_local_recognizer.py b/test/unittests/client/test_local_recognizer.py index 92f21c230e..5dbae7c670 100644 --- a/test/unittests/client/test_local_recognizer.py +++ b/test/unittests/client/test_local_recognizer.py @@ -48,6 +48,27 @@ class PocketSphinxRecognizerTest(unittest.TestCase): with source as audio: assert self.recognizer.found_wake_word(audio.stream.read()) + @patch.object(Configuration, 'get') + def testRecognitionFallback(self, mock_config_get): + """If language config doesn't exist set default (english)""" + conf = base_config() + conf['hotwords']['hey mycroft'] = { + 'lang': 'DOES NOT EXIST', + 'module': 'pocketsphinx', + 'phonemes': 'HH EY . M AY K R AO F T', + 'threshold': 1e-90 + } + conf['lang'] = 'DOES NOT EXIST' + mock_config_get.return_value = conf + + rl = RecognizerLoop() + ps_hotword = RecognizerLoop.create_wake_word_recognizer(rl) + + expected = 'en-us' + res = ps_hotword.decoder.get_config().get_string('-hmm') + self.assertEqual(expected, res.split('/')[-2]) + self.assertEqual('does not exist', ps_hotword.lang) + class LocalRecognizerInitTest(unittest.TestCase): @patch.object(Configuration, 'get') diff --git a/test/unittests/client/test_noise_tracker.py b/test/unittests/client/test_noise_tracker.py new file mode 100644 index 0000000000..72421a2914 --- /dev/null +++ b/test/unittests/client/test_noise_tracker.py @@ -0,0 +1,97 @@ +# Copyright 2020 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. +# + +from unittest import TestCase + +from mycroft.client.speech.mic import NoiseTracker + + +LOUD_TIME_LIMIT = 2.0 # Must be loud for 2 seconds +SILENCE_TIME_LIMIT = 5.0 # Time out after 5 seconds of silence +SECS_PER_BUFFER = 0.5 + +MIN_NOISE = 0 +MAX_NOISE = 25 + + +class TestNoiseTracker(TestCase): + def test_no_loud_data(self): + """Check that no loud data generates complete after silence timeout.""" + noise_tracker = NoiseTracker(MIN_NOISE, MAX_NOISE, SECS_PER_BUFFER, + LOUD_TIME_LIMIT, SILENCE_TIME_LIMIT) + + num_updates_timeout = int(SILENCE_TIME_LIMIT / SECS_PER_BUFFER) + num_low_updates = int(LOUD_TIME_LIMIT / SECS_PER_BUFFER) + for _ in range(num_low_updates): + noise_tracker.update(False) + self.assertFalse(noise_tracker.recording_complete()) + remaining_until_low_timeout = num_updates_timeout - num_low_updates + + for _ in range(remaining_until_low_timeout): + noise_tracker.update(False) + self.assertFalse(noise_tracker.recording_complete()) + + noise_tracker.update(False) + self.assertTrue(noise_tracker.recording_complete()) + + def test_silence_reset(self): + """Check that no loud data generates complete after silence timeout.""" + noise_tracker = NoiseTracker(MIN_NOISE, MAX_NOISE, SECS_PER_BUFFER, + LOUD_TIME_LIMIT, SILENCE_TIME_LIMIT) + + num_updates_timeout = int(SILENCE_TIME_LIMIT / SECS_PER_BUFFER) + num_low_updates = int(LOUD_TIME_LIMIT / SECS_PER_BUFFER) + for _ in range(num_low_updates): + noise_tracker.update(False) + + # Insert a is_loud=True shall reset the silence tracker + noise_tracker.update(True) + + remaining_until_low_timeout = num_updates_timeout - num_low_updates + + # Extra is needed for the noise to be reduced down to quiet level + for _ in range(remaining_until_low_timeout + 1): + noise_tracker.update(False) + self.assertFalse(noise_tracker.recording_complete()) + + # Adding low noise samples to complete the timeout + for _ in range(num_low_updates + 1): + noise_tracker.update(False) + self.assertTrue(noise_tracker.recording_complete()) + + def test_all_loud_data(self): + """Check that only loud samples doesn't generate a complete recording. + """ + noise_tracker = NoiseTracker(MIN_NOISE, MAX_NOISE, SECS_PER_BUFFER, + LOUD_TIME_LIMIT, SILENCE_TIME_LIMIT) + + num_high_updates = int(LOUD_TIME_LIMIT / SECS_PER_BUFFER) + 1 + for _ in range(num_high_updates): + noise_tracker.update(True) + self.assertFalse(noise_tracker.recording_complete()) + + def test_all_loud_followed_by_silence(self): + """Check that a long enough high sentence is completed after silence. + """ + noise_tracker = NoiseTracker(MIN_NOISE, MAX_NOISE, SECS_PER_BUFFER, + LOUD_TIME_LIMIT, SILENCE_TIME_LIMIT) + + num_high_updates = int(LOUD_TIME_LIMIT / SECS_PER_BUFFER) + 1 + for _ in range(num_high_updates): + noise_tracker.update(True) + self.assertFalse(noise_tracker.recording_complete()) + while not noise_tracker._quiet_enough(): + noise_tracker.update(False) + self.assertTrue(noise_tracker.recording_complete()) diff --git a/test/unittests/dialog/test_dialog.py b/test/unittests/dialog/test_dialog.py index 3a1aa98cf3..0c3b11447e 100644 --- a/test/unittests/dialog/test_dialog.py +++ b/test/unittests/dialog/test_dialog.py @@ -17,7 +17,7 @@ import unittest import pathlib import json -from mycroft.dialog import MustacheDialogRenderer, DialogLoader, get +from mycroft.dialog import MustacheDialogRenderer, load_dialogs, get from mycroft.util import resolve_resource_file @@ -90,15 +90,13 @@ class DialogTest(unittest.TestCase): def test_dialog_loader(self): template_path = self.topdir.joinpath('./multiple_dialogs') - loader = DialogLoader() - renderer = loader.load(template_path) + renderer = load_dialogs(template_path) self.assertEqual(renderer.render('one'), 'ONE') self.assertEqual(renderer.render('two'), 'TWO') def test_dialog_loader_missing(self): template_path = self.topdir.joinpath('./missing_dialogs') - loader = DialogLoader() - renderer = loader.load(template_path) + renderer = load_dialogs(template_path) self.assertEqual(renderer.render('test'), 'test') def test_get(self): diff --git a/test/unittests/enclosure/__init__.py b/test/unittests/enclosure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/unittests/enclosure/test_gui.py b/test/unittests/enclosure/test_gui.py new file mode 100644 index 0000000000..3f0a591e80 --- /dev/null +++ b/test/unittests/enclosure/test_gui.py @@ -0,0 +1,152 @@ +# Copyright 2020 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. +# +"""Tests for the Enclosure GUI interface.""" + +from unittest import TestCase, mock + +from mycroft.enclosure.gui import SkillGUI +from mycroft.messagebus import Message +from mycroft.util.file_utils import resolve_resource_file + + +class TestSkillGUI(TestCase): + def setUp(self): + self.mock_skill = mock.Mock(name='Skill') + self.mock_skill.skill_id = 'fortytwo-skill' + + def find_resource(page, folder): + return '/test/{}/{}'.format(folder, page) + self.mock_skill.find_resource = find_resource + self.gui = SkillGUI(self.mock_skill) + + def test_show_page(self): + self.gui.show_page('meaning.qml') + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + self.assertEqual(sent_message.msg_type, 'gui.page.show') + self.assertEqual(sent_message.data['__from'], 'fortytwo-skill') + self.assertEqual(sent_message.data['page'], + ['file:///test/ui/meaning.qml']) + self.assertEqual(sent_message.data['__idle'], None) + + def test_show_page_idle_override(self): + self.gui.show_page('meaning.qml', override_idle=60) + + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + self.assertEqual(sent_message.data['__idle'], 60) + + def test_show_pages(self): + self.gui.show_pages(['meaning.qml', 'life.qml', + 'universe.qml', 'everything.qml']) + + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + + expected_pages = ['file:///test/ui/meaning.qml', + 'file:///test/ui/life.qml', + 'file:///test/ui/universe.qml', + 'file:///test/ui/everything.qml'] + self.assertEqual(sent_message.data['page'], expected_pages) + + def test_remove_page(self): + self.gui.remove_page('vogon_poetry.qml') + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + + self.assertEqual(sent_message.msg_type, 'gui.page.delete') + self.assertEqual(sent_message.data['__from'], 'fortytwo-skill') + expected_page = 'file:///test/ui/vogon_poetry.qml' + self.assertEqual(sent_message.data['page'], [expected_page]) + + def test_show_image(self): + self.gui.show_image('arthur_dent.jpg') + + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + page_path = resolve_resource_file('ui/SYSTEM_ImageFrame.qml') + page_url = 'file://{}'.format(page_path) + self.assertEqual(sent_message.data['page'], [page_url]) + self.assertEqual(self.gui['image'], 'arthur_dent.jpg') + + def test_show_animated_image(self): + self.gui.show_animated_image('dancing_zaphod.gif') + + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + page_path = resolve_resource_file('ui/SYSTEM_AnimatedImageFrame.qml') + page_url = 'file://{}'.format(page_path) + self.assertEqual(sent_message.data['page'], [page_url]) + self.assertEqual(self.gui['image'], 'dancing_zaphod.gif') + + def test_show_url(self): + page = ('https://en.wikipedia.org/wiki/' + 'The_Hitchhiker%27s_Guide_to_the_Galaxy') + self.gui.show_url(page) + + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + page_path = resolve_resource_file('ui/SYSTEM_UrlFrame.qml') + page_url = 'file://{}'.format(page_path) + self.assertEqual(sent_message.data['page'], [page_url]) + self.assertEqual(self.gui['url'], page) + + def test_show_html(self): + html = 'This Page!' + self.gui.show_html(html) + + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + page_path = resolve_resource_file('ui/SYSTEM_HtmlFrame.qml') + page_url = 'file://{}'.format(page_path) + self.assertEqual(sent_message.data['page'], [page_url]) + self.assertEqual(self.gui['html'], html) + + def test_send_event(self): + """Check that send_event sends message using the correct format.""" + params = 'Not again' + self.gui.send_event('not.again', params) + sent_message = self.mock_skill.bus.emit.call_args_list[-1][0][0] + self.assertEqual(sent_message.msg_type, 'gui.event.send') + self.assertEqual(sent_message.data['__from'], 'fortytwo-skill') + self.assertEqual(sent_message.data['params'], params) + + def test_on_gui_change_callback(self): + """Check that the registered function gets called on message from gui. + """ + result = False + + def callback(): + nonlocal result + result = True + + self.gui.set_on_gui_changed(callback) + self.gui.gui_set(Message('dummy')) + self.assertTrue(result) + + def test_gui_set(self): + """Assert that the gui can set gui variables.""" + vars_from_gui = {'meaning': 43, 'no': 42} + self.gui.gui_set(Message('dummy', data=vars_from_gui)) + self.assertEqual(self.gui['meaning'], 43) + self.assertEqual(self.gui['no'], 42) + + def test_not_connected(self): + response = Message('dummy', data={'connected': False}) + self.mock_skill.bus.wait_for_response.return_value = response + self.assertFalse(self.gui.connected) + + def test_connected(self): + response = Message('dummy', data={'connected': True}) + self.mock_skill.bus.wait_for_response.return_value = response + self.assertTrue(self.gui.connected) + + def test_connected_no_response(self): + """Ensure that a timeout response results in not connected.""" + response = None + self.mock_skill.bus.wait_for_response.return_value = response + self.assertFalse(self.gui.connected) diff --git a/test/unittests/messagebus/client/test_client.py b/test/unittests/messagebus/client/test_client.py index fd3e62d42f..4f0896df32 100644 --- a/test/unittests/messagebus/client/test_client.py +++ b/test/unittests/messagebus/client/test_client.py @@ -12,9 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from unittest.mock import patch +from unittest import TestCase +from unittest.mock import patch, Mock -from mycroft.messagebus.client import MessageBusClient +from mycroft.messagebus.client import MessageBusClient, MessageWaiter WS_CONF = { 'websocket': { @@ -37,3 +38,22 @@ class TestMessageBusClient: def test_create_client(self, mock_conf): mc = MessageBusClient() assert mc.client.url == 'ws://testhost:1337/core' + + +class TestMessageWaiter(TestCase): + def test_message_wait_success(self): + bus = Mock() + waiter = MessageWaiter(bus, 'delayed.message') + bus.once.assert_called_with('delayed.message', waiter._handler) + + test_msg = Mock(name='test_msg') + waiter._handler(test_msg) # Inject response + + self.assertEqual(waiter.wait(), test_msg) + + def test_message_wait_timeout(self): + bus = Mock() + waiter = MessageWaiter(bus, 'delayed.message') + bus.once.assert_called_with('delayed.message', waiter._handler) + + self.assertEqual(waiter.wait(0.3), None) diff --git a/test/unittests/messagebus/client/test_threaded_event_emitter.py b/test/unittests/messagebus/client/test_threaded_event_emitter.py deleted file mode 100644 index 5a711d32c4..0000000000 --- a/test/unittests/messagebus/client/test_threaded_event_emitter.py +++ /dev/null @@ -1,58 +0,0 @@ -# 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. -# -from time import sleep - -from mycroft import Message -from mycroft.messagebus.client.threaded_event_emitter import \ - ThreadedEventEmitter - - -class TestThreadedEventEmitter: - def setup(self): - self.emitter = ThreadedEventEmitter() - self.count = 0 - self.msg = Message('testing') - - def example_event(self, message): - self.count += 1 - - def test_on(self): - self.emitter.on(self.msg.msg_type, self.example_event) - self.emitter.emit(self.msg.msg_type, self.msg) - self.emitter.emit(self.msg.msg_type, self.msg) - sleep(0.1) - assert self.count == 2 - - def test_once(self): - self.emitter.once(self.msg.msg_type, self.example_event) - self.emitter.emit(self.msg.msg_type, self.msg) - self.emitter.emit(self.msg.msg_type, self.msg) - sleep(0.1) - assert self.count == 1 - - def test_remove_listener_on(self): - self.emitter.on(self.msg.msg_type, self.example_event) - self.emitter.remove_listener(self.msg.msg_type, self.example_event) - self.emitter.emit(self.msg.msg_type) - sleep(0.1) - assert self.count == 0 - - def test_remove_all_listeners(self): - self.emitter.on(self.msg.msg_type, self.example_event) - self.emitter.once(self.msg.msg_type, self.example_event) - self.emitter.remove_all_listeners(self.msg.msg_type) - self.emitter.emit(self.msg.msg_type) - sleep(0.1) - assert self.count == 0 diff --git a/test/unittests/skills/test_context.py b/test/unittests/skills/test_context.py new file mode 100644 index 0000000000..6e02180c38 --- /dev/null +++ b/test/unittests/skills/test_context.py @@ -0,0 +1,42 @@ +from unittest import TestCase, mock + +from mycroft.skills.context import adds_context, removes_context +""" +Tests for the adapt context decorators. +""" + + +class ContextSkillMock(mock.Mock): + """Mock class to apply decorators on.""" + @adds_context('DestroyContext') + def handler_adding_context(self): + pass + + @adds_context('DestroyContext', 'exterminate') + def handler_adding_context_with_words(self): + pass + + @removes_context('DestroyContext') + def handler_removing_context(self): + pass + + +class TestContextDecorators(TestCase): + def test_adding_context(self): + """Check that calling handler adds the correct Keyword.""" + skill = ContextSkillMock() + skill.handler_adding_context() + skill.set_context.assert_called_once_with('DestroyContext', '') + + def test_adding_context_with_words(self): + """Ensure that decorated handler adds Keyword and content.""" + skill = ContextSkillMock() + skill.handler_adding_context_with_words() + skill.set_context.assert_called_once_with('DestroyContext', + 'exterminate') + + def test_removing_context(self): + """Make sure the decorated handler removes the specified context.""" + skill = ContextSkillMock() + skill.handler_removing_context() + skill.remove_context.assert_called_once_with('DestroyContext') diff --git a/test/unittests/skills/test_event_scheduler.py b/test/unittests/skills/test_event_scheduler.py index 92fc867c28..5e624d1021 100644 --- a/test/unittests/skills/test_event_scheduler.py +++ b/test/unittests/skills/test_event_scheduler.py @@ -4,10 +4,9 @@ import unittest import time +from pyee import ExecutorEventEmitter from unittest.mock import MagicMock, patch -from mycroft.messagebus.client.threaded_event_emitter import ( - ThreadedEventEmitter) from mycroft.skills.event_scheduler import (EventScheduler, EventSchedulerInterface) @@ -108,7 +107,7 @@ class TestEventSchedulerInterface(unittest.TestCase): def f(message): print('TEST FUNC') - bus = ThreadedEventEmitter() + bus = ExecutorEventEmitter() es = EventSchedulerInterface('tester') es.set_bus(bus) @@ -116,9 +115,9 @@ class TestEventSchedulerInterface(unittest.TestCase): # Schedule a repeating event es.schedule_repeating_event(f, None, 10, name='f') - es.shutdown() + self.assertTrue(len(bus._events['id:f']) == 1) + es.shutdown() # Check that the reference to the function has been removed from the # bus emitter - self.assertTrue(len(bus.wrappers) == 0) self.assertTrue(len(bus._events['id:f']) == 0) diff --git a/test/unittests/skills/test_fallback_skill.py b/test/unittests/skills/test_fallback_skill.py new file mode 100644 index 0000000000..d13f136261 --- /dev/null +++ b/test/unittests/skills/test_fallback_skill.py @@ -0,0 +1,53 @@ +from unittest import TestCase, mock + +from mycroft.skills import FallbackSkill + + +def setup_fallback(fb_class): + fb_skill = fb_class() + fb_skill.bind(mock.Mock(name='bus')) + fb_skill.initialize() + return fb_skill + + +class TestFallbackSkill(TestCase): + def test_life_cycle(self): + """Test startup and shutdown of a fallback skill. + + Ensure that an added handler is removed as part of default shutdown. + """ + self.assertEqual(len(FallbackSkill.fallback_handlers), 0) + fb_skill = setup_fallback(SimpleFallback) + self.assertEqual(len(FallbackSkill.fallback_handlers), 1) + self.assertEqual(FallbackSkill.wrapper_map[0][0], + fb_skill.fallback_handler) + self.assertEqual(len(FallbackSkill.wrapper_map), 1) + + fb_skill.default_shutdown() + self.assertEqual(len(FallbackSkill.fallback_handlers), 0) + self.assertEqual(len(FallbackSkill.wrapper_map), 0) + + def test_manual_removal(self): + """Test that the call to remove_fallback() removes the handler""" + self.assertEqual(len(FallbackSkill.fallback_handlers), 0) + + # Create skill adding a single handler + fb_skill = setup_fallback(SimpleFallback) + self.assertEqual(len(FallbackSkill.fallback_handlers), 1) + + self.assertTrue(fb_skill.remove_fallback(fb_skill.fallback_handler)) + # Both internal trackers of handlers should be cleared now + self.assertEqual(len(FallbackSkill.fallback_handlers), 0) + self.assertEqual(len(FallbackSkill.wrapper_map), 0) + + # Removing after it's already been removed should fail + self.assertFalse(fb_skill.remove_fallback(fb_skill.fallback_handler)) + + +class SimpleFallback(FallbackSkill): + """Simple fallback skill used for test.""" + def initialize(self): + self.register_fallback(self.fallback_handler, 42) + + def fallback_handler(self): + pass diff --git a/test/unittests/skills/test_intent_service.py b/test/unittests/skills/test_intent_service.py index 2272a5bad0..02b8c6c053 100644 --- a/test/unittests/skills/test_intent_service.py +++ b/test/unittests/skills/test_intent_service.py @@ -14,8 +14,21 @@ # from unittest import TestCase, mock +from adapt.intent import IntentBuilder + +from mycroft.configuration import Configuration from mycroft.messagebus import Message -from mycroft.skills.intent_service import ContextManager, IntentService +from mycroft.skills.intent_service import (ContextManager, IntentService, + _get_message_lang) + +from test.util import base_config + +# Setup configurations to use with default language tests +BASE_CONF = base_config() +BASE_CONF['lang'] = 'it-it' + +NO_LANG_CONF = base_config() +NO_LANG_CONF.pop('lang') class MockEmitter(object): @@ -103,7 +116,7 @@ class ConversationTest(TestCase): data={'lang': 'en-US', 'utterances': hello}) result = self.intent_service._converse(hello, 'en-US', utterance_msg) - + self.intent_service.add_active_skill(result.skill_id) # Check that the active skill list was updated to set the responding # Skill first. first_active_skill = self.intent_service.active_skills[0][0] @@ -176,3 +189,141 @@ class ConversationTest(TestCase): self.assertTrue(check_converse_request(atari_message, 'atari_skill')) first_active_skill = self.intent_service.active_skills[0][0] self.assertEqual(first_active_skill, 'atari_skill') + + +class TestLanguageExtraction(TestCase): + @mock.patch.dict(Configuration._Configuration__config, BASE_CONF) + def test_no_lang_in_message(self): + """No lang in message should result in lang from config.""" + msg = Message('test msg', data={}) + self.assertEqual(_get_message_lang(msg), 'it-it') + + @mock.patch.dict(Configuration._Configuration__config, NO_LANG_CONF) + def test_no_lang_at_all(self): + """Not in message and not in config, should result in en-us.""" + msg = Message('test msg', data={}) + self.assertEqual(_get_message_lang(msg), 'en-us') + + @mock.patch.dict(Configuration._Configuration__config, BASE_CONF) + def test_lang_exists(self): + """Message has a lang code in data, it should be used.""" + msg = Message('test msg', data={'lang': 'de-de'}) + self.assertEqual(_get_message_lang(msg), 'de-de') + msg = Message('test msg', data={'lang': 'sv-se'}) + self.assertEqual(_get_message_lang(msg), 'sv-se') + + +def create_vocab_msg(keyword, value): + """Create a message for registering an adapt keyword.""" + return Message('register_vocab', + {'start': value, 'end': keyword}) + + +def get_last_message(bus): + """Get last sent message on mock bus.""" + last = bus.emit.call_args + return last[0][0] + + +class TestIntentServiceApi(TestCase): + def setUp(self): + self.intent_service = IntentService(mock.Mock()) + + def setup_simple_adapt_intent(self): + msg = create_vocab_msg('testKeyword', 'test') + self.intent_service.handle_register_vocab(msg) + + intent = IntentBuilder('skill:testIntent').require('testKeyword') + msg = Message('register_intent', intent.__dict__) + self.intent_service.handle_register_intent(msg) + + def test_get_adapt_intent(self): + self.setup_simple_adapt_intent() + # Check that the intent is returned + msg = Message('intent.service.adapt.get', data={'utterance': 'test'}) + self.intent_service.handle_get_adapt(msg) + + reply = get_last_message(self.intent_service.bus) + self.assertEqual(reply.data['intent']['intent_type'], + 'skill:testIntent') + + def test_get_adapt_intent_no_match(self): + """Check that if the intent doesn't match at all None is returned.""" + self.setup_simple_adapt_intent() + # Check that no intent is matched + msg = Message('intent.service.adapt.get', data={'utterance': 'five'}) + self.intent_service.handle_get_adapt(msg) + reply = get_last_message(self.intent_service.bus) + self.assertEqual(reply.data['intent'], None) + + def test_get_intent(self): + """Check that the registered adapt intent is triggered.""" + self.setup_simple_adapt_intent() + # Check that the intent is returned + msg = Message('intent.service.adapt.get', data={'utterance': 'test'}) + self.intent_service.handle_get_intent(msg) + + reply = get_last_message(self.intent_service.bus) + self.assertEqual(reply.data['intent']['intent_type'], + 'skill:testIntent') + + def test_get_intent_no_match(self): + """Check that if the intent doesn't match at all None is returned.""" + self.setup_simple_adapt_intent() + # Check that no intent is matched + msg = Message('intent.service.intent.get', data={'utterance': 'five'}) + self.intent_service.handle_get_intent(msg) + reply = get_last_message(self.intent_service.bus) + self.assertEqual(reply.data['intent'], None) + + def test_get_intent_manifest(self): + """Check that if the intent doesn't match at all None is returned.""" + self.setup_simple_adapt_intent() + # Check that no intent is matched + msg = Message('intent.service.intent.get', data={'utterance': 'five'}) + self.intent_service.handle_get_intent(msg) + reply = get_last_message(self.intent_service.bus) + self.assertEqual(reply.data['intent'], None) + + def test_get_adapt_intent_manifest(self): + """Make sure the manifest returns a list of Intent Parser objects.""" + self.setup_simple_adapt_intent() + msg = Message('intent.service.adapt.manifest.get') + self.intent_service.handle_manifest(msg) + reply = get_last_message(self.intent_service.bus) + self.assertEqual(reply.data['intents'][0]['name'], + 'skill:testIntent') + + def test_get_adapt_vocab_manifest(self): + self.setup_simple_adapt_intent() + msg = Message('intent.service.adapt.vocab.manifest.get') + self.intent_service.handle_vocab_manifest(msg) + reply = get_last_message(self.intent_service.bus) + value = reply.data['vocab'][0]['start'] + keyword = reply.data['vocab'][0]['end'] + self.assertEqual(keyword, 'testKeyword') + self.assertEqual(value, 'test') + + def test_get_no_match_after_detach(self): + """Check that a removed intent doesn't match.""" + self.setup_simple_adapt_intent() + # Check that no intent is matched + msg = Message('detach_intent', + data={'intent_name': 'skill:testIntent'}) + self.intent_service.handle_detach_intent(msg) + msg = Message('intent.service.adapt.get', data={'utterance': 'test'}) + self.intent_service.handle_get_adapt(msg) + reply = get_last_message(self.intent_service.bus) + self.assertEqual(reply.data['intent'], None) + + def test_get_no_match_after_detach_skill(self): + """Check that a removed skill's intent doesn't match.""" + self.setup_simple_adapt_intent() + # Check that no intent is matched + msg = Message('detach_intent', + data={'skill_id': 'skill'}) + self.intent_service.handle_detach_skill(msg) + msg = Message('intent.service.adapt.get', data={'utterance': 'test'}) + self.intent_service.handle_get_adapt(msg) + reply = get_last_message(self.intent_service.bus) + self.assertEqual(reply.data['intent'], None) diff --git a/test/unittests/skills/test_mycroft_skill.py b/test/unittests/skills/test_mycroft_skill.py index 5274bae0f1..0a0fdc787a 100644 --- a/test/unittests/skills/test_mycroft_skill.py +++ b/test/unittests/skills/test_mycroft_skill.py @@ -602,6 +602,13 @@ class TestMycroftSkill(unittest.TestCase): # Restore lang to en-us s.config_core['lang'] = 'en-us' + def test_speak_dialog_render_not_initialized(self): + """Test that non-initialized dialog_renderer won't raise an error.""" + s = SimpleSkill1() + s.bind(self.emitter) + s.dialog_renderer = None + s.speak_dialog(key='key') + class _TestSkill(MycroftSkill): def __init__(self): diff --git a/test/unittests/skills/test_settings.py b/test/unittests/skills/test_settings.py index 027c13ea0e..3db5a2de58 100644 --- a/test/unittests/skills/test_settings.py +++ b/test/unittests/skills/test_settings.py @@ -235,6 +235,16 @@ class TestSettingsDownloader(MycroftUnitTestBase): self.downloader.last_download_result ) + def test_stop_downloading(self): + """Ensure that the timer is cancelled and the continue flag is lowered. + """ + self.is_paired_mock.return_value = False # Skip all the download logic + self.downloader.download() # Start downloading creates the timer + self.downloader.stop_downloading() + self.assertFalse(self.downloader.continue_downloading) + self.assertTrue( + self.downloader.download_timer.cancel.called_once_with()) + def _check_api_called(self): self.assertListEqual( [call.get_skill_settings()], diff --git a/test/unittests/skills/test_skill_manager.py b/test/unittests/skills/test_skill_manager.py index 7d84f001e0..f2bdde3fa4 100644 --- a/test/unittests/skills/test_skill_manager.py +++ b/test/unittests/skills/test_skill_manager.py @@ -41,6 +41,18 @@ class TestUploadQueue(TestCase): queue.send() self.assertEqual(len(queue), 0) + def test_upload_queue_preloaded(self): + queue = UploadQueue() + loaders = [Mock(), Mock(), Mock(), Mock()] + for i, l in enumerate(loaders): + queue.put(l) + self.assertEqual(len(queue), i + 1) + # Check that starting the queue will send all the items in the queue + queue.start() + self.assertEqual(len(queue), 0) + for l in loaders: + l.instance.settings_meta.upload.assert_called_once_with() + class TestSkillManager(MycroftUnitTestBase): mock_package = 'mycroft.skills.skill_manager.' diff --git a/test/unittests/stt/test_stt.py b/test/unittests/stt/test_stt.py index b5768b5103..2eefb0302a 100644 --- a/test/unittests/stt/test_stt.py +++ b/test/unittests/stt/test_stt.py @@ -35,13 +35,18 @@ class TestSTT(unittest.TestCase): 'google': {'credential': {'token': 'FOOBAR'}}, 'bing': {'credential': {'token': 'FOOBAR'}}, 'houndify': {'credential': {'client_id': 'FOO', - "client_key": "BAR"}}, + "client_key": 'BAR'}}, 'google_cloud': { 'credential': { 'json': {} } }, - 'ibm': {'credential': {'token': 'FOOBAR'}}, + 'ibm': { + 'credential': { + 'token': 'FOOBAR' + }, + 'url': 'https://test.com/' + }, 'kaldi': {'uri': 'https://test.com'}, 'mycroft': {'uri': 'https://test.com'} }, @@ -164,26 +169,64 @@ class TestSTT(unittest.TestCase): stt.execute(audio) self.assertTrue(stt.recognizer.recognize_google_cloud.called) + @patch('mycroft.stt.post') @patch.object(Configuration, 'get') - def test_ibm_stt(self, mock_get): - mycroft.stt.Recognizer = MagicMock + def test_ibm_stt(self, mock_get, mock_post): + import json + config = base_config() config.merge( { 'stt': { 'module': 'ibm', 'ibm': { - 'credential': {'username': 'FOO', 'password': 'BAR'} + 'credential': { + 'token': 'FOOBAR' + }, + 'url': 'https://test.com' }, }, 'lang': 'en-US' - }) + } + ) mock_get.return_value = config + requests_object = MagicMock() + requests_object.status_code = 200 + requests_object.text = json.dumps({ + 'results': [ + { + 'alternatives': [ + { + 'confidence': 0.96, + 'transcript': 'sample response' + } + ], + 'final': True + } + ], + 'result_index': 0 + }) + mock_post.return_value = requests_object + audio = MagicMock() + audio.sample_rate = 16000 + stt = mycroft.stt.IBMSTT() stt.execute(audio) - self.assertTrue(stt.recognizer.recognize_ibm.called) + + test_url_base = 'https://test.com/v1/recognize' + mock_post.assert_called_with(test_url_base, + auth=('apikey', 'FOOBAR'), + headers={ + 'Content-Type': 'audio/x-flac', + 'X-Watson-Learning-Opt-Out': 'true' + }, + data=audio.get_flac_data(), + params={ + 'model': 'en-US_BroadbandModel', + 'profanity_filter': 'false' + }) @patch.object(Configuration, 'get') def test_wit_stt(self, mock_get): diff --git a/test/unittests/tts/test_espeak_tts.py b/test/unittests/tts/test_espeak_tts.py new file mode 100644 index 0000000000..0168bd8f4e --- /dev/null +++ b/test/unittests/tts/test_espeak_tts.py @@ -0,0 +1,23 @@ +import unittest +from unittest import mock + +from mycroft.tts.espeak_tts import ESpeak + + +@mock.patch('mycroft.tts.tts.PlaybackThread') +class TestMimic(unittest.TestCase): + @mock.patch('mycroft.tts.espeak_tts.subprocess') + def test_get_tts(self, mock_subprocess, _): + conf = { + "lang": "english-us", + "voice": "m1" + } + e = ESpeak('en-US', conf) + sentence = 'hello' + wav_filename = 'abc.wav' + wav, phonemes = e.get_tts(sentence, wav_filename) + self.assertTrue(phonemes is None) + mock_subprocess.call.called_with(['espeak', '-v', + conf['lang'] + '+' + conf['voice'], + '-w', wav_filename, + sentence]) diff --git a/test/unittests/tts/test_google_tts.py b/test/unittests/tts/test_google_tts.py index 7f4d1269e3..68e018f3e6 100644 --- a/test/unittests/tts/test_google_tts.py +++ b/test/unittests/tts/test_google_tts.py @@ -2,6 +2,7 @@ import unittest from unittest import mock from mycroft.tts.google_tts import GoogleTTS, GoogleTTSValidator +import mycroft.tts.google_tts as google_tts_mod @mock.patch('mycroft.tts.google_tts.gTTS') @@ -13,7 +14,7 @@ class TestGoogleTTS(unittest.TestCase): tts = GoogleTTS('en-US', {}) sentence = 'help me Obi-Wan Kenobi, you are my only hope' mp3_file, vis = tts.get_tts(sentence, 'output.mp3') - gtts_mock.assert_called_with(text=sentence, lang='en-US') + gtts_mock.assert_called_with(text=sentence, lang='en-us') gtts_response.save.assert_called_with('output.mp3') def test_validator(self, _, gtts_mock): @@ -24,3 +25,13 @@ class TestGoogleTTS(unittest.TestCase): raise Exception gtts_mock.side_effect = sideeffect validator.validate_connection() + + @mock.patch('mycroft.tts.google_tts.tts_langs') + def test_lang_connection_error(self, mock_get_langs, _, gtts_mock): + google_tts_mod._supported_langs = None + + def sideeffect(**kwargs): + raise Exception + mock_get_langs.side_effect = sideeffect + tts = GoogleTTS('en-US', {}) + self.assertEqual(tts.google_lang, 'en-us') diff --git a/test/unittests/tts/test_mimic_tts.py b/test/unittests/tts/test_mimic_tts.py index f60213ecbe..15bc66ba86 100644 --- a/test/unittests/tts/test_mimic_tts.py +++ b/test/unittests/tts/test_mimic_tts.py @@ -23,9 +23,9 @@ class TestMimic(unittest.TestCase): mock_device_api.return_value = device_instance_mock m = Mimic('en-US', {}) wav, phonemes = m.get_tts('hello', 'abc.wav') + mock_subprocess.check_output.assert_called_once_with( + m.args + ['-o', 'abc.wav', '-t', 'hello']) self.assertEqual(phonemes, mock_subprocess.check_output().decode()) - mock_subprocess.check_output_called_with(m.args + ['-o', 'abc.wav', - '-t', 'hello']) def test_viseme(self, _, mock_device_api): mock_device_api.return_value = device_instance_mock diff --git a/test/unittests/tts/test_tts.py b/test/unittests/tts/test_tts.py index a5712d244a..6ee036f334 100644 --- a/test/unittests/tts/test_tts.py +++ b/test/unittests/tts/test_tts.py @@ -120,7 +120,6 @@ class TestTTS(unittest.TestCase): self.assertEqual(read_phonemes, 'phonemes') # assert stripped def test_ssml_support(self, _): - sentence = "Prosody can be used to change the way words " \ "sound. The following words are " \ " " \ @@ -175,6 +174,16 @@ class TestTTS(unittest.TestCase): self.assertEqual(mycroft.tts.TTS.remove_ssml(sentence), sentence_no_ssml) + def test_load_spellings(self, _): + """Check that the spelling dictionary gets loaded.""" + tts = MockTTS("en-US", {}, MockTTSValidator(None)) + self.assertTrue(tts.spellings != {}) + + def test_load_spelling_missing(self, _): + """Test that a missing phonetic spelling dictionary counts as empty.""" + tts = MockTTS("as-DF", {}, MockTTSValidator(None)) + self.assertTrue(tts.spellings == {}) + class TestTTSFactory(unittest.TestCase): @mock.patch('mycroft.tts.tts.Configuration') diff --git a/test/unittests/util/muppets.dict b/test/unittests/util/muppets.dict new file mode 100644 index 0000000000..dc736bc4c9 --- /dev/null +++ b/test/unittests/util/muppets.dict @@ -0,0 +1,2 @@ +muppet = miss piggy +fraggle = gobo diff --git a/test/unittests/util/test_audio_utils.py b/test/unittests/util/test_audio_utils.py new file mode 100644 index 0000000000..23ead2b189 --- /dev/null +++ b/test/unittests/util/test_audio_utils.py @@ -0,0 +1,162 @@ +from unittest import TestCase, mock + +from test.util import Anything +from mycroft.util import (play_ogg, play_mp3, play_wav, play_audio_file, + record) + +test_config = { + 'play_wav_cmdline': 'mock_wav %1', + 'play_mp3_cmdline': 'mock_mp3 %1', + 'play_ogg_cmdline': 'mock_ogg %1' +} + + +@mock.patch('mycroft.configuration.Configuration') +@mock.patch('mycroft.util.audio_utils.subprocess') +class TestPlaySounds(TestCase): + def test_play_ogg(self, mock_subprocess, mock_conf): + mock_conf.get.return_value = test_config + play_ogg('insult.ogg') + mock_subprocess.Popen.assert_called_once_with(['mock_ogg', + 'insult.ogg'], + env=Anything()) + + @mock.patch('mycroft.util.audio_utils.LOG') + def test_play_ogg_file_not_found(self, mock_log, + mock_subprocess, mock_conf): + """Test that simple log is raised when subprocess can't find command. + """ + def raise_filenotfound(*arg, **kwarg): + raise FileNotFoundError('TEST FILE NOT FOUND') + + mock_subprocess.Popen.side_effect = raise_filenotfound + mock_conf.get.return_value = test_config + self.assertEqual(play_ogg('insult.ogg'), None) + mock_log.error.called_once_with(Anything()) + + @mock.patch('mycroft.util.audio_utils.LOG') + def test_play_ogg_exception(self, mock_log, + mock_subprocess, mock_conf): + """Test that stack trace is provided when unknown excpetion occurs""" + def raise_exception(*arg, **kwarg): + raise Exception + + mock_subprocess.Popen.side_effect = raise_exception + mock_conf.get.return_value = test_config + self.assertEqual(play_ogg('insult.ogg'), None) + mock_log.exception.called_once_with(Anything()) + + def test_play_mp3(self, mock_subprocess, mock_conf): + mock_conf.get.return_value = test_config + play_mp3('praise.mp3') + mock_subprocess.Popen.assert_called_once_with(['mock_mp3', + 'praise.mp3'], + env=Anything()) + + @mock.patch('mycroft.util.audio_utils.LOG') + def test_play_mp3_file_not_found(self, mock_log, + mock_subprocess, mock_conf): + """Test that simple log is raised when subprocess can't find command. + """ + def raise_filenotfound(*arg, **kwarg): + raise FileNotFoundError('TEST FILE NOT FOUND') + + mock_subprocess.Popen.side_effect = raise_filenotfound + mock_conf.get.return_value = test_config + self.assertEqual(play_mp3('praise.mp3'), None) + mock_log.error.called_once_with(Anything()) + + @mock.patch('mycroft.util.audio_utils.LOG') + def test_play_mp3_exception(self, mock_log, + mock_subprocess, mock_conf): + """Test that stack trace is provided when unknown excpetion occurs""" + def raise_exception(*arg, **kwarg): + raise Exception + + mock_subprocess.Popen.side_effect = raise_exception + mock_conf.get.return_value = test_config + self.assertEqual(play_mp3('praise.mp3'), None) + mock_log.exception.called_once_with(Anything()) + + def test_play_wav(self, mock_subprocess, mock_conf): + mock_conf.get.return_value = test_config + play_wav('indifference.wav') + mock_subprocess.Popen.assert_called_once_with(['mock_wav', + 'indifference.wav'], + env=Anything()) + + @mock.patch('mycroft.util.audio_utils.LOG') + def test_play_wav_file_not_found(self, mock_log, + mock_subprocess, mock_conf): + """Test that simple log is raised when subprocess can't find command. + """ + def raise_filenotfound(*arg, **kwarg): + raise FileNotFoundError('TEST FILE NOT FOUND') + + mock_subprocess.Popen.side_effect = raise_filenotfound + mock_conf.get.return_value = test_config + self.assertEqual(play_wav('indifference.wav'), None) + mock_log.error.called_once_with(Anything()) + + @mock.patch('mycroft.util.audio_utils.LOG') + def test_play_wav_exception(self, mock_log, + mock_subprocess, mock_conf): + """Test that stack trace is provided when unknown excpetion occurs""" + def raise_exception(*arg, **kwarg): + raise Exception + + mock_subprocess.Popen.side_effect = raise_exception + mock_conf.get.return_value = test_config + self.assertEqual(play_wav('indifference.wav'), None) + mock_log.exception.called_once_with(Anything()) + + def test_play_audio_file(self, mock_subprocess, mock_conf): + mock_conf.get.return_value = test_config + play_audio_file('indifference.wav') + mock_subprocess.Popen.assert_called_once_with(['mock_wav', + 'indifference.wav'], + env=Anything()) + mock_subprocess.Popen.reset_mock() + + play_audio_file('praise.mp3') + mock_subprocess.Popen.assert_called_once_with(['mock_mp3', + 'praise.mp3'], + env=Anything()) + mock_subprocess.Popen.reset_mock() + mock_conf.get.return_value = test_config + play_audio_file('insult.ogg') + mock_subprocess.Popen.assert_called_once_with(['mock_ogg', + 'insult.ogg'], + env=Anything()) + + +@mock.patch('mycroft.util.audio_utils.subprocess') +class TestRecordSounds(TestCase): + def test_record_with_duration(self, mock_subprocess): + mock_proc = mock.Mock()(name='mock process') + mock_subprocess.Popen.return_value = mock_proc + rate = 16000 + channels = 1 + filename = '/tmp/test.wav' + duration = 42 + res = record(filename, duration, rate, channels) + mock_subprocess.Popen.assert_called_once_with(['arecord', + '-r', str(rate), + '-c', str(channels), + '-d', str(duration), + filename]) + self.assertEqual(res, mock_proc) + + def test_record_without_duration(self, mock_subprocess): + mock_proc = mock.Mock(name='mock process') + mock_subprocess.Popen.return_value = mock_proc + rate = 16000 + channels = 1 + filename = '/tmp/test.wav' + duration = 0 + res = record(filename, duration, rate, channels) + mock_subprocess.Popen.assert_called_once_with(['arecord', + '-r', str(rate), + '-c', str(channels), + filename]) + self.assertEqual(res, mock_proc) diff --git a/test/unittests/util/test_download.py b/test/unittests/util/test_download.py new file mode 100644 index 0000000000..fddde00542 --- /dev/null +++ b/test/unittests/util/test_download.py @@ -0,0 +1,105 @@ +from threading import Event +from unittest import TestCase, mock + +from mycroft.util.download import (download, _running_downloads, + _get_download_tmp) + +TEST_URL = 'http://example.com/mycroft-test.tar.gz' +TEST_DEST = '/tmp/file.tar.gz' + + +@mock.patch('mycroft.util.download.subprocess') +@mock.patch('mycroft.util.download.os') +class TestDownload(TestCase): + def setUp(self): + """Remove any cached instance.""" + for key in list(_running_downloads.keys()): + _running_downloads.pop(key) + + def test_download_basic(self, mock_os, mock_subprocess): + """Test the basic download call.""" + mock_subprocess.call.return_value = 0 + + downloader = download(url=TEST_URL, + dest=TEST_DEST) + downloader.join() + mock_subprocess.call.assert_called_once_with(['wget', '-c', TEST_URL, + '-O', + TEST_DEST + '.part', + '--tries=20', + '--read-timeout=5']) + self.assertTrue(downloader.done) + + def test_download_with_header(self, mock_os, mock_subprocess): + """Test download with specific header.""" + mock_subprocess.call.return_value = 0 + + test_hdr = 'TEST_HEADER' + downloader = download(url=TEST_URL, + dest=TEST_DEST, + header=test_hdr) + downloader.join() + + self.assertTrue(downloader.done) + mock_subprocess.call.assert_called_once_with(['wget', '-c', TEST_URL, + '-O', + TEST_DEST + '.part', + '--tries=20', + '--read-timeout=5', + '--header=' + test_hdr]) + + def test_download_callback(self, mock_os, mock_subprocess): + """Check that callback function is called with correct destination.""" + mock_subprocess.call.return_value = 0 + action_called_with = None + + def action(dest): + nonlocal action_called_with + action_called_with = dest + + downloader = download(url=TEST_URL, + dest=TEST_DEST, + complete_action=action) + downloader.join() + + self.assertTrue(downloader.done) + self.assertEqual(action_called_with, TEST_DEST) + + def test_download_cache(self, mock_os, mock_subprocess): + """Make sure that a cached download is used if exists.""" + + transfer_done = Event() + + def wget_call(*args, **kwargs): + nonlocal transfer_done + transfer_done.wait() + return 0 + + downloader = download(url=TEST_URL, + dest=TEST_DEST) + downloader2 = download(url=TEST_URL, + dest=TEST_DEST) + # When called with the same args a cached download in progress should + # be returned instead of a new one. + self.assertTrue(downloader is downloader2) + transfer_done.set() + downloader.join() + + +@mock.patch('mycroft.util.download.glob') +class TestGetTemp(TestCase): + def test_no_existing(self, mock_glob): + mock_glob.return_value = [] + dest = '/tmp/test' + self.assertEqual(_get_download_tmp(dest), dest + '.part') + + def test_existing(self, mock_glob): + mock_glob.return_value = ['/tmp/test.part'] + dest = '/tmp/test' + self.assertEqual(_get_download_tmp(dest), dest + '.part.1') + + def test_multiple_existing(self, mock_glob): + mock_glob.return_value = ['/tmp/test.part', '/tmp/test.part.1', + '/tmp/test.part.2'] + dest = '/tmp/test' + self.assertEqual(_get_download_tmp(dest), dest + '.part.3') diff --git a/test/unittests/util/test_file_utils.py b/test/unittests/util/test_file_utils.py new file mode 100644 index 0000000000..dcc4d72676 --- /dev/null +++ b/test/unittests/util/test_file_utils.py @@ -0,0 +1,169 @@ +from os import makedirs +from os.path import (abspath, dirname, expanduser, join, normpath, isdir, + exists) +import shutil +import tempfile +from unittest import TestCase, mock + +from mycroft import MYCROFT_ROOT_PATH +from mycroft.util import (resolve_resource_file, curate_cache, create_file, + get_cache_directory, read_stripped_lines, read_dict) + + +test_config = { + 'data_dir': join(dirname(__file__), 'datadir'), + 'cache_dir': tempfile.gettempdir(), +} + + +@mock.patch('mycroft.configuration.Configuration') +class TestResolveResource(TestCase): + def test_absolute_path(self, mock_conf): + mock_conf.get.return_value = test_config + test_path = abspath(__file__) + + self.assertEqual(resolve_resource_file(test_path), test_path) + + @mock.patch('os.path.isfile') + def test_dot_mycroft(self, mock_isfile, mock_conf): + mock_conf.get.return_value = test_config + + def files_in_dotmycroft_exists(path): + return '.mycroft/' in path + + mock_isfile.side_effect = files_in_dotmycroft_exists + self.assertEqual(resolve_resource_file('1984.txt'), + expanduser('~/.mycroft/1984.txt')) + + @mock.patch('os.path.isfile') + def test_data_dir(self, mock_isfile, mock_conf): + """Check for file in the "configured data dir"/res/""" + mock_conf.get.return_value = test_config + + def files_in_mycroft_datadir_exists(path): + return 'datadir' in path + + mock_isfile.side_effect = files_in_mycroft_datadir_exists + self.assertEqual(resolve_resource_file('1984.txt'), + join(test_config['data_dir'], 'res', '1984.txt')) + + def test_source_package(self, mock_conf): + """Check file shipped in the mycroft res folder.""" + mock_conf.get.return_value = test_config + expected_path = join(MYCROFT_ROOT_PATH, 'mycroft', 'res', + 'text', 'en-us', 'and.word') + res_path = resolve_resource_file('text/en-us/and.word') + + self.assertEqual(normpath(res_path), normpath(expected_path)) + + def test_missing_file(self, mock_conf): + """Assert that the function returns None when file is not foumd.""" + mock_conf.get.return_value = test_config + self.assertTrue(resolve_resource_file('1984.txt') is None) + + +def create_cache_files(cache_dir): + """Create a couple of files in the cache directory.""" + huxley_path = join(cache_dir, 'huxley.txt') + aldous_path = join(cache_dir, 'alduos.txt') + f = open(huxley_path, 'w+') + f.close() + f = open(aldous_path, 'w+') + f.close() + return huxley_path, aldous_path + + +class TestReadFiles(TestCase): + base = dirname(__file__) + + def test_read_stripped_lines(self): + expected = ['Once upon a time', 'there was a great Dragon', + 'It was red and cute', 'The end'] + unstripped_path = join(TestReadFiles.base, 'unstripped_lines.txt') + self.assertEqual(list(read_stripped_lines(unstripped_path)), expected) + + def test_read_dict(self): + expected = {'fraggle': 'gobo', 'muppet': 'miss piggy'} + dict_path = join(TestReadFiles.base, 'muppets.dict') + self.assertEqual(read_dict(dict_path), expected) + + +@mock.patch('mycroft.configuration.Configuration') +class TestCache(TestCase): + def tearDownClass(): + shutil.rmtree(test_config['cache_dir'], ignore_errors=True) + + def test_get_cache_directory(self, mock_conf): + mock_conf.get.return_value = test_config + expected_path = join(test_config['cache_dir'], 'mycroft', 'cache') + self.assertEqual(get_cache_directory(), expected_path) + self.assertTrue(isdir(expected_path)) + + def test_get_cache_directory_with_domain(self, mock_conf): + mock_conf.get.return_value = test_config + expected_path = join(test_config['cache_dir'], 'mycroft', + 'cache', 'whales') + self.assertEqual(get_cache_directory('whales'), expected_path) + self.assertTrue(isdir(expected_path)) + + @mock.patch('mycroft.util.file_utils.psutil') + def test_curate_cache(self, mock_psutil, mock_conf): + """Test removal of cache files when disk space is running low.""" + mock_conf.get.return_value = test_config + space = mock.Mock(name='diskspace') + mock_psutil.disk_usage.return_value = space + + cache_dir = get_cache_directory('braveNewWorld') + huxley_path, aldous_path = create_cache_files(cache_dir) + # Create files in the cache directory + + # Test plenty of space free + space.percent = 5.0 + space.free = 2 * 1024 * 1024 * 1024 # 2GB + space.total = 20 * 1024 * 1024 * 1024 # 20GB + curate_cache(cache_dir) + self.assertTrue(exists(aldous_path)) + self.assertTrue(exists(huxley_path)) + + # Free Percentage low but not free space + space.percent = 96.0 + space.free = 2 * 1024 * 1024 * 1024 # 2GB + curate_cache(cache_dir) + self.assertTrue(exists(aldous_path)) + self.assertTrue(exists(huxley_path)) + + # Free space low, but not percentage + space.percent = 95.0 + space.free = 2 * 1024 * 1024 # 2MB + curate_cache(cache_dir) + self.assertTrue(exists(aldous_path)) + self.assertTrue(exists(huxley_path)) + + # Free space and percentage low + space.percent = 96.0 + space.free = 2 * 1024 * 1024 # 2MB + curate_cache(cache_dir) + self.assertFalse(exists(aldous_path)) + self.assertFalse(exists(huxley_path)) + + +TEST_CREATE_FILE_DIR = join(tempfile.gettempdir(), 'create_file_test') + + +class TestCreateFile(TestCase): + def setUp(self): + shutil.rmtree(TEST_CREATE_FILE_DIR, ignore_errors=True) + + def test_create_file_in_existing_dir(self): + makedirs(TEST_CREATE_FILE_DIR) + test_path = join(TEST_CREATE_FILE_DIR, 'test_file') + create_file(test_path) + self.assertTrue(exists(test_path)) + + def test_create_file_in_nonexisting_dir(self): + test_path = join(TEST_CREATE_FILE_DIR, 'test_file') + create_file(test_path) + self.assertTrue(exists(test_path)) + + def tearDownClass(): + shutil.rmtree(TEST_CREATE_FILE_DIR, ignore_errors=True) diff --git a/test/unittests/util/test_monotonic_event.py b/test/unittests/util/test_monotonic_event.py new file mode 100644 index 0000000000..9c7d27770e --- /dev/null +++ b/test/unittests/util/test_monotonic_event.py @@ -0,0 +1,32 @@ +from threading import Thread +from time import sleep +from unittest import TestCase, mock + +from mycroft.util.monotonic_event import MonotonicEvent + + +class MonotonicEventTest(TestCase): + def test_wait_set(self): + event = MonotonicEvent() + event.set() + self.assertTrue(event.wait()) + + def test_wait_timeout(self): + event = MonotonicEvent() + self.assertFalse(event.wait(0.1)) + + def test_wait_set_with_timeout(self): + wait_result = False + event = MonotonicEvent() + + def wait_event(): + nonlocal wait_result + wait_result = event.wait(30) + + wait_thread = Thread(target=wait_event) + wait_thread.start() + + sleep(0.1) + event.set() + wait_thread.join() + self.assertTrue(wait_result) diff --git a/test/unittests/util/test_platform.py b/test/unittests/util/test_platform.py new file mode 100644 index 0000000000..431d14438a --- /dev/null +++ b/test/unittests/util/test_platform.py @@ -0,0 +1,11 @@ +from unittest import TestCase, mock + +from mycroft.util import get_arch + + +class TestPlatform(TestCase): + @mock.patch('os.uname') + def test_get_arch(self, mock_uname): + mock_uname.return_value = ('Linux', 'Woodstock', '4.15.0-39-generic', + 'Awesome test system Mark 7', 'x86_64') + self.assertEqual(get_arch(), 'x86_64') diff --git a/test/unittests/util/test_process_utils.py b/test/unittests/util/test_process_utils.py new file mode 100644 index 0000000000..fe72f201fd --- /dev/null +++ b/test/unittests/util/test_process_utils.py @@ -0,0 +1,77 @@ +from unittest import TestCase, mock + +from mycroft.util.process_utils import (_update_log_level, bus_logging_status, + create_daemon) + + +class TestCreateDaemon(TestCase): + def test_create(self): + """Make sure deamon thread is created, and runs the expected function. + """ + thread_ran = False + + def thread_func(): + nonlocal thread_ran + thread_ran = True + + thread = create_daemon(thread_func) + self.assertTrue(thread.daemon) + self.assertTrue(thread_ran) + thread.join() + + def test_create_with_args(self): + """Check that the args and kwargs is passed to the thread function.""" + test_args = (1, 2, 3) + test_kwargs = {'meaning': 42, 'borg': '7 of 9'} + passed_args = None + passed_kwargs = None + + def thread_func(*args, **kwargs): + nonlocal passed_args + nonlocal passed_kwargs + passed_args = args + passed_kwargs = kwargs + + thread = create_daemon(thread_func, test_args, test_kwargs) + thread.join() + self.assertEqual(test_args, passed_args) + self.assertEqual(test_kwargs, passed_kwargs) + + +@mock.patch('mycroft.util.process_utils.LOG') +class TestUpdateLogLevel(TestCase): + def test_no_data(self, mock_log): + mock_log.level = 'UNSET' + log_msg = {'msg_type': 'mycroft.debug.log', + 'data': {}} + _update_log_level(log_msg, 'Test') + self.assertEqual(mock_log.level, 'UNSET') + + def test_set_debug(self, mock_log): + mock_log.level = 'UNSET' + log_msg = {'type': 'mycroft.debug.log', + 'data': {'level': 'DEBUG'}} + _update_log_level(log_msg, 'Test') + self.assertEqual(mock_log.level, 'DEBUG') + + def test_set_lowecase_debug(self, mock_log): + mock_log.level = 'UNSET' + log_msg = {'type': 'mycroft.debug.log', + 'data': {'level': 'debug'}} + _update_log_level(log_msg, 'Test') + self.assertEqual(mock_log.level, 'DEBUG') + + def test_set_invalid_level(self, mock_log): + mock_log.level = 'UNSET' + log_msg = {'type': 'mycroft.debug.log', + 'data': {'level': 'snowcrash'}} + _update_log_level(log_msg, 'Test') + self.assertEqual(mock_log.level, 'UNSET') + + def test_set_bus_logging(self, mock_log): + mock_log.level = 'UNSET' + log_msg = {'type': 'mycroft.debug.log', + 'data': {'bus': True}} + self.assertFalse(bus_logging_status()) + _update_log_level(log_msg, 'Test') + self.assertTrue(bus_logging_status()) diff --git a/test/unittests/util/test_string_utils.py b/test/unittests/util/test_string_utils.py new file mode 100644 index 0000000000..fd65bd16df --- /dev/null +++ b/test/unittests/util/test_string_utils.py @@ -0,0 +1,9 @@ +from unittest import TestCase +from mycroft.util import camel_case_split + + +class TestStringFunctions(TestCase): + def test_camel_case_split(self): + """Check that camel case string is split properly.""" + self.assertEqual(camel_case_split('MyCoolSkill'), 'My Cool Skill') + self.assertEqual(camel_case_split('MyCOOLSkill'), 'My COOL Skill') diff --git a/test/unittests/util/test_time.py b/test/unittests/util/test_time.py new file mode 100644 index 0000000000..9716f12ffc --- /dev/null +++ b/test/unittests/util/test_time.py @@ -0,0 +1,76 @@ +from datetime import datetime +from dateutil.tz import tzfile, tzlocal, gettz +from unittest import TestCase, mock + +from mycroft.util.time import (default_timezone, now_local, now_utc, to_utc, + to_local, to_system) + +test_config = { + 'location': { + 'timezone': { + 'code': 'America/Chicago', + 'name': 'Central Standard Time', + 'dstOffset': 3600000, # Daylight saving offset in milliseconds + 'offset': -21600000 # Timezone offset in milliseconds + } + } +} + + +@mock.patch('mycroft.configuration.Configuration') +class TestTimeFuncs(TestCase): + def test_default_timezone(self, mock_conf): + mock_conf.get.return_value = test_config + self.assertEqual(default_timezone(), + tzfile('/usr/share/zoneinfo/America/Chicago')) + # Test missing tz-info + mock_conf.get.return_value = {} + self.assertEqual(default_timezone(), tzlocal()) + + @mock.patch('mycroft.util.time.datetime') + def test_now_local(self, mock_dt, mock_conf): + dt_test = datetime(year=1985, month=10, day=25, hour=8, minute=18) + mock_dt.now.return_value = dt_test + mock_conf.get.return_value = test_config + + self.assertEqual(now_local(), dt_test) + + expected_timezone = tzfile('/usr/share/zoneinfo/America/Chicago') + mock_dt.now.assert_called_with(expected_timezone) + + now_local(tzfile('/usr/share/zoneinfo/Europe/Stockholm')) + expected_timezone = tzfile('/usr/share/zoneinfo/Europe/Stockholm') + mock_dt.now.assert_called_with(expected_timezone) + + @mock.patch('mycroft.util.time.datetime') + def test_now_utc(self, mock_dt, mock_conf): + dt_test = datetime(year=1985, month=10, day=25, hour=8, minute=18) + mock_dt.utcnow.return_value = dt_test + mock_conf.get.return_value = test_config + + self.assertEqual(now_utc(), dt_test.replace(tzinfo=gettz('UTC'))) + mock_dt.utcnow.assert_called_with() + + def test_to_utc(self, mock_conf): + mock_conf.get.return_value = test_config + dt = datetime(year=2000, month=1, day=1, + hour=0, minute=0, second=0, + tzinfo=gettz('Europe/Stockholm')) + self.assertEqual(to_utc(dt), dt) + self.assertEqual(to_utc(dt).tzinfo, gettz('UTC')) + + def test_to_local(self, mock_conf): + mock_conf.get.return_value = test_config + dt = datetime(year=2000, month=1, day=1, + hour=0, minute=0, second=0, + tzinfo=gettz('Europe/Stockholm')) + self.assertEqual(to_local(dt), dt) + self.assertEqual(to_local(dt).tzinfo, gettz('America/Chicago')) + + def test_to_system(self, mock_conf): + mock_conf.get.return_value = test_config + dt = datetime(year=2000, month=1, day=1, + hour=0, minute=0, second=0, + tzinfo=gettz('Europe/Stockholm')) + self.assertEqual(to_system(dt), dt) + self.assertEqual(to_system(dt).tzinfo, tzlocal()) diff --git a/test/unittests/util/unstripped_lines.txt b/test/unittests/util/unstripped_lines.txt new file mode 100644 index 0000000000..d8dee8ccdf --- /dev/null +++ b/test/unittests/util/unstripped_lines.txt @@ -0,0 +1,5 @@ + Once upon a time +there was a great Dragon +It was red and cute + + The end diff --git a/test/util.py b/test/util.py index 6fa294ca3d..f80c0f9bd4 100644 --- a/test/util.py +++ b/test/util.py @@ -7,3 +7,12 @@ __config = LocalConf(DEFAULT_CONFIG) # Base config to use when mocking def base_config(): return deepcopy(__config) + + +class Anything: + """Class matching any object. + + Useful for assert_called_with arguments. + """ + def __eq__(self, other): + return True diff --git a/test/wake_word/wake_word_test.py b/test/wake_word/wake_word_test.py index b91bd04aa2..0a5abfee8f 100644 --- a/test/wake_word/wake_word_test.py +++ b/test/wake_word/wake_word_test.py @@ -100,7 +100,7 @@ class AudioTester: def test_audio(self, file_name): source = FileMockMicrophone(file_name) - ee = pyee.EventEmitter() + ee = pyee.BaseEventEmitter() class SharedData: times_found = 0