mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 17:31:15 -04:00
Add De Dietrich integration (#181516)
This commit is contained in:
Generated
+2
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user