Add De Dietrich integration (#181516)

This commit is contained in:
Daan Vervacke
2026-09-14 22:12:24 +02:00
committed by GitHub
parent 4b7d418cb7
commit 83ca3988d5
20 changed files with 2233 additions and 0 deletions
Generated
+2
View File
@@ -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
@@ -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)
@@ -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,
)
@@ -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
@@ -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
@@ -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
)
@@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"fan_speed": {
"default": "mdi:fan"
}
}
}
}
@@ -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"]
}
@@ -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
@@ -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)
@@ -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}"
}
}
}
+1
View File
@@ -151,6 +151,7 @@ FLOWS = {
"daikin",
"data_grand_lyon",
"datadog",
"de_dietrich",
"deako",
"deconz",
"decora_wifi",
@@ -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",
+3
View File
@@ -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
+45
View File
@@ -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
+62
View File
@@ -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
@@ -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({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Boiler temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_boiler_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Boiler temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_boiler_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '60.0',
})
# ---
# name: test_all_entities[sensor.de_dietrich_boiler_temperature_target-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.de_dietrich_boiler_temperature_target',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Boiler temperature target',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_boiler_temperature_target-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Boiler temperature target',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_boiler_temperature_target',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '65.0',
})
# ---
# name: test_all_entities[sensor.de_dietrich_circuit_a_room_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Circuit A room temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_circuit_a_room_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Circuit A room temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_circuit_a_room_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '21.0',
})
# ---
# name: test_all_entities[sensor.de_dietrich_circuit_b_room_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Circuit B room temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_circuit_b_room_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Circuit B room temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_circuit_b_room_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '20.5',
})
# ---
# name: test_all_entities[sensor.de_dietrich_fan_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.de_dietrich_fan_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Fan speed',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'rpm',
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2500',
})
# ---
# name: test_all_entities[sensor.de_dietrich_flue_gas_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.de_dietrich_flue_gas_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Flue gas temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_flue_gas_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Flue gas temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_flue_gas_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '120.0',
})
# ---
# name: test_all_entities[sensor.de_dietrich_hot_water_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Hot water temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_hot_water_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Hot water temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_hot_water_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '45.0',
})
# ---
# name: test_all_entities[sensor.de_dietrich_ionization_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.de_dietrich_ionization_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Ionization current',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'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': <UnitOfElectricCurrent.MICROAMPERE: 'μA'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_ionization_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'current',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Ionization current',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfElectricCurrent.MICROAMPERE: 'μA'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_ionization_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3.5',
})
# ---
# name: test_all_entities[sensor.de_dietrich_outdoor_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Outdoor temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_outdoor_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Outdoor temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_outdoor_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
# name: test_all_entities[sensor.de_dietrich_return_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Return temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_return_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Return temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_return_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '55.0',
})
# ---
# name: test_all_entities[sensor.de_dietrich_water_pressure-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.PRESSURE: 'pressure'>,
'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': <UnitOfPressure.BAR: 'bar'>,
})
# ---
# name: test_all_entities[sensor.de_dietrich_water_pressure-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'pressure',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'De Dietrich Water pressure',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfPressure.BAR: 'bar'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_dietrich_water_pressure',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.5',
})
# ---
@@ -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()
+269
View File
@@ -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
+171
View File
@@ -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"