working 2.0 clone
This commit is contained in:
745
custom_components/remote_homeassistant/__init__.py
Normal file
745
custom_components/remote_homeassistant/__init__.py
Normal file
@@ -0,0 +1,745 @@
|
||||
"""
|
||||
Connect two Home Assistant instances via the Websocket API.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/remote_homeassistant/
|
||||
"""
|
||||
import asyncio
|
||||
import copy
|
||||
import fnmatch
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
from contextlib import suppress
|
||||
|
||||
import aiohttp
|
||||
import homeassistant.components.websocket_api.auth as api
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
from homeassistant.config import DATA_CUSTOMIZE
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (CONF_ABOVE, CONF_ACCESS_TOKEN, CONF_BELOW,
|
||||
CONF_DOMAINS, CONF_ENTITIES, CONF_ENTITY_ID,
|
||||
CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE,
|
||||
CONF_PORT, CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VERIFY_SSL, EVENT_CALL_SERVICE,
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED,
|
||||
SERVICE_RELOAD)
|
||||
from homeassistant.core import (Context, EventOrigin, HomeAssistant, callback,
|
||||
split_entity_id)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from custom_components.remote_homeassistant.views import DiscoveryInfoView
|
||||
|
||||
from .const import (CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES,
|
||||
CONF_INCLUDE_DOMAINS, CONF_INCLUDE_ENTITIES,
|
||||
CONF_LOAD_COMPONENTS, CONF_OPTIONS, CONF_REMOTE_CONNECTION,
|
||||
CONF_SERVICE_PREFIX, CONF_SERVICES, CONF_UNSUB_LISTENER,
|
||||
DOMAIN, REMOTE_ID, DEFAULT_MAX_MSG_SIZE)
|
||||
from .proxy_services import ProxyServices
|
||||
from .rest_api import UnsupportedVersion, async_get_discovery_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = ["sensor"]
|
||||
|
||||
CONF_INSTANCES = "instances"
|
||||
CONF_SECURE = "secure"
|
||||
CONF_SUBSCRIBE_EVENTS = "subscribe_events"
|
||||
CONF_ENTITY_PREFIX = "entity_prefix"
|
||||
CONF_FILTER = "filter"
|
||||
CONF_MAX_MSG_SIZE = "max_message_size"
|
||||
|
||||
STATE_INIT = "initializing"
|
||||
STATE_CONNECTING = "connecting"
|
||||
STATE_CONNECTED = "connected"
|
||||
STATE_AUTH_INVALID = "auth_invalid"
|
||||
STATE_AUTH_REQUIRED = "auth_required"
|
||||
STATE_RECONNECTING = "reconnecting"
|
||||
STATE_DISCONNECTED = "disconnected"
|
||||
|
||||
DEFAULT_ENTITY_PREFIX = ""
|
||||
|
||||
INSTANCES_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT, default=8123): cv.port,
|
||||
vol.Optional(CONF_SECURE, default=False): cv.boolean,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
|
||||
vol.Required(CONF_ACCESS_TOKEN): cv.string,
|
||||
vol.Optional(CONF_MAX_MSG_SIZE, default=DEFAULT_MAX_MSG_SIZE): vol.Coerce(int),
|
||||
vol.Optional(CONF_EXCLUDE, default={}): vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
|
||||
vol.Optional(CONF_DOMAINS, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
),
|
||||
vol.Optional(CONF_INCLUDE, default={}): vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
|
||||
vol.Optional(CONF_DOMAINS, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
),
|
||||
vol.Optional(CONF_FILTER, default=[]): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ENTITY_ID): cv.string,
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_ABOVE): vol.Coerce(float),
|
||||
vol.Optional(CONF_BELOW): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
],
|
||||
),
|
||||
vol.Optional(CONF_SUBSCRIBE_EVENTS): cv.ensure_list,
|
||||
vol.Optional(CONF_ENTITY_PREFIX, default=DEFAULT_ENTITY_PREFIX): cv.string,
|
||||
vol.Optional(CONF_LOAD_COMPONENTS): cv.ensure_list,
|
||||
vol.Required(CONF_SERVICE_PREFIX, default="remote_"): cv.string,
|
||||
vol.Optional(CONF_SERVICES): cv.ensure_list,
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_INSTANCES): vol.All(
|
||||
cv.ensure_list, [INSTANCES_SCHEMA]
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
HEARTBEAT_INTERVAL = 20
|
||||
HEARTBEAT_TIMEOUT = 5
|
||||
|
||||
INTERNALLY_USED_EVENTS = [EVENT_STATE_CHANGED]
|
||||
|
||||
|
||||
def async_yaml_to_config_entry(instance_conf):
|
||||
"""Convert YAML config into data and options used by a config entry."""
|
||||
conf = instance_conf.copy()
|
||||
options = {}
|
||||
|
||||
if CONF_INCLUDE in conf:
|
||||
include = conf.pop(CONF_INCLUDE)
|
||||
if CONF_ENTITIES in include:
|
||||
options[CONF_INCLUDE_ENTITIES] = include[CONF_ENTITIES]
|
||||
if CONF_DOMAINS in include:
|
||||
options[CONF_INCLUDE_DOMAINS] = include[CONF_DOMAINS]
|
||||
|
||||
if CONF_EXCLUDE in conf:
|
||||
exclude = conf.pop(CONF_EXCLUDE)
|
||||
if CONF_ENTITIES in exclude:
|
||||
options[CONF_EXCLUDE_ENTITIES] = exclude[CONF_ENTITIES]
|
||||
if CONF_DOMAINS in exclude:
|
||||
options[CONF_EXCLUDE_DOMAINS] = exclude[CONF_DOMAINS]
|
||||
|
||||
for option in [
|
||||
CONF_FILTER,
|
||||
CONF_SUBSCRIBE_EVENTS,
|
||||
CONF_ENTITY_PREFIX,
|
||||
CONF_LOAD_COMPONENTS,
|
||||
CONF_SERVICE_PREFIX,
|
||||
CONF_SERVICES,
|
||||
]:
|
||||
if option in conf:
|
||||
options[option] = conf.pop(option)
|
||||
|
||||
return conf, options
|
||||
|
||||
|
||||
async def _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf):
|
||||
"""Update a config entry with the latest yaml."""
|
||||
try:
|
||||
info = await async_get_discovery_info(
|
||||
hass,
|
||||
conf[CONF_HOST],
|
||||
conf[CONF_PORT],
|
||||
conf[CONF_SECURE],
|
||||
conf[CONF_ACCESS_TOKEN],
|
||||
conf[CONF_VERIFY_SSL],
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception(f"reload of {conf[CONF_HOST]} failed")
|
||||
else:
|
||||
entry = entries_by_id.get(info["uuid"])
|
||||
if entry:
|
||||
data, options = async_yaml_to_config_entry(conf)
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
|
||||
|
||||
async def setup_remote_instance(hass: HomeAssistantType):
|
||||
hass.http.register_view(DiscoveryInfoView())
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistantType, config: ConfigType):
|
||||
"""Set up the remote_homeassistant component."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
async def _handle_reload(service):
|
||||
"""Handle reload service call."""
|
||||
config = await async_integration_yaml_config(hass, DOMAIN)
|
||||
|
||||
if not config or DOMAIN not in config:
|
||||
return
|
||||
|
||||
current_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
entries_by_id = {entry.unique_id: entry for entry in current_entries}
|
||||
|
||||
instances = config[DOMAIN][CONF_INSTANCES]
|
||||
update_tasks = [
|
||||
_async_update_config_entry_if_from_yaml(hass, entries_by_id, instance)
|
||||
for instance in instances
|
||||
]
|
||||
|
||||
await asyncio.gather(*update_tasks)
|
||||
|
||||
hass.async_create_task(setup_remote_instance(hass))
|
||||
|
||||
hass.helpers.service.async_register_admin_service(
|
||||
DOMAIN,
|
||||
SERVICE_RELOAD,
|
||||
_handle_reload,
|
||||
)
|
||||
|
||||
instances = config.get(DOMAIN, {}).get(CONF_INSTANCES, [])
|
||||
for instance in instances:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=instance
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up Remote Home-Assistant from a config entry."""
|
||||
_async_import_options_from_yaml(hass, entry)
|
||||
if entry.unique_id == REMOTE_ID:
|
||||
hass.async_create_task(setup_remote_instance(hass))
|
||||
return True
|
||||
else:
|
||||
remote = RemoteConnection(hass, entry)
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
CONF_REMOTE_CONNECTION: remote,
|
||||
CONF_UNSUB_LISTENER: entry.add_update_listener(_update_listener),
|
||||
}
|
||||
|
||||
async def setup_components_and_platforms():
|
||||
"""Set up platforms and initiate connection."""
|
||||
for domain in entry.options.get(CONF_LOAD_COMPONENTS, []):
|
||||
hass.async_create_task(async_setup_component(hass, domain, {}))
|
||||
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
for platform in PLATFORMS
|
||||
]
|
||||
)
|
||||
await remote.async_connect()
|
||||
|
||||
hass.async_create_task(setup_components_and_platforms())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Unload a config entry."""
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||
for platform in PLATFORMS
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if unload_ok:
|
||||
data = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await data[CONF_REMOTE_CONNECTION].async_stop()
|
||||
data[CONF_UNSUB_LISTENER]()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
@callback
|
||||
def _async_import_options_from_yaml(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Import options from YAML into options section of config entry."""
|
||||
if CONF_OPTIONS in entry.data:
|
||||
data = entry.data.copy()
|
||||
options = data.pop(CONF_OPTIONS)
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
|
||||
|
||||
async def _update_listener(hass, config_entry):
|
||||
"""Update listener."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
||||
class RemoteConnection(object):
|
||||
"""A Websocket connection to a remote home-assistant instance."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
"""Initialize the connection."""
|
||||
self._hass = hass
|
||||
self._entry = config_entry
|
||||
self._secure = config_entry.data.get(CONF_SECURE, False)
|
||||
self._verify_ssl = config_entry.data.get(CONF_VERIFY_SSL, False)
|
||||
self._access_token = config_entry.data.get(CONF_ACCESS_TOKEN)
|
||||
self._max_msg_size = config_entry.data.get(CONF_MAX_MSG_SIZE)
|
||||
|
||||
# see homeassistant/components/influxdb/__init__.py
|
||||
# for include/exclude logic
|
||||
self._whitelist_e = set(config_entry.options.get(CONF_INCLUDE_ENTITIES, []))
|
||||
self._whitelist_d = set(config_entry.options.get(CONF_INCLUDE_DOMAINS, []))
|
||||
self._blacklist_e = set(config_entry.options.get(CONF_EXCLUDE_ENTITIES, []))
|
||||
self._blacklist_d = set(config_entry.options.get(CONF_EXCLUDE_DOMAINS, []))
|
||||
|
||||
self._filter = [
|
||||
{
|
||||
CONF_ENTITY_ID: re.compile(fnmatch.translate(f.get(CONF_ENTITY_ID)))
|
||||
if f.get(CONF_ENTITY_ID)
|
||||
else None,
|
||||
CONF_UNIT_OF_MEASUREMENT: f.get(CONF_UNIT_OF_MEASUREMENT),
|
||||
CONF_ABOVE: f.get(CONF_ABOVE),
|
||||
CONF_BELOW: f.get(CONF_BELOW),
|
||||
}
|
||||
for f in config_entry.options.get(CONF_FILTER, [])
|
||||
]
|
||||
|
||||
self._subscribe_events = set(
|
||||
config_entry.options.get(CONF_SUBSCRIBE_EVENTS, []) + INTERNALLY_USED_EVENTS
|
||||
)
|
||||
self._entity_prefix = config_entry.options.get(CONF_ENTITY_PREFIX, "")
|
||||
|
||||
self._connection = None
|
||||
self._heartbeat_task = None
|
||||
self._is_stopping = False
|
||||
self._entities = set()
|
||||
self._all_entity_names = set()
|
||||
self._handlers = {}
|
||||
self._remove_listener = None
|
||||
self.proxy_services = ProxyServices(hass, config_entry, self)
|
||||
|
||||
self.set_connection_state(STATE_CONNECTING)
|
||||
|
||||
self.__id = 1
|
||||
|
||||
def _prefixed_entity_id(self, entity_id):
|
||||
if self._entity_prefix:
|
||||
domain, object_id = split_entity_id(entity_id)
|
||||
object_id = self._entity_prefix + object_id
|
||||
entity_id = domain + "." + object_id
|
||||
return entity_id
|
||||
return entity_id
|
||||
|
||||
def set_connection_state(self, state):
|
||||
"""Change current connection state."""
|
||||
signal = f"remote_homeassistant_{self._entry.unique_id}"
|
||||
async_dispatcher_send(self._hass, signal, state)
|
||||
|
||||
@callback
|
||||
def _get_url(self):
|
||||
"""Get url to connect to."""
|
||||
return "%s://%s:%s/api/websocket" % (
|
||||
"wss" if self._secure else "ws",
|
||||
self._entry.data[CONF_HOST],
|
||||
self._entry.data[CONF_PORT],
|
||||
)
|
||||
|
||||
async def async_connect(self):
|
||||
"""Connect to remote home-assistant websocket..."""
|
||||
|
||||
async def _async_stop_handler(event):
|
||||
"""Stop when Home Assistant is shutting down."""
|
||||
await self.async_stop()
|
||||
|
||||
async def _async_instance_get_info():
|
||||
"""Fetch discovery info from remote instance."""
|
||||
try:
|
||||
return await async_get_discovery_info(
|
||||
self._hass,
|
||||
self._entry.data[CONF_HOST],
|
||||
self._entry.data[CONF_PORT],
|
||||
self._secure,
|
||||
self._access_token,
|
||||
self._verify_ssl,
|
||||
)
|
||||
except OSError:
|
||||
_LOGGER.exception("failed to connect")
|
||||
except UnsupportedVersion:
|
||||
_LOGGER.error("Unsupported version, at least 0.111 is required.")
|
||||
except Exception:
|
||||
_LOGGER.exception("failed to fetch instance info")
|
||||
return None
|
||||
|
||||
@callback
|
||||
def _async_instance_id_match(info):
|
||||
"""Verify if remote instance id matches the expected id."""
|
||||
if not info:
|
||||
return False
|
||||
if info and info["uuid"] != self._entry.unique_id:
|
||||
_LOGGER.error(
|
||||
"instance id not matching: %s != %s",
|
||||
info["uuid"],
|
||||
self._entry.unique_id,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
url = self._get_url()
|
||||
|
||||
session = async_get_clientsession(self._hass, self._verify_ssl)
|
||||
self.set_connection_state(STATE_CONNECTING)
|
||||
|
||||
while True:
|
||||
info = await _async_instance_get_info()
|
||||
|
||||
# Verify we are talking to correct instance
|
||||
if not _async_instance_id_match(info):
|
||||
self.set_connection_state(STATE_RECONNECTING)
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
|
||||
try:
|
||||
_LOGGER.info("Connecting to %s", url)
|
||||
self._connection = await session.ws_connect(url, max_msg_size = self._max_msg_size)
|
||||
except aiohttp.client_exceptions.ClientError:
|
||||
_LOGGER.error("Could not connect to %s, retry in 10 seconds...", url)
|
||||
self.set_connection_state(STATE_RECONNECTING)
|
||||
await asyncio.sleep(10)
|
||||
else:
|
||||
_LOGGER.info("Connected to home-assistant websocket at %s", url)
|
||||
break
|
||||
|
||||
self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_handler)
|
||||
|
||||
device_registry = dr.async_get(self._hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self._entry.entry_id,
|
||||
identifiers={(DOMAIN, f"remote_{self._entry.unique_id}")},
|
||||
name=info.get("location_name"),
|
||||
manufacturer="Home Assistant",
|
||||
model=info.get("installation_type"),
|
||||
sw_version=info.get("ha_version"),
|
||||
)
|
||||
|
||||
asyncio.ensure_future(self._recv())
|
||||
self._heartbeat_task = self._hass.loop.create_task(self._heartbeat_loop())
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
"""Send periodic heartbeats to remote instance."""
|
||||
while not self._connection.closed:
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
_LOGGER.debug("Sending ping")
|
||||
event = asyncio.Event()
|
||||
|
||||
def resp(message):
|
||||
_LOGGER.debug("Got pong: %s", message)
|
||||
event.set()
|
||||
|
||||
await self.call(resp, "ping")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), HEARTBEAT_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("heartbeat failed")
|
||||
|
||||
# Schedule closing on event loop to avoid deadlock
|
||||
asyncio.ensure_future(self._connection.close())
|
||||
break
|
||||
|
||||
async def async_stop(self):
|
||||
"""Close connection."""
|
||||
self._is_stopping = True
|
||||
if self._connection is not None:
|
||||
await self._connection.close()
|
||||
await self.proxy_services.unload()
|
||||
|
||||
def _next_id(self):
|
||||
_id = self.__id
|
||||
self.__id += 1
|
||||
return _id
|
||||
|
||||
async def call(self, callback, message_type, **extra_args):
|
||||
_id = self._next_id()
|
||||
self._handlers[_id] = callback
|
||||
try:
|
||||
await self._connection.send_json(
|
||||
{"id": _id, "type": message_type, **extra_args}
|
||||
)
|
||||
except aiohttp.client_exceptions.ClientError as err:
|
||||
_LOGGER.error("remote websocket connection closed: %s", err)
|
||||
await self._disconnected()
|
||||
|
||||
async def _disconnected(self):
|
||||
# Remove all published entries
|
||||
for entity in self._entities:
|
||||
self._hass.states.async_remove(entity)
|
||||
if self._heartbeat_task is not None:
|
||||
self._heartbeat_task.cancel()
|
||||
try:
|
||||
await self._heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self._remove_listener is not None:
|
||||
self._remove_listener()
|
||||
|
||||
self.set_connection_state(STATE_DISCONNECTED)
|
||||
self._heartbeat_task = None
|
||||
self._remove_listener = None
|
||||
self._entities = set()
|
||||
self._all_entity_names = set()
|
||||
if not self._is_stopping:
|
||||
asyncio.ensure_future(self.async_connect())
|
||||
|
||||
async def _recv(self):
|
||||
while not self._connection.closed:
|
||||
try:
|
||||
data = await self._connection.receive()
|
||||
except aiohttp.client_exceptions.ClientError as err:
|
||||
_LOGGER.error("remote websocket connection closed: %s", err)
|
||||
break
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
if data.type in (
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.CLOSING,
|
||||
):
|
||||
_LOGGER.debug("websocket connection is closing")
|
||||
break
|
||||
|
||||
if data.type == aiohttp.WSMsgType.ERROR:
|
||||
_LOGGER.error("websocket connection had an error")
|
||||
if data.data.code == aiohttp.WSCloseCode.MESSAGE_TOO_BIG:
|
||||
_LOGGER.error(f"please consider increasing message size with `{CONF_MAX_MSG_SIZE}`")
|
||||
break
|
||||
|
||||
try:
|
||||
message = data.json()
|
||||
except TypeError as err:
|
||||
_LOGGER.error("could not decode data (%s) as json: %s", data, err)
|
||||
break
|
||||
|
||||
if message is None:
|
||||
break
|
||||
|
||||
_LOGGER.debug("received: %s", message)
|
||||
|
||||
if message["type"] == api.TYPE_AUTH_OK:
|
||||
self.set_connection_state(STATE_CONNECTED)
|
||||
await self._init()
|
||||
|
||||
elif message["type"] == api.TYPE_AUTH_REQUIRED:
|
||||
if self._access_token:
|
||||
data = {"type": api.TYPE_AUTH, "access_token": self._access_token}
|
||||
else:
|
||||
_LOGGER.error("Access token required, but not provided")
|
||||
self.set_connection_state(STATE_AUTH_REQUIRED)
|
||||
return
|
||||
try:
|
||||
await self._connection.send_json(data)
|
||||
except Exception as err:
|
||||
_LOGGER.error("could not send data to remote connection: %s", err)
|
||||
break
|
||||
|
||||
elif message["type"] == api.TYPE_AUTH_INVALID:
|
||||
_LOGGER.error("Auth invalid, check your access token")
|
||||
self.set_connection_state(STATE_AUTH_INVALID)
|
||||
await self._connection.close()
|
||||
return
|
||||
|
||||
else:
|
||||
callback = self._handlers.get(message["id"])
|
||||
if callback is not None:
|
||||
if inspect.iscoroutinefunction(callback):
|
||||
await callback(message)
|
||||
else:
|
||||
callback(message)
|
||||
|
||||
await self._disconnected()
|
||||
|
||||
async def _init(self):
|
||||
async def forward_event(event):
|
||||
"""Send local event to remote instance.
|
||||
|
||||
The affected entity_id has to origin from that remote instance,
|
||||
otherwise the event is dicarded.
|
||||
"""
|
||||
event_data = event.data
|
||||
service_data = event_data["service_data"]
|
||||
|
||||
if not service_data:
|
||||
return
|
||||
|
||||
entity_ids = service_data.get("entity_id", None)
|
||||
|
||||
if not entity_ids:
|
||||
return
|
||||
|
||||
if isinstance(entity_ids, str):
|
||||
entity_ids = (entity_ids.lower(),)
|
||||
|
||||
entities = {entity_id.lower() for entity_id in self._entities}
|
||||
|
||||
entity_ids = entities.intersection(entity_ids)
|
||||
|
||||
if not entity_ids:
|
||||
return
|
||||
|
||||
if self._entity_prefix:
|
||||
|
||||
def _remove_prefix(entity_id):
|
||||
domain, object_id = split_entity_id(entity_id)
|
||||
object_id = object_id.replace(self._entity_prefix.lower(), "", 1)
|
||||
return domain + "." + object_id
|
||||
|
||||
entity_ids = {_remove_prefix(entity_id) for entity_id in entity_ids}
|
||||
|
||||
event_data = copy.deepcopy(event_data)
|
||||
event_data["service_data"]["entity_id"] = list(entity_ids)
|
||||
|
||||
# Remove service_call_id parameter - websocket API
|
||||
# doesn't accept that one
|
||||
event_data.pop("service_call_id", None)
|
||||
|
||||
_id = self._next_id()
|
||||
data = {"id": _id, "type": event.event_type, **event_data}
|
||||
|
||||
_LOGGER.debug("forward event: %s", data)
|
||||
|
||||
try:
|
||||
await self._connection.send_json(data)
|
||||
except Exception as err:
|
||||
_LOGGER.error("could not send data to remote connection: %s", err)
|
||||
await self._disconnected()
|
||||
|
||||
def state_changed(entity_id, state, attr):
|
||||
"""Publish remote state change on local instance."""
|
||||
domain, object_id = split_entity_id(entity_id)
|
||||
|
||||
self._all_entity_names.add(entity_id)
|
||||
|
||||
if entity_id in self._blacklist_e or domain in self._blacklist_d:
|
||||
return
|
||||
|
||||
if (
|
||||
(self._whitelist_e or self._whitelist_d)
|
||||
and entity_id not in self._whitelist_e
|
||||
and domain not in self._whitelist_d
|
||||
):
|
||||
return
|
||||
|
||||
for f in self._filter:
|
||||
if f[CONF_ENTITY_ID] and not f[CONF_ENTITY_ID].match(entity_id):
|
||||
continue
|
||||
if f[CONF_UNIT_OF_MEASUREMENT]:
|
||||
if CONF_UNIT_OF_MEASUREMENT not in attr:
|
||||
continue
|
||||
if f[CONF_UNIT_OF_MEASUREMENT] != attr[CONF_UNIT_OF_MEASUREMENT]:
|
||||
continue
|
||||
try:
|
||||
if f[CONF_BELOW] and float(state) < f[CONF_BELOW]:
|
||||
_LOGGER.info(
|
||||
"%s: ignoring state '%s', because " "below '%s'",
|
||||
entity_id,
|
||||
state,
|
||||
f[CONF_BELOW],
|
||||
)
|
||||
return
|
||||
if f[CONF_ABOVE] and float(state) > f[CONF_ABOVE]:
|
||||
_LOGGER.info(
|
||||
"%s: ignoring state '%s', because " "above '%s'",
|
||||
entity_id,
|
||||
state,
|
||||
f[CONF_ABOVE],
|
||||
)
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
entity_id = self._prefixed_entity_id(entity_id)
|
||||
|
||||
# Add local customization data
|
||||
if DATA_CUSTOMIZE in self._hass.data:
|
||||
attr.update(self._hass.data[DATA_CUSTOMIZE].get(entity_id))
|
||||
|
||||
self._entities.add(entity_id)
|
||||
self._hass.states.async_set(entity_id, state, attr)
|
||||
|
||||
def fire_event(message):
|
||||
"""Publish remove event on local instance."""
|
||||
if message["type"] == "result":
|
||||
return
|
||||
|
||||
if message["type"] != "event":
|
||||
return
|
||||
|
||||
if message["event"]["event_type"] == "state_changed":
|
||||
data = message["event"]["data"]
|
||||
entity_id = data["entity_id"]
|
||||
if not data["new_state"]:
|
||||
entity_id = self._prefixed_entity_id(entity_id)
|
||||
# entity was removed in the remote instance
|
||||
with suppress(ValueError, AttributeError, KeyError):
|
||||
self._entities.remove(entity_id)
|
||||
with suppress(ValueError, AttributeError, KeyError):
|
||||
self._all_entity_names.remove(entity_id)
|
||||
self._hass.states.async_remove(entity_id)
|
||||
return
|
||||
|
||||
state = data["new_state"]["state"]
|
||||
attr = data["new_state"]["attributes"]
|
||||
state_changed(entity_id, state, attr)
|
||||
else:
|
||||
event = message["event"]
|
||||
self._hass.bus.async_fire(
|
||||
event_type=event["event_type"],
|
||||
event_data=event["data"],
|
||||
context=Context(
|
||||
id=event["context"].get("id"),
|
||||
user_id=event["context"].get("user_id"),
|
||||
parent_id=event["context"].get("parent_id"),
|
||||
),
|
||||
origin=EventOrigin.remote,
|
||||
)
|
||||
|
||||
def got_states(message):
|
||||
"""Called when list of remote states is available."""
|
||||
for entity in message["result"]:
|
||||
entity_id = entity["entity_id"]
|
||||
state = entity["state"]
|
||||
attributes = entity["attributes"]
|
||||
|
||||
state_changed(entity_id, state, attributes)
|
||||
|
||||
self._remove_listener = self._hass.bus.async_listen(
|
||||
EVENT_CALL_SERVICE, forward_event
|
||||
)
|
||||
|
||||
for event in self._subscribe_events:
|
||||
await self.call(fire_event, "subscribe_events", event_type=event)
|
||||
|
||||
await self.call(got_states, "get_states")
|
||||
|
||||
await self.proxy_services.load()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
371
custom_components/remote_homeassistant/config_flow.py
Normal file
371
custom_components/remote_homeassistant/config_flow.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""Config flow for Remote Home-Assistant integration."""
|
||||
import logging
|
||||
import enum
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries, core
|
||||
from homeassistant.const import (CONF_ABOVE, CONF_ACCESS_TOKEN, CONF_BELOW,
|
||||
CONF_ENTITY_ID, CONF_HOST, CONF_PORT,
|
||||
CONF_UNIT_OF_MEASUREMENT, CONF_VERIFY_SSL, CONF_TYPE)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.instance_id import async_get
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import async_yaml_to_config_entry
|
||||
from .const import (CONF_ENTITY_PREFIX, # pylint:disable=unused-import
|
||||
CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES, CONF_FILTER,
|
||||
CONF_INCLUDE_DOMAINS, CONF_INCLUDE_ENTITIES,
|
||||
CONF_LOAD_COMPONENTS, CONF_MAIN, CONF_OPTIONS, CONF_REMOTE, CONF_REMOTE_CONNECTION,
|
||||
CONF_SECURE, CONF_SERVICE_PREFIX, CONF_SERVICES, CONF_MAX_MSG_SIZE,
|
||||
CONF_SUBSCRIBE_EVENTS, DOMAIN, REMOTE_ID, DEFAULT_MAX_MSG_SIZE)
|
||||
from .rest_api import (ApiProblem, CannotConnect, EndpointMissing, InvalidAuth,
|
||||
UnsupportedVersion, async_get_discovery_info)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ADD_NEW_EVENT = "add_new_event"
|
||||
|
||||
FILTER_OPTIONS = [CONF_ENTITY_ID, CONF_UNIT_OF_MEASUREMENT, CONF_ABOVE, CONF_BELOW]
|
||||
|
||||
|
||||
def _filter_str(index, filter):
|
||||
entity_id = filter[CONF_ENTITY_ID]
|
||||
unit = filter[CONF_UNIT_OF_MEASUREMENT]
|
||||
above = filter[CONF_ABOVE]
|
||||
below = filter[CONF_BELOW]
|
||||
return f"{index+1}. {entity_id}, unit: {unit}, above: {above}, below: {below}"
|
||||
|
||||
|
||||
async def validate_input(hass: core.HomeAssistant, conf):
|
||||
"""Validate the user input allows us to connect."""
|
||||
try:
|
||||
info = await async_get_discovery_info(
|
||||
hass,
|
||||
conf[CONF_HOST],
|
||||
conf[CONF_PORT],
|
||||
conf.get(CONF_SECURE, False),
|
||||
conf[CONF_ACCESS_TOKEN],
|
||||
conf.get(CONF_VERIFY_SSL, False),
|
||||
)
|
||||
except OSError:
|
||||
raise CannotConnect()
|
||||
|
||||
return {"title": info["location_name"], "uuid": info["uuid"]}
|
||||
|
||||
|
||||
class InstanceType(enum.Enum):
|
||||
"""Possible options for instance type."""
|
||||
|
||||
remote = "Setup as remote node"
|
||||
main = "Add a remote"
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Remote Home-Assistant."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a new ConfigFlow."""
|
||||
self.prefill = {CONF_PORT: 8123, CONF_SECURE: True, CONF_MAX_MSG_SIZE: DEFAULT_MAX_MSG_SIZE}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get options flow for this handler."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[CONF_TYPE] == CONF_REMOTE:
|
||||
await self.async_set_unique_id(REMOTE_ID)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Remote instance", data=user_input)
|
||||
|
||||
elif user_input[CONF_TYPE] == CONF_MAIN:
|
||||
return await self.async_step_connection_details()
|
||||
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TYPE): vol.In([CONF_REMOTE, CONF_MAIN])
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
async def async_step_connection_details(self, user_input=None):
|
||||
"""Handle the connection details step."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except ApiProblem:
|
||||
errors["base"] = "api_problem"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except UnsupportedVersion:
|
||||
errors["base"] = "unsupported_version"
|
||||
except EndpointMissing:
|
||||
errors["base"] = "missing_endpoint"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(info["uuid"])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
|
||||
user_input = user_input or dict()
|
||||
host = user_input.get(CONF_HOST, self.prefill.get(CONF_HOST) or vol.UNDEFINED)
|
||||
port = user_input.get(CONF_PORT, self.prefill.get(CONF_PORT) or vol.UNDEFINED)
|
||||
secure = user_input.get(CONF_SECURE, self.prefill.get(CONF_SECURE) or vol.UNDEFINED)
|
||||
max_msg_size = user_input.get(CONF_MAX_MSG_SIZE, self.prefill.get(CONF_MAX_MSG_SIZE) or vol.UNDEFINED)
|
||||
return self.async_show_form(
|
||||
step_id="connection_details",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=host): str,
|
||||
vol.Required(CONF_PORT, default=port): int,
|
||||
vol.Required(CONF_ACCESS_TOKEN, default=user_input.get(CONF_ACCESS_TOKEN, vol.UNDEFINED)): str,
|
||||
vol.Required(CONF_MAX_MSG_SIZE, default=max_msg_size): int,
|
||||
vol.Optional(CONF_SECURE, default=secure): bool,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)): bool,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(self, info):
|
||||
"""Handle instance discovered via zeroconf."""
|
||||
properties = info.properties
|
||||
port = info.port
|
||||
uuid = properties["uuid"]
|
||||
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if await async_get(self.hass) == uuid:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
url = properties.get("internal_url")
|
||||
if not url:
|
||||
url = properties.get("base_url")
|
||||
url = urlparse(url)
|
||||
|
||||
self.prefill = {
|
||||
CONF_HOST: url.hostname,
|
||||
CONF_PORT: port,
|
||||
CONF_SECURE: url.scheme == "https",
|
||||
}
|
||||
|
||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||
self.context["identifier"] = self.unique_id
|
||||
self.context["title_placeholders"] = {"name": properties["location_name"]}
|
||||
return await self.async_step_connection_details()
|
||||
|
||||
async def async_step_import(self, user_input):
|
||||
"""Handle import from YAML."""
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except Exception:
|
||||
_LOGGER.exception(f"import of {user_input[CONF_HOST]} failed")
|
||||
return self.async_abort(reason="import_failed")
|
||||
|
||||
conf, options = async_yaml_to_config_entry(user_input)
|
||||
|
||||
# Options cannot be set here, so store them in a special key and import them
|
||||
# before setting up an entry
|
||||
conf[CONF_OPTIONS] = options
|
||||
|
||||
await self.async_set_unique_id(info["uuid"])
|
||||
self._abort_if_unique_id_configured(updates=conf)
|
||||
|
||||
return self.async_create_entry(title=f"{info['title']} (YAML)", data=conf)
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow for the Home Assistant remote integration."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize remote_homeassistant options flow."""
|
||||
self.config_entry = config_entry
|
||||
self.filters = None
|
||||
self.events = None
|
||||
self.options = None
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage basic options."""
|
||||
if self.config_entry.unique_id == REMOTE_ID:
|
||||
return self.async_abort(reason="not_supported")
|
||||
|
||||
if user_input is not None:
|
||||
self.options = user_input.copy()
|
||||
return await self.async_step_domain_entity_filters()
|
||||
|
||||
domains, _ = self._domains_and_entities()
|
||||
domains = set(domains + self.config_entry.options.get(CONF_LOAD_COMPONENTS, []))
|
||||
|
||||
remote = self.hass.data[DOMAIN][self.config_entry.entry_id][
|
||||
CONF_REMOTE_CONNECTION
|
||||
]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_ENTITY_PREFIX,
|
||||
description={
|
||||
"suggested_value": self.config_entry.options.get(
|
||||
CONF_ENTITY_PREFIX
|
||||
)
|
||||
},
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_LOAD_COMPONENTS,
|
||||
default=self._default(CONF_LOAD_COMPONENTS),
|
||||
): cv.multi_select(sorted(domains)),
|
||||
vol.Required(
|
||||
CONF_SERVICE_PREFIX, default=self.config_entry.options.get(CONF_SERVICE_PREFIX) or slugify(self.config_entry.title)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_SERVICES,
|
||||
default=self._default(CONF_SERVICES),
|
||||
): cv.multi_select(remote.proxy_services.services),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_domain_entity_filters(self, user_input=None):
|
||||
"""Manage domain and entity filters."""
|
||||
if user_input is not None:
|
||||
self.options.update(user_input)
|
||||
return await self.async_step_general_filters()
|
||||
|
||||
domains, entities = self._domains_and_entities()
|
||||
return self.async_show_form(
|
||||
step_id="domain_entity_filters",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_INCLUDE_DOMAINS,
|
||||
default=self._default(CONF_INCLUDE_DOMAINS),
|
||||
): cv.multi_select(domains),
|
||||
vol.Optional(
|
||||
CONF_INCLUDE_ENTITIES,
|
||||
default=self._default(CONF_INCLUDE_ENTITIES),
|
||||
): cv.multi_select(entities),
|
||||
vol.Optional(
|
||||
CONF_EXCLUDE_DOMAINS,
|
||||
default=self._default(CONF_EXCLUDE_DOMAINS),
|
||||
): cv.multi_select(domains),
|
||||
vol.Optional(
|
||||
CONF_EXCLUDE_ENTITIES,
|
||||
default=self._default(CONF_EXCLUDE_ENTITIES),
|
||||
): cv.multi_select(entities),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_general_filters(self, user_input=None):
|
||||
"""Manage domain and entity filters."""
|
||||
if user_input is not None:
|
||||
# Continue to next step if entity id is not specified
|
||||
if CONF_ENTITY_ID not in user_input:
|
||||
# Each filter string is prefixed with a number (index in self.filter+1).
|
||||
# Extract all of them and build the final filter list.
|
||||
selected_indices = [
|
||||
int(filter.split(".")[0]) - 1
|
||||
for filter in user_input.get(CONF_FILTER, [])
|
||||
]
|
||||
self.options[CONF_FILTER] = [self.filters[i] for i in selected_indices]
|
||||
return await self.async_step_events()
|
||||
|
||||
selected = user_input.get(CONF_FILTER, [])
|
||||
new_filter = {conf: user_input.get(conf) for conf in FILTER_OPTIONS}
|
||||
selected.append(_filter_str(len(self.filters), new_filter))
|
||||
self.filters.append(new_filter)
|
||||
else:
|
||||
self.filters = self.config_entry.options.get(CONF_FILTER, [])
|
||||
selected = [_filter_str(i, filter) for i, filter in enumerate(self.filters)]
|
||||
|
||||
strings = [_filter_str(i, filter) for i, filter in enumerate(self.filters)]
|
||||
return self.async_show_form(
|
||||
step_id="general_filters",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_FILTER, default=selected): cv.multi_select(
|
||||
strings
|
||||
),
|
||||
vol.Optional(CONF_ENTITY_ID): str,
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): str,
|
||||
vol.Optional(CONF_ABOVE): vol.Coerce(float),
|
||||
vol.Optional(CONF_BELOW): vol.Coerce(float),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_events(self, user_input=None):
|
||||
"""Manage event options."""
|
||||
if user_input is not None:
|
||||
if ADD_NEW_EVENT not in user_input:
|
||||
self.options[CONF_SUBSCRIBE_EVENTS] = user_input.get(
|
||||
CONF_SUBSCRIBE_EVENTS, []
|
||||
)
|
||||
return self.async_create_entry(title="", data=self.options)
|
||||
|
||||
selected = user_input.get(CONF_SUBSCRIBE_EVENTS, [])
|
||||
self.events.add(user_input[ADD_NEW_EVENT])
|
||||
selected.append(user_input[ADD_NEW_EVENT])
|
||||
else:
|
||||
self.events = set(
|
||||
self.config_entry.options.get(CONF_SUBSCRIBE_EVENTS) or []
|
||||
)
|
||||
selected = self._default(CONF_SUBSCRIBE_EVENTS)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="events",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_SUBSCRIBE_EVENTS, default=selected
|
||||
): cv.multi_select(self.events),
|
||||
vol.Optional(ADD_NEW_EVENT): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def _default(self, conf):
|
||||
"""Return default value for an option."""
|
||||
return self.config_entry.options.get(conf) or vol.UNDEFINED
|
||||
|
||||
def _domains_and_entities(self):
|
||||
"""Return all entities and domains exposed by remote instance."""
|
||||
remote = self.hass.data[DOMAIN][self.config_entry.entry_id][
|
||||
CONF_REMOTE_CONNECTION
|
||||
]
|
||||
|
||||
# Include entities we have in the config explicitly, otherwise they will be
|
||||
# pre-selected and not possible to remove if they are no lobger present on
|
||||
# the remote host.
|
||||
include_entities = set(self.config_entry.options.get(CONF_INCLUDE_ENTITIES, []))
|
||||
exclude_entities = set(self.config_entry.options.get(CONF_EXCLUDE_ENTITIES, []))
|
||||
entities = sorted(
|
||||
remote._all_entity_names | include_entities | exclude_entities
|
||||
)
|
||||
domains = sorted(set([entity_id.split(".")[0] for entity_id in entities]))
|
||||
return domains, entities
|
||||
34
custom_components/remote_homeassistant/const.py
Normal file
34
custom_components/remote_homeassistant/const.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Constants used by integration."""
|
||||
|
||||
CONF_REMOTE_CONNECTION = "remote_connection"
|
||||
CONF_UNSUB_LISTENER = "unsub_listener"
|
||||
CONF_OPTIONS = "options"
|
||||
CONF_REMOTE_INFO = "remote_info"
|
||||
CONF_LOAD_COMPONENTS = "load_components"
|
||||
CONF_SERVICE_PREFIX = "service_prefix"
|
||||
CONF_SERVICES = "services"
|
||||
|
||||
CONF_FILTER = "filter"
|
||||
CONF_SECURE = "secure"
|
||||
CONF_API_PASSWORD = "api_password"
|
||||
CONF_SUBSCRIBE_EVENTS = "subscribe_events"
|
||||
CONF_ENTITY_PREFIX = "entity_prefix"
|
||||
CONF_MAX_MSG_SIZE = "max_message_size"
|
||||
|
||||
CONF_INCLUDE_DOMAINS = "include_domains"
|
||||
CONF_INCLUDE_ENTITIES = "include_entities"
|
||||
CONF_EXCLUDE_DOMAINS = "exclude_domains"
|
||||
CONF_EXCLUDE_ENTITIES = "exclude_entities"
|
||||
|
||||
# FIXME: There seems to be ne way to make these strings translateable
|
||||
CONF_MAIN = "Add a remote node"
|
||||
CONF_REMOTE = "Setup as remote node"
|
||||
|
||||
DOMAIN = "remote_homeassistant"
|
||||
|
||||
REMOTE_ID = "remote"
|
||||
|
||||
# replaces 'from homeassistant.core import SERVICE_CALL_LIMIT'
|
||||
SERVICE_CALL_LIMIT = 10
|
||||
|
||||
DEFAULT_MAX_MSG_SIZE = 16*1024*1024
|
||||
18
custom_components/remote_homeassistant/manifest.json
Normal file
18
custom_components/remote_homeassistant/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"domain": "remote_homeassistant",
|
||||
"name": "Remote Home-Assistant",
|
||||
"issue_tracker": "https://github.com/custom-components/remote_homeassistant/issues",
|
||||
"documentation": "https://github.com/custom-components/remote_homeassistant",
|
||||
"dependencies": ["http"],
|
||||
"config_flow": true,
|
||||
"codeowners": [
|
||||
"@lukas-hetzenecker",
|
||||
"@postlund"
|
||||
],
|
||||
"requirements": [],
|
||||
"zeroconf": [
|
||||
"_home-assistant._tcp.local."
|
||||
],
|
||||
"version": "3.11",
|
||||
"iot_class": "local_push"
|
||||
}
|
||||
107
custom_components/remote_homeassistant/proxy_services.py
Normal file
107
custom_components/remote_homeassistant/proxy_services.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Support for proxy services."""
|
||||
import asyncio
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.service import SERVICE_DESCRIPTION_CACHE
|
||||
|
||||
from .const import CONF_SERVICE_PREFIX, CONF_SERVICES, SERVICE_CALL_LIMIT
|
||||
|
||||
|
||||
class ProxyServices:
|
||||
"""Manages remote proxy services."""
|
||||
|
||||
def __init__(self, hass, entry, remote):
|
||||
"""Initialize a new ProxyServices instance."""
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.remote = remote
|
||||
self.remote_services = {}
|
||||
self.registered_services = []
|
||||
|
||||
@property
|
||||
def services(self):
|
||||
"""Return list of service names."""
|
||||
result = []
|
||||
for domain, services in self.remote_services.items():
|
||||
for service in services.keys():
|
||||
result.append(f"{domain}.{service}")
|
||||
return sorted(result)
|
||||
|
||||
async def load(self):
|
||||
"""Call to make initial registration of services."""
|
||||
await self.remote.call(self._async_got_services, "get_services")
|
||||
|
||||
async def unload(self):
|
||||
"""Call to unregister all registered services."""
|
||||
description_cache = self.hass.data[SERVICE_DESCRIPTION_CACHE]
|
||||
|
||||
for domain, service_name in self.registered_services:
|
||||
self.hass.services.async_remove(domain, service_name)
|
||||
|
||||
# Remove from internal description cache
|
||||
service = f"{domain}.{service_name}"
|
||||
if service in description_cache:
|
||||
del description_cache[service]
|
||||
|
||||
async def _async_got_services(self, message):
|
||||
"""Called when list of remote services is available."""
|
||||
self.remote_services = message["result"]
|
||||
|
||||
# A service prefix is needed to not clash with original service names
|
||||
service_prefix = self.entry.options.get(CONF_SERVICE_PREFIX)
|
||||
if not service_prefix:
|
||||
return
|
||||
|
||||
description_cache = self.hass.data[SERVICE_DESCRIPTION_CACHE]
|
||||
for service in self.entry.options.get(CONF_SERVICES, []):
|
||||
domain, service_name = service.split(".")
|
||||
service = service_prefix + service_name
|
||||
|
||||
# Register new service with same name as original service but with prefix
|
||||
self.hass.services.async_register(
|
||||
domain,
|
||||
service,
|
||||
self._async_handle_service_call,
|
||||
vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
# <HERE_BE_DRAGON>
|
||||
# Service metadata can only be provided via a services.yaml file for a
|
||||
# particular component, something not possible here. A cache is used
|
||||
# internally for loaded service descriptions and that's abused here. If
|
||||
# the internal representation of the cache change, this sill break.
|
||||
# </HERE_BE_DRAGONS>
|
||||
service_info = self.remote_services.get(domain, {}).get(service_name)
|
||||
if service_info:
|
||||
description_cache[f"{domain}.{service}"] = service_info
|
||||
|
||||
self.registered_services.append((domain, service))
|
||||
|
||||
async def _async_handle_service_call(self, event):
|
||||
"""Handle service call to proxy service."""
|
||||
# An eception must be raised from the service call handler (thus method) in
|
||||
# order to end up in the frontend. The code below synchronizes reception of
|
||||
# the service call result, so potential error message can be used as exception
|
||||
# message. Not very pretty...
|
||||
ev = asyncio.Event()
|
||||
res = None
|
||||
|
||||
def _resp(message):
|
||||
nonlocal res
|
||||
res = message
|
||||
ev.set()
|
||||
|
||||
service_prefix = self.entry.options.get(CONF_SERVICE_PREFIX)
|
||||
service = event.service[len(service_prefix) :]
|
||||
await self.remote.call(
|
||||
_resp,
|
||||
"call_service",
|
||||
domain=event.domain,
|
||||
service=service,
|
||||
service_data=event.data.copy(),
|
||||
)
|
||||
|
||||
await asyncio.wait_for(ev.wait(), SERVICE_CALL_LIMIT)
|
||||
if not res["success"]:
|
||||
raise HomeAssistantError(res["error"]["message"])
|
||||
59
custom_components/remote_homeassistant/rest_api.py
Normal file
59
custom_components/remote_homeassistant/rest_api.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Simple implementation to call Home Assistant REST API."""
|
||||
|
||||
from homeassistant import exceptions
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
API_URL = "{proto}://{host}:{port}/api/remote_homeassistant/discovery"
|
||||
|
||||
|
||||
class ApiProblem(exceptions.HomeAssistantError):
|
||||
"""Error to indicate problem reaching API."""
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class BadResponse(exceptions.HomeAssistantError):
|
||||
"""Error to indicate a bad response was received."""
|
||||
|
||||
|
||||
class UnsupportedVersion(exceptions.HomeAssistantError):
|
||||
"""Error to indicate an unsupported version of Home Assistant."""
|
||||
|
||||
|
||||
class EndpointMissing(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
async def async_get_discovery_info(hass, host, port, secure, access_token, verify_ssl):
|
||||
"""Get discovery information from server."""
|
||||
url = API_URL.format(
|
||||
proto="https" if secure else "http",
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
headers = {
|
||||
"Authorization": "Bearer " + access_token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
session = async_get_clientsession(hass, verify_ssl)
|
||||
|
||||
# Fetch discovery info location for name and unique UUID
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status == 404:
|
||||
raise EndpointMissing()
|
||||
if 400 <= resp.status < 500:
|
||||
raise InvalidAuth()
|
||||
if resp.status != 200:
|
||||
raise ApiProblem()
|
||||
json = await resp.json()
|
||||
if not isinstance(json, dict):
|
||||
raise BadResponse(f"Bad response data: {json}")
|
||||
if "uuid" not in json:
|
||||
raise UnsupportedVersion()
|
||||
return json
|
||||
64
custom_components/remote_homeassistant/sensor.py
Normal file
64
custom_components/remote_homeassistant/sensor.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Sensor platform for connection status.."""
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
|
||||
from .const import DOMAIN, CONF_ENTITY_PREFIX, CONF_SECURE, CONF_MAX_MSG_SIZE, DEFAULT_MAX_MSG_SIZE
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up sensor based ok config entry."""
|
||||
async_add_entities([ConnectionStatusSensor(config_entry)])
|
||||
|
||||
|
||||
class ConnectionStatusSensor(Entity):
|
||||
"""Representation of a remote_homeassistant sensor."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize the remote_homeassistant sensor."""
|
||||
self._state = None
|
||||
self._entry = config_entry
|
||||
|
||||
proto = 'http' if config_entry.data.get(CONF_SECURE) else 'https'
|
||||
host = config_entry.data[CONF_HOST]
|
||||
port = config_entry.data[CONF_PORT]
|
||||
self._attr_name = f"Remote connection to {host}:{port}"
|
||||
self._attr_unique_id = config_entry.unique_id
|
||||
self._attr_should_poll = False
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name="Home Assistant",
|
||||
configuration_url=f"{proto}://{host}:{port}",
|
||||
identifiers={(DOMAIN, f"remote_{self._attr_unique_id}")},
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return sensor state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return device state attributes."""
|
||||
return {
|
||||
"host": self._entry.data[CONF_HOST],
|
||||
"port": self._entry.data[CONF_PORT],
|
||||
"secure": self._entry.data.get(CONF_SECURE, False),
|
||||
"verify_ssl": self._entry.data.get(CONF_VERIFY_SSL, False),
|
||||
"max_msg_size": self._entry.data.get(CONF_MAX_MSG_SIZE, DEFAULT_MAX_MSG_SIZE),
|
||||
"entity_prefix": self._entry.options.get(CONF_ENTITY_PREFIX, ""),
|
||||
"uuid": self.unique_id,
|
||||
}
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to events."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
def _update_handler(state):
|
||||
"""Update entity state when status was updated."""
|
||||
self._state = state
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
signal = f"remote_homeassistant_{self._entry.unique_id}"
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, signal, _update_handler)
|
||||
)
|
||||
2
custom_components/remote_homeassistant/services.yaml
Normal file
2
custom_components/remote_homeassistant/services.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
reload:
|
||||
description: Reload remote_homeassistant and re-process yaml configuration.
|
||||
84
custom_components/remote_homeassistant/translations/de.json
Normal file
84
custom_components/remote_homeassistant/translations/de.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "Remote: {name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Installationstyp wählen",
|
||||
"description": "Der Remote Node ist die Instanz, von der die Daten gesammelt werden"
|
||||
},
|
||||
"connection_details": {
|
||||
"title": "Verbindungsdetails",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"secure": "Sicher",
|
||||
"verify_ssl": "SSL verifizieren",
|
||||
"access_token": "Verbindungstoken",
|
||||
"max_message_size": "Maximale Nachrichtengröße"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"api_problem": "Unbekannte Antwort vom Server",
|
||||
"cannot_connect": "Verbindung zum Server fehlgeschlagen",
|
||||
"invalid_auth": "Ungültige Anmeldeinformationen",
|
||||
"unsupported_version": "Version nicht unterstützt. Mindestens Version 0.111 benötigt.",
|
||||
"unknown": "Ein unbekannter Fehler trat auf",
|
||||
"missing_endpoint": "Sie müssen Remote Home Assistant auf diesem Host installieren und remote_homeassistant: zu seiner Konfiguration hinzufügen."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Bereits konfiguriert"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"_": {
|
||||
"disconnected": "Getrennt",
|
||||
"connecting": "Verbindet",
|
||||
"connected": "Verbunden",
|
||||
"reconnecting": "Wiederverbinden",
|
||||
"auth_invalid": "Ungültiger Zugangstoken",
|
||||
"auth_required": "Authentifizierung erforderlich"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Basis-Einstellungen (Schritt 1/4)",
|
||||
"data": {
|
||||
"entity_prefix": "Entitätspräfix (optional)",
|
||||
"load_components": "Komponente laden (wenn nicht geladen)",
|
||||
"service_prefix": "Servicepräfix",
|
||||
"services": "Remote Services"
|
||||
}
|
||||
},
|
||||
"domain_entity_filters": {
|
||||
"title": "Domain- und Entitätsfilter (Schritt 2/4)",
|
||||
"data": {
|
||||
"include_domains": "Domains einbeziehen",
|
||||
"include_entities": "Entitäten einbeziehen",
|
||||
"exclude_domains": "Domains ausschließen",
|
||||
"exclude_entities": "Entitäten ausschließen"
|
||||
}
|
||||
},
|
||||
"general_filters": {
|
||||
"title": "Filter (Schritt 3/4)",
|
||||
"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.",
|
||||
"data": {
|
||||
"filter": "Filter",
|
||||
"entity_id": "Entitäts-ID",
|
||||
"unit_of_measurement": "Maßeinheit",
|
||||
"above": "Über",
|
||||
"below": "Unter"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"title": "Abonnierte Events (Schritt 4/4)",
|
||||
"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.",
|
||||
"data": {
|
||||
"subscribe_events": "Events",
|
||||
"add_new_event": "Neue Events hinzufügen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
custom_components/remote_homeassistant/translations/en.json
Normal file
87
custom_components/remote_homeassistant/translations/en.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "Remote: {name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Select installation type",
|
||||
"description": "The remote node is the instance on which the states are gathered from"
|
||||
},
|
||||
"connection_details": {
|
||||
"title": "Connection details",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"secure": "Secure",
|
||||
"verify_ssl": "Verify SSL",
|
||||
"access_token": "Access token",
|
||||
"max_message_size": "Maximum Message Size"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"api_problem": "Bad response from server",
|
||||
"cannot_connect": "Failed to connect to server",
|
||||
"invalid_auth": "Invalid credentials",
|
||||
"unsupported_version": "Unsupported version. At least version 0.111 is required.",
|
||||
"unknown": "An unknown error occurred",
|
||||
"missing_endpoint": "You need to install Remote Home Assistant on this host and add remote_homeassistant: to its configuration."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Already configured"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"_": {
|
||||
"disconnected": "Disconnected",
|
||||
"connecting": "Connecting",
|
||||
"connected": "Connected",
|
||||
"reconnecting": "Re-connecting",
|
||||
"auth_invalid": "Invalid access token",
|
||||
"auth_required": "Authentication Required"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Basic Options (step 1/4)",
|
||||
"data": {
|
||||
"entity_prefix": "Entity prefix (optional)",
|
||||
"load_components": "Load component (if not loaded)",
|
||||
"service_prefix": "Service prefix",
|
||||
"services": "Remote Services"
|
||||
}
|
||||
},
|
||||
"domain_entity_filters": {
|
||||
"title": "Domain and entity filters (step 2/4)",
|
||||
"data": {
|
||||
"include_domains": "Include domains",
|
||||
"include_entities": "Include entities",
|
||||
"exclude_domains": "Exclude domains",
|
||||
"exclude_entities": "Exclude entities"
|
||||
}
|
||||
},
|
||||
"general_filters": {
|
||||
"title": "Filters (step 3/4)",
|
||||
"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.",
|
||||
"data": {
|
||||
"filter": "Filters",
|
||||
"entity_id": "Entity ID",
|
||||
"unit_of_measurement": "Unit of measurement",
|
||||
"above": "Above",
|
||||
"below": "Below"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"title": "Subscribed events (step 4/4)",
|
||||
"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.",
|
||||
"data": {
|
||||
"subscribe_events": "Events",
|
||||
"add_new_event": "Add new event"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"not_supported": "No configuration options supported for a remote node"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "Remote: {name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Selecione o tipo de instalação",
|
||||
"description": "O nó remoto é a instância na qual os estados são coletados de"
|
||||
},
|
||||
"connection_details": {
|
||||
"title": "Detalhes da conexão",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Porta",
|
||||
"secure": "Protegido",
|
||||
"verify_ssl": "Verificar SSL",
|
||||
"access_token": "Token de acesso",
|
||||
"max_message_size": "Tamanho máximo da mensagem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"api_problem": "Resposta ruim do servidor",
|
||||
"cannot_connect": "Falha ao conectar ao servidor",
|
||||
"invalid_auth": "Credenciais inválidas",
|
||||
"unsupported_version": "Versão não suportada. Pelo menos a versão 0.111 é necessária.",
|
||||
"unknown": "Ocorreu um erro desconhecido",
|
||||
"missing_endpoint": "Você precisa instalar o Remote Home Assistant neste host e adicionar remote_homeassistant: à sua configuração."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Já configurado"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"_": {
|
||||
"disconnected": "Desconectado",
|
||||
"connecting": "Conectando",
|
||||
"connected": "Conectado",
|
||||
"reconnecting": "Reconectando",
|
||||
"auth_invalid": "Token de acesso inválido",
|
||||
"auth_required": "Autentificação requerida"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Opções básicas (passo 1/4)",
|
||||
"data": {
|
||||
"entity_prefix": "Prefixo da entidade (opcional)",
|
||||
"load_components": "Carregar componente (se não estiver carregado)",
|
||||
"service_prefix": "Prefixo do serviço",
|
||||
"services": "Serviços remotos"
|
||||
}
|
||||
},
|
||||
"domain_entity_filters": {
|
||||
"title": "Filtros de domínio e entidade (etapa 2/4)",
|
||||
"data": {
|
||||
"include_domains": "Incluir domínios",
|
||||
"include_entities": "Incluir entidades",
|
||||
"exclude_domains": "Excluir domínios",
|
||||
"exclude_entities": "Excluir entidades"
|
||||
}
|
||||
},
|
||||
"general_filters": {
|
||||
"title": "Filtros (etapa 3/4)",
|
||||
"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.",
|
||||
"data": {
|
||||
"filter": "Filtros",
|
||||
"entity_id": "ID da entidade",
|
||||
"unit_of_measurement": "Unidade de medida",
|
||||
"above": "Acima de",
|
||||
"below": "Abaixo de"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"title": "Eventos inscritos (passo 4/4)",
|
||||
"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.",
|
||||
"data": {
|
||||
"subscribe_events": "Eventos",
|
||||
"add_new_event": "Adicionar novo evento"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
custom_components/remote_homeassistant/views.py
Normal file
25
custom_components/remote_homeassistant/views.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import homeassistant
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
|
||||
ATTR_INSTALLATION_TYPE = "installation_type"
|
||||
|
||||
|
||||
class DiscoveryInfoView(HomeAssistantView):
|
||||
"""Get all logged errors and warnings."""
|
||||
|
||||
url = "/api/remote_homeassistant/discovery"
|
||||
name = "api:remote_homeassistant:discovery"
|
||||
|
||||
async def get(self, request):
|
||||
"""Get discovery information."""
|
||||
hass = request.app["hass"]
|
||||
system_info = await async_get_system_info(hass)
|
||||
return self.json(
|
||||
{
|
||||
"uuid": await hass.helpers.instance_id.async_get(),
|
||||
"location_name": hass.config.location_name,
|
||||
"ha_version": homeassistant.const.__version__,
|
||||
"installation_type": system_info[ATTR_INSTALLATION_TYPE],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user