diff --git a/mycroft/gui/__init__.py b/mycroft/gui/__init__.py index 995242ae26c9..799a8625b5c9 100644 --- a/mycroft/gui/__init__.py +++ b/mycroft/gui/__init__.py @@ -82,7 +82,7 @@ def remote_url(self): def build_message_type(self, event): """Builds a message matching the output from the enclosure.""" - return '{}.{}'.format(self.skill.skill_id, event) + return f'{self.skill.skill_id}.{event}' def setup_default_handlers(self): """Sets the handlers for the default messages.""" @@ -197,7 +197,7 @@ def _pages2uri(self, page_names): else: page_urls.append("file://" + page) else: - raise FileNotFoundError("Unable to find page: {}".format(name)) + raise FileNotFoundError(f"Unable to find page: {name}") return page_urls diff --git a/mycroft/gui/__main__.py b/mycroft/gui/__main__.py index 3d79c6d2d643..c4263e9d756f 100644 --- a/mycroft/gui/__main__.py +++ b/mycroft/gui/__main__.py @@ -13,7 +13,7 @@ def on_stopping(): def on_error(e='Unknown'): - LOG.error('GUI websocket failed: {}'.format(repr(e))) + LOG.error(f'GUI websocket failed: {repr(e)}') def main(ready_hook=on_ready, error_hook=on_error, stopping_hook=on_stopping): diff --git a/mycroft/gui/bus.py b/mycroft/gui/bus.py new file mode 100644 index 000000000000..79d06292bcf4 --- /dev/null +++ b/mycroft/gui/bus.py @@ -0,0 +1,177 @@ +# Copyright 2022 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. +# +"""GUI message bus implementation + +The basic mechanism is: + 1) GUI client connects to the core messagebus + 2) Core prepares a port for a socket connection to this GUI + 3) The availability of the port is sent over the Core + 4) The GUI connects to the GUI message bus websocket + 5) Connection persists for graphical interaction indefinitely + +If the connection is lost, it must be renegotiated and restarted. +""" +import asyncio +import json +from threading import Lock + +from tornado import ioloop +from tornado.options import parse_command_line +from tornado.web import Application +from tornado.websocket import WebSocketHandler + +from mycroft.configuration import Configuration +from mycroft.messagebus import Message +from mycroft.util.log import LOG +from mycroft.util.process_utils import create_daemon + +write_lock = Lock() + + +def get_gui_websocket_config(): + """Retrieves the configuration values for establishing a GUI message bus""" + config = Configuration.get() + websocket_config = config["gui_websocket"] + + return websocket_config + + +def create_gui_service(enclosure) -> Application: + """Initiate a websocket for communicating with the GUI service.""" + LOG.info('Starting message bus for GUI...') + websocket_config = get_gui_websocket_config() + # Disable all tornado logging so mycroft loglevel isn't overridden + parse_command_line(['--logging=None']) + + routes = [(websocket_config['route'], GUIWebsocketHandler)] + application = Application(routes, debug=True) + application.enclosure = enclosure + application.listen( + websocket_config['base_port'], websocket_config['host'] + ) + + create_daemon(ioloop.IOLoop.instance().start) + LOG.info('GUI Message bus started!') + return application + + +def send_message_to_gui(message): + """Sends the supplied message to all connected GUI clients.""" + for connection in GUIWebsocketHandler.clients: + try: + connection.send(message) + except Exception as e: + LOG.exception(repr(e)) + + +def determine_if_gui_connected(): + """Returns True if any clients are connected to the GUI bus.""" + return len(GUIWebsocketHandler.clients) > 0 + + +class GUIWebsocketHandler(WebSocketHandler): + """Defines the websocket pipeline between the GUI and Mycroft.""" + clients = [] + + 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 to the last connected. """ + namespace_pos = 0 + enclosure = self.application.enclosure + + for namespace in enclosure.active_namespaces: + LOG.info(f'Sync {namespace.name}') + # Insert namespace + self.send({"type": "mycroft.session.list.insert", + "namespace": "mycroft.system.active_skills", + "position": namespace_pos, + "data": [{"skill_id": namespace.name}] + }) + # Insert pages + self.send({"type": "mycroft.gui.list.insert", + "namespace": namespace.name, + "position": 0, + "data": [{"url": p.url} for p in namespace.pages] + }) + # Insert data + for key, value in namespace.data.items(): + self.send({"type": "mycroft.session.set", + "namespace": namespace.name, + "data": {key: value} + }) + namespace_pos += 1 + + def on_message(self, message): + LOG.info("Received: {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 + event_name = msg.get('event_name') + if event_name == 'page_gained_focus': + msg_type = 'gui.page_gained_focus' + else: + msg_type = 'gui.page_interaction' + + msg_data = {'namespace': msg['namespace'], + 'page_number': msg['parameters'].get('number'), + 'skill_id': msg['parameters'].get('skillId')} + elif msg.get('type') == "mycroft.events.triggered": + # A normal event was triggered + msg_type = '{}.{}'.format(msg['namespace'], msg['event_name']) + msg_data = msg['parameters'] + + elif msg.get('type') == 'mycroft.session.set': + # A value was changed send it back to the skill + msg_type = '{}.{}'.format(msg['namespace'], 'set') + msg_data = msg['data'] + + message = Message(msg_type, msg_data) + LOG.info('Forwarding to bus...') + self.application.enclosure.core_bus.emit(message) + LOG.info('Done!') + + def write_message(self, *arg, **kwarg): + """Wraps WebSocketHandler.write_message() with a lock. """ + try: + asyncio.get_event_loop() + except RuntimeError: + asyncio.set_event_loop(asyncio.new_event_loop()) + + with write_lock: + super().write_message(*arg, **kwarg) + + def send(self, data): + """Send the given data across the socket as JSON + + Args: + data (dict): Data to transmit + """ + s = json.dumps(data) + #LOG.info('Sending {}'.format(s)) + self.write_message(s) + + def check_origin(self, origin): + """Disable origin check to make js connections work.""" + return True diff --git a/mycroft/gui/namespace.py b/mycroft/gui/namespace.py new file mode 100644 index 000000000000..2d7533af6e37 --- /dev/null +++ b/mycroft/gui/namespace.py @@ -0,0 +1,749 @@ +# Copyright 2022 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. +# +"""Defines the API for the QT GUI. + +Manages what is displayed on a device with a touch screen using a LIFO stack +of "active" namespaces (e.g. skills). At the bottom of the stack is the +namespace for the idle screen skill (if one is specified in the device +configuration). The namespace for the idle screen skill should never be +removed from the stack. + +When a skill with a GUI is triggered by the user, the namespace for that skill +is placed at the top of the stack. The namespace at the top of the stack +represents the namespace that is visible on the device. When the skill is +finished displaying information on the screen, it is removed from the top of +the stack. This will result in the previously active namespace being +displayed. + +The persistence of a namespace indicates how long that namespace stays in the +active stack. A persistence expressed using a number represents how many +seconds the namespace will be active. A persistence expressed with a True +value will be active until the skill issues a command to remove the namespace. +If a skill with a numeric persistence replaces a namespace at the top of the +stack that also has a numeric persistence, the namespace being replaced will +be removed from the active namespace stack. + +The state of the active namespace stack is maintained locally and in the GUI +code. Changes to namespaces, and their contents, are communicated to the GUI +over the GUI message bus. +""" +from threading import Lock, Timer +from typing import List, Union + +from mycroft.configuration import Configuration +from mycroft.messagebus import Message, MessageBusClient +from mycroft.util.log import LOG +from .bus import ( + create_gui_service, + determine_if_gui_connected, + get_gui_websocket_config, + send_message_to_gui +) +from .page import GuiPage +import sys + +namespace_lock = Lock() + +RESERVED_KEYS = ['__from', '__idle'] + +class Namespace: + """A grouping mechanism for related GUI pages and data. + + In the majority of cases, a namespace represents a skill. There is a + SYSTEM namespace for GUI screens that exist outside of skills. This class + defines an API to manage a namespace, its pages and its data. Actions + are communicated to the GUI message bus. + + Attributes: + name: the name of the Namespace, generally the skill ID + persistent: indicates whether or not the namespace persists for a + period of time or until the namespace is removed. + duration: if the namespace persists for a period of time, this is the + number of seconds of persistence + pages: when the namespace is active, contains all the pages that are + displayed at the same time + data: a key/value pair representing the data used to populate the GUI + """ + def __init__(self, name: str): + self.name = name + self.persistent = False + self.duration = 30 + self.pages = list() + self.data = dict() + self.page_number = 0 + self.session_set = False + + def add(self): + """Adds a namespace to the list of active namespaces.""" + LOG.info(f"Adding \"{self.name}\" to active GUI namespaces") + message = dict( + type="mycroft.session.list.insert", + namespace="mycroft.system.active_skills", + position=0, + data=[dict(skill_id=self.name)] + ) + send_message_to_gui(message) + + def activate(self, position: int): + """Activates an namespace already in the list of active namespaces.""" + LOG.info(f"Activating GUI namespace \"{self.name}\"") + message = { + "type": "mycroft.session.list.move", + "namespace": "mycroft.system.active_skills", + "from": position, + "to": 0, + "items_number": 1 + } + send_message_to_gui(message) + + def remove(self, position: int): + """Removes a namespace from the list of active namespaces.""" + LOG.info(f"Removing {self.name} from active GUI namespaces") + + # unload the data first before removing the namespace + # use the keys of the data to unload the data + for key in self.data: + self.unload_data(key) + + message = dict( + type="mycroft.session.list.remove", + namespace="mycroft.system.active_skills", + position=position, + items_number=1 + ) + send_message_to_gui(message) + self.session_set = False + self.pages = list() + self.data = dict() + + def load_data(self, name: str, value: str): + """Adds or changes the value of a namespace data attribute. + + Args: + name: The name of the attribute + value: The attribute's value + """ + message = dict( + type="mycroft.session.set", + namespace=self.name, + data={name: value} + ) + + #LOG.info(f"Setting data {message} in GUI namespace {self.name}") + send_message_to_gui(message) + + def unload_data(self, name: str): + """ Delete data from the namespace """ + message = dict( + type="mycroft.session.delete", + property=name, + namespace=self.name + ) + #LOG.info(f"Deleting data {message} from GUI namespace {self.name}") + send_message_to_gui(message) + + def get_position_of_last_item_in_data(self): + """ Get the position of the last item """ + return len(self.data) - 1 + + def set_persistence(self, skill_type: str): + """Sets the duration of the namespace's time in the active list. + + Args: + skill_type: if skill type is idleDisplaySkill, the namespace will + always persist. Otherwise, the namespace will persist based on the + active page's persistence. + """ + # check if skill_type is idleDisplaySkill + if skill_type == "idleDisplaySkill": + self.persistent = True + self.duration = 0 + + else: + # get the active page in the namespace + active_page = self.get_active_page() + + # if type(persistence) == int: + # Get the duration of the active page if it is not persistent + if active_page is not None and not active_page.persistent: + self.persistent = False + self.duration = active_page.duration + + # elif type(persistence) == bool: + # Get the persistance of the active page + elif active_page is not None and active_page.persistent: + self.persistent = True + self.duration = 0 + + # else use the default duration of 30 seconds + else: + self.persistent = False + self.duration = 30 + + def load_pages(self, pages: List[str]): + """Maintains a list of active pages within the active namespace. + + Skills with multiple pages of data can either show all the screens + at once, allowing the user to swipe back and forth among them, or + the pages can be loaded one at a time. The latter is represented by + a single list item, the former by multiple list items + + Args: + pages: one or more pages to be displayed + """ + new_pages = list() + + for page in pages: + if page.url not in [page.url for page in self.pages]: + new_pages.append(page) + + self.pages.extend(new_pages) + if new_pages: + self._add_pages(new_pages) + else: + page = pages[0] + + self._activate_page(pages[0]) + + def _add_pages(self, new_pages: List[str]): + """Adds once or more pages to the active page list. + + Args: + new_pages: pages to add to the active page list + """ + LOG.info(f"Adding pages to GUI namespace {self.name}: {new_pages}") + LOG.info(f"Current pages: {self.pages}") + # print the attributes of the new pages + for page in new_pages: + LOG.info(f"Page: {page.url}, {page.name}, {page.persistent}, {page.duration}") + + #Find position of new page in self.pages + position = self.pages.index(new_pages[0]) + + message = dict( + type="mycroft.gui.list.insert", + namespace=self.name, + position=position, + data=[dict(url=page.url) for page in new_pages] + ) + send_message_to_gui(message) + + def _activate_page(self, page: str): + """Returns focus to a page already in the active page list. + + Args: + page: the page that will gain focus + """ + LOG.info(f"Activating page {page.name} in GUI namespace {self.name}") + LOG.info(f"Current pages from _activate_page: {self.pages}") + # get the index of the page in the self.pages list + page_index = 0 + for i, p in enumerate(self.pages): + if p.url == page.url: + page_index = i + break + + self.page_number = page_index + + # set the page active attribute to True and update the self.pages list, mark all other pages as inactive + + page.active = True; + + for p in self.pages: + if p != page: + p.active = False; + # update the self.pages list with the page active status changes + self.pages[self.pages.index(p)] = p + + self.pages[page_index] = page + + message = dict( + type="mycroft.events.triggered", + namespace=self.name, + event_name="page_gained_focus", + data=dict(number=page_index) + ) + send_message_to_gui(message) + + def remove_pages(self, positions: List[int]): + """Deletes one or more pages from the active page list. + + Args: + positions: page position to remove + """ + print(f"Removing pages from GUI namespace {self.name}: {positions}") + for position in positions: + page = self.pages.pop(position) + LOG.info(f"Deleting {page} from GUI namespace {self.name}") + message = dict( + type="mycroft.gui.list.remove", + namespace=self.name, + position=position, + items_number=1 + ) + send_message_to_gui(message) + + def page_gained_focus(self, page_number): + """Updates the active page in self.pages + + Args: + page_number: the page number of the page that will gain focus + """ + LOG.info(f"Page {page_number} gained focus in GUI namespace {self.name}") + self._activate_page(self.pages[page_number]) + + def page_update_interaction(self, page_number): + """Update the interaction of the page_number""" + + LOG.info(f"Page {page_number} update interaction in GUI namespace {self.name}") + page = self.pages.index(page_number) + if not page.persistent and page.duration > 0: + page.duration = page.duration / 2 + + # update the self.pages list with the page interaction status changes + self.pages[page_number] = page + self.set_persistence(skill_type="genericSkill") + + def get_page_at_position(self, position: int): + """Returns the position of the page in the active page list.""" + return self.pages.index(position) + + def get_active_page(self): + """Returns the currently active page from self.pages where the page attribute active is true""" + for page in self.pages: + if page.active: + return page + return None + + def get_active_page_index(self): + # get the active page index in the self.pages list + active_page = self.get_active_page() + if active_page is not None: + return self.pages.index(active_page) + + def index_in_pages_list(self, index): + return(index < len(self.pages)) + +def _validate_page_message(message: Message): + """Validates the contents of the message data for page add/remove messages. + + Args: + message: A core message bus message to add/remove one or more pages + from a namespace. + """ + valid = ( + "page" in message.data + and "__from" in message.data + and isinstance(message.data["page"], list) + ) + if not valid: + if message.msg_type == "gui.page.show": + action = "shown" + else: + action = "removed" + LOG.error( + f"Page will not be {action} due to malformed data in the" + f"{message.msg_type} message" + ) + + return valid + + +def _get_idle_display_config(): + """Retrieves the current value of the idle display skill configuration.""" + LOG.info("Getting Idle Skill From Config") + config = Configuration.get() + enclosure_config = config.get("enclosure") + idle_display_skill = enclosure_config.get("idle_display_skill") + + return idle_display_skill + + +class NamespaceManager: + """Manages the active namespace stack and the content of namespaces. + + Attributes: + core_bus: client for communicating with the core message bus + gui_bus: client for communicating with the GUI message bus + loaded_namespaces: cache of namespaces that have been introduced + active_namespaces: LIFO stack of namespaces being displayed + remove_namespace_timers: background process to remove a namespace with + a persistence expressed in seconds + idle_display_skill: skill ID of the skill that controls the idle screen + """ + def __init__(self, core_bus: MessageBusClient): + self.core_bus = core_bus + self.gui_bus = create_gui_service(self) + self.loaded_namespaces = dict() + self.active_namespaces = list() + self.remove_namespace_timers = dict() + self.idle_display_skill = _get_idle_display_config() + self._define_message_handlers() + + def _define_message_handlers(self): + """Assigns methods as handlers for specified message types.""" + self.core_bus.on("gui.clear.namespace", self.handle_clear_namespace) + self.core_bus.on("gui.event.send", self.handle_send_event) + self.core_bus.on("gui.page.delete", self.handle_delete_page) + self.core_bus.on("gui.page.show", self.handle_show_page) + self.core_bus.on("gui.status.request", self.handle_status_request) + self.core_bus.on("gui.value.set", self.handle_set_value) + self.core_bus.on("mycroft.gui.connected", self.handle_client_connected) + self.core_bus.on("gui.page_interaction", self.handle_page_interaction) + self.core_bus.on("gui.page_gained_focus", self.handle_page_gained_focus) + + def handle_clear_namespace(self, message: Message): + """Handles a request to remove a namespace. + + Args: + message: the message requesting namespace removal + """ + try: + namespace_name = message.data['__from'] + except KeyError: + LOG.error( + "Request to delete namespace failed: no namespace specified" + ) + else: + with namespace_lock: + self._remove_namespace(namespace_name) + + @staticmethod + def handle_send_event(message: Message): + """Handles a request to send a message to the GUI message bus. + + Args: + message: the message requesting a message to be sent to the GUI + message bus. + """ + try: + message = dict( + type='mycroft.events.triggered', + namespace=message.data.get('__from'), + event_name=message.data.get('event_name'), + params=message.data.get('params') + ) + send_message_to_gui(message) + except Exception: + LOG.exception('Could not send event trigger') + + def handle_delete_page(self, message: Message): + """Handles request to remove one or more pages from a namespace. + + Args: + message: the message requesting page removal + """ + message_is_valid = _validate_page_message(message) + if message_is_valid: + namespace_name = message.data["__from"] + pages_to_remove = message.data["page"] + with namespace_lock: + self._remove_pages(namespace_name, pages_to_remove) + + def _remove_pages(self, namespace_name: str, pages_to_remove: List[str]): + """Removes one or more pages from a namespace. + + Pages are removed from the bottom of the stack. + + Args: + namespace_name: the affected namespace + pages_to_remove: names of pages to delete + """ + namespace = self.loaded_namespaces.get(namespace_name) + if namespace is not None and namespace in self.active_namespaces: + page_positions = [] + for index, page in enumerate(pages_to_remove): + # if page matches namespace.pages.url: + if page == namespace.pages[index].url: + page_positions.append(index) + + page_positions.sort(reverse=True) + namespace.remove_pages(page_positions) + + def handle_show_page(self, message: Message): + """Handles a request to show one or more pages on the screen. + + Args: + message: the message containing the page show request + """ + LOG.info("Handling page show request") + message_is_valid = _validate_page_message(message) + if message_is_valid: + namespace_name = message.data["__from"] + pages_to_show = message.data["page"] + persistence = message.data["__idle"] + + pages_to_load = list() + for page in pages_to_show: + name = page.split('/')[-1] + # check if persistence is type of int or bool + if isinstance(persistence, bool): + persist = persistence + duration = 0 + + # check if persistence is type of int + elif isinstance(persistence, int): + persist = False + duration = persistence + + else: + persist = False + duration = 30 + + pages_to_load.append(GuiPage(page, name, persist, duration)) + + with namespace_lock: + self._activate_namespace(namespace_name) + self._load_pages(pages_to_load) + self._update_namespace_persistence(persistence) + + def _activate_namespace(self, namespace_name: str): + """Instructs the GUI to load a namespace and its associated data. + + Args: + namespace_name: the name of the namespace to load + """ + namespace = self._ensure_namespace_exists(namespace_name) + LOG.info(f"Activating namespace: {namespace_name}") + + if namespace in self.active_namespaces: + namespace_position = self.active_namespaces.index(namespace) + namespace.activate(namespace_position) + self.active_namespaces.insert( + 0, self.active_namespaces.pop(namespace_position) + ) + else: + namespace.add() + self.active_namespaces.insert(0, namespace) + for key, value in namespace.data.items(): + namespace.load_data(key, value) + + self._emit_namespace_displayed_event() + + def _ensure_namespace_exists(self, namespace_name: str) -> Namespace: + """Retrieves the requested namespace, creating one if it doesn't exist. + + Args: + namespace_name: the name of the namespace being retrieved + + Returns: + the requested namespace + """ + # TODO: - Update sync to match. + namespace = self.loaded_namespaces.get(namespace_name) + if namespace is None: + namespace = Namespace(namespace_name) + self.loaded_namespaces[namespace_name] = namespace + + return namespace + + def _load_pages(self, pages_to_show: str): + """Loads the requested pages in the namespace. + + Args: + pages_to_show: the pages requested to be loaded + """ + active_namespace = self.active_namespaces[0] + active_namespace.load_pages(pages_to_show) + + def _update_namespace_persistence(self, persistence: Union[bool, int]): + """Sets the persistence of the namespace being activated. + + A namespace's persistence is the same as the persistence of the + most recent pages added to a namespace. For example, a multi-page + namespace could show the first set of pages with a persistence of + True (show until removed) and the last page with a persistence of + 15 seconds. This would ensure that the namespace isn't removed while + the skill is showing the pages. + + Args: + persistence: length of time the namespace should be displayed + """ + LOG.debug(f"Setting namespace persistence to {persistence}") + for position, namespace in enumerate(self.active_namespaces): + if position: + if not namespace.persistent: + self._remove_namespace(namespace.name) + else: + if namespace.name == self.idle_display_skill: + namespace.set_persistence(skill_type="idleDisplaySkill") + else: + namespace.set_persistence(skill_type="genericSkill") + + # check if there is a scheduled remove_namespace_timer and cancel it + if namespace.persistent: + if namespace.name in self.remove_namespace_timers: + self.remove_namespace_timers[namespace.name].cancel() + self._del_namespace_in_remove_timers(namespace.name) + + if not namespace.persistent: + LOG.info("It is being scheduled here") + self._schedule_namespace_removal(namespace) + + def _schedule_namespace_removal(self, namespace: Namespace): + """Uses a timer thread to remove the namespace. + + Args: + namespace: the namespace to be removed + """ + # Before removing check if there isn't already a timer for this namespace + if namespace.name in self.remove_namespace_timers: + return + + remove_namespace_timer = Timer( + namespace.duration, + self._remove_namespace_via_timer, + args=(namespace.name,) + ) + LOG.debug(f"Scheduled removal of namespace {namespace.name} in duration {namespace.duration}") + remove_namespace_timer.start() + self.remove_namespace_timers[namespace.name] = remove_namespace_timer + + def _remove_namespace_via_timer(self, namespace_name: str): + """Removes a namespace and the corresponding timer instance.""" + self._remove_namespace(namespace_name) + self._del_namespace_in_remove_timers(namespace_name) + + def _remove_namespace(self, namespace_name: str): + """Removes a namespace from the active namespace stack. + + Args: + namespace_name: namespace to remove + """ + LOG.debug("Removing namespace {namespace_name}") + # Remove all timers associated with the namespace + if namespace_name in self.remove_namespace_timers: + self.remove_namespace_timers[namespace_name].cancel() + self._del_namespace_in_remove_timers(namespace_name) + + namespace = self.loaded_namespaces.get(namespace_name) + if namespace is not None and namespace in self.active_namespaces: + namespace_position = self.active_namespaces.index(namespace) + namespace.remove(namespace_position) + self.active_namespaces.remove(namespace) + self._emit_namespace_displayed_event() + + def _emit_namespace_displayed_event(self): + if self.active_namespaces: + displaying_namespace = self.active_namespaces[0] + message_data = dict(skill_id=displaying_namespace.name) + self.core_bus.emit( + Message("gui.namespace.displayed", data=message_data) + ) + + def handle_status_request(self, message: Message): + """Handles a GUI status request by replying with the connection status. + + Args: + message: the request for status of the GUI + """ + gui_connected = determine_if_gui_connected() + reply = message.reply( + "gui.status.request.response", dict(connected=gui_connected) + ) + self.core_bus.emit(reply) + + def handle_set_value(self, message: Message): + """Handles a request to set the value of namespace data attributes. + + Args: + message: the request to set attribute values + """ + try: + namespace_name = message.data['__from'] + except KeyError: + LOG.error( + "Request to set gui attribute value failed: no " + "namespace specified" + ) + else: + with namespace_lock: + self._update_namespace_data(namespace_name, message.data) + + def _update_namespace_data(self, namespace_name: str, data: dict): + """Updates the values of namespace data attributes, unless unchanged. + + Args: + namespace_name: the name of the namespace to update + data: the name and new value of one or more data attributes + """ + namespace = self._ensure_namespace_exists(namespace_name) + for key, value in data.items(): + if key not in RESERVED_KEYS and namespace.data.get(key) != value: + LOG.debug(f"Setting {key} to {value} in namespace {namespace.name}") + namespace.data[key] = value + if namespace in self.active_namespaces: + namespace.load_data(key, value) + + def handle_client_connected(self, message: Message): + """Handles an event from the GUI indicating it is connected to the bus. + + Args: + message: the event sent by the GUI + """ + # GUI has announced presence + # Announce connection, the GUI should connect on it soon + gui_id = message.data.get("gui_id") + LOG.info(f"GUI with ID {gui_id} connected to core message bus") + websocket_config = get_gui_websocket_config() + port = websocket_config["base_port"] + message = Message("mycroft.gui.port", dict(port=port, gui_id=gui_id)) + self.core_bus.emit(message) + + def handle_page_interaction(self, message: Message): + """Handles an event from the GUI indicating the page has been interacted with. + + Args: + message: the event sent by the GUI + """ + # GUI has interacted with a page + # Update and increase the namespace duration and reset the remove timer + namespace_name = message.data.get("skill_id") + LOG.debug(f"GUI interacted with page in namespace {namespace_name}") + if namespace_name == self.idle_display_skill: + return + else: + namespace = self.loaded_namespaces.get(namespace_name) + if not namespace.persistent: + if self.remove_namespace_timers[namespace.name]: + self.remove_namespace_timers[namespace.name].cancel() + self._del_namespace_in_remove_timers(namespace.name) + self._schedule_namespace_removal(namespace) + + def handle_page_gained_focus(self, message: Message): + """Handles focus events from the GUI indicating the page has gained focus. + + Args: + message: the event sent by the GUI + """ + namespace_name = message.data.get("skill_id") + namespace_page_number = message.data.get("page_number") + LOG.debug(f"Page in namespace {namespace_name} gained focus") + namespace = self.loaded_namespaces.get(namespace_name) + + # first check if the namespace is already active + if namespace in self.active_namespaces: + # if the namespace is already active, check if the page number has changed + if namespace_page_number != namespace.page_number: + namespace.page_gained_focus(namespace_page_number) + + def _del_namespace_in_remove_timers(self, namespace_name): + """ Delete namespace from remove_namespace_timers dict. + + Args: + namespace: namespace to be deleted + """ + if namespace_name in self.remove_namespace_timers: + del self.remove_namespace_timers[namespace_name] diff --git a/mycroft/gui/page.py b/mycroft/gui/page.py new file mode 100644 index 000000000000..a1d7a1ed8a7e --- /dev/null +++ b/mycroft/gui/page.py @@ -0,0 +1,25 @@ +from mycroft.util.log import LOG + +class GuiPage: + """ A representation of a GUI Page + + A GuiPage represents a single GUI Display within a given namespace. A Page + has a name, a position and can have either Persistence or Duration during + which it will exist + + Attributes: + name: the name of the page that is shown in a given namespace, assigned + by the skill author + persistent: indicated weather or not the page itself should persists for a + period of time or unit the it is removed manually + duration: the duration of the page in the namespace, assigned by the skill + author if the page is not persistent + active: indicates whether the page is currently active in the namespace + """ + + def __init__(self, url: str, name: str, persistent: bool, duration: int): + self.url = url + self.name = name + self.persistent = persistent + self.duration = duration + self.active = False diff --git a/mycroft/gui/service.py b/mycroft/gui/service.py index 10acc94833e8..45bf2285b4a4 100644 --- a/mycroft/gui/service.py +++ b/mycroft/gui/service.py @@ -1,520 +1,115 @@ -import asyncio -import json -from collections import namedtuple -from threading import Lock - -import tornado.web as web -from tornado import ioloop -from tornado.websocket import WebSocketHandler - -from mycroft.configuration import Configuration +from mycroft.messagebus import Message from mycroft.messagebus.client import MessageBusClient -from mycroft.messagebus.message import Message from mycroft.util import create_daemon, start_message_bus_client +from mycroft.configuration import Configuration, LocalConf, USER_CONFIG from mycroft.util.log import LOG - -Namespace = namedtuple('Namespace', ['name', 'pages']) -write_lock = Lock() -namespace_lock = Lock() - -RESERVED_KEYS = ['__from', '__idle'] -gui_app_settings = { - 'debug': True -} - +from .namespace import NamespaceManager class GUIService: def __init__(self): - self.global_config = Configuration.get() - # Create Message Bus Client self.bus = MessageBusClient() + self.gui = NamespaceManager(self.bus) - self.gui_protocol = self.create_gui_socket() - - # This datastore holds the data associated with the GUI provider. Data - # is stored in Namespaces, so you can have: - # self.datastore["namespace"]["name"] = value - # Typically the namespace is a meaningless identifier, but there is a - # special "SYSTEM" namespace. - self.datastore = {} - - # self.loaded is a list, each element consists of a namespace named - # tuple. - # The namespace namedtuple has the properties "name" and "pages" - # The name contains the namespace name as a string and pages is a - # mutable list of loaded pages. - # - # [Namespace name, [List of loaded qml pages]] - # [ - # ["SKILL_NAME", ["page1.qml, "page2.qml", ... , "pageN.qml"] - # [...] - # ] - self.loaded = [] # list of lists in order. - self.explicit_move = True # Set to true to send reorder commands - - # Listen for new GUI clients to announce themselves on the main bus - self.active_namespaces = [] - self.bus.on("mycroft.gui.connected", self.on_gui_client_connected) - - # First send any data: - self.bus.on("gui.value.set", self.on_gui_set_value) - self.bus.on("gui.page.show", self.on_gui_show_page) - self.bus.on("gui.page.delete", self.on_gui_delete_page) - self.bus.on("gui.clear.namespace", self.on_gui_delete_namespace) - self.bus.on("gui.event.send", self.on_gui_send_event) - self.bus.on("gui.status.request", self.handle_gui_status_request) - - def create_gui_socket(self): - 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']) - config = self.global_config['gui_websocket'] - - routes = [(config['route'], GUIWebsocketHandler)] - application = web.Application(routes, debug=True) - application.service = self - application.listen(config['base_port'], config['host']) - - create_daemon(ioloop.IOLoop.instance().start) - LOG.info('GUI Message bus started!') - return application + self.homescreens = [] + self.bus.on('homescreen.manager.add', self.add_homescreen) + self.bus.on('homescreen.manager.remove', self.remove_homescreen) + self.bus.on('homescreen.manager.list', self.get_homescreens) + self.bus.on("homescreen.manager.get_active", self.get_active_homescreen) + self.bus.on("homescreen.manager.set_active", self.set_active_homescreen) + self.bus.on("homescreen.manager.disable_active", self.disable_active_homescreen) + self.bus.on("mycroft.mark2.register_idle", self.register_old_style_homescreen) def run(self): """Start the GUI after it has been constructed.""" # Allow exceptions to be raised to the GUI Service # if they may cause the Service to fail. start_message_bus_client("GUI_SERVICE", self.bus) + self.reload_homescreens_list() + + def add_homescreen(self, homescreen): + # if homescreen[id] not in self.homescreens then add it + homescreen_id = homescreen.data["id"] + homescreen_class = homescreen.data["class"] + LOG.info(f"Homescreen Manager: Adding Homescreen {homescreen_id}") + # check if the list is empty + if len(self.homescreens) == 0: + self.homescreens.append(homescreen.data) + else: + # check if id is in list of homescreen dicts in self.homescreens + for h in self.homescreens: + if homescreen_id != h["id"]: + self.homescreens.append(homescreen.data) + + self.show_homescreen_on_add(homescreen_id, homescreen_class) + + def remove_homescreen(self, homescreen): + homescreen_id = homescreen.data["id"] + LOG.info(f"Homescreen Manager: Removing Homescreen {homescreen_id}") + for h in self.homescreens: + if homescreen_id == h["id"]: + self.homescreens.pop(h) + + def get_homescreens(self): + return self.homescreens + + def get_active_homescreen(self): + config = Configuration.get() + enclosure_config = config.get("enclosure") + active_homescreen = enclosure_config.get("idle_display_skill") + LOG.info(f"Homescreen Manager: Active Homescreen {active_homescreen}") + for h in self.homescreens: + if h["id"] == active_homescreen: + return active_homescreen + + def set_active_homescreen(self, homescreen): + homescreen_id = homescreen.data["id"] + conf = LocalConf(USER_CONFIG) + conf["enclosure"] = { + "idle_display_skill": homescreen_id, + } + conf.store() + self.bus.emit(Message("configuration.patch", {"config": conf})) + + def reload_homescreens_list(self): + LOG.info("Homescreen Manager: Reloading Homescreen List") + self.collect_old_style_homescreens() + self.bus.emit(Message("homescreen.manager.reload.list")) + + def show_homescreen_on_add(self, homescreen_id, homescreen_class): + active_homescreen = self.get_active_homescreen() + if active_homescreen == homescreen_id: + if homescreen_class == "IdleDisplaySkill": + LOG.info(f"Homescreen Manager: Displaying Homescreen {active_homescreen}") + self.bus.emit(Message("homescreen.manager.activate.display", {"homescreen_id": active_homescreen})) + elif homescreen_class == "MycroftSkill": + LOG.info(f"Homescreen Manager: Displaying Homescreen {active_homescreen}") + self.bus.emit(Message("{}.idle".format(homescreen_id))) + + def disable_active_homescreen(self, message): + conf = LocalConf(USER_CONFIG) + conf["enclosure"] = { + "idle_display_skill": None, + } + conf.store() + self.bus.emit(Message("configuration.patch", {"config": conf})) + + ### Add compabitility with older versions of the Resting Screen Class + + def collect_old_style_homescreens(self): + """Trigger collection of older resting screens.""" + self.bus.emit(Message("mycroft.mark2.collect_idle")) + + def register_old_style_homescreen(self, message): + if "name" in message.data and "id" in message.data: + super_class_name = "MycroftSkill" + super_class_object = message.data["name"] + skill_id = message.data["id"] + _homescreen_entry = {"class": super_class_name, "name": super_class_object , "id": skill_id} + LOG.debug("Homescreen Manager: Adding OLD Homescreen {skill_id}") + self.add_homescreen(Message("homescreen.manager.add", _homescreen_entry)) + else: + LOG.error("Malformed idle screen registration received") def stop(self): """Perform any GUI shutdown processes.""" pass - - ###################################################################### - # GUI client API - @property - def gui_connected(self): - """Returns True if at least 1 gui is connected, else False""" - return len(GUIWebsocketHandler.clients) > 0 - - def handle_gui_status_request(self, message): - """Reply to gui status request, allows querying if a gui is - connected using the message bus""" - self.bus.emit(message.reply("gui.status.request.response", - {"connected": self.gui_connected})) - - def send(self, msg_dict): - """ Send to all registered GUIs. """ - 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. """ - try: - data = {'type': 'mycroft.events.triggered', - 'namespace': message.data.get('__from'), - 'event_name': message.data.get('event_name'), - 'params': message.data.get('params')} - self.send(data) - except Exception as e: - LOG.error('Could not send event ({})'.format(repr(e))) - - def on_gui_set_value(self, message): - data = message.data - namespace = data.get("__from", "") - - # Pass these values on to the GUI renderers - for key in data: - if key not in RESERVED_KEYS: - try: - self.set(namespace, key, data[key]) - except Exception as e: - LOG.exception(repr(e)) - - def set(self, namespace, name, value): - """ Perform the send of the values to the connected GUIs. """ - if namespace not in self.datastore: - self.datastore[namespace] = {} - if self.datastore[namespace].get(name) != value: - self.datastore[namespace][name] = value - - # If the namespace is loaded send data to GUI - if namespace in [l.name for l in self.loaded]: - msg = {"type": "mycroft.session.set", - "namespace": namespace, - "data": {name: value}} - self.send(msg) - - def on_gui_delete_page(self, message): - """ Bus handler for removing pages. """ - page, namespace, _ = self._get_page_data(message) - try: - with namespace_lock: - self.remove_pages(namespace, page) - except Exception as e: - LOG.exception(repr(e)) - - def on_gui_delete_namespace(self, message): - """ Bus handler for removing namespace. """ - try: - namespace = message.data['__from'] - with namespace_lock: - self.remove_namespace(namespace) - except Exception as e: - LOG.exception(repr(e)) - - def on_gui_show_page(self, message): - try: - page, namespace, index = self._get_page_data(message) - # Pass the request to the GUI(s) to pull up a page template - with namespace_lock: - self.show(namespace, page, index) - except Exception as e: - LOG.exception(repr(e)) - - @staticmethod - def _get_page_data(message): - """ Extract page related data from a message. - - Args: - message: messagebus message object - Returns: - tuple (page, namespace, index) - Raises: - ValueError if value is missing. - """ - data = message.data - # Note: 'page' can be either a string or a list of strings - if 'page' not in data: - raise ValueError("Page missing in data") - if 'index' in data: - index = data['index'] - else: - index = 0 - page = data.get("page", "") - namespace = data.get("__from", "") - return page, namespace, index - - def __find_namespace(self, namespace): - for i, skill in enumerate(self.loaded): - if skill[0] == namespace: - return i - return None - - def __insert_pages(self, namespace, pages): - """ Insert pages into the namespace - - Args: - namespace (str): Namespace to add to - pages (list): Pages (str) to insert - """ - LOG.debug("Inserting new pages") - if not isinstance(pages, list): - raise ValueError('Argument must be list of pages') - - self.send({"type": "mycroft.gui.list.insert", - "namespace": namespace, - "position": len(self.loaded[0].pages), - "data": [{"url": p} for p in pages] - }) - # Insert the pages into local reprensentation as well. - updated = Namespace(self.loaded[0].name, self.loaded[0].pages + pages) - self.loaded[0] = updated - - def __remove_page(self, namespace, pos): - """ Delete page. - - Args: - namespace (str): Namespace to remove from - pos (int): Page position to remove - """ - LOG.debug("Deleting {} from {}".format(pos, namespace)) - self.send({"type": "mycroft.gui.list.remove", - "namespace": namespace, - "position": pos, - "items_number": 1 - }) - # Remove the page from the local reprensentation as well. - self.loaded[0].pages.pop(pos) - # Add a check to return any display to idle from position 0 - if (pos == 0 and len(self.loaded[0].pages) == 0): - self.bus.emit(Message("mycroft.device.show.idle")) - - def __insert_new_namespace(self, namespace, pages): - """ Insert new namespace and pages. - - This first sends a message adding a new namespace at the - highest priority (position 0 in the namespace stack) - - Args: - namespace (str): The skill namespace to create - pages (str): Pages to insert (name matches QML) - """ - LOG.debug("Inserting new namespace") - self.send({"type": "mycroft.session.list.insert", - "namespace": "mycroft.system.active_skills", - "position": 0, - "data": [{"skill_id": namespace}] - }) - - # Load any already stored Data - data = self.datastore.get(namespace, {}) - for key in dict(data): - msg = {"type": "mycroft.session.set", - "namespace": namespace, - "data": {key: data[key]}} - self.send(msg) - - LOG.debug("Inserting new page") - self.send({"type": "mycroft.gui.list.insert", - "namespace": namespace, - "position": 0, - "data": [{"url": p} for p in pages] - }) - # Make sure the local copy is updated - self.loaded.insert(0, Namespace(namespace, pages)) - - def __move_namespace(self, from_pos, to_pos): - """ Move an existing namespace to a new position in the stack. - - Args: - from_pos (int): Position in the stack to move from - to_pos (int): Position to move to - """ - LOG.debug("Activating existing namespace") - # Seems like the namespace is moved to the top automatically when - # a page change is done. Deactivating this for now. - if self.explicit_move: - LOG.debug("move {} to {}".format(from_pos, to_pos)) - self.send({"type": "mycroft.session.list.move", - "namespace": "mycroft.system.active_skills", - "from": from_pos, "to": to_pos, - "items_number": 1}) - # Move the local representation of the skill from current - # position to position 0. - self.loaded.insert(to_pos, self.loaded.pop(from_pos)) - - def __switch_page(self, namespace, pages): - """ Switch page to an already loaded page. - - Args: - pages (list): pages (str) to switch to - namespace (str): skill namespace - """ - try: - num = self.loaded[0].pages.index(pages[0]) - except Exception as e: - LOG.exception(repr(e)) - num = 0 - - LOG.debug('Switching to already loaded page at ' - 'index {} in namespace {}'.format(num, namespace)) - self.send({"type": "mycroft.events.triggered", - "namespace": namespace, - "event_name": "page_gained_focus", - "data": {"number": num}}) - - def show(self, namespace, page, index): - """ Show a page and load it as needed. - - Args: - page (str or list): page(s) to show - namespace (str): skill namespace - index (int): ??? TODO: Unused in code ??? - - TODO: - Update sync to match. - - Separate into multiple functions/methods - """ - - LOG.debug("GUIConnection activating: " + namespace) - pages = page if isinstance(page, list) else [page] - - # find namespace among loaded namespaces - try: - index = self.__find_namespace(namespace) - if index is None: - # This namespace doesn't exist, insert them first so they're - # shown. - self.__insert_new_namespace(namespace, pages) - return - else: # Namespace exists - if index > 0: - # Namespace is inactive, activate it by moving it to - # position 0 - self.__move_namespace(index, 0) - - # Find if any new pages needs to be inserted - new_pages = [p for p in pages if p not in self.loaded[0].pages] - if new_pages: - self.__insert_pages(namespace, new_pages) - else: - # No new pages, just switch - self.__switch_page(namespace, pages) - except Exception as e: - LOG.exception(repr(e)) - - def remove_namespace(self, namespace): - """ Remove namespace. - - Args: - namespace (str): namespace to remove - """ - index = self.__find_namespace(namespace) - if index is None: - return - else: - LOG.debug("Removing namespace {} at {}".format(namespace, index)) - self.send({"type": "mycroft.session.list.remove", - "namespace": "mycroft.system.active_skills", - "position": index, - "items_number": 1 - }) - # Remove namespace from loaded namespaces - self.loaded.pop(index) - - def remove_pages(self, namespace, pages): - """ Remove the listed pages from the provided namespace. - - Args: - namespace (str): The namespace to modify - pages (list): List of page names (str) to delete - """ - try: - index = self.__find_namespace(namespace) - if index is None: - return - else: - # Remove any pages that doesn't exist in the namespace - pages = [p for p in pages if p in self.loaded[index].pages] - # Make sure to remove pages from the back - indexes = [self.loaded[index].pages.index(p) for p in pages] - indexes = sorted(indexes) - indexes.reverse() - for page_index in indexes: - self.__remove_page(namespace, page_index) - except Exception as e: - LOG.exception(repr(e)) - - ###################################################################### - # GUI client socket - # - # The basic mechanism is: - # 1) GUI client announces itself on the main messagebus - # 2) Mycroft prepares a port for a socket connection to this GUI - # 3) The port is announced over the messagebus - # 4) The GUI connects on the socket - # 5) Connection persists for graphical interaction indefinitely - # - # 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") - - 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": port, - "gui_id": gui_id})) - - -class GUIWebsocketHandler(WebSocketHandler): - """The socket pipeline between the GUI and Mycroft.""" - clients = [] - - def open(self): - GUIWebsocketHandler.clients.append(self) - LOG.info('New Connection opened!') - self.synchronize() - - def on_close(self, *args): - LOG.info('Closing {}'.format(id(self))) - GUIWebsocketHandler.clients.remove(self) - - def synchronize(self): - """ Upload namespaces, pages and data to the last connected. """ - namespace_pos = 0 - service = self.application.service - - for namespace, pages in service.loaded: - LOG.info('Sync {}'.format(namespace)) - # Insert namespace - self.send({"type": "mycroft.session.list.insert", - "namespace": "mycroft.system.active_skills", - "position": namespace_pos, - "data": [{"skill_id": namespace}] - }) - # Insert pages - self.send({"type": "mycroft.gui.list.insert", - "namespace": namespace, - "position": 0, - "data": [{"url": p} for p in pages] - }) - # Insert data - data = service.datastore.get(namespace, {}) - for key in data: - self.send({"type": "mycroft.session.set", - "namespace": namespace, - "data": {key: data[key]} - }) - namespace_pos += 1 - - def on_message(self, *args): - if len(args) == 1: - message = args[0] - else: - message = args[1] - 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'), - 'skill_id': msg['parameters'].get('skillId')} - elif msg.get('type') == "mycroft.events.triggered": - # A normal event was triggered - msg_type = '{}.{}'.format(msg['namespace'], msg['event_name']) - msg_data = msg['parameters'] - - elif msg.get('type') == 'mycroft.session.set': - # A value was changed send it back to the skill - msg_type = '{}.{}'.format(msg['namespace'], 'set') - msg_data = msg['data'] - - message = Message(msg_type, msg_data) - LOG.info('Forwarding to bus...') - self.application.service.bus.emit(message) - LOG.info('Done!') - - def write_message(self, *arg, **kwarg): - """Wraps WebSocketHandler.write_message() with a lock. """ - try: - asyncio.get_event_loop() - except RuntimeError: - asyncio.set_event_loop(asyncio.new_event_loop()) - - with write_lock: - super().write_message(*arg, **kwarg) - - def send(self, data): - """Send the given data across the socket as JSON - - Args: - data (dict): Data to transmit - """ - s = json.dumps(data) - LOG.info('Sending {}'.format(s)) - self.write_message(s) - - def check_origin(self, origin): - """Disable origin check to make js connections work.""" - return True diff --git a/requirements/requirements.txt b/requirements/requirements.txt index dc83a1739141..b8161bcb5779 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -16,7 +16,7 @@ ovos-tts-plugin-google-tx~=0.0.3 ovos-ww-plugin-pocketsphinx~=0.1.2 ovos-ww-plugin-precise-lite~=0.1.1 ovos-ww-plugin-precise~=0.1.1 -ovos_workshop>=0.0.5a7 +ovos_workshop>=0.0.5a12 ovos_PHAL>=0.0.1 ovos-lingua-franca~=0.4.3a1 @@ -25,4 +25,4 @@ mycroft-messagebus-client~=0.9.1,!=0.9.2,!=0.9.3 adapt-parser~=0.5 padatious~=0.4.8 fann2==1.0.7 -padaos~=0.1 \ No newline at end of file +padaos~=0.1 diff --git a/test/unittests/gui/__init__.py b/test/unittests/gui/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/unittests/gui/test_namespace.py b/test/unittests/gui/test_namespace.py new file mode 100644 index 000000000000..03dabb7a09a0 --- /dev/null +++ b/test/unittests/gui/test_namespace.py @@ -0,0 +1,134 @@ +# Copyright 2022 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for the GUI namespace helper class.""" + +from unittest import TestCase, mock + +from mycroft.gui.namespace import Namespace +from mycroft.gui.page import GuiPage + +PATCH_MODULE = "mycroft.gui.namespace" + + +class TestNamespace(TestCase): + def setUp(self): + self.namespace = Namespace("foo") + + def test_add(self): + add_namespace_message = dict( + type="mycroft.session.list.insert", + namespace="mycroft.system.active_skills", + position=0, + data=[dict(skill_id="foo")] + ) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function) as send_message_mock: + self.namespace.add() + send_message_mock.assert_called_with(add_namespace_message) + + def test_activate(self): + activate_namespace_message = { + "type": "mycroft.session.list.move", + "namespace": "mycroft.system.active_skills", + "from": 5, + "to": 0, + "items_number": 1 + } + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function) as send_message_mock: + self.namespace.activate(position=5) + send_message_mock.assert_called_with(activate_namespace_message) + + def test_remove(self): + self.namespace.data = dict(foo="bar") + self.namespace.pages = ["foo", "bar"] + remove_namespace_message = dict( + type="mycroft.session.list.remove", + namespace="mycroft.system.active_skills", + position=3, + items_number=1 + ) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function) as send_message_mock: + self.namespace.remove(position=3) + send_message_mock.assert_called_with(remove_namespace_message) + + self.assertFalse(self.namespace.data) + self.assertFalse(self.namespace.pages) + + def test_load_data(self): + load_data_message = dict( + type="mycroft.session.set", + namespace="foo", + data=dict(foo="bar") + ) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function) as send_message_mock: + self.namespace.load_data(name="foo", value="bar") + send_message_mock.assert_called_with(load_data_message) + + def test_set_persistence_numeric(self): + self.namespace.set_persistence("genericSkill") + self.assertEqual(self.namespace.duration, 30) + self.assertFalse(self.namespace.persistent) + + def test_set_persistence_boolean(self): + self.namespace.set_persistence("idleDisplaySkill") + self.assertEqual(self.namespace.duration, 0) + self.assertTrue(self.namespace.persistent) + + def test_load_new_pages(self): + self.namespace.pages = [GuiPage("foo", "foo.qml", True, 0), GuiPage("bar", "bar.qml", False, 30)] + new_pages = [GuiPage("foobar", "foobar.qml", False, 30)] + load_page_message = dict( + type="mycroft.events.triggered", + namespace="foo", + event_name="page_gained_focus", + data=dict(number=2) + ) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function) as send_message_mock: + self.namespace.load_pages(new_pages) + send_message_mock.assert_called_with(load_page_message) + self.assertListEqual(self.namespace.pages, self.namespace.pages) + + def test_load_existing_pages(self): + self.namespace.pages = [GuiPage("foo", "foo.qml", True, 0), GuiPage("bar", "bar.qml", False, 30)] + new_pages = [GuiPage("foo", "foo.qml", True, 0)] + load_page_message = dict( + type="mycroft.events.triggered", + namespace="foo", + event_name="page_gained_focus", + data=dict(number=0) + ) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function) as send_message_mock: + self.namespace.load_pages(new_pages) + send_message_mock.assert_called_with(load_page_message) + self.assertListEqual(self.namespace.pages, self.namespace.pages) + + def test_remove_pages(self): + self.namespace.pages = ["foo", "bar", "foobar"] + remove_page_message = dict( + type="mycroft.gui.list.remove", + namespace="foo", + position=2, + items_number=1 + ) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function) as send_message_mock: + self.namespace.remove_pages([2]) + send_message_mock.assert_called_with(remove_page_message) + self.assertListEqual(["foo", "bar"], self.namespace.pages) diff --git a/test/unittests/gui/test_namespace_manager.py b/test/unittests/gui/test_namespace_manager.py new file mode 100644 index 000000000000..d67b2fbdfe5f --- /dev/null +++ b/test/unittests/gui/test_namespace_manager.py @@ -0,0 +1,113 @@ +# Copyright 2022 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for the GUI namespace manager helper class.""" + +from unittest import TestCase, mock + +from mycroft.gui.namespace import Namespace, NamespaceManager +from mycroft.gui.page import GuiPage +from mycroft.messagebus import Message +from ..mocks import MessageBusMock + +PATCH_MODULE = "mycroft.gui.namespace" + + +class TestNamespace(TestCase): + def setUp(self): + with mock.patch(PATCH_MODULE + ".create_gui_service"): + self.namespace_manager = NamespaceManager(MessageBusMock()) + + def test_handle_clear_active_namespace(self): + namespace = Namespace("foo") + namespace.remove = mock.Mock() + self.namespace_manager.loaded_namespaces = dict(foo=namespace) + self.namespace_manager.active_namespaces = [namespace] + + message = Message("gui.clear.namespace", data={"__from": "foo"}) + self.namespace_manager.handle_clear_namespace(message) + namespace.remove.assert_called_with(0) + + def test_handle_clear_inactive_namespace(self): + message = Message("gui.clear.namespace", data={"__from": "foo"}) + namespace = Namespace("foo") + namespace.remove = mock.Mock() + self.namespace_manager.handle_clear_namespace(message) + namespace.remove.assert_not_called() + + def test_handle_send_event(self): + message_data = { + "__from": "foo", "event_name": "bar", "params": "foobar" + } + message = Message("gui.clear.namespace", data=message_data) + event_triggered_message = dict( + type='mycroft.events.triggered', + namespace="foo", + event_name="bar", + params="foobar" + ) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function) as send_message_mock: + self.namespace_manager.handle_send_event(message) + send_message_mock.assert_called_with(event_triggered_message) + + def test_handle_delete_active_namespace_page(self): + namespace = Namespace("foo") + namespace.pages = [GuiPage("bar", "bar.qml", True, 0)] + namespace.remove_pages = mock.Mock() + self.namespace_manager.loaded_namespaces = dict(foo=namespace) + self.namespace_manager.active_namespaces = [namespace] + + message_data = {"__from": "foo", "page": ["bar"]} + message = Message("gui.clear.namespace", data=message_data) + self.namespace_manager.handle_delete_page(message) + namespace.remove_pages.assert_called_with([0]) + + def test_handle_delete_inactive_namespace_page(self): + namespace = Namespace("foo") + namespace.pages = ["bar"] + namespace.remove_pages = mock.Mock() + + message_data = {"__from": "foo", "page": ["bar"]} + message = Message("gui.clear.namespace", data=message_data) + self.namespace_manager.handle_delete_page(message) + namespace.remove_pages.assert_not_called() + + def test_handle_show_pages(self): + message_data = {"__from": "foo", "__idle": 10, "page": ["bar"]} + message = Message("gui.page.show", data=message_data) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function): + self.namespace_manager._schedule_namespace_removal = mock.Mock() + self.namespace_manager.handle_show_page(message) + + self.assertEqual( + "foo", self.namespace_manager.active_namespaces[0].name + ) + self.assertTrue("foo" in self.namespace_manager.loaded_namespaces) + namespace = self.namespace_manager.loaded_namespaces["foo"] + self.assertListEqual(namespace.pages, namespace.pages) + + def test_handle_show_pages_invalid_message(self): + namespace = Namespace("foo") + namespace.load_pages = mock.Mock() + + message_data = {"__from": "foo"} + message = Message("gui.page.show", data=message_data) + patch_function = PATCH_MODULE + ".send_message_to_gui" + with mock.patch(patch_function): + self.namespace_manager.handle_show_page(message) + + self.assertListEqual([], self.namespace_manager.active_namespaces) + self.assertDictEqual({}, self.namespace_manager.loaded_namespaces)