├── .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 |
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
--------------------------------------------------------------------------------