working 2.0 clone

This commit is contained in:
root
2024-10-01 14:06:39 +00:00
commit 873064d680
704 changed files with 46961 additions and 0 deletions

View 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()

View 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

View 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

View 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"
}

View 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"])

View 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

View 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)
)

View File

@@ -0,0 +1,2 @@
reload:
description: Reload remote_homeassistant and re-process yaml configuration.

View 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"
}
}
}
}
}

View 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"
}
}
}

View File

@@ -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"
}
}
}
}
}

View 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],
}
)