diff --git a/CODEOWNERS b/CODEOWNERS index 281cf4fbecb0..ecc0753add5a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -366,6 +366,8 @@ CLAUDE.md @home-assistant/core /tests/components/date/ @home-assistant/core /homeassistant/components/datetime/ @home-assistant/core /tests/components/datetime/ @home-assistant/core +/homeassistant/components/de_dietrich/ @DaanVervacke +/tests/components/de_dietrich/ @DaanVervacke /homeassistant/components/deako/ @sebirdman @balake @deakolights /tests/components/deako/ @sebirdman @balake @deakolights /homeassistant/components/debugpy/ @frenck diff --git a/homeassistant/components/de_dietrich/__init__.py b/homeassistant/components/de_dietrich/__init__.py new file mode 100644 index 000000000000..71521f93ade1 --- /dev/null +++ b/homeassistant/components/de_dietrich/__init__.py @@ -0,0 +1,100 @@ +"""Integrate De Dietrich devices into Home Assistant.""" + +import logging + +import diematic_modbus +from modbus_connection import ModbusError, ModbusTcpParams + +from homeassistant.components.modbus import async_get_unit +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import CONF_UNIT_ID, DOMAIN, MESSAGE_SPACING, MODBUS_FRAMER +from .coordinator import DeDietrichConfigEntry, DeDietrichDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: DeDietrichConfigEntry) -> bool: + """Set up De Dietrich from a config entry.""" + try: + unit = async_get_unit( + hass, + entry, + ModbusTcpParams( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + framer=MODBUS_FRAMER, + ), + entry.data[CONF_UNIT_ID], + ) + except HomeAssistantError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="link_settings_in_use", + translation_placeholders={"error": str(err)}, + ) from err + unit.set_message_spacing(MESSAGE_SPACING) + + try: + detection = await diematic_modbus.async_detect(unit) + except diematic_modbus.DiematicProbeError as err: + outcomes = [ + block.outcome + for block in (*err.detection.base_probe, *err.detection.isystem_probe) + ] + _LOGGER.error( + "%s: Diematic detection failed: %s, probe outcomes: %s", + entry.title, + err.detection, + outcomes, + exc_info=err, + ) + if any( + block.outcome == "error" + for block in (*err.detection.base_probe, *err.detection.isystem_probe) + ): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="modbus_error", + ) from err + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unsupported_device", + translation_placeholders={"error": str(err)}, + ) from err + except ModbusError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="modbus_error", + ) from err + + assert detection.device is not None + device = detection.device + coordinator = DeDietrichDataUpdateCoordinator(hass, entry, device) + await coordinator.async_config_entry_first_refresh() + + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **coordinator.device_info + ) + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DeDietrichConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/de_dietrich/config_flow.py b/homeassistant/components/de_dietrich/config_flow.py new file mode 100644 index 000000000000..707c52f299c7 --- /dev/null +++ b/homeassistant/components/de_dietrich/config_flow.py @@ -0,0 +1,131 @@ +"""Config flow for De Dietrich devices.""" + +import logging +from typing import Any, override + +import diematic_modbus +from modbus_connection import ModbusError, ModbusTcpParams +import probatio as vol + +from homeassistant.components.modbus import async_get_temporary_unit +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) + +from .const import ( + CONF_UNIT_ID, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_UNIT_ID, + DOMAIN, + MESSAGE_SPACING, + MODBUS_FRAMER, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535) + ), + vol.Coerce(int), + ), + vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All( + NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=247) + ), + vol.Coerce(int), + ), + } +) + + +async def _async_detect( + hass: HomeAssistant, host: str, port: int, unit_id: int +) -> diematic_modbus.DiematicDetection: + """Connect to the boiler and read its identity, or raise.""" + params = ModbusTcpParams(host=host, port=port, framer=MODBUS_FRAMER) + async with async_get_temporary_unit(hass, params, unit_id) as unit: + unit.set_message_spacing(MESSAGE_SPACING) + return await diematic_modbus.async_detect(unit) + + +class DeDietrichConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a De Dietrich config flow.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial connection step.""" + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + if user_input is not None: + connection = { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_UNIT_ID: user_input[CONF_UNIT_ID], + } + self._async_abort_entries_match(connection) + try: + await _async_detect( + self.hass, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_UNIT_ID], + ) + except diematic_modbus.DiematicProbeError as err: + outcomes = [ + block.outcome + for block in ( + *err.detection.base_probe, + *err.detection.isystem_probe, + ) + ] + _LOGGER.warning( + "Diematic detection failed: %s, probe outcomes: %s", + err.detection, + outcomes, + ) + errors["base"] = ( + "cannot_connect" + if any( + block.outcome == "error" + for block in ( + *err.detection.base_probe, + *err.detection.isystem_probe, + ) + ) + else "unsupported_device" + ) + description_placeholders["error"] = str(err) + except (ModbusError, HomeAssistantError) as err: + errors["base"] = "cannot_connect" + description_placeholders["error"] = str(err) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=DEFAULT_NAME, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders=description_placeholders, + ) diff --git a/homeassistant/components/de_dietrich/const.py b/homeassistant/components/de_dietrich/const.py new file mode 100644 index 000000000000..c11212f0910c --- /dev/null +++ b/homeassistant/components/de_dietrich/const.py @@ -0,0 +1,13 @@ +"""Constants for the De Dietrich integration.""" + +from typing import Final + +DOMAIN = "de_dietrich" +ATTR_MANUFACTURER = "De Dietrich" +DEFAULT_NAME = "De Dietrich" +DEFAULT_PORT = 502 +DEFAULT_UNIT_ID = 10 +SCAN_INTERVAL = 15 +CONF_UNIT_ID = "unit_id" +MODBUS_FRAMER: Final = "rtu" +MESSAGE_SPACING: Final = 0.05 diff --git a/homeassistant/components/de_dietrich/coordinator.py b/homeassistant/components/de_dietrich/coordinator.py new file mode 100644 index 000000000000..ab65c0cc7a87 --- /dev/null +++ b/homeassistant/components/de_dietrich/coordinator.py @@ -0,0 +1,94 @@ +"""Data update coordinator for De Dietrich devices.""" + +from datetime import timedelta +import logging +from typing import override + +from diematic_modbus import Diematic, DiematicISystem, UpdateReport +from modbus_connection import ModbusError +from propcache.api import cached_property + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ATTR_MANUFACTURER, DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +type DeDietrichConfigEntry = ConfigEntry[DeDietrichDataUpdateCoordinator] + + +class DeDietrichDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): + """Class to manage fetching De Dietrich data.""" + + config_entry: DeDietrichConfigEntry + device: Diematic | DiematicISystem + + def __init__( + self, + hass: HomeAssistant, + entry: DeDietrichConfigEntry, + device: Diematic | DiematicISystem, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=entry.title, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + self.device = device + + @override + async def _async_setup(self) -> None: + """Read the identity before registering device information.""" + try: + await self.device.identity.async_update() + except ModbusError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="modbus_error", + ) from err + + @cached_property + def device_info(self) -> dr.DeviceInfo: + """Return device information.""" + device = self.device + sw_version = ( + device.identity.software_version + if isinstance(device, DiematicISystem) + else None + ) + return dr.DeviceInfo( + identifiers={(DOMAIN, self.config_entry.entry_id)}, + manufacturer=ATTR_MANUFACTURER, + sw_version=str(sw_version) if sw_version is not None else None, + ) + + @override + async def _async_update_data(self) -> UpdateReport: + try: + report = await self.device.async_update() + except ModbusError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="modbus_error", + ) from err + # Failed blocks keep their previous values in the library and are + # retried on the next poll, so only a total failure aborts the update. + if not report.updated: + errors = list(report.failed.values()) + if not errors: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_component_answered", + ) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_component_answered", + ) from ExceptionGroup("all components failed to refresh", errors) + return report diff --git a/homeassistant/components/de_dietrich/entity.py b/homeassistant/components/de_dietrich/entity.py new file mode 100644 index 000000000000..aa43cf8fc829 --- /dev/null +++ b/homeassistant/components/de_dietrich/entity.py @@ -0,0 +1,45 @@ +"""Base entity for De Dietrich devices.""" + +from dataclasses import dataclass +from typing import override + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import DeDietrichDataUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class DeDietrichEntityDescription(EntityDescription): + """Describe a De Dietrich entity.""" + + component: str + + +class DeDietrichEntity(CoordinatorEntity[DeDietrichDataUpdateCoordinator]): + """Defines a base De Dietrich entity.""" + + _attr_has_entity_name = True + entity_description: DeDietrichEntityDescription + + def __init__( + self, + coordinator: DeDietrichDataUpdateCoordinator, + entity_description: DeDietrichEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{entity_description.key}" + ) + self._attr_device_info = coordinator.device_info + + @property + @override + def available(self) -> bool: + """Whether this entity's component answered the most recent poll.""" + return ( + super().available + and self.entity_description.component not in self.coordinator.data.failed + ) diff --git a/homeassistant/components/de_dietrich/icons.json b/homeassistant/components/de_dietrich/icons.json new file mode 100644 index 000000000000..bd80d1b60f56 --- /dev/null +++ b/homeassistant/components/de_dietrich/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "fan_speed": { + "default": "mdi:fan" + } + } + } +} diff --git a/homeassistant/components/de_dietrich/manifest.json b/homeassistant/components/de_dietrich/manifest.json new file mode 100644 index 000000000000..e980c3c99c7c --- /dev/null +++ b/homeassistant/components/de_dietrich/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "de_dietrich", + "name": "De Dietrich", + "codeowners": ["@DaanVervacke"], + "config_flow": true, + "dependencies": ["modbus"], + "documentation": "https://www.home-assistant.io/integrations/de_dietrich", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["diematic-modbus==0.7.5"] +} diff --git a/homeassistant/components/de_dietrich/quality_scale.yaml b/homeassistant/components/de_dietrich/quality_scale.yaml new file mode 100644 index 000000000000..f7d55a5a6518 --- /dev/null +++ b/homeassistant/components/de_dietrich/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide any service actions. + docs-conditions: + status: exempt + comment: This integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: local_polling; entities rely solely on CoordinatorEntity's built-in lifecycle, no manual event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: todo + comment: Partial-failure transitions are not logged yet; only full-outage UpdateFailed goes through the coordinator's built-in logging. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Local Modbus TCP; there is no authentication that can expire or be invalidated. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: No discovery event identifies the boiler behind its generic RS485-to-TCP bridge, so a new bridge address cannot be matched to the configured boiler and unit ID. + discovery: + status: exempt + comment: A DHCP hostname or MAC address identifies only the user-supplied generic RS485-to-TCP bridge, not the attached De Dietrich boiler, its Modbus unit ID, or its register layout. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: A config entry maps to a single boiler, so no devices are added dynamically. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: A config entry maps to a single boiler removed with the entry, so none become stale. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration communicates over Modbus TCP, not HTTP. + strict-typing: todo diff --git a/homeassistant/components/de_dietrich/sensor.py b/homeassistant/components/de_dietrich/sensor.py new file mode 100644 index 000000000000..936c6288965e --- /dev/null +++ b/homeassistant/components/de_dietrich/sensor.py @@ -0,0 +1,181 @@ +"""Support for De Dietrich sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from diematic_modbus import Diematic, DiematicISystem + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + REVOLUTIONS_PER_MINUTE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import DeDietrichConfigEntry +from .entity import DeDietrichEntity, DeDietrichEntityDescription + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class DeDietrichSensorDescription(SensorEntityDescription, DeDietrichEntityDescription): + """Describe a De Dietrich sensor.""" + + value_fn: Callable[[Diematic | DiematicISystem], StateType] + + +SENSOR_DESCRIPTIONS: tuple[DeDietrichSensorDescription, ...] = ( + DeDietrichSensorDescription( + key="outdoor_temperature", + translation_key="outdoor_temperature", + component="sensors", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.sensors.outdoor_temp, + ), + DeDietrichSensorDescription( + key="boiler_temperature", + translation_key="boiler_temperature", + component="sensors", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.sensors.boiler_temp, + ), + DeDietrichSensorDescription( + key="return_temperature", + translation_key="return_temperature", + component="sensors", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.sensors.return_temp, + ), + DeDietrichSensorDescription( + key="exhaust_temperature", + translation_key="exhaust_temperature", + component="sensors", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.sensors.smoke_temp, + ), + DeDietrichSensorDescription( + key="water_pressure", + translation_key="water_pressure", + component="sensors", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.sensors.water_pressure, + ), + DeDietrichSensorDescription( + key="calc_boiler_temperature", + translation_key="calc_boiler_temperature", + component="sensors", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.sensors.calc_boiler_temp, + ), + DeDietrichSensorDescription( + key="fan_speed", + translation_key="fan_speed", + component="sensors", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.sensors.fan_speed, + ), + DeDietrichSensorDescription( + key="ionization_current", + translation_key="ionization_current", + component="sensors", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.MICROAMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.sensors.ionization_current, + ), + DeDietrichSensorDescription( + key="hot_water_temperature", + translation_key="hot_water_temperature", + component="hot_water", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.hot_water.temp, + ), + DeDietrichSensorDescription( + key="circuit_a_room_temperature", + translation_key="circuit_a_room_temperature", + component="circuit_a", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.circuit_a.room_temp, + ), + DeDietrichSensorDescription( + key="circuit_b_room_temperature", + translation_key="circuit_b_room_temperature", + component="circuit_b", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda device: device.circuit_b.room_temp, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DeDietrichConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the De Dietrich sensor platform.""" + coordinator = entry.runtime_data + async_add_entities( + DeDietrichSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class DeDietrichSensor(DeDietrichEntity, SensorEntity): + """A read-only value off one of the boiler's components.""" + + entity_description: DeDietrichSensorDescription + + @property + @override + def native_value(self) -> StateType: + """Return the value this sensor reads from the device.""" + return self.entity_description.value_fn(self.coordinator.device) diff --git a/homeassistant/components/de_dietrich/strings.json b/homeassistant/components/de_dietrich/strings.json new file mode 100644 index 000000000000..3cf3d0bbc2e8 --- /dev/null +++ b/homeassistant/components/de_dietrich/strings.json @@ -0,0 +1,78 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "Failed to connect: {error}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unsupported_device": "This device is not supported: {error}" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "unit_id": "Modbus unit ID" + }, + "data_description": { + "host": "The hostname or IP address of the plain RS485-to-TCP bridge connected to the boiler. It must pass through raw Modbus RTU frames, not translate between Modbus variants.", + "port": "The Modbus TCP port used by the bridge.", + "unit_id": "The boiler's Modbus unit ID." + }, + "description": "Connect over Modbus TCP. The boiler's register layout is detected automatically." + } + } + }, + "entity": { + "sensor": { + "boiler_temperature": { + "name": "Boiler temperature" + }, + "calc_boiler_temperature": { + "name": "Boiler temperature target" + }, + "circuit_a_room_temperature": { + "name": "Circuit A room temperature" + }, + "circuit_b_room_temperature": { + "name": "Circuit B room temperature" + }, + "exhaust_temperature": { + "name": "Flue gas temperature" + }, + "fan_speed": { + "name": "Fan speed" + }, + "hot_water_temperature": { + "name": "Hot water temperature" + }, + "ionization_current": { + "name": "Ionization current" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "return_temperature": { + "name": "Return temperature" + }, + "water_pressure": { + "name": "Water pressure" + } + } + }, + "exceptions": { + "link_settings_in_use": { + "message": "The boiler cannot be set up with these link settings: {error}" + }, + "modbus_error": { + "message": "Error communicating with the boiler." + }, + "no_component_answered": { + "message": "No component answered." + }, + "unsupported_device": { + "message": "This device is not supported: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 848a2d286915..bc4392725e6b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -151,6 +151,7 @@ FLOWS = { "daikin", "data_grand_lyon", "datadog", + "de_dietrich", "deako", "deconz", "decora_wifi", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cefc07b2b148..3f527adbd689 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1306,6 +1306,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "de_dietrich": { + "name": "De Dietrich", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "deako": { "name": "Deako", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 1215190e5705..71aed8dc921d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -865,6 +865,9 @@ devolo-home-control-api==0.19.1 # homeassistant.components.devolo_home_network devolo-plc-api==1.5.1 +# homeassistant.components.de_dietrich +diematic-modbus==0.7.5 + # homeassistant.components.chacon_dio dio-chacon-wifi-api==1.3.0 diff --git a/tests/components/de_dietrich/__init__.py b/tests/components/de_dietrich/__init__.py new file mode 100644 index 000000000000..f522999f735d --- /dev/null +++ b/tests/components/de_dietrich/__init__.py @@ -0,0 +1,45 @@ +"""Tests for the De Dietrich integration.""" + +from modbus_connection.exceptions import IllegalDataAddressError +from modbus_connection.mock import MockModbusUnit + +from homeassistant.components.de_dietrich.const import CONF_UNIT_ID, DEFAULT_UNIT_ID +from homeassistant.const import CONF_HOST, CONF_PORT + +MOCK_TITLE = "De Dietrich" +MOCK_ENTRY_ID = "01K5G6X6VZXZ9GJ2YFVZ2VE9RB" + +MOCK_USER_INPUT = { + CONF_HOST: "192.168.1.50", + CONF_PORT: 502, + CONF_UNIT_ID: DEFAULT_UNIT_ID, +} + + +def seed_boiler(unit: MockModbusUnit, boiler_type: int = 24) -> None: + """Seed a base-layout boiler for detection.""" + unit.holding.update( + {3: 400, 4: 14, 5: 30, 6: 2, 108: 10, 109: 9, 110: 25, 457: boiler_type} + ) + unit.fail_read(600, IllegalDataAddressError()) + unit.fail_read(679, IllegalDataAddressError()) + + +def seed_isystem_boiler(unit: MockModbusUnit) -> None: + """Seed a DiematicISystem boiler with canned sensor and identity values.""" + seed_boiler(unit) + unit.fail_read(600, None) + unit.fail_read(679, None) + unit.holding[600] = 100 # software_version + unit.holding.update({679: 12, 680: 30, 681: 2, 682: 10, 683: 9, 684: 25}) + unit.holding[601] = 50 # outdoor_temp -> 5.0 °C + unit.holding[602] = 600 # boiler_temp -> 60.0 °C + unit.holding[603] = 450 # hot_water.temp -> 45.0 °C + unit.holding[604] = 1200 # smoke_temp -> 120.0 °C + unit.holding[607] = 550 # return_temp -> 55.0 °C + unit.holding[608] = 35 # ionization_current -> 3.5 µA + unit.holding[609] = 2500 # fan_speed -> 2500 rpm + unit.holding[610] = 15 # water_pressure -> 1.5 bar + unit.holding[614] = 210 # circuit_a.room_temp -> 21.0 °C + unit.holding[616] = 205 # circuit_b.room_temp -> 20.5 °C + unit.holding[620] = 650 # calc_boiler_temp -> 65.0 °C diff --git a/tests/components/de_dietrich/conftest.py b/tests/components/de_dietrich/conftest.py new file mode 100644 index 000000000000..31a82239a76c --- /dev/null +++ b/tests/components/de_dietrich/conftest.py @@ -0,0 +1,62 @@ +"""Common fixtures for the De Dietrich tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from modbus_connection.mock import MockModbusConnection +import pytest + +from homeassistant.components.de_dietrich.const import DEFAULT_UNIT_ID, DOMAIN +from homeassistant.core import HomeAssistant + +from . import MOCK_ENTRY_ID, MOCK_TITLE, MOCK_USER_INPUT, seed_isystem_boiler + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.de_dietrich.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_connection() -> MockModbusConnection: + """A fake Modbus TCP connection seeded as a DiematicISystem boiler.""" + connection = MockModbusConnection() + seed_isystem_boiler(connection.for_unit(DEFAULT_UNIT_ID)) + return connection + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a De Dietrich Diematic Modbus config entry.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id=MOCK_ENTRY_ID, + data=MOCK_USER_INPUT, + title=MOCK_TITLE, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MockModbusConnection, +) -> MockConfigEntry: + """Set up the De Dietrich Diematic Modbus integration for testing.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + return mock_config_entry diff --git a/tests/components/de_dietrich/snapshots/test_sensor.ambr b/tests/components/de_dietrich/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..507ce1074cf6 --- /dev/null +++ b/tests/components/de_dietrich/snapshots/test_sensor.ambr @@ -0,0 +1,635 @@ +# serializer version: 1 +# name: test_all_entities[sensor.de_dietrich_boiler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_dietrich_boiler_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boiler temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boiler temperature', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'boiler_temperature', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_boiler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'De Dietrich Boiler temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_boiler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_boiler_temperature_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.de_dietrich_boiler_temperature_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boiler temperature target', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boiler temperature target', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'calc_boiler_temperature', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_calc_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_boiler_temperature_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'De Dietrich Boiler temperature target', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_boiler_temperature_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.0', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_circuit_a_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_dietrich_circuit_a_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Circuit A room temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Circuit A room temperature', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'circuit_a_room_temperature', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_circuit_a_room_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_circuit_a_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'De Dietrich Circuit A room temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_circuit_a_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_circuit_b_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_dietrich_circuit_b_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Circuit B room temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Circuit B room temperature', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'circuit_b_room_temperature', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_circuit_b_room_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_circuit_b_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'De Dietrich Circuit B room temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_circuit_b_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.de_dietrich_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fan speed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan speed', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fan_speed', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_fan_speed', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'De Dietrich Fan speed', + : , + : 'rpm', + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2500', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_flue_gas_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.de_dietrich_flue_gas_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flue gas temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flue gas temperature', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_temperature', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_exhaust_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_flue_gas_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'De Dietrich Flue gas temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_flue_gas_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_hot_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_dietrich_hot_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hot water temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water temperature', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hot_water_temperature', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_hot_water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_hot_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'De Dietrich Hot water temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_hot_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.0', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_ionization_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.de_dietrich_ionization_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ionization current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ionization current', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionization_current', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_ionization_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_ionization_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'De Dietrich Ionization current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_ionization_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.5', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_outdoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_dietrich_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_outdoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'De Dietrich Outdoor temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_return_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_dietrich_return_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Return temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return temperature', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'return_temperature', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_return_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_return_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'De Dietrich Return temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_return_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.0', + }) +# --- +# name: test_all_entities[sensor.de_dietrich_water_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_dietrich_water_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water pressure', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water pressure', + 'platform': 'de_dietrich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_pressure', + 'unique_id': '01K5G6X6VZXZ9GJ2YFVZ2VE9RB_water_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.de_dietrich_water_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'pressure', + : 'De Dietrich Water pressure', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.de_dietrich_water_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- diff --git a/tests/components/de_dietrich/test_config_flow.py b/tests/components/de_dietrich/test_config_flow.py new file mode 100644 index 000000000000..da299cd9ccb0 --- /dev/null +++ b/tests/components/de_dietrich/test_config_flow.py @@ -0,0 +1,286 @@ +"""Test the De Dietrich config flow.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from unittest.mock import _patch, patch + +from modbus_connection import ModbusConnectionError, ModbusTcpParams, ModbusTimeoutError +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest + +from homeassistant import config_entries +from homeassistant.components.de_dietrich.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError + +from . import MOCK_USER_INPUT, seed_boiler + +from tests.common import MockConfigEntry + + +def _patch_temporary_unit(connection: MockModbusConnection) -> _patch: + """Stand in for async_get_temporary_unit, handing out a unit on connection.""" + + @asynccontextmanager + async def _get_temporary_unit( + hass: HomeAssistant, params: ModbusTcpParams, unit_id: int + ) -> AsyncIterator[MockModbusUnit]: + yield connection.for_unit(unit_id) + + return patch( + "homeassistant.components.de_dietrich.config_flow.async_get_temporary_unit", + side_effect=_get_temporary_unit, + ) + + +@pytest.mark.parametrize( + "boiler_type", + [ + pytest.param(20, id="diematic_3_type_20"), + pytest.param(22, id="diematic_3_type_22"), + pytest.param(24, id="diematic_4_type_24"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_step_success(hass: HomeAssistant, boiler_type: int) -> None: + """Test the flow detects supported base layouts and stores settings.""" + mock_conn = MockModbusConnection() + unit = mock_conn.for_unit(10) + seed_boiler(unit, boiler_type) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "De Dietrich" + assert result["data"] == MOCK_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_step_rejects_unknown_device( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the flow rejects an unknown device type.""" + mock_conn = MockModbusConnection() + seed_boiler(mock_conn.for_unit(10), 999) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_device"} + assert "raw_type_code=999" in caplog.text + assert ( + "probe outcomes: ['success', 'success', 'success', 'unsupported'" in caplog.text + ) + + working_conn = MockModbusConnection() + seed_boiler(working_conn.for_unit(10)) + + with _patch_temporary_unit(working_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + "boiler_type", + [ + pytest.param(20, id="diematic_3_type_20"), + pytest.param(22, id="diematic_3_type_22"), + pytest.param(24, id="diematic_4_type_24"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_step_detects_isystem(hass: HomeAssistant, boiler_type: int) -> None: + """Test iSystem detection survives a failed base-layout probe.""" + mock_conn = MockModbusConnection() + unit = mock_conn.for_unit(10) + seed_boiler(unit, boiler_type) + unit.fail_read(3, ModbusTimeoutError("base probe timeout")) + unit.fail_read(600, None) + unit.fail_read(679, None) + unit.holding.update({600: 100, 679: 12, 680: 30, 681: 2, 682: 10, 683: 9, 684: 25}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_step_cannot_connect( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the user step reports cannot_connect on a dead link, then recovers.""" + mock_conn = MockModbusConnection() + mock_conn.for_unit(10).fail_requests(ModbusConnectionError("stuck")) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + assert result["description_placeholders"] == { + "error": "Could not identify the Diematic register layout" + } + assert "probe outcomes: ['error'" in caplog.text + + working_conn = MockModbusConnection() + seed_boiler(working_conn.for_unit(10)) + + with _patch_temporary_unit(working_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_step_identity_read_fails(hass: HomeAssistant) -> None: + """Test cannot_connect when neither identity block can be read.""" + mock_conn = MockModbusConnection() + unit = mock_conn.for_unit(10) + unit.fail_read(457, ModbusTimeoutError("no identity")) + unit.fail_read(600, ModbusTimeoutError("no iSystem identity")) + unit.fail_read(679, ModbusTimeoutError("no iSystem clock")) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with _patch_temporary_unit(mock_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + working_conn = MockModbusConnection() + seed_boiler(working_conn.for_unit(10)) + + with _patch_temporary_unit(working_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_step_link_settings_conflict(hass: HomeAssistant) -> None: + """Test a shared Modbus link conflict is recoverable.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.de_dietrich.config_flow.async_get_temporary_unit", + side_effect=HomeAssistantError("different framing"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + assert result["description_placeholders"] == {"error": "different framing"} + + working_conn = MockModbusConnection() + seed_boiler(working_conn.for_unit(10)) + + with _patch_temporary_unit(working_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_step_unknown_error(hass: HomeAssistant) -> None: + """Test unexpected errors are logged and shown as unknown.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.de_dietrich.config_flow.async_get_temporary_unit", + side_effect=Exception("boom"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + working_conn = MockModbusConnection() + seed_boiler(working_conn.for_unit(10)) + + with _patch_temporary_unit(working_conn): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_step_already_configured(hass: HomeAssistant) -> None: + """Test duplicates are rejected without connecting to the boiler.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.de_dietrich.config_flow.async_get_temporary_unit", + side_effect=ModbusConnectionError("offline"), + ) as mock_get_unit: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_get_unit.assert_not_called() diff --git a/tests/components/de_dietrich/test_init.py b/tests/components/de_dietrich/test_init.py new file mode 100644 index 000000000000..15114f9e46f8 --- /dev/null +++ b/tests/components/de_dietrich/test_init.py @@ -0,0 +1,269 @@ +"""Test the De Dietrich setup.""" + +from unittest.mock import patch + +import diematic_modbus +from diematic_modbus import UpdateReport +from modbus_connection import ModbusTimeoutError +from modbus_connection.mock import MockModbusConnection +import pytest + +from homeassistant.components.de_dietrich.const import DEFAULT_UNIT_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from . import MOCK_ENTRY_ID, MOCK_TITLE, MOCK_USER_INPUT, seed_boiler + +from tests.common import MockConfigEntry + + +async def test_setup_retry_when_identity_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MockModbusConnection, +) -> None: + """Test setup is retried when the boiler identity cannot be read.""" + mock_connection.for_unit(DEFAULT_UNIT_ID).fail_read(457, ModbusTimeoutError("boom")) + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_retry_when_detection_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MockModbusConnection, +) -> None: + """Test setup is retried when detection cannot reach the boiler.""" + mock_connection.for_unit(DEFAULT_UNIT_ID).fail_requests(ModbusTimeoutError("boom")) + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_retry_when_detection_raises_modbus_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MockModbusConnection, +) -> None: + """Test setup is retried when detection raises a Modbus error.""" + mock_config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ), + patch( + "homeassistant.components.de_dietrich.diematic_modbus.async_detect", + side_effect=ModbusTimeoutError("boom"), + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_retry_when_coordinator_identity_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MockModbusConnection, +) -> None: + """Test setup retries when the coordinator cannot read the identity.""" + unit = mock_connection.for_unit(DEFAULT_UNIT_ID) + detection = await diematic_modbus.async_detect(unit) + unit.fail_read(679, ModbusTimeoutError("identity unavailable")) + mock_config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ), + patch( + "homeassistant.components.de_dietrich.diematic_modbus.async_detect", + return_value=detection, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "failed", + [ + pytest.param({}, id="no_error_details"), + pytest.param( + {"sensors": ModbusTimeoutError("component unavailable")}, + id="with_error_details", + ), + ], +) +async def test_setup_retry_when_no_component_answers( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MockModbusConnection, + failed: dict[str, ModbusTimeoutError], +) -> None: + """Test setup retries when no component returns an update.""" + unit = mock_connection.for_unit(DEFAULT_UNIT_ID) + detection = await diematic_modbus.async_detect(unit) + assert detection.device is not None + mock_config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ), + patch( + "homeassistant.components.de_dietrich.diematic_modbus.async_detect", + return_value=detection, + ), + patch.object( + type(detection.device), + "async_update", + return_value=UpdateReport( + updated=frozenset(), + failed=failed, + ), + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_error_when_device_is_unsupported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection: MockModbusConnection, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup reports an unsupported device with detection evidence.""" + seed_boiler(mock_connection.for_unit(DEFAULT_UNIT_ID), boiler_type=999) + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert "This device is not supported" in mock_config_entry.reason + assert "raw_type_code=999" in caplog.text + + +async def test_setup_isystem_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_connection: MockModbusConnection, +) -> None: + """Test setup registers iSystem software information.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert device is not None + assert device.sw_version == "100" + + +async def test_setup_error_when_link_settings_conflict( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup reports incompatible shared Modbus link settings.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=HomeAssistantError("different framing"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert ( + mock_config_entry.reason + == "The boiler cannot be set up with these link settings: different framing" + ) + + +async def test_base_layout_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_connection: MockModbusConnection, +) -> None: + """Test base-layout device information omits unavailable metadata.""" + seed_boiler(mock_connection.for_unit(DEFAULT_UNIT_ID)) + entry = MockConfigEntry( + domain=DOMAIN, + entry_id=MOCK_ENTRY_ID, + data=MOCK_USER_INPUT, + title=MOCK_TITLE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, entry.entry_id), entry.entry_id + ) + assert device is not None + assert device.name == "De Dietrich" + assert device.manufacturer == "De Dietrich" + assert device.model is None + assert device.serial_number is None + assert device.sw_version is None + + +async def test_unload_entry( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test unloading the config entry unloads the sensor platform.""" + assert await hass.config_entries.async_unload(init_integration.entry_id) + assert init_integration.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/de_dietrich/test_sensor.py b/tests/components/de_dietrich/test_sensor.py new file mode 100644 index 000000000000..4d2379249c0e --- /dev/null +++ b/tests/components/de_dietrich/test_sensor.py @@ -0,0 +1,171 @@ +"""Test the De Dietrich sensor platform.""" + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from modbus_connection import ModbusConnectionError, ModbusError, ModbusTimeoutError +from modbus_connection.mock import MockModbusConnection +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.de_dietrich.const import ( + DEFAULT_UNIT_ID, + DOMAIN, + SCAN_INTERVAL, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test all sensors match their snapshot.""" + device = device_registry.async_get_device_by_identifier( + (DOMAIN, init_integration.entry_id), init_integration.entry_id + ) + assert device is not None + assert device.name == "De Dietrich" + assert device.model is None + assert device.manufacturer == "De Dietrich" + assert device.sw_version == "100" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +async def test_diagnostic_entities_disabled_by_default( + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test diagnostic sensors are disabled by default.""" + entries = er.async_entries_for_config_entry( + entity_registry, init_integration.entry_id + ) + disabled = { + entry.unique_id.removeprefix(f"{init_integration.entry_id}_") + for entry in entries + if entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + } + assert disabled == { + "calc_boiler_temperature", + "exhaust_temperature", + "fan_speed", + "ionization_current", + } + + +@pytest.mark.parametrize( + ("register", "entity_id"), + [ + pytest.param( + 614, + "sensor.de_dietrich_circuit_a_room_temperature", + id="circuit_a", + ), + pytest.param( + 616, + "sensor.de_dietrich_circuit_b_room_temperature", + id="circuit_b", + ), + ], +) +async def test_circuit_sensor_without_room_temperature( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, + register: int, + entity_id: str, +) -> None: + """Test a circuit sensor is unknown when no room temperature is reported.""" + mock_connection.for_unit(DEFAULT_UNIT_ID).holding[register] = 0xFFFF + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.de_dietrich.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(entity_id)) is not None + assert state.state == "unknown" + + +@pytest.mark.parametrize( + "error", + [ + pytest.param(ModbusTimeoutError("stuck"), id="timeout"), + pytest.param(ModbusConnectionError("dead"), id="connection"), + ], +) +async def test_sensor_unavailable_on_update_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_connection: MockModbusConnection, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + error: ModbusError, +) -> None: + """Test a sensor becomes unavailable when a poll fails, then recovers.""" + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{init_integration.entry_id}_outdoor_temperature" + ) + assert entity_id is not None + assert (state := hass.states.get(entity_id)) is not None + assert state.state != "unavailable" + + unit = mock_connection.for_unit(DEFAULT_UNIT_ID) + unit.fail_requests(error) + freezer.tick(timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert (state := hass.states.get(entity_id)) is not None + assert state.state == "unavailable" + + unit.fail_requests(None) + freezer.tick(timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert (state := hass.states.get(entity_id)) is not None + assert state.state != "unavailable" + + +@pytest.mark.usefixtures("init_integration") +async def test_sensor_partial_update_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_connection: MockModbusConnection, +) -> None: + """Test a failed circuit becomes unavailable while other readings update.""" + circuit_id = "sensor.de_dietrich_circuit_a_room_temperature" + outdoor_id = "sensor.de_dietrich_outdoor_temperature" + assert hass.states.get(circuit_id).state == "21.0" + assert hass.states.get(outdoor_id).state == "5.0" + + unit = mock_connection.for_unit(DEFAULT_UNIT_ID) + unit.fail_read(654, ModbusTimeoutError("circuit unavailable")) + unit.holding[601] = 60 + freezer.tick(timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(circuit_id).state == "unavailable" + assert hass.states.get(outdoor_id).state == "6.0" + + unit.fail_read(654, None) + unit.holding[614] = 220 + freezer.tick(timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(circuit_id).state == "22.0" + assert hass.states.get(outdoor_id).state == "6.0"