diff --git a/.coveragerc b/.coveragerc index c717c1624c3..44fe8c41b52 100644 --- a/.coveragerc +++ b/.coveragerc @@ -975,6 +975,10 @@ omit = homeassistant/components/rainmachine/switch.py homeassistant/components/rainmachine/update.py homeassistant/components/rainmachine/util.py + homeassistant/components/renson/__init__.py + homeassistant/components/renson/const.py + homeassistant/components/renson/entity.py + homeassistant/components/renson/sensor.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py diff --git a/CODEOWNERS b/CODEOWNERS index 63833d6c1fa..c1545d61429 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1005,6 +1005,8 @@ build.json @home-assistant/supervisor /tests/components/remote/ @home-assistant/core /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet +/homeassistant/components/renson/ @jimmyd-be +/tests/components/renson/ @jimmyd-be /homeassistant/components/reolink/ @starkillerOG /tests/components/reolink/ @starkillerOG /homeassistant/components/repairs/ @home-assistant/core diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py new file mode 100644 index 00000000000..2e2f4e8f253 --- /dev/null +++ b/homeassistant/components/renson/__init__.py @@ -0,0 +1,87 @@ +"""The Renson integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +import async_timeout +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [ + Platform.SENSOR, +] + + +@dataclass +class RensonData: + """Renson data class.""" + + api: RensonVentilation + coordinator: RensonCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Renson from a config entry.""" + + api = RensonVentilation(entry.data[CONF_HOST]) + coordinator = RensonCoordinator("Renson", hass, api) + + if not await hass.async_add_executor_job(api.connect): + raise ConfigEntryNotReady("Cannot connect to Renson device") + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RensonData( + api, + coordinator, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class RensonCoordinator(DataUpdateCoordinator): + """Data update coordinator for Renson.""" + + def __init__( + self, + name: str, + hass: HomeAssistant, + api: RensonVentilation, + update_interval=timedelta(seconds=30), + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=name, + # Polling interval. Will only be polled if there are subscribers. + update_interval=update_interval, + ) + self.api = api + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + async with async_timeout.timeout(30): + return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/config_flow.py b/homeassistant/components/renson/config_flow.py new file mode 100644 index 00000000000..9883772ce02 --- /dev/null +++ b/homeassistant/components/renson/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Renson integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from renson_endura_delta import renson +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Renson.""" + + VERSION = 1 + + async def validate_input( + self, hass: HomeAssistant, data: dict[str, Any] + ) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + api = renson.RensonVentilation(data[CONF_HOST]) + + if not await self.hass.async_add_executor_job(api.connect): + raise CannotConnect + + return {"title": "Renson"} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await self.validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/renson/const.py b/homeassistant/components/renson/const.py new file mode 100644 index 00000000000..840e1ce428a --- /dev/null +++ b/homeassistant/components/renson/const.py @@ -0,0 +1,3 @@ +"""Constants for the Renson integration.""" + +DOMAIN = "renson" diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py new file mode 100644 index 00000000000..9ba30b43aa7 --- /dev/null +++ b/homeassistant/components/renson/entity.py @@ -0,0 +1,47 @@ +"""Entity class for Renson ventilation unit.""" +from __future__ import annotations + +from renson_endura_delta.field_enum import ( + DEVICE_NAME_FIELD, + FIRMWARE_VERSION_FIELD, + HARDWARE_VERSION_FIELD, + MAC_ADDRESS, +) +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RensonCoordinator +from .const import DOMAIN + + +class RensonEntity(CoordinatorEntity): + """Renson entity.""" + + def __init__( + self, name: str, api: RensonVentilation, coordinator: RensonCoordinator + ) -> None: + """Initialize the Renson entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, api.get_field_value(coordinator.data, MAC_ADDRESS.name)) + }, + manufacturer="Renson", + model=api.get_field_value(coordinator.data, DEVICE_NAME_FIELD.name), + name="Ventilation", + sw_version=api.get_field_value( + coordinator.data, FIRMWARE_VERSION_FIELD.name + ), + hw_version=api.get_field_value( + coordinator.data, HARDWARE_VERSION_FIELD.name + ), + ) + + self.api = api + + self._attr_unique_id = ( + api.get_field_value(coordinator.data, MAC_ADDRESS.name) + f"{name}" + ) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json new file mode 100644 index 00000000000..5ff219cc26c --- /dev/null +++ b/homeassistant/components/renson/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "renson", + "name": "Renson", + "codeowners": ["@jimmyd-be"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/renson", + "iot_class": "local_polling", + "requirements": ["renson-endura-delta==1.5.0"] +} diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py new file mode 100644 index 00000000000..dc9f69c2914 --- /dev/null +++ b/homeassistant/components/renson/sensor.py @@ -0,0 +1,317 @@ +"""Sensor data of the Renson ventilation unit.""" +from __future__ import annotations + +from dataclasses import dataclass + +from renson_endura_delta.field_enum import ( + AIR_QUALITY_FIELD, + BREEZE_LEVEL_FIELD, + BREEZE_TEMPERATURE_FIELD, + BYPASS_LEVEL_FIELD, + BYPASS_TEMPERATURE_FIELD, + CO2_FIELD, + CO2_HYSTERESIS_FIELD, + CO2_QUALITY_FIELD, + CO2_THRESHOLD_FIELD, + CURRENT_AIRFLOW_EXTRACT_FIELD, + CURRENT_AIRFLOW_INGOING_FIELD, + CURRENT_LEVEL_FIELD, + DAY_POLLUTION_FIELD, + DAYTIME_FIELD, + FILTER_REMAIN_FIELD, + HUMIDITY_FIELD, + INDOOR_TEMP_FIELD, + MANUAL_LEVEL_FIELD, + NIGHT_POLLUTION_FIELD, + NIGHTTIME_FIELD, + OUTDOOR_TEMP_FIELD, + FieldEnum, +) +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfTemperature, + UnitOfTime, + UnitOfVolumeFlowRate, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator +from .const import DOMAIN +from .entity import RensonEntity + + +@dataclass +class RensonSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + field: FieldEnum + raw_format: bool + + +@dataclass +class RensonSensorEntityDescription( + SensorEntityDescription, RensonSensorEntityDescriptionMixin +): + """Description of sensor.""" + + +SENSORS: tuple[RensonSensorEntityDescription, ...] = ( + RensonSensorEntityDescription( + key="CO2_QUALITY_FIELD", + name="CO2 quality category", + field=CO2_QUALITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["good", "bad", "poor"], + ), + RensonSensorEntityDescription( + key="AIR_QUALITY_FIELD", + name="Air quality category", + field=AIR_QUALITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["good", "bad", "poor"], + ), + RensonSensorEntityDescription( + key="CO2_FIELD", + name="CO2 quality", + field=CO2_FIELD, + raw_format=True, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + RensonSensorEntityDescription( + key="AIR_FIELD", + name="Air quality", + field=AIR_QUALITY_FIELD, + state_class=SensorStateClass.MEASUREMENT, + raw_format=True, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + RensonSensorEntityDescription( + key="CURRENT_LEVEL_FIELD", + name="Ventilation level", + field=CURRENT_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze", "Holiday"], + ), + RensonSensorEntityDescription( + key="CURRENT_AIRFLOW_EXTRACT_FIELD", + name="Total airflow out", + field=CURRENT_AIRFLOW_EXTRACT_FIELD, + raw_format=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + RensonSensorEntityDescription( + key="CURRENT_AIRFLOW_INGOING_FIELD", + name="Total airflow in", + field=CURRENT_AIRFLOW_INGOING_FIELD, + raw_format=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + RensonSensorEntityDescription( + key="OUTDOOR_TEMP_FIELD", + name="Outdoor air temperature", + field=OUTDOOR_TEMP_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="INDOOR_TEMP_FIELD", + name="Extract air temperature", + field=INDOOR_TEMP_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="FILTER_REMAIN_FIELD", + name="Filter change", + field=FILTER_REMAIN_FIELD, + raw_format=False, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.DAYS, + ), + RensonSensorEntityDescription( + key="HUMIDITY_FIELD", + name="Relative humidity", + field=HUMIDITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + RensonSensorEntityDescription( + key="MANUAL_LEVEL_FIELD", + name="Manual level", + field=MANUAL_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze", "Holiday"], + ), + RensonSensorEntityDescription( + key="BREEZE_TEMPERATURE_FIELD", + name="Breeze temperature", + field=BREEZE_TEMPERATURE_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="BREEZE_LEVEL_FIELD", + name="Breeze level", + field=BREEZE_LEVEL_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze"], + ), + RensonSensorEntityDescription( + key="DAYTIME_FIELD", + name="Start day time", + field=DAYTIME_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="NIGHTTIME_FIELD", + name="Start night time", + field=NIGHTTIME_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="DAY_POLLUTION_FIELD", + name="Day pollution level", + field=DAY_POLLUTION_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "Level1", + "Level2", + "Level3", + "Level4", + ], + ), + RensonSensorEntityDescription( + key="NIGHT_POLLUTION_FIELD", + name="Night pollution level", + field=NIGHT_POLLUTION_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "Level1", + "Level2", + "Level3", + "Level4", + ], + ), + RensonSensorEntityDescription( + key="CO2_THRESHOLD_FIELD", + name="CO2 threshold", + field=CO2_THRESHOLD_FIELD, + raw_format=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="CO2_HYSTERESIS_FIELD", + name="CO2 hysteresis", + field=CO2_HYSTERESIS_FIELD, + raw_format=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="BYPASS_TEMPERATURE_FIELD", + name="Bypass activation temperature", + field=BYPASS_TEMPERATURE_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="BYPASS_LEVEL_FIELD", + name="Bypass level", + field=BYPASS_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +class RensonSensor(RensonEntity, SensorEntity): + """Get a sensor data from the Renson API and store it in the state of the class.""" + + def __init__( + self, + description: RensonSensorEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, api, coordinator) + + self.field = description.field + self.entity_description = description + + self.data_type = description.field.field_type + self.raw_format = description.raw_format + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + all_data = self.coordinator.data + + value = self.api.get_field_value(all_data, self.field.name) + + if self.raw_format: + self._attr_native_value = value + else: + self._attr_native_value = self.api.parse_value(value, self.data_type) + + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson sensor platform.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + entities: list = [] + for description in SENSORS: + entities.append(RensonSensor(description, api, coordinator)) + + async_add_entities(entities) diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json new file mode 100644 index 00000000000..16c5de158a9 --- /dev/null +++ b/homeassistant/components/renson/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f938bdfd8d1..96cb74cb316 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -365,6 +365,7 @@ FLOWS = { "rdw", "recollect_waste", "renault", + "renson", "reolink", "rfxtrx", "rhasspy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2203819cc85..044bb8fec68 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4557,6 +4557,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "renson": { + "name": "Renson", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "reolink": { "name": "Reolink IP NVR/camera", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ce7bce75f11..84bc7e6cf90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2264,6 +2264,9 @@ regenmaschine==2023.05.1 # homeassistant.components.renault renault-api==0.1.13 +# homeassistant.components.renson +renson-endura-delta==1.5.0 + # homeassistant.components.reolink reolink-aio==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3faf412388..dbc0c5d7e83 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1651,6 +1651,9 @@ regenmaschine==2023.05.1 # homeassistant.components.renault renault-api==0.1.13 +# homeassistant.components.renson +renson-endura-delta==1.5.0 + # homeassistant.components.reolink reolink-aio==0.6.0 diff --git a/tests/components/renson/__init__.py b/tests/components/renson/__init__.py new file mode 100644 index 00000000000..fa2bbe6b4a0 --- /dev/null +++ b/tests/components/renson/__init__.py @@ -0,0 +1 @@ +"""Tests for the Renson integration.""" diff --git a/tests/components/renson/test_config_flow.py b/tests/components/renson/test_config_flow.py new file mode 100644 index 00000000000..6b9f54cd454 --- /dev/null +++ b/tests/components/renson/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Renson config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.renson.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.renson.config_flow.renson", + return_value={"title": "Renson"}, + ), patch( + "homeassistant.components.renson.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Renson" + assert result2["data"] == { + "host": "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.renson.config_flow.renson.RensonVentilation.connect", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.renson.config_flow.renson.RensonVentilation.connect", + side_effect=ValueError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"}