├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ └── ci.yaml ├── hacs.json ├── custom_components └── comfoconnect │ ├── const.py │ ├── manifest.json │ ├── strings.json │ ├── translations │ └── en.json │ ├── button.py │ ├── binary_sensor.py │ ├── fan.py │ ├── config_flow.py │ ├── __init__.py │ ├── select.py │ └── sensor.py ├── Makefile ├── LICENSE ├── pyproject.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | **/__pycache__/ 3 | poetry.lock -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: michaelarnauts 2 | ko_fi: michaelarnauts 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Zehnder ComfoAirQ", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the ComfoConnect integration.""" 2 | 3 | DOMAIN = "comfoconnect" 4 | 5 | CONF_LOCAL_UUID = "local_uuid" 6 | CONF_UUID = "uuid" 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | check: 2 | @poetry run ruff check 3 | @poetry run ruff format --check 4 | 5 | codefix: 6 | @poetry run ruff check --fix 7 | @poetry run ruff format 8 | 9 | .PHONY: check codefix 10 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "comfoconnect", 3 | "name": "Zehnder ComfoAir Q", 4 | "config_flow": true, 5 | "documentation": "https://www.home-assistant.io/integrations/comfoconnect", 6 | "integration_type": "hub", 7 | "requirements": ["aiocomfoconnect==0.1.15"], 8 | "codeowners": ["@michaelarnauts"], 9 | "iot_class": "local_push", 10 | "loggers": ["aiocomfoconnect"], 11 | "version": "0.4.0" 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Michaël Arnauts 4 | https://github.com/michaelarnauts/aiocomfoconnect 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # Run action when pushed to master, or for commits in a pull request. 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | defaults: 13 | run: 14 | shell: bash 15 | 16 | jobs: 17 | checks: 18 | name: Code checks 19 | runs-on: ${{ matrix.os }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | os: [ ubuntu-latest ] 24 | python-version: [ "3.13" ] 25 | steps: 26 | - name: Check out ${{ github.sha }} from repository ${{ github.repository }} 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | 34 | - name: Install poetry 35 | run: | 36 | python -m pip install -U pip poetry 37 | poetry --version 38 | 39 | - name: Install dependencies 40 | run: | 41 | poetry check --no-interaction 42 | poetry config virtualenvs.in-project true 43 | poetry install --no-interaction 44 | 45 | - name: Run checks 46 | run: make check 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "home-assistant-comfoconnect" 3 | version = "0.4.0" 4 | description = "Home Assistant Zehnder ComfoAirQ integration" 5 | authors = ["Michaël Arnauts "] 6 | readme = "README.md" 7 | packages = [{include = "custom_components/comfoconnect"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = ">=3.12,<3.13" 11 | aiocomfoconnect = "0.1.15" 12 | 13 | [tool.poetry.group.dev.dependencies] 14 | homeassistant = "^2024.11.0b1" 15 | ruff = "^0.5.2" 16 | 17 | [build-system] 18 | requires = ["poetry-core"] 19 | build-backend = "poetry.core.masonry.api" 20 | 21 | [tool.ruff] 22 | extend-exclude = [ 23 | "__pycache__", 24 | "build", 25 | "dist", 26 | ] 27 | target-version = "py312" 28 | line-length = 150 29 | src = ["custom_components"] 30 | 31 | [tool.ruff.lint] 32 | extend-select = [ 33 | "C4", 34 | "D200", 35 | "D201", 36 | "D204", 37 | "D205", 38 | "D206", 39 | "D210", 40 | "D211", 41 | "D213", 42 | "D300", 43 | "D400", 44 | "D402", 45 | "D403", 46 | "D404", 47 | "D419", 48 | "E", 49 | "F", 50 | "G010", 51 | "I001", 52 | "INP001", 53 | "N805", 54 | "PERF101", 55 | "PERF102", 56 | "PERF401", 57 | "PERF402", 58 | "PGH004", 59 | "PGH005", 60 | "PIE794", 61 | "PIE796", 62 | "PIE807", 63 | "PIE810", 64 | "RET502", 65 | "RET503", 66 | "RET504", 67 | "RET505", 68 | "RUF015", 69 | "RUF100", 70 | "S101", 71 | "T20", 72 | "W", 73 | ] 74 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "select": { 4 | "setting": { 5 | "state": { 6 | "auto": "Auto", 7 | "manual": "Manual", 8 | "on": "[%key:common::state::on%]", 9 | "off": "[%key:common::state::off%]" 10 | } 11 | }, 12 | "balance": { 13 | "state": { 14 | "balance": "Balance", 15 | "supply_only": "Supply only", 16 | "exhaust_only": "Exhaust only" 17 | } 18 | }, 19 | "temperature_profile": { 20 | "state": { 21 | "warm": "Warm", 22 | "normal": "Normal", 23 | "cool": "Cool" 24 | } 25 | }, 26 | "comfocool": { 27 | "state": { 28 | "auto": "Auto", 29 | "off": "Off" 30 | } 31 | } 32 | } 33 | }, 34 | "config": { 35 | "error": { 36 | "invalid_pin": "Invalid PIN" 37 | }, 38 | "step": { 39 | "user": { 40 | "data": { 41 | "host": "[%key:common::config_flow::data::host%]" 42 | }, 43 | "title": "Add a ComfoConnect LAN C bridge", 44 | "description": "Select the ComfoConnect LAN C bridge from the list below." 45 | }, 46 | "manual": { 47 | "data": { 48 | "host": "[%key:common::config_flow::data::host%]" 49 | }, 50 | "title": "Add a ComfoConnect LAN C bridge manually", 51 | "description": "Enter a ComfoConnect LAN C bridge hostname or IP address below." 52 | }, 53 | "enter_pin": { 54 | "title": "Enter PIN", 55 | "description": "The selected ComfoConnect LAN C bridge isn't using the default PIN. Please enter the correct PIN." 56 | }, 57 | "confirm": { 58 | "description": "[%key:common::config_flow::description::confirm_setup%]" 59 | } 60 | }, 61 | "create_entry": { 62 | "default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration." 63 | }, 64 | "abort": { 65 | "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", 66 | "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Zehnder ComfoAirQ / ComfoCoolQ integration 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) 4 | 5 | This is a custom integration for Home Assistant to integrate with the Zehnder ComfoAirQ ventilation system. It's using the [aiocomfoconnect](https://github.com/michaelarnauts/aiocomfoconnect) library. 6 | 7 | This custom integration is an upgrade over the existing `comfoconnect` integration and is meant for testing purposes. The goal is eventually to replace the existing `comfoconnect` 8 | integration in Home Assistant. 9 | 10 | ## Features 11 | 12 | * Control ventilation speed 13 | * Control ventilation mode (auto / manual) 14 | * Control ComfoCool mode (auto / off) 15 | * Show various sensors 16 | 17 | This integration supports the following additional features over the existing integration: 18 | 19 | * Configurable through the UI 20 | * Support for multiple bridges 21 | * Allows to modify the balance mode, bypass mode, temperature profile and ventilation mode 22 | * Changes to fan speed won't be reverted after 2 hours 23 | * Support to clear alarms 24 | * Ignores invalid sensor values at the beginning of a session (Workaround for bridge firmware bug) 25 | * Throttles high frequency sensor updates (airflow & fan duty) to once every 10 seconds 26 | 27 | **Note: Not all sensors are enabled by default. You can enable them on the integration page.** 28 | 29 | ## Installation 30 | 31 | ### HACS 32 | 33 | The easiest way to install this integration is through [HACS](https://hacs.xyz/). 34 | 35 | 1. Add this repository (`https://github.com/michaelarnauts/home-assistant-comfoconnect`) as a custom repository in HACS. 36 | See [here](https://hacs.xyz/docs/faq/custom_repositories) for more information. 37 | 2. Install the `Zehnder ComfoAirQ` integration. 38 | 3. Restart Home Assistant. 39 | 40 | If you have the existing `comfoconnect` integration installed, the configuration should be picked up, but you might need to change your existing sensors ids. 41 | You should also remove the old configuration from the `configuration.yaml` file. 42 | 43 | If not, you can add the integration through the UI by going to the integrations page and adding the `Zehnder ComfoAirQ` integration. 44 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "no_devices_found": "No devices found on the network", 5 | "reauth_successful": "Re-authentication was successful" 6 | }, 7 | "create_entry": { 8 | "default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration." 9 | }, 10 | "error": { 11 | "invalid_pin": "Invalid PIN" 12 | }, 13 | "step": { 14 | "confirm": { 15 | "description": "Do you want to start set up?" 16 | }, 17 | "enter_pin": { 18 | "description": "The selected ComfoConnect LAN C bridge isn't using the default PIN. Please enter the correct PIN.", 19 | "title": "Enter PIN" 20 | }, 21 | "manual": { 22 | "data": { 23 | "host": "Host" 24 | }, 25 | "description": "Enter a ComfoConnect LAN C bridge hostname or IP address below.", 26 | "title": "Add a ComfoConnect LAN C bridge manually" 27 | }, 28 | "user": { 29 | "data": { 30 | "host": "Host" 31 | }, 32 | "description": "Select the ComfoConnect LAN C bridge from the list below.", 33 | "title": "Add a ComfoConnect LAN C bridge" 34 | } 35 | } 36 | }, 37 | "entity": { 38 | "select": { 39 | "balance": { 40 | "state": { 41 | "balance": "Balance", 42 | "exhaust_only": "Exhaust only", 43 | "supply_only": "Supply only" 44 | } 45 | }, 46 | "setting": { 47 | "state": { 48 | "auto": "Auto", 49 | "manual": "Manual", 50 | "off": "Off", 51 | "on": "On" 52 | } 53 | }, 54 | "temperature_profile": { 55 | "state": { 56 | "cool": "Cool", 57 | "normal": "Normal", 58 | "warm": "Warm" 59 | } 60 | }, 61 | "comfocool": { 62 | "state": { 63 | "auto": "Auto", 64 | "off": "Off" 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/button.py: -------------------------------------------------------------------------------- 1 | """Button for the ComfoConnect integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Awaitable, Coroutine 7 | from dataclasses import dataclass 8 | from typing import Any, Callable, cast 9 | 10 | from homeassistant.components.button import ButtonEntity, ButtonEntityDescription 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | 16 | from . import DOMAIN, ComfoConnectBridge 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | @dataclass 22 | class ComfoconnectRequiredKeysMixin: 23 | """Mixin for required keys.""" 24 | 25 | press_fn: Callable[[ComfoConnectBridge, str], Awaitable[Any]] 26 | 27 | 28 | @dataclass 29 | class ComfoconnectButtonEntityDescription(ButtonEntityDescription, ComfoconnectRequiredKeysMixin): 30 | """Describes ComfoConnect button entity.""" 31 | 32 | 33 | BUTTON_TYPES = ( 34 | ComfoconnectButtonEntityDescription( 35 | key="reset_errors", 36 | press_fn=lambda ccb, option: cast(Coroutine, ccb.clear_errors()), 37 | name="Reset errors", 38 | entity_category=EntityCategory.DIAGNOSTIC, 39 | ), 40 | ) 41 | 42 | 43 | async def async_setup_entry( 44 | hass: HomeAssistant, 45 | config_entry: ConfigEntry, 46 | async_add_entities: AddEntitiesCallback, 47 | ) -> None: 48 | """Set up the ComfoConnect binary sensors.""" 49 | ccb = hass.data[DOMAIN][config_entry.entry_id] 50 | 51 | sensors = [ComfoConnectButton(ccb=ccb, config_entry=config_entry, description=description) for description in BUTTON_TYPES] 52 | 53 | async_add_entities(sensors, True) 54 | 55 | 56 | class ComfoConnectButton(ButtonEntity): 57 | """Representation of a ComfoConnect button.""" 58 | 59 | _attr_has_entity_name = True 60 | entity_description: ComfoconnectButtonEntityDescription 61 | 62 | def __init__( 63 | self, 64 | ccb: ComfoConnectBridge, 65 | config_entry: ConfigEntry, 66 | description: ComfoconnectButtonEntityDescription, 67 | ) -> None: 68 | """Initialize the ComfoConnect sensor.""" 69 | self._ccb = ccb 70 | self.entity_description = description 71 | self._attr_name = f"{description.name}" 72 | self._attr_unique_id = f"{self._ccb.uuid}-{description.key}" 73 | self._attr_device_info = DeviceInfo( 74 | identifiers={(DOMAIN, self._ccb.uuid)}, 75 | ) 76 | 77 | async def async_press(self) -> None: 78 | """Press the button.""" 79 | await self.entity_description.press_fn(self._ccb, self._attr_unique_id) 80 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary Sensor for the ComfoConnect integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from dataclasses import dataclass 7 | 8 | from aiocomfoconnect.sensors import ( 9 | SENSOR_COMFOCOOL_STATE, 10 | SENSOR_COMFOFOND_GHE_PRESENT, 11 | SENSOR_SEASON_COOLING_ACTIVE, 12 | SENSOR_SEASON_HEATING_ACTIVE, 13 | SENSORS, 14 | ) 15 | from aiocomfoconnect.sensors import ( 16 | Sensor as AioComfoConnectSensor, 17 | ) 18 | from homeassistant.components.binary_sensor import ( 19 | BinarySensorEntity, 20 | BinarySensorEntityDescription, 21 | ) 22 | from homeassistant.config_entries import ConfigEntry 23 | from homeassistant.core import HomeAssistant 24 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 25 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 26 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 27 | 28 | from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | @dataclass 34 | class ComfoconnectRequiredKeysMixin: 35 | """Mixin for required keys.""" 36 | 37 | ccb_sensor: AioComfoConnectSensor 38 | 39 | 40 | @dataclass 41 | class ComfoconnectBinarySensorEntityDescription(BinarySensorEntityDescription, ComfoconnectRequiredKeysMixin): 42 | """Describes ComfoConnect binary sensor entity.""" 43 | 44 | 45 | SENSOR_TYPES = ( 46 | ComfoconnectBinarySensorEntityDescription( 47 | key=SENSOR_SEASON_HEATING_ACTIVE, 48 | name="Heating Season Active", 49 | ccb_sensor=SENSORS.get(SENSOR_SEASON_HEATING_ACTIVE), 50 | entity_registry_enabled_default=False, 51 | entity_category=EntityCategory.DIAGNOSTIC, 52 | ), 53 | ComfoconnectBinarySensorEntityDescription( 54 | key=SENSOR_SEASON_COOLING_ACTIVE, 55 | name="Cooling Season Active", 56 | ccb_sensor=SENSORS.get(SENSOR_SEASON_COOLING_ACTIVE), 57 | entity_registry_enabled_default=False, 58 | entity_category=EntityCategory.DIAGNOSTIC, 59 | ), 60 | ComfoconnectBinarySensorEntityDescription( 61 | key=SENSOR_COMFOFOND_GHE_PRESENT, 62 | name="ComfoFond GHE present", 63 | ccb_sensor=SENSORS.get(SENSOR_COMFOFOND_GHE_PRESENT), 64 | entity_registry_enabled_default=False, 65 | entity_category=EntityCategory.DIAGNOSTIC, 66 | ), 67 | ComfoconnectBinarySensorEntityDescription( 68 | key=SENSOR_COMFOCOOL_STATE, 69 | name="ComfoCool state", 70 | ccb_sensor=SENSORS.get(SENSOR_COMFOCOOL_STATE), 71 | entity_registry_enabled_default=False, 72 | entity_category=EntityCategory.DIAGNOSTIC, 73 | ), 74 | ) 75 | 76 | 77 | async def async_setup_entry( 78 | hass: HomeAssistant, 79 | config_entry: ConfigEntry, 80 | async_add_entities: AddEntitiesCallback, 81 | ) -> None: 82 | """Set up the ComfoConnect binary sensors.""" 83 | ccb = hass.data[DOMAIN][config_entry.entry_id] 84 | 85 | sensors = [ComfoConnectBinarySensor(ccb=ccb, config_entry=config_entry, description=description) for description in SENSOR_TYPES] 86 | 87 | async_add_entities(sensors, True) 88 | 89 | 90 | class ComfoConnectBinarySensor(BinarySensorEntity): 91 | """Representation of a ComfoConnect sensor.""" 92 | 93 | _attr_should_poll = False 94 | _attr_has_entity_name = True 95 | entity_description: ComfoconnectBinarySensorEntityDescription 96 | 97 | def __init__( 98 | self, 99 | ccb: ComfoConnectBridge, 100 | config_entry: ConfigEntry, 101 | description: ComfoconnectBinarySensorEntityDescription, 102 | ) -> None: 103 | """Initialize the ComfoConnect sensor.""" 104 | self._ccb = ccb 105 | self.entity_description = description 106 | self._attr_name = f"{description.name}" 107 | self._attr_unique_id = f"{self._ccb.uuid}-{description.key}" 108 | self._attr_device_info = DeviceInfo( 109 | identifiers={(DOMAIN, self._ccb.uuid)}, 110 | ) 111 | 112 | async def async_added_to_hass(self) -> None: 113 | """Register for sensor updates.""" 114 | _LOGGER.debug( 115 | "Registering for sensor %s (%d)", 116 | self.entity_description.name, 117 | self.entity_description.key, 118 | ) 119 | self.async_on_remove( 120 | async_dispatcher_connect( 121 | self.hass, 122 | SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._ccb.uuid, self.entity_description.key), 123 | self._handle_update, 124 | ) 125 | ) 126 | await self._ccb.register_sensor(self.entity_description.ccb_sensor) 127 | 128 | def _handle_update(self, value): 129 | """Handle update callbacks.""" 130 | _LOGGER.debug( 131 | "Handle update for sensor %s (%d): %s", 132 | self.entity_description.name, 133 | self.entity_description.key, 134 | value, 135 | ) 136 | 137 | self._attr_is_on = True if value else False 138 | self.schedule_update_ha_state() 139 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/fan.py: -------------------------------------------------------------------------------- 1 | """Fan for the ComfoConnect integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from aiocomfoconnect.const import VentilationMode, VentilationSpeed 9 | from aiocomfoconnect.sensors import ( 10 | SENSOR_FAN_SPEED_MODE, 11 | SENSOR_OPERATING_MODE, 12 | SENSORS, 13 | ) 14 | from homeassistant.components.fan import FanEntity, FanEntityFeature 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 18 | from homeassistant.helpers.entity import DeviceInfo 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | from homeassistant.util.percentage import ( 21 | ordered_list_item_to_percentage, 22 | percentage_to_ordered_list_item, 23 | ) 24 | 25 | from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | FAN_SPEEDS = [VentilationSpeed.LOW, VentilationSpeed.MEDIUM, VentilationSpeed.HIGH] 30 | PRESET_MODES = [VentilationMode.AUTO, VentilationMode.MANUAL] 31 | 32 | FAN_SPEED_MAPPING = { 33 | 0: VentilationSpeed.AWAY, 34 | 1: VentilationSpeed.LOW, 35 | 2: VentilationSpeed.MEDIUM, 36 | 3: VentilationSpeed.HIGH, 37 | } 38 | 39 | 40 | async def async_setup_entry( 41 | hass: HomeAssistant, 42 | config_entry: ConfigEntry, 43 | async_add_entities: AddEntitiesCallback, 44 | ) -> None: 45 | """Set up the ComfoConnect fan.""" 46 | ccb = hass.data[DOMAIN][config_entry.entry_id] 47 | 48 | async_add_entities([ComfoConnectFan(ccb=ccb, config_entry=config_entry)], True) 49 | 50 | 51 | class ComfoConnectFan(FanEntity): 52 | """Representation of the ComfoConnect fan platform.""" 53 | 54 | _attr_enable_turn_on_off_backwards_compatibility = False 55 | _attr_icon = "mdi:air-conditioner" 56 | _attr_should_poll = False 57 | _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF 58 | _attr_preset_modes = list(PRESET_MODES) 59 | _attr_speed_count = len(FAN_SPEEDS) 60 | _attr_has_entity_name = True 61 | _attr_name = None 62 | 63 | def __init__(self, ccb: ComfoConnectBridge, config_entry: ConfigEntry) -> None: 64 | """Initialize the ComfoConnect fan.""" 65 | self._ccb = ccb 66 | self._attr_unique_id = self._ccb.uuid 67 | self._attr_preset_mode = None 68 | self._attr_device_info = DeviceInfo( 69 | identifiers={(DOMAIN, self._ccb.uuid)}, 70 | ) 71 | 72 | async def async_added_to_hass(self) -> None: 73 | """Register for sensor updates.""" 74 | _LOGGER.debug("Registering for fan speed") 75 | self.async_on_remove( 76 | async_dispatcher_connect( 77 | self.hass, 78 | SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._ccb.uuid, SENSOR_FAN_SPEED_MODE), 79 | self._handle_speed_update, 80 | ) 81 | ) 82 | await self._ccb.register_sensor(SENSORS.get(SENSOR_FAN_SPEED_MODE)) 83 | 84 | _LOGGER.debug("Registering for operating mode") 85 | self.async_on_remove( 86 | async_dispatcher_connect( 87 | self.hass, 88 | SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._ccb.uuid, SENSOR_OPERATING_MODE), 89 | self._handle_mode_update, 90 | ) 91 | ) 92 | await self._ccb.register_sensor(SENSORS.get(SENSOR_OPERATING_MODE)) 93 | 94 | def _handle_speed_update(self, value: int) -> None: 95 | """Handle update callbacks.""" 96 | _LOGGER.debug("Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value) 97 | if value == 0: 98 | self._attr_percentage = 0 99 | else: 100 | self._attr_percentage = ordered_list_item_to_percentage(FAN_SPEEDS, FAN_SPEED_MAPPING[value]) 101 | 102 | self.schedule_update_ha_state() 103 | 104 | def _handle_mode_update(self, value: int) -> None: 105 | """Handle update callbacks.""" 106 | _LOGGER.debug( 107 | "Handle update for operating mode (%d): %s", 108 | SENSOR_OPERATING_MODE, 109 | value, 110 | ) 111 | self._attr_preset_mode = VentilationMode.AUTO if value == -1 else VentilationMode.MANUAL 112 | self.schedule_update_ha_state() 113 | 114 | @property 115 | def is_on(self) -> bool | None: 116 | """Return true if the entity is on.""" 117 | return self.percentage > 0 118 | 119 | async def async_turn_on( 120 | self, 121 | percentage: int | None = None, 122 | preset_mode: str | None = None, 123 | **kwargs: Any, 124 | ) -> None: 125 | """Turn on the fan.""" 126 | if preset_mode: 127 | await self.async_set_preset_mode(preset_mode) 128 | return 129 | 130 | if percentage is None: 131 | await self.async_set_percentage(1) # Set fan speed to low 132 | else: 133 | await self.async_set_percentage(percentage) 134 | 135 | async def async_turn_off(self, **kwargs: Any) -> None: 136 | """Turn off the fan (to away).""" 137 | await self.async_set_percentage(0) 138 | 139 | async def async_set_percentage(self, percentage: int) -> None: 140 | """Set fan speed percentage.""" 141 | _LOGGER.debug("Changing fan speed percentage to %s", percentage) 142 | 143 | if percentage == 0: 144 | speed = VentilationSpeed.AWAY 145 | else: 146 | speed = percentage_to_ordered_list_item(FAN_SPEEDS, percentage) 147 | 148 | await self._ccb.set_speed(speed) 149 | 150 | async def async_set_preset_mode(self, preset_mode: str) -> None: 151 | """Set new preset mode.""" 152 | if preset_mode not in self.preset_modes: 153 | raise ValueError(f"Invalid preset mode: {preset_mode}") 154 | 155 | _LOGGER.debug("Changing preset mode to %s", preset_mode) 156 | await self._ccb.set_mode(preset_mode) 157 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for the ComfoConnect integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import aiocomfoconnect 9 | import voluptuous as vol 10 | from aiocomfoconnect import Bridge 11 | from aiocomfoconnect.exceptions import ComfoConnectNotAllowed 12 | from homeassistant import config_entries 13 | from homeassistant.const import CONF_HOST, CONF_PIN 14 | from homeassistant.data_entry_flow import FlowResult 15 | from homeassistant.helpers.typing import ConfigType 16 | from homeassistant.util.uuid import random_uuid_hex 17 | 18 | from .const import CONF_LOCAL_UUID, CONF_UUID, DOMAIN 19 | 20 | DEFAULT_PIN = "0000" 21 | COMFOCONNECT_MANUAL_BRIDGE_ID = "manual" 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | class ComfoConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 26 | """Handle a ComfoConnect config flow.""" 27 | 28 | VERSION = 1 29 | 30 | def __init__(self) -> None: 31 | """Initialize the Hue flow.""" 32 | self.bridge: Bridge | None = None 33 | self.local_uuid: str | None = None 34 | self.discovered_bridges: dict[str, Bridge] | None = None 35 | 36 | async def async_step_import(self, import_config: ConfigType | None) -> FlowResult: 37 | """Import a config entry from configuration.yaml.""" 38 | self.local_uuid = import_config.get("token") 39 | return await self.async_step_manual({CONF_HOST: import_config[CONF_HOST]}) 40 | 41 | async def async_step_reauth(self, user_input: dict[str, Any] | None = None) -> FlowResult: 42 | """Handle a flow reauth.""" 43 | self.bridge = Bridge(user_input[CONF_HOST], user_input[CONF_UUID]) 44 | self.local_uuid = user_input[CONF_LOCAL_UUID] 45 | 46 | return await self._register() 47 | 48 | async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: 49 | """Handle a flow initiated by the user.""" 50 | 51 | if user_input is not None: 52 | # User has chosen to manually enter a bridge 53 | if user_input[CONF_UUID] == COMFOCONNECT_MANUAL_BRIDGE_ID: 54 | return await self.async_step_manual() 55 | 56 | # User has selected a discovered bridge 57 | if user_input[CONF_UUID] is not None: 58 | self.bridge = self.discovered_bridges[user_input[CONF_UUID]] 59 | 60 | # Don't allow to configure the same bridge twice 61 | await self.async_set_unique_id(self.bridge.uuid, raise_on_progress=False) 62 | self._abort_if_unique_id_configured() 63 | 64 | return await self._register() 65 | 66 | # Find bridges on the network and filter out the ones we already have configured 67 | bridges = await aiocomfoconnect.discover_bridges() 68 | self.discovered_bridges = {bridge.uuid: bridge for bridge in bridges if bridge.uuid not in self._async_current_ids(False)} 69 | 70 | # Show the bridge selection form 71 | return self.async_show_form( 72 | step_id="user", 73 | data_schema=vol.Schema( 74 | { 75 | vol.Required(CONF_UUID): vol.In( 76 | { 77 | **{bridge.uuid: bridge.host for bridge in self.discovered_bridges.values()}, 78 | COMFOCONNECT_MANUAL_BRIDGE_ID: "Manually add a ComfoConnect LAN C Bridge", 79 | } 80 | ) 81 | } 82 | ), 83 | ) 84 | 85 | async def async_step_manual(self, user_input: ConfigType | None = None) -> FlowResult: 86 | """Handle manual bridge setup.""" 87 | errors = {} 88 | if user_input is not None and user_input[CONF_HOST] is not None: 89 | # We need to discover the bridge to get its UUID 90 | bridges = await aiocomfoconnect.discover_bridges(user_input[CONF_HOST]) 91 | if len(bridges) == 0: 92 | # Could not discover the bridge 93 | errors = {"base": "invalid_host"} 94 | else: 95 | self.bridge = bridges[0] 96 | # Don't allow to configure the same bridge twice 97 | await self.async_set_unique_id(self.bridge.uuid, raise_on_progress=False) 98 | self._abort_if_unique_id_configured() 99 | 100 | return await self._register() 101 | 102 | return self.async_show_form( 103 | step_id="manual", 104 | errors=errors, 105 | data_schema=vol.Schema({vol.Required(CONF_HOST): str}), 106 | ) 107 | 108 | async def _register(self, pin: int = None) -> FlowResult: 109 | """Register on the bridge.""" 110 | 111 | if self.local_uuid is None: 112 | # Generate our own UUID if non is provided 113 | self.local_uuid = random_uuid_hex() 114 | 115 | # Connect to the bridge 116 | await self.bridge._connect(self.local_uuid) 117 | try: 118 | await self.bridge.cmd_start_session(True) 119 | 120 | except ComfoConnectNotAllowed: 121 | try: 122 | # We probably are not registered yet, lets try to register. 123 | await self.bridge.cmd_register_app( 124 | self.local_uuid, 125 | "Home Assistant (%s)" % self.hass.config.location_name, 126 | pin or DEFAULT_PIN, 127 | ) 128 | 129 | except ComfoConnectNotAllowed: 130 | # We have tried connecting, but we have an invalid PIN. Ask the user for a new PIN. 131 | errors = {"base": "invalid_pin"} if pin is not None else {} 132 | return await self.async_step_enter_pin({}, errors) 133 | 134 | # Registration went fine, connect to the bridge again 135 | await self.bridge.cmd_start_session(True) 136 | 137 | finally: 138 | # Disconnect 139 | await self.bridge._disconnect() 140 | 141 | if self.context.get("source") == config_entries.SOURCE_REAUTH: 142 | self.hass.async_create_task(self.hass.config_entries.async_reload(self.context["entry_id"])) 143 | return self.async_abort(reason="reauth_successful") 144 | 145 | return self.async_create_entry( 146 | title=self.bridge.host, 147 | data={ 148 | CONF_HOST: self.bridge.host, 149 | CONF_UUID: self.bridge.uuid, 150 | CONF_LOCAL_UUID: self.local_uuid, 151 | }, 152 | ) 153 | 154 | async def async_step_enter_pin( 155 | self, 156 | user_input: dict[str, Any] | None = None, 157 | errors: dict[str, str] | None = None, 158 | ) -> FlowResult: 159 | """Handle the PIN entry step.""" 160 | if user_input and CONF_PIN in user_input: 161 | return await self._register(user_input[CONF_PIN]) 162 | 163 | return self.async_show_form( 164 | step_id="enter_pin", 165 | errors=errors or {}, 166 | data_schema=vol.Schema( 167 | { 168 | vol.Required(CONF_PIN): vol.All( 169 | vol.Coerce(int), 170 | vol.Range(min=0, max=9999, msg="A PIN must be between 0000 and 9999"), 171 | ) 172 | } 173 | ), 174 | ) 175 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/__init__.py: -------------------------------------------------------------------------------- 1 | """Support to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from datetime import timedelta 7 | 8 | from aiocomfoconnect import ComfoConnect, discover_bridges 9 | from aiocomfoconnect.exceptions import ( 10 | AioComfoConnectNotConnected, 11 | AioComfoConnectTimeout, 12 | ComfoConnectError, 13 | ComfoConnectNotAllowed, 14 | ) 15 | from aiocomfoconnect.properties import ( 16 | PROPERTY_FIRMWARE_VERSION, 17 | PROPERTY_MODEL, 18 | PROPERTY_NAME, 19 | ) 20 | from aiocomfoconnect.sensors import Sensor 21 | from aiocomfoconnect.util import version_decode 22 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 23 | from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform 24 | from homeassistant.core import HomeAssistant, callback 25 | from homeassistant.exceptions import ( 26 | ConfigEntryAuthFailed, 27 | ConfigEntryError, 28 | ConfigEntryNotReady, 29 | ) 30 | from homeassistant.helpers import device_registry as dr 31 | from homeassistant.helpers.dispatcher import dispatcher_send 32 | from homeassistant.helpers.event import async_track_time_interval 33 | from homeassistant.helpers.typing import ConfigType 34 | 35 | from .const import CONF_LOCAL_UUID, CONF_UUID, DOMAIN 36 | 37 | PLATFORMS: list[Platform] = [ 38 | Platform.FAN, 39 | Platform.SENSOR, 40 | Platform.BINARY_SENSOR, 41 | Platform.SELECT, 42 | Platform.BUTTON, 43 | ] 44 | 45 | _LOGGER = logging.getLogger(__name__) 46 | 47 | SIGNAL_COMFOCONNECT_UPDATE_RECEIVED = "comfoconnect_update_{}_{}" 48 | 49 | KEEP_ALIVE_INTERVAL = timedelta(seconds=30) 50 | 51 | 52 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 53 | """Set up Zehnder ComfoConnect integration from yaml.""" 54 | if DOMAIN in config: 55 | hass.async_create_task( 56 | hass.config_entries.flow.async_init( 57 | DOMAIN, 58 | context={"source": SOURCE_IMPORT}, 59 | data=config[DOMAIN], 60 | ) 61 | ) 62 | return True 63 | 64 | 65 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 66 | """Set up Zehnder ComfoConnect from a config entry.""" 67 | 68 | hass.data.setdefault(DOMAIN, {}) 69 | 70 | try: 71 | bridge = ComfoConnectBridge(hass, entry.data[CONF_HOST], entry.data[CONF_UUID]) 72 | await bridge.connect(entry.data[CONF_LOCAL_UUID]) 73 | 74 | except ComfoConnectNotAllowed: 75 | raise ConfigEntryAuthFailed("Access denied") 76 | 77 | except ComfoConnectError as err: 78 | raise ConfigEntryError from err 79 | 80 | except AioComfoConnectTimeout as err: 81 | # We got a timeout, this can happen when the IP address of the bridge has changed. 82 | _LOGGER.warning( 83 | 'Timeout connecting to bridge "%s", trying discovery again.', 84 | entry.data[CONF_HOST], 85 | ) 86 | 87 | bridges = await discover_bridges() 88 | discovered_bridge = next((b for b in bridges if b.uuid == entry.data[CONF_UUID]), None) 89 | if not discovered_bridge: 90 | _LOGGER.warning('Unable to discover bridge "%s". Retrying later.', entry.data[CONF_UUID]) 91 | raise ConfigEntryNotReady from err 92 | 93 | # Try again, with the updated host this time 94 | bridge = ComfoConnectBridge(hass, discovered_bridge.host, entry.data[CONF_UUID]) 95 | try: 96 | await bridge.connect(entry.data[CONF_LOCAL_UUID]) 97 | 98 | # Update the host in the config entry 99 | hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_HOST: discovered_bridge.host}) 100 | 101 | except ComfoConnectNotAllowed: 102 | raise ConfigEntryAuthFailed("Access denied") 103 | 104 | except ComfoConnectError as err: 105 | raise ConfigEntryNotReady from err 106 | 107 | hass.data[DOMAIN][entry.entry_id] = bridge 108 | 109 | # Get device information 110 | bridge_info = await bridge.cmd_version_request() 111 | unit_model = await bridge.get_property(PROPERTY_MODEL) 112 | unit_firmware = await bridge.get_property(PROPERTY_FIRMWARE_VERSION) 113 | unit_name = await bridge.get_property(PROPERTY_NAME) 114 | 115 | device_registry = dr.async_get(hass) 116 | 117 | # Add Bridge to device registry 118 | device_registry.async_get_or_create( 119 | config_entry_id=entry.entry_id, 120 | identifiers={(DOMAIN, bridge_info.serialNumber)}, 121 | manufacturer="Zehnder", 122 | name=bridge_info.serialNumber, 123 | model="ComfoConnect LAN C", 124 | sw_version=version_decode(bridge_info.gatewayVersion), 125 | ) 126 | 127 | # Add Ventilation Unit to device registry 128 | device_registry.async_get_or_create( 129 | config_entry_id=entry.entry_id, 130 | identifiers={(DOMAIN, bridge.uuid)}, 131 | manufacturer="Zehnder", 132 | name=unit_name, 133 | model=unit_model, 134 | sw_version=version_decode(unit_firmware), 135 | via_device=(DOMAIN, bridge_info.serialNumber), 136 | ) 137 | 138 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 139 | 140 | @callback 141 | async def send_keepalive(now) -> None: 142 | """Send keepalive to the bridge.""" 143 | _LOGGER.debug("Sending keepalive...") 144 | try: 145 | # Use cmd_time_request as a keepalive since cmd_keepalive doesn't send back a reply we can wait for 146 | await bridge.cmd_time_request() 147 | 148 | # TODO: Mark sensors as available 149 | 150 | except (AioComfoConnectNotConnected, AioComfoConnectTimeout): 151 | # Reconnect when connection has been dropped 152 | try: 153 | await bridge.connect(entry.data[CONF_LOCAL_UUID]) 154 | except AioComfoConnectTimeout: 155 | _LOGGER.debug("Connection timed out. Retrying later...") 156 | 157 | # TODO: Mark all sensors as unavailable 158 | 159 | entry.async_on_unload(async_track_time_interval(hass, send_keepalive, KEEP_ALIVE_INTERVAL)) 160 | 161 | # Disconnect when shutting down 162 | async def disconnect_bridge(event): 163 | """Close connection to the bridge.""" 164 | await bridge.disconnect() 165 | 166 | entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect_bridge)) 167 | 168 | return True 169 | 170 | 171 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 172 | """Unload a config entry.""" 173 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 174 | bridge = hass.data[DOMAIN][entry.entry_id] 175 | await bridge.disconnect() 176 | hass.data[DOMAIN].pop(entry.entry_id) 177 | 178 | return unload_ok 179 | 180 | 181 | class ComfoConnectBridge(ComfoConnect): 182 | """Representation of a ComfoConnect bridge.""" 183 | 184 | def __init__(self, hass: HomeAssistant, host: str, uuid: str): 185 | """Initialize the ComfoConnect bridge.""" 186 | super().__init__( 187 | host, 188 | uuid, 189 | hass.loop, 190 | self.sensor_callback, 191 | self.alarm_callback, 192 | ) 193 | self.hass = hass 194 | 195 | @callback 196 | def sensor_callback(self, sensor: Sensor, value): 197 | """Notify listeners that we have received an update.""" 198 | dispatcher_send( 199 | self.hass, 200 | SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self.uuid, sensor.id), 201 | value, 202 | ) 203 | 204 | @callback 205 | def alarm_callback(self, node_id, errors): 206 | """Print alarm updates.""" 207 | message = f"Alarm received for Node {node_id}:\n" 208 | for error_id, error in errors.items(): 209 | message += f"* {error_id}: {error}\n" 210 | _LOGGER.warning(message) 211 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/select.py: -------------------------------------------------------------------------------- 1 | """Select for the ComfoConnect integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Awaitable, Coroutine 7 | from dataclasses import dataclass 8 | from typing import Any, Callable, cast 9 | 10 | from aiocomfoconnect.const import ( 11 | ComfoCoolMode, 12 | VentilationBalance, 13 | VentilationMode, 14 | VentilationSetting, 15 | VentilationTemperatureProfile, 16 | ) 17 | from aiocomfoconnect.sensors import ( 18 | SENSOR_BYPASS_ACTIVATION_STATE, 19 | SENSOR_COMFOCOOL_STATE, 20 | SENSOR_OPERATING_MODE, 21 | SENSOR_PROFILE_TEMPERATURE, 22 | SENSORS, 23 | ) 24 | from aiocomfoconnect.sensors import ( 25 | Sensor as AioComfoConnectSensor, 26 | ) 27 | from homeassistant.components.select import SelectEntity, SelectEntityDescription 28 | from homeassistant.config_entries import ConfigEntry 29 | from homeassistant.core import HomeAssistant 30 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 31 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 32 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 33 | 34 | from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | @dataclass 40 | class ComfoconnectSelectDescriptionMixin: 41 | """Mixin for required keys.""" 42 | 43 | set_value_fn: Callable[[ComfoConnectBridge, str], Awaitable[Any]] 44 | get_value_fn: Callable[[ComfoConnectBridge], Awaitable[Any]] 45 | 46 | 47 | @dataclass 48 | class ComfoconnectSelectEntityDescription(SelectEntityDescription, ComfoconnectSelectDescriptionMixin): 49 | """Describes ComfoConnect select entity.""" 50 | 51 | sensor: AioComfoConnectSensor = None 52 | sensor_value_fn: Callable[[str], Any] = None 53 | 54 | 55 | SELECT_TYPES = ( 56 | ComfoconnectSelectEntityDescription( 57 | key="select_mode", 58 | name="Ventilation Mode", 59 | icon="mdi:fan-auto", 60 | entity_category=EntityCategory.CONFIG, 61 | get_value_fn=lambda ccb: cast(Coroutine, ccb.get_mode()), 62 | set_value_fn=lambda ccb, option: cast(Coroutine, ccb.set_mode(option)), 63 | options=[VentilationMode.AUTO, VentilationMode.MANUAL], 64 | # translation_key="setting", 65 | sensor=SENSORS.get(SENSOR_OPERATING_MODE), 66 | sensor_value_fn=lambda value: { 67 | -1: VentilationMode.AUTO, 68 | 1: VentilationMode.MANUAL, 69 | }.get(value), 70 | ), 71 | ComfoconnectSelectEntityDescription( 72 | key="bypass_mode", 73 | name="Bypass Mode", 74 | icon="mdi:camera-iris", 75 | entity_category=EntityCategory.CONFIG, 76 | get_value_fn=lambda ccb: cast(Coroutine, ccb.get_bypass()), 77 | set_value_fn=lambda ccb, option: cast(Coroutine, ccb.set_bypass(option)), 78 | options=[ 79 | VentilationSetting.AUTO, 80 | VentilationSetting.ON, 81 | VentilationSetting.OFF, 82 | ], 83 | # translation_key="setting", 84 | sensor=SENSORS.get(SENSOR_BYPASS_ACTIVATION_STATE), 85 | sensor_value_fn=lambda value: { 86 | 0: VentilationSetting.AUTO, 87 | 1: VentilationSetting.ON, 88 | 2: VentilationSetting.OFF, 89 | }.get(value), 90 | ), 91 | ComfoconnectSelectEntityDescription( 92 | key="balance_mode", 93 | name="Balance Mode", 94 | entity_category=EntityCategory.CONFIG, 95 | get_value_fn=lambda ccb: cast(Coroutine, ccb.get_balance_mode()), 96 | set_value_fn=lambda ccb, option: cast(Coroutine, ccb.set_balance_mode(option)), 97 | options=[ 98 | VentilationBalance.BALANCE, 99 | VentilationBalance.SUPPLY_ONLY, 100 | VentilationBalance.EXHAUST_ONLY, 101 | ], 102 | # translation_key="balance", 103 | ), 104 | ComfoconnectSelectEntityDescription( 105 | key="temperature_profile", 106 | name="Temperature Profile", 107 | icon="mdi:thermometer-auto", 108 | entity_category=EntityCategory.CONFIG, 109 | get_value_fn=lambda ccb: cast(Coroutine, ccb.get_temperature_profile()), 110 | set_value_fn=lambda ccb, option: cast(Coroutine, ccb.set_temperature_profile(option)), 111 | options=[ 112 | VentilationTemperatureProfile.WARM, 113 | VentilationTemperatureProfile.NORMAL, 114 | VentilationTemperatureProfile.COOL, 115 | ], 116 | # translation_key="temperature_profile", 117 | sensor=SENSORS.get(SENSOR_PROFILE_TEMPERATURE), 118 | sensor_value_fn=lambda value: { 119 | 0: VentilationTemperatureProfile.NORMAL, 120 | 1: VentilationTemperatureProfile.COOL, 121 | 2: VentilationTemperatureProfile.WARM, 122 | }.get(value), 123 | ), 124 | ComfoconnectSelectEntityDescription( 125 | key="comfocool", 126 | name="ComfoCool Mode", 127 | entity_category=EntityCategory.CONFIG, 128 | get_value_fn=lambda ccb: cast(Coroutine, ccb.get_comfocool_mode()), 129 | set_value_fn=lambda ccb, option: cast(Coroutine, ccb.set_comfocool_mode(option)), 130 | options=[ 131 | ComfoCoolMode.AUTO, 132 | ComfoCoolMode.OFF, 133 | ], 134 | # translation_key="comfocool", 135 | sensor=SENSORS.get(SENSOR_COMFOCOOL_STATE), 136 | sensor_value_fn=lambda value: { 137 | 0: ComfoCoolMode.OFF, 138 | 1: ComfoCoolMode.AUTO, 139 | }.get(value), 140 | ), 141 | ComfoconnectSelectEntityDescription( 142 | key="boost_timeout", 143 | name="Boost Mode", 144 | icon="mdi:fan-plus", 145 | get_value_fn=lambda ccb: cast(Coroutine, ccb.get_boost()), 146 | set_value_fn=lambda ccb, option: cast(Coroutine, ccb.set_boost(True, int(option.split()[0]) * 60)), 147 | options=["10 Minutes", "20 Minutes", "30 Minutes", "40 Minutes", "50 Minutes", "60 Minutes"], 148 | ), 149 | ) 150 | 151 | 152 | async def async_setup_entry( 153 | hass: HomeAssistant, 154 | config_entry: ConfigEntry, 155 | async_add_entities: AddEntitiesCallback, 156 | ) -> None: 157 | """Set up the ComfoConnect selects.""" 158 | ccb = hass.data[DOMAIN][config_entry.entry_id] 159 | 160 | selects = [ComfoConnectSelect(ccb=ccb, config_entry=config_entry, description=description) for description in SELECT_TYPES] 161 | 162 | async_add_entities(selects, True) 163 | 164 | 165 | class ComfoConnectSelect(SelectEntity): 166 | """Representation of a ComfoConnect select.""" 167 | 168 | _attr_has_entity_name = True 169 | entity_description: ComfoconnectSelectEntityDescription 170 | 171 | def __init__( 172 | self, 173 | ccb: ComfoConnectBridge, 174 | config_entry: ConfigEntry, 175 | description: ComfoconnectSelectEntityDescription, 176 | ) -> None: 177 | """Initialize the ComfoConnect select.""" 178 | self._ccb = ccb 179 | self.entity_description = description 180 | self._attr_should_poll = False if description.sensor else True 181 | self._attr_unique_id = f"{self._ccb.uuid}-{description.key}" 182 | self._attr_device_info = DeviceInfo( 183 | identifiers={(DOMAIN, self._ccb.uuid)}, 184 | ) 185 | 186 | async def async_added_to_hass(self) -> None: 187 | """Register for sensor updates.""" 188 | if not self.entity_description.sensor: 189 | return 190 | 191 | _LOGGER.debug( 192 | "Registering for sensor %s (%d)", 193 | self.entity_description.sensor.name, 194 | self.entity_description.sensor.id, 195 | ) 196 | self.async_on_remove( 197 | async_dispatcher_connect( 198 | self.hass, 199 | SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._ccb.uuid, self.entity_description.sensor.id), 200 | self._handle_update, 201 | ) 202 | ) 203 | await self._ccb.register_sensor(self.entity_description.sensor) 204 | 205 | def _handle_update(self, value): 206 | """Handle update callbacks.""" 207 | _LOGGER.debug( 208 | "Handle update for sensor %s (%s): %s", 209 | self.entity_description.sensor.name, 210 | self.entity_description.sensor.id, 211 | value, 212 | ) 213 | 214 | self._attr_current_option = self.entity_description.sensor_value_fn(value) 215 | self.schedule_update_ha_state() 216 | 217 | async def async_update(self) -> None: 218 | """Update the state.""" 219 | self._attr_current_option = await self.entity_description.get_value_fn(self._ccb) 220 | 221 | async def async_select_option(self, option: str) -> None: 222 | """Set the selected option.""" 223 | await self.entity_description.set_value_fn(self._ccb, option) 224 | self._attr_current_option = option 225 | self.schedule_update_ha_state() 226 | -------------------------------------------------------------------------------- /custom_components/comfoconnect/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor for the ComfoConnect integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from dataclasses import dataclass 7 | from datetime import timedelta 8 | from typing import Callable 9 | 10 | from aiocomfoconnect.sensors import ( 11 | SENSOR_AIRFLOW_CONSTRAINTS, 12 | SENSOR_ANALOG_INPUT_1, 13 | SENSOR_ANALOG_INPUT_2, 14 | SENSOR_ANALOG_INPUT_3, 15 | SENSOR_ANALOG_INPUT_4, 16 | SENSOR_BYPASS_STATE, 17 | SENSOR_COMFOCOOL_CONDENSOR_TEMP, 18 | SENSOR_COMFOFOND_GHE_STATE, 19 | SENSOR_COMFOFOND_TEMP_GROUND, 20 | SENSOR_COMFOFOND_TEMP_OUTDOOR, 21 | SENSOR_DAYS_TO_REPLACE_FILTER, 22 | SENSOR_FAN_EXHAUST_DUTY, 23 | SENSOR_FAN_EXHAUST_FLOW, 24 | SENSOR_FAN_EXHAUST_SPEED, 25 | SENSOR_FAN_SUPPLY_DUTY, 26 | SENSOR_FAN_SUPPLY_FLOW, 27 | SENSOR_FAN_SUPPLY_SPEED, 28 | SENSOR_HUMIDITY_EXHAUST, 29 | SENSOR_HUMIDITY_EXTRACT, 30 | SENSOR_HUMIDITY_OUTDOOR, 31 | SENSOR_HUMIDITY_SUPPLY, 32 | SENSOR_POWER_USAGE, 33 | SENSOR_POWER_USAGE_TOTAL, 34 | SENSOR_PREHEATER_POWER, 35 | SENSOR_PREHEATER_POWER_TOTAL, 36 | SENSOR_RMOT, 37 | SENSOR_TEMPERATURE_EXHAUST, 38 | SENSOR_TEMPERATURE_EXTRACT, 39 | SENSOR_TEMPERATURE_OUTDOOR, 40 | SENSOR_TEMPERATURE_SUPPLY, 41 | SENSORS, 42 | ) 43 | from aiocomfoconnect.sensors import ( 44 | Sensor as AioComfoConnectSensor, 45 | ) 46 | from homeassistant.components.sensor import ( 47 | SensorDeviceClass, 48 | SensorEntity, 49 | SensorEntityDescription, 50 | SensorStateClass, 51 | ) 52 | from homeassistant.config_entries import ConfigEntry 53 | from homeassistant.const import ( 54 | PERCENTAGE, 55 | REVOLUTIONS_PER_MINUTE, 56 | UnitOfElectricPotential, 57 | UnitOfEnergy, 58 | UnitOfPower, 59 | UnitOfTemperature, 60 | UnitOfTime, 61 | UnitOfVolumeFlowRate, 62 | ) 63 | from homeassistant.core import HomeAssistant 64 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 65 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 66 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 67 | from homeassistant.util import Throttle 68 | 69 | from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge 70 | 71 | _LOGGER = logging.getLogger(__name__) 72 | 73 | MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) 74 | 75 | 76 | @dataclass 77 | class ComfoconnectRequiredKeysMixin: 78 | """Mixin for required keys.""" 79 | 80 | ccb_sensor: AioComfoConnectSensor 81 | 82 | 83 | @dataclass 84 | class ComfoconnectSensorEntityDescription(SensorEntityDescription, ComfoconnectRequiredKeysMixin): 85 | """Describes ComfoConnect sensor entity.""" 86 | 87 | throttle: bool = False 88 | mapping: Callable = None 89 | 90 | 91 | SENSOR_TYPES = ( 92 | ComfoconnectSensorEntityDescription( 93 | key=SENSOR_TEMPERATURE_EXTRACT, 94 | device_class=SensorDeviceClass.TEMPERATURE, 95 | state_class=SensorStateClass.MEASUREMENT, 96 | name="Inside temperature", 97 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 98 | ccb_sensor=SENSORS.get(SENSOR_TEMPERATURE_EXTRACT), 99 | ), 100 | ComfoconnectSensorEntityDescription( 101 | key=SENSOR_HUMIDITY_EXTRACT, 102 | device_class=SensorDeviceClass.HUMIDITY, 103 | state_class=SensorStateClass.MEASUREMENT, 104 | name="Inside humidity", 105 | native_unit_of_measurement=PERCENTAGE, 106 | ccb_sensor=SENSORS.get(SENSOR_HUMIDITY_EXTRACT), 107 | ), 108 | ComfoconnectSensorEntityDescription( 109 | key=SENSOR_RMOT, 110 | device_class=SensorDeviceClass.TEMPERATURE, 111 | state_class=SensorStateClass.MEASUREMENT, 112 | name="Current RMOT", 113 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 114 | ccb_sensor=SENSORS.get(SENSOR_RMOT), 115 | entity_registry_enabled_default=False, 116 | entity_category=EntityCategory.DIAGNOSTIC, 117 | ), 118 | ComfoconnectSensorEntityDescription( 119 | key=SENSOR_TEMPERATURE_OUTDOOR, 120 | device_class=SensorDeviceClass.TEMPERATURE, 121 | state_class=SensorStateClass.MEASUREMENT, 122 | name="Outside temperature", 123 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 124 | ccb_sensor=SENSORS.get(SENSOR_TEMPERATURE_OUTDOOR), 125 | ), 126 | ComfoconnectSensorEntityDescription( 127 | key=SENSOR_HUMIDITY_OUTDOOR, 128 | device_class=SensorDeviceClass.HUMIDITY, 129 | state_class=SensorStateClass.MEASUREMENT, 130 | name="Outside humidity", 131 | native_unit_of_measurement=PERCENTAGE, 132 | ccb_sensor=SENSORS.get(SENSOR_HUMIDITY_OUTDOOR), 133 | ), 134 | ComfoconnectSensorEntityDescription( 135 | key=SENSOR_TEMPERATURE_SUPPLY, 136 | device_class=SensorDeviceClass.TEMPERATURE, 137 | state_class=SensorStateClass.MEASUREMENT, 138 | name="Supply temperature", 139 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 140 | ccb_sensor=SENSORS.get(SENSOR_TEMPERATURE_SUPPLY), 141 | ), 142 | ComfoconnectSensorEntityDescription( 143 | key=SENSOR_HUMIDITY_SUPPLY, 144 | device_class=SensorDeviceClass.HUMIDITY, 145 | state_class=SensorStateClass.MEASUREMENT, 146 | name="Supply humidity", 147 | native_unit_of_measurement=PERCENTAGE, 148 | ccb_sensor=SENSORS.get(SENSOR_HUMIDITY_SUPPLY), 149 | ), 150 | ComfoconnectSensorEntityDescription( 151 | key=SENSOR_FAN_SUPPLY_SPEED, 152 | state_class=SensorStateClass.MEASUREMENT, 153 | name="Supply fan speed", 154 | native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, 155 | icon="mdi:fan-plus", 156 | ccb_sensor=SENSORS.get(SENSOR_FAN_SUPPLY_SPEED), 157 | entity_registry_enabled_default=False, 158 | entity_category=EntityCategory.DIAGNOSTIC, 159 | throttle=True, 160 | ), 161 | ComfoconnectSensorEntityDescription( 162 | key=SENSOR_FAN_SUPPLY_DUTY, 163 | state_class=SensorStateClass.MEASUREMENT, 164 | name="Supply fan duty", 165 | native_unit_of_measurement=PERCENTAGE, 166 | icon="mdi:fan-plus", 167 | ccb_sensor=SENSORS.get(SENSOR_FAN_SUPPLY_DUTY), 168 | entity_registry_enabled_default=False, 169 | entity_category=EntityCategory.DIAGNOSTIC, 170 | throttle=True, 171 | ), 172 | ComfoconnectSensorEntityDescription( 173 | key=SENSOR_FAN_EXHAUST_SPEED, 174 | state_class=SensorStateClass.MEASUREMENT, 175 | name="Exhaust fan speed", 176 | native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, 177 | icon="mdi:fan-minus", 178 | ccb_sensor=SENSORS.get(SENSOR_FAN_EXHAUST_SPEED), 179 | entity_registry_enabled_default=False, 180 | entity_category=EntityCategory.DIAGNOSTIC, 181 | throttle=True, 182 | ), 183 | ComfoconnectSensorEntityDescription( 184 | key=SENSOR_FAN_EXHAUST_DUTY, 185 | state_class=SensorStateClass.MEASUREMENT, 186 | name="Exhaust fan duty", 187 | native_unit_of_measurement=PERCENTAGE, 188 | icon="mdi:fan-minus", 189 | ccb_sensor=SENSORS.get(SENSOR_FAN_EXHAUST_DUTY), 190 | entity_registry_enabled_default=False, 191 | entity_category=EntityCategory.DIAGNOSTIC, 192 | throttle=True, 193 | ), 194 | ComfoconnectSensorEntityDescription( 195 | key=SENSOR_TEMPERATURE_EXHAUST, 196 | device_class=SensorDeviceClass.TEMPERATURE, 197 | state_class=SensorStateClass.MEASUREMENT, 198 | name="Exhaust temperature", 199 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 200 | ccb_sensor=SENSORS.get(SENSOR_TEMPERATURE_EXHAUST), 201 | ), 202 | ComfoconnectSensorEntityDescription( 203 | key=SENSOR_HUMIDITY_EXHAUST, 204 | device_class=SensorDeviceClass.HUMIDITY, 205 | state_class=SensorStateClass.MEASUREMENT, 206 | name="Exhaust humidity", 207 | native_unit_of_measurement=PERCENTAGE, 208 | ccb_sensor=SENSORS.get(SENSOR_HUMIDITY_EXHAUST), 209 | ), 210 | ComfoconnectSensorEntityDescription( 211 | key=SENSOR_FAN_SUPPLY_FLOW, 212 | state_class=SensorStateClass.MEASUREMENT, 213 | name="Supply airflow", 214 | native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, 215 | icon="mdi:fan-plus", 216 | ccb_sensor=SENSORS.get(SENSOR_FAN_SUPPLY_FLOW), 217 | entity_registry_enabled_default=False, 218 | entity_category=EntityCategory.DIAGNOSTIC, 219 | throttle=True, 220 | ), 221 | ComfoconnectSensorEntityDescription( 222 | key=SENSOR_FAN_EXHAUST_FLOW, 223 | state_class=SensorStateClass.MEASUREMENT, 224 | name="Exhaust airflow", 225 | native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, 226 | icon="mdi:fan-minus", 227 | ccb_sensor=SENSORS.get(SENSOR_FAN_EXHAUST_FLOW), 228 | entity_registry_enabled_default=False, 229 | entity_category=EntityCategory.DIAGNOSTIC, 230 | throttle=True, 231 | ), 232 | ComfoconnectSensorEntityDescription( 233 | key=SENSOR_BYPASS_STATE, 234 | state_class=SensorStateClass.MEASUREMENT, 235 | name="Bypass state", 236 | native_unit_of_measurement=PERCENTAGE, 237 | icon="mdi:camera-iris", 238 | ccb_sensor=SENSORS.get(SENSOR_BYPASS_STATE), 239 | ), 240 | ComfoconnectSensorEntityDescription( 241 | key=SENSOR_DAYS_TO_REPLACE_FILTER, 242 | name="Days to replace filter", 243 | native_unit_of_measurement=UnitOfTime.DAYS, 244 | icon="mdi:calendar", 245 | ccb_sensor=SENSORS.get(SENSOR_DAYS_TO_REPLACE_FILTER), 246 | entity_category=EntityCategory.DIAGNOSTIC, 247 | ), 248 | ComfoconnectSensorEntityDescription( 249 | key=SENSOR_POWER_USAGE, 250 | device_class=SensorDeviceClass.POWER, 251 | state_class=SensorStateClass.MEASUREMENT, 252 | name="Ventilation current power usage", 253 | native_unit_of_measurement=UnitOfPower.WATT, 254 | ccb_sensor=SENSORS.get(SENSOR_POWER_USAGE), 255 | entity_registry_enabled_default=False, 256 | entity_category=EntityCategory.DIAGNOSTIC, 257 | throttle=True, 258 | ), 259 | ComfoconnectSensorEntityDescription( 260 | key=SENSOR_POWER_USAGE_TOTAL, 261 | device_class=SensorDeviceClass.ENERGY, 262 | state_class=SensorStateClass.TOTAL_INCREASING, 263 | name="Ventilation total energy usage", 264 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 265 | ccb_sensor=SENSORS.get(SENSOR_POWER_USAGE_TOTAL), 266 | entity_registry_enabled_default=False, 267 | entity_category=EntityCategory.DIAGNOSTIC, 268 | throttle=True, 269 | ), 270 | ComfoconnectSensorEntityDescription( 271 | key=SENSOR_PREHEATER_POWER, 272 | device_class=SensorDeviceClass.POWER, 273 | state_class=SensorStateClass.MEASUREMENT, 274 | name="Preheater current power usage", 275 | native_unit_of_measurement=UnitOfPower.WATT, 276 | ccb_sensor=SENSORS.get(SENSOR_PREHEATER_POWER), 277 | entity_registry_enabled_default=False, 278 | entity_category=EntityCategory.DIAGNOSTIC, 279 | throttle=True, 280 | ), 281 | ComfoconnectSensorEntityDescription( 282 | key=SENSOR_PREHEATER_POWER_TOTAL, 283 | device_class=SensorDeviceClass.ENERGY, 284 | state_class=SensorStateClass.TOTAL_INCREASING, 285 | name="Preheater total energy usage", 286 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 287 | ccb_sensor=SENSORS.get(SENSOR_PREHEATER_POWER_TOTAL), 288 | entity_registry_enabled_default=False, 289 | entity_category=EntityCategory.DIAGNOSTIC, 290 | throttle=True, 291 | ), 292 | ComfoconnectSensorEntityDescription( 293 | key=SENSOR_ANALOG_INPUT_1, 294 | device_class=SensorDeviceClass.VOLTAGE, 295 | state_class=SensorStateClass.MEASUREMENT, 296 | name="Analog Input 1", 297 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 298 | ccb_sensor=SENSORS.get(SENSOR_ANALOG_INPUT_1), 299 | entity_category=EntityCategory.DIAGNOSTIC, 300 | throttle=True, 301 | ), 302 | ComfoconnectSensorEntityDescription( 303 | key=SENSOR_ANALOG_INPUT_2, 304 | device_class=SensorDeviceClass.VOLTAGE, 305 | state_class=SensorStateClass.MEASUREMENT, 306 | name="Analog Input 2", 307 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 308 | ccb_sensor=SENSORS.get(SENSOR_ANALOG_INPUT_2), 309 | entity_category=EntityCategory.DIAGNOSTIC, 310 | throttle=True, 311 | ), 312 | ComfoconnectSensorEntityDescription( 313 | key=SENSOR_ANALOG_INPUT_3, 314 | device_class=SensorDeviceClass.VOLTAGE, 315 | state_class=SensorStateClass.MEASUREMENT, 316 | name="Analog Input 3", 317 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 318 | ccb_sensor=SENSORS.get(SENSOR_ANALOG_INPUT_3), 319 | entity_category=EntityCategory.DIAGNOSTIC, 320 | throttle=True, 321 | ), 322 | ComfoconnectSensorEntityDescription( 323 | key=SENSOR_ANALOG_INPUT_4, 324 | device_class=SensorDeviceClass.VOLTAGE, 325 | state_class=SensorStateClass.MEASUREMENT, 326 | name="Analog Input 4", 327 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 328 | ccb_sensor=SENSORS.get(SENSOR_ANALOG_INPUT_4), 329 | entity_category=EntityCategory.DIAGNOSTIC, 330 | throttle=True, 331 | ), 332 | ComfoconnectSensorEntityDescription( 333 | key=SENSOR_AIRFLOW_CONSTRAINTS, 334 | icon="mdi:fan-alert", 335 | name="Airflow Constraint", 336 | ccb_sensor=SENSORS.get(SENSOR_AIRFLOW_CONSTRAINTS), 337 | entity_category=EntityCategory.DIAGNOSTIC, 338 | mapping=lambda x: x[0] if x else "", 339 | ), 340 | ComfoconnectSensorEntityDescription( 341 | key=SENSOR_COMFOFOND_GHE_STATE, 342 | state_class=SensorStateClass.MEASUREMENT, 343 | name="ComfoFond GHE state", 344 | native_unit_of_measurement=PERCENTAGE, 345 | ccb_sensor=SENSORS.get(SENSOR_COMFOFOND_GHE_STATE), 346 | entity_registry_enabled_default=False, 347 | entity_category=EntityCategory.DIAGNOSTIC, 348 | ), 349 | ComfoconnectSensorEntityDescription( 350 | key=SENSOR_COMFOFOND_TEMP_GROUND, 351 | device_class=SensorDeviceClass.TEMPERATURE, 352 | state_class=SensorStateClass.MEASUREMENT, 353 | name="ComfoFond ground temperature", 354 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 355 | ccb_sensor=SENSORS.get(SENSOR_COMFOFOND_TEMP_GROUND), 356 | entity_registry_enabled_default=False, 357 | entity_category=EntityCategory.DIAGNOSTIC, 358 | ), 359 | ComfoconnectSensorEntityDescription( 360 | key=SENSOR_COMFOFOND_TEMP_OUTDOOR, 361 | device_class=SensorDeviceClass.TEMPERATURE, 362 | state_class=SensorStateClass.MEASUREMENT, 363 | name="ComfoFond outdoor air temperature", 364 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 365 | ccb_sensor=SENSORS.get(SENSOR_COMFOFOND_TEMP_OUTDOOR), 366 | entity_registry_enabled_default=False, 367 | entity_category=EntityCategory.DIAGNOSTIC, 368 | ), 369 | ComfoconnectSensorEntityDescription( 370 | key=SENSOR_COMFOCOOL_CONDENSOR_TEMP, 371 | device_class=SensorDeviceClass.TEMPERATURE, 372 | state_class=SensorStateClass.MEASUREMENT, 373 | name="ComfoCool condensor temperature", 374 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 375 | ccb_sensor=SENSORS.get(SENSOR_COMFOCOOL_CONDENSOR_TEMP), 376 | entity_registry_enabled_default=False, 377 | entity_category=EntityCategory.DIAGNOSTIC, 378 | ), 379 | ) 380 | 381 | 382 | async def async_setup_entry( 383 | hass: HomeAssistant, 384 | config_entry: ConfigEntry, 385 | async_add_entities: AddEntitiesCallback, 386 | ) -> None: 387 | """Set up the ComfoConnect sensors.""" 388 | ccb = hass.data[DOMAIN][config_entry.entry_id] 389 | 390 | sensors = [ComfoConnectSensor(ccb=ccb, config_entry=config_entry, description=description) for description in SENSOR_TYPES] 391 | 392 | async_add_entities(sensors, True) 393 | 394 | 395 | class ComfoConnectSensor(SensorEntity): 396 | """Representation of a ComfoConnect sensor.""" 397 | 398 | _attr_should_poll = False 399 | _attr_has_entity_name = True 400 | entity_description: ComfoconnectSensorEntityDescription 401 | 402 | def __init__( 403 | self, 404 | ccb: ComfoConnectBridge, 405 | config_entry: ConfigEntry, 406 | description: ComfoconnectSensorEntityDescription, 407 | ) -> None: 408 | """Initialize the ComfoConnect sensor.""" 409 | self._ccb = ccb 410 | self.entity_description = description 411 | self._attr_unique_id = f"{self._ccb.uuid}-{description.key}" 412 | self._attr_device_info = DeviceInfo( 413 | identifiers={(DOMAIN, self._ccb.uuid)}, 414 | ) 415 | 416 | async def async_added_to_hass(self) -> None: 417 | """Register for sensor updates.""" 418 | _LOGGER.debug( 419 | "Registering for sensor %s (%d)", 420 | self.entity_description.name, 421 | self.entity_description.key, 422 | ) 423 | 424 | # If the sensor should be throttled, pass it through the Throttle utility 425 | if self.entity_description.throttle: 426 | update_handler = Throttle(MIN_TIME_BETWEEN_UPDATES)(self._handle_update) 427 | else: 428 | update_handler = self._handle_update 429 | 430 | self.async_on_remove( 431 | async_dispatcher_connect( 432 | self.hass, 433 | SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._ccb.uuid, self.entity_description.key), 434 | update_handler, 435 | ) 436 | ) 437 | await self._ccb.register_sensor(self.entity_description.ccb_sensor) 438 | 439 | def _handle_update(self, value): 440 | """Handle update callbacks.""" 441 | _LOGGER.debug( 442 | "Handle update for sensor %s (%d): %s", 443 | self.entity_description.name, 444 | self.entity_description.key, 445 | value, 446 | ) 447 | 448 | if self.entity_description.mapping: 449 | self._attr_native_value = self.entity_description.mapping(value) 450 | else: 451 | self._attr_native_value = value 452 | self.schedule_update_ha_state() 453 | --------------------------------------------------------------------------------