diff --git a/custom_components/brink_ventilation/__init__.py b/custom_components/brink_ventilation/__init__.py index cf68c6d..46b8dcc 100644 --- a/custom_components/brink_ventilation/__init__.py +++ b/custom_components/brink_ventilation/__init__.py @@ -2,21 +2,33 @@ from __future__ import annotations +# __init__.py import asyncio -from datetime import timedelta import logging +from datetime import timedelta from typing import Any import aiohttp - +import voluptuous as vol +from aiohttp import ClientError as ClientError +from aiohttp import ClientResponseError as ClientResponseError +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.config import ConfigType from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import service from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .api import async_get_devices from .const import ( DATA_CLIENT, DATA_COORDINATOR, @@ -27,15 +39,54 @@ PARAM_DEVICE_TYPE, PARAM_SOFTWARE_LABEL, ) +from .coordinator import BrinkCoordinator from .core.brink_home_cloud import BrinkAuthError, BrinkHomeCloud _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SELECT, Platform.BINARY_SENSOR, Platform.SENSOR, Platform.FAN] +PLATFORMS = [Platform.SELECT, Platform.BINARY_SENSOR, Platform.SENSOR, Platform.FAN, Platform.NUMBER] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +# pylint: disable=unused-argument, unnecessary-async +async def async_setup( + hass: HomeAssistant, + _config: ConfigType, +) -> bool: + """Set up the Brink Ventilation integration.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + "set_level", + entity_domain=FAN_DOMAIN, + schema={ + vol.Required("level"): vol.All( + vol.Coerce(int), + vol.In([0, 1, 2, 3]), + ), + }, + func="async_set_level", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + "set_airflow", + entity_domain=FAN_DOMAIN, + schema={ + vol.Required("airflow"): vol.All( + vol.Coerce(int), + vol.Range(min=0, max=300), + ), + }, + func="async_set_airflow", + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Brink Home from a config entry.""" username = entry.data[CONF_USERNAME] @@ -50,33 +101,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except BrinkAuthError as ex: await brink_client.close() raise ConfigEntryAuthFailed from ex - except aiohttp.ClientResponseError as ex: + except ClientResponseError as ex: await brink_client.close() if ex.status == 401: raise ConfigEntryAuthFailed from ex raise ConfigEntryNotReady from ex - except (aiohttp.ClientError, asyncio.TimeoutError) as ex: + except (ClientError, asyncio.TimeoutError) as ex: await brink_client.close() raise ConfigEntryNotReady from ex - async def async_update_data() -> dict[int, dict[str, Any]]: + async def async_update_data() -> dict[int, dict[str, object]]: try: return await async_get_devices(brink_client) except BrinkAuthError as ex: raise ConfigEntryAuthFailed from ex - except aiohttp.ClientResponseError as ex: + except ClientResponseError as ex: if ex.status == 401: raise ConfigEntryAuthFailed from ex raise UpdateFailed(ex) from ex - except (aiohttp.ClientError, asyncio.TimeoutError) as ex: + except (ClientError, asyncio.TimeoutError) as ex: raise UpdateFailed(ex) from ex - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=DOMAIN, - update_method=async_update_data, - update_interval=timedelta(seconds=scan_interval), + coordinator = BrinkCoordinator( + hass=hass, + config_entry=entry, + brink_client=brink_client, + scan_interval=scan_interval, ) hass.data.setdefault(DOMAIN, {}) @@ -95,27 +145,6 @@ async def async_update_data() -> dict[int, dict[str, Any]]: return True -async def async_get_devices(brink_client: BrinkHomeCloud) -> dict[int, dict[str, Any]]: - """Fetch and normalize Brink systems plus the parameters this integration uses.""" - systems = await brink_client.get_systems() - - devices: dict[int, dict[str, Any]] = {} - for system in systems: - system_id = system["system_id"] - parameters = await brink_client.get_device_data(system_id) - devices[system_id] = { - "system_id": system_id, - "name": system.get("name") or DEFAULT_NAME, - "serial_number": system.get("serial_number"), - "gateway_state": system.get("gateway_state"), - "model": parameters.get(PARAM_DEVICE_TYPE, {}).get("value") or DEFAULT_MODEL, - "sw_version": parameters.get(PARAM_SOFTWARE_LABEL, {}).get("value"), - "parameters": parameters, - } - - return devices - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/brink_ventilation/api.py b/custom_components/brink_ventilation/api.py new file mode 100644 index 0000000..f1c0d96 --- /dev/null +++ b/custom_components/brink_ventilation/api.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +# api.py +import asyncio +import logging + +from aiohttp import ClientError as ClientError +from aiohttp import ClientResponseError as ClientResponseError + +from .const import ( + DEFAULT_MODEL, + DEFAULT_NAME, + PARAM_DEVICE_TYPE, + PARAM_SOFTWARE_LABEL, +) +from .core.brink_home_cloud import BrinkHomeCloud +from .models import BrinkDeviceData + +_LOGGER = logging.getLogger(__name__) +PARAM_IP_ADDRESS = "ip_address" +PARAM_DNS_SERVER = "dns_server" +PARAM_API_ONLINE = "api_is_online" + + +async def async_get_devices(brink_client: BrinkHomeCloud) -> dict[int, BrinkDeviceData]: + """Fetch and normalize Brink systems plus the parameters this integration uses.""" + systems = await brink_client.get_systems() + + _LOGGER.debug("Brink Flair nr of systems: %s", len(systems)) + + # device = await brink_client.get_device(3074) + + # _LOGGER.debug("Brink Flair device: %s", device) + + # gateway_type_id = device.get("gatewayTypeId") + # is_online = device.get("isOnline") + + # _LOGGER.debug("Brink Flair gateway_type_id: %s", gateway_type_id) # always 3 ? + # _LOGGER.debug("Brink Flair device is_online: %s", is_online) + + devices: dict[int, BrinkDeviceData] = {} + + for system in systems: + system_id = int(system["system_id"]) + is_system_owner = system["is_system_owner"] + is_editable = system["is_editable"] + access_level = system["access_level"] + owner_group_name = system["owner_group_name"] + is_favorite = system["is_favorite"] + gateway_state = system["gateway_state"] + active_alert_count = system["active_alert_count"] + iana_time_zone = system["iana_time_zone"] + two_letter_country_code = system["two_letter_country_code"] + user_group_names = system["user_group_names"] + total_count = system["total_count"] + _LOGGER.debug("Brink Flair system [%s] properties: %s", system_id, system) + device, alerts, parameters = await asyncio.gather( + brink_client.async_get_device(system_id), + brink_client.async_get_active_alerts(system_id), + brink_client.get_device_data(system_id), + ) + _LOGGER.debug("Parsed alerts: %s", alerts) + _LOGGER.debug("Brink Flair device: %s", device) + gateway_type_id = device.get("gatewayTypeId") + is_online = device.get("isOnline") + _LOGGER.debug("Brink Flair device parameters: %s", parameters) + devices[system_id] = BrinkDeviceData( + api_is_online=True, + system_id=system_id, + name=system.get("name") or DEFAULT_NAME, + internal_name=system.get("name") + or system.get("name") + or DEFAULT_NAME, + serial_number=system.get("serial_number"), + gateway_state=system.get("gateway_state"), + gateway_type_id=gateway_type_id, + device_is_online=bool(is_online), + ip_address=( + str(parameters[PARAM_IP_ADDRESS]["value"]) + if PARAM_IP_ADDRESS in parameters + else None + ), + dns_server=( + str(parameters[PARAM_DNS_SERVER]["value"]) + if PARAM_DNS_SERVER in parameters + else None + ), + model=str( + parameters.get(PARAM_DEVICE_TYPE, {}).get("value") + or DEFAULT_MODEL + ), + sw_version=( + str(parameters.get(PARAM_SOFTWARE_LABEL, {}).get("value")) + if parameters.get(PARAM_SOFTWARE_LABEL, {}).get("value") is not None + else None + ), + parameters=parameters, + alerts=alerts, + ) + + return devices diff --git a/custom_components/brink_ventilation/binary_sensor.py b/custom_components/brink_ventilation/binary_sensor.py index 8a063e4..3368397 100644 --- a/custom_components/brink_ventilation/binary_sensor.py +++ b/custom_components/brink_ventilation/binary_sensor.py @@ -1,7 +1,16 @@ from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity +import logging +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from .const import ( @@ -10,100 +19,397 @@ DOMAIN, GATEWAY_STATE_LABELS, GATEWAY_STATE_ONLINE, + PARAM_CN1_POSITION, + PARAM_CN1_SWITCH_INPUT, + PARAM_CN2_POSITION, + PARAM_CN2_SWITCH_INPUT, PARAM_FILTER_STATUS, + PARAM_RH_SENSOR_STATUS, ) -from .entity import BrinkHomeDeviceEntity, BrinkHomeSystemEntity +from .entity import BrinkHomeDeviceEntity +from .models import BrinkAlertLevel +_LOGGER: logging.Logger = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): - """Set up the Brink filter status binary sensor platform.""" - client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - entities = [] - for system_id, device in (coordinator.data or {}).items(): - if device.get("gateway_state") is not None: - entities.append(BrinkSystemOnlineBinarySensor(client, coordinator, system_id)) - if device.get("parameters", {}).get(PARAM_FILTER_STATUS): - entities.append( - BrinkFilterNeedChangeBinarySensor( - client, coordinator, system_id, PARAM_FILTER_STATUS - ) - ) +@dataclass(frozen=True, kw_only=True) +class BrinkBinarySensorDescription(BinarySensorEntityDescription): + """Describe a Brink binary sensor.""" - async_add_entities(entities) + parameter_key: str + requires_parameter: bool = True + value_fn: Callable[ + [BrinkHomeBinarySensorEntity], + bool | None, + ] | None = None -def _gateway_state_value(device: dict | None) -> int | None: - """Normalize the Brink gateway state.""" - if device is None: - return None + attr_fn: Callable[ + [BrinkHomeBinarySensorEntity], + dict[str, object], + ] | None = None - state = device.get("gateway_state") - try: - return int(state) - except (TypeError, ValueError): - return None + icon_fn: Callable[ + [BrinkHomeBinarySensorEntity], + str | None, + ] | None = None + available_fn: Callable[ + [BrinkHomeBinarySensorEntity], + bool, + ] | None = None -class BrinkSystemOnlineBinarySensor(BrinkHomeSystemEntity, BinarySensorEntity): - """Binary sensor that reflects whether the Brink system is online.""" - @property - def unique_id(self): - return f"{DOMAIN}_{self.system_id}_online" +class BrinkHomeBinarySensorEntity( + BrinkHomeDeviceEntity, + BinarySensorEntity, +): + """Representation of a Brink binary sensor.""" - @property - def name(self): - return f"{self.device_name} Online" + _attr_has_entity_name = True + + entity_description: BrinkBinarySensorDescription + + def __init__( + self, + client, + coordinator, + system_id: int, + description: BrinkBinarySensorDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__( + client, + coordinator, + system_id, + description.parameter_key, + ) + self.entity_description = description @property - def is_on(self): - state = _gateway_state_value(self._device) - if state is None: - return None - return state == GATEWAY_STATE_ONLINE + def unique_id(self) -> str: + """Return a unique ID.""" + return ( + f"{DOMAIN}_{self.system_id}_" + f"{self.entity_description.key}" + ) @property - def device_class(self): - return BinarySensorDeviceClass.CONNECTIVITY + def icon(self) -> str | None: + """Return the entity icon.""" + icon_fn = self.entity_description.icon_fn + + if icon_fn is not None: + return icon_fn(self) + + return self.entity_description.icon @property - def extra_state_attributes(self): - state = _gateway_state_value(self._device) - if state is None: + def is_on(self) -> bool | None: + """Return sensor state.""" + + value_fn = self.entity_description.value_fn + + if value_fn is not None: + return value_fn(self) + + data = self.data + + if data is None: return None - return { - "gateway_state": state, - "gateway_state_label": GATEWAY_STATE_LABELS.get(state, "unknown"), - } + value = data.get("value") + if value is None: + return None -class BrinkFilterNeedChangeBinarySensor(BrinkHomeDeviceEntity, BinarySensorEntity): - """Binary sensor that indicates when the Brink filter needs attention.""" + return str(value) == "1" @property - def unique_id(self): - return f"{DOMAIN}_{self.system_id}_{self.parameter_key}_binary_sensor" + def available(self) -> bool: + """Return if entity is available.""" + + available_fn = self.entity_description.available_fn + if available_fn is not None: + return bool(available_fn(self)) + + if not self.entity_description.requires_parameter: + return True + + if not super().available: + return False + + data = self.data + + return ( + data is not None + and data.get("value_state") != 5 + ) @property - def name(self): - return f"{self.device_name} {self.parameter_name}" + def extra_state_attributes(self) -> dict[str, object]: + """Return extra state attributes.""" + attributes: dict[str, object] = { + "key": _debug_value(self.entity_description.key), + "translation_key": _debug_value( + self.entity_description.translation_key, + ), + } + + data = self.data + if data is not None: + attributes.update( + { + "name": _debug_value(data.get("name")), + "raw_name": _debug_value(data.get("raw_name")), + "value": _debug_value(data.get("value")), + "value_state": _debug_value(data.get("value_state")), + "default_value": _debug_value(data.get("default_value")), + "numeric_id": _debug_value(data.get("numeric_id")), + "read_write": _debug_value(data.get("read_write")), + "control_type": _debug_value(data.get("control_type")), + "value_id": _debug_value(data.get("value_id")), + "list_items": _debug_value(data.get("list_items")), + "component_id": _debug_value(data.get("component_id")), + "options": data.get("options"), + "options_str": _debug_value(data.get("options")), + } + ) + + # Binary sensors should not expose these. If present they + # probably belong to a Number or Select entity. + for key in ( + "min_value", + "max_value", + "step_width", + "decimals", + "unit_of_measure", + ): + if data.get(key) is not None: + attributes[key] = data[key] + + attr_fn = self.entity_description.attr_fn + if attr_fn is not None: + custom_attributes = attr_fn(self) + if custom_attributes: + attributes.update(custom_attributes) + + return attributes @property - def icon(self): - return "mdi:air-filter" + def gateway_state(self) -> int | None: + """Return the gateway state.""" + device = self._device + return None if device is None else device.gateway_state @property - def is_on(self): - param = self.data - if param is None: - return None - return str(param.get("value")) == "1" + def device_is_online(self) -> bool | None: + """Return whether the device is online.""" + device = self._device + + return None if device is None else device.device_is_online @property - def device_class(self): - return BinarySensorDeviceClass.PROBLEM + def gateway_type_id(self) -> int | None: + """Return the gateway type ID.""" + device = self._device + + return None if device is None else device.gateway_type_id + + +def _debug_value(value: object) -> str: + """Convert a value to a debug-friendly string.""" + return str(value) + + +BINARY_SENSOR_DESCRIPTIONS: tuple[BrinkBinarySensorDescription, ...] = ( + BrinkBinarySensorDescription( + key="gateway_status", + translation_key="gateway_status", + parameter_key="gateway_status", + requires_parameter=False, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda entity: entity.gateway_state == GATEWAY_STATE_ONLINE, + attr_fn=lambda entity: { + "gateway_state": entity.gateway_state, + "device_is_online": entity.device_is_online, + "gateway_type_id": entity.gateway_type_id, + "gateway_state_label": GATEWAY_STATE_LABELS.get( + entity.gateway_state, + "unknown", + ), + }, + available_fn=lambda entity: entity.coordinator.last_update_success, + icon_fn=lambda entity: ( + "mdi:connection" + if entity.is_on + else "mdi:lan-disconnect" + ), + ), + BrinkBinarySensorDescription( + key="device_online", + translation_key="device_online", + parameter_key="device_online", + requires_parameter=False, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda entity: entity.device_is_online, + available_fn=lambda entity: entity.coordinator.last_update_success, + icon_fn=lambda entity: ( + "mdi:connection" + if entity.is_on + else "mdi:lan-disconnect" + ), + ), + BrinkBinarySensorDescription( + key=PARAM_FILTER_STATUS, + translation_key="status_filter_message", + parameter_key=PARAM_FILTER_STATUS, + icon="mdi:air-filter", + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda entity: entity.parameter_value == "1" + ), + BrinkBinarySensorDescription( + key=PARAM_RH_SENSOR_STATUS, + translation_key="rh_sensor_status", + parameter_key=PARAM_RH_SENSOR_STATUS, + icon="mdi:water-percent", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + ), + BrinkBinarySensorDescription( + key="ebus_co2_sensor_status", + translation_key="ebus_co2_sensor_status", + parameter_key="ebus_co2_sensor_status", + icon="mdi:molecule-co2", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + available_fn=lambda data: True, + entity_registry_enabled_default=False, + ), + BrinkBinarySensorDescription( + key="cn1_position", + translation_key="cn1_position", + parameter_key=PARAM_CN1_POSITION, + icon="mdi:electric-switch", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkBinarySensorDescription( + key="cn2_position", + translation_key="cn2_position", + parameter_key=PARAM_CN2_POSITION, + icon="mdi:electric-switch", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkBinarySensorDescription( + key="cn1_switch_input", + translation_key="cn1_switch_input", + parameter_key=PARAM_CN1_SWITCH_INPUT, + icon="mdi:electric-switch", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + BrinkBinarySensorDescription( + key="cn2_switch_input", + translation_key="cn2_switch_input", + parameter_key=PARAM_CN2_SWITCH_INPUT, + icon="mdi:electric-switch", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + BrinkBinarySensorDescription( + key="blocking_error", + translation_key="blocking_error", + parameter_key="blocking_error", + requires_parameter=False, + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:alert-octagon", + value_fn=lambda entity: entity.coordinator.has_blocking_error, + attr_fn=lambda entity: { + "alert_count": len(entity.coordinator.active_alerts), + "highest_level": ( + max( + (alert.level for alert in entity.coordinator.active_alerts), + default=BrinkAlertLevel.INFORMATION, + ).name + ), + "highest_level_value": max( + (int(alert.level) for alert in entity.coordinator.active_alerts), + default=BrinkAlertLevel.INFORMATION, + ), + "alerts": [ + { + "id": alert.id, + "component_id": alert.component_id, + "code": alert.code, + "level": alert.level.name, + "level_value": int(alert.level), + "is_active": alert.is_active, + "is_archived": alert.is_archived, + "incoming": alert.incoming.isoformat(), + "outgoing": ( + alert.outgoing.isoformat() + if alert.outgoing is not None + else None + ), + "code_text": alert.code_text, + "description": alert.description, + } + for alert in entity.coordinator.active_alerts + ], + }, + ), + BrinkBinarySensorDescription( + key="api_online", + translation_key="api_online", + parameter_key="api_online", + requires_parameter=False, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda entity: entity.api_is_online, + attr_fn=lambda entity: ( + { + "api_error": entity.api_error, + "api_error_code": entity.api_error_code, + "api_error_type": entity.api_error_type, + } + if not entity.api_is_online + else {} + ), + available_fn=lambda entity: True, + icon_fn=lambda entity: ( + "mdi:cloud-check" + if entity.is_on + else "mdi:cloud-off-outline" + ), + entity_category=EntityCategory.DIAGNOSTIC, + ) +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities, +) -> None: + """Set up Brink binary sensors.""" + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + + entities = [] + + for system_id, device in (coordinator.data or {}).items(): + for description in BINARY_SENSOR_DESCRIPTIONS: + if ( + not description.requires_parameter + or device.parameters.get(description.parameter_key) is not None + ): + entities.append( + BrinkHomeBinarySensorEntity( + client, + coordinator, + system_id, + description, + ) + ) + + async_add_entities(entities) diff --git a/custom_components/brink_ventilation/brand/logo.png b/custom_components/brink_ventilation/brand/logo.png new file mode 100644 index 0000000..b7d5dea Binary files /dev/null and b/custom_components/brink_ventilation/brand/logo.png differ diff --git a/custom_components/brink_ventilation/const.py b/custom_components/brink_ventilation/const.py index 9e8da79..7f840a4 100644 --- a/custom_components/brink_ventilation/const.py +++ b/custom_components/brink_ventilation/const.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import dataclass + DOMAIN = "brink_ventilation" DEFAULT_NAME = "Brink" DEFAULT_MODEL = "Brink ventilation" @@ -11,14 +13,115 @@ DEFAULT_SCAN_INTERVAL = 30 -API_V1_URL = "https://www.brink-home.com/portal/api/v1.1/" +API_BASE = "https://www.brink-home.com" +API_V1_URL = f"{API_BASE}/portal/api/v1.1/" + +ALERTS_URL = ( + f"{API_V1_URL}/systems/{{system_id}}/alerts/criterion" + "?pageIndex=0" + "&pageSize=10" + "&sortColumn=level" + "&sortDirection=2" + "&filterSpecs%5B0%5D.fieldName=isArchived" + "&filterSpecs%5B0%5D.value=false" +) -OIDC_AUTH_URL = "https://www.brink-home.com/idsrv/connect/authorize" -OIDC_TOKEN_URL = "https://www.brink-home.com/idsrv/connect/token" +OIDC_AUTH_URL = f"{API_BASE}/idsrv/connect/authorize" +OIDC_TOKEN_URL = f"{API_BASE}/idsrv/connect/token" OIDC_CLIENT_ID = "spa" -OIDC_REDIRECT_URI = "https://www.brink-home.com/app/" +OIDC_REDIRECT_URI = f"{API_BASE}/app/" OIDC_SCOPE = "openid api role locale" +MODE_AUTOMATIC = "automatic" +MODE_MANUAL = "manual" +MODE_HOLIDAY = "holiday" +MODE_PARTY = "party" +MODE_NIGHT = "night_ventilation" + +ACCESS_LEVEL_MAP: dict[int, str] = { + 4: "User (Read)", + 12: "User (Read/Write)", + 16: "User+ (Read)", + 48: "User+ (Read/Write)", + 256: "Expert (Read)", + 768: "Expert (Read/Write)", + 16384: "Manufacturer (Read)", + 49152: "Manufacturer (Read/Write)", +} + +ALERTS_URL_PARAMS = { + "pageIndex": 0, + "pageSize": 10, + "sortColumn": "level", + "sortDirection": 2, + "filterSpecs[0].fieldName": "isArchived", + "filterSpecs[0].value": "false", +} + +ACCESS_LEVELS_FOR_SYSTEM_SHARE: dict[int, str] = { + 4: "accessLevelUserRead", + 12: "accessLevelUserReadWrite", + 16: "accessLevelUserPlusRead", + 48: "accessLevelUserPlusReadWrite", + 256: "accessLevelExpertRead", + 768: "accessLevelExpertReadWrite", + 16384: "accessLevelManufacturerRead", + 49152: "accessLevelManufacturerReadWrite", +} + +LEVEL_LABELS = { + 0: "off", + 1: "low", + 2: "medium", + 3: "high", + 4: "CN1", +} + +VENTILATION_LEVEL_LABELS = { + 0: "Off", + 1: "Low", + 2: "Medium", + 3: "High", + 4: "CN1", +} + +# when changing gateway password within brink device the portal will be in locked state untill you (re)logged in to the portal again. +GATEWAY_STATE_LABELS: dict[int, str] = { + 0: "locked", + 1: "offline", + 2: "online", +} + +GATEWAY_STATE_MAP = { + 0: "locked", + 1: "offline", + 2: "online", +} + +GATEWAY_TYPE_LABELS = { + 3: "Brink Home Gateway", +} + +VALUE_STATE_VALID = 0 +VALUE_STATE_CONFIG = 1 +VALUE_STATE_NO_DATA = 5 + +VALUE_STATE_MAP: dict[int, str] = { + VALUE_STATE_VALID: "valid", + VALUE_STATE_CONFIG: "config", + VALUE_STATE_NO_DATA: "no_data", +} + +CONTROL_TYPE_ENUM = 0 +CONTROL_TYPE_NUMERIC = 6 +CONTROL_TYPE_TEXT = 9 + +CONTROL_TYPE_MAP: dict[int, str] = { + CONTROL_TYPE_ENUM: "enum", + CONTROL_TYPE_NUMERIC: "numeric", + CONTROL_TYPE_TEXT: "text", +} + PARAM_DEVICE_TYPE = "device_type" PARAM_SOFTWARE_LABEL = "software_label" PARAM_VENTILATION_LEVEL = "ventilation_level" @@ -26,12 +129,19 @@ PARAM_FILTER_STATUS = "filter_status" PARAM_REMAINING_DURATION = "remaining_duration" PARAM_ACTIVE_CONTROL_STATUS = "active_control_status" -PARAM_SUPPLY_AIR_FLOW = "supply_air_flow" -PARAM_EXHAUST_AIR_FLOW = "exhaust_air_flow" +PARAM_SUPPLY_AIR_FLOW = "actual_supply_air_flow" +PARAM_ACTUAL_SUPPLY_AIR_FLOW = "actual_supply_air_flow" +PARAM_ACTUAL_EXHAUST_AIR_FLOW = "actual_exhaust_air_flow" +PARAM_EXHAUST_AIR_FLOW = "actual_exhaust_air_flow" +PARAM_NOMINAL_SUPPLY_AIR_FLOW = "nominal_supply_air_flow" +PARAM_NOMINAL_EXHAUST_AIR_FLOW = "nominal_exhaust_air_flow" PARAM_EXHAUST_TEMP = "exhaust_temp" PARAM_FRESH_AIR_TEMP = "fresh_air_temp" PARAM_SUPPLY_TEMP = "supply_temp" +PARAM_SUPPLY_AIR_PRESSURE = "supply_air_pressure" +PARAM_EXHAUST_AIR_PRESSURE = "exhaust_air_pressure" PARAM_HUMIDITY = "humidity" +PARAM_PREHEATER_POWER = "preheater_power" PARAM_PREHEATER_STATUS = "preheater_status" PARAM_BYPASS_VALVE_STATUS = "bypass_valve_status" PARAM_BYPASS_OPERATION = "bypass_operation" @@ -40,6 +150,29 @@ PARAM_CO2_SENSOR_3 = "co2_sensor_3" PARAM_CO2_SENSOR_4 = "co2_sensor_4" PARAM_DAYS_SINCE_FILTER_RESET = "days_since_filter_reset" +PARAM_VENTILATION_MODE_0 = "ventilation_mode_0_airflow" +PARAM_VENTILATION_MODE_1 = "ventilation_mode_1_airflow" +PARAM_VENTILATION_MODE_2 = "ventilation_mode_2_airflow" +PARAM_VENTILATION_MODE_3 = "ventilation_mode_3_airflow" +PARAM_FROST_PROTECTION_STATUS = "frost_protection_status" +PARAM_STATUS_GEOTHERMAL_HEAT_EXCHANGER = "status_geothermal_heat_exchanger" +PARAM_RH_SENSOR_STATUS = "rh_sensor_status" +PARAM_CO1_SENSOR_STATUS = "co1_sensor_status" +PARAM_CO2_SENSOR_STATUS = "co2_sensor_status" +PARAM_CN1_POSITION = "cn1_position" +PARAM_CN2_POSITION = "cn2_position" +PARAM_CONTACT_1_EXHAUST_FAN_ACTION = "contact_1_exhaust_fan_action" +PARAM_CONTACT_2_EXHAUST_FAN_ACTION = "contact_2_exhaust_fan_action" +PARAM_CN1_SWITCH_INPUT_CONDITION = "cn1_switch_input_condition" +PARAM_CN2_SWITCH_INPUT_CONDITION = "cn2_switch_input_condition" + +CN_SWITCH_INPUT_CONDITION_LABELS: dict[str, str] = { + "0": "off", + "1": "on", + "2": "on_if_bypass_conditions_met", + "3": "bypass_control", + "4": "bedroom_valve", +} PARAM_NAME_MAP: dict[str, str] = { "deviceTypeTitle": PARAM_DEVICE_TYPE, @@ -65,22 +198,63 @@ "Anzahl der Tage seit Filterreset": PARAM_DAYS_SINCE_FILTER_RESET, } -ACTIVE_CONTROL_STATUS_LABELS: dict[str, str] = { - "0": "Standby", - "1": "Bootloader", - "2": "Non-blocking Error", - "3": "Blocking Error", - "4": "Manual", - "5": "Holiday", - "6": "Night Ventilation", - "7": "Party", - "8": "Bypass Boost", - "9": "Normal Boost", - "10": "Auto CO2", - "11": "Auto eBus", - "12": "Auto Modbus", - "13": "Auto LAN/WLAN Portal", - "14": "Auto LAN/WLAN Local", +RH_SENSOR_SENSITIVITY_LABELS: dict[str, str] = { + "-2": "very_low", + "-1": "low", + "0": "normal", + "1": "high", + "2": "very_high", +} + +BYPASS_VALVE_STATUS_LABELS: dict[int, str] = { + 0: "initialization", + 1: "opening", + 2: "closing", + 3: "open", + 4: "closed", +} + +FILTER_FAULT_CONDITION_LABELS: dict[str, str] = { + "0": "off", + "1": "filter_condition_only", + "2": "fault_condition_only", + "3": "filter_and_fault_condition", +} + +ACTIVE_CONTROL_STATUS_LABELS: dict[int, str] = { + 0: "Standby", + 1: "Bootloader", + 2: "Non-blocking Error", + 3: "Blocking Error", + 4: "Manual", + 5: "Holiday", + 6: "Night Ventilation", + 7: "Party", + 8: "Bypass Boost", + 9: "Normal Boost", + 10: "Auto CO2", + 11: "Auto eBus", + 12: "Auto Modbus", + 13: "Auto LAN/WLAN Portal", + 14: "Auto LAN/WLAN Local", +} + +ACTIVE_CONTROL_STATUS_LABELS: dict[int, str] = { + 0: "standby", + 1: "bootloader", + 2: "non_locking_fault", + 3: "blocking_error", + 4: "manual", + 5: "holiday", + 6: "night_ventilation_mode", + 7: "party", + 8: "bypass_boost", + 9: "normal_boost", + 10: "auto_co2", + 11: "auto_ebus", + 12: "auto_modbus", + 13: "auto_lan_wlan_portal", + 14: "auto_lan_wlan_local", } BYPASS_OPERATION_LABELS: dict[str, str] = { @@ -89,6 +263,18 @@ "2": "Bypass Open", } +FAN_ACTION_LABELS: dict[str, str] = { + "0": "fan_off", + "1": "fan_absolute_minimum", + "2": "fan_setting_1", + "3": "fan_setting_2", + "4": "fan_setting_3", + "5": "fan_setting_0", + "6": "fan_multiple_switch", + "7": "fan_absolute_maximum", + "8": "no_exhaust_fan_control", +} + # gatewayState enum from the Brink web app. GATEWAY_STATE_LOCKED = 0 GATEWAY_STATE_OFFLINE = 1 @@ -100,5 +286,689 @@ GATEWAY_STATE_ONLINE: "online", } +FILTER_STATUS_LABELS: dict[int, str] = { + 0: "not_dirty", + 1: "dirty", +} + +CONTACT_TYPE_LABELS: dict[str, str] = { + "0": "normally_open", + "1": "normally_closed", +} + +ON_OFF_LABELS = { + "0": "off", + "1": "on", +} + +OPERATING_MODE_LABELS: dict[str, str] = { + "0": "automatic", + "1": "manual", + "2": "holiday", + "3": "party", + "4": "night_ventilation", +} + +FAN_OPERATING_MODE_LABELS: dict[str, str] = { + "0": "Automatic", + "1": "Manual", + "2": "Holiday", + "3": "Party", + "4": "Night ventilation", +} + +VALVE_CONTROL_LABELS: dict[str, str] = { + "0": "relay_output_1", + "1": "relay_output_2", + "2": "analog_output_1", + "3": "analog_output_2", +} + +_SIGNAL_OUTPUT_MODE_LABELS: dict[str, str] = { + "0": "0V", + "1": "24V", +} + +SIGNAL_OUTPUT_MODE_LABELS: dict[str, str] = { + "0": "Off", + "1": "Only filtercondition", + "2": "Only faultcondition", + "3": "Filter and fault condition", +} + +CN_CONDITIONS_LABELS: dict[str, str] = { + "0": "Only filtercondition", + "1": "Only faultcondition", + "2": "Filter and fault condition", +} + +FROST_PROTECTION_STATE_LABELS: dict[str, str] = { + "0": "unknown", + "1": "not_initialized", + "2": "power_up_delay", + "3": "no_frost", + "4": "start_delay", + "5": "wait_for_ice", + "6": "heating", + "7": "wait_for_fan_control", + "8": "fan_control", + "9": "fan_off", + "10": "fan_restart", + "11": "error", + "12": "water_block_test", +} + +FROST_PROTECTION_STATUS_LABELS: dict[int, str] = { + 0: "not_initialized", + 1: "power_up_delay", + 2: "no_frost", + 3: "no_frost_delay", + 4: "frost_control_start_delay", + 5: "wait_for_icing", + 6: "ice_detected_delay", + 7: "heating", + 8: "wait_for_free_heater", + 9: "fan_control_start_delay", + 10: "fan_control_wait", + 11: "fan_control", + 12: "fan_off_delay", + 13: "fan_off", + 14: "fan_restarting", + 15: "error", + 16: "periodic_coil_test", +} + +FROST_PROTECTION_STATE_TRANSLATIONS: dict[str, str] = { + "unknown": "Onbekend", + "not_initialized": "Niet geïnitialiseerd", + "power_up_delay": "Wachten op opstarten", + "no_frost": "Geen vorst", + "start_delay": "Opstartvertraging", + "wait_for_ice": "Wachten op ijsvorming", + "heating": "Verwarmen", + "wait_for_fan_control": "Wachten op ventilatorregeling", + "fan_control": "Ventilatorregeling", + "fan_off": "Ventilator uit", + "fan_restart": "Herstart ventilator", + "error": "Fout", + "water_block_test": "Waterbloktest", +} + +PREHEATER_STATUS_LABELS: dict[int, str] = { + 0: "off", + 1: "auto", + 2: "lock_current", + 3: "lock_maximum", +} + +GEOTHERMAL_HEAT_EXCHANGER_LABELS: dict[int, str] = { + 0: "open_low", + 1: "closed", + 3: "open_high", +} + +CN_POSITION_LABELS: dict[str, str] = { + "0": "closed", + "1": "Open", +} + +SUPPLY_FAN_ACTION_LABELS: dict[str, str] = { + "0": "Supply fan off", + "1": "Min. ventilation 50 m³/h", + "2": "Air flow Level 1", + "3": "Air flow Level 2", + "4": "Air flow Level 3", + "5": "Max. air flow", + "6": "No control supply fan", +} + +EXHAUST_FAN_ACTION_LABELS: dict[str, str] = { + "0": "Extract fan off", + "1": "Min. ventilation 50 m³/h", + "2": "Air flow Level 1", + "3": "Air flow Level 2", + "4": "Air flow Level 3", + "5": "Max. air flow", + "6": "No control extract fan", +} + +BINARY_SENSOR_LABELS: dict[str, str] = { + "0": "off", + "1": "on", +} + +MODE_VALVE_24V_CONTROL_LABELS: dict[str, str] = { + "0": "open", + "1": "closed", +} + +MODE_INPUT_LABELS: dict[str, str] = { + "0": "Off", + "1": "On", +} + +PARAM_VENTILATION_MODE_0 = "ventilation_mode_0_airflow" +PARAM_VENTILATION_MODE_1 = "ventilation_mode_1_airflow" +PARAM_VENTILATION_MODE_2 = "ventilation_mode_2_airflow" +PARAM_VENTILATION_MODE_3 = "ventilation_mode_3_airflow" + +PARAM_SWITCH_TEMP_1 = "switch_temp_1" +PARAM_SWITCH_TEMP_2 = "switch_temp_2" + +PARAM_IMBALANCE_FIREPLACE = "imbalance_fireplace" +PARAM_BYPASS_TEMPERATURE = "bypass_temperature" +PARAM_BYPASS_HYSTERESIS = "bypass_hysteresis" +PARAM_MINIMUM_INTAKE_TEMPERATURE = "minimum_intake_temperature" +PARAM_BYPASS_FUNCTION = "bypass_function" +PARAM_MODE_VALVE_24V_CONTROL = "mode_valve_24v_control" +PARAM_VALVE_CONTROL = "valve_control" +PARAM_SIGNAL_OUTPUT_MODE = "signal_output_mode" +PARAM_FILTER_STATUS = "filter_status" +PARAM_FILTER_MESSAGE = "filter_message" +PARAM_CN1_SWITCH_INPUT = "cn1_switch_input" +PARAM_CN2_SWITCH_INPUT = "cn2_switch_input" +PARAM_EXHAUST_AIR_PRESSURE = "exhaust_air_pressure" +PARAM_SUPPLY_AIR_PRESSURE = "supply_air_pressure" +PARAM_RH_SENSOR_SENSITIVITY = "rh_sensor_sensitivity" +PARAM_DAYS_UNTIL_FILTER_MESSAGE = "days_until_filter_message" +PARAM_CO2_SENSOR_1_MIN_PPM = "co2_sensor_1_min_ppm" +PARAM_CO2_SENSOR_1_MAX_PPM = "co2_sensor_1_max_ppm" +PARAM_CO2_SENSOR_2_MIN_PPM = "co2_sensor_2_min_ppm" +PARAM_CO2_SENSOR_2_MAX_PPM = "co2_sensor_2_max_ppm" +PARAM_CO2_SENSOR_3_MIN_PPM = "co2_sensor_3_min_ppm" +PARAM_CO2_SENSOR_3_MAX_PPM = "co2_sensor_3_max_ppm" +PARAM_CO2_SENSOR_4_MIN_PPM = "co2_sensor_4_min_ppm" +PARAM_CO2_SENSOR_4_MAX_PPM = "co2_sensor_4_max_ppm" + +PARAMETER_NAMES = { + 16000: "device_type", + 16001: "nominal_supply_air_flow", + 16002: "nominal_exhaust_air_flow", + 16006: "filter_status", + 16007: "days_since_filter_reset", + 16009: "active_control_status", + 16011: "ventilation_level", + 16012: "operating_mode", + 16015: "actual_supply_air_flow", # Sensor + 16016: "supply_air_flow_setpoint", + 16017: "exhaust_air_flow", + 16018: "exhaust_air_flow_setpoint", + 16019: "fresh_air_temp", + 16020: "supply_air_temp", # Sensor + 16021: "exhaust_air_temp", + 16022: "discharge_air_temp", + 16024: "bypass_valve_status", + 16025: "preheater_status", + 16031: "ventilation_mode_0_airflow", + 16032: "ventilation_mode_1_airflow", + 16033: "ventilation_mode_2_airflow", + 16034: "ventilation_mode_3_airflow", + 16038: "supply_air_pressure", + 16039: "exhaust_air_pressure", + 16041: "bypass_valve_status", # Enum sensor (duplicaat van 16024) + 16042: "bypass_temperature", + 16043: "bypass_hysteresis", + 16044: "bypass_operation", + 16048: "frost_protection_status", # Enum sensor + 16049: "preheater_power", + 16054: "filter_message", + 16055: "days_since_filter_reset", + 16057: "days_until_filter_message", + 16059: "relative_humidity", + 16060: "rh_sensor_status", # Binary sensor + 16061: "rh_sensor_sensitivity", + 16062: "ebus_co2_sensor_status", # Binary sensor + 16064: "co2_sensor_1", + 16065: "co2_sensor_1_min_ppm", + 16066: "co2_sensor_1_max_ppm", + 16068: "co2_sensor_2", + 16069: "co2_sensor_2_min_ppm", + 16070: "co2_sensor_2_max_ppm", + 16072: "co2_sensor_3", + 16073: "co2_sensor_3_min_ppm", + 16074: "co2_sensor_3_max_ppm", + 16076: "co2_sensor_4", + 16077: "co2_sensor_4_min_ppm", + 16078: "co2_sensor_4_max_ppm", + 16088: "status_geothermal_heat_exchanger", # Enum sensor + 16089: "additional_temperature_sensor", + 16090: "cn1_switch_input", # Binary sensor + 16091: "cn2_switch_input", # Binary sensor + 16095: "v1_analog_input", # Voltage sensor + 16096: "v2_analog_input", # Voltage sensor + 16099: "v1_minimum_voltage", + 16100: "v1_maximum_voltage", + 16101: "cn1_switch_input_condition", + 16102: "v2_minimum_voltage", + 16103: "v2_maximum_voltage", + 16104: "cn2_switch_input_condition", + 16106: "signal_output_mode", + 16116: "imbalance_fireplace", + 16118: "minimum_intake_temperature", + 16125: "mode_input_1", + 16126: "contact_1_type", + 16127: "contact_1_supply_fan_action", + 16128: "contact_1_exhaust_fan_action", + 16129: "contact_2_type", + 16130: "contact_2_supply_fan_action", + 16131: "contact_2_exhaust_fan_action", + 16132: "mode_input_2", + 16134: "switch_temp_1", + 16135: "switch_temp_2", + 16136: "mode_valve_24v_control", + 16137: "valve_control", + 16143: "bypass_operation", + 21002: "deviceTypeTitle", + 21008: "ip_address", + 21009: "subnet_mask", + 21010: "default_gateway", + 21011: "dns_server", + 21012: "dhcp_active", + 21013: "wifi_active", + 21014: "internet_connection_enabled", + 21015: "lan_mac_address", + 21016: "wifi_mac_address", + 21017: "system_name", +} + +UID_PARAMETER_MAP: dict[int, str] = { + # Airflows + 8: "supply_air_flow", + 9: "exhaust_air_flow", + 10: "supply_air_flow_setpoint", + 11: "exhaust_air_flow_setpoint", + + # Ventilation + 17: "ventilation_level", + 18: "ventilation_level_requested", + + # Pressures + 19: "supply_duct_pressure", + 20: "exhaust_duct_pressure", + + # Statuses + 21: "bypass_status", + 22: "frost_protection_status", + 23: "preheater_status", + 30: "filter_status", + 32: "operating_mode", + 35: "active_control_status", + 44: "bypass_operation", + + # Temperatures (tentative) + 45: "outdoor_air_temperature", + 51: "supply_air_temperature", + 53: "extract_air_temperature", + + # Humidity + 52: "supply_air_humidity", + 54: "extract_air_humidity", + + # Software versions + 70: "software_version_base", + 73: "software_version_uif", + 78: "software_version_webserver", + + # Network + 91: "ip_address", + + 60000: "default_gateway", + 60001: "subnet_mask", + 60002: "primary_dns", + 60003: "secondary_dns", + + # Brink Home + 60004: "home_module_name", + 60005: "destination_server", + 60006: "destination_server_port", + + # Wireless / network (unknown) + 60011: "wifi_ssid_1", + 60012: "wifi_password_1", + 60013: "wifi_ssid_2", + 60014: "wifi_password_2", + 60015: "wifi_ssid_3", + 60016: "wifi_password_3", + 60017: "wifi_ssid_4", + 60018: "wifi_password_4", + + # Router / provider (tentative) + 60019: "router_name", + 60020: "provider_name", +} + +ACTUALS_UID_MAP: dict[int, str] = { + 10000: "ventilation_level", + + 10060: "supply_air_flow", + 10120: "exhaust_air_flow", + + 10180: "supply_duct_pressure", + 10240: "exhaust_duct_pressure", + + 10300: "bypass_status", + 10360: "frost_protection_status", + 10420: "preheater_status", + + 10480: "supply_air_temperature", + 10540: "extract_air_temperature", + 10600: "outdoor_air_temperature", + + 10660: "supply_air_humidity", + 10720: "extract_air_humidity", + + 10780: "supply_fan_rpm", + 10840: "exhaust_fan_rpm", + + 10900: "co2_sensor_1", + 10960: "co2_sensor_2", + 11020: "co2_sensor_3", + 11080: "co2_sensor_4", + + 11100: "humidity_sensor", + + 11140: "software_version_base", + 11200: "software_version_uif", + 11260: "software_version_webserver", + 11320: "software_version_webapp", + 11380: "software_version_extension", + + 11440: "device_serial_number", + + 11500: "days_until_filter_message", + + 13180: "dipswitch_value", + + 13240: "ip_address", + 13300: "default_gateway", + 13360: "subnet_mask", + 13420: "primary_dns", + 13480: "secondary_dns", + + 13540: "home_module_name", + + 13600: "destination_server", + 13660: "destination_server_port", +} + +ERROR_CATEGORY_LABELS: dict[int, str] = { + 20000: "self_test_failed", + 20060: "flash_error", + 20120: "eeprom_error", + 20960: "bypass_fault", + 21800: "uif_fault", + 22160: "ebus_fault", + 22700: "usb_fault", +} + +ERROR_COMPONENT_LABELS: dict[int, str] = { + 20180: "requested_supply_airflow", + 20240: "requested_exhaust_airflow", + 20300: "outdoor_air_temperature", + 20360: "supply_fan", + 20420: "supply_fan_rpm", + 20480: "supply_fan_anemometer", + 20540: "supply_fan_temperature_sensor", + 20600: "supply_fan_humidity_sensor", + 20720: "exhaust_fan", + 20780: "exhaust_fan_rpm", + 20840: "exhaust_fan_anemometer", + 21200: "outdoor_temperature_sensor", + 21320: "external_humidity_sensor", + 21440: "four_position_switch", + 21500: "24v_four_position_switch", + 21560: "internal_preheater", + 21620: "external_preheater", + 21680: "external_reheater", + 21740: "relay_output", + 22100: "ebus_co2_sensor", +} + +ERROR_STATE_LABELS: dict[int, str] = { + 23000: "not_reached", + 23060: "too_high", + 23120: "fault", + 23180: "not_running", + 23240: "too_low", + 23300: "too_high", + 23360: "no_communication", + 23420: "communication_error", + 23480: "fault", + 23540: "temperature_sensor_fault", + 23600: "humidity_sensor_fault", + 23660: "detected", + 23720: "not_connected", + 23780: "value_too_low", + 24140: "too_warm", + 24200: "short_circuit", + 24320: "overvoltage", + 24380: "wrong_master", + 24440: "no_communication", + 24500: "sensor_fault", + 24620: "unknown_position", + 24680: "short_circuit", +} + +PARAMETER_UID_MAP: dict[int, str] = { + 0: "ventilation_mode_0_airflow", # 150 + 1: "ventilation_mode_1_airflow", # 225 + 2: "ventilation_mode_2_airflow", # 300 + 3: "ventilation_mode_3_airflow", # 240 + + 4: "imbalance_fireplace", # 100 + 5: "default_ventilation_level", # 0 + + 10: "days_until_filter_message", # 210 + + 24: "minimum_co2_sensor_1", # 50 + 25: "maximum_co2_sensor_1", # 250 + + 44: "humidity_sensor_sensitivity", # 25 + + 45: "contact_1_type", # 1 + 46: "cn1_conditions", # 2 + + 47: "contact_1_supply_fan_action", # 400 + 48: "contact_1_supply_fan_action_max", + + 49: "contact_1_exhaust_fan_action", # 400 + 50: "contact_1_exhaust_fan_action_max", + + 51: "contact_2_supply_fan_action", # 400 + 52: "contact_2_supply_fan_action_max", + + 53: "contact_2_exhaust_fan_action", # 400 + 54: "contact_2_exhaust_fan_action_max", + + 55: "mode_input_1", + 56: "v1_minimum_voltage", + 57: "v1_maximum_voltage", + + 58: "mode_input_2", + 59: "v2_minimum_voltage", + 60: "v2_maximum_voltage", + + 61: "status_geothermal_heat_exchanger", + + 62: "switch_temperature_1", + 63: "switch_temperature_2", + + 67: "mode_valve_24v_control", + + 69: "valve_control", + + 70: "signal_output_mode", +} + +PARAMETER_UID_TO_KEY: dict[int, str] = { + # Ventilation + 1: "ventilation_mode_1_airflow", + 2: "ventilation_mode_2_airflow", + 3: "ventilation_mode_3_airflow", + 29: "ventilation_mode_0_airflow", + + # Bypass + 4: "bypass_open_temperature", + 5: "bypass_close_temperature", + 26: "bypass_operation", + 44: "bypass_hysteresis", + 66: "bypass_function", + 67: "bypass_boost_level", + + # Fireplace / imbalance + 64: "imbalance_supply", + 65: "imbalance_exhaust", + 93: "imbalance_fireplace", + + # Default ventilation level + 58: "default_ventilation_level", + + # Frost / preheater + 68: "frost_protection_temperature_offset", + 103: "minimum_intake_temperature", + + # Filter + 69: "days_until_filter_message", + + # CO2 + 47: "co2_sensor_1_min_ppm", + 48: "co2_sensor_1_max_ppm", + 49: "co2_sensor_2_min_ppm", + 50: "co2_sensor_2_max_ppm", + 51: "co2_sensor_3_min_ppm", + 52: "co2_sensor_3_max_ppm", + 53: "co2_sensor_4_min_ppm", + 54: "co2_sensor_4_max_ppm", + 55: "ebus_co2_sensor_status", + + # Humidity + 45: "humidity_sensor_enabled", + 46: "rh_sensor_sensitivity", + + # Contact 1 + 11: "contact_1_type", + 14: "cn1_conditions", + 15: "contact_1_supply_fan_action", + 16: "contact_1_exhaust_fan_action", + + # Contact 2 + 17: "contact_2_type", + 20: "cn2_conditions", + 21: "contact_2_supply_fan_action", + 22: "contact_2_exhaust_fan_action", + + # 0-10V input 1 + 79: "mode_input_1", + 12: "v1_minimum_voltage", + 13: "v1_maximum_voltage", + + # 0-10V input 2 + 80: "mode_input_2", + 18: "v2_minimum_voltage", + 19: "v2_maximum_voltage", + + # Geothermal heat exchanger + 23: "geothermal_heat_exchanger_enabled", + 24: "switch_temperature_1", + 25: "switch_temperature_2", + + # Valve control + 82: "mode_valve_24v_control", + 81: "valve_control", + + # Signal output + 102: "signal_output_mode", + + # Network + 107: "network_method", + 108: "network_configuration", + 110: "advanced_network_configuration", + 111: "reset_network", + + # Bus communication + 84: "bus_connection_type", + 59: "slave_address", + 60: "baudrate", + 61: "parity", + + # Localization + 88: "language", + 89: "date_time_format", + 104: "daylight_saving_time", + + # Miscellaneous + 6: "device_configuration", + 7: "standby_mode", + 8: "fireplace_enabled", + 9: "external_humidity_sensor_enabled", + 10: "external_humidity_sensor_temperature", + 30: "preheater_enabled", + 83: "standby_command", +} + +LOCAL_UID_TO_KEY = { + 1: "ventilation_level", + 17: "bypass_status", + 18: "frost_protection_status", + 19: "supply_air_flow", + 20: "exhaust_air_flow", + 21: "heater_status", + 52: "supply_humidity", + 54: "exhaust_humidity", + 83: "time", + 84: "date", + 91: "ip_address", +} + +LOCAL_UID_TO_PARAMETER_ID: dict[int, int] = { + 0: 16001, # ventilation_level + 1: 16001, # ventilation_mode + 11: 16057, # Dagen tot filtermelding + 17: 16023, # bypass_status + 18: 16024, # frost_protection_status + 19: 16015, # Actuele toevoerdebiet m³/h + 20: 16017, # Actuele afvoerdebiet m³/h + 21: 16025, # preheater_status + 51: 16019, # Temperatuur toevoer (?) + 52: 16059, # Relatieve vochtigheid toevoer + 53: 16020, # Temperatuur buiten + 54: 16028, # exhaust_humidity + # 83: # Tijd + # 84: # Datum + # 91: # IP-adres +} + +READ_WRITE_MAP = { + 0: "hidden", + 1: "read-only", + 3: "read-write", +} + MODE_MANUAL_VALUE = "1" WRITE_VALUE_STATE = 0 + + +@dataclass(frozen=True, slots=True) +class BrinkError: + """Describe a Brink error.""" + + code: str + blocking: bool + title: str + description: str + + +ERRORS: dict[str, BrinkError] = { + "E1124": BrinkError( + code="E1124", + blocking=True, + title="Exhaust fan", + description="No communication", + ), + "E1125": BrinkError( + code="E1125", + blocking=True, + title="Supply fan", + description="No communication", + ), +} diff --git a/custom_components/brink_ventilation/coordinator.py b/custom_components/brink_ventilation/coordinator.py new file mode 100644 index 0000000..f3fc14c --- /dev/null +++ b/custom_components/brink_ventilation/coordinator.py @@ -0,0 +1,184 @@ +"""Coordinator for the Brink Home integration.""" + +from __future__ import annotations + +import logging +from datetime import timedelta + +from aiohttp import ( + ClientError, + ClientResponseError, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) + +from .api import async_get_devices +from .core.brink_home_cloud import ( + BrinkApiError, + BrinkAuthError, + BrinkHomeCloud, +) +from .models import BrinkAlert, BrinkAlertLevel, BrinkDeviceData + +_LOGGER = logging.getLogger(__name__) + + +class BrinkCoordinator( + DataUpdateCoordinator[dict[int, BrinkDeviceData]] +): + """Coordinate Brink Home API updates.""" + + api_is_online: bool = False + api_error: str | None = None + api_error_code: int | None = None + api_error_type: str | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + brink_client: BrinkHomeCloud, + scan_interval: int, + ) -> None: + """Initialize the coordinator.""" + self.config_entry = config_entry + self._client = brink_client + update_interval = timedelta(seconds=scan_interval) + + super().__init__( + hass, + logger=_LOGGER, + name="Brink Home", + config_entry=config_entry, + update_interval=update_interval, + ) + + @property + def has_blocking_error(self) -> bool: + """Return whether any device has an active blocking error.""" + return any( + alert.level == BrinkAlertLevel.BLOCKING + for alert in self.active_alerts + ) + + @property + def active_alerts(self) -> list[BrinkAlert]: + """Return all active alerts.""" + if self.data is None: + return [] + + return [ + alert + for device in self.data.values() + for alert in device.alerts + ] + + async def _async_update_data( + self, + ) -> dict[int, BrinkDeviceData]: + """Fetch the latest data from Brink.""" + _LOGGER.debug("Fetching device data from Brink") + + try: + data = await async_get_devices(self._client) + + except BrinkAuthError as err: + self._set_api_error( + error=str(err), + error_code=401, + error_type="authentication", + ) + _LOGGER.exception("Brink authentication error") + raise ConfigEntryAuthFailed from err + + except ClientResponseError as err: + self._set_api_error( + error=( + f"HTTP {err.status}: " + f"{err.message or 'HTTP request failed'}" + ), + error_code=err.status, + error_type="http", + ) + _LOGGER.exception( + "Brink HTTP error: status=%s", + err.status, + ) + + if err.status == 401: + raise ConfigEntryAuthFailed from err + + raise UpdateFailed(err) from err + + except (ClientError, TimeoutError) as err: + self._set_api_error( + error=str(err), + error_code=None, + error_type="connection", + ) + _LOGGER.exception("Brink connection error") + raise UpdateFailed(err) from err + + except BrinkApiError as err: + self._set_api_error( + error=str(err), + error_code=err.error_code, + error_type=err.error_type, + ) + _LOGGER.exception("Brink API error") + raise UpdateFailed(err) from err + + self._set_api_online() + + return data + + def _set_api_online(self) -> None: + """Set the API status to online.""" + self.api_is_online = True + self.api_error = None + self.api_error_code = None + self.api_error_type = None + + def _set_api_error( + self, + *, + error: str, + error_code: int | None, + error_type: str, + ) -> None: + """Set the API status to offline.""" + self.api_is_online = False + self.api_error = error + self.api_error_code = error_code + self.api_error_type = error_type + + async def old_async_update_data( + self, + ) -> dict[int, BrinkDeviceData]: + """Fetch the latest data from Brink.""" + _LOGGER.debug("Fetching device data from Brink") + + try: + return await async_get_devices(self._client) + except BrinkAuthError as err: + _LOGGER.exception("Brink authentication error") + raise ConfigEntryAuthFailed from err + except ClientResponseError as err: + _LOGGER.exception( + "Brink HTTP error: status=%s", + err.status, + ) + if err.status == 401: + raise ConfigEntryAuthFailed from err + raise UpdateFailed(err) from err + except (ClientError, TimeoutError) as err: + _LOGGER.exception("Brink connection error") + raise UpdateFailed(err) from err + except BrinkApiError as err: + _LOGGER.exception("Brink API error") + raise UpdateFailed(err) from err diff --git a/custom_components/brink_ventilation/core/__pycache__/brink_home_cloud.cpython-314.pyc b/custom_components/brink_ventilation/core/__pycache__/brink_home_cloud.cpython-314.pyc new file mode 100644 index 0000000..c1fc583 Binary files /dev/null and b/custom_components/brink_ventilation/core/__pycache__/brink_home_cloud.cpython-314.pyc differ diff --git a/custom_components/brink_ventilation/core/brink_home_cloud.py b/custom_components/brink_ventilation/core/brink_home_cloud.py index ab92305..196344b 100644 --- a/custom_components/brink_ventilation/core/brink_home_cloud.py +++ b/custom_components/brink_ventilation/core/brink_home_cloud.py @@ -8,29 +8,61 @@ import logging import secrets import time +from dataclasses import dataclass +from datetime import datetime from html.parser import HTMLParser -from typing import Any +from typing import Any, cast from urllib.parse import parse_qs, urlparse -import aiohttp import async_timeout +from aiohttp import ( + ClientConnectionError, + ClientConnectorError, + ClientError, + ClientResponse, + ClientResponseError, + ClientSession, + ClientTimeout, + CookieJar, + ServerDisconnectedError, +) from ..const import ( API_V1_URL, + CONTROL_TYPE_MAP, OIDC_AUTH_URL, OIDC_CLIENT_ID, OIDC_REDIRECT_URI, OIDC_SCOPE, OIDC_TOKEN_URL, PARAM_NAME_MAP, + PARAMETER_NAMES, + VALUE_STATE_MAP, WRITE_VALUE_STATE, ) +from ..models import BrinkAlert, BrinkAlertLevel, BrinkSettings, BrinkSystem, BrinkUser from ..translations import TRANSLATIONS _LOGGER = logging.getLogger(__name__) _TRUSTED_HOST = "www.brink-home.com" +class BrinkApiError(Exception): + """Represent a Brink API error.""" + + def __init__( + self, + message: str, + *, + error_code: int | None = None, + error_type: str = "api", + ) -> None: + """Initialize the Brink API error.""" + super().__init__(message) + self.error_code = error_code + self.error_type = error_type + + class _InputFieldExtractor(HTMLParser): """Extract values from HTML input fields.""" @@ -56,14 +88,49 @@ def __init__(self, message: str, *, is_credentials_error: bool = False) -> None: self.is_credentials_error = is_credentials_error +class BrinkClient: + """Base class for Brink Home API clients.""" + + def __init__(self, session: ClientSession) -> None: + self._session = session + + +@dataclass(slots=True, frozen=True) +class BrinkOidcConfig: + """Brink OIDC configuration.""" + + authority: str + client_id: str + response_type: str + scope: str + redirect_url: str + + +OIDC_CONFIG = BrinkOidcConfig( + authority="https://www.brink-home.com/idsrv", + client_id="spa", + response_type="code", + scope="openid api role locale", + redirect_url="https://www.brink-home.com/app/", +) + + class BrinkHomeCloud: """Interact with Brink Home through the v1.1 API.""" - def __init__(self, session: aiohttp.ClientSession, username: str, password: str): + _OBSERVED_VALUE_STATES: set[int] = set() + _OBSERVED_CONTROL_TYPES: set[int] = set() + _OBSERVED_HINTS: set[int] = set() + + def __init__(self, session: ClientSession, username: str, password: str): + self.api_is_online: bool = False + self.api_error: str | None = None + self.api_error_code: int | None = None + self.api_error_type: str | None = None self._session = session self._username = username self._password = password - self._timeout = 20 + self._timeout = 30 self._access_token: str | None = None self._token_expiry: float = 0.0 self._refresh_token: str | None = None @@ -81,29 +148,194 @@ async def close(self) -> None: self._username = "" self._password = "" - async def get_systems(self) -> list[dict[str, Any]]: + async def async_get_user(self) -> BrinkUser: + """Return the authenticated Brink user.""" + + response = await self._api_request( + "GET", + f"{API_V1_URL}user", + ) + + async with response: + payload = cast(dict[str, object], await response.json()) + + _LOGGER.debug("Brink user payload: %s", payload) + + return payload + + async def async_update_user_general( + self, + mail_address_id: int, + user_role_id: int, + culture_info_code: str, + ) -> dict[str, object]: + """Update the authenticated user's general settings.""" + + response = await self._api_request( + "PUT", + f"{API_V1_URL}user/general", + json_data={ + "mailAddressId": mail_address_id, + "userRoleId": user_role_id, + "cultureInfoCode": culture_info_code, + }, + ) + + async with response: + payload = cast(dict[str, object], await response.json()) + + _LOGGER.debug("Brink user general response: %s", payload) + + return payload + + async def async_get_alerts( + self, + system_id: int, + ) -> dict[str, object]: + """Return alerts for a Brink system.""" + response = await self._api_request( + "GET", + f"{API_V1_URL}systems/{system_id}/alerts/criterion", + params={ + "pageIndex": 0, + "pageSize": 10, + "sortColumn": "level", + "sortDirection": 2, + "filterSpecs[0].fieldName": "isArchived", + "filterSpecs[0].value": "false", + }, + ) + + async with response: + payload = cast(dict[str, object], await response.json()) + _LOGGER.debug("Brink Flair alerts payload: %s", payload) + return payload + + async def async_get_active_alerts( + self, + system_id: int, + ) -> list[BrinkAlert]: + """Return active alerts for a Brink system.""" + data = await self.async_get_alerts(system_id) + + items = data.get("items") + if not isinstance(items, list): + return [] + + alerts: list[BrinkAlert] = [] + + for item in items: + if not isinstance(item, dict): + continue + + if not item.get("isActive", False): + continue + + alerts.append(self._parse_alert(item)) + + return alerts + + @staticmethod + def _parse_alert(item: dict[str, object]) -> BrinkAlert: + """Parse a Brink alert.""" + + outgoing = item.get("outgoing") + + return BrinkAlert( + id=int(item["id"]), + component_id=int(item["componentId"]), + code=int(item["code"]), + level=BrinkAlertLevel(int(item["level"])), + is_active=bool(item["isActive"]), + is_archived=bool(item["isArchived"]), + incoming=datetime.fromisoformat( + str(item["incoming"]) + ), + outgoing=( + datetime.fromisoformat(str(outgoing)) + if outgoing + else None + ), + code_text=item.get("codeTextId"), + description=item.get("codeDescriptionTextId"), + ) + + async def get_systems(self) -> list[BrinkSystem]: """Return the systems visible to the current account.""" - response = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=5") + response = await self._api_request( + "GET", + f"{API_V1_URL}systems?pageSize=5", + ) + try: payload = await response.json() finally: await response.release() - systems: list[dict[str, Any]] = [] - for item in payload.get("items", []): + _LOGGER.debug("Brink Flair system payload: %s", payload) + + items = payload.get("items") + if not isinstance(items, list): + return [] + + systems: list[dict[str, object]] = [] + + for item in items: + if not isinstance(item, dict): + continue + system_id = item.get("systemShareId") if system_id is None: continue - systems.append( - { - "system_id": system_id, - "name": item.get("systemName") or "Brink", - "serial_number": item.get("serialNumber"), - "gateway_state": item.get("gatewayState"), - } + + system: dict[str, object] = { + "name": item.get("systemName") or "Brink", + "serial_number": item.get("serialNumber"), + "system_id": system_id, + "is_system_owner": item.get("isSystemOwner"), + "is_editable": item.get("isEditable"), + "access_level": item.get("accessLevel"), + "owner_group_name": item.get("ownerGroupName"), + "is_favorite": item.get("isFavorite"), + "gateway_state": item.get("gatewayState"), + "active_alert_count": item.get("activeAlertCount"), + "iana_time_zone": item.get("ianaTimeZone"), + "two_letter_country_code": item.get("twoLetterCountryCode"), + "user_group_names": item.get("userGroupNames"), + "total_count": item.get("totalCount"), + } + + systems.append(system) + + _LOGGER.debug( + "Brink Flair system [%s] properties: %s", + system_id, + system, + ) + _LOGGER.info( + "Brink Flair system %s access_level=%s owner=%s is_system_owner=%s editable=%s", + system_id, + item.get("accessLevel"), + item.get("ownerGroupName"), + item.get("isSystemOwner"), + item.get("isEditable"), ) + return systems + async def async_get_device(self, system_id: int) -> dict[str, dict[str, object]]: + """Return a flattened parameter map for a system.""" + response = await self._api_request( + "GET", f"{API_V1_URL}systems/{system_id}" + ) + try: + payload = await response.json() + _LOGGER.debug("Brink Flair device payload: %s", payload) + finally: + await response.release() + + return payload + async def get_device_data(self, system_id: int) -> dict[str, dict[str, Any]]: """Return a flattened parameter map for a system.""" response = await self._api_request( @@ -111,6 +343,7 @@ async def get_device_data(self, system_id: int) -> dict[str, dict[str, Any]]: ) try: payload = await response.json() + _LOGGER.debug("Brink Flair device_data payload: %s", payload) finally: await response.release() @@ -134,50 +367,181 @@ async def write_parameters( for value_id, value in params ] } + _LOGGER.debug("Brink Flair write payload: %s", payload) response = await self._api_request( "PUT", f"{API_V1_URL}systems/{system_id}/parameter-values", json_data=payload, ) + _LOGGER.debug("Brink Flair device_data response: %s", response) try: await response.read() finally: await response.release() + async def async_get_settings(self) -> BrinkSettings: + """Return Brink portal settings.""" + + response = await self._api_request( + "GET", + f"{API_V1_URL}settings", + ) + + async with response: + payload = cast(dict[str, object], await response.json()) + + _LOGGER.debug("Brink settings payload: %s", payload) + + return payload + + async def async_get_translations( + self, + language: str = "en", + *, + translation_format: int = 2, + version: str = "2.4.3", + ) -> dict[str, str]: + """Return Brink translations.""" + + response = await self._api_request( + "GET", + f"{API_V1_URL}settings/languages/{language}", + params={ + "translationFormat": translation_format, + "v": version, + }, + ) + + async with response: + payload = await response.json() + + return cast(dict[str, str], payload) + async def _api_request( self, method: str, url: str, *, - json_data: dict[str, Any] | None = None, - ) -> aiohttp.ClientResponse: + json_data: dict[str, object] | None = None, + params: dict[str, object] | None = None, + ) -> ClientResponse: """Perform an authenticated v1.1 API request.""" - for attempt in range(2): + max_attempts = 3 + + _LOGGER.debug( + "Brink request %s %s timeout=%s", + method, + url, + self._timeout, + ) + + for attempt in range(max_attempts): await self._ensure_token() - async with async_timeout.timeout(self._timeout): + + try: response = await self._session.request( method, url, json=json_data, + params=params, + timeout=ClientTimeout(total=self._timeout), headers={ "Authorization": f"Bearer {self._access_token}", "Accept": "application/json", }, ) - if response.status == 401 and attempt == 0: - await response.release() - async with self._token_lock: - self._token_expiry = 0.0 - self._access_token = None - continue + if response.status == 401: + await response.release() - if response.status == 401: - await response.release() - raise BrinkAuthError("Authentication failed after retry") + if attempt == 0: + async with self._token_lock: + self._token_expiry = 0.0 + self._access_token = None + continue + + self.api_is_online = False + self.api_error_code = 401 + self.api_error_type = "http" + self.api_error = "HTTP 401: Authentication failed" + + raise BrinkAuthError( + "Authentication failed after retry" + ) + + try: + response.raise_for_status() + except ClientResponseError as err: + await response.release() + + raise BrinkApiError( + f"HTTP {err.status}: " + f"{err.message or 'HTTP request failed'}", + error_code=err.status, + error_type="http", + ) from err + + # The complete HTTP request was successful. + self.api_is_online = True + self.api_error = None + self.api_error_code = None + self.api_error_type = None - response.raise_for_status() - return response + return response + + except ClientConnectorError as err: + self.api_is_online = False + self.api_error = str(err) + self.api_error_code = None + self.api_error_type = "connection" + + _LOGGER.warning( + "Brink connection/DNS error: %s %s (%s)", + method, + url, + err, + ) + + raise BrinkApiError( + f"Unable to connect to Brink API: {method} {url}" + ) from err + + except ( + ClientConnectionError, + ServerDisconnectedError, + TimeoutError, + ) as err: + if attempt < max_attempts - 1: + _LOGGER.debug( + "Brink request failed (%s/%s): %s %s (%s)", + attempt + 1, + max_attempts, + method, + url, + err, + ) + else: + _LOGGER.warning( + "Brink request failed (%s/%s): %s %s (%s)", + attempt + 1, + max_attempts, + method, + url, + err, + ) + + if attempt == max_attempts - 1: + self.api_is_online = False + self.api_error = str(err) + self.api_error_code = None + self.api_error_type = "connection" + + raise BrinkApiError( + f"Brink request failed after {max_attempts} attempts: " + f"{method} {url}" + ) from err + + await asyncio.sleep(2) raise BrinkAuthError("Authentication failed before request") @@ -206,8 +570,8 @@ async def _oidc_login(self) -> None: state = secrets.token_urlsafe(32) nonce = secrets.token_urlsafe(32) - jar = aiohttp.CookieJar(unsafe=False) - async with aiohttp.ClientSession(cookie_jar=jar) as oidc_session: + jar = CookieJar(unsafe=False) + async with ClientSession(cookie_jar=jar) as oidc_session: login_url, csrf_token, return_url = await self._fetch_login_page( oidc_session, code_challenge, state, nonce ) @@ -219,7 +583,7 @@ async def _oidc_login(self) -> None: async def _fetch_login_page( self, - session: aiohttp.ClientSession, + session: ClientSession, code_challenge: str, state: str, nonce: str, @@ -261,7 +625,7 @@ async def _fetch_login_page( async def _submit_login_credentials( self, - session: aiohttp.ClientSession, + session: ClientSession, login_url: str, csrf_token: str, return_url: str | None, @@ -272,11 +636,8 @@ async def _submit_login_credentials( "Password": self._password, "__RequestVerificationToken": csrf_token, } - if return_url: - if return_url.startswith("/") and not return_url.startswith("//"): - form_data["ReturnUrl"] = return_url - elif self._is_trusted_url(return_url): - form_data["ReturnUrl"] = return_url + if return_url and return_url.startswith("/") and not return_url.startswith("//") or self._is_trusted_url(return_url): + form_data["ReturnUrl"] = return_url async with async_timeout.timeout(30): response = await session.post( @@ -320,7 +681,7 @@ async def _exchange_code_for_tokens( "code_verifier": code_verifier, } - async with async_timeout.timeout(20): + async with async_timeout.timeout(30): response = await self._session.post( OIDC_TOKEN_URL, data=token_data, @@ -332,6 +693,7 @@ async def _exchange_code_for_tokens( f"OIDC token exchange failed with status {response.status}" ) payload = await response.json() + _LOGGER.debug("Brink Flair code_for_tokens payload: %s", payload) await response.release() access_token = payload.get("access_token") @@ -368,8 +730,9 @@ async def _refresh_access_token(self) -> None: f"Refresh token rejected (HTTP {response.status})" ) payload = await response.json() + # _LOGGER.debug("Brink Flair refresh access token payload: %s", payload) await response.release() - except (aiohttp.ClientError, asyncio.TimeoutError) as ex: + except (ClientError, asyncio.TimeoutError) as ex: self._refresh_token = None raise BrinkAuthError("Refresh token request failed") from ex @@ -383,9 +746,15 @@ async def _refresh_access_token(self) -> None: self._token_expiry = time.monotonic() + expires_in - 60 self._refresh_token = payload.get("refresh_token", self._refresh_token) + _LOGGER.debug( + "Brink token refreshed successfully: expires_in=%s refresh_token=%s", + expires_in, + bool(self._refresh_token), + ) + async def _follow_redirects_for_code( self, - session: aiohttp.ClientSession, + session: ClientSession, redirect_url: str, base_url: str, expected_state: str, @@ -418,7 +787,7 @@ async def _follow_redirects_for_code( final_url = str(response.url) await response.release() return self._extract_code_from_redirect(final_url, expected_state) - except aiohttp.ClientError: + except ClientError: return None return None @@ -429,17 +798,119 @@ def _extract_parameters( parameters: dict[str, dict[str, Any]], ) -> None: """Flatten parameters from all navigation items into one map.""" + for nav_item in nav_items: for group in nav_item.get("parameterGroups", []): for param in group.get("parameters", []): raw_name = param.get("name", "") + numeric_id = param.get("id") + key = PARAM_NAME_MAP.get(raw_name) + + if key is None and numeric_id is not None: + key = PARAMETER_NAMES.get(numeric_id) + if key is None: - numeric_id = param.get("id") if numeric_id is None: continue key = f"unknown_{numeric_id}" + if key.startswith("unknown_"): + _LOGGER.info( + "Unknown parameter id=%s name=%s value=%s", + numeric_id, + raw_name, + param.get("value"), + ) + + list_items = param.get("listItems") or [] + + options = ( + BrinkHomeCloud._extract_options(list_items) + if list_items + else [] + ) + + # if options: + # """Show parameter with options""" + # _LOGGER.debug( + # "Parameter %s (%s) current_value=%s options=%s read_write=%s " + # "min=%s max=%s step_width=%s decimals=%s " + # "nr_options=%s", + # key, + # numeric_id, + # param.get("value"), + # options, + # param.get("readWrite"), + # param.get("minValue"), + # param.get("maxValue"), + # param.get("stepWidth"), + # param.get("decimals"), + # len(options), + # ) + + value_state = param.get("valueState") + + # if value_state not in (None, 0): + # """Show valid values""" + # _LOGGER.info( + # "Parameter: %s (%s) valueState=%s value=%s", + # key, + # numeric_id, + # value_state, + # param.get("value"), + # ) + + if isinstance(value_state, int) and value_state not in BrinkHomeCloud._OBSERVED_VALUE_STATES: + BrinkHomeCloud._OBSERVED_VALUE_STATES.add(value_state) + + _LOGGER.info( + "Discovered valueState=%s (%s) parameter=%s (%s)", + value_state, + VALUE_STATE_MAP.get(value_state, "unknown"), + key, + numeric_id, + ) + +# if value_state != 0: +# _LOGGER.info( +# "Parameter !=0: %s (%s) value=%s valueState=%s readWrite=%s controlType=%s", +# key, +# numeric_id, +# param.get("value"), +# value_state, +# param.get("readWrite"), +# param.get("controlType"), +# ) + + control_type = param.get("controlType") + + if isinstance(control_type, int): + if control_type not in BrinkHomeCloud._OBSERVED_CONTROL_TYPES: + BrinkHomeCloud._OBSERVED_CONTROL_TYPES.add(control_type) + + _LOGGER.info( + "Discovered controlType=%s (%s) parameter=%s (%s)", + control_type, + CONTROL_TYPE_MAP.get(control_type, "unknown"), + key, + numeric_id, + ) + + hints = param.get("hints") + + if isinstance(hints, int): + if hints not in BrinkHomeCloud._OBSERVED_HINTS: + BrinkHomeCloud._OBSERVED_HINTS.add(hints) + + _LOGGER.info( + "Discovered hints=%s parameter=%s (%s) controlType=%s", + hints, + key, + numeric_id, + param.get("controlType"), + ) + parameters[key] = { "name": TRANSLATIONS.get(raw_name, raw_name), "raw_name": raw_name, @@ -448,17 +919,16 @@ def _extract_parameters( "value_state": param.get("valueState"), "read_write": param.get("readWrite"), "control_type": param.get("controlType"), - "list_items": param.get("listItems"), + "list_items": list_items, "min_value": param.get("minValue"), "max_value": param.get("maxValue"), - "unit_of_measure": ( - param.get("unit") or param.get("unitOfMeasure") - ), + "default_value": param.get("defaultValue"), + "step_width": param.get("stepWidth"), + "decimals": param.get("decimals"), + "unit_of_measure": param.get("unit"), "component_id": param.get("componentId"), - "numeric_id": param.get("id"), - "options": BrinkHomeCloud._extract_options( - param.get("listItems", []) - ), + "numeric_id": numeric_id, + "options": options, } BrinkHomeCloud._extract_parameters( nav_item.get("navigationItems", []), parameters @@ -486,6 +956,7 @@ def _extract_options(list_items: list[dict[str, Any]]) -> list[dict[str, str]]: "label": TRANSLATIONS.get(label_source, label_source), } ) + # _LOGGER.debug("Brink Flair options: %s", options) return options @staticmethod @@ -529,3 +1000,26 @@ def _is_trusted_url(url: str) -> bool: and parsed.hostname == _TRUSTED_HOST and parsed.port in (None, 443) ) + + +""" +ENDPOINTS = { + "settings": f"{API_V1_URL}settings", +GET user +PUT user/general +PATCH user/password +PUT user/expert-role +GET user/edit-form +GET user/expert-role/edit-form +DELETE user +POST systems +GET systems/{id} +GET systems/{id}/properties +PATCH systems/{id}/unlock +GET systems/{id}/unlock-form +GET systems/{id}/changelogs/description +GET systems/{id}/alerts +GET systems/{id}/firmware +GET settings/gateway/master-data-descriptions +} +""" diff --git a/custom_components/brink_ventilation/core/brink_home_local.py b/custom_components/brink_ventilation/core/brink_home_local.py new file mode 100644 index 0000000..8095b89 --- /dev/null +++ b/custom_components/brink_ventilation/core/brink_home_local.py @@ -0,0 +1,70 @@ +"""Local API client for Brink Home devices.""" + +from __future__ import annotations + +import logging + +from aiohttp import ClientError, ClientSession + +_LOGGER = logging.getLogger(__name__) + +HOST = "brink.local" + + +class BrinkHomeLocalError(Exception): + """Raised when communication with the local Brink device fails.""" + + +class BrinkHomeLocalClient: + """Client for the local Brink Home web interface.""" + + def __init__( + self, + session: ClientSession, + host: str = "brink.local", + *, + port: int = 80, + ) -> None: + """Initialize the local Brink client.""" + self._session = session + self._base_url = f"http://{host}:{port}" + + async def async_get_uid_values(self) -> dict[str, str]: + """Return the raw UID values from the local web interface.""" + return await self._async_get_json("actuals.json") + + async def async_get_commands(self) -> dict[str, object]: + """Return the supported commands.""" + return await self._async_get_json("commands.json") + + async def async_get_configuration(self) -> dict[str, object]: + """Return the configuration.""" + return await self._async_get_json("configuration.json") + + async def _async_get_json( + self, + endpoint: str, + ) -> dict[str, object]: + """Fetch a JSON endpoint.""" + url = f"{self._base_url}/{endpoint}" + + _LOGGER.debug("Fetching Brink local endpoint %s", url) + + try: + async with self._session.get(url) as response: + response.raise_for_status() + + payload = await response.json(content_type=None) + + except ClientError as err: + raise BrinkHomeLocalError( + f"Failed to fetch '{endpoint}' from {self._base_url}" + ) from err + + _LOGGER.debug( + "Received %d keys from %s", + len(payload), + endpoint, + ) + + return payload diff --git a/custom_components/brink_ventilation/core/uid_definitions.py b/custom_components/brink_ventilation/core/uid_definitions.py new file mode 100644 index 0000000..b81f9c8 --- /dev/null +++ b/custom_components/brink_ventilation/core/uid_definitions.py @@ -0,0 +1,375 @@ +"""Definitions for Brink local UIDs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum, StrEnum + + +class ActiveControlStatus(IntEnum): + """Brink active control status.""" + + STANDBY = 0 + BOOTLOADER = 1 + NON_BLOCKING_ERROR = 2 + BLOCKING_ERROR = 3 + MANUAL = 4 + HOLIDAY = 5 + NIGHT_VENTILATION = 6 + PARTY = 7 + BYPASS_BOOST = 8 + NORMAL_BOOST = 9 + AUTO_CO2 = 10 + AUTO_EBUS = 11 + AUTO_MODBUS = 12 + AUTO_LAN_WLAN_PORTAL = 13 + AUTO_LAN_WLAN_LOCAL = 14 + + +ACTIVE_CONTROL_STATUS_LABELS: dict[int, str] = { + ActiveControlStatus.STANDBY: "Standby", + ActiveControlStatus.BOOTLOADER: "Bootloader", + ActiveControlStatus.NON_BLOCKING_ERROR: "Non-blocking error", + ActiveControlStatus.BLOCKING_ERROR: "Blocking error", + ActiveControlStatus.MANUAL: "Manual", + ActiveControlStatus.HOLIDAY: "Holiday", + ActiveControlStatus.NIGHT_VENTILATION: "Night ventilation", + ActiveControlStatus.PARTY: "Party", + ActiveControlStatus.BYPASS_BOOST: "Bypass boost", + ActiveControlStatus.NORMAL_BOOST: "Normal boost", + ActiveControlStatus.AUTO_CO2: "Automatic CO₂", + ActiveControlStatus.AUTO_EBUS: "Automatic eBus", + ActiveControlStatus.AUTO_MODBUS: "Automatic Modbus", + ActiveControlStatus.AUTO_LAN_WLAN_PORTAL: "Automatic LAN/WLAN Portal", + ActiveControlStatus.AUTO_LAN_WLAN_LOCAL: "Automatic LAN/WLAN Local", +} + + +class UIDType(StrEnum): + """Supported UID value types.""" + + BOOL = "bool" + INT = "int" + FLOAT = "float" + ENUM = "enum" + UINT16 = "uint16" + UINT32 = "uint32" + TEMPERATURE = "temperature" + HUMIDITY = "humidity" + PRESSURE = "pressure" + AIRFLOW = "airflow" + RPM = "rpm" + ASCII = "ascii" + IP_ADDRESS = "ip_address" + VERSION = "version" + DATE = "date" + TIME = "time" + + +class UIDUnit(StrEnum): + """Supported engineering units.""" + CELSIUS = "°C" + PERCENT = "%" + PASCAL = "Pa" + CUBIC_METERS_PER_HOUR = "m³/h" + RPM = "RPM" + + +class UIDAccess(StrEnum): + """Supported access modes.""" + + READ = "read" + WRITE = "write" + READ_WRITE = "read_write" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class UIDDefinition: + """Describe a Brink local UID.""" + + uid: int + key: str + value_type: UIDType + scale: int = 1 + unit: UIDUnit | None = None + enum_type: type | None = None + writable: bool = False + description: str | None = None + available_since: tuple[int, int] | None = None + access: UIDAccess = UIDAccess.READ + + +UID_DEFINITIONS: tuple[UIDDefinition, ...] = ( + # + # Operating state + # + UIDDefinition( + uid=0, + key="base_firmware_descriptor", + value_type=UIDType.ASCII, + description="base firmware descriptor", + ), + UIDDefinition( + uid=1, + key="ventilation_mode", + value_type=UIDType.INT, + description="Current ventilation mode", + ), + UIDDefinition( + uid=2, + key="supply_fan_rpm", + value_type=UIDType.RPM, + unit=UIDUnit.RPM, + description="Supply fan speed", + ), + UIDDefinition( + uid=3, + key="exhaust_fan_rpm", + value_type=UIDType.RPM, + unit=UIDUnit.RPM, + description="Exhaust fan speed", + ), + UIDDefinition( + uid=6, + key="outside_temperature", + value_type=UIDType.TEMPERATURE, + unit=UIDUnit.CELSIUS, + scale=10, + ), + UIDDefinition( + uid=8, + key="supply_airflow", + value_type=UIDType.AIRFLOW, + unit=UIDUnit.CUBIC_METERS_PER_HOUR, + description="Current supply airflow", + ), + UIDDefinition( + uid=9, + key="exhaust_airflow", + value_type=UIDType.AIRFLOW, + unit=UIDUnit.CUBIC_METERS_PER_HOUR, + description="Current exhaust airflow", + ), + UIDDefinition( + uid=10, + key="supply_airflow_setpoint", + value_type=UIDType.AIRFLOW, + unit=UIDUnit.CUBIC_METERS_PER_HOUR, + description="Current supply airflow setpoint", + ), + UIDDefinition( + uid=11, + key="exhaust_airflow_setpoint", + value_type=UIDType.AIRFLOW, + unit=UIDUnit.CUBIC_METERS_PER_HOUR, + description="Current exhaust airflow setpoint", + ), + UIDDefinition( + uid=19, + key="supply_pressure", + value_type=UIDType.PRESSURE, + unit=UIDUnit.PASCAL, + description="Supply duct pressure", + ), + UIDDefinition( + uid=20, + key="exhaust_pressure", + value_type=UIDType.PRESSURE, + unit=UIDUnit.PASCAL, + description="Exhaust duct pressure", + ), + UIDDefinition( + uid=22, + key="bypass_open", + value_type=UIDType.BOOL, + description="Bypass valve open", + ), + UIDDefinition( + uid=23, + key="frost_protection", + value_type=UIDType.BOOL, + description="Frost protection active", + ), + UIDDefinition( + uid=35, + key="operating_mode", + value_type=UIDType.ENUM, + enum_type=ActiveControlStatus, + description="Operating mode", + ), + UIDDefinition( + uid=44, + key="active_control_status", + value_type=UIDType.ENUM, + enum_type=ActiveControlStatus, + description="Active control status", + ), + UIDDefinition( + uid=45, + key="days_since_filter_reset?", + value_type=UIDType.UINT32, + description="Days since filter reset", + ), + UIDDefinition( + uid=46, + key="unknown_46", + value_type=UIDType.UINT32, + description="Unknown raw supply fan value", + ), + UIDDefinition( + uid=47, + key="unknown_47", + value_type=UIDType.UINT32, + description="Unknown raw exhaust fan value", + ), + UIDDefinition( + uid=49, + key="dipswitch", + value_type=UIDType.INT, + description="DIP switch value", + ), + UIDDefinition( + uid=51, + key="supply_temperature", + value_type=UIDType.TEMPERATURE, + unit=UIDUnit.CELSIUS, + scale=10, + description="Supply temperature", + ), + UIDDefinition( + uid=52, + key="supply_humidity", + value_type=UIDType.HUMIDITY, + unit=UIDUnit.PERCENT, + description="Supply air relative humidity", + scale=1, + ), + UIDDefinition( + uid=53, + key="exhaust_temperature", + value_type=UIDType.TEMPERATURE, + unit=UIDUnit.CELSIUS, + scale=10, + description="Exhaust temperature", + ), + UIDDefinition( + uid=54, + key="exhaust_humidity", + value_type=UIDType.HUMIDITY, + unit=UIDUnit.PERCENT, + description="Extract air relative humidity", + ), + UIDDefinition( + uid=70, + key="base_firmware_version", + value_type=UIDType.ASCII, + description="Base firmware version", + ), + UIDDefinition( + uid=73, + key="uif_firmware_version", + value_type=UIDType.ASCII, + description="UIF firmware version", + ), + UIDDefinition( + uid=78, + key="webserver_version", + value_type=UIDType.ASCII, + description="Web server version", + ), + UIDDefinition( + uid=83, + key="device_time", + value_type=UIDType.TIME, + description="Time on the device", + ), + UIDDefinition( + uid=84, + key="device_date", + value_type=UIDType.DATE, + description="Date on the device", + ), + # + # Network + # + UIDDefinition( + uid=91, + key="ip_address", + value_type=UIDType.IP_ADDRESS, + ), + UIDDefinition( + uid=16049, + key="preheater_power", + value_type=UIDType.INT, + available_since=(3, 1), + ), + UIDDefinition( + uid=60000, + key="default_gateway", + value_type=UIDType.IP_ADDRESS, + ), + UIDDefinition( + uid=60001, + key="subnet_mask", + value_type=UIDType.IP_ADDRESS, + ), + UIDDefinition( + uid=60002, + key="primary_dns", + value_type=UIDType.IP_ADDRESS, + ), + UIDDefinition( + uid=60003, + key="secondary_dns", + value_type=UIDType.IP_ADDRESS, + ), + UIDDefinition( + uid=60004, + key="module_name", + value_type=UIDType.ASCII, + description="Configured Home Module name", + ), + UIDDefinition( + uid=60005, + key="destination_server", + value_type=UIDType.ASCII, + description="Configured Brink cloud server hostname", + ), + UIDDefinition( + uid=60006, + key="destination_port", + value_type=UIDType.UINT16, + ), + UIDDefinition( + uid=60008, + key="wifi_name", + value_type=UIDType.ASCII, + description="WiFi name", + ), + UIDDefinition( + uid=60031, + key="wifi_ssid", + value_type=UIDType.ASCII, + description="Configured Wi-Fi SSID", + ), +) + +UID_LOOKUP: dict[int, UIDDefinition] = { + definition.uid: definition + for definition in UID_DEFINITIONS +} + + +def parse_ascii(values: list[int]) -> str: + """Parse a null-terminated ASCII string.""" + return bytes(value for value in values).split(b"\x00", 1)[0].decode("ascii") + + +def parse_base_version(values: list[int]) -> str: + """Parse the base firmware version.""" + + if len(values) < 4: + return "" + + prefix = chr(values[0]) + return f"{prefix}{values[1]}.{values[2]:02}.{values[3]:02}" diff --git a/custom_components/brink_ventilation/core/uid_parser.py b/custom_components/brink_ventilation/core/uid_parser.py new file mode 100644 index 0000000..0db01a9 --- /dev/null +++ b/custom_components/brink_ventilation/core/uid_parser.py @@ -0,0 +1,175 @@ +"""Parser for Brink local UID values.""" + +from __future__ import annotations + +from collections.abc import Callable + +from .uid_definitions import UID_LOOKUP, UIDType + + +def _parse_bytes(value: str) -> list[int]: + """Parse a comma-separated byte string.""" + return [int(part.strip()) for part in value.split(",")] + + +def parse_uint16(values: list[int]) -> int: + """Parse a little-endian unsigned 16-bit integer.""" + if len(values) < 2: + raise ValueError("Expected at least two bytes.") + + if any(byte < 0 or byte > 255 for byte in values[:2]): + raise ValueError("Bytes must be in range 0..255.") + + return values[0] | (values[1] << 8) + + +def decode_uint16(value: str) -> int: + """Decode a little-endian unsigned 16-bit integer.""" + return parse_uint16(_parse_bytes(value)) + + +def decode_uint32(value: str) -> int: + """Decode a little-endian unsigned 32-bit integer.""" + bytes_list = _parse_bytes(value) + if len(bytes_list) < 4: + raise ValueError("Expected at least four bytes.") + + if any(byte < 0 or byte > 255 for byte in bytes_list[:4]): + raise ValueError("Bytes must be in range 0..255.") + + return ( + bytes_list[0] + | (bytes_list[1] << 8) + | (bytes_list[2] << 16) + | (bytes_list[3] << 24) + ) + + +def decode_bool(value: str) -> bool: + """Decode a boolean value.""" + return decode_uint16(value) != 0 + + +def decode_percent(value: str) -> int: + """Decode a percentage.""" + return decode_uint16(value) + + +def decode_rpm(value: str) -> int: + """Decode a fan speed.""" + return decode_uint16(value) + + +def decode_airflow(value: str) -> int: + """Decode airflow in m³/h.""" + return decode_uint16(value) + + +def decode_temperature(value: str) -> float: + """Decode a temperature. + + Brink stores temperatures as tenths of a degree. + Example: + "231,0" -> 23.1 + """ + return decode_uint16(value) / 10.0 + + +def decode_humidity(value: str) -> int: + """Decode relative humidity.""" + return decode_uint16(value) + + +def decode_pressure(value: str) -> int: + """Decode pressure in Pa.""" + return decode_uint16(value) + + +def decode_ip_address(value: str) -> str: + """Decode an IP address.""" + bytes_list = _parse_bytes(value) + if len(bytes_list) != 4: + raise ValueError("Expected exactly four bytes for an IP address.") + return ".".join(str(byte) for byte in bytes_list) + + +def decode_version(value: str) -> str: + """Decode a version string.""" + bytes_list = _parse_bytes(value) + if len(bytes_list) < 4: + raise ValueError("Expected at least four bytes for a version string.") + prefix = chr(bytes_list[0]) + return f"{prefix}{bytes_list[1]}.{bytes_list[2]:02}.{bytes_list[3]:02}" + + +def decode_ascii(value: str) -> str: + """Decode a null-terminated ASCII string.""" + return ( + bytes(number for number in _parse_bytes(value) if number) + .decode("ascii", errors="strict") + ) + + +def decode_time(value: str) -> str: + """Decode a device time.""" + bytes_list = _parse_bytes(value) + + if len(bytes_list) < 2: + raise ValueError("Expected at least two bytes for a time.") + + return f"{bytes_list[0]:02d}:{bytes_list[1]:02d}" + + +def decode_date(value: str) -> str: + """Decode a date in YYYY-MM-DD format.""" + bytes_list = _parse_bytes(value) + if len(bytes_list) < 3: + raise ValueError("Expected at least three bytes for a date.") + day = bytes_list[0] + month = bytes_list[1] + year = 2000 + bytes_list[2] + return f"{year:04d}-{month:02d}-{day:02d}" + + +DECODERS: dict[UIDType, Callable[[str], object]] = { + UIDType.UINT16: decode_uint16, + UIDType.UINT32: decode_uint32, + UIDType.BOOL: decode_bool, + UIDType.INT: decode_uint16, + UIDType.RPM: decode_rpm, + UIDType.AIRFLOW: decode_airflow, + UIDType.PRESSURE: decode_pressure, + UIDType.TEMPERATURE: decode_temperature, + UIDType.HUMIDITY: decode_humidity, + UIDType.ASCII: decode_ascii, + UIDType.IP_ADDRESS: decode_ip_address, + UIDType.VERSION: decode_version, + UIDType.TIME: decode_time, + UIDType.DATE: decode_date, +} + + +def parse_uid_values( + values: dict[str, str], +) -> dict[str, object]: + """Convert raw UID values into named values.""" + parsed: dict[str, object] = {} + + for uid_key, raw_value in values.items(): + uid = int(uid_key.removeprefix("UID")) + + definition = UID_LOOKUP.get(uid) + if definition is None: + continue + + decoder = DECODERS.get(definition.value_type) + if decoder is None: + parsed[definition.key] = raw_value + continue + + try: + parsed[definition.key] = decoder(raw_value) + except (TypeError, ValueError): + parsed[definition.key] = raw_value + + return parsed diff --git a/custom_components/brink_ventilation/entity.py b/custom_components/brink_ventilation/entity.py index 334321b..801e65c 100644 --- a/custom_components/brink_ventilation/entity.py +++ b/custom_components/brink_ventilation/entity.py @@ -2,14 +2,24 @@ from __future__ import annotations -from typing import Any - +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_MODEL, DEFAULT_NAME, DOMAIN +from .const import ( + DEFAULT_MODEL, + DEFAULT_NAME, + DOMAIN, + PARAM_EXHAUST_AIR_FLOW, + PARAM_EXHAUST_AIR_PRESSURE, + PARAM_SUPPLY_AIR_FLOW, + PARAM_SUPPLY_AIR_PRESSURE, +) +from .coordinator import BrinkCoordinator +from .models import BrinkAlertLevel, BrinkDeviceData -class BrinkHomeSystemEntity(CoordinatorEntity): +# class BrinkHomeSystemEntity(CoordinatorEntity): +class BrinkHomeSystemEntity(CoordinatorEntity[BrinkCoordinator]): """Common entity helpers for a Brink system.""" def __init__(self, client, coordinator, system_id: int) -> None: @@ -19,63 +29,231 @@ def __init__(self, client, coordinator, system_id: int) -> None: self.system_id = system_id @property - def _device(self) -> dict[str, Any] | None: - """Return the current device payload.""" - data = self.coordinator.data or {} - return data.get(self.system_id) + def _device(self) -> BrinkDeviceData | None: + """Return the current device.""" + if self.coordinator.data is None: + return None + + return self.coordinator.data.get(self.system_id) @property def device_name(self) -> str: """Return the Brink system display name.""" device = self._device - if device is None: - return DEFAULT_NAME - return device.get("name", DEFAULT_NAME) + + return DEFAULT_NAME if device is None else device.name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for the Brink entity.""" - device = self._device or {} - return { - "identifiers": {(DOMAIN, str(self.system_id))}, - "name": self.device_name, - "manufacturer": DEFAULT_NAME, - "model": device.get("model", DEFAULT_MODEL), - "serial_number": device.get("serial_number"), - "sw_version": device.get("sw_version"), - } + device = self._device + name = self.device_name + if device is None: + return DeviceInfo( + identifiers={(DOMAIN, str(self.system_id))}, + name=self.device_name, + manufacturer=DEFAULT_NAME, + model=DEFAULT_MODEL, + ) + kwargs: dict[str, object] = ( + {"configuration_url": "http://brink.local/"} + if device.ip_address + else {} + ) + return DeviceInfo( + identifiers={(DOMAIN, str(self.system_id))}, + name=device.model or self.device_name, + manufacturer=DEFAULT_NAME, + model=device.model or DEFAULT_MODEL, + serial_number=device.serial_number, + sw_version=device.sw_version, + **kwargs, + ) @property def available(self) -> bool: """Return entity availability.""" - return self.coordinator.last_update_success and self._device is not None + return ( + super().available + and self._device is not None + ) + + +class BrinkGatewayStateSensor(BrinkHomeSystemEntity): + """Sensor for the gateway state.""" + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available class BrinkHomeDeviceEntity(BrinkHomeSystemEntity): """Common entity helpers for a Brink system parameter.""" - def __init__(self, client, coordinator, system_id: int, parameter_key: str) -> None: + # coordinator: BrinkCoordinator + + def __init__(self, client, coordinator: BrinkCoordinator, system_id: int, parameter_key: str) -> None: """Initialize the Brink parameter entity.""" super().__init__(client, coordinator, system_id) + # self.client = client + # self.system_id = system_id self.parameter_key = parameter_key @property - def data(self) -> dict[str, Any] | None: + def data(self) -> dict[str, object] | None: """Return the current parameter payload.""" + parameter = self._parameters.get(self.parameter_key) + + return parameter if isinstance(parameter, dict) else None + + @property + def _parameters(self) -> dict[str, dict[str, object]]: + """Return the device parameters.""" device = self._device - if device is None: - return None - return device.get("parameters", {}).get(self.parameter_key) + + return {} if device is None else device.parameters @property def parameter_name(self) -> str: """Return the translated parameter name.""" param = self.data + + default_name = self.parameter_key.replace("_", " ") + if param is None: - return self.parameter_key.replace("_", " ") - return param.get("name", self.parameter_key.replace("_", " ")) + return default_name + + name = param.get("name") + + return str(name) if name is not None else default_name + + @property + def parameter_value(self) -> str | None: + """Return the current parameter value.""" + data = self.data + + if data is None: + return None + + value = data.get("value") + + return None if value is None else str(value) + + @property + def has_blocking_error(self) -> bool: + """Return True if the device has a blocking fault.""" + device = self._device + if device is None: + return False + + return any( + alert.level == BrinkAlertLevel.BLOCKING + for alert in device.alerts + ) + + @property + def api_is_online(self) -> bool: + """Return whether the Brink API is online.""" + return self.coordinator.api_is_online + + @property + def api_error(self) -> str | None: + """Return the last Brink API error.""" + return self.coordinator.api_error + + @property + def api_error_code(self) -> int | None: + """Return the last Brink API error code.""" + return self.coordinator.api_error_code + + @property + def api_error_type(self) -> str | None: + """Return the last Brink API error type.""" + return self.coordinator.api_error_type @property def available(self) -> bool: - """Return entity availability.""" - return super().available and self.data is not None + """Return whether the entity is available.""" + device = self._device + + if not self.coordinator.last_update_success: + return False + + if self.has_blocking_error: + return False + + return ( + super().available + and self.data is not None + and device is not None + and device.device_is_online + ) + + @property + def cabinet_sound_power(self) -> float | None: + """Return the estimated cabinet sound power.""" + parameters = self._parameters + + supply_airflow = parameters.get(PARAM_SUPPLY_AIR_FLOW) + exhaust_airflow = parameters.get(PARAM_EXHAUST_AIR_FLOW) + supply_pressure = parameters.get(PARAM_SUPPLY_AIR_PRESSURE) + exhaust_pressure = parameters.get(PARAM_EXHAUST_AIR_PRESSURE) + + if not all( + isinstance(parameter, dict) + for parameter in ( + supply_airflow, + exhaust_airflow, + supply_pressure, + exhaust_pressure, + ) + ): + return None + + try: + airflow = ( + float(supply_airflow["value"]) + + float(exhaust_airflow["value"]) + ) / 2.0 + + pressure = ( + float(supply_pressure["value"]) + + float(exhaust_pressure["value"]) + ) / 2.0 + except (KeyError, TypeError, ValueError): + return None + + return cabinet_sound_power( + airflow=airflow, + pressure=pressure, + ) + + def _parameter_value( + self, + parameter_key: str, + ) -> float | None: + """Return the numeric value of a parameter.""" + parameter = self._parameters.get(parameter_key) + + if not isinstance(parameter, dict): + return None + + value = parameter.get("value") + + try: + return float(value) + except (TypeError, ValueError): + return None + + +async def old_async_set_airflow(self, airflow: int) -> None: + """Set the target airflow in m³/h.""" + await self.coordinator.async_set_airflow(self._device_id, airflow) + await self.coordinator.async_request_refresh() + + +async def old_async_set_level(self, level: int) -> None: + """Set the ventilation level.""" + await self.coordinator.async_set_level(self._device_id, level) + await self.coordinator.async_request_refresh() diff --git a/custom_components/brink_ventilation/fan.py b/custom_components/brink_ventilation/fan.py index 0606f2a..53b1a43 100644 --- a/custom_components/brink_ventilation/fan.py +++ b/custom_components/brink_ventilation/fan.py @@ -2,54 +2,264 @@ import logging import math +from dataclasses import dataclass -from homeassistant.components.fan import FanEntity, FanEntityFeature +import voluptuous as vol +from homeassistant.components.fan import ( + FanEntity, + FanEntityDescription, + FanEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.percentage import int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage +from homeassistant.helpers import entity_platform +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range from .const import ( + CONTROL_TYPE_MAP, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, + FAN_OPERATING_MODE_LABELS, MODE_MANUAL_VALUE, PARAM_OPERATING_MODE, PARAM_VENTILATION_LEVEL, + READ_WRITE_MAP, + VALUE_STATE_MAP, ) from .entity import BrinkHomeDeviceEntity _LOGGER = logging.getLogger(__name__) +SERVICE_SET_AIRFLOW = "set_airflow" +SERVICE_SET_LEVEL = "set_level" + +ATTR_AIRFLOW = "airflow" +ATTR_LEVEL = "level" + SPEED_RANGE = (1, 3) +DEFAULT_ON_PERCENTAGE = 33 + +OPERATING_MODE_LABELS_REVERSE = { + label: value + for value, label in FAN_OPERATING_MODE_LABELS.items() +} + + +@dataclass(frozen=True, kw_only=True) +class BrinkFanEntityDescription(FanEntityDescription): + """Describe a Brink fan entity.""" + + parameter_key: str + + +VENTILATION_FAN_DESCRIPTION = BrinkFanEntityDescription( + key="speed", + translation_key="ventilation_airflow", + parameter_key=PARAM_VENTILATION_LEVEL, +) + +FAN_DESCRIPTION = BrinkFanEntityDescription( + key="mode", + translation_key="ventilation_level", + parameter_key=PARAM_VENTILATION_LEVEL, +) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities +) -> None: """Set up the Brink ventilation fan platform.""" client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - entities = [ - BrinkHomeVentilationFanEntity(client, coordinator, system_id, PARAM_VENTILATION_LEVEL) - for system_id, device in (coordinator.data or {}).items() - if device.get("parameters", {}).get(PARAM_VENTILATION_LEVEL) - ] + entities: list[FanEntity] = [] + + for system_id, device in (coordinator.data or {}).items(): + if not device.parameters.get(PARAM_VENTILATION_LEVEL): + continue + + entities.append( + BrinkHomeVentilationFanEntity( + client, + coordinator, + system_id, + ) + ) + + entities.append( + BrinkHomeLevelFanEntity( + client, + coordinator, + system_id, + ) + ) async_add_entities(entities) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_SET_AIRFLOW, + { + vol.Required(ATTR_AIRFLOW): vol.All( + vol.Coerce(int), vol.Range(min=0, max=300) + ), + }, + "async_set_airflow", + ) + + platform.async_register_entity_service( + SERVICE_SET_LEVEL, + { + vol.Required(ATTR_LEVEL): vol.All( + vol.Coerce(int), vol.In([0, 1, 2, 3]) + ), + }, + "async_set_level", + ) -class BrinkHomeVentilationFanEntity(BrinkHomeDeviceEntity, FanEntity): + +class BrinkHomeBaseFanEntity(BrinkHomeDeviceEntity, FanEntity): """Representation of the Brink ventilation level control.""" - async def async_set_percentage(self, percentage: int) -> None: - if percentage <= 0: - await self._async_write_level("0") - return + _attr_has_entity_name = True - target_level = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) - target_level = max(SPEED_RANGE[0], min(SPEED_RANGE[1], target_level)) - await self._async_write_level(str(target_level)) + entity_description: BrinkFanEntityDescription + + def __init__( + self, + client, + coordinator, + system_id: int, + ) -> None: + """Initialize the fan.""" + super().__init__( + client, + coordinator, + system_id, + self.entity_description.parameter_key, + ) + + self._max_airflow = 300 + self._level_0_max = 50 + self._level_1_max = 133 + self._level_2_max = 216 + self._level_3_max = 300 + # device = next(iter((self.coordinator.data or {}).values()), {}) + device = (self.coordinator.data or {}).get(self.system_id, {}) + parameters = device.parameters + ventilation = self.data or {} + level = int(ventilation.get("value", 0)) + airflow_param = ( + device.parameters.get(f"ventilation_mode_{level}_airflow", {}) + ) + + self._current_airflow = ( + parameters.get("supply_air_flow_setpoint", {}) + .get("value") + ) + + self._max_airflow = ( + parameters.get("ventilation_mode_3_airflow", {}) + .get("max_value", self._max_airflow) + ) + + self._level_3_max = self._max_airflow + + async def async_set_airflow(self, airflow: int) -> None: + """Set the target airflow.""" + + target_airflow = max(0, min(airflow, self._max_airflow)) + + if target_airflow <= self._level_0_max: + level = 0 + elif target_airflow <= self._level_1_max: + level = 1 + elif target_airflow <= self._level_2_max: + level = 2 + else: + level = 3 + + await self._set_airflow_for_level(level, target_airflow) + await self._async_write_level(str(level)) + + async def async_set_level( + self, + level: int, + ) -> None: + """Set ventilation level.""" + + if level not in (0, 1, 2, 3): + raise HomeAssistantError( + f"Invalid ventilation level: {level}" + ) + + await self._async_write_level(str(level)) + + async def _set_airflow_for_level( + self, + level: int, + airflow: int, + ) -> None: + """Set airflow for a ventilation level.""" + + device = next(iter((self.coordinator.data or {}).values()), {}) + parameters = device.parameters + + parameter = parameters.get( + f"ventilation_mode_{level}_airflow" + ) + + if parameter is None or parameter.get("value_id") is None: + raise HomeAssistantError( + f"Airflow parameter for level {level} is unavailable" + ) + + min_value = int(parameter.get("min_value") or 0) + max_value = int(parameter.get("max_value") or airflow) + + airflow = max( + min_value, + min(airflow, max_value), + ) + + params = [ + ( + int(parameter["value_id"]), + str(airflow), + ) + ] + + _LOGGER.debug( + "Writing airflow params: %s", + params, + ) + + await self.client.write_parameters( + self.system_id, + params, + ) + + parameter["value"] = str(airflow) + + self.coordinator.async_set_updated_data( + dict(self.coordinator.data) + ) + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + _LOGGER.debug( + "Ventilation level=%s airflow=%s", + level, + airflow, + ) async def _async_write_level(self, level_value: str) -> None: ventilation = self.data @@ -57,11 +267,24 @@ async def _async_write_level(self, level_value: str) -> None: raise HomeAssistantError("Ventilation parameter is unavailable") params = [] - mode = self._device.get("parameters", {}).get(PARAM_OPERATING_MODE) if self._device else None - if mode and mode.get("value_id") is not None: + # mode = self._operating_mode + mode = self._parameters.get(PARAM_OPERATING_MODE) + + if ( + isinstance(mode, dict) + and mode.get("value_id") is not None + ): + _LOGGER.debug( + "Switching operating mode to manual due to fan speed change" + ) params.append((int(mode["value_id"]), MODE_MANUAL_VALUE)) params.append((int(ventilation["value_id"]), level_value)) + _LOGGER.debug( + "Writing fan params: %s", + params, + ) + await self.client.write_parameters(self.system_id, params) ventilation["value"] = level_value if mode is not None: @@ -69,51 +292,424 @@ async def _async_write_level(self, level_value: str) -> None: self.coordinator.async_set_updated_data(dict(self.coordinator.data)) await self.coordinator.async_request_refresh() - @property - def percentage(self): - """Return the current speed percentage.""" - param = self.data - if param is None or param.get("value") is None: - return None - current_value = int(param["value"]) - if current_value <= 0: - return 0 - return ranged_value_to_percentage(SPEED_RANGE, current_value) + _LOGGER.debug( + "Operating mode=%s ventilation=%s", + mode.get("value") if mode else None, + ventilation.get("value"), + ) @property - def speed_count(self) -> int: - """Return the number of supported speeds.""" - return int_states_in_range(SPEED_RANGE) + def preset_modes(self) -> list[str]: + """Return available preset modes.""" + # Fan preset modes currently do not support frontend translations. + # Use translation keys to stay aligned with SelectEntity translations + # and future Home Assistant support. + return list(FAN_OPERATING_MODE_LABELS.values()) @property - def name(self): - return f"{self.device_name} {self.parameter_name}" + def preset_mode(self) -> str | None: + mode = self._operating_mode + + if mode is None: + return None + + # return FAN_OPERATING_MODE_LABELS.get(str(mode.get("value"))) + return FAN_OPERATING_MODE_LABELS.get(mode) + + async def async_set_preset_mode( + self, + preset_mode: str, + ) -> None: + """Set the preset mode.""" + mode = self._operating_mode + + if mode is None or mode.get("value_id") is None: + raise HomeAssistantError( + "Operating mode parameter unavailable" + ) + + selected_value = OPERATING_MODE_LABELS_REVERSE.get( + preset_mode + ) + + if selected_value is None: + raise HomeAssistantError( + f"Unknown preset mode: {preset_mode}" + ) + + await self.client.write_parameters( + self.system_id, + [(int(mode["value_id"]), selected_value)], + ) + + mode["value"] = selected_value + + await self.coordinator.async_request_refresh() @property - def unique_id(self): - return f"{DOMAIN}_{self.system_id}_{self.parameter_key}_fan" + def werkt_percentage(self) -> int | None: + level = int((self.data or {}).get("value", 0)) + + device = self._device or {} + parameters = device.parameters + + airflow = ( + parameters.get( + f"ventilation_mode_{level}_airflow", + {}, + ).get("value") + ) + + if airflow is None: + return None + + _LOGGER.debug( + "percentage airflow=%s", + airflow, + ) + + return round(int(airflow) / self._max_airflow * 100) @property - def supported_features(self): - return FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON | FanEntityFeature.SET_SPEED + def supported_features(self) -> FanEntityFeature: + """Return supported features.""" + return ( + FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + | FanEntityFeature.SET_SPEED + | FanEntityFeature.PRESET_MODE + ) @property - def is_on(self): + def is_on(self) -> bool | None: param = self.data if param is None or param.get("value") is None: return None - return int(param["value"]) != 0 + try: + return int(param["value"]) != 0 + except (TypeError, ValueError): + return None async def async_turn_on( self, - speed: str = None, - percentage: int = None, - preset_mode: str = None, + percentage: int | None = None, + preset_mode: str | None = None, **kwargs, ) -> None: + """Turn on the fan.""" + _LOGGER.debug( + "Setting fan percentage=%s speed=%s preset_mode=%s", + percentage, + preset_mode, + ) + + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + return + +# if percentage is None: +# percentage = ranged_value_to_percentage( +# SPEED_RANGE, +# 1, +# ) +# await self.async_set_percentage(percentage) if percentage is None: - percentage = 33 + await self._async_write_level("1") + return + await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: await self._async_write_level("0") + + @property + def old_operating_mode(self) -> dict | None: + """Return the operating mode parameter.""" + if self._device is None: + return None + + return self._device.get( + "parameters", + {}, + ).get(PARAM_OPERATING_MODE) + + @property + def _operating_mode(self) -> str | None: + """Return the operating mode.""" + parameter = self._parameters.get(PARAM_OPERATING_MODE) + + if not isinstance(parameter, dict): + return None + + value = parameter.get("value") + + return str(value) if value is not None else None + + @property + def extra_state_attributes(self) -> dict[str, object]: + """Return extra state attributes.""" + + attributes: dict[str, object] = {} + + param = self.data or {} + + _LOGGER.debug( + "Coordinator data: %s", + self.coordinator.data, + ) + + # _LOGGER.debug( + # "Entity data keys: %s", + # list((self.data or {}).keys()), + # ) + + attributes.update( + { + "key": str(self.entity_description.key), + "translation_key": str(self.entity_description.translation_key), + "name": str(param.get("name")), + "raw_name": str(param.get("raw_name")), + "value": str(param.get("value")), + "numeric_id": str(param.get("numeric_id")), + "value_id": str(param.get("value_id")), + "list_items": param.get("list_items"), + "component_id": str(param.get("component_id")), + } + ) + + value_state = param.get("value_state") + attributes["raw_value_state"] = value_state + + if isinstance(value_state, int): + attributes["value_state"] = VALUE_STATE_MAP.get(value_state, "unknown") + else: + attributes["value_state"] = "unavailable" + + control_type = param.get("control_type") + attributes["raw_control_type"] = control_type + + if isinstance(control_type, int): + attributes["control_type"] = CONTROL_TYPE_MAP.get(control_type, "unknown") + else: + attributes["control_type"] = "unavailable" + + read_write = param.get("read_write") + attributes["raw_read_write"] = read_write + + if isinstance(read_write, int): + attributes["read_write"] = READ_WRITE_MAP.get(read_write, "unknown") + else: + attributes["read_write"] = "unavailable" + + device = self._device + parameters = device.parameters + level = int(param.get("value", 0)) + airflow_param = ( + parameters.get(f"ventilation_mode_{level}_airflow", {}) + ) + + attributes["airflow"] = airflow_param.get("value") + airflow_setpoint = ( + parameters.get("supply_air_flow_setpoint", {}) + .get("value") + ) + attributes["airflow_setpont"] = airflow_setpoint + + if param.get("default_value") is not None: + attributes["default_value"] = str(param["default_value"]) + + if param.get("options"): + attributes["raw_options"] = [ + option.get("label") + for option in param.get("options", []) + ] + + # These attibutes are NOT available to a fan entity, if they do it should not be a fan entity + for key in ( + "default_value", + "unit_of_measure", + "min_value", + "max_value", + "step_width", + ): + if (value := param.get(key)) is not None: + attributes[key] = value + + return attributes + + +class BrinkHomeVentilationFanEntity(BrinkHomeBaseFanEntity): + """Continuous airflow fan.""" + + entity_description = VENTILATION_FAN_DESCRIPTION + + def __init__( + self, + client, + coordinator, + system_id: int, + ) -> None: + """Initialize the airflow fan.""" + super().__init__( + client, + coordinator, + system_id, + ) + + self._attr_unique_id = ( + f"{DOMAIN}_{system_id}_{self.parameter_key}_ventilation" + ) + + @property + def percentage_step(self) -> float: + """Return percentage step.""" + return 1 + + @property + def percentage(self) -> int | None: + """Return current fan percentage.""" + + param = self.data or {} + + try: + level = int(param.get("value", 0)) + except (TypeError, ValueError): + return None + + device = self._device or {} + parameters = device.parameters + + airflow = ( + parameters.get( + f"ventilation_mode_{level}_airflow", + {}, + ).get("value") + ) + + try: + airflow_value = int(airflow) + except (TypeError, ValueError): + return None + + _LOGGER.debug( + "percentage=%s airflow=%s", + round(int(airflow) / self._max_airflow * 100), + airflow, + ) + + return round( + airflow_value / self._max_airflow * 100 + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set fan speed percentage.""" + + target_airflow = round( + percentage / 100 * self._max_airflow + ) + + if target_airflow <= 50: + level = 0 + elif target_airflow <= self._level_1_max: + level = 1 + elif target_airflow <= self._level_2_max: + level = 2 + else: + level = 3 + + _LOGGER.debug( + "Requested percentage=%s target_airflow=%s level=%s", + percentage, + target_airflow, + level, + ) + + await self._set_airflow_for_level( + level=level, + airflow=target_airflow, + ) + + await self._async_write_level(str(level)) + + async def async_set_airflow( + self, + airflow: int, + ) -> None: + """Set airflow.""" + + await self.async_set_percentage( + round(airflow / self._max_airflow * 100) + ) + + +class BrinkHomeLevelFanEntity(BrinkHomeBaseFanEntity): + """Discrete level fan.""" + + entity_description = FAN_DESCRIPTION + + def __init__( + self, + client, + coordinator, + system_id: int, + ) -> None: + """Initialize the level fan.""" + super().__init__( + client, + coordinator, + system_id, + ) + + self._attr_unique_id = ( + f"{DOMAIN}_{system_id}_{self.parameter_key}_level_fan" + ) + + @property + def speed_count(self) -> int: + """Return the number of supported speeds.""" + return int_states_in_range(SPEED_RANGE) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + param = self.data + if param is None: + return None + + value = param.get("value") + if value is None: + return None + + try: + current_value = int(value) + except (TypeError, ValueError): + return None + + if current_value == 0: + return 0 + + return ranged_value_to_percentage( + SPEED_RANGE, + current_value, + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the fan percentage.""" + + if percentage <= 0: + await self._async_write_level("0") + return + + target_level = math.ceil( + percentage_to_ranged_value(SPEED_RANGE, percentage) + ) + target_level = max( + SPEED_RANGE[0], + min(SPEED_RANGE[1], target_level), + ) + _LOGGER.debug( + "Setting fan percentage=%s level=%s", + percentage, + target_level, + ) + await self._async_write_level(str(target_level)) diff --git a/custom_components/brink_ventilation/manifest.json b/custom_components/brink_ventilation/manifest.json index d8203fe..eebd9e7 100644 --- a/custom_components/brink_ventilation/manifest.json +++ b/custom_components/brink_ventilation/manifest.json @@ -3,12 +3,9 @@ "name": "Brink-home Ventilation", "codeowners": ["@samuolis"], "config_flow": true, - "dependencies": ["http"], "documentation": "https://github.com/samuolis/brink/blob/master/README.md", + "integration_type": "device", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/samuolis/brink/issues", - "requirements": [], - "ssdp": [], - "version": "1.0.1", - "zeroconf": [] -} + "version": "2.0.0" +} \ No newline at end of file diff --git a/custom_components/brink_ventilation/models.py b/custom_components/brink_ventilation/models.py new file mode 100644 index 0000000..2258511 --- /dev/null +++ b/custom_components/brink_ventilation/models.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import IntEnum, IntFlag, StrEnum +from math import hypot, isclose +from typing import TypedDict + +_EPSILON = 1e-9 + + +class BrinkSystem(TypedDict): + system_id: int + name: str + serial_number: str | None + gateway_state: str | None + is_system_owner: bool + is_editable: bool + access_level: int + owner_group_name: str | None + is_favorite: bool + active_alert_count: int + iana_time_zone: str + two_letter_country_code: str + user_group_names: list[str] + total_count: int + + +@dataclass(slots=True, frozen=True) +class BrinkDeviceData: + """Brink device data.""" + api_is_online: bool + system_id: int + + # User-configurable device name. + name: str # API/internal name, e.g. Flair1214_11 + + # Immutable internal Brink device identifier. + internal_name: str | None # Flair1214_11 + + # Marketing/product model. + model: str # Flair 300 + + serial_number: str | None + gateway_state: str | None + gateway_type_id: int | None + device_is_online: bool + ip_address: str | None + dns_server: str | None + sw_version: str | None + alerts: list[BrinkAlert] + parameters: dict[str, dict[str, object]] + + +@dataclass(slots=True, frozen=True) +class BrinkAlert: + """Brink alert.""" + + id: int + component_id: int + code: int + level: BrinkAlertLevel + is_active: bool + is_archived: bool + incoming: datetime + outgoing: datetime | None + code_text: str | None + description: str | None + + +class BrinkAlertLevel(IntEnum): + """Brink alert severity level.""" + + INFORMATION = 0 + WARNING = 1 + ERROR = 2 + BLOCKING = 3 + + +@dataclass(slots=True, frozen=True) +class BrinkUser: + """Brink user.""" + + username: str + culture_info_code: str + user_role_id: int + mail_address_id: int + + +class BrinkUserRole(IntEnum): + """Brink user role.""" + + END_USER = 1 + END_USER_PLUS = 2 + EXPERT = 3 + OEM = 4 + + +class BrinkAccessLevel(IntFlag): + """Brink system access level.""" + + USER_READ = 0x0004 + USER_WRITE = 0x0008 + + EXPERT_READ = 0x0100 + EXPERT_WRITE = 0x0200 + + MANUFACTURER_READ = 0x4000 + MANUFACTURER_WRITE = 0x8000 + + +@dataclass(slots=True, frozen=True) +class BrinkSettingValue: + """Brink setting value.""" + + value: int + translation_id: str + + +@dataclass(slots=True, frozen=True) +class BrinkSettings: + """Brink portal settings.""" + + access_levels: list[BrinkSettingValue] + user_roles: list[BrinkSettingValue] + receiver_periods: list[BrinkSettingValue] + + +@dataclass(slots=True, frozen=True) +class SoundPowerPoint: + """Reference sound power measurement.""" + + airflow: float + pressure: float + cabinet: float + exhaust: float + supply: float + + def sound_power( + self, + channel: SoundChannel, + ) -> float: + """Return the sound power for the requested channel.""" + return { + SoundChannel.CABINET: self.cabinet, + SoundChannel.EXHAUST: self.exhaust, + SoundChannel.SUPPLY: self.supply, + }[channel] + + +SOUND_POWER_POINTS = ( + # airflow, pressure, cabinet, exhaust, supply + SoundPowerPoint(0.0, 0.0, 0.0, 0.0, 0.0), + SoundPowerPoint(150.0, 25.0, 34.0, 40.0, 49.0), + SoundPowerPoint(200.0, 50.0, 40.0, 46.0, 55.0), + SoundPowerPoint(228.0, 50.0, 41.0, 48.0, 56.0), + SoundPowerPoint(250.0, 100.0, 46.0, 49.0, 61.0), + SoundPowerPoint(300.0, 100.0, 49.0, 53.0, 65.0), +) + + +class SoundChannel(StrEnum): + """Sound power channel.""" + + CABINET = "cabinet" + EXHAUST = "exhaust" + SUPPLY = "supply" + + +def interpolate_sound_power( + airflow: float, + pressure: float, + channel: SoundChannel, +) -> float: + """Estimate the sound power using 2D inverse distance weighting. + + The estimation is based on the Brink reference measurements for + airflow (m³/h) and static pressure (Pa). + + Args: + airflow: Airflow in m³/h. + pressure: Static pressure in Pa. + channel: Sound power channel to estimate. + + Returns: + Estimated sound power in dB(A). + """ + if airflow <= 0.0: + return 0.0 + + airflow = min(max(airflow, 0.0), 300.0) + pressure = min(max(pressure, 0.0), 100.0) + + weighted_sum = 0.0 + total_weight = 0.0 + + for point in SOUND_POWER_POINTS: + distance = hypot( + airflow - point.airflow, + pressure - point.pressure, + ) + + if isclose(distance, 0.0, abs_tol=_EPSILON): + return point.sound_power(channel) + + weight = 1.0 / (distance * distance) + + weighted_sum += weight * point.sound_power(channel) + total_weight += weight + + if isclose(total_weight, 0.0, abs_tol=_EPSILON): + return 0.0 + + return round(weighted_sum / total_weight, 1) diff --git a/custom_components/brink_ventilation/number.py b/custom_components/brink_ventilation/number.py new file mode 100644 index 0000000..81174f5 --- /dev/null +++ b/custom_components/brink_ventilation/number.py @@ -0,0 +1,669 @@ +"""Number entities for Brink ventilation.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import IntEnum + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + EntityCategory, + UnitOfElectricPotential, + UnitOfTemperature, + UnitOfVolumeFlowRate, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .const import ( + CONTROL_TYPE_MAP, + DATA_CLIENT, + DATA_COORDINATOR, + DOMAIN, + PARAM_BYPASS_HYSTERESIS, + PARAM_BYPASS_TEMPERATURE, + PARAM_CO2_SENSOR_1_MAX_PPM, + PARAM_CO2_SENSOR_1_MIN_PPM, + PARAM_CO2_SENSOR_2_MAX_PPM, + PARAM_CO2_SENSOR_2_MIN_PPM, + PARAM_CO2_SENSOR_3_MAX_PPM, + PARAM_CO2_SENSOR_3_MIN_PPM, + PARAM_CO2_SENSOR_4_MAX_PPM, + PARAM_CO2_SENSOR_4_MIN_PPM, + PARAM_DAYS_UNTIL_FILTER_MESSAGE, + PARAM_IMBALANCE_FIREPLACE, + PARAM_MINIMUM_INTAKE_TEMPERATURE, + PARAM_RH_SENSOR_SENSITIVITY, + PARAM_SWITCH_TEMP_1, + PARAM_SWITCH_TEMP_2, + PARAM_VENTILATION_MODE_0, + PARAM_VENTILATION_MODE_1, + PARAM_VENTILATION_MODE_2, + PARAM_VENTILATION_MODE_3, + READ_WRITE_MAP, + VALUE_STATE_MAP, +) +from .entity import BrinkHomeDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class BrinkNumberEntityDescription(NumberEntityDescription): + """Describe a Brink number entity.""" + + parameter_key: str + mode: NumberMode = NumberMode.AUTO + + +NUMBER_DESCRIPTIONS: tuple[BrinkNumberEntityDescription, ...] = ( + BrinkNumberEntityDescription( + key="ventilation_mode_0_airflow", + translation_key="ventilation_mode_0_airflow", + parameter_key=PARAM_VENTILATION_MODE_0, + mode=NumberMode.SLIDER, + icon="mdi:fan-off", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=NumberDeviceClass.VOLUME_FLOW_RATE, + entity_category=EntityCategory.CONFIG, + ), + BrinkNumberEntityDescription( + key="ventilation_mode_1_airflow", + translation_key="ventilation_mode_1_airflow", + parameter_key=PARAM_VENTILATION_MODE_1, + mode=NumberMode.SLIDER, + icon="mdi:fan-speed-1", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=NumberDeviceClass.VOLUME_FLOW_RATE, + entity_category=EntityCategory.CONFIG, + ), + BrinkNumberEntityDescription( + key="ventilation_mode_2_airflow", + translation_key="ventilation_mode_2_airflow", + parameter_key=PARAM_VENTILATION_MODE_2, + mode=NumberMode.SLIDER, + icon="mdi:fan-speed-2", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=NumberDeviceClass.VOLUME_FLOW_RATE, + entity_category=EntityCategory.CONFIG, + ), + BrinkNumberEntityDescription( + key="ventilation_mode_3_airflow", + translation_key="ventilation_mode_3_airflow", + parameter_key=PARAM_VENTILATION_MODE_3, + mode=NumberMode.SLIDER, + icon="mdi:fan-speed-3", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=NumberDeviceClass.VOLUME_FLOW_RATE, + entity_category=EntityCategory.CONFIG, + ), + BrinkNumberEntityDescription( + key="switch_temp_1", + translation_key="switch_temp_1", + parameter_key=PARAM_SWITCH_TEMP_1, + mode=NumberMode.SLIDER, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + icon="mdi:thermometer-low", + ), + BrinkNumberEntityDescription( + key="switch_temp_2", + translation_key="switch_temp_2", + parameter_key=PARAM_SWITCH_TEMP_2, + mode=NumberMode.SLIDER, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + icon="mdi:thermometer-high", + ), + BrinkNumberEntityDescription( + key="bypass_temperature", + translation_key="bypass_temperature", + parameter_key=PARAM_BYPASS_TEMPERATURE, + mode=NumberMode.SLIDER, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + icon="mdi:thermometer-chevron-up", + ), + BrinkNumberEntityDescription( + key="bypass_hysteresis", + translation_key="bypass_hysteresis", + parameter_key=PARAM_BYPASS_HYSTERESIS, + mode=NumberMode.SLIDER, + native_min_value=0.0, + native_max_value=5.0, + native_step=0.5, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + icon="mdi:thermometer-lines", + ), + BrinkNumberEntityDescription( + key="minimum_intake_temperature", + translation_key="minimum_intake_temperature", + parameter_key=PARAM_MINIMUM_INTAKE_TEMPERATURE, + mode=NumberMode.SLIDER, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + icon="mdi:thermometer-low", + ), + BrinkNumberEntityDescription( + key="maximum_intake_temperature", + translation_key="maximum_intake_temperature", + parameter_key="maximum_intake_temperature", + mode=NumberMode.SLIDER, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + icon="mdi:thermometer-low", + ), + BrinkNumberEntityDescription( + key="days_until_filter_message", + translation_key="days_until_filter_message", + parameter_key=PARAM_DAYS_UNTIL_FILTER_MESSAGE, + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + icon="mdi:air-filter", + ), + BrinkNumberEntityDescription( + key="v1_minimum_voltage", + translation_key="v1_minimum_voltage", + parameter_key="v1_minimum_voltage", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=NumberDeviceClass.VOLTAGE, + icon="mdi:current-dc", + ), + BrinkNumberEntityDescription( + key="v1_maximum_voltage", + translation_key="v1_maximum_voltage", + parameter_key="v1_maximum_voltage", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=NumberDeviceClass.VOLTAGE, + icon="mdi:current-dc", + ), + BrinkNumberEntityDescription( + key="v2_minimum_voltage", + translation_key="v2_minimum_voltage", + parameter_key="v2_minimum_voltage", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=NumberDeviceClass.VOLTAGE, + icon="mdi:current-dc", + ), + BrinkNumberEntityDescription( + key="v2_maximum_voltage", + translation_key="v2_maximum_voltage", + parameter_key="v2_maximum_voltage", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=NumberDeviceClass.VOLTAGE, + icon="mdi:current-dc", + ), + BrinkNumberEntityDescription( + key="imbalance_fireplace", + translation_key="imbalance_fireplace", + parameter_key=PARAM_IMBALANCE_FIREPLACE, + mode=NumberMode.SLIDER, + native_min_value=0, + native_max_value=20, + native_step=1, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:fire", + ), + BrinkNumberEntityDescription( + key="rh_sensor_sensitivity", + translation_key="rh_sensor_sensitivity", + parameter_key=PARAM_RH_SENSOR_SENSITIVITY, + mode=NumberMode.SLIDER, + native_min_value=-2, + native_max_value=2, + native_step=1, + entity_category=EntityCategory.CONFIG, + icon="mdi:water-percent", + ), + BrinkNumberEntityDescription( + key="co2_sensor_1_min_ppm", + translation_key="co2_sensor_1_min_ppm", + parameter_key=PARAM_CO2_SENSOR_1_MIN_PPM, + mode=NumberMode.BOX, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=NumberDeviceClass.CO2, + entity_category=EntityCategory.CONFIG, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), + BrinkNumberEntityDescription( + key="co2_sensor_1_max_ppm", + translation_key="co2_sensor_1_max_ppm", + parameter_key=PARAM_CO2_SENSOR_1_MAX_PPM, + mode=NumberMode.BOX, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=NumberDeviceClass.CO2, + entity_category=EntityCategory.CONFIG, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), + BrinkNumberEntityDescription( + key="co2_sensor_2_min_ppm", + translation_key="co2_sensor_2_min_ppm", + parameter_key=PARAM_CO2_SENSOR_2_MIN_PPM, + mode=NumberMode.BOX, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=NumberDeviceClass.CO2, + entity_category=EntityCategory.CONFIG, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), + BrinkNumberEntityDescription( + key="co2_sensor_2_max_ppm", + translation_key="co2_sensor_2_max_ppm", + parameter_key=PARAM_CO2_SENSOR_2_MAX_PPM, + mode=NumberMode.BOX, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=NumberDeviceClass.CO2, + entity_category=EntityCategory.CONFIG, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), + BrinkNumberEntityDescription( + key="co2_sensor_3_min_ppm", + translation_key="co2_sensor_3_min_ppm", + parameter_key=PARAM_CO2_SENSOR_3_MIN_PPM, + mode=NumberMode.BOX, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=NumberDeviceClass.CO2, + entity_category=EntityCategory.CONFIG, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), + BrinkNumberEntityDescription( + key="co2_sensor_3_max_ppm", + translation_key="co2_sensor_3_max_ppm", + parameter_key=PARAM_CO2_SENSOR_3_MAX_PPM, + mode=NumberMode.BOX, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=NumberDeviceClass.CO2, + entity_category=EntityCategory.CONFIG, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), + BrinkNumberEntityDescription( + key="co2_sensor_4_min_ppm", + translation_key="co2_sensor_4_min_ppm", + parameter_key=PARAM_CO2_SENSOR_4_MIN_PPM, + mode=NumberMode.BOX, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=NumberDeviceClass.CO2, + entity_category=EntityCategory.CONFIG, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), + BrinkNumberEntityDescription( + key="co2_sensor_4_max_ppm", + translation_key="co2_sensor_4_max_ppm", + parameter_key=PARAM_CO2_SENSOR_4_MAX_PPM, + mode=NumberMode.BOX, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=NumberDeviceClass.CO2, + entity_category=EntityCategory.CONFIG, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), +) + + +class ValueState(IntEnum): + """Represents the state of a sensor value.""" + INVALID = 5 + + +class BrinkHomeNumberEntity(BrinkHomeDeviceEntity, NumberEntity): + """Representation of a Brink number.""" + + _attr_has_entity_name = True + + entity_description: BrinkNumberEntityDescription + + def __init__( + self, + client, + coordinator, + system_id: int, + description: BrinkNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__( + client, + coordinator, + system_id, + description.parameter_key, + ) + self.entity_description = description + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.system_id}_{self.parameter_key}_number" + + @property + def native_value(self) -> int | float | None: + """Return the current value.""" + param = self.data + + if param is None: + return None + + value = param.get("value") + + if value is None: + return None + + try: + numeric_value = float(value) + + return ( + int(numeric_value) + if int(param.get("decimals", 0)) == 0 + else numeric_value + ) + except (TypeError, ValueError): + return None + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + if ( + self.entity_description.native_unit_of_measurement + is not None + ): + return str( + self.entity_description.native_unit_of_measurement + ) + + param = self.data + + if param is None: + return None + + unit = param.get("unit_of_measure") + + if not unit: + return None + + return str(unit) + + @property + def native_default_value(self) -> float | None: + """Return the default value.""" + param = self.data + + if param is None: + return None + + default_value = param.get("default_value") + + if default_value is None: + default_value = param.get("min_value") + + if default_value is None: + return None + + try: + numeric_value = float(default_value) + + if int(param.get("decimals", 0)) == 0: + return int(numeric_value) + + return numeric_value + except (TypeError, ValueError): + return None + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + param = self.data + + if param is None: + return self.entity_description.native_min_value or 0 + + try: + min_value = param.get("min_value") + + if min_value is not None: + return float(min_value) + + except (TypeError, ValueError): + pass + + return self.entity_description.native_min_value or 0 + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + param = self.data + + if param is None: + return self.entity_description.native_max_value or 100 + + try: + max_value = param.get("max_value") + + if max_value is not None: + return float(max_value) + + except (TypeError, ValueError): + pass + + return self.entity_description.native_max_value or 100 + + @property + def native_step(self) -> float: + """Return the step size.""" + param = self.data + + if param is None: + return 1.0 + + step_width = param.get("step_width") + if step_width is None: + return 1.0 + + try: + step_width = param.get("step_width") + + if step_width is not None: + return float(step_width) + + decimals = int(param.get("decimals", 0)) + + if decimals > 0: + return 10**-decimals + + except (TypeError, ValueError): + pass + + return 1.0 + + @property + def available(self) -> bool: + """Return if entity is available.""" + + device = next(iter((self.coordinator.data or {}).values()), {}) + parameters = device.parameters + + param = self.data + + if ( + param is None + or param.get("value_state") == ValueState.INVALID + ): + _LOGGER.debug( + "gateway_state available: key=%s super=%s data=%s raw=%s", + self.entity_description.parameter_key, + super().available, + self.data, + self.value, + ) + + if self.entity_description.parameter_key == "ventilation_percentage": + return True + + if self.entity_description.parameter_key == "gateway_state": + return True + + if "co2_sensor_" in self.entity_description.parameter_key and not self.entity_description.parameter_key == "ebus_co2_sensor_status": + co2_sensor = int( + parameters.get("ebus_co2_sensor_status", {}) + .get("value") + ) + return (co2_sensor == 1) + + if self.entity_description.parameter_key == "rh_sensor_sensitivity": + rh_sensor = int( + parameters.get("ebus_co2_sensor_status", {}) + .get("value") + ) + return (rh_sensor == 1) + + param = self.data + + return ( + super().available + and param is not None + and param.get("value_state") != 5 + ) + + @property + def extra_state_attributes(self) -> dict[str, object]: + """Return extra state attributes.""" + + # Get base attributes + attributes: dict[str, object] = {} + + param = self.data or {} + + attributes = { + "key": str(self.entity_description.key), + "translation_key": str(self.entity_description.translation_key), + "name": str(param.get("name")), + "raw_name": str(param.get("raw_name")), + "value": str(param.get("value")), + "decimals": str(param.get("decimals")), + "numeric_id": str(param.get("numeric_id")), + "value_id": str(param.get("value_id")), + "component_id": str(param.get("component_id")), + "raw_options": param.get("options"), + "default_value": str(param.get("default_value")), + "min_value": str(param.get("min_value")), + "max_value": str(param.get("max_value")), + "step_width": str(param.get("step_width")), + "unit_of_measure": str(param.get("unit_of_measure")), + } + + value_state = param.get("value_state") + attributes["raw_value_state"] = value_state + + if isinstance(value_state, int): + attributes["value_state"] = VALUE_STATE_MAP.get(value_state, "unknown") + else: + attributes["value_state"] = "unavailable" + + control_type = param.get("control_type") + attributes["raw_control_type"] = control_type + + if isinstance(control_type, int): + attributes["control_type"] = CONTROL_TYPE_MAP.get(control_type, "unknown") + else: + attributes["control_type"] = "unavailable" + + read_write = param.get("read_write") + attributes["raw_read_write"] = read_write + + if isinstance(read_write, int): + attributes["read_write"] = READ_WRITE_MAP.get(read_write, "unknown") + else: + attributes["read_write"] = "unavailable" + + if param.get("options") != []: + attributes["raw_options"] = param.get("options") + + if param.get("list_items") != []: + attributes["list_items"] = param.get("list_items") + + return attributes + + async def async_set_native_value(self, value: float) -> None: + """Set the airflow value.""" + param = self.data + if param is None or param.get("value_id") is None: + raise HomeAssistantError( + f"{self.parameter_name} parameter is unavailable" + ) + + min_value = self.native_min_value + max_value = self.native_max_value + + if not min_value <= value <= max_value: + raise HomeAssistantError( + f"Value {value} outside allowed range " + f"{min_value} - {max_value}" + ) + + decimals = int(param.get("decimals", 0)) + + if decimals > 0: + new_value = f"{value:.{decimals}f}" + else: + new_value = str(int(round(value))) + + await self.client.write_parameters( + self.system_id, + [(int(param["value_id"]), new_value)], + ) + + param["value"] = new_value + + self.coordinator.async_set_updated_data( + dict(self.coordinator.data) + ) + await self.coordinator.async_request_refresh() + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up the Brink select platform.""" + client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + + entities = [ + BrinkHomeNumberEntity( + client, + coordinator, + system_id, + description, + ) + for system_id, device in (coordinator.data or {}).items() + for description in NUMBER_DESCRIPTIONS + if device.parameters.get(description.parameter_key) + ] + + async_add_entities(entities) diff --git a/custom_components/brink_ventilation/select.py b/custom_components/brink_ventilation/select.py index 8a4d6ee..9911fee 100644 --- a/custom_components/brink_ventilation/select.py +++ b/custom_components/brink_ventilation/select.py @@ -1,139 +1,501 @@ from __future__ import annotations -from homeassistant.components.select import SelectEntity +import logging +from collections.abc import Mapping +from dataclasses import dataclass + +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from .const import ( - BYPASS_OPERATION_LABELS, + CN_SWITCH_INPUT_CONDITION_LABELS, + CONTROL_TYPE_MAP, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, + FILTER_FAULT_CONDITION_LABELS, + PARAM_BYPASS_FUNCTION, PARAM_BYPASS_OPERATION, + PARAM_CN1_SWITCH_INPUT_CONDITION, + PARAM_CN2_SWITCH_INPUT_CONDITION, + PARAM_CONTACT_1_EXHAUST_FAN_ACTION, + PARAM_CONTACT_2_EXHAUST_FAN_ACTION, + PARAM_MODE_VALVE_24V_CONTROL, PARAM_OPERATING_MODE, + PARAM_RH_SENSOR_SENSITIVITY, + PARAM_SIGNAL_OUTPUT_MODE, + PARAM_VALVE_CONTROL, + PARAM_VENTILATION_LEVEL, + READ_WRITE_MAP, + RH_SENSOR_SENSITIVITY_LABELS, + VALUE_STATE_MAP, ) from .entity import BrinkHomeDeviceEntity +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): - """Set up the Brink select platform.""" - client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] +CN1_EXTERNAL_LEVEL = "4" +CN1_EXTERNAL_LABEL = "CN1" - entities = [] - for system_id, device in (coordinator.data or {}).items(): - parameters = device.get("parameters", {}) - if parameters.get(PARAM_OPERATING_MODE): - entities.append( - BrinkHomeModeSelectEntity( - client, coordinator, system_id, PARAM_OPERATING_MODE - ) - ) - if parameters.get(PARAM_BYPASS_OPERATION): - entities.append( - BrinkHomeBypassOperationSelectEntity( - client, coordinator, system_id, PARAM_BYPASS_OPERATION - ) - ) - async_add_entities(entities) +@dataclass(frozen=True, kw_only=True) +class BrinkSelectEntityDescription(SelectEntityDescription): + """Describe a Brink select entity.""" + + parameter_key: str + label_map: Mapping[str, str] | None = None + + @property + def reverse_label_map(self) -> dict[str, str]: + """Return reverse label mapping.""" + return ( + { + label: value + for value, label in self.label_map.items() + } + if self.label_map + else {} + ) + + +SELECT_DESCRIPTIONS: tuple[BrinkSelectEntityDescription, ...] = ( + BrinkSelectEntityDescription( + key="operating_mode", + translation_key="operating_mode", + parameter_key=PARAM_OPERATING_MODE, + icon="mdi:fan-auto", + ), + BrinkSelectEntityDescription( + key="bypass_operation", + translation_key="bypass_operation", + parameter_key=PARAM_BYPASS_OPERATION, + icon="mdi:swap-horizontal", + ), + BrinkSelectEntityDescription( + key="ventilation_level", + translation_key="ventilation_level", + parameter_key=PARAM_VENTILATION_LEVEL, + icon="mdi:fan", + ), + BrinkSelectEntityDescription( + key="bypass_function", + translation_key="bypass_function", + parameter_key=PARAM_BYPASS_FUNCTION, + icon="mdi:tune", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="mode_valve_24v_control", + translation_key="mode_valve_24v_control", + parameter_key=PARAM_MODE_VALVE_24V_CONTROL, + icon="mdi:valve-open", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="valve_control", + translation_key="valve_control", + parameter_key=PARAM_VALVE_CONTROL, + icon="mdi:valve", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="signal_output_mode", + translation_key="signal_output_mode", + parameter_key=PARAM_SIGNAL_OUTPUT_MODE, + label_map=FILTER_FAULT_CONDITION_LABELS, + icon="mdi:export-variant", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="cn1_conditions", + translation_key="cn1_conditions", + parameter_key="cn1_conditions", + icon="mdi:connection", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="cn2_conditions", + translation_key="cn2_conditions", + parameter_key="cn2_conditions", + icon="mdi:connection", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="mode_input_1", + translation_key="mode_input_1", + parameter_key="mode_input_1", + icon="mdi:import", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="mode_input_2", + translation_key="mode_input_2", + parameter_key="mode_input_2", + icon="mdi:import", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="contact_1_type", + translation_key="contact_1_type", + parameter_key="contact_1_type", + icon="mdi:electric-switch", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="contact_1_supply_fan_action", + translation_key="contact_1_supply_fan_action", + parameter_key="contact_1_supply_fan_action", + icon="mdi:fan", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="contact_1_exhaust_fan_action", + translation_key="contact_1_exhaust_fan_action", + parameter_key=PARAM_CONTACT_1_EXHAUST_FAN_ACTION, + icon="mdi:fan", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="contact_2_type", + translation_key="contact_2_type", + parameter_key="contact_2_type", + icon="mdi:electric-switch", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="contact_2_supply_fan_action", + translation_key="contact_2_supply_fan_action", + parameter_key="contact_2_supply_fan_action", + icon="mdi:fan", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="contact_2_exhaust_fan_action", + translation_key="contact_2_exhaust_fan_action", + parameter_key=PARAM_CONTACT_2_EXHAUST_FAN_ACTION, + icon="mdi:fan", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="supply_fan_control", + translation_key="supply_fan_control", + parameter_key="supply_fan_control", + icon="mdi:fan", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="cn1_switch_input_condition", + translation_key="cn1_switch_input_condition", + parameter_key=PARAM_CN1_SWITCH_INPUT_CONDITION, + label_map=CN_SWITCH_INPUT_CONDITION_LABELS, + icon="mdi:connection", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="cn2_switch_input_condition", + translation_key="cn2_switch_input_condition", + parameter_key=PARAM_CN2_SWITCH_INPUT_CONDITION, + label_map=CN_SWITCH_INPUT_CONDITION_LABELS, + icon="mdi:connection", + entity_category=EntityCategory.CONFIG, + ), + BrinkSelectEntityDescription( + key="rh_sensor_sensitivity", + translation_key="rh_sensor_sensitivity", + parameter_key=PARAM_RH_SENSOR_SENSITIVITY, + label_map=RH_SENSOR_SENSITIVITY_LABELS, + entity_category=EntityCategory.CONFIG, + icon="mdi:water-percent", + ) +) class BrinkHomeSelectEntity(BrinkHomeDeviceEntity, SelectEntity): """Base Brink select entity.""" + _attr_has_entity_name = True + + entity_description: BrinkSelectEntityDescription + + def __init__( + self, + client, + coordinator, + system_id: int, + description: BrinkSelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__( + client, + coordinator, + system_id, + description.parameter_key, + ) + self.entity_description = description + async def _async_write_value(self, value: str) -> None: + """Write a value to the Brink device.""" param = self.data + if param is None or param.get("value_id") is None: - raise HomeAssistantError(f"{self.parameter_name} parameter is unavailable") + raise HomeAssistantError( + f"{self.parameter_name} parameter is unavailable" + ) await self.client.write_parameters( self.system_id, [(int(param["value_id"]), value)], ) + param["value"] = value - self.coordinator.async_set_updated_data(dict(self.coordinator.data)) + + self.coordinator.async_set_updated_data( + dict(self.coordinator.data) + ) + await self.coordinator.async_request_refresh() @property - def name(self): - return f"{self.device_name} {self.parameter_name}" + def icon(self) -> str | None: + """Return the entity icon.""" - @property - def unique_id(self): - return f"{DOMAIN}_{self.system_id}_{self.parameter_key}_select" + if self.entity_description.key == PARAM_VENTILATION_LEVEL: + value = str((self.data or {}).get("value", "")) + + return { + "0": "mdi:fan-off", + "1": "mdi:fan-speed-1", + "2": "mdi:fan-speed-2", + "3": "mdi:fan-speed-3", + "4": "mdi:connection" + }.get(value, "mdi:fan") + + return self.entity_description.icon @property - def icon(self): - return "mdi:hvac" + def extra_state_attributes(self) -> dict[str, object]: + """Return extra state attributes.""" + param = self.data or {} + + # Get base attributes + attributes: dict[str, object] = {} + + # These attibutes are available to select entity + attributes |= { + "key": self.entity_description.key, + "translation_key": self.entity_description.translation_key, + "name": param.get("name", "unavailable"), + "raw_name": param.get("raw_name", "unavailable"), + "value": param.get("value", "unavailable"), + "numeric_id": param.get("numeric_id", "unavailable"), + "value_id": param.get("value_id", "onbeschikbaar"), + "component_id": param.get("component_id", "unavailable"), + "raw_options": param.get("options", "unavailable"), + } + + value_state = param.get("value_state") + attributes["raw_value_state"] = value_state + + if isinstance(value_state, int): + attributes["value_state"] = VALUE_STATE_MAP.get(value_state, "unknown") + else: + attributes["value_state"] = "unavailable" + + control_type = param.get("control_type") + attributes["raw_control_type"] = control_type + + if isinstance(control_type, int): + attributes["control_type"] = CONTROL_TYPE_MAP.get(control_type, "unknown") + else: + attributes["control_type"] = "unavailable" + read_write = param.get("read_write") + attributes["raw_read_write"] = read_write -class BrinkHomeModeSelectEntity(BrinkHomeSelectEntity): - """Representation of the Brink operating mode selector.""" + if isinstance(read_write, int): + attributes["read_write"] = READ_WRITE_MAP.get(read_write, "unknown") + else: + attributes["read_write"] = "unavailable" + + # These attibutes are NOT available to select entity, if they do it should not be a select entity + for key in ( + "default_value", + "raw_value", + "unit_of_measure", + "min_value", + "max_value", + "step_width", + "decimals", + ): + if (value := param.get(key)) is not None: + attributes[key] = value + # else: + # attributes[key] = "unavailable" + + return attributes + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return ( + f"{DOMAIN}_{self.system_id}_" + f"{self.parameter_key}_select" + ) + + async def async_select_option( + self, + option: str, + ) -> None: + """Select an option.""" + if option == "CN1": + raise HomeAssistantError( + "CN1 is controlled by an external input." + ) - async def async_select_option(self, option: str) -> None: param = self.data + if param is None: - raise HomeAssistantError("Operating mode parameter is unavailable") + raise HomeAssistantError( + f"{self.parameter_name} parameter is unavailable" + ) - selected = next( - (item for item in param.get("options", []) if item["label"] == option), - None, - ) - if selected is None: - raise HomeAssistantError(f"Unknown operating mode option: {option}") + reverse_label_map = self.entity_description.reverse_label_map - await self._async_write_value(selected["value"]) + if reverse_label_map: + selected_value = reverse_label_map.get(option) + else: + selected_value = next( + ( + item["value"] + for item in param.get("options", []) + if item["label"] == option + ), + None, + ) + + if selected_value is None: + raise HomeAssistantError( + f"Unknown option '{option}' " + f"for {self.parameter_name}" + ) + + await self._async_write_value(str(selected_value)) @property def current_option(self) -> str | None: + """Return current option.""" param = self.data + if param is None: return None - for option in param.get("options", []): - if option["value"] == str(param.get("value")): - return option["label"] - return None + + current_value = str(param.get("value")) + + if ( + self.entity_description.parameter_key == PARAM_VENTILATION_LEVEL + and current_value == CN1_EXTERNAL_LEVEL + ): + return CN1_EXTERNAL_LABEL + + if (label_map := self.entity_description.label_map) is not None: + return label_map.get(current_value) + + return next( + ( + option["label"] + for option in param.get("options", []) + if option["value"] == current_value + ), + None, + ) @property def options(self) -> list[str]: + """Return available options.""" + + label_map = self.entity_description.label_map + + if label_map: + result = list(label_map.values()) + + return result + param = self.data + if param is None: return [] - return [item["label"] for item in param.get("options", [])] + options = [ + str(option["label"]) + for option in param.get("options", []) + ] + + if self.entity_description.parameter_key == PARAM_VENTILATION_LEVEL and self.current_option == "CN1": + options.append("CN1") -class BrinkHomeBypassOperationSelectEntity(BrinkHomeSelectEntity): - """Representation of the Brink bypass operation selector.""" + return options @property - def name(self): - return f"{self.device_name} Bypass Operation" + def available(self) -> bool: + """Return if entity is available.""" - async def async_select_option(self, option: str) -> None: - selected_value = next( - ( - value - for value, label in BYPASS_OPERATION_LABELS.items() - if label == option - ), + if not super().available: + return False + + device = next( + iter((self.coordinator.data or {}).values()), None, ) - if selected_value is None: - raise HomeAssistantError(f"Unknown bypass operation option: {option}") + if device is None: + return False - await self._async_write_value(selected_value) + parameters = device.parameters + + # _LOGGER.debug( + # "gateway_state available: super=%s data=%s current_value=%s", + # super().available, + # self.data, + # self.current_option, + # ) + + if self.entity_description.parameter_key == "rh_sensor_sensitivity": + rh_sensor_value = parameters.get("ebus_co2_sensor_status", {}).get("value") + + try: + rh_sensor = int(rh_sensor_value) + except (TypeError, ValueError): + return False + + return rh_sensor == 1 - @property - def current_option(self) -> str | None: param = self.data - if param is None: - return None - return BYPASS_OPERATION_LABELS.get(str(param.get("value"))) - @property - def options(self) -> list[str]: - return list(BYPASS_OPERATION_LABELS.values()) + return ( + param is not None + and param.get("value_state") != 5 + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Brink select platform.""" + client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + + entities = [ + BrinkHomeSelectEntity( + client, + coordinator, + system_id, + description, + ) + for system_id, device in (coordinator.data or {}).items() + for description in SELECT_DESCRIPTIONS + if device.parameters.get(description.parameter_key) is not None + ] + + async_add_entities(entities) diff --git a/custom_components/brink_ventilation/sensor.py b/custom_components/brink_ventilation/sensor.py index 09503b7..530e881 100644 --- a/custom_components/brink_ventilation/sensor.py +++ b/custom_components/brink_ventilation/sensor.py @@ -1,6 +1,9 @@ from __future__ import annotations +import logging +from collections.abc import Callable, Mapping from dataclasses import dataclass +from enum import IntEnum from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,74 +15,168 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, EntityCategory, + UnitOfElectricPotential, + UnitOfPressure, + UnitOfSoundPressure, UnitOfTemperature, UnitOfTime, UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType from .const import ( ACTIVE_CONTROL_STATUS_LABELS, + BYPASS_VALVE_STATUS_LABELS, + CONTROL_TYPE_MAP, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, + FROST_PROTECTION_STATUS_LABELS, + GATEWAY_STATE_LABELS, + GEOTHERMAL_HEAT_EXCHANGER_LABELS, PARAM_ACTIVE_CONTROL_STATUS, + PARAM_ACTUAL_SUPPLY_AIR_FLOW, PARAM_BYPASS_VALVE_STATUS, PARAM_CO2_SENSOR_1, PARAM_CO2_SENSOR_2, PARAM_CO2_SENSOR_3, PARAM_CO2_SENSOR_4, PARAM_DAYS_SINCE_FILTER_RESET, + PARAM_DAYS_UNTIL_FILTER_MESSAGE, PARAM_EXHAUST_AIR_FLOW, + PARAM_EXHAUST_AIR_PRESSURE, PARAM_EXHAUST_TEMP, PARAM_FRESH_AIR_TEMP, + PARAM_FROST_PROTECTION_STATUS, PARAM_HUMIDITY, + PARAM_NOMINAL_EXHAUST_AIR_FLOW, + PARAM_NOMINAL_SUPPLY_AIR_FLOW, + PARAM_PREHEATER_POWER, PARAM_PREHEATER_STATUS, PARAM_REMAINING_DURATION, - PARAM_SUPPLY_TEMP, + PARAM_STATUS_GEOTHERMAL_HEAT_EXCHANGER, PARAM_SUPPLY_AIR_FLOW, + PARAM_SUPPLY_AIR_PRESSURE, + PARAM_SUPPLY_TEMP, + PARAM_VENTILATION_MODE_0, + PARAM_VENTILATION_MODE_1, + PARAM_VENTILATION_MODE_2, + PARAM_VENTILATION_MODE_3, + PREHEATER_STATUS_LABELS, + READ_WRITE_MAP, + VALUE_STATE_MAP, + VENTILATION_LEVEL_LABELS, ) from .entity import BrinkHomeDeviceEntity +from .sound import ( + CABINET_SOUND_POWER_COEFFICIENTS, + EXHAUST_SOUND_POWER_COEFFICIENTS, + SUPPLY_SOUND_POWER_COEFFICIENTS, + estimate_sound_power, +) + +_LOGGER = logging.getLogger(__name__) + +class ValueState(IntEnum): + """Represents the state of a sensor value.""" + INVALID = 5 -@dataclass(frozen=True) + +# @dataclass(frozen=True) +@dataclass(slots=True, frozen=True, kw_only=True) class BrinkSensorDescription(SensorEntityDescription): """Describe a Brink sensor entity.""" - parameter_key: str = "" + parameter_key: str is_enum: bool = False + is_device_attribute: bool = False required_value_state: int | None = None - value_map: dict[str, str] | None = None + # value_map: dict[int, str] | None = None + value_map: Mapping[int, str] | None = None enabled_value_state: int | None = None + # _attr_has_entity_name = True + value_fn: Callable[[BrinkHomeSensorEntity], StateType | None] | None = None + attr_fn: Callable[[BrinkHomeSensorEntity], Mapping[str, object] | None] | None = None SENSOR_DESCRIPTIONS: tuple[BrinkSensorDescription, ...] = ( + BrinkSensorDescription( + key="ventilation_level", + translation_key="ventilation_level", + parameter_key="ventilation_level", + icon="mdi:fan", + device_class=SensorDeviceClass.ENUM, + is_enum=True, + value_map=VENTILATION_LEVEL_LABELS + ), BrinkSensorDescription( key=PARAM_SUPPLY_AIR_FLOW, - name="Supply Air Flow", + translation_key="actual_supply_air_flow", parameter_key=PARAM_SUPPLY_AIR_FLOW, + icon="mdi:fan-chevron-up", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE + ), + BrinkSensorDescription( + key=PARAM_ACTUAL_SUPPLY_AIR_FLOW, + translation_key="actual_supply_air_flow", + parameter_key=PARAM_ACTUAL_SUPPLY_AIR_FLOW, + icon="mdi:fan-chevron-up", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE ), BrinkSensorDescription( key=PARAM_EXHAUST_AIR_FLOW, - name="Exhaust Air Flow", + translation_key="actual_exhaust_air_flow", parameter_key=PARAM_EXHAUST_AIR_FLOW, + icon="mdi:fan-chevron-down", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE + ), + BrinkSensorDescription( + key=PARAM_NOMINAL_SUPPLY_AIR_FLOW, + translation_key="nominal_supply_air_flow", + parameter_key=PARAM_NOMINAL_SUPPLY_AIR_FLOW, + icon="mdi:fan-chevron-up", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE + ), + BrinkSensorDescription( + key=PARAM_NOMINAL_EXHAUST_AIR_FLOW, + translation_key="nominal_exhaust_air_flow", + parameter_key=PARAM_NOMINAL_EXHAUST_AIR_FLOW, + icon="mdi:fan-chevron-down", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE ), BrinkSensorDescription( key=PARAM_FRESH_AIR_TEMP, - name="Fresh Air Temperature", + translation_key="fresh_air_temp", parameter_key=PARAM_FRESH_AIR_TEMP, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), + BrinkSensorDescription( + key="extract_air_temperature", + translation_key="extract_air_temperature", + parameter_key="extract_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), BrinkSensorDescription( key=PARAM_EXHAUST_TEMP, - name="Exhaust Air Temperature", + translation_key="exhaust_temp", parameter_key=PARAM_EXHAUST_TEMP, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -87,7 +184,7 @@ class BrinkSensorDescription(SensorEntityDescription): ), BrinkSensorDescription( key=PARAM_SUPPLY_TEMP, - name="Supply Air Temperature", + translation_key="supply_temp", parameter_key=PARAM_SUPPLY_TEMP, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -95,101 +192,475 @@ class BrinkSensorDescription(SensorEntityDescription): ), BrinkSensorDescription( key=PARAM_HUMIDITY, - name="Humidity", + translation_key="humidity", parameter_key=PARAM_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, required_value_state=1, ), + BrinkSensorDescription( + key="supply_air_humidity", + translation_key="supply_air_humidity", + parameter_key="supply_air_humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + BrinkSensorDescription( + key="extract_air_humidity", + translation_key="extract_air_humidity", + parameter_key="extract_air_humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + BrinkSensorDescription( + key="relative_humidity", + translation_key="relative_humidity", + parameter_key="relative_humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + required_value_state=1, + ), BrinkSensorDescription( key=PARAM_DAYS_SINCE_FILTER_RESET, - name="Days Since Filter Reset", + translation_key="days_since_filter_reset", parameter_key=PARAM_DAYS_SINCE_FILTER_RESET, + icon="mdi:air-filter", native_unit_of_measurement=UnitOfTime.DAYS, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + BrinkSensorDescription( + key=PARAM_DAYS_UNTIL_FILTER_MESSAGE, + translation_key="days_until_filter_message", + parameter_key=PARAM_DAYS_UNTIL_FILTER_MESSAGE, + icon="mdi:air-filter", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), BrinkSensorDescription( key=PARAM_REMAINING_DURATION, - name="Remaining Mode Duration", + translation_key="remaining_duration", parameter_key=PARAM_REMAINING_DURATION, + icon="mdi:timer-sand", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, ), BrinkSensorDescription( key=PARAM_ACTIVE_CONTROL_STATUS, - name="Active Control Status", + translation_key="active_control_status", parameter_key=PARAM_ACTIVE_CONTROL_STATUS, + icon="mdi:tune", device_class=SensorDeviceClass.ENUM, - entity_category=EntityCategory.DIAGNOSTIC, is_enum=True, value_map=ACTIVE_CONTROL_STATUS_LABELS, - entity_registry_enabled_default=False, + entity_registry_enabled_default=True, ), BrinkSensorDescription( key=PARAM_PREHEATER_STATUS, - name="Preheater Status", + translation_key="preheater_status", parameter_key=PARAM_PREHEATER_STATUS, + icon="mdi:radiator", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, is_enum=True, required_value_state=1, + value_map=PREHEATER_STATUS_LABELS, ), BrinkSensorDescription( key=PARAM_BYPASS_VALVE_STATUS, - name="Bypass Valve Status", + translation_key="bypass_valve_status", parameter_key=PARAM_BYPASS_VALVE_STATUS, + icon="mdi:call-split", device_class=SensorDeviceClass.ENUM, - entity_category=EntityCategory.DIAGNOSTIC, is_enum=True, + value_map=BYPASS_VALVE_STATUS_LABELS, ), BrinkSensorDescription( key=PARAM_CO2_SENSOR_1, - name="CO2 Sensor 1", + translation_key="co2_sensor_1", parameter_key=PARAM_CO2_SENSOR_1, device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, enabled_value_state=1, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkSensorDescription( key=PARAM_CO2_SENSOR_2, - name="CO2 Sensor 2", + translation_key="co2_sensor_2", parameter_key=PARAM_CO2_SENSOR_2, device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, enabled_value_state=1, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkSensorDescription( key=PARAM_CO2_SENSOR_3, - name="CO2 Sensor 3", + translation_key="co2_sensor_3", parameter_key=PARAM_CO2_SENSOR_3, device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, enabled_value_state=1, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkSensorDescription( key=PARAM_CO2_SENSOR_4, - name="CO2 Sensor 4", + translation_key="co2_sensor_4", parameter_key=PARAM_CO2_SENSOR_4, device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, enabled_value_state=1, + icon="mdi:molecule-co2", + entity_registry_enabled_default=False, + ), + BrinkSensorDescription( + key="extra_air_temp", + translation_key="extra_air_temp", + parameter_key="extra_air_temp", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + entity_registry_enabled_default=False, + ), + BrinkSensorDescription( + key="additional_temperature_sensor", + translation_key="additional_temperature_sensor", + parameter_key="additional_temperature_sensor", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + entity_registry_enabled_default=False, + ), + BrinkSensorDescription( + key=PARAM_FROST_PROTECTION_STATUS, + translation_key="frost_protection_status", + parameter_key=PARAM_FROST_PROTECTION_STATUS, + icon="mdi:snowflake-alert", + device_class=SensorDeviceClass.ENUM, + is_enum=True, + value_map=FROST_PROTECTION_STATUS_LABELS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key=PARAM_STATUS_GEOTHERMAL_HEAT_EXCHANGER, + translation_key="status_geothermal_heat_exchanger", + parameter_key=PARAM_STATUS_GEOTHERMAL_HEAT_EXCHANGER, + icon="mdi:heat-pump", + device_class=SensorDeviceClass.ENUM, + is_enum=True, + value_map=GEOTHERMAL_HEAT_EXCHANGER_LABELS, + ), + BrinkSensorDescription( + key="analog_input_1", + translation_key="analog_input_1", + parameter_key="analog_input_1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:sine-wave", + ), + BrinkSensorDescription( + key="analog_input_2", + translation_key="analog_input_2", + parameter_key="analog_input_2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:sine-wave", + ), + BrinkSensorDescription( + key="supply_air_pressure", + translation_key="supply_air_pressure", + parameter_key=PARAM_SUPPLY_AIR_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PA, + state_class=SensorStateClass.MEASUREMENT, + ), + BrinkSensorDescription( + key="exhaust_air_pressure", + translation_key="exhaust_air_pressure", + parameter_key=PARAM_EXHAUST_AIR_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PA, + state_class=SensorStateClass.MEASUREMENT, + ), + BrinkSensorDescription( + key="supply_air_flow_setpoint", + translation_key="supply_air_flow_setpoint", + parameter_key="supply_air_flow_setpoint", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + icon="mdi:fan-chevron-up", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="exhaust_air_flow_setpoint", + translation_key="exhaust_air_flow_setpoint", + parameter_key="exhaust_air_flow_setpoint", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + icon="mdi:fan-chevron-down", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="nominal_supply_air_flow", + translation_key="nominal_supply_air_flow", + parameter_key="nominal_supply_air_flow", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + icon="mdi:fan-chevron-down", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="nominal_exhaust_air_flow", + translation_key="nominal_exhaust_air_flow", + parameter_key="nominal_exhaust_air_flow", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + icon="mdi:fan-chevron-up", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="ventilation_percentage", + translation_key="ventilation_percentage", + parameter_key="ventilation_percentage", + icon="mdi:fan", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + BrinkSensorDescription( + key="v1_analog_input", + translation_key="v1_analog_input", + parameter_key="v1_analog_input", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:sine-wave", + ), + BrinkSensorDescription( + key="v2_analog_input", + translation_key="v2_analog_input", + parameter_key="v2_analog_input", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:sine-wave", + ), + BrinkSensorDescription( + key="ventilation_mode_0_airflow", + translation_key="ventilation_mode_0_airflow", + parameter_key=PARAM_VENTILATION_MODE_0, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + icon="mdi:fan-off", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="ventilation_mode_1_airflow", + translation_key="ventilation_mode_1_airflow", + parameter_key=PARAM_VENTILATION_MODE_1, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + icon="mdi:fan-speed-1", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="ventilation_mode_2_airflow", + translation_key="ventilation_mode_2_airflow", + parameter_key=PARAM_VENTILATION_MODE_2, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + icon="mdi:fan-speed-2", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="ventilation_mode_3_airflow", + translation_key="ventilation_mode_3_airflow", + parameter_key=PARAM_VENTILATION_MODE_3, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + icon="mdi:fan-speed-3", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="supply_duct_pressure", + translation_key="supply_duct_pressure", + parameter_key="supply_duct_pressure", + native_unit_of_measurement=UnitOfPressure.PA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="exhaust_duct_pressure", + translation_key="exhaust_duct_pressure", + parameter_key="exhaust_duct_pressure", + native_unit_of_measurement=UnitOfPressure.PA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="supply_fan_rpm", + translation_key="supply_fan_rpm", + parameter_key="supply_fan_rpm", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + icon="mdi:fan", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="exhaust_fan_rpm", + translation_key="exhaust_fan_rpm", + parameter_key="exhaust_fan_rpm", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + icon="mdi:fan", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="preheater_power", + translation_key="preheater_power", + parameter_key=PARAM_PREHEATER_POWER, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radiator", + entity_registry_enabled_default=False, + ), + BrinkSensorDescription( + key="gateway_state", + translation_key="gateway_state", + parameter_key="gateway_state", + icon="mdi:lan", + device_class=SensorDeviceClass.ENUM, + options=list(GATEWAY_STATE_LABELS.values()), + is_enum=True, + is_device_attribute=True, + value_map=GATEWAY_STATE_LABELS, + ), + BrinkSensorDescription( + key="ip_address", + translation_key="ip_address", + parameter_key="ip_address", + icon="mdi:lan", + is_device_attribute=True, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="dns_server", + translation_key="dns_server", + parameter_key="dns_server", + icon="mdi:lan", + is_device_attribute=True, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkSensorDescription( + key="cabinet_sound_power", + translation_key="cabinet_sound_power", + parameter_key="cabinet_sound_power", + icon="mdi:speaker", + native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.cabinet_sound_power, + attr_fn=lambda entity: { + "average_airflow": entity.cabinet_airflow, + "average_pressure": entity.cabinet_pressure, + "supply_airflow": entity.supply_airflow, + "supply_pressure": entity.supply_pressure, + "exhaust_airflow": entity.exhaust_airflow, + "exhaust_pressure": entity.exhaust_pressure, + "calculation": "2D second-order polynomial regression", + }, + ), + BrinkSensorDescription( + key="supply_sound_power", + translation_key="supply_sound_power", + parameter_key="supply_sound_power", + icon="mdi:speaker", + native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.supply_sound_power, + attr_fn=lambda entity: { + "airflow": entity.supply_airflow, + "pressure": entity.supply_pressure, + "method": "2D polynomial regression", + }, + ), + BrinkSensorDescription( + key="exhaust_sound_power", + translation_key="exhaust_sound_power", + parameter_key="exhaust_sound_power", + icon="mdi:speaker", + native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.exhaust_sound_power, + attr_fn=lambda entity: { + "airflow": entity.exhaust_airflow, + "pressure": entity.exhaust_pressure, + "method": "2D polynomial regression", + }, ), ) def _should_create_sensor(device: dict, description: BrinkSensorDescription) -> bool: """Return True when the Brink parameter should be exposed as a sensor.""" - param = device.get("parameters", {}).get(description.parameter_key) + + if description.parameter_key in { + "ventilation_percentage", + "cabinet_sound_power", + "supply_sound_power", + "exhaust_sound_power", + }: + return True + + if description.is_device_attribute: + return True + + param = device.parameters.get(description.parameter_key) + if not param: return False + if param.get("valueState") == 5: + return False + + # CO2 sensors are only available when the value is not 0, otherwise they are not connected + if description.parameter_key in { + PARAM_CO2_SENSOR_1, + PARAM_CO2_SENSOR_2, + PARAM_CO2_SENSOR_3, + PARAM_CO2_SENSOR_4, + }: + return str(param.get("value")) != "0" + required_value_state = description.required_value_state if required_value_state is None: return True @@ -204,6 +675,82 @@ async def async_setup_entry( client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + known_parameters = { + description.parameter_key + for description in SENSOR_DESCRIPTIONS + } + + known_parameters.add("supply_air_pressure") + # device + known_parameters.add("device_type") + known_parameters.add("software_label") + # fan + known_parameters.add("ventilation_level") + known_parameters.add("operating_mode") + # select + known_parameters.add("bypass_operation") + known_parameters.add("signal_output_mode") + + known_parameters.add("contact_1_type") + known_parameters.add("contact_2_type") + + known_parameters.add("cn1_switch_input_condition") + known_parameters.add("cn2_switch_input_condition") + + known_parameters.add("contact_1_supply_fan_action") + known_parameters.add("contact_1_exhaust_fan_action") + known_parameters.add("contact_2_supply_fan_action") + known_parameters.add("contact_2_exhaust_fan_action") + + known_parameters.add("mode_input_1") + known_parameters.add("mode_input_2") + + known_parameters.add("mode_valve_24v_control") + known_parameters.add("valve_control") + known_parameters.add("rh_sensor_sensitivity") + + # number + known_parameters.add("bypass_temperature") + known_parameters.add("bypass_hysteresis") + known_parameters.add("minimum_intake_temperature") + known_parameters.add("imbalance_fireplace") + known_parameters.add("v1_minimum_voltage") + known_parameters.add("v1_maximum_voltage") + known_parameters.add("v2_minimum_voltage") + known_parameters.add("v2_maximum_voltage") + known_parameters.add("switch_temp_1") + known_parameters.add("switch_temp_2") + + known_parameters.add("co2_sensor_1_min_ppm") + known_parameters.add("co2_sensor_1_max_ppm") + known_parameters.add("co2_sensor_2_min_ppm") + known_parameters.add("co2_sensor_2_max_ppm") + known_parameters.add("co2_sensor_3_min_ppm") + known_parameters.add("co2_sensor_3_max_ppm") + known_parameters.add("co2_sensor_4_min_ppm") + known_parameters.add("co2_sensor_4_max_ppm") + + # Binary + known_parameters.add("cn1_switch_input") + known_parameters.add("cn2_switch_input") + + known_parameters.add("rh_sensor_status") + known_parameters.add("ebus_co2_sensor_status") + + for system_id, device in (coordinator.data or {}).items(): + for key, param in device.parameters.items(): + if key not in known_parameters: + _LOGGER.info( + "Unhandled parameter key=%s name=%s value=%s " + "control_type=%s read_write=%s options=%s", + key, + param.get("name"), + param.get("value"), + param.get("control_type"), + param.get("read_write"), + len(param.get("options", [])), + ) + entities = [ BrinkHomeSensorEntity(client, coordinator, system_id, description) for system_id, device in (coordinator.data or {}).items() @@ -216,6 +763,7 @@ async def async_setup_entry( class BrinkHomeSensorEntity(BrinkHomeDeviceEntity, SensorEntity): """Representation of a Brink sensor.""" + _attr_has_entity_name = True entity_description: BrinkSensorDescription def __init__(self, client, coordinator, system_id: int, description: BrinkSensorDescription): @@ -228,9 +776,99 @@ def unique_id(self): return f"{DOMAIN}_{self.system_id}_{self.parameter_key}_sensor" @property - def name(self): - label = self.entity_description.name or self.parameter_name - return f"{self.device_name} {label}" + def extra_state_attributes(self) -> dict[str, object]: + """Return extra state attributes.""" + if self.entity_description.parameter_key == "ventilation_percentage": + return {} + + # Get base attributes + attributes: dict[str, object] = {} + + param = self.data or {} + + attributes = { + "key": str(self.entity_description.key), + "translation_key": str(self.entity_description.translation_key), + "name": str(param.get("name")), + "raw_name": str(param.get("raw_name")), + "value": str(param.get("value")), + "_raw_value": str(self._raw_value), + "decimals": str(param.get("decimals")), + "numeric_id": str(param.get("numeric_id")), + "value_id": str(param.get("value_id")), + "component_id": str(param.get("component_id")), + } + + value_state = param.get("value_state") + attributes["raw_value_state"] = value_state + + if isinstance(value_state, int): + attributes["value_state"] = VALUE_STATE_MAP.get(value_state, "unknown") + else: + attributes["value_state"] = "unavailable" + + control_type = param.get("control_type") + attributes["raw_control_type"] = control_type + + if isinstance(control_type, int): + attributes["control_type"] = CONTROL_TYPE_MAP.get(control_type, "unknown") + else: + attributes["control_type"] = "unavailable" + + read_write = param.get("read_write") + attributes["raw_read_write"] = read_write + + if isinstance(read_write, int): + attributes["read_write"] = READ_WRITE_MAP.get(read_write, "unknown") + else: + attributes["read_write"] = "unavailable" + + if param.get("options") != []: + attributes["raw_options"] = param.get("options") + + # These attibutes are NOT available to sensor entity, if they do it should not be a select,enum or number entity + for key in ( + "options", + "list_items", + "default_value", + "unit_of_measure", + "min_value", + "max_value", + "step_width", + ): + if (value := param.get(key)) is not None and value != []: + attributes[key] = value + + if param.get("options"): + attributes["raw_options"] = [ + option.get("label") + for option in param.get("options", []) + ] + + return attributes + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + if ( + self.entity_description.native_unit_of_measurement + is not None + ): + return str( + self.entity_description.native_unit_of_measurement + ) + + param = self.data + + if param is None: + return None + + unit = param.get("unit_of_measure") + + if not unit: + return None + + return str(unit) @property def options(self) -> list[str] | None: @@ -259,31 +897,314 @@ def entity_registry_enabled_default(self) -> bool: return param.get("value_state") == enabled_value_state @property - def native_value(self): + def _raw_value(self) -> object | None: + """Return the raw sensor value.""" + if self.entity_description.is_device_attribute: + device = self._device + + if device is None: + return None + + return getattr(device, self.parameter_key, None) + param = self.data + if param is None: return None - value = param.get("value") + return param.get("value") + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + + value_fn = self.entity_description.value_fn + if value_fn is not None: + return value_fn(self) + + if self.parameter_key == "ventilation_percentage": + return self._calculate_ventilation_percentage() + + if self.parameter_key in { + "ip_address", + "dns_server", + }: + if self.data is None: + return None + value = self.data.get("value") + if not value: + return None + return str(value) + + _LOGGER.debug( + "sensor value %s raw=%s", + self.parameter_key, + self._raw_value, + ) + + value = self._raw_value + if value is None: return None if self.entity_description.is_enum: value_map = self.entity_description.value_map + mapped = value_map.get(int(value)) + if value_map is not None: - return value_map.get(str(value), str(value)) + try: + mapped = value_map.get(int(value)) + except (TypeError, ValueError): + mapped = None - selected = next( - (item["label"] for item in param.get("options", []) if item["value"] == str(value)), - None, - ) - return selected or str(value) + if mapped is None: + _LOGGER.debug( + "Unknown enum value %s for %s", + value, + self.parameter_key, + ) + return None + + _LOGGER.debug( + "Enum %s raw=%s mapped=%s options=%s", + self.parameter_key, + value, + mapped, + self.options, + ) + return mapped + + param = self.data + + if param is not None: + selected = next( + ( + item["label"] + for item in param.get("options", []) + if item["value"] == str(value) + ), + None, + ) + + if selected is not None: + return selected + + return str(value) try: number = float(value) except (TypeError, ValueError): - return value + return str(value) + + return int(number) if number.is_integer() else number + + @property + def available(self) -> bool: + """Return if entity is available.""" + + device = self._device + if device: + parameters = device.parameters + parameter_key = self.entity_description.parameter_key + param = self.data + + if ( + param is None + or param.get("value_state") == ValueState.INVALID + ): + _LOGGER.debug( + "gateway_state available: key=%s super=%s data=%s raw=%s", + parameter_key, + super().available, + param, + self._raw_value, + ) + # return False + + if parameter_key == "ventilation_percentage": + if device is None: + return True + + parameters = device.parameters + if not isinstance(parameters, dict): + return False + + airflow_parameter = parameters.get("ventilation_mode_3_airflow") + if not isinstance(airflow_parameter, dict): + return False + + try: + return float(airflow_parameter.get("max_value", 0)) > 0 + except (TypeError, ValueError): + return False + + if parameter_key in { + "cabinet_sound_power", + "supply_sound_power", + "exhaust_sound_power", + }: + _LOGGER.debug("available parameter_key: %s coordinator: %s has_blocking_error: %s super: %s device: %s device_is_online %s", + parameter_key, + self.coordinator.last_update_success, + not self.has_blocking_error, + not super().available, + device is not None, + device.device_is_online if device else False) + return ( + self.coordinator.last_update_success + and not self.has_blocking_error + and not super().available + and device is not None + and device.device_is_online + ) + + if parameter_key == "gateway_state": + return self.coordinator.last_update_success + + if parameter_key in { + "ip_address", + "dns_server", + }: + return self.data is not None + + return ( + super().available + and param is not None + and param.get("value_state") != ValueState.INVALID + ) + + def _calculate_cabinet_sound_power(self) -> float | None: + """Calculate the estimated cabinet sound power.""" + airflow = self.cabinet_airflow + pressure = self.cabinet_pressure + + if airflow is None or pressure is None: + return None + + return estimate_sound_power( + airflow=airflow, + pressure=pressure, + coefficients=CABINET_SOUND_POWER_COEFFICIENTS, + ) + + def _calculate_ventilation_percentage(self) -> int | None: + """Calculate ventilation percentage based on configured airflow levels.""" + if self._device: + parameters = self._device.parameters + + try: + supply_flow = float( + parameters["actual_supply_air_flow"]["value"] + ) + exhaust_flow = float( + parameters["actual_exhaust_air_flow"]["value"] + ) + min_airflow = 0 + max_airflow = float( + parameters["ventilation_mode_3_airflow"]["max_value"] + ) + except ( + KeyError, + TypeError, + ValueError, + ): + return None + + current_flow = ( + supply_flow + + exhaust_flow + ) / 2 + + if max_airflow <= min_airflow: + return None + + if current_flow <= min_airflow: + return 0 + + if current_flow >= max_airflow: + return 100 + + # Calculate percentage based on current flow + fraction = (current_flow - min_airflow) / (max_airflow - min_airflow) + return round(fraction * 100) + + @property + def cabinet_airflow(self) -> float | None: + """Return the average cabinet airflow in m³/h.""" + if self.supply_airflow is None or self.exhaust_airflow is None: + return None + + return (self.supply_airflow + self.exhaust_airflow) / 2.0 + + @property + def cabinet_pressure(self) -> float | None: + """Return the average cabinet pressure in Pa.""" + if self.supply_pressure is None or self.exhaust_pressure is None: + return None + + return (self.supply_pressure + self.exhaust_pressure) / 2.0 + + @property + def cabinet_sound_power(self) -> float | None: + """Return the estimated cabinet sound power.""" + airflow = self.cabinet_airflow + pressure = self.cabinet_pressure + + if airflow is None or pressure is None: + return None + + return estimate_sound_power( + airflow=airflow, + pressure=pressure, + coefficients=CABINET_SOUND_POWER_COEFFICIENTS, + ) + + @property + def supply_airflow(self) -> float | None: + """Return the current supply airflow in m³/h.""" + return self._parameter_value(PARAM_SUPPLY_AIR_FLOW) + + @property + def exhaust_airflow(self) -> float | None: + """Return the current exhaust airflow in m³/h.""" + return self._parameter_value(PARAM_EXHAUST_AIR_FLOW) + + @property + def supply_pressure(self) -> float | None: + """Return the current supply channel pressure in Pa.""" + return self._parameter_value(PARAM_SUPPLY_AIR_PRESSURE) + + @property + def exhaust_pressure(self) -> float | None: + """Return the current exhaust channel pressure in Pa.""" + return self._parameter_value(PARAM_EXHAUST_AIR_PRESSURE) + + @property + def supply_sound_power(self) -> float | None: + """Return the estimated supply duct sound power.""" + airflow = self.supply_airflow + pressure = self.supply_pressure + + if airflow is None or pressure is None: + return None + + return estimate_sound_power( + airflow=airflow, + pressure=pressure, + coefficients=SUPPLY_SOUND_POWER_COEFFICIENTS, + ) + + @property + def exhaust_sound_power(self) -> float | None: + """Return the estimated exhaust duct sound power.""" + airflow = self.exhaust_airflow + pressure = self.exhaust_pressure + + if airflow is None or pressure is None: + return None - if number.is_integer(): - return int(number) - return number + return estimate_sound_power( + airflow=airflow, + pressure=pressure, + coefficients=EXHAUST_SOUND_POWER_COEFFICIENTS, + ) diff --git a/custom_components/brink_ventilation/services.yaml b/custom_components/brink_ventilation/services.yaml new file mode 100644 index 0000000..aa8fb02 --- /dev/null +++ b/custom_components/brink_ventilation/services.yaml @@ -0,0 +1,40 @@ +set_airflow: + name: Set airflow + description: Set the target airflow in cubic meters per hour. + target: + entity: + domain: fan + integration: brink_ventilation + fields: + airflow: + name: Airflow + description: Target airflow. + required: true + selector: + number: + min: 0 + max: 300 + step: 5 + mode: slider + unit_of_measurement: m³/h + +set_level: + name: Set ventilation level + description: Set the Brink ventilation level. + target: + entity: + domain: fan + integration: brink_ventilation + fields: + level: + name: Ventilation level + description: Select the ventilation level. + required: true + selector: + select: + options: + - "0" + - "1" + - "2" + - "3" + translation_key: brink_level \ No newline at end of file diff --git a/custom_components/brink_ventilation/sound.py b/custom_components/brink_ventilation/sound.py new file mode 100644 index 0000000..c1b2427 --- /dev/null +++ b/custom_components/brink_ventilation/sound.py @@ -0,0 +1,93 @@ +from dataclasses import dataclass +from typing import Final + +REFERENCE_MAX_AIRFLOW: Final[float] = 300.0 +REFERENCE_MAX_PRESSURE: Final[float] = 100.0 + + +@dataclass(frozen=True, slots=True) +class SoundPowerCoefficients: + """Sound power regression coefficients.""" + + constant: float + airflow: float + pressure: float + airflow_pressure: float + airflow_squared: float + pressure_squared: float + + +CABINET_SOUND_POWER_COEFFICIENTS = SoundPowerCoefficients( + constant=0.0, + airflow=0.395520111, + pressure=-0.343800277, + airflow_pressure=0.003548405, + airflow_squared=-0.001255201, + pressure_squared=-0.002876006, +) + +SUPPLY_SOUND_POWER_COEFFICIENTS = SoundPowerCoefficients( + constant=0.0, + airflow=0.632995839, + pressure=-0.747489598, + airflow_pressure=0.006001479, + airflow_squared=-0.002096625, + pressure_squared=-0.004149792, +) + +EXHAUST_SOUND_POWER_COEFFICIENTS = SoundPowerCoefficients( + constant=0.0, + airflow=0.449778086, + pressure=-0.379445215, + airflow_pressure=0.003256681, + airflow_squared=-0.001264448, + pressure_squared=-0.002788904, +) + + +def estimate_sound_power( + airflow: float, + pressure: float, + coefficients: SoundPowerCoefficients, +) -> float: + """Estimate the sound power level in dB(A). + + The estimate is based on a second-order polynomial regression fitted to + Brink reference measurements. + The input values are clamped to the supported operating range of the + Brink reference measurements. + + Args: + airflow: Airflow in m³/h. + pressure: Static pressure in Pa. + coefficients: Polynomial regression coefficients. + + Returns: + Estimated sound power in dB(A). + """ + + if airflow <= 0.0: + return 0.0 + + airflow = _clamp(airflow, 0.0, REFERENCE_MAX_AIRFLOW) + pressure = _clamp(pressure, 0.0, REFERENCE_MAX_PRESSURE) + + sound_power = ( + coefficients.constant + + coefficients.airflow * airflow + + coefficients.pressure * pressure + + coefficients.airflow_pressure * airflow * pressure + + coefficients.airflow_squared * airflow**2 + + coefficients.pressure_squared * pressure**2 + ) + + return max(sound_power, 0.0) + + +def _clamp( + value: float, + minimum: float, + maximum: float, +) -> float: + """Clamp a value to the specified range.""" + return max(minimum, min(maximum, value)) diff --git a/custom_components/brink_ventilation/strings.json b/custom_components/brink_ventilation/strings.json new file mode 100644 index 0000000..5ba02ac --- /dev/null +++ b/custom_components/brink_ventilation/strings.json @@ -0,0 +1,34 @@ +{ + "services": { + "set_airflow": { + "name": "Set airflow", + "description": "Set the target airflow in cubic meters per hour.", + "fields": { + "airflow": { + "name": "Airflow", + "description": "Target airflow." + } + } + }, + "set_level": { + "name": "Set ventilation level", + "description": "Set the Brink ventilation level.", + "fields": { + "level": { + "name": "Ventilation level", + "description": "Select the ventilation level." + } + } + } + }, + "selector": { + "brink_level": { + "options": { + "0": "Off", + "1": "Low", + "2": "Medium", + "3": "High" + } + } + } +} \ No newline at end of file diff --git a/custom_components/brink_ventilation/translations/en - kopie.json b/custom_components/brink_ventilation/translations/en - kopie.json new file mode 100644 index 0000000..b9ae137 --- /dev/null +++ b/custom_components/brink_ventilation/translations/en - kopie.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "title": "Brink Home Ventilation", + "description": "Please login to your Brink Home portal services account.", + "data": { + "username": "Email", + "password": "Password" + } + } + }, + "abort": { + "already_configured": "This Brink Home account is already configured.", + "reauth_successful": "Re-authentication was successful." + }, + "error": { + "cannot_connect": "Can't connect. Try again later.", + "invalid_auth": "Email or password is incorrect.", + "unknown": "Unknown error." + } + }, + "options": { + "step": { + "init": { + "title": "Configure Brink Home integration", + "description": "Update the scan interval to poll for data more often.", + "data": { + "scan_interval": "Scan Interval (seconds)" + } + } + } + } +} diff --git a/custom_components/brink_ventilation/translations/en.json b/custom_components/brink_ventilation/translations/en.json index b9ae137..dbffb0c 100644 --- a/custom_components/brink_ventilation/translations/en.json +++ b/custom_components/brink_ventilation/translations/en.json @@ -30,5 +30,150 @@ } } } + }, + "entity": { + "select": { + "mode_input_1": { + "name": "Ingang 1 modus" + }, + "mode_input_2": { + "name": "Ingang 2 modus" + }, + "contact_1_supply_fan_action": { + "name": "Contact 1 toevoer fan actie" + }, + "contact_1_exhaust_fan_action": { + "name": "Contact 1 afvoer fan actie" + }, + "contact_1_type": { + "name": "Contact 1 type", + "state":{ + "normally_open": "Normally open contact", + "normally_closed": "Normally closed contact" + } + }, + "contact_2_type": { + "name": "Contact 2 type", + "state":{ + "normally_open": "Normally open contact", + "normally_closed": "Normally closed contact" + } + }, + "operating_mode": { + "name": "Ventilatiemodus", + "state": { + "automatic_mode": "Automatisch", + "manual": "Handmatig", + "holiday_mode": "Vakantie", + "party_mode": "Party", + "night_ventilation_mode": "Nachtventilatie" + } + }, + "bypass_operation": { + "name": "Bypassbediening", + "state": { + "Auto mode": "Automatic mode", + "Bypass closed": "Bypass closed", + "Bypass open": "Bypass open" + } + }, + "bypass_function": { + "name": "Bypassfunctie" + }, + "mode_valve_24v_control": { + "name": "24V klepmodus" + }, + "valve_control": { + "name": "Klepaansturing" + }, + "signal_output_mode": { + "name": "Signaaluitgang modus" + } + }, + "sensor": { + "supply_air_flow": { + "name": "Toevoerdebiet" + }, + "exhaust_air_flow": { + "name": "Afvoerdebiet" + }, + "fresh_air_temp": { + "name": "Buitenluchttemperatuur" + }, + "exhaust_temp": { + "name": "Afvoerluchttemperatuur" + }, + "supply_temp": { + "name": "Toevoerluchttemperatuur" + }, + "humidity": { + "name": "Relatieve luchtvochtigheid" + }, + "days_since_filter_reset": { + "name": "Days since filter reset" + }, + "remaining_duration": { + "name": "Resterende duur" + }, + "active_control_status": { + "name": "Active control status", + "state": { + "standby": "Standby", + "bootloader": "Bootloader", + "non_locking_fault": "Non-locking fault", + "blocking_error": "Blocking error", + "manual": "Manual", + "holiday": "Holiday", + "night_ventilation_mode": "Night ventilation mode", + "party": "Party", + "bypass_boost": "Bypass Boost", + "normal_boost": "Normal Boost", + "auto_co2": "Auto CO₂", + "auto_ebus": "Auto eBus", + "auto_modbus": "Auto Modbus", + "auto_lan_wlan_portal": "Auto LAN/WLAN Portal", + "auto_lan_wlan_local": "Auto LAN/WLAN Local" + } + }, + "filter_status": { + "name": "Filter status", + "state": { + "not_dirty": "Not dirty", + "dirty": "Dirty" + } + }, + "preheater_status": { + "name": "Status voorverwarmer" + }, + "bypass_valve_status": { + "name": "Status bypassklep" + }, + "co2_sensor_1": { + "name": "CO₂-sensor 1" + }, + "co2_sensor_2": { + "name": "CO₂-sensor 2" + }, + "co2_sensor_3": { + "name": "CO₂-sensor 3" + }, + "co2_sensor_4": { + "name": "CO₂-sensor 4" + } + }, + "binary_sensor": { + "rh_sensor_status": { + "name": "Humidity sensor" + }, + "co2_sensor_status": { + "name": "CO₂ sensor" + }, + "cn1_position": { + "name": "CN1 input" + }, + "cn2_position": { + "name": "CN2 input" + } + } } -} +} \ No newline at end of file diff --git a/custom_components/brink_ventilation/translations/nl.json b/custom_components/brink_ventilation/translations/nl.json new file mode 100644 index 0000000..a4fee82 --- /dev/null +++ b/custom_components/brink_ventilation/translations/nl.json @@ -0,0 +1,777 @@ +{ + "config": { + "step": { + "user": { + "title": "Brink Home Ventilatie", + "description": "Meld u aan met uw Brink Home Portal-account.", + "data": { + "username": "Email", + "password": "Password" + } + } + }, + "abort": { + "already_configured": "Dit Brink Home account is al geconfigureerd.", + "reauth_successful": "Re-authentication was successful." + }, + "error": { + "cannot_connect": "Kan niet verbinden. Probeer het later opnieuw.", + "invalid_auth": "Email of password is onjuist.", + "unknown": "Onbekende fout." + } + }, + "options": { + "step": { + "init": { + "title": "Brink Home-integratie configureren", + "description": "Werk het scaninterval bij om vaker gegevens op te halen.", + "data": { + "scan_interval": "Scaninterval (seconden)" + } + } + } + }, + "services": { + "set_airflow": { + "name": "Luchtstroom instellen", + "description": "Stel de gewenste luchtstroom in kubieke meter per uur in.", + "fields": { + "airflow": { + "name": "Luchtstroom", + "description": "Gewenste luchtstroom." + } + } + }, + "set_level": { + "name": "Ventilatieniveau instellen", + "description": "Stel het Brink-ventilatieniveau in.", + "fields": { + "level": { + "name": "Ventilatieniveau", + "description": "Selecteer het ventilatieniveau." + } + } + } + }, + "selector": { + "brink_level": { + "options": { + "0": "Uit", + "1": "Laag", + "2": "Gemiddeld", + "3": "Hoog" + } + } + }, + "entity_component": { + "fan": { + "preset_mode": { + "automatic": "Automatisch", + "manual": "Handmatig", + "holiday": "Vakantie", + "party": "Party", + "night_ventilation": "Nachtventilatie", + "Automatic mode": "Automatisch", + "Manual": "Handmatig", + "Holiday mode": "Vakantie", + "Party mode": "Party", + "Night ventilation mode": "Nachtventilatie" + } + } + }, + "entity": { + "fan": { + "ventilation_airflow": { + "name": "Ventilatie snelheid", + "state_attributes": { + "preset_mode": { + "state": { + "Automatic": "Automatisch", + "Manual": "Handmatig", + "Holiday": "Vakantie", + "Party": "Feest", + "Night ventilation": "Nachtventilatie" + } + } + } + }, + "ventilation_level": { + "name": "Ventilatiestand", + "state_attributes": { + "preset_mode": { + "state": { + "Automatic": "Automatisch", + "Manual": "Handmatig", + "Holiday": "Vakantie", + "Party": "Feest", + "Night ventilation": "Nachtventilatie" + } + } + } + } + }, + "select": { + "ventilation_level": { + "name": "Ventilatiestand", + "state": { + "Level 0": "Uit", + "Level 1": "Stand 1", + "Level 2": "Stand 2", + "Level 3": "Stand 3" + } + }, + "mode_input_1": { + "name": "Modusingang 1", + "state": { + "0": "Uit", + "1": "Aan", + "off": "Uit", + "on": "Aan", + "Off": "Uit", + "On": "Aan" + } + }, + "mode_input_2": { + "name": "Modusingang 2", + "state": { + "0": "Uit", + "1": "Aan", + "off": "Uit", + "on": "Aan", + "Off": "Uit", + "On": "Aan" + } + }, + "contact_1_supply_fan_action": { + "name": "Contact 1 toevoer fan actie", + "state":{ + "Fan off": "Ventilator uit", + "Fan runs at absolute minimum": "Ventilator op absoluut minimum", + "Fan at setting 0": "Ventilator op stand 0", + "Fan at setting 1": "Ventilator op stand 1", + "Fan at setting 2": "Ventilator op stand 2", + "Fan at setting 3": "Ventilator op stand 3", + "Fan at step 0": "Ventilator op stand 0", + "Fan according to multiple switch": "Ventilator volgens meerstandenschakelaar", + "Fan runs at absolute maximum": "Ventilator op absoluut maximum", + "No supply fan control": "Geen regeling afvoerventilator" + } + }, + "contact_1_exhaust_fan_action": { + "name": "Contact 1 afvoer fan actie", + "state": { + "fan_off": "Ventilator uit", + "fan_absolute_minimum": "Ventilator op absoluut minimum", + "fan_setting_1": "Ventilator op stand 1", + "fan_setting_2": "Ventilator op stand 2", + "fan_setting_3": "Ventilator op stand 3", + "fan_setting_0": "Ventilator op stand 0", + "fan_multiple_switch": "Ventilator volgens meerstandenschakelaar", + "fan_absolute_maximum": "Ventilator op absoluut maximum", + "no_exhaust_fan_control": "Geen regeling afvoerventilator", + "Fan off": "Ventilator uit", + "Fan runs at absolute minimum": "Ventilator op absoluut minimum", + "Fan at setting 1": "Ventilator op stand 1", + "Fan at setting 2": "Ventilator op stand 2", + "Fan at setting 3": "Ventilator op stand 3", + "Fan at setting 0": "Ventilator op stand 0", + "Fan at step 0": "Ventilator op stand 0", + "Fan according to multiple switch": "Ventilator volgens meerstandenschakelaar", + "Fan runs at absolute maximum": "Ventilator op absoluut maximum", + "No exhaust fan control": "Geen regeling afvoerventilator" + } + }, + "contact_1_type": { + "name": "Contact 1 type", + "state":{ + "0": "Normaal open contact", + "1": "Normaal gesloten contact", + "make_contact": "Normaal open (NO)", + "break_contact": "Normaal gesloten (NC)", + "Normaly open contact": "Normaal open (NO)", + "NC contact (normally closed)": "Normaal gesloten (NC)" + } + }, + "contact_2_supply_fan_action": { + "name": "Contact 2 toevoer fan actie", + "state":{ + "Fan off": "Ventilator uit", + "Fan runs at absolute minimum": "Ventilator op absoluut minimum", + "Fan at setting 0": "Ventilator op stand 0", + "Fan at setting 1": "Ventilator op stand 1", + "Fan at setting 2": "Ventilator op stand 2", + "Fan at setting 3": "Ventilator op stand 3", + "Fan at step 0": "Ventilator op stand 0", + "Fan according to multiple switch": "Ventilator volgens meerstandenschakelaar", + "Fan runs at absolute maximum": "Ventilator op absoluut maximum", + "No supply fan control": "Geen regeling afvoerventilator" + } + }, + "contact_2_exhaust_fan_action": { + "name": "Contact 2 afvoer fan actie", + "state": { + "fan_off": "Ventilator uit", + "fan_absolute_minimum": "Ventilator op absoluut minimum", + "fan_setting_1": "Ventilator op stand 1", + "fan_setting_2": "Ventilator op stand 2", + "fan_setting_3": "Ventilator op stand 3", + "fan_setting_0": "Ventilator op stand 0", + "fan_multiple_switch": "Ventilator volgens meerstandenschakelaar", + "fan_absolute_maximum": "Ventilator op absoluut maximum", + "no_exhaust_fan_control": "Geen regeling afvoerventilator", + "Fan off": "Ventilator uit", + "Fan runs at absolute minimum": "Ventilator op absoluut minimum", + "Fan at setting 1": "Ventilator op stand 1", + "Fan at setting 2": "Ventilator op stand 2", + "Fan at setting 3": "Ventilator op stand 3", + "Fan at setting 0": "Ventilator op stand 0", + "Fan at step 0": "Ventilator op stand 0", + "Fan according to multiple switch": "Ventilator volgens meerstandenschakelaar", + "Fan runs at absolute maximum": "Ventilator op absoluut maximum", + "No exhaust fan control": "Geen regeling afvoerventilator" + } + }, + "contact_2_type": { + "name": "Contact 2 type", + "state":{ + "0": "Normaal open contact", + "1": "Normaal gesloten contact", + "make_contact": "Normaal open (NO)", + "break_contact": "Normaal gesloten (NC)", + "Normaly open contact": "Normaal open (NO)", + "NC contact (normally closed)": "Normaal gesloten (NC)" + } + }, + "cn1_conditions": { + "state": { + "0": "Alleen filtermelding", + "1": "Alleen storing", + "2": "Filtermelding en storing" + } + }, + "cn2_conditions": { + "state": { + "0": "Alleen filtermelding", + "1": "Alleen storing", + "2": "Filtermelding en storing" + } + }, + "operating_mode": { + "name": "Ventilatiemodus", + "state": { + "automatic_mode": "Automatisch", + "manual": "Handmatig", + "holiday_mode": "Vakantie", + "party_mode": "Party", + "night_ventilation_mode": "Nachtventilatie", + "Automatic mode": "Automatisch", + "Manual": "Handmatig", + "Holiday mode": "Vakantie", + "Party mode": "Feest", + "Night ventilation mode": "Nachtventilatie" + } + }, + "bypass_operation": { + "name": "Bypassbediening", + "state": { + "0": "Automatisch", + "1": "Bypass gesloten", + "2": "Bypass geopend", + "Auto mode": "Automatisch", + "Bypass closed": "Bypass gesloten", + "Bypass open": "Bypass open" + } + }, + "bypass_function": { + "name": "Bypassfunctie" + }, + "mode_valve_24v_control": { + "name": "24V klepmodus", + "state": { + "Open": "Open", + "Closed": "Gesloten" + } + }, + "valve_control": { + "name": "Klepaansturing", + "state": { + "relay_output_1": "Relaisuitgang 1", + "relay_output_2": "Relaisuitgang 2", + "analog_output_1": "Analoge uitgang 1", + "analog_output_2": "Analoge uitgang 2" + } + }, + "signal_output_mode": { + "name": "Signaaluitgang modus", + "state": { + "off": "Uit", + "filter_condition_only": "Alleen filterconditie", + "fault_condition_only": "Alleen foutconditie", + "filter_and_fault_condition": "Filter- en foutconditie", + "Off":"Uit", + "'Off'":"Uit", + "Only filtercondition":"Alleen filtermelding", + "Only faultcondition":"Alleen storing", + "Filter and fault condition": "Filtermelding en storing" + } + }, + "cn1_switch_input_condition": { + "name": "CN1 schakelingang conditie", + "state": { + "off": "Uit", + "on": "Aan", + "on_if_bypass_conditions_met": "Aan indien bypassvoorwaarden zijn vervuld", + "bypass_control": "Bypassregeling", + "bedroom_valve": "Slaapkamerklep" + } + }, + "cn2_switch_input_condition": { + "name": "CN2 schakelingang conditie", + "state": { + "off": "Uit", + "on": "Aan", + "on_if_bypass_conditions_met": "Aan indien bypassvoorwaarden zijn vervuld", + "bypass_control": "Bypassregeling", + "bedroom_valve": "Slaapkamerklep" + } + }, + "rh_sensor_sensitivity": { + "name": "Gevoeligheid vochtigheidssendor", + "state": { + "-2": "Laag", + "-1": "Lager dan normaal", + "0": "Normaal", + "1": "Hoger dan normaal", + "2": "Hoog", + "very_low": "Laag", + "low": "Lager dan normaal", + "normal": "Normaal", + "high": "Hoger dan normaal", + "very_high": "Hoog" + } + } + }, + "number": { + "imbalance_fireplace": { + "name": "Onbalans open haard" + }, + "ventilation_mode_0_airflow": { + "name": "Ventilatiestand 0" + }, + "ventilation_mode_1_airflow": { + "name": "Ventilatiestand 1" + }, + "ventilation_mode_2_airflow": { + "name": "Ventilatiestand 2" + }, + "ventilation_mode_3_airflow": { + "name": "Ventilatiestand 3" + }, + "bypass_hysteresis": { + "name": "Bypass hysterese" + }, + "minimum_intake_temperature": { + "name": "Minimale inblaastemperatuur" + }, + "days_until_filter_message": { + "name": "Dagen tot filtermelding" + }, + "bypass_temperature": { + "name": "Bypasstemperatuur" + }, + "switch_temp_1": { + "name": "Omschakeltemperatuur aardwarmtewisselaar 1" + }, + "switch_temp_2": { + "name": "Omschakeltemperatuur aardwarmtewisselaar 2" + }, + "v1_minimum_voltage": { + "name": "Minimale spanning klep 1" + }, + "v2_minimum_voltage": { + "name": "Minimale spanning klep 2" + }, + "v1_maximum_voltage": { + "name": "Maximale spanning klep 1" + }, + "v2_maximum_voltage": { + "name": "Maximale spanning klep 2" + }, + "rh_sensor_sensitivity": { + "name": "Gevoeligheid vochtigheidssendor", + "state": { + "-2": "Laag", + "-1": "Lager dan normaal", + "0": "Normaal", + "1": "Hoger dan normaal", + "2": "Hoog", + "very_low": "Laag", + "low": "Lager dan normaal", + "normal": "Normaal", + "high": "Hoger dan normaal", + "very_high": "Hoog" + } + }, + "co2_sensor_1_min_ppm": { + "name": "eBus CO2 sensor 1 - min. ppm" + }, + "co2_sensor_1_max_ppm": { + "name": "eBus CO2 sensor 1 - max. ppm" + }, + "co2_sensor_2_min_ppm": { + "name": "eBus CO2 sensor 2 - min. ppm" + }, + "co2_sensor_2_max_ppm": { + "name": "eBus CO2 sensor 2 - max. ppm" + }, + "co2_sensor_3_min_ppm": { + "name": "eBus CO2 sensor 3 - min. ppm" + }, + "co2_sensor_3_max_ppm": { + "name": "eBus CO2 sensor 3 - max. ppm" + }, + "co2_sensor_4_min_ppm": { + "name": "eBus CO2 sensor 4 - min. ppm" + }, + "co2_sensor_4_max_ppm": { + "name": "eBus CO2 sensor 4 - max. ppm" + } + }, + "sensor": { + "ip_address":{ + "name": "IP adres" + }, + "dns_server":{ + "name": "DNS server" + }, + "additional_temperature_sensor":{ + "name": "Extra temperatuur sensor" + }, + "preheater_power":{ + "name": "Vermogen voorverwarmer" + }, + "v1_analog_input": { + "name": "Analoge ingang 1" + }, + "v2_analog_input": { + "name": "Analoge ingang 2" + }, + "ventilation_level": { + "name": "Ventilatiestand", + "state": { + "Off": "Uit", + "Low": "Laag", + "Medium": "Gemiddeld", + "High": "Hoog" + } + }, + "ventilation_mode_0_airflow": { + "name": "Ventilatiestand 0" + }, + "ventilation_mode_1_airflow": { + "name": "Ventilatiestand 1" + }, + "ventilation_mode_2_airflow": { + "name": "Ventilatiestand 2" + }, + "ventilation_mode_3_airflow": { + "name": "Ventilatiestand 3" + }, + "ventilation_percentage": { + "name": "Ventilatie" + }, + "filter_message": { + "name": "Filtermelding", + "state": { + "0": "Geen filtermelding", + "1": "Filter heeft aandacht nodig", + "No active filter message": "Geen filtermelding", + "Filter needs attention": "Filter heeft aandacht nodig", + "Not dirty": "Geen filtermelding", + "Dirty": "Filter heeft aandacht nodig" + } + }, + "days_until_filter_message": { + "name": "Dagen tot filtermelding" + }, + "bypass_temperature": { + "name": "Bypasstemperatuur" + }, + "supply_air_flow_setpoint": { + "name": "Toevoerdebiet setpoint" + }, + "actual_supply_air_flow": { + "name": "Toevoerdebiet" + }, + "supply_air_pressure": { + "name": "Toevoerluchtdruk" + }, + "exhaust_air_flow_setpoint": { + "name": "Afvoerdebiet setpoint" + }, + "actual_exhaust_air_flow": { + "name": "Afvoerdebiet" + }, + "exhaust_air_pressure": { + "name": "Afvoerluchtdruk" + }, + "fresh_air_temp": { + "name": "Temperatuur van buiten" + }, + "exhaust_temp": { + "name": "Afvoerluchttemperatuur" + }, + "supply_temp": { + "name": "Temperatuur naar woning" + }, + "humidity": { + "name": "Relatieve luchtvochtigheid" + }, + "days_since_filter_reset": { + "name": "Dagen sinds filterreset" + }, + "remaining_duration": { + "name": "Resterende looptijd bedrijfsmodus" + }, + "active_control_status": { + "name": "Status actieve regeling", + "state": { + "0": "Stand-by", + "1": "Bootloader", + "2": "Niet-blokkerende storing", + "3": "Blokkerende storing", + "4": "Handmatig", + "5": "Vakantie", + "6": "Nachtventilatie", + "7": "Party", + "8": "Bypass boost", + "9": "Normale boost", + "10": "Auto CO₂", + "11": "Auto eBus", + "12": "Auto Modbus", + "13": "Auto LAN/WLAN-portaal", + "14": "Auto LAN/WLAN lokaal", + "standby": "Stand-by", + "bootloader": "Bootloader", + "non_locking_fault": "Niet-blokkerende storing", + "blocking_fault": "Blokkerende storing", + "blocking_error": "Blokkerende storing", + "manual": "Handmatig", + "holiday": "Vakantie", + "night_ventilation_mode": "Nachtventilatie", + "party": "Party", + "bypass_boost": "Bypass boost", + "normal_boost": "Normale boost", + "auto_co2": "Automatisch CO₂", + "auto_ebus": "Automatisch eBus", + "auto_modbus": "Automatisch Modbus", + "auto_lan_wlan_portal": "Automatisch LAN/WLAN-portaal", + "auto_lan_wlan_local": "Automatisch LAN/WLAN lokaal" + } + }, + "filter_status": { + "name": "Filterstatus", + "state": { + "0": "Filter schoon", + "1": "Filter vervangen", + "not_dirty": "Schoon", + "dirty": "Vervuild" + } + }, + "preheater_status": { + "name": "Status voorverwarmer", + "state": { + "off": "Uit", + "auto": "Automatisch", + "lock_current": "Huidige stand vergrendeld", + "lock_maximum": "Maximum vergrendeld" + } + }, + "bypass_valve_status": { + "name": "Status bypassklep", + "state": { + "Initialisation": "Initialisatie", + "Opens": "Openen", + "Open": "Open", + "Closed": "Gesloten", + "initialization": "Initialisatie", + "opening": "Openen", + "closing": "Sluiten", + "open": "Open", + "closed": "Gesloten" + } + }, + "co2_sensor_1": { + "name": "CO₂-sensor 1" + }, + "co2_sensor_2": { + "name": "CO₂-sensor 2" + }, + "co2_sensor_3": { + "name": "CO₂-sensor 3" + }, + "co2_sensor_4": { + "name": "CO₂-sensor 4" + }, + "status_geothermal_heat_exchanger": { + "name": "Geothermische warmtewisselaar status", + "state": { + "0": "Laag geopend", + "1": "Gesloten", + "3": "Hoog geopend", + "open_low": "Laag geopend", + "closed": "Gesloten", + "open_high": "Hoog geopend" + } + }, + "frost_protection_status": { + "name": "Vorstbeveiliging status", + "state": { + "0": "Niet geïnitialiseerd", + "1": "Opstartvertraging", + "2": "Geen vorst", + "3": "Geen vorst vertraging", + "4": "Startvertraging vorstregeling", + "5": "Wachten op ijsvorming", + "6": "Vertraging ijs gedetecteerd", + "7": "Verwarming actief", + "8": "Wachten op vrije verwarming", + "9": "Startvertraging ventilatorregeling", + "10": "Wachten ventilatorregeling", + "11": "Ventilatorregeling", + "12": "Uitschakelvertraging ventilator", + "13": "Ventilator uit", + "14": "Ventilator herstart", + "15": "Fout", + "16": "Periodieke batterijtest", + "unknown": "Onbekend", + "not_initialized": "Niet geïnitialiseerd", + "power_up_delay": "Opstartvertraging", + "no_frost": "Geen vorst", + "no_frost_delay": "Vertraging geen vorst", + "frost_control_start_delay": "Startvertraging vorstbeveiliging", + "wait_for_icing": "Wachten op ijsvorming", + "ice_detected_delay": "Vertraging na ijsdetectie", + "heating": "Verwarmen", + "wait_for_free_heater": "Wachten op vrijgave verwarming", + "fan_control_start_delay": "Startvertraging ventilatorregeling", + "fan_control_wait": "Wachten op ventilatorregeling", + "fan_control": "Ventilatorregeling actief", + "fan_off_delay": "Uitschakelvertraging ventilator", + "fan_off": "Ventilator uit", + "fan_restarting": "Ventilator herstarten", + "error": "Fout", + "periodic_coil_test": "Periodieke warmtewisselaartest", + "start_delay": "Opstartvertraging", + "wait_for_ice": "Wachten op ijsvorming", + "wait_for_fan_control": "Wachten op ventilatorregeling", + "fan_restart": "Herstart ventilator", + "water_block_test": "Waterbloktest" + } + }, + "gateway_state": { + "name": "Gateway state", + "state": { + "locked": "Op slot", + "offline": "Offline", + "online": "Online" + } + }, + "cabinet_sound_power": { + "name": "Geluidsvermogen kastafstraling" + }, + "supply_sound_power": { + "name": "Geluidsvermogen kanaal naar woning" + }, + "exhaust_sound_power": { + "name": "Geluidsvermogen kanaal uit woning" + } + }, + "binary_sensor": { + "api_online": { + "name": "API status", + "state": { + "off": "Offline", + "on": "Online" + } + }, + "blocking_error": { + "name": "Blokkerende fout", + "state": { + "off": "Geen blokkerende fout", + "on": "Blokkerende fout" + } + }, + "gateway_status": { + "name": "Gateway status", + "state": { + "off": "Offline", + "on": "Online" + } + }, + "device_online": { + "name": "Device status", + "state": { + "off": "Offline", + "on": "Online" + } + }, + "ebus_co2_sensor_status": { + "name": "eBus CO₂-sensor", + "state": { + "off": "Niet actief", + "on": "Actief" + } + }, + "status_filter_message": { + "name": "Status filtermelding", + "state": { + "off": "Geen filtermelding", + "on": "Filter heeft aandacht nodig" + } + }, + "rh_sensor_status": { + "name": "Vochtigheidssensor", + "state": { + "off": "Niet actief", + "on": "Actief" + } + }, + "co2_sensor_status": { + "name": "CO₂-sensor", + "state": { + "off": "Niet actief", + "on": "Actief" + } + }, + "cn1_position": { + "name": "CN1 ingang", + "state": { + "off": "Gesloten", + "on": "Open" + } + }, + "cn2_position": { + "name": "CN2 ingang", + "state": { + "off": "Gesloten", + "on": "Open" + } + }, + "cn1_switch_input": { + "name": "CN1 schakelaar input", + "state": { + "off": "Dicht", + "on": "Open" + } + }, + "cn2_switch_input": { + "name": "CN2 schakelaar input", + "state": { + "off": "Dicht", + "on": "Open" + } + } + } + } +} \ No newline at end of file