Skip to content

Commit

Permalink
Merge pull request #55 from bj00rn/feat/support-multiple-instances
Browse files Browse the repository at this point in the history
feat: support multiple instances
  • Loading branch information
bj00rn authored Nov 13, 2024
2 parents 768e66f + 1c4fac2 commit a321134
Show file tree
Hide file tree
Showing 11 changed files with 239 additions and 137 deletions.
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
[![Project Maintenance][maintenance-shield]][user_profile]
[![BuyMeCoffee][buymecoffeebadge]][buymecoffee]

*Component to integrate with [Saleryd HRV Systems](https://saleryd.se/produkt-kategori/ftx-ventilation/)*
*Component to integrate with [Saleryd HRV unit](https://saleryd.se/produkt-kategori/ftx-ventilation/)*

## :warning: Disclaimer

:nerd_face: This integration has been developed for my HRV system for personal use.
:nerd_face: This integration has been developed for my HRV unit for personal use.

:biohazard: Be careful when altering settings on your ventilation system. Improper settings on your ventilation system can over time damage your house and personal health.

Expand All @@ -20,7 +20,7 @@

## Motivation

Monitor and control HRV system from Home Assistant.
Monitor and control Saleryd HRV units from Home Assistant.

### Ideas for automations

Expand Down Expand Up @@ -73,14 +73,14 @@ Switch | Description | State attributes

Name | Description | Fields
-- | -- | --
`set_cooling_mode` | Set cooling mode | value: `integer` (0=On, 1=Off)
`set_fireplace_mode` | Set fireplace mode | value: `integer` (0=On, 1=Off)
`set_temperature_mode` | Set temperature mode | value: `integer` (0=Normal,1=Economy,2=Cool)
`set_ventilation_mode` | Set ventilation mode | value: `integer` (0=Home,1=Away,2=Boost)
`set_system_active_mode` | Set system active mode. (Maintenance settings must be enabled) | value: `integer` (0=Off,1=On,2=Reset)
`set_target_temperature_normal` | Set target temperature for normal temperature mode. (Maintenance settings must be enabled) | value: `number` (temperature 10-30 degrees celcius)
`set_target_temperature_cool` | Set target temperature for cool temperature mode. (Maintenance settings must be enabled) | value: `number` (temperature 10-30 degrees celcius)
`set_target_temperature_economy` | Set target temperature for economy temperature mode. (Maintenance settings must be enabled) | value: `number` (temperature 10-30 degrees celcius)
`set_cooling_mode` | Set cooling mode | device: `str` target device, value: `integer` (0=On, 1=Off)
`set_fireplace_mode` | Set fireplace mode | device: `str` target device, value: `integer` (0=On, 1=Off)
`set_temperature_mode` | Set temperature mode | device: `str` target device, value: `integer` (0=Normal,1=Economy,2=Cool)
`set_ventilation_mode` | Set ventilation mode | device: `str` target device, value: `integer` (0=Home,1=Away,2=Boost)
`set_system_active_mode` | Set system active mode. (Maintenance settings must be enabled) | device: `str` target device, value: `integer` (0=Off,1=On,2=Reset)
`set_target_temperature_normal` | Set target temperature for normal temperature mode. (Maintenance settings must be enabled) | device: `str` target device, value: `number` (temperature 10-30 degrees celcius)
`set_target_temperature_cool` | Set target temperature for cool temperature mode. (Maintenance settings must be enabled) | device: `str` target device, value: `number` (temperature 10-30 degrees celcius)
`set_target_temperature_economy` | Set target temperature for economy temperature mode. (Maintenance settings must be enabled) | device: `str` target device, value: `number` (temperature 10-30 degrees celcius)

## Experimental features

Expand Down
179 changes: 104 additions & 75 deletions custom_components/saleryd_hrv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@
Custom integration to integrate Saleryd HRV system with Home Assistant.
"""

from __future__ import annotations

import asyncio
from datetime import timedelta

import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_DEVICE, CONF_NAME
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.loader import async_get_integration
from homeassistant.util import slugify
from pysaleryd.client import Client

type SalerydLokeConfigurationEntry = ConfigEntry[SalerydLokeDataUpdateCoordinator]

from .const import (
CONF_ENABLE_MAINTENANCE_SETTINGS,
CONF_MAINTENANCE_PASSWORD,
CONF_VALUE,
CONF_WEBSOCKET_IP,
CONF_WEBSOCKET_PORT,
CONFIG_VERSION,
Expand Down Expand Up @@ -62,7 +69,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_update_entry(entry, data=new_data, version=2)

if entry.version is None or entry.version < 3:
unique_id = entry.data.get(CONF_NAME, DEFAULT_NAME)
unique_id = slugify(entry.data.get(CONF_NAME, DEFAULT_NAME))
LOGGER.info("Upgrading entry to version 3, setting unique_id to %s", unique_id)
hass.config_entries.async_update_entry(
entry,
Expand All @@ -73,93 +80,73 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True


async def async_setup(hass: HomeAssistant, processed_config):
"Setup integration"
integration = await async_get_integration(hass, DOMAIN)
LOGGER.info(STARTUP_MESSAGE, integration.name, integration.version)
return True
def _get_entry_from_service_data(
hass: HomeAssistant, call: ServiceCall
) -> SalerydLokeDataUpdateCoordinator:
"""Return coordinator for entry id."""
device_id = call.get(CONF_DEVICE)
device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id)
if device is None:
raise HomeAssistantError(f"Cannot find device {device_id} is not found")
if device.disabled:
raise HomeAssistantError(f"Device {device_id} is disabled")

entry = hass.config_entries.async_get_entry(device.primary_config_entry)
if entry is None or entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError(
f"Config entry for device {device_id} is not found or not loaded"
)
return entry


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up the integration from ConfigEntry."""
hass.data.setdefault(DOMAIN, {})
def setup_hass_services(hass: HomeAssistant) -> None:
"""Register ingegration services."""

url = entry.data.get(CONF_WEBSOCKET_IP)
port = entry.data.get(CONF_WEBSOCKET_PORT)

session = async_create_clientsession(hass, raise_for_status=True)
client = Client(url, port, session, SCAN_INTERVAL.seconds)
try:
async with async_timeout.timeout(10):
await client.connect()
except (TimeoutError, asyncio.CancelledError) as ex:
client.disconnect()
raise ConfigEntryNotReady(f"Timeout while connecting to {url}:{port}") from ex
else:
coordinator = SalerydLokeDataUpdateCoordinator(hass, client, LOGGER)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.unique_id] = coordinator

# Setup platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

async def control_request(key, value=None, authenticate=False):
async def control_request(call: ServiceCall, key, authenticate=False):
"""Helper for system control calls"""
entry = _get_entry_from_service_data(hass, call.data)
value = call.data.get(CONF_VALUE)
coordinator: SalerydLokeDataUpdateCoordinator = entry.runtime_data
if authenticate:
maintenance_password = entry.data.get(CONF_MAINTENANCE_PASSWORD)
LOGGER.debug("Unlock maintenance settings control request")
await client.send_command("IP", maintenance_password)
await coordinator.client.send_command("IP", maintenance_password)

LOGGER.debug("Sending control request %s with payload %s", key, value)
await client.send_command(key, value)
await coordinator.client.send_command(key, value)

async def set_fireplace_mode(call):
async def set_fireplace_mode(call: ServiceCall):
"""Set fireplace mode"""
value = call.data.get("value")
LOGGER.debug("Sending set fire mode request of %s", value)
await control_request("MB", value)
await control_request(call, "MB")

async def set_ventilation_mode(call):
async def set_ventilation_mode(call: ServiceCall):
"""Set ventilation mode"""
value = call.data.get("value")
LOGGER.debug("Sending set ventilation mode request of %s", value)
await control_request("MF", value)
await control_request(call, "MF")

async def set_temperature_mode(call):
async def set_temperature_mode(call: ServiceCall):
"""Set temperature mode"""
value = call.data.get("value")
LOGGER.debug("Sending set temperature mode request of %s", value)
await control_request("MT", value)
await control_request(call, "MT")

async def set_cooling_mode(call):
async def set_cooling_mode(call: ServiceCall):
"""Set cooling mode"""
value = call.data.get("value")
LOGGER.debug("Sending set cooling mode request of %s", value)
await control_request("MK", value)
await control_request(call, "MK")

async def set_system_active_mode(call):
async def set_system_active_mode(call: ServiceCall):
"""Set system active mode"""
value = call.data.get("value")
LOGGER.debug("Sending system active mode request of %s", value)
await control_request("MP", value, True)
await control_request(call, "MP", True)

async def set_target_temperature_cool(call):
async def set_target_temperature_cool(call: ServiceCall):
"""Set target temperature for Cool temperature mode"""
value = call.data.get("value")
LOGGER.debug("Sending set target temperature for Cool mode of %s", value)
await control_request("TF", value, True)
await control_request(call, "TF", True)

async def set_target_temperature_normal(call):
async def set_target_temperature_normal(call: ServiceCall):
"""Set target temperature for Normal temperature mode"""
value = call.data.get("value")
LOGGER.debug("Sending set target temperature for Normal mode of %s", value)
await control_request("TD", value, True)
await control_request(call, "TD", True)

async def set_target_temperature_economy(call):
async def set_target_temperature_economy(call: ServiceCall):
"""Set target temperature for Economy temperature mode"""
value = call.data.get("value")
LOGGER.debug("Sending set target temperature for Economy mode of %s", value)
await control_request("TE", value, True)
await control_request(call, "TE", True)

services = {
SERVICE_SET_FIREPLACE_MODE: set_fireplace_mode,
Expand All @@ -177,12 +164,42 @@ async def set_target_temperature_economy(call):

# register services
for key, fn in services.items():
if hass.services.has_service(DOMAIN, key):
continue
hass.services.async_register(DOMAIN, key, fn)

# register maintenance services
if entry.data.get(CONF_ENABLE_MAINTENANCE_SETTINGS):
for key, fn in maintenance_services.items():
hass.services.async_register(DOMAIN, key, fn)
for key, fn in maintenance_services.items():
if hass.services.has_service(DOMAIN, key):
continue
hass.services.async_register(DOMAIN, key, fn)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up the integration from ConfigEntry."""

integration = await async_get_integration(hass, DOMAIN)
LOGGER.info(STARTUP_MESSAGE, integration.name, integration.version)
setup_hass_services(hass)

url = entry.data.get(CONF_WEBSOCKET_IP)
port = entry.data.get(CONF_WEBSOCKET_PORT)

session = async_create_clientsession(hass, raise_for_status=True)
client = Client(url, port, session, SCAN_INTERVAL.seconds)
try:
async with async_timeout.timeout(10):
await client.connect()
except (TimeoutError, asyncio.CancelledError) as ex:
client.disconnect()
raise ConfigEntryNotReady(f"Timeout while connecting to {url}:{port}") from ex
else:
coordinator = SalerydLokeDataUpdateCoordinator(hass, client, LOGGER)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

# Setup platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
Expand All @@ -191,16 +208,28 @@ async def set_target_temperature_economy(call):
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle unload of an entry."""

# unload platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

# disconnect client
coordinator: SalerydLokeDataUpdateCoordinator = hass.data[DOMAIN][entry.unique_id]
coordinator: SalerydLokeDataUpdateCoordinator = entry.runtime_data
coordinator.client.disconnect()

# remove services
for key in hass.services.async_services().get(DOMAIN, dict()):
hass.services.async_remove(DOMAIN, key)

# unload platforms
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
loaded_entries = [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.state is ConfigEntryState.LOADED
]
if len(loaded_entries) == 1:
# If this is the last loaded instance, deregister any services
# defined during integration setup:

for key in hass.services.async_services().get(DOMAIN, dict()):
hass.services.async_remove(DOMAIN, key)

return unload_ok


async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
Expand Down
2 changes: 1 addition & 1 deletion custom_components/saleryd_hrv/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def current_temperature(self):

async def async_setup_entry(hass, entry, async_add_entities: AddEntitiesCallback):
"""Setup sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data

entities = [
SalerydVentilation(
Expand Down
4 changes: 0 additions & 4 deletions custom_components/saleryd_hrv/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,6 @@ async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
self._errors = {}

# Comment the next 2 lines if multiple instances of the integration is allowed
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")

if user_input is not None:
try:
async with async_timeout.timeout(10):
Expand Down
1 change: 1 addition & 0 deletions custom_components/saleryd_hrv/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
CONF_WEBSOCKET_PORT = "websocket_port"
CONF_MAINTENANCE_PASSWORD = "maintenance_password"
CONF_ENABLE_MAINTENANCE_SETTINGS = "enable_maintenance_settings"
CONF_VALUE = "value"

# Defaults
DEFAULT_NAME = DOMAIN
Expand Down
4 changes: 4 additions & 0 deletions custom_components/saleryd_hrv/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ def async_set_updated_data(self, data) -> None:
_data = data.copy()
self.inject_virtual_keys(_data)
return super().async_set_updated_data(_data)

async def send_command(self, key, data):
"""Send command to client"""
await self.client.send_command(key, data)
Loading

0 comments on commit a321134

Please sign in to comment.