Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add parts of previous pull requests, add local unique IDs and fix latest deprecation warnings #288

Merged
merged 6 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/hassfest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v2"
- uses: "actions/checkout@v4"
- uses: "home-assistant/actions/hassfest@master"

2 changes: 1 addition & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v2"
- uses: "actions/checkout@v4"
- name: HACS validation
uses: "hacs/action@main"
with:
Expand Down
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.yaml": "home-assistant"
}
}
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![hacs][hacsbadge]][hacs]
![Project Maintenance][maintenance-shield]

<img align="left" width="80" height="80" src="https://raw.githubusercontent.com/lukas-hetzenecker/home-assistant-remote/master/icons/icon.png" alt="App icon">
<img align="left" width="80" height="80" src="icons/icon.png" alt="App icon">

# Remote Home-Assistant

Expand All @@ -19,6 +19,7 @@ The main instance connects to the Websocket APIs of the remote instances (alread

After the connection is completed, the remote states get populated into the master instance.
The entity ids can optionally be prefixed via the `entity_prefix` parameter.
The entity friendly names can optionally be prefixed via the `entity_friendly_name_prefix` parameter.

The component keeps track which objects originate from which instance. Whenever a service is called on an object, the call gets forwarded to the particular remote instance.

Expand Down Expand Up @@ -57,29 +58,29 @@ This is not needed on the main instance.

1. Add a new Remote Home-Assistant integration

<img src="https://raw.githubusercontent.com/lukas-hetzenecker/home-assistant-remote/master/img/setup.png" height="400"/>
<img src="img/setup.png" height="400"/>

2. Specify the connection details to the remote instance

<img src="https://raw.githubusercontent.com/lukas-hetzenecker/home-assistant-remote/master/img/device.png" height="400"/>
<img src="img/device.png" height="400"/>

You can generate an access token in the by logging into your remote instance, clicking on your user profile icon, and then selecting "Create Token" under "Long-Lived Access Tokens".

Check "Secure" if you want to connect via a secure (https/wss) connection

3. After the instance is added, you can configure additional Options by clicking the "Options" button.

<img src="https://raw.githubusercontent.com/lukas-hetzenecker/home-assistant-remote/master/img/options.png" height="200"/>
<img src="img/options.png" height="200"/>

4. You can configure an optional prefix that gets prepended to all remote entities (if unsure, leave this blank).

<img src="https://raw.githubusercontent.com/lukas-hetzenecker/home-assistant-remote/master/img/step1.png" height="200"/>
<img src="img/step1.png" height="200"/>

Click "Submit" to proceed to the next step.

5. You can also define filters, that include/exclude specified entities or domains from the remote instance.

<img src="https://raw.githubusercontent.com/lukas-hetzenecker/home-assistant-remote/master/img/step2.png" height="200"/>
<img src="img/step2.png" height="200"/>



Expand Down Expand Up @@ -248,7 +249,7 @@ If you have remote domains (e.g. `switch`), that are not loaded on the main inst

E.g. on the master:

```
```yaml
remote_homeassistant:
instances:
- host: 10.0.0.2
Expand Down
115 changes: 89 additions & 26 deletions custom_components/remote_homeassistant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/remote_homeassistant/
"""
from __future__ import annotations
import asyncio
from typing import Optional
import copy
import fnmatch
import inspect
Expand All @@ -13,6 +15,7 @@
from contextlib import suppress

import aiohttp
from aiohttp import ClientWebSocketResponse
import homeassistant.components.websocket_api.auth as api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
Expand All @@ -28,10 +31,12 @@
from homeassistant.core import (Context, EventOrigin, HomeAssistant, callback,
split_entity_id)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
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.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component

from custom_components.remote_homeassistant.views import DiscoveryInfoView
Expand All @@ -52,6 +57,7 @@
CONF_SECURE = "secure"
CONF_SUBSCRIBE_EVENTS = "subscribe_events"
CONF_ENTITY_PREFIX = "entity_prefix"
CONF_ENTITY_FRIENDLY_NAME_PREFIX = "entity_friendly_name_prefix"
CONF_FILTER = "filter"
CONF_MAX_MSG_SIZE = "max_message_size"

Expand All @@ -64,6 +70,7 @@
STATE_DISCONNECTED = "disconnected"

DEFAULT_ENTITY_PREFIX = ""
DEFAULT_ENTITY_FRIENDLY_NAME_PREFIX = ""

INSTANCES_SCHEMA = vol.Schema(
{
Expand Down Expand Up @@ -103,7 +110,10 @@
],
),
vol.Optional(CONF_SUBSCRIBE_EVENTS): cv.ensure_list,
vol.Optional(CONF_ENTITY_PREFIX, default=DEFAULT_ENTITY_PREFIX): cv.string,
vol.Optional(CONF_ENTITY_PREFIX,
default=DEFAULT_ENTITY_PREFIX): cv.string,
vol.Optional(CONF_ENTITY_FRIENDLY_NAME_PREFIX,
default=DEFAULT_ENTITY_FRIENDLY_NAME_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,
Expand Down Expand Up @@ -152,6 +162,7 @@ def async_yaml_to_config_entry(instance_conf):
CONF_FILTER,
CONF_SUBSCRIBE_EVENTS,
CONF_ENTITY_PREFIX,
CONF_ENTITY_FRIENDLY_NAME_PREFIX,
CONF_LOAD_COMPONENTS,
CONF_SERVICE_PREFIX,
CONF_SERVICES,
Expand Down Expand Up @@ -182,11 +193,11 @@ async def _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf):
hass.config_entries.async_update_entry(entry, data=data, options=options)


async def setup_remote_instance(hass: HomeAssistantType):
async def setup_remote_instance(hass: HomeAssistant.core.HomeAssistant):
hass.http.register_view(DiscoveryInfoView())


async def async_setup(hass: HomeAssistantType, config: ConfigType):
async def async_setup(hass: HomeAssistant.core.HomeAssistant, config: ConfigType):
"""Set up the remote_homeassistant component."""
hass.data.setdefault(DOMAIN, {})

Expand All @@ -210,7 +221,7 @@ async def _handle_reload(service):

hass.async_create_task(setup_remote_instance(hass))

hass.helpers.service.async_register_admin_service(
async_register_admin_service(hass,
DOMAIN,
SERVICE_RELOAD,
_handle_reload,
Expand Down Expand Up @@ -292,7 +303,7 @@ async def _update_listener(hass, config_entry):
await hass.config_entries.async_reload(config_entry.entry_id)


class RemoteConnection(object):
class RemoteConnection:
"""A Websocket connection to a remote home-assistant instance."""

def __init__(self, hass, config_entry):
Expand Down Expand Up @@ -326,9 +337,12 @@ def __init__(self, hass, config_entry):
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._entity_prefix = config_entry.options.get(
CONF_ENTITY_PREFIX, "")
self._entity_friendly_name_prefix = config_entry.options.get(
CONF_ENTITY_FRIENDLY_NAME_PREFIX, "")

self._connection = None
self._connection : Optional[ClientWebSocketResponse] = None
self._heartbeat_task = None
self._is_stopping = False
self._entities = set()
Expand All @@ -349,6 +363,26 @@ def _prefixed_entity_id(self, entity_id):
return entity_id
return entity_id

def _prefixed_entity_friendly_name(self, entity_friendly_name):
if (self._entity_friendly_name_prefix
and entity_friendly_name.startswith(self._entity_friendly_name_prefix)
== False):
entity_friendly_name = (self._entity_friendly_name_prefix +
entity_friendly_name)
return entity_friendly_name
return entity_friendly_name

def _full_picture_url(self, url):
baseURL = "%s://%s:%s" % (
"https" if self._secure else "http",
self._entry.data[CONF_HOST],
self._entry.data[CONF_PORT],
)
if url.startswith(baseURL) == False:
url = baseURL + url
return url
return url

def set_connection_state(self, state):
"""Change current connection state."""
signal = f"remote_homeassistant_{self._entry.unique_id}"
Expand Down Expand Up @@ -445,7 +479,7 @@ def _async_instance_id_match(info):

async def _heartbeat_loop(self):
"""Send periodic heartbeats to remote instance."""
while not self._connection.closed:
while self._connection is not None and not self._connection.closed:
await asyncio.sleep(HEARTBEAT_INTERVAL)

_LOGGER.debug("Sending ping")
Expand Down Expand Up @@ -478,9 +512,13 @@ def _next_id(self):
self.__id += 1
return _id

async def call(self, callback, message_type, **extra_args):
async def call(self, handler, message_type, **extra_args) -> None:
if self._connection is None:
_LOGGER.error("No remote websocket connection")
return

_id = self._next_id()
self._handlers[_id] = callback
self._handlers[_id] = handler
try:
await self._connection.send_json(
{"id": _id, "type": message_type, **extra_args}
Expand Down Expand Up @@ -511,7 +549,7 @@ async def _disconnected(self):
asyncio.ensure_future(self.async_connect())

async def _recv(self):
while not self._connection.closed:
while self._connection is not None and not self._connection.closed:
try:
data = await self._connection.receive()
except aiohttp.client_exceptions.ClientError as err:
Expand Down Expand Up @@ -552,13 +590,13 @@ async def _recv(self):

elif message["type"] == api.TYPE_AUTH_REQUIRED:
if self._access_token:
data = {"type": api.TYPE_AUTH, "access_token": self._access_token}
json_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)
await self._connection.send_json(json_data)
except Exception as err:
_LOGGER.error("could not send data to remote connection: %s", err)
break
Expand All @@ -570,21 +608,21 @@ async def _recv(self):
return

else:
callback = self._handlers.get(message["id"])
if callback is not None:
if inspect.iscoroutinefunction(callback):
await callback(message)
handler = self._handlers.get(message["id"])
if handler is not None:
if inspect.iscoroutinefunction(handler):
await handler(message)
else:
callback(message)
handler(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.
The affected entity_id has to originate from that remote instance,
otherwise the event is discarded.
"""
event_data = event.data
service_data = event_data["service_data"]
Expand Down Expand Up @@ -627,7 +665,10 @@ def _remove_prefix(entity_id):
data = {"id": _id, "type": event.event_type, **event_data}

_LOGGER.debug("forward event: %s", data)


if self._connection is None:
_LOGGER.error("There is no remote connecion to send send data to")
return
try:
await self._connection.send_json(data)
except Exception as err:
Expand All @@ -636,7 +677,7 @@ def _remove_prefix(entity_id):

def state_changed(entity_id, state, attr):
"""Publish remote state change on local instance."""
domain, object_id = split_entity_id(entity_id)
domain, _object_id = split_entity_id(entity_id)

self._all_entity_names.add(entity_id)

Expand All @@ -661,15 +702,15 @@ def state_changed(entity_id, state, attr):
try:
if f[CONF_BELOW] and float(state) < f[CONF_BELOW]:
_LOGGER.info(
"%s: ignoring state '%s', because " "below '%s'",
"%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'",
"%s: ignoring state '%s', because above '%s'",
entity_id,
state,
f[CONF_ABOVE],
Expand All @@ -680,15 +721,32 @@ def state_changed(entity_id, state, attr):

entity_id = self._prefixed_entity_id(entity_id)

# Add local unique id
domain, object_id = split_entity_id(entity_id)
attr['unique_id'] = f"{self._entry.unique_id[:16]}_{entity_id}"
entity_registry = er.async_get(self._hass)
entity_registry.async_get_or_create(
domain=domain,
platform='remote_homeassistant',
unique_id=attr['unique_id'],
suggested_object_id=object_id,
)

# Add local customization data
if DATA_CUSTOMIZE in self._hass.data:
attr.update(self._hass.data[DATA_CUSTOMIZE].get(entity_id))

for attrId, value in attr.items():
if attrId == "friendly_name":
attr[attrId] = self._prefixed_entity_friendly_name(value)
if attrId == "entity_picture":
attr[attrId] = self._full_picture_url(value)

self._entities.add(entity_id)
self._hass.states.async_set(entity_id, state, attr)

def fire_event(message):
"""Publish remove event on local instance."""
"""Publish remote event on local instance."""
if message["type"] == "result":
return

Expand Down Expand Up @@ -730,6 +788,11 @@ def got_states(message):
entity_id = entity["entity_id"]
state = entity["state"]
attributes = entity["attributes"]
for attr, value in attributes.items():
if attr == "friendly_name":
attributes[attr] = self._prefixed_entity_friendly_name(value)
if attr == "entity_picture":
attributes[attr] = self._full_picture_url(value)

state_changed(entity_id, state, attributes)

Expand Down
Loading
Loading