From 6c839e86e3af55e988c0c90470d50161e1d9f203 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 21 Jul 2026 21:54:26 +0200 Subject: [PATCH 1/9] Add files via upload --- .../brink_ventilation/__init__.py | 32 +- .../brink_ventilation/binary_sensor.py | 423 ++++++++-- custom_components/brink_ventilation/const.py | 790 +++++++++++++++++- .../brink_home_cloud.cpython-314.pyc | Bin 0 -> 37016 bytes .../core/brink_home_cloud.py | 222 ++++- custom_components/brink_ventilation/entity.py | 56 +- custom_components/brink_ventilation/fan.py | 492 ++++++++++- .../brink_ventilation/manifest.json | 1 + custom_components/brink_ventilation/number.py | 612 ++++++++++++++ custom_components/brink_ventilation/select.py | 491 +++++++++-- custom_components/brink_ventilation/sensor.py | 735 +++++++++++++++- .../translations/en - kopie.json | 34 + .../brink_ventilation/translations/en.json | 147 +++- .../brink_ventilation/translations/nl.json | 745 +++++++++++++++++ 14 files changed, 4537 insertions(+), 243 deletions(-) create mode 100644 custom_components/brink_ventilation/core/__pycache__/brink_home_cloud.cpython-314.pyc create mode 100644 custom_components/brink_ventilation/number.py create mode 100644 custom_components/brink_ventilation/translations/en - kopie.json create mode 100644 custom_components/brink_ventilation/translations/nl.json diff --git a/custom_components/brink_ventilation/__init__.py b/custom_components/brink_ventilation/__init__.py index cf68c6d..48c551a 100644 --- a/custom_components/brink_ventilation/__init__.py +++ b/custom_components/brink_ventilation/__init__.py @@ -3,14 +3,18 @@ from __future__ import annotations import asyncio -from datetime import timedelta import logging +from datetime import timedelta from typing import Any import aiohttp - 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 @@ -31,7 +35,7 @@ _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) @@ -74,6 +78,7 @@ async def async_update_data() -> dict[int, dict[str, Any]]: coordinator = DataUpdateCoordinator( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_method=async_update_data, update_interval=timedelta(seconds=scan_interval), @@ -99,15 +104,34 @@ async def async_get_devices(brink_client: BrinkHomeCloud) -> dict[int, dict[str, """Fetch and normalize Brink systems plus the parameters this integration uses.""" systems = await brink_client.get_systems() + _LOGGER.debug("Brink Flair systems: %s", 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, dict[str, Any]] = {} for system in systems: system_id = system["system_id"] + device = await brink_client.get_device(system_id) + _LOGGER.debug("Brink Flair device: %s", device) + gateway_type_id = device.get("gatewayTypeId") + is_online = device.get("isOnline") parameters = await brink_client.get_device_data(system_id) + _LOGGER.debug("Brink Flair device parameters: %s", parameters) 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"), + "gateway_type_id": gateway_type_id, + "device_is_online": is_online, "model": parameters.get(PARAM_DEVICE_TYPE, {}).get("value") or DEFAULT_MODEL, "sw_version": parameters.get(PARAM_SOFTWARE_LABEL, {}).get("value"), "parameters": parameters, diff --git a/custom_components/brink_ventilation/binary_sensor.py b/custom_components/brink_ventilation/binary_sensor.py index 8a063e4..15d3a00 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,382 @@ DOMAIN, GATEWAY_STATE_LABELS, GATEWAY_STATE_ONLINE, + PARAM_CN1_POSITION, + PARAM_CN1_SWITCH_INPUT, + PARAM_CN2_POSITION, + PARAM_CN2_SWITCH_INPUT, + PARAM_CO1_SENSOR_STATUS, + PARAM_CO2_SENSOR_STATUS, PARAM_FILTER_STATUS, + PARAM_RH_SENSOR_STATUS, ) -from .entity import BrinkHomeDeviceEntity, BrinkHomeSystemEntity +from .entity import BrinkHomeDeviceEntity +_LOGGER: logging.Logger = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + +@dataclass(frozen=True, kw_only=True) +class BrinkBinarySensorDescription( + BinarySensorEntityDescription ): - """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] + """Describe a Brink binary sensor.""" + parameter_key: str + data_fn: Callable[[dict[str, object] | None], dict[str, object] | None] | None = None + value_fn: Callable[[dict[str, object]], bool | None] | None = None + attr_fn: Callable[[dict[str, object]], dict[str, object] | None] | None = None + icon_fn: Callable[[bool | None], str | None] | None = None + available_fn: Callable[ + [dict[str, object]], + bool | None, + ] | None = None - 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 - ) - ) - async_add_entities(entities) +class BrinkHomeBinarySensorEntity( + BrinkHomeDeviceEntity, + BinarySensorEntity, +): + """Representation of a Brink binary sensor.""" + _attr_has_entity_name = True -def _gateway_state_value(device: dict | None) -> int | None: - """Normalize the Brink gateway state.""" - if device is None: - return None + entity_description: BrinkBinarySensorDescription - state = device.get("gateway_state") - try: - return int(state) - except (TypeError, ValueError): - return None + 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 - -class BrinkSystemOnlineBinarySensor(BrinkHomeSystemEntity, BinarySensorEntity): - """Binary sensor that reflects whether the Brink system is online.""" + @property + def old_unique_id(self) -> str: + """Return a unique ID.""" + return ( + f"{DOMAIN}_{self.system_id}_" + f"{self.parameter_key}_binary_sensor" + ) @property - def unique_id(self): - return f"{DOMAIN}_{self.system_id}_online" + def unique_id(self) -> str: + """Return a unique ID.""" + return ( + f"{DOMAIN}_{self.system_id}_" + f"{self.entity_description.key}" + ) @property - def name(self): - return f"{self.device_name} Online" + def icon(self) -> str | None: + """Return the entity icon.""" + + if self.entity_description.icon_fn is not None: + return self.entity_description.icon_fn(self.is_on) + + return self.entity_description.icon @property - def is_on(self): - state = _gateway_state_value(self._device) - if state is None: + def is_on(self) -> bool | None: + """Return sensor state.""" + + _LOGGER.debug( + "Gateway data for %s: %s", + self.system_id, + self._entity_data, + ) + + data = self._entity_data + + if data is None: return None - return state == GATEWAY_STATE_ONLINE + + if self.entity_description.value_fn is not None: + result = self.entity_description.value_fn(data) + + if self.entity_description.key == "gateway_state": + _LOGGER.debug( + "Gateway state=%s online=%s result=%s", + data.get("value"), + GATEWAY_STATE_ONLINE, + result, + ) + + return result + + value = data.get("value") + + if value is None: + return None + + return str(value) == "1" @property - def device_class(self): - return BinarySensorDeviceClass.CONNECTIVITY + def available(self) -> bool: + """Return if entity is available.""" + + data = self._entity_data + + if data is None: + return False + + # Override availability for system/device entities. + if self.entity_description.available_fn is not None: + return bool(self.entity_description.available_fn(data)) + + return ( + super().available + and ( + data.get("value_state") is None + or data.get("value_state") != 5 + ) + ) @property - def extra_state_attributes(self): - state = _gateway_state_value(self._device) - if state is None: - return None + def extra_state_attributes(self) -> dict[str, object]: + """Return extra state attributes.""" + + data = self._entity_data or {} - return { - "gateway_state": state, - "gateway_state_label": GATEWAY_STATE_LABELS.get(state, "unknown"), + attributes = { + "key": _debug_value(self.entity_description.key), + "translation_key": _debug_value(self.entity_description.translation_key), + "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 have this keys but if they do they are in the wrong sensor section (select or number) + for key in ( + "min_value", + "max_value", + "step_width", + "decimals", + "unit_of_measure", + ): + if data.get(key) is not None: + attributes[key] = data[key] -class BrinkFilterNeedChangeBinarySensor(BrinkHomeDeviceEntity, BinarySensorEntity): - """Binary sensor that indicates when the Brink filter needs attention.""" + if self.entity_description.attr_fn is not None: + attributes: dict[str, object] = { + "key": _debug_value(self.entity_description.key), + "translation_key": _debug_value(self.entity_description.translation_key), + "value": _debug_value(data.get("value")) + } + custom_attributes = self.entity_description.attr_fn(data) - @property - def unique_id(self): - return f"{DOMAIN}_{self.system_id}_{self.parameter_key}_binary_sensor" + if custom_attributes: + attributes.update(custom_attributes) + # attributes = custom_attributes - @property - def name(self): - return f"{self.device_name} {self.parameter_name}" + return attributes @property - def icon(self): - return "mdi:air-filter" + def _entity_data(self) -> dict[str, object] | None: + """Return the data source for this entity.""" - @property - def is_on(self): - param = self.data - if param is None: - return None - return str(param.get("value")) == "1" + if self.entity_description.data_fn is not None: + return self.entity_description.data_fn(self._device) - @property - def device_class(self): - return BinarySensorDeviceClass.PROBLEM + return self.data + + +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", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + data_fn=lambda device: ( + { + "value": device.get("gateway_state"), + "device_is_online": device.get("device_is_online"), + "gateway_type_id": device.get("gateway_type_id"), + } + if device is not None + else None + ), + value_fn=lambda data: bool(str(data.get("value")) == str(GATEWAY_STATE_ONLINE)), + attr_fn=lambda data: { + "gateway_state": data.get("value"), + "device_is_online": data.get("device_is_online"), + "gateway_type_id": data.get("gateway_type_id"), + "gateway_state_label": GATEWAY_STATE_LABELS.get( + int(data.get("value", -1)), + "unknown", + ), + }, + icon_fn=lambda is_on: ( + "mdi:connection" + if is_on + else "mdi:lan-disconnect" + ), + available_fn=lambda data: True + ), + BrinkBinarySensorDescription( + key="device_online", + translation_key="device_online", + parameter_key="device_online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + data_fn=lambda device: ( + { + "value": device.get("device_is_online"), + } + if device is not None + else None + ), + value_fn=lambda data: data.get("value"), + icon_fn=lambda is_on: ( + "mdi:connection" + if is_on + else "mdi:lan-disconnect" + ), + available_fn=lambda data: True + ), + BrinkBinarySensorDescription( + key=PARAM_FILTER_STATUS, + translation_key="filter_need_change", + parameter_key=PARAM_FILTER_STATUS, + icon="mdi:air-filter", + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda param: str(param.get("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, + ), + BrinkBinarySensorDescription( + key=PARAM_CO1_SENSOR_STATUS, + translation_key="co1_sensor_status", + parameter_key=PARAM_CO1_SENSOR_STATUS, + icon="mdi:molecule-co2", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BrinkBinarySensorDescription( + key=PARAM_CO2_SENSOR_STATUS, + translation_key="co2_sensor_status", + parameter_key=PARAM_CO2_SENSOR_STATUS, + icon="mdi:molecule-co2", + entity_category=EntityCategory.DIAGNOSTIC, + ), + 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, + ), +) + + +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 description.data_fn is not None: + entities.append( + BrinkHomeBinarySensorEntity( + client, + coordinator, + system_id, + description, + ) + ) + continue + + parameters = device.get("parameters", {}) + _LOGGER.debug( + "ebus_co2_sensor_status exists=%s value=%s", + "ebus_co2_sensor_status" in parameters, + parameters.get("ebus_co2_sensor_status"), + ) + if parameters.get(description.parameter_key) is not None: + entities.append( + BrinkHomeBinarySensorEntity( + client, + coordinator, + system_id, + description, + ) + ) + + async_add_entities(entities) + + +def _gateway_state_value(device: dict | None) -> int | None: + """Normalize the Brink gateway state.""" + if device is None: + return None + + state = device.get("gateway_state") + try: + return state + except (TypeError, ValueError): + return None diff --git a/custom_components/brink_ventilation/const.py b/custom_components/brink_ventilation/const.py index 9e8da79..4fbf4af 100644 --- a/custom_components/brink_ventilation/const.py +++ b/custom_components/brink_ventilation/const.py @@ -19,6 +19,51 @@ OIDC_REDIRECT_URI = "https://www.brink-home.com/app/" OIDC_SCOPE = "openid api role locale" +LEVEL_LABELS = { + 0: "off", + 1: "low", + 2: "medium", + 3: "high", +} + +VENTILATION_LEVEL_LABELS = { + 0: "stand_0", + 1: "stand_1", + 2: "stand_2", + 3: "stand_3", +} + +# 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_STR: dict[str, str] = { + "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_LABELS: 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_LABELS: 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 +71,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 +92,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,6 +140,29 @@ "Anzahl der Tage seit Filterreset": PARAM_DAYS_SINCE_FILTER_RESET, } +RH_SENSOR_SENSITIVITY_LABELS: dict[str, str] = { + "-2": "very_low", + "-1": "low", + "0": "normal", + "1": "high", + "2": "very_high", +} + +BYPASS_VALVE_STATUS_LABELS: dict[str, 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[str, str] = { "0": "Standby", "1": "Bootloader", @@ -83,12 +181,42 @@ "14": "Auto LAN/WLAN Local", } +ACTIVE_CONTROL_STATUS_LABELS: dict[str, 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] = { "0": "Automatic", "1": "Bypass Closed", "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 +228,663 @@ GATEWAY_STATE_ONLINE: "online", } +FILTER_STATUS_LABELS: dict[str, str] = { + "0": "not_dirty", + "1": "dirty", +} + +CONTACT_TYPE_LABELS: dict[str, str] = { + "0": "Normaly open contact", + "1": "NC contact (normally closed)", +} + +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[str, 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[str, str] = { + "0": "off", + "1": "auto", + "2": "lock_current", + "3": "lock_maximum", +} + +GEOTHERMAL_HEAT_EXCHANGER_LABELS: dict[str, 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", +} + +GEOTHERMAL_HEAT_EXCHANGER_LABELS: dict[str, str] = { + "0": "open_low", + "1": "closed", + "3": "open_high", +} + +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", +} + +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 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 0000000000000000000000000000000000000000..5e4a54d90b869c8e3d5504204f40067cead71403 GIT binary patch literal 37016 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 literal 0 HcmV?d00001 diff --git a/custom_components/brink_ventilation/core/brink_home_cloud.py b/custom_components/brink_ventilation/core/brink_home_cloud.py index ab92305..6fdf6c2 100644 --- a/custom_components/brink_ventilation/core/brink_home_cloud.py +++ b/custom_components/brink_ventilation/core/brink_home_cloud.py @@ -17,12 +17,15 @@ from ..const import ( API_V1_URL, + CONTROL_TYPE_LABELS, OIDC_AUTH_URL, OIDC_CLIENT_ID, OIDC_REDIRECT_URI, OIDC_SCOPE, OIDC_TOKEN_URL, PARAM_NAME_MAP, + PARAMETER_NAMES, + VALUE_STATE_LABELS, WRITE_VALUE_STATE, ) from ..translations import TRANSLATIONS @@ -31,6 +34,10 @@ _TRUSTED_HOST = "www.brink-home.com" +class BrinkApiError(Exception): + """Brink API communication error.""" + + class _InputFieldExtractor(HTMLParser): """Extract values from HTML input fields.""" @@ -59,11 +66,15 @@ def __init__(self, message: str, *, is_credentials_error: bool = False) -> None: class BrinkHomeCloud: """Interact with Brink Home through the v1.1 API.""" + _OBSERVED_VALUE_STATES: set[int] = set() + _OBSERVED_CONTROL_TYPES: set[int] = set() + _OBSERVED_HINTS: set[int] = set() + def __init__(self, session: aiohttp.ClientSession, username: str, password: str): 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 @@ -86,6 +97,7 @@ async def get_systems(self) -> list[dict[str, Any]]: response = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=5") try: payload = await response.json() + _LOGGER.debug("Brink Flair system payload: %s", payload) finally: await response.release() @@ -104,6 +116,25 @@ async def get_systems(self) -> list[dict[str, Any]]: ) return systems + async def get_device(self, system_id: int) -> dict[str, dict[str, Any]]: + """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 + + parameters: dict[str, dict[str, Any]] = {} + self._extract_parameters( + (payload.get("root") or {}).get("navigationItems", []), parameters + ) + return parameters + 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 +142,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,11 +166,13 @@ 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: @@ -152,32 +186,71 @@ async def _api_request( json_data: dict[str, Any] | None = None, ) -> aiohttp.ClientResponse: """Perform an authenticated v1.1 API request.""" - for attempt in range(2): + _LOGGER.debug( + "Brink request %s %s timeout=%s", + method, + url, + self._timeout, + ) + for attempt in range(3): await self._ensure_token() - async with async_timeout.timeout(self._timeout): + + try: response = await self._session.request( method, url, json=json_data, + timeout=aiohttp.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 + + raise BrinkAuthError("Authentication failed after retry") + + try: + response.raise_for_status() + except aiohttp.ClientResponseError: + await response.release() + raise + return response + + except (aiohttp.ClientError, TimeoutError) as err: + if attempt < 2: + _LOGGER.debug( + "Brink request failed (%s/%s): %s %s (%s)", + attempt + 1, + 3, + method, + url, + err, + ) + else: + _LOGGER.warning( + "Brink request failed (%s/%s): %s %s (%s)", + attempt + 1, + 3, + method, + url, + err, + ) - response.raise_for_status() - return response + if attempt == 2: + raise BrinkApiError( + f"Brink request failed after 3 attempts: {method} {url}" + ) from err + + await asyncio.sleep(2) raise BrinkAuthError("Authentication failed before request") @@ -332,6 +405,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,6 +442,7 @@ 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: self._refresh_token = None @@ -429,17 +504,118 @@ 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: + _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): + _LOGGER.info( + "Parameter: %s (%s) valueState=%s value=%s", + key, + numeric_id, + value_state, + param.get("value"), + ) + + if isinstance(value_state, int): + if 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_LABELS.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_LABELS.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 +624,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 +661,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 diff --git a/custom_components/brink_ventilation/entity.py b/custom_components/brink_ventilation/entity.py index 334321b..fa18502 100644 --- a/custom_components/brink_ventilation/entity.py +++ b/custom_components/brink_ventilation/entity.py @@ -2,8 +2,7 @@ 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 @@ -19,7 +18,7 @@ def __init__(self, client, coordinator, system_id: int) -> None: self.system_id = system_id @property - def _device(self) -> dict[str, Any] | None: + def _device(self) -> dict[str, object] | None: """Return the current device payload.""" data = self.coordinator.data or {} return data.get(self.system_id) @@ -28,22 +27,26 @@ def _device(self) -> dict[str, Any] | None: 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) + + name = device.get("name") + + return str(name) if name is not None else DEFAULT_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"), - } + return DeviceInfo( + 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"), + ) @property def available(self) -> bool: @@ -60,20 +63,37 @@ def __init__(self, client, coordinator, system_id: int, parameter_key: str) -> N 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, object]: + """Return the device parameters.""" device = self._device + if device is None: - return None - return device.get("parameters", {}).get(self.parameter_key) + return {} + + parameters = device.get("parameters") + + return parameters if isinstance(parameters, dict) else {} @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 available(self) -> bool: diff --git a/custom_components/brink_ventilation/fan.py b/custom_components/brink_ventilation/fan.py index 0606f2a..6f32986 100644 --- a/custom_components/brink_ventilation/fan.py +++ b/custom_components/brink_ventilation/fan.py @@ -1,18 +1,24 @@ from __future__ import annotations 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 .const import ( DATA_CLIENT, DATA_COORDINATOR, DOMAIN, + FAN_OPERATING_MODE_LABELS, MODE_MANUAL_VALUE, PARAM_OPERATING_MODE, PARAM_VENTILATION_LEVEL, @@ -21,18 +27,58 @@ _LOGGER = logging.getLogger(__name__) -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="ventilation", + translation_key="ventilation", + 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] + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "set_level", + { + vol.Required("level"): vol.All( + vol.Coerce(int), + vol.In([0, 1, 2, 3]), + ) + }, + "async_set_level", + ) + + platform.async_register_entity_service( + "set_airflow", + {"airflow": int}, + "async_set_airflow", + ) + entities = [ - BrinkHomeVentilationFanEntity(client, coordinator, system_id, PARAM_VENTILATION_LEVEL) + BrinkHomeVentilationFanEntity(client, coordinator, system_id) for system_id, device in (coordinator.data or {}).items() if device.get("parameters", {}).get(PARAM_VENTILATION_LEVEL) ] @@ -42,14 +88,162 @@ async def async_setup_entry( class BrinkHomeVentilationFanEntity(BrinkHomeDeviceEntity, FanEntity): """Representation of the Brink ventilation level control.""" + _attr_has_entity_name = True + + entity_description: BrinkFanEntityDescription = ( + VENTILATION_FAN_DESCRIPTION + ) + + def __init__( + self, + client, + coordinator, + system_id: int, + ) -> None: + """Initialize the fan.""" + super().__init__( + client, + coordinator, + system_id, + self.entity_description.parameter_key, + ) + self._attr_unique_id = ( + f"{DOMAIN}_{system_id}_{self.parameter_key}_fan" + ) + 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()), {}) + parameters = device.get("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") + ) + + async def async_set_airflow( + self, + airflow: int, + ) -> None: + """Set airflow.""" + + _LOGGER.warning( + "async_set_airflow called with airflow=%s", + airflow, + ) + + await self.async_set_percentage( + round(airflow / self._max_airflow * 100) + ) + + 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 async_set_percentage(self, percentage: int) -> None: - if percentage <= 0: - await self._async_write_level("0") - return + """Set fan speed percentage.""" - 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)) + 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 _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.get("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 +251,20 @@ 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 + mode = self._operating_mode + if mode 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 +272,266 @@ 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() + _LOGGER.debug( + "Operating mode=%s ventilation=%s", + mode.get("value") if mode else None, + ventilation.get("value"), + ) + @property - def percentage(self): - """Return the current speed percentage.""" - param = self.data - if param is None or param.get("value") is None: + 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 preset_mode(self) -> str | None: + mode = self._operating_mode + + if mode is None: return None - current_value = int(param["value"]) - if current_value <= 0: - return 0 - return ranged_value_to_percentage(SPEED_RANGE, current_value) + + return FAN_OPERATING_MODE_LABELS.get(str(mode.get("value"))) + + 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 speed_count(self) -> int: - """Return the number of supported speeds.""" - return int_states_in_range(SPEED_RANGE) + def old_percentage(self) -> int | None: + """Return current fan percentage.""" + device = next(iter((self.coordinator.data or {}).values()), {}) + parameters = device.get("parameters", {}) + + airflow = ( + parameters.get("supply_air_flow_setpoint", {}) + .get("value") + ) + + if airflow is None: + return None + + return round(int(airflow) / self._max_airflow * 100) @property - def name(self): - return f"{self.device_name} {self.parameter_name}" + def werkt_percentage(self) -> int | None: + level = int((self.data or {}).get("value", 0)) + + device = self._device or {} + parameters = device.get("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 unique_id(self): - return f"{DOMAIN}_{self.system_id}_{self.parameter_key}_fan" + 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.get("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 + ) + + @property + def percentage_step(self) -> float: + """Return percentage step.""" + return 1 @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 _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 extra_state_attributes(self) -> dict[str, object]: + """Return extra state attributes.""" + + coordinator_data = self.coordinator.data or {} + attributes = dict(super().extra_state_attributes or {}) + + param = self.data or {} + + _LOGGER.debug( + "Coordinator data: %s", + self.coordinator.data, + ) + + _LOGGER.debug( + "supply_air_flow_setpoint=%s", + (self.coordinator.data or {}).get("supply_air_flow_setpoint"), + ) + + _LOGGER.debug( + "Entity data keys: %s", + list((self.data or {}).keys()), + ) + # Entity data keys: ['name', 'raw_name', 'value', 'value_id', 'value_state', 'read_write', 'control_type', 'list_items', 'min_value', 'max_value', 'default_value', 'step_width', 'decimals', 'unit_of_measure', 'unit_of_measure1', 'unit_of_measure2', 'component_id', 'numeric_id', 'options'] + + 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")), + "value_state": param.get("value_state"), + "numeric_id": str(param.get("numeric_id")), + "read_write": param.get("read_write"), + "control_type": param.get("control_type"), + "value_id": str(param.get("value_id")), + "list_items": param.get("list_items"), + "component_id": str(param.get("component_id")), + } + ) + + device = self._device + parameters = device.get("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", []) + ] + + return attributes diff --git a/custom_components/brink_ventilation/manifest.json b/custom_components/brink_ventilation/manifest.json index d8203fe..265ef5f 100644 --- a/custom_components/brink_ventilation/manifest.json +++ b/custom_components/brink_ventilation/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://github.com/samuolis/brink/blob/master/README.md", + "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/samuolis/brink/issues", "requirements": [], diff --git a/custom_components/brink_ventilation/number.py b/custom_components/brink_ventilation/number.py new file mode 100644 index 0000000..baa6077 --- /dev/null +++ b/custom_components/brink_ventilation/number.py @@ -0,0 +1,612 @@ +"""Number entities for Brink ventilation.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +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 ( + 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, +) +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", + ), + 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", + ), + 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", + ), + 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", + ), + 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", + ), + 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", + ), + 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", + ), + 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", + ), +) + + +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.get("parameters", {}) + + _LOGGER.debug( + "gateway_state available: super=%s data=%s raw=%s", + 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.""" + + return { + "key": str(self.entity_description.key), + "translation_key": str(self.entity_description.translation_key), + "name": str(self.data.get("name")), + "raw_name": str(self.data.get("raw_name")), + "value": str(self.data.get("value")), + "value_state": str(self.data.get("value_state")), + "default_value": str(self.data.get("default_value")), + "numeric_id": str(self.data.get("numeric_id")), + "read_write": str(self.data.get("read_write")), + "control_type": str(self.data.get("control_type")), + "value_id": str(self.data.get("value_id")), + "min_value": str(self.data.get("min_value")), + "max_value": str(self.data.get("max_value")), + "step_width": str(self.data.get("step_width")), + "decimals": str(self.data.get("decimals")), + "list_items": str(self.data.get("list_items")), + "unit_of_measure": str(self.data.get("unit_of_measure")), + "component_id": str(self.data.get("component_id")), + "options": str(self.data.get("options")), + } + + 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.get("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..48cdc08 100644 --- a/custom_components/brink_ventilation/select.py +++ b/custom_components/brink_ventilation/select.py @@ -1,139 +1,490 @@ 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, 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, + RH_SENSOR_SENSITIVITY_LABELS, ) 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] - 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 - ) - ) +@dataclass(frozen=True, kw_only=True) +class BrinkSelectEntityDescription(SelectEntityDescription): + """Describe a Brink select entity.""" - async_add_entities(entities) + 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", + }.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 {} + return { + "key": self.entity_description.key, + "translation_key": self.entity_description.translation_key, + "name": param.get("name", "onbeschikbaar"), + "raw_name": param.get("raw_name", "onbeschikbaar"), + "value": param.get("value", "onbeschikbaar"), + "value_state": param.get("value_state", "onbeschikbaar"), + "default_value": param.get("default_value", "onbeschikbaar"), + "numeric_id": param.get("numeric_id", "onbeschikbaar"), + "read_write": param.get("read_write", "onbeschikbaar"), + "control_type": param.get("control_type", "onbeschikbaar"), + "value_id": param.get("value_id", "onbeschikbaar"), + "min_value": param.get("min_value", "onbeschikbaar"), + "max_value": param.get("max_value", "onbeschikbaar"), + "step_width": param.get("step_width", "onbeschikbaar"), + "decimals": param.get("decimals", "onbeschikbaar"), + "unit_of_measure": param.get("unit_of_measure", "onbeschikbaar"), + "component_id": param.get("component_id", "onbeschikbaar"), + "raw_options": param.get("options", "onbeschikbaar"), + } -class BrinkHomeModeSelectEntity(BrinkHomeSelectEntity): - """Representation of the Brink operating mode selector.""" + @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: + async def async_select_option( + self, + option: str, + ) -> None: + """Select an option.""" 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: + def old_current_option(self) -> str | None: + """Return the currently selected 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")) + + label_map = self.entity_description.label_map + + if label_map: + return label_map.get(current_value, current_value) + + return next( + ( + option["label"] + for option in param.get("options", []) + if option["value"] == current_value + ), + None, + ) @property - def options(self) -> list[str]: + def current_option(self) -> str | None: + """Return current option.""" param = self.data + if param is None: - return [] - return [item["label"] for item in param.get("options", [])] + return None + current_value = str(param.get("value")) -class BrinkHomeBypassOperationSelectEntity(BrinkHomeSelectEntity): - """Representation of the Brink bypass operation selector.""" + label_map = self.entity_description.label_map - @property - def name(self): - return f"{self.device_name} Bypass Operation" + if label_map: + result = label_map.get(current_value) + + # _LOGGER.warning( + # "Select label map %s current=%r value=%r", + # self.parameter_key, + # result, + # current_value, + # ) - async def async_select_option(self, option: str) -> None: - selected_value = next( + return result + + return next( ( - value - for value, label in BYPASS_OPERATION_LABELS.items() - if label == option + option["label"] + for option in param.get("options", []) + if option["value"] == current_value ), None, ) - if selected_value is None: - raise HomeAssistantError(f"Unknown bypass operation option: {option}") - - await self._async_write_value(selected_value) @property - def current_option(self) -> str | None: + def old_options(self) -> list[str]: + """Return available options.""" param = self.data + if param is None: - return None - return BYPASS_OPERATION_LABELS.get(str(param.get("value"))) + return [] + + if self.entity_description.label_map: + return list( + self.entity_description.label_map.values() + ) + + return [ + option["label"] + for option in param.get("options", []) + ] @property def options(self) -> list[str]: - return list(BYPASS_OPERATION_LABELS.values()) + """Return available options.""" + + label_map = self.entity_description.label_map + + if label_map: + result = list(label_map.values()) + # _LOGGER.debug( + # "Select %s options=%r", + # self.parameter_key, + # result, + # ) + return result + + param = self.data + + if param is None: + return [] + + return [ + str(option["label"]) + for option in param.get("options", []) + ] + + @property + def available(self) -> bool: + """Return if entity is available.""" + + device = next(iter((self.coordinator.data or {}).values()), {}) + parameters = device.get("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 = 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 + ) + + +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.get("parameters", {}).get( + description.parameter_key + ) + ] + + async_add_entities(entities) diff --git a/custom_components/brink_ventilation/sensor.py b/custom_components/brink_ventilation/sensor.py index 09503b7..95d62ef 100644 --- a/custom_components/brink_ventilation/sensor.py +++ b/custom_components/brink_ventilation/sensor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from dataclasses import dataclass from homeassistant.components.sensor import ( @@ -13,6 +14,8 @@ CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, EntityCategory, + UnitOfElectricPotential, + UnitOfPressure, UnitOfTemperature, UnitOfTime, UnitOfVolumeFlowRate, @@ -21,27 +24,51 @@ from .const import ( ACTIVE_CONTROL_STATUS_LABELS, + BYPASS_VALVE_STATUS_LABELS, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, + FILTER_STATUS_LABELS, + FROST_PROTECTION_STATUS_LABELS, + GATEWAY_STATE_LABELS_STR, + 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_FILTER_MESSAGE, + PARAM_FILTER_STATUS, 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, ) from .entity import BrinkHomeDeviceEntity +_LOGGER = logging.getLogger(__name__) +REVOLUTIONS_PER_MINUTE = "rpm" + @dataclass(frozen=True) class BrinkSensorDescription(SensorEntityDescription): @@ -49,37 +76,79 @@ class BrinkSensorDescription(SensorEntityDescription): 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 enabled_value_state: int | None = None + _attr_has_entity_name = True SENSOR_DESCRIPTIONS: tuple[BrinkSensorDescription, ...] = ( + BrinkSensorDescription( + key="ventilation_level", + translation_key="ventilation_level", + parameter_key="ventilation_level", + icon="mdi:fan", + ), 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, + ), + 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, ), 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, + ), + 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, + ), + 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, ), 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 +156,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 +164,412 @@ 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="filter_message", + translation_key="filter_message", + parameter_key=PARAM_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", + ), + 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", + ), + BrinkSensorDescription( + key=PARAM_FILTER_STATUS, + translation_key="filter_status", + parameter_key=PARAM_FILTER_STATUS, + icon="mdi:air-filter", + device_class=SensorDeviceClass.ENUM, + is_enum=True, + value_map=FILTER_STATUS_LABELS, + ), + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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_STR.values()), + is_enum=True, + is_device_attribute=True, + value_map=GATEWAY_STATE_LABELS_STR, ), ) def _should_create_sensor(device: dict, description: BrinkSensorDescription) -> bool: """Return True when the Brink parameter should be exposed as a sensor.""" + + if description.parameter_key == "ventilation_percentage": + return True + + if description.is_device_attribute: + return True + param = device.get("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 +584,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.get("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,21 +672,82 @@ 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): """Initialize the Brink sensor.""" super().__init__(client, coordinator, system_id, description.parameter_key) self.entity_description = description +# _LOGGER.warning( +# "translation_key=%s", +# self.entity_description.translation_key, +# ) @property 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 {} + + 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")), + "value_state": param.get("value_state"), + "default_value": str(param.get("default_value")), # remove + "numeric_id": str(param.get("numeric_id")), + "read_write": param.get("read_write"), + "control_type": param.get("control_type"), + "value_id": str(param.get("value_id")), + "min_value": str(param.get("min_value")), # remove + "max_value": str(param.get("max_value")), # remove + "step_width": str(param.get("step_width")), # remove + "decimals": str(param.get("decimals")), + "list_items": param.get("list_items"), # remove + "unit_of_measure": str(param.get("unit_of_measure")), + "component_id": str(param.get("component_id")), + "raw_options": param.get("options"), # remove + } + + 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,7 +776,81 @@ 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 device.get(self.parameter_key) + + param = self.data + + if param is None: + return None + + return param.get("value") + + @property + def native_value(self) -> str | int | float | None: + """Return the sensor value.""" + + if self.parameter_key == "ventilation_percentage": + return self._calculate_ventilation_percentage() + + _LOGGER.debug( + "gateway_state raw=%s", + 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 + + if value_map is not None: + _LOGGER.debug( + "gateway_state value=%s mapped=%s options=%s", + value, + value_map.get(str(value), str(value)), + self.options, + ) + return value_map.get(str(value), "unknown") + + 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 str(value) + + return int(number) if number.is_integer() else number + + @property + def old_native_value(self): + if self.parameter_key == "ventilation_percentage": + return self._calculate_ventilation_percentage() + param = self.data if param is None: return None @@ -287,3 +878,103 @@ def native_value(self): if number.is_integer(): return int(number) return number + + @property + def available(self) -> bool: + """Return if entity is available.""" + + _LOGGER.debug( + "gateway_state available: super=%s data=%s raw=%s", + super().available, + self.data, + self._raw_value, + ) + + if self.entity_description.parameter_key == "ventilation_percentage": + return True + + if self.entity_description.parameter_key == "gateway_state": + return True + + param = self.data + + return ( + super().available + and param is not None + and param.get("value_state") != 5 + ) + + def _calculate_ventilation_percentage(self) -> int | None: + """Calculate ventilation percentage based on configured airflow levels.""" + parameters = self._device.get("parameters", {}) + + try: + supply_flow = float( + parameters["actual_supply_air_flow"]["value"] + ) + exhaust_flow = float( + parameters["actual_exhaust_air_flow"]["value"] + ) + + airflow_0 = float( + parameters["ventilation_mode_0_airflow"]["value"] + ) + airflow_1 = float( + parameters["ventilation_mode_1_airflow"]["value"] + ) + airflow_2 = float( + parameters["ventilation_mode_2_airflow"]["value"] + ) + airflow_3 = float( + parameters["ventilation_mode_3_airflow"]["value"] + ) + except ( + KeyError, + TypeError, + ValueError, + ): + return None + + current_flow = ( + supply_flow + + exhaust_flow + ) / 2 + + levels = [ + (airflow_0, 0.0), + (airflow_1, 33.333), + (airflow_2, 66.667), + (airflow_3, 100.0), + ] + + if current_flow <= levels[0][0]: + return 0 + + if current_flow >= levels[-1][0]: + return 100 + + for index in range(len(levels) - 1): + lower_flow, lower_percent = levels[index] + upper_flow, upper_percent = levels[index + 1] + upper_flow = 300 # do not use airflow_3 as max, use 300 for 100% + + if lower_flow <= current_flow <= upper_flow: + if upper_flow == lower_flow: + return round(upper_percent) + + fraction = ( + (current_flow - lower_flow) + / (upper_flow - lower_flow) + ) + + percentage = ( + lower_percent + + ( + fraction + * (upper_percent - lower_percent) + ) + ) + + return round(percentage) + + return None 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..64b1b22 --- /dev/null +++ b/custom_components/brink_ventilation/translations/nl.json @@ -0,0 +1,745 @@ +{ + "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)" + } + } + } + }, + "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": { + "brink_ventilation": { + "state_attributes": { + "preset_mode": { + "state": { + "Automatic": "Automatisch", + "automatic": "Automatisch", + "manual": "Handmatig", + "holiday": "Vakantie", + "party": "Feest", + "night_ventilation": "Nachtventilatie" + } + } + } + }, + "ventilation": { + "name": "Ventilatie" + }, + "ventilation_level": { + "name": "Ventilatiestand", + "state": { + "Automatic": "Automatisch", + "automatic": "Automatisch", + "manual": "Handmatig", + "holiday": "Vakantie", + "party": "Feest", + "night_ventilation": "Nachtventilatie" + } + }, + "ventilation_mode": { + "name": "Ventilatie modus", + "preset_mode": { + "state":{ + "automatic": "Automatisch", + "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" + }, + "automatic": "Automatisch", + "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" + }, + "state":{ + "automatic": "Automatisch", + "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" + } + } + }, + "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 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": { + "additional_temperature_sensor":{ + "name": "Extra temperatuur sensor" + }, + "preheater_power":{ + "name": "Vermogen voorverwarmer" + }, + "ventilation_level": { + "name": "Ventilatiestand" + }, + "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" + } + } + }, + "binary_sensor": { + "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 From 5a1f58e780be6d29872e8f5b19db80e3b306f211 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 21 Jul 2026 22:36:31 +0200 Subject: [PATCH 2/9] Refactor to use ClientSession from aiohttp directly --- .../brink_ventilation/core/brink_home_cloud.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/custom_components/brink_ventilation/core/brink_home_cloud.py b/custom_components/brink_ventilation/core/brink_home_cloud.py index 6fdf6c2..1f6996a 100644 --- a/custom_components/brink_ventilation/core/brink_home_cloud.py +++ b/custom_components/brink_ventilation/core/brink_home_cloud.py @@ -14,6 +14,7 @@ import aiohttp import async_timeout +from aiohttp import ClientSession, CookieJar from ..const import ( API_V1_URL, @@ -70,7 +71,7 @@ class BrinkHomeCloud: _OBSERVED_CONTROL_TYPES: set[int] = set() _OBSERVED_HINTS: set[int] = set() - def __init__(self, session: aiohttp.ClientSession, username: str, password: str): + def __init__(self, session: ClientSession, username: str, password: str): self._session = session self._username = username self._password = password @@ -279,8 +280,9 @@ 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) + # jar = aiohttp.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 ) @@ -292,7 +294,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, @@ -334,7 +336,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, @@ -460,7 +462,7 @@ async def _refresh_access_token(self) -> None: async def _follow_redirects_for_code( self, - session: aiohttp.ClientSession, + session: ClientSession, redirect_url: str, base_url: str, expected_state: str, From b707e9406ef07973b1bf219c28d793cfaaf00681 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 21 Jul 2026 22:38:36 +0200 Subject: [PATCH 3/9] Remove unused parameter extraction in brink_home_cloud Removed unused parameter extraction code from the method. --- .../brink_ventilation/core/brink_home_cloud.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/custom_components/brink_ventilation/core/brink_home_cloud.py b/custom_components/brink_ventilation/core/brink_home_cloud.py index 1f6996a..ad37ba5 100644 --- a/custom_components/brink_ventilation/core/brink_home_cloud.py +++ b/custom_components/brink_ventilation/core/brink_home_cloud.py @@ -130,12 +130,6 @@ async def get_device(self, system_id: int) -> dict[str, dict[str, Any]]: return payload - parameters: dict[str, dict[str, Any]] = {} - self._extract_parameters( - (payload.get("root") or {}).get("navigationItems", []), parameters - ) - return parameters - 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( From 558ddf7a235515f7a8a2c30ecd6c9fd77d1fdc53 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 21 Jul 2026 22:43:31 +0200 Subject: [PATCH 4/9] Refactor aiohttp imports and update usage --- .../core/brink_home_cloud.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/custom_components/brink_ventilation/core/brink_home_cloud.py b/custom_components/brink_ventilation/core/brink_home_cloud.py index ad37ba5..1b1a085 100644 --- a/custom_components/brink_ventilation/core/brink_home_cloud.py +++ b/custom_components/brink_ventilation/core/brink_home_cloud.py @@ -12,9 +12,15 @@ from typing import Any from urllib.parse import parse_qs, urlparse -import aiohttp import async_timeout -from aiohttp import ClientSession, CookieJar +from aiohttp import ( + ClientError, + ClientResponse, + ClientResponseError, + ClientSession, + ClientTimeout, + CookieJar, +) from ..const import ( API_V1_URL, @@ -179,7 +185,7 @@ async def _api_request( url: str, *, json_data: dict[str, Any] | None = None, - ) -> aiohttp.ClientResponse: + ) -> ClientResponse: """Perform an authenticated v1.1 API request.""" _LOGGER.debug( "Brink request %s %s timeout=%s", @@ -195,7 +201,7 @@ async def _api_request( method, url, json=json_data, - timeout=aiohttp.ClientTimeout(total=self._timeout), + timeout=ClientTimeout(total=self._timeout), headers={ "Authorization": f"Bearer {self._access_token}", "Accept": "application/json", @@ -215,12 +221,12 @@ async def _api_request( try: response.raise_for_status() - except aiohttp.ClientResponseError: + except ClientResponseError: await response.release() raise return response - except (aiohttp.ClientError, TimeoutError) as err: + except (ClientError, TimeoutError) as err: if attempt < 2: _LOGGER.debug( "Brink request failed (%s/%s): %s %s (%s)", @@ -440,7 +446,7 @@ async def _refresh_access_token(self) -> None: 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 @@ -489,7 +495,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 From a0529267be9389132a4152152b9cf48278df7d34 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 21 Jul 2026 23:13:29 +0200 Subject: [PATCH 5/9] Update device name retrieval in Brink entity Use device model as device title in UI instead of name. --- custom_components/brink_ventilation/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/brink_ventilation/entity.py b/custom_components/brink_ventilation/entity.py index fa18502..689f8c6 100644 --- a/custom_components/brink_ventilation/entity.py +++ b/custom_components/brink_ventilation/entity.py @@ -41,7 +41,7 @@ def device_info(self) -> DeviceInfo: device = self._device or {} return DeviceInfo( identifiers={(DOMAIN, str(self.system_id))}, - name=self.device_name, + name=device.get("model", self.device_name), # use device model as device title in UI manufacturer=DEFAULT_NAME, model=device.get("model", DEFAULT_MODEL), serial_number=device.get("serial_number"), From 9fa39d3e757063b397d27e8797d26e9ac7fea49b Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 21 Jul 2026 23:18:33 +0200 Subject: [PATCH 6/9] Update version to 1.0.2 and clean manifest.json Removed empty fields and updated version number. --- custom_components/brink_ventilation/manifest.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/custom_components/brink_ventilation/manifest.json b/custom_components/brink_ventilation/manifest.json index 265ef5f..37dad6a 100644 --- a/custom_components/brink_ventilation/manifest.json +++ b/custom_components/brink_ventilation/manifest.json @@ -8,8 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/samuolis/brink/issues", - "requirements": [], - "ssdp": [], - "version": "1.0.1", - "zeroconf": [] + "version": "1.0.2" } From 7f10bba2f79bab657504e7664c52cbde4725ad43 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 21 Jul 2026 23:21:54 +0200 Subject: [PATCH 7/9] Change integration type from 'hub' to 'device' --- custom_components/brink_ventilation/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/brink_ventilation/manifest.json b/custom_components/brink_ventilation/manifest.json index 37dad6a..8bc53fb 100644 --- a/custom_components/brink_ventilation/manifest.json +++ b/custom_components/brink_ventilation/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://github.com/samuolis/brink/blob/master/README.md", - "integration_type": "hub", + "integration_type": "device", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/samuolis/brink/issues", "version": "1.0.2" From 8ecbb88749c34b94f15b149c07ac87ea185c32d8 Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 8 Aug 2026 06:27:44 +0200 Subject: [PATCH 8/9] Updated file --- .../brink_ventilation/__init__.py | 15 +- .../brink_ventilation/binary_sensor.py | 18 +- custom_components/brink_ventilation/const.py | 183 ++++---- .../brink_home_cloud.cpython-314.pyc | Bin 37016 -> 39389 bytes .../core/brink_home_cloud.py | 62 ++- .../core/brink_home_local.py | 68 +++ .../brink_ventilation/core/uid_definitions.py | 373 +++++++++++++++++ .../brink_ventilation/core/uid_parser.py | 64 +++ custom_components/brink_ventilation/entity.py | 13 +- custom_components/brink_ventilation/fan.py | 395 ++++++++++++------ .../brink_ventilation/manifest.json | 3 +- custom_components/brink_ventilation/number.py | 83 +++- custom_components/brink_ventilation/select.py | 125 +++--- custom_components/brink_ventilation/sensor.py | 227 +++++++--- .../brink_ventilation/translations/nl.json | 88 ++-- 15 files changed, 1243 insertions(+), 474 deletions(-) create mode 100644 custom_components/brink_ventilation/core/brink_home_local.py create mode 100644 custom_components/brink_ventilation/core/uid_definitions.py create mode 100644 custom_components/brink_ventilation/core/uid_parser.py diff --git a/custom_components/brink_ventilation/__init__.py b/custom_components/brink_ventilation/__init__.py index 48c551a..a55dccb 100644 --- a/custom_components/brink_ventilation/__init__.py +++ b/custom_components/brink_ventilation/__init__.py @@ -104,7 +104,8 @@ async def async_get_devices(brink_client: BrinkHomeCloud) -> dict[int, dict[str, """Fetch and normalize Brink systems plus the parameters this integration uses.""" systems = await brink_client.get_systems() - _LOGGER.debug("Brink Flair systems: %s", systems) + _LOGGER.debug("Brink Flair nr of systems: %s", len(systems)) + _LOGGER.debug("Brink Flair init systems: %s", systems) # device = await brink_client.get_device(3074) @@ -119,6 +120,18 @@ async def async_get_devices(brink_client: BrinkHomeCloud) -> dict[int, dict[str, devices: dict[int, dict[str, Any]] = {} for system in systems: system_id = 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 = await brink_client.get_device(system_id) _LOGGER.debug("Brink Flair device: %s", device) gateway_type_id = device.get("gatewayTypeId") diff --git a/custom_components/brink_ventilation/binary_sensor.py b/custom_components/brink_ventilation/binary_sensor.py index 15d3a00..67b3cdb 100644 --- a/custom_components/brink_ventilation/binary_sensor.py +++ b/custom_components/brink_ventilation/binary_sensor.py @@ -23,8 +23,6 @@ PARAM_CN1_SWITCH_INPUT, PARAM_CN2_POSITION, PARAM_CN2_SWITCH_INPUT, - PARAM_CO1_SENSOR_STATUS, - PARAM_CO2_SENSOR_STATUS, PARAM_FILTER_STATUS, PARAM_RH_SENSOR_STATUS, ) @@ -201,7 +199,6 @@ def extra_state_attributes(self) -> dict[str, object]: if custom_attributes: attributes.update(custom_attributes) - # attributes = custom_attributes return attributes @@ -287,20 +284,7 @@ def _debug_value(value: object) -> str: icon="mdi:water-percent", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), - BrinkBinarySensorDescription( - key=PARAM_CO1_SENSOR_STATUS, - translation_key="co1_sensor_status", - parameter_key=PARAM_CO1_SENSOR_STATUS, - icon="mdi:molecule-co2", - entity_category=EntityCategory.DIAGNOSTIC, - ), - BrinkBinarySensorDescription( - key=PARAM_CO2_SENSOR_STATUS, - translation_key="co2_sensor_status", - parameter_key=PARAM_CO2_SENSOR_STATUS, - icon="mdi:molecule-co2", - entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), BrinkBinarySensorDescription( key="ebus_co2_sensor_status", diff --git a/custom_components/brink_ventilation/const.py b/custom_components/brink_ventilation/const.py index 4fbf4af..43ac774 100644 --- a/custom_components/brink_ventilation/const.py +++ b/custom_components/brink_ventilation/const.py @@ -19,6 +19,12 @@ OIDC_REDIRECT_URI = "https://www.brink-home.com/app/" OIDC_SCOPE = "openid api role locale" +MODE_AUTOMATIC = "automatic" +MODE_MANUAL = "manual" +MODE_HOLIDAY = "holiday" +MODE_PARTY = "party" +MODE_NIGHT = "night_ventilation" + LEVEL_LABELS = { 0: "off", 1: "low", @@ -27,17 +33,17 @@ } VENTILATION_LEVEL_LABELS = { - 0: "stand_0", - 1: "stand_1", - 2: "stand_2", - 3: "stand_3", + 0: "Off", + 1: "Low", + 2: "Medium", + 3: "High", } # 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_STR: dict[str, str] = { - "0": "locked", - "1": "offline", - "2": "online", +GATEWAY_STATE_LABELS: dict[int, str] = { + 0: "locked", + 1: "offline", + 2: "online", } GATEWAY_TYPE_LABELS = { @@ -48,7 +54,7 @@ VALUE_STATE_CONFIG = 1 VALUE_STATE_NO_DATA = 5 -VALUE_STATE_LABELS: dict[int, str] = { +VALUE_STATE_MAP: dict[int, str] = { VALUE_STATE_VALID: "valid", VALUE_STATE_CONFIG: "config", VALUE_STATE_NO_DATA: "no_data", @@ -58,7 +64,7 @@ CONTROL_TYPE_NUMERIC = 6 CONTROL_TYPE_TEXT = 9 -CONTROL_TYPE_LABELS: dict[int, str] = { +CONTROL_TYPE_MAP: dict[int, str] = { CONTROL_TYPE_ENUM: "enum", CONTROL_TYPE_NUMERIC: "numeric", CONTROL_TYPE_TEXT: "text", @@ -148,12 +154,12 @@ "2": "very_high", } -BYPASS_VALVE_STATUS_LABELS: dict[str, str] = { - "0": "initialization", - "1": "opening", - "2": "closing", - "3": "open", - "4": "closed", +BYPASS_VALVE_STATUS_LABELS: dict[int, str] = { + 0: "initialization", + 1: "opening", + 2: "closing", + 3: "open", + 4: "closed", } FILTER_FAULT_CONDITION_LABELS: dict[str, str] = { @@ -163,40 +169,40 @@ "3": "filter_and_fault_condition", } -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", -} - -ACTIVE_CONTROL_STATUS_LABELS: dict[str, 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", +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] = { @@ -228,9 +234,9 @@ GATEWAY_STATE_ONLINE: "online", } -FILTER_STATUS_LABELS: dict[str, str] = { - "0": "not_dirty", - "1": "dirty", +FILTER_STATUS_LABELS: dict[int, str] = { + 0: "not_dirty", + 1: "dirty", } CONTACT_TYPE_LABELS: dict[str, str] = { @@ -305,24 +311,24 @@ "12": "water_block_test", } -FROST_PROTECTION_STATUS_LABELS: dict[str, 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_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] = { @@ -341,17 +347,17 @@ "water_block_test": "Waterbloktest", } -PREHEATER_STATUS_LABELS: dict[str, str] = { - "0": "off", - "1": "auto", - "2": "lock_current", - "3": "lock_maximum", +PREHEATER_STATUS_LABELS: dict[int, str] = { + 0: "off", + 1: "auto", + 2: "lock_current", + 3: "lock_maximum", } -GEOTHERMAL_HEAT_EXCHANGER_LABELS: dict[str, str] = { - "0": "open_low", - "1": "closed", - "3": "open_high", +GEOTHERMAL_HEAT_EXCHANGER_LABELS: dict[int, str] = { + 0: "open_low", + 1: "closed", + 3: "open_high", } CN_POSITION_LABELS: dict[str, str] = { @@ -384,12 +390,6 @@ "1": "on", } -GEOTHERMAL_HEAT_EXCHANGER_LABELS: dict[str, str] = { - "0": "open_low", - "1": "closed", - "3": "open_high", -} - MODE_VALVE_24V_CONTROL_LABELS: dict[str, str] = { "0": "open", "1": "closed", @@ -511,6 +511,17 @@ 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] = { 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 index 5e4a54d90b869c8e3d5504204f40067cead71403..c1fc58350e9628cf1f1be6a7a0b0980632d4c182 100644 GIT binary patch delta 9964 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 D1n{N! diff --git a/custom_components/brink_ventilation/core/brink_home_cloud.py b/custom_components/brink_ventilation/core/brink_home_cloud.py index 1b1a085..aa7bf86 100644 --- a/custom_components/brink_ventilation/core/brink_home_cloud.py +++ b/custom_components/brink_ventilation/core/brink_home_cloud.py @@ -24,7 +24,7 @@ from ..const import ( API_V1_URL, - CONTROL_TYPE_LABELS, + CONTROL_TYPE_MAP, OIDC_AUTH_URL, OIDC_CLIENT_ID, OIDC_REDIRECT_URI, @@ -32,7 +32,7 @@ OIDC_TOKEN_URL, PARAM_NAME_MAP, PARAMETER_NAMES, - VALUE_STATE_LABELS, + VALUE_STATE_MAP, WRITE_VALUE_STATE, ) from ..translations import TRANSLATIONS @@ -101,8 +101,23 @@ async def close(self) -> None: async def get_systems(self) -> list[dict[str, Any]]: """Return the systems visible to the current account.""" - response = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=5") + response1 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=1") + response2 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=2") + response3 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=3") + response4 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=4") + response5 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=5") + response = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=6") try: + payload = await response1.json() + _LOGGER.debug("Brink Flair system payload1: %s", payload) + payload = await response2.json() + _LOGGER.debug("Brink Flair system payload2: %s", payload) + payload = await response3.json() + _LOGGER.debug("Brink Flair system payload3: %s", payload) + payload = await response4.json() + _LOGGER.debug("Brink Flair system payload4: %s", payload) + payload = await response5.json() + _LOGGER.debug("Brink Flair system payload5: %s", payload) payload = await response.json() _LOGGER.debug("Brink Flair system payload: %s", payload) finally: @@ -115,10 +130,20 @@ async def get_systems(self) -> list[dict[str, Any]]: continue systems.append( { - "system_id": system_id, "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"), } ) return systems @@ -281,7 +306,6 @@ async def _oidc_login(self) -> None: nonce = secrets.token_urlsafe(32) jar = CookieJar(unsafe=False) - # jar = aiohttp.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 @@ -347,11 +371,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( @@ -567,17 +588,16 @@ def _extract_parameters( param.get("value"), ) - if isinstance(value_state, int): - if value_state not in BrinkHomeCloud._OBSERVED_VALUE_STATES: - BrinkHomeCloud._OBSERVED_VALUE_STATES.add(value_state) + 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_LABELS.get(value_state, "unknown"), - key, - numeric_id, - ) + _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( @@ -599,7 +619,7 @@ def _extract_parameters( _LOGGER.info( "Discovered controlType=%s (%s) parameter=%s (%s)", control_type, - CONTROL_TYPE_LABELS.get(control_type, "unknown"), + CONTROL_TYPE_MAP.get(control_type, "unknown"), key, numeric_id, ) 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..2b20412 --- /dev/null +++ b/custom_components/brink_ventilation/core/brink_home_local.py @@ -0,0 +1,68 @@ +"""Local API client for Brink Home devices.""" + +from __future__ import annotations + +import logging + +from aiohttp import ClientError, ClientSession + +_LOGGER = logging.getLogger(__name__) + + +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, + *, + 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..1b8392d --- /dev/null +++ b/custom_components/brink_ventilation/core/uid_definitions.py @@ -0,0 +1,373 @@ +"""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="unknown_supply_control_value", + value_type=UIDType.INT, + description="unknown_supply_control_value", + ), + UIDDefinition( + uid=3, + key="unknown_exhaust_control_value", + value_type=UIDType.INT, + description="unknown_exhaust_control_value", + ), + 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 pressure", + ), + UIDDefinition( + uid=20, + key="exhaust_pressure", + value_type=UIDType.PRESSURE, + unit=UIDUnit.PASCAL, + description="Exhaust 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="", + value_type=UIDType.UINT32, + description="", + ), + UIDDefinition( + uid=47, + key="", + value_type=UIDType.UINT32, + description="", + ), + 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..051af2e --- /dev/null +++ b/custom_components/brink_ventilation/core/uid_parser.py @@ -0,0 +1,64 @@ +"""Parser for Brink local UID values.""" + +from __future__ import annotations + +from .uid_definitions import UID_DEFINITIONS, UID_LOOKUP, UIDDefinition + + +def decode_int(value: str) -> int: + """Decode an integer value.""" + return int(value.split(",")[0]) + + +def decode_bool(value: str) -> bool: + """Decode a boolean value.""" + return decode_int(value) != 0 + + +def decode_percent(value: str) -> int: + """Decode a percentage.""" + return decode_int(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_int(value) / 10 + + +def decode_ascii(value: str) -> str: + """Decode an ASCII string.""" + return bytes( + number + for number in map(int, value.split(",")) + if number != 0 + ).decode("ascii") + + +_UID_LOOKUP: dict[str, UIDDefinition] = { + definition.uid: definition + for definition in UID_DEFINITIONS +} + + +def parse_uid_values( + values: dict[str, str], +) -> dict[str, object]: + """Convert raw UID values into named values.""" + parsed: dict[str, object] = {} + + for uid, value in values.items(): + definition = UID_LOOKUP.get(int(uid.removeprefix("UID"))) + if definition is None: + continue + + try: + parsed[definition.key] = definition.decoder(value) + except (TypeError, ValueError): + parsed[definition.key] = value + + return parsed diff --git a/custom_components/brink_ventilation/entity.py b/custom_components/brink_ventilation/entity.py index 689f8c6..b370bea 100644 --- a/custom_components/brink_ventilation/entity.py +++ b/custom_components/brink_ventilation/entity.py @@ -38,14 +38,23 @@ def device_name(self) -> str: @property def device_info(self) -> DeviceInfo: """Return device info for the Brink entity.""" - device = self._device or {} + device = self._device + if device is None: + device = {} + ip_address = device.get("ip_address") + kwargs: dict[str, object] = ( + {"configuration_url": "http://brink.local/"} + if ip_address + else {} + ) return DeviceInfo( identifiers={(DOMAIN, str(self.system_id))}, - name=device.get("model", self.device_name), # use device model as device title in UI + name=device.get("model", self.device_name), manufacturer=DEFAULT_NAME, model=device.get("model", DEFAULT_MODEL), serial_number=device.get("serial_number"), sw_version=device.get("sw_version"), + **kwargs, ) @property diff --git a/custom_components/brink_ventilation/fan.py b/custom_components/brink_ventilation/fan.py index 6f32986..0bf56f9 100644 --- a/custom_components/brink_ventilation/fan.py +++ b/custom_components/brink_ventilation/fan.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import math from dataclasses import dataclass import voluptuous as vol @@ -13,8 +14,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError 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, @@ -22,11 +29,14 @@ MODE_MANUAL_VALUE, PARAM_OPERATING_MODE, PARAM_VENTILATION_LEVEL, + READ_WRITE_MAP, + VALUE_STATE_MAP, ) from .entity import BrinkHomeDeviceEntity _LOGGER = logging.getLogger(__name__) +SPEED_RANGE = (1, 3) DEFAULT_ON_PERCENTAGE = 33 OPERATING_MODE_LABELS_REVERSE = { @@ -43,8 +53,14 @@ class BrinkFanEntityDescription(FanEntityDescription): VENTILATION_FAN_DESCRIPTION = BrinkFanEntityDescription( - key="ventilation", - translation_key="ventilation", + 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, ) @@ -77,22 +93,36 @@ async def async_setup_entry( "async_set_airflow", ) - entities = [ - BrinkHomeVentilationFanEntity(client, coordinator, system_id) - 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.get("parameters", {}).get(PARAM_VENTILATION_LEVEL): + continue + + entities.append( + BrinkHomeVentilationFanEntity( + client, + coordinator, + system_id, + ) + ) + + entities.append( + BrinkHomeLevelFanEntity( + client, + coordinator, + system_id, + ) + ) async_add_entities(entities) -class BrinkHomeVentilationFanEntity(BrinkHomeDeviceEntity, FanEntity): +class BrinkHomeBaseFanEntity(BrinkHomeDeviceEntity, FanEntity): """Representation of the Brink ventilation level control.""" _attr_has_entity_name = True - entity_description: BrinkFanEntityDescription = ( - VENTILATION_FAN_DESCRIPTION - ) + entity_description: BrinkFanEntityDescription def __init__( self, @@ -107,9 +137,7 @@ def __init__( system_id, self.entity_description.parameter_key, ) - self._attr_unique_id = ( - f"{DOMAIN}_{system_id}_{self.parameter_key}_fan" - ) + self._max_airflow = 300 self._level_0_max = 50 self._level_1_max = 133 @@ -129,21 +157,6 @@ def __init__( .get("value") ) - async def async_set_airflow( - self, - airflow: int, - ) -> None: - """Set airflow.""" - - _LOGGER.warning( - "async_set_airflow called with airflow=%s", - airflow, - ) - - await self.async_set_percentage( - round(airflow / self._max_airflow * 100) - ) - async def async_set_level( self, level: int, @@ -157,36 +170,6 @@ async def async_set_level( await self._async_write_level(str(level)) - 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 _set_airflow_for_level( self, level: int, @@ -325,22 +308,6 @@ async def async_set_preset_mode( await self.coordinator.async_request_refresh() - @property - def old_percentage(self) -> int | None: - """Return current fan percentage.""" - device = next(iter((self.coordinator.data or {}).values()), {}) - parameters = device.get("parameters", {}) - - airflow = ( - parameters.get("supply_air_flow_setpoint", {}) - .get("value") - ) - - if airflow is None: - return None - - return round(int(airflow) / self._max_airflow * 100) - @property def werkt_percentage(self) -> int | None: level = int((self.data or {}).get("value", 0)) @@ -365,47 +332,6 @@ def werkt_percentage(self) -> int | None: return round(int(airflow) / self._max_airflow * 100) - @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.get("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 - ) - - @property - def percentage_step(self) -> float: - """Return percentage step.""" - return 1 - @property def supported_features(self) -> FanEntityFeature: """Return supported features.""" @@ -473,8 +399,7 @@ def _operating_mode(self) -> dict | None: def extra_state_attributes(self) -> dict[str, object]: """Return extra state attributes.""" - coordinator_data = self.coordinator.data or {} - attributes = dict(super().extra_state_attributes or {}) + attributes: dict[str, object] = {} param = self.data or {} @@ -483,16 +408,10 @@ def extra_state_attributes(self) -> dict[str, object]: self.coordinator.data, ) - _LOGGER.debug( - "supply_air_flow_setpoint=%s", - (self.coordinator.data or {}).get("supply_air_flow_setpoint"), - ) - - _LOGGER.debug( - "Entity data keys: %s", - list((self.data or {}).keys()), - ) - # Entity data keys: ['name', 'raw_name', 'value', 'value_id', 'value_state', 'read_write', 'control_type', 'list_items', 'min_value', 'max_value', 'default_value', 'step_width', 'decimals', 'unit_of_measure', 'unit_of_measure1', 'unit_of_measure2', 'component_id', 'numeric_id', 'options'] + # _LOGGER.debug( + # "Entity data keys: %s", + # list((self.data or {}).keys()), + # ) attributes.update( { @@ -501,16 +420,37 @@ def extra_state_attributes(self) -> dict[str, object]: "name": str(param.get("name")), "raw_name": str(param.get("raw_name")), "value": str(param.get("value")), - "value_state": param.get("value_state"), "numeric_id": str(param.get("numeric_id")), - "read_write": param.get("read_write"), - "control_type": param.get("control_type"), "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.get("parameters", {}) level = int(param.get("value", 0)) @@ -534,4 +474,197 @@ def extra_state_attributes(self) -> dict[str, object]: 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.get("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.""" + + _LOGGER.warning( + "async_set_airflow called with airflow=%s", + 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 8bc53fb..e60295e 100644 --- a/custom_components/brink_ventilation/manifest.json +++ b/custom_components/brink_ventilation/manifest.json @@ -3,10 +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", "version": "1.0.2" -} +} \ No newline at end of file diff --git a/custom_components/brink_ventilation/number.py b/custom_components/brink_ventilation/number.py index baa6077..27beca9 100644 --- a/custom_components/brink_ventilation/number.py +++ b/custom_components/brink_ventilation/number.py @@ -24,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError from .const import ( + CONTROL_TYPE_MAP, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, @@ -47,6 +48,8 @@ PARAM_VENTILATION_MODE_1, PARAM_VENTILATION_MODE_2, PARAM_VENTILATION_MODE_3, + READ_WRITE_MAP, + VALUE_STATE_MAP, ) from .entity import BrinkHomeDeviceEntity @@ -245,6 +248,7 @@ class BrinkNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.CO2, entity_category=EntityCategory.CONFIG, icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkNumberEntityDescription( key="co2_sensor_1_max_ppm", @@ -255,6 +259,7 @@ class BrinkNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.CO2, entity_category=EntityCategory.CONFIG, icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkNumberEntityDescription( key="co2_sensor_2_min_ppm", @@ -265,6 +270,7 @@ class BrinkNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.CO2, entity_category=EntityCategory.CONFIG, icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkNumberEntityDescription( key="co2_sensor_2_max_ppm", @@ -275,6 +281,7 @@ class BrinkNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.CO2, entity_category=EntityCategory.CONFIG, icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkNumberEntityDescription( key="co2_sensor_3_min_ppm", @@ -285,6 +292,7 @@ class BrinkNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.CO2, entity_category=EntityCategory.CONFIG, icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkNumberEntityDescription( key="co2_sensor_3_max_ppm", @@ -295,6 +303,7 @@ class BrinkNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.CO2, entity_category=EntityCategory.CONFIG, icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkNumberEntityDescription( key="co2_sensor_4_min_ppm", @@ -305,6 +314,7 @@ class BrinkNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.CO2, entity_category=EntityCategory.CONFIG, icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), BrinkNumberEntityDescription( key="co2_sensor_4_max_ppm", @@ -315,6 +325,7 @@ class BrinkNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.CO2, entity_category=EntityCategory.CONFIG, icon="mdi:molecule-co2", + entity_registry_enabled_default=False, ), ) @@ -493,7 +504,8 @@ def available(self) -> bool: parameters = device.get("parameters", {}) _LOGGER.debug( - "gateway_state available: super=%s data=%s raw=%s", + "gateway_state available: key=%s super=%s data=%s raw=%s", + self.entity_description.parameter_key, super().available, self.data, self.value, @@ -531,28 +543,61 @@ def available(self) -> bool: def extra_state_attributes(self) -> dict[str, object]: """Return extra state attributes.""" - 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(self.data.get("name")), - "raw_name": str(self.data.get("raw_name")), - "value": str(self.data.get("value")), - "value_state": str(self.data.get("value_state")), - "default_value": str(self.data.get("default_value")), - "numeric_id": str(self.data.get("numeric_id")), - "read_write": str(self.data.get("read_write")), - "control_type": str(self.data.get("control_type")), - "value_id": str(self.data.get("value_id")), - "min_value": str(self.data.get("min_value")), - "max_value": str(self.data.get("max_value")), - "step_width": str(self.data.get("step_width")), - "decimals": str(self.data.get("decimals")), - "list_items": str(self.data.get("list_items")), - "unit_of_measure": str(self.data.get("unit_of_measure")), - "component_id": str(self.data.get("component_id")), - "options": str(self.data.get("options")), + "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 diff --git a/custom_components/brink_ventilation/select.py b/custom_components/brink_ventilation/select.py index 48cdc08..525ae3f 100644 --- a/custom_components/brink_ventilation/select.py +++ b/custom_components/brink_ventilation/select.py @@ -12,6 +12,7 @@ from .const import ( CN_SWITCH_INPUT_CONDITION_LABELS, + CONTROL_TYPE_MAP, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, @@ -28,7 +29,9 @@ PARAM_SIGNAL_OUTPUT_MODE, PARAM_VALVE_CONTROL, PARAM_VENTILATION_LEVEL, + READ_WRITE_MAP, RH_SENSOR_SENSITIVITY_LABELS, + VALUE_STATE_MAP, ) from .entity import BrinkHomeDeviceEntity @@ -273,27 +276,62 @@ def extra_state_attributes(self) -> dict[str, object]: """Return extra state attributes.""" param = self.data or {} - return { + # 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", "onbeschikbaar"), - "raw_name": param.get("raw_name", "onbeschikbaar"), - "value": param.get("value", "onbeschikbaar"), - "value_state": param.get("value_state", "onbeschikbaar"), - "default_value": param.get("default_value", "onbeschikbaar"), - "numeric_id": param.get("numeric_id", "onbeschikbaar"), - "read_write": param.get("read_write", "onbeschikbaar"), - "control_type": param.get("control_type", "onbeschikbaar"), + "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"), - "min_value": param.get("min_value", "onbeschikbaar"), - "max_value": param.get("max_value", "onbeschikbaar"), - "step_width": param.get("step_width", "onbeschikbaar"), - "decimals": param.get("decimals", "onbeschikbaar"), - "unit_of_measure": param.get("unit_of_measure", "onbeschikbaar"), - "component_id": param.get("component_id", "onbeschikbaar"), - "raw_options": param.get("options", "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 + + 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", + "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.""" @@ -336,30 +374,6 @@ async def async_select_option( await self._async_write_value(str(selected_value)) - @property - def old_current_option(self) -> str | None: - """Return the currently selected option.""" - param = self.data - - if param is None: - return None - - current_value = str(param.get("value")) - - label_map = self.entity_description.label_map - - if label_map: - return label_map.get(current_value, current_value) - - return next( - ( - option["label"] - for option in param.get("options", []) - if option["value"] == current_value - ), - None, - ) - @property def current_option(self) -> str | None: """Return current option.""" @@ -375,13 +389,6 @@ def current_option(self) -> str | None: if label_map: result = label_map.get(current_value) - # _LOGGER.warning( - # "Select label map %s current=%r value=%r", - # self.parameter_key, - # result, - # current_value, - # ) - return result return next( @@ -393,24 +400,6 @@ def current_option(self) -> str | None: None, ) - @property - def old_options(self) -> list[str]: - """Return available options.""" - param = self.data - - if param is None: - return [] - - if self.entity_description.label_map: - return list( - self.entity_description.label_map.values() - ) - - return [ - option["label"] - for option in param.get("options", []) - ] - @property def options(self) -> list[str]: """Return available options.""" @@ -419,11 +408,7 @@ def options(self) -> list[str]: if label_map: result = list(label_map.values()) - # _LOGGER.debug( - # "Select %s options=%r", - # self.parameter_key, - # result, - # ) + return result param = self.data diff --git a/custom_components/brink_ventilation/sensor.py b/custom_components/brink_ventilation/sensor.py index 95d62ef..70a5e60 100644 --- a/custom_components/brink_ventilation/sensor.py +++ b/custom_components/brink_ventilation/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, EntityCategory, UnitOfElectricPotential, UnitOfPressure, @@ -25,12 +26,13 @@ from .const import ( ACTIVE_CONTROL_STATUS_LABELS, BYPASS_VALVE_STATUS_LABELS, + CONTROL_TYPE_MAP, DATA_CLIENT, DATA_COORDINATOR, DOMAIN, FILTER_STATUS_LABELS, FROST_PROTECTION_STATUS_LABELS, - GATEWAY_STATE_LABELS_STR, + GATEWAY_STATE_LABELS, GEOTHERMAL_HEAT_EXCHANGER_LABELS, PARAM_ACTIVE_CONTROL_STATUS, PARAM_ACTUAL_SUPPLY_AIR_FLOW, @@ -63,11 +65,13 @@ PARAM_VENTILATION_MODE_2, PARAM_VENTILATION_MODE_3, PREHEATER_STATUS_LABELS, + READ_WRITE_MAP, + VALUE_STATE_MAP, + VENTILATION_LEVEL_LABELS, ) from .entity import BrinkHomeDeviceEntity _LOGGER = logging.getLogger(__name__) -REVOLUTIONS_PER_MINUTE = "rpm" @dataclass(frozen=True) @@ -78,7 +82,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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 enabled_value_state: int | None = None _attr_has_entity_name = True @@ -89,6 +93,9 @@ class BrinkSensorDescription(SensorEntityDescription): 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, @@ -97,6 +104,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, @@ -105,6 +113,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, @@ -113,6 +122,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, @@ -121,6 +131,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, @@ -129,6 +140,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, @@ -228,7 +240,6 @@ class BrinkSensorDescription(SensorEntityDescription): icon="mdi:timer-sand", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, ), BrinkSensorDescription( key=PARAM_ACTIVE_CONTROL_STATUS, @@ -257,7 +268,6 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -317,6 +327,7 @@ class BrinkSensorDescription(SensorEntityDescription): device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", + entity_registry_enabled_default=False, ), BrinkSensorDescription( key="additional_temperature_sensor", @@ -326,6 +337,7 @@ class BrinkSensorDescription(SensorEntityDescription): device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", + entity_registry_enabled_default=False, ), BrinkSensorDescription( key=PARAM_FILTER_STATUS, @@ -395,6 +407,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -404,6 +417,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -413,6 +427,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -422,6 +437,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -457,6 +473,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -466,6 +483,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -475,6 +493,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -484,6 +503,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -492,6 +512,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -500,6 +521,7 @@ class BrinkSensorDescription(SensorEntityDescription): 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, ), @@ -536,10 +558,26 @@ class BrinkSensorDescription(SensorEntityDescription): parameter_key="gateway_state", icon="mdi:lan", device_class=SensorDeviceClass.ENUM, - options=list(GATEWAY_STATE_LABELS_STR.values()), + options=list(GATEWAY_STATE_LABELS.values()), is_enum=True, is_device_attribute=True, - value_map=GATEWAY_STATE_LABELS_STR, + 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, ), ) @@ -679,10 +717,6 @@ def __init__(self, client, coordinator, system_id: int, description: BrinkSensor """Initialize the Brink sensor.""" super().__init__(client, coordinator, system_id, description.parameter_key) self.entity_description = description -# _LOGGER.warning( -# "translation_key=%s", -# self.entity_description.translation_key, -# ) @property def unique_id(self): @@ -694,6 +728,9 @@ def extra_state_attributes(self) -> dict[str, object]: if self.entity_description.parameter_key == "ventilation_percentage": return {} + # Get base attributes + attributes: dict[str, object] = {} + param = self.data or {} attributes = { @@ -702,22 +739,53 @@ def extra_state_attributes(self) -> dict[str, object]: "name": str(param.get("name")), "raw_name": str(param.get("raw_name")), "value": str(param.get("value")), - "value_state": param.get("value_state"), - "default_value": str(param.get("default_value")), # remove + "_raw_value": str(self._raw_value), + "decimals": str(param.get("decimals")), "numeric_id": str(param.get("numeric_id")), - "read_write": param.get("read_write"), - "control_type": param.get("control_type"), "value_id": str(param.get("value_id")), - "min_value": str(param.get("min_value")), # remove - "max_value": str(param.get("max_value")), # remove - "step_width": str(param.get("step_width")), # remove - "decimals": str(param.get("decimals")), - "list_items": param.get("list_items"), # remove - "unit_of_measure": str(param.get("unit_of_measure")), "component_id": str(param.get("component_id")), - "raw_options": param.get("options"), # remove } + 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") @@ -800,8 +868,21 @@ def native_value(self) -> str | int | float | None: if self.parameter_key == "ventilation_percentage": return self._calculate_ventilation_percentage() + if self.parameter_key == "ip_address": + if self.data is None: + return None + value = str(self.data.get("value")) + return value + + if self.parameter_key == "dns_server": + if self.data is None: + return None + value = str(self.data.get("value")) + return value + _LOGGER.debug( - "gateway_state raw=%s", + "sensor value %s raw=%s", + self.parameter_key, self._raw_value, ) @@ -817,10 +898,10 @@ def native_value(self) -> str | int | float | None: _LOGGER.debug( "gateway_state value=%s mapped=%s options=%s", value, - value_map.get(str(value), str(value)), + value_map.get(int(value), str(value)), self.options, ) - return value_map.get(str(value), "unknown") + return value_map.get(int(value), "unknown") param = self.data @@ -846,45 +927,13 @@ def native_value(self) -> str | int | float | None: return int(number) if number.is_integer() else number - @property - def old_native_value(self): - if self.parameter_key == "ventilation_percentage": - return self._calculate_ventilation_percentage() - - param = self.data - if param is None: - return None - - value = param.get("value") - if value is None: - return None - - if self.entity_description.is_enum: - value_map = self.entity_description.value_map - if value_map is not None: - return value_map.get(str(value), str(value)) - - selected = next( - (item["label"] for item in param.get("options", []) if item["value"] == str(value)), - None, - ) - return selected or str(value) - - try: - number = float(value) - except (TypeError, ValueError): - return value - - if number.is_integer(): - return int(number) - return number - @property def available(self) -> bool: """Return if entity is available.""" _LOGGER.debug( - "gateway_state available: super=%s data=%s raw=%s", + "gateway_state available: key=%s super=%s data=%s raw=%s", + self.entity_description.parameter_key, super().available, self.data, self._raw_value, @@ -896,6 +945,12 @@ def available(self) -> bool: if self.entity_description.parameter_key == "gateway_state": return True + if self.entity_description.parameter_key == "ip_address": + return True + + if self.entity_description.parameter_key == "dns_server": + return True + param = self.data return ( @@ -904,7 +959,7 @@ def available(self) -> bool: and param.get("value_state") != 5 ) - def _calculate_ventilation_percentage(self) -> int | None: + def old_calculate_ventilation_percentage(self) -> int | None: """Calculate ventilation percentage based on configured airflow levels.""" parameters = self._device.get("parameters", {}) @@ -915,7 +970,10 @@ def _calculate_ventilation_percentage(self) -> int | None: exhaust_flow = float( parameters["actual_exhaust_air_flow"]["value"] ) - + min_airflow = 0 + max_airflow = float( + parameters["ventilation_mode_3_airflow"]["max_value"] + ) airflow_0 = float( parameters["ventilation_mode_0_airflow"]["value"] ) @@ -956,7 +1014,6 @@ def _calculate_ventilation_percentage(self) -> int | None: for index in range(len(levels) - 1): lower_flow, lower_percent = levels[index] upper_flow, upper_percent = levels[index + 1] - upper_flow = 300 # do not use airflow_3 as max, use 300 for 100% if lower_flow <= current_flow <= upper_flow: if upper_flow == lower_flow: @@ -969,12 +1026,50 @@ def _calculate_ventilation_percentage(self) -> int | None: percentage = ( lower_percent - + ( - fraction - * (upper_percent - lower_percent) - ) + + fraction * (upper_percent - lower_percent) ) return round(percentage) return None + + def _calculate_ventilation_percentage(self) -> int | None: + """Calculate ventilation percentage based on configured airflow levels.""" + if self._device: + parameters = self._device.get("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) diff --git a/custom_components/brink_ventilation/translations/nl.json b/custom_components/brink_ventilation/translations/nl.json index 64b1b22..9cb5427 100644 --- a/custom_components/brink_ventilation/translations/nl.json +++ b/custom_components/brink_ventilation/translations/nl.json @@ -49,74 +49,32 @@ }, "entity": { "fan": { - "brink_ventilation": { + "ventilation_airflow": { + "name": "Ventilatie snelheid", "state_attributes": { "preset_mode": { "state": { "Automatic": "Automatisch", - "automatic": "Automatisch", - "manual": "Handmatig", - "holiday": "Vakantie", - "party": "Feest", - "night_ventilation": "Nachtventilatie" + "Manual": "Handmatig", + "Holiday": "Vakantie", + "Party": "Feest", + "Night ventilation": "Nachtventilatie" } } } }, - "ventilation": { - "name": "Ventilatie" - }, "ventilation_level": { "name": "Ventilatiestand", - "state": { - "Automatic": "Automatisch", - "automatic": "Automatisch", - "manual": "Handmatig", - "holiday": "Vakantie", - "party": "Feest", - "night_ventilation": "Nachtventilatie" - } - }, - "ventilation_mode": { - "name": "Ventilatie modus", - "preset_mode": { - "state":{ - "automatic": "Automatisch", - "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" - }, - "automatic": "Automatisch", - "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" - }, - "state":{ - "automatic": "Automatisch", - "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" + "state_attributes": { + "preset_mode": { + "state": { + "Automatic": "Automatisch", + "Manual": "Handmatig", + "Holiday": "Vakantie", + "Party": "Feest", + "Night ventilation": "Nachtventilatie" + } + } } } }, @@ -447,6 +405,12 @@ } }, "sensor": { + "ip_address":{ + "name": "IP adres" + }, + "dns_server":{ + "name": "DNS server" + }, "additional_temperature_sensor":{ "name": "Extra temperatuur sensor" }, @@ -454,7 +418,13 @@ "name": "Vermogen voorverwarmer" }, "ventilation_level": { - "name": "Ventilatiestand" + "name": "Ventilatiestand", + "state": { + "Off": "Uit", + "Low": "Laag", + "Medium": "Gemiddeld", + "High": "Hoog" + } }, "ventilation_mode_0_airflow": { "name": "Ventilatiestand 0" From 07db867f1a3cbf4ee9f740b46be4656d3e119690 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 10 Sep 2026 22:53:13 +0200 Subject: [PATCH 9/9] Add files via upload --- .../brink_ventilation/__init__.py | 120 +++-- custom_components/brink_ventilation/api.py | 101 ++++ .../brink_ventilation/binary_sensor.py | 331 +++++++------ .../brink_ventilation/brand/logo.png | Bin 0 -> 3543 bytes custom_components/brink_ventilation/const.py | 93 +++- .../brink_ventilation/coordinator.py | 184 +++++++ .../core/brink_home_cloud.py | 450 +++++++++++++++--- .../core/brink_home_local.py | 4 +- .../brink_ventilation/core/uid_definitions.py | 28 +- .../brink_ventilation/core/uid_parser.py | 153 +++++- custom_components/brink_ventilation/entity.py | 209 ++++++-- custom_components/brink_ventilation/fan.py | 119 +++-- .../brink_ventilation/manifest.json | 2 +- custom_components/brink_ventilation/models.py | 214 +++++++++ custom_components/brink_ventilation/number.py | 32 +- custom_components/brink_ventilation/select.py | 72 ++- custom_components/brink_ventilation/sensor.py | 395 ++++++++++----- .../brink_ventilation/services.yaml | 40 ++ custom_components/brink_ventilation/sound.py | 93 ++++ .../brink_ventilation/strings.json | 34 ++ .../brink_ventilation/translations/nl.json | 62 +++ 21 files changed, 2169 insertions(+), 567 deletions(-) create mode 100644 custom_components/brink_ventilation/api.py create mode 100644 custom_components/brink_ventilation/brand/logo.png create mode 100644 custom_components/brink_ventilation/coordinator.py create mode 100644 custom_components/brink_ventilation/models.py create mode 100644 custom_components/brink_ventilation/services.yaml create mode 100644 custom_components/brink_ventilation/sound.py create mode 100644 custom_components/brink_ventilation/strings.json diff --git a/custom_components/brink_ventilation/__init__.py b/custom_components/brink_ventilation/__init__.py index a55dccb..46b8dcc 100644 --- a/custom_components/brink_ventilation/__init__.py +++ b/custom_components/brink_ventilation/__init__.py @@ -2,12 +2,18 @@ from __future__ import annotations +# __init__.py import asyncio 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, @@ -18,9 +24,11 @@ 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, @@ -31,6 +39,7 @@ PARAM_DEVICE_TYPE, PARAM_SOFTWARE_LABEL, ) +from .coordinator import BrinkCoordinator from .core.brink_home_cloud import BrinkAuthError, BrinkHomeCloud _LOGGER = logging.getLogger(__name__) @@ -40,6 +49,44 @@ 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] @@ -54,34 +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, + coordinator = BrinkCoordinator( + hass=hass, config_entry=entry, - name=DOMAIN, - update_method=async_update_data, - update_interval=timedelta(seconds=scan_interval), + brink_client=brink_client, + scan_interval=scan_interval, ) hass.data.setdefault(DOMAIN, {}) @@ -100,59 +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() - - _LOGGER.debug("Brink Flair nr of systems: %s", len(systems)) - _LOGGER.debug("Brink Flair init systems: %s", 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, dict[str, Any]] = {} - for system in systems: - system_id = 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 = await brink_client.get_device(system_id) - _LOGGER.debug("Brink Flair device: %s", device) - gateway_type_id = device.get("gatewayTypeId") - is_online = device.get("isOnline") - parameters = await brink_client.get_device_data(system_id) - _LOGGER.debug("Brink Flair device parameters: %s", parameters) - 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"), - "gateway_type_id": gateway_type_id, - "device_is_online": is_online, - "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 67b3cdb..3368397 100644 --- a/custom_components/brink_ventilation/binary_sensor.py +++ b/custom_components/brink_ventilation/binary_sensor.py @@ -27,25 +27,38 @@ PARAM_RH_SENSOR_STATUS, ) from .entity import BrinkHomeDeviceEntity +from .models import BrinkAlertLevel _LOGGER: logging.Logger = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) -class BrinkBinarySensorDescription( - BinarySensorEntityDescription -): +class BrinkBinarySensorDescription(BinarySensorEntityDescription): """Describe a Brink binary sensor.""" + parameter_key: str - data_fn: Callable[[dict[str, object] | None], dict[str, object] | None] | None = None - value_fn: Callable[[dict[str, object]], bool | None] | None = None - attr_fn: Callable[[dict[str, object]], dict[str, object] | None] | None = None - icon_fn: Callable[[bool | None], str | None] | None = None - available_fn: Callable[ - [dict[str, object]], + requires_parameter: bool = True + + value_fn: Callable[ + [BrinkHomeBinarySensorEntity], bool | None, ] | None = None + attr_fn: Callable[ + [BrinkHomeBinarySensorEntity], + dict[str, object], + ] | None = None + + icon_fn: Callable[ + [BrinkHomeBinarySensorEntity], + str | None, + ] | None = None + + available_fn: Callable[ + [BrinkHomeBinarySensorEntity], + bool, + ] | None = None + class BrinkHomeBinarySensorEntity( BrinkHomeDeviceEntity, @@ -73,14 +86,6 @@ def __init__( ) self.entity_description = description - @property - def old_unique_id(self) -> str: - """Return a unique ID.""" - return ( - f"{DOMAIN}_{self.system_id}_" - f"{self.parameter_key}_binary_sensor" - ) - @property def unique_id(self) -> str: """Return a unique ID.""" @@ -92,9 +97,10 @@ def unique_id(self) -> str: @property def icon(self) -> str | None: """Return the entity icon.""" + icon_fn = self.entity_description.icon_fn - if self.entity_description.icon_fn is not None: - return self.entity_description.icon_fn(self.is_on) + if icon_fn is not None: + return icon_fn(self) return self.entity_description.icon @@ -102,30 +108,16 @@ def icon(self) -> str | None: def is_on(self) -> bool | None: """Return sensor state.""" - _LOGGER.debug( - "Gateway data for %s: %s", - self.system_id, - self._entity_data, - ) + value_fn = self.entity_description.value_fn + + if value_fn is not None: + return value_fn(self) - data = self._entity_data + data = self.data if data is None: return None - if self.entity_description.value_fn is not None: - result = self.entity_description.value_fn(data) - - if self.entity_description.key == "gateway_state": - _LOGGER.debug( - "Gateway state=%s online=%s result=%s", - data.get("value"), - GATEWAY_STATE_ONLINE, - result, - ) - - return result - value = data.get("value") if value is None: @@ -137,79 +129,92 @@ def is_on(self) -> bool | None: def available(self) -> bool: """Return if entity is available.""" - data = self._entity_data + available_fn = self.entity_description.available_fn + if available_fn is not None: + return bool(available_fn(self)) - if data is None: + if not self.entity_description.requires_parameter: + return True + + if not super().available: return False - # Override availability for system/device entities. - if self.entity_description.available_fn is not None: - return bool(self.entity_description.available_fn(data)) + data = self.data return ( - super().available - and ( - data.get("value_state") is None - or data.get("value_state") != 5 - ) + data is not None + and data.get("value_state") != 5 ) @property def extra_state_attributes(self) -> dict[str, object]: """Return extra state attributes.""" - - data = self._entity_data or {} - - attributes = { + attributes: dict[str, object] = { "key": _debug_value(self.entity_description.key), - "translation_key": _debug_value(self.entity_description.translation_key), - "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")), + "translation_key": _debug_value( + self.entity_description.translation_key, + ), } - # binary sensors should not have this keys but if they do they are in the wrong sensor section (select or number) - for key in ( - "min_value", - "max_value", - "step_width", - "decimals", - "unit_of_measure", - ): - if data.get(key) is not None: - attributes[key] = data[key] - - if self.entity_description.attr_fn is not None: - attributes: dict[str, object] = { - "key": _debug_value(self.entity_description.key), - "translation_key": _debug_value(self.entity_description.translation_key), - "value": _debug_value(data.get("value")) - } - custom_attributes = self.entity_description.attr_fn(data) + 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 _entity_data(self) -> dict[str, object] | None: - """Return the data source for this entity.""" + 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 device_is_online(self) -> bool | None: + """Return whether the device is online.""" + device = self._device - if self.entity_description.data_fn is not None: - return self.entity_description.data_fn(self._device) + return None if device is None else device.device_is_online - return self.data + @property + 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: @@ -222,60 +227,46 @@ def _debug_value(value: object) -> str: key="gateway_status", translation_key="gateway_status", parameter_key="gateway_status", + requires_parameter=False, device_class=BinarySensorDeviceClass.CONNECTIVITY, - data_fn=lambda device: ( - { - "value": device.get("gateway_state"), - "device_is_online": device.get("device_is_online"), - "gateway_type_id": device.get("gateway_type_id"), - } - if device is not None - else None - ), - value_fn=lambda data: bool(str(data.get("value")) == str(GATEWAY_STATE_ONLINE)), - attr_fn=lambda data: { - "gateway_state": data.get("value"), - "device_is_online": data.get("device_is_online"), - "gateway_type_id": data.get("gateway_type_id"), + 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( - int(data.get("value", -1)), + entity.gateway_state, "unknown", ), }, - icon_fn=lambda is_on: ( + available_fn=lambda entity: entity.coordinator.last_update_success, + icon_fn=lambda entity: ( "mdi:connection" - if is_on + if entity.is_on else "mdi:lan-disconnect" ), - available_fn=lambda data: True ), BrinkBinarySensorDescription( key="device_online", translation_key="device_online", parameter_key="device_online", + requires_parameter=False, device_class=BinarySensorDeviceClass.CONNECTIVITY, - data_fn=lambda device: ( - { - "value": device.get("device_is_online"), - } - if device is not None - else None - ), - value_fn=lambda data: data.get("value"), - icon_fn=lambda is_on: ( + value_fn=lambda entity: entity.device_is_online, + available_fn=lambda entity: entity.coordinator.last_update_success, + icon_fn=lambda entity: ( "mdi:connection" - if is_on + if entity.is_on else "mdi:lan-disconnect" ), - available_fn=lambda data: True ), BrinkBinarySensorDescription( key=PARAM_FILTER_STATUS, - translation_key="filter_need_change", + translation_key="status_filter_message", parameter_key=PARAM_FILTER_STATUS, icon="mdi:air-filter", device_class=BinarySensorDeviceClass.PROBLEM, - value_fn=lambda param: str(param.get("value")) == "1", + value_fn=lambda entity: entity.parameter_value == "1" ), BrinkBinarySensorDescription( key=PARAM_RH_SENSOR_STATUS, @@ -326,6 +317,72 @@ def _debug_value(value: object) -> str: 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, + ) ) @@ -342,24 +399,10 @@ async def async_setup_entry( for system_id, device in (coordinator.data or {}).items(): for description in BINARY_SENSOR_DESCRIPTIONS: - if description.data_fn is not None: - entities.append( - BrinkHomeBinarySensorEntity( - client, - coordinator, - system_id, - description, - ) - ) - continue - - parameters = device.get("parameters", {}) - _LOGGER.debug( - "ebus_co2_sensor_status exists=%s value=%s", - "ebus_co2_sensor_status" in parameters, - parameters.get("ebus_co2_sensor_status"), - ) - if parameters.get(description.parameter_key) is not None: + if ( + not description.requires_parameter + or device.parameters.get(description.parameter_key) is not None + ): entities.append( BrinkHomeBinarySensorEntity( client, @@ -370,15 +413,3 @@ async def async_setup_entry( ) async_add_entities(entities) - - -def _gateway_state_value(device: dict | None) -> int | None: - """Normalize the Brink gateway state.""" - if device is None: - return None - - state = device.get("gateway_state") - try: - return state - except (TypeError, ValueError): - return None diff --git a/custom_components/brink_ventilation/brand/logo.png b/custom_components/brink_ventilation/brand/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b7d5dea0f2a4ca70f652d5818d905af5e2d598eb GIT binary patch literal 3543 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 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/brink_home_cloud.py b/custom_components/brink_ventilation/core/brink_home_cloud.py index aa7bf86..196344b 100644 --- a/custom_components/brink_ventilation/core/brink_home_cloud.py +++ b/custom_components/brink_ventilation/core/brink_home_cloud.py @@ -8,18 +8,23 @@ 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 async_timeout from aiohttp import ( + ClientConnectionError, + ClientConnectorError, ClientError, ClientResponse, ClientResponseError, ClientSession, ClientTimeout, CookieJar, + ServerDisconnectedError, ) from ..const import ( @@ -35,6 +40,7 @@ VALUE_STATE_MAP, WRITE_VALUE_STATE, ) +from ..models import BrinkAlert, BrinkAlertLevel, BrinkSettings, BrinkSystem, BrinkUser from ..translations import TRANSLATIONS _LOGGER = logging.getLogger(__name__) @@ -42,7 +48,19 @@ class BrinkApiError(Exception): - """Brink API communication error.""" + """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): @@ -70,6 +88,33 @@ 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.""" @@ -78,6 +123,10 @@ class BrinkHomeCloud: _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 @@ -99,56 +148,182 @@ 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.""" - response1 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=1") - response2 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=2") - response3 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=3") - response4 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=4") - response5 = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=5") - response = await self._api_request("GET", f"{API_V1_URL}systems?pageSize=6") + response = await self._api_request( + "GET", + f"{API_V1_URL}systems?pageSize=5", + ) + try: - payload = await response1.json() - _LOGGER.debug("Brink Flair system payload1: %s", payload) - payload = await response2.json() - _LOGGER.debug("Brink Flair system payload2: %s", payload) - payload = await response3.json() - _LOGGER.debug("Brink Flair system payload3: %s", payload) - payload = await response4.json() - _LOGGER.debug("Brink Flair system payload4: %s", payload) - payload = await response5.json() - _LOGGER.debug("Brink Flair system payload5: %s", payload) payload = await response.json() - _LOGGER.debug("Brink Flair system payload: %s", payload) 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( - { - "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"), - } + + 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 get_device(self, system_id: int) -> dict[str, dict[str, Any]]: + 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}" @@ -204,21 +379,63 @@ async def write_parameters( 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, + json_data: dict[str, object] | None = None, + params: dict[str, object] | None = None, ) -> ClientResponse: """Perform an authenticated v1.1 API request.""" + max_attempts = 3 + _LOGGER.debug( "Brink request %s %s timeout=%s", method, url, self._timeout, ) - for attempt in range(3): + + for attempt in range(max_attempts): await self._ensure_token() try: @@ -226,6 +443,7 @@ async def _api_request( method, url, json=json_data, + params=params, timeout=ClientTimeout(total=self._timeout), headers={ "Authorization": f"Bearer {self._access_token}", @@ -242,21 +460,62 @@ async def _api_request( self._access_token = None continue - raise BrinkAuthError("Authentication failed after retry") + 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: + except ClientResponseError as err: await response.release() - raise + + 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 + return response - except (ClientError, TimeoutError) as err: - if attempt < 2: + 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, - 3, + max_attempts, method, url, err, @@ -265,15 +524,21 @@ async def _api_request( _LOGGER.warning( "Brink request failed (%s/%s): %s %s (%s)", attempt + 1, - 3, + max_attempts, method, url, err, ) - if attempt == 2: + 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 3 attempts: {method} {url}" + f"Brink request failed after {max_attempts} attempts: " + f"{method} {url}" ) from err await asyncio.sleep(2) @@ -416,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, @@ -465,7 +730,7 @@ 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) + # _LOGGER.debug("Brink Flair refresh access token payload: %s", payload) await response.release() except (ClientError, asyncio.TimeoutError) as ex: self._refresh_token = None @@ -481,6 +746,12 @@ 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: ClientSession, @@ -560,33 +831,35 @@ def _extract_parameters( else [] ) - if 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), - ) + # 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): - _LOGGER.info( - "Parameter: %s (%s) valueState=%s value=%s", - key, - numeric_id, - value_state, - param.get("value"), - ) + # 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) @@ -727,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 index 2b20412..8095b89 100644 --- a/custom_components/brink_ventilation/core/brink_home_local.py +++ b/custom_components/brink_ventilation/core/brink_home_local.py @@ -8,6 +8,8 @@ _LOGGER = logging.getLogger(__name__) +HOST = "brink.local" + class BrinkHomeLocalError(Exception): """Raised when communication with the local Brink device fails.""" @@ -19,7 +21,7 @@ class BrinkHomeLocalClient: def __init__( self, session: ClientSession, - host: str, + host: str = "brink.local", *, port: int = 80, ) -> None: diff --git a/custom_components/brink_ventilation/core/uid_definitions.py b/custom_components/brink_ventilation/core/uid_definitions.py index 1b8392d..b81f9c8 100644 --- a/custom_components/brink_ventilation/core/uid_definitions.py +++ b/custom_components/brink_ventilation/core/uid_definitions.py @@ -117,15 +117,17 @@ class UIDDefinition: ), UIDDefinition( uid=2, - key="unknown_supply_control_value", - value_type=UIDType.INT, - description="unknown_supply_control_value", + key="supply_fan_rpm", + value_type=UIDType.RPM, + unit=UIDUnit.RPM, + description="Supply fan speed", ), UIDDefinition( uid=3, - key="unknown_exhaust_control_value", - value_type=UIDType.INT, - description="unknown_exhaust_control_value", + key="exhaust_fan_rpm", + value_type=UIDType.RPM, + unit=UIDUnit.RPM, + description="Exhaust fan speed", ), UIDDefinition( uid=6, @@ -167,14 +169,14 @@ class UIDDefinition: key="supply_pressure", value_type=UIDType.PRESSURE, unit=UIDUnit.PASCAL, - description="Supply pressure", + description="Supply duct pressure", ), UIDDefinition( uid=20, key="exhaust_pressure", value_type=UIDType.PRESSURE, unit=UIDUnit.PASCAL, - description="Exhaust pressure", + description="Exhaust duct pressure", ), UIDDefinition( uid=22, @@ -204,21 +206,21 @@ class UIDDefinition: ), UIDDefinition( uid=45, - key="days_since_filter_reset", + key="days_since_filter_reset?", value_type=UIDType.UINT32, description="Days since filter reset", ), UIDDefinition( uid=46, - key="", + key="unknown_46", value_type=UIDType.UINT32, - description="", + description="Unknown raw supply fan value", ), UIDDefinition( uid=47, - key="", + key="unknown_47", value_type=UIDType.UINT32, - description="", + description="Unknown raw exhaust fan value", ), UIDDefinition( uid=49, diff --git a/custom_components/brink_ventilation/core/uid_parser.py b/custom_components/brink_ventilation/core/uid_parser.py index 051af2e..0db01a9 100644 --- a/custom_components/brink_ventilation/core/uid_parser.py +++ b/custom_components/brink_ventilation/core/uid_parser.py @@ -2,22 +2,67 @@ from __future__ import annotations -from .uid_definitions import UID_DEFINITIONS, UID_LOOKUP, UIDDefinition +from collections.abc import Callable +from .uid_definitions import UID_LOOKUP, UIDType -def decode_int(value: str) -> int: - """Decode an integer value.""" - return int(value.split(",")[0]) + +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_int(value) != 0 + return decode_uint16(value) != 0 def decode_percent(value: str) -> int: """Decode a percentage.""" - return decode_int(value) + 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: @@ -27,21 +72,80 @@ def decode_temperature(value: str) -> float: Example: "231,0" -> 23.1 """ - return decode_int(value) / 10 + return decode_uint16(value) / 10.0 -def decode_ascii(value: str) -> str: - """Decode an ASCII string.""" - return bytes( - number - for number in map(int, value.split(",")) - if number != 0 - ).decode("ascii") +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) -_UID_LOOKUP: dict[str, UIDDefinition] = { - definition.uid: definition - for definition in UID_DEFINITIONS +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, } @@ -51,14 +155,21 @@ def parse_uid_values( """Convert raw UID values into named values.""" parsed: dict[str, object] = {} - for uid, value in values.items(): - definition = UID_LOOKUP.get(int(uid.removeprefix("UID"))) + 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] = definition.decoder(value) + parsed[definition.key] = decoder(raw_value) except (TypeError, ValueError): - parsed[definition.key] = value + parsed[definition.key] = raw_value return parsed diff --git a/custom_components/brink_ventilation/entity.py b/custom_components/brink_ventilation/entity.py index b370bea..801e65c 100644 --- a/custom_components/brink_ventilation/entity.py +++ b/custom_components/brink_ventilation/entity.py @@ -5,10 +5,21 @@ 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: @@ -18,57 +29,75 @@ def __init__(self, client, coordinator, system_id: int) -> None: self.system_id = system_id @property - def _device(self) -> dict[str, object] | 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 - - name = device.get("name") - - return str(name) if name is not None else DEFAULT_NAME + return DEFAULT_NAME if device is None else device.name @property def device_info(self) -> DeviceInfo: """Return device info for the Brink entity.""" device = self._device + name = self.device_name if device is None: - device = {} - ip_address = device.get("ip_address") + 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 ip_address + if device.ip_address else {} ) return DeviceInfo( identifiers={(DOMAIN, str(self.system_id))}, - name=device.get("model", self.device_name), + name=device.model or self.device_name, manufacturer=DEFAULT_NAME, - model=device.get("model", DEFAULT_MODEL), - serial_number=device.get("serial_number"), - sw_version=device.get("sw_version"), + 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 @@ -79,16 +108,11 @@ def data(self) -> dict[str, object] | None: return parameter if isinstance(parameter, dict) else None @property - def _parameters(self) -> dict[str, object]: + def _parameters(self) -> dict[str, dict[str, object]]: """Return the device parameters.""" device = self._device - if device is None: - return {} - - parameters = device.get("parameters") - - return parameters if isinstance(parameters, dict) else {} + return {} if device is None else device.parameters @property def parameter_name(self) -> str: @@ -104,7 +128,132 @@ def parameter_name(self) -> str: 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 0bf56f9..53b1a43 100644 --- a/custom_components/brink_ventilation/fan.py +++ b/custom_components/brink_ventilation/fan.py @@ -36,6 +36,12 @@ _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 @@ -74,29 +80,10 @@ async def async_setup_entry( client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - platform = entity_platform.async_get_current_platform() - - platform.async_register_entity_service( - "set_level", - { - vol.Required("level"): vol.All( - vol.Coerce(int), - vol.In([0, 1, 2, 3]), - ) - }, - "async_set_level", - ) - - platform.async_register_entity_service( - "set_airflow", - {"airflow": int}, - "async_set_airflow", - ) - entities: list[FanEntity] = [] for system_id, device in (coordinator.data or {}).items(): - if not device.get("parameters", {}).get(PARAM_VENTILATION_LEVEL): + if not device.parameters.get(PARAM_VENTILATION_LEVEL): continue entities.append( @@ -116,6 +103,28 @@ async def async_setup_entry( ) 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 BrinkHomeBaseFanEntity(BrinkHomeDeviceEntity, FanEntity): """Representation of the Brink ventilation level control.""" @@ -143,13 +152,13 @@ def __init__( self._level_1_max = 133 self._level_2_max = 216 self._level_3_max = 300 - device = next(iter((self.coordinator.data or {}).values()), {}) - parameters = device.get("parameters", {}) + # 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", {}) + device.parameters.get(f"ventilation_mode_{level}_airflow", {}) ) self._current_airflow = ( @@ -157,6 +166,30 @@ def __init__( .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, @@ -178,7 +211,7 @@ async def _set_airflow_for_level( """Set airflow for a ventilation level.""" device = next(iter((self.coordinator.data or {}).values()), {}) - parameters = device.get("parameters", {}) + parameters = device.parameters parameter = parameters.get( f"ventilation_mode_{level}_airflow" @@ -234,9 +267,13 @@ async def _async_write_level(self, level_value: str) -> None: raise HomeAssistantError("Ventilation parameter is unavailable") params = [] - mode = self._operating_mode + # mode = self._operating_mode + mode = self._parameters.get(PARAM_OPERATING_MODE) - if mode and mode.get("value_id") is not None: + if ( + isinstance(mode, dict) + and mode.get("value_id") is not None + ): _LOGGER.debug( "Switching operating mode to manual due to fan speed change" ) @@ -276,7 +313,8 @@ def preset_mode(self) -> str | None: if mode is None: return None - return FAN_OPERATING_MODE_LABELS.get(str(mode.get("value"))) + # return FAN_OPERATING_MODE_LABELS.get(str(mode.get("value"))) + return FAN_OPERATING_MODE_LABELS.get(mode) async def async_set_preset_mode( self, @@ -313,7 +351,7 @@ def werkt_percentage(self) -> int | None: level = int((self.data or {}).get("value", 0)) device = self._device or {} - parameters = device.get("parameters", {}) + parameters = device.parameters airflow = ( parameters.get( @@ -385,7 +423,7 @@ async def async_turn_off(self, **kwargs) -> None: await self._async_write_level("0") @property - def _operating_mode(self) -> dict | None: + def old_operating_mode(self) -> dict | None: """Return the operating mode parameter.""" if self._device is None: return None @@ -395,6 +433,18 @@ def _operating_mode(self) -> dict | None: {}, ).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.""" @@ -452,7 +502,7 @@ def extra_state_attributes(self) -> dict[str, object]: attributes["read_write"] = "unavailable" device = self._device - parameters = device.get("parameters", {}) + parameters = device.parameters level = int(param.get("value", 0)) airflow_param = ( parameters.get(f"ventilation_mode_{level}_airflow", {}) @@ -527,7 +577,7 @@ def percentage(self) -> int | None: return None device = self._device or {} - parameters = device.get("parameters", {}) + parameters = device.parameters airflow = ( parameters.get( @@ -587,11 +637,6 @@ async def async_set_airflow( ) -> None: """Set airflow.""" - _LOGGER.warning( - "async_set_airflow called with airflow=%s", - airflow, - ) - await self.async_set_percentage( round(airflow / self._max_airflow * 100) ) diff --git a/custom_components/brink_ventilation/manifest.json b/custom_components/brink_ventilation/manifest.json index e60295e..eebd9e7 100644 --- a/custom_components/brink_ventilation/manifest.json +++ b/custom_components/brink_ventilation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/samuolis/brink/issues", - "version": "1.0.2" + "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 index 27beca9..81174f5 100644 --- a/custom_components/brink_ventilation/number.py +++ b/custom_components/brink_ventilation/number.py @@ -4,6 +4,7 @@ import logging from dataclasses import dataclass +from enum import IntEnum from homeassistant.components.number import ( NumberDeviceClass, @@ -330,6 +331,11 @@ class BrinkNumberEntityDescription(NumberEntityDescription): ) +class ValueState(IntEnum): + """Represents the state of a sensor value.""" + INVALID = 5 + + class BrinkHomeNumberEntity(BrinkHomeDeviceEntity, NumberEntity): """Representation of a Brink number.""" @@ -501,15 +507,21 @@ def available(self) -> bool: """Return if entity is available.""" device = next(iter((self.coordinator.data or {}).values()), {}) - parameters = device.get("parameters", {}) - - _LOGGER.debug( - "gateway_state available: key=%s super=%s data=%s raw=%s", - self.entity_description.parameter_key, - super().available, - self.data, - self.value, - ) + 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 @@ -651,7 +663,7 @@ async def async_setup_entry( ) for system_id, device in (coordinator.data or {}).items() for description in NUMBER_DESCRIPTIONS - if device.get("parameters", {}).get(description.parameter_key) + 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 525ae3f..9911fee 100644 --- a/custom_components/brink_ventilation/select.py +++ b/custom_components/brink_ventilation/select.py @@ -37,6 +37,9 @@ _LOGGER = logging.getLogger(__name__) +CN1_EXTERNAL_LEVEL = "4" +CN1_EXTERNAL_LABEL = "CN1" + @dataclass(frozen=True, kw_only=True) class BrinkSelectEntityDescription(SelectEntityDescription): @@ -267,6 +270,7 @@ def icon(self) -> str | None: "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 @@ -319,6 +323,7 @@ def extra_state_attributes(self) -> dict[str, object]: # 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", @@ -345,6 +350,11 @@ async def async_select_option( option: str, ) -> None: """Select an option.""" + if option == "CN1": + raise HomeAssistantError( + "CN1 is controlled by an external input." + ) + param = self.data if param is None: @@ -384,12 +394,14 @@ def current_option(self) -> str | None: current_value = str(param.get("value")) - label_map = self.entity_description.label_map - - if label_map: - result = label_map.get(current_value) + if ( + self.entity_description.parameter_key == PARAM_VENTILATION_LEVEL + and current_value == CN1_EXTERNAL_LEVEL + ): + return CN1_EXTERNAL_LABEL - return result + if (label_map := self.entity_description.label_map) is not None: + return label_map.get(current_value) return next( ( @@ -416,37 +428,53 @@ def options(self) -> list[str]: if param is None: return [] - return [ + 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") + + return options + @property def available(self) -> bool: """Return if entity is available.""" - device = next(iter((self.coordinator.data or {}).values()), {}) - parameters = device.get("parameters", {}) + if not super().available: + return False - _LOGGER.debug( - "gateway_state available: super=%s data=%s current_value=%s", - super().available, - self.data, - self.current_option, + device = next( + iter((self.coordinator.data or {}).values()), + None, ) + if device is None: + return False + + 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 = int( - parameters.get("ebus_co2_sensor_status", {}) - .get("value") - ) - return (rh_sensor == 1) + 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 param = self.data return ( - super().available - and param is not None + param is not None and param.get("value_state") != 5 ) @@ -467,9 +495,7 @@ async def async_setup_entry( ) for system_id, device in (coordinator.data or {}).items() for description in SELECT_DESCRIPTIONS - if device.get("parameters", {}).get( - description.parameter_key - ) + 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 70a5e60..530e881 100644 --- a/custom_components/brink_ventilation/sensor.py +++ b/custom_components/brink_ventilation/sensor.py @@ -1,7 +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, @@ -17,11 +19,13 @@ 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, @@ -30,7 +34,6 @@ DATA_CLIENT, DATA_COORDINATOR, DOMAIN, - FILTER_STATUS_LABELS, FROST_PROTECTION_STATUS_LABELS, GATEWAY_STATE_LABELS, GEOTHERMAL_HEAT_EXCHANGER_LABELS, @@ -46,8 +49,6 @@ PARAM_EXHAUST_AIR_FLOW, PARAM_EXHAUST_AIR_PRESSURE, PARAM_EXHAUST_TEMP, - PARAM_FILTER_MESSAGE, - PARAM_FILTER_STATUS, PARAM_FRESH_AIR_TEMP, PARAM_FROST_PROTECTION_STATUS, PARAM_HUMIDITY, @@ -70,21 +71,36 @@ 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__) -@dataclass(frozen=True) +class ValueState(IntEnum): + """Represents the state of a sensor value.""" + INVALID = 5 + + +# @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[int, 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 + # _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, ...] = ( @@ -225,14 +241,6 @@ class BrinkSensorDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), - BrinkSensorDescription( - key="filter_message", - translation_key="filter_message", - parameter_key=PARAM_FILTER_MESSAGE, - icon="mdi:air-filter", - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), BrinkSensorDescription( key=PARAM_REMAINING_DURATION, translation_key="remaining_duration", @@ -339,15 +347,6 @@ class BrinkSensorDescription(SensorEntityDescription): icon="mdi:thermometer", entity_registry_enabled_default=False, ), - BrinkSensorDescription( - key=PARAM_FILTER_STATUS, - translation_key="filter_status", - parameter_key=PARAM_FILTER_STATUS, - icon="mdi:air-filter", - device_class=SensorDeviceClass.ENUM, - is_enum=True, - value_map=FILTER_STATUS_LABELS, - ), BrinkSensorDescription( key=PARAM_FROST_PROTECTION_STATUS, translation_key="frost_protection_status", @@ -579,19 +578,73 @@ class BrinkSensorDescription(SensorEntityDescription): 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.""" - if description.parameter_key == "ventilation_percentage": + 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.get("parameters", {}).get(description.parameter_key) + param = device.parameters.get(description.parameter_key) if not param: return False @@ -685,7 +738,7 @@ async def async_setup_entry( known_parameters.add("ebus_co2_sensor_status") for system_id, device in (coordinator.data or {}).items(): - for key, param in device.get("parameters", {}).items(): + for key, param in device.parameters.items(): if key not in known_parameters: _LOGGER.info( "Unhandled parameter key=%s name=%s value=%s " @@ -846,13 +899,13 @@ def entity_registry_enabled_default(self) -> bool: @property 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 device.get(self.parameter_key) + + return getattr(device, self.parameter_key, None) param = self.data @@ -862,23 +915,26 @@ def _raw_value(self) -> object | None: return param.get("value") @property - def native_value(self) -> str | int | float | None: + 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 == "ip_address": + if self.parameter_key in { + "ip_address", + "dns_server", + }: if self.data is None: return None - value = str(self.data.get("value")) - return value - - if self.parameter_key == "dns_server": - if self.data is None: + value = self.data.get("value") + if not value: return None - value = str(self.data.get("value")) - return value + return str(value) _LOGGER.debug( "sensor value %s raw=%s", @@ -893,15 +949,30 @@ def native_value(self) -> str | int | float | 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: + try: + mapped = value_map.get(int(value)) + except (TypeError, ValueError): + mapped = None + + if mapped is None: + _LOGGER.debug( + "Unknown enum value %s for %s", + value, + self.parameter_key, + ) + return None + _LOGGER.debug( - "gateway_state value=%s mapped=%s options=%s", + "Enum %s raw=%s mapped=%s options=%s", + self.parameter_key, value, - value_map.get(int(value), str(value)), + mapped, self.options, ) - return value_map.get(int(value), "unknown") + return mapped param = self.data @@ -931,112 +1002,95 @@ def native_value(self) -> str | int | float | None: def available(self) -> bool: """Return if entity is available.""" - _LOGGER.debug( - "gateway_state available: key=%s super=%s data=%s raw=%s", - self.entity_description.parameter_key, - super().available, - self.data, - self._raw_value, - ) - - if self.entity_description.parameter_key == "ventilation_percentage": - return True + device = self._device + if device: + parameters = device.parameters + parameter_key = self.entity_description.parameter_key + param = self.data - if self.entity_description.parameter_key == "gateway_state": - return True + 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 self.entity_description.parameter_key == "ip_address": - return True + 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 self.entity_description.parameter_key == "dns_server": - return True + if parameter_key == "gateway_state": + return self.coordinator.last_update_success - param = self.data + 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") != 5 + and param.get("value_state") != ValueState.INVALID ) - def old_calculate_ventilation_percentage(self) -> int | None: - """Calculate ventilation percentage based on configured airflow levels.""" - parameters = self._device.get("parameters", {}) + def _calculate_cabinet_sound_power(self) -> float | None: + """Calculate the estimated cabinet sound power.""" + airflow = self.cabinet_airflow + pressure = self.cabinet_pressure - 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"] - ) - airflow_0 = float( - parameters["ventilation_mode_0_airflow"]["value"] - ) - airflow_1 = float( - parameters["ventilation_mode_1_airflow"]["value"] - ) - airflow_2 = float( - parameters["ventilation_mode_2_airflow"]["value"] - ) - airflow_3 = float( - parameters["ventilation_mode_3_airflow"]["value"] - ) - except ( - KeyError, - TypeError, - ValueError, - ): + if airflow is None or pressure is None: return None - current_flow = ( - supply_flow - + exhaust_flow - ) / 2 - - levels = [ - (airflow_0, 0.0), - (airflow_1, 33.333), - (airflow_2, 66.667), - (airflow_3, 100.0), - ] - - if current_flow <= levels[0][0]: - return 0 - - if current_flow >= levels[-1][0]: - return 100 - - for index in range(len(levels) - 1): - lower_flow, lower_percent = levels[index] - upper_flow, upper_percent = levels[index + 1] - - if lower_flow <= current_flow <= upper_flow: - if upper_flow == lower_flow: - return round(upper_percent) - - fraction = ( - (current_flow - lower_flow) - / (upper_flow - lower_flow) - ) - - percentage = ( - lower_percent - + fraction * (upper_percent - lower_percent) - ) - - return round(percentage) - - 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.get("parameters", {}) + parameters = self._device.parameters try: supply_flow = float( @@ -1073,3 +1127,84 @@ def _calculate_ventilation_percentage(self) -> int | None: # 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 + + 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/nl.json b/custom_components/brink_ventilation/translations/nl.json index 9cb5427..a4fee82 100644 --- a/custom_components/brink_ventilation/translations/nl.json +++ b/custom_components/brink_ventilation/translations/nl.json @@ -31,6 +31,38 @@ } } }, + "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": { @@ -193,6 +225,7 @@ "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" @@ -417,6 +450,12 @@ "preheater_power":{ "name": "Vermogen voorverwarmer" }, + "v1_analog_input": { + "name": "Analoge ingang 1" + }, + "v2_analog_input": { + "name": "Analoge ingang 2" + }, "ventilation_level": { "name": "Ventilatiestand", "state": { @@ -637,9 +676,32 @@ "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": {