diff --git a/.gitignore b/.gitignore
index 8f636ba2b58d..3155be6df2a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@ doc/_build/
.installed
.mypy_cache
.vscode
+.theia
.venv/
# Created by unit tests
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 000000000000..3a9ebb01aa96
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,209 @@
+pipeline {
+ agent any
+ options {
+ // Running builds concurrently could cause a race condition with
+ // building the Docker image.
+ disableConcurrentBuilds()
+ buildDiscarder(logRotator(numToKeepStr: '5'))
+ lock resource: 'VoightKampff'
+ }
+ stages {
+ // Run the build in the against the dev branch to check for compile errors
+ stage('Run Integration Tests') {
+ when {
+ anyOf {
+ branch 'dev'
+ branch 'master'
+ changeRequest target: 'dev'
+ }
+ }
+ environment {
+ // Some branches have a "/" in their name (e.g. feature/new-and-cool)
+ // Some commands, such as those tha deal with directories, don't
+ // play nice with this naming convention. Define an alias for the
+ // branch name that can be used in these scenarios.
+ BRANCH_ALIAS = sh(
+ script: 'echo $BRANCH_NAME | sed -e "s#/#-#g"',
+ returnStdout: true
+ ).trim()
+ }
+ steps {
+ echo 'Building Mark I Voight-Kampff Docker Image'
+ sh 'cp test/Dockerfile.test Dockerfile'
+ sh 'docker build \
+ --target voight_kampff_builder \
+ --build-arg platform=mycroft_mark_1 \
+ -t voight-kampff-mark-1:${BRANCH_ALIAS} .'
+ echo 'Running Mark I Voight-Kampff Test Suite'
+ timeout(time: 60, unit: 'MINUTES')
+ {
+ sh 'docker run \
+ -v "$HOME/voight-kampff/identity:/root/.mycroft/identity" \
+ -v "$HOME/voight-kampff/:/root/allure" \
+ voight-kampff-mark-1:${BRANCH_ALIAS} \
+ -f allure_behave.formatter:AllureFormatter \
+ -o /root/allure/allure-result --tags ~@xfail'
+ }
+ }
+ post {
+ always {
+ echo 'Report Test Results'
+ echo 'Changing ownership...'
+ sh 'docker run \
+ -v "$HOME/voight-kampff/:/root/allure" \
+ --entrypoint=/bin/bash \
+ voight-kampff-mark-1:${BRANCH_ALIAS} \
+ -x -c "chown $(id -u $USER):$(id -g $USER) \
+ -R /root/allure/"'
+
+ echo 'Transferring...'
+ sh 'rm -rf allure-result/*'
+ sh 'mv $HOME/voight-kampff/allure-result allure-result'
+ script {
+ allure([
+ includeProperties: false,
+ jdk: '',
+ properties: [],
+ reportBuildPolicy: 'ALWAYS',
+ results: [[path: 'allure-result']]
+ ])
+ }
+ unarchive mapping:['allure-report.zip': 'allure-report.zip']
+ sh (
+ label: 'Publish Report to Web Server',
+ script: '''scp allure-report.zip root@157.245.127.234:~;
+ ssh root@157.245.127.234 "unzip -o ~/allure-report.zip";
+ ssh root@157.245.127.234 "rm -rf /var/www/voight-kampff/core/${BRANCH_ALIAS}";
+ ssh root@157.245.127.234 "mv allure-report /var/www/voight-kampff/core/${BRANCH_ALIAS}"
+ '''
+ )
+ echo 'Report Published'
+ }
+ failure {
+ script {
+ // Create comment for Pull Requests
+ if (env.CHANGE_ID) {
+ echo 'Sending PR comment'
+ pullRequest.comment('Voight Kampff Integration Test Failed ([Results](https://reports.mycroft.ai/core/' + env.BRANCH_ALIAS + '))')
+ }
+ }
+ // Send failure email containing a link to the Jenkins build
+ // the results report and the console log messages to Mycroft
+ // developers, the developers of the pull request and the
+ // developers that caused the build to fail.
+ echo 'Sending Failure Email'
+ emailext (
+ attachLog: true,
+ subject: "FAILED - Core Integration Tests - Build ${BRANCH_NAME} #${BUILD_NUMBER}",
+ body: """
+
+ One or more integration tests failed. Use the
+ resources below to identify the issue and fix
+ the failing tests.
+
+
+
+
+ Jenkins Build Details
+
+  (Requires account on Mycroft's Jenkins instance)
+
+
+
+
+ Report of Test Results
+
+
+
+ Console log is attached.
""",
+ replyTo: 'devops@mycroft.ai',
+ to: 'dev@mycroft.ai',
+ recipientProviders: [
+ [$class: 'RequesterRecipientProvider'],
+ [$class:'CulpritsRecipientProvider'],
+ [$class:'DevelopersRecipientProvider']
+ ]
+ )
+ }
+ success {
+ script {
+ if (env.CHANGE_ID) {
+ echo 'Sending PR comment'
+ pullRequest.comment('Voight Kampff Integration Test Succeeded ([Results](https://reports.mycroft.ai/core/' + env.BRANCH_ALIAS + '))')
+ }
+ }
+ // Send success email containing a link to the Jenkins build
+ // and the results report to Mycroft developers, the developers
+ // of the pull request and the developers that caused the
+ // last failed build.
+ echo 'Sending Success Email'
+ emailext (
+ subject: "SUCCESS - Core Integration Tests - Build ${BRANCH_NAME} #${BUILD_NUMBER}",
+ body: """
+
+ All integration tests passed. No further action required.
+
+
+
+
+ Jenkins Build Details
+
+  (Requires account on Mycroft's Jenkins instance)
+
+
+
+
+ Report of Test Results
+
+
""",
+ replyTo: 'devops@mycroft.ai',
+ to: 'dev@mycroft.ai',
+ recipientProviders: [
+ [$class: 'RequesterRecipientProvider'],
+ [$class:'CulpritsRecipientProvider'],
+ [$class:'DevelopersRecipientProvider']
+ ]
+ )
+ }
+ }
+ }
+ // 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
+ // the breaking changes included in said release.
+ stage('Build Major Release Image') {
+ when {
+ tag "release/v*.*.0"
+ }
+ environment {
+ // Tag name is usually formatted like "20.2.0" whereas skill
+ // branch names are usually "20.02". Reformat the tag name
+ // to the skill branch format so this image will be easy to find
+ // in the mycroft-skill repository.
+ SKILL_BRANCH = sh(
+ script: 'echo $TAG_NAME | sed -e "s/v//g" -e "s/[.]0//g" -e "s/[.]/.0/g"',
+ returnStdout: true
+ ).trim()
+ }
+ steps {
+ echo 'Building ${TAG_NAME} Docker Image for Skill Testing'
+ sh 'cp test/Dockerfile.test Dockerfile'
+ sh 'docker build \
+ --target voight_kampff_builder \
+ --build-arg platform=mycroft_mark_1 \
+ -t voight-kampff-mark-1:${SKILL_BRANCH} .'
+ }
+ }
+ }
+ post {
+ cleanup {
+ sh(
+ label: 'Docker Container and Image Cleanup',
+ script: '''
+ docker container prune --force;
+ docker image prune --force;
+ '''
+ )
+ }
+ }
+}
diff --git a/README.md b/README.md
index aeddef71434c..1665b0a68dd5 100644
--- a/README.md
+++ b/README.md
@@ -84,14 +84,32 @@ When the configuration loader starts, it looks in these locations in this order,
## Using Mycroft Without Home
-If you do not wish to use the Mycroft Home service, you may insert your own API keys into the configuration files listed below in configuration.
+If you do not wish to use the Mycroft Home service, before starting Mycroft for the first time, create `$HOME/.mycroft/mycroft.conf` with the following contents:
-The place to insert the API key looks like the following:
+```
+{
+ "skills": {
+ "blacklisted_skills": [
+ "mycroft-configuration.mycroftai",
+ "mycroft-pairing.mycroftai"
+ ]
+ }
+}
+```
+
+Mycroft will then be unable to perform speech-to-text conversion, so you'll need to set that up as well, using one of the [STT engines Mycroft supports](https://mycroft-ai.gitbook.io/docs/using-mycroft-ai/customizations/stt-engine).
-`[WeatherSkill]`
-`api_key = ""`
+You may insert your own API keys into the configuration files listed above in Configuration. For example, to insert the API key for the Weather skill, create a new JSON key in the configuration file like so:
-Put a relevant key inside the quotes and mycroft-core should begin to use the key immediately.
+```
+{
+ // other configuration settings...
+ //
+ "WeatherSkill": {
+ "api_key": ""
+ }
+}
+```
## API Key Services
diff --git a/bin/mycroft-skill-testrunner b/bin/mycroft-skill-testrunner
index 9699a4d138f2..53ebda644a13 100755
--- a/bin/mycroft-skill-testrunner
+++ b/bin/mycroft-skill-testrunner
@@ -23,6 +23,9 @@ source "$DIR/../venv-activate.sh" -q
# 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 "$@"
else
python -m test.integrationtests.skills.runner $@
-fi
\ No newline at end of file
+fi
diff --git a/dev_setup.sh b/dev_setup.sh
index 55c067a0beb8..3c59bc427b0d 100755
--- a/dev_setup.sh
+++ b/dev_setup.sh
@@ -274,7 +274,7 @@ fi" > ~/.profile_mycroft
# Add PEP8 pre-commit hook
sleep 0.5
echo '
-(Devloper) Do you want to automatically check code-style when submitting code.
+(Developer) Do you want to automatically check code-style when submitting code.
If unsure answer yes.
'
if get_YN ; then
diff --git a/mycroft/client/enclosure/__main__.py b/mycroft/client/enclosure/__main__.py
index 7d75ee0a7877..20cb41aaac21 100644
--- a/mycroft/client/enclosure/__main__.py
+++ b/mycroft/client/enclosure/__main__.py
@@ -12,44 +12,67 @@
# 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 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():
+ """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)
+ wait_for_exit_signal()
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 58000715399b..f97138f7e898 100644
--- a/mycroft/client/enclosure/base.py
+++ b/mycroft/client/enclosure/base.py
@@ -22,9 +22,9 @@
from mycroft.util import create_daemon
from mycroft.util.log import LOG
-import tornado.web
import json
-from tornado import autoreload, ioloop
+import tornado.web as web
+from tornado import ioloop
from tornado.websocket import WebSocketHandler
from mycroft.messagebus.message import Message
@@ -63,7 +63,6 @@ class Enclosure:
def __init__(self):
# Establish Enclosure's websocket connection to the messagebus
self.bus = MessageBusClient()
-
# Load full config
Configuration.set_config_update_handlers(self.bus)
config = Configuration.get()
@@ -72,6 +71,7 @@ def __init__(self):
self.config = config.get("enclosure")
self.global_config = config
+ self.gui = create_gui_service(self, config['gui_websocket'])
# This datastore holds the data associated with the GUI provider. Data
# is stored in Namespaces, so you can have:
# self.datastore["namespace"]["name"] = value
@@ -94,7 +94,6 @@ def __init__(self):
self.explicit_move = True # Set to true to send reorder commands
# Listen for new GUI clients to announce themselves on the main bus
- self.GUIs = {} # GUIs, either local or remote
self.active_namespaces = []
self.bus.on("mycroft.gui.connected", self.on_gui_client_connected)
self.register_gui_handlers()
@@ -116,13 +115,14 @@ def run(self):
######################################################################
# GUI client API
- def send(self, *args, **kwargs):
+ def send(self, msg_dict):
""" Send to all registered GUIs. """
- for gui in self.GUIs.values():
- if gui.socket:
- gui.socket.send(*args, **kwargs)
- else:
- LOG.error('GUI connection {} has no socket!'.format(gui))
+ LOG.info('SENDING...')
+ for connection in GUIWebsocketHandler.clients:
+ try:
+ connection.send(msg_dict)
+ except Exception as e:
+ LOG.exception(repr(e))
def on_gui_send_event(self, message):
""" Send an event to the GUIs. """
@@ -398,37 +398,18 @@ def remove_pages(self, namespace, pages):
# If the connection is lost, it must be renegotiated and restarted.
def on_gui_client_connected(self, message):
# GUI has announced presence
+ LOG.info('GUI HAS ANNOUNCED!')
+ port = self.global_config["gui_websocket"]["base_port"]
LOG.debug("on_gui_client_connected")
gui_id = message.data.get("gui_id")
- # Spin up a new communication socket for this GUI
- if gui_id in self.GUIs:
- # TODO: Close it?
- pass
- try:
- asyncio.get_event_loop()
- except RuntimeError:
- asyncio.set_event_loop(asyncio.new_event_loop())
-
- self.GUIs[gui_id] = GUIConnection(gui_id, self.global_config,
- self.callback_disconnect, self)
LOG.debug("Heard announcement from gui_id: {}".format(gui_id))
# Announce connection, the GUI should connect on it soon
self.bus.emit(Message("mycroft.gui.port",
- {"port": self.GUIs[gui_id].port,
+ {"port": port,
"gui_id": gui_id}))
- def callback_disconnect(self, gui_id):
- LOG.info("Disconnecting!")
- # TODO: Whatever is needed to kill the websocket instance
- LOG.info(self.GUIs.keys())
- LOG.info('deleting: {}'.format(gui_id))
- if gui_id in self.GUIs:
- del self.GUIs[gui_id]
- else:
- LOG.warning('ID doesn\'t exist')
-
def register_gui_handlers(self):
# TODO: Register handlers for standard (Mark 1) events
# self.bus.on('enclosure.eyes.on', self.on)
@@ -472,125 +453,73 @@ def register_gui_handlers(self):
}
-class GUIConnection:
- """ A single GUIConnection exists per graphic interface. This object
- maintains the socket used for communication and keeps the state of the
- Mycroft data in sync with the GUIs data.
-
- Serves as a communication interface between Qt/QML frontend and Mycroft
- Core. This is bidirectional, e.g. "show me this visual" to the frontend as
- well as "the user just tapped this button" from the frontend.
-
- For the rough protocol, see:
- https://cgit.kde.org/scratch/mart/mycroft-gui.git/tree/transportProtocol.txt?h=newapi # nopep8
-
- TODO: Implement variable deletion
- TODO: Implement 'models' support
- TODO: Implement events
- TODO: Implement data coming back from Qt to Mycroft
- """
+def create_gui_service(enclosure, config):
+ import tornado.options
+ LOG.info('Starting message bus for GUI...')
+ # Disable all tornado logging so mycroft loglevel isn't overridden
+ tornado.options.parse_command_line(['--logging=None'])
- _last_idx = 0 # this is incremented by 1 for each open GUIConnection
- server_thread = None
+ routes = [(config['route'], GUIWebsocketHandler)]
+ application = web.Application(routes, debug=True)
+ application.enclosure = enclosure
+ application.listen(config['base_port'], config['host'])
- def __init__(self, id, config, callback_disconnect, enclosure):
- LOG.debug("Creating GUIConnection")
- self.id = id
- self.socket = None
- self.callback_disconnect = callback_disconnect
- self.enclosure = enclosure
+ create_daemon(ioloop.IOLoop.instance().start)
+ LOG.info('GUI Message bus started!')
+ return application
- # Each connection will run its own Tornado server. If the
- # connection drops, the server is killed.
- websocket_config = config.get("gui_websocket")
- host = websocket_config.get("host")
- route = websocket_config.get("route")
- base_port = websocket_config.get("base_port")
- while True:
- self.port = base_port + GUIConnection._last_idx
- GUIConnection._last_idx += 1
+class GUIWebsocketHandler(WebSocketHandler):
+ """The socket pipeline between the GUI and Mycroft."""
+ clients = []
- try:
- self.webapp = tornado.web.Application(
- [(route, GUIWebsocketHandler)], **gui_app_settings
- )
- # Hacky way to associate socket with this object:
- self.webapp.gui = self
- self.webapp.listen(self.port, host)
- except Exception as e:
- LOG.debug('Error: {}'.format(repr(e)))
- continue
- break
- # Can't run two IOLoop's in the same process
- if not GUIConnection.server_thread:
- GUIConnection.server_thread = create_daemon(
- ioloop.IOLoop.instance().start)
- LOG.debug('IOLoop started @ '
- 'ws://{}:{}{}'.format(host, self.port, route))
-
- def on_connection_opened(self, socket_handler):
- LOG.debug("on_connection_opened")
- self.socket = socket_handler
+ def open(self):
+ GUIWebsocketHandler.clients.append(self)
+ LOG.info('New Connection opened!')
self.synchronize()
+ def on_close(self):
+ LOG.info('Closing {}'.format(id(self)))
+ GUIWebsocketHandler.clients.remove(self)
+
def synchronize(self):
- """ Upload namespaces, pages and data. """
+ """ Upload namespaces, pages and data to the last connected. """
namespace_pos = 0
- for namespace, pages in self.enclosure.loaded:
+ enclosure = self.application.enclosure
+
+ for namespace, pages in enclosure.loaded:
+ LOG.info('Sync {}'.format(namespace))
# Insert namespace
- self.socket.send({"type": "mycroft.session.list.insert",
- "namespace": "mycroft.system.active_skills",
- "position": namespace_pos,
- "data": [{"skill_id": namespace}]
- })
+ self.send({"type": "mycroft.session.list.insert",
+ "namespace": "mycroft.system.active_skills",
+ "position": namespace_pos,
+ "data": [{"skill_id": namespace}]
+ })
# Insert pages
- self.socket.send({"type": "mycroft.gui.list.insert",
- "namespace": namespace,
- "position": 0,
- "data": [{"url": p} for p in pages]
- })
+ self.send({"type": "mycroft.gui.list.insert",
+ "namespace": namespace,
+ "position": 0,
+ "data": [{"url": p} for p in pages]
+ })
# Insert data
- data = self.enclosure.datastore.get(namespace, {})
+ data = enclosure.datastore.get(namespace, {})
for key in data:
- self.socket.send({"type": "mycroft.session.set",
- "namespace": namespace,
- "data": {key: data[key]}
- })
-
+ self.send({"type": "mycroft.session.set",
+ "namespace": namespace,
+ "data": {key: data[key]}
+ })
namespace_pos += 1
- def on_connection_closed(self, socket):
- # Self-destruct (can't reconnect on the same port)
- LOG.debug("on_connection_closed")
- if self.socket:
- LOG.debug("Server stopped: {}".format(self.socket))
- # TODO: How to stop the webapp for this socket?
- # self.socket.stop()
- self.socket = None
- self.callback_disconnect(self.id)
-
-
-class GUIWebsocketHandler(WebSocketHandler):
- """
- The socket pipeline between Qt and Mycroft
- """
-
- def open(self):
- self.application.gui.on_connection_opened(self)
-
def on_message(self, message):
- LOG.debug("Received: {}".format(message))
+ LOG.info("Received: {}".format(message))
msg = json.loads(message)
if (msg.get('type') == "mycroft.events.triggered" and
(msg.get('event_name') == 'page_gained_focus' or
msg.get('event_name') == 'system.gui.user.interaction')):
# System event, a page was changed
msg_type = 'gui.page_interaction'
- msg_data = {
- 'namespace': msg['namespace'],
- 'page_number': msg['parameters'].get('number')
- }
+ msg_data = {'namespace': msg['namespace'],
+ 'page_number': msg['parameters'].get('number')}
elif msg.get('type') == "mycroft.events.triggered":
# A normal event was triggered
msg_type = '{}.{}'.format(msg['namespace'], msg['event_name'])
@@ -602,10 +531,12 @@ def on_message(self, message):
msg_data = msg['data']
message = Message(msg_type, msg_data)
- self.application.gui.enclosure.bus.emit(message)
+ LOG.info('Forwarding to bus...')
+ self.application.enclosure.bus.emit(message)
+ LOG.info('Done!')
def write_message(self, *arg, **kwarg):
- """ Wraps WebSocketHandler.write_message() with a lock. """
+ """Wraps WebSocketHandler.write_message() with a lock. """
try:
asyncio.get_event_loop()
except RuntimeError:
@@ -614,13 +545,6 @@ def write_message(self, *arg, **kwarg):
with write_lock:
super().write_message(*arg, **kwarg)
- def send_message(self, message):
- if isinstance(message, Message):
- self.write_message(message.serialize())
- else:
- LOG.info('message: {}'.format(message))
- self.write_message(str(message))
-
def send(self, data):
"""Send the given data across the socket as JSON
@@ -628,7 +552,9 @@ def send(self, data):
data (dict): Data to transmit
"""
s = json.dumps(data)
+ LOG.info('Sending {}'.format(s))
self.write_message(s)
- def on_close(self):
- self.application.gui.on_connection_closed(self)
+ def check_origin(self, origin):
+ """Disable origin check to make js connections work."""
+ return True
diff --git a/mycroft/client/enclosure/mark1/eyes.py b/mycroft/client/enclosure/mark1/eyes.py
index 10d0eac16d39..668b5416f38a 100644
--- a/mycroft/client/enclosure/mark1/eyes.py
+++ b/mycroft/client/enclosure/mark1/eyes.py
@@ -23,6 +23,9 @@ class EnclosureEyes:
def __init__(self, bus, writer):
self.bus = bus
self.writer = writer
+
+ self._num_pixels = 12 * 2
+ self._current_rgb = [(255, 255, 255) for i in range(self._num_pixels)]
self.__init_events()
def __init_events(self):
@@ -40,6 +43,16 @@ def __init_events(self):
self.bus.on('enclosure.eyes.setpixel', self.set_pixel)
self.bus.on('enclosure.eyes.fill', self.fill)
+ self.bus.on('enclosure.eyes.rgb.get', self.handle_get_color)
+
+ def handle_get_color(self, message):
+ """Get the eye RGB color for all pixels
+ Returns:
+ (list) list of (r,g,b) tuples for each eye pixel
+ """
+ self.bus.emit(message.reply("enclosure.eyes.rgb",
+ {"pixels": self._current_rgb}))
+
def on(self, event=None):
self.writer.write("eyes.on")
@@ -67,6 +80,7 @@ def color(self, event=None):
g = int(event.data.get("g", g))
b = int(event.data.get("b", b))
color = (r * 65536) + (g * 256) + b
+ self._current_rgb = [(r, g, b) for i in range(self._num_pixels)]
self.writer.write("eyes.color=" + str(color))
def set_pixel(self, event=None):
@@ -77,6 +91,7 @@ def set_pixel(self, event=None):
r = int(event.data.get("r", r))
g = int(event.data.get("g", g))
b = int(event.data.get("b", b))
+ self._current_rgb[idx] = (r, g, b)
color = (r * 65536) + (g * 256) + b
self.writer.write("eyes.set=" + str(idx) + "," + str(color))
diff --git a/mycroft/client/speech/hotword_factory.py b/mycroft/client/speech/hotword_factory.py
index 41fccc691e39..32011be0095d 100644
--- a/mycroft/client/speech/hotword_factory.py
+++ b/mycroft/client/speech/hotword_factory.py
@@ -299,7 +299,7 @@ def __init__(self, key_phrase="hey mycroft", config=None, lang="en-us"):
def found_wake_word(self, frame_data):
wake_word = self.snowboy.detector.RunDetection(frame_data)
- return wake_word == 1
+ return wake_word >= 1
class PorcupineHotWord(HotWordEngine):
diff --git a/mycroft/client/speech/mic.py b/mycroft/client/speech/mic.py
index d72c21684102..33ee8bdc8d5d 100644
--- a/mycroft/client/speech/mic.py
+++ b/mycroft/client/speech/mic.py
@@ -50,14 +50,14 @@ 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:
@@ -197,10 +197,12 @@ class ResponsiveRecognizer(speech_recognition.Recognizer):
# before a phrase will be considered complete
MIN_SILENCE_AT_END = 0.25
+ # TODO: Remove in 20.08
# The maximum seconds a phrase can be recorded,
# provided there is noise the entire time
RECORDING_TIMEOUT = 10.0
+ # TODO: Remove in 20.08
# The maximum time it will continue to record silence
# when not enough noise has been detected
RECORDING_TIMEOUT_WITH_SILENCE = 3.0
@@ -252,6 +254,17 @@ def __init__(self, wake_word_recognizer):
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',
+ self.RECORDING_TIMEOUT)
+
+ # 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',
+ self.RECORDING_TIMEOUT_WITH_SILENCE)
+
@property
def account_id(self):
"""Fetch account from backend when needed.
@@ -288,7 +301,7 @@ def _record_phrase(
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
@@ -326,11 +339,11 @@ def decrease_noise(level):
min_loud_chunks = int(self.MIN_LOUD_SEC_PER_PHRASE / sec_per_buffer)
# 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 /
+ max_chunks_of_silence = int(self.recording_timeout_with_silence /
sec_per_buffer)
# bytearray to store audio in
diff --git a/mycroft/configuration/mycroft.conf b/mycroft/configuration/mycroft.conf
index fb781f4eb093..b3e324eb5531 100644
--- a/mycroft/configuration/mycroft.conf
+++ b/mycroft/configuration/mycroft.conf
@@ -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
diff --git a/mycroft/enclosure/api.py b/mycroft/enclosure/api.py
index 1c40b0434484..9b20bb658bf4 100644
--- a/mycroft/enclosure/api.py
+++ b/mycroft/enclosure/api.py
@@ -324,3 +324,25 @@ def deactivate_mouth_events(self):
"""Disable movement of the mouth with speech"""
self.bus.emit(Message('enclosure.mouth.events.deactivate',
context={"destination": ["enclosure"]}))
+
+ def get_eyes_color(self):
+ """Get the eye RGB color for all pixels
+ Returns:
+ (list) pixels - list of (r,g,b) tuples for each eye pixel
+ """
+ message = Message("enclosure.eyes.rgb.get",
+ context={"source": "enclosure_api",
+ "destination": "enclosure"})
+ response = self.bus.wait_for_response(message, "enclosure.eyes.rgb")
+ if response:
+ return response.data["pixels"]
+ raise TimeoutError("Enclosure took too long to respond")
+
+ def get_eyes_pixel_color(self, idx):
+ """Get the RGB color for a specific eye pixel
+ Returns:
+ (r,g,b) tuples for selected pixel
+ """
+ if idx < 0 or idx > 23:
+ raise ValueError('idx ({}) must be between 0-23'.format(str(idx)))
+ return self.get_eyes_color()[idx]
diff --git a/mycroft/res/text/da-dk/and.word b/mycroft/res/text/da-dk/and.word
index ae9a4e643575..3db2cff883a1 100644
--- a/mycroft/res/text/da-dk/and.word
+++ b/mycroft/res/text/da-dk/and.word
@@ -1 +1 @@
-og
\ No newline at end of file
+og
diff --git a/mycroft/res/text/da-dk/backend.down.dialog b/mycroft/res/text/da-dk/backend.down.dialog
index 74122c86570c..00638b919ad9 100644
--- a/mycroft/res/text/da-dk/backend.down.dialog
+++ b/mycroft/res/text/da-dk/backend.down.dialog
@@ -1,4 +1,4 @@
-Jeg har problemer med at kommunikere med Mycroft serverne. Giv mig et par minutter, før du prver at tale med mig.
-Jeg har problemer med at kommunikere med Mycroft serverne. Vent et par minutter, før du prver at tale med mig.
-Det ser ud til, at jeg ikke kan oprette forbindelse til Mycroft-serverne. Giv mig et par minutter, før du prver at tale med mig.
-Det ser ud til, at jeg ikke kan oprette forbindelse til Mycroft-serverne. Vent et par minutter, før du prver at tale med mig.
+Jeg har problemer med at kommunikere med Mycroft-serverne. Giv mig et par minutter, før du prøver at tale med mig.
+Jeg har problemer med at kommunikere med Mycroft-serverne. Vent venligst et par minutter, før du prøver at tale med mig.
+Det ser ud til, at jeg ikke kan oprette forbindelse til Mycroft-serverne. Giv mig et par minutter, før du prøver at tale med mig.
+Det ser ud til, at jeg ikke kan oprette forbindelse til Mycroft-serverne. Vent venligst et par minutter, før du prøver at tale med mig.
diff --git a/mycroft/res/text/da-dk/cancel.voc b/mycroft/res/text/da-dk/cancel.voc
index 4046aa003e63..8bbcf0b913d7 100644
--- a/mycroft/res/text/da-dk/cancel.voc
+++ b/mycroft/res/text/da-dk/cancel.voc
@@ -1,3 +1,4 @@
-afbryd det
-ignorer det
-glem det
\ No newline at end of file
+afbryd
+Glem det
+abort
+afslut
\ No newline at end of file
diff --git a/mycroft/res/text/da-dk/checking for updates.dialog b/mycroft/res/text/da-dk/checking for updates.dialog
index 42ef958d5d99..f82994596708 100644
--- a/mycroft/res/text/da-dk/checking for updates.dialog
+++ b/mycroft/res/text/da-dk/checking for updates.dialog
@@ -1,2 +1,2 @@
-Leder efter opdateringer
-Et øjeblik, mens jeg opdaterer mig selv
\ No newline at end of file
+Kontrollerer for opdateringer
+vent et øjeblik, mens jeg opdaterer mig selv
diff --git a/mycroft/res/text/da-dk/day.word b/mycroft/res/text/da-dk/day.word
index 73e686aad032..9e4015a98474 100644
--- a/mycroft/res/text/da-dk/day.word
+++ b/mycroft/res/text/da-dk/day.word
@@ -1 +1 @@
-dag
\ No newline at end of file
+dag
diff --git a/mycroft/res/text/da-dk/days.word b/mycroft/res/text/da-dk/days.word
index 1b5c2d69c5e1..9d526d03a5ee 100644
--- a/mycroft/res/text/da-dk/days.word
+++ b/mycroft/res/text/da-dk/days.word
@@ -1 +1 @@
-dage
\ No newline at end of file
+dage
diff --git a/mycroft/res/text/da-dk/hour.word b/mycroft/res/text/da-dk/hour.word
index 0082886fdac7..3f3e7c6703ff 100644
--- a/mycroft/res/text/da-dk/hour.word
+++ b/mycroft/res/text/da-dk/hour.word
@@ -1 +1 @@
-time
\ No newline at end of file
+time
diff --git a/mycroft/res/text/da-dk/hours.word b/mycroft/res/text/da-dk/hours.word
index 036d1ab325fd..a0bf4afd1f7e 100644
--- a/mycroft/res/text/da-dk/hours.word
+++ b/mycroft/res/text/da-dk/hours.word
@@ -1 +1 @@
-timer
\ No newline at end of file
+timer
diff --git a/mycroft/res/text/da-dk/i didn't catch that.dialog b/mycroft/res/text/da-dk/i didn't catch that.dialog
index 31feeef08fe8..8cc475e38df4 100644
--- a/mycroft/res/text/da-dk/i didn't catch that.dialog
+++ b/mycroft/res/text/da-dk/i didn't catch that.dialog
@@ -1,4 +1,4 @@
-Desværre, det forstod jeg ikke
-Jeg er bange for, at jeg ikke kunne forstå det
-Kan du sige det igen?
-Kan du gentage det?
\ No newline at end of file
+Undskyld, Det fangede jeg ikke
+Jeg er bange for, at jeg ikke kunne forstå det
+Kan du sige det igen?
+Kan du venligst gentage det?
diff --git a/mycroft/res/text/da-dk/last.voc b/mycroft/res/text/da-dk/last.voc
new file mode 100644
index 000000000000..bcd713e75292
--- /dev/null
+++ b/mycroft/res/text/da-dk/last.voc
@@ -0,0 +1,3 @@
+sidste valg
+sidste mulighed
+sidste
diff --git a/mycroft/res/text/da-dk/learning disabled.dialog b/mycroft/res/text/da-dk/learning disabled.dialog
index 5bddc74cc43b..2c53e154a6f0 100644
--- a/mycroft/res/text/da-dk/learning disabled.dialog
+++ b/mycroft/res/text/da-dk/learning disabled.dialog
@@ -1 +1 @@
-Interaktionsdata vil ikke længere blive sendt til Mycroft AI.
\ No newline at end of file
+Interaktionsdata vil ikke længere blive sendt til Mycroft AI.
diff --git a/mycroft/res/text/da-dk/learning enabled.dialog b/mycroft/res/text/da-dk/learning enabled.dialog
index ed63881f9c7e..eba2ab033451 100644
--- a/mycroft/res/text/da-dk/learning enabled.dialog
+++ b/mycroft/res/text/da-dk/learning enabled.dialog
@@ -1 +1 @@
-Jeg vil nu uploade interaktionsdata til Mycroft AI, så jeg kan blive klogere. I øjeblikket omfatter dette optagelser af wake-up ord.
\ No newline at end of file
+Jeg vil nu uploade interaktionsdata til Mycroft AI for at give mig mulighed for at blive smartere. I øjeblikket inkluderer dette optagelser af wake-word-aktiveringer.
diff --git a/mycroft/res/text/da-dk/message_loading.skills.dialog b/mycroft/res/text/da-dk/message_loading.skills.dialog
new file mode 100644
index 000000000000..8827db56fdf4
--- /dev/null
+++ b/mycroft/res/text/da-dk/message_loading.skills.dialog
@@ -0,0 +1 @@
+< < < INDLÆSER < < <>
diff --git a/mycroft/res/text/da-dk/message_rebooting.dialog b/mycroft/res/text/da-dk/message_rebooting.dialog
index fe2a1660cba3..f21d8c4d7cb0 100644
--- a/mycroft/res/text/da-dk/message_rebooting.dialog
+++ b/mycroft/res/text/da-dk/message_rebooting.dialog
@@ -1 +1 @@
-STARTAR IGEN...
+GENSTARTER...
diff --git a/mycroft/res/text/da-dk/message_synching.clock.dialog b/mycroft/res/text/da-dk/message_synching.clock.dialog
index da7303a6fd97..81d09f90deb9 100644
--- a/mycroft/res/text/da-dk/message_synching.clock.dialog
+++ b/mycroft/res/text/da-dk/message_synching.clock.dialog
@@ -1 +1 @@
-< < < SYNKRONISERE < < <
+< < < SYNC < < <
\ No newline at end of file
diff --git a/mycroft/res/text/da-dk/message_updating.dialog b/mycroft/res/text/da-dk/message_updating.dialog
index 8b67540bcada..22ff0d847d99 100644
--- a/mycroft/res/text/da-dk/message_updating.dialog
+++ b/mycroft/res/text/da-dk/message_updating.dialog
@@ -1 +1 @@
-< < < OPDATERER < < <
+< < < OPDATERER < < <
diff --git a/mycroft/res/text/da-dk/minute.word b/mycroft/res/text/da-dk/minute.word
index 4b98366b131c..155a2f0b856d 100644
--- a/mycroft/res/text/da-dk/minute.word
+++ b/mycroft/res/text/da-dk/minute.word
@@ -1 +1 @@
-minut
\ No newline at end of file
+minut
diff --git a/mycroft/res/text/da-dk/minutes.word b/mycroft/res/text/da-dk/minutes.word
index caf1b024a4e6..476acc7b3912 100644
--- a/mycroft/res/text/da-dk/minutes.word
+++ b/mycroft/res/text/da-dk/minutes.word
@@ -1 +1 @@
-minuter
\ No newline at end of file
+minutter
diff --git a/mycroft/res/text/da-dk/mycroft.intro.dialog b/mycroft/res/text/da-dk/mycroft.intro.dialog
index 70d255642dbc..beb065ceef6d 100644
--- a/mycroft/res/text/da-dk/mycroft.intro.dialog
+++ b/mycroft/res/text/da-dk/mycroft.intro.dialog
@@ -1 +1 @@
-Hej Jeg er Mycroft, din nye assistent. For at hjælpe dig skal jeg være forbundet til internettet. Du kan enten forbinde mig med et netværkskabel eller bruge wifi. Følg disse instruktioner for at konfigurere Wi-Fi:
\ No newline at end of file
+Hej jeg er Mycroft, din nye assistent. For at hjælpe dig skal jeg være tilsluttet internettet. Du kan enten tilslutte mig et netværkskabel, eller brug wifi. Følg disse instruktioner for at konfigurere wifi:
diff --git a/mycroft/res/text/da-dk/no.voc b/mycroft/res/text/da-dk/no.voc
index 30b4969de720..ec9532ea922d 100644
--- a/mycroft/res/text/da-dk/no.voc
+++ b/mycroft/res/text/da-dk/no.voc
@@ -1,5 +1,5 @@
-no
nope
-nah
-negative
-nej
+nix
+nah
+negativ
+nej
\ No newline at end of file
diff --git a/mycroft/res/text/da-dk/not connected to the internet.dialog b/mycroft/res/text/da-dk/not connected to the internet.dialog
index d7b137657c91..54a31dfac742 100644
--- a/mycroft/res/text/da-dk/not connected to the internet.dialog
+++ b/mycroft/res/text/da-dk/not connected to the internet.dialog
@@ -1,4 +1,5 @@
-Det ser ud til, at jeg ikke har forbindelse til internettet
-Jeg synes ikke at være forbundet til internettet
-Jeg kan ikke nå internettet lige nu
-Jeg kan ikke nå internettet
\ No newline at end of file
+Det ser ud til, at jeg ikke har forbindelse til Internettet, Kontroller din netværksforbindelse.
+Jeg ser ikke ud til at være tilsluttet internettet, Kontroller din netværksforbindelse.
+Jeg kan ikke forbinde til internettet lige nu, Kontroller din netværksforbindelse.
+Jeg kan ikke forbinde til internettet, Kontroller din netværksforbindelse.
+Jeg har problemer med at forbinde til internettet lige nu, Kontroller din netværksforbindelse.
diff --git a/mycroft/res/text/da-dk/not.loaded.dialog b/mycroft/res/text/da-dk/not.loaded.dialog
index 421bd4301189..e82e8c78ae1d 100644
--- a/mycroft/res/text/da-dk/not.loaded.dialog
+++ b/mycroft/res/text/da-dk/not.loaded.dialog
@@ -1,5 +1 @@
-Jeg har problemer med at kommunikere med Mycroft serverne. Giv mig et par minutter, før du prver at tale til mig.
-Jeg har problemer med at kommunikere med Mycroft serverne. Vent et par minutter, før du prver at tale til mig.
-Det ser ud til, at jeg ikke kan oprette forbindelse til Mycroft-serverne. Giv mig et par minutter, før du prøver at tale til mig.
-Det ser ud til, at jeg ikke kan oprette forbindelse til Mycroft-serverne. Vent et par minutter, før du prøver at tale til mig.
-Vent et øjeblik, til jeg er færdig med at starte op.
+Vent et øjeblik, til jeg er færdig med at starte op.
diff --git a/mycroft/res/text/da-dk/or.word b/mycroft/res/text/da-dk/or.word
index d99648a07ac1..3a97698c63bd 100644
--- a/mycroft/res/text/da-dk/or.word
+++ b/mycroft/res/text/da-dk/or.word
@@ -1 +1 @@
-eller
\ No newline at end of file
+eller
diff --git a/mycroft/res/text/da-dk/phonetic_spellings.txt b/mycroft/res/text/da-dk/phonetic_spellings.txt
index 7d0ecbe6f02a..06eb41a79b88 100644
--- a/mycroft/res/text/da-dk/phonetic_spellings.txt
+++ b/mycroft/res/text/da-dk/phonetic_spellings.txt
@@ -7,3 +7,7 @@ seksten: sejsten
spotify: spåtifej
spot-ify: spåtifej
chat: tjat
+wifi: vejfej
+uploade: oplåte
+wake-word-aktiveringer: wæik word aktiveringer
+SSH-login: SSH-log-in
\ No newline at end of file
diff --git a/mycroft/res/text/da-dk/reset to factory defaults.dialog b/mycroft/res/text/da-dk/reset to factory defaults.dialog
index fc1c30b7a1f8..ec343723a861 100644
--- a/mycroft/res/text/da-dk/reset to factory defaults.dialog
+++ b/mycroft/res/text/da-dk/reset to factory defaults.dialog
@@ -1 +1 @@
-Jeg er blevet nulstillet til fabriksindstillingerne.
\ No newline at end of file
+Jeg er nulstillet til fabriksindstillinger.
diff --git a/mycroft/res/text/da-dk/second.word b/mycroft/res/text/da-dk/second.word
index 300f8e50c43a..1c4aa7d3d59f 100644
--- a/mycroft/res/text/da-dk/second.word
+++ b/mycroft/res/text/da-dk/second.word
@@ -1 +1 @@
-sekund
\ No newline at end of file
+anden
diff --git a/mycroft/res/text/da-dk/seconds.word b/mycroft/res/text/da-dk/seconds.word
index aa5fc1206453..d27a05a918ba 100644
--- a/mycroft/res/text/da-dk/seconds.word
+++ b/mycroft/res/text/da-dk/seconds.word
@@ -1 +1 @@
-sekunder
\ No newline at end of file
+sekunder
diff --git a/mycroft/res/text/da-dk/skill.error.dialog b/mycroft/res/text/da-dk/skill.error.dialog
index 34e98a9e8f5e..8ea1edecd312 100644
--- a/mycroft/res/text/da-dk/skill.error.dialog
+++ b/mycroft/res/text/da-dk/skill.error.dialog
@@ -1,6 +1 @@
-Jeg har problemer med at kommunikere med Mycroft serverne. Giv mig et par minutter, før du prver at tale med mig.
-Jeg har problemer med at kommunikere med Mycroft serverne. Vent et par minutter, før du prver at tale med mig.
-Det ser ud til, at jeg ikke kan oprette forbindelse til Mycroft-serverne. Giv mig et par minutter, før du prver at tale med mig.
-Det ser ud til, at jeg ikke kan oprette forbindelse til Mycroft-serverne. Vent et par minutter, før du prøver at tale med mig.
-Vent et øjeblik, til jeg er førdig med at starte op.
-Der opstod en fejl under behandling af en anmodning i {{skill}}
+Der opstod en fejl under behandling af en anmodning i {{skill}}
diff --git a/mycroft/res/text/da-dk/skills updated.dialog b/mycroft/res/text/da-dk/skills updated.dialog
index ec4f312653a7..cb74fe479876 100644
--- a/mycroft/res/text/da-dk/skills updated.dialog
+++ b/mycroft/res/text/da-dk/skills updated.dialog
@@ -1,2 +1,2 @@
-Jeg har nu opdateret mine færdigheder. Jeg kan derfor godt hjælpe dig nu
-Mine færdigheder er nu opdateret. Jeg er klar til at hjælpe dig.
\ No newline at end of file
+Jeg er opdateret nu
+Færdigheder opdateret. Jeg er klar til at hjælpe dig.
diff --git a/mycroft/res/text/da-dk/sorry I couldn't install default skills.dialog b/mycroft/res/text/da-dk/sorry I couldn't install default skills.dialog
index 68f98e675590..d2a863365bc5 100644
--- a/mycroft/res/text/da-dk/sorry I couldn't install default skills.dialog
+++ b/mycroft/res/text/da-dk/sorry I couldn't install default skills.dialog
@@ -1 +1 @@
-Der opstod en fejl under opdatering af færdigheder
\ No newline at end of file
+der opstod en fejl under opdatering af færdigheder
diff --git a/mycroft/res/text/da-dk/ssh disabled.dialog b/mycroft/res/text/da-dk/ssh disabled.dialog
index a6c43c09a4bc..70547678d4f8 100644
--- a/mycroft/res/text/da-dk/ssh disabled.dialog
+++ b/mycroft/res/text/da-dk/ssh disabled.dialog
@@ -1 +1 @@
-SSH login er blevet deaktiveret
\ No newline at end of file
+SSH-login er deaktiveret
diff --git a/mycroft/res/text/da-dk/ssh enabled.dialog b/mycroft/res/text/da-dk/ssh enabled.dialog
index de6ef14aa48e..e19adcca99bc 100644
--- a/mycroft/res/text/da-dk/ssh enabled.dialog
+++ b/mycroft/res/text/da-dk/ssh enabled.dialog
@@ -1 +1 @@
-SSH logins er nu tilladt
\ No newline at end of file
+SSH-login er nu tilladt
diff --git a/mycroft/res/text/da-dk/time.changed.reboot.dialog b/mycroft/res/text/da-dk/time.changed.reboot.dialog
index 51f986bb82fa..603f5f749062 100644
--- a/mycroft/res/text/da-dk/time.changed.reboot.dialog
+++ b/mycroft/res/text/da-dk/time.changed.reboot.dialog
@@ -1 +1 @@
-Jeg skal genstarte efter synkronisering af mit ur med internettet, er snart tilbage.
\ No newline at end of file
+Jeg bliver nødt til at genstarte efter at have synkroniseret mit ur med internettet. Jeg er straks tilbage.
diff --git a/mycroft/res/text/da-dk/yes.voc b/mycroft/res/text/da-dk/yes.voc
index 13817b120f11..8ff628abf811 100644
--- a/mycroft/res/text/da-dk/yes.voc
+++ b/mycroft/res/text/da-dk/yes.voc
@@ -1,6 +1,5 @@
-yes
-yeah
-yep
-ja
-ja tak
-tak
\ No newline at end of file
+Ja
+yeah
+jep
+jo da
+jepper
\ No newline at end of file
diff --git a/mycroft/skills/audioservice.py b/mycroft/skills/audioservice.py
index 0ec301dc8a1c..e138dc4df62b 100644
--- a/mycroft/skills/audioservice.py
+++ b/mycroft/skills/audioservice.py
@@ -149,7 +149,7 @@ def track_info(self):
info = self.bus.wait_for_response(
Message('mycroft.audio.service.track_info'),
reply_type='mycroft.audio.service.track_info_reply',
- timeout=5)
+ timeout=1)
return info.data if info else {}
def available_backends(self):
diff --git a/mycroft/skills/intent_service.py b/mycroft/skills/intent_service.py
index c7d2b14e6f31..f92b6ca18ade 100644
--- a/mycroft/skills/intent_service.py
+++ b/mycroft/skills/intent_service.py
@@ -19,7 +19,6 @@
from adapt.intent import IntentBuilder
from mycroft.configuration import Configuration
-from mycroft.messagebus.message import Message
from mycroft.util.lang import set_active_lang
from mycroft.util.log import LOG
from mycroft.util.parse import normalize
@@ -124,7 +123,6 @@ def get_context(self, max_frames=None, missing_entities=None):
if entity['origin'] != last or entity['origin'] == '':
depth += 1
last = entity['origin']
- print(depth)
result = []
if len(missing_entities) > 0:
@@ -175,8 +173,6 @@ def __init__(self, bus):
self.bus.on('remove_context', self.handle_remove_context)
self.bus.on('clear_context', self.handle_clear_context)
# Converse method
- self.bus.on('skill.converse.response', self.handle_converse_response)
- self.bus.on('skill.converse.error', self.handle_converse_error)
self.bus.on('mycroft.speech.recognition.unknown', self.reset_converse)
self.bus.on('mycroft.skills.loaded', self.update_skill_name_dict)
@@ -186,9 +182,6 @@ def add_active_skill_handler(message):
self.bus.on('active_skill_request', add_active_skill_handler)
self.active_skills = [] # [skill_id , timestamp]
self.converse_timeout = 5 # minutes to prune active_skills
- self.waiting_for_converse = False
- self.converse_result = False
- self.converse_skill_id = ""
# Intents API
self.registered_intents = []
@@ -203,14 +196,11 @@ def add_active_skill_handler(message):
self.handle_vocab_manifest)
def update_skill_name_dict(self, message):
- """
- Messagebus handler, updates dictionary of if to skill name
- conversions.
- """
+ """Messagebus handler, updates dict of id to skill name conversions."""
self.skill_names[message.data['id']] = message.data['name']
def get_skill_name(self, skill_id):
- """ Get skill name from skill ID.
+ """Get skill name from skill ID.
Args:
skill_id: a skill id as encoded in Intent handlers.
@@ -228,33 +218,23 @@ def reset_converse(self, message):
self.do_converse(None, skill[0], lang, message)
def do_converse(self, utterances, skill_id, lang, message):
- self.waiting_for_converse = True
- self.converse_result = False
- self.converse_skill_id = skill_id
- self.bus.emit(message.reply("skill.converse.request", {
+ converse_msg = (message.reply("skill.converse.request", {
"skill_id": skill_id, "utterances": utterances, "lang": lang}))
- start_time = time.time()
- t = 0
- while self.waiting_for_converse and t < 5:
- t = time.time() - start_time
- time.sleep(0.1)
- self.waiting_for_converse = False
- self.converse_skill_id = ""
- return self.converse_result
+ 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
+ elif result is not None:
+ return result.data.get('result', False)
+ else:
+ return False
def handle_converse_error(self, message):
+ 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)
- if skill_id == self.converse_skill_id:
- self.converse_result = False
- self.waiting_for_converse = False
-
- def handle_converse_response(self, message):
- skill_id = message.data["skill_id"]
- if skill_id == self.converse_skill_id:
- self.converse_result = message.data.get("result", False)
- self.waiting_for_converse = False
def remove_active_skill(self, skill_id):
for skill in self.active_skills:
@@ -262,14 +242,26 @@ def remove_active_skill(self, skill_id):
self.active_skills.remove(skill)
def add_active_skill(self, skill_id):
+ """Add a skill or update the position of an active skill.
+
+ The skill is added to the front of the list, if it's already in the
+ list it's removed so there is only a single entry of it.
+
+ Arguments:
+ skill_id (str): identifier of skill to be added.
+ """
# search the list for an existing entry that already contains it
# and remove that reference
- self.remove_active_skill(skill_id)
- # add skill with timestamp to start of skill_list
- self.active_skills.insert(0, [skill_id, time.time()])
+ if skill_id != '':
+ self.remove_active_skill(skill_id)
+ # add skill with timestamp to start of skill_list
+ self.active_skills.insert(0, [skill_id, time.time()])
+ else:
+ 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.
+ """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
@@ -288,8 +280,7 @@ def update_context(self, intent):
self.context_manager.inject_context(context_entity)
def send_metrics(self, intent, context, stopwatch):
- """
- Send timing metrics to the backend.
+ """Send timing metrics to the backend.
NOTE: This only applies to those with Opt In.
"""
@@ -307,7 +298,7 @@ def send_metrics(self, intent, context, stopwatch):
{'intent_type': 'intent_failure'})
def handle_utterance(self, message):
- """ Main entrypoint for handling user utterances with Mycroft skills
+ """Main entrypoint for handling user utterances with Mycroft skills
Monitor the messagebus for 'recognizer_loop:utterance', typically
generated by a spoken interaction but potentially also from a CLI
@@ -402,7 +393,7 @@ def handle_utterance(self, message):
LOG.exception(e)
def _converse(self, utterances, lang, message):
- """ Give active skills a chance at the utterance
+ """Give active skills a chance at the utterance
Args:
utterances (list): list of utterances
@@ -419,7 +410,7 @@ def _converse(self, utterances, lang, message):
1] <= self.converse_timeout * 60]
# check if any skill wants to handle utterance
- for skill in self.active_skills:
+ for skill in copy(self.active_skills):
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
@@ -428,7 +419,7 @@ def _converse(self, utterances, lang, message):
return False
def _adapt_intent_match(self, raw_utt, norm_utt, lang):
- """ Run the Adapt engine to search for an matching intent
+ """Run the Adapt engine to search for an matching intent
Args:
raw_utt (list): list of utterances
@@ -500,7 +491,7 @@ def handle_detach_skill(self, message):
self.engine.intent_parsers = new_parsers
def handle_add_context(self, message):
- """ Add context
+ """Add context
Args:
message: data contains the 'context' item to add
@@ -521,7 +512,7 @@ def handle_add_context(self, message):
self.context_manager.inject_context(entity)
def handle_remove_context(self, message):
- """ Remove specific context
+ """Remove specific context
Args:
message: data contains the 'context' item to remove
@@ -531,7 +522,7 @@ def handle_remove_context(self, message):
self.context_manager.remove_context(context)
def handle_clear_context(self, message):
- """ Clears all keywords from context """
+ """Clears all keywords from context """
self.context_manager.clear_context()
def handle_get_adapt(self, message):
diff --git a/mycroft/skills/mycroft_skill/mycroft_skill.py b/mycroft/skills/mycroft_skill/mycroft_skill.py
index 77c0683e3ea6..4f3b6d9da672 100644
--- a/mycroft/skills/mycroft_skill/mycroft_skill.py
+++ b/mycroft/skills/mycroft_skill/mycroft_skill.py
@@ -392,9 +392,9 @@ def validator_default(utterance):
validator = validator or validator_default
# Speak query and wait for user response
- utterance = self.dialog_renderer.render(dialog, data)
- if utterance:
- self.speak(utterance, expect_response=True, wait=True)
+ dialog_exists = self.dialog_renderer.render(dialog, data)
+ if dialog_exists:
+ self.speak_dialog(dialog, data, expect_response=True, wait=True)
else:
self.bus.emit(Message('mycroft.mic.listen'))
return self._wait_response(is_cancel, validator, on_fail_fn,
@@ -1058,7 +1058,7 @@ def register_regex(self, regex_str):
re.compile(regex) # validate regex
self.intent_service.register_adapt_regex(regex)
- def speak(self, utterance, expect_response=False, wait=False):
+ def speak(self, utterance, expect_response=False, wait=False, meta=None):
"""Speak a sentence.
Arguments:
@@ -1068,11 +1068,15 @@ def speak(self, utterance, expect_response=False, wait=False):
speaking the utterance.
wait (bool): set to True to block while the text
is being spoken.
+ meta: Information of what built the sentence.
"""
# registers the skill as being active
+ meta = meta or {}
+ meta['skill'] = self.name
self.enclosure.register(self.name)
data = {'utterance': utterance,
- 'expect_response': expect_response}
+ 'expect_response': expect_response,
+ 'meta': meta}
message = dig_for_message()
m = message.forward("speak", data) if message \
else Message("speak", data)
@@ -1096,7 +1100,7 @@ def speak_dialog(self, key, data=None, expect_response=False, wait=False):
"""
data = data or {}
self.speak(self.dialog_renderer.render(key, data),
- expect_response, wait)
+ expect_response, wait, meta={'dialog': key, 'data': data})
def acknowledge(self):
"""Acknowledge a successful request.
diff --git a/mycroft/skills/padatious_service.py b/mycroft/skills/padatious_service.py
index 04b18c5a755c..5d0652228a89 100644
--- a/mycroft/skills/padatious_service.py
+++ b/mycroft/skills/padatious_service.py
@@ -82,6 +82,10 @@ def __init__(self, bus, service):
self.registered_intents = []
self.registered_entities = []
+ def make_active(self):
+ """Override the make active since this is not a real fallback skill."""
+ pass
+
def train(self, message=None):
padatious_single_thread = Configuration.get()[
'padatious']['single_thread']
diff --git a/mycroft/skills/skill_manager.py b/mycroft/skills/skill_manager.py
index 6ddb6e9412c8..cc166b003707 100644
--- a/mycroft/skills/skill_manager.py
+++ b/mycroft/skills/skill_manager.py
@@ -16,7 +16,7 @@
import os
from glob import glob
from threading import Thread, Event, Lock
-from time import sleep, time
+from time import sleep, time, monotonic
from mycroft.api import is_paired
from mycroft.enclosure.api import EnclosureAPI
@@ -48,8 +48,12 @@ def __init__(self):
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."""
+ self.started = False
def send(self):
"""Loop through all stored loaders triggering settingsmeta upload."""
@@ -59,7 +63,10 @@ def send(self):
if queue:
LOG.info('New Settings meta to upload.')
for loader in queue:
- loader.instance.settings_meta.upload()
+ if self.started:
+ loader.instance.settings_meta.upload()
+ else:
+ break
def __len__(self):
return len(self._queue)
@@ -77,6 +84,29 @@ def put(self, loader):
self._queue.append(loader)
+def _shutdown_skill(instance):
+ """Shutdown a skill.
+
+ Call the default_shutdown method of the skill, will produce a warning if
+ the shutdown process takes longer than 1 second.
+
+ Arguments:
+ instance (MycroftSkill): Skill instance to shutdown
+ """
+ try:
+ ref_time = monotonic()
+ # Perform the shutdown
+ instance.default_shutdown()
+
+ shutdown_time = monotonic() - ref_time
+ if shutdown_time > 1:
+ LOG.warning('{} shutdown took {} seconds'.format(instance.skill_id,
+ shutdown_time))
+ except Exception:
+ LOG.exception('Failed to shut down skill: '
+ '{}'.format(instance.skill_id))
+
+
class SkillManager(Thread):
_msm = None
@@ -378,16 +408,12 @@ def stop(self):
"""Tell the manager to shutdown."""
self._stop_event.set()
self.settings_downloader.stop_downloading()
+ self.upload_queue.stop()
# Do a clean shutdown of all skills
for skill_loader in self.skill_loaders.values():
if skill_loader.instance is not None:
- try:
- skill_loader.instance.default_shutdown()
- except Exception:
- LOG.exception(
- 'Failed to shut down skill: ' + skill_loader.skill_id
- )
+ _shutdown_skill(skill_loader.instance)
def handle_converse_request(self, message):
"""Check if the targeted skill id can handle conversation
@@ -406,7 +432,10 @@ def handle_converse_request(self, message):
self._emit_converse_error(message, skill_id, error_message)
break
try:
- self._emit_converse_response(message, skill_loader)
+ utterances = message.data['utterances']
+ lang = message.data['lang']
+ result = skill_loader.instance.converse(utterances, lang)
+ self._emit_converse_response(result, message, skill_loader)
except Exception:
error_message = 'exception in converse method'
LOG.exception(error_message)
@@ -419,16 +448,17 @@ def handle_converse_request(self, message):
self._emit_converse_error(message, skill_id, error_message)
def _emit_converse_error(self, message, skill_id, error_msg):
- reply = message.reply(
- 'skill.converse.error',
- data=dict(skill_id=skill_id, error=error_msg)
- )
+ """Emit a message reporting the error back to the intent service."""
+ 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, message, skill_loader):
- utterances = message.data['utterances']
- lang = message.data['lang']
- result = skill_loader.instance.converse(utterances, lang)
+ def _emit_converse_response(self, result, message, skill_loader):
reply = message.reply(
'skill.converse.response',
data=dict(skill_id=skill_loader.skill_id, result=result)
diff --git a/mycroft/tts/dummy_tts.py b/mycroft/tts/dummy_tts.py
new file mode 100644
index 000000000000..86172d80c7a5
--- /dev/null
+++ b/mycroft/tts/dummy_tts.py
@@ -0,0 +1,45 @@
+# 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.
+#
+
+"""A Dummy TTS without any audio output."""
+
+from mycroft.util.log import LOG
+
+from .tts import TTS, TTSValidator
+
+
+class DummyTTS(TTS):
+ def __init__(self, lang, config):
+ super().__init__(lang, config, DummyValidator(self), 'wav')
+
+ def execute(self, sentence, ident=None, listen=False):
+ """Don't do anything, return nothing."""
+ LOG.info('Mycroft: {}'.format(sentence))
+ return None
+
+
+class DummyValidator(TTSValidator):
+ """Do no tests."""
+ def __init__(self, tts):
+ super().__init__(tts)
+
+ def validate_lang(self):
+ pass
+
+ def validate_connection(self):
+ pass
+
+ def get_tts_class(self):
+ return DummyTTS
diff --git a/mycroft/tts/google_tts.py b/mycroft/tts/google_tts.py
index 0bb3c0441ec6..7f8aef028fea 100755
--- a/mycroft/tts/google_tts.py
+++ b/mycroft/tts/google_tts.py
@@ -13,13 +13,19 @@
# limitations under the License.
#
from gtts import gTTS
+from gtts.lang import tts_langs
from .tts import TTS, TTSValidator
+supported_langs = tts_langs()
+
class GoogleTTS(TTS):
"""Interface to google TTS."""
def __init__(self, lang, config):
+ if lang.lower() not in supported_langs and \
+ lang[:2].lower() in supported_langs:
+ lang = lang[:2]
super(GoogleTTS, self).__init__(lang, config, GoogleTTSValidator(
self), 'mp3')
@@ -42,8 +48,10 @@ def __init__(self, tts):
super(GoogleTTSValidator, self).__init__(tts)
def validate_lang(self):
- # TODO
- pass
+ lang = self.tts.lang
+ if lang.lower() not in supported_langs:
+ raise ValueError("Language not supported by gTTS: {}"
+ .format(lang))
def validate_connection(self):
try:
diff --git a/mycroft/tts/ibm_tts.py b/mycroft/tts/ibm_tts.py
index 7263b5f962eb..d07e23ba973a 100644
--- a/mycroft/tts/ibm_tts.py
+++ b/mycroft/tts/ibm_tts.py
@@ -23,13 +23,17 @@ class WatsonTTS(RemoteTTS):
PARAMS = {'accept': 'audio/wav'}
def __init__(self, lang, config,
- url="https://stream.watsonplatform.net/text-to-speech/api"):
- super(WatsonTTS, self).__init__(lang, config, url, '/v1/synthesize',
+ url='https://stream.watsonplatform.net/text-to-speech/api',
+ api_path='/v1/synthesize'):
+ super(WatsonTTS, self).__init__(lang, config, url, api_path,
WatsonTTSValidator(self))
self.type = "wav"
user = self.config.get("user") or self.config.get("username")
password = self.config.get("password")
api_key = self.config.get("apikey")
+ if self.url.endswith(api_path):
+ self.url = self.url[:-len(api_path)]
+
if api_key is None:
self.auth = HTTPBasicAuth(user, password)
else:
@@ -39,6 +43,8 @@ def build_request_params(self, sentence):
params = self.PARAMS.copy()
params['LOCALE'] = self.lang
params['voice'] = self.voice
+ params['X-Watson-Learning-Opt-Out'] = self.config.get(
+ 'X-Watson-Learning-Opt-Out', 'true')
params['text'] = sentence.encode('utf-8')
return params
diff --git a/mycroft/tts/remote_tts.py b/mycroft/tts/remote_tts.py
index 0c64520e82a7..8c70eba48a42 100644
--- a/mycroft/tts/remote_tts.py
+++ b/mycroft/tts/remote_tts.py
@@ -17,7 +17,7 @@
from requests_futures.sessions import FuturesSession
from .tts import TTS
-from mycroft.util import remove_last_slash, play_wav
+from mycroft.util import play_wav
from mycroft.util.log import LOG
@@ -41,7 +41,7 @@ def __init__(self, lang, config, url, api_path, validator):
super(RemoteTTS, self).__init__(lang, config, validator)
self.api_path = api_path
self.auth = None
- self.url = remove_last_slash(url)
+ self.url = config.get('url', url).rstrip('/')
self.session = FuturesSession()
def execute(self, sentence, ident=None, listen=False):
diff --git a/mycroft/tts/tts.py b/mycroft/tts/tts.py
index 3ab244bb773e..4cd036d763ec 100644
--- a/mycroft/tts/tts.py
+++ b/mycroft/tts/tts.py
@@ -40,6 +40,9 @@
_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.
@@ -51,6 +54,7 @@ def __init__(self, queue):
self._terminated = False
self._processing_queue = False
self.enclosure = None
+ self.p = None
# Check if the tts shall have a ducking role set
if Configuration.get().get('tts', {}).get('pulse_duck'):
self.pulse_env = _TTS_ENV
@@ -102,8 +106,9 @@ def run(self):
self.p = play_mp3(data, environment=self.pulse_env)
if visemes:
self.show_visemes(visemes)
- self.p.communicate()
- self.p.wait()
+ if self.p:
+ self.p.communicate()
+ self.p.wait()
report_timing(ident, 'speech_playback', stopwatch)
if self.queue.empty():
@@ -312,6 +317,15 @@ def execute(self, sentence, ident=None, listen=False):
sentence = self.validate_ssml(sentence)
create_signal("isSpeaking")
+ try:
+ self._execute(sentence, ident, listen)
+ except Exception:
+ # 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:
for word in re.findall(r"[\w']+", sentence):
if word.lower() in self.spellings:
@@ -462,6 +476,7 @@ class TTSFactory:
from mycroft.tts.responsive_voice_tts import ResponsiveVoice
from mycroft.tts.mimic2_tts import Mimic2
from mycroft.tts.yandex_tts import YandexTTS
+ from mycroft.tts.dummy_tts import DummyTTS
CLASSES = {
"mimic": Mimic,
@@ -474,7 +489,8 @@ class TTSFactory:
"watson": WatsonTTS,
"bing": BingTTS,
"responsive_voice": ResponsiveVoice,
- "yandex": YandexTTS
+ "yandex": YandexTTS,
+ "dummy": DummyTTS
}
@staticmethod
diff --git a/mycroft/util/format.py b/mycroft/util/format.py
index 358be0eb138a..d8b1faa907a0 100644
--- a/mycroft/util/format.py
+++ b/mycroft/util/format.py
@@ -307,8 +307,6 @@ def _duration_handler(time1, lang=None, speech=True, *, time2=None,
if len(out.split()) > 3 or seconds < 1:
out += _translate_word("and", lang) + " "
# speaking "zero point five seconds" is better than "point five"
- if seconds < 1:
- out += pronounce_number(0, lang)
out += pronounce_number(seconds, lang) + " "
out += _translate_word("second" if seconds ==
1 else "seconds", lang)
diff --git a/mycroft/version/__init__.py b/mycroft/version/__init__.py
index c76f61f950e1..279f03fb56b5 100644
--- a/mycroft/version/__init__.py
+++ b/mycroft/version/__init__.py
@@ -25,7 +25,7 @@
# START_VERSION_BLOCK
CORE_VERSION_MAJOR = 20
CORE_VERSION_MINOR = 2
-CORE_VERSION_BUILD = 1
+CORE_VERSION_BUILD = 2
# END_VERSION_BLOCK
CORE_VERSION_TUPLE = (CORE_VERSION_MAJOR,
@@ -45,7 +45,7 @@ def get():
return json.load(f)
except Exception:
LOG.error("Failed to load version from '%s'" % version_file)
- return {"coreVersion": None, "enclosureVersion": None}
+ return {"coreVersion": CORE_VERSION_STR, "enclosureVersion": None}
def check_version(version_string):
diff --git a/requirements.txt b/requirements.txt
index 26ad94823c15..22720d93cc66 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,7 +10,7 @@ requests-futures==0.9.5
pyalsaaudio==0.8.2
xmlrunner==1.7.7
pyserial==3.0
-psutil==5.2.1
+psutil==5.6.6
pocketsphinx==0.1.0
inflection==0.3.1
pillow==6.2.1
@@ -21,7 +21,7 @@ google-api-python-client==1.6.4
fasteners==0.14.1
PyYAML==5.1.2
-lingua-franca==0.2.0
+lingua-franca==0.2.1
msm==0.8.7
msk==0.3.14
adapt-parser==0.3.4
diff --git a/scripts/prepare-msm.sh b/scripts/prepare-msm.sh
index a7f948edb90f..aae08b96f94c 100755
--- a/scripts/prepare-msm.sh
+++ b/scripts/prepare-msm.sh
@@ -43,7 +43,7 @@ fi
# change ownership of ${mycroft_root_dir} to ${setup_user } recursively
function change_ownership {
echo "Changing ownership of" ${mycroft_root_dir} "to user:" ${setup_user} "with group:" ${setup_group}
- $SUDO chown -Rvf ${setup_user}:${setup_group} ${mycroft_root_dir}
+ $SUDO chown -Rf ${setup_user}:${setup_group} ${mycroft_root_dir}
}
diff --git a/start-mycroft.sh b/start-mycroft.sh
index 1397756768d6..3bc4f7bf3a18 100755
--- a/start-mycroft.sh
+++ b/start-mycroft.sh
@@ -40,6 +40,7 @@ function help() {
echo " cli the Command Line Interface"
echo " unittest run mycroft-core unit tests (requires pytest)"
echo " skillstest run the skill autotests for all skills (requires pytest)"
+ echo " vktest run the Voight Kampff integration test suite"
echo
echo "Util COMMANDs:"
echo " audiotest attempt simple audio validation"
@@ -236,6 +237,10 @@ case ${_opt} in
source-venv
pytest test/integrationtests/skills/discover_tests.py "$@"
;;
+ "vktest")
+ source-venv
+ python -m test.integrationtests.voight_kampff "$@"
+ ;;
"audiotest")
launch-process ${_opt}
;;
diff --git a/stop-mycroft.sh b/stop-mycroft.sh
index 51582cdfacff..da74fbecd886 100755
--- a/stop-mycroft.sh
+++ b/stop-mycroft.sh
@@ -94,11 +94,11 @@ case ${OPT} in
;&
"")
echo "Stopping all mycroft-core services"
- end-process messagebus.service
end-process skills
end-process audio
end-process speech
end-process enclosure
+ end-process messagebus.service
;;
"bus")
end-process messagebus.service
diff --git a/test-requirements.txt b/test-requirements.txt
index 935cc6d808e9..874a0f130d47 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -5,3 +5,5 @@ pytest-cov==2.8.1
cov-core==1.15.0
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
diff --git a/test/Dockerfile.test b/test/Dockerfile.test
new file mode 100644
index 000000000000..87e3c85287eb
--- /dev/null
+++ b/test/Dockerfile.test
@@ -0,0 +1,106 @@
+# Build an Ubuntu-based container to run Mycroft
+#
+# The steps in this build are ordered from least likely to change to most
+# likely to change. The intent behind this is to reduce build time so things
+# like Jenkins jobs don't spend a lot of time re-building things that did not
+# change from one build to the next.
+#
+FROM ubuntu:18.04 as core_builder
+ARG platform
+ENV TERM linux
+ENV DEBIAN_FRONTEND noninteractive
+# Un-comment any package sources that include a multiverse
+RUN sed -i 's/# \(.*multiverse$\)/\1/g' /etc/apt/sources.list
+# Install Server Dependencies for Mycroft
+RUN apt-get update && apt-get install -y \
+ autoconf \
+ automake \
+ bison \
+ build-essential \
+ curl \
+ flac \
+ git \
+ jq \
+ libfann-dev \
+ libffi-dev \
+ libicu-dev \
+ libjpeg-dev \
+ libglib2.0-dev \
+ libssl-dev \
+ libtool \
+ locales \
+ mpg123 \
+ pkg-config \
+ portaudio19-dev \
+ pulseaudio \
+ pulseaudio-utils \
+ python3 \
+ python3-dev \
+ python3-pip \
+ python3-setuptools \
+ python3-venv \
+ screen \
+ sudo \
+ swig \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
+
+# Set the locale
+RUN locale-gen en_US.UTF-8
+ENV LANG en_US.UTF-8
+ENV LANGUAGE en_US:en
+ENV LC_ALL en_US.UTF-8
+
+ENV USER root
+ENV CI true
+# Setup the virtual environment
+# This may not be the most efficient way to do this in terms of number of
+# steps, but it is built to take advantage of Docker's caching mechanism
+# to only rebuild things that have changed since the last build.
+RUN mkdir -p /opt/mycroft/mycroft-core /opt/mycroft/skills /root/.mycroft /var/log/mycroft
+RUN python3 -m venv "/opt/mycroft/mycroft-core/.venv"
+
+# Install required Python packages. Generate hash, which mycroft core uses to
+# 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 .
+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 dev_setup.sh .
+RUN md5sum requirements.txt test-requirements.txt dev_setup.sh > .installed
+
+# Add the mycroft core virtual environment to the system path.
+ENV PATH /opt/mycroft/mycroft-core/.venv/bin:$PATH
+
+# Install Mark I default skills
+RUN msm -p mycroft_mark_1 default
+COPY . /opt/mycroft/mycroft-core
+RUN .venv/bin/python -m pip install --no-deps /opt/mycroft/mycroft-core
+EXPOSE 8181
+
+
+# Integration Test Suite
+#
+# Build against this target to set the container up as an executable that
+# will run the "voight_kampff" integration test suite.
+#
+FROM core_builder as voight_kampff_builder
+ARG platform
+# Setup a dummy TTS backend for the audio process
+RUN mkdir /etc/mycroft
+RUN echo '{"tts": {"module": "dummy"}}' > /etc/mycroft/mycroft.conf
+RUN mkdir ~/.mycroft/allure-result
+
+# The behave feature files for a skill are defined within the skill's
+# repository. Copy those files into the local feature file directory
+# for test discovery.
+WORKDIR /opt/mycroft/mycroft-core
+# Generate hash of required packages
+RUN python -m test.integrationtests.voight_kampff.test_setup -c test/integrationtests/voight_kampff/default.yml
+
+# Setup and run the integration tests
+ENV PYTHONPATH /opt/mycroft/mycroft-core/
+WORKDIR /opt/mycroft/mycroft-core/test/integrationtests/voight_kampff
+ENTRYPOINT ["./run_test_suite.sh"]
diff --git a/test/integrationtests/voight_kampff/README.md b/test/integrationtests/voight_kampff/README.md
new file mode 100644
index 000000000000..5b0909921224
--- /dev/null
+++ b/test/integrationtests/voight_kampff/README.md
@@ -0,0 +1,86 @@
+# Voight Kampff tester
+
+> You’re watching television. Suddenly you realize there’s a wasp crawling on your arm.
+
+The Voight Kampff tester is an integration test system based on the "behave" framework using human readable test cases. The tester connects to the running mycroft-core instance and performs tests. Checking that user utterances returns a correct response.
+
+## Test setup
+`test_setup` collects feature files for behave and installs any skills that should be present during the test.
+
+## Running the test
+After the test has been setup run `behave` to start the test.
+
+## Feature file
+Feature files is the way tests are specified for behave (Read more [here](https://behave.readthedocs.io/en/latest/tutorial.html))
+
+Below is an example of a feature file that can be used with the test suite.
+```feature
+Feature: mycroft-weather
+ Scenario Outline: current local weather question
+ Given an english speaking user
+ When the user says ""
+ Then "mycroft-weather" should reply with "Right now, it's overcast clouds and 32 degrees."
+
+ Examples: local weather questions
+ | current local weather |
+ | what's the weather like |
+ | current weather |
+ | tell me the weather |
+
+ Scenario: Temperature in paris
+ Given an english speaking user
+ When the user says "how hot will it be in paris"
+ Then "mycroft-weather" should reply with dialog from "current.high.temperature.dialog"
+```
+
+### Given ...
+
+Given is used to perform initial setup for the test case. currently this has little effect and the test will always be performed in english but need to be specified in each test as
+
+```Given an english speaking user```
+
+### When ...
+The When is the start of the test and will inject a message on the running mycroft instance. The current available When is
+
+`When the user says ""`
+
+where utterance is the sentence to test.
+
+### Then ...
+The "Then" step will verify Mycroft's response, handle a followup action or check for messages on the messagebus.
+
+#### Expected dialog:
+`"" should reply with dialog from ""`
+
+Example phrase:
+`Then "" should reply with ""
+
+This will try to map the example phrase to a dialog file and will allow any response from that dialog file. This one is somewhat experimental et the moment.
+
+#### Should contain:
+`mycroft reply should contain ""`
+
+This will match any sentence containing the specified text.
+
+#### User reply:
+`Then the user says ""`
+
+This allows setting up scenarios with conversational aspects, e.g. when using `get_response()` in the skill.
+
+Example:
+```feature
+Scenario: Bridge of death
+ Given an english speaking user
+ When the user says "let's go to the bridge of death"
+ Then "death-bridge" should reply with dialog from "questions_one.dialog"
+ Then the user says "My name is Sir Lancelot of Camelot"
+ Then "death-bridge" should reply with dialog from "questions_two.dialog"
+ Then the user says "To seek the holy grail"
+ Then "death-bridge" should reply with dialog from "questions_three.dialog"
+ Then the user says "blue"
+```
+
+#### Mycroft messagebus message:
+`mycroft should send the message ""`
+
+This verifies that a specific message is emitted on the messagebus. This can be used to check that a playback request is sent or other action is triggered.
diff --git a/test/integrationtests/voight_kampff/__init__.py b/test/integrationtests/voight_kampff/__init__.py
new file mode 100644
index 000000000000..c77a32ba3d82
--- /dev/null
+++ b/test/integrationtests/voight_kampff/__init__.py
@@ -0,0 +1,17 @@
+# 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 .tools import (emit_utterance, wait_for_dialog, then_wait,
+ mycroft_responses, print_mycroft_responses)
diff --git a/test/integrationtests/voight_kampff/__main__.py b/test/integrationtests/voight_kampff/__main__.py
new file mode 100644
index 000000000000..f10f7399f381
--- /dev/null
+++ b/test/integrationtests/voight_kampff/__main__.py
@@ -0,0 +1,37 @@
+# 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 os
+import subprocess
+import sys
+from .test_setup import main as test_setup
+from .test_setup import create_argument_parser
+"""Voigt Kampff Test Module
+
+A single interface for the Voice Kampff integration test module.
+
+Full documentation can be found at https://mycroft.ai/docs
+"""
+
+
+def main(cmdline_args):
+ parser = create_argument_parser()
+ setup_args, behave_args = parser.parse_known_args(cmdline_args)
+ test_setup(setup_args)
+ os.chdir(os.path.dirname(__file__))
+ subprocess.call(['./run_test_suite.sh', *behave_args])
+
+
+if __name__ == '__main__':
+ main(sys.argv[1:])
diff --git a/test/integrationtests/voight_kampff/default.yml b/test/integrationtests/voight_kampff/default.yml
new file mode 100644
index 000000000000..7ffe2f5ee176
--- /dev/null
+++ b/test/integrationtests/voight_kampff/default.yml
@@ -0,0 +1,19 @@
+platform: mycroft_mark_1
+test_skills:
+- mycroft-alarm
+- mycroft-timer
+- mycroft-date-time
+- mycroft-npr-news
+- mycroft-weather
+- mycroft-hello-world
+- mycroft-pairing
+- mycroft-wiki
+- mycroft-personal
+- mycroft-npr-news
+- mycroft-installer
+- mycroft-singing
+- mycroft-stock
+- mycroft-mark-1
+- fallback-unknown
+- fallback-query
+- mycroft-volume
diff --git a/test/integrationtests/voight_kampff/features/environment.py b/test/integrationtests/voight_kampff/features/environment.py
new file mode 100644
index 000000000000..fa1d87011063
--- /dev/null
+++ b/test/integrationtests/voight_kampff/features/environment.py
@@ -0,0 +1,118 @@
+# 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 logging
+from threading import Event, Lock
+from time import sleep, monotonic
+from behave.contrib.scenario_autoretry import patch_scenario_with_autoretry
+
+from msm import MycroftSkillsManager
+from mycroft.audio import wait_while_speaking
+from mycroft.messagebus.client import MessageBusClient
+from mycroft.messagebus import Message
+from mycroft.util import create_daemon
+
+
+def create_voight_kampff_logger():
+ fmt = logging.Formatter('{asctime} | {name} | {levelname} | {message}',
+ style='{')
+ handler = logging.StreamHandler()
+ handler.setFormatter(fmt)
+ log = logging.getLogger('Voight Kampff')
+ log.addHandler(handler)
+ log.setLevel(logging.INFO)
+ log.propagate = False
+ return log
+
+
+class InterceptAllBusClient(MessageBusClient):
+ def __init__(self):
+ super().__init__()
+ self.messages = []
+ self.message_lock = Lock()
+ self.new_message_available = Event()
+
+ def on_message(self, message):
+ with self.message_lock:
+ self.messages.append(Message.deserialize(message))
+ self.new_message_available.set()
+ super().on_message(message)
+
+ def get_messages(self, msg_type):
+ with self.message_lock:
+ if msg_type is None:
+ return [m for m in self.messages]
+ else:
+ return [m for m in self.messages if m.msg_type == msg_type]
+
+ def remove_message(self, msg):
+ with self.message_lock:
+ self.messages.remove(msg)
+
+ def clear_messages(self):
+ with self.message_lock:
+ self.messages = []
+
+
+def before_all(context):
+ log = create_voight_kampff_logger()
+ bus = InterceptAllBusClient()
+ bus_connected = Event()
+ bus.once('open', bus_connected.set)
+
+ create_daemon(bus.run_forever)
+
+ context.msm = MycroftSkillsManager()
+ # Wait for connection
+ log.info('Waiting for messagebus connection...')
+ bus_connected.wait()
+
+ log.info('Waiting for skills to be loaded...')
+ start = monotonic()
+ while True:
+ response = bus.wait_for_response(Message('mycroft.skills.all_loaded'))
+ if response and response.data['status']:
+ break
+ elif monotonic() - start >= 2 * 60:
+ raise Exception('Timeout waiting for skills to become ready.')
+ else:
+ sleep(1)
+
+ context.bus = bus
+ context.matched_message = None
+ context.log = log
+
+
+def before_feature(context, feature):
+ context.log.info('Starting tests for {}'.format(feature.name))
+ for scenario in feature.scenarios:
+ patch_scenario_with_autoretry(scenario, max_attempts=2)
+
+
+def after_all(context):
+ context.bus.close()
+
+
+def after_feature(context, feature):
+ context.log.info('Result: {} ({:.2f}s)'.format(str(feature.status.name),
+ feature.duration))
+ sleep(1)
+
+
+def after_scenario(context, scenario):
+ # TODO wait for skill handler complete
+ sleep(0.5)
+ wait_while_speaking()
+ context.bus.clear_messages()
+ context.matched_message = None
diff --git a/test/integrationtests/voight_kampff/features/steps/utterance_responses.py b/test/integrationtests/voight_kampff/features/steps/utterance_responses.py
new file mode 100644
index 000000000000..4bd36056bcb0
--- /dev/null
+++ b/test/integrationtests/voight_kampff/features/steps/utterance_responses.py
@@ -0,0 +1,225 @@
+# 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.
+#
+"""
+Predefined step definitions for handling dialog interaction with Mycroft for
+use with behave.
+"""
+from os.path import join, exists, basename
+from glob import glob
+import re
+import time
+
+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
+
+
+TIMEOUT = 10
+SLEEP_LENGTH = 0.25
+
+
+def find_dialog(skill_path, dialog, lang):
+ """Check the usual location for dialogs.
+
+ TODO: subfolders
+ """
+ if exists(join(skill_path, 'dialog')):
+ return join(skill_path, 'dialog', lang, dialog)
+ else:
+ return join(skill_path, 'locale', lang, dialog)
+
+
+def load_dialog_file(dialog_path):
+ """Load dialog files and get the contents."""
+ with open(dialog_path) as f:
+ lines = f.readlines()
+ return [l.strip().lower() for l in lines
+ if l.strip() != '' and l.strip()[0] != '#']
+
+
+def load_dialog_list(skill_path, dialog):
+ """Load dialog from files into a single list.
+
+ Arguments:
+ skill (MycroftSkill): skill to load dialog from
+ dialog (list): Dialog names (str) to load
+
+ Returns:
+ tuple (list of Expanded dialog strings, debug string)
+ """
+ dialog_path = find_dialog(skill_path, dialog)
+
+ debug = 'Opening {}\n'.format(dialog_path)
+ return load_dialog_file(dialog_path), debug
+
+
+def dialog_from_sentence(sentence, skill_path, lang):
+ """Find dialog file from example sentence.
+
+ Arguments:
+ sentence (str): Text to match
+ skill_path (str): path to skill directory
+ lang (str): language code to use
+
+ Returns (str): Dialog file best matching the sentence.
+ """
+ dialog_paths = join(skill_path, 'dialog', lang, '*.dialog')
+ best = (None, 0)
+ for path in glob(dialog_paths):
+ patterns = load_dialog_file(path)
+ match, _ = _match_dialog_patterns(patterns, sentence.lower())
+ if match is not False:
+ if len(patterns[match]) > best[1]:
+ best = (path, len(patterns[match]))
+ if best[0] is not None:
+ return basename(best[0])
+ else:
+ return None
+
+
+def _match_dialog_patterns(dialogs, sentence):
+ """Match sentence against a list of dialog patterns.
+
+ Returns index of found match.
+ """
+ # Allow custom fields to be anything
+ dialogs = [re.sub(r'{.*?\}', r'.*', dia) for dia in dialogs]
+ # Remove left over '}'
+ dialogs = [re.sub(r'\}', r'', dia) for dia in dialogs]
+ dialogs = [re.sub(r' .* ', r' .*', dia) for dia in dialogs]
+ # Merge consequtive .*'s into a single .*
+ dialogs = [re.sub(r'\.\*( \.\*)+', r'.*', dia) for dia in dialogs]
+ # Remove double whitespaces
+ dialogs = ['^' + ' '.join(dia.split()) for dia in dialogs]
+ debug = 'MATCHING: {}\n'.format(sentence)
+ for index, regex in enumerate(dialogs):
+ match = re.match(regex, sentence)
+ debug += '---------------\n'
+ debug += '{} {}\n'.format(regex, match is not None)
+ if match:
+ return index, debug
+ else:
+ return False, debug
+
+
+@given('an english speaking user')
+def given_english(context):
+ context.lang = 'en-us'
+
+
+@when('the user says "{text}"')
+def when_user_says(context, text):
+ context.bus.emit(Message('recognizer_loop:utterance',
+ data={'utterances': [text],
+ 'lang': context.lang,
+ 'session': '',
+ 'ident': time.time()},
+ context={'client_name': 'mycroft_listener'}))
+
+
+@then('"{skill}" should reply with dialog from "{dialog}"')
+def then_dialog(context, skill, dialog):
+ def check_dialog(message):
+ utt_dialog = message.data.get('meta', {}).get('dialog')
+ return (utt_dialog == dialog.replace('.dialog', ''), '')
+
+ passed, debug = then_wait('speak', check_dialog, context)
+ if not passed:
+ assert_msg = debug
+ assert_msg += mycroft_responses(context)
+
+ assert passed, assert_msg or 'Mycroft didn\'t respond'
+
+
+@then('"{skill}" should reply with "{example}"')
+def then_example(context, skill, example):
+ skill_path = context.msm.find_skill(skill).path
+ dialog = dialog_from_sentence(example, skill_path, context.lang)
+ print('Matching with the dialog file: {}'.format(dialog))
+ assert dialog is not None, 'No matching dialog...'
+ then_dialog(context, skill, dialog)
+
+
+@then('"{skill}" should reply with anything')
+def then_anything(context, skill):
+ def check_any_messages(message):
+ debug = ''
+ result = message is not None
+ return (result, debug)
+
+ passed = then_wait('speak', check_any_messages, context)
+ assert passed, 'No speech received at all'
+
+
+@then('"{skill}" should reply with exactly "{text}"')
+def then_exactly(context, skill, text):
+ def check_exact_match(message):
+ utt = message.data['utterance'].lower()
+ debug = 'Comparing {} with expected {}\n'.format(utt, text)
+ result = utt == text.lower()
+ return (result, debug)
+
+ passed, debug = then_wait('speak', check_exact_match, context)
+ if not passed:
+ assert_msg = debug
+ assert_msg += mycroft_responses(context)
+ assert passed, assert_msg
+
+
+@then('mycroft reply should contain "{text}"')
+def then_contains(context, text):
+ def check_contains(message):
+ utt = message.data['utterance'].lower()
+ debug = 'Checking if "{}" contains "{}"\n'.format(utt, text)
+ result = text.lower() in utt
+ return (result, debug)
+
+ passed, debug = then_wait('speak', check_contains, context)
+
+ if not passed:
+ assert_msg = 'No speech contained the expected content'
+ assert_msg += mycroft_responses(context)
+
+ assert passed, assert_msg
+
+
+@then('the user replies with "{text}"')
+@then('the user replies "{text}"')
+@then('the user says "{text}"')
+def then_user_follow_up(context, text):
+ time.sleep(2)
+ wait_while_speaking()
+ context.bus.emit(Message('recognizer_loop:utterance',
+ data={'utterances': [text],
+ 'lang': context.lang,
+ 'session': '',
+ 'ident': time.time()},
+ context={'client_name': 'mycroft_listener'}))
+
+
+@then('mycroft should send the message "{message_type}"')
+def then_messagebus_message(context, message_type):
+ cnt = 0
+ while context.bus.get_messages(message_type) == []:
+ if cnt > int(TIMEOUT * (1.0 / SLEEP_LENGTH)):
+ assert False, "Message not found"
+ break
+ else:
+ cnt += 1
+
+ time.sleep(SLEEP_LENGTH)
diff --git a/test/integrationtests/voight_kampff/generate_feature.py b/test/integrationtests/voight_kampff/generate_feature.py
new file mode 100644
index 000000000000..eeaee230ad0a
--- /dev/null
+++ b/test/integrationtests/voight_kampff/generate_feature.py
@@ -0,0 +1,65 @@
+# 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 glob import glob
+import json
+from pathlib import Path
+import sys
+
+"""Convert existing intent tests to behave tests."""
+
+TEMPLATE = """
+ Scenario: {scenario}
+ Given an english speaking user
+ When the user says "{utterance}"
+ Then "{skill}" should reply with dialog from "{dialog_file}.dialog"
+"""
+
+
+def json_files(path):
+ """Generator function returning paths of all json files in a folder."""
+ for json_file in sorted(glob(str(Path(path, '*.json')))):
+ yield Path(json_file)
+
+
+def generate_feature(skill, skill_path):
+ """Generate a feature file provided a skill name and a path to the skill.
+ """
+ test_path = Path(skill_path, 'test', 'intent')
+ case = []
+ if test_path.exists() and test_path.is_dir():
+ for json_file in json_files(test_path):
+ with open(str(json_file)) as test_file:
+ test = json.load(test_file)
+ if 'utterance' and 'expected_dialog' in test:
+ utt = test['utterance']
+ dialog = test['expected_dialog']
+ # Simple handling of multiple accepted dialogfiles
+ if isinstance(dialog, list):
+ dialog = dialog[0]
+
+ case.append((json_file.name, utt, dialog))
+
+ output = ''
+ if case:
+ output += 'Feature: {}\n'.format(skill)
+ for c in case:
+ output += TEMPLATE.format(skill=skill, scenario=c[0],
+ utterance=c[1], dialog_file=c[2])
+
+ return output
+
+
+if __name__ == '__main__':
+ print(generate_feature(*sys.argv[1:]))
diff --git a/test/integrationtests/voight_kampff/run_test_suite.sh b/test/integrationtests/voight_kampff/run_test_suite.sh
new file mode 100755
index 000000000000..7286fb9c5c25
--- /dev/null
+++ b/test/integrationtests/voight_kampff/run_test_suite.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+# Script to setup the integration test environment and run the tests.
+#
+# The comands runing in this script are those that need to be executed at
+# runtime. Assumes running within a Docker container where the PATH environment
+# variable has been set to include the virtual envionrment's bin directory
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
+
+# Start pulseaudio if running in CI environment
+if [[ -v CI ]]; then
+ pulseaudio -D
+fi
+# Start all mycroft core services.
+${SCRIPT_DIR}/../../../start-mycroft.sh all
+# Run the integration test suite. Results will be formatted for input into
+# the Allure reporting tool.
+echo "Running behave with the arguments \"$@\""
+behave $@
+RESULT=$?
+# Stop all mycroft core services.
+${SCRIPT_DIR}/../../../stop-mycroft.sh all
+
+# Reort the result of the behave test as exit status
+exit $RESULT
diff --git a/test/integrationtests/voight_kampff/test_setup.py b/test/integrationtests/voight_kampff/test_setup.py
new file mode 100644
index 000000000000..a3754169c517
--- /dev/null
+++ b/test/integrationtests/voight_kampff/test_setup.py
@@ -0,0 +1,225 @@
+# 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 argparse
+from argparse import RawTextHelpFormatter
+from glob import glob
+from os.path import join, dirname, expanduser, basename, exists
+from random import shuffle
+import shutil
+import sys
+
+import yaml
+from msm import MycroftSkillsManager, SkillRepo
+from msm.exceptions import MsmException
+
+from .generate_feature import generate_feature
+
+"""Test environment setup for voigt kampff test
+
+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.
+"""
+
+FEATURE_DIR = join(dirname(__file__), 'features') + '/'
+
+
+def copy_feature_files(source, destination):
+ """Copy all feature files from source to destination."""
+ # Copy feature files to the feature directory
+ for f in glob(join(source, '*.feature')):
+ shutil.copyfile(f, join(destination, basename(f)))
+
+
+def copy_step_files(source, destination):
+ """Copy all python files from source to destination."""
+ # Copy feature files to the feature directory
+ for f in glob(join(source, '*.py')):
+ shutil.copyfile(f, join(destination, basename(f)))
+
+
+def apply_config(config, args):
+ """Load config and add to unset arguments."""
+ with open(expanduser(config)) as f:
+ conf_dict = yaml.safe_load(f)
+
+ if not args.test_skills and 'test_skills' in conf_dict:
+ args.test_skills = conf_dict['test_skills']
+ if not args.extra_skills and 'extra_skills' in conf_dict:
+ args.extra_skills = conf_dict['extra_skills']
+ if not args.platform and 'platform' in conf_dict:
+ args.platform = conf_dict['platform']
+
+
+def create_argument_parser():
+ """Create the argument parser for the command line options.
+
+ Returns: ArgumentParser
+ """
+ class TestSkillAction(argparse.Action):
+ def __call__(self, parser, args, values, option_string=None):
+ args.test_skills = values.replace(',', ' ').split()
+
+ class ExtraSkillAction(argparse.Action):
+ def __call__(self, parser, args, values, option_string=None):
+ args.extra_skills = values.replace(',', ' ').split()
+
+ platforms = list(MycroftSkillsManager.SKILL_GROUPS)
+ parser = argparse.ArgumentParser(formatter_class=RawTextHelpFormatter)
+ parser.add_argument('-p', '--platform', choices=platforms,
+ default='mycroft_mark_1')
+ parser.add_argument('-t', '--test-skills', default=[],
+ action=TestSkillAction,
+ help=('Comma-separated list of skills to test.\n'
+ 'Ex: "mycroft-weather, mycroft-stock"'))
+ parser.add_argument('-s', '--extra-skills', default=[],
+ action=ExtraSkillAction,
+ help=('Comma-separated list of extra skills to '
+ 'install.\n'
+ 'Ex: "cocktails, laugh"'))
+ parser.add_argument('-r', '--random-skills', default=0, type=int,
+ help='Number of random skills to install.')
+ parser.add_argument('-d', '--skills-dir')
+ parser.add_argument('-u', '--repo-url',
+ help='URL for skills repo to install / update from')
+ parser.add_argument('-b', '--branch',
+ help='repo branch to use')
+ parser.add_argument('-c', '--config',
+ help='Path to test configuration file.')
+ return parser
+
+
+def get_random_skills(msm, num_random_skills):
+ """Install random skills from uninstalled skill list."""
+ random_skills = [s for s in msm.all_skills if not s.is_local]
+ shuffle(random_skills) # Make them random
+ return [s.name for s in random_skills[:num_random_skills]]
+
+
+def install_or_upgrade_skills(msm, skills):
+ """Install needed skills if uninstalled, otherwise try to update.
+
+ Arguments:
+ msm: msm instance to use for the operations
+ skills: list of skills
+ """
+ skills = [msm.find_skill(s) for s in skills]
+ for s in skills:
+ if not s.is_local:
+ print('Installing {}'.format(s))
+ msm.install(s)
+ else:
+ try:
+ msm.update(s)
+ except MsmException:
+ pass
+
+
+def collect_test_cases(msm, skills):
+ """Collect feature files and step files for each skill.
+
+ Arguments:
+ msm: msm instance to use for the operations
+ skills: list of skills
+ """
+ for skill_name in skills:
+ skill = msm.find_skill(skill_name)
+ behave_dir = join(skill.path, 'test', 'behave')
+ if exists(behave_dir):
+ copy_feature_files(behave_dir, FEATURE_DIR)
+
+ step_dir = join(behave_dir, 'steps')
+ if exists(step_dir):
+ copy_step_files(step_dir, join(FEATURE_DIR, 'steps'))
+ else:
+ # Generate feature file if no data exists yet
+ print('No feature files exists for {}, '
+ 'generating...'.format(skill_name))
+ # No feature files setup, generate automatically
+ feature = generate_feature(skill_name, skill.path)
+ with open(join(FEATURE_DIR, skill_name + '.feature'), 'w') as f:
+ f.write(feature)
+
+
+def print_install_report(platform, test_skills, extra_skills):
+ """Print in nice format."""
+ print('-------- TEST SETUP --------')
+ yml = yaml.dump({
+ 'platform': platform,
+ 'test_skills': test_skills,
+ 'extra_skills': extra_skills
+ })
+ print(yml)
+ print('----------------------------')
+
+
+def get_arguments(cmdline_args):
+ """Get arguments for test setup.
+
+ Parses the commandline and if specified applies configuration file.
+
+ Arguments:
+ cmdline_args (list): argv like list of arguments
+
+ Returns:
+ Argument parser NameSpace
+ """
+ parser = create_argument_parser()
+ args = parser.parse_args(cmdline_args)
+ return args
+
+
+def create_skills_manager(platform, skills_dir, url, branch):
+ """Create mycroft skills manager for the given url / branch.
+
+ Arguments:
+ platform (str): platform to use
+ skills_dir (str): skill directory to use
+ url (str): skills repo url
+ branch (str): skills repo branch
+
+ Returns:
+ MycroftSkillsManager
+ """
+ repo = SkillRepo(url=url, branch=branch)
+ return MycroftSkillsManager(platform, skills_dir, repo)
+
+
+def main(args):
+ """Parse arguments and run test environment setup.
+
+ This installs and/or upgrades any skills needed for the tests and
+ collects the feature and step files for the skills.
+ """
+ if args.config:
+ apply_config(args.config, args)
+
+ msm = create_skills_manager(args.platform, args.skills_dir,
+ args.repo_url, args.branch)
+
+ random_skills = get_random_skills(msm, args.random_skills)
+ all_skills = args.test_skills + args.extra_skills + random_skills
+
+ install_or_upgrade_skills(msm, all_skills)
+ collect_test_cases(msm, args.test_skills)
+
+ print_install_report(msm.platform, args.test_skills,
+ args.extra_skills + random_skills)
+
+
+if __name__ == '__main__':
+ main(get_arguments(sys.argv[1:]))
diff --git a/test/integrationtests/voight_kampff/tools.py b/test/integrationtests/voight_kampff/tools.py
new file mode 100644
index 000000000000..7644e5a6ee5e
--- /dev/null
+++ b/test/integrationtests/voight_kampff/tools.py
@@ -0,0 +1,110 @@
+# 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 tools to use when creating step files for behave tests."""
+
+import time
+
+from mycroft.messagebus import Message
+
+
+TIMEOUT = 10
+
+
+def then_wait(msg_type, criteria_func, context, timeout=TIMEOUT):
+ """Wait for a specified time for criteria to be 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
+ """
+ start_time = time.monotonic()
+ debug = ''
+ while time.monotonic() < start_time + timeout:
+ for message in context.bus.get_messages(msg_type):
+ status, test_dbg = criteria_func(message)
+ debug += test_dbg
+ if status:
+ context.matched_message = message
+ context.bus.remove_message(message)
+ return True, debug
+ context.bus.new_message_available.wait(0.5)
+ # Timed out return debug from test
+ return False, debug
+
+
+def mycroft_responses(context):
+ """Collect and format mycroft responses from context.
+
+ Arguments:
+ context: behave context to extract messages from.
+
+ Returns: (str) Mycroft responses including skill and dialog file
+ """
+ responses = ''
+ messages = context.bus.get_messages('speak')
+ if len(messages) > 0:
+ responses = 'Mycroft responded with:\n'
+ for m in messages:
+ responses += 'Mycroft: '
+ if 'dialog' in m.data['meta']:
+ responses += '{}.dialog'.format(m.data['meta']['dialog'])
+ responses += '({})\n'.format(m.data['meta'].get('skill'))
+ responses += '"{}"\n'.format(m.data['utterance'])
+ return responses
+
+
+def print_mycroft_responses(context):
+ print(mycroft_responses(context))
+
+
+def emit_utterance(bus, utt):
+ """Emit an utterance on the bus.
+
+ Arguments:
+ bus (InterceptAllBusClient): Bus instance to listen on
+ dialogs (list): list of acceptable dialogs
+ """
+ bus.emit(Message('recognizer_loop:utterance',
+ data={'utterances': [utt],
+ 'lang': 'en-us',
+ 'session': '',
+ 'ident': time.time()},
+ context={'client_name': 'mycroft_listener'}))
+
+
+def wait_for_dialog(bus, dialogs, timeout=TIMEOUT):
+ """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.
+ """
+ start_time = time.monotonic()
+ while time.monotonic() < start_time + timeout:
+ for message in bus.get_messages('speak'):
+ dialog = message.data.get('meta', {}).get('dialog')
+ if dialog in dialogs:
+ bus.clear_messages()
+ return
+ bus.new_message_available.wait(0.5)
+ bus.clear_messages()
diff --git a/test/unittests/skills/test_intent_service.py b/test/unittests/skills/test_intent_service.py
index 402057e4b2ab..2272a5bad04e 100644
--- a/test/unittests/skills/test_intent_service.py
+++ b/test/unittests/skills/test_intent_service.py
@@ -12,8 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
-from threading import Thread
-import time
from unittest import TestCase, mock
from mycroft.messagebus import Message
@@ -89,27 +87,22 @@ def test_converse(self):
Also check that the skill that handled the query is moved to the
top of the active skill list.
"""
- result = None
+ def response(message, return_msg_type):
+ c64 = Message(return_msg_type, {'skill_id': 'c64_skill',
+ 'result': False})
+ atari = Message(return_msg_type, {'skill_id': 'atari_skill',
+ 'result': True})
+ msgs = {'c64_skill': c64, 'atari_skill': atari}
- def runner(utterances, lang, message):
- nonlocal result
- result = self.intent_service._converse(utterances, lang, message)
+ return msgs[message.data['skill_id']]
+
+ self.intent_service.bus.wait_for_response.side_effect = response
hello = ['hello old friend']
utterance_msg = Message('recognizer_loop:utterance',
data={'lang': 'en-US',
'utterances': hello})
- t = Thread(target=runner, args=(hello, 'en-US', utterance_msg))
- t.start()
- time.sleep(0.5)
- self.intent_service.handle_converse_response(
- Message('converse.response', {'skill_id': 'c64_skill',
- 'result': False}))
- time.sleep(0.5)
- self.intent_service.handle_converse_response(
- Message('converse.response', {'skill_id': 'atari_skill',
- 'result': True}))
- t.join()
+ result = self.intent_service._converse(hello, 'en-US', utterance_msg)
# Check that the active skill list was updated to set the responding
# Skill first.
@@ -119,25 +112,67 @@ def runner(utterances, lang, message):
# Check that a skill responded that it could handle the message
self.assertTrue(result)
+ def test_converse_error(self):
+ """Check that all skill IDs in the active_skills list are called.
+ even if there's an error.
+ """
+ def response(message, return_msg_type):
+ c64 = Message(return_msg_type, {'skill_id': 'c64_skill',
+ 'result': False})
+ amiga = Message(return_msg_type,
+ {'skill_id': 'amiga_skill',
+ 'error': 'skill id does not exist'})
+ atari = Message(return_msg_type, {'skill_id': 'atari_skill',
+ 'result': False})
+ msgs = {'c64_skill': c64,
+ 'atari_skill': atari,
+ 'amiga_skill': amiga}
+
+ return msgs[message.data['skill_id']]
+
+ self.intent_service.add_active_skill('amiga_skill')
+ self.intent_service.bus.wait_for_response.side_effect = response
+
+ hello = ['hello old friend']
+ utterance_msg = Message('recognizer_loop:utterance',
+ data={'lang': 'en-US',
+ 'utterances': hello})
+ result = self.intent_service._converse(hello, 'en-US', utterance_msg)
+
+ # Check that the active skill list was updated to set the responding
+ # Skill first.
+
+ # Check that a skill responded that it couldn't handle the message
+ self.assertFalse(result)
+
+ # Check that each skill in the list of active skills were called
+ call_args = self.intent_service.bus.wait_for_response.call_args_list
+ sent_skill_ids = [call[0][0].data['skill_id'] for call in call_args]
+ self.assertEqual(sent_skill_ids,
+ ['amiga_skill', 'c64_skill', 'atari_skill'])
+
def test_reset_converse(self):
"""Check that a blank stt sends the reset signal to the skills."""
- print(self.intent_service.active_skills)
+ def response(message, return_msg_type):
+ c64 = Message(return_msg_type,
+ {'skill_id': 'c64_skill',
+ 'error': 'skill id does not exist'})
+ atari = Message(return_msg_type, {'skill_id': 'atari_skill',
+ 'result': False})
+ msgs = {'c64_skill': c64, 'atari_skill': atari}
+
+ return msgs[message.data['skill_id']]
+
reset_msg = Message('mycroft.speech.recognition.unknown',
data={'lang': 'en-US'})
- t = Thread(target=self.intent_service.reset_converse,
- args=(reset_msg,))
- t.start()
- time.sleep(0.5)
- self.intent_service.handle_converse_error(
- Message('converse.error', {'skill_id': 'c64_skill',
- 'error': 'skill id does not exist'}))
- time.sleep(0.5)
- self.intent_service.handle_converse_response(
- Message('converse.response', {'skill_id': 'atari_skill',
- 'result': False}))
+ self.intent_service.bus.wait_for_response.side_effect = response
+ self.intent_service.reset_converse(reset_msg)
# Check send messages
- c64_message = self.intent_service.bus.emit.call_args_list[0][0][0]
+ wait_for_response_mock = self.intent_service.bus.wait_for_response
+ c64_message = wait_for_response_mock.call_args_list[0][0][0]
self.assertTrue(check_converse_request(c64_message, 'c64_skill'))
- atari_message = self.intent_service.bus.emit.call_args_list[1][0][0]
+ atari_message = wait_for_response_mock.call_args_list[1][0][0]
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')
diff --git a/test/unittests/skills/test_skill_manager.py b/test/unittests/skills/test_skill_manager.py
index 697755e2d8e7..f2bdde3fa466 100644
--- a/test/unittests/skills/test_skill_manager.py
+++ b/test/unittests/skills/test_skill_manager.py
@@ -41,6 +41,18 @@ def test_upload_queue_use(self):
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.'
@@ -83,6 +95,7 @@ def _mock_skill_loader_instance(self):
self.skill_loader_mock.instance = Mock()
self.skill_loader_mock.instance.default_shutdown = Mock()
self.skill_loader_mock.instance.converse = Mock()
+ self.skill_loader_mock.instance.converse.return_value = True
self.skill_loader_mock.skill_id = 'test_skill'
self.skill_manager.skill_loaders = {
str(self.skill_dir): self.skill_loader_mock
@@ -217,7 +230,8 @@ def test_stop(self):
def test_handle_converse_request(self):
message = Mock()
- message.data = dict(skill_id='test_skill')
+ message.data = dict(skill_id='test_skill', utterances=['hey you'],
+ lang='en-US')
self.skill_loader_mock.loaded = True
converse_response_mock = Mock()
self.skill_manager._emit_converse_response = converse_response_mock
@@ -226,6 +240,7 @@ def test_handle_converse_request(self):
self.skill_manager.handle_converse_request(message)
converse_response_mock.assert_called_once_with(
+ True,
message,
self.skill_loader_mock
)
diff --git a/test/unittests/version/test_version.py b/test/unittests/version/test_version.py
index fde48e51457c..9068728e432c 100644
--- a/test/unittests/version/test_version.py
+++ b/test/unittests/version/test_version.py
@@ -13,10 +13,9 @@
# limitations under the License.
#
import unittest
-
from unittest.mock import mock_open, patch
-import mycroft.version
+from mycroft.version import check_version, CORE_VERSION_STR, VersionManager
VERSION_INFO = """
@@ -34,32 +33,36 @@ def test_get_version(self):
Assures that only lower versions return True
"""
- self.assertTrue(mycroft.version.check_version('0.0.1'))
- self.assertTrue(mycroft.version.check_version('0.8.1'))
- self.assertTrue(mycroft.version.check_version('0.8.20'))
- self.assertFalse(mycroft.version.check_version('0.8.22'))
- self.assertFalse(mycroft.version.check_version('0.9.12'))
- self.assertFalse(mycroft.version.check_version('1.0.2'))
+ self.assertTrue(check_version('0.0.1'))
+ self.assertTrue(check_version('0.8.1'))
+ self.assertTrue(check_version('0.8.20'))
+ self.assertFalse(check_version('0.8.22'))
+ self.assertFalse(check_version('0.9.12'))
+ self.assertFalse(check_version('1.0.2'))
@patch('mycroft.version.isfile')
@patch('mycroft.version.exists')
@patch('mycroft.version.open',
mock_open(read_data=VERSION_INFO), create=True)
- def test_version_manager(self, mock_exists, mock_isfile):
- """
- Test mycroft.version.VersionManager.get()
+ def test_version_manager_get(self, mock_exists, mock_isfile):
+ """Test mycroft.version.VersionManager.get()
- asserts that the method returns expected data
+ Asserts that the method returns data from version file
"""
mock_isfile.return_value = True
mock_exists.return_value = True
- version = mycroft.version.VersionManager.get()
+ version = VersionManager.get()
self.assertEqual(version['coreVersion'], "1505203453")
self.assertEqual(version['enclosureVersion'], "1.0.0")
- # Check file not existing case
+ @patch('mycroft.version.exists')
+ def test_version_manager_get_no_file(self, mock_exists):
+ """Test mycroft.version.VersionManager.get()
+
+ Asserts that the method returns current version if no file exists.
+ """
mock_exists.return_value = False
- version = mycroft.version.VersionManager.get()
- self.assertEqual(version['coreVersion'], None)
+ version = VersionManager.get()
+ self.assertEqual(version['coreVersion'], CORE_VERSION_STR)
self.assertEqual(version['enclosureVersion'], None)