Added guntamatic heater integration (#167419)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Jens Timmerman
2026-05-14 14:54:37 +02:00
committed by GitHub
parent 885bb41416
commit ec918e0460
22 changed files with 1140 additions and 0 deletions
+1
View File
@@ -250,6 +250,7 @@ homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*
homeassistant.components.group.*
homeassistant.components.guardian.*
homeassistant.components.guntamatic.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
Generated
+2
View File
@@ -695,6 +695,8 @@ CLAUDE.md @home-assistant/core
/tests/components/growatt_server/ @johanzander
/homeassistant/components/guardian/ @bachya
/tests/components/guardian/ @bachya
/homeassistant/components/guntamatic/ @JensTimmerman
/tests/components/guntamatic/ @JensTimmerman
/homeassistant/components/habitica/ @tr4nt0r
/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hanna/ @bestycame
@@ -0,0 +1,26 @@
"""The guntamatic integration."""
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator
_LOGGER = logging.getLogger(__name__)
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool:
"""Set up guntamatic from a config entry."""
coordinator = GuntamaticCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
@@ -0,0 +1,90 @@
"""Config flow for the guntamatic integration."""
import logging
from typing import Any
from guntamatic.heater import Heater, NoSerialException
import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
}
)
class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for guntamatic."""
_discovered_ip: str
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
heater = Heater(discovery_info.ip)
try:
data = await self.hass.async_add_executor_job(heater.parse_data)
except requests.exceptions.RequestException:
return self.async_abort(reason="cannot_connect")
except NoSerialException:
return self.async_abort(reason="bad_data")
await self.async_set_unique_id(data["serial"][0])
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
self._discovered_ip = discovery_info.ip
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
return self.async_create_entry(
title="Guntamatic Heater",
data={CONF_HOST: self._discovered_ip},
)
self._set_confirm_only()
return self.async_show_form(step_id="discovery_confirm")
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
heater = Heater(user_input[CONF_HOST])
data = await self.hass.async_add_executor_job(heater.parse_data)
except requests.exceptions.RequestException:
errors["base"] = "cannot_connect"
except NoSerialException:
errors["base"] = "bad_data"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# set serial as unique id for deduplication, ip isn't a good match
await self.async_set_unique_id(data["serial"][0])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Guntamatic Heater", data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
@@ -0,0 +1,6 @@
"""Constants for the guntamatic integration."""
from datetime import timedelta
DOMAIN = "guntamatic"
SCAN_INTERVAL = timedelta(seconds=30)
@@ -0,0 +1,42 @@
"""Coordinator for Guntamatic integration."""
import logging
from guntamatic.heater import Heater
import requests
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type GuntamaticConfigEntry = ConfigEntry[GuntamaticCoordinator]
class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]):
"""Guntamatic data coordinator."""
def __init__(self, hass: HomeAssistant, entry: GuntamaticConfigEntry) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self.heater = Heater(entry.data[CONF_HOST])
async def _async_update_data(self) -> dict[str, list[str]]:
"""Fetch data from heater."""
try:
data: dict[str, list[str]] = await self.hass.async_add_executor_job(
self.heater.parse_data
)
except requests.exceptions.ConnectionError as err:
raise UpdateFailed(f"Cannot connect to heater: {err}") from err
return data
@@ -0,0 +1,18 @@
{
"domain": "guntamatic",
"name": "Guntamatic",
"codeowners": ["@JensTimmerman"],
"config_flow": true,
"dependencies": [],
"dhcp": [
{
"hostname": "kessel*",
"macaddress": "0024BD*"
}
],
"documentation": "https://www.home-assistant.io/integrations/guntamatic",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["guntamatic==1.6.0"]
}
@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
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: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options flow
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: No authentication required.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Single device
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,151 @@
"""Support for Guntamatic sensors in Home Assistant."""
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator
PARALLEL_UPDATES = 0
GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [
SensorEntityDescription(
key="program",
translation_key="program",
device_class=SensorDeviceClass.ENUM,
options=[
"off",
"timer",
"dhw",
"heat",
"hibernate",
"hibernate_to",
"dhw_boost",
],
),
SensorEntityDescription(
key="boiler_temperature",
translation_key="boiler_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="outdoor_temperature",
translation_key="outdoor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="buffer_top_temperature",
translation_key="buffer_top_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="buffer_center_temperature",
translation_key="buffer_center_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="buffer_bottom_temperature",
translation_key="buffer_bottom_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="domestic_home_water_temperature",
translation_key="domestic_home_water_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="room_0_temperature",
translation_key="room_0_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="room_1_temperature",
translation_key="room_1_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="room_2_temperature",
translation_key="room_2_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key="buffer_load",
translation_key="buffer_load",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: GuntamaticConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Guntamatic sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities(
GuntamaticSensor(coordinator, description)
for description in GUNTAMATIC_SENSORS
if description.key in coordinator.data
)
class GuntamaticSensor(CoordinatorEntity[GuntamaticCoordinator], SensorEntity):
"""Representation of a single Guntamatic sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: GuntamaticCoordinator,
entity_description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = entity_description
serial = coordinator.data["serial"][0]
self._attr_unique_id = f"{serial.replace('.', '_')}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial)},
manufacturer="Guntamatic",
serial_number=serial,
sw_version=coordinator.data["version"][0],
)
@property
def native_value(self) -> StateType:
"""Return the current value of the sensor."""
return self.coordinator.data[self.entity_description.key][0]
@@ -0,0 +1,77 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"bad_data": "The heater did not provide the required identification information. Verify that the host points to a supported controller and the correct endpoint.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"error": {
"bad_data": "The heater did not provide the required identification information. Verify that the host points to a supported controller and the correct endpoint.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"discovery_confirm": {
"description": "Do you want to set up this Guntamatic heater?",
"title": "Discovered Guntamatic heater"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Guntamatic heater."
}
}
}
},
"entity": {
"sensor": {
"boiler_temperature": {
"name": "Boiler temperature"
},
"buffer_bottom_temperature": {
"name": "Buffer bottom temperature"
},
"buffer_center_temperature": {
"name": "Buffer center temperature"
},
"buffer_load": {
"name": "Buffer load"
},
"buffer_top_temperature": {
"name": "Buffer top temperature"
},
"domestic_hot_water_0_temperature": {
"name": "Domestic hot water circuit 0 temperature"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"program": {
"name": "Program",
"state": {
"dhw": "Domestic hot water",
"dhw_boost": "Domestic hot water boost",
"heat": "Heat",
"hibernate": "Hibernate",
"hibernate_to": "Hibernate to",
"off": "[%key:common::state::off%]",
"timer": "Timer"
}
},
"room_0_temperature": {
"name": "Room 0 temperature"
},
"room_1_temperature": {
"name": "Room 1 temperature"
},
"room_2_temperature": {
"name": "Room 2 temperature"
},
"status": {
"name": "Status"
}
}
}
}
+1
View File
@@ -292,6 +292,7 @@ FLOWS = {
"green_planet_energy",
"growatt_server",
"guardian",
"guntamatic",
"habitica",
"hanna",
"harmony",
+5
View File
@@ -303,6 +303,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "guardian*",
"macaddress": "30AEA4*",
},
{
"domain": "guntamatic",
"hostname": "kessel*",
"macaddress": "0024BD*",
},
{
"domain": "home_connect",
"hostname": "balay-*",
@@ -2731,6 +2731,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"guntamatic": {
"name": "Guntamatic",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"habitica": {
"name": "Habitica",
"integration_type": "service",
Generated
+10
View File
@@ -2257,6 +2257,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.guntamatic.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.habitica.*]
check_untyped_defs = true
disallow_incomplete_defs = true
+3
View File
@@ -1182,6 +1182,9 @@ growattServer==2.1.0
# homeassistant.components.google_sheets
gspread==5.5.0
# homeassistant.components.guntamatic
guntamatic==1.6.0
# homeassistant.components.profiler
guppy3==3.1.6
+3
View File
@@ -1058,6 +1058,9 @@ growattServer==2.1.0
# homeassistant.components.google_sheets
gspread==5.5.0
# homeassistant.components.guntamatic
guntamatic==1.6.0
# homeassistant.components.profiler
guppy3==3.1.6
+15
View File
@@ -0,0 +1,15 @@
"""Tests for the Guntamatic integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Set up the Guntamatic integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
+67
View File
@@ -0,0 +1,67 @@
"""Common fixtures for the guntamatic tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.guntamatic.const import DOMAIN
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry
MOCK_DATA = {
"Boiler temperature": ["14.09", "°C"],
"Outdoor temperature": ["15.95", "°C"],
"Buffer load": ["22", "%"],
"Program": ["HEAT", ""],
"Status": ["Service Ign.", ""],
"Serial": ["959103", ""],
"Version": ["32a", ""],
}
MOCK_PARSE_DATA = {
"boiler_temperature": ["14.09", "°C"],
"outdoor_temperature": ["15.95", "°C"],
"buffer_load": ["22", "%"],
"program": ["heat", ""],
"status": ["Service Ign.", ""],
"serial": ["959103", ""],
"version": ["32a", ""],
}
@pytest.fixture
def mock_heater() -> Generator[MagicMock]:
"""Mock the Heater class."""
with (
patch(
"homeassistant.components.guntamatic.coordinator.Heater",
autospec=True,
) as mock,
patch("homeassistant.components.guntamatic.config_flow.Heater", new=mock),
):
instance = mock.return_value
instance.parse_data.return_value = MOCK_PARSE_DATA
instance.host = "1.1.1.1"
yield instance
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "1.1.1.1"},
unique_id=MOCK_DATA["Serial"][0],
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.guntamatic.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@@ -0,0 +1,241 @@
# serializer version: 1
# name: test_all_entities[sensor.mock_title_boiler_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'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.mock_title_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': 'guntamatic',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'boiler_temperature',
'unique_id': '959103_boiler_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.mock_title_boiler_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Title Boiler temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_boiler_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '14.09',
})
# ---
# name: test_all_entities[sensor.mock_title_buffer_load-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'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.mock_title_buffer_load',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Buffer load',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Buffer load',
'platform': 'guntamatic',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'buffer_load',
'unique_id': '959103_buffer_load',
'unit_of_measurement': '%',
})
# ---
# name: test_all_entities[sensor.mock_title_buffer_load-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Buffer load',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_buffer_load',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '22',
})
# ---
# name: test_all_entities[sensor.mock_title_outdoor_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'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.mock_title_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': 'guntamatic',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'outdoor_temperature',
'unique_id': '959103_outdoor_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.mock_title_outdoor_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Title Outdoor temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_outdoor_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '15.95',
})
# ---
# name: test_all_entities[sensor.mock_title_program-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'off',
'timer',
'dhw',
'heat',
'hibernate',
'hibernate_to',
'dhw_boost',
]),
}),
'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.mock_title_program',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Program',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Program',
'platform': 'guntamatic',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'program',
'unique_id': '959103_program',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.mock_title_program-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Mock Title Program',
'options': list([
'off',
'timer',
'dhw',
'heat',
'hibernate',
'hibernate_to',
'dhw_boost',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_program',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
@@ -0,0 +1,182 @@
"""Test the guntamatic config flow."""
from unittest.mock import AsyncMock, MagicMock
from guntamatic.heater import NoSerialException
import pytest
import requests
from homeassistant.components.guntamatic.const import DOMAIN
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import MOCK_DATA, MOCK_PARSE_DATA
from tests.common import MockConfigEntry
async def test_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_heater: MagicMock,
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "1.1.1.1"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Guntamatic Heater"
assert result["data"] == {CONF_HOST: "1.1.1.1"}
assert result["result"].unique_id == MOCK_DATA["Serial"][0]
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(requests.exceptions.ConnectionError, "cannot_connect"),
(NoSerialException, "bad_data"),
(Exception("Unknown error"), "unknown"),
],
)
async def test_form_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
side_effect: Exception,
expected_error: str,
mock_heater: MagicMock,
) -> None:
"""Test we handle errors correctly."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_heater.parse_data.side_effect = (side_effect,)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
# Recover from error
mock_heater.parse_data.side_effect = None
mock_heater.parse_data.return_value = MOCK_PARSE_DATA
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "1.1.1.1"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_form_already_configured(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_heater: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test we abort if already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "1.1.1.1"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_dhcp_discovery(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_heater: MagicMock,
) -> None:
"""Test DHCP discovery shows confirmation form."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip="1.1.1.1",
hostname="kessel0001",
macaddress="0024bd123456",
),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "1.1.1.1"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_HOST: "1.1.1.1"}
assert result["result"].unique_id == MOCK_DATA["Serial"][0]
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(requests.exceptions.ConnectionError, "cannot_connect"),
(NoSerialException, "bad_data"),
],
)
async def test_dhcp_discovery_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
side_effect: Exception,
expected_error: str,
mock_heater: MagicMock,
) -> None:
"""Test DHCP discovery shows confirmation form."""
mock_heater.parse_data.side_effect = (side_effect,)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip="1.1.1.1",
hostname="kessel0001",
macaddress="0024bd123456",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == expected_error
async def test_dhcp_updates_ip(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_heater: MagicMock,
) -> None:
"""Test DHCP discovery updates IP when device changes address."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip="1.1.1.2",
hostname="kessel0001",
macaddress="0024bd123456",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_HOST] == "1.1.1.2"
assert mock_config_entry.unique_id == MOCK_DATA["Serial"][0]
+47
View File
@@ -0,0 +1,47 @@
"""Test the Guntamatic integration setup."""
from unittest.mock import MagicMock
import pytest
import requests
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
async def test_setup_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_heater: MagicMock,
) -> None:
"""Test successful setup of the integration."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("side_effect", "expected_state"),
[
(
requests.exceptions.ConnectionError("Cannot connect"),
ConfigEntryState.SETUP_RETRY,
),
],
)
async def test_setup_entry_fails(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_heater: MagicMock,
side_effect: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test setup fails correctly for different error types."""
mock_heater.parse_data.side_effect = side_effect
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is expected_state
@@ -0,0 +1,75 @@
"""Tests for the Guntamatic sensor platform."""
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from guntamatic.heater import NoSerialException
import pytest
import requests
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.guntamatic.const import SCAN_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("mock_heater")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch(
"homeassistant.components.guntamatic._PLATFORMS",
[Platform.SENSOR],
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
@pytest.mark.parametrize(
"side_effect",
[
requests.exceptions.ConnectionError("Connection lost"),
NoSerialException,
Exception("Unknown error"),
],
)
async def test_state_unavailable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_heater: MagicMock,
side_effect: Exception,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sensors handle failures."""
await setup_integration(hass, mock_config_entry)
mock_heater.parse_data.side_effect = side_effect
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.mock_title_boiler_temperature")
assert state.state == STATE_UNAVAILABLE
# Recovery
mock_heater.parse_data.side_effect = None
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.mock_title_boiler_temperature")
assert state.state != STATE_UNAVAILABLE