From 20579c80db5dd50d1b0e53b50a11b9eb2f90deaf Mon Sep 17 00:00:00 2001 From: Aditya Mehra Date: Thu, 12 Jan 2023 21:55:33 +1030 Subject: [PATCH 1/8] Switch wsconnector to internal socket client --- ovos_PHAL_plugin_homeassistant/__init__.py | 50 ++-- .../logic/connector.py | 77 +++-- .../logic/socketclient.py | 274 ++++++++++++++++++ requirements.txt | 2 +- 4 files changed, 336 insertions(+), 67 deletions(-) create mode 100644 ovos_PHAL_plugin_homeassistant/logic/socketclient.py diff --git a/ovos_PHAL_plugin_homeassistant/__init__.py b/ovos_PHAL_plugin_homeassistant/__init__.py index a6d2daf..7085c14 100644 --- a/ovos_PHAL_plugin_homeassistant/__init__.py +++ b/ovos_PHAL_plugin_homeassistant/__init__.py @@ -32,6 +32,8 @@ def __init__(self, bus=None, config=None): self.gui = GUIInterface(bus=self.bus, skill_id=self.name) self.integrator = Integrator(self.bus, self.gui) self.instance_available = False + self.use_ws = False + self.enable_debug = self.config.get("enable_debug", False) self.device_types = { "sensor": HomeAssistantSensor, "binary_sensor": HomeAssistantBinarySensor, @@ -95,14 +97,19 @@ def validate_instance_connection(self, host, api_key): bool: True if the connection is valid, False otherwise """ try: - if self.config.get('use_websocket'): - LOG.info("Using websocket connection") - validator = HomeAssistantWSConnector(host, api_key) + if self.use_ws: + validator = HomeAssistantWSConnector(host, api_key, self.enable_debug) else: - validator = HomeAssistantRESTConnector(host, api_key) + validator = HomeAssistantRESTConnector(host, api_key, self.enable_debug) validator.get_all_devices() + + if self.use_ws: + if validator.client: + validator.disconnect() + return True + except Exception as e: LOG.error(e) return False @@ -118,15 +125,7 @@ def setup_configuration(self, message): if host and key: if host.startswith("ws") or host.startswith("wss"): - config_patch = { - "PHAL": { - "ovos-PHAL-plugin-homeassistant": { - "use_websocket": True - } - } - } - update_mycroft_config(config=config_patch, bus=self.bus) - sleep(2) # wait for config to be updated + self.use_ws = True if self.validate_instance_connection(host, key): self.config["host"] = host @@ -149,21 +148,24 @@ def init_configuration(self, message=None): """ Initialize instance configuration """ configuration_host = self.config.get("host", "") configuration_api_key = self.config.get("api_key", "") + if configuration_host.startswith("ws") or configuration_host.startswith("wss"): + self.use_ws = True + if not self.config.get("use_group_display"): self.config["use_group_display"] = False if configuration_host != "" and configuration_api_key != "": self.instance_available = True - if self.config.get('use_websocket'): + if self.use_ws: self.connector = HomeAssistantWSConnector(configuration_host, - configuration_api_key) + configuration_api_key, self.enable_debug) else: self.connector = HomeAssistantRESTConnector( - configuration_host, configuration_api_key) + configuration_host, configuration_api_key, self.enable_debug) self.devices = self.connector.get_all_devices() self.registered_devices = [] self.build_devices() - self.gui["use_websocket"] = self.config.get("use_websocket", False) + self.gui["use_websocket"] = self.use_ws self.gui["instanceAvailable"] = True self.bus.emit(Message("ovos.phal.plugin.homeassistant.ready")) else: @@ -185,8 +187,10 @@ def build_devices(self): device_icon = f"mdi:{device_type}" device_state = device.get("state", None) device_area = device.get("area_id", None) - LOG.info( - f"Device added: {device_name} - {device_type} - {device_area}") + if self.enable_debug: + LOG.info( + f"Device added: {device_name} - {device_type} - {device_area}") + device_attributes = device.get("attributes", {}) if device_type in self.device_types: self.registered_devices.append(self.device_types[device_type]( @@ -396,7 +400,7 @@ def handle_show_dashboard(self, message=None): message (Message): The message object """ if self.instance_available: - self.gui["use_websocket"] = self.config.get("use_websocket", False) + self.gui["use_websocket"] = self.use_ws if not self.config.get("use_group_display"): display_list_model = { "items": self.build_display_dashboard_device_model()} @@ -420,8 +424,9 @@ def handle_show_dashboard(self, message=None): self.gui["use_group_display"] = self.config.get("use_group_display", False) self.gui.show_page(page, override_idle=True) - LOG.info("Using group display") - LOG.info(self.config["use_group_display"]) + if self.enable_debug: + LOG.debug("Using group display") + LOG.debug(self.config["use_group_display"]) def handle_close_dashboard(self, message): """ Handle the close dashboard message @@ -499,7 +504,6 @@ def handle_set_group_display_settings(self, message): "ovos-PHAL-plugin-homeassistant": { "host": self.config.get("host"), "api_key": self.config.get("api_key"), - "use_websocket": self.config.get("use_websocket", False), "use_group_display": use_group_display } } diff --git a/ovos_PHAL_plugin_homeassistant/logic/connector.py b/ovos_PHAL_plugin_homeassistant/logic/connector.py index 6368444..2cf970c 100644 --- a/ovos_PHAL_plugin_homeassistant/logic/connector.py +++ b/ovos_PHAL_plugin_homeassistant/logic/connector.py @@ -4,20 +4,19 @@ import requests import json import sys -import asyncio -from hass_client.client import HomeAssistantClient from ovos_utils.log import LOG - +from ovos_PHAL_plugin_homeassistant.logic.socketclient import HomeAssistantClient class HomeAssistantConnector: - def __init__(self, host, api_key): + def __init__(self, host, api_key, enable_debug=False): """ Constructor Args: host (str): The host of the home assistant instance. api_key (str): The api key """ + self.enable_debug = enable_debug self.host = host self.api_key = api_key @@ -115,8 +114,9 @@ def call_function(self, device_id, device_type, function, arguments=None): class HomeAssistantRESTConnector(HomeAssistantConnector): - def __init__(self, host, api_key): + def __init__(self, host, api_key, enable_debug=False): super().__init__(host, api_key) + self.enable_debug = enable_debug self.headers = {'Authorization': 'Bearer ' + self.api_key, 'content-type': 'application/json'} @@ -251,37 +251,33 @@ def call_function(self, device_id, device_type, function, arguments=None): class HomeAssistantWSConnector(HomeAssistantConnector): - def __init__(self, host, api_key): + def __init__(self, host, api_key, enable_debug=False): super().__init__(host, api_key) if self.host.startswith('http'): self.host.replace('http', 'ws', 1) - try: - self._loop = asyncio.get_event_loop() - except RuntimeError: - asyncio.set_event_loop(asyncio.new_event_loop()) - self._loop = asyncio.get_event_loop() - self._client: HomeAssistantClient = None - self._loop.run_until_complete(self.start_client()) - - @property - def client(self): - if not self._client.connected: - LOG.error("Client not connected, re-initializing") - self._loop.run_until_complete(self.start_client()) - return self._client - - async def start_client(self): - self._client = self._client or HomeAssistantClient(self.host, - self.api_key) - await self._client.connect() + self.enable_debug = enable_debug + self._connection = HomeAssistantClient(self.host, self.api_key) + self._connection.connect() + + # Initialize client instance + self.client = self._connection.get_instance_sync() + self.client.build_registries_sync() + self.client.register_event_listener(self.event_listener) + self.client.subscribe_events_sync() + + def event_listener(self, message): + # Todo: Implementation with UI + # For now it uses the old states update method + pass @staticmethod - def _device_entry_compat(devices: dict): + def _device_entry_compat(devices: dict, enable_debug): disabled_devices = list() for idx, dev in devices.items(): if dev.get('disabled_by'): - LOG.debug(f'Ignoring {dev.get("entity_id")} disabled by ' - f'{dev.get("disabled_by")}') + if(enable_debug): + LOG.debug(f'Ignoring {dev.get("entity_id")} disabled by ' + f'{dev.get("disabled_by")}') disabled_devices.append(idx) else: devices[idx].setdefault( @@ -291,14 +287,12 @@ def _device_entry_compat(devices: dict): def get_all_devices(self) -> list: devices = self.client.entity_registry - self._device_entry_compat(devices) + self._device_entry_compat(devices, self.enable_debug) devices_with_area = self.assign_group_for_devices(devices) return list(devices_with_area.values()) def get_device_state(self, entity_id: str) -> dict: - message = {'type': 'get_states'} - states = self._loop.run_until_complete( - self.client.send_command(message)) + states = self.client.get_states_sync() for state in states: if state['entity_id'] == entity_id: return state @@ -324,30 +318,27 @@ def get_all_devices_with_type_and_attribute_not_in(self, device_type, attribute, def turn_on(self, device_id, device_type): LOG.debug(f"Turn on {device_id}") - self._loop.run_until_complete( - self.client.call_service(device_type, 'turn_on', - {'entity_id': device_id})) + self.client.call_service_sync(device_type, 'turn_on', {'entity_id': device_id}) def turn_off(self, device_id, device_type): LOG.debug(f"Turn off {device_id}") - self._loop.run_until_complete( - self.client.call_service(device_type, 'turn_off', - {'entity_id': device_id})) + self.client.call_service_sync(device_type, 'turn_off', {'entity_id': device_id}) def call_function(self, device_id, device_type, function, arguments=None): arguments = arguments or dict() arguments['entity_id'] = device_id - self._loop.run_until_complete( - self.client.call_service(device_type, function, arguments)) + self.client.call_service_sync(device_type, function, arguments) def assign_group_for_devices(self, devices): - devices_from_registry = self._loop.run_until_complete( - self.client.send_command({'type': 'config/device_registry/list'})) + devices_from_registry = self.client.send_command_sync('config/device_registry/list') - for device_item in devices_from_registry: + for device_item in devices_from_registry["result"]: for device in devices.values(): if device['device_id'] == device_item['id']: device['area_id'] = device_item['area_id'] break return devices + + def disconnect(self): + self._connection.disconnect() \ No newline at end of file diff --git a/ovos_PHAL_plugin_homeassistant/logic/socketclient.py b/ovos_PHAL_plugin_homeassistant/logic/socketclient.py new file mode 100644 index 0000000..abf9d42 --- /dev/null +++ b/ovos_PHAL_plugin_homeassistant/logic/socketclient.py @@ -0,0 +1,274 @@ +import asyncio +import json +import threading + +import requests +import websockets +from ovos_utils.log import LOG + + +class HomeAssistantClient: + def __init__(self, url, token): + self.url = url + self.token = token + self.websocket = None + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.response_queue = asyncio.Queue() + self.event_queue = asyncio.Queue() + self.id_list = [] + self.thread = threading.Thread(target=self.run) + self.last_id = None + self.event_listener = None + self.authenticated = False + + self._device_registry = {} + self._entity_registry = {} + self._area_registry = {} + + async def authenticate(self): + await self.websocket.send(f'{{"type": "auth", "access_token": "{self.token}"}}') + message = await self.websocket.recv() + message = json.loads(message) + if message.get("type") == "auth_ok": + self.authenticated = True + await self.listen() + else: + self.authenticated = False + LOG.error("WS HA Connection Failed to authenticate") + return + + async def _connect(self): + try: + uri = f"{self.url}/api/websocket" + self.websocket = await websockets.connect(uri) + + # Wait for the auth_required message + message = await self.websocket.recv() + message = json.loads(message) + if message.get("type") == "auth_required": + await self.authenticate() + if not self.authenticated: + return + else: + raise Exception("Expected auth_required message") + except Exception as e: + LOG.error(e) + await self._disconnect() + return + + async def _disconnect(self): + if self.websocket is not None: + await self.websocket.close() + self.websocket = None + + async def listen(self): + while self.websocket is not None: + message = await self.websocket.recv() + message = json.loads(message) + if message.get("type") == "event": + if self.event_listener is not None: + self.event_listener(message) + else: + await self.event_queue.put(message) + else: + await self.response_queue.put(message) + + async def send_command(self, command): + id = self.counter + self.last_id = id + message = { + "id": id, + "type": command + } + await self.websocket.send(json.dumps(message)) + + async def send_raw_command(self, command): + id = self.counter + self.last_id = id + message = { + "id": id, + "type": command + } + await self.websocket.send(json.dumps(message)) + response = await self.response_queue.get() + self.response_queue.task_done() + return response + + async def call_service(self, domain, service, service_data): + id = self.counter + self.last_id = id + message = { + "id": id, + "type": "call_service", + "domain": domain, + "service": service, + "service_data": service_data + } + await self.websocket.send(json.dumps(message)) + response = await self.response_queue.get() + self.response_queue.task_done() + return response + + async def get_states(self): + await self.send_command("get_states") + message = await self.response_queue.get() + self.response_queue.task_done() + if message.get("result") is None: + LOG.info("No states found") + return [] + else: + return message["result"] + + async def subscribe_events(self): + await self.send_command("subscribe_events") + message = await self.response_queue.get() + self.response_queue.task_done() + return message + + async def get_instance(self): + while self.websocket is None: + await asyncio.sleep(0.1) + return self + + async def build_registries(self): + # First clean the registries + self._device_registry = {} + self._entity_registry = {} + self._area_registry = {} + + # device registry + await self.send_command("config/device_registry/list") + message = await self.response_queue.get() + self.response_queue.task_done() + for item in message["result"]: + item_id = item["id"] + self._device_registry[item_id] = item + + # entity registry + await self.send_command("config/entity_registry/list") + message = await self.response_queue.get() + self.response_queue.task_done() + for item in message["result"]: + item_id = item["entity_id"] + self._entity_registry[item_id] = item + + # area registry + await self.send_command("config/area_registry/list") + message = await self.response_queue.get() + self.response_queue.task_done() + for item in message["result"]: + item_id = item["area_id"] + self._area_registry[item_id] = item + + return True + + @property + def device_registry(self) -> dict: + """Return device registry.""" + if not self._device_registry: + asyncio.run_coroutine_threadsafe( + self.build_registries(), self.loop) + LOG.debug("Registry is empty, building registry first.") + return self._device_registry + + @property + def entity_registry(self) -> dict: + """Return device registry.""" + if not self._entity_registry: + asyncio.run_coroutine_threadsafe( + self.build_registries(), self.loop) + LOG.debug("Registry is empty, building registry first.") + return self._entity_registry + + @property + def area_registry(self) -> dict: + """Return device registry.""" + if not self._area_registry: + asyncio.run_coroutine_threadsafe( + self.build_registries(), self.loop) + LOG.debug("Registry is empty, building registry first.") + return self._area_registry + + @property + def counter(self): + if len(self.id_list) == 0: + self.id_list.append(1) + return 1 + else: + new_id = max(self.id_list) + 1 + self.id_list.append(new_id) + return new_id + + def set_state(self, entity_id, state, attributes): + id = self.counter + self.last_id = id + data = { + "id": id, + "type": "get_state", + "entity_id": entity_id, + "state": state, + "attributes": attributes + } + response = self._post_request(f"states/{entity_id}", data) + return response + + def _post_request(self, endpoint, data): + url = self.url.replace("wss", "https").replace("ws", "http") + full_url = f"{url}{endpoint}" + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + response = requests.post(full_url, headers=headers, json=data) + return response.json() + + def run(self): + self.loop.run_until_complete(self._connect()) + LOG.info(self.loop.is_running()) + + def connect(self): + self.thread.start() + + def disconnect(self): + asyncio.run_coroutine_threadsafe(self._disconnect(), self.loop) + self.thread.join() + + def get_states_sync(self): + task = asyncio.run_coroutine_threadsafe(self.get_states(), self.loop) + return task.result() + + def subscribe_events_sync(self): + task = asyncio.run_coroutine_threadsafe( + self.subscribe_events(), self.loop) + return task.result() + + def get_instance_sync(self): + task = asyncio.run_coroutine_threadsafe(self.get_instance(), self.loop) + return task.result() + + def get_event_sync(self): + task = asyncio.run_coroutine_threadsafe( + self.event_queue.get(), self.loop) + return task.result() + + def build_registries_sync(self): + task = asyncio.run_coroutine_threadsafe( + self.build_registries(), self.loop) + return task.result() + + def register_event_listener(self, listener): + self.event_listener = listener + + def unregister_event_listener(self): + self.event_listener = None + + def send_command_sync(self, command): + task = asyncio.run_coroutine_threadsafe( + self.send_raw_command(command), self.loop) + return task.result() + + def call_service_sync(self, domain, service, service_data): + task = asyncio.run_coroutine_threadsafe( + self.call_service(domain, service, service_data), self.loop) + return task.result() diff --git a/requirements.txt b/requirements.txt index 690bf0b..669fcbf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ ovos-config~=0.0.5 mycroft-messagebus-client youtube-search pytube -hass-client~=0.1 \ No newline at end of file +websockets From 9ea78a29be9cd072d4df22721aba5ac5c88ec42d Mon Sep 17 00:00:00 2001 From: Aditya Mehra Date: Thu, 19 Jan 2023 00:54:49 +1030 Subject: [PATCH 2/8] Add support for QR based oauth setup --- ovos_PHAL_plugin_homeassistant/__init__.py | 90 ++++++- .../logic/connector.py | 5 +- .../logic/socketclient.py | 14 +- .../ui/Dashboard.qml | 239 +----------------- .../ui/InstanceGridButton.qml | 83 ++++++ .../ui/InstanceSetup.qml | 216 ++++++++++++++++ .../ui/InstanceSetupFooterButtons.qml | 188 ++++++++++++++ .../ui/icons/qr-mobile.svg | 128 ++++++++++ .../ui/icons/token-device.svg | 114 +++++++++ 9 files changed, 840 insertions(+), 237 deletions(-) create mode 100644 ovos_PHAL_plugin_homeassistant/ui/InstanceGridButton.qml create mode 100644 ovos_PHAL_plugin_homeassistant/ui/InstanceSetup.qml create mode 100644 ovos_PHAL_plugin_homeassistant/ui/InstanceSetupFooterButtons.qml create mode 100644 ovos_PHAL_plugin_homeassistant/ui/icons/qr-mobile.svg create mode 100644 ovos_PHAL_plugin_homeassistant/ui/icons/token-device.svg diff --git a/ovos_PHAL_plugin_homeassistant/__init__.py b/ovos_PHAL_plugin_homeassistant/__init__.py index 7085c14..f8dd726 100644 --- a/ovos_PHAL_plugin_homeassistant/__init__.py +++ b/ovos_PHAL_plugin_homeassistant/__init__.py @@ -1,3 +1,5 @@ +import json +import uuid from os.path import dirname, join from ovos_utils.log import LOG from mycroft_bus_client.message import Message @@ -14,8 +16,6 @@ from ovos_PHAL_plugin_homeassistant.logic.utils import (map_entity_to_device_type, check_if_device_type_is_group) from ovos_config.config import update_mycroft_config -from time import sleep - class HomeAssistantPlugin(PHALPlugin): def __init__(self, bus=None, config=None): @@ -26,6 +26,9 @@ def __init__(self, bus=None, config=None): config (dict): The plugin configuration """ super().__init__(bus=bus, name="ovos-PHAL-plugin-homeassistant", config=config) + self.oauth_client_id = None + self.munged_id = "ovos-PHAL-plugin-homeassistant_homeassistant-phal-plugin" + self.temporary_instance = None self.connector = None self.registered_devices = [] self.bus = bus @@ -60,6 +63,7 @@ def __init__(self, bus=None, config=None): self.handle_get_device_display_list_model) self.bus.on("ovos.phal.plugin.homeassistant.call.supported.function", self.handle_call_supported_function) + self.bus.on("ovos.phal.plugin.homeassistant.start.oauth.flow", self.handle_start_oauth_flow) # GUI EVENTS self.bus.on("ovos-PHAL-plugin-homeassistant.home", @@ -83,6 +87,11 @@ def __init__(self, bus=None, config=None): self.bus.on("configuration.updated", self.init_configuration) self.bus.on("configuration.patch", self.init_configuration) + # LISTEN FOR OAUTH RESPONSE + self.bus.on("oauth.app.host.info.response", self.handle_oauth_host_info) + self.bus.on("oauth.generate.qr.response", self.handle_qr_oauth_response) + self.bus.on(f"oauth.token.response.{self.munged_id}", self.handle_token_oauth_response) + self.init_configuration() # SETUP INSTANCE SUPPORT @@ -510,4 +519,79 @@ def handle_set_group_display_settings(self, message): } update_mycroft_config(config=config_patch, bus=self.bus) self.gui["use_group_display"] = self.config.get("use_group_display") - self.handle_show_dashboard() \ No newline at end of file + self.handle_show_dashboard() + +# OAuth QR Code Flow Handlers + def request_host_info_from_oauth(self): + self.bus.emit(Message("oauth.get.app.host.info")) + + def handle_oauth_host_info(self, message): + host = message.data.get("host", None) + port = message.data.get("port", None) + self.oauth_client_id = f"http://{host}:{port}" + + if self.temporary_instance: + self.oauth_register() + self.start_oauth_flow() + + def handle_start_oauth_flow(self, message): + """ Handle the start oauth flow message + + Args: + message (Message): The message object + """ + instance = message.data.get("instance", None) + if instance: + self.temporary_instance = instance + self.request_host_info_from_oauth() + + def oauth_register(self): + """ Register the phal plugin with the oauth service """ + host = self.temporary_instance.replace("ws://", "http://").replace("wss://", "https://") + auth_endpoint = f"{host}/auth/authorize" + token_endpoint = f"{host}/auth/token" + self.bus.emit(Message("oauth.register", { + "client_id": self.oauth_client_id, + "skill_id": "ovos-PHAL-plugin-homeassistant", + "app_id": "homeassistant-phal-plugin", + "auth_endpoint": auth_endpoint, + "token_endpoint": token_endpoint, + "refresh_endpoint": "", + })) + + def start_oauth_flow(self): + host = self.temporary_instance.replace("ws://", "http://").replace("wss://", "https://") + app_id = "homeassistant-phal-plugin" + skill_id = "ovos-PHAL-plugin-homeassistant" + self.bus.emit(Message("oauth.generate.qr.request", { + "app_id": app_id, + "skill_id": skill_id + })) + + def handle_qr_oauth_response(self, message): + qr_code_url = message.data.get("qr", None) + self.gui.send_event("ovos.phal.plugin.homeassistant.oauth.qr.update", { + "qr": qr_code_url + }) + + def handle_token_oauth_response(self, message): + response = message.data + access_token = response.get("access_token", None) + if access_token: + self.get_long_term_token(access_token) + + def get_long_term_token(self, short_term_token): + instance = self.temporary_instance.replace("http://", "ws://").replace("https://", "wss://") + token = short_term_token + wsClient = HomeAssistantWSConnector(instance, token, self.enable_debug) + client_name = "ovos-PHAL-plugin-homeassistant-" + str(uuid.uuid4().hex)[:4] + token_response = wsClient.call_command("auth/long_lived_access_token", {"client_name": client_name, "lifespan": 1825}) + + if wsClient.client: + wsClient.disconnect() + + if token_response: + if token_response["success"]: + long_term_token = token_response["result"] + self.gui.send_event("ovos.phal.plugin.homeassistant.oauth.success", {}) + self.setup_configuration(Message("ovos.phal.plugin.homeassistant.setup", {"url": instance, "api_key": long_term_token})) \ No newline at end of file diff --git a/ovos_PHAL_plugin_homeassistant/logic/connector.py b/ovos_PHAL_plugin_homeassistant/logic/connector.py index 2cf970c..3202207 100644 --- a/ovos_PHAL_plugin_homeassistant/logic/connector.py +++ b/ovos_PHAL_plugin_homeassistant/logic/connector.py @@ -112,7 +112,6 @@ def call_function(self, device_id, device_type, function, arguments=None): arguments (dict): The arguments to pass to the function. """ - class HomeAssistantRESTConnector(HomeAssistantConnector): def __init__(self, host, api_key, enable_debug=False): super().__init__(host, api_key) @@ -329,6 +328,10 @@ def call_function(self, device_id, device_type, function, arguments=None): arguments['entity_id'] = device_id self.client.call_service_sync(device_type, function, arguments) + def call_command(self, command, arguments=None): + response = self.client.send_command_sync(command, arguments) + return response + def assign_group_for_devices(self, devices): devices_from_registry = self.client.send_command_sync('config/device_registry/list') diff --git a/ovos_PHAL_plugin_homeassistant/logic/socketclient.py b/ovos_PHAL_plugin_homeassistant/logic/socketclient.py index abf9d42..508c6f6 100644 --- a/ovos_PHAL_plugin_homeassistant/logic/socketclient.py +++ b/ovos_PHAL_plugin_homeassistant/logic/socketclient.py @@ -6,7 +6,6 @@ import websockets from ovos_utils.log import LOG - class HomeAssistantClient: def __init__(self, url, token): self.url = url @@ -83,13 +82,18 @@ async def send_command(self, command): } await self.websocket.send(json.dumps(message)) - async def send_raw_command(self, command): + async def send_raw_command(self, command, args): id = self.counter self.last_id = id message = { "id": id, "type": command } + + if not args is None: + for key, value in args.items(): + message[key] = value + await self.websocket.send(json.dumps(message)) response = await self.response_queue.get() self.response_queue.task_done() @@ -263,12 +267,12 @@ def register_event_listener(self, listener): def unregister_event_listener(self): self.event_listener = None - def send_command_sync(self, command): + def send_command_sync(self, command, args=None): task = asyncio.run_coroutine_threadsafe( - self.send_raw_command(command), self.loop) + self.send_raw_command(command, args), self.loop) return task.result() def call_service_sync(self, domain, service, service_data): task = asyncio.run_coroutine_threadsafe( self.call_service(domain, service, service_data), self.loop) - return task.result() + return task.result() \ No newline at end of file diff --git a/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml b/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml index 23c0ebb..b24539a 100644 --- a/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml +++ b/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml @@ -21,6 +21,7 @@ Mycroft.Delegate { property bool useWebsocket: sessionData.use_websocket property var tabBarModel property bool horizontalMode: width >= height ? true : false + property var qrImagePath function get_page_name() { if (dashboardSwipeView.currentIndex == 0) { @@ -80,7 +81,13 @@ Mycroft.Delegate { case "ovos.phal.plugin.homeassistant.integration.query_media.result": deviceControlsLoader.mediaModel = data.results console.log(JSON.stringify(data.results)) - break + break + case "ovos.phal.plugin.homeassistant.oauth.qr.update": + dashboardRoot.qrImagePath = Qt.resolvedUrl(data.qr) + break + case "ovos.phal.plugin.homeassistant.oauth.success": + instaceSetupPopupBox.close() + break } } @@ -522,236 +529,12 @@ Mycroft.Delegate { } } - Popup { + InstanceSetup { id: instaceSetupPopupBox x: (parent.width - width) / 2 y: (parent.height - height) / 2 - width: parent.width * 0.8 - height: parent.height * 0.8 - - background: Rectangle { - color: Qt.darker(Kirigami.Theme.backgroundColor, 1) - radius: Mycroft.Units.gridUnit * 0.5 - } - - contentItem: Item { - Item { - anchors.fill: parent - anchors.margins: Mycroft.Units.gridUnit - - Kirigami.Heading { - id: instanceSetupPopupTitle - level: 2 - text: qsTr("Setup Home Assistant Instance") - font.bold: true - color: Kirigami.Theme.textColor - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: Mycroft.Units.gridUnit * 2 - } - - Kirigami.Separator { - anchors.top: instanceSetupPopupTitle.bottom - anchors.left: parent.left - anchors.right: parent.right - color: Kirigami.Theme.highlightColor - } - - Label { - id: instanceSetupPopupUrlLabel - text: qsTr("Home Assistant Instance URL") - fontSizeMode: Text.Fit - minimumPixelSize: 10 - elide: Text.ElideRight - font.pixelSize: Mycroft.Units.gridUnit * 1.5 - color: Kirigami.Theme.textColor - anchors.top: instanceSetupPopupTitle.bottom - anchors.topMargin: Kirigami.Units.smallSpacing - anchors.left: parent.left - anchors.right: parent.right - height: Mycroft.Units.gridUnit * 2 - } - - Label { - id: subTextInstanceSetupPopupUrlLabel - fontSizeMode: Text.Fit - minimumPixelSize: 8 - elide: Text.ElideRight - font.pixelSize: 12 - color: Kirigami.Theme.textColor - anchors.top: instanceSetupPopupUrlLabel.bottom - anchors.topMargin: Kirigami.Units.smallSpacing - anchors.left: parent.left - anchors.right: parent.right - height: Mycroft.Units.gridUnit * 2 - text: qsTr("HTTP: http://homeassistant.local:8123") + "\n" + qsTr("Websocket: ws://homeassistant.local:8123") - } - - TextField { - id: instanceSetupPopupUrl - placeholderText: qsTr("http://homeassistant.local:8123 or ws://homeassistant.local:8123") - font.pixelSize: Mycroft.Units.gridUnit * 1.5 - color: Kirigami.Theme.textColor - anchors.left: parent.left - anchors.right: parent.right - anchors.top: subTextInstanceSetupPopupUrlLabel.bottom - anchors.topMargin: Mycroft.Units.gridUnit * 0.5 - height: Mycroft.Units.gridUnit * 3 - } - - Label { - id: instanceSetupPopupApiKeyLabel - text: qsTr("Home Assistant Instance API Key") - fontSizeMode: Text.Fit - minimumPixelSize: 10 - elide: Text.ElideRight - font.pixelSize: Mycroft.Units.gridUnit * 1.5 - color: Kirigami.Theme.textColor - anchors.top: instanceSetupPopupUrl.bottom - anchors.topMargin: Kirigami.Units.smallSpacing - anchors.left: parent.left - anchors.right: parent.right - height: Mycroft.Units.gridUnit * 2 - } - - TextField { - id: instanceSetupPopupApiKey - placeholderText: qsTr("API Key") - font.pixelSize: Mycroft.Units.gridUnit * 1.5 - color: Kirigami.Theme.textColor - anchors.left: parent.left - anchors.right: parent.right - anchors.top: instanceSetupPopupApiKeyLabel.bottom - anchors.topMargin: Mycroft.Units.gridUnit * 0.5 - height: Mycroft.Units.gridUnit * 3 - } - - RowLayout { - id: instanceSetupPopupButtons - anchors.top: instanceSetupPopupApiKey.bottom - anchors.topMargin: Kirigami.Units.smallSpacing - anchors.left: parent.left - anchors.right: parent.right - height: Mycroft.Units.gridUnit * 3 - - Button { - id: instanceSetupPopupConfirmButton - Layout.fillWidth: true - Layout.fillHeight: true - - background: Rectangle { - id: instanceSetupPopupConfirmButtonBackground - color: Kirigami.Theme.highlightColor - radius: Mycroft.Units.gridUnit * 0.5 - } - - contentItem: Item { - RowLayout { - id: instanceSetupPopupConfirmButtonLayout - anchors.centerIn: parent - - Kirigami.Icon { - id: instanceSetupPopupConfirmButtonIcon - Layout.fillHeight: true - Layout.preferredWidth: height - Layout.alignment: Qt.AlignVCenter - source: "answer-correct" - - ColorOverlay { - anchors.fill: parent - source: parent - color: Kirigami.Theme.textColor - } - } - - Kirigami.Heading { - id: instanceSetupPopupConfirmButtonText - level: 2 - Layout.fillHeight: true - wrapMode: Text.WordWrap - font.bold: true - elide: Text.ElideRight - color: Kirigami.Theme.textColor - text: qsTr("Confirm") - verticalAlignment: Text.AlignVCenter - horizontalAlignment: Text.AlignLeft - } - } - } - - onClicked: { - Mycroft.MycroftController.sendRequest("ovos.phal.plugin.homeassistant.setup.instance", {"url": instanceSetupPopupUrl.text, "api_key": instanceSetupPopupApiKey.text}) - instaceSetupPopupBox.close() - } - - onPressed: { - instanceSetupPopupConfirmButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) - } - onReleased: { - instanceSetupPopupConfirmButtonBackground.color = Kirigami.Theme.highlightColor - } - } - - Button { - id: instanceSetupPopupCancelButton - Layout.fillWidth: true - Layout.fillHeight: true - - background: Rectangle { - id: instanceSetupPopupCancelButtonBackground - color: Kirigami.Theme.highlightColor - radius: Mycroft.Units.gridUnit * 0.5 - } - - contentItem: Item { - RowLayout { - id: instanceSetupPopupCancelButtonLayout - anchors.centerIn: parent - - Kirigami.Icon { - id: instanceSetupPopupCancelButtonIcon - Layout.fillHeight: true - Layout.preferredWidth: height - Layout.alignment: Qt.AlignVCenter - source: "window-close-symbolic" - - ColorOverlay { - anchors.fill: parent - source: parent - color: Kirigami.Theme.textColor - } - } - - Kirigami.Heading { - id: instanceSetupPopupCancelButtonText - level: 2 - Layout.fillHeight: true - wrapMode: Text.WordWrap - font.bold: true - elide: Text.ElideRight - color: Kirigami.Theme.textColor - text: qsTr("Cancel") - verticalAlignment: Text.AlignVCenter - horizontalAlignment: Text.AlignLeft - } - } - } - - onClicked: { - instaceSetupPopupBox.close() - } - - onPressed: { - instanceSetupPopupCancelButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) - } - onReleased: { - instanceSetupPopupCancelButtonBackground.color = Kirigami.Theme.highlightColor - } - } - } - } - } + width: parent.width * 0.95 + height: parent.height * 0.95 } ItemDelegate { diff --git a/ovos_PHAL_plugin_homeassistant/ui/InstanceGridButton.qml b/ovos_PHAL_plugin_homeassistant/ui/InstanceGridButton.qml new file mode 100644 index 0000000..89b0951 --- /dev/null +++ b/ovos_PHAL_plugin_homeassistant/ui/InstanceGridButton.qml @@ -0,0 +1,83 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import org.kde.kirigami 2.11 as Kirigami +import Mycroft 1.0 as Mycroft +import QtGraphicalEffects 1.0 +import "code/helper.js" as HelperJS + +Rectangle { + id: gridItem + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: 1 + radius: 5 + property int index + property alias icon: onDisplayIconType.source + property alias text: onDisplayIconLabel.text + property bool hasAction: false + property var action + property var actionData + + MouseArea { + anchors.fill: parent + + onClicked: { + loginChoiceStackLayout.currentIndex = index + + if (gridItem.hasAction) { + Mycroft.MycroftController.sendRequest(action, actionData) + } + } + + onPressed: { + gridItem.color = Qt.rgba(1, 1, 1, 0.2) + onDisplayIconLabelBackground.color = Qt.darker(Kirigami.Theme.backgroundColor, 2) + } + onReleased: { + gridItem.color = "transparent" + onDisplayIconLabelBackground.color = Kirigami.Theme.highlightColor + } + } + + ColumnLayout { + anchors.fill: parent + + Kirigami.Icon { + id: onDisplayIconType + Layout.preferredWidth: root.horizontalMode ? (parent.width / 2) : (parent.height / 2) + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + + ColorOverlay { + anchors.fill: parent + source: parent + color: Kirigami.Theme.textColor + } + } + + Rectangle { + id: onDisplayIconLabelBackground + Layout.fillWidth: true + Layout.preferredHeight: parent.height * 0.40 + Layout.alignment: Qt.AlignTop + color: Kirigami.Theme.highlightColor + radius: 5 + + Label { + id: onDisplayIconLabel + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + fontSizeMode: Text.Fit + minimumPixelSize: 10 + font.pixelSize: 32 + color: Kirigami.Theme.textColor + } + } + } +} \ No newline at end of file diff --git a/ovos_PHAL_plugin_homeassistant/ui/InstanceSetup.qml b/ovos_PHAL_plugin_homeassistant/ui/InstanceSetup.qml new file mode 100644 index 0000000..3f0eb79 --- /dev/null +++ b/ovos_PHAL_plugin_homeassistant/ui/InstanceSetup.qml @@ -0,0 +1,216 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import org.kde.kirigami 2.11 as Kirigami +import Mycroft 1.0 as Mycroft +import QtGraphicalEffects 1.0 +import "delegates" as Delegates +import "code/helper.js" as HelperJS + +Popup { + id: instaceSetupPopupBox + property bool horizontalMode: width > height ? 1 : 0 + + background: Rectangle { + color: Qt.darker(Kirigami.Theme.backgroundColor, 1) + radius: Mycroft.Units.gridUnit * 0.5 + } + + contentItem: Item { + Item { + anchors.fill: parent + anchors.margins: Mycroft.Units.gridUnit + + Kirigami.Heading { + id: instanceSetupPopupTitle + level: 2 + text: qsTr("Setup Home Assistant Instance") + font.bold: true + color: Kirigami.Theme.textColor + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: Mycroft.Units.gridUnit * 2 + } + + Kirigami.Separator { + anchors.top: instanceSetupPopupTitle.bottom + anchors.left: parent.left + anchors.right: parent.right + color: Kirigami.Theme.highlightColor + } + + StackLayout { + id: loginChoiceStackLayout + anchors.top: instanceSetupPopupTitle.bottom + anchors.topMargin: Mycroft.Units.gridUnit * 0.5 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + currentIndex: 0 + + Item { + id: selectLoginMethodItem + + Label { + id: instanceSetupPopupUrlLabel + text: qsTr("Home Assistant Instance URL") + fontSizeMode: Text.Fit + minimumPixelSize: 10 + elide: Text.ElideRight + font.pixelSize: Mycroft.Units.gridUnit * 1.5 + color: Kirigami.Theme.textColor + anchors.top: instanceSetupPopupTitle.bottom + anchors.topMargin: Kirigami.Units.smallSpacing + anchors.left: parent.left + anchors.right: parent.right + height: Mycroft.Units.gridUnit * 2 + } + + Label { + id: subTextInstanceSetupPopupUrlLabel + fontSizeMode: Text.Fit + minimumPixelSize: 8 + elide: Text.ElideRight + font.pixelSize: 12 + color: Kirigami.Theme.highlightColor + anchors.top: instanceSetupPopupUrlLabel.bottom + anchors.topMargin: Kirigami.Units.smallSpacing + anchors.left: parent.left + anchors.right: parent.right + height: Mycroft.Units.gridUnit * 2 + text: qsTr("HTTP: http://homeassistant.local:8123") + "|" + qsTr("Websocket: ws://homeassistant.local:8123") + } + + TextField { + id: instanceSetupPopupUrl + placeholderText: qsTr("http://homeassistant.local:8123 or ws://homeassistant.local:8123") + font.pixelSize: Mycroft.Units.gridUnit * 1.5 + color: Kirigami.Theme.textColor + anchors.left: parent.left + anchors.right: parent.right + anchors.top: subTextInstanceSetupPopupUrlLabel.bottom + anchors.topMargin: Mycroft.Units.gridUnit * 0.5 + height: Mycroft.Units.gridUnit * 3 + } + + GridLayout { + anchors.top: instanceSetupPopupUrl.bottom + anchors.topMargin: Mycroft.Units.gridUnit * 0.5 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + columns: instaceSetupPopupBox.horizontalMode ? 2 : 1 + + InstanceGridButton { + id: qrCodeLoginButton + index: 1 + icon: Qt.resolvedUrl("icons/qr-mobile.svg") + text: qsTr("Use QR Code") + hasAction: true + action: "ovos.phal.plugin.homeassistant.start.oauth.flow" + actionData: {"instance": instanceSetupPopupUrl.text} + } + + InstanceGridButton { + id: tokenLoginButton + index: 2 + icon: Qt.resolvedUrl("icons/token-device.svg") + text: qsTr("Use Access Token") + hasAction: false + } + } + } + + Item { + id: useQrCodeLoginItem + + Rectangle{ + id: instanceSetupPopupQrCodeLabel + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: Mycroft.Units.gridUnit * 3 + color: Kirigami.Theme.highlightColor + + Label { + text: qsTr("Scan the QR code below to continue") + fontSizeMode: Text.Fit + minimumPixelSize: 10 + elide: Text.ElideRight + font.pixelSize: Mycroft.Units.gridUnit * 1.5 + color: Kirigami.Theme.textColor + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + anchors.margins: Mycroft.Units.gridUnit * 0.5 + } + } + + Image { + id: qrCodeImage + anchors.top: instanceSetupPopupQrCodeLabel.bottom + anchors.topMargin: Mycroft.Units.gridUnit * 0.5 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: instanceSetupFooterButtonsQrCodeArea.top + anchors.bottomMargin: Mycroft.Units.gridUnit * 0.5 + fillMode: Image.PreserveAspectFit + source: dashboardRoot.qrImagePath + } + + InstanceSetupFooterButtons { + id: instanceSetupFooterButtonsQrCodeArea + anchors.bottom: parent.bottom + anchors.bottomMargin: Mycroft.Units.gridUnit * 0.5 + anchors.left: parent.left + anchors.right: parent.right + height: Mycroft.Units.gridUnit * 3 + isTokenLogin: false + } + } + + Item { + id: useTokenLoginItem + + Label { + id: instanceSetupPopupApiKeyLabel + text: qsTr("Home Assistant Instance API Key") + fontSizeMode: Text.Fit + minimumPixelSize: 10 + elide: Text.ElideRight + font.pixelSize: Mycroft.Units.gridUnit * 1.5 + color: Kirigami.Theme.textColor + anchors.top: instanceSetupPopupUrl.bottom + anchors.topMargin: Kirigami.Units.smallSpacing + anchors.left: parent.left + anchors.right: parent.right + height: Mycroft.Units.gridUnit * 2 + } + + TextField { + id: instanceSetupPopupApiKey + placeholderText: qsTr("API Key") + font.pixelSize: Mycroft.Units.gridUnit * 1.5 + color: Kirigami.Theme.textColor + anchors.left: parent.left + anchors.right: parent.right + anchors.top: instanceSetupPopupApiKeyLabel.bottom + anchors.topMargin: Mycroft.Units.gridUnit * 0.5 + height: Mycroft.Units.gridUnit * 3 + } + + InstanceSetupFooterButtons { + id: instanceSetupFooterButtonsTokenArea + anchors.top: instanceSetupPopupApiKey.bottom + anchors.topMargin: Mycroft.Units.gridUnit * 0.5 + anchors.left: parent.left + anchors.right: parent.right + height: Mycroft.Units.gridUnit * 3 + isTokenLogin: true + } + } + } + } + } +} \ No newline at end of file diff --git a/ovos_PHAL_plugin_homeassistant/ui/InstanceSetupFooterButtons.qml b/ovos_PHAL_plugin_homeassistant/ui/InstanceSetupFooterButtons.qml new file mode 100644 index 0000000..76238fe --- /dev/null +++ b/ovos_PHAL_plugin_homeassistant/ui/InstanceSetupFooterButtons.qml @@ -0,0 +1,188 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import org.kde.kirigami 2.11 as Kirigami +import Mycroft 1.0 as Mycroft +import QtGraphicalEffects 1.0 +import "delegates" as Delegates +import "code/helper.js" as HelperJS + +RowLayout { + id: instanceSetupPopupButtonsTokenLogin + property bool isTokenLogin: false + + Button { + id: instanceSetupPopupStackBackButton + Layout.fillWidth: true + Layout.fillHeight: true + + background: Rectangle { + id: instanceSetupPopupStackBackButtonBackground + color: Kirigami.Theme.highlightColor + radius: Mycroft.Units.gridUnit * 0.5 + } + + contentItem: Item{ + RowLayout { + id: instanceSetupPopupStackBackButtonLayout + anchors.centerIn: parent + + Kirigami.Icon { + id: instanceSetupPopupStackBackButtonIcon + Layout.fillHeight: true + Layout.preferredWidth: height + Layout.alignment: Qt.AlignVCenter + source: "go-previous-symbolic" + + ColorOverlay { + anchors.fill: parent + source: parent + color: Kirigami.Theme.textColor + } + } + + Kirigami.Heading { + id: instanceSetupPopupStackBackButtonText + level: 2 + Layout.fillHeight: true + wrapMode: Text.WordWrap + font.bold: true + elide: Text.ElideRight + color: Kirigami.Theme.textColor + text: qsTr("Back") + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignLeft + } + } + } + + onClicked: { + loginChoiceStackLayout.currentIndex = 0 + } + + onPressed: { + instanceSetupPopupStackBackButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) + } + + onReleased: { + instanceSetupPopupStackBackButtonBackground.color = Kirigami.Theme.highlightColor + } + } + + Button { + id: instanceSetupPopupConfirmButton + Layout.fillWidth: true + Layout.fillHeight: true + visible: instanceSetupPopupButtonsTokenLogin.isTokenLogin ? 1 : 0 + enabled: instanceSetupPopupButtonsTokenLogin.isTokenLogin ? 1 : 0 + + background: Rectangle { + id: instanceSetupPopupConfirmButtonBackground + color: Kirigami.Theme.highlightColor + radius: Mycroft.Units.gridUnit * 0.5 + } + + contentItem: Item { + RowLayout { + id: instanceSetupPopupConfirmButtonLayout + anchors.centerIn: parent + + Kirigami.Icon { + id: instanceSetupPopupConfirmButtonIcon + Layout.fillHeight: true + Layout.preferredWidth: height + Layout.alignment: Qt.AlignVCenter + source: "answer-correct" + + ColorOverlay { + anchors.fill: parent + source: parent + color: Kirigami.Theme.textColor + } + } + + Kirigami.Heading { + id: instanceSetupPopupConfirmButtonText + level: 2 + Layout.fillHeight: true + wrapMode: Text.WordWrap + font.bold: true + elide: Text.ElideRight + color: Kirigami.Theme.textColor + text: qsTr("Confirm") + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignLeft + } + } + } + + onClicked: { + Mycroft.MycroftController.sendRequest("ovos.phal.plugin.homeassistant.setup.instance", {"url": instanceSetupPopupUrl.text, "api_key": instanceSetupPopupApiKey.text}) + instaceSetupPopupBox.close() + } + + onPressed: { + instanceSetupPopupConfirmButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) + } + onReleased: { + instanceSetupPopupConfirmButtonBackground.color = Kirigami.Theme.highlightColor + } + } + + Button { + id: instanceSetupPopupCancelButton + Layout.fillWidth: true + Layout.fillHeight: true + + background: Rectangle { + id: instanceSetupPopupCancelButtonBackground + color: Kirigami.Theme.highlightColor + radius: Mycroft.Units.gridUnit * 0.5 + } + + contentItem: Item { + RowLayout { + id: instanceSetupPopupCancelButtonLayout + anchors.centerIn: parent + + Kirigami.Icon { + id: instanceSetupPopupCancelButtonIcon + Layout.fillHeight: true + Layout.preferredWidth: height + Layout.alignment: Qt.AlignVCenter + source: "window-close-symbolic" + + ColorOverlay { + anchors.fill: parent + source: parent + color: Kirigami.Theme.textColor + } + } + + Kirigami.Heading { + id: instanceSetupPopupCancelButtonText + level: 2 + Layout.fillHeight: true + wrapMode: Text.WordWrap + font.bold: true + elide: Text.ElideRight + color: Kirigami.Theme.textColor + text: qsTr("Cancel") + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignLeft + } + } + } + + onClicked: { + instaceSetupPopupBox.close() + } + + onPressed: { + instanceSetupPopupCancelButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) + } + onReleased: { + instanceSetupPopupCancelButtonBackground.color = Kirigami.Theme.highlightColor + } + } +} \ No newline at end of file diff --git a/ovos_PHAL_plugin_homeassistant/ui/icons/qr-mobile.svg b/ovos_PHAL_plugin_homeassistant/ui/icons/qr-mobile.svg new file mode 100644 index 0000000..8bee8e9 --- /dev/null +++ b/ovos_PHAL_plugin_homeassistant/ui/icons/qr-mobile.svg @@ -0,0 +1,128 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ovos_PHAL_plugin_homeassistant/ui/icons/token-device.svg b/ovos_PHAL_plugin_homeassistant/ui/icons/token-device.svg new file mode 100644 index 0000000..a3a26f6 --- /dev/null +++ b/ovos_PHAL_plugin_homeassistant/ui/icons/token-device.svg @@ -0,0 +1,114 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 187870448d1ab760b0ad54857f6f84f47eb1b52e Mon Sep 17 00:00:00 2001 From: Aditya Mehra Date: Fri, 20 Jan 2023 16:30:44 +1030 Subject: [PATCH 3/8] fixes reported bugs in review --- ovos_PHAL_plugin_homeassistant/__init__.py | 2 +- .../logic/connector.py | 17 +- .../logic/device.py | 18 +- .../ui/Dashboard.qml | 186 +++++++++++++----- .../ui/InstanceGridButton.qml | 2 +- .../ui/InstanceSetup.qml | 2 +- .../ui/InstanceSetupFooterButtons.qml | 18 +- 7 files changed, 167 insertions(+), 78 deletions(-) diff --git a/ovos_PHAL_plugin_homeassistant/__init__.py b/ovos_PHAL_plugin_homeassistant/__init__.py index f8dd726..6f7ff3e 100644 --- a/ovos_PHAL_plugin_homeassistant/__init__.py +++ b/ovos_PHAL_plugin_homeassistant/__init__.py @@ -488,7 +488,7 @@ def handle_update_area_dashboard(self, message): Args: message (Message): The message object """ - area = message.data.get("area", None) + area = message.data.get("area_type", None) if area is not None: self.gui["areaDashboardModel"] = { "items": self.build_display_area_devices_model(area)} diff --git a/ovos_PHAL_plugin_homeassistant/logic/connector.py b/ovos_PHAL_plugin_homeassistant/logic/connector.py index 3202207..c2e8393 100644 --- a/ovos_PHAL_plugin_homeassistant/logic/connector.py +++ b/ovos_PHAL_plugin_homeassistant/logic/connector.py @@ -291,10 +291,13 @@ def get_all_devices(self) -> list: return list(devices_with_area.values()) def get_device_state(self, entity_id: str) -> dict: - states = self.client.get_states_sync() - for state in states: - if state['entity_id'] == entity_id: - return state + try: + states = self.client.get_states_sync() + for state in states: + if state['entity_id'] == entity_id: + return state + except Exception as e: + pass def set_device_state(self, entity_id: str, state: str, attributes: Optional[dict] = None): self.client.set_state(entity_id, state, attributes) @@ -316,11 +319,13 @@ def get_all_devices_with_type_and_attribute_not_in(self, device_type, attribute, return [d for d in devices if d['attributes'].get(attribute) not in value] def turn_on(self, device_id, device_type): - LOG.debug(f"Turn on {device_id}") + if self.enable_debug: + LOG.debug(f"Turn on {device_id}") self.client.call_service_sync(device_type, 'turn_on', {'entity_id': device_id}) def turn_off(self, device_id, device_type): - LOG.debug(f"Turn off {device_id}") + if self.enable_debug: + LOG.debug(f"Turn off {device_id}") self.client.call_service_sync(device_type, 'turn_off', {'entity_id': device_id}) def call_function(self, device_id, device_type, function, arguments=None): diff --git a/ovos_PHAL_plugin_homeassistant/logic/device.py b/ovos_PHAL_plugin_homeassistant/logic/device.py index b87ae2c..55cff34 100644 --- a/ovos_PHAL_plugin_homeassistant/logic/device.py +++ b/ovos_PHAL_plugin_homeassistant/logic/device.py @@ -155,15 +155,15 @@ def set_device_attributes(self, device_id, attributes): def poll(self): """ Poll the device. """ full_state_json = self.connector.get_device_state(self.device_id) - # LOG.debug(full_state_json) - if full_state_json == 'unavailable': - LOG.warning(f"State unavailable for device: {self.device_id}") - elif not isinstance(full_state_json, dict): - LOG.error(f'({self.device_name}) Expected dict state but got: ' - f'{full_state_json}') - else: - self.device_state = full_state_json.get("state", "unknown") - self.device_attributes = full_state_json.get("attributes", {}) + if full_state_json: + if full_state_json == 'unavailable': + LOG.warning(f"State unavailable for device: {self.device_id}") + elif not isinstance(full_state_json, dict): + LOG.error(f'({self.device_name}) Expected dict state but got: ' + f'{full_state_json}') + else: + self.device_state = full_state_json.get("state", "unknown") + self.device_attributes = full_state_json.get("attributes", {}) def get_device_display_model(self): """ Get the display model of the device. """ diff --git a/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml b/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml index b24539a..3459bb5 100644 --- a/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml +++ b/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml @@ -24,11 +24,11 @@ Mycroft.Delegate { property var qrImagePath function get_page_name() { - if (dashboardSwipeView.currentIndex == 0) { - return "Dashboard" - } else { - return tabBarModel[bar.currentIndex].name - } + if (dashboardSwipeView.currentIndex == 0) { + return "Dashboard" + } else { + return tabBarModel[bar.currentIndex].name + } } function change_tab_to_type(type) { @@ -68,26 +68,25 @@ Mycroft.Delegate { onGuiEvent: { switch (eventName) { - case "ovos.phal.plugin.homeassistant.change.dashboard": - var requested_page = data.dash_type - if (requested_page === "main") { - dashboardSwipeView.currentIndex = 0 - } else if (requested_page === "device") { - dashboardSwipeView.currentIndex = 1 - } else if (requested_page === "area") { - dashboardSwipeView.currentIndex = 1 - } - break - case "ovos.phal.plugin.homeassistant.integration.query_media.result": - deviceControlsLoader.mediaModel = data.results - console.log(JSON.stringify(data.results)) - break - case "ovos.phal.plugin.homeassistant.oauth.qr.update": - dashboardRoot.qrImagePath = Qt.resolvedUrl(data.qr) - break - case "ovos.phal.plugin.homeassistant.oauth.success": - instaceSetupPopupBox.close() - break + case "ovos.phal.plugin.homeassistant.change.dashboard": + var requested_page = data.dash_type + if (requested_page === "main") { + dashboardSwipeView.currentIndex = 0 + } else if (requested_page === "device") { + dashboardSwipeView.currentIndex = 1 + } else if (requested_page === "area") { + dashboardSwipeView.currentIndex = 1 + } + break + case "ovos.phal.plugin.homeassistant.integration.query_media.result": + deviceControlsLoader.mediaModel = data.results + break + case "ovos.phal.plugin.homeassistant.oauth.qr.update": + dashboardRoot.qrImagePath = Qt.resolvedUrl(data.qr) + break + case "ovos.phal.plugin.homeassistant.oauth.success": + instaceSetupPopupBox.close() + break } } @@ -100,14 +99,17 @@ Mycroft.Delegate { var tabModel = [{"name": "Home", "type": "main"}] for (var i = 0; i < dashboardModel.items.length; i++) { var item = dashboardModel.items[i] - tabModel.push({"name": item.name + "s", "type": item.type}) + if(dashboardRoot.useGroupDisplay) { + tabModel.push({"name": item.name, "type": item.type}) + } else { + tabModel.push({"name": item.name + "s", "type": item.type}) + } } tabBarModel = tabModel } } onDeviceDashboardModelChanged: { - console.log("deviceDashboardModel changed") if (deviceDashboardModel) { devicesGridView.model = deviceDashboardModel.items @@ -118,7 +120,6 @@ Mycroft.Delegate { } onAreaDashboardModelChanged: { - console.log("areaDashboardModel changed") if (areaDashboardModel) { devicesGridView.model = areaDashboardModel.items @@ -346,7 +347,7 @@ Mycroft.Delegate { Kirigami.Heading { id: instanceSetupButtonText level: 2 - Layout.fillHeight: true + Layout.fillHeight: true wrapMode: Text.WordWrap font.bold: true elide: Text.ElideRight @@ -360,7 +361,7 @@ Mycroft.Delegate { onClicked: { instaceSetupPopupBox.open() - } + } } } @@ -438,36 +439,119 @@ Mycroft.Delegate { anchors.right: parent.right color: Kirigami.Theme.highlightColor } - - TabBar { - id: bar + Item { + id: bottomBarAreaTabsContainer width: parent.width - height: parent.height - Kirigami.Units.smallSpacing + height: parent.height anchors.bottom: parent.bottom visible: dashboardRoot.horizontalMode ? 1 : 0 + property bool leftButtonActive: tabBarFlickableObject.contentX > 0 ? 1 : 0 + property bool rightButtonActive: tabBarFlickableObject.contentX < tabBarFlickableObject.contentWidth - tabBarFlickableObject.width - Mycroft.Units.gridUnit * 12 ? 1 : 0 + + Button { + id: arrowLeftTabBarFlicker + anchors.left: parent.left + anchors.bottom: parent.bottom + width: Mycroft.Units.gridUnit * 3 + height: Mycroft.Units.gridUnit * 3 + enabled: bottomBarAreaTabsContainer.leftButtonActive ? 1 : 0 + opacity: enabled ? 1 : 0.5 + + background: Rectangle { + color: "transparent" + } - Repeater { - model: tabBarModel - delegate: TabButton { - text: modelData.name - width: parent.width / tabBarModel.count - height: parent.height - - onClicked: { - if(dashboardRoot.horizontalMode) { - if(modelData.type === "main") { - dashboardSwipeView.currentIndex = 0 - } else { - if(dashboardRoot.useGroupDisplay) { - Mycroft.MycroftController.sendRequest("ovos.phal.plugin.homeassistant.show.area.dashboard", {"area": modelData.type}) - } else { - Mycroft.MycroftController.sendRequest("ovos.phal.plugin.homeassistant.show.device.dashboard", {"device_type": modelData.type}) + contentItem: Item { + Kirigami.Icon { + id: arrowLeftTabBarFlickerIcon + width: parent.width * 0.8 + height: parent.height * 0.8 + anchors.centerIn: parent + source: "go-previous-symbolic" + } + } + + onClicked: { + if (tabBarFlickableObject.contentX > 0) { + tabBarFlickableObject.contentX -= Mycroft.Units.gridUnit * 12 + } + } + } + + Flickable { + id: tabBarFlickableObject + anchors.left: arrowLeftTabBarFlicker.right + anchors.leftMargin: Mycroft.Units.gridUnit * 0.5 + anchors.rightMargin: Mycroft.Units.gridUnit * 0.5 + anchors.right: arrowRightTabBarFlicker.left + height: parent.height + anchors.bottom: parent.bottom + flickableDirection: Flickable.HorizontalFlick + contentWidth: parent.width * 2 + contentHeight: height + clip: true + + TabBar { + id: bar + width: (Mycroft.Units.gridUnit * 12) * tabBarModel.count + height: parent.height - Kirigami.Units.smallSpacing + anchors.bottom: parent.bottom + + Repeater { + id: tabBarModelRepeater + model: tabBarModel + delegate: TabButton { + text: modelData.name + width: Mycroft.Units.gridUnit * 12 + height: parent.height + + onClicked: { + if(dashboardRoot.horizontalMode) { + if(modelData.type === "main") { + dashboardSwipeView.currentIndex = 0 + } else { + if(dashboardRoot.useGroupDisplay) { + Mycroft.MycroftController.sendRequest("ovos.phal.plugin.homeassistant.show.area.dashboard", {"area": modelData.type}) + } else { + Mycroft.MycroftController.sendRequest("ovos.phal.plugin.homeassistant.show.device.dashboard", {"device_type": modelData.type}) + } + } } } } } } } + + Button { + id: arrowRightTabBarFlicker + anchors.right: parent.right + anchors.bottom: parent.bottom + width: Mycroft.Units.gridUnit * 3 + height: Mycroft.Units.gridUnit * 3 + enabled: bottomBarAreaTabsContainer.rightButtonActive && tabBarModelRepeater.count > 2 ? 1 : 0 + opacity: enabled ? 1 : 0.5 + + background: Rectangle { + color: "transparent" + } + + contentItem: Item { + Kirigami.Icon { + id: arrowRightTabBarFlickerIcon + width: parent.width * 0.8 + height: parent.height * 0.8 + anchors.centerIn: parent + source: "go-next-symbolic" + } + } + + onClicked: { + if(tabBarFlickableObject.contentX < tabBarFlickableObject.contentWidth - tabBarFlickableObject.width) { + tabBarFlickableObject.contentX += Mycroft.Units.gridUnit * 12 + } + } + } } Button { @@ -504,7 +588,7 @@ Mycroft.Delegate { Kirigami.Heading { id: returnToMainDashboardButtonVerticalModeText level: 2 - Layout.fillHeight: true + Layout.fillHeight: true wrapMode: Text.WordWrap font.bold: true elide: Text.ElideRight diff --git a/ovos_PHAL_plugin_homeassistant/ui/InstanceGridButton.qml b/ovos_PHAL_plugin_homeassistant/ui/InstanceGridButton.qml index 89b0951..0fdfac7 100644 --- a/ovos_PHAL_plugin_homeassistant/ui/InstanceGridButton.qml +++ b/ovos_PHAL_plugin_homeassistant/ui/InstanceGridButton.qml @@ -80,4 +80,4 @@ Rectangle { } } } -} \ No newline at end of file +} diff --git a/ovos_PHAL_plugin_homeassistant/ui/InstanceSetup.qml b/ovos_PHAL_plugin_homeassistant/ui/InstanceSetup.qml index 3f0eb79..39b49ab 100644 --- a/ovos_PHAL_plugin_homeassistant/ui/InstanceSetup.qml +++ b/ovos_PHAL_plugin_homeassistant/ui/InstanceSetup.qml @@ -213,4 +213,4 @@ Popup { } } } -} \ No newline at end of file +} diff --git a/ovos_PHAL_plugin_homeassistant/ui/InstanceSetupFooterButtons.qml b/ovos_PHAL_plugin_homeassistant/ui/InstanceSetupFooterButtons.qml index 76238fe..434fb1d 100644 --- a/ovos_PHAL_plugin_homeassistant/ui/InstanceSetupFooterButtons.qml +++ b/ovos_PHAL_plugin_homeassistant/ui/InstanceSetupFooterButtons.qml @@ -44,7 +44,7 @@ RowLayout { Kirigami.Heading { id: instanceSetupPopupStackBackButtonText level: 2 - Layout.fillHeight: true + Layout.fillHeight: true wrapMode: Text.WordWrap font.bold: true elide: Text.ElideRight @@ -61,7 +61,7 @@ RowLayout { } onPressed: { - instanceSetupPopupStackBackButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) + instanceSetupPopupStackBackButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) } onReleased: { @@ -104,7 +104,7 @@ RowLayout { Kirigami.Heading { id: instanceSetupPopupConfirmButtonText level: 2 - Layout.fillHeight: true + Layout.fillHeight: true wrapMode: Text.WordWrap font.bold: true elide: Text.ElideRight @@ -122,11 +122,11 @@ RowLayout { } onPressed: { - instanceSetupPopupConfirmButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) + instanceSetupPopupConfirmButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) } onReleased: { instanceSetupPopupConfirmButtonBackground.color = Kirigami.Theme.highlightColor - } + } } Button { @@ -162,7 +162,7 @@ RowLayout { Kirigami.Heading { id: instanceSetupPopupCancelButtonText level: 2 - Layout.fillHeight: true + Layout.fillHeight: true wrapMode: Text.WordWrap font.bold: true elide: Text.ElideRight @@ -179,10 +179,10 @@ RowLayout { } onPressed: { - instanceSetupPopupCancelButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) + instanceSetupPopupCancelButtonBackground.color = Qt.darker(Kirigami.Theme.highlightColor, 2) } onReleased: { instanceSetupPopupCancelButtonBackground.color = Kirigami.Theme.highlightColor - } + } } -} \ No newline at end of file +} From 0cbf59d177e050fa723acc90f720bfab11276389 Mon Sep 17 00:00:00 2001 From: Aditya Mehra Date: Fri, 20 Jan 2023 17:16:02 +1030 Subject: [PATCH 4/8] mark shell_integration as false as app handles QR code display --- ovos_PHAL_plugin_homeassistant/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ovos_PHAL_plugin_homeassistant/__init__.py b/ovos_PHAL_plugin_homeassistant/__init__.py index 6f7ff3e..4596a6e 100644 --- a/ovos_PHAL_plugin_homeassistant/__init__.py +++ b/ovos_PHAL_plugin_homeassistant/__init__.py @@ -556,6 +556,7 @@ def oauth_register(self): "app_id": "homeassistant-phal-plugin", "auth_endpoint": auth_endpoint, "token_endpoint": token_endpoint, + "shell_integration": False, "refresh_endpoint": "", })) From 1a0242afe04f3527eeb66898420ef13b4a75e3d5 Mon Sep 17 00:00:00 2001 From: Aditya Mehra Date: Tue, 24 Jan 2023 15:03:24 +1030 Subject: [PATCH 5/8] fix dashboard scroll issue --- .../ui/Dashboard.qml | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml b/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml index 3459bb5..59aca16 100644 --- a/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml +++ b/ovos_PHAL_plugin_homeassistant/ui/Dashboard.qml @@ -446,7 +446,7 @@ Mycroft.Delegate { anchors.bottom: parent.bottom visible: dashboardRoot.horizontalMode ? 1 : 0 property bool leftButtonActive: tabBarFlickableObject.contentX > 0 ? 1 : 0 - property bool rightButtonActive: tabBarFlickableObject.contentX < tabBarFlickableObject.contentWidth - tabBarFlickableObject.width - Mycroft.Units.gridUnit * 12 ? 1 : 0 + property bool rightButtonActive: tabBarFlickableObject.contentX < tabBarFlickableObject.contentWidth - tabBarFlickableObject.width ? 1 : 0 Button { id: arrowLeftTabBarFlicker @@ -458,6 +458,7 @@ Mycroft.Delegate { opacity: enabled ? 1 : 0.5 background: Rectangle { + id: arrowLeftTabBarFlickerBackground color: "transparent" } @@ -476,6 +477,18 @@ Mycroft.Delegate { tabBarFlickableObject.contentX -= Mycroft.Units.gridUnit * 12 } } + + onPressAndHold: { + tabBarFlickableObject.contentX = 0 + arrowLeftTabBarFlickerBackground.color = "transparent" + } + + onPressed: { + arrowLeftTabBarFlickerBackground.color = Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.5) + } + onReleased: { + arrowLeftTabBarFlickerBackground.color = "transparent" + } } Flickable { @@ -487,7 +500,7 @@ Mycroft.Delegate { height: parent.height anchors.bottom: parent.bottom flickableDirection: Flickable.HorizontalFlick - contentWidth: parent.width * 2 + contentWidth: tabBarModelRepeater.count * Mycroft.Units.gridUnit * 12 contentHeight: height clip: true @@ -533,6 +546,7 @@ Mycroft.Delegate { opacity: enabled ? 1 : 0.5 background: Rectangle { + id: arrowRightTabBarFlickerBackground color: "transparent" } @@ -551,6 +565,18 @@ Mycroft.Delegate { tabBarFlickableObject.contentX += Mycroft.Units.gridUnit * 12 } } + + onPressAndHold: { + tabBarFlickableObject.contentX = tabBarFlickableObject.contentWidth - tabBarFlickableObject.width + arrowRightTabBarFlickerBackground.color = "transparent" + } + + onPressed: { + arrowRightTabBarFlickerBackground.color = Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.5) + } + onReleased: { + arrowRightTabBarFlickerBackground.color = "transparent" + } } } From 4617a52a6832663cf700f1d7173cc9212e3fd594 Mon Sep 17 00:00:00 2001 From: Aditya Mehra Date: Tue, 24 Jan 2023 22:50:21 +1030 Subject: [PATCH 6/8] fix requires --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 669fcbf..73f493b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ ovos-plugin-manager>=0.0.1 -ovos-utils~=0.0.27a3 +ovos-utils ovos-config~=0.0.5 mycroft-messagebus-client youtube-search From c9ab525d8ac7fdb28a1218def73785aff510f8ef Mon Sep 17 00:00:00 2001 From: Aditya Mehra Date: Wed, 25 Jan 2023 00:28:21 +1030 Subject: [PATCH 7/8] fix requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 73f493b..134a390 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ ovos-plugin-manager>=0.0.1 -ovos-utils +ovos-utils~=0.0, >=0.0.28 ovos-config~=0.0.5 mycroft-messagebus-client youtube-search From 6165adba4ae0fee68db96c5c0aa0bb9dcaf525bb Mon Sep 17 00:00:00 2001 From: Aditya Mehra Date: Wed, 25 Jan 2023 09:07:54 +1030 Subject: [PATCH 8/8] fix utils version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 134a390..e86471d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ ovos-plugin-manager>=0.0.1 -ovos-utils~=0.0, >=0.0.28 +ovos-utils>=0.0.27 ovos-config~=0.0.5 mycroft-messagebus-client youtube-search