├── .github └── workflows │ ├── hassfest.yml │ └── validate.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── custom_components └── remote_homeassistant │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── manifest.json │ ├── proxy_services.py │ ├── rest_api.py │ ├── sensor.py │ ├── services.yaml │ ├── translations │ ├── de.json │ ├── en.json │ ├── pt-BR.json │ ├── sensor.de.json │ ├── sensor.en.json │ ├── sensor.pt-BR.json │ ├── sensor.sk.json │ └── sk.json │ └── views.py ├── hacs.json ├── icons ├── icon.png ├── icon.svg └── icon@2x.png └── img ├── device.png ├── options.png ├── setup.png ├── step1.png └── step2.png /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "home-assistant" 4 | } 5 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License][license-shield]](LICENSE.md) 2 | 3 | [![hacs][hacsbadge]][hacs] 4 | ![Project Maintenance][maintenance-shield] 5 | 6 | App icon 7 | 8 | # Remote Home-Assistant 9 | 10 | _Component to link multiple Home-Assistant instances together._ 11 | 12 | **This component will set up the following platforms.** 13 | 14 | Platform | Description 15 | -- | -- 16 | `remote_homeassistant` | Link multiple Home-Assistant instances together . 17 | 18 | The main instance connects to the Websocket APIs of the remote instances (already enabled out of box), the connection options are specified via the `host`, `port`, and `secure` configuration parameters. If the remote instance requires an access token to connect (created on the Profile page), it can be set via the `access_token` parameter. To ignore SSL warnings in secure mode, set the `verify_ssl` parameter to false. 19 | 20 | After the connection is completed, the remote states get populated into the master instance. 21 | The entity ids can optionally be prefixed via the `entity_prefix` parameter. 22 | 23 | The entity friendly names can optionally be prefixed via the `entity_friendly_name_prefix` parameter. 24 | 25 | The component keeps track which objects originate from which instance. Whenever a service is called on an object, the call gets forwarded to the particular remote instance. 26 | 27 | When the connection to the remote instance is lost, all previously published states are removed again from the local state registry. 28 | 29 | A possible use case for this is to be able to use different Z-Wave networks, on different Z-Wave sticks (with the second one possible running on another computer in a different location). 30 | 31 | 32 | ## Installation 33 | 34 | This component *must* be installed on both the main and remote instance of Home Assistant 35 | 36 | If you use HACS: 37 | 38 | 1. Click install. 39 | 40 | Otherwise: 41 | 42 | 1. To use this plugin, copy the `remote_homeassistant` folder into your [custom_components folder](https://developers.home-assistant.io/docs/creating_integration_file_structure/#where-home-assistant-looks-for-integrations). 43 | 44 | 45 | **Remote instance** 46 | 47 | On the remote instance you also need to add this to `configuration.yaml`: 48 | 49 | ```yaml 50 | remote_homeassistant: 51 | instances: 52 | ``` 53 | 54 | This is not needed on the main instance. 55 | 56 | ## Configuration (main instance) 57 | 58 | ### Web (Config flow) 59 | 60 | 1. Add a new Remote Home-Assistant integration 61 | 62 | 63 | 64 | 2. Specify the connection details to the remote instance 65 | 66 | 67 | 68 | You can generate an access token in the by logging into your remote instance, clicking on your user profile icon, and then selecting "Create Token" under "Long-Lived Access Tokens". 69 | 70 | Check "Secure" if you want to connect via a secure (https/wss) connection 71 | 72 | 3. After the instance is added, you can configure additional Options by clicking the "Options" button. 73 | 74 | 75 | 76 | 4. You can configure an optional prefix that gets prepended to all remote entities (if unsure, leave this blank). 77 | 78 | 79 | 80 | Click "Submit" to proceed to the next step. 81 | 82 | 5. You can also define filters, that include/exclude specified entities or domains from the remote instance. 83 | 84 | 85 | 86 | 87 | 88 | --- 89 | 90 | or via.. 91 | 92 | ### YAML 93 | 94 | To integrate `remote_homeassistant` into Home Assistant, add the following section to your `configuration.yaml` file: 95 | 96 | Simple example: 97 | 98 | ```yaml 99 | # Example configuration.yaml entry 100 | remote_homeassistant: 101 | instances: 102 | - host: raspberrypi.local 103 | ``` 104 | 105 | 106 | Full example: 107 | 108 | ```yaml 109 | # Example configuration.yaml entry 110 | remote_homeassistant: 111 | instances: 112 | - host: localhost 113 | port: 8124 114 | - host: localhost 115 | port: 8125 116 | secure: true 117 | verify_ssl: false 118 | access_token: !secret access_token 119 | entity_prefix: "instance02_" 120 | entity_friendly_name_prefix: "Instance02 " 121 | include: 122 | domains: 123 | - sensor 124 | - switch 125 | - group 126 | entities: 127 | - zwave.controller 128 | - zwave.desk_light 129 | exclude: 130 | domains: 131 | - persistent_notification 132 | entities: 133 | - group.all_switches 134 | filter: 135 | - entity_id: sensor.faulty_pc_energy 136 | above: 100 137 | - unit_of_measurement: W 138 | below: 0 139 | above: 1000 140 | - entity_id: sensor.faulty_*_power 141 | unit_of_measurement: W 142 | below: 500 143 | subscribe_events: 144 | - state_changed 145 | - service_registered 146 | - zwave.network_ready 147 | - zwave.node_event 148 | load_components: 149 | - zwave 150 | ``` 151 | 152 | ``` 153 | host: 154 | host: Hostname or IP address of remote instance. 155 | required: true 156 | type: string 157 | port: 158 | description: Port of remote instance. 159 | required: false 160 | type: int 161 | secure: 162 | description: Use TLS (wss://) to connect to the remote instance. 163 | required: false 164 | type: bool 165 | verify_ssl: 166 | description: Enables / disables verification of the SSL certificate of the remote instance. 167 | required: false 168 | type: bool 169 | default: true 170 | access_token: 171 | description: Access token of the remote instance, if set. 172 | required: false 173 | type: string 174 | max_message_size: 175 | description: Maximum message size, you can expand size limit in case of an error. 176 | required: false 177 | type: int 178 | entity_prefix: 179 | description: Prefix for all entities of the remote instance. 180 | required: false 181 | type: string 182 | entity_friendly_name_prefix: 183 | description: Prefix for all entity friendly names of the remote instance. 184 | required: false 185 | type: string 186 | include: 187 | description: Configures what should be included from the remote instance. Values set by the exclude lists will take precedence. 188 | required: false 189 | default: include everything 190 | type: mapping of 191 | entities: 192 | description: The list of entity ids to be included from the remote instance 193 | type: list 194 | domains: 195 | description: The list of domains to be included from the remote instance 196 | type: list 197 | exclude: 198 | description: Configures what should be excluded from the remote instance 199 | required: false 200 | default: exclude nothing 201 | type: mapping of 202 | entities: 203 | description: The list of entity ids to be excluded from the remote instance 204 | type: list 205 | domains: 206 | description: The list of domains to be excluded from the remote instance 207 | type: list 208 | filter: 209 | description: Filters out states above or below a certain threshold, e.g. outliers reported by faulty sensors 210 | required: false 211 | type: list of 212 | entity_id: 213 | description: which entities the filter should match, supports wildcards 214 | required: false 215 | type: string 216 | unit_of_measurement 217 | description: which units of measurement the filter should match 218 | required: false 219 | type: string 220 | above: 221 | description: states above this threshold will be ignored 222 | required: false 223 | type: float 224 | below: 225 | description: states below this threshold will be ignored 226 | required: false 227 | type: float 228 | subscribe_events: 229 | description: Further list of events, which should be forwarded from the remote instance. If you override this, you probably will want to add state_changed!! 230 | required: false 231 | type: list 232 | default: 233 | - state_changed 234 | - service_registered 235 | load_components: 236 | description: Load components of specified domains only present on the remote instance, e.g. to register services that would otherwise not be available. 237 | required: false 238 | type: list 239 | service_prefix: garage_ 240 | description: Prefix used for proxy services. Must be unique for all instances. 241 | required: false 242 | type: str 243 | default: remote_ 244 | services: 245 | description: Name of services to set up proxy services for. 246 | required: false 247 | type: list 248 | ``` 249 | 250 | ## Special notes 251 | 252 | ### Missing Components 253 | 254 | If you have remote domains (e.g. `switch`), that are not loaded on the main instance you need to list them under `load_components`, otherwise you'll get a `Call service failed` error. 255 | 256 | E.g. on the master: 257 | 258 | ```yaml 259 | remote_homeassistant: 260 | instances: 261 | - host: 10.0.0.2 262 | load_components: 263 | - zwave 264 | ``` 265 | 266 | to enable all `zwave` services. This can also be configured via options under Configuration->Integrations. 267 | 268 | ### Proxy Services 269 | 270 | Some components do not use entities to handle service calls, but handle the 271 | service calls themselves. One such example is `hdmi_cec`. This becomes a 272 | problem as it is not possible to forward the service calls properly. To work 273 | around this limitation, it's possible to set up a *proxy service*. 274 | 275 | A proxy service is registered like a new service on the master instance, but 276 | it mirrors a service on the remote instance. When the proxy service is called 277 | on the master, the mirrored service is called on the remote instance. Any 278 | error is propagated back to the master. To distinguish proxy services from 279 | regular services, a service prefix must be provided. 280 | 281 | Example: If a proxy service is set up for `hdmi_cec.volume` with service 282 | prefix `remote_`, a new service called `hdmi_cec.remote_volume` will be 283 | registered on the master instance. When called, the actual call will be forwarded 284 | to `hdmi_cec.volume` on the remote instance. The YAML config would 285 | look like this: 286 | 287 | ```yaml 288 | remote_homeassistant: 289 | instances: 290 | - host: 10.0.0. 291 | service_prefix: remote_ 292 | services: 293 | - hdmi_cec.volume 294 | ``` 295 | 296 | This can also be set up via Options for the integration under 297 | Configuration -> Integrations. 298 | 299 | --- 300 | 301 | See also the discussion on https://github.com/home-assistant/home-assistant/pull/13876 and https://github.com/home-assistant/architecture/issues/246 for this component 302 | 303 | [hacs]: https://github.com/hacs/integration 304 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 305 | [license-shield]: https://img.shields.io/github/license/lukas-hetzenecker/home-assistant-remote.svg?style=for-the-badge 306 | [maintenance-shield]: https://img.shields.io/badge/maintainer-lukas--hetzenecker-blue.svg?style=for-the-badge 307 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connect two Home Assistant instances via the Websocket API. 3 | 4 | For more details about this component, please refer to the documentation at 5 | https://home-assistant.io/components/remote_homeassistant/ 6 | """ 7 | from __future__ import annotations 8 | import asyncio 9 | from typing import Optional 10 | import copy 11 | import fnmatch 12 | import inspect 13 | import logging 14 | import re 15 | from contextlib import suppress 16 | 17 | import aiohttp 18 | from aiohttp import ClientWebSocketResponse 19 | import homeassistant.components.websocket_api.auth as api 20 | import homeassistant.helpers.config_validation as cv 21 | import voluptuous as vol 22 | try: 23 | from homeassistant.core_config import DATA_CUSTOMIZE 24 | except (ModuleNotFoundError, ImportError): 25 | # hass 2024.10 or older 26 | from homeassistant.config import DATA_CUSTOMIZE 27 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 28 | from homeassistant.const import (CONF_ABOVE, CONF_ACCESS_TOKEN, CONF_BELOW, 29 | CONF_DOMAINS, CONF_ENTITIES, CONF_ENTITY_ID, 30 | CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, 31 | CONF_PORT, CONF_UNIT_OF_MEASUREMENT, 32 | CONF_VERIFY_SSL, EVENT_CALL_SERVICE, 33 | EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, 34 | SERVICE_RELOAD) 35 | from homeassistant.core import (Context, EventOrigin, HomeAssistant, callback, 36 | split_entity_id) 37 | from homeassistant.helpers import device_registry as dr 38 | from homeassistant.helpers import entity_registry as er 39 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 40 | from homeassistant.helpers.dispatcher import async_dispatcher_send 41 | from homeassistant.helpers.reload import async_integration_yaml_config 42 | from homeassistant.helpers.service import async_register_admin_service 43 | from homeassistant.helpers.typing import ConfigType 44 | from homeassistant.setup import async_setup_component 45 | 46 | from custom_components.remote_homeassistant.views import DiscoveryInfoView 47 | 48 | from .const import (CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES, 49 | CONF_INCLUDE_DOMAINS, CONF_INCLUDE_ENTITIES, 50 | CONF_LOAD_COMPONENTS, CONF_OPTIONS, CONF_REMOTE_CONNECTION, 51 | CONF_SERVICE_PREFIX, CONF_SERVICES, CONF_UNSUB_LISTENER, 52 | DOMAIN, REMOTE_ID, DEFAULT_MAX_MSG_SIZE) 53 | from .proxy_services import ProxyServices 54 | from .rest_api import UnsupportedVersion, async_get_discovery_info 55 | 56 | _LOGGER = logging.getLogger(__name__) 57 | 58 | PLATFORMS = ["sensor"] 59 | 60 | CONF_INSTANCES = "instances" 61 | CONF_SECURE = "secure" 62 | CONF_SUBSCRIBE_EVENTS = "subscribe_events" 63 | CONF_ENTITY_PREFIX = "entity_prefix" 64 | CONF_ENTITY_FRIENDLY_NAME_PREFIX = "entity_friendly_name_prefix" 65 | CONF_FILTER = "filter" 66 | CONF_MAX_MSG_SIZE = "max_message_size" 67 | 68 | STATE_INIT = "initializing" 69 | STATE_CONNECTING = "connecting" 70 | STATE_CONNECTED = "connected" 71 | STATE_AUTH_INVALID = "auth_invalid" 72 | STATE_AUTH_REQUIRED = "auth_required" 73 | STATE_RECONNECTING = "reconnecting" 74 | STATE_DISCONNECTED = "disconnected" 75 | 76 | DEFAULT_ENTITY_PREFIX = "" 77 | DEFAULT_ENTITY_FRIENDLY_NAME_PREFIX = "" 78 | 79 | INSTANCES_SCHEMA = vol.Schema( 80 | { 81 | vol.Required(CONF_HOST): cv.string, 82 | vol.Optional(CONF_PORT, default=8123): cv.port, 83 | vol.Optional(CONF_SECURE, default=False): cv.boolean, 84 | vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, 85 | vol.Required(CONF_ACCESS_TOKEN): cv.string, 86 | vol.Optional(CONF_MAX_MSG_SIZE, default=DEFAULT_MAX_MSG_SIZE): vol.Coerce(int), 87 | vol.Optional(CONF_EXCLUDE, default={}): vol.Schema( 88 | { 89 | vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, 90 | vol.Optional(CONF_DOMAINS, default=[]): vol.All( 91 | cv.ensure_list, [cv.string] 92 | ), 93 | } 94 | ), 95 | vol.Optional(CONF_INCLUDE, default={}): vol.Schema( 96 | { 97 | vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, 98 | vol.Optional(CONF_DOMAINS, default=[]): vol.All( 99 | cv.ensure_list, [cv.string] 100 | ), 101 | } 102 | ), 103 | vol.Optional(CONF_FILTER, default=[]): vol.All( 104 | cv.ensure_list, 105 | [ 106 | vol.Schema( 107 | { 108 | vol.Optional(CONF_ENTITY_ID): cv.string, 109 | vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, 110 | vol.Optional(CONF_ABOVE): vol.Coerce(float), 111 | vol.Optional(CONF_BELOW): vol.Coerce(float), 112 | } 113 | ) 114 | ], 115 | ), 116 | vol.Optional(CONF_SUBSCRIBE_EVENTS): cv.ensure_list, 117 | vol.Optional(CONF_ENTITY_PREFIX, 118 | default=DEFAULT_ENTITY_PREFIX): cv.string, 119 | vol.Optional(CONF_ENTITY_FRIENDLY_NAME_PREFIX, 120 | default=DEFAULT_ENTITY_FRIENDLY_NAME_PREFIX): cv.string, 121 | vol.Optional(CONF_LOAD_COMPONENTS): cv.ensure_list, 122 | vol.Required(CONF_SERVICE_PREFIX, default="remote_"): cv.string, 123 | vol.Optional(CONF_SERVICES): cv.ensure_list, 124 | } 125 | ) 126 | 127 | CONFIG_SCHEMA = vol.Schema( 128 | { 129 | DOMAIN: vol.Schema( 130 | { 131 | vol.Required(CONF_INSTANCES): vol.All( 132 | cv.ensure_list, [INSTANCES_SCHEMA] 133 | ), 134 | } 135 | ), 136 | }, 137 | extra=vol.ALLOW_EXTRA, 138 | ) 139 | 140 | HEARTBEAT_INTERVAL = 20 141 | HEARTBEAT_TIMEOUT = 5 142 | 143 | INTERNALLY_USED_EVENTS = [EVENT_STATE_CHANGED] 144 | 145 | 146 | def async_yaml_to_config_entry(instance_conf): 147 | """Convert YAML config into data and options used by a config entry.""" 148 | conf = instance_conf.copy() 149 | options = {} 150 | 151 | if CONF_INCLUDE in conf: 152 | include = conf.pop(CONF_INCLUDE) 153 | if CONF_ENTITIES in include: 154 | options[CONF_INCLUDE_ENTITIES] = include[CONF_ENTITIES] 155 | if CONF_DOMAINS in include: 156 | options[CONF_INCLUDE_DOMAINS] = include[CONF_DOMAINS] 157 | 158 | if CONF_EXCLUDE in conf: 159 | exclude = conf.pop(CONF_EXCLUDE) 160 | if CONF_ENTITIES in exclude: 161 | options[CONF_EXCLUDE_ENTITIES] = exclude[CONF_ENTITIES] 162 | if CONF_DOMAINS in exclude: 163 | options[CONF_EXCLUDE_DOMAINS] = exclude[CONF_DOMAINS] 164 | 165 | for option in [ 166 | CONF_FILTER, 167 | CONF_SUBSCRIBE_EVENTS, 168 | CONF_ENTITY_PREFIX, 169 | CONF_ENTITY_FRIENDLY_NAME_PREFIX, 170 | CONF_LOAD_COMPONENTS, 171 | CONF_SERVICE_PREFIX, 172 | CONF_SERVICES, 173 | ]: 174 | if option in conf: 175 | options[option] = conf.pop(option) 176 | 177 | return conf, options 178 | 179 | 180 | async def _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf): 181 | """Update a config entry with the latest yaml.""" 182 | try: 183 | info = await async_get_discovery_info( 184 | hass, 185 | conf[CONF_HOST], 186 | conf[CONF_PORT], 187 | conf[CONF_SECURE], 188 | conf[CONF_ACCESS_TOKEN], 189 | conf[CONF_VERIFY_SSL], 190 | ) 191 | except Exception: 192 | _LOGGER.exception(f"reload of {conf[CONF_HOST]} failed") 193 | else: 194 | entry = entries_by_id.get(info["uuid"]) 195 | if entry: 196 | data, options = async_yaml_to_config_entry(conf) 197 | hass.config_entries.async_update_entry(entry, data=data, options=options) 198 | 199 | 200 | async def setup_remote_instance(hass: HomeAssistant.core.HomeAssistant): 201 | hass.http.register_view(DiscoveryInfoView()) 202 | 203 | 204 | async def async_setup(hass: HomeAssistant.core.HomeAssistant, config: ConfigType): 205 | """Set up the remote_homeassistant component.""" 206 | hass.data.setdefault(DOMAIN, {}) 207 | 208 | async def _handle_reload(service): 209 | """Handle reload service call.""" 210 | config = await async_integration_yaml_config(hass, DOMAIN) 211 | 212 | if not config or DOMAIN not in config: 213 | return 214 | 215 | current_entries = hass.config_entries.async_entries(DOMAIN) 216 | entries_by_id = {entry.unique_id: entry for entry in current_entries} 217 | 218 | instances = config[DOMAIN][CONF_INSTANCES] 219 | update_tasks = [ 220 | _async_update_config_entry_if_from_yaml(hass, entries_by_id, instance) 221 | for instance in instances 222 | ] 223 | 224 | await asyncio.gather(*update_tasks) 225 | 226 | hass.async_create_task(setup_remote_instance(hass)) 227 | 228 | async_register_admin_service(hass, 229 | DOMAIN, 230 | SERVICE_RELOAD, 231 | _handle_reload, 232 | ) 233 | 234 | instances = config.get(DOMAIN, {}).get(CONF_INSTANCES, []) 235 | for instance in instances: 236 | hass.async_create_task( 237 | hass.config_entries.flow.async_init( 238 | DOMAIN, context={"source": SOURCE_IMPORT}, data=instance 239 | ) 240 | ) 241 | 242 | return True 243 | 244 | 245 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 246 | """Set up Remote Home-Assistant from a config entry.""" 247 | _async_import_options_from_yaml(hass, entry) 248 | if entry.unique_id == REMOTE_ID: 249 | hass.async_create_task(setup_remote_instance(hass)) 250 | return True 251 | else: 252 | remote = RemoteConnection(hass, entry) 253 | 254 | hass.data[DOMAIN][entry.entry_id] = { 255 | CONF_REMOTE_CONNECTION: remote, 256 | CONF_UNSUB_LISTENER: entry.add_update_listener(_update_listener), 257 | } 258 | 259 | async def setup_components_and_platforms(): 260 | """Set up platforms and initiate connection.""" 261 | for domain in entry.options.get(CONF_LOAD_COMPONENTS, []): 262 | hass.async_create_task(async_setup_component(hass, domain, {})) 263 | 264 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 265 | await remote.async_connect() 266 | 267 | hass.async_create_task(setup_components_and_platforms()) 268 | 269 | return True 270 | 271 | 272 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 273 | """Unload a config entry.""" 274 | unload_ok = all( 275 | await asyncio.gather( 276 | *[ 277 | hass.config_entries.async_forward_entry_unload(entry, platform) 278 | for platform in PLATFORMS 279 | ] 280 | ) 281 | ) 282 | 283 | if unload_ok: 284 | data = hass.data[DOMAIN].pop(entry.entry_id) 285 | await data[CONF_REMOTE_CONNECTION].async_stop() 286 | data[CONF_UNSUB_LISTENER]() 287 | 288 | return unload_ok 289 | 290 | 291 | @callback 292 | def _async_import_options_from_yaml(hass: HomeAssistant, entry: ConfigEntry): 293 | """Import options from YAML into options section of config entry.""" 294 | if CONF_OPTIONS in entry.data: 295 | data = entry.data.copy() 296 | options = data.pop(CONF_OPTIONS) 297 | hass.config_entries.async_update_entry(entry, data=data, options=options) 298 | 299 | 300 | async def _update_listener(hass, config_entry): 301 | """Update listener.""" 302 | await hass.config_entries.async_reload(config_entry.entry_id) 303 | 304 | 305 | class RemoteConnection: 306 | """A Websocket connection to a remote home-assistant instance.""" 307 | 308 | def __init__(self, hass, config_entry): 309 | """Initialize the connection.""" 310 | self._hass = hass 311 | self._entry = config_entry 312 | self._secure = config_entry.data.get(CONF_SECURE, False) 313 | self._verify_ssl = config_entry.data.get(CONF_VERIFY_SSL, False) 314 | self._access_token = config_entry.data.get(CONF_ACCESS_TOKEN) 315 | self._max_msg_size = config_entry.data.get(CONF_MAX_MSG_SIZE, DEFAULT_MAX_MSG_SIZE) 316 | 317 | # see homeassistant/components/influxdb/__init__.py 318 | # for include/exclude logic 319 | self._whitelist_e = set(config_entry.options.get(CONF_INCLUDE_ENTITIES, [])) 320 | self._whitelist_d = set(config_entry.options.get(CONF_INCLUDE_DOMAINS, [])) 321 | self._blacklist_e = set(config_entry.options.get(CONF_EXCLUDE_ENTITIES, [])) 322 | self._blacklist_d = set(config_entry.options.get(CONF_EXCLUDE_DOMAINS, [])) 323 | 324 | self._filter = [ 325 | { 326 | CONF_ENTITY_ID: re.compile(fnmatch.translate(f.get(CONF_ENTITY_ID))) 327 | if f.get(CONF_ENTITY_ID) 328 | else None, 329 | CONF_UNIT_OF_MEASUREMENT: f.get(CONF_UNIT_OF_MEASUREMENT), 330 | CONF_ABOVE: f.get(CONF_ABOVE), 331 | CONF_BELOW: f.get(CONF_BELOW), 332 | } 333 | for f in config_entry.options.get(CONF_FILTER, []) 334 | ] 335 | 336 | self._subscribe_events = set( 337 | config_entry.options.get(CONF_SUBSCRIBE_EVENTS, []) + INTERNALLY_USED_EVENTS 338 | ) 339 | self._entity_prefix = config_entry.options.get( 340 | CONF_ENTITY_PREFIX, "") 341 | self._entity_friendly_name_prefix = config_entry.options.get( 342 | CONF_ENTITY_FRIENDLY_NAME_PREFIX, "") 343 | 344 | self._connection : Optional[ClientWebSocketResponse] = None 345 | self._heartbeat_task = None 346 | self._is_stopping = False 347 | self._entities = set() 348 | self._all_entity_names = set() 349 | self._handlers = {} 350 | self._remove_listener = None 351 | self.proxy_services = ProxyServices(hass, config_entry, self) 352 | 353 | self.set_connection_state(STATE_CONNECTING) 354 | 355 | self.__id = 1 356 | 357 | def _prefixed_entity_id(self, entity_id): 358 | if self._entity_prefix: 359 | domain, object_id = split_entity_id(entity_id) 360 | object_id = self._entity_prefix + object_id 361 | entity_id = domain + "." + object_id 362 | return entity_id 363 | return entity_id 364 | 365 | def _prefixed_entity_friendly_name(self, entity_friendly_name): 366 | if (self._entity_friendly_name_prefix 367 | and entity_friendly_name.startswith(self._entity_friendly_name_prefix) 368 | == False): 369 | entity_friendly_name = (self._entity_friendly_name_prefix + 370 | entity_friendly_name) 371 | return entity_friendly_name 372 | return entity_friendly_name 373 | 374 | def _full_picture_url(self, url): 375 | baseURL = "%s://%s:%s" % ( 376 | "https" if self._secure else "http", 377 | self._entry.data[CONF_HOST], 378 | self._entry.data[CONF_PORT], 379 | ) 380 | if url.startswith(baseURL) == False: 381 | url = baseURL + url 382 | return url 383 | return url 384 | 385 | def set_connection_state(self, state): 386 | """Change current connection state.""" 387 | signal = f"remote_homeassistant_{self._entry.unique_id}" 388 | async_dispatcher_send(self._hass, signal, state) 389 | 390 | @callback 391 | def _get_url(self): 392 | """Get url to connect to.""" 393 | return "%s://%s:%s/api/websocket" % ( 394 | "wss" if self._secure else "ws", 395 | self._entry.data[CONF_HOST], 396 | self._entry.data[CONF_PORT], 397 | ) 398 | 399 | async def async_connect(self): 400 | """Connect to remote home-assistant websocket...""" 401 | 402 | async def _async_stop_handler(event): 403 | """Stop when Home Assistant is shutting down.""" 404 | await self.async_stop() 405 | 406 | async def _async_instance_get_info(): 407 | """Fetch discovery info from remote instance.""" 408 | try: 409 | return await async_get_discovery_info( 410 | self._hass, 411 | self._entry.data[CONF_HOST], 412 | self._entry.data[CONF_PORT], 413 | self._secure, 414 | self._access_token, 415 | self._verify_ssl, 416 | ) 417 | except OSError: 418 | _LOGGER.exception("failed to connect") 419 | except UnsupportedVersion: 420 | _LOGGER.error("Unsupported version, at least 0.111 is required.") 421 | except Exception: 422 | _LOGGER.exception("failed to fetch instance info") 423 | return None 424 | 425 | @callback 426 | def _async_instance_id_match(info): 427 | """Verify if remote instance id matches the expected id.""" 428 | if not info: 429 | return False 430 | if info and info["uuid"] != self._entry.unique_id: 431 | _LOGGER.error( 432 | "instance id not matching: %s != %s", 433 | info["uuid"], 434 | self._entry.unique_id, 435 | ) 436 | return False 437 | return True 438 | 439 | url = self._get_url() 440 | 441 | session = async_get_clientsession(self._hass, self._verify_ssl) 442 | self.set_connection_state(STATE_CONNECTING) 443 | 444 | while True: 445 | info = await _async_instance_get_info() 446 | 447 | # Verify we are talking to correct instance 448 | if not _async_instance_id_match(info): 449 | self.set_connection_state(STATE_RECONNECTING) 450 | await asyncio.sleep(10) 451 | continue 452 | 453 | try: 454 | _LOGGER.info("Connecting to %s", url) 455 | self._connection = await session.ws_connect(url, max_msg_size = self._max_msg_size) 456 | except aiohttp.client_exceptions.ClientError: 457 | _LOGGER.error("Could not connect to %s, retry in 10 seconds...", url) 458 | self.set_connection_state(STATE_RECONNECTING) 459 | await asyncio.sleep(10) 460 | else: 461 | _LOGGER.info("Connected to home-assistant websocket at %s", url) 462 | break 463 | 464 | self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_handler) 465 | 466 | device_registry = dr.async_get(self._hass) 467 | device_registry.async_get_or_create( 468 | config_entry_id=self._entry.entry_id, 469 | identifiers={(DOMAIN, f"remote_{self._entry.unique_id}")}, 470 | name=info.get("location_name"), 471 | manufacturer="Home Assistant", 472 | model=info.get("installation_type"), 473 | sw_version=info.get("ha_version"), 474 | ) 475 | 476 | asyncio.ensure_future(self._recv()) 477 | self._heartbeat_task = self._hass.loop.create_task(self._heartbeat_loop()) 478 | 479 | async def _heartbeat_loop(self): 480 | """Send periodic heartbeats to remote instance.""" 481 | while self._connection is not None and not self._connection.closed: 482 | await asyncio.sleep(HEARTBEAT_INTERVAL) 483 | 484 | _LOGGER.debug("Sending ping") 485 | event = asyncio.Event() 486 | 487 | def resp(message): 488 | _LOGGER.debug("Got pong: %s", message) 489 | event.set() 490 | 491 | await self.call(resp, "ping") 492 | 493 | try: 494 | await asyncio.wait_for(event.wait(), HEARTBEAT_TIMEOUT) 495 | except asyncio.TimeoutError: 496 | _LOGGER.warning("heartbeat failed") 497 | 498 | # Schedule closing on event loop to avoid deadlock 499 | asyncio.ensure_future(self._connection.close()) 500 | break 501 | 502 | async def async_stop(self): 503 | """Close connection.""" 504 | self._is_stopping = True 505 | if self._connection is not None: 506 | await self._connection.close() 507 | await self.proxy_services.unload() 508 | 509 | def _next_id(self): 510 | _id = self.__id 511 | self.__id += 1 512 | return _id 513 | 514 | async def call(self, handler, message_type, **extra_args) -> None: 515 | if self._connection is None: 516 | _LOGGER.error("No remote websocket connection") 517 | return 518 | 519 | _id = self._next_id() 520 | self._handlers[_id] = handler 521 | try: 522 | await self._connection.send_json( 523 | {"id": _id, "type": message_type, **extra_args} 524 | ) 525 | except aiohttp.client_exceptions.ClientError as err: 526 | _LOGGER.error("remote websocket connection closed: %s", err) 527 | await self._disconnected() 528 | 529 | async def _disconnected(self): 530 | # Remove all published entries 531 | for entity in self._entities: 532 | self._hass.states.async_remove(entity) 533 | if self._heartbeat_task is not None: 534 | self._heartbeat_task.cancel() 535 | try: 536 | await self._heartbeat_task 537 | except asyncio.CancelledError: 538 | pass 539 | if self._remove_listener is not None: 540 | self._remove_listener() 541 | 542 | self.set_connection_state(STATE_DISCONNECTED) 543 | self._heartbeat_task = None 544 | self._remove_listener = None 545 | self._entities = set() 546 | self._all_entity_names = set() 547 | if not self._is_stopping: 548 | asyncio.ensure_future(self.async_connect()) 549 | 550 | async def _recv(self): 551 | while self._connection is not None and not self._connection.closed: 552 | try: 553 | data = await self._connection.receive() 554 | except aiohttp.client_exceptions.ClientError as err: 555 | _LOGGER.error("remote websocket connection closed: %s", err) 556 | break 557 | 558 | if not data: 559 | break 560 | 561 | if data.type in ( 562 | aiohttp.WSMsgType.CLOSE, 563 | aiohttp.WSMsgType.CLOSED, 564 | aiohttp.WSMsgType.CLOSING, 565 | ): 566 | _LOGGER.debug("websocket connection is closing") 567 | break 568 | 569 | if data.type == aiohttp.WSMsgType.ERROR: 570 | _LOGGER.error("websocket connection had an error") 571 | if data.data.code == aiohttp.WSCloseCode.MESSAGE_TOO_BIG: 572 | _LOGGER.error(f"please consider increasing message size with `{CONF_MAX_MSG_SIZE}`") 573 | break 574 | 575 | try: 576 | message = data.json() 577 | except TypeError as err: 578 | _LOGGER.error("could not decode data (%s) as json: %s", data, err) 579 | break 580 | 581 | if message is None: 582 | break 583 | 584 | _LOGGER.debug("received: %s", message) 585 | 586 | if message["type"] == api.TYPE_AUTH_OK: 587 | self.set_connection_state(STATE_CONNECTED) 588 | await self._init() 589 | 590 | elif message["type"] == api.TYPE_AUTH_REQUIRED: 591 | if self._access_token: 592 | json_data = {"type": api.TYPE_AUTH, "access_token": self._access_token} 593 | else: 594 | _LOGGER.error("Access token required, but not provided") 595 | self.set_connection_state(STATE_AUTH_REQUIRED) 596 | return 597 | try: 598 | await self._connection.send_json(json_data) 599 | except Exception as err: 600 | _LOGGER.error("could not send data to remote connection: %s", err) 601 | break 602 | 603 | elif message["type"] == api.TYPE_AUTH_INVALID: 604 | _LOGGER.error("Auth invalid, check your access token") 605 | self.set_connection_state(STATE_AUTH_INVALID) 606 | await self._connection.close() 607 | return 608 | 609 | else: 610 | handler = self._handlers.get(message["id"]) 611 | if handler is not None: 612 | if inspect.iscoroutinefunction(handler): 613 | await handler(message) 614 | else: 615 | handler(message) 616 | 617 | await self._disconnected() 618 | 619 | async def _init(self): 620 | async def forward_event(event): 621 | """Send local event to remote instance. 622 | 623 | The affected entity_id has to originate from that remote instance, 624 | otherwise the event is discarded. 625 | """ 626 | event_data = event.data 627 | service_data = event_data["service_data"] 628 | 629 | if not service_data: 630 | return 631 | 632 | entity_ids = service_data.get("entity_id", None) 633 | 634 | if not entity_ids: 635 | return 636 | 637 | if isinstance(entity_ids, str): 638 | entity_ids = (entity_ids.lower(),) 639 | 640 | entities = {entity_id.lower() for entity_id in self._entities} 641 | 642 | entity_ids = entities.intersection(entity_ids) 643 | 644 | if not entity_ids: 645 | return 646 | 647 | if self._entity_prefix: 648 | 649 | def _remove_prefix(entity_id): 650 | domain, object_id = split_entity_id(entity_id) 651 | object_id = object_id.replace(self._entity_prefix.lower(), "", 1) 652 | return domain + "." + object_id 653 | 654 | entity_ids = {_remove_prefix(entity_id) for entity_id in entity_ids} 655 | 656 | event_data = copy.deepcopy(event_data) 657 | event_data["service_data"]["entity_id"] = list(entity_ids) 658 | 659 | # Remove service_call_id parameter - websocket API 660 | # doesn't accept that one 661 | event_data.pop("service_call_id", None) 662 | 663 | _id = self._next_id() 664 | data = {"id": _id, "type": event.event_type, **event_data} 665 | 666 | _LOGGER.debug("forward event: %s", data) 667 | 668 | if self._connection is None: 669 | _LOGGER.error("There is no remote connecion to send send data to") 670 | return 671 | try: 672 | await self._connection.send_json(data) 673 | except Exception as err: 674 | _LOGGER.error("could not send data to remote connection: %s", err) 675 | await self._disconnected() 676 | 677 | def state_changed(entity_id, state, attr): 678 | """Publish remote state change on local instance.""" 679 | domain, _object_id = split_entity_id(entity_id) 680 | 681 | self._all_entity_names.add(entity_id) 682 | 683 | if entity_id in self._blacklist_e or domain in self._blacklist_d: 684 | return 685 | 686 | if ( 687 | (self._whitelist_e or self._whitelist_d) 688 | and entity_id not in self._whitelist_e 689 | and domain not in self._whitelist_d 690 | ): 691 | return 692 | 693 | for f in self._filter: 694 | if f[CONF_ENTITY_ID] and not f[CONF_ENTITY_ID].match(entity_id): 695 | continue 696 | if f[CONF_UNIT_OF_MEASUREMENT]: 697 | if CONF_UNIT_OF_MEASUREMENT not in attr: 698 | continue 699 | if f[CONF_UNIT_OF_MEASUREMENT] != attr[CONF_UNIT_OF_MEASUREMENT]: 700 | continue 701 | try: 702 | if f[CONF_BELOW] and float(state) < f[CONF_BELOW]: 703 | _LOGGER.info( 704 | "%s: ignoring state '%s', because below '%s'", 705 | entity_id, 706 | state, 707 | f[CONF_BELOW], 708 | ) 709 | return 710 | if f[CONF_ABOVE] and float(state) > f[CONF_ABOVE]: 711 | _LOGGER.info( 712 | "%s: ignoring state '%s', because above '%s'", 713 | entity_id, 714 | state, 715 | f[CONF_ABOVE], 716 | ) 717 | return 718 | except ValueError: 719 | pass 720 | 721 | entity_id = self._prefixed_entity_id(entity_id) 722 | 723 | # Add local unique id 724 | domain, object_id = split_entity_id(entity_id) 725 | attr['unique_id'] = f"{self._entry.unique_id[:16]}_{entity_id}" 726 | entity_registry = er.async_get(self._hass) 727 | entity_registry.async_get_or_create( 728 | domain=domain, 729 | platform='remote_homeassistant', 730 | unique_id=attr['unique_id'], 731 | suggested_object_id=object_id, 732 | ) 733 | 734 | # Add local customization data 735 | if DATA_CUSTOMIZE in self._hass.data: 736 | attr.update(self._hass.data[DATA_CUSTOMIZE].get(entity_id)) 737 | 738 | for attrId, value in attr.items(): 739 | if attrId == "friendly_name": 740 | attr[attrId] = self._prefixed_entity_friendly_name(value) 741 | if attrId == "entity_picture": 742 | attr[attrId] = self._full_picture_url(value) 743 | 744 | self._entities.add(entity_id) 745 | self._hass.states.async_set(entity_id, state, attr) 746 | 747 | def fire_event(message): 748 | """Publish remote event on local instance.""" 749 | if message["type"] == "result": 750 | return 751 | 752 | if message["type"] != "event": 753 | return 754 | 755 | if message["event"]["event_type"] == "state_changed": 756 | data = message["event"]["data"] 757 | entity_id = data["entity_id"] 758 | if not data["new_state"]: 759 | entity_id = self._prefixed_entity_id(entity_id) 760 | # entity was removed in the remote instance 761 | with suppress(ValueError, AttributeError, KeyError): 762 | self._entities.remove(entity_id) 763 | with suppress(ValueError, AttributeError, KeyError): 764 | self._all_entity_names.remove(entity_id) 765 | self._hass.states.async_remove(entity_id) 766 | return 767 | 768 | state = data["new_state"]["state"] 769 | attr = data["new_state"]["attributes"] 770 | state_changed(entity_id, state, attr) 771 | else: 772 | event = message["event"] 773 | self._hass.bus.async_fire( 774 | event_type=event["event_type"], 775 | event_data=event["data"], 776 | context=Context( 777 | id=event["context"].get("id"), 778 | user_id=event["context"].get("user_id"), 779 | parent_id=event["context"].get("parent_id"), 780 | ), 781 | origin=EventOrigin.remote, 782 | ) 783 | 784 | def got_states(message): 785 | """Called when list of remote states is available.""" 786 | for entity in message["result"]: 787 | entity_id = entity["entity_id"] 788 | state = entity["state"] 789 | attributes = entity["attributes"] 790 | for attr, value in attributes.items(): 791 | if attr == "friendly_name": 792 | attributes[attr] = self._prefixed_entity_friendly_name(value) 793 | if attr == "entity_picture": 794 | attributes[attr] = self._full_picture_url(value) 795 | 796 | state_changed(entity_id, state, attributes) 797 | 798 | self._remove_listener = self._hass.bus.async_listen( 799 | EVENT_CALL_SERVICE, forward_event 800 | ) 801 | 802 | for event in self._subscribe_events: 803 | await self.call(fire_event, "subscribe_events", event_type=event) 804 | 805 | await self.call(got_states, "get_states") 806 | 807 | await self.proxy_services.load() 808 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Remote Home-Assistant integration.""" 2 | from __future__ import annotations 3 | import logging 4 | import enum 5 | from typing import Any, Mapping 6 | 7 | from urllib.parse import urlparse 8 | 9 | import homeassistant.helpers.config_validation as cv 10 | import voluptuous as vol 11 | from homeassistant import config_entries, core 12 | from homeassistant.const import (CONF_ABOVE, CONF_ACCESS_TOKEN, CONF_BELOW, 13 | CONF_ENTITY_ID, CONF_HOST, CONF_PORT, 14 | CONF_UNIT_OF_MEASUREMENT, CONF_VERIFY_SSL, CONF_TYPE) 15 | from homeassistant.core import callback 16 | from homeassistant.helpers.instance_id import async_get 17 | from homeassistant.util import slugify 18 | 19 | from . import async_yaml_to_config_entry 20 | from .const import (CONF_ENTITY_PREFIX, # pylint:disable=unused-import 21 | CONF_ENTITY_FRIENDLY_NAME_PREFIX, 22 | CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES, CONF_FILTER, 23 | CONF_INCLUDE_DOMAINS, CONF_INCLUDE_ENTITIES, 24 | CONF_LOAD_COMPONENTS, CONF_MAIN, CONF_OPTIONS, CONF_REMOTE, CONF_REMOTE_CONNECTION, 25 | CONF_SECURE, CONF_SERVICE_PREFIX, CONF_SERVICES, CONF_MAX_MSG_SIZE, 26 | CONF_SUBSCRIBE_EVENTS, DOMAIN, REMOTE_ID, DEFAULT_MAX_MSG_SIZE) 27 | from .rest_api import (ApiProblem, CannotConnect, EndpointMissing, InvalidAuth, 28 | UnsupportedVersion, async_get_discovery_info) 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | ADD_NEW_EVENT = "add_new_event" 33 | 34 | FILTER_OPTIONS = [CONF_ENTITY_ID, CONF_UNIT_OF_MEASUREMENT, CONF_ABOVE, CONF_BELOW] 35 | 36 | 37 | def _filter_str(index, filter_conf: Mapping[str, str|float]): 38 | entity_id = filter_conf[CONF_ENTITY_ID] 39 | unit = filter_conf[CONF_UNIT_OF_MEASUREMENT] 40 | above = filter_conf[CONF_ABOVE] 41 | below = filter_conf[CONF_BELOW] 42 | return f"{index+1}. {entity_id}, unit: {unit}, above: {above}, below: {below}" 43 | 44 | 45 | async def validate_input(hass: core.HomeAssistant, conf): 46 | """Validate the user input allows us to connect.""" 47 | try: 48 | info = await async_get_discovery_info( 49 | hass, 50 | conf[CONF_HOST], 51 | conf[CONF_PORT], 52 | conf.get(CONF_SECURE, False), 53 | conf[CONF_ACCESS_TOKEN], 54 | conf.get(CONF_VERIFY_SSL, False), 55 | ) 56 | except OSError as exc: 57 | raise CannotConnect() from exc 58 | 59 | return {"title": info["location_name"], "uuid": info["uuid"]} 60 | 61 | 62 | class InstanceType(enum.Enum): 63 | """Possible options for instance type.""" 64 | 65 | remote = "Setup as remote node" 66 | main = "Add a remote" 67 | 68 | 69 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 70 | """Handle a config flow for Remote Home-Assistant.""" 71 | 72 | VERSION = 1 73 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH 74 | 75 | def __init__(self): 76 | """Initialize a new ConfigFlow.""" 77 | self.prefill = {CONF_PORT: 8123, CONF_SECURE: True, CONF_MAX_MSG_SIZE: DEFAULT_MAX_MSG_SIZE} 78 | 79 | @staticmethod 80 | @callback 81 | def async_get_options_flow(config_entry): 82 | """Get options flow for this handler.""" 83 | return OptionsFlowHandler(config_entry) 84 | 85 | async def async_step_user(self, user_input=None): 86 | """Handle the initial step.""" 87 | errors = {} 88 | 89 | if user_input is not None: 90 | if user_input[CONF_TYPE] == CONF_REMOTE: 91 | await self.async_set_unique_id(REMOTE_ID) 92 | self._abort_if_unique_id_configured() 93 | return self.async_create_entry(title="Remote instance", data=user_input) 94 | 95 | elif user_input[CONF_TYPE] == CONF_MAIN: 96 | return await self.async_step_connection_details() 97 | 98 | errors["base"] = "unknown" 99 | 100 | return self.async_show_form( 101 | step_id="user", 102 | data_schema=vol.Schema( 103 | { 104 | vol.Required(CONF_TYPE): vol.In([CONF_REMOTE, CONF_MAIN]) 105 | } 106 | ), 107 | errors=errors, 108 | ) 109 | 110 | 111 | async def async_step_connection_details(self, user_input=None): 112 | """Handle the connection details step.""" 113 | errors = {} 114 | if user_input is not None: 115 | try: 116 | info = await validate_input(self.hass, user_input) 117 | except ApiProblem: 118 | errors["base"] = "api_problem" 119 | except CannotConnect: 120 | errors["base"] = "cannot_connect" 121 | except InvalidAuth: 122 | errors["base"] = "invalid_auth" 123 | except UnsupportedVersion: 124 | errors["base"] = "unsupported_version" 125 | except EndpointMissing: 126 | errors["base"] = "missing_endpoint" 127 | except Exception: # pylint: disable=broad-except 128 | _LOGGER.exception("Unexpected exception") 129 | errors["base"] = "unknown" 130 | else: 131 | await self.async_set_unique_id(info["uuid"]) 132 | self._abort_if_unique_id_configured() 133 | return self.async_create_entry(title=info["title"], data=user_input) 134 | 135 | user_input = user_input or {} 136 | host = user_input.get(CONF_HOST, self.prefill.get(CONF_HOST) or vol.UNDEFINED) 137 | port = user_input.get(CONF_PORT, self.prefill.get(CONF_PORT) or vol.UNDEFINED) 138 | secure = user_input.get(CONF_SECURE, self.prefill.get(CONF_SECURE) or vol.UNDEFINED) 139 | max_msg_size = user_input.get(CONF_MAX_MSG_SIZE, self.prefill.get(CONF_MAX_MSG_SIZE) or vol.UNDEFINED) 140 | return self.async_show_form( 141 | step_id="connection_details", 142 | data_schema=vol.Schema( 143 | { 144 | vol.Required(CONF_HOST, default=host): str, 145 | vol.Required(CONF_PORT, default=port): int, 146 | vol.Required(CONF_ACCESS_TOKEN, default=user_input.get(CONF_ACCESS_TOKEN, vol.UNDEFINED)): str, 147 | vol.Required(CONF_MAX_MSG_SIZE, default=max_msg_size): int, 148 | vol.Optional(CONF_SECURE, default=secure): bool, 149 | vol.Optional(CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)): bool, 150 | } 151 | ), 152 | errors=errors, 153 | ) 154 | 155 | async def async_step_zeroconf(self, discovery_info): 156 | """Handle instance discovered via zeroconf.""" 157 | properties = discovery_info.properties 158 | port = discovery_info.port 159 | uuid = properties["uuid"] 160 | 161 | await self.async_set_unique_id(uuid) 162 | self._abort_if_unique_id_configured() 163 | 164 | if await async_get(self.hass) == uuid: 165 | return self.async_abort(reason="already_configured") 166 | 167 | url = properties.get("internal_url") 168 | if not url: 169 | url = properties.get("base_url") 170 | url = urlparse(url) 171 | 172 | self.prefill = { 173 | CONF_HOST: url.hostname, 174 | CONF_PORT: port, 175 | CONF_SECURE: url.scheme == "https", 176 | } 177 | 178 | # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 179 | self.context["identifier"] = self.unique_id 180 | self.context["title_placeholders"] = {"name": properties["location_name"]} 181 | return await self.async_step_connection_details() 182 | 183 | async def async_step_import(self, user_input): 184 | """Handle import from YAML.""" 185 | try: 186 | info = await validate_input(self.hass, user_input) 187 | except Exception: 188 | _LOGGER.exception(f"import of {user_input[CONF_HOST]} failed") 189 | return self.async_abort(reason="import_failed") 190 | 191 | conf, options = async_yaml_to_config_entry(user_input) 192 | 193 | # Options cannot be set here, so store them in a special key and import them 194 | # before setting up an entry 195 | conf[CONF_OPTIONS] = options 196 | 197 | await self.async_set_unique_id(info["uuid"]) 198 | self._abort_if_unique_id_configured(updates=conf) 199 | 200 | return self.async_create_entry(title=f"{info['title']} (YAML)", data=conf) 201 | 202 | 203 | class OptionsFlowHandler(config_entries.OptionsFlow): 204 | """Handle options flow for the Home Assistant remote integration.""" 205 | 206 | def __init__(self, config_entry): 207 | """Initialize remote_homeassistant options flow.""" 208 | self.config_entry = config_entry 209 | self.filters : list[Any] | None = None 210 | self.events : set[Any] | None = None 211 | self.options : dict[str, Any] | None = None 212 | 213 | async def async_step_init(self, user_input : dict[str, str] | None = None): 214 | """Manage basic options.""" 215 | if self.config_entry.unique_id == REMOTE_ID: 216 | return self.async_abort(reason="not_supported") 217 | 218 | if user_input is not None: 219 | self.options = user_input.copy() 220 | return await self.async_step_domain_entity_filters() 221 | 222 | domains, _ = self._domains_and_entities() 223 | domains = set(domains + self.config_entry.options.get(CONF_LOAD_COMPONENTS, [])) 224 | 225 | remote = self.hass.data[DOMAIN][self.config_entry.entry_id][ 226 | CONF_REMOTE_CONNECTION 227 | ] 228 | 229 | return self.async_show_form( 230 | step_id="init", 231 | data_schema=vol.Schema( 232 | { 233 | vol.Optional( 234 | CONF_ENTITY_PREFIX, 235 | description={ 236 | "suggested_value": self.config_entry.options.get( 237 | CONF_ENTITY_PREFIX 238 | ) 239 | }, 240 | ): str, 241 | vol.Optional( 242 | CONF_ENTITY_FRIENDLY_NAME_PREFIX, 243 | description={ 244 | "suggested_value": self.config_entry.options.get( 245 | CONF_ENTITY_FRIENDLY_NAME_PREFIX 246 | ) 247 | }, 248 | ): str, 249 | vol.Optional( 250 | CONF_LOAD_COMPONENTS, 251 | default=self._default(CONF_LOAD_COMPONENTS), 252 | ): cv.multi_select(sorted(domains)), 253 | vol.Required( 254 | CONF_SERVICE_PREFIX, default=self.config_entry.options.get(CONF_SERVICE_PREFIX) or slugify(self.config_entry.title) 255 | ): str, 256 | vol.Optional( 257 | CONF_SERVICES, 258 | default=self._default(CONF_SERVICES), 259 | ): cv.multi_select(remote.proxy_services.services), 260 | } 261 | ), 262 | ) 263 | 264 | async def async_step_domain_entity_filters(self, user_input=None): 265 | """Manage domain and entity filters.""" 266 | if self.options is not None and user_input is not None: 267 | self.options.update(user_input) 268 | return await self.async_step_general_filters() 269 | 270 | domains, entities = self._domains_and_entities() 271 | return self.async_show_form( 272 | step_id="domain_entity_filters", 273 | data_schema=vol.Schema( 274 | { 275 | vol.Optional( 276 | CONF_INCLUDE_DOMAINS, 277 | default=self._default(CONF_INCLUDE_DOMAINS), 278 | ): cv.multi_select(domains), 279 | vol.Optional( 280 | CONF_INCLUDE_ENTITIES, 281 | default=self._default(CONF_INCLUDE_ENTITIES), 282 | ): cv.multi_select(entities), 283 | vol.Optional( 284 | CONF_EXCLUDE_DOMAINS, 285 | default=self._default(CONF_EXCLUDE_DOMAINS), 286 | ): cv.multi_select(domains), 287 | vol.Optional( 288 | CONF_EXCLUDE_ENTITIES, 289 | default=self._default(CONF_EXCLUDE_ENTITIES), 290 | ): cv.multi_select(entities), 291 | } 292 | ), 293 | ) 294 | 295 | async def async_step_general_filters(self, user_input=None): 296 | """Manage domain and entity filters.""" 297 | if user_input is not None: 298 | # Continue to next step if entity id is not specified 299 | if CONF_ENTITY_ID not in user_input: 300 | # Each filter string is prefixed with a number (index in self.filter+1). 301 | # Extract all of them and build the final filter list. 302 | selected_indices = [ 303 | int(filterItem.split(".")[0]) - 1 304 | for filterItem in user_input.get(CONF_FILTER, []) 305 | ] 306 | if self.options is not None: 307 | self.options[CONF_FILTER] = [self.filters[i] for i in selected_indices] # type: ignore 308 | return await self.async_step_events() 309 | 310 | selected = user_input.get(CONF_FILTER, []) 311 | new_filter = {conf: user_input.get(conf) for conf in FILTER_OPTIONS} 312 | 313 | selected.append(_filter_str(len(self.filters), new_filter)) # type: ignore 314 | self.filters.append(new_filter) # type: ignore 315 | else: 316 | self.filters = self.config_entry.options.get(CONF_FILTER, []) 317 | selected = [_filter_str(i, filterItem) for i, filterItem in enumerate(self.filters)] # type: ignore 318 | 319 | if self.filters is None: 320 | self.filters = [] 321 | strings = [_filter_str(i, filterItem) for i, filterItem in enumerate(self.filters)] 322 | return self.async_show_form( 323 | step_id="general_filters", 324 | data_schema=vol.Schema( 325 | { 326 | vol.Optional(CONF_FILTER, default=selected): cv.multi_select( 327 | strings 328 | ), 329 | vol.Optional(CONF_ENTITY_ID): str, 330 | vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, 331 | vol.Optional(CONF_ABOVE): vol.Coerce(float), 332 | vol.Optional(CONF_BELOW): vol.Coerce(float), 333 | } 334 | ), 335 | ) 336 | 337 | async def async_step_events(self, user_input=None): 338 | """Manage event options.""" 339 | if user_input is not None: 340 | if ADD_NEW_EVENT not in user_input and self.options is not None: 341 | self.options[CONF_SUBSCRIBE_EVENTS] = user_input.get( 342 | CONF_SUBSCRIBE_EVENTS, [] 343 | ) 344 | return self.async_create_entry(title="", data=self.options) 345 | 346 | selected = user_input.get(CONF_SUBSCRIBE_EVENTS, []) 347 | if self.events is None: 348 | self.events = set() 349 | self.events.add(user_input[ADD_NEW_EVENT]) 350 | selected.append(user_input[ADD_NEW_EVENT]) 351 | else: 352 | self.events = set( 353 | self.config_entry.options.get(CONF_SUBSCRIBE_EVENTS) or [] 354 | ) 355 | selected = self._default(CONF_SUBSCRIBE_EVENTS) 356 | 357 | return self.async_show_form( 358 | step_id="events", 359 | data_schema=vol.Schema( 360 | { 361 | vol.Optional( 362 | CONF_SUBSCRIBE_EVENTS, default=selected 363 | ): cv.multi_select(self.events), 364 | vol.Optional(ADD_NEW_EVENT): str, 365 | } 366 | ), 367 | ) 368 | 369 | def _default(self, conf): 370 | """Return default value for an option.""" 371 | return self.config_entry.options.get(conf) or vol.UNDEFINED 372 | 373 | def _domains_and_entities(self): 374 | """Return all entities and domains exposed by remote instance.""" 375 | remote = self.hass.data[DOMAIN][self.config_entry.entry_id][ 376 | CONF_REMOTE_CONNECTION 377 | ] 378 | 379 | # Include entities we have in the config explicitly, otherwise they will be 380 | # pre-selected and not possible to remove if they are no lobger present on 381 | # the remote host. 382 | include_entities = set(self.config_entry.options.get(CONF_INCLUDE_ENTITIES, [])) 383 | exclude_entities = set(self.config_entry.options.get(CONF_EXCLUDE_ENTITIES, [])) 384 | entities = sorted( 385 | remote._all_entity_names | include_entities | exclude_entities 386 | ) 387 | domains = sorted(set([entity_id.split(".")[0] for entity_id in entities])) 388 | return domains, entities 389 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/const.py: -------------------------------------------------------------------------------- 1 | """Constants used by integration.""" 2 | 3 | CONF_REMOTE_CONNECTION = "remote_connection" 4 | CONF_UNSUB_LISTENER = "unsub_listener" 5 | CONF_OPTIONS = "options" 6 | CONF_REMOTE_INFO = "remote_info" 7 | CONF_LOAD_COMPONENTS = "load_components" 8 | CONF_SERVICE_PREFIX = "service_prefix" 9 | CONF_SERVICES = "services" 10 | 11 | CONF_FILTER = "filter" 12 | CONF_SECURE = "secure" 13 | CONF_API_PASSWORD = "api_password" 14 | CONF_SUBSCRIBE_EVENTS = "subscribe_events" 15 | CONF_ENTITY_PREFIX = "entity_prefix" 16 | CONF_ENTITY_FRIENDLY_NAME_PREFIX = "entity_friendly_name_prefix" 17 | CONF_MAX_MSG_SIZE = "max_message_size" 18 | 19 | CONF_INCLUDE_DOMAINS = "include_domains" 20 | CONF_INCLUDE_ENTITIES = "include_entities" 21 | CONF_EXCLUDE_DOMAINS = "exclude_domains" 22 | CONF_EXCLUDE_ENTITIES = "exclude_entities" 23 | 24 | # FIXME: There seems to be no way to make these strings translateable 25 | CONF_MAIN = "Add a remote node" 26 | CONF_REMOTE = "Setup as remote node" 27 | 28 | DOMAIN = "remote_homeassistant" 29 | 30 | REMOTE_ID = "remote" 31 | 32 | # replaces 'from homeassistant.core import SERVICE_CALL_LIMIT' 33 | SERVICE_CALL_LIMIT = 10 34 | 35 | DEFAULT_MAX_MSG_SIZE = 16*1024*1024 36 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "remote_homeassistant", 3 | "name": "Remote Home-Assistant", 4 | "codeowners": [ 5 | "@jaym25", 6 | "@lukas-hetzenecker", 7 | "@postlund" 8 | ], 9 | "config_flow": true, 10 | "dependencies": ["http"], 11 | "documentation": "https://github.com/custom-components/remote_homeassistant", 12 | "iot_class": "local_push", 13 | "issue_tracker": "https://github.com/custom-components/remote_homeassistant/issues", 14 | "requirements": [], 15 | "version": "4.5", 16 | "zeroconf": [ 17 | "_home-assistant._tcp.local." 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/proxy_services.py: -------------------------------------------------------------------------------- 1 | """Support for proxy services.""" 2 | from __future__ import annotations 3 | import asyncio 4 | from typing import Any 5 | 6 | import voluptuous as vol 7 | from homeassistant.exceptions import HomeAssistantError 8 | from homeassistant.helpers.service import SERVICE_DESCRIPTION_CACHE 9 | 10 | from .const import CONF_SERVICE_PREFIX, CONF_SERVICES, SERVICE_CALL_LIMIT 11 | 12 | 13 | class ProxyServices: 14 | """Manages remote proxy services.""" 15 | 16 | def __init__(self, hass, entry, remote): 17 | """Initialize a new ProxyServices instance.""" 18 | self.hass = hass 19 | self.entry = entry 20 | self.remote = remote 21 | self.remote_services = {} 22 | self.registered_services = [] 23 | 24 | @property 25 | def services(self): 26 | """Return list of service names.""" 27 | result = [] 28 | for domain, services in self.remote_services.items(): 29 | for service in services.keys(): 30 | result.append(f"{domain}.{service}") 31 | return sorted(result) 32 | 33 | async def load(self): 34 | """Call to make initial registration of services.""" 35 | await self.remote.call(self._async_got_services, "get_services") 36 | 37 | async def unload(self): 38 | """Call to unregister all registered services.""" 39 | description_cache = self.hass.data[SERVICE_DESCRIPTION_CACHE] 40 | 41 | for domain, service_name in self.registered_services: 42 | self.hass.services.async_remove(domain, service_name) 43 | 44 | # Remove from internal description cache 45 | service = f"{domain}.{service_name}" 46 | if service in description_cache: 47 | del description_cache[service] 48 | 49 | async def _async_got_services(self, message): 50 | """Called when list of remote services is available.""" 51 | self.remote_services = message["result"] 52 | 53 | # A service prefix is needed to not clash with original service names 54 | service_prefix = self.entry.options.get(CONF_SERVICE_PREFIX) 55 | if not service_prefix: 56 | return 57 | 58 | description_cache = self.hass.data[SERVICE_DESCRIPTION_CACHE] 59 | for service in self.entry.options.get(CONF_SERVICES, []): 60 | domain, service_name = service.split(".") 61 | service = service_prefix + service_name 62 | 63 | # Register new service with same name as original service but with prefix 64 | self.hass.services.async_register( 65 | domain, 66 | service, 67 | self._async_handle_service_call, 68 | vol.Schema({}, extra=vol.ALLOW_EXTRA), 69 | ) 70 | 71 | # 72 | # Service metadata can only be provided via a services.yaml file for a 73 | # particular component, something not possible here. A cache is used 74 | # internally for loaded service descriptions and that's abused here. If 75 | # the internal representation of the cache change, this sill break. 76 | # 77 | service_info = self.remote_services.get(domain, {}).get(service_name) 78 | if service_info: 79 | description_cache[f"{domain}.{service}"] = service_info 80 | 81 | self.registered_services.append((domain, service)) 82 | 83 | async def _async_handle_service_call(self, event) -> None: 84 | """Handle service call to proxy service.""" 85 | # An exception must be raised from the service call handler (thus method) in 86 | # order to end up in the frontend. The code below synchronizes reception of 87 | # the service call result, so potential error message can be used as exception 88 | # message. Not very pretty... 89 | ev = asyncio.Event() 90 | res : dict[str,Any] | None = None 91 | 92 | def _resp(message): 93 | nonlocal res 94 | res = message 95 | ev.set() 96 | 97 | service_prefix = self.entry.options.get(CONF_SERVICE_PREFIX) 98 | service = event.service[len(service_prefix) :] 99 | await self.remote.call( 100 | _resp, 101 | "call_service", 102 | domain=event.domain, 103 | service=service, 104 | service_data=event.data.copy(), 105 | ) 106 | 107 | await asyncio.wait_for(ev.wait(), SERVICE_CALL_LIMIT) 108 | if isinstance(res, dict) and not res["success"]: 109 | raise HomeAssistantError(res["error"]["message"]) 110 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/rest_api.py: -------------------------------------------------------------------------------- 1 | """Simple implementation to call Home Assistant REST API.""" 2 | 3 | from homeassistant import exceptions 4 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 5 | 6 | API_URL = "{proto}://{host}:{port}/api/remote_homeassistant/discovery" 7 | 8 | 9 | class ApiProblem(exceptions.HomeAssistantError): 10 | """Error to indicate problem reaching API.""" 11 | 12 | 13 | class CannotConnect(exceptions.HomeAssistantError): 14 | """Error to indicate we cannot connect.""" 15 | 16 | 17 | class InvalidAuth(exceptions.HomeAssistantError): 18 | """Error to indicate there is invalid auth.""" 19 | 20 | 21 | class BadResponse(exceptions.HomeAssistantError): 22 | """Error to indicate a bad response was received.""" 23 | 24 | 25 | class UnsupportedVersion(exceptions.HomeAssistantError): 26 | """Error to indicate an unsupported version of Home Assistant.""" 27 | 28 | 29 | class EndpointMissing(exceptions.HomeAssistantError): 30 | """Error to indicate there is invalid auth.""" 31 | 32 | 33 | async def async_get_discovery_info(hass, host, port, secure, access_token, verify_ssl): 34 | """Get discovery information from server.""" 35 | url = API_URL.format( 36 | proto="https" if secure else "http", 37 | host=host, 38 | port=port, 39 | ) 40 | headers = { 41 | "Authorization": "Bearer " + access_token, 42 | "Content-Type": "application/json", 43 | } 44 | session = async_get_clientsession(hass, verify_ssl) 45 | 46 | # Fetch discovery info location for name and unique UUID 47 | async with session.get(url, headers=headers) as resp: 48 | if resp.status == 404: 49 | raise EndpointMissing() 50 | if 400 <= resp.status < 500: 51 | raise InvalidAuth() 52 | if resp.status != 200: 53 | raise ApiProblem() 54 | json = await resp.json() 55 | if not isinstance(json, dict): 56 | raise BadResponse(f"Bad response data: {json}") 57 | if "uuid" not in json: 58 | raise UnsupportedVersion() 59 | return json 60 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for connection status..""" 2 | from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL 3 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 4 | from homeassistant.helpers.entity import DeviceInfo, Entity 5 | 6 | from .const import (DOMAIN, CONF_ENTITY_PREFIX, 7 | CONF_ENTITY_FRIENDLY_NAME_PREFIX, 8 | CONF_SECURE, CONF_MAX_MSG_SIZE, 9 | DEFAULT_MAX_MSG_SIZE) 10 | 11 | async def async_setup_entry(hass, config_entry, async_add_entities): 12 | """Set up sensor based ok config entry.""" 13 | async_add_entities([ConnectionStatusSensor(config_entry)]) 14 | 15 | 16 | class ConnectionStatusSensor(Entity): 17 | """Representation of a remote_homeassistant sensor.""" 18 | 19 | def __init__(self, config_entry): 20 | """Initialize the remote_homeassistant sensor.""" 21 | self._state = None 22 | self._entry = config_entry 23 | 24 | proto = 'http' if config_entry.data.get(CONF_SECURE) else 'https' 25 | host = config_entry.data[CONF_HOST] 26 | port = config_entry.data[CONF_PORT] 27 | self._attr_name = f"Remote connection to {host}:{port}" 28 | self._attr_unique_id = config_entry.unique_id 29 | self._attr_should_poll = False 30 | self._attr_device_info = DeviceInfo( 31 | name="Home Assistant", 32 | configuration_url=f"{proto}://{host}:{port}", 33 | identifiers={(DOMAIN, f"remote_{self._attr_unique_id}")}, 34 | ) 35 | 36 | @property 37 | def state(self): 38 | """Return sensor state.""" 39 | return self._state 40 | 41 | @property 42 | def extra_state_attributes(self): 43 | """Return device state attributes.""" 44 | return { 45 | "host": self._entry.data[CONF_HOST], 46 | "port": self._entry.data[CONF_PORT], 47 | "secure": self._entry.data.get(CONF_SECURE, False), 48 | "verify_ssl": self._entry.data.get(CONF_VERIFY_SSL, False), 49 | "max_msg_size": self._entry.data.get(CONF_MAX_MSG_SIZE, DEFAULT_MAX_MSG_SIZE), 50 | "entity_prefix": self._entry.options.get(CONF_ENTITY_PREFIX, ""), 51 | "entity_friendly_name_prefix": self._entry.options.get(CONF_ENTITY_FRIENDLY_NAME_PREFIX, ""), 52 | "uuid": self.unique_id, 53 | } 54 | 55 | async def async_added_to_hass(self): 56 | """Subscribe to events.""" 57 | await super().async_added_to_hass() 58 | 59 | def _update_handler(state): 60 | """Update entity state when status was updated.""" 61 | self._state = state 62 | self.schedule_update_ha_state() 63 | 64 | signal = f"remote_homeassistant_{self._entry.unique_id}" 65 | self.async_on_remove( 66 | async_dispatcher_connect(self.hass, signal, _update_handler) 67 | ) 68 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | name: Reload Remote Home-Assistant 3 | description: Reload remote_homeassistant and re-process yaml configuration. 4 | -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "Remote: {name}", 4 | "step": { 5 | "user": { 6 | "title": "Installationstyp wählen", 7 | "description": "Der Remote Node ist die Instanz, von der die Daten gesammelt werden" 8 | }, 9 | "connection_details": { 10 | "title": "Verbindungsdetails", 11 | "data": { 12 | "host": "Host", 13 | "port": "Port", 14 | "secure": "Sicher", 15 | "verify_ssl": "SSL verifizieren", 16 | "access_token": "Verbindungstoken", 17 | "max_message_size": "Maximale Nachrichtengröße" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "api_problem": "Unbekannte Antwort vom Server", 23 | "cannot_connect": "Verbindung zum Server fehlgeschlagen", 24 | "invalid_auth": "Ungültige Anmeldeinformationen", 25 | "unsupported_version": "Version nicht unterstützt. Mindestens Version 0.111 benötigt.", 26 | "unknown": "Ein unbekannter Fehler trat auf", 27 | "missing_endpoint": "Sie müssen Remote Home Assistant auf diesem Host installieren und remote_homeassistant: zu seiner Konfiguration hinzufügen." 28 | }, 29 | "abort": { 30 | "already_configured": "Bereits konfiguriert" 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "init": { 36 | "title": "Basis-Einstellungen (Schritt 1/4)", 37 | "data": { 38 | "entity_prefix": "Entitätspräfix (optional)", 39 | "entity_friendly_name_prefix": "Entitätsname präfix (optional)", 40 | "load_components": "Komponente laden (wenn nicht geladen)", 41 | "service_prefix": "Servicepräfix", 42 | "services": "Remote Services" 43 | } 44 | }, 45 | "domain_entity_filters": { 46 | "title": "Domain- und Entitätsfilter (Schritt 2/4)", 47 | "data": { 48 | "include_domains": "Domains einbeziehen", 49 | "include_entities": "Entitäten einbeziehen", 50 | "exclude_domains": "Domains ausschließen", 51 | "exclude_entities": "Entitäten ausschließen" 52 | } 53 | }, 54 | "general_filters": { 55 | "title": "Filter (Schritt 3/4)", 56 | "description": "Fügen Sie einen neuen Filter hinzu, indem Sie die „Entitäts-ID“, ein oder mehrere Filterattribute angeben und auf „Absenden“ klicken. Entfernen Sie vorhandene Filter, indem Sie sie unter „Filter“ deaktivieren.\n\nLassen Sie „Entitäts-ID“ leer und klicken Sie auf „Absenden“, um keine weiteren Änderungen vorzunehmen.", 57 | "data": { 58 | "filter": "Filter", 59 | "entity_id": "Entitäts-ID", 60 | "unit_of_measurement": "Maßeinheit", 61 | "above": "Über", 62 | "below": "Unter" 63 | } 64 | }, 65 | "events": { 66 | "title": "Abonnierte Events (Schritt 4/4)", 67 | "description": "Fügen Sie neue abonnierte Events hinzu, indem Sie ihren Namen in „Neue Events hinzufügen“ eingeben und auf „Absenden“ klicken. Deaktivieren Sie vorhandene Events, indem Sie sie unter „Events“ entfernen.\n\nLassen Sie „Neue Events hinzufügen“ leer und klicken Sie auf „Absenden“, um keine weiteren Änderungen vorzunehmen.", 68 | "data": { 69 | "subscribe_events": "Events", 70 | "add_new_event": "Neue Events hinzufügen" 71 | } 72 | } 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "Remote: {name}", 4 | "step": { 5 | "user": { 6 | "title": "Select installation type", 7 | "description": "The remote node is the instance on which the states are gathered from" 8 | }, 9 | "connection_details": { 10 | "title": "Connection details", 11 | "data": { 12 | "host": "Host", 13 | "port": "Port", 14 | "secure": "Secure", 15 | "verify_ssl": "Verify SSL", 16 | "access_token": "Access token", 17 | "max_message_size": "Maximum Message Size" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "api_problem": "Bad response from server", 23 | "cannot_connect": "Failed to connect to server", 24 | "invalid_auth": "Invalid credentials", 25 | "unsupported_version": "Unsupported version. At least version 0.111 is required.", 26 | "unknown": "An unknown error occurred", 27 | "missing_endpoint": "You need to install Remote Home Assistant on this host and add remote_homeassistant: to its configuration." 28 | }, 29 | "abort": { 30 | "already_configured": "Already configured" 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "init": { 36 | "title": "Basic Options (step 1/4)", 37 | "data": { 38 | "entity_prefix": "Entity prefix (optional)", 39 | "entity_friendly_name_prefix": "Entity name prefix (optional)", 40 | "load_components": "Load component (if not loaded)", 41 | "service_prefix": "Service prefix", 42 | "services": "Remote Services" 43 | } 44 | }, 45 | "domain_entity_filters": { 46 | "title": "Domain and entity filters (step 2/4)", 47 | "data": { 48 | "include_domains": "Include domains", 49 | "include_entities": "Include entities", 50 | "exclude_domains": "Exclude domains", 51 | "exclude_entities": "Exclude entities" 52 | } 53 | }, 54 | "general_filters": { 55 | "title": "Filters (step 3/4)", 56 | "description": "Add a new filter by specifying `Entity ID`, one or more filter attributes and press `Submit`. Remove existing filters by unticking them in `Filters`.\n\nLeave `Entity ID` empty and press `Submit` to make no further changes.", 57 | "data": { 58 | "filter": "Filters", 59 | "entity_id": "Entity ID", 60 | "unit_of_measurement": "Unit of measurement", 61 | "above": "Above", 62 | "below": "Below" 63 | } 64 | }, 65 | "events": { 66 | "title": "Subscribed events (step 4/4)", 67 | "description": "Add a new subscribed event by entering its name in `Add new event` and press `Submit`. Remove existing events by unticking them in `Events`.\n\nLeave `Add new event` and press `Submit` to make no further changes.", 68 | "data": { 69 | "subscribe_events": "Events", 70 | "add_new_event": "Add new event" 71 | } 72 | } 73 | }, 74 | "abort": { 75 | "not_supported": "No configuration options supported for a remote node" 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "Remote: {name}", 4 | "step": { 5 | "user": { 6 | "title": "Selecione o tipo de instalação", 7 | "description": "O nó remoto é a instância na qual os estados são coletados de" 8 | }, 9 | "connection_details": { 10 | "title": "Detalhes da conexão", 11 | "data": { 12 | "host": "Host", 13 | "port": "Porta", 14 | "secure": "Protegido", 15 | "verify_ssl": "Verificar SSL", 16 | "access_token": "Token de acesso", 17 | "max_message_size": "Tamanho máximo da mensagem" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "api_problem": "Resposta ruim do servidor", 23 | "cannot_connect": "Falha ao conectar ao servidor", 24 | "invalid_auth": "Credenciais inválidas", 25 | "unsupported_version": "Versão não suportada. Pelo menos a versão 0.111 é necessária.", 26 | "unknown": "Ocorreu um erro desconhecido", 27 | "missing_endpoint": "Você precisa instalar o Remote Home Assistant neste host e adicionar remote_homeassistant: à sua configuração." 28 | }, 29 | "abort": { 30 | "already_configured": "Já configurado" 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "init": { 36 | "title": "Opções básicas (passo 1/4)", 37 | "data": { 38 | "entity_prefix": "Prefixo da entidade (opcional)", 39 | "entity_friendly_name_prefix": "Prefixo da entidade nombre (opcional)", 40 | "load_components": "Carregar componente (se não estiver carregado)", 41 | "service_prefix": "Prefixo do serviço", 42 | "services": "Serviços remotos" 43 | } 44 | }, 45 | "domain_entity_filters": { 46 | "title": "Filtros de domínio e entidade (etapa 2/4)", 47 | "data": { 48 | "include_domains": "Incluir domínios", 49 | "include_entities": "Incluir entidades", 50 | "exclude_domains": "Excluir domínios", 51 | "exclude_entities": "Excluir entidades" 52 | } 53 | }, 54 | "general_filters": { 55 | "title": "Filtros (etapa 3/4)", 56 | "description": "Adicione um novo filtro especificando `ID da entidade`, um ou mais atributos de filtro e pressione `Enviar`. Remova os filtros existentes desmarcando-os em `Filtros`.\n\nDeixe `ID da entidade` vazio e pressione `Enviar` para não fazer mais alterações.", 57 | "data": { 58 | "filter": "Filtros", 59 | "entity_id": "ID da entidade", 60 | "unit_of_measurement": "Unidade de medida", 61 | "above": "Acima de", 62 | "below": "Abaixo de" 63 | } 64 | }, 65 | "events": { 66 | "title": "Eventos inscritos (passo 4/4)", 67 | "description": "Adicione um novo evento inscrito digitando seu nome em `Adicionar novo evento` e pressione `Enviar`. Remova os eventos existentes desmarcando-os em `Eventos`.\n\nDeixe `Adicionar novo evento` e pressione `Enviar` para não fazer mais alterações.", 68 | "data": { 69 | "subscribe_events": "Eventos", 70 | "add_new_event": "Adicionar novo evento" 71 | } 72 | } 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/translations/sensor.de.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "remote_homeassistant___": { 4 | "disconnected": "Getrennt", 5 | "connecting": "Verbindet", 6 | "connected": "Verbunden", 7 | "reconnecting": "Wiederverbinden", 8 | "auth_invalid": "Ungültiger Zugangstoken", 9 | "auth_required": "Authentifizierung erforderlich" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/translations/sensor.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "remote_homeassistant___": { 4 | "disconnected": "Disconnected", 5 | "connecting": "Connecting", 6 | "connected": "Connected", 7 | "reconnecting": "Re-connecting", 8 | "auth_invalid": "Invalid access token", 9 | "auth_required": "Authentication Required" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/translations/sensor.pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "remote_homeassistant___": { 4 | "disconnected": "Desconectado", 5 | "connecting": "Conectando", 6 | "connected": "Conectado", 7 | "reconnecting": "Reconectando", 8 | "auth_invalid": "Token de acesso inválido", 9 | "auth_required": "Autentificação requerida" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/translations/sensor.sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "remote_homeassistant___": { 4 | "disconnected": "Odpojené", 5 | "connecting": "Pripája sa", 6 | "connected": "Pripojené", 7 | "reconnecting": "Opätovné pripojenie", 8 | "auth_invalid": "Neplatný prístupový token", 9 | "auth_required": "Vyžaduje sa overenie" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "Diaľkové ovládanie: {name}", 4 | "step": { 5 | "user": { 6 | "title": "Vyberte typ inštalácie", 7 | "description": "Vzdialený uzol je inštancia, z ktorej sa zhromažďujú stavy" 8 | }, 9 | "connection_details": { 10 | "title": "Podrobnosti pripojenia", 11 | "data": { 12 | "host": "Host", 13 | "port": "Port", 14 | "secure": "Zabezpečiť", 15 | "verify_ssl": "Overiť SSL", 16 | "access_token": "Prístupový token", 17 | "max_message_size": "Maximálna veľkosť správy" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "api_problem": "Zlá odpoveď zo servera", 23 | "cannot_connect": "Nepodarilo sa pripojiť k serveru", 24 | "invalid_auth": "Neplatné poverenia", 25 | "unsupported_version": "Nepodporovaná verzia. Vyžaduje sa aspoň verzia 0.111.", 26 | "unknown": "Vyskytla sa neznáma chyba", 27 | "missing_endpoint": "Na tohto hostiteľa si musíte nainštalovať Remote Home Assistant a do jeho konfigurácie pridať remote_homeassistant:." 28 | }, 29 | "abort": { 30 | "already_configured": "Už je nakonfigurovaný" 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "init": { 36 | "title": "Základné možnosti (krok 1/4)", 37 | "data": { 38 | "entity_prefix": "Predpona entity (voliteľné)", 39 | "entity_friendly_name_prefix": "Predpona entity name (voliteľné)", 40 | "load_components": "Načítať komponent (ak nie je načítaný)", 41 | "service_prefix": "Predpona služby", 42 | "services": "Vzdialené služby" 43 | } 44 | }, 45 | "domain_entity_filters": { 46 | "title": "Filtre domén a entít (krok 2/4)", 47 | "data": { 48 | "include_domains": "Zahrnúť domény", 49 | "include_entities": "Zahrnúť entity", 50 | "exclude_domains": "Vylúčiť domény", 51 | "exclude_entities": "Vylúčiť entity" 52 | } 53 | }, 54 | "general_filters": { 55 | "title": "Filtre (krok 3/4)", 56 | "description": "Zadajte nový filter `Entity ID`, jeden alebo viac atribútov filtra a stlačte `Submit`. Odstráňte existujúce filtre tak, že ich zrušíte `Filters`.\n\nOpustiť `Entity ID` vyprázdnite a stlačte `Submit` aby ste nevykonali žiadne ďalšie zmeny.", 57 | "data": { 58 | "filter": "Filtre", 59 | "entity_id": "Entity ID", 60 | "unit_of_measurement": "Jednotka merania", 61 | "above": "Nad", 62 | "below": "Pod" 63 | } 64 | }, 65 | "events": { 66 | "title": "Odoberané udalosti (krok 4/4)", 67 | "description": "Pridajte novú odoberanú udalosť zadaním jej názvu `Add new event` a stlačiť `Submit`. Odstráňte existujúce udalosti zrušením ich začiarknutia `Events`.\n\nOpustiť `Add new event` a stlačiť `Submit` aby ste nevykonali žiadne ďalšie zmeny.", 68 | "data": { 69 | "subscribe_events": "Udalosti", 70 | "add_new_event": "Pridať novú udalosť" 71 | } 72 | } 73 | }, 74 | "abort": { 75 | "not_supported": "Pre vzdialený uzol nie sú podporované žiadne možnosti konfigurácie" 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /custom_components/remote_homeassistant/views.py: -------------------------------------------------------------------------------- 1 | import homeassistant 2 | from homeassistant.components.http import HomeAssistantView 3 | from homeassistant.helpers.system_info import async_get_system_info 4 | from homeassistant.helpers.instance_id import async_get as async_get_instance_id 5 | 6 | ATTR_INSTALLATION_TYPE = "installation_type" 7 | 8 | 9 | class DiscoveryInfoView(HomeAssistantView): 10 | """Get all logged errors and warnings.""" 11 | 12 | url = "/api/remote_homeassistant/discovery" 13 | name = "api:remote_homeassistant:discovery" 14 | 15 | async def get(self, request): 16 | """Get discovery information.""" 17 | hass = request.app["hass"] 18 | system_info = await async_get_system_info(hass) 19 | return self.json( 20 | { 21 | "uuid": await async_get_instance_id(hass), 22 | "location_name": hass.config.location_name, 23 | "ha_version": homeassistant.const.__version__, 24 | "installation_type": system_info[ATTR_INSTALLATION_TYPE], 25 | } 26 | ) 27 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Remote Home-Assistant", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/remote_homeassistant/e07e60ebc2b01fa7e407c8be3e5cc5a52aa03a05/icons/icon.png -------------------------------------------------------------------------------- /icons/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/remote_homeassistant/e07e60ebc2b01fa7e407c8be3e5cc5a52aa03a05/icons/icon@2x.png -------------------------------------------------------------------------------- /img/device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/remote_homeassistant/e07e60ebc2b01fa7e407c8be3e5cc5a52aa03a05/img/device.png -------------------------------------------------------------------------------- /img/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/remote_homeassistant/e07e60ebc2b01fa7e407c8be3e5cc5a52aa03a05/img/options.png -------------------------------------------------------------------------------- /img/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/remote_homeassistant/e07e60ebc2b01fa7e407c8be3e5cc5a52aa03a05/img/setup.png -------------------------------------------------------------------------------- /img/step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/remote_homeassistant/e07e60ebc2b01fa7e407c8be3e5cc5a52aa03a05/img/step1.png -------------------------------------------------------------------------------- /img/step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/remote_homeassistant/e07e60ebc2b01fa7e407c8be3e5cc5a52aa03a05/img/step2.png --------------------------------------------------------------------------------