GUI update number 6 (#1964)

* Add communication from GUI to skills

- "set" events from Qt will set/update a variable in the skills .gui member
- It's possible to add general event handlers using self.gui.register_handler()
- Moved registration of skill_id to just after skill init

* Ensure that simultaneously writes doesn't occur

Wrap WebSocketHandler.write_message() with a lock in an attempt to handle "buffererror: existing exports of data: object cannot be re-sized."

* Add better logging to help debug disconnect issue

* Allow overriding the idle page

SkillGUI.show_page() and SkillGUI.show_pages() now takes an optional
override_idle parameter. This is used as a hint by the mark-2 skill
and if possible the idle screen will not be shown.

* Improve debugging using Logger

* Raise exception when sending a non-existing gui page

* Restore running state to new connections

When a GUI is connected data and running namespaces are synchronised and
shown.

This refactors the code quite a bit moving the GUI state from the GUIConnection
object to the Enclosure.

The GUIConnection object does the handles the sync in the on_connection_open()
method.

* Add gui.page_interaction message

Currently triggered on page change on the display.

* Handle message when gui changes sessionData

* Check if socket exists on gui before sending data

* Increase port on each failure and retry
pull/1970/head
Åke 2019-01-22 15:45:19 +01:00 committed by Steve Penrod
parent 1b0ff61609
commit 9ef95506d0
2 changed files with 326 additions and 240 deletions

View File

@ -13,6 +13,7 @@
# limitations under the License.
#
from collections import namedtuple
from threading import Lock
from mycroft.configuration import Configuration
from mycroft.messagebus.client.ws import WebsocketClient
@ -27,15 +28,13 @@ from mycroft.messagebus.message import Message
Namespace = namedtuple('Namespace', ['name', 'pages'])
write_lock = Lock()
def DEBUG(str):
print(str)
# pass # disable by default
RESERVED_KEYS = ['__from', '__idle']
class Enclosure:
def __init__(self):
# Establish Enclosure's websocket connection to the messagebus
self.bus = WebsocketClient()
@ -48,6 +47,27 @@ class Enclosure:
self.config = config.get("enclosure")
self.global_config = config
# 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.GUIs = {} # GUIs, either local or remote
self.active_namespaces = []
@ -68,35 +88,42 @@ class Enclosure:
######################################################################
# GUI client API
def _gui_activate(self, namespace, move_to_top=False):
if not namespace:
return
if namespace not in self.active_namespaces:
if move_to_top:
self.active_namespaces.insert(0, namespace)
def send(self, *args, **kwargs):
""" Send to all registered GUIs. """
for gui in self.GUIs.values():
if gui.socket:
gui.socket.send(*args, **kwargs)
else:
self.active_namespaces.append(namespace)
elif move_to_top:
self.active_namespaces.remove(namespace)
self.active_namespaces.insert(0, namespace)
# TODO: Keep a timestamp and auto-cull?
LOG.error('GUI connection {} has no socket!'.format(gui))
def on_gui_set_value(self, message):
data = message.data
namespace = data.get("__from", "")
self._gui_activate(namespace)
# Pass these values on to the GUI renderers
for id in self.GUIs:
for key in data:
if key != "__from":
self.GUIs[id].set(namespace, key, data[key])
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_show_page(self, message):
data = message.data
# Note: 'page' can be either a string or a list of strings
if 'page' not in data:
return
@ -104,12 +131,143 @@ class Enclosure:
index = data['index']
else:
index = 0
page = data.get("page", "")
namespace = data.get("__from", "")
self._gui_activate(namespace, move_to_top=True)
# Pass the request to the GUI(s) to pull up a page template
for id in self.GUIs:
self.GUIs[id].show(namespace, data['page'], index)
try:
self.show(namespace, page, index)
except Exception as e:
LOG.exception(repr(e))
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 """
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]
})
# append pages to local representation
for p in pages:
self.loaded[0].pages.append(p)
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)
Arguments:
namespace: The skill namespace to create
pages: Pages to insert
"""
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 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.
Arguments:
from_pos: Position in the stack to move from
to_pos: 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.
Arguments:
pages: pages to switch to
namespace: 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.
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))
######################################################################
# GUI client socket
@ -122,10 +280,9 @@ class Enclosure:
# 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
DEBUG("on_gui_client_connected")
LOG.debug("on_gui_client_connected")
gui_id = message.data.get("gui_id")
# Spin up a new communication socket for this GUI
@ -134,7 +291,7 @@ class Enclosure:
pass
self.GUIs[gui_id] = GUIConnection(gui_id, self.global_config,
self.callback_disconnect, self)
DEBUG("Heard announcement from gui_id: {}".format(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",
@ -142,9 +299,14 @@ class Enclosure:
"gui_id": gui_id}))
def callback_disconnect(self, gui_id):
DEBUG("Disconnecting!")
LOG.info("Disconnecting!")
# TODO: Whatever is needed to kill the websocket instance
del self.GUIs[gui_id]
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
@ -211,233 +373,82 @@ class GUIConnection:
server_thread = None
def __init__(self, id, config, callback_disconnect, enclosure):
DEBUG("Creating GUIConnection")
LOG.debug("Creating GUIConnection")
self.id = id
self.socket = None
self.callback_disconnect = callback_disconnect
self.enclosure = enclosure
self._active_namespaces = None
# 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.current_namespace = None
self.current_pages = []
self.current_index = None
# 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
# 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")
self.port = websocket_config.get("base_port") + GUIConnection._last_idx
GUIConnection._last_idx += 1
base_port = websocket_config.get("base_port")
try:
self.webapp = tornado.web.Application([
(route, GUIWebsocketHandler)
], **gui_app_settings)
self.webapp.gui = self # Hacky way to associate socket with this
self.webapp.listen(self.port, host)
except Exception as e:
DEBUG('Error: {}'.format(repr(e)))
while True:
self.port = base_port + GUIConnection._last_idx
GUIConnection._last_idx += 1
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)
DEBUG("IOLoop started @ ws://{}:{}{}".format(host, self.port, route))
LOG.debug('IOLoop started @ '
'ws://{}:{}{}'.format(host, self.port, route))
def on_connection_opened(self, socket_handler):
DEBUG("on_connection_opened")
LOG.debug("on_connection_opened")
self.socket = socket_handler
self.synchronize()
# Synchronize existing datastore
for namespace in self.datastore:
msg = {"type": "mycroft.session.set",
"namespace": namespace,
"data": self.datastore[namespace]}
self.socket.send_message(msg)
# TODO REPLACE WITH CODE USING self.loaded
# if self.current_pages:
# self.show(self.current_namespace, self.current_pages,
# self.current_index)
def synchronize(self):
""" Upload namespaces, pages and data. """
namespace_pos = 0
for namespace, pages in self.enclosure.loaded:
# Insert namespace
self.socket.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]
})
# Insert data
data = self.enclosure.datastore.get(namespace, {})
for key in data:
self.socket.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)
DEBUG("on_connection_closed")
LOG.debug("on_connection_closed")
if self.socket:
DEBUG("Server stopped: {}".format(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)
def set(self, namespace, name, value):
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.socket.send(msg)
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 """
DEBUG("Inserting new pages")
if not isinstance(pages, list):
raise ValueError('Argument must be list of pages')
self.socket.send({"type": "mycroft.gui.list.insert",
"namespace": namespace,
"position": len(self.loaded[0].pages),
"data": [{"url": p} for p in pages]
})
# append pages to local representation
for p in pages:
self.loaded[0].pages.append(p)
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)
Arguments:
namespace: The skill namespace to create
pages: Pages to insert
"""
DEBUG("Inserting new namespace")
self.socket.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 data:
msg = {"type": "mycroft.session.set",
"namespace": namespace,
"data": {key: data[key]}}
self.socket.send(msg)
DEBUG("Inserting new page")
self.socket.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.
Arguments:
from_pos: Position in the stack to move from
to_pos: Position to move to
"""
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:
DEBUG("move {} to {}".format(from_pos, to_pos))
self.socket.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.
Arguments:
pages: pages to switch to
namespace: skill namespace
"""
try:
num = self.loaded[0].pages.index(pages[0])
except Exception as e:
DEBUG(e)
num = 0
DEBUG("Switching to already loaded page at "
"index {} in namespace {}".format(num, namespace))
self.socket.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.
TODO: - Update sync to match.
- Separate into multiple functions/methods
"""
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:
DEBUG(repr(e))
# TODO: Not quite sure this is needed or if self.loaded can be used
self.current_namespace = namespace
self.current_pages = pages
self.current_index = index
class GUIWebsocketHandler(WebSocketHandler):
"""
@ -448,10 +459,40 @@ class GUIWebsocketHandler(WebSocketHandler):
self.application.gui.on_connection_opened(self)
def on_message(self, message):
DEBUG("Received: {}".format(message))
LOG.debug("Received: {}".format(message))
msg = json.loads(message)
if (msg.get('type') == "mycroft.events.triggered" and
msg.get('event_name') == 'page_gained_focus'):
# System event, a page was changed
msg_type = 'gui.page_interaction'
msg_data = {
'namespace': msg['namespace'],
'page_number': msg['parameters']['number']
}
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)
self.application.gui.enclosure.bus.emit(message)
def write_message(self, *arg, **kwarg):
""" Wraps WebSocketHandler.write_message() with a lock. """
with write_lock:
super().write_message(*arg, **kwarg)
def send_message(self, message):
self.write_message(message.serialize())
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

View File

@ -128,11 +128,11 @@ def load_skill(skill_descriptor, bus, skill_id, BLACKLISTED_SKILLS=None):
callable(skill_module.create_skill)):
# v2 skills framework
skill = skill_module.create_skill()
skill.skill_id = skill_id
skill.settings.allow_overwrite = True
skill.settings.load_skill_settings_from_file()
skill.bind(bus)
try:
skill.skill_id = skill_id
skill.load_data_files(path)
# Set up intent handlers
skill._register_decorated()
@ -228,6 +228,46 @@ class SkillGUI:
self.__session_data = {} # synced to GUI for use by this skill's pages
self.page = None # the active GUI page (e.g. QML template) to show
self.skill = skill
self.on_gui_changed_callback = None
def build_message_type(self, event):
""" Builds a message matching the output from the enclosure. """
return '{}.{}'.format(self.skill.skill_id, event)
def setup_default_handlers(self):
""" Sets the handlers for the default messages. """
msg_type = self.build_message_type('set')
print("LISTENING FOR {}".format(msg_type))
self.skill.add_event(msg_type, self.gui_set)
def register_handler(self, event, handler):
""" Register a handler for gui events.
when using the triggerEvent method from Qt
triggerEvent("event", {"data": "cool"})
Arguments:
event (str): event to catch
handler: function to handle the event
"""
msg_type = self.build_message_type(event)
self.skill.add_event(msg_type, handler)
def set_on_gui_changed(self, callback):
""" Registers a callback function to run when a value is
changed from the GUI.
Arguments:
callback: Function to call when a value is changed
"""
self.on_gui_changed_callback = callback
def gui_set(self, message):
for key in message.data:
print("SETTING {} TO {}".format(key, message.data[key]))
self[key] = message.data[key]
if self.on_gui_changed_callback:
self.on_gui_changed_callback()
def __setitem__(self, key, value):
self.__session_data[key] = value
@ -249,16 +289,17 @@ class SkillGUI:
self.__session_data = {}
self.page = None
def show_page(self, name):
def show_page(self, name, override_idle=None):
"""
Begin showing the page in the GUI
Args:
name (str): Name of page (e.g "mypage.qml") to display
override_idle: If set will override the idle screen
"""
self.show_pages([name])
self.show_pages([name], 0, override_idle)
def show_pages(self, page_names, index=0):
def show_pages(self, page_names, index=0, override_idle=None):
"""
Begin showing the list of pages in the GUI
@ -267,6 +308,7 @@ class SkillGUI:
["Weather.qml", "Forecast.qml", "Details.qml"]
index (int): Page number (0-based) to show initially. For the
above list a value of 1 would start on "Forecast.qml"
override_idle: If set will override the idle screen
"""
if not isinstance(page_names, list):
raise ValueError('page_names must be a list')
@ -288,13 +330,13 @@ class SkillGUI:
if page:
page_urls.append("file://" + page)
else:
self.skill.log.debug("Unable to find page: " + str(name))
return
raise FileNotFoundError("Unable to find page: {}".format(name))
self.skill.bus.emit(Message("gui.page.show",
{"page": page_urls,
"index": index,
"__from": self.skill.skill_id}))
"__from": self.skill.skill_id,
"__idle": override_idle}))
def show_text(self, text, title=None):
""" Display a GUI page for viewing simple text
@ -458,6 +500,9 @@ class MycroftSkill:
bus.on(name, func)
self.events.append((name, func))
# Intialize the SkillGui
self.gui.setup_default_handlers()
def detach(self):
for (name, intent) in self.registered_intents:
name = str(self.skill_id) + ':' + name