diff --git a/.gitignore b/.gitignore index 1f1a246..5affc21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /.idea/ -/__pycache__/ \ No newline at end of file +__pycache__/ \ No newline at end of file diff --git a/README.md b/README.md index 2cd778d..97482c5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ You have two options for installation: - Restart HA server. ### WORKING ON: +- Brink Flair 200 - Brink Renovent 180 - Brink Renovent 300 - Brink Renovent 400 Plus diff --git a/custom_components/brink_ventilation/__init__.py b/custom_components/brink_ventilation/__init__.py index 9679443..fa56171 100644 --- a/custom_components/brink_ventilation/__init__.py +++ b/custom_components/brink_ventilation/__init__.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SELECT, Platform.BINARY_SENSOR, Platform.FAN] +PLATFORMS = [Platform.SELECT, Platform.BINARY_SENSOR, Platform.FAN, Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -100,9 +100,15 @@ async def async_get_devices(hass: HomeAssistant, entry: ConfigEntry, brink_clien # Retrieve additional description for system in systems: description = await brink_client.get_description_values(system["system_id"], system["gateway_id"]) + + # Add core ventilation control values system["ventilation"] = description["ventilation"] system["mode"] = description["mode"] system["filters_need_change"] = description["filters_need_change"] + + # Add any additional sensors (CO2, temperature, humidity, etc.) + for key, value in description.items(): + system[key] = value hass.data[DOMAIN][entry.entry_id][DATA_DEVICES] = systems diff --git a/custom_components/brink_ventilation/binary_sensor.py b/custom_components/brink_ventilation/binary_sensor.py index fc8d74c..a5d0948 100644 --- a/custom_components/brink_ventilation/binary_sensor.py +++ b/custom_components/brink_ventilation/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from custom_components.brink_ventilation import BrinkHomeDeviceEntity +from . import BrinkHomeDeviceEntity from .const import ( DATA_CLIENT, diff --git a/custom_components/brink_ventilation/const.py b/custom_components/brink_ventilation/const.py index 8011ace..48ee3dd 100644 --- a/custom_components/brink_ventilation/const.py +++ b/custom_components/brink_ventilation/const.py @@ -1,4 +1,6 @@ """Constant values for the Brink Home component.""" +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONCENTRATION_PARTS_PER_MILLION, UnitOfTime DOMAIN = "brink_ventilation" DEFAULT_NAME = "Brink" @@ -11,3 +13,28 @@ DEFAULT_SCAN_INTERVAL = 30 API_URL = "https://www.brink-home.com/portal/api/portal/" + +# Define sensor types with their properties +SENSOR_TYPES = { + "co2": { + "device_class": SensorDeviceClass.CO2, + "state_class": SensorStateClass.MEASUREMENT, + "unit": CONCENTRATION_PARTS_PER_MILLION, + "icon": "mdi:molecule-co2", + "pattern": r"(?=.*\bPPM\b)(?=.*\bCO2\b)", + }, + "bypass": { + "device_class": None, + "state_class": None, + "unit": None, + "icon": "mdi:valve", + "pattern": r"Status Bypassklappe", + }, + "mode_timer": { + "device_class": SensorDeviceClass.DURATION, + "state_class": None, + "unit": UnitOfTime.MINUTES, + "icon": "mdi:timer-outline", + "pattern": r"Restlaufzeit Betriebsartfunktion", + }, +} diff --git a/custom_components/brink_ventilation/core/brink_home_cloud.py b/custom_components/brink_ventilation/core/brink_home_cloud.py index 5ddadca..aca7c69 100644 --- a/custom_components/brink_ventilation/core/brink_home_cloud.py +++ b/custom_components/brink_ventilation/core/brink_home_cloud.py @@ -1,10 +1,12 @@ """Implementation for Brink-Home Cloud""" import asyncio -import async_timeout import logging +import re + import aiohttp +import async_timeout -from ..const import API_URL +from ..const import API_URL, SENSOR_TYPES from ..translations import TRANSLATIONS _LOGGER = logging.getLogger(__name__) @@ -31,7 +33,7 @@ async def _api_call(self, url, method, data=None): "%s request: %s, data %s", method, url, - data + data, ) try: async with async_timeout.timeout(self.timeout): @@ -39,7 +41,7 @@ async def _api_call(self, url, method, data=None): method, url, json=data, - headers=self.headers + headers=self.headers, ) if req.status == 401: @@ -72,7 +74,7 @@ async def login(self): _LOGGER.debug( "login result: %s", - result + result, ) return result @@ -87,50 +89,80 @@ async def get_systems(self): mapped_result = [] for system in result: - mapped_result.append({ - 'system_id': system["id"], - 'gateway_id': system["gatewayId"], - 'name': system['name'] - }) + mapped_result.append( + { + 'system_id': system["id"], + 'gateway_id': system["gatewayId"], + 'name': system['name'] + }, + ) _LOGGER.debug( "get_systems result: %s", - mapped_result + mapped_result, ) return mapped_result async def get_description_values(self, system_id, gateway_id): """Gets values info.""" - url = f"{API_URL}GetAppGuiDescriptionForGateway?GatewayId={gateway_id}&SystemId={system_id}" + url = (f"{API_URL}GetAppGuiDescriptionForGateway?GatewayId=" + f"{gateway_id}&SystemId={system_id}") response = await self._api_call(url, "GET") result = await response.json() _LOGGER.debug( "Response result: %s", - result + result, ) - + menu_items = result.get("menuItems", []) + if not menu_items: + _LOGGER.debug("No menu items found in API response") + return {} + menu_item = menu_items[0] pages = menu_item.get("pages", []) - home_page = pages[0] - parameters = home_page.get("parameterDescriptors", []) - ventilation = self.__find(parameters, "uiId", "Lüftungsstufe") - mode = self.__find(parameters, "uiId", "Betriebsart") - filters_need_change = self.__find(parameters, "uiId", "Status Filtermeldung") - + if not pages: + _LOGGER.debug("No pages found in menu item") + return {} + + # Extract all parameters from all pages + all_parameters = [] + for page in pages: + parameters = page.get("parameterDescriptors", []) + all_parameters.extend(parameters) + + _LOGGER.debug(f"Found {len(all_parameters)} parameters across all pages") + + # Find the basic parameters + ventilation = self.__find(all_parameters, "uiId", "Lüftungsstufe") + mode = self.__find(all_parameters, "uiId", "Betriebsart") + filters_need_change = self.__find( + all_parameters, "uiId", "Status Filtermeldung", + ) + # Initialize the result dictionary with the basic parameters description_result = { "ventilation": self.__get_type(ventilation), "mode": self.__get_type(mode), "filters_need_change": self.__get_type(filters_need_change) } + # Look for all sensor types and add them to the result + for param in all_parameters: + param_name = param.get("name", "") + + # Check against all defined sensor types + for sensor_type, properties in SENSOR_TYPES.items(): + if re.search(properties["pattern"], param_name): + _LOGGER.debug(f"Found {sensor_type} sensor: {param_name}") + description_result[param_name] = self.__get_type(param) + _LOGGER.debug( "get_description_values result: %s", - description_result + description_result, ) return description_result @@ -149,15 +181,21 @@ def __get_values(type): extracted = [] for value in values: if value["isSelectable"]: - extracted.append({ - "value": value["value"], - "text": TRANSLATIONS.get(value["displayText"], value["displayText"]) - }) + extracted.append( + { + "value": value["value"], + "text": TRANSLATIONS.get( + value["displayText"], value["displayText"], + ) + }, + ) return extracted # 1 as mode value changes mode to manual every time you change ventilation value - async def set_ventilation_value(self, system_id, gateway_id, mode, ventilation, value): + async def set_ventilation_value( + self, system_id, gateway_id, mode, ventilation, value, + ): ventilation_value = ventilation["values"][value]["value"] if ventilation_value is None: return @@ -203,7 +241,7 @@ async def set_mode_value(self, system_id, gateway_id, mode, value): await self._api_call(url, "POST", data) - def __find(self, arr , attr, value): + def __find(self, arr, attr, value): for obj in arr: try: if obj[attr] == value: @@ -211,6 +249,5 @@ def __find(self, arr , attr, value): except: _LOGGER.debug( "find error: %s", - value + value, ) - diff --git a/custom_components/brink_ventilation/fan.py b/custom_components/brink_ventilation/fan.py index c4956a0..d3c3849 100644 --- a/custom_components/brink_ventilation/fan.py +++ b/custom_components/brink_ventilation/fan.py @@ -3,16 +3,12 @@ import logging import math -from homeassistant.components.fan import ( - DOMAIN, - FanEntity, - FanEntityFeature -) +from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.util.percentage import int_states_in_range, ranged_value_to_percentage, percentage_to_ranged_value -from custom_components.brink_ventilation import BrinkHomeDeviceEntity +from . import BrinkHomeDeviceEntity from .const import ( DATA_CLIENT, diff --git a/custom_components/brink_ventilation/select.py b/custom_components/brink_ventilation/select.py index 83fbd62..3dc1115 100644 --- a/custom_components/brink_ventilation/select.py +++ b/custom_components/brink_ventilation/select.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from custom_components.brink_ventilation import BrinkHomeDeviceEntity +from . import BrinkHomeDeviceEntity from .const import ( DATA_CLIENT, diff --git a/custom_components/brink_ventilation/sensor.py b/custom_components/brink_ventilation/sensor.py new file mode 100644 index 0000000..ab7e0c2 --- /dev/null +++ b/custom_components/brink_ventilation/sensor.py @@ -0,0 +1,120 @@ +"""Support for Brink ventilation sensors.""" +from __future__ import annotations + +import logging +import re + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import BrinkHomeDeviceEntity +from .const import ( + DATA_CLIENT, + DATA_COORDINATOR, + DOMAIN, + SENSOR_TYPES, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities): + """Set up the Brink Home sensor platform.""" + client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + entities = [] + + _LOGGER.debug(f"Setting up Brink ventilation sensors") + + # Look for CO2 sensors and other specific sensors in the data + for device_index, device_data in enumerate(coordinator.data): + _LOGGER.debug(f"Device {device_index} available keys: {list(device_data.keys())}") + + # Check for sensors that might be stored as top-level keys + for key, value in device_data.items(): + if isinstance(value, dict) and "name" in value and "value" in value: + # Check if the key matches any of our sensor patterns + for sensor_type, properties in SENSOR_TYPES.items(): + if re.search(properties["pattern"], key): + _LOGGER.debug(f"Found {sensor_type} sensor: {key}") + entities.append( + BrinkSensor( + client, + coordinator, + device_index, + key, + value["name"], + properties["device_class"], + properties["state_class"], + properties["unit"], + properties["icon"] + ) + ) + break # Only add each sensor once + + if entities: + _LOGGER.info(f"Adding {len(entities)} sensor entities: {[e.entity_name for e in entities]}") + async_add_entities(entities) + else: + _LOGGER.warning("No sensors found in the data. Make sure your Brink system has sensors and they are enabled.") + + +class BrinkSensor(BrinkHomeDeviceEntity, SensorEntity): + """Representation of a Brink sensor.""" + + def __init__(self, client, coordinator, device_index, entity_name, display_name, + device_class, state_class, unit, icon): + """Initialize the Brink sensor.""" + super().__init__(client, coordinator, device_index, entity_name) + self._display_name = display_name + self._attr_device_class = device_class + self._attr_state_class = state_class + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + + @property + def id(self): + """Return the ID of the sensor.""" + return f"{DOMAIN}_{self.entity_name}_{self.device_index}_sensor" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return self.id + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.coordinator.data[self.device_index]['name']} {self._display_name}" + + @property + def native_value(self): + """Return the state of the sensor.""" + try: + # First try accessing the value directly as it might be a sensor key + data = self.coordinator.data[self.device_index][self.entity_name] + if isinstance(data, dict) and "value" in data: + value = data["value"] + + # For sensors with selectable list items (like bypass status), + # translate the value to the display text + if "values" in data and data["values"]: + for item in data["values"]: + if item.get("value") == value: + return item.get("text", value) + + # Otherwise attempt to convert to a number if possible + try: + if isinstance(value, str) and "." in value: + return float(value) + elif isinstance(value, str): + return int(value) + return value + except (ValueError, TypeError): + return value + else: + return data + except (KeyError, TypeError, ValueError) as e: + _LOGGER.debug(f"Error getting value for {self.entity_name}: {e}") + return None